Proper guided character builder + level-up wizard

The bare name/class/level form left new characters as shells (1 HP, all-10
abilities, no proficiencies). Now creation and level-up are guided and auto-populate.

- Hand-authored progression tables (no SRD class/slot data exists on disk):
  src/lib/rules/{dnd5e,pf2e}/progression.ts — core classes' hit die/HP, key
  abilities, save & perception proficiencies, trained-skill counts, caster type;
  5e full/half/pact spell-slot tables, pf2e slot approximation; ASI/boost/skill
  schedules.
- src/lib/rules/progression.ts: getClassDefs/getClassDef, classSkillChoices/Count,
  buildCharacter() (HP from hit die + CON over levels, trained saves/skills, spell
  slots + ability), planLevelUp() (HP gain, new slots, ASI/boost/skill choices),
  applyIncreases/bumpRank. Pure + 12 unit tests.
- Multi-step CreationWizard (Basics → Abilities → Skills → Review) replaces the
  old form; pulls PF2e ancestry HP/speed from the bestiary; review previews HP/AC/
  saves/skills/slots; lands on a ready-to-play sheet.
- LevelUpModal reworked into a guided wizard: auto HP + spell slots, presents the
  level's actual choices (5e ASI/feat, pf2e ability boosts + skill increase) and
  applies them; keeps the strategic build-route advisor.

e2e helper drives the wizard; updated 7 specs to the new flow; new character-wizard
spec asserts a complete level-3 Wizard (HP 17, slots 4xL1 2xL2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 09:55:48 +02:00
parent 7271978d7d
commit 04fa90e580
18 changed files with 946 additions and 147 deletions
+5 -10
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/'); await page.goto('/');
@@ -14,18 +15,12 @@ test('generate ability scores and level up', async ({ page }) => {
await page.locator('input[data-autofocus]').fill('Build'); await page.locator('input[data-autofocus]').fill('Build');
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Characters' }).click(); await page.getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: '+ New character' }).first().click(); // Guided wizard builds a complete level-1 character (standard array → STR 15).
await page.locator('input[data-autofocus]').fill('Builder'); await createCharacter(page, 'Builder');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: /Builder/ }).click();
// Generate scores via standard array (default assignment 15,14,13,12,10,8)
await page.getByRole('button', { name: 'Generate' }).click();
await page.getByRole('button', { name: 'Apply' }).click();
await expect(page.getByLabel('STR score')).toHaveValue('15'); await expect(page.getByLabel('STR score')).toHaveValue('15');
// Level up from 1 -> 2 // Guided level-up from 1 -> 2 (HP + any choices applied automatically)
await page.getByRole('button', { name: 'Level up' }).click(); await page.getByRole('button', { name: 'Level up' }).click();
await page.getByRole('button', { name: 'Level up' }).last().click(); await page.getByRole('button', { name: /Apply level 2/ }).click();
await expect(page.getByRole('spinbutton', { name: 'Level', exact: true })).toHaveValue('2'); await expect(page.getByRole('spinbutton', { name: 'Level', exact: true })).toHaveValue('2');
}); });
+2 -4
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/'); await page.goto('/');
@@ -15,10 +16,7 @@ test('character depth: inventory, spellcasting, attacks, resources + rest', asyn
await page.locator('input[data-autofocus]').fill('Phandelver'); await page.locator('input[data-autofocus]').fill('Phandelver');
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Characters' }).click(); await page.getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: '+ New character' }).first().click(); await createCharacter(page, 'Gandalf');
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 // Set INT high so spell DC is computable
await page.getByLabel('INT score').fill('18'); await page.getByLabel('INT score').fill('18');
+42
View File
@@ -0,0 +1,42 @@
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('creation wizard builds a complete character (HP, skills, spell slots)', async ({ page }) => {
await page.getByRole('button', { name: '+ New campaign' }).first().click();
await page.locator('input[data-autofocus]').fill('Wizard Camp');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: '+ New character' }).first().click();
// Basics: a level-3 Wizard
await page.getByLabel('Name').fill('Mira');
await page.getByLabel('Class').selectOption('Wizard');
await page.getByLabel('Level', { exact: true }).fill('3');
await page.getByRole('button', { name: 'Next' }).click();
// Abilities: standard array assignment is valid by default (CON 13 → +1)
await page.getByRole('button', { name: 'Next' }).click();
// Skills: Wizard chooses 2
const boxes = page.locator('input[type="checkbox"]');
await boxes.nth(0).check();
await boxes.nth(1).check();
await page.getByRole('button', { name: 'Next' }).click();
// Review shows derived HP + spell slots before finishing
await expect(page.getByText('Max HP')).toBeVisible();
await expect(page.getByText('Spell slots')).toBeVisible();
await page.getByRole('button', { name: 'Create character' }).click();
// Lands on the sheet with real HP (d6 + CON over 3 levels = 17), not the old 1/1
await expect(page.getByText('Hit Points')).toBeVisible();
await expect(page.getByText(/\/\s*17/)).toBeVisible();
});
+2 -3
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/'); await page.goto('/');
@@ -15,9 +16,7 @@ test('compendium: browse 5e categories, switch to PF2e, cross-link a spell', asy
await page.locator('input[data-autofocus]').fill('Saltmarsh'); await page.locator('input[data-autofocus]').fill('Saltmarsh');
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Characters' }).click(); await page.getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: '+ New character' }).first().click(); await createCharacter(page, 'Wizard Wendy');
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) // Compendium → 5e categories exist (Feats is a new one)
await page.getByRole('link', { name: 'Compendium' }).click(); await page.getByRole('link', { name: 'Compendium' }).click();
+3 -3
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/'); await page.goto('/');
@@ -66,9 +67,8 @@ test('combat condition picker adds preset and valued conditions as tags', async
test('character can be deleted', async ({ page }) => { test('character can be deleted', async ({ page }) => {
await makeCampaign(page, 'Delete Test'); await makeCampaign(page, 'Delete Test');
await page.getByRole('link', { name: 'Characters' }).click(); await page.getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: '+ New character' }).first().click(); await createCharacter(page, 'Doomed'); // lands on the sheet
await page.locator('input[data-autofocus]').fill('Doomed'); await page.getByLabel('Primary').getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: 'Create' }).click();
await expect(page.getByRole('heading', { name: 'Doomed' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Doomed' })).toBeVisible();
await page.getByRole('button', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete' }).click();
+22
View File
@@ -0,0 +1,22 @@
import { type Page } from '@playwright/test';
/**
* Walk the guided creation wizard and land on the new character's sheet.
* Uses the default class; selects the minimum required trained skills.
*/
export async function createCharacter(page: Page, name: string): Promise<void> {
await page.getByRole('button', { name: '+ New character' }).first().click();
await page.getByLabel('Name').fill(name);
await page.getByRole('button', { name: 'Next' }).click(); // → Abilities (standard array is valid by default)
await page.getByRole('button', { name: 'Next' }).click(); // → Skills
const next = page.getByRole('button', { name: 'Next' });
const boxes = page.locator('input[type="checkbox"]');
const count = await boxes.count();
for (let i = 0; i < count; i++) {
if (!(await next.isDisabled())) break;
await boxes.nth(i).check();
}
await next.click(); // → Review
await page.getByRole('button', { name: 'Create character' }).click();
}
+2 -4
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/'); await page.goto('/');
@@ -14,10 +15,7 @@ test('rolling a skill from the sheet shows the roll tray', async ({ page }) => {
await page.locator('input[data-autofocus]').fill('Dice Camp'); await page.locator('input[data-autofocus]').fill('Dice Camp');
await page.getByRole('button', { name: 'Create' }).click(); await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: 'Characters' }).click(); await page.getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: '+ New character' }).first().click(); await createCharacter(page, 'Roller');
await page.locator('input[data-autofocus]').fill('Roller');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: /Roller/ }).click();
// Click the Acrobatics modifier to roll it // Click the Acrobatics modifier to roll it
await page.getByTitle('Roll Acrobatics (DEX)').click(); await page.getByTitle('Roll Acrobatics (DEX)').click();
+2 -3
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
await page.goto('/'); await page.goto('/');
@@ -16,9 +17,7 @@ test('player view shows the party and hides enemy HP numbers', async ({ page })
// A player character // A player character
await page.getByRole('link', { name: 'Characters' }).click(); await page.getByRole('link', { name: 'Characters' }).click();
await page.getByRole('button', { name: '+ New character' }).first().click(); await createCharacter(page, 'Hero');
await page.locator('input[data-autofocus]').fill('Hero');
await page.getByRole('button', { name: 'Create' }).click();
// An active encounter with the hero + a monster // An active encounter with the hero + a monster
await page.getByRole('link', { name: 'Combat' }).click(); await page.getByRole('link', { name: 'Combat' }).click();
+2 -4
View File
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test'; import { test, expect } from '@playwright/test';
import { createCharacter } from './helpers';
// Each test starts from a clean IndexedDB so runs are deterministic. // Each test starts from a clean IndexedDB so runs are deterministic.
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
@@ -24,10 +25,7 @@ test('full core flow: campaign → character → dice → combat → compendium'
// --- Character --- // --- Character ---
await page.getByRole('link', { name: 'Characters' }).click(); await page.getByRole('link', { name: 'Characters' }).click();
await expect(page.getByRole('heading', { name: 'Characters' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Characters' })).toBeVisible();
await page.getByRole('button', { name: '+ New character' }).first().click(); await createCharacter(page, 'Ireena');
await page.locator('input[data-autofocus]').fill('Ireena');
await page.getByRole('button', { name: 'Create' }).click();
await page.getByRole('link', { name: /Ireena/ }).click();
// On the sheet: set STR to 16 and expect +3 modifier // On the sheet: set STR to 16 and expect +3 modifier
await page.getByLabel('STR score').fill('16'); await page.getByLabel('STR score').fill('16');
+2 -76
View File
@@ -9,7 +9,7 @@ import { useCharacters } from './hooks';
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Field, Input, Select } from '@/components/ui/Input'; import { CreationWizard } from './builder/CreationWizard';
export function CharactersPage() { export function CharactersPage() {
return <RequireCampaign>{(campaign) => <CharactersList campaign={campaign} />}</RequireCampaign>; return <RequireCampaign>{(campaign) => <CharactersList campaign={campaign} />}</RequireCampaign>;
@@ -74,7 +74,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
</div> </div>
)} )}
{creating && <CharacterFormModal campaign={campaign} onClose={() => setCreating(false)} />} {creating && <CreationWizard campaign={campaign} onClose={() => setCreating(false)} />}
</Page> </Page>
); );
} }
@@ -164,77 +164,3 @@ function CharacterCard({ c }: { c: Character }) {
); );
} }
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,296 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from '@tanstack/react-router';
import type { Campaign, Character } from '@/lib/schemas';
import { charactersRepo } from '@/lib/db/repositories';
import {
getSystem, ABILITY_ABBR, abilityModifier, type AbilityKey, type AbilityScores,
getClassNames, getClassDef, classSkillChoices, classSkillCount, buildCharacter,
} from '@/lib/rules';
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
import { loadPf2e } from '@/lib/compendium';
import { createRng } from '@/lib/rng';
import { formatModifier } from '@/lib/format';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Field, Input, Select } from '@/components/ui/Input';
import { NumberField } from '@/components/ui/NumberField';
import { cn } from '@/lib/cn';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
const HIT_DICE = [6, 8, 10, 12];
type AbilityMethod = 'standard' | 'pointbuy' | 'roll';
type Ancestry = { name: string; hp?: number; speed?: number };
const STEPS = ['Basics', 'Abilities', 'Skills', 'Review'] as const;
export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
const navigate = useNavigate();
const sys = getSystem(campaign.system);
const allSkillKeys = sys.skills.map((s) => s.key);
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key;
const [step, setStep] = useState(0);
// Basics
const [name, setName] = useState('');
const [kind, setKind] = useState<Character['kind']>('pc');
const [level, setLevel] = useState(1);
const [classChoice, setClassChoice] = useState(getClassNames(campaign.system)[0] ?? 'Custom');
const [customClass, setCustomClass] = useState('');
const [hitDie, setHitDie] = useState(8);
const [ancestry, setAncestry] = useState('');
const [background, setBackground] = useState('');
// PF2e ancestries (for HP/speed)
const [ancestries, setAncestries] = useState<Ancestry[]>([]);
useEffect(() => {
if (campaign.system !== 'pf2e') return;
let live = true;
void loadPf2e('ancestries').then((rows) => {
if (!live) return;
setAncestries(rows.map((r) => {
const speed = r.speed as { land?: number } | undefined;
return {
name: String(r.name),
...(typeof r.hp === 'number' ? { hp: r.hp } : {}),
...(speed?.land ? { speed: speed.land } : {}),
};
}));
});
return () => { live = false; };
}, [campaign.system]);
const className = classChoice === 'Custom' ? customClass.trim() : classChoice;
const def = getClassDef(campaign.system, className);
const selectedAncestry = ancestries.find((a) => a.name === ancestry);
// Abilities
const [method, setMethod] = useState<AbilityMethod>('standard');
const [pool, setPool] = useState<number[]>([...STANDARD_ARRAY]);
const [assignment, setAssignment] = useState<number[]>(ABILITIES.map((_, i) => i));
const [pb, setPb] = useState<number[]>([8, 8, 8, 8, 8, 8]);
const usesPool = method === 'standard' || method === 'roll';
const chooseMethod = (m: AbilityMethod) => {
setMethod(m);
if (m === 'standard') { setPool([...STANDARD_ARRAY]); setAssignment(ABILITIES.map((_, i) => i)); }
if (m === 'roll') { setPool(rollAbilityScores(createRng())); setAssignment(ABILITIES.map((_, i) => i)); }
};
const abilities = useMemo<AbilityScores>(() => {
const out = {} as AbilityScores;
ABILITIES.forEach((a, i) => { out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!; });
return out;
}, [usesPool, pool, assignment, pb]);
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
const pbValid = method !== 'pointbuy' || pointBuyRemaining(pb) >= 0;
// Skills
const [skills, setSkills] = useState<string[]>([]);
const intMod = abilityModifier(abilities.int);
const skillCount = classSkillCount(campaign.system, className, intMod);
const skillOptions = classSkillChoices(campaign.system, allSkillKeys, className);
const toggleSkill = (key: string) =>
setSkills((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : prev.length < skillCount ? [...prev, key] : prev));
// Derived preview
const built = useMemo(
() => buildCharacter(campaign.system, {
className, level, abilities, skillChoices: skills,
...(selectedAncestry?.hp ? { ancestryHp: selectedAncestry.hp } : {}),
hitDieOverride: hitDie,
}),
[campaign.system, className, level, abilities, skills, selectedAncestry, hitDie],
);
const stepValid = [
name.trim().length > 0 && className.length > 0,
poolValid && pbValid,
skills.length === Math.min(skillCount, skillOptions.length),
true,
];
const finish = async () => {
const created = await charactersRepo.create(campaign.id, {
system: campaign.system, name: name.trim(), kind,
ancestry: ancestry.trim(), className, level,
});
await charactersRepo.update(created.id, {
...built,
notes: background.trim() ? `Background: ${background.trim()}` : '',
...(selectedAncestry?.speed ? { speed: selectedAncestry.speed } : {}),
});
onClose();
void navigate({ to: '/characters/$characterId', params: { characterId: created.id } });
};
const isLast = step === STEPS.length - 1;
return (
<Modal
open
onClose={onClose}
title={`New character — ${STEPS[step]}`}
className="max-w-2xl"
footer={
<>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
{step > 0 && <Button variant="secondary" onClick={() => setStep((s) => s - 1)}>Back</Button>}
{isLast
? <Button variant="primary" onClick={finish}>Create character</Button>
: <Button variant="primary" disabled={!stepValid[step]} onClick={() => setStep((s) => s + 1)}>Next</Button>}
</>
}
>
{/* Step indicator */}
<ol className="mb-4 flex gap-1 text-xs">
{STEPS.map((s, i) => (
<li key={s} className={cn('flex-1 rounded-md px-2 py-1 text-center', i === step ? 'bg-accent text-accent-ink' : i < step ? 'bg-elevated text-ink' : 'bg-surface text-muted')}>
{i + 1}. {s}
</li>
))}
</ol>
{step === 0 && (
<div className="space-y-3">
<Field label="Name"><Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Character name" /></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"><NumberField value={level} min={1} max={20} onChange={setLevel} aria-label="Level" /></Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Class">
<Select value={classChoice} onChange={(e) => setClassChoice(e.target.value)} aria-label="Class">
{getClassNames(campaign.system).map((c) => <option key={c} value={c}>{c}</option>)}
<option value="Custom">Custom</option>
</Select>
</Field>
{classChoice === 'Custom'
? <Field label="Custom class"><Input value={customClass} onChange={(e) => setCustomClass(e.target.value)} placeholder="Class name" /></Field>
: <Field label="Hit die / HP"><Input value={def ? (campaign.system === 'pf2e' ? `${def.hitDie} HP/level` : `d${def.hitDie}`) : '—'} disabled /></Field>}
</div>
{classChoice === 'Custom' && campaign.system === '5e' && (
<Field label="Hit die">
<Select value={hitDie} onChange={(e) => setHitDie(Number(e.target.value))}>{HIT_DICE.map((d) => <option key={d} value={d}>d{d}</option>)}</Select>
</Field>
)}
<div className="grid grid-cols-2 gap-3">
<Field label="Ancestry / race">
{campaign.system === 'pf2e' && ancestries.length > 0 ? (
<Select value={ancestry} onChange={(e) => setAncestry(e.target.value)} aria-label="Ancestry">
<option value="">Choose</option>
{ancestries.map((a) => <option key={a.name} value={a.name}>{a.name}</option>)}
</Select>
) : (
<Input value={ancestry} onChange={(e) => setAncestry(e.target.value)} placeholder="e.g. Human" />
)}
</Field>
<Field label="Background (optional)"><Input value={background} onChange={(e) => setBackground(e.target.value)} placeholder="e.g. Acolyte" /></Field>
</div>
{def && <p className="text-xs text-muted">{classSummary(campaign.system, className)}</p>}
</div>
)}
{step === 1 && (
<div>
<div className="mb-3 flex gap-1">
{([['standard', 'Standard array'], ['pointbuy', 'Point buy'], ['roll', '4d6 drop lowest']] as const).map(([m, label]) => (
<button key={m} onClick={() => chooseMethod(m)} className={cn('rounded-md px-3 py-1.5 text-sm', method === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink')}>{label}</button>
))}
</div>
{method === 'roll' && <Button size="sm" variant="secondary" className="mb-3" onClick={() => chooseMethod('roll')}> Reroll</Button>}
{method === 'pointbuy' && <p className={cn('mb-3 text-sm', pointBuyRemaining(pb) < 0 ? 'text-danger' : 'text-muted')}>Points remaining: <span className="font-semibold text-ink">{pointBuyRemaining(pb)}</span> / 27</p>}
{def && <p className="mb-3 text-xs text-muted">Suggested key {def.keyAbilities.length > 1 ? 'abilities' : 'ability'}: <span className="text-ink">{def.keyAbilities.map((k) => ABILITY_ABBR[k]).join(' / ')}</span></p>}
{usesPool && !poolValid && <p className="mb-2 text-sm text-warning">Assign each value to a different ability.</p>}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{ABILITIES.map((a, i) => {
const value = usesPool ? (pool[assignment[i]!] ?? 0) : pb[i]!;
const isKey = def?.keyAbilities.includes(a);
return (
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}{isKey ? ' ★' : ''}</div>
{usesPool ? (
<Select className="mt-1" aria-label={`${ABILITY_ABBR[a]} value`} value={assignment[i]} onChange={(e) => setAssignment((prev) => prev.map((v, j) => (j === i ? Number(e.target.value) : v)))}>
{pool.map((p, idx) => <option key={idx} value={idx} disabled={assignment.includes(idx) && assignment[i] !== idx}>{p}</option>)}
</Select>
) : (
<NumberField className="mt-1" value={pb[i]!} min={POINT_BUY_MIN} max={POINT_BUY_MAX} aria-label={`${ABILITY_ABBR[a]} score`} onChange={(v) => setPb((prev) => prev.map((x, j) => (j === i ? Math.max(POINT_BUY_MIN, Math.min(POINT_BUY_MAX, v)) : x)))} />
)}
<div className="mt-1 text-xs text-accent">{formatModifier(abilityModifier(value))}</div>
</div>
);
})}
</div>
</div>
)}
{step === 2 && (
<div>
<p className="mb-3 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, skillOptions.length)}</span> trained {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{skillOptions.map((key) => {
const checked = skills.includes(key);
const disabled = !checked && skills.length >= skillCount;
return (
<label key={key} className={cn('flex items-center gap-2 rounded-md border px-3 py-2 text-sm', checked ? 'border-accent/60 bg-accent/5 text-ink' : disabled ? 'border-line text-muted opacity-50' : 'border-line text-ink')}>
<input type="checkbox" checked={checked} disabled={disabled} onChange={() => toggleSkill(key)} />
{skillLabel(key)}
</label>
);
})}
</div>
</div>
)}
{step === 3 && (
<div className="space-y-3 text-sm">
<div className="rounded-lg border border-line bg-surface p-3">
<div className="font-medium text-ink">{name || 'Unnamed'} level {level} {[ancestry, className].filter(Boolean).join(' ')}</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-center">
<Stat label="Max HP" value={built.hp.max} />
<Stat label="AC" value={sys.baseArmorClass({ level, abilities, armorBonus: 0 })} />
</div>
</div>
<Review label="Ability scores" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} />
<Review label="Trained saves" value={Object.keys(built.saveRanks).map((k) => k.toUpperCase()).join(', ') || '—'} />
<Review label="Trained skills" value={Object.keys(built.skillRanks).map(skillLabel).join(', ') || '—'} />
{built.spellcasting.slots.length > 0 && (
<Review label="Spell slots" value={built.spellcasting.slots.map((s) => `${s.max}×L${s.level}`).join(' ')} />
)}
{built.spellcasting.pact && <Review label="Pact slots" value={`${built.spellcasting.pact.max}× L${built.spellcasting.pact.level}`} />}
<p className="text-xs text-muted">
You can add equipment, spells known, class features, and resources on the sheet next.
</p>
</div>
)}
</Modal>
);
}
function classSummary(system: Campaign['system'], className: string): string {
const def = getClassDef(system, className);
if (!def) return '';
const caster = def.caster === 'none' ? 'non-caster' : def.caster === 'pact' ? 'pact caster' : `${def.caster} caster`;
return `${className}: ${system === 'pf2e' ? `${def.hitDie} HP/level` : `d${def.hitDie} hit die`}, ${caster}.`;
}
function Stat({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-md border border-line bg-panel p-2">
<div className="font-display text-lg font-semibold text-accent">{value}</div>
<div className="text-xs uppercase tracking-wide text-muted">{label}</div>
</div>
);
}
function Review({ label, value }: { label: string; value: string }) {
return (
<div className="flex gap-2">
<span className="w-28 shrink-0 text-xs uppercase tracking-wide text-muted">{label}</span>
<span className="text-ink">{value}</span>
</div>
);
}
+135 -39
View File
@@ -1,6 +1,9 @@
import { useState } from 'react'; import { useMemo, useState } from 'react';
import type { Campaign, Character } from '@/lib/schemas'; import type { Campaign, Character } from '@/lib/schemas';
import { abilityModifier } from '@/lib/rules'; import {
abilityModifier, getSystem, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
planLevelUp, applyIncreases, bumpRank, getClassDef,
} from '@/lib/rules';
import { rollDice } from '@/lib/dice/notation'; import { rollDice } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng'; import { createRng } from '@/lib/rng';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
@@ -8,68 +11,161 @@ import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Input'; import { Select } from '@/components/ui/Input';
import { LevelUpAdvisor } from './LevelUpAdvisor'; import { LevelUpAdvisor } from './LevelUpAdvisor';
const HIT_DICE = [6, 8, 10, 12] as const; const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
export function LevelUpModal({ character, onApply, onClose }: { export function LevelUpModal({ character, onApply, onClose }: {
character: Character; character: Character;
onApply: (patch: Partial<Character>) => void; onApply: (patch: Partial<Character>) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const [die, setDie] = useState<number>(8);
const [method, setMethod] = useState<'average' | 'roll'>('average');
const conMod = abilityModifier(character.abilities.con); const conMod = abilityModifier(character.abilities.con);
const plan = useMemo(
() => planLevelUp(character.system, character.className, character.level, conMod),
[character.system, character.className, character.level, conMod],
);
const sys = getSystem(character.system);
const def = getClassDef(character.system, character.className);
const average = Math.ceil(die / 2) + 1; // 5e fixed value per level const [hpMethod, setHpMethod] = useState<'average' | 'roll'>('average');
const asi = plan.choices.find((c) => c.kind === 'asi');
const boosts = plan.choices.find((c) => c.kind === 'boosts');
const skillInc = plan.choices.find((c) => c.kind === 'skill-increase');
// ASI (5e): two +1 picks (same twice = +2) or take a feat instead.
const keyDefaults = def?.keyAbilities ?? ['str', 'dex'];
const [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi');
const [asiPicks, setAsiPicks] = useState<AbilityKey[]>([keyDefaults[0] ?? 'str', keyDefaults[1] ?? keyDefaults[0] ?? 'con']);
// Boosts (pf2e): four distinct picks.
const [boostPicks, setBoostPicks] = useState<AbilityKey[]>(['str', 'dex', 'con', 'wis']);
// Skill increase (pf2e): one skill to bump.
const [skillKey, setSkillKey] = useState<string>(sys.skills[0]?.key ?? '');
const atMax = character.level >= 20;
const apply = () => { const apply = () => {
const base = method === 'average' ? average : rollDice(`1d${die}`, createRng()).total; const gain = character.system === 'pf2e'
const gain = Math.max(1, base + conMod); ? plan.hpGainAverage
onApply({ : hpMethod === 'average' ? plan.hpGainAverage : Math.max(1, rollDice(`1d${plan.hitDie}`, createRng()).total + conMod);
level: Math.min(20, character.level + 1),
let abilities = character.abilities;
if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e');
if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e');
const patch: Partial<Character> = {
level: plan.nextLevel,
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain }, hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
}); abilities,
};
if (plan.slots) {
patch.spellcasting = { ...character.spellcasting, slots: plan.slots, ...(plan.pact ? { pact: plan.pact } : {}) };
}
if (skillInc && skillKey) {
const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained';
patch.skillRanks = { ...character.skillRanks, [skillKey]: bumpRank(current) };
}
onApply(patch);
onClose(); onClose();
}; };
const preview = (method === 'average' ? average : `1d${die}`) + (conMod !== 0 ? ` ${conMod >= 0 ? '+' : ''}${conMod}` : ''); const hpPreview = character.system === 'pf2e'
? `+${plan.hpGainAverage}`
// The advisor only needs the system + id; synthesize a campaign from the character. : hpMethod === 'average' ? `+${plan.hpGainAverage}` : `1d${plan.hitDie} ${conMod >= 0 ? '+' : ''}${conMod}`;
const campaign: Campaign = {
id: character.campaignId, name: '', system: character.system, description: '', createdAt: '', updatedAt: '',
};
return ( return (
<Modal <Modal
open open
onClose={onClose} onClose={onClose}
title={`Level up to ${Math.min(20, character.level + 1)}`} title={`Level up to ${plan.nextLevel}`}
className="max-w-xl"
footer={ footer={
<> <>
<Button variant="ghost" onClick={onClose}>Cancel</Button> <Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={character.level >= 20} onClick={apply}>Level up</Button> <Button variant="primary" disabled={atMax} onClick={apply}>Apply level {plan.nextLevel}</Button>
</> </>
} }
> >
<div className="space-y-4"> {atMax ? (
<label className="block text-xs text-muted"> <p className="text-sm text-warning">Already at level 20.</p>
Hit die ) : (
<Select value={die} onChange={(e) => setDie(Number(e.target.value))}> <div className="space-y-4">
{HIT_DICE.map((d) => <option key={d} value={d}>d{d}</option>)} {/* HP */}
</Select> <section>
</label> <h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Hit points</h3>
<label className="block text-xs text-muted"> {character.system === '5e' ? (
HP method <div className="flex items-center gap-2">
<Select value={method} onChange={(e) => setMethod(e.target.value as 'average' | 'roll')}> <Select value={hpMethod} onChange={(e) => setHpMethod(e.target.value as 'average' | 'roll')} className="w-44" aria-label="HP method">
<option value="average">Average (+{average})</option> <option value="average">Average (+{plan.hpGainAverage})</option>
<option value="roll">Roll the hit die</option> <option value="roll">Roll d{plan.hitDie}</option>
</Select> </Select>
</label> <span className="text-sm text-muted">Gain: <span className="font-medium text-ink">{hpPreview}</span> (CON {conMod >= 0 ? '+' : ''}{conMod})</span>
<p className="text-sm text-muted"> </div>
HP gained: <span className="font-medium text-ink">{preview}</span> (CON {conMod >= 0 ? '+' : ''}{conMod}) ) : (
</p> <p className="text-sm text-muted">Gain: <span className="font-medium text-ink">{hpPreview}</span> (class {plan.hitDie} + CON {conMod >= 0 ? '+' : ''}{conMod})</p>
{character.level >= 20 && <p className="text-sm text-warning">Already at level 20.</p>} )}
</section>
{character.level < 20 && <LevelUpAdvisor campaign={campaign} character={character} />} {plan.notes.map((n) => <p key={n} className="text-sm text-muted"> {n}</p>)}
</div>
{/* ASI (5e) */}
{asi && (
<section>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Ability Score Improvement</h3>
<div className="mb-2 flex gap-1">
{(['asi', 'feat'] as const).map((m) => (
<button key={m} onClick={() => setAsiMode(m)} className={`rounded-md px-3 py-1 text-sm ${asiMode === m ? 'bg-accent text-accent-ink' : 'bg-elevated text-muted hover:text-ink'}`}>
{m === 'asi' ? '+2 abilities' : 'Take a feat'}
</button>
))}
</div>
{asiMode === 'asi' ? (
<div className="flex gap-2">
{[0, 1].map((i) => (
<Select key={i} aria-label={`ASI ability ${i + 1}`} value={asiPicks[i]} onChange={(e) => setAsiPicks((p) => p.map((v, j) => (j === i ? e.target.value as AbilityKey : v)))}>
{ABILITIES.map((a) => <option key={a} value={a}>{ABILITY_ABBR[a]} (+1)</option>)}
</Select>
))}
<span className="self-center text-xs text-muted">pick the same twice for +2</span>
</div>
) : (
<p className="text-sm text-muted">Add your chosen feat on the sheet after leveling.</p>
)}
</section>
)}
{/* Boosts (pf2e) */}
{boosts && (
<section>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Ability boosts (choose 4)</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[0, 1, 2, 3].map((i) => (
<Select key={i} aria-label={`Boost ${i + 1}`} value={boostPicks[i]} onChange={(e) => setBoostPicks((p) => p.map((v, j) => (j === i ? e.target.value as AbilityKey : v)))}>
{ABILITIES.map((a) => <option key={a} value={a} disabled={boostPicks.includes(a) && boostPicks[i] !== a}>{ABILITY_ABBR[a]}</option>)}
</Select>
))}
</div>
</section>
)}
{/* Skill increase (pf2e) */}
{skillInc && (
<section>
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wide text-muted">Skill increase</h3>
<Select value={skillKey} onChange={(e) => setSkillKey(e.target.value)} aria-label="Skill to increase">
{sys.skills.map((s) => {
const cur = (character.skillRanks[s.key] as ProficiencyRank) ?? 'untrained';
return <option key={s.key} value={s.key}>{s.label}: {cur} {bumpRank(cur)}</option>;
})}
</Select>
</section>
)}
{plan.choices.filter((c) => c.kind === 'feat').map((c) => (
<p key={c.label} className="text-sm text-muted"> {c.label}</p>
))}
{/* Strategic build-route advice */}
<LevelUpAdvisor campaign={{ id: character.campaignId, name: '', system: character.system, description: '', createdAt: '', updatedAt: '' } as Campaign} character={character} />
</div>
)}
</Modal> </Modal>
); );
} }
+84
View File
@@ -0,0 +1,84 @@
import type { ClassDef } from '../progression';
/**
* Curated 5e SRD class data for the guided builder. The on-disk MPMB files carry
* only names, so hit dice / proficiencies / caster type are authored here for the
* core classes. Unknown classes fall back to manual entry in the wizard.
*/
export const DND5E_CLASSES: ClassDef[] = [
{ name: 'Barbarian', hitDie: 12, keyAbilities: ['str'], saveProficient: ['str', 'con'], skillCount: 2,
skillKeys: ['animal-handling', 'athletics', 'intimidation', 'nature', 'perception', 'survival'], caster: 'none' },
{ name: 'Bard', hitDie: 8, keyAbilities: ['cha'], saveProficient: ['dex', 'cha'], skillCount: 3,
skillKeys: [], caster: 'full', spellAbility: 'cha' },
{ name: 'Cleric', hitDie: 8, keyAbilities: ['wis'], saveProficient: ['wis', 'cha'], skillCount: 2,
skillKeys: ['history', 'insight', 'medicine', 'persuasion', 'religion'], caster: 'full', spellAbility: 'wis' },
{ name: 'Druid', hitDie: 8, keyAbilities: ['wis'], saveProficient: ['int', 'wis'], skillCount: 2,
skillKeys: ['arcana', 'animal-handling', 'insight', 'medicine', 'nature', 'perception', 'religion', 'survival'], caster: 'full', spellAbility: 'wis' },
{ name: 'Fighter', hitDie: 10, keyAbilities: ['str', 'dex'], saveProficient: ['str', 'con'], skillCount: 2,
skillKeys: ['acrobatics', 'animal-handling', 'athletics', 'history', 'insight', 'intimidation', 'perception', 'survival'], caster: 'none' },
{ name: 'Monk', hitDie: 8, keyAbilities: ['dex', 'wis'], saveProficient: ['str', 'dex'], skillCount: 2,
skillKeys: ['acrobatics', 'athletics', 'history', 'insight', 'religion', 'stealth'], caster: 'none' },
{ name: 'Paladin', hitDie: 10, keyAbilities: ['str', 'cha'], saveProficient: ['wis', 'cha'], skillCount: 2,
skillKeys: ['athletics', 'insight', 'intimidation', 'medicine', 'persuasion', 'religion'], caster: 'half', spellAbility: 'cha' },
{ name: 'Ranger', hitDie: 10, keyAbilities: ['dex', 'wis'], saveProficient: ['str', 'dex'], skillCount: 3,
skillKeys: ['animal-handling', 'athletics', 'insight', 'investigation', 'nature', 'perception', 'stealth', 'survival'], caster: 'half', spellAbility: 'wis' },
{ name: 'Rogue', hitDie: 8, keyAbilities: ['dex'], saveProficient: ['dex', 'int'], skillCount: 4,
skillKeys: ['acrobatics', 'athletics', 'deception', 'insight', 'intimidation', 'investigation', 'perception', 'performance', 'persuasion', 'sleight-of-hand', 'stealth'], caster: 'none' },
{ name: 'Sorcerer', hitDie: 6, keyAbilities: ['cha'], saveProficient: ['con', 'cha'], skillCount: 2,
skillKeys: ['arcana', 'deception', 'insight', 'intimidation', 'persuasion', 'religion'], caster: 'full', spellAbility: 'cha' },
{ name: 'Warlock', hitDie: 8, keyAbilities: ['cha'], saveProficient: ['wis', 'cha'], skillCount: 2,
skillKeys: ['arcana', 'deception', 'history', 'intimidation', 'investigation', 'nature', 'religion'], caster: 'pact', spellAbility: 'cha' },
{ name: 'Wizard', hitDie: 6, keyAbilities: ['int'], saveProficient: ['int', 'wis'], skillCount: 2,
skillKeys: ['arcana', 'history', 'insight', 'investigation', 'medicine', 'religion'], caster: 'full', spellAbility: 'int' },
];
// Spell slots [1st..9th] by character level for a full caster (PHB).
const FULL: Record<number, number[]> = {
1: [2], 2: [3], 3: [4, 2], 4: [4, 3], 5: [4, 3, 2], 6: [4, 3, 3], 7: [4, 3, 3, 1], 8: [4, 3, 3, 2],
9: [4, 3, 3, 3, 1], 10: [4, 3, 3, 3, 2], 11: [4, 3, 3, 3, 2, 1], 12: [4, 3, 3, 3, 2, 1],
13: [4, 3, 3, 3, 2, 1, 1], 14: [4, 3, 3, 3, 2, 1, 1], 15: [4, 3, 3, 3, 2, 1, 1, 1], 16: [4, 3, 3, 3, 2, 1, 1, 1],
17: [4, 3, 3, 3, 2, 1, 1, 1, 1], 18: [4, 3, 3, 3, 3, 1, 1, 1, 1], 19: [4, 3, 3, 3, 3, 2, 1, 1, 1], 20: [4, 3, 3, 3, 3, 2, 2, 1, 1],
};
// Half caster (Paladin/Ranger), spells from level 2.
const HALF: Record<number, number[]> = {
2: [2], 3: [3], 4: [3], 5: [4, 2], 6: [4, 2], 7: [4, 3], 8: [4, 3], 9: [4, 3, 2], 10: [4, 3, 2],
11: [4, 3, 3], 12: [4, 3, 3], 13: [4, 3, 3, 1], 14: [4, 3, 3, 1], 15: [4, 3, 3, 2], 16: [4, 3, 3, 2],
17: [4, 3, 3, 3, 1], 18: [4, 3, 3, 3, 1], 19: [4, 3, 3, 3, 2], 20: [4, 3, 3, 3, 2],
};
// Warlock pact magic: [slotCount, slotLevel].
const PACT: Record<number, [number, number]> = {
1: [1, 1], 2: [2, 1], 3: [2, 2], 4: [2, 2], 5: [2, 3], 6: [2, 3], 7: [2, 4], 8: [2, 4], 9: [2, 5], 10: [2, 5],
11: [3, 5], 12: [3, 5], 13: [3, 5], 14: [3, 5], 15: [3, 5], 16: [3, 5], 17: [4, 5], 18: [4, 5], 19: [4, 5], 20: [4, 5],
};
function clamp(level: number): number {
return Math.max(1, Math.min(20, Math.floor(level) || 1));
}
export function dnd5eSlots(caster: ClassDef['caster'], level: number): { level: number; max: number; current: number }[] {
const lvl = clamp(level);
const table = caster === 'full' ? FULL[lvl] : caster === 'half' ? HALF[lvl] : undefined;
if (!table) return [];
return table.map((max, i) => ({ level: i + 1, max, current: max }));
}
export function dnd5ePact(level: number): { level: number; max: number; current: number } | undefined {
const p = PACT[clamp(level)];
return p ? { level: p[1], max: p[0], current: p[0] } : undefined;
}
/** Average HP at a level: max die at L1, then per-level average + CON each level. */
export function dnd5eHp(hitDie: number, level: number, conMod: number): number {
const lvl = clamp(level);
const avg = Math.ceil(hitDie / 2) + 1;
return Math.max(1, hitDie + conMod + (lvl - 1) * (avg + conMod));
}
/** Levels that grant an Ability Score Improvement (or feat). Includes class extras. */
export function dnd5eAsiLevels(className: string): number[] {
const base = [4, 8, 12, 16, 19];
const c = className.toLowerCase();
if (c === 'fighter') return [...base, 6, 14].sort((a, b) => a - b);
if (c === 'rogue') return [...base, 10].sort((a, b) => a - b);
return base;
}
+1
View File
@@ -17,6 +17,7 @@ export const SYSTEM_OPTIONS: { id: SystemId; label: string }[] = [
]; ];
export * from './types'; export * from './types';
export * from './progression';
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities'; export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
export { applyRest } from './rest'; export { applyRest } from './rest';
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions'; export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
+71
View File
@@ -0,0 +1,71 @@
import type { ClassDef } from '../progression';
/**
* Curated PF2e class data for the guided builder. There is no class data file on
* disk, so the core classes' HP, key ability, initial proficiencies, trained-skill
* count, and caster type are authored here. `hitDie` holds HP-per-level (PF2e style).
* Save ranks are keyed by the ability the save uses (con/dex/wis) to match the sheet.
*/
export const PF2E_CLASSES: ClassDef[] = [
cls('Alchemist', 8, ['int'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'trained', 3, 'none'),
cls('Barbarian', 12, ['str'], { con: 'expert', dex: 'trained', wis: 'expert' }, 'expert', 3, 'none'),
cls('Bard', 8, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'expert', 4, 'full', 'cha'),
cls('Champion', 10, ['str'], { con: 'expert', dex: 'trained', wis: 'expert' }, 'trained', 2, 'none'),
cls('Cleric', 8, ['wis'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 2, 'full', 'wis'),
cls('Druid', 8, ['wis'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'trained', 2, 'full', 'wis'),
cls('Fighter', 10, ['str', 'dex'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'expert', 3, 'none'),
cls('Investigator', 8, ['int'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'expert', 4, 'none'),
cls('Monk', 10, ['str', 'dex'], { con: 'expert', dex: 'expert', wis: 'expert' }, 'trained', 4, 'none'),
cls('Oracle', 8, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 3, 'full', 'cha'),
cls('Ranger', 10, ['str', 'dex'], { con: 'expert', dex: 'expert', wis: 'trained' }, 'expert', 4, 'none'),
cls('Rogue', 8, ['dex'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'expert', 7, 'none'),
cls('Sorcerer', 6, ['cha'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 2, 'full', 'cha'),
cls('Swashbuckler', 10, ['dex'], { con: 'trained', dex: 'expert', wis: 'expert' }, 'expert', 4, 'none'),
cls('Witch', 6, ['int'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 3, 'full', 'int'),
cls('Wizard', 6, ['int'], { con: 'trained', dex: 'trained', wis: 'expert' }, 'trained', 2, 'full', 'int'),
];
function cls(
name: string, hp: number, keyAbilities: ClassDef['keyAbilities'],
saves: NonNullable<ClassDef['saveRanks']>, perception: ClassDef['perception'],
skillCount: number, caster: ClassDef['caster'], spellAbility?: ClassDef['spellAbility'],
): ClassDef {
return {
name, hitDie: hp, keyAbilities, saveProficient: [], saveRanks: saves,
perception: perception ?? 'trained', skillKeys: [], skillCount, caster,
...(spellAbility ? { spellAbility, spellRank: 'trained' as const } : {}),
};
}
/** PF2e HP = ancestry HP + (class HP + CON mod) × level. */
export function pf2eHp(classHp: number, level: number, conMod: number, ancestryHp: number): number {
const lvl = Math.max(1, Math.min(20, Math.floor(level) || 1));
return Math.max(1, ancestryHp + (classHp + conMod) * lvl);
}
/**
* Spell slots per rank for a full caster. A new rank unlocks at each odd level;
* each available rank gets 3 slots (the standard for prepared/spontaneous casters).
* Approximate and editable on the sheet.
*/
export function pf2eSlots(caster: ClassDef['caster'], level: number): { level: number; max: number; current: number }[] {
if (caster !== 'full') return [];
const lvl = Math.max(1, Math.min(20, Math.floor(level) || 1));
const topRank = Math.min(10, Math.ceil(lvl / 2));
const slots: { level: number; max: number; current: number }[] = [];
for (let r = 1; r <= topRank; r++) {
const max = r === 10 ? 1 : 3;
slots.push({ level: r, max, current: max });
}
return slots;
}
/** Levels granting 4 ability boosts. */
export function pf2eBoostLevels(): number[] {
return [5, 10, 15, 20];
}
/** Levels granting a skill increase. */
export function pf2eSkillIncreaseLevels(): number[] {
return [3, 5, 7, 9, 11, 13, 15, 17, 19];
}
+86
View File
@@ -0,0 +1,86 @@
import { describe, it, expect } from 'vitest';
import {
getClassDef, getClassNames, classSkillCount, classSkillChoices,
buildCharacter, planLevelUp, applyIncreases, bumpRank,
} from './progression';
import type { AbilityScores } from './types';
const abilities: AbilityScores = { str: 16, dex: 12, con: 14, int: 10, wis: 13, cha: 8 };
describe('class tables', () => {
it('exposes core classes for both systems', () => {
expect(getClassNames('5e')).toContain('Wizard');
expect(getClassNames('pf2e')).toContain('Champion');
expect(getClassDef('5e', 'fighter')?.hitDie).toBe(10);
});
it('pf2e skill count adds INT modifier; 5e does not', () => {
expect(classSkillCount('5e', 'Rogue', 3)).toBe(4); // fixed
expect(classSkillCount('pf2e', 'Rogue', 3)).toBe(10); // 7 + INT 3
});
it('restricts skill choices for a class with a list, full list otherwise', () => {
expect(classSkillChoices('5e', ['a', 'b'], 'Cleric')).toContain('religion');
expect(classSkillChoices('5e', ['acrobatics', 'arcana'], 'Bard')).toEqual(['acrobatics', 'arcana']);
});
});
describe('buildCharacter — 5e', () => {
it('computes HP from hit die + CON over levels and sets save proficiencies', () => {
const built = buildCharacter('5e', { className: 'Fighter', level: 1, abilities, skillChoices: ['athletics'] });
// d10 + CON 2 at level 1
expect(built.hp.max).toBe(12);
expect(built.saveRanks).toEqual({ str: 'trained', con: 'trained' });
expect(built.skillRanks).toEqual({ athletics: 'trained' });
expect(built.spellcasting.slots).toEqual([]);
});
it('gives a full caster spell slots and a spell ability', () => {
const built = buildCharacter('5e', { className: 'Wizard', level: 3, abilities, skillChoices: [] });
expect(built.spellcastingAbility).toBe('int');
expect(built.spellcasting.slots).toEqual([
{ level: 1, max: 4, current: 4 }, { level: 2, max: 2, current: 2 },
]);
});
it('gives a warlock pact magic', () => {
const built = buildCharacter('5e', { className: 'Warlock', level: 3, abilities, skillChoices: [] });
expect(built.spellcasting.pact).toEqual({ level: 2, max: 2, current: 2 });
});
});
describe('buildCharacter — pf2e', () => {
it('computes HP = ancestry + (class + CON) × level and sets save ranks/perception', () => {
const built = buildCharacter('pf2e', { className: 'Fighter', level: 1, abilities, skillChoices: ['athletics'], ancestryHp: 8 });
expect(built.hp.max).toBe(8 + (10 + 2) * 1); // 20
expect(built.saveRanks).toMatchObject({ con: 'expert', dex: 'expert', wis: 'trained' });
expect(built.perceptionRank).toBe('expert');
});
});
describe('planLevelUp', () => {
it('flags a 5e ASI at level 4 and updates caster slots', () => {
const plan = planLevelUp('5e', 'Wizard', 3, 2);
expect(plan.nextLevel).toBe(4);
expect(plan.choices.some((c) => c.kind === 'asi')).toBe(true);
expect(plan.slots?.length).toBeGreaterThan(0);
});
it('flags pf2e ability boosts at level 5 and a skill increase', () => {
const plan = planLevelUp('pf2e', 'Rogue', 4, 1);
expect(plan.nextLevel).toBe(5);
expect(plan.choices.some((c) => c.kind === 'boosts')).toBe(true);
expect(plan.choices.some((c) => c.kind === 'skill-increase')).toBe(true);
});
});
describe('apply helpers', () => {
it('5e increases add +1 per pick (same twice = +2)', () => {
expect(applyIncreases(abilities, ['str', 'str'], '5e').str).toBe(18);
expect(applyIncreases(abilities, ['dex', 'con'], '5e')).toMatchObject({ dex: 13, con: 15 });
});
it('pf2e boosts add +2 under 18, +1 at/over 18', () => {
expect(applyIncreases({ ...abilities, str: 16 }, ['str'], 'pf2e').str).toBe(18);
expect(applyIncreases({ ...abilities, str: 18 }, ['str'], 'pf2e').str).toBe(19);
});
it('bumpRank walks the ladder and caps at legendary', () => {
expect(bumpRank('untrained')).toBe('trained');
expect(bumpRank('expert')).toBe('master');
expect(bumpRank('legendary')).toBe('legendary');
});
});
+188
View File
@@ -0,0 +1,188 @@
import type { AbilityKey, AbilityScores, ProficiencyRank, SystemId } from './types';
import { abilityModifier } from './abilities';
import { DND5E_CLASSES, dnd5eSlots, dnd5ePact, dnd5eHp, dnd5eAsiLevels } from './dnd5e/progression';
import { PF2E_CLASSES, pf2eHp, pf2eSlots, pf2eBoostLevels, pf2eSkillIncreaseLevels } from './pf2e/progression';
export type CasterKind = 'none' | 'full' | 'half' | 'pact';
/** Spellcasting abilities allowed by the character schema. */
export type SpellAbility = 'int' | 'wis' | 'cha';
export interface ClassDef {
name: string;
/** 5e: hit die size (6/8/10/12). PF2e: HP gained per level. */
hitDie: number;
keyAbilities: AbilityKey[];
/** 5e: abilities whose saves become trained. */
saveProficient: AbilityKey[];
/** PF2e: initial save ranks keyed by the save's ability (con/dex/wis). */
saveRanks?: Partial<Record<AbilityKey, ProficiencyRank>>;
/** PF2e: initial perception rank. */
perception?: ProficiencyRank;
/** Skills to choose from (5e). Empty = choose from all (Bard / all PF2e). */
skillKeys: string[];
/** Number of trained skills to choose (PF2e adds INT mod on top at build time). */
skillCount: number;
caster: CasterKind;
spellAbility?: SpellAbility;
spellRank?: ProficiencyRank;
}
export interface SpellSlot { level: number; max: number; current: number }
export interface BuildChoices {
className: string;
level: number;
abilities: AbilityScores;
/** chosen skill keys → trained */
skillChoices: string[];
/** PF2e ancestry HP (from the ancestries data); ignored for 5e. */
ancestryHp?: number;
/** Used only when the class is unknown (custom): 5e hit die size. */
hitDieOverride?: number;
}
/** Derived character fields produced by the builder — a Partial<Character> patch. */
export interface BuiltCharacter {
abilities: AbilityScores;
hp: { current: number; max: number; temp: number };
skillRanks: Record<string, ProficiencyRank>;
saveRanks: Record<string, ProficiencyRank>;
perceptionRank: ProficiencyRank;
spellcastingAbility?: SpellAbility;
spellcastingRank?: ProficiencyRank;
spellcasting: { slots: SpellSlot[]; pact?: SpellSlot; spells: never[] };
}
const TABLES: Record<SystemId, ClassDef[]> = { '5e': DND5E_CLASSES, pf2e: PF2E_CLASSES };
export function getClassDefs(system: SystemId): ClassDef[] {
return TABLES[system];
}
export function getClassNames(system: SystemId): string[] {
return TABLES[system].map((c) => c.name);
}
export function getClassDef(system: SystemId, className: string): ClassDef | undefined {
const want = className.trim().toLowerCase();
return TABLES[system].find((c) => c.name.toLowerCase() === want);
}
/** Skills the class may choose from (full list when unrestricted). */
export function classSkillChoices(system: SystemId, allSkillKeys: string[], className: string): string[] {
const def = getClassDef(system, className);
if (def && def.skillKeys.length > 0) return def.skillKeys;
return allSkillKeys;
}
/** How many skills to pick (PF2e adds INT modifier, min 0). */
export function classSkillCount(system: SystemId, className: string, intMod: number): number {
const def = getClassDef(system, className);
const base = def?.skillCount ?? (system === 'pf2e' ? 3 : 2);
return system === 'pf2e' ? base + Math.max(0, intMod) : base;
}
/** Build the derived fields for a brand-new character from the wizard's choices. */
export function buildCharacter(system: SystemId, choices: BuildChoices): BuiltCharacter {
const def = getClassDef(system, choices.className);
const conMod = abilityModifier(choices.abilities.con);
const die = def?.hitDie ?? choices.hitDieOverride ?? (system === 'pf2e' ? 8 : 8);
const hpMax = system === 'pf2e'
? pf2eHp(die, choices.level, conMod, choices.ancestryHp ?? 8)
: dnd5eHp(die, choices.level, conMod);
const skillRanks: Record<string, ProficiencyRank> = {};
for (const key of choices.skillChoices) skillRanks[key] = 'trained';
const saveRanks: Record<string, ProficiencyRank> = {};
if (system === 'pf2e') {
const ranks: Partial<Record<AbilityKey, ProficiencyRank>> = def?.saveRanks ?? { con: 'trained', dex: 'trained', wis: 'trained' };
for (const [ability, rank] of Object.entries(ranks)) {
if (rank) saveRanks[ability] = rank;
}
} else {
for (const ability of def?.saveProficient ?? []) saveRanks[ability] = 'trained';
}
const caster = def?.caster ?? 'none';
const slots = system === 'pf2e' ? pf2eSlots(caster, choices.level) : dnd5eSlots(caster, choices.level);
const pact = system === '5e' && caster === 'pact' ? dnd5ePact(choices.level) : undefined;
return {
abilities: choices.abilities,
hp: { current: hpMax, max: hpMax, temp: 0 },
skillRanks,
saveRanks,
perceptionRank: system === 'pf2e' ? def?.perception ?? 'trained' : 'trained',
...(def?.spellAbility ? { spellcastingAbility: def.spellAbility, spellcastingRank: def.spellRank ?? 'trained' } : {}),
spellcasting: { slots, ...(pact ? { pact } : {}), spells: [] },
};
}
// ---------------- Level-up planning ----------------
export type LevelChoice =
| { kind: 'asi'; label: string }
| { kind: 'boosts'; count: number; label: string }
| { kind: 'skill-increase'; label: string }
| { kind: 'feat'; label: string };
export interface LevelUpPlan {
nextLevel: number;
hpGainAverage: number;
hitDie: number;
slots?: SpellSlot[];
pact?: SpellSlot;
notes: string[];
choices: LevelChoice[];
}
export function planLevelUp(system: SystemId, className: string, currentLevel: number, conMod: number): LevelUpPlan {
const nextLevel = Math.min(20, Math.floor(currentLevel) + 1);
const def = getClassDef(system, className);
const die = def?.hitDie ?? 8;
const caster = def?.caster ?? 'none';
const notes: string[] = [];
const choices: LevelChoice[] = [];
if (system === '5e') {
const hpGainAverage = Math.max(1, Math.ceil(die / 2) + 1 + conMod);
const slots = dnd5eSlots(caster, nextLevel);
const pact = caster === 'pact' ? dnd5ePact(nextLevel) : undefined;
if (dnd5eAsiLevels(className).includes(nextLevel)) {
choices.push({ kind: 'asi', label: 'Ability Score Improvement — raise abilities by +2 total, or take a feat instead.' });
}
if (slots.length) notes.push('Spell slots updated for the new level.');
return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), ...(pact ? { pact } : {}), notes, choices };
}
// PF2e
const hpGainAverage = Math.max(1, die + conMod);
const slots = pf2eSlots(caster, nextLevel);
if (pf2eBoostLevels().includes(nextLevel)) {
choices.push({ kind: 'boosts', count: 4, label: 'Ability boosts — raise four different abilities (+2 if under 18, otherwise +1).' });
}
if (pf2eSkillIncreaseLevels().includes(nextLevel)) {
choices.push({ kind: 'skill-increase', label: 'Skill increase — raise one skill to the next proficiency rank.' });
}
if (nextLevel % 2 === 0) choices.push({ kind: 'feat', label: 'Class feat — pick a new class feat for this level.' });
if (slots.length) notes.push('Spell slots updated for the new level.');
return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), notes, choices };
}
// ---------------- Apply helpers (pure) ----------------
const RANK_ORDER: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
export function bumpRank(rank: ProficiencyRank): ProficiencyRank {
const i = RANK_ORDER.indexOf(rank);
return RANK_ORDER[Math.min(RANK_ORDER.length - 1, i + 1)]!;
}
/** Apply ability increases. 5e: +1 per pick (pick the same twice for +2). PF2e: +2 if <18 else +1. */
export function applyIncreases(abilities: AbilityScores, picks: AbilityKey[], system: SystemId): AbilityScores {
const next = { ...abilities };
for (const k of picks) {
next[k] = system === 'pf2e' ? (next[k] >= 18 ? next[k] + 1 : next[k] + 2) : next[k] + 1;
}
return next;
}
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/main.tsx","./src/router.tsx","./src/vite-env.d.ts","./src/app/RootLayout.tsx","./src/components/CommandPalette.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/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./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/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.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/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.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/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.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/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.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/CommandPalette.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/components/ui/RollTray.tsx","./src/features/assistant/AssistantPage.tsx","./src/features/assistant/CampaignInsights.tsx","./src/features/assistant/EncounterTipCard.tsx","./src/features/assistant/useEncounterAdvisor.ts","./src/features/assistant/useLevelUpAdvisor.ts","./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/builder/CreationWizard.tsx","./src/features/characters/sheet/AbilityGenModal.tsx","./src/features/characters/sheet/AttacksSection.tsx","./src/features/characters/sheet/DefensesSection.tsx","./src/features/characters/sheet/InventorySection.tsx","./src/features/characters/sheet/LevelUpAdvisor.tsx","./src/features/characters/sheet/LevelUpModal.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/features/player/PlayerViewPage.tsx","./src/features/settings/AssistantSettings.tsx","./src/features/settings/SettingsPage.tsx","./src/features/world/CalendarPage.tsx","./src/features/world/DashboardPage.tsx","./src/features/world/HomebrewPage.tsx","./src/features/world/MapsPage.tsx","./src/features/world/NotesPage.tsx","./src/features/world/NpcsPage.tsx","./src/features/world/QuestsPage.tsx","./src/features/world/homebrew.ts","./src/features/world/hooks.ts","./src/lib/cn.ts","./src/lib/format.ts","./src/lib/ids.ts","./src/lib/rng.ts","./src/lib/sample.ts","./src/lib/useDebouncedCallback.ts","./src/lib/useRoll.ts","./src/lib/wikilinks.test.ts","./src/lib/wikilinks.ts","./src/lib/assistant/advisors.test.ts","./src/lib/assistant/advisors.ts","./src/lib/assistant/context.test.ts","./src/lib/assistant/context.ts","./src/lib/assistant/encounter.ts","./src/lib/assistant/levelup.test.ts","./src/lib/assistant/levelup.ts","./src/lib/assistant/patterns.test.ts","./src/lib/assistant/patterns.ts","./src/lib/assistant/prompts.test.ts","./src/lib/assistant/prompts.ts","./src/lib/combat/budget.test.ts","./src/lib/combat/budget.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/check.test.ts","./src/lib/dice/check.ts","./src/lib/dice/notation.test.ts","./src/lib/dice/notation.ts","./src/lib/io/backup.test.ts","./src/lib/io/backup.ts","./src/lib/io/character.test.ts","./src/lib/io/character.ts","./src/lib/io/file.ts","./src/lib/llm/client.test.ts","./src/lib/llm/client.ts","./src/lib/llm/types.ts","./src/lib/rules/abilities.ts","./src/lib/rules/abilityGen.test.ts","./src/lib/rules/abilityGen.ts","./src/lib/rules/conditions.ts","./src/lib/rules/depth.test.ts","./src/lib/rules/index.ts","./src/lib/rules/progression.test.ts","./src/lib/rules/progression.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/progression.ts","./src/lib/rules/dnd5e/skills.ts","./src/lib/rules/pf2e/index.ts","./src/lib/rules/pf2e/progression.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/lib/schemas/world.ts","./src/lib/sync/index.ts","./src/stores/assistantStore.ts","./src/stores/macroStore.ts","./src/stores/rollStore.ts","./src/stores/uiStore.ts","./vitest.setup.ts"],"version":"5.9.3"}