Phase 2: full compendium across all categories + PF2e data

- Scraped PF2e reference from Archives of Nethys ES into public/data/pf2e/
  (spells, creatures, feats, equipment, weapons, armor, ancestries, heritages,
  backgrounds, archetypes, actions, conditions, deities) via scripts/fetch_pf2e.ts
- Registry-driven compendium: system toggle + per-category nav + generic filters
- 5e expanded: weapons, armor, feats, conditions added alongside spells/monsters/items
- PF2e data served as static assets (fetched, not bundled) for fast native parse
- Cross-links: add spell->spellbook / item->inventory to a campaign character;
  add monster/creature -> open encounter
- Condition tooltips in combat sourced from the conditions glossary
- Tests: registry filter unit tests, compendium e2e (browse + cross-link)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 01:00:40 +02:00
parent 0b356adf82
commit 379b79e6c4
28 changed files with 997 additions and 267 deletions
+6 -1
View File
@@ -6,6 +6,7 @@ import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
import { useCharacters } from '@/features/characters/hooks';
import { useConditionGlossary } from './useConditionGlossary';
import {
addCombatant,
applyDamage,
@@ -25,6 +26,7 @@ import { NumberField } from '@/components/ui/NumberField';
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
const characters = useCharacters(campaign.id);
const glossary = useConditionGlossary(campaign.system);
const mutate = (fn: (e: Encounter) => Encounter) => {
void encountersRepo.save(fn(encounter));
@@ -87,6 +89,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
key={c.id}
combatant={c}
system={campaign.system}
glossary={glossary}
isCurrent={isActive && idx === encounter.turnIndex}
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
onDamage={(amt) => mutate((e) => updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }))}
@@ -206,6 +209,7 @@ function AddCombatantBar({
function CombatantRow({
combatant: c,
system,
glossary,
isCurrent,
onChange,
onDamage,
@@ -215,6 +219,7 @@ function CombatantRow({
}: {
combatant: Combatant;
system: SystemId;
glossary: Map<string, string>;
isCurrent: boolean;
onChange: (patch: Partial<Combatant>) => void;
onDamage: (amt: number) => void;
@@ -251,7 +256,7 @@ function CombatantRow({
<button
key={`${cond.name}-${i}`}
className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:line-through"
title="Click to remove"
title={glossary.get(cond.name.toLowerCase()) || 'Click to remove'}
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
>
{cond.name}
@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react';
import type { SystemId } from '@/lib/rules';
/** name (lowercased) -> short description, per system. Cached across mounts. */
const cache = new Map<SystemId, Map<string, string>>();
export function useConditionGlossary(system: SystemId): Map<string, string> {
const [map, setMap] = useState<Map<string, string>>(() => cache.get(system) ?? new Map());
useEffect(() => {
if (cache.has(system)) {
setMap(cache.get(system)!);
return;
}
let cancelled = false;
void (async () => {
const m = new Map<string, string>();
try {
if (system === '5e') {
const mod = await import('@/data/srd/conditions.json');
for (const c of mod.default as { name: string; description?: string }[]) {
m.set(c.name.toLowerCase(), c.description ?? '');
}
} else {
const res = await fetch(`${import.meta.env.BASE_URL}data/pf2e/conditions.json`);
if (res.ok) {
for (const c of (await res.json()) as { name: string; text?: string }[]) {
m.set(c.name.toLowerCase(), (c.text ?? '').trim().slice(0, 400));
}
}
}
} catch {
/* tooltips are best-effort */
}
cache.set(system, m);
if (!cancelled) setMap(m);
})();
return () => { cancelled = true; };
}, [system]);
return map;
}