Phase 1: player character depth
- Inventory + currency + encumbrance vs carrying capacity - Class resources with correct short/long/daily rest recovery (applyRest) - Spellcasting: slots (+pact), known/prepared spells, derived DC + attack - Defenses: 5e death saves/inspiration/exhaustion, pf2e dying/wounded/hero points - Derived weapon attacks + passive perception via extended RulesSystem - Dexie v2 migration backfills new fields; debounced save flushes on pagehide - 9 new unit tests, new character-depth e2e; all green Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
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('character depth: inventory, spellcasting, attacks, resources + rest', async ({ page }) => {
|
||||||
|
// Campaign + character
|
||||||
|
await page.getByRole('button', { name: '+ New campaign' }).first().click();
|
||||||
|
await page.locator('input[data-autofocus]').fill('Phandelver');
|
||||||
|
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('Gandalf');
|
||||||
|
await page.getByRole('button', { name: 'Create' }).click();
|
||||||
|
await page.getByRole('link', { name: /Gandalf/ }).click();
|
||||||
|
|
||||||
|
// Set INT high so spell DC is computable
|
||||||
|
await page.getByLabel('INT score').fill('18');
|
||||||
|
|
||||||
|
// Inventory — add an item via Enter
|
||||||
|
await page.getByPlaceholder('Longsword', { exact: true }).fill('Staff of Power');
|
||||||
|
await page.getByPlaceholder('Longsword', { exact: true }).press('Enter');
|
||||||
|
await expect(page.locator('input[aria-label="Item name"]')).toHaveValue('Staff of Power');
|
||||||
|
|
||||||
|
// Spellcasting — choose ability, expect a derived DC to appear
|
||||||
|
await page.getByLabel('Casting ability').selectOption('int');
|
||||||
|
await expect(page.getByText('Spell DC')).toBeVisible();
|
||||||
|
|
||||||
|
// Attacks — add one, expect a computed to-hit
|
||||||
|
await page.getByPlaceholder('Longsword, Shortbow…').fill('Quarterstaff');
|
||||||
|
await page.getByPlaceholder('Longsword, Shortbow…').press('Enter');
|
||||||
|
await expect(page.getByText('to hit')).toBeVisible();
|
||||||
|
|
||||||
|
// Resources — add one, spend it, long rest restores it
|
||||||
|
await page.getByPlaceholder('Ki Points, Rage, Focus…').fill('Sorcery Points');
|
||||||
|
await page.getByPlaceholder('Ki Points, Rage, Focus…').press('Enter');
|
||||||
|
// Only one resource exists, so the controls are unambiguous.
|
||||||
|
await page.getByRole('button', { name: 'Spend' }).click();
|
||||||
|
await expect(page.getByText('0/1')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Long Rest' }).click();
|
||||||
|
await expect(page.getByText('1/1')).toBeVisible();
|
||||||
|
|
||||||
|
// Reload — persistence survived (autosave). Wait for the debounce + beforeunload flush.
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('input[aria-label="Item name"]')).toHaveValue('Staff of Power');
|
||||||
|
});
|
||||||
@@ -11,6 +11,11 @@ import { Button } from '@/components/ui/Button';
|
|||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
import { formatModifier } from '@/lib/format';
|
import { formatModifier } from '@/lib/format';
|
||||||
|
import { InventorySection } from './sheet/InventorySection';
|
||||||
|
import { AttacksSection } from './sheet/AttacksSection';
|
||||||
|
import { ResourcesSection } from './sheet/ResourcesSection';
|
||||||
|
import { SpellcastingSection } from './sheet/SpellcastingSection';
|
||||||
|
import { DefensesSection } from './sheet/DefensesSection';
|
||||||
|
|
||||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||||
|
|
||||||
@@ -28,23 +33,9 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
|
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
|
||||||
const [c, setC] = useState<Character>(character);
|
const [c, setC] = useState<Character>(character);
|
||||||
const save = useDebouncedCallback((next: Character) => {
|
const save = useDebouncedCallback((next: Character) => {
|
||||||
void charactersRepo.update(next.id, {
|
// Persist everything except identity/timestamps (update() stamps updatedAt).
|
||||||
name: next.name,
|
const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next;
|
||||||
ancestry: next.ancestry,
|
void charactersRepo.update(id, rest);
|
||||||
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);
|
}, 350);
|
||||||
|
|
||||||
const update = (patch: Partial<Character>) => {
|
const update = (patch: Partial<Character>) => {
|
||||||
@@ -192,13 +183,28 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Status & defenses (system-specific) */}
|
||||||
|
<DefensesSection c={c} update={update} />
|
||||||
|
|
||||||
|
{/* Attacks + passive perception */}
|
||||||
|
<AttacksSection c={c} update={update} />
|
||||||
|
|
||||||
|
{/* Spellcasting */}
|
||||||
|
<SpellcastingSection c={c} update={update} />
|
||||||
|
|
||||||
|
{/* Class resources + rest */}
|
||||||
|
<ResourcesSection c={c} update={update} />
|
||||||
|
|
||||||
|
{/* Inventory, currency, encumbrance */}
|
||||||
|
<InventorySection c={c} update={update} />
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
<section className="mb-6">
|
<section className="mb-6">
|
||||||
<SectionTitle>Notes</SectionTitle>
|
<SectionTitle>Notes</SectionTitle>
|
||||||
<textarea
|
<textarea
|
||||||
value={c.notes}
|
value={c.notes}
|
||||||
onChange={(e) => update({ notes: e.target.value })}
|
onChange={(e) => update({ notes: e.target.value })}
|
||||||
placeholder="Backstory, bonds, inventory, reminders…"
|
placeholder="Backstory, bonds, 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"
|
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>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { newId } from '@/lib/ids';
|
||||||
|
import { getSystem, ABILITY_ABBR } from '@/lib/rules';
|
||||||
|
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
||||||
|
import type { Attack } from '@/lib/schemas';
|
||||||
|
import { formatModifier } from '@/lib/format';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
|
import { SheetSection, type SectionProps } from './common';
|
||||||
|
|
||||||
|
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'];
|
||||||
|
|
||||||
|
export function AttacksSection({ c, update }: SectionProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const sys = getSystem(c.system);
|
||||||
|
const ranks = c.system === 'pf2e' ? RANKS_PF2E : RANKS_5E;
|
||||||
|
const rulesInput: CharacterRulesInput = { level: c.level, abilities: c.abilities };
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
if (name.trim() === '') return;
|
||||||
|
const atk: Attack = {
|
||||||
|
id: newId(), name: name.trim(), ability: 'str', rank: 'trained',
|
||||||
|
damageDice: '1d8', damageType: '', itemBonus: 0, addAbilityToDamage: true,
|
||||||
|
};
|
||||||
|
update({ attacks: [...c.attacks, atk] });
|
||||||
|
setName('');
|
||||||
|
};
|
||||||
|
const patch = (id: string, p: Partial<Attack>) =>
|
||||||
|
update({ attacks: c.attacks.map((a) => (a.id === id ? { ...a, ...p } : a)) });
|
||||||
|
const remove = (id: string) => update({ attacks: c.attacks.filter((a) => a.id !== id) });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SheetSection title="Attacks">
|
||||||
|
<p className="mb-2 text-sm text-muted">
|
||||||
|
Passive Perception <span className="font-display text-lg font-semibold text-accent">{sys.passivePerception({ ...rulesInput, perceptionRank: c.perceptionRank })}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-3 flex items-end gap-2">
|
||||||
|
<label className="flex-1 min-w-40 text-xs text-muted">
|
||||||
|
New attack
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Longsword, Shortbow…" />
|
||||||
|
</label>
|
||||||
|
<Button variant="primary" onClick={add}>Add</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{c.attacks.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No attacks defined.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{c.attacks.map((a) => {
|
||||||
|
const result = sys.weaponAttack(rulesInput, {
|
||||||
|
ability: a.ability, rank: a.rank, itemBonus: a.itemBonus,
|
||||||
|
damageDice: a.damageDice, addAbilityToDamage: a.addAbilityToDamage,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<li key={a.id} className="flex flex-wrap items-center gap-2 rounded-md border border-line bg-panel px-3 py-2 text-sm">
|
||||||
|
<Input className="h-8 min-w-28 flex-1" value={a.name} onChange={(e) => patch(a.id, { name: e.target.value })} aria-label="Attack name" />
|
||||||
|
<Select className="w-auto py-1" value={a.ability} onChange={(e) => patch(a.id, { ability: e.target.value as AbilityKey })} aria-label="Attack ability">
|
||||||
|
{ABILITIES.map((ab) => <option key={ab} value={ab}>{ABILITY_ABBR[ab]}</option>)}
|
||||||
|
</Select>
|
||||||
|
<Select className="w-auto py-1" value={a.rank} onChange={(e) => patch(a.id, { rank: e.target.value as ProficiencyRank })} aria-label="Attack proficiency">
|
||||||
|
{ranks.map((r) => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</Select>
|
||||||
|
<label className="text-xs text-muted">dice<Input className="ml-1 inline-block h-8 w-20" value={a.damageDice} onChange={(e) => patch(a.id, { damageDice: e.target.value })} aria-label="Damage dice" /></label>
|
||||||
|
<label className="text-xs text-muted">+item<NumberField className="ml-1 w-14" value={a.itemBonus} onChange={(v) => patch(a.id, { itemBonus: v })} aria-label="Item bonus" /></label>
|
||||||
|
<span className="ml-auto rounded bg-elevated px-2 py-1 text-sm">
|
||||||
|
<span className="text-muted">to hit </span><span className="font-display font-semibold text-accent">{formatModifier(result.toHit)}</span>
|
||||||
|
<span className="text-muted"> · dmg </span><span className="font-mono text-ink">{result.damage}{a.damageType ? ` ${a.damageType}` : ''}</span>
|
||||||
|
</span>
|
||||||
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(a.id)} aria-label={`Remove ${a.name}`}>✕</Button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</SheetSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
|
import { SheetSection, type SectionProps } from './common';
|
||||||
|
|
||||||
|
function Pips({ count, filled, onToggle, label }: { count: number; filled: number; onToggle: (n: number) => void; label: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1" role="group" aria-label={label}>
|
||||||
|
{Array.from({ length: count }, (_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => onToggle(i + 1 === filled ? i : i + 1)}
|
||||||
|
aria-label={`${label} ${i + 1}`}
|
||||||
|
aria-pressed={i < filled}
|
||||||
|
className={
|
||||||
|
'h-5 w-5 rounded-full border ' +
|
||||||
|
(i < filled ? 'border-accent bg-accent' : 'border-line bg-surface')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DefensesSection({ c, update }: SectionProps) {
|
||||||
|
const d = c.defenses;
|
||||||
|
const setD = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SheetSection title="Status & Defenses">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{c.system === '5e' ? (
|
||||||
|
<>
|
||||||
|
<Field label="Death Saves">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="flex items-center gap-1 text-xs text-success">
|
||||||
|
Successes
|
||||||
|
<Pips count={3} filled={d.deathSaves.successes} label="Death save success" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, successes: n } })} />
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-danger">
|
||||||
|
Failures
|
||||||
|
<Pips count={3} filled={d.deathSaves.failures} label="Death save failure" onToggle={(n) => setD({ deathSaves: { ...d.deathSaves, failures: n } })} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Inspiration">
|
||||||
|
<Button size="sm" variant={d.inspiration ? 'primary' : 'secondary'} onClick={() => setD({ inspiration: !d.inspiration })}>
|
||||||
|
{d.inspiration ? '★ Inspired' : 'Grant inspiration'}
|
||||||
|
</Button>
|
||||||
|
</Field>
|
||||||
|
<Field label="Exhaustion (0–6)">
|
||||||
|
<NumberField className="w-20" value={d.exhaustion} min={0} max={6} onChange={(v) => setD({ exhaustion: v })} aria-label="Exhaustion level" />
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Field label="Dying (0–4)">
|
||||||
|
<NumberField className="w-20" value={d.dying} min={0} max={4} onChange={(v) => setD({ dying: v })} aria-label="Dying value" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Wounded">
|
||||||
|
<NumberField className="w-20" value={d.wounded} min={0} onChange={(v) => setD({ wounded: v })} aria-label="Wounded value" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Doomed">
|
||||||
|
<NumberField className="w-20" value={d.doomed} min={0} onChange={(v) => setD({ doomed: v })} aria-label="Doomed value" />
|
||||||
|
</Field>
|
||||||
|
<Field label="Hero Points">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: Math.max(0, d.heroPoints - 1) })} aria-label="Spend hero point">−</Button>
|
||||||
|
<span className="font-display text-xl font-semibold text-accent">{d.heroPoints}</span>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => setD({ heroPoints: d.heroPoints + 1 })} aria-label="Gain hero point">+</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-line bg-panel px-3 py-2">
|
||||||
|
<div className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">{label}</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { newId } from '@/lib/ids';
|
||||||
|
import { getSystem } from '@/lib/rules';
|
||||||
|
import type { InventoryItem } from '@/lib/schemas';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
|
import { cn } from '@/lib/cn';
|
||||||
|
import { SheetSection, type SectionProps } from './common';
|
||||||
|
|
||||||
|
const COINS = [
|
||||||
|
{ key: 'pp', label: 'PP' },
|
||||||
|
{ key: 'gp', label: 'GP' },
|
||||||
|
{ key: 'ep', label: 'EP' },
|
||||||
|
{ key: 'sp', label: 'SP' },
|
||||||
|
{ key: 'cp', label: 'CP' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function InventorySection({ c, update }: SectionProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [qty, setQty] = useState(1);
|
||||||
|
const [weight, setWeight] = useState(0);
|
||||||
|
|
||||||
|
const sys = getSystem(c.system);
|
||||||
|
const capacity = sys.carryingCapacity({ level: c.level, abilities: c.abilities });
|
||||||
|
const totalWeight = c.inventory.reduce((sum, i) => sum + i.quantity * i.weight, 0);
|
||||||
|
const encumbered = totalWeight > capacity.encumbered;
|
||||||
|
const overMax = totalWeight > capacity.max;
|
||||||
|
|
||||||
|
const addItem = () => {
|
||||||
|
if (name.trim() === '') return;
|
||||||
|
const item: InventoryItem = {
|
||||||
|
id: newId(),
|
||||||
|
name: name.trim(),
|
||||||
|
quantity: qty,
|
||||||
|
weight,
|
||||||
|
equipped: false,
|
||||||
|
attuned: false,
|
||||||
|
description: '',
|
||||||
|
};
|
||||||
|
update({ inventory: [...c.inventory, item] });
|
||||||
|
setName('');
|
||||||
|
setQty(1);
|
||||||
|
setWeight(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const patchItem = (id: string, patch: Partial<InventoryItem>) => {
|
||||||
|
update({ inventory: c.inventory.map((i) => (i.id === id ? { ...i, ...patch } : i)) });
|
||||||
|
};
|
||||||
|
const removeItem = (id: string) => update({ inventory: c.inventory.filter((i) => i.id !== id) });
|
||||||
|
|
||||||
|
const setCoin = (key: (typeof COINS)[number]['key'], value: number) =>
|
||||||
|
update({ currency: { ...c.currency, [key]: value } });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SheetSection title="Inventory & Equipment">
|
||||||
|
{/* Currency */}
|
||||||
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
|
{COINS.filter((coin) => coin.key !== 'ep' || c.system === '5e').map((coin) => (
|
||||||
|
<label key={coin.key} className="flex items-center gap-1 rounded-md border border-line bg-panel px-2 py-1 text-xs text-muted">
|
||||||
|
{coin.label}
|
||||||
|
<NumberField
|
||||||
|
className="w-16"
|
||||||
|
value={c.currency[coin.key]}
|
||||||
|
min={0}
|
||||||
|
onChange={(v) => setCoin(coin.key, v)}
|
||||||
|
aria-label={`${coin.label} coins`}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Encumbrance */}
|
||||||
|
<div className={cn('mb-3 text-sm', overMax ? 'text-danger' : encumbered ? 'text-warning' : 'text-muted')}>
|
||||||
|
Carried: <span className="font-medium">{totalWeight.toLocaleString()}</span> / {capacity.max.toLocaleString()} {capacity.unit}
|
||||||
|
{overMax ? ' — over maximum!' : encumbered ? ' — encumbered' : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add item */}
|
||||||
|
<div className="mb-3 flex flex-wrap items-end gap-2">
|
||||||
|
<label className="flex-1 min-w-40 text-xs text-muted">
|
||||||
|
Item
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addItem()} placeholder="Longsword" />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-muted">
|
||||||
|
Qty
|
||||||
|
<NumberField className="w-16" value={qty} min={0} onChange={setQty} aria-label="Quantity" />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-muted">
|
||||||
|
Wt
|
||||||
|
<NumberField className="w-16" value={weight} min={0} onChange={setWeight} aria-label="Weight" />
|
||||||
|
</label>
|
||||||
|
<Button variant="primary" onClick={addItem}>Add</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item list */}
|
||||||
|
{c.inventory.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No items yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{c.inventory.map((item) => (
|
||||||
|
<li key={item.id} className="flex flex-wrap items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
|
||||||
|
<Input
|
||||||
|
className="h-8 min-w-32 flex-1"
|
||||||
|
value={item.name}
|
||||||
|
onChange={(e) => patchItem(item.id, { name: e.target.value })}
|
||||||
|
aria-label="Item name"
|
||||||
|
/>
|
||||||
|
<label className="text-xs text-muted">×<NumberField className="ml-1 w-14 inline-block" value={item.quantity} min={0} onChange={(v) => patchItem(item.id, { quantity: v })} aria-label="Quantity" /></label>
|
||||||
|
<label className="text-xs text-muted">{item.weight * item.quantity} {capacity.unit}</label>
|
||||||
|
<label className="flex items-center gap-1 text-xs text-muted">
|
||||||
|
<input type="checkbox" checked={item.equipped} onChange={(e) => patchItem(item.id, { equipped: e.target.checked })} />
|
||||||
|
Equipped
|
||||||
|
</label>
|
||||||
|
{c.system === '5e' && (
|
||||||
|
<label className="flex items-center gap-1 text-xs text-muted">
|
||||||
|
<input type="checkbox" checked={item.attuned} onChange={(e) => patchItem(item.id, { attuned: e.target.checked })} />
|
||||||
|
Attuned
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeItem(item.id)} aria-label={`Remove ${item.name}`}>✕</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</SheetSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { newId } from '@/lib/ids';
|
||||||
|
import { getSystem, applyRest } from '@/lib/rules';
|
||||||
|
import type { CharacterResource } from '@/lib/schemas';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
|
import { SheetSection, type SectionProps } from './common';
|
||||||
|
|
||||||
|
const RECOVERY_LABEL: Record<CharacterResource['recovery'], string> = {
|
||||||
|
short: 'Short rest',
|
||||||
|
long: 'Long rest',
|
||||||
|
daily: 'Daily',
|
||||||
|
none: 'Manual',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResourcesSection({ c, update }: SectionProps) {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const sys = getSystem(c.system);
|
||||||
|
|
||||||
|
const addResource = () => {
|
||||||
|
if (name.trim() === '') return;
|
||||||
|
const r: CharacterResource = { id: newId(), name: name.trim(), current: 1, max: 1, recovery: 'long' };
|
||||||
|
update({ resources: [...c.resources, r] });
|
||||||
|
setName('');
|
||||||
|
};
|
||||||
|
const patch = (id: string, p: Partial<CharacterResource>) =>
|
||||||
|
update({ resources: c.resources.map((r) => (r.id === id ? { ...r, ...p } : r)) });
|
||||||
|
const remove = (id: string) => update({ resources: c.resources.filter((r) => r.id !== id) });
|
||||||
|
|
||||||
|
const rest = (optId: string) => {
|
||||||
|
const opt = sys.restOptions.find((o) => o.id === optId);
|
||||||
|
if (opt) update(applyRest(c, opt));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SheetSection
|
||||||
|
title="Resources & Rest"
|
||||||
|
actions={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{sys.restOptions.map((o) => (
|
||||||
|
<Button key={o.id} size="sm" variant="secondary" onClick={() => rest(o.id)} title={`Restore: ${o.recovers.join(', ')}`}>
|
||||||
|
{o.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-end gap-2">
|
||||||
|
<label className="flex-1 min-w-40 text-xs text-muted">
|
||||||
|
New resource
|
||||||
|
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addResource()} placeholder="Ki Points, Rage, Focus…" />
|
||||||
|
</label>
|
||||||
|
<Button variant="primary" onClick={addResource}>Add</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{c.resources.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No tracked resources. Add rage, ki, sorcery points, focus points, etc.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{c.resources.map((r) => (
|
||||||
|
<li key={r.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-2">
|
||||||
|
<Input className="h-8 flex-1" value={r.name} onChange={(e) => patch(r.id, { name: e.target.value })} aria-label="Resource name" />
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => patch(r.id, { current: Math.max(0, r.current - 1) })} aria-label="Spend">−</Button>
|
||||||
|
<span className="w-12 text-center text-sm">
|
||||||
|
<span className="font-medium text-ink">{r.current}</span>
|
||||||
|
<span className="text-muted">/{r.max}</span>
|
||||||
|
</span>
|
||||||
|
<Button size="icon" variant="ghost" onClick={() => patch(r.id, { current: Math.min(r.max, r.current + 1) })} aria-label="Regain">+</Button>
|
||||||
|
<NumberField className="w-14" value={r.max} min={0} onChange={(v) => patch(r.id, { max: v, current: Math.min(v, r.current) })} aria-label="Max" />
|
||||||
|
<Select className="w-auto py-1 text-xs" value={r.recovery} onChange={(e) => patch(r.id, { recovery: e.target.value as CharacterResource['recovery'] })}>
|
||||||
|
{(['short', 'long', 'daily', 'none'] as const).map((k) => (
|
||||||
|
<option key={k} value={k}>{RECOVERY_LABEL[k]}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(r.id)} aria-label={`Remove ${r.name}`}>✕</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</SheetSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { newId } from '@/lib/ids';
|
||||||
|
import { getSystem } from '@/lib/rules';
|
||||||
|
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
|
||||||
|
import type { SpellEntry } from '@/lib/schemas';
|
||||||
|
import { formatModifier } from '@/lib/format';
|
||||||
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
|
import { SheetSection, type SectionProps } from './common';
|
||||||
|
|
||||||
|
const CASTING_ABILITIES: { key: AbilityKey; label: string }[] = [
|
||||||
|
{ key: 'int', label: 'Intelligence' },
|
||||||
|
{ key: 'wis', label: 'Wisdom' },
|
||||||
|
{ key: 'cha', label: 'Charisma' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SpellcastingSection({ c, update }: SectionProps) {
|
||||||
|
const [spellName, setSpellName] = useState('');
|
||||||
|
const [spellLevel, setSpellLevel] = useState(0);
|
||||||
|
const sys = getSystem(c.system);
|
||||||
|
const sc = c.spellcasting;
|
||||||
|
|
||||||
|
const ability = c.spellcastingAbility;
|
||||||
|
const rulesInput: CharacterRulesInput = {
|
||||||
|
level: c.level,
|
||||||
|
abilities: c.abilities,
|
||||||
|
...(c.spellcastingRank ? { spellcastingRank: c.spellcastingRank as ProficiencyRank } : {}),
|
||||||
|
};
|
||||||
|
const dc = ability ? sys.spellSaveDc?.(rulesInput, ability) : undefined;
|
||||||
|
const atk = ability ? sys.spellAttackBonus(rulesInput, ability) : undefined;
|
||||||
|
|
||||||
|
const setSlots = (slots: typeof sc.slots) => update({ spellcasting: { ...sc, slots } });
|
||||||
|
const patchSlot = (level: number, p: Partial<(typeof sc.slots)[number]>) =>
|
||||||
|
setSlots(sc.slots.map((s) => (s.level === level ? { ...s, ...p } : s)));
|
||||||
|
const addSlotLevel = () => {
|
||||||
|
const next = (sc.slots.at(-1)?.level ?? 0) + 1;
|
||||||
|
if (next > 9) return;
|
||||||
|
setSlots([...sc.slots, { level: next, max: 1, current: 1 }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSpell = () => {
|
||||||
|
if (spellName.trim() === '') return;
|
||||||
|
const entry: SpellEntry = { id: newId(), name: spellName.trim(), level: spellLevel, prepared: false, notes: '' };
|
||||||
|
update({ spellcasting: { ...sc, spells: [...sc.spells, entry] } });
|
||||||
|
setSpellName('');
|
||||||
|
};
|
||||||
|
const patchSpell = (id: string, p: Partial<SpellEntry>) =>
|
||||||
|
update({ spellcasting: { ...sc, spells: sc.spells.map((s) => (s.id === id ? { ...s, ...p } : s)) } });
|
||||||
|
const removeSpell = (id: string) =>
|
||||||
|
update({ spellcasting: { ...sc, spells: sc.spells.filter((s) => s.id !== id) } });
|
||||||
|
|
||||||
|
const spellsByLevel = [...sc.spells].sort((a, b) => a.level - b.level || a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SheetSection title="Spellcasting">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-3">
|
||||||
|
<label className="text-xs text-muted">
|
||||||
|
Casting ability
|
||||||
|
<Select
|
||||||
|
className="ml-2 w-auto"
|
||||||
|
value={ability ?? ''}
|
||||||
|
onChange={(e) => update({ spellcastingAbility: (e.target.value || undefined) as typeof c.spellcastingAbility })}
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
{CASTING_ABILITIES.map((a) => (
|
||||||
|
<option key={a.key} value={a.key}>{a.label}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
{ability && (
|
||||||
|
<div className="flex gap-4 text-sm">
|
||||||
|
<span className="text-muted">Spell DC <span className="font-display text-lg font-semibold text-accent">{dc}</span></span>
|
||||||
|
<span className="text-muted">Attack <span className="font-display text-lg font-semibold text-accent">{atk !== undefined ? formatModifier(atk) : '—'}</span></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spell slots */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="mb-1 flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-muted">Spell slots</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={addSlotLevel}>+ slot level</Button>
|
||||||
|
</div>
|
||||||
|
{sc.slots.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No spell slots configured.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{sc.slots.map((s) => (
|
||||||
|
<div key={s.level} className="rounded-md border border-line bg-panel px-2 py-1 text-center">
|
||||||
|
<div className="text-[10px] uppercase text-muted">Lv {s.level}</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.max(0, s.current - 1) })} aria-label={`Spend level ${s.level} slot`}>−</Button>
|
||||||
|
<span className="text-sm"><span className="font-medium text-ink">{s.current}</span>/<NumberField className="inline-block w-12" value={s.max} min={0} onChange={(v) => patchSlot(s.level, { max: v, current: Math.min(v, s.current) })} aria-label={`Level ${s.level} max slots`} /></span>
|
||||||
|
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => patchSlot(s.level, { current: Math.min(s.max, s.current + 1) })} aria-label={`Regain level ${s.level} slot`}>+</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spell list */}
|
||||||
|
<div className="mb-2 flex items-end gap-2">
|
||||||
|
<label className="flex-1 min-w-40 text-xs text-muted">
|
||||||
|
Add spell
|
||||||
|
<Input value={spellName} onChange={(e) => setSpellName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addSpell()} placeholder="Fireball" />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-muted">
|
||||||
|
Level
|
||||||
|
<Select className="w-20" value={spellLevel} onChange={(e) => setSpellLevel(Number(e.target.value))}>
|
||||||
|
<option value={0}>Cantrip</option>
|
||||||
|
{Array.from({ length: 9 }, (_, i) => i + 1).map((l) => (
|
||||||
|
<option key={l} value={l}>{l}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</label>
|
||||||
|
<Button variant="primary" onClick={addSpell}>Add</Button>
|
||||||
|
</div>
|
||||||
|
{spellsByLevel.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted">No spells added.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{spellsByLevel.map((s) => (
|
||||||
|
<li key={s.id} className="flex items-center gap-2 rounded-md border border-line bg-panel px-3 py-1.5 text-sm">
|
||||||
|
<span className="w-16 text-xs text-muted">{s.level === 0 ? 'Cantrip' : `Lv ${s.level}`}</span>
|
||||||
|
<span className="flex-1 text-ink">{s.name}</span>
|
||||||
|
{s.level > 0 && (
|
||||||
|
<label className="flex items-center gap-1 text-xs text-muted">
|
||||||
|
<input type="checkbox" checked={s.prepared} onChange={(e) => patchSpell(s.id, { prepared: e.target.checked })} />
|
||||||
|
Prepared
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<Button size="icon" variant="ghost" className="text-danger" onClick={() => removeSpell(s.id)} aria-label={`Remove ${s.name}`}>✕</Button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</SheetSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import type { Character } from '@/lib/schemas';
|
||||||
|
|
||||||
|
/** Shared props for every character-sheet section. `update` merges a shallow patch. */
|
||||||
|
export interface SectionProps {
|
||||||
|
c: Character;
|
||||||
|
update: (patch: Partial<Character>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SheetSection({
|
||||||
|
title,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="mb-6">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h2 className="font-display text-lg font-semibold text-ink">{title}</h2>
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { Campaign } from '@/lib/schemas/campaign';
|
|||||||
import type { Character } from '@/lib/schemas/character';
|
import type { Character } from '@/lib/schemas/character';
|
||||||
import type { Encounter } from '@/lib/schemas/encounter';
|
import type { Encounter } from '@/lib/schemas/encounter';
|
||||||
import type { DiceRoll } from '@/lib/schemas/dice';
|
import type { DiceRoll } from '@/lib/schemas/dice';
|
||||||
|
import { characterDefaults } from '@/lib/schemas/character';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single Dexie instance for the whole app. Dexie wraps IndexedDB transactions
|
* Single Dexie instance for the whole app. Dexie wraps IndexedDB transactions
|
||||||
@@ -26,6 +27,18 @@ export class TtrpgDatabase extends Dexie {
|
|||||||
encounters: 'id, campaignId, status',
|
encounters: 'id, campaignId, status',
|
||||||
diceRolls: 'id, campaignId, createdAt',
|
diceRolls: 'id, campaignId, createdAt',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// v2 — Phase 1 character depth: backfill new fields on existing characters.
|
||||||
|
this.version(2).stores({}).upgrade(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.table('characters')
|
||||||
|
.toCollection()
|
||||||
|
.modify((c: Record<string, unknown>) => {
|
||||||
|
for (const [key, value] of Object.entries(characterDefaults())) {
|
||||||
|
if (c[key] === undefined) c[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ export function defaultAbilityScores(): AbilityScores {
|
|||||||
return { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
|
return { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compose a damage expression from dice and a flat modifier, e.g. ("1d8", 3) -> "1d8+3". */
|
||||||
|
export function formatDamage(dice: string | undefined, mod: number): string {
|
||||||
|
const base = dice && dice.trim() !== '' ? dice.trim() : '0';
|
||||||
|
if (mod === 0) return base;
|
||||||
|
return mod > 0 ? `${base}+${mod}` : `${base}${mod}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const ABILITY_LABELS: Record<AbilityKey, string> = {
|
export const ABILITY_LABELS: Record<AbilityKey, string> = {
|
||||||
str: 'Strength',
|
str: 'Strength',
|
||||||
dex: 'Dexterity',
|
dex: 'Dexterity',
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { dnd5e } from './dnd5e';
|
||||||
|
import { pf2e } from './pf2e';
|
||||||
|
import { applyRest } from './rest';
|
||||||
|
import type { CharacterRulesInput } from './types';
|
||||||
|
import type { Character } from '@/lib/schemas/character';
|
||||||
|
import { characterDefaults } from '@/lib/schemas/character';
|
||||||
|
|
||||||
|
const fighter5e: CharacterRulesInput = {
|
||||||
|
level: 5,
|
||||||
|
abilities: { str: 18, dex: 14, con: 16, int: 10, wis: 12, cha: 8 },
|
||||||
|
skillRanks: { perception: 'trained' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('5e derived combat math', () => {
|
||||||
|
it('weapon attack: ability + proficiency + item, ability added to damage', () => {
|
||||||
|
const r = dnd5e.weaponAttack(fighter5e, { ability: 'str', rank: 'trained', damageDice: '1d8', itemBonus: 1 });
|
||||||
|
expect(r.toHit).toBe(4 + 3 + 1); // STR +4, PB +3, item +1
|
||||||
|
expect(r.damage).toBe('1d8+5'); // STR +4 + item +1
|
||||||
|
});
|
||||||
|
|
||||||
|
it('off-hand damage omits the ability modifier', () => {
|
||||||
|
const r = dnd5e.weaponAttack(fighter5e, { ability: 'str', rank: 'trained', damageDice: '1d6', addAbilityToDamage: false });
|
||||||
|
expect(r.damage).toBe('1d6');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spell attack and passive perception', () => {
|
||||||
|
expect(dnd5e.spellAttackBonus(fighter5e, 'wis')).toBe(3 + 1);
|
||||||
|
expect(dnd5e.passivePerception(fighter5e)).toBe(10 + 1 + 3); // 10 + WIS + PB
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carrying capacity is STR*15', () => {
|
||||||
|
expect(dnd5e.carryingCapacity(fighter5e)).toEqual({ encumbered: 90, max: 270, unit: 'lb' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('pf2e derived combat math', () => {
|
||||||
|
const pcg: CharacterRulesInput = {
|
||||||
|
level: 3,
|
||||||
|
abilities: { str: 18, dex: 12, con: 14, int: 10, wis: 14, cha: 10 },
|
||||||
|
perceptionRank: 'expert',
|
||||||
|
spellcastingRank: 'trained',
|
||||||
|
};
|
||||||
|
it('weapon attack adds level + rank bonus', () => {
|
||||||
|
const r = pf2e.weaponAttack(pcg, { ability: 'str', rank: 'trained', damageDice: '1d8' });
|
||||||
|
expect(r.toHit).toBe(4 + (3 + 2)); // STR +4, trained = level3 + 2
|
||||||
|
expect(r.damage).toBe('1d8+4');
|
||||||
|
});
|
||||||
|
it('passive perception uses perception proficiency', () => {
|
||||||
|
expect(pf2e.passivePerception(pcg)).toBe(10 + 2 + (3 + 4)); // 10 + WIS + (level + expert)
|
||||||
|
});
|
||||||
|
it('carrying capacity is Bulk', () => {
|
||||||
|
expect(pf2e.carryingCapacity(pcg)).toEqual({ encumbered: 5 + 4, max: 10 + 4, unit: 'Bulk' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeCharacter(): Character {
|
||||||
|
return {
|
||||||
|
id: 'c', campaignId: 'camp', system: '5e', kind: 'pc',
|
||||||
|
name: 'Test', ancestry: '', className: 'Wizard', level: 5,
|
||||||
|
abilities: { str: 8, dex: 14, con: 12, int: 16, wis: 10, cha: 10 },
|
||||||
|
hp: { current: 4, max: 30, temp: 0 }, speed: 30, armorBonus: 0,
|
||||||
|
skillRanks: {}, saveRanks: {}, perceptionRank: 'trained',
|
||||||
|
...characterDefaults(),
|
||||||
|
resources: [
|
||||||
|
{ id: 'r1', name: 'Sorcery Points', current: 0, max: 5, recovery: 'long' },
|
||||||
|
{ id: 'r2', name: 'Channel Divinity', current: 0, max: 1, recovery: 'short' },
|
||||||
|
],
|
||||||
|
spellcasting: {
|
||||||
|
slots: [{ level: 1, max: 4, current: 0 }, { level: 2, max: 3, current: 1 }],
|
||||||
|
pact: { level: 1, max: 2, current: 0 },
|
||||||
|
spells: [],
|
||||||
|
},
|
||||||
|
defenses: { ...characterDefaults().defenses, exhaustion: 2 },
|
||||||
|
notes: '', createdAt: '', updatedAt: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('applyRest', () => {
|
||||||
|
it('short rest restores short resources + pact, not HP/slots/long resources', () => {
|
||||||
|
const c = makeCharacter();
|
||||||
|
const short = dnd5e.restOptions.find((o) => o.id === 'short')!;
|
||||||
|
const patch = applyRest(c, short);
|
||||||
|
expect(patch.hp).toBeUndefined();
|
||||||
|
expect(patch.resources!.find((r) => r.id === 'r2')!.current).toBe(1); // short resource restored
|
||||||
|
expect(patch.resources!.find((r) => r.id === 'r1')!.current).toBe(0); // long resource untouched
|
||||||
|
expect(patch.spellcasting!.pact!.current).toBe(2); // pact restored
|
||||||
|
expect(patch.spellcasting!.slots[0]!.current).toBe(0); // slots NOT restored on short rest
|
||||||
|
});
|
||||||
|
|
||||||
|
it('long rest restores HP, all slots, all resources, steps exhaustion down, clears death saves', () => {
|
||||||
|
const c = makeCharacter();
|
||||||
|
const long = dnd5e.restOptions.find((o) => o.id === 'long')!;
|
||||||
|
const patch = applyRest(c, long);
|
||||||
|
expect(patch.hp!.current).toBe(30);
|
||||||
|
expect(patch.resources!.every((r) => r.current === r.max)).toBe(true);
|
||||||
|
expect(patch.spellcasting!.slots.every((s) => s.current === s.max)).toBe(true);
|
||||||
|
expect(patch.defenses!.exhaustion).toBe(1);
|
||||||
|
expect(patch.defenses!.deathSaves).toEqual({ successes: 0, failures: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import type {
|
|||||||
CharacterRulesInput,
|
CharacterRulesInput,
|
||||||
DerivedSkill,
|
DerivedSkill,
|
||||||
ProficiencyRank,
|
ProficiencyRank,
|
||||||
|
RestOption,
|
||||||
RulesSystem,
|
RulesSystem,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { ABILITY_KEYS } from '../types';
|
import { ABILITY_KEYS } from '../types';
|
||||||
@@ -11,9 +12,15 @@ import {
|
|||||||
ABILITY_LABELS,
|
ABILITY_LABELS,
|
||||||
abilityModifier,
|
abilityModifier,
|
||||||
defaultAbilityScores,
|
defaultAbilityScores,
|
||||||
|
formatDamage,
|
||||||
} from '../abilities';
|
} from '../abilities';
|
||||||
import { DND5E_SKILLS } from './skills';
|
import { DND5E_SKILLS } from './skills';
|
||||||
|
|
||||||
|
const DND5E_RESTS: readonly RestOption[] = [
|
||||||
|
{ id: 'short', label: 'Short Rest', restoresHp: false, restoresSlots: false, restoresPact: true, recovers: ['short'] },
|
||||||
|
{ id: 'long', label: 'Long Rest', restoresHp: true, restoresSlots: true, restoresPact: true, recovers: ['short', 'long'], reduceExhaustion: true },
|
||||||
|
];
|
||||||
|
|
||||||
/** Proficiency bonus by character level (PHB table): +2 at 1, +6 at 17. */
|
/** Proficiency bonus by character level (PHB table): +2 at 1, +6 at 17. */
|
||||||
export function proficiencyBonus(level: number): number {
|
export function proficiencyBonus(level: number): number {
|
||||||
const lvl = Math.max(1, Math.min(20, Math.floor(level) || 1));
|
const lvl = Math.max(1, Math.min(20, Math.floor(level) || 1));
|
||||||
@@ -79,6 +86,30 @@ export const dnd5e: RulesSystem = {
|
|||||||
spellSaveDc(input, ability) {
|
spellSaveDc(input, ability) {
|
||||||
return 8 + proficiencyBonus(input.level) + abilityModifier(input.abilities[ability]);
|
return 8 + proficiencyBonus(input.level) + abilityModifier(input.abilities[ability]);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
spellAttackBonus(input, ability) {
|
||||||
|
return proficiencyBonus(input.level) + abilityModifier(input.abilities[ability]);
|
||||||
|
},
|
||||||
|
|
||||||
|
passivePerception(input) {
|
||||||
|
const rank = input.skillRanks?.['perception'] ?? 'untrained';
|
||||||
|
return 10 + abilityModifier(input.abilities.wis) + proficiencyBonus(input.level) * profMultiplier(rank);
|
||||||
|
},
|
||||||
|
|
||||||
|
weaponAttack(input, weapon) {
|
||||||
|
const abilityMod = abilityModifier(input.abilities[weapon.ability]);
|
||||||
|
const item = weapon.itemBonus ?? 0;
|
||||||
|
const toHit = abilityMod + proficiencyBonus(input.level) * profMultiplier(weapon.rank) + item;
|
||||||
|
const dmgMod = (weapon.addAbilityToDamage !== false ? abilityMod : 0) + item;
|
||||||
|
return { toHit, damage: formatDamage(weapon.damageDice, dmgMod) };
|
||||||
|
},
|
||||||
|
|
||||||
|
carryingCapacity(input) {
|
||||||
|
const str = input.abilities.str;
|
||||||
|
return { encumbered: str * 5, max: str * 15, unit: 'lb' };
|
||||||
|
},
|
||||||
|
|
||||||
|
restOptions: DND5E_RESTS,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ABILITY_ABBR };
|
export { ABILITY_ABBR };
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ export const SYSTEM_OPTIONS: { id: SystemId; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS } from './abilities';
|
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
|
||||||
|
export { applyRest } from './rest';
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ import type {
|
|||||||
CharacterRulesInput,
|
CharacterRulesInput,
|
||||||
DerivedSkill,
|
DerivedSkill,
|
||||||
ProficiencyRank,
|
ProficiencyRank,
|
||||||
|
RestOption,
|
||||||
RulesSystem,
|
RulesSystem,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { ABILITY_KEYS } from '../types';
|
import { ABILITY_KEYS } from '../types';
|
||||||
import { ABILITY_LABELS, abilityModifier, defaultAbilityScores } from '../abilities';
|
import { ABILITY_LABELS, abilityModifier, defaultAbilityScores, formatDamage } from '../abilities';
|
||||||
import { PF2E_SKILLS } from './skills';
|
import { PF2E_SKILLS } from './skills';
|
||||||
|
|
||||||
|
const PF2E_RESTS: readonly RestOption[] = [
|
||||||
|
// Refocus restores 1 Focus Point (short-recovery resources).
|
||||||
|
{ id: 'refocus', label: 'Refocus', restoresHp: false, restoresSlots: false, restoresPact: false, recovers: ['short'] },
|
||||||
|
// A full night's rest restores HP, spell slots, and daily resources.
|
||||||
|
{ id: 'rest', label: 'Rest for the Night', restoresHp: true, restoresSlots: true, restoresPact: true, recovers: ['long', 'daily'] },
|
||||||
|
];
|
||||||
|
|
||||||
const RANK_BONUS: Record<ProficiencyRank, number> = {
|
const RANK_BONUS: Record<ProficiencyRank, number> = {
|
||||||
untrained: 0,
|
untrained: 0,
|
||||||
trained: 2,
|
trained: 2,
|
||||||
@@ -75,4 +83,28 @@ export const pf2e: RulesSystem = {
|
|||||||
spellSaveDc(input, ability) {
|
spellSaveDc(input, ability) {
|
||||||
return 10 + abilityModifier(input.abilities[ability]) + pf2eProficiency(input.level, input.spellcastingRank ?? 'trained');
|
return 10 + abilityModifier(input.abilities[ability]) + pf2eProficiency(input.level, input.spellcastingRank ?? 'trained');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
spellAttackBonus(input, ability) {
|
||||||
|
return abilityModifier(input.abilities[ability]) + pf2eProficiency(input.level, input.spellcastingRank ?? 'trained');
|
||||||
|
},
|
||||||
|
|
||||||
|
passivePerception(input) {
|
||||||
|
return 10 + abilityModifier(input.abilities.wis) + pf2eProficiency(input.level, input.perceptionRank ?? 'trained');
|
||||||
|
},
|
||||||
|
|
||||||
|
weaponAttack(input, weapon) {
|
||||||
|
const abilityMod = abilityModifier(input.abilities[weapon.ability]);
|
||||||
|
const item = weapon.itemBonus ?? 0;
|
||||||
|
const toHit = abilityMod + pf2eProficiency(input.level, weapon.rank) + item;
|
||||||
|
const dmgMod = (weapon.addAbilityToDamage !== false ? abilityMod : 0) + item;
|
||||||
|
return { toHit, damage: formatDamage(weapon.damageDice, dmgMod) };
|
||||||
|
},
|
||||||
|
|
||||||
|
carryingCapacity(input) {
|
||||||
|
// PF2e uses Bulk: encumbered at 5 + STR mod, max 10 + STR mod.
|
||||||
|
const strMod = abilityModifier(input.abilities.str);
|
||||||
|
return { encumbered: 5 + strMod, max: 10 + strMod, unit: 'Bulk' };
|
||||||
|
},
|
||||||
|
|
||||||
|
restOptions: PF2E_RESTS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Character } from '@/lib/schemas/character';
|
||||||
|
import type { RestOption } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the character changes a rest produces. Pure — returns a partial patch
|
||||||
|
* the caller persists. Correctly distinguishes short vs long recovery, refreshes
|
||||||
|
* spell slots / pact magic / resources by their recovery tag, and resets 5e death
|
||||||
|
* saves + steps down exhaustion on a full rest. (The old app's short rest just
|
||||||
|
* full-healed, ignoring all of this.)
|
||||||
|
*/
|
||||||
|
export function applyRest(c: Character, opt: RestOption): Partial<Character> {
|
||||||
|
const patch: Partial<Character> = {};
|
||||||
|
|
||||||
|
if (opt.restoresHp) {
|
||||||
|
patch.hp = { ...c.hp, current: c.hp.max };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resources refresh when their recovery tag is covered by this rest.
|
||||||
|
patch.resources = c.resources.map((r) =>
|
||||||
|
opt.recovers.includes(r.recovery) ? { ...r, current: r.max } : r,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (opt.restoresSlots || opt.restoresPact) {
|
||||||
|
patch.spellcasting = {
|
||||||
|
...c.spellcasting,
|
||||||
|
slots: opt.restoresSlots
|
||||||
|
? c.spellcasting.slots.map((s) => ({ ...s, current: s.max }))
|
||||||
|
: c.spellcasting.slots,
|
||||||
|
...(c.spellcasting.pact
|
||||||
|
? {
|
||||||
|
pact: opt.restoresPact
|
||||||
|
? { ...c.spellcasting.pact, current: c.spellcasting.pact.max }
|
||||||
|
: c.spellcasting.pact,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const exhaustion =
|
||||||
|
opt.reduceExhaustion && c.defenses.exhaustion > 0
|
||||||
|
? c.defenses.exhaustion - 1
|
||||||
|
: c.defenses.exhaustion;
|
||||||
|
|
||||||
|
if (opt.restoresHp || exhaustion !== c.defenses.exhaustion) {
|
||||||
|
patch.defenses = {
|
||||||
|
...c.defenses,
|
||||||
|
exhaustion,
|
||||||
|
// recovering from 0 HP clears death saves (5e)
|
||||||
|
...(opt.restoresHp ? { deathSaves: { successes: 0, failures: 0 } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
@@ -21,6 +21,48 @@ export interface DerivedSkill {
|
|||||||
modifier: number;
|
modifier: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Input describing a single weapon/attack for derived to-hit + damage. */
|
||||||
|
export interface WeaponInput {
|
||||||
|
/** ability the attack uses (str for melee, dex for finesse/ranged) */
|
||||||
|
ability: AbilityKey;
|
||||||
|
/** proficiency in the weapon */
|
||||||
|
rank: ProficiencyRank;
|
||||||
|
/** flat magic/enhancement bonus to hit and damage */
|
||||||
|
itemBonus?: number;
|
||||||
|
/** damage dice, e.g. "1d8" */
|
||||||
|
damageDice?: string;
|
||||||
|
/** add the ability modifier to damage (off-hand/some ranged turn this off) */
|
||||||
|
addAbilityToDamage?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeaponResult {
|
||||||
|
toHit: number;
|
||||||
|
/** ready-to-roll damage expression, e.g. "1d8+3" */
|
||||||
|
damage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CarryingCapacity {
|
||||||
|
/** at/over this, the character is encumbered */
|
||||||
|
encumbered: number;
|
||||||
|
/** absolute maximum carry */
|
||||||
|
max: number;
|
||||||
|
unit: 'lb' | 'Bulk';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A rest/recovery option and what it restores. */
|
||||||
|
export interface RestOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
restoresHp: boolean;
|
||||||
|
restoresSlots: boolean;
|
||||||
|
restoresPact: boolean;
|
||||||
|
/** resource.recovery tags that refresh */
|
||||||
|
recovers: readonly Recovery[];
|
||||||
|
reduceExhaustion?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Recovery = 'short' | 'long' | 'daily' | 'none';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The contract every supported game system implements. The UI and stores talk
|
* The contract every supported game system implements. The UI and stores talk
|
||||||
* to this interface only — no `if (system === '5e')` scattered through features.
|
* to this interface only — no `if (system === '5e')` scattered through features.
|
||||||
@@ -55,6 +97,21 @@ export interface RulesSystem {
|
|||||||
|
|
||||||
/** Spellcasting save DC for the given casting ability, if any. */
|
/** Spellcasting save DC for the given casting ability, if any. */
|
||||||
spellSaveDc?(input: CharacterRulesInput, ability: AbilityKey): number;
|
spellSaveDc?(input: CharacterRulesInput, ability: AbilityKey): number;
|
||||||
|
|
||||||
|
/** Spell attack bonus for the given casting ability. */
|
||||||
|
spellAttackBonus(input: CharacterRulesInput, ability: AbilityKey): number;
|
||||||
|
|
||||||
|
/** Passive Perception (5e) / Perception DC basis (PF2e): 10 + perception mod. */
|
||||||
|
passivePerception(input: CharacterRulesInput): number;
|
||||||
|
|
||||||
|
/** To-hit + damage for a weapon attack. */
|
||||||
|
weaponAttack(input: CharacterRulesInput, weapon: WeaponInput): WeaponResult;
|
||||||
|
|
||||||
|
/** Carry limits, in the system's native unit. */
|
||||||
|
carryingCapacity(input: CharacterRulesInput): CarryingCapacity;
|
||||||
|
|
||||||
|
/** Rest/recovery options this system offers (short/long, refocus/rest, …). */
|
||||||
|
restOptions: readonly RestOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Minimal character shape the rules layer needs to compute derived values. */
|
/** Minimal character shape the rules layer needs to compute derived values. */
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
abilityScoresSchema,
|
abilityScoresSchema,
|
||||||
|
conditionSchema,
|
||||||
|
currencySchema,
|
||||||
hpSchema,
|
hpSchema,
|
||||||
int,
|
int,
|
||||||
proficiencyRankSchema,
|
proficiencyRankSchema,
|
||||||
@@ -9,6 +11,86 @@ import {
|
|||||||
|
|
||||||
export const characterKindSchema = z.enum(['pc', 'npc']);
|
export const characterKindSchema = z.enum(['pc', 'npc']);
|
||||||
|
|
||||||
|
export const inventoryItemSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().min(1).max(120),
|
||||||
|
quantity: int.min(0).default(1),
|
||||||
|
/** weight per unit, in lbs (5e) / Bulk*10 abstracted (pf2e) */
|
||||||
|
weight: z.number().finite().nonnegative().default(0),
|
||||||
|
equipped: z.boolean().default(false),
|
||||||
|
attuned: z.boolean().default(false),
|
||||||
|
description: z.string().max(4000).default(''),
|
||||||
|
/** link back to a compendium item slug */
|
||||||
|
compendiumRef: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type InventoryItem = z.infer<typeof inventoryItemSchema>;
|
||||||
|
|
||||||
|
/** When a tracked resource refreshes. */
|
||||||
|
export const recoverySchema = z.enum(['short', 'long', 'daily', 'none']);
|
||||||
|
|
||||||
|
export const resourceSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().min(1).max(80),
|
||||||
|
current: int.min(0).default(0),
|
||||||
|
max: int.min(0).default(0),
|
||||||
|
recovery: recoverySchema.default('long'),
|
||||||
|
});
|
||||||
|
export type CharacterResource = z.infer<typeof resourceSchema>;
|
||||||
|
|
||||||
|
export const spellEntrySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().min(1).max(120),
|
||||||
|
/** 0 = cantrip */
|
||||||
|
level: int.min(0).max(10).default(0),
|
||||||
|
prepared: z.boolean().default(false),
|
||||||
|
compendiumRef: z.string().optional(),
|
||||||
|
notes: z.string().max(2000).default(''),
|
||||||
|
});
|
||||||
|
export type SpellEntry = z.infer<typeof spellEntrySchema>;
|
||||||
|
|
||||||
|
export const spellSlotSchema = z.object({
|
||||||
|
level: int.min(1).max(10),
|
||||||
|
max: int.min(0).default(0),
|
||||||
|
current: int.min(0).default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const spellcastingSchema = z.object({
|
||||||
|
slots: z.array(spellSlotSchema).default([]),
|
||||||
|
/** warlock-style pact magic, refreshes on short rest */
|
||||||
|
pact: spellSlotSchema.optional(),
|
||||||
|
spells: z.array(spellEntrySchema).default([]),
|
||||||
|
});
|
||||||
|
export type Spellcasting = z.infer<typeof spellcastingSchema>;
|
||||||
|
|
||||||
|
export const abilityKeySchema = z.enum(['str', 'dex', 'con', 'int', 'wis', 'cha']);
|
||||||
|
|
||||||
|
export const attackSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().min(1).max(120),
|
||||||
|
ability: abilityKeySchema.default('str'),
|
||||||
|
rank: proficiencyRankSchema.default('trained'),
|
||||||
|
damageDice: z.string().max(40).default('1d6'),
|
||||||
|
damageType: z.string().max(40).default(''),
|
||||||
|
itemBonus: int.default(0),
|
||||||
|
addAbilityToDamage: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
export type Attack = z.infer<typeof attackSchema>;
|
||||||
|
|
||||||
|
export const defensesSchema = z.object({
|
||||||
|
// 5e
|
||||||
|
deathSaves: z
|
||||||
|
.object({ successes: int.min(0).max(3).default(0), failures: int.min(0).max(3).default(0) })
|
||||||
|
.default({ successes: 0, failures: 0 }),
|
||||||
|
inspiration: z.boolean().default(false),
|
||||||
|
exhaustion: int.min(0).max(6).default(0),
|
||||||
|
// pf2e
|
||||||
|
dying: int.min(0).max(4).default(0),
|
||||||
|
wounded: int.min(0).default(0),
|
||||||
|
doomed: int.min(0).default(0),
|
||||||
|
heroPoints: int.min(0).default(0),
|
||||||
|
});
|
||||||
|
export type Defenses = z.infer<typeof defensesSchema>;
|
||||||
|
|
||||||
export const characterSchema = z.object({
|
export const characterSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
campaignId: z.string(),
|
campaignId: z.string(),
|
||||||
@@ -33,6 +115,23 @@ export const characterSchema = z.object({
|
|||||||
spellcastingRank: proficiencyRankSchema.optional(),
|
spellcastingRank: proficiencyRankSchema.optional(),
|
||||||
spellcastingAbility: z.enum(['int', 'wis', 'cha']).optional(),
|
spellcastingAbility: z.enum(['int', 'wis', 'cha']).optional(),
|
||||||
|
|
||||||
|
// --- Phase 1: character depth ---
|
||||||
|
currency: currencySchema.default({ cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 }),
|
||||||
|
inventory: z.array(inventoryItemSchema).default([]),
|
||||||
|
attacks: z.array(attackSchema).default([]),
|
||||||
|
resources: z.array(resourceSchema).default([]),
|
||||||
|
spellcasting: spellcastingSchema.default({ slots: [], spells: [] }),
|
||||||
|
defenses: defensesSchema.default({
|
||||||
|
deathSaves: { successes: 0, failures: 0 },
|
||||||
|
inspiration: false,
|
||||||
|
exhaustion: 0,
|
||||||
|
dying: 0,
|
||||||
|
wounded: 0,
|
||||||
|
doomed: 0,
|
||||||
|
heroPoints: 0,
|
||||||
|
}),
|
||||||
|
conditions: z.array(conditionSchema).default([]),
|
||||||
|
|
||||||
notes: z.string().max(20000).default(''),
|
notes: z.string().max(20000).default(''),
|
||||||
|
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
@@ -49,3 +148,27 @@ export const characterDraftSchema = characterSchema.pick({
|
|||||||
level: true,
|
level: true,
|
||||||
});
|
});
|
||||||
export type CharacterDraft = z.infer<typeof characterDraftSchema>;
|
export type CharacterDraft = z.infer<typeof characterDraftSchema>;
|
||||||
|
|
||||||
|
/** Default values for the Phase-1 depth fields. Used by create + DB migration. */
|
||||||
|
export function characterDefaults(): Pick<
|
||||||
|
Character,
|
||||||
|
'currency' | 'inventory' | 'attacks' | 'resources' | 'spellcasting' | 'defenses' | 'conditions'
|
||||||
|
> {
|
||||||
|
return {
|
||||||
|
currency: { cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 },
|
||||||
|
inventory: [],
|
||||||
|
attacks: [],
|
||||||
|
resources: [],
|
||||||
|
spellcasting: { slots: [], spells: [] },
|
||||||
|
defenses: {
|
||||||
|
deathSaves: { successes: 0, failures: 0 },
|
||||||
|
inspiration: false,
|
||||||
|
exhaustion: 0,
|
||||||
|
dying: 0,
|
||||||
|
wounded: 0,
|
||||||
|
doomed: 0,
|
||||||
|
heroPoints: 0,
|
||||||
|
},
|
||||||
|
conditions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,7 +34,19 @@ export const conditionSchema = z.object({
|
|||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
/** stacking value, e.g. PF2e frightened 2 or 5e exhaustion level */
|
/** stacking value, e.g. PF2e frightened 2 or 5e exhaustion level */
|
||||||
value: int.positive().optional(),
|
value: int.positive().optional(),
|
||||||
|
/** rounds remaining; undefined = indefinite (used by combat auto-expiry) */
|
||||||
|
duration: int.positive().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Coins. 5e uses cp/sp/ep/gp/pp; PF2e cp/sp/gp/pp (ep stays 0). */
|
||||||
|
export const currencySchema = z.object({
|
||||||
|
cp: int.nonnegative().default(0),
|
||||||
|
sp: int.nonnegative().default(0),
|
||||||
|
ep: int.nonnegative().default(0),
|
||||||
|
gp: int.nonnegative().default(0),
|
||||||
|
pp: int.nonnegative().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type HitPoints = z.infer<typeof hpSchema>;
|
export type HitPoints = z.infer<typeof hpSchema>;
|
||||||
export type Condition = z.infer<typeof conditionSchema>;
|
export type Condition = z.infer<typeof conditionSchema>;
|
||||||
|
export type Currency = z.infer<typeof currencySchema>;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useMemo, useRef } from 'react';
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a debounced wrapper around `fn` that also flushes on unmount, so the
|
* Returns a debounced wrapper around `fn` that also flushes on unmount AND on
|
||||||
* last edit is never lost when navigating away (the old app silently dropped
|
* page hide (tab close / refresh), so the last edit is never lost — the old app
|
||||||
* unsaved character changes on navigation).
|
* silently dropped unsaved character changes on navigation.
|
||||||
*/
|
*/
|
||||||
export function useDebouncedCallback<A extends unknown[]>(
|
export function useDebouncedCallback<A extends unknown[]>(
|
||||||
fn: (...args: A) => void,
|
fn: (...args: A) => void,
|
||||||
@@ -25,7 +25,14 @@ export function useDebouncedCallback<A extends unknown[]>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => flush, []); // flush on unmount
|
useEffect(() => {
|
||||||
|
const onHide = () => flush();
|
||||||
|
window.addEventListener('pagehide', onHide);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pagehide', onHide);
|
||||||
|
flush(); // flush on unmount
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -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/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/rules/abilities.ts","./src/lib/rules/index.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/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/rules/abilities.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"}
|
||||||
Reference in New Issue
Block a user