Build MVP: campaigns, characters, combat, dice, compendium
- Rules abstraction (5e + pf2e) behind one interface, fully tested - Pure combat engine: turn-order safe across add/remove/reorder/init-change - Dexie storage with real cascade deletes + Zod validation on write - Seedable dice engine with notation parser - Lazy SRD compendium (code-split), bestiary -> combat - Strict TS, 54 unit tests, Playwright e2e smoke (all green) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
||||
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import { PF2E_SAVES } from '@/lib/rules/pf2e/skills';
|
||||
import { useDebouncedCallback } from '@/lib/useDebouncedCallback';
|
||||
import { Page } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
import { NumberField } from '@/components/ui/NumberField';
|
||||
import { formatModifier } from '@/lib/format';
|
||||
|
||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||
|
||||
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
|
||||
const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
|
||||
const RANK_LABEL: Record<ProficiencyRank, string> = {
|
||||
untrained: 'Untrained',
|
||||
trained: 'Trained',
|
||||
expert: 'Expert',
|
||||
master: 'Master',
|
||||
legendary: 'Legendary',
|
||||
};
|
||||
|
||||
export function CharacterSheet({ character }: { character: Character }) {
|
||||
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
|
||||
const [c, setC] = useState<Character>(character);
|
||||
const save = useDebouncedCallback((next: Character) => {
|
||||
void charactersRepo.update(next.id, {
|
||||
name: next.name,
|
||||
ancestry: next.ancestry,
|
||||
className: next.className,
|
||||
level: next.level,
|
||||
kind: next.kind,
|
||||
abilities: next.abilities,
|
||||
hp: next.hp,
|
||||
speed: next.speed,
|
||||
armorBonus: next.armorBonus,
|
||||
skillRanks: next.skillRanks,
|
||||
saveRanks: next.saveRanks,
|
||||
perceptionRank: next.perceptionRank,
|
||||
...(next.spellcastingAbility ? { spellcastingAbility: next.spellcastingAbility } : {}),
|
||||
...(next.spellcastingRank ? { spellcastingRank: next.spellcastingRank } : {}),
|
||||
notes: next.notes,
|
||||
});
|
||||
}, 350);
|
||||
|
||||
const update = (patch: Partial<Character>) => {
|
||||
setC((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
save(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const sys = getSystem(c.system);
|
||||
const rulesInput: CharacterRulesInput = {
|
||||
level: c.level,
|
||||
abilities: c.abilities,
|
||||
skillRanks: c.skillRanks as Record<string, ProficiencyRank>,
|
||||
saveRanks: c.saveRanks as Partial<Record<AbilityKey, ProficiencyRank>>,
|
||||
armorBonus: c.armorBonus,
|
||||
perceptionRank: c.perceptionRank,
|
||||
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank } : {}),
|
||||
};
|
||||
|
||||
const ac = sys.baseArmorClass(rulesInput);
|
||||
const initiative = sys.initiativeModifier(rulesInput);
|
||||
const skills = sys.skillModifiers(rulesInput);
|
||||
const saves = sys.saveModifiers(rulesInput);
|
||||
const profLabel = c.system === '5e' ? `+${sys.proficiencyValue(c.level, 'trained')} prof` : `level ${c.level}`;
|
||||
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<div className="mb-4">
|
||||
<Link to="/characters" className="text-sm text-muted hover:text-ink">
|
||||
← Back to characters
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
className="max-w-xs font-display text-xl font-bold"
|
||||
value={c.name}
|
||||
aria-label="Character name"
|
||||
onChange={(e) => update({ name: e.target.value })}
|
||||
/>
|
||||
<span className="rounded-full bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted">
|
||||
{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}
|
||||
</span>
|
||||
<div className="ml-auto text-sm text-muted">{profLabel}</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
|
||||
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
|
||||
</Labeled>
|
||||
<Labeled label="Class">
|
||||
<Input value={c.className} onChange={(e) => update({ className: e.target.value })} />
|
||||
</Labeled>
|
||||
<Labeled label="Level">
|
||||
<NumberField value={c.level} min={1} max={20} onChange={(level) => update({ level })} />
|
||||
</Labeled>
|
||||
<Labeled label="Speed">
|
||||
<NumberField value={c.speed} min={0} onChange={(speed) => update({ speed })} />
|
||||
</Labeled>
|
||||
</div>
|
||||
|
||||
{/* Vital stats */}
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-3">
|
||||
<StatCard label="Armor Class" value={ac} hint={`10 + DEX${c.armorBonus ? ` + ${c.armorBonus}` : ''}`}>
|
||||
<div className="mt-2 flex items-center justify-center gap-2 text-xs text-muted">
|
||||
<span>Armor/shield bonus</span>
|
||||
<NumberField className="w-16" value={c.armorBonus} onChange={(armorBonus) => update({ armorBonus })} aria-label="Armor bonus" />
|
||||
</div>
|
||||
</StatCard>
|
||||
|
||||
<StatCard label="Initiative" value={formatModifier(initiative)} hint={c.system === 'pf2e' ? 'Perception' : 'DEX mod'} />
|
||||
|
||||
<HpCard c={c} update={update} />
|
||||
</div>
|
||||
|
||||
{/* Abilities */}
|
||||
<section className="mb-6">
|
||||
<SectionTitle>Ability Scores</SectionTitle>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
|
||||
{ABILITIES.map((a) => {
|
||||
const mod = sys.abilityModifier(c.abilities[a]);
|
||||
return (
|
||||
<div key={a} className="rounded-lg border border-line bg-panel p-3 text-center">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}</div>
|
||||
<div className="my-1 font-display text-2xl font-bold text-accent">{formatModifier(mod)}</div>
|
||||
<NumberField
|
||||
value={c.abilities[a]}
|
||||
min={1}
|
||||
max={30}
|
||||
aria-label={`${ABILITY_ABBR[a]} score`}
|
||||
onChange={(v) => update({ abilities: { ...c.abilities, [a]: v } })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Saves */}
|
||||
<section className="mb-6">
|
||||
<SectionTitle>Saving Throws</SectionTitle>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{c.system === 'pf2e'
|
||||
? PF2E_SAVES.map((s) => (
|
||||
<RankRow
|
||||
key={s.key}
|
||||
label={s.label}
|
||||
modifier={saves[s.ability]}
|
||||
rank={(c.saveRanks[s.ability] as ProficiencyRank) ?? 'untrained'}
|
||||
ranks={ranks}
|
||||
onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [s.ability]: rank } })}
|
||||
/>
|
||||
))
|
||||
: ABILITIES.map((a) => (
|
||||
<RankRow
|
||||
key={a}
|
||||
label={`${ABILITY_ABBR[a]} save`}
|
||||
modifier={saves[a]}
|
||||
rank={(c.saveRanks[a] as ProficiencyRank) ?? 'untrained'}
|
||||
ranks={ranks}
|
||||
onRank={(rank) => update({ saveRanks: { ...c.saveRanks, [a]: rank } })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Skills */}
|
||||
<section className="mb-6">
|
||||
<SectionTitle>Skills</SectionTitle>
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{skills.map((s) => (
|
||||
<RankRow
|
||||
key={s.key}
|
||||
label={`${s.label} (${ABILITY_ABBR[s.ability]})`}
|
||||
modifier={s.modifier}
|
||||
rank={(c.skillRanks[s.key] as ProficiencyRank) ?? 'untrained'}
|
||||
ranks={ranks}
|
||||
onRank={(rank) => update({ skillRanks: { ...c.skillRanks, [s.key]: rank } })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notes */}
|
||||
<section className="mb-6">
|
||||
<SectionTitle>Notes</SectionTitle>
|
||||
<textarea
|
||||
value={c.notes}
|
||||
onChange={(e) => update({ notes: e.target.value })}
|
||||
placeholder="Backstory, bonds, inventory, reminders…"
|
||||
className="min-h-32 w-full resize-y rounded-md border border-line bg-surface px-3 py-2 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||
/>
|
||||
</section>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
|
||||
const [delta, setDelta] = useState(0);
|
||||
const apply = (mode: 'damage' | 'heal') => {
|
||||
if (!Number.isFinite(delta) || delta <= 0) return;
|
||||
if (mode === 'damage') {
|
||||
const absorbed = Math.min(c.hp.temp, delta);
|
||||
update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } });
|
||||
} else {
|
||||
update({ hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + delta) } });
|
||||
}
|
||||
setDelta(0);
|
||||
};
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4 text-center">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">Hit Points</div>
|
||||
<div className="my-1 font-display text-2xl font-bold text-ink">
|
||||
{c.hp.current}
|
||||
<span className="text-base text-muted"> / {c.hp.max}</span>
|
||||
{c.hp.temp > 0 && <span className="ml-1 text-base text-info">(+{c.hp.temp})</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1 text-[11px] text-muted">
|
||||
<label>Cur<NumberField value={c.hp.current} onChange={(current) => update({ hp: { ...c.hp, current } })} aria-label="Current HP" /></label>
|
||||
<label>Max<NumberField value={c.hp.max} min={0} onChange={(max) => update({ hp: { ...c.hp, max } })} aria-label="Max HP" /></label>
|
||||
<label>Temp<NumberField value={c.hp.temp} min={0} onChange={(temp) => update({ hp: { ...c.hp, temp } })} aria-label="Temp HP" /></label>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<NumberField className="w-16" value={delta} min={0} onChange={setDelta} aria-label="HP change amount" />
|
||||
<Button size="sm" variant="danger" className="flex-1" onClick={() => apply('damage')}>
|
||||
Damage
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" className="flex-1" onClick={() => apply('heal')}>
|
||||
Heal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RankRow({
|
||||
label,
|
||||
modifier,
|
||||
rank,
|
||||
ranks,
|
||||
onRank,
|
||||
}: {
|
||||
label: string;
|
||||
modifier: number;
|
||||
rank: ProficiencyRank;
|
||||
ranks: ProficiencyRank[];
|
||||
onRank: (r: ProficiencyRank) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5">
|
||||
<span className="w-8 font-display text-lg font-semibold text-accent">{formatModifier(modifier)}</span>
|
||||
<span className="flex-1 truncate text-sm text-ink">{label}</span>
|
||||
<Select className="w-auto py-1 text-xs" value={rank} onChange={(e) => onRank(e.target.value as ProficiencyRank)}>
|
||||
{ranks.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{RANK_LABEL[r]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Labeled({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-xs font-semibold uppercase tracking-wide text-muted">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value, hint, children }: { label: string; value: string | number; hint?: string; children?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-line bg-panel p-4 text-center">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{label}</div>
|
||||
<div className="my-1 font-display text-3xl font-bold text-accent">{value}</div>
|
||||
{hint && <div className="text-xs text-muted">{hint}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return <h2 className="mb-2 font-display text-lg font-semibold text-ink">{children}</h2>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useParams } from '@tanstack/react-router';
|
||||
import { useCharacter } from './hooks';
|
||||
import { CharacterSheet } from './CharacterSheet';
|
||||
import { Page, EmptyState } from '@/components/ui/Page';
|
||||
|
||||
export function CharacterSheetPage() {
|
||||
const { characterId } = useParams({ from: '/characters/$characterId' });
|
||||
const character = useCharacter(characterId);
|
||||
|
||||
if (character === undefined) {
|
||||
// useLiveQuery returns undefined while loading AND when not found.
|
||||
return (
|
||||
<Page>
|
||||
<EmptyState title="Loading character…" />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
// Keyed so local sheet state resets cleanly when switching characters.
|
||||
return <CharacterSheet key={character.id} character={character} />;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import type { Campaign, Character } from '@/lib/schemas';
|
||||
import { getSystem } from '@/lib/rules';
|
||||
import { useCharacters } from './hooks';
|
||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Field, Input, Select } from '@/components/ui/Input';
|
||||
|
||||
export function CharactersPage() {
|
||||
return <RequireCampaign>{(campaign) => <CharactersList campaign={campaign} />}</RequireCampaign>;
|
||||
}
|
||||
|
||||
function CharactersList({ campaign }: { campaign: Campaign }) {
|
||||
const characters = useCharacters(campaign.id);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const pcs = characters.filter((c) => c.kind === 'pc');
|
||||
const npcs = characters.filter((c) => c.kind === 'npc');
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title="Characters"
|
||||
subtitle={campaign.name}
|
||||
actions={
|
||||
<Button variant="primary" onClick={() => setCreating(true)}>
|
||||
+ New character
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{characters.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No characters yet"
|
||||
hint="Add player characters and NPCs to this campaign."
|
||||
action={
|
||||
<Button variant="primary" onClick={() => setCreating(true)}>
|
||||
+ New character
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<CharacterGroup title="Player Characters" items={pcs} />
|
||||
<CharacterGroup title="NPCs" items={npcs} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{creating && <CharacterFormModal campaign={campaign} onClose={() => setCreating(false)} />}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function CharacterGroup({ title, items }: { title: string; items: Character[] }) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<section>
|
||||
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">{title}</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
to="/characters/$characterId"
|
||||
params={{ characterId: c.id }}
|
||||
className="rounded-lg border border-line bg-panel p-4 transition-colors hover:border-accent/50"
|
||||
>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<h3 className="font-display text-lg font-semibold text-ink">{c.name}</h3>
|
||||
<span className="text-sm text-muted">Lv {c.level}</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-muted">
|
||||
{[c.ancestry, c.className].filter(Boolean).join(' ') || '—'}
|
||||
</p>
|
||||
<div className="mt-3 flex gap-4 text-sm">
|
||||
<span className="text-muted">
|
||||
HP <span className="font-medium text-ink">{c.hp.current}/{c.hp.max}</span>
|
||||
</span>
|
||||
<span className="text-muted">
|
||||
AC <span className="font-medium text-ink">{getSystem(c.system).baseArmorClass({ level: c.level, abilities: c.abilities, armorBonus: c.armorBonus })}</span>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CharacterFormModal({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
|
||||
const [name, setName] = useState('');
|
||||
const [kind, setKind] = useState<Character['kind']>('pc');
|
||||
const [ancestry, setAncestry] = useState('');
|
||||
const [className, setClassName] = useState('');
|
||||
const [level, setLevel] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const save = async () => {
|
||||
if (name.trim() === '') {
|
||||
setError('Name is required');
|
||||
return;
|
||||
}
|
||||
await charactersRepo.create(campaign.id, {
|
||||
system: campaign.system,
|
||||
name: name.trim(),
|
||||
kind,
|
||||
ancestry: ancestry.trim(),
|
||||
className: className.trim(),
|
||||
level,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title="New character"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={save}>
|
||||
Create
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field label="Name">
|
||||
<Input data-autofocus value={name} onChange={(e) => { setName(e.target.value); setError(null); }} placeholder="Aria Stormwind" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Type">
|
||||
<Select value={kind} onChange={(e) => setKind(e.target.value as Character['kind'])}>
|
||||
<option value="pc">Player Character</option>
|
||||
<option value="npc">NPC</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Level">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={20}
|
||||
value={level}
|
||||
onChange={(e) => setLevel(Math.max(1, Math.min(20, Number(e.target.value) || 1)))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label={campaign.system === 'pf2e' ? 'Ancestry' : 'Race'}>
|
||||
<Input value={ancestry} onChange={(e) => setAncestry(e.target.value)} placeholder={campaign.system === 'pf2e' ? 'Dwarf' : 'Half-Elf'} />
|
||||
</Field>
|
||||
<Field label="Class">
|
||||
<Input value={className} onChange={(e) => setClassName(e.target.value)} placeholder="Fighter" />
|
||||
</Field>
|
||||
</div>
|
||||
{error && <p className="text-sm text-danger">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { charactersRepo } from '@/lib/db/repositories';
|
||||
import type { Character } from '@/lib/schemas';
|
||||
|
||||
export function useCharacters(campaignId: string): Character[] {
|
||||
return useLiveQuery(() => charactersRepo.listByCampaign(campaignId), [campaignId], []);
|
||||
}
|
||||
|
||||
export function useCharacter(id: string): Character | undefined {
|
||||
return useLiveQuery(() => charactersRepo.get(id), [id], undefined);
|
||||
}
|
||||
Reference in New Issue
Block a user