@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,850 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reference.ts — Game reference tools: conditions, spells, items
|
|
3
|
+
*
|
|
4
|
+
* Conditions: hardcoded lookup table, no API required.
|
|
5
|
+
* Spells: built from character-service always-known/prepared endpoints
|
|
6
|
+
* across all 8 spellcasting classes; cached 24 h.
|
|
7
|
+
* Items: single game-data/items endpoint; cached 24 h.
|
|
8
|
+
*
|
|
9
|
+
* All character-service endpoints use the cobalt Bearer token.
|
|
10
|
+
*/
|
|
11
|
+
import { sessionFetch, getCobaltToken } from "../session-fetch.js";
|
|
12
|
+
import { TtlCache } from "../cache.js";
|
|
13
|
+
import { stripHtml } from "../utils.js";
|
|
14
|
+
import { fetchO5Cantrips, o5SearchSpells, o5GetSpell, o5SearchItems, o5GetItem, o5GetWeapon, o5GetArmor, o5SearchRaces, o5SearchClasses, o5SearchBackgrounds, o5SearchFeats, o5SearchSections, o5GetSection, } from "../open5e.js";
|
|
15
|
+
const CHARACTER_SERVICE = "https://character-service.dndbeyond.com";
|
|
16
|
+
// ── Cache ─────────────────────────────────────────────────────────────────────
|
|
17
|
+
const referenceCache = new TtlCache(24 * 60 * 60_000, 50);
|
|
18
|
+
// ── Auth helper ───────────────────────────────────────────────────────────────
|
|
19
|
+
async function refFetch(url) {
|
|
20
|
+
const { token } = await getCobaltToken();
|
|
21
|
+
return sessionFetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
22
|
+
}
|
|
23
|
+
const CONDITIONS = {
|
|
24
|
+
blinded: {
|
|
25
|
+
name: "Blinded",
|
|
26
|
+
description: "A blinded creature can't see and automatically fails any ability check that requires sight.",
|
|
27
|
+
effects: [
|
|
28
|
+
"Attack rolls against the creature have advantage.",
|
|
29
|
+
"The creature's attack rolls have disadvantage.",
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
charmed: {
|
|
33
|
+
name: "Charmed",
|
|
34
|
+
description: "A charmed creature can't attack the charmer or target the charmer with harmful abilities or magical effects.",
|
|
35
|
+
effects: [
|
|
36
|
+
"The charmer has advantage on any ability check to interact socially with the creature.",
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
deafened: {
|
|
40
|
+
name: "Deafened",
|
|
41
|
+
description: "A deafened creature can't hear and automatically fails any ability check that requires hearing.",
|
|
42
|
+
effects: [],
|
|
43
|
+
},
|
|
44
|
+
exhaustion: {
|
|
45
|
+
name: "Exhaustion",
|
|
46
|
+
description: "Exhaustion is measured in six levels. An effect can give a creature one or more levels of exhaustion.",
|
|
47
|
+
effects: [
|
|
48
|
+
"Level 1: Disadvantage on ability checks",
|
|
49
|
+
"Level 2: Speed halved",
|
|
50
|
+
"Level 3: Disadvantage on attack rolls and saving throws",
|
|
51
|
+
"Level 4: Hit point maximum halved",
|
|
52
|
+
"Level 5: Speed reduced to 0",
|
|
53
|
+
"Level 6: Death",
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
frightened: {
|
|
57
|
+
name: "Frightened",
|
|
58
|
+
description: "A frightened creature has disadvantage on ability checks and attack rolls while the source of its fear is within line of sight.",
|
|
59
|
+
effects: [
|
|
60
|
+
"The creature can't willingly move closer to the source of its fear.",
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
grappled: {
|
|
64
|
+
name: "Grappled",
|
|
65
|
+
description: "A grappled creature's speed becomes 0, and it can't benefit from any bonus to its speed.",
|
|
66
|
+
effects: [
|
|
67
|
+
"The condition ends if the grappler is incapacitated.",
|
|
68
|
+
"The condition ends if an effect removes the grappled creature from the reach of the grappler.",
|
|
69
|
+
],
|
|
70
|
+
},
|
|
71
|
+
incapacitated: {
|
|
72
|
+
name: "Incapacitated",
|
|
73
|
+
description: "An incapacitated creature can't take actions or reactions.",
|
|
74
|
+
effects: [],
|
|
75
|
+
},
|
|
76
|
+
invisible: {
|
|
77
|
+
name: "Invisible",
|
|
78
|
+
description: "An invisible creature is impossible to see without the aid of magic or a special sense.",
|
|
79
|
+
effects: [
|
|
80
|
+
"Attack rolls against the creature have disadvantage.",
|
|
81
|
+
"The creature's attack rolls have advantage.",
|
|
82
|
+
"The creature can always try to hide.",
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
paralyzed: {
|
|
86
|
+
name: "Paralyzed",
|
|
87
|
+
description: "A paralyzed creature is incapacitated and can't move or speak.",
|
|
88
|
+
effects: [
|
|
89
|
+
"The creature automatically fails Strength and Dexterity saving throws.",
|
|
90
|
+
"Attack rolls against the creature have advantage.",
|
|
91
|
+
"Any attack that hits the creature is a critical hit if the attacker is within 5 feet.",
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
petrified: {
|
|
95
|
+
name: "Petrified",
|
|
96
|
+
description: "A petrified creature is transformed into a solid inanimate substance. It ceases aging.",
|
|
97
|
+
effects: [
|
|
98
|
+
"The creature is incapacitated, can't move or speak, and is unaware of its surroundings.",
|
|
99
|
+
"Attack rolls against the creature have advantage.",
|
|
100
|
+
"The creature automatically fails Strength and Dexterity saving throws.",
|
|
101
|
+
"The creature has resistance to all damage.",
|
|
102
|
+
"The creature is immune to poison and disease.",
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
poisoned: {
|
|
106
|
+
name: "Poisoned",
|
|
107
|
+
description: "A poisoned creature has disadvantage on attack rolls and ability checks.",
|
|
108
|
+
effects: [],
|
|
109
|
+
},
|
|
110
|
+
prone: {
|
|
111
|
+
name: "Prone",
|
|
112
|
+
description: "A prone creature's only movement option is to crawl, unless it stands up and thereby ends the condition.",
|
|
113
|
+
effects: [
|
|
114
|
+
"The creature has disadvantage on attack rolls.",
|
|
115
|
+
"Attack rolls against the creature have advantage if the attacker is within 5 feet; otherwise disadvantage.",
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
restrained: {
|
|
119
|
+
name: "Restrained",
|
|
120
|
+
description: "A restrained creature's speed becomes 0, and it can't benefit from any bonus to its speed.",
|
|
121
|
+
effects: [
|
|
122
|
+
"Attack rolls against the creature have advantage.",
|
|
123
|
+
"The creature's attack rolls have disadvantage.",
|
|
124
|
+
"The creature has disadvantage on Dexterity saving throws.",
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
stunned: {
|
|
128
|
+
name: "Stunned",
|
|
129
|
+
description: "A stunned creature is incapacitated, can't move, and can speak only falteringly.",
|
|
130
|
+
effects: [
|
|
131
|
+
"The creature automatically fails Strength and Dexterity saving throws.",
|
|
132
|
+
"Attack rolls against the creature have advantage.",
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
unconscious: {
|
|
136
|
+
name: "Unconscious",
|
|
137
|
+
description: "An unconscious creature is incapacitated, can't move or speak, and is unaware of its surroundings.",
|
|
138
|
+
effects: [
|
|
139
|
+
"The creature drops whatever it's holding and falls prone.",
|
|
140
|
+
"The creature automatically fails Strength and Dexterity saving throws.",
|
|
141
|
+
"Attack rolls against the creature have advantage.",
|
|
142
|
+
"Any attack that hits the creature is a critical hit if the attacker is within 5 feet.",
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
export function getCondition(conditionName) {
|
|
147
|
+
const key = conditionName.toLowerCase().trim();
|
|
148
|
+
// Exact key match
|
|
149
|
+
let condition = CONDITIONS[key];
|
|
150
|
+
// Partial name match
|
|
151
|
+
if (!condition) {
|
|
152
|
+
condition = Object.values(CONDITIONS).find(c => c.name.toLowerCase().includes(key)) ?? undefined;
|
|
153
|
+
}
|
|
154
|
+
if (!condition) {
|
|
155
|
+
const available = Object.values(CONDITIONS).map(c => c.name).join(", ");
|
|
156
|
+
return `Condition "${conditionName}" not found.\n\nAvailable conditions: ${available}`;
|
|
157
|
+
}
|
|
158
|
+
const lines = [`**${condition.name}**\n`, condition.description];
|
|
159
|
+
if (condition.effects.length > 0) {
|
|
160
|
+
lines.push("");
|
|
161
|
+
for (const effect of condition.effects) {
|
|
162
|
+
lines.push(`• ${effect}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return lines.join("\n");
|
|
166
|
+
}
|
|
167
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
168
|
+
// SPELLS
|
|
169
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
170
|
+
const SPELLCASTING_CLASS_IDS = [1, 2, 3, 4, 5, 6, 7, 8]; // Bard, Cleric, Druid, Paladin, Ranger, Sorcerer, Warlock, Wizard
|
|
171
|
+
let spellCompendium = null;
|
|
172
|
+
function parseO5Range(rangeStr) {
|
|
173
|
+
if (!rangeStr)
|
|
174
|
+
return undefined;
|
|
175
|
+
const s = rangeStr.trim();
|
|
176
|
+
if (/^self$/i.test(s))
|
|
177
|
+
return { origin: "Self" };
|
|
178
|
+
if (/^touch$/i.test(s))
|
|
179
|
+
return { origin: "Touch" };
|
|
180
|
+
if (/^unlimited$/i.test(s))
|
|
181
|
+
return { origin: "Unlimited" };
|
|
182
|
+
const match = s.match(/^(\d+)\s*(?:feet|foot|ft\.?)/i);
|
|
183
|
+
if (match)
|
|
184
|
+
return { origin: "Ranged", rangeValue: parseInt(match[1], 10) };
|
|
185
|
+
return { origin: s };
|
|
186
|
+
}
|
|
187
|
+
function parseO5Components(compStr) {
|
|
188
|
+
if (!compStr)
|
|
189
|
+
return { components: [] };
|
|
190
|
+
const matMatch = compStr.match(/\(([^)]+)\)/);
|
|
191
|
+
const matDesc = matMatch ? matMatch[1] : undefined;
|
|
192
|
+
const MAP = { V: 1, S: 2, M: 3 };
|
|
193
|
+
const components = compStr
|
|
194
|
+
.replace(/\([^)]*\)/g, "")
|
|
195
|
+
.split(",")
|
|
196
|
+
.map(s => MAP[s.trim().toUpperCase()])
|
|
197
|
+
.filter(Boolean);
|
|
198
|
+
return { components, ...(matDesc ? { componentsDescription: matDesc } : {}) };
|
|
199
|
+
}
|
|
200
|
+
// Spells extracted from character JSON (cantrips, Warlock choices, etc.)
|
|
201
|
+
// These are chosen spells absent from always-known/prepared endpoints.
|
|
202
|
+
const characterSpellBuffer = new Map();
|
|
203
|
+
async function loadSpellCompendium() {
|
|
204
|
+
if (spellCompendium)
|
|
205
|
+
return spellCompendium;
|
|
206
|
+
const cached = referenceCache.get("spell-compendium");
|
|
207
|
+
if (cached) {
|
|
208
|
+
spellCompendium = JSON.parse(cached);
|
|
209
|
+
return spellCompendium;
|
|
210
|
+
}
|
|
211
|
+
const allSpells = new Map();
|
|
212
|
+
// Build all 32 request combos (8 classes × 2 endpoints × 2 levels) and fire in parallel.
|
|
213
|
+
// JS is single-threaded so Map writes between await points are race-free.
|
|
214
|
+
const tasks = SPELLCASTING_CLASS_IDS.flatMap(classId => ["always-known-spells", "always-prepared-spells"].flatMap(endpoint => [1, 20].map(level => ({ classId, endpoint, level }))));
|
|
215
|
+
await Promise.all(tasks.map(async ({ classId, endpoint, level }) => {
|
|
216
|
+
try {
|
|
217
|
+
// classLevel=1 gets auto-granted spells, classLevel=20 gets the full list
|
|
218
|
+
const url = `${CHARACTER_SERVICE}/character/v5/game-data/${endpoint}?classId=${classId}&classLevel=${level}&sharingSetting=2`;
|
|
219
|
+
const resp = await refFetch(url);
|
|
220
|
+
if (!resp.ok)
|
|
221
|
+
return;
|
|
222
|
+
const json = await resp.json();
|
|
223
|
+
const spells = (Array.isArray(json) ? json : json.data) ?? [];
|
|
224
|
+
for (const spell of spells) {
|
|
225
|
+
const name = spell.definition?.name;
|
|
226
|
+
if (name && !allSpells.has(name)) {
|
|
227
|
+
allSpells.set(name, spell);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
// Continue — partial compendium is still useful
|
|
233
|
+
}
|
|
234
|
+
}));
|
|
235
|
+
if (allSpells.size === 0) {
|
|
236
|
+
throw new Error("Failed to load spell compendium — all API requests failed. Check login status.");
|
|
237
|
+
}
|
|
238
|
+
// Merge character-sourced spells (cantrips, Warlock choices, etc.)
|
|
239
|
+
for (const [name, spell] of characterSpellBuffer) {
|
|
240
|
+
if (!allSpells.has(name))
|
|
241
|
+
allSpells.set(name, spell);
|
|
242
|
+
}
|
|
243
|
+
// Supplement with SRD cantrips from Open5e (covers cold lookups before any character is loaded)
|
|
244
|
+
try {
|
|
245
|
+
const cantrips = await fetchO5Cantrips();
|
|
246
|
+
for (const c of cantrips) {
|
|
247
|
+
if (!allSpells.has(c.name)) {
|
|
248
|
+
const { components, componentsDescription } = parseO5Components(c.components);
|
|
249
|
+
allSpells.set(c.name, {
|
|
250
|
+
definition: {
|
|
251
|
+
name: c.name,
|
|
252
|
+
level: c.level_int,
|
|
253
|
+
school: c.school,
|
|
254
|
+
description: c.desc,
|
|
255
|
+
concentration: c.requires_concentration,
|
|
256
|
+
ritual: c.ritual === "yes",
|
|
257
|
+
activation: { activationTime: 1, activationType: 1 },
|
|
258
|
+
range: parseO5Range(c.range),
|
|
259
|
+
components,
|
|
260
|
+
...(componentsDescription ? { componentsDescription } : {}),
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// Open5e down — proceed without SRD cantrips from this source
|
|
268
|
+
}
|
|
269
|
+
spellCompendium = Array.from(allSpells.values());
|
|
270
|
+
referenceCache.set("spell-compendium", JSON.stringify(spellCompendium));
|
|
271
|
+
return spellCompendium;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Supplements the spell compendium with spells from a parsed character's
|
|
275
|
+
* classSpells array. Captures cantrips and chosen spells absent from the
|
|
276
|
+
* always-known endpoints. Called from parseCharacterData().
|
|
277
|
+
*/
|
|
278
|
+
export function addCharacterSpellsToCompendium(char) {
|
|
279
|
+
const arrOf = (v) => (Array.isArray(v) ? v : []);
|
|
280
|
+
const objOf = (v) => v != null && typeof v === "object" && !Array.isArray(v)
|
|
281
|
+
? v
|
|
282
|
+
: {};
|
|
283
|
+
let added = false;
|
|
284
|
+
for (const cs of arrOf(char.classSpells)) {
|
|
285
|
+
for (const spell of arrOf(cs.spells)) {
|
|
286
|
+
const name = objOf(spell.definition).name;
|
|
287
|
+
if (name && !characterSpellBuffer.has(name)) {
|
|
288
|
+
characterSpellBuffer.set(name, spell);
|
|
289
|
+
added = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// Merge immediately into the live compendium if already loaded
|
|
294
|
+
if (added && spellCompendium) {
|
|
295
|
+
const existing = new Set(spellCompendium.map(s => s.definition?.name));
|
|
296
|
+
for (const [name, spell] of characterSpellBuffer) {
|
|
297
|
+
if (!existing.has(name))
|
|
298
|
+
spellCompendium.push(spell);
|
|
299
|
+
}
|
|
300
|
+
referenceCache.set("spell-compendium", JSON.stringify(spellCompendium));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function formatSpell(spell) {
|
|
304
|
+
const d = spell.definition;
|
|
305
|
+
const ACTIVATION_TYPES = {
|
|
306
|
+
1: "Action", 3: "Bonus Action", 6: "Reaction",
|
|
307
|
+
};
|
|
308
|
+
const levelLabel = d.level === 0 ? "Cantrip" : `Level ${d.level}`;
|
|
309
|
+
const castingTime = d.activation
|
|
310
|
+
? `${d.activation.activationTime} ${ACTIVATION_TYPES[d.activation.activationType] ?? "Action"}`
|
|
311
|
+
: "1 Action";
|
|
312
|
+
let range = "Self";
|
|
313
|
+
if (d.range) {
|
|
314
|
+
if (d.range.rangeValue && d.range.origin !== "Self")
|
|
315
|
+
range = `${d.range.rangeValue} ft`;
|
|
316
|
+
else
|
|
317
|
+
range = d.range.origin;
|
|
318
|
+
if (d.range.aoeType && d.range.aoeValue)
|
|
319
|
+
range += ` (${d.range.aoeValue}-ft ${d.range.aoeType})`;
|
|
320
|
+
}
|
|
321
|
+
let duration = "Instantaneous";
|
|
322
|
+
if (d.duration) {
|
|
323
|
+
const isConc = d.duration.durationType === "Concentration";
|
|
324
|
+
if (d.duration.durationInterval && d.duration.durationUnit) {
|
|
325
|
+
duration = `${isConc ? "Concentration, up to " : ""}${d.duration.durationInterval} ${d.duration.durationUnit}${Number(d.duration.durationInterval) > 1 ? "s" : ""}`;
|
|
326
|
+
}
|
|
327
|
+
else if (isConc) {
|
|
328
|
+
duration = "Concentration";
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const components = (d.components ?? [])
|
|
332
|
+
.map((c) => ({ 1: "V", 2: "S", 3: "M" })[c])
|
|
333
|
+
.filter(Boolean).join(", ");
|
|
334
|
+
const matNote = d.componentsDescription ? ` (${d.componentsDescription})` : "";
|
|
335
|
+
const tags = [];
|
|
336
|
+
if (d.concentration)
|
|
337
|
+
tags.push("Concentration");
|
|
338
|
+
if (d.ritual)
|
|
339
|
+
tags.push("Ritual");
|
|
340
|
+
const tagStr = tags.length ? ` [${tags.join(", ")}]` : "";
|
|
341
|
+
const lines = [
|
|
342
|
+
`**${d.name}** — ${levelLabel} ${d.school}${tagStr}`,
|
|
343
|
+
`Casting Time: ${castingTime}`,
|
|
344
|
+
`Range: ${range}`,
|
|
345
|
+
`Components: ${components || "None"}${matNote}`,
|
|
346
|
+
`Duration: ${duration}`,
|
|
347
|
+
"",
|
|
348
|
+
stripHtml(d.description),
|
|
349
|
+
];
|
|
350
|
+
return lines.join("\n");
|
|
351
|
+
}
|
|
352
|
+
export async function searchSpells(params) {
|
|
353
|
+
try {
|
|
354
|
+
const spells = await loadSpellCompendium();
|
|
355
|
+
let matched = spells;
|
|
356
|
+
if (params.name) {
|
|
357
|
+
const q = params.name.toLowerCase();
|
|
358
|
+
matched = matched.filter(s => s.definition.name.toLowerCase().includes(q));
|
|
359
|
+
}
|
|
360
|
+
if (params.level !== undefined) {
|
|
361
|
+
matched = matched.filter(s => s.definition.level === params.level);
|
|
362
|
+
}
|
|
363
|
+
if (params.school) {
|
|
364
|
+
const q = params.school.toLowerCase();
|
|
365
|
+
matched = matched.filter(s => s.definition.school.toLowerCase().includes(q));
|
|
366
|
+
}
|
|
367
|
+
if (params.concentration !== undefined) {
|
|
368
|
+
matched = matched.filter(s => s.definition.concentration === params.concentration);
|
|
369
|
+
}
|
|
370
|
+
if (params.ritual !== undefined) {
|
|
371
|
+
matched = matched.filter(s => s.definition.ritual === params.ritual);
|
|
372
|
+
}
|
|
373
|
+
matched.sort((a, b) => {
|
|
374
|
+
if (a.definition.level !== b.definition.level)
|
|
375
|
+
return a.definition.level - b.definition.level;
|
|
376
|
+
return a.definition.name.localeCompare(b.definition.name);
|
|
377
|
+
});
|
|
378
|
+
const total = matched.length;
|
|
379
|
+
const limit = params.limit ?? 20;
|
|
380
|
+
const offset = params.offset ?? 0;
|
|
381
|
+
const page = matched.slice(offset, offset + limit);
|
|
382
|
+
if (page.length === 0)
|
|
383
|
+
return "No spells found matching the criteria.";
|
|
384
|
+
const header = total > limit
|
|
385
|
+
? `**Spell Search** — showing ${offset + 1}–${offset + page.length} of ${total}`
|
|
386
|
+
: `**Spell Search** (${total} found)`;
|
|
387
|
+
const lines = [header + "\n"];
|
|
388
|
+
for (const s of page) {
|
|
389
|
+
const d = s.definition;
|
|
390
|
+
const level = d.level === 0 ? "Cantrip" : `Level ${d.level}`;
|
|
391
|
+
const tags = [];
|
|
392
|
+
if (d.concentration)
|
|
393
|
+
tags.push("Conc.");
|
|
394
|
+
if (d.ritual)
|
|
395
|
+
tags.push("Ritual");
|
|
396
|
+
const tagStr = tags.length ? ` (${tags.join(", ")})` : "";
|
|
397
|
+
lines.push(`- **${d.name}** — ${level} ${d.school}${tagStr}`);
|
|
398
|
+
}
|
|
399
|
+
if (total > offset + limit) {
|
|
400
|
+
lines.push(`\n*Use offset: ${offset + limit} to see more.*`);
|
|
401
|
+
}
|
|
402
|
+
return lines.join("\n");
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
try {
|
|
406
|
+
return await o5SearchSpells(params);
|
|
407
|
+
}
|
|
408
|
+
catch {
|
|
409
|
+
throw err;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
export async function getSpell(spellName) {
|
|
414
|
+
try {
|
|
415
|
+
const spells = await loadSpellCompendium();
|
|
416
|
+
const q = spellName.toLowerCase();
|
|
417
|
+
const spell = spells.find(s => s.definition.name.toLowerCase() === q)
|
|
418
|
+
?? spells.find(s => s.definition.name.toLowerCase().includes(q));
|
|
419
|
+
if (!spell) {
|
|
420
|
+
const o5Result = await o5GetSpell(spellName).catch(() => null);
|
|
421
|
+
if (o5Result)
|
|
422
|
+
return o5Result;
|
|
423
|
+
return `Spell "${spellName}" not found in the compendium. Try ddb_search_spells with a partial name.`;
|
|
424
|
+
}
|
|
425
|
+
return formatSpell(spell);
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
try {
|
|
429
|
+
const o5Result = await o5GetSpell(spellName);
|
|
430
|
+
if (o5Result)
|
|
431
|
+
return o5Result;
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
// fall through
|
|
435
|
+
}
|
|
436
|
+
throw err;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
let itemCompendium = null;
|
|
440
|
+
async function loadItemCompendium() {
|
|
441
|
+
if (itemCompendium)
|
|
442
|
+
return itemCompendium;
|
|
443
|
+
const cached = referenceCache.get("item-compendium");
|
|
444
|
+
if (cached) {
|
|
445
|
+
itemCompendium = JSON.parse(cached);
|
|
446
|
+
return itemCompendium;
|
|
447
|
+
}
|
|
448
|
+
const url = `${CHARACTER_SERVICE}/character/v5/game-data/items?sharingSetting=2`;
|
|
449
|
+
const resp = await refFetch(url);
|
|
450
|
+
if (!resp.ok)
|
|
451
|
+
throw new Error(`Item compendium fetch failed: ${resp.status} ${resp.statusText}`);
|
|
452
|
+
const json = await resp.json();
|
|
453
|
+
const items = (Array.isArray(json) ? json : json.data) ?? [];
|
|
454
|
+
itemCompendium = items;
|
|
455
|
+
referenceCache.set("item-compendium", JSON.stringify(items));
|
|
456
|
+
return items;
|
|
457
|
+
}
|
|
458
|
+
function formatItem(item) {
|
|
459
|
+
const type = item.filterType || item.type || "Item";
|
|
460
|
+
const rarity = item.rarity || "Common";
|
|
461
|
+
const lines = [`**${item.name}** — ${type}, ${rarity}`];
|
|
462
|
+
if (item.requiresAttunement) {
|
|
463
|
+
lines.push(`Requires Attunement${item.attunementDescription ? ": " + item.attunementDescription : ""}`);
|
|
464
|
+
}
|
|
465
|
+
if (item.weight)
|
|
466
|
+
lines.push(`Weight: ${item.weight} lb.`);
|
|
467
|
+
if (item.armorClass) {
|
|
468
|
+
let acLine = `AC: ${item.armorClass}`;
|
|
469
|
+
if (item.strengthRequirement)
|
|
470
|
+
acLine += ` | Requires STR ${item.strengthRequirement}`;
|
|
471
|
+
if (item.stealthCheck)
|
|
472
|
+
acLine += ` | Stealth disadvantage`;
|
|
473
|
+
lines.push(acLine);
|
|
474
|
+
}
|
|
475
|
+
if (item.damage?.diceString) {
|
|
476
|
+
const versatileNotes = item.properties?.find(p => p.name === "Versatile")?.notes;
|
|
477
|
+
const typeStr = item.damageType ? ` ${item.damageType.toLowerCase()}` : "";
|
|
478
|
+
let dmgLine = `Damage: ${item.damage.diceString}${typeStr}`;
|
|
479
|
+
if (versatileNotes)
|
|
480
|
+
dmgLine += ` (${versatileNotes} two-handed)`;
|
|
481
|
+
lines.push(dmgLine);
|
|
482
|
+
}
|
|
483
|
+
// Suppress melee range (5/5) — implied. Show ranged weapons only (longRange > range).
|
|
484
|
+
if (item.range != null && item.longRange != null && item.longRange > item.range) {
|
|
485
|
+
lines.push(`Range: ${item.range}/${item.longRange} ft.`);
|
|
486
|
+
}
|
|
487
|
+
else if (item.range != null && item.longRange == null && item.range > 5) {
|
|
488
|
+
lines.push(`Range: ${item.range} ft.`);
|
|
489
|
+
}
|
|
490
|
+
if (item.properties?.length) {
|
|
491
|
+
lines.push(`Properties: ${item.properties.map(p => p.name).join(", ")}`);
|
|
492
|
+
}
|
|
493
|
+
lines.push("", stripHtml(item.description || item.snippet || "No description available."));
|
|
494
|
+
return lines.join("\n");
|
|
495
|
+
}
|
|
496
|
+
export async function searchItems(params) {
|
|
497
|
+
const items = await loadItemCompendium();
|
|
498
|
+
let matched = items;
|
|
499
|
+
if (params.name) {
|
|
500
|
+
const q = params.name.toLowerCase();
|
|
501
|
+
matched = matched.filter(i => i.name.toLowerCase().includes(q));
|
|
502
|
+
}
|
|
503
|
+
if (params.rarity) {
|
|
504
|
+
const q = params.rarity.toLowerCase();
|
|
505
|
+
matched = matched.filter(i => i.rarity?.toLowerCase().includes(q));
|
|
506
|
+
}
|
|
507
|
+
if (params.type) {
|
|
508
|
+
const q = params.type.toLowerCase();
|
|
509
|
+
matched = matched.filter(i => i.type?.toLowerCase().includes(q) || i.filterType?.toLowerCase().includes(q));
|
|
510
|
+
}
|
|
511
|
+
matched.sort((a, b) => a.name.localeCompare(b.name));
|
|
512
|
+
const total = matched.length;
|
|
513
|
+
const shown = matched.slice(0, 30);
|
|
514
|
+
if (shown.length === 0) {
|
|
515
|
+
return await o5SearchItems(params.name ?? "").catch(() => "No items found matching the criteria.");
|
|
516
|
+
}
|
|
517
|
+
const lines = [`**Item Search** (${total > 30 ? `showing 30 of ${total}` : `${total} found`})\n`];
|
|
518
|
+
for (const item of shown) {
|
|
519
|
+
const rarity = item.rarity || "Common";
|
|
520
|
+
const type = item.filterType || item.type || "Item";
|
|
521
|
+
const attune = item.requiresAttunement ? " (attunement)" : "";
|
|
522
|
+
lines.push(`- **${item.name}** — ${rarity} ${type}${attune}`);
|
|
523
|
+
}
|
|
524
|
+
if (total > 30)
|
|
525
|
+
lines.push(`\n*Refine your search to see more results.*`);
|
|
526
|
+
return lines.join("\n");
|
|
527
|
+
}
|
|
528
|
+
export async function getItem(itemName) {
|
|
529
|
+
try {
|
|
530
|
+
const items = await loadItemCompendium();
|
|
531
|
+
const q = itemName.toLowerCase();
|
|
532
|
+
const item = items.find(i => i.name.toLowerCase() === q)
|
|
533
|
+
?? items.find(i => i.name.toLowerCase().includes(q));
|
|
534
|
+
if (!item) {
|
|
535
|
+
// Try magic items, then weapons, then armour via Open5e
|
|
536
|
+
for (const fallback of [o5GetItem, o5GetWeapon, o5GetArmor]) {
|
|
537
|
+
const result = await fallback(itemName).catch(() => null);
|
|
538
|
+
if (result)
|
|
539
|
+
return result;
|
|
540
|
+
}
|
|
541
|
+
return `Item "${itemName}" not found. Try ddb_search_equipment with a partial name.`;
|
|
542
|
+
}
|
|
543
|
+
return formatItem(item);
|
|
544
|
+
}
|
|
545
|
+
catch (err) {
|
|
546
|
+
for (const fallback of [o5GetItem, o5GetWeapon, o5GetArmor]) {
|
|
547
|
+
try {
|
|
548
|
+
const result = await fallback(itemName);
|
|
549
|
+
if (result)
|
|
550
|
+
return result;
|
|
551
|
+
}
|
|
552
|
+
catch { /* continue */ }
|
|
553
|
+
}
|
|
554
|
+
throw err;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
558
|
+
// COMPENDIUM TOOLS — races, classes, backgrounds, feats, features, traits
|
|
559
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
560
|
+
function paginateResults(label, items, format, limit = 30, offset = 0) {
|
|
561
|
+
const total = items.length;
|
|
562
|
+
if (total === 0)
|
|
563
|
+
return "";
|
|
564
|
+
const page = items.slice(offset, offset + limit);
|
|
565
|
+
const header = total > limit
|
|
566
|
+
? `**${label} Search** — showing ${offset + 1}–${offset + page.length} of ${total}`
|
|
567
|
+
: `**${label} Search** (${total} found)`;
|
|
568
|
+
const lines = [header + "\n", ...page.map(format)];
|
|
569
|
+
if (total > offset + limit) {
|
|
570
|
+
lines.push(`\n*Use offset: ${offset + limit} to see more.*`);
|
|
571
|
+
}
|
|
572
|
+
return lines.join("\n");
|
|
573
|
+
}
|
|
574
|
+
export async function searchRaces(name, limit, offset) {
|
|
575
|
+
try {
|
|
576
|
+
const cacheKey = "game-data:races";
|
|
577
|
+
const cached = referenceCache.get(cacheKey);
|
|
578
|
+
let races;
|
|
579
|
+
if (cached) {
|
|
580
|
+
races = JSON.parse(cached);
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
const resp = await refFetch(`${CHARACTER_SERVICE}/character/v5/game-data/races`);
|
|
584
|
+
if (!resp.ok)
|
|
585
|
+
throw new Error(`Races fetch failed: ${resp.status} ${resp.statusText}`);
|
|
586
|
+
const json = await resp.json();
|
|
587
|
+
races = (Array.isArray(json) ? json : json.data) ?? [];
|
|
588
|
+
referenceCache.set(cacheKey, JSON.stringify(races));
|
|
589
|
+
}
|
|
590
|
+
let matched = races.filter(r => r.fullName || r.baseName);
|
|
591
|
+
if (name) {
|
|
592
|
+
const q = name.toLowerCase();
|
|
593
|
+
matched = matched.filter(r => (r.fullName || r.baseName).toLowerCase().includes(q));
|
|
594
|
+
}
|
|
595
|
+
matched.sort((a, b) => (a.fullName || a.baseName).localeCompare(b.fullName || b.baseName));
|
|
596
|
+
const result = paginateResults("Race", matched, race => {
|
|
597
|
+
const raceName = race.fullName || race.baseName;
|
|
598
|
+
const tags = [];
|
|
599
|
+
if (race.isSubRace)
|
|
600
|
+
tags.push("Subrace");
|
|
601
|
+
if (race.isLegacy)
|
|
602
|
+
tags.push("Legacy");
|
|
603
|
+
if (race.isHomebrew)
|
|
604
|
+
tags.push("Homebrew");
|
|
605
|
+
const tagStr = tags.length ? ` *(${tags.join(", ")})*` : "";
|
|
606
|
+
const desc = stripHtml(race.description || "").slice(0, 100);
|
|
607
|
+
return `- **${raceName}**${tagStr}${desc ? ` — ${desc}${desc.length >= 100 ? "..." : ""}` : ""}`;
|
|
608
|
+
}, limit ?? 30, offset ?? 0);
|
|
609
|
+
if (!result) {
|
|
610
|
+
return await o5SearchRaces(name).catch(() => `No races found matching "${name}".`);
|
|
611
|
+
}
|
|
612
|
+
return result;
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
return await o5SearchRaces(name).catch(() => "Races unavailable — DnD Beyond and Open5e both failed.");
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// ── Classes ───────────────────────────────────────────────────────────────────
|
|
619
|
+
const SPELLCASTING_ABILITY = {
|
|
620
|
+
1: "STR", 2: "DEX", 3: "CON", 4: "INT", 5: "WIS", 6: "CHA",
|
|
621
|
+
};
|
|
622
|
+
export async function searchClasses(name, limit, offset) {
|
|
623
|
+
try {
|
|
624
|
+
const cacheKey = "game-data:classes";
|
|
625
|
+
const cached = referenceCache.get(cacheKey);
|
|
626
|
+
let classes;
|
|
627
|
+
if (cached) {
|
|
628
|
+
classes = JSON.parse(cached);
|
|
629
|
+
}
|
|
630
|
+
else {
|
|
631
|
+
const resp = await refFetch(`${CHARACTER_SERVICE}/character/v5/game-data/classes`);
|
|
632
|
+
if (!resp.ok)
|
|
633
|
+
throw new Error(`Classes fetch failed: ${resp.status} ${resp.statusText}`);
|
|
634
|
+
const json = await resp.json();
|
|
635
|
+
classes = (Array.isArray(json) ? json : json.data) ?? [];
|
|
636
|
+
referenceCache.set(cacheKey, JSON.stringify(classes));
|
|
637
|
+
}
|
|
638
|
+
let matched = classes;
|
|
639
|
+
if (name) {
|
|
640
|
+
const q = name.toLowerCase();
|
|
641
|
+
matched = matched.filter(c => c.name.toLowerCase().includes(q));
|
|
642
|
+
}
|
|
643
|
+
matched.sort((a, b) => a.name.localeCompare(b.name));
|
|
644
|
+
const result = paginateResults("Class", matched, cls => {
|
|
645
|
+
const hitDie = cls.hitDice ? `d${cls.hitDice}` : "?";
|
|
646
|
+
const spellcasting = cls.spellCastingAbilityId
|
|
647
|
+
? ` | Spellcasting: ${SPELLCASTING_ABILITY[cls.spellCastingAbilityId] ?? "Yes"}`
|
|
648
|
+
: "";
|
|
649
|
+
const homebrew = cls.isHomebrew ? " *(Homebrew)*" : "";
|
|
650
|
+
const subclasses = cls.subclasses?.length
|
|
651
|
+
? `\n Subclasses: ${cls.subclasses.map(s => s.name).join(", ")}`
|
|
652
|
+
: "";
|
|
653
|
+
const desc = stripHtml(cls.description || "").slice(0, 120);
|
|
654
|
+
return `- **${cls.name}**${homebrew} — Hit Die: ${hitDie}${spellcasting}${desc ? `\n ${desc}${desc.length >= 120 ? "..." : ""}` : ""}${subclasses}`;
|
|
655
|
+
}, limit ?? 30, offset ?? 0);
|
|
656
|
+
if (!result) {
|
|
657
|
+
return await o5SearchClasses(name).catch(() => `No classes found matching "${name}".`);
|
|
658
|
+
}
|
|
659
|
+
return result;
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
return await o5SearchClasses(name).catch(() => "Classes unavailable — DnD Beyond and Open5e both failed.");
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
export async function searchBackgrounds(name, limit, offset) {
|
|
666
|
+
try {
|
|
667
|
+
const cacheKey = "game-data:backgrounds";
|
|
668
|
+
const cached = referenceCache.get(cacheKey);
|
|
669
|
+
let backgrounds;
|
|
670
|
+
if (cached) {
|
|
671
|
+
backgrounds = JSON.parse(cached);
|
|
672
|
+
}
|
|
673
|
+
else {
|
|
674
|
+
const resp = await refFetch(`${CHARACTER_SERVICE}/character/v5/game-data/backgrounds`);
|
|
675
|
+
if (!resp.ok)
|
|
676
|
+
throw new Error(`Backgrounds fetch failed: ${resp.status} ${resp.statusText}`);
|
|
677
|
+
const json = await resp.json();
|
|
678
|
+
backgrounds = (Array.isArray(json) ? json : json.data) ?? [];
|
|
679
|
+
referenceCache.set(cacheKey, JSON.stringify(backgrounds));
|
|
680
|
+
}
|
|
681
|
+
let matched = backgrounds;
|
|
682
|
+
if (name) {
|
|
683
|
+
const q = name.toLowerCase();
|
|
684
|
+
matched = matched.filter(b => b.name.toLowerCase().includes(q));
|
|
685
|
+
}
|
|
686
|
+
matched.sort((a, b) => a.name.localeCompare(b.name));
|
|
687
|
+
const result = paginateResults("Background", matched, bg => {
|
|
688
|
+
const homebrew = bg.isHomebrew ? " *(Homebrew)*" : "";
|
|
689
|
+
const desc = stripHtml(bg.description || "").slice(0, 120);
|
|
690
|
+
return `- **${bg.name}**${homebrew}${desc ? ` — ${desc}${desc.length >= 120 ? "..." : ""}` : ""}`;
|
|
691
|
+
}, limit ?? 30, offset ?? 0);
|
|
692
|
+
if (!result) {
|
|
693
|
+
return await o5SearchBackgrounds(name).catch(() => `No backgrounds found matching "${name}".`);
|
|
694
|
+
}
|
|
695
|
+
return result;
|
|
696
|
+
}
|
|
697
|
+
catch {
|
|
698
|
+
return await o5SearchBackgrounds(name).catch(() => "Backgrounds unavailable — DnD Beyond and Open5e both failed.");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
export async function searchFeats(params) {
|
|
702
|
+
try {
|
|
703
|
+
const cacheKey = "game-data:feats";
|
|
704
|
+
const cached = referenceCache.get(cacheKey);
|
|
705
|
+
let feats;
|
|
706
|
+
if (cached) {
|
|
707
|
+
feats = JSON.parse(cached);
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
const resp = await refFetch(`${CHARACTER_SERVICE}/character/v5/game-data/feats`);
|
|
711
|
+
if (!resp.ok)
|
|
712
|
+
throw new Error(`Feats fetch failed: ${resp.status} ${resp.statusText}`);
|
|
713
|
+
const json = await resp.json();
|
|
714
|
+
feats = (Array.isArray(json) ? json : json.data) ?? [];
|
|
715
|
+
referenceCache.set(cacheKey, JSON.stringify(feats));
|
|
716
|
+
}
|
|
717
|
+
let matched = feats;
|
|
718
|
+
if (params.name) {
|
|
719
|
+
const q = params.name.toLowerCase();
|
|
720
|
+
matched = matched.filter(f => f.name.toLowerCase().includes(q));
|
|
721
|
+
}
|
|
722
|
+
if (params.prerequisite) {
|
|
723
|
+
const q = params.prerequisite.toLowerCase();
|
|
724
|
+
matched = matched.filter(f => f.prerequisite?.toLowerCase().includes(q));
|
|
725
|
+
}
|
|
726
|
+
matched.sort((a, b) => a.name.localeCompare(b.name));
|
|
727
|
+
const result = paginateResults("Feat", matched, feat => {
|
|
728
|
+
const prereq = feat.prerequisite ? ` *(Requires: ${feat.prerequisite})*` : "";
|
|
729
|
+
const homebrew = feat.isHomebrew ? " *(Homebrew)*" : "";
|
|
730
|
+
const desc = stripHtml(feat.snippet || feat.description || "").slice(0, 100);
|
|
731
|
+
return `- **${feat.name}**${homebrew}${prereq}${desc ? ` — ${desc}${desc.length >= 100 ? "..." : ""}` : ""}`;
|
|
732
|
+
}, params.limit ?? 30, params.offset ?? 0);
|
|
733
|
+
if (!result) {
|
|
734
|
+
return await o5SearchFeats(params.name).catch(() => "No feats found matching the search criteria.");
|
|
735
|
+
}
|
|
736
|
+
return result;
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
return await o5SearchFeats(params.name).catch(() => "Feats unavailable — DnD Beyond and Open5e both failed.");
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
export async function searchClassFeatures(params) {
|
|
743
|
+
const cacheKey = "game-data:class-features";
|
|
744
|
+
const cached = referenceCache.get(cacheKey);
|
|
745
|
+
let features;
|
|
746
|
+
if (cached) {
|
|
747
|
+
features = JSON.parse(cached);
|
|
748
|
+
}
|
|
749
|
+
else {
|
|
750
|
+
const resp = await refFetch(`${CHARACTER_SERVICE}/character/v5/game-data/class-feature/collection`);
|
|
751
|
+
if (resp.status === 404) {
|
|
752
|
+
return "Class feature search is not available on this account. Use ddb_character_lookup with a character_name to look up features for a specific character instead.";
|
|
753
|
+
}
|
|
754
|
+
if (!resp.ok)
|
|
755
|
+
throw new Error(`Class features fetch failed: ${resp.status} ${resp.statusText}`);
|
|
756
|
+
const json = await resp.json();
|
|
757
|
+
features = (Array.isArray(json) ? json : json.data) ?? [];
|
|
758
|
+
referenceCache.set(cacheKey, JSON.stringify(features));
|
|
759
|
+
}
|
|
760
|
+
let matched = features;
|
|
761
|
+
if (params.name) {
|
|
762
|
+
const q = params.name.toLowerCase();
|
|
763
|
+
matched = matched.filter(f => f.name.toLowerCase().includes(q));
|
|
764
|
+
}
|
|
765
|
+
if (params.className) {
|
|
766
|
+
const q = params.className.toLowerCase();
|
|
767
|
+
matched = matched.filter(f => f.className?.toLowerCase().includes(q));
|
|
768
|
+
}
|
|
769
|
+
if (params.level !== undefined) {
|
|
770
|
+
matched = matched.filter(f => f.requiredLevel === params.level);
|
|
771
|
+
}
|
|
772
|
+
matched.sort((a, b) => {
|
|
773
|
+
const classComp = (a.className || "").localeCompare(b.className || "");
|
|
774
|
+
if (classComp !== 0)
|
|
775
|
+
return classComp;
|
|
776
|
+
if (a.requiredLevel !== b.requiredLevel)
|
|
777
|
+
return a.requiredLevel - b.requiredLevel;
|
|
778
|
+
return a.name.localeCompare(b.name);
|
|
779
|
+
});
|
|
780
|
+
if (matched.length === 0)
|
|
781
|
+
return "No class features found matching the search criteria.";
|
|
782
|
+
return paginateResults("Class Feature", matched, feature => {
|
|
783
|
+
const className = feature.className || "Unknown";
|
|
784
|
+
const level = feature.requiredLevel ?? "?";
|
|
785
|
+
const desc = stripHtml(feature.snippet || feature.description || "").slice(0, 100);
|
|
786
|
+
return `- **${feature.name}** — ${className} level ${level}${desc ? `\n ${desc}${desc.length >= 100 ? "..." : ""}` : ""}`;
|
|
787
|
+
}, params.limit ?? 30, params.offset ?? 0);
|
|
788
|
+
}
|
|
789
|
+
export async function searchRacialTraits(params) {
|
|
790
|
+
const cacheKey = "game-data:racial-traits";
|
|
791
|
+
const cached = referenceCache.get(cacheKey);
|
|
792
|
+
let traits;
|
|
793
|
+
if (cached) {
|
|
794
|
+
traits = JSON.parse(cached);
|
|
795
|
+
}
|
|
796
|
+
else {
|
|
797
|
+
const resp = await refFetch(`${CHARACTER_SERVICE}/character/v5/game-data/racial-trait/collection`);
|
|
798
|
+
if (!resp.ok)
|
|
799
|
+
throw new Error(`Racial traits fetch failed: ${resp.status} ${resp.statusText}`);
|
|
800
|
+
const json = await resp.json();
|
|
801
|
+
let raw = json;
|
|
802
|
+
if (!Array.isArray(raw)) {
|
|
803
|
+
raw = json.data
|
|
804
|
+
?? json.collection
|
|
805
|
+
?? json.results
|
|
806
|
+
?? [];
|
|
807
|
+
}
|
|
808
|
+
traits = Array.isArray(raw) ? raw : [];
|
|
809
|
+
if (traits.length === 0) {
|
|
810
|
+
process.stderr.write(`[ddb-mcp] racial-trait/collection response keys: ${Object.keys(json).join(", ")}\n`);
|
|
811
|
+
}
|
|
812
|
+
referenceCache.set(cacheKey, JSON.stringify(traits));
|
|
813
|
+
}
|
|
814
|
+
let matched = traits;
|
|
815
|
+
if (params.name) {
|
|
816
|
+
const q = params.name.toLowerCase();
|
|
817
|
+
matched = matched.filter(t => t.name.toLowerCase().includes(q));
|
|
818
|
+
}
|
|
819
|
+
if (params.raceName) {
|
|
820
|
+
const q = params.raceName.toLowerCase();
|
|
821
|
+
matched = matched.filter(t => t.raceName?.toLowerCase().includes(q));
|
|
822
|
+
}
|
|
823
|
+
matched.sort((a, b) => {
|
|
824
|
+
const raceComp = (a.raceName || "").localeCompare(b.raceName || "");
|
|
825
|
+
if (raceComp !== 0)
|
|
826
|
+
return raceComp;
|
|
827
|
+
return a.name.localeCompare(b.name);
|
|
828
|
+
});
|
|
829
|
+
if (matched.length === 0)
|
|
830
|
+
return "No racial traits found matching the search criteria.";
|
|
831
|
+
return paginateResults("Racial Trait", matched, trait => {
|
|
832
|
+
const raceName = trait.raceName || "Unknown";
|
|
833
|
+
const desc = stripHtml(trait.snippet || trait.description || "").slice(0, 100);
|
|
834
|
+
return `- **${trait.name}** *(${raceName})*${desc ? ` — ${desc}${desc.length >= 100 ? "..." : ""}` : ""}`;
|
|
835
|
+
}, params.limit ?? 30, params.offset ?? 0);
|
|
836
|
+
}
|
|
837
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
838
|
+
// RULES SECTIONS
|
|
839
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
840
|
+
export async function searchRules(query) {
|
|
841
|
+
return o5SearchSections(query);
|
|
842
|
+
}
|
|
843
|
+
export async function getRule(nameOrSlug, maxChars, query) {
|
|
844
|
+
const result = await o5GetSection(nameOrSlug, maxChars, query);
|
|
845
|
+
if (!result) {
|
|
846
|
+
return `Rules section "${nameOrSlug}" not found. Try ddb_search_rules to browse available sections.`;
|
|
847
|
+
}
|
|
848
|
+
return result;
|
|
849
|
+
}
|
|
850
|
+
//# sourceMappingURL=reference.js.map
|