Phase 1: builder/UX quick wins (5e wording, no-campaign, details, ability UI)
- 5e skill/save proficiency shows 'Not proficient/Proficient/Expertise' instead of the
PF2e rank names (Untrained/Trained/Expert); PF2e keeps its ranks. (shared rankLabel)
- Characters can be created without a campaign: campaignId is optional ('' = unassigned),
CharactersPage no longer requires an active campaign, the wizard's campaign prop is
optional, and the sheet has a Campaign selector to attach/move later.
- New character details: appearance, personality/behaviour, alignment, and a real
background field (promoted out of notes); a Details wizard step + a Details sheet card.
- Ability step shows a clearer total + 'base · +race' breakdown.
- Point-buy verified correct (8:0..15:9 / 27, clamped 8-15, Next gated on budget).
All schema additions default-safe (characterDefaults updated). 329 tests green, build OK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -129,8 +129,7 @@ describe('RoomHub', () => {
|
||||
});
|
||||
|
||||
it('survives a player reconnect: the seat is reclaimed with the same stable playerId', () => {
|
||||
let t = 0;
|
||||
const hub = new RoomHub(() => t);
|
||||
const hub = new RoomHub(() => 0);
|
||||
const gm = fake(); hub.host(gm);
|
||||
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
|
||||
const PID = 'stable-player-1';
|
||||
|
||||
@@ -19,6 +19,8 @@ import { Badge, Meter } from '@/components/ui/Codex';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
import { NumberField } from '@/components/ui/NumberField';
|
||||
import { formatModifier } from '@/lib/format';
|
||||
import { useCampaigns } from '@/features/campaigns/hooks';
|
||||
import { rankLabel } from './sheet/labels';
|
||||
import { InventorySection } from './sheet/InventorySection';
|
||||
import { AttacksSection } from './sheet/AttacksSection';
|
||||
import { ResourcesSection } from './sheet/ResourcesSection';
|
||||
@@ -32,17 +34,11 @@ const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||
|
||||
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
|
||||
const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
|
||||
const RANK_LABEL: Record<ProficiencyRank, string> = {
|
||||
untrained: 'Untrained',
|
||||
trained: 'Trained',
|
||||
expert: 'Expert',
|
||||
master: 'Master',
|
||||
legendary: 'Legendary',
|
||||
};
|
||||
|
||||
export function CharacterSheet({ character }: { character: Character }) {
|
||||
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
|
||||
const [c, setC] = useState<Character>(character);
|
||||
const campaigns = useCampaigns();
|
||||
const [levelUp, setLevelUp] = useState(false);
|
||||
const [genScores, setGenScores] = useState(false);
|
||||
const [shared, setShared] = useState(false);
|
||||
@@ -184,6 +180,12 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
<Labeled label="Speed">
|
||||
<NumberField value={c.speed} min={0} onChange={(speed) => update({ speed })} />
|
||||
</Labeled>
|
||||
<Labeled label="Campaign">
|
||||
<Select value={c.campaignId} onChange={(e) => update({ campaignId: e.target.value })}>
|
||||
<option value="">Unassigned</option>
|
||||
{campaigns.map((cm) => <option key={cm.id} value={cm.id}>{cm.name}</option>)}
|
||||
</Select>
|
||||
</Labeled>
|
||||
</div>
|
||||
|
||||
{/* Vital stats */}
|
||||
@@ -313,6 +315,29 @@ export function CharacterSheet({ character }: { character: Character }) {
|
||||
{/* Inventory, currency, encumbrance */}
|
||||
<InventorySection c={c} update={update} />
|
||||
|
||||
{/* Details */}
|
||||
<section className="mb-6">
|
||||
<SectionTitle>Details</SectionTitle>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Labeled label="Background">
|
||||
<Input value={c.background} onChange={(e) => update({ background: e.target.value })} placeholder="Acolyte, Soldier…" />
|
||||
</Labeled>
|
||||
<Labeled label="Alignment">
|
||||
<Input value={c.alignment} onChange={(e) => update({ alignment: e.target.value })} placeholder="Chaotic Good, Unaligned…" />
|
||||
</Labeled>
|
||||
<Labeled label="Appearance">
|
||||
<textarea value={c.appearance} onChange={(e) => update({ appearance: e.target.value })} rows={3}
|
||||
placeholder="Looks, age, distinguishing features…"
|
||||
className="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" />
|
||||
</Labeled>
|
||||
<Labeled label="Personality & behaviour">
|
||||
<textarea value={c.personality} onChange={(e) => update({ personality: e.target.value })} rows={3}
|
||||
placeholder="Traits, ideals, bonds, flaws…"
|
||||
className="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" />
|
||||
</Labeled>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Notes */}
|
||||
<section className="mb-6">
|
||||
<SectionTitle>Notes</SectionTitle>
|
||||
@@ -427,7 +452,7 @@ function RankRow({
|
||||
<Select className="w-auto py-1 text-xs" value={rank} onChange={(e) => onRank(e.target.value as ProficiencyRank)}>
|
||||
{ranks.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{RANK_LABEL[r]}
|
||||
{rankLabel(r, system)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
@@ -7,20 +7,23 @@ import { getSystem } from '@/lib/rules';
|
||||
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
|
||||
import { pickTextFile } from '@/lib/io/file';
|
||||
import { useCharacters, useAllPcs } from './hooks';
|
||||
import { Page, PageHeader, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
||||
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||
import { Page, PageHeader, EmptyState } from '@/components/ui/Page';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Avatar, Badge } from '@/components/ui/Codex';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { CreationWizard } from './builder/CreationWizard';
|
||||
|
||||
// A campaign is NOT required: PCs are campaign-agnostic and can be created unassigned.
|
||||
export function CharactersPage() {
|
||||
return <RequireCampaign>{(campaign) => <CharactersList campaign={campaign} />}</RequireCampaign>;
|
||||
const campaign = useActiveCampaign();
|
||||
return <CharactersList campaign={campaign} />;
|
||||
}
|
||||
|
||||
function CharactersList({ campaign }: { campaign: Campaign }) {
|
||||
function CharactersList({ campaign }: { campaign?: Campaign | undefined }) {
|
||||
// PCs are campaign-agnostic — show every player character; NPCs stay per-campaign.
|
||||
const pcs = useAllPcs();
|
||||
const npcs = useCharacters(campaign.id).filter((c) => c.kind === 'npc');
|
||||
const npcs = useCharacters(campaign?.id ?? '').filter((c) => c.kind === 'npc');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
|
||||
@@ -29,7 +32,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
|
||||
const text = await pickTextFile();
|
||||
if (text === null) return;
|
||||
try {
|
||||
const character = parseCharacterImport(text, campaign.id);
|
||||
const character = parseCharacterImport(text, campaign?.id ?? '');
|
||||
await charactersRepo.insert(character);
|
||||
} catch (e) {
|
||||
setImportError(e instanceof CharacterImportError ? e.message : 'Import failed.');
|
||||
@@ -41,7 +44,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
|
||||
<PageHeader
|
||||
eyebrow="The Roster"
|
||||
title="Characters"
|
||||
subtitle={campaign.name}
|
||||
subtitle={campaign?.name ?? 'All player characters'}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={importCharacter}>
|
||||
@@ -78,6 +81,9 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
|
||||
)}
|
||||
|
||||
{creating && <CreationWizard campaign={campaign} onClose={() => setCreating(false)} />}
|
||||
{npcs.length === 0 && !campaign && pcs.length > 0 && (
|
||||
<p className="mt-4 text-xs text-muted">Tip: select or create a campaign to add NPCs and link this roster.</p>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,11 +66,11 @@ const TEMPLATES: Record<string, { label: string; hint: string; className: string
|
||||
],
|
||||
};
|
||||
|
||||
export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onClose: () => void }) {
|
||||
export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | undefined; onClose: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
// Characters are campaign-agnostic: the wizard asks which system to build for,
|
||||
// defaulting to the campaign's. Changing it resets all dependent selections.
|
||||
const [system, setSystem] = useState<SystemId>(campaign.system);
|
||||
const [system, setSystem] = useState<SystemId>(campaign?.system ?? '5e');
|
||||
const sys = getSystem(system);
|
||||
const allSkillKeys = sys.skills.map((s) => s.key);
|
||||
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? key;
|
||||
@@ -165,6 +165,9 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
const [name, setName] = useState('');
|
||||
const [kind, setKind] = useState<Character['kind']>('pc');
|
||||
const [level, setLevel] = useState(1);
|
||||
const [alignment, setAlignment] = useState('');
|
||||
const [appearance, setAppearance] = useState('');
|
||||
const [personality, setPersonality] = useState('');
|
||||
const [classSlug, setClassSlug] = useState('');
|
||||
const [subclass, setSubclass] = useState('');
|
||||
const [ancestry, setAncestry] = useState('');
|
||||
@@ -303,7 +306,7 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
const leveledSlots = useMemo(() => built.spellcasting.slots.reduce((n, s) => n + (s.level > 0 ? s.max : 0), 0), [built]);
|
||||
const suggestedSpells = Math.max(4, leveledSlots + 2);
|
||||
|
||||
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Review'], [isCaster]);
|
||||
const STEPS = useMemo(() => ['Class', 'Origin', 'Abilities', 'Skills', ...(isCaster ? ['Spells'] : []), 'Details', 'Review'], [isCaster]);
|
||||
const stepName = STEPS[Math.min(step, STEPS.length - 1)]!;
|
||||
|
||||
const changeSystem = (next: SystemId) => {
|
||||
@@ -339,12 +342,13 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
Abilities: system === 'pf2e' ? true : poolValid && pbValid,
|
||||
Skills: skills.length === Math.min(skillCount, skillOptions.length),
|
||||
Spells: true,
|
||||
Details: true,
|
||||
Review: true,
|
||||
};
|
||||
|
||||
const finish = async () => {
|
||||
if (!selectedClass) return;
|
||||
const created = await charactersRepo.create(campaign.id, {
|
||||
const created = await charactersRepo.create(campaign?.id, {
|
||||
system, name: name.trim() || selectedClass.name, kind,
|
||||
ancestry: ancestry.trim(), className: selectedClass.name, level,
|
||||
});
|
||||
@@ -354,11 +358,16 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
|
||||
}
|
||||
const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }));
|
||||
const notes = [subclass ? `Subclass: ${subclass}` : '', background ? `Background: ${background}` : ''].filter(Boolean).join('\n');
|
||||
// Subclass still rides in notes for now (Phase 3 moves it onto classes[]); background is a real field.
|
||||
const notes = subclass ? `Subclass: ${subclass}` : '';
|
||||
await charactersRepo.update(created.id, {
|
||||
...built,
|
||||
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
|
||||
spellcasting: { ...built.spellcasting, spells },
|
||||
...(background ? { background } : {}),
|
||||
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
|
||||
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
|
||||
...(personality.trim() ? { personality: personality.trim() } : {}),
|
||||
...(notes ? { notes } : {}),
|
||||
...(selectedOrigin?.speed ? { speed: selectedOrigin.speed } : {}),
|
||||
});
|
||||
@@ -596,8 +605,10 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
) : (
|
||||
<NumberField className="mt-1" value={pb[i]!} min={fMin} max={fMax} aria-label={`${ABILITY_ABBR[a]} score`} onChange={(v) => setPb((prev) => prev.map((x, j) => (j === i ? Math.max(fMin, Math.min(fMax, v)) : x)))} />
|
||||
)}
|
||||
<div className="mt-1 text-xs text-accent">
|
||||
{racial ? <span className="text-muted">{base}+{racial} · </span> : null}{formatModifier(abilityModifier(value))}
|
||||
<div className="mt-1">
|
||||
<span className="font-display text-sm font-semibold text-ink">{value}</span>
|
||||
<span className="ml-1 text-xs text-accent">{formatModifier(abilityModifier(value))}</span>
|
||||
{racial ? <div className="text-[10px] leading-tight text-muted">{base} base · +{racial} race</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -659,6 +670,32 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stepName === 'Details' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted">Optional flavour — you can fill any of this in later on the sheet.</p>
|
||||
<Field label="Alignment">
|
||||
<Select value={alignment} onChange={(e) => setAlignment(e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
{['Lawful Good', 'Neutral Good', 'Chaotic Good', 'Lawful Neutral', 'True Neutral', 'Chaotic Neutral', 'Lawful Evil', 'Neutral Evil', 'Chaotic Evil', 'Unaligned'].map((a) => (
|
||||
<option key={a} value={a}>{a}</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<label className="block text-xs text-muted">
|
||||
Appearance
|
||||
<textarea value={appearance} onChange={(e) => setAppearance(e.target.value)} rows={3}
|
||||
placeholder="Looks, age, distinguishing features…"
|
||||
className="mt-1 w-full rounded-md border border-line bg-surface px-2 py-1.5 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60" />
|
||||
</label>
|
||||
<label className="block text-xs text-muted">
|
||||
Personality & behaviour
|
||||
<textarea value={personality} onChange={(e) => setPersonality(e.target.value)} rows={3}
|
||||
placeholder="Traits, ideals, bonds, flaws, how they act…"
|
||||
className="mt-1 w-full rounded-md border border-line bg-surface px-2 py-1.5 text-sm text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60" />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stepName === 'Review' && selectedClass && (
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="rounded-lg border border-line bg-surface p-3">
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
import { rankLabel } from './labels';
|
||||
|
||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
|
||||
@@ -65,7 +66,7 @@ export function AttacksSection({ c, update }: SectionProps) {
|
||||
{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>)}
|
||||
{ranks.map((r) => <option key={r} value={r}>{rankLabel(r, c.system)}</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>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ProficiencyRank, SystemId } from '@/lib/rules';
|
||||
|
||||
const RANK_LABEL_PF2E: Record<ProficiencyRank, string> = {
|
||||
untrained: 'Untrained', trained: 'Trained', expert: 'Expert', master: 'Master', legendary: 'Legendary',
|
||||
};
|
||||
// 5e proficiency is binary; "Expertise" = doubled proficiency (stored as 'expert').
|
||||
const RANK_LABEL_5E: Record<ProficiencyRank, string> = {
|
||||
untrained: 'Not proficient', trained: 'Proficient', expert: 'Expertise', master: 'Expertise', legendary: 'Expertise',
|
||||
};
|
||||
|
||||
/** Display label for a proficiency rank in the active system (5e avoids PF2e rank names). */
|
||||
export function rankLabel(rank: ProficiencyRank, system: SystemId): string {
|
||||
return system === '5e' ? RANK_LABEL_5E[rank] : RANK_LABEL_PF2E[rank];
|
||||
}
|
||||
@@ -106,7 +106,7 @@ export const charactersRepo = {
|
||||
},
|
||||
|
||||
async create(
|
||||
campaignId: string,
|
||||
campaignId: string | undefined,
|
||||
draft: Pick<Character, 'name' | 'kind' | 'ancestry' | 'className' | 'level'> & {
|
||||
system: Character['system'];
|
||||
},
|
||||
@@ -115,7 +115,7 @@ export const charactersRepo = {
|
||||
const sys = getSystem(draft.system);
|
||||
const character = characterSchema.parse({
|
||||
id: newId(),
|
||||
campaignId,
|
||||
campaignId: campaignId ?? '',
|
||||
system: draft.system,
|
||||
kind: draft.kind,
|
||||
name: draft.name,
|
||||
|
||||
@@ -134,7 +134,8 @@ export type Defenses = z.infer<typeof defensesSchema>;
|
||||
|
||||
export const characterSchema = z.object({
|
||||
id: z.string(),
|
||||
campaignId: z.string(),
|
||||
/** Owning campaign; '' = unassigned (PCs can be created without a campaign and attached later). */
|
||||
campaignId: z.string().default(''),
|
||||
system: systemIdSchema,
|
||||
kind: characterKindSchema.default('pc'),
|
||||
|
||||
@@ -146,6 +147,14 @@ export const characterSchema = z.object({
|
||||
/** class, free text for MVP */
|
||||
className: z.string().max(80).default(''),
|
||||
level: int.min(1).max(20).default(1),
|
||||
/** background / heritage (promoted out of the notes string). */
|
||||
background: z.string().max(80).default(''),
|
||||
/** alignment, free text (e.g. "Chaotic Good", "Unaligned"). */
|
||||
alignment: z.string().max(40).default(''),
|
||||
/** physical description / looks. */
|
||||
appearance: z.string().max(4000).default(''),
|
||||
/** personality, behaviour, bonds, ideals, flaws. */
|
||||
personality: z.string().max(4000).default(''),
|
||||
|
||||
abilities: abilityScoresSchema,
|
||||
hp: hpSchema,
|
||||
@@ -204,9 +213,13 @@ 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' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions'
|
||||
'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' | 'background' | 'alignment' | 'appearance' | 'personality'
|
||||
> {
|
||||
return {
|
||||
background: '',
|
||||
alignment: '',
|
||||
appearance: '',
|
||||
personality: '',
|
||||
currency: { cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 },
|
||||
inventory: [],
|
||||
feats: [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user