Fix monster statblock display + PF2e dedup + Drow ASI data

- MonsterDetail now renders all movement modes (fly/swim/climb/burrow were dropped for
  174 monsters), plus Saving Throws and Skills lines (present in data, never shown).
- Passive Perception is computed from the creature's own WIS/Perception so it can't
  disagree with its stats (fixes the 7 SRD monsters with a wrong passive, e.g. Blink
  Dog 10->13, Bone/Chain Devil). Also surfaces damage vulnerabilities.
- loadPf2e dedupes entries by name (Remaster + legacy duplicates), so the compendium
  shows one 'Longsword'/'Goblin Warrior' instead of two or three.
- races.json: Drow ASI corrected to +2 DEX / +1 CHA (was a non-SRD +2 INT).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:27:18 +02:00
parent 4d354571a5
commit ea60b16385
4 changed files with 69 additions and 5 deletions
File diff suppressed because one or more lines are too long
+40 -3
View File
@@ -18,6 +18,21 @@ function StatLine({ label, children }: { label: string; children?: React.ReactNo
); );
} }
/** Join all movement modes into a statblock Speed line, e.g. "40 ft., climb 40 ft., fly 80 ft." */
function speedLine(speed: Monster['speed']): string {
if (!speed) return '';
const entries = Object.entries(speed).filter(([, v]) => typeof v === 'number') as [string, number][];
const walk = entries.find(([k]) => k === 'walk');
const rest = entries.filter(([k]) => k !== 'walk');
const parts = [...(walk ? [`${walk[1]} ft.`] : []), ...rest.map(([k, v]) => `${k} ${v} ft.`)];
return parts.join(', ');
}
const SAVE_LABELS: [keyof Monster, string][] = [
['strength_save', 'STR'], ['dexterity_save', 'DEX'], ['constitution_save', 'CON'],
['intelligence_save', 'INT'], ['wisdom_save', 'WIS'], ['charisma_save', 'CHA'],
];
export function MonsterDetail({ monster: m }: { monster: Monster }) { export function MonsterDetail({ monster: m }: { monster: Monster }) {
const abilities: [string, number | undefined][] = [ const abilities: [string, number | undefined][] = [
['STR', m.strength], ['STR', m.strength],
@@ -28,6 +43,25 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
['CHA', m.charisma], ['CHA', m.charisma],
]; ];
const subtitle = [m.size, m.type, m.alignment].filter(Boolean).join(', '); const subtitle = [m.size, m.type, m.alignment].filter(Boolean).join(', ');
const saves = SAVE_LABELS
.map(([k, label]) => [label, m[k] as number | null | undefined] as const)
.filter(([, v]) => typeof v === 'number')
.map(([label, v]) => `${label} ${formatModifier(v as number)}`)
.join(', ');
const skills = m.skills
? Object.entries(m.skills).map(([k, v]) => `${k[0]!.toUpperCase()}${k.slice(1)} ${formatModifier(v)}`).join(', ')
: '';
// Passive Perception, computed from the creature's own stats so it can never
// disagree with them (7 SRD monsters ship a wrong passive in their senses string).
const percMod = m.skills?.perception ?? (m.wisdom !== undefined ? abilityModifier(m.wisdom) : undefined);
const passivePerception = percMod !== undefined ? 10 + percMod : undefined;
const senses = m.senses && passivePerception !== undefined
? /passive Perception\s+\d+/i.test(m.senses)
? m.senses.replace(/passive Perception\s+\d+/i, `passive Perception ${passivePerception}`)
: `${m.senses}, passive Perception ${passivePerception}`
: m.senses;
return ( return (
<div className="font-display"> <div className="font-display">
<header> <header>
@@ -40,7 +74,7 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
<div className="space-y-1"> <div className="space-y-1">
<StatLine label="Armor Class">{m.armor_class ?? '—'}{m.armor_desc ? ` (${m.armor_desc})` : ''}</StatLine> <StatLine label="Armor Class">{m.armor_class ?? '—'}{m.armor_desc ? ` (${m.armor_desc})` : ''}</StatLine>
<StatLine label="Hit Points">{m.hit_points ?? '—'}{m.hit_dice ? ` (${m.hit_dice})` : ''}</StatLine> <StatLine label="Hit Points">{m.hit_points ?? '—'}{m.hit_dice ? ` (${m.hit_dice})` : ''}</StatLine>
{m.speed?.walk !== undefined && <StatLine label="Speed">{m.speed.walk} ft.</StatLine>} <StatLine label="Speed">{speedLine(m.speed) || null}</StatLine>
</div> </div>
<hr className="gilt-rule my-4" /> <hr className="gilt-rule my-4" />
@@ -57,14 +91,17 @@ export function MonsterDetail({ monster: m }: { monster: Monster }) {
))} ))}
</div> </div>
{(m.damage_resistances || m.damage_immunities || m.condition_immunities || m.senses || m.languages) && ( {(saves || skills || m.damage_resistances || m.damage_immunities || m.condition_immunities || senses || m.languages) && (
<> <>
<hr className="gilt-rule my-4" /> <hr className="gilt-rule my-4" />
<div className="space-y-1"> <div className="space-y-1">
<StatLine label="Saving Throws">{saves || null}</StatLine>
<StatLine label="Skills">{skills || null}</StatLine>
<StatLine label="Vulnerabilities">{m.damage_vulnerabilities}</StatLine>
<StatLine label="Resistances">{m.damage_resistances}</StatLine> <StatLine label="Resistances">{m.damage_resistances}</StatLine>
<StatLine label="Immunities">{m.damage_immunities}</StatLine> <StatLine label="Immunities">{m.damage_immunities}</StatLine>
<StatLine label="Condition Immunities">{m.condition_immunities}</StatLine> <StatLine label="Condition Immunities">{m.condition_immunities}</StatLine>
<StatLine label="Senses">{m.senses}</StatLine> <StatLine label="Senses">{senses}</StatLine>
<StatLine label="Languages">{m.languages}</StatLine> <StatLine label="Languages">{m.languages}</StatLine>
</div> </div>
</> </>
+20 -1
View File
@@ -118,11 +118,30 @@ export async function loadPf2e(file: string): Promise<CompendiumEntry[]> {
if (!pf2eCache.has(file)) { if (!pf2eCache.has(file)) {
const res = await fetch(`${import.meta.env.BASE_URL}data/pf2e/${file}.json`); const res = await fetch(`${import.meta.env.BASE_URL}data/pf2e/${file}.json`);
if (!res.ok) throw new Error(`Failed to load PF2e ${file} (${res.status})`); if (!res.ok) throw new Error(`Failed to load PF2e ${file} (${res.status})`);
pf2eCache.set(file, (await res.json()) as CompendiumEntry[]); const raw = (await res.json()) as CompendiumEntry[];
pf2eCache.set(file, dedupePf2e(raw));
} }
return pf2eCache.get(file)!; return pf2eCache.get(file)!;
} }
/**
* Archives of Nethys lists each reprinted element once per source (Remaster +
* legacy), so the raw datasets carry thousands of duplicate names. Collapse them
* by name (first occurrence wins) so the compendium shows one row per element.
*/
function dedupePf2e(entries: CompendiumEntry[]): CompendiumEntry[] {
const seen = new Set<string>();
const out: CompendiumEntry[] = [];
for (const e of entries) {
const key = String((e.name ?? e.slug) ?? '').trim().toLowerCase();
if (!key) { out.push(e); continue; }
if (seen.has(key)) continue;
seen.add(key);
out.push(e);
}
return out;
}
/** Build a combat-ready stat block summary from a monster. */ /** Build a combat-ready stat block summary from a monster. */
export function monsterToCombatant(m: Monster): { export function monsterToCombatant(m: Monster): {
name: string; name: string;
+8
View File
@@ -29,6 +29,14 @@ export interface Monster {
wisdom?: number; wisdom?: number;
charisma?: number; charisma?: number;
perception?: number; perception?: number;
strength_save?: number | null;
dexterity_save?: number | null;
constitution_save?: number | null;
intelligence_save?: number | null;
wisdom_save?: number | null;
charisma_save?: number | null;
/** skill bonuses keyed by skill name, e.g. { perception: 10, stealth: 6 } */
skills?: Record<string, number>;
senses?: string; senses?: string;
languages?: string; languages?: string;
/** numeric challenge rating (0.25 for "1/4") */ /** numeric challenge rating (0.25 for "1/4") */