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', () => {
|
it('survives a player reconnect: the seat is reclaimed with the same stable playerId', () => {
|
||||||
let t = 0;
|
const hub = new RoomHub(() => 0);
|
||||||
const hub = new RoomHub(() => t);
|
|
||||||
const gm = fake(); hub.host(gm);
|
const gm = fake(); hub.host(gm);
|
||||||
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
|
const hosted = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
|
||||||
const PID = 'stable-player-1';
|
const PID = 'stable-player-1';
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { Badge, Meter } from '@/components/ui/Codex';
|
|||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
import { formatModifier } from '@/lib/format';
|
import { formatModifier } from '@/lib/format';
|
||||||
|
import { useCampaigns } from '@/features/campaigns/hooks';
|
||||||
|
import { rankLabel } from './sheet/labels';
|
||||||
import { InventorySection } from './sheet/InventorySection';
|
import { InventorySection } from './sheet/InventorySection';
|
||||||
import { AttacksSection } from './sheet/AttacksSection';
|
import { AttacksSection } from './sheet/AttacksSection';
|
||||||
import { ResourcesSection } from './sheet/ResourcesSection';
|
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_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
|
||||||
const RANKS_PF2E: ProficiencyRank[] = ['untrained', 'trained', 'expert', 'master', 'legendary'];
|
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 }) {
|
export function CharacterSheet({ character }: { character: Character }) {
|
||||||
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
|
// Local editable copy; write-through to the DB on change (debounced + flush on unmount).
|
||||||
const [c, setC] = useState<Character>(character);
|
const [c, setC] = useState<Character>(character);
|
||||||
|
const campaigns = useCampaigns();
|
||||||
const [levelUp, setLevelUp] = useState(false);
|
const [levelUp, setLevelUp] = useState(false);
|
||||||
const [genScores, setGenScores] = useState(false);
|
const [genScores, setGenScores] = useState(false);
|
||||||
const [shared, setShared] = useState(false);
|
const [shared, setShared] = useState(false);
|
||||||
@@ -184,6 +180,12 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
<Labeled label="Speed">
|
<Labeled label="Speed">
|
||||||
<NumberField value={c.speed} min={0} onChange={(speed) => update({ speed })} />
|
<NumberField value={c.speed} min={0} onChange={(speed) => update({ speed })} />
|
||||||
</Labeled>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Vital stats */}
|
{/* Vital stats */}
|
||||||
@@ -313,6 +315,29 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
{/* Inventory, currency, encumbrance */}
|
{/* Inventory, currency, encumbrance */}
|
||||||
<InventorySection c={c} update={update} />
|
<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 */}
|
{/* Notes */}
|
||||||
<section className="mb-6">
|
<section className="mb-6">
|
||||||
<SectionTitle>Notes</SectionTitle>
|
<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)}>
|
<Select className="w-auto py-1 text-xs" value={rank} onChange={(e) => onRank(e.target.value as ProficiencyRank)}>
|
||||||
{ranks.map((r) => (
|
{ranks.map((r) => (
|
||||||
<option key={r} value={r}>
|
<option key={r} value={r}>
|
||||||
{RANK_LABEL[r]}
|
{rankLabel(r, system)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -7,20 +7,23 @@ import { getSystem } from '@/lib/rules';
|
|||||||
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
|
import { exportCharacter, parseCharacterImport, CharacterImportError } from '@/lib/io/character';
|
||||||
import { pickTextFile } from '@/lib/io/file';
|
import { pickTextFile } from '@/lib/io/file';
|
||||||
import { useCharacters, useAllPcs } from './hooks';
|
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 { Button } from '@/components/ui/Button';
|
||||||
import { Avatar, Badge } from '@/components/ui/Codex';
|
import { Avatar, Badge } from '@/components/ui/Codex';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { CreationWizard } from './builder/CreationWizard';
|
import { CreationWizard } from './builder/CreationWizard';
|
||||||
|
|
||||||
|
// A campaign is NOT required: PCs are campaign-agnostic and can be created unassigned.
|
||||||
export function CharactersPage() {
|
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.
|
// PCs are campaign-agnostic — show every player character; NPCs stay per-campaign.
|
||||||
const pcs = useAllPcs();
|
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 [creating, setCreating] = useState(false);
|
||||||
const [importError, setImportError] = useState<string | null>(null);
|
const [importError, setImportError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -29,7 +32,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
|
|||||||
const text = await pickTextFile();
|
const text = await pickTextFile();
|
||||||
if (text === null) return;
|
if (text === null) return;
|
||||||
try {
|
try {
|
||||||
const character = parseCharacterImport(text, campaign.id);
|
const character = parseCharacterImport(text, campaign?.id ?? '');
|
||||||
await charactersRepo.insert(character);
|
await charactersRepo.insert(character);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setImportError(e instanceof CharacterImportError ? e.message : 'Import failed.');
|
setImportError(e instanceof CharacterImportError ? e.message : 'Import failed.');
|
||||||
@@ -41,7 +44,7 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow="The Roster"
|
eyebrow="The Roster"
|
||||||
title="Characters"
|
title="Characters"
|
||||||
subtitle={campaign.name}
|
subtitle={campaign?.name ?? 'All player characters'}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button variant="secondary" onClick={importCharacter}>
|
<Button variant="secondary" onClick={importCharacter}>
|
||||||
@@ -78,6 +81,9 @@ function CharactersList({ campaign }: { campaign: Campaign }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{creating && <CreationWizard campaign={campaign} onClose={() => setCreating(false)} />}
|
{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>
|
</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();
|
const navigate = useNavigate();
|
||||||
// Characters are campaign-agnostic: the wizard asks which system to build for,
|
// Characters are campaign-agnostic: the wizard asks which system to build for,
|
||||||
// defaulting to the campaign's. Changing it resets all dependent selections.
|
// 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 sys = getSystem(system);
|
||||||
const allSkillKeys = sys.skills.map((s) => s.key);
|
const allSkillKeys = sys.skills.map((s) => s.key);
|
||||||
const skillLabel = (key: string) => sys.skills.find((s) => s.key === key)?.label ?? 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 [name, setName] = useState('');
|
||||||
const [kind, setKind] = useState<Character['kind']>('pc');
|
const [kind, setKind] = useState<Character['kind']>('pc');
|
||||||
const [level, setLevel] = useState(1);
|
const [level, setLevel] = useState(1);
|
||||||
|
const [alignment, setAlignment] = useState('');
|
||||||
|
const [appearance, setAppearance] = useState('');
|
||||||
|
const [personality, setPersonality] = useState('');
|
||||||
const [classSlug, setClassSlug] = useState('');
|
const [classSlug, setClassSlug] = useState('');
|
||||||
const [subclass, setSubclass] = useState('');
|
const [subclass, setSubclass] = useState('');
|
||||||
const [ancestry, setAncestry] = 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 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 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 stepName = STEPS[Math.min(step, STEPS.length - 1)]!;
|
||||||
|
|
||||||
const changeSystem = (next: SystemId) => {
|
const changeSystem = (next: SystemId) => {
|
||||||
@@ -339,12 +342,13 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
Abilities: system === 'pf2e' ? true : poolValid && pbValid,
|
Abilities: system === 'pf2e' ? true : poolValid && pbValid,
|
||||||
Skills: skills.length === Math.min(skillCount, skillOptions.length),
|
Skills: skills.length === Math.min(skillCount, skillOptions.length),
|
||||||
Spells: true,
|
Spells: true,
|
||||||
|
Details: true,
|
||||||
Review: true,
|
Review: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const finish = async () => {
|
const finish = async () => {
|
||||||
if (!selectedClass) return;
|
if (!selectedClass) return;
|
||||||
const created = await charactersRepo.create(campaign.id, {
|
const created = await charactersRepo.create(campaign?.id, {
|
||||||
system, name: name.trim() || selectedClass.name, kind,
|
system, name: name.trim() || selectedClass.name, kind,
|
||||||
ancestry: ancestry.trim(), className: selectedClass.name, level,
|
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;
|
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 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, {
|
await charactersRepo.update(created.id, {
|
||||||
...built,
|
...built,
|
||||||
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
|
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
|
||||||
spellcasting: { ...built.spellcasting, spells },
|
spellcasting: { ...built.spellcasting, spells },
|
||||||
|
...(background ? { background } : {}),
|
||||||
|
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
|
||||||
|
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
|
||||||
|
...(personality.trim() ? { personality: personality.trim() } : {}),
|
||||||
...(notes ? { notes } : {}),
|
...(notes ? { notes } : {}),
|
||||||
...(selectedOrigin?.speed ? { speed: selectedOrigin.speed } : {}),
|
...(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)))} />
|
<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">
|
<div className="mt-1">
|
||||||
{racial ? <span className="text-muted">{base}+{racial} · </span> : null}{formatModifier(abilityModifier(value))}
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -659,6 +670,32 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
</div>
|
</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 && (
|
{stepName === 'Review' && selectedClass && (
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div className="rounded-lg border border-line bg-surface p-3">
|
<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 { Input, Select } from '@/components/ui/Input';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
import { NumberField } from '@/components/ui/NumberField';
|
||||||
import { SheetSection, type SectionProps } from './common';
|
import { SheetSection, type SectionProps } from './common';
|
||||||
|
import { rankLabel } from './labels';
|
||||||
|
|
||||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
||||||
const RANKS_5E: ProficiencyRank[] = ['untrained', 'trained', 'expert'];
|
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>)}
|
{ABILITIES.map((ab) => <option key={ab} value={ab}>{ABILITY_ABBR[ab]}</option>)}
|
||||||
</Select>
|
</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">
|
<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>
|
</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">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>
|
<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(
|
async create(
|
||||||
campaignId: string,
|
campaignId: string | undefined,
|
||||||
draft: Pick<Character, 'name' | 'kind' | 'ancestry' | 'className' | 'level'> & {
|
draft: Pick<Character, 'name' | 'kind' | 'ancestry' | 'className' | 'level'> & {
|
||||||
system: Character['system'];
|
system: Character['system'];
|
||||||
},
|
},
|
||||||
@@ -115,7 +115,7 @@ export const charactersRepo = {
|
|||||||
const sys = getSystem(draft.system);
|
const sys = getSystem(draft.system);
|
||||||
const character = characterSchema.parse({
|
const character = characterSchema.parse({
|
||||||
id: newId(),
|
id: newId(),
|
||||||
campaignId,
|
campaignId: campaignId ?? '',
|
||||||
system: draft.system,
|
system: draft.system,
|
||||||
kind: draft.kind,
|
kind: draft.kind,
|
||||||
name: draft.name,
|
name: draft.name,
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ export type Defenses = z.infer<typeof defensesSchema>;
|
|||||||
|
|
||||||
export const characterSchema = z.object({
|
export const characterSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
campaignId: z.string(),
|
/** Owning campaign; '' = unassigned (PCs can be created without a campaign and attached later). */
|
||||||
|
campaignId: z.string().default(''),
|
||||||
system: systemIdSchema,
|
system: systemIdSchema,
|
||||||
kind: characterKindSchema.default('pc'),
|
kind: characterKindSchema.default('pc'),
|
||||||
|
|
||||||
@@ -146,6 +147,14 @@ export const characterSchema = z.object({
|
|||||||
/** class, free text for MVP */
|
/** class, free text for MVP */
|
||||||
className: z.string().max(80).default(''),
|
className: z.string().max(80).default(''),
|
||||||
level: int.min(1).max(20).default(1),
|
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,
|
abilities: abilityScoresSchema,
|
||||||
hp: hpSchema,
|
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. */
|
/** Default values for the Phase-1 depth fields. Used by create + DB migration. */
|
||||||
export function characterDefaults(): Pick<
|
export function characterDefaults(): Pick<
|
||||||
Character,
|
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 {
|
return {
|
||||||
|
background: '',
|
||||||
|
alignment: '',
|
||||||
|
appearance: '',
|
||||||
|
personality: '',
|
||||||
currency: { cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 },
|
currency: { cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 },
|
||||||
inventory: [],
|
inventory: [],
|
||||||
feats: [],
|
feats: [],
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user