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:
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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") */
|
||||||
|
|||||||
Reference in New Issue
Block a user