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:
@@ -0,0 +1,47 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('/');
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
indexedDB.deleteDatabase('ttrpg-manager');
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
await page.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compendium: browse 5e categories, switch to PF2e, cross-link a spell', async ({ page }) => {
|
||||||
|
// Set up a 5e campaign + character to receive a spell
|
||||||
|
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
||||||
|
await page.locator('input[data-autofocus]').fill('Saltmarsh');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Characters' }).click();
|
||||||
|
await page.getByRole('button', { name: '+ New character' }).first().click();
|
||||||
|
await page.locator('input[data-autofocus]').fill('Wizard Wendy');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
|
||||||
|
// Compendium → 5e categories exist (Feats is a new one)
|
||||||
|
await page.getByRole('link', { name: 'Compendium' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Feats' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Conditions' }).click();
|
||||||
|
await expect(page.getByText(/results/)).toBeVisible();
|
||||||
|
|
||||||
|
// Spells → add one to the character
|
||||||
|
await page.getByRole('button', { name: 'Spells', exact: true }).click();
|
||||||
|
await page.getByPlaceholder(/Search spells/i).fill('fireball');
|
||||||
|
await page.getByRole('button', { name: /Fireball/ }).first().click();
|
||||||
|
await page.getByLabel('Add spell to character').selectOption({ label: 'Wizard Wendy' });
|
||||||
|
await expect(page.getByText(/Added to Wizard Wendy/)).toBeVisible();
|
||||||
|
|
||||||
|
// Switch to PF2e — bestiary loads from the static dataset
|
||||||
|
await page.getByRole('button', { name: 'Pathfinder 2e' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Bestiary' }).click();
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const t = (await page.getByText(/results/).textContent()) ?? '';
|
||||||
|
return parseInt(t.replace(/\D/g, ''), 10) || 0;
|
||||||
|
}, { timeout: 15000 }).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify the spell landed on the character sheet
|
||||||
|
await page.getByRole('link', { name: 'Characters' }).click();
|
||||||
|
await page.getByRole('link', { name: /Wizard Wendy/ }).click();
|
||||||
|
await expect(page.getByText('Fireball')).toBeVisible();
|
||||||
|
});
|
||||||
+1
-1
@@ -84,7 +84,7 @@ test('compendium CR filter narrows the bestiary', async ({ page }) => {
|
|||||||
const countText = async () => (await page.getByText(/results/).textContent()) ?? '';
|
const countText = async () => (await page.getByText(/results/).textContent()) ?? '';
|
||||||
const before = parseInt((await countText()).replace(/\D/g, ''), 10);
|
const before = parseInt((await countText()).replace(/\D/g, ''), 10);
|
||||||
|
|
||||||
await page.getByLabel('Minimum CR').selectOption({ label: 'CR ≥ 10' });
|
await page.getByLabel('Min CR').selectOption({ label: 'CR ≥ 10' });
|
||||||
// wait for the count to update
|
// wait for the count to update
|
||||||
await expect.poll(async () => parseInt((await countText()).replace(/\D/g, ''), 10)).toBeLessThan(before);
|
await expect.poll(async () => parseInt((await countText()).replace(/\D/g, ''), 10)).toBeLessThan(before);
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -53,7 +53,7 @@ test('full core flow: campaign → character → dice → combat → compendium'
|
|||||||
// --- Compendium ---
|
// --- Compendium ---
|
||||||
await page.getByRole('link', { name: 'Compendium' }).click();
|
await page.getByRole('link', { name: 'Compendium' }).click();
|
||||||
await expect(page.getByText(/results/)).toBeVisible();
|
await expect(page.getByText(/results/)).toBeVisible();
|
||||||
await page.getByPlaceholder('Search monsters…').fill('goblin');
|
await page.getByPlaceholder('Search bestiary…').fill('goblin');
|
||||||
await page.getByRole('button', { name: /^Goblin/ }).first().click();
|
await page.getByRole('button', { name: /^Goblin/ }).first().click();
|
||||||
await expect(page.getByRole('heading', { name: 'Goblin', exact: true })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Goblin', exact: true })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"category": "spell",
|
||||||
|
"file": "spells",
|
||||||
|
"count": 2455
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "creature",
|
||||||
|
"file": "creatures",
|
||||||
|
"count": 4702
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "condition",
|
||||||
|
"file": "conditions",
|
||||||
|
"count": 98
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "feat",
|
||||||
|
"file": "feats",
|
||||||
|
"count": 8402
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "ancestry",
|
||||||
|
"file": "ancestries",
|
||||||
|
"count": 94
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "heritage",
|
||||||
|
"file": "heritages",
|
||||||
|
"count": 432
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "background",
|
||||||
|
"file": "backgrounds",
|
||||||
|
"count": 612
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "archetype",
|
||||||
|
"file": "archetypes",
|
||||||
|
"count": 335
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "weapon",
|
||||||
|
"file": "weapons",
|
||||||
|
"count": 614
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "armor",
|
||||||
|
"file": "armor",
|
||||||
|
"count": 75
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "equipment",
|
||||||
|
"file": "equipment",
|
||||||
|
"count": 8605
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "action",
|
||||||
|
"file": "actions",
|
||||||
|
"count": 3950
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "deity",
|
||||||
|
"file": "deities",
|
||||||
|
"count": 716
|
||||||
|
}
|
||||||
|
]
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Fetch Pathfinder 2e reference data from the Archives of Nethys Elasticsearch
|
||||||
|
* index (the same data the AoN website uses) and normalize it into per-category
|
||||||
|
* JSON files under src/data/srd/pf2e/.
|
||||||
|
*
|
||||||
|
* Run with: bun run scripts/fetch_pf2e.ts (or: bunx tsx scripts/fetch_pf2e.ts)
|
||||||
|
*
|
||||||
|
* The index is public and CC-licensed Paizo content. We keep the structured
|
||||||
|
* fields plus the plain-text `text` body, and drop the bulky *_markdown fields.
|
||||||
|
*/
|
||||||
|
import { writeFile, mkdir } from 'node:fs/promises';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const ES_URL = 'https://elasticsearch.aonprd.com/aon/_search';
|
||||||
|
// Served as static assets (large files load faster via fetch + native JSON.parse
|
||||||
|
// than as bundled JS modules), so they live under public/, not src/.
|
||||||
|
const OUT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'public', 'data', 'pf2e');
|
||||||
|
|
||||||
|
/** AoN category -> output filename. */
|
||||||
|
const CATEGORIES: Record<string, string> = {
|
||||||
|
spell: 'spells',
|
||||||
|
creature: 'creatures',
|
||||||
|
condition: 'conditions',
|
||||||
|
feat: 'feats',
|
||||||
|
ancestry: 'ancestries',
|
||||||
|
heritage: 'heritages',
|
||||||
|
background: 'backgrounds',
|
||||||
|
archetype: 'archetypes',
|
||||||
|
weapon: 'weapons',
|
||||||
|
armor: 'armor',
|
||||||
|
equipment: 'equipment',
|
||||||
|
action: 'actions',
|
||||||
|
deity: 'deities',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fields that are bulky duplicates of `text` or internal — drop them.
|
||||||
|
const EXCLUDES = [
|
||||||
|
'markdown',
|
||||||
|
'*_markdown',
|
||||||
|
'*_scale',
|
||||||
|
'*_scale_number',
|
||||||
|
'*_raw',
|
||||||
|
'release_date',
|
||||||
|
'rarity_id',
|
||||||
|
'size_id',
|
||||||
|
'remaster_id',
|
||||||
|
'is_standard_ancestry_feat',
|
||||||
|
'exclude_from_search',
|
||||||
|
'npc',
|
||||||
|
'hp_scale_number',
|
||||||
|
];
|
||||||
|
|
||||||
|
interface EsHit {
|
||||||
|
_source: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE = 1000;
|
||||||
|
const MAX_WINDOW = 10000;
|
||||||
|
|
||||||
|
async function fetchCategory(category: string): Promise<Record<string, unknown>[]> {
|
||||||
|
const all: Record<string, unknown>[] = [];
|
||||||
|
|
||||||
|
// from/size pagination within the 10k window (every category fits under it).
|
||||||
|
for (let from = 0; from < MAX_WINDOW; from += PAGE) {
|
||||||
|
const body = {
|
||||||
|
from,
|
||||||
|
size: PAGE,
|
||||||
|
_source: { excludes: EXCLUDES },
|
||||||
|
query: { bool: { filter: [{ term: { category } }] } },
|
||||||
|
};
|
||||||
|
const res = await fetch(ES_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`ES ${res.status} for ${category}`);
|
||||||
|
const json = (await res.json()) as { hits: { hits: EsHit[] } };
|
||||||
|
const hits = json.hits.hits;
|
||||||
|
if (hits.length === 0) break;
|
||||||
|
for (const h of hits) all.push(normalize(h._source));
|
||||||
|
process.stdout.write(`\r ${category}: ${all.length}`);
|
||||||
|
if (hits.length < PAGE) break;
|
||||||
|
}
|
||||||
|
process.stdout.write('\n');
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep a tidy, predictable shape; coerce traits to a string[]. */
|
||||||
|
function normalize(src: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = { ...src };
|
||||||
|
if (out.trait && !Array.isArray(out.trait)) out.trait = [out.trait];
|
||||||
|
// a stable slug for keys/links
|
||||||
|
out.slug = String(out.id ?? out.name ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await mkdir(OUT_DIR, { recursive: true });
|
||||||
|
const index: { category: string; file: string; count: number }[] = [];
|
||||||
|
|
||||||
|
for (const [category, file] of Object.entries(CATEGORIES)) {
|
||||||
|
const entries = await fetchCategory(category);
|
||||||
|
const path = join(OUT_DIR, `${file}.json`);
|
||||||
|
await writeFile(path, JSON.stringify(entries));
|
||||||
|
index.push({ category, file, count: entries.length });
|
||||||
|
console.log(` wrote ${file}.json (${entries.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(join(OUT_DIR, 'index.json'), JSON.stringify(index, null, 2));
|
||||||
|
console.log('\nDone:', index.map((i) => `${i.file}=${i.count}`).join(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { createRng } from '@/lib/rng';
|
|||||||
import { rollDice } from '@/lib/dice/notation';
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
|
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
|
||||||
import { useCharacters } from '@/features/characters/hooks';
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
|
import { useConditionGlossary } from './useConditionGlossary';
|
||||||
import {
|
import {
|
||||||
addCombatant,
|
addCombatant,
|
||||||
applyDamage,
|
applyDamage,
|
||||||
@@ -25,6 +26,7 @@ import { NumberField } from '@/components/ui/NumberField';
|
|||||||
|
|
||||||
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
|
export function EncounterTracker({ encounter, campaign }: { encounter: Encounter; campaign: Campaign }) {
|
||||||
const characters = useCharacters(campaign.id);
|
const characters = useCharacters(campaign.id);
|
||||||
|
const glossary = useConditionGlossary(campaign.system);
|
||||||
|
|
||||||
const mutate = (fn: (e: Encounter) => Encounter) => {
|
const mutate = (fn: (e: Encounter) => Encounter) => {
|
||||||
void encountersRepo.save(fn(encounter));
|
void encountersRepo.save(fn(encounter));
|
||||||
@@ -87,6 +89,7 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
key={c.id}
|
key={c.id}
|
||||||
combatant={c}
|
combatant={c}
|
||||||
system={campaign.system}
|
system={campaign.system}
|
||||||
|
glossary={glossary}
|
||||||
isCurrent={isActive && idx === encounter.turnIndex}
|
isCurrent={isActive && idx === encounter.turnIndex}
|
||||||
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
|
onChange={(patch) => mutate((e) => updateCombatant(e, c.id, patch))}
|
||||||
onDamage={(amt) => mutate((e) => updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }))}
|
onDamage={(amt) => mutate((e) => updateCombatant(e, c.id, { hp: applyDamage(c, amt).hp }))}
|
||||||
@@ -206,6 +209,7 @@ function AddCombatantBar({
|
|||||||
function CombatantRow({
|
function CombatantRow({
|
||||||
combatant: c,
|
combatant: c,
|
||||||
system,
|
system,
|
||||||
|
glossary,
|
||||||
isCurrent,
|
isCurrent,
|
||||||
onChange,
|
onChange,
|
||||||
onDamage,
|
onDamage,
|
||||||
@@ -215,6 +219,7 @@ function CombatantRow({
|
|||||||
}: {
|
}: {
|
||||||
combatant: Combatant;
|
combatant: Combatant;
|
||||||
system: SystemId;
|
system: SystemId;
|
||||||
|
glossary: Map<string, string>;
|
||||||
isCurrent: boolean;
|
isCurrent: boolean;
|
||||||
onChange: (patch: Partial<Combatant>) => void;
|
onChange: (patch: Partial<Combatant>) => void;
|
||||||
onDamage: (amt: number) => void;
|
onDamage: (amt: number) => void;
|
||||||
@@ -251,7 +256,7 @@ function CombatantRow({
|
|||||||
<button
|
<button
|
||||||
key={`${cond.name}-${i}`}
|
key={`${cond.name}-${i}`}
|
||||||
className="rounded-full bg-warning/15 px-2 py-0.5 text-xs text-warning hover:line-through"
|
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) })}
|
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
|
||||||
>
|
>
|
||||||
{cond.name}
|
{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;
|
||||||
|
}
|
||||||
@@ -1,157 +1,163 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import Fuse from 'fuse.js';
|
import Fuse from 'fuse.js';
|
||||||
import type { CompendiumKind, Monster, Spell, MagicItem } from '@/lib/compendium/types';
|
import type { SystemId } from '@/lib/rules';
|
||||||
import { loadMonsters, loadSpells, loadItems, crLabel } from '@/lib/compendium';
|
import { SYSTEM_OPTIONS } from '@/lib/rules';
|
||||||
import { abilityModifier } from '@/lib/rules';
|
|
||||||
import { newId } from '@/lib/ids';
|
import { newId } from '@/lib/ids';
|
||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
import { rollDice } from '@/lib/dice/notation';
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
import { addCombatant } from '@/lib/combat/engine';
|
import { addCombatant } from '@/lib/combat/engine';
|
||||||
import { encountersRepo } from '@/lib/db/repositories';
|
import { charactersRepo, encountersRepo } from '@/lib/db/repositories';
|
||||||
|
import type { Character, InventoryItem, SpellEntry } from '@/lib/schemas';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
|
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||||
|
import { useCharacters } from '@/features/characters/hooks';
|
||||||
import { Page, PageHeader } from '@/components/ui/Page';
|
import { Page, PageHeader } from '@/components/ui/Page';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
import { cn } from '@/lib/cn';
|
import { cn } from '@/lib/cn';
|
||||||
import { MonsterDetail } from './MonsterDetail';
|
import { categoriesForSystem, filterMatches, type CategoryDef, type Entry } from './registry';
|
||||||
|
|
||||||
type AnyEntry = Monster | Spell | MagicItem;
|
|
||||||
|
|
||||||
const TABS: { kind: CompendiumKind; label: string }[] = [
|
|
||||||
{ kind: 'monsters', label: 'Bestiary' },
|
|
||||||
{ kind: 'spells', label: 'Spells' },
|
|
||||||
{ kind: 'items', label: 'Magic Items' },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface Filters {
|
|
||||||
type?: string;
|
|
||||||
school?: string;
|
|
||||||
level?: number;
|
|
||||||
rarity?: string;
|
|
||||||
crMin?: number;
|
|
||||||
crMax?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function distinct<T>(items: T[], get: (x: T) => string | undefined): string[] {
|
|
||||||
return [...new Set(items.map(get).filter((v): v is string => !!v && v.trim() !== ''))].sort((a, b) =>
|
|
||||||
a.localeCompare(b),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function crNumLabel(n: number): string {
|
|
||||||
if (n === 0.125) return '1/8';
|
|
||||||
if (n === 0.25) return '1/4';
|
|
||||||
if (n === 0.5) return '1/2';
|
|
||||||
return String(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters(kind: CompendiumKind, data: AnyEntry[], f: Filters): AnyEntry[] {
|
|
||||||
if (kind === 'monsters') {
|
|
||||||
return (data as Monster[]).filter((m) => {
|
|
||||||
const cr = m.cr ?? 0;
|
|
||||||
return (
|
|
||||||
(!f.type || m.type === f.type) &&
|
|
||||||
(f.crMin === undefined || cr >= f.crMin) &&
|
|
||||||
(f.crMax === undefined || cr <= f.crMax)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (kind === 'spells') {
|
|
||||||
return (data as Spell[]).filter(
|
|
||||||
(s) => (f.level === undefined || s.level_int === f.level) && (!f.school || s.school === f.school),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (data as MagicItem[]).filter(
|
|
||||||
(i) => (!f.rarity || i.rarity === f.rarity) && (!f.type || i.type === f.type),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CompendiumPage() {
|
export function CompendiumPage() {
|
||||||
const [kind, setKind] = useState<CompendiumKind>('monsters');
|
const activeCampaign = useActiveCampaign();
|
||||||
const [data, setData] = useState<AnyEntry[]>([]);
|
const [system, setSystem] = useState<SystemId>(activeCampaign?.system ?? '5e');
|
||||||
const [loading, setLoading] = useState(true);
|
const categories = categoriesForSystem(system);
|
||||||
const [query, setQuery] = useState('');
|
const [categoryId, setCategoryId] = useState(categories[0]!.id);
|
||||||
const [filters, setFilters] = useState<Filters>({});
|
const category = categories.find((c) => c.id === categoryId) ?? categories[0]!;
|
||||||
const [selected, setSelected] = useState<AnyEntry | null>(null);
|
|
||||||
|
|
||||||
|
const [data, setData] = useState<Entry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [selected, setSelected] = useState<Entry | null>(null);
|
||||||
|
|
||||||
|
// Load the active category's dataset.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
const loader = kind === 'monsters' ? loadMonsters : kind === 'spells' ? loadSpells : loadItems;
|
setQuery('');
|
||||||
void loader().then((d) => {
|
setFilters({});
|
||||||
if (cancelled) return;
|
category.load().then(
|
||||||
setData(d);
|
(d) => { if (!cancelled) { setData(d); setLoading(false); } },
|
||||||
setLoading(false);
|
(e: unknown) => { if (!cancelled) { setError(e instanceof Error ? e.message : 'Failed to load'); setLoading(false); } },
|
||||||
});
|
);
|
||||||
return () => {
|
return () => { cancelled = true; };
|
||||||
cancelled = true;
|
}, [category]);
|
||||||
};
|
|
||||||
}, [kind]);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => applyFilters(kind, data, filters), [kind, data, filters]);
|
const filtered = useMemo(() => {
|
||||||
|
const active = Object.entries(filters).filter(([, v]) => v !== '');
|
||||||
|
if (active.length === 0) return data;
|
||||||
|
return data.filter((e) =>
|
||||||
|
active.every(([key, value]) => {
|
||||||
|
const def = category.filters.find((f) => f.key === key);
|
||||||
|
return def ? filterMatches(def, e, value) : true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [data, filters, category]);
|
||||||
|
|
||||||
const fuse = useMemo(
|
const fuse = useMemo(
|
||||||
() => new Fuse(filtered, { keys: ['name', 'type', 'school', 'rarity'], threshold: 0.35, ignoreLocation: true }),
|
() => new Fuse(filtered, { keys: category.searchKeys, threshold: 0.34, ignoreLocation: true }),
|
||||||
[filtered],
|
[filtered, category],
|
||||||
);
|
);
|
||||||
|
|
||||||
const results = useMemo(() => {
|
const results = useMemo(() => {
|
||||||
const list = query.trim() ? fuse.search(query.trim()).map((r) => r.item) : filtered;
|
const list = query.trim() ? fuse.search(query.trim()).map((r) => r.item) : filtered;
|
||||||
return list.slice(0, 300);
|
return list.slice(0, 300);
|
||||||
}, [query, fuse, filtered]);
|
}, [query, fuse, filtered]);
|
||||||
|
|
||||||
const switchTab = (k: CompendiumKind) => {
|
const changeSystem = (s: SystemId) => {
|
||||||
setKind(k);
|
setSystem(s);
|
||||||
setQuery('');
|
setCategoryId(categoriesForSystem(s)[0]!.id);
|
||||||
setFilters({});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<PageHeader title="Compendium" subtitle="SRD reference for D&D 5e — searchable bestiary, spells, and items." />
|
<PageHeader
|
||||||
|
title="Compendium"
|
||||||
<div className="mb-4 flex gap-1">
|
subtitle="Searchable reference — bestiary, spells, items, feats, and more."
|
||||||
{TABS.map((t) => (
|
actions={
|
||||||
|
<div className="flex gap-1 rounded-md border border-line p-0.5">
|
||||||
|
{SYSTEM_OPTIONS.map((o) => (
|
||||||
<button
|
<button
|
||||||
key={t.kind}
|
key={o.id}
|
||||||
onClick={() => switchTab(t.kind)}
|
onClick={() => changeSystem(o.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md px-3 py-1.5 text-sm transition-colors',
|
'rounded px-3 py-1 text-sm transition-colors',
|
||||||
kind === t.kind ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink',
|
system === o.id ? 'bg-accent text-accent-ink' : 'text-muted hover:text-ink',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{t.label}
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Category chips */}
|
||||||
|
<div className="mb-4 flex flex-wrap gap-1">
|
||||||
|
{categories.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => setCategoryId(c.id)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md px-3 py-1.5 text-sm transition-colors',
|
||||||
|
c.id === category.id ? 'bg-elevated text-accent ring-1 ring-accent/40' : 'bg-panel text-muted hover:text-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[320px_1fr]">
|
<div className="grid gap-4 lg:grid-cols-[340px_1fr]">
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder={`Search ${kind}…`}
|
placeholder={`Search ${category.label.toLowerCase()}…`}
|
||||||
aria-label="Search compendium"
|
aria-label="Search compendium"
|
||||||
/>
|
/>
|
||||||
|
{category.filters.length > 0 && (
|
||||||
<CompendiumFilters kind={kind} data={data} filters={filters} onChange={setFilters} />
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
|
{category.filters.map((f) => {
|
||||||
|
const opts = f.options(data);
|
||||||
|
if (opts.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
key={f.key}
|
||||||
|
className="w-auto py-1 text-xs"
|
||||||
|
aria-label={f.label}
|
||||||
|
value={filters[f.key] ?? ''}
|
||||||
|
onChange={(e) => setFilters((prev) => ({ ...prev, [f.key]: e.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">{f.label}</option>
|
||||||
|
{opts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{Object.values(filters).some((v) => v) && (
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setFilters({})}>Clear</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="mt-1 text-xs text-muted">
|
<p className="mt-1 text-xs text-muted">
|
||||||
{loading ? 'Loading…' : `${results.length}${results.length === 300 ? '+' : ''} results`}
|
{loading ? 'Loading…' : error ? <span className="text-danger">{error}</span> : `${results.length}${results.length === 300 ? '+' : ''} results`}
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-2 max-h-[65vh] space-y-1 overflow-auto">
|
|
||||||
{results.map((entry) => (
|
<ul className="mt-2 max-h-[62vh] space-y-1 overflow-auto">
|
||||||
<li key={(entry as { slug: string }).slug}>
|
{results.map((entry, i) => (
|
||||||
|
<li key={(entry.slug as string) ?? `${entry.name}-${i}`}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelected(entry)}
|
onClick={() => setSelected(entry)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm',
|
'flex w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm',
|
||||||
selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
|
selected === entry ? 'border-accent bg-elevated' : 'border-line bg-panel hover:border-accent/40',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="truncate text-ink">{entry.name}</span>
|
<span className="truncate text-ink">{entry.name}</span>
|
||||||
<EntryMeta kind={kind} entry={entry} />
|
<span className="shrink-0 text-xs text-muted">{category.meta(entry)}</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -160,7 +166,10 @@ export function CompendiumPage() {
|
|||||||
|
|
||||||
<div className="rounded-lg border border-line bg-panel p-5">
|
<div className="rounded-lg border border-line bg-panel p-5">
|
||||||
{selected ? (
|
{selected ? (
|
||||||
<DetailView kind={kind} entry={selected} />
|
<div>
|
||||||
|
<DetailActions category={category} entry={selected} system={system} />
|
||||||
|
{category.detail(selected)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted">Select an entry to view details.</p>
|
<p className="text-sm text-muted">Select an entry to view details.</p>
|
||||||
)}
|
)}
|
||||||
@@ -170,190 +179,86 @@ export function CompendiumPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompendiumFilters({
|
function DetailActions({ category, entry, system }: { category: CategoryDef; entry: Entry; system: SystemId }) {
|
||||||
kind,
|
const hasActions = category.toCombatant || category.linkAs;
|
||||||
data,
|
if (!hasActions) return null;
|
||||||
filters,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
kind: CompendiumKind;
|
|
||||||
data: AnyEntry[];
|
|
||||||
filters: Filters;
|
|
||||||
onChange: (f: Filters) => void;
|
|
||||||
}) {
|
|
||||||
const options = useMemo(() => {
|
|
||||||
if (kind === 'monsters') {
|
|
||||||
const m = data as Monster[];
|
|
||||||
const crs = [...new Set(m.map((x) => x.cr).filter((c): c is number => c !== undefined))].sort((a, b) => a - b);
|
|
||||||
return { types: distinct(m, (x) => x.type), crs };
|
|
||||||
}
|
|
||||||
if (kind === 'spells') {
|
|
||||||
const s = data as Spell[];
|
|
||||||
const levels = [...new Set(s.map((x) => x.level_int).filter((l): l is number => l !== undefined))].sort((a, b) => a - b);
|
|
||||||
return { schools: distinct(s, (x) => x.school), levels };
|
|
||||||
}
|
|
||||||
const i = data as MagicItem[];
|
|
||||||
return { rarities: distinct(i, (x) => x.rarity), itemTypes: distinct(i, (x) => x.type) };
|
|
||||||
}, [kind, data]);
|
|
||||||
|
|
||||||
const setKey = <K extends keyof Filters>(key: K, value: Filters[K] | undefined) => {
|
|
||||||
const next: Filters = { ...filters };
|
|
||||||
if (value === undefined) delete next[key];
|
|
||||||
else next[key] = value;
|
|
||||||
onChange(next);
|
|
||||||
};
|
|
||||||
const numOrUndef = (v: string) => (v === '' ? undefined : Number(v));
|
|
||||||
const strOrUndef = (v: string) => v || undefined;
|
|
||||||
const active = Object.values(filters).some((v) => v !== undefined);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
<div className="mb-3 flex flex-wrap items-center gap-2 border-b border-line pb-3">
|
||||||
{kind === 'monsters' && (
|
{category.toCombatant && <AddToCombat stats={category.toCombatant(entry)} />}
|
||||||
<>
|
{category.linkAs && <AddToCharacter kind={category.linkAs} entry={entry} system={system} />}
|
||||||
<Select className="w-auto py-1 text-xs" aria-label="Filter by type" value={filters.type ?? ''} onChange={(e) => setKey('type', strOrUndef(e.target.value))}>
|
|
||||||
<option value="">Any type</option>
|
|
||||||
{options.types!.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
||||||
</Select>
|
|
||||||
<Select className="w-auto py-1 text-xs" aria-label="Minimum CR" value={filters.crMin ?? ''} onChange={(e) => setKey('crMin', numOrUndef(e.target.value))}>
|
|
||||||
<option value="">CR ≥ any</option>
|
|
||||||
{options.crs!.map((c) => <option key={c} value={c}>CR ≥ {crNumLabel(c)}</option>)}
|
|
||||||
</Select>
|
|
||||||
<Select className="w-auto py-1 text-xs" aria-label="Maximum CR" value={filters.crMax ?? ''} onChange={(e) => setKey('crMax', numOrUndef(e.target.value))}>
|
|
||||||
<option value="">CR ≤ any</option>
|
|
||||||
{options.crs!.map((c) => <option key={c} value={c}>CR ≤ {crNumLabel(c)}</option>)}
|
|
||||||
</Select>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{kind === 'spells' && (
|
|
||||||
<>
|
|
||||||
<Select className="w-auto py-1 text-xs" aria-label="Filter by level" value={filters.level ?? ''} onChange={(e) => setKey('level', numOrUndef(e.target.value))}>
|
|
||||||
<option value="">Any level</option>
|
|
||||||
{options.levels!.map((l) => <option key={l} value={l}>{l === 0 ? 'Cantrip' : `Level ${l}`}</option>)}
|
|
||||||
</Select>
|
|
||||||
<Select className="w-auto py-1 text-xs" aria-label="Filter by school" value={filters.school ?? ''} onChange={(e) => setKey('school', strOrUndef(e.target.value))}>
|
|
||||||
<option value="">Any school</option>
|
|
||||||
{options.schools!.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
||||||
</Select>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{kind === 'items' && (
|
|
||||||
<>
|
|
||||||
<Select className="w-auto py-1 text-xs" aria-label="Filter by rarity" value={filters.rarity ?? ''} onChange={(e) => setKey('rarity', strOrUndef(e.target.value))}>
|
|
||||||
<option value="">Any rarity</option>
|
|
||||||
{options.rarities!.map((r) => <option key={r} value={r}>{r}</option>)}
|
|
||||||
</Select>
|
|
||||||
<Select className="w-auto py-1 text-xs" aria-label="Filter by item type" value={filters.type ?? ''} onChange={(e) => setKey('type', strOrUndef(e.target.value))}>
|
|
||||||
<option value="">Any type</option>
|
|
||||||
{options.itemTypes!.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
||||||
</Select>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{active && (
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => onChange({})}>Clear</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EntryMeta({ kind, entry }: { kind: CompendiumKind; entry: AnyEntry }) {
|
function AddToCombat({ stats }: { stats: { name: string; ac: number; hp: number; initBonus: number } }) {
|
||||||
if (kind === 'monsters') return <span className="text-xs text-muted">CR {crLabel(entry as Monster)}</span>;
|
|
||||||
if (kind === 'spells') {
|
|
||||||
const lvl = (entry as Spell).level_int;
|
|
||||||
return <span className="text-xs text-muted">{lvl === 0 ? 'Cantrip' : `Lv ${lvl ?? '?'}`}</span>;
|
|
||||||
}
|
|
||||||
return <span className="text-xs text-muted">{(entry as MagicItem).rarity ?? ''}</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function DetailView({ kind, entry }: { kind: CompendiumKind; entry: AnyEntry }) {
|
|
||||||
if (kind === 'monsters') return <MonsterPane monster={entry as Monster} />;
|
|
||||||
if (kind === 'spells') return <SpellPane spell={entry as Spell} />;
|
|
||||||
return <ItemPane item={entry as MagicItem} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MonsterPane({ monster }: { monster: Monster }) {
|
|
||||||
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
const activeEncounterId = useUiStore((s) => s.activeEncounterId);
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
const addToCombat = async () => {
|
const add = async () => {
|
||||||
if (!activeEncounterId) return;
|
if (!activeEncounterId) return;
|
||||||
const enc = await encountersRepo.get(activeEncounterId);
|
const enc = await encountersRepo.get(activeEncounterId);
|
||||||
if (!enc) {
|
if (!enc) { setMsg('Open an encounter in Combat first.'); return; }
|
||||||
setMsg('Open an encounter in the Combat tab first.');
|
const initiative = rollDice('1d20', createRng()).total + stats.initBonus;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const initiative = rollDice('1d20', createRng()).total + abilityModifier(monster.dexterity ?? 10);
|
|
||||||
const hp = monster.hit_points ?? 1;
|
|
||||||
await encountersRepo.save(
|
await encountersRepo.save(
|
||||||
addCombatant(enc, {
|
addCombatant(enc, {
|
||||||
id: newId(),
|
id: newId(), name: stats.name, kind: 'monster',
|
||||||
name: monster.name,
|
initiative, ac: stats.ac, hp: { current: stats.hp, max: stats.hp, temp: 0 },
|
||||||
kind: 'monster',
|
conditions: [], notes: '',
|
||||||
monsterRef: monster.slug,
|
|
||||||
initiative,
|
|
||||||
ac: monster.armor_class ?? 10,
|
|
||||||
hp: { current: hp, max: hp, temp: 0 },
|
|
||||||
conditions: [],
|
|
||||||
notes: '',
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
setMsg(`Added to “${enc.name}” (initiative ${initiative}).`);
|
setMsg(`Added to "${enc.name}" (init ${initiative}).`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<Button size="sm" variant="primary" disabled={!activeEncounterId} onClick={add} title={activeEncounterId ? 'Add to the open encounter' : 'Open an encounter in Combat first'}>
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="sm"
|
|
||||||
disabled={!activeEncounterId}
|
|
||||||
onClick={addToCombat}
|
|
||||||
title={activeEncounterId ? 'Add to the open encounter' : 'Open an encounter in Combat first'}
|
|
||||||
>
|
|
||||||
+ Add to combat
|
+ Add to combat
|
||||||
</Button>
|
</Button>
|
||||||
{msg && <span className="text-xs text-success" aria-live="polite">{msg}</span>}
|
{msg && <span className="text-xs text-success" aria-live="polite">{msg}</span>}
|
||||||
</div>
|
</>
|
||||||
<MonsterDetail monster={monster} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SpellPane({ spell: s }: { spell: Spell }) {
|
function AddToCharacter({ kind, entry, system }: { kind: 'spell' | 'item'; entry: Entry; system: SystemId }) {
|
||||||
return (
|
const campaign = useActiveCampaign();
|
||||||
<div className="space-y-3">
|
const characters = useCharacters(campaign?.id ?? '');
|
||||||
<header>
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
<h2 className="font-display text-2xl font-bold text-accent">{s.name}</h2>
|
|
||||||
<p className="text-sm italic text-muted">
|
const add = async (characterId: string) => {
|
||||||
{s.level_int === 0 ? `${s.school} cantrip` : `Level ${s.level_int} ${s.school ?? ''}`}
|
const c = await charactersRepo.get(characterId);
|
||||||
</p>
|
if (!c) return;
|
||||||
</header>
|
if (kind === 'spell') {
|
||||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted">
|
const level = system === '5e' ? Number(entry.level_int ?? 0) : Number(entry.level ?? 0);
|
||||||
<span><strong className="text-ink">Casting Time</strong> {s.casting_time}</span>
|
const spell: SpellEntry = {
|
||||||
<span><strong className="text-ink">Range</strong> {s.range}</span>
|
id: newId(), name: entry.name, level: Number.isFinite(level) ? level : 0,
|
||||||
<span><strong className="text-ink">Duration</strong> {s.duration}</span>
|
prepared: false, notes: '', ...(entry.slug ? { compendiumRef: String(entry.slug) } : {}),
|
||||||
<span><strong className="text-ink">Components</strong> {s.components}</span>
|
};
|
||||||
</div>
|
await charactersRepo.update(c.id, { spellcasting: { ...c.spellcasting, spells: [...c.spellcasting.spells, spell] } });
|
||||||
<p className="whitespace-pre-wrap text-sm text-ink">{s.desc}</p>
|
} else {
|
||||||
{s.higher_level && (
|
const item: InventoryItem = {
|
||||||
<p className="whitespace-pre-wrap text-sm text-muted">
|
id: newId(), name: entry.name, quantity: 1, weight: 0, equipped: false, attuned: false,
|
||||||
<strong className="text-ink">At Higher Levels.</strong> {s.higher_level}
|
description: '', ...(entry.slug ? { compendiumRef: String(entry.slug) } : {}),
|
||||||
</p>
|
};
|
||||||
)}
|
await charactersRepo.update(c.id, { inventory: [...c.inventory, item] });
|
||||||
</div>
|
}
|
||||||
);
|
setMsg(`Added to ${c.name}.`);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
if (!campaign) return <span className="text-xs text-muted">Select a campaign to add to a character.</span>;
|
||||||
|
if (characters.length === 0) return <span className="text-xs text-muted">No characters in this campaign yet.</span>;
|
||||||
|
|
||||||
function ItemPane({ item }: { item: MagicItem }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<>
|
||||||
<header>
|
<Select
|
||||||
<h2 className="font-display text-2xl font-bold text-accent">{item.name}</h2>
|
className="w-auto py-1 text-sm"
|
||||||
<p className="text-sm italic text-muted">
|
aria-label={`Add ${kind} to character`}
|
||||||
{[item.type, item.rarity].filter(Boolean).join(', ')}
|
value=""
|
||||||
{item.requires_attunement ? ` (requires attunement ${item.requires_attunement})` : ''}
|
onChange={(e) => { if (e.target.value) { void add(e.target.value); e.target.value = ''; } }}
|
||||||
</p>
|
>
|
||||||
</header>
|
<option value="">+ Add {kind} to…</option>
|
||||||
<p className="whitespace-pre-wrap text-sm text-ink">{item.desc}</p>
|
{characters.map((c: Character) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||||
</div>
|
</Select>
|
||||||
|
{msg && <span className="text-xs text-success" aria-live="polite">{msg}</span>}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import type {
|
||||||
|
Spell,
|
||||||
|
MagicItem,
|
||||||
|
Weapon5e,
|
||||||
|
Armor5e,
|
||||||
|
Feat5e,
|
||||||
|
Condition5e,
|
||||||
|
CompendiumEntry,
|
||||||
|
} from '@/lib/compendium/types';
|
||||||
|
|
||||||
|
function Header({ title, subtitle }: { title: string; subtitle?: string | undefined }) {
|
||||||
|
return (
|
||||||
|
<header>
|
||||||
|
<h2 className="font-display text-2xl font-bold text-accent">{title}</h2>
|
||||||
|
{subtitle && <p className="text-sm italic text-muted">{subtitle}</p>}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Traits({ traits }: { traits?: string[] | undefined }) {
|
||||||
|
if (!traits || traits.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{traits.map((t) => (
|
||||||
|
<span key={t} className="rounded bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted">
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spell5eDetail({ entry: s }: { entry: Spell }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Header title={s.name} subtitle={s.level_int === 0 ? `${s.school} cantrip` : `Level ${s.level_int} ${s.school ?? ''}`} />
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted">
|
||||||
|
<span><strong className="text-ink">Casting Time</strong> {s.casting_time}</span>
|
||||||
|
<span><strong className="text-ink">Range</strong> {s.range}</span>
|
||||||
|
<span><strong className="text-ink">Duration</strong> {s.duration}</span>
|
||||||
|
<span><strong className="text-ink">Components</strong> {s.components}</span>
|
||||||
|
</div>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-ink">{s.desc}</p>
|
||||||
|
{s.higher_level && (
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-muted">
|
||||||
|
<strong className="text-ink">At Higher Levels.</strong> {s.higher_level}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Item5eDetail({ entry: item }: { entry: MagicItem }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Header
|
||||||
|
title={item.name}
|
||||||
|
subtitle={
|
||||||
|
[item.type, item.rarity].filter(Boolean).join(', ') +
|
||||||
|
(item.requires_attunement ? ` (requires attunement ${item.requires_attunement})` : '')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-ink">{item.desc}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Weapon5eDetail({ entry: w }: { entry: Weapon5e }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Header title={w.name} subtitle={w.category} />
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted">
|
||||||
|
<span><strong className="text-ink">Damage</strong> {w.damage_dice} {w.damage_type}</span>
|
||||||
|
<span><strong className="text-ink">Cost</strong> {w.cost ?? '—'}</span>
|
||||||
|
<span><strong className="text-ink">Weight</strong> {w.weight ?? '—'}</span>
|
||||||
|
</div>
|
||||||
|
{w.properties && w.properties.length > 0 && (
|
||||||
|
<p className="text-sm text-muted"><strong className="text-ink">Properties</strong> {w.properties.join(', ')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Armor5eDetail({ entry: a }: { entry: Armor5e }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Header title={a.name} subtitle={`${a.type ?? ''} armor`} />
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted">
|
||||||
|
<span><strong className="text-ink">AC</strong> {a.ac}</span>
|
||||||
|
<span><strong className="text-ink">Cost</strong> {a.cost ?? '—'}</span>
|
||||||
|
<span><strong className="text-ink">Weight</strong> {a.weight ?? '—'} lb</span>
|
||||||
|
<span><strong className="text-ink">Stealth</strong> {a.stealthPenalty ? 'Disadvantage' : '—'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Feat5eDetail({ entry: f }: { entry: Feat5e }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Header title={f.name} subtitle="Feat" />
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-ink">{f.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Condition5eDetail({ entry: c }: { entry: Condition5e }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Header title={c.name} subtitle="Condition" />
|
||||||
|
{c.description && <p className="text-sm text-ink">{c.description}</p>}
|
||||||
|
{c.effects && c.effects.length > 0 && (
|
||||||
|
<ul className="list-disc space-y-1 pl-5 text-sm text-muted">
|
||||||
|
{c.effects.map((e, i) => <li key={i}>{e}</li>)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic renderer for PF2e (AoN) entries: header, traits, key facts, text body. */
|
||||||
|
export function Pf2eDetail({ entry }: { entry: CompendiumEntry }) {
|
||||||
|
const traits = (entry.trait as string[] | undefined) ?? undefined;
|
||||||
|
const facts: [string, unknown][] = [
|
||||||
|
['Level', entry.level],
|
||||||
|
['Rarity', entry.rarity],
|
||||||
|
['Tradition', entry.tradition],
|
||||||
|
['Actions', entry.actions],
|
||||||
|
['Price', entry.price],
|
||||||
|
['AC', entry.ac],
|
||||||
|
['HP', entry.hp],
|
||||||
|
['Damage', entry.damage],
|
||||||
|
['Source', entry.source],
|
||||||
|
];
|
||||||
|
const shown = facts.filter(([, v]) => v !== undefined && v !== null && v !== '');
|
||||||
|
const text = (entry.text as string | undefined) ?? (entry.summary as string | undefined) ?? '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Header title={entry.name} subtitle={(entry.type as string | undefined) ?? undefined} />
|
||||||
|
<Traits traits={traits} />
|
||||||
|
{shown.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm text-muted sm:grid-cols-3">
|
||||||
|
{shown.map(([k, v]) => (
|
||||||
|
<span key={k}><strong className="text-ink">{k}</strong> {String(v)}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="whitespace-pre-line text-sm text-ink">{text}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { filterMatches, categoriesForSystem, type FilterDef, type Entry } from './registry';
|
||||||
|
|
||||||
|
const eqType: FilterDef = {
|
||||||
|
key: 'type', label: 'Type', kind: 'eq',
|
||||||
|
get: (e) => e.type as string, options: () => [],
|
||||||
|
};
|
||||||
|
const crGte: FilterDef = {
|
||||||
|
key: 'crMin', label: 'Min CR', kind: 'gte',
|
||||||
|
get: (e) => e.cr as number, options: () => [],
|
||||||
|
};
|
||||||
|
const traitEq: FilterDef = {
|
||||||
|
key: 'trait', label: 'Trait', kind: 'eq',
|
||||||
|
get: (e) => e.trait as string[], options: () => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('filterMatches', () => {
|
||||||
|
it('eq matches exact string', () => {
|
||||||
|
expect(filterMatches(eqType, { name: 'x', type: 'dragon' } as Entry, 'dragon')).toBe(true);
|
||||||
|
expect(filterMatches(eqType, { name: 'x', type: 'goblin' } as Entry, 'dragon')).toBe(false);
|
||||||
|
});
|
||||||
|
it('gte compares numerically', () => {
|
||||||
|
expect(filterMatches(crGte, { name: 'x', cr: 10 } as Entry, '5')).toBe(true);
|
||||||
|
expect(filterMatches(crGte, { name: 'x', cr: 2 } as Entry, '5')).toBe(false);
|
||||||
|
});
|
||||||
|
it('array fields match by membership', () => {
|
||||||
|
expect(filterMatches(traitEq, { name: 'x', trait: ['Fire', 'Evocation'] } as Entry, 'Fire')).toBe(true);
|
||||||
|
expect(filterMatches(traitEq, { name: 'x', trait: ['Cold'] } as Entry, 'Fire')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('category registry', () => {
|
||||||
|
it('exposes 5e and pf2e categories', () => {
|
||||||
|
const fivee = categoriesForSystem('5e').map((c) => c.label);
|
||||||
|
const pf2e = categoriesForSystem('pf2e').map((c) => c.label);
|
||||||
|
expect(fivee).toContain('Bestiary');
|
||||||
|
expect(fivee).toContain('Spells');
|
||||||
|
expect(fivee).toContain('Feats');
|
||||||
|
expect(pf2e).toContain('Bestiary');
|
||||||
|
expect(pf2e).toContain('Spells');
|
||||||
|
expect(pf2e.length).toBeGreaterThan(8);
|
||||||
|
});
|
||||||
|
it('every category has a unique id', () => {
|
||||||
|
const all = [...categoriesForSystem('5e'), ...categoriesForSystem('pf2e')];
|
||||||
|
const ids = all.map((c) => c.id);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { SystemId } from '@/lib/rules';
|
||||||
|
import { abilityModifier } from '@/lib/rules';
|
||||||
|
import type { CompendiumEntry, Monster, Spell, MagicItem } from '@/lib/compendium/types';
|
||||||
|
import {
|
||||||
|
loadMonsters,
|
||||||
|
loadSpells,
|
||||||
|
loadItems,
|
||||||
|
loadWeapons5e,
|
||||||
|
loadArmor5e,
|
||||||
|
loadFeats5e,
|
||||||
|
loadConditions5e,
|
||||||
|
loadPf2e,
|
||||||
|
crLabel,
|
||||||
|
} from '@/lib/compendium';
|
||||||
|
import { MonsterDetail } from './MonsterDetail';
|
||||||
|
import {
|
||||||
|
Spell5eDetail,
|
||||||
|
Item5eDetail,
|
||||||
|
Weapon5eDetail,
|
||||||
|
Armor5eDetail,
|
||||||
|
Feat5eDetail,
|
||||||
|
Condition5eDetail,
|
||||||
|
Pf2eDetail,
|
||||||
|
} from './details';
|
||||||
|
|
||||||
|
export type Entry = CompendiumEntry;
|
||||||
|
|
||||||
|
export interface FilterDef {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
kind: 'eq' | 'gte' | 'lte';
|
||||||
|
get: (e: Entry) => string | number | (string | number)[] | undefined;
|
||||||
|
options: (data: Entry[]) => { value: string; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryDef {
|
||||||
|
id: string;
|
||||||
|
system: SystemId;
|
||||||
|
label: string;
|
||||||
|
load: () => Promise<Entry[]>;
|
||||||
|
searchKeys: string[];
|
||||||
|
meta: (e: Entry) => string;
|
||||||
|
detail: (e: Entry) => ReactNode;
|
||||||
|
filters: FilterDef[];
|
||||||
|
/** enables "add to character" for spells/items */
|
||||||
|
linkAs?: 'spell' | 'item';
|
||||||
|
/** enables "add to combat" for monsters/creatures */
|
||||||
|
toCombatant?: (e: Entry) => { name: string; ac: number; hp: number; initBonus: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterMatches(def: FilterDef, entry: Entry, value: string): boolean {
|
||||||
|
const g = def.get(entry);
|
||||||
|
if (Array.isArray(g)) return g.map(String).includes(value);
|
||||||
|
if (def.kind === 'eq') return String(g) === value;
|
||||||
|
const n = Number(g);
|
||||||
|
if (!Number.isFinite(n)) return false;
|
||||||
|
return def.kind === 'gte' ? n >= Number(value) : n <= Number(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- filter option helpers ----
|
||||||
|
function distinctStrings(data: Entry[], get: (e: Entry) => unknown): { value: string; label: string }[] {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const e of data) {
|
||||||
|
const v = get(e);
|
||||||
|
if (Array.isArray(v)) v.forEach((x) => x && set.add(String(x)));
|
||||||
|
else if (v !== undefined && v !== null && String(v).trim() !== '') set.add(String(v));
|
||||||
|
}
|
||||||
|
return [...set].sort((a, b) => a.localeCompare(b)).map((v) => ({ value: v, label: v }));
|
||||||
|
}
|
||||||
|
function numericOptions(data: Entry[], get: (e: Entry) => unknown, fmt: (n: number) => string): { value: string; label: string }[] {
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const e of data) {
|
||||||
|
const v = get(e);
|
||||||
|
if (typeof v === 'number' && Number.isFinite(v)) set.add(v);
|
||||||
|
}
|
||||||
|
return [...set].sort((a, b) => a - b).map((n) => ({ value: String(n), label: fmt(n) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const traitFilter = (): FilterDef => ({
|
||||||
|
key: 'trait',
|
||||||
|
label: 'Trait',
|
||||||
|
kind: 'eq',
|
||||||
|
get: (e) => e.trait as string[] | undefined,
|
||||||
|
options: (d) => distinctStrings(d, (e) => e.trait),
|
||||||
|
});
|
||||||
|
const rarityFilter = (): FilterDef => ({
|
||||||
|
key: 'rarity',
|
||||||
|
label: 'Rarity',
|
||||||
|
kind: 'eq',
|
||||||
|
get: (e) => e.rarity as string | undefined,
|
||||||
|
options: (d) => distinctStrings(d, (e) => e.rarity),
|
||||||
|
});
|
||||||
|
const levelMin = (): FilterDef => ({
|
||||||
|
key: 'levelMin',
|
||||||
|
label: 'Min level',
|
||||||
|
kind: 'gte',
|
||||||
|
get: (e) => e.level as number | undefined,
|
||||||
|
options: (d) => numericOptions(d, (e) => e.level, (n) => `Lvl ≥ ${n}`),
|
||||||
|
});
|
||||||
|
const levelMax = (): FilterDef => ({
|
||||||
|
key: 'levelMax',
|
||||||
|
label: 'Max level',
|
||||||
|
kind: 'lte',
|
||||||
|
get: (e) => e.level as number | undefined,
|
||||||
|
options: (d) => numericOptions(d, (e) => e.level, (n) => `Lvl ≤ ${n}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
function pf2eMeta(e: Entry): string {
|
||||||
|
if (typeof e.level === 'number') return `Lvl ${e.level}`;
|
||||||
|
if (e.rarity) return String(e.rarity);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pf2eCategory(id: string, label: string, file: string, opts: { level?: boolean; linkAs?: 'spell' | 'item' } = {}): CategoryDef {
|
||||||
|
const filters: FilterDef[] = [traitFilter(), rarityFilter()];
|
||||||
|
if (opts.level !== false) filters.unshift(levelMin(), levelMax());
|
||||||
|
return {
|
||||||
|
id: `pf2e-${id}`,
|
||||||
|
system: 'pf2e',
|
||||||
|
label,
|
||||||
|
load: () => loadPf2e(file),
|
||||||
|
searchKeys: ['name', 'trait'],
|
||||||
|
meta: pf2eMeta,
|
||||||
|
detail: (e) => <Pf2eDetail entry={e} />,
|
||||||
|
filters,
|
||||||
|
...(opts.linkAs ? { linkAs: opts.linkAs } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CATEGORIES: CategoryDef[] = [
|
||||||
|
// ---------- D&D 5e ----------
|
||||||
|
{
|
||||||
|
id: '5e-monsters', system: '5e', label: 'Bestiary',
|
||||||
|
load: () => loadMonsters() as unknown as Promise<Entry[]>,
|
||||||
|
searchKeys: ['name', 'type'],
|
||||||
|
meta: (e) => `CR ${crLabel(e as unknown as Monster)}`,
|
||||||
|
detail: (e) => <MonsterDetail monster={e as unknown as Monster} />,
|
||||||
|
toCombatant: (e) => {
|
||||||
|
const m = e as unknown as Monster;
|
||||||
|
return { name: m.name, ac: m.armor_class ?? 10, hp: m.hit_points ?? 1, initBonus: abilityModifier(m.dexterity ?? 10) };
|
||||||
|
},
|
||||||
|
filters: [
|
||||||
|
{ key: 'type', label: 'Type', kind: 'eq', get: (e) => e.type as string, options: (d) => distinctStrings(d, (e) => e.type) },
|
||||||
|
{ key: 'crMin', label: 'Min CR', kind: 'gte', get: (e) => e.cr as number, options: (d) => numericOptions(d, (e) => e.cr, (n) => `CR ≥ ${crNum(n)}`) },
|
||||||
|
{ key: 'crMax', label: 'Max CR', kind: 'lte', get: (e) => e.cr as number, options: (d) => numericOptions(d, (e) => e.cr, (n) => `CR ≤ ${crNum(n)}`) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5e-spells', system: '5e', label: 'Spells', linkAs: 'spell',
|
||||||
|
load: () => loadSpells() as unknown as Promise<Entry[]>,
|
||||||
|
searchKeys: ['name', 'school'],
|
||||||
|
meta: (e) => { const l = (e as unknown as Spell).level_int; return l === 0 ? 'Cantrip' : `Lv ${l ?? '?'}`; },
|
||||||
|
detail: (e) => <Spell5eDetail entry={e as unknown as Spell} />,
|
||||||
|
filters: [
|
||||||
|
{ key: 'level', label: 'Level', kind: 'eq', get: (e) => e.level_int as number, options: (d) => numericOptions(d, (e) => e.level_int, (n) => (n === 0 ? 'Cantrip' : `Level ${n}`)) },
|
||||||
|
{ key: 'school', label: 'School', kind: 'eq', get: (e) => e.school as string, options: (d) => distinctStrings(d, (e) => e.school) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5e-items', system: '5e', label: 'Magic Items', linkAs: 'item',
|
||||||
|
load: () => loadItems() as unknown as Promise<Entry[]>,
|
||||||
|
searchKeys: ['name', 'type'],
|
||||||
|
meta: (e) => (e.rarity as string) ?? '',
|
||||||
|
detail: (e) => <Item5eDetail entry={e as unknown as MagicItem} />,
|
||||||
|
filters: [
|
||||||
|
{ key: 'rarity', label: 'Rarity', kind: 'eq', get: (e) => e.rarity as string, options: (d) => distinctStrings(d, (e) => e.rarity) },
|
||||||
|
{ key: 'type', label: 'Type', kind: 'eq', get: (e) => e.type as string, options: (d) => distinctStrings(d, (e) => e.type) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5e-weapons', system: '5e', label: 'Weapons',
|
||||||
|
load: () => loadWeapons5e() as unknown as Promise<Entry[]>,
|
||||||
|
searchKeys: ['name'],
|
||||||
|
meta: (e) => (e.category as string) ?? '',
|
||||||
|
detail: (e) => <Weapon5eDetail entry={e as never} />,
|
||||||
|
filters: [
|
||||||
|
{ key: 'category', label: 'Category', kind: 'eq', get: (e) => e.category as string, options: (d) => distinctStrings(d, (e) => e.category) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5e-armor', system: '5e', label: 'Armor',
|
||||||
|
load: () => loadArmor5e() as unknown as Promise<Entry[]>,
|
||||||
|
searchKeys: ['name'],
|
||||||
|
meta: (e) => (e.type as string) ?? '',
|
||||||
|
detail: (e) => <Armor5eDetail entry={e as never} />,
|
||||||
|
filters: [
|
||||||
|
{ key: 'type', label: 'Type', kind: 'eq', get: (e) => e.type as string, options: (d) => distinctStrings(d, (e) => e.type) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5e-feats', system: '5e', label: 'Feats',
|
||||||
|
load: () => loadFeats5e() as unknown as Promise<Entry[]>,
|
||||||
|
searchKeys: ['name'],
|
||||||
|
meta: () => 'Feat',
|
||||||
|
detail: (e) => <Feat5eDetail entry={e as never} />,
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5e-conditions', system: '5e', label: 'Conditions',
|
||||||
|
load: () => loadConditions5e() as unknown as Promise<Entry[]>,
|
||||||
|
searchKeys: ['name'],
|
||||||
|
meta: () => '',
|
||||||
|
detail: (e) => <Condition5eDetail entry={e as never} />,
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ---------- Pathfinder 2e ----------
|
||||||
|
{
|
||||||
|
...pf2eCategory('creatures', 'Bestiary', 'creatures'),
|
||||||
|
toCombatant: (e) => ({
|
||||||
|
name: e.name,
|
||||||
|
ac: Number(e.ac) || 10,
|
||||||
|
hp: Number(e.hp) || 1,
|
||||||
|
initBonus: Number(e.perception) || 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
pf2eCategory('spells', 'Spells', 'spells', { linkAs: 'spell' }),
|
||||||
|
pf2eCategory('feats', 'Feats', 'feats'),
|
||||||
|
pf2eCategory('equipment', 'Equipment', 'equipment', { linkAs: 'item' }),
|
||||||
|
pf2eCategory('weapons', 'Weapons', 'weapons', { linkAs: 'item' }),
|
||||||
|
pf2eCategory('armor', 'Armor', 'armor', { linkAs: 'item' }),
|
||||||
|
pf2eCategory('ancestries', 'Ancestries', 'ancestries', { level: false }),
|
||||||
|
pf2eCategory('heritages', 'Heritages', 'heritages', { level: false }),
|
||||||
|
pf2eCategory('backgrounds', 'Backgrounds', 'backgrounds', { level: false }),
|
||||||
|
pf2eCategory('archetypes', 'Archetypes', 'archetypes', { level: false }),
|
||||||
|
pf2eCategory('actions', 'Actions', 'actions', { level: false }),
|
||||||
|
pf2eCategory('conditions', 'Conditions', 'conditions', { level: false }),
|
||||||
|
pf2eCategory('deities', 'Deities', 'deities', { level: false }),
|
||||||
|
];
|
||||||
|
|
||||||
|
function crNum(n: number): string {
|
||||||
|
if (n === 0.125) return '1/8';
|
||||||
|
if (n === 0.25) return '1/4';
|
||||||
|
if (n === 0.5) return '1/2';
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function categoriesForSystem(system: SystemId): CategoryDef[] {
|
||||||
|
return CATEGORIES.filter((c) => c.system === system);
|
||||||
|
}
|
||||||
@@ -1,4 +1,13 @@
|
|||||||
import type { Monster, Spell, MagicItem } from './types';
|
import type {
|
||||||
|
Monster,
|
||||||
|
Spell,
|
||||||
|
MagicItem,
|
||||||
|
Weapon5e,
|
||||||
|
Armor5e,
|
||||||
|
Feat5e,
|
||||||
|
Condition5e,
|
||||||
|
CompendiumEntry,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lazy, cached loaders for the SRD datasets. Dynamic `import()` keeps the
|
* Lazy, cached loaders for the SRD datasets. Dynamic `import()` keeps the
|
||||||
@@ -33,6 +42,54 @@ export async function loadItems(): Promise<MagicItem[]> {
|
|||||||
return itemsCache;
|
return itemsCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Additional 5e categories (bundled — these files are small) ----
|
||||||
|
const cache = new Map<string, unknown[]>();
|
||||||
|
|
||||||
|
export async function loadWeapons5e(): Promise<Weapon5e[]> {
|
||||||
|
if (!cache.has('w5e')) {
|
||||||
|
const mod = await import('@/data/srd/weapons-full.json');
|
||||||
|
cache.set('w5e', mod.default as unknown as Weapon5e[]);
|
||||||
|
}
|
||||||
|
return cache.get('w5e') as Weapon5e[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadArmor5e(): Promise<Armor5e[]> {
|
||||||
|
if (!cache.has('a5e')) {
|
||||||
|
const mod = await import('@/data/srd/equipment.json');
|
||||||
|
cache.set('a5e', (mod.default as { armor: Armor5e[] }).armor);
|
||||||
|
}
|
||||||
|
return cache.get('a5e') as Armor5e[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadFeats5e(): Promise<Feat5e[]> {
|
||||||
|
if (!cache.has('f5e')) {
|
||||||
|
const mod = await import('@/data/srd/mpmb-feats.json');
|
||||||
|
const obj = mod.default as Record<string, Feat5e>;
|
||||||
|
cache.set('f5e', Object.values(obj).sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
}
|
||||||
|
return cache.get('f5e') as Feat5e[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadConditions5e(): Promise<Condition5e[]> {
|
||||||
|
if (!cache.has('c5e')) {
|
||||||
|
const mod = await import('@/data/srd/conditions.json');
|
||||||
|
cache.set('c5e', mod.default as unknown as Condition5e[]);
|
||||||
|
}
|
||||||
|
return cache.get('c5e') as Condition5e[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PF2e categories (static assets fetched at runtime; large files) ----
|
||||||
|
const pf2eCache = new Map<string, CompendiumEntry[]>();
|
||||||
|
|
||||||
|
export async function loadPf2e(file: string): Promise<CompendiumEntry[]> {
|
||||||
|
if (!pf2eCache.has(file)) {
|
||||||
|
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})`);
|
||||||
|
pf2eCache.set(file, (await res.json()) as CompendiumEntry[]);
|
||||||
|
}
|
||||||
|
return pf2eCache.get(file)!;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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;
|
||||||
|
|||||||
@@ -73,3 +73,39 @@ export interface MagicItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CompendiumKind = 'monsters' | 'spells' | 'items';
|
export type CompendiumKind = 'monsters' | 'spells' | 'items';
|
||||||
|
|
||||||
|
/** A loosely-typed entry for the generic, registry-driven categories. */
|
||||||
|
export type CompendiumEntry = Record<string, unknown> & { name: string; slug?: string };
|
||||||
|
|
||||||
|
export interface Weapon5e {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
category?: string;
|
||||||
|
cost?: string;
|
||||||
|
damage_dice?: string;
|
||||||
|
damage_type?: string;
|
||||||
|
weight?: string | number;
|
||||||
|
properties?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Armor5e {
|
||||||
|
name: string;
|
||||||
|
type?: string;
|
||||||
|
ac?: string;
|
||||||
|
dexCap?: number | null;
|
||||||
|
stealthPenalty?: boolean;
|
||||||
|
weight?: number;
|
||||||
|
cost?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Feat5e {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
source?: { source: string; page: number }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Condition5e {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
effects?: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/dice/DicePage.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/ui/Button.tsx","./src/components/ui/ErrorBoundary.tsx","./src/components/ui/Input.tsx","./src/components/ui/Modal.tsx","./src/components/ui/NumberField.tsx","./src/components/ui/Page.tsx","./src/features/campaigns/CampaignsPage.tsx","./src/features/campaigns/hooks.ts","./src/features/characters/CharacterSheet.tsx","./src/features/characters/CharacterSheetPage.tsx","./src/features/characters/CharactersPage.tsx","./src/features/characters/hooks.ts","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/ResourcesSection.tsx","./src/features/characters/sheet/SpellcastingSection.tsx","./src/features/characters/sheet/common.tsx","./src/features/combat/CombatPage.tsx","./src/features/combat/EncounterTracker.tsx","./src/features/combat/hooks.ts","./src/features/combat/useConditionGlossary.ts","./src/features/compendium/CompendiumPage.tsx","./src/features/compendium/MonsterDetail.tsx","./src/features/compendium/details.tsx","./src/features/compendium/registry.test.ts","./src/features/compendium/registry.tsx","./src/features/dice/DicePage.tsx","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/useDebouncedCallback.ts","./src/lib/combat/engine.test.ts","./src/lib/combat/engine.ts","./src/lib/compendium/index.ts","./src/lib/compendium/types.ts","./src/lib/db/db.ts","./src/lib/db/repositories.test.ts","./src/lib/db/repositories.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/rules/abilities.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/rest.ts","./src/lib/rules/rules.test.ts","./src/lib/rules/types.ts","./src/lib/rules/dnd5e/index.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/skills.ts","./src/lib/schemas/campaign.ts","./src/lib/schemas/character.ts","./src/lib/schemas/common.ts","./src/lib/schemas/dice.ts","./src/lib/schemas/encounter.ts","./src/lib/schemas/index.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}
|
||||||
@@ -1 +1 @@
|
|||||||
{"root":["./vite.config.ts","./vitest.config.ts"],"version":"5.9.3"}
|
{"root":["./vite.config.ts","./vitest.config.ts","./scripts/fetch_pf2e.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user