Files
initiative/apps/web/src/adapters/bestiary-adapter.ts
Lukas e161645228
All checks were successful
CI / check (push) Successful in 2m31s
CI / build-image (push) Successful in 26s
Add PF2e spell description popovers in stat blocks
Clicking a spell name in a PF2e creature's stat block now opens a
popover (desktop) or bottom sheet (mobile) showing full spell details:
description, traits, rank, range, target, area, duration, defense,
action cost icons, and heightening rules. All data is sourced from
the embedded Foundry VTT spell items already in the bestiary cache.

- Add SpellReference type replacing bare string spell arrays
- Extract full spell data in pf2e-bestiary-adapter (description,
  traits, traditions, range, target, area, duration, defense,
  action cost, heightening, overlays)
- Strip inline heightening text from descriptions to avoid duplication
- Bold save outcome labels (Critical Success/Failure) in descriptions
- Bump DB_VERSION to 6 for cache invalidation
- Add useSwipeToDismissDown hook for mobile bottom sheet
- Portal popover to document.body to escape transformed ancestors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:18:08 +02:00

540 lines
14 KiB
TypeScript

import type {
Creature,
CreatureId,
DailySpells,
LegendaryBlock,
SpellcastingBlock,
SpellReference,
TraitBlock,
TraitListItem,
TraitSegment,
} from "@initiative/domain";
import { creatureId, proficiencyBonus } from "@initiative/domain";
import { stripTags } from "./strip-tags.js";
const LEADING_DIGITS_REGEX = /^(\d+)/;
// --- Raw 5etools types (minimal, for parsing) ---
interface RawMonster {
name: string;
source: string;
size: string[];
type: string | { type: string; tags?: string[]; swarmSize?: string };
alignment?: string[];
ac: (number | { ac: number; from?: string[] } | { special: string })[];
hp: { average?: number; formula?: string; special?: string };
speed: Record<
string,
number | { number: number; condition?: string } | boolean
>;
str: number;
dex: number;
con: number;
int: number;
wis: number;
cha: number;
save?: Record<string, string>;
skill?: Record<string, string>;
senses?: string[];
passive: number;
resist?: (string | { special: string })[];
immune?: (string | { special: string })[];
vulnerable?: (string | { special: string })[];
conditionImmune?: string[];
languages?: string[];
cr?: string | { cr: string };
trait?: RawEntry[];
action?: RawEntry[];
bonus?: RawEntry[];
reaction?: RawEntry[];
legendary?: RawEntry[];
legendaryActions?: number;
legendaryActionsLair?: number;
legendaryHeader?: string[];
spellcasting?: RawSpellcasting[];
initiative?: { proficiency?: number };
_copy?: unknown;
}
interface RawEntry {
name: string;
entries: (string | RawEntryObject)[];
}
interface RawEntryObject {
type: string;
items?: (
| string
| {
type: string;
name?: string;
entry?: string;
entries?: (string | RawEntryObject)[];
}
)[];
style?: string;
name?: string;
entries?: (string | RawEntryObject)[];
colLabels?: string[];
rows?: (string | RawEntryObject)[][];
}
interface RawSpellcasting {
name: string;
headerEntries: string[];
will?: string[];
daily?: Record<string, string[]>;
rest?: Record<string, string[]>;
hidden?: string[];
ability?: string;
displayAs?: string;
legendary?: Record<string, string[]>;
}
// --- Source mapping ---
let sourceDisplayNames: Record<string, string> = {};
export function setSourceDisplayNames(names: Record<string, string>): void {
sourceDisplayNames = names;
}
// --- Size mapping ---
const SIZE_MAP: Record<string, string> = {
T: "Tiny",
S: "Small",
M: "Medium",
L: "Large",
H: "Huge",
G: "Gargantuan",
};
// --- Alignment mapping ---
const ALIGNMENT_MAP: Record<string, string> = {
L: "Lawful",
N: "Neutral",
C: "Chaotic",
G: "Good",
E: "Evil",
U: "Unaligned",
};
function formatAlignment(codes?: string[]): string {
if (!codes || codes.length === 0) return "Unaligned";
if (codes.length === 1 && codes[0] === "U") return "Unaligned";
if (codes.length === 1 && codes[0] === "N") return "Neutral";
return codes.map((c) => ALIGNMENT_MAP[c] ?? c).join(" ");
}
// --- Helpers ---
function formatSize(sizes: string[]): string {
return sizes.map((s) => SIZE_MAP[s] ?? s).join(" or ");
}
function formatType(
type:
| string
| {
type: string | { choose: string[] };
tags?: string[];
swarmSize?: string;
},
): string {
if (typeof type === "string") return capitalize(type);
const baseType =
typeof type.type === "string"
? capitalize(type.type)
: type.type.choose.map(capitalize).join(" or ");
let result = baseType;
if (type.tags && type.tags.length > 0) {
const tagStrs = type.tags
.filter((t): t is string => typeof t === "string")
.map(capitalize);
if (tagStrs.length > 0) {
result += ` (${tagStrs.join(", ")})`;
}
}
if (type.swarmSize) {
const swarmSizeLabel = SIZE_MAP[type.swarmSize] ?? type.swarmSize;
result = `Swarm of ${swarmSizeLabel} ${result}s`;
}
return result;
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
function extractAc(ac: RawMonster["ac"]): {
value: number;
source?: string;
} {
const first = ac[0];
if (typeof first === "number") {
return { value: first };
}
if ("special" in first) {
// Variable AC (e.g. spell summons) — parse leading number if possible
const match = LEADING_DIGITS_REGEX.exec(first.special);
return {
value: match ? Number(match[1]) : 0,
source: first.special,
};
}
return {
value: first.ac,
source: first.from ? stripTags(first.from.join(", ")) : undefined,
};
}
function formatSpeed(speed: RawMonster["speed"]): string {
const parts: string[] = [];
for (const [mode, value] of Object.entries(speed)) {
if (mode === "canHover") continue;
if (typeof value === "boolean") continue;
let numStr: string;
let condition = "";
if (typeof value === "number") {
numStr = `${value} ft.`;
} else {
numStr = `${value.number} ft.`;
if (value.condition) {
condition = ` ${value.condition}`;
}
}
if (mode === "walk") {
parts.push(`${numStr}${condition}`);
} else {
parts.push(`${mode} ${numStr}${condition}`);
}
}
return parts.join(", ");
}
function formatSaves(save?: Record<string, string>): string | undefined {
if (!save) return undefined;
return Object.entries(save)
.map(([key, val]) => `${key.toUpperCase()} ${val}`)
.join(", ");
}
function formatSkills(skill?: Record<string, string>): string | undefined {
if (!skill) return undefined;
return Object.entries(skill)
.map(([key, val]) => `${capitalize(key)} ${val}`)
.join(", ");
}
function formatDamageList(
items?: (string | Record<string, unknown>)[],
): string | undefined {
if (!items || items.length === 0) return undefined;
return items
.map((item) => {
if (typeof item === "string") return capitalize(stripTags(item));
if (typeof item.special === "string") return stripTags(item.special);
// Handle conditional entries like { vulnerable: [...], note: "..." }
const damageTypes = (Object.values(item).find((v) => Array.isArray(v)) ??
[]) as string[];
const note = typeof item.note === "string" ? ` ${item.note}` : "";
return damageTypes.map((d) => capitalize(stripTags(d))).join(", ") + note;
})
.join(", ");
}
function formatConditionImmunities(
items?: (string | { conditionImmune?: string[]; note?: string })[],
): string | undefined {
if (!items || items.length === 0) return undefined;
return items
.flatMap((c) => {
if (typeof c === "string") return [capitalize(stripTags(c))];
if (c.conditionImmune) {
const conds = c.conditionImmune.map((ci) => capitalize(stripTags(ci)));
const note = c.note ? ` ${c.note}` : "";
return conds.map((ci) => `${ci}${note}`);
}
return [];
})
.join(", ");
}
function toListItem(
item:
| string
| {
type: string;
name?: string;
entry?: string;
entries?: (string | RawEntryObject)[];
},
): TraitListItem | undefined {
if (typeof item === "string") {
return { text: stripTags(item) };
}
if (item.name && item.entries) {
return { label: stripTags(item.name), text: renderEntries(item.entries) };
}
if (item.name && item.entry) {
return { label: stripTags(item.name), text: stripTags(item.entry) };
}
return undefined;
}
function renderEntryObject(entry: RawEntryObject, parts: string[]): void {
if (entry.type === "list" || entry.type === "table") {
// Handled structurally in segmentizeEntries
return;
}
if (entry.type === "item" && entry.name && entry.entries) {
parts.push(`${stripTags(entry.name)}: ${renderEntries(entry.entries)}`);
} else if (entry.entries) {
parts.push(renderEntries(entry.entries));
}
}
function renderEntries(entries: (string | RawEntryObject)[]): string {
const parts: string[] = [];
for (const entry of entries) {
if (typeof entry === "string") {
parts.push(stripTags(entry));
} else {
renderEntryObject(entry, parts);
}
}
return parts.join(" ");
}
function tableRowToListItem(row: (string | RawEntryObject)[]): TraitListItem {
return {
label: typeof row[0] === "string" ? stripTags(row[0]) : undefined,
text: row
.slice(1)
.map((cell) =>
typeof cell === "string" ? stripTags(cell) : renderEntries([cell]),
)
.join(" "),
};
}
function entryToListSegment(entry: RawEntryObject): TraitSegment | undefined {
if (entry.type === "list") {
const items = (entry.items ?? [])
.map(toListItem)
.filter((i): i is TraitListItem => i !== undefined);
return items.length > 0 ? { type: "list", items } : undefined;
}
if (entry.type === "table" && entry.rows) {
const items = entry.rows.map(tableRowToListItem);
return items.length > 0 ? { type: "list", items } : undefined;
}
return undefined;
}
function segmentizeEntries(
entries: (string | RawEntryObject)[],
): TraitSegment[] {
const segments: TraitSegment[] = [];
const textParts: string[] = [];
const flushText = () => {
if (textParts.length > 0) {
segments.push({ type: "text", value: textParts.join(" ") });
textParts.length = 0;
}
};
for (const entry of entries) {
if (typeof entry === "string") {
textParts.push(stripTags(entry));
continue;
}
const listSeg = entryToListSegment(entry);
if (listSeg) {
flushText();
segments.push(listSeg);
} else {
renderEntryObject(entry, textParts);
}
}
flushText();
return segments;
}
function normalizeTraits(raw?: RawEntry[]): TraitBlock[] | undefined {
if (!raw || raw.length === 0) return undefined;
return raw.map((t) => ({
name: stripTags(t.name),
segments: segmentizeEntries(t.entries),
}));
}
function normalizeSpellcasting(
raw?: RawSpellcasting[],
): SpellcastingBlock[] | undefined {
if (!raw || raw.length === 0) return undefined;
return raw.map((sc) => {
const block: {
name: string;
headerText: string;
atWill?: SpellReference[];
daily?: DailySpells[];
restLong?: DailySpells[];
} = {
name: stripTags(sc.name),
headerText: sc.headerEntries.map((e) => stripTags(e)).join(" "),
};
const hidden = new Set(sc.hidden ?? []);
if (sc.will && !hidden.has("will")) {
block.atWill = sc.will.map((s) => ({ name: stripTags(s) }));
}
if (sc.daily) {
block.daily = parseDailyMap(sc.daily);
}
if (sc.rest) {
block.restLong = parseDailyMap(sc.rest);
}
return block;
});
}
function parseDailyMap(map: Record<string, string[]>): DailySpells[] {
return Object.entries(map).map(([key, spells]) => {
const each = key.endsWith("e");
const uses = Number.parseInt(each ? key.slice(0, -1) : key, 10);
return {
uses,
each,
spells: spells.map((s) => ({ name: stripTags(s) })),
};
});
}
function normalizeLegendary(
raw?: RawEntry[],
monster?: RawMonster,
): LegendaryBlock | undefined {
if (!raw || raw.length === 0) return undefined;
const name = monster?.name ?? "creature";
const count = monster?.legendaryActions ?? 3;
const preamble = `${name} can take ${count} Legendary Actions, choosing from the options below. Only one Legendary Action can be used at a time and only at the end of another creature's turn. ${name} regains spent Legendary Actions at the start of its turn.`;
return {
preamble,
entries: raw.map((e) => ({
name: stripTags(e.name),
segments: segmentizeEntries(e.entries),
})),
};
}
function extractCr(cr: string | { cr: string } | undefined): string {
if (cr === undefined) return "—";
return typeof cr === "string" ? cr : cr.cr;
}
function makeCreatureId(source: string, name: string): CreatureId {
const slug = name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, "-")
.replaceAll(/(^-|-$)/g, "");
return creatureId(`${source.toLowerCase()}:${slug}`);
}
/**
* Normalizes raw 5etools bestiary JSON into domain Creature[].
*/
export function normalizeBestiary(raw: { monster: RawMonster[] }): Creature[] {
// Filter out _copy entries (reference another source's monster) and
// monsters missing required fields (ac, hp, size, type)
const monsters = raw.monster.filter((m) => {
if (m._copy) return false;
return (
Array.isArray(m.ac) &&
m.ac.length > 0 &&
m.hp !== undefined &&
Array.isArray(m.size) &&
m.size.length > 0 &&
m.type !== undefined
);
});
const creatures: Creature[] = [];
for (const m of monsters) {
try {
creatures.push(normalizeMonster(m));
} catch {
// Skip monsters with unexpected data shapes
}
}
return creatures;
}
function normalizeMonster(m: RawMonster): Creature {
const crStr = extractCr(m.cr);
const ac = extractAc(m.ac);
return {
id: makeCreatureId(m.source, m.name),
name: m.name,
source: m.source,
sourceDisplayName: sourceDisplayNames[m.source] ?? m.source,
size: formatSize(m.size),
type: formatType(m.type),
alignment: formatAlignment(m.alignment),
ac: ac.value,
acSource: ac.source,
hp: {
average: m.hp.average ?? 0,
formula: m.hp.formula ?? m.hp.special ?? "",
},
speed: formatSpeed(m.speed),
abilities: {
str: m.str,
dex: m.dex,
con: m.con,
int: m.int,
wis: m.wis,
cha: m.cha,
},
cr: crStr,
initiativeProficiency: m.initiative?.proficiency ?? 0,
proficiencyBonus: proficiencyBonus(crStr),
passive: m.passive,
savingThrows: formatSaves(m.save),
skills: formatSkills(m.skill),
resist: formatDamageList(m.resist),
immune: formatDamageList(m.immune),
vulnerable: formatDamageList(m.vulnerable),
conditionImmune: formatConditionImmunities(m.conditionImmune),
senses:
m.senses && m.senses.length > 0
? m.senses.map((s) => stripTags(s)).join(", ")
: undefined,
languages:
m.languages && m.languages.length > 0
? m.languages.join(", ")
: undefined,
traits: normalizeTraits(m.trait),
actions: normalizeTraits(m.action),
bonusActions: normalizeTraits(m.bonus),
reactions: normalizeTraits(m.reaction),
legendaryActions: normalizeLegendary(m.legendary, m),
spellcasting: normalizeSpellcasting(m.spellcasting),
};
}