Phase 6: character creation & level-up

- Ability generation: standard array, point buy (27-pt, validated), 4d6kh3 roll;
  AbilityGenModal with assign-from-pool + steppers (pure abilityGen lib + tests)
- Level-up flow: increment level, HP gain (average or roll hit die + CON)
- Print / save-to-PDF: print button + @media print hides app chrome and roll tray
- Plan: added Phase 11 (data-driven Assistant) to the roadmap

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 01:56:23 +02:00
parent d5977e4c63
commit 522ff8abce
9 changed files with 331 additions and 4 deletions
+20 -2
View File
@@ -17,6 +17,8 @@ import { AttacksSection } from './sheet/AttacksSection';
import { ResourcesSection } from './sheet/ResourcesSection';
import { SpellcastingSection } from './sheet/SpellcastingSection';
import { DefensesSection } from './sheet/DefensesSection';
import { AbilityGenModal } from './sheet/AbilityGenModal';
import { LevelUpModal } from './sheet/LevelUpModal';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
@@ -33,6 +35,8 @@ const RANK_LABEL: Record<ProficiencyRank, string> = {
export function CharacterSheet({ character }: { character: Character }) {
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
const [c, setC] = useState<Character>(character);
const [levelUp, setLevelUp] = useState(false);
const [genScores, setGenScores] = useState(false);
const save = useDebouncedCallback((next: Character) => {
// Persist everything except identity/timestamps (update() stamps updatedAt).
const { id, campaignId: _campaignId, createdAt: _createdAt, updatedAt: _updatedAt, ...rest } = next;
@@ -84,7 +88,11 @@ export function CharacterSheet({ character }: { character: Character }) {
<span className="rounded-full bg-elevated px-2 py-0.5 text-xs uppercase tracking-wide text-muted">
{c.kind === 'pc' ? 'PC' : 'NPC'} · {sys.label}
</span>
<div className="ml-auto text-sm text-muted">{profLabel}</div>
<div className="ml-auto flex items-center gap-2">
<span className="text-sm text-muted">{profLabel}</span>
<Button size="sm" variant="secondary" onClick={() => setLevelUp(true)}>Level up</Button>
<Button size="sm" variant="ghost" onClick={() => window.print()} title="Print / save as PDF">Print</Button>
</div>
</div>
<div className="mb-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
@@ -118,7 +126,10 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Abilities */}
<section className="mb-6">
<SectionTitle>Ability Scores</SectionTitle>
<div className="mb-2 flex items-center justify-between">
<SectionTitle>Ability Scores</SectionTitle>
<Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>
</div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
{ABILITIES.map((a) => {
const mod = sys.abilityModifier(c.abilities[a]);
@@ -212,6 +223,13 @@ export function CharacterSheet({ character }: { character: Character }) {
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>
{genScores && (
<AbilityGenModal onClose={() => setGenScores(false)} onApply={(abilities) => update({ abilities })} />
)}
{levelUp && (
<LevelUpModal character={c} onClose={() => setLevelUp(false)} onApply={(patch) => update(patch)} />
)}
</Page>
);
}
@@ -0,0 +1,123 @@
import { useState } from 'react';
import type { AbilityKey, AbilityScores } from '@/lib/rules';
import { ABILITY_ABBR, abilityModifier } from '@/lib/rules';
import { createRng } from '@/lib/rng';
import {
STANDARD_ARRAY,
rollAbilityScores,
pointBuyRemaining,
POINT_BUY_MIN,
POINT_BUY_MAX,
} from '@/lib/rules/abilityGen';
import { formatModifier } from '@/lib/format';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { 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'];
type Method = 'standard' | 'roll' | 'pointbuy';
export function AbilityGenModal({ onApply, onClose }: { onApply: (scores: AbilityScores) => void; onClose: () => void }) {
const [method, setMethod] = useState<Method>('standard');
const [pool, setPool] = useState<number[]>([...STANDARD_ARRAY]);
// assignment[i] = pool index assigned to ABILITIES[i], or -1
const [assignment, setAssignment] = useState<number[]>(ABILITIES.map((_, i) => i));
const [pb, setPb] = useState<number[]>([8, 8, 8, 8, 8, 8]);
const chooseMethod = (m: Method) => {
setMethod(m);
if (m === 'standard') { setPool([...STANDARD_ARRAY]); setAssignment(ABILITIES.map((_, i) => i)); }
if (m === 'roll') { const p = rollAbilityScores(createRng()); setPool(p); setAssignment(ABILITIES.map((_, i) => i)); }
};
const reroll = () => { setPool(rollAbilityScores(createRng())); setAssignment(ABILITIES.map((_, i) => i)); };
const usesPool = method === 'standard' || method === 'roll';
const remaining = pointBuyRemaining(pb);
const toScores = (): AbilityScores => {
const out = {} as AbilityScores;
ABILITIES.forEach((a, i) => {
out[a] = usesPool ? (pool[assignment[i]!] ?? 10) : pb[i]!;
});
return out;
};
const poolValid = !usesPool || new Set(assignment).size === ABILITIES.length;
const pbValid = method !== 'pointbuy' || remaining >= 0;
const valid = poolValid && pbValid;
return (
<Modal
open
onClose={onClose}
title="Generate ability scores"
className="max-w-xl"
footer={
<>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={!valid} onClick={() => { onApply(toScores()); onClose(); }}>Apply</Button>
</>
}
>
<div className="mb-4 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={reroll}> Reroll</Button>
)}
{method === 'pointbuy' && (
<p className={cn('mb-3 text-sm', remaining < 0 ? 'text-danger' : 'text-muted')}>
Points remaining: <span className="font-semibold text-ink">{remaining}</span> / 27
</p>
)}
{usesPool && !poolValid && <p className="mb-3 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]!;
return (
<div key={a} className="rounded-lg border border-line bg-surface p-3 text-center">
<div className="text-xs font-semibold uppercase tracking-wide text-muted">{ABILITY_ABBR[a]}</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>
</Modal>
);
}
@@ -0,0 +1,67 @@
import { useState } from 'react';
import type { Character } from '@/lib/schemas';
import { abilityModifier } from '@/lib/rules';
import { rollDice } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Input';
const HIT_DICE = [6, 8, 10, 12] as const;
export function LevelUpModal({ character, onApply, onClose }: {
character: Character;
onApply: (patch: Partial<Character>) => void;
onClose: () => void;
}) {
const [die, setDie] = useState<number>(8);
const [method, setMethod] = useState<'average' | 'roll'>('average');
const conMod = abilityModifier(character.abilities.con);
const average = Math.ceil(die / 2) + 1; // 5e fixed value per level
const apply = () => {
const base = method === 'average' ? average : rollDice(`1d${die}`, createRng()).total;
const gain = Math.max(1, base + conMod);
onApply({
level: Math.min(20, character.level + 1),
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
});
onClose();
};
const preview = (method === 'average' ? average : `1d${die}`) + (conMod !== 0 ? ` ${conMod >= 0 ? '+' : ''}${conMod}` : '');
return (
<Modal
open
onClose={onClose}
title={`Level up to ${Math.min(20, character.level + 1)}`}
footer={
<>
<Button variant="ghost" onClick={onClose}>Cancel</Button>
<Button variant="primary" disabled={character.level >= 20} onClick={apply}>Level up</Button>
</>
}
>
<div className="space-y-4">
<label className="block text-xs text-muted">
Hit die
<Select value={die} onChange={(e) => setDie(Number(e.target.value))}>
{HIT_DICE.map((d) => <option key={d} value={d}>d{d}</option>)}
</Select>
</label>
<label className="block text-xs text-muted">
HP method
<Select value={method} onChange={(e) => setMethod(e.target.value as 'average' | 'roll')}>
<option value="average">Average (+{average})</option>
<option value="roll">Roll the hit die</option>
</Select>
</label>
<p className="text-sm text-muted">
HP gained: <span className="font-medium text-ink">{preview}</span> (CON {conMod >= 0 ? '+' : ''}{conMod})
</p>
{character.level >= 20 && <p className="text-sm text-warning">Already at level 20.</p>}
</div>
</Modal>
);
}