Cleanup: pf2e subclasses + all native dialogs → themed Modals
- pf2e classes now show the defining 1st-level choice (instinct / doctrine / bloodline / mystery / racket / implement / thesis / …) in the wizard's subclass picker, like 5e. Curated PF2E_SUBCLASSES in pf2e/progression.ts, merged into the class during the wizard's pf2e enrich step. - replaced every remaining native dialog with a themed Modal: - PlayerView session-password prompt → password Modal - Settings cloud-conflict confirm → "Use cloud / Keep this device" Modal - SessionControl host-password prompt → host Modal (optional password) - MapEditor text-label prompt → label Modal (captures the click point) No window.confirm / window.prompt / window.alert remain in the app. Realtime e2e updated for the host Modal (Host → Start hosting). Gate: 293 unit + 35 e2e + 2 realtime; tsc + app/server build clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,6 +20,7 @@ test('GM hosts a session; a player on another device sees live combat', async ({
|
|||||||
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
|
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
|
||||||
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
||||||
await gm.getByRole('button', { name: 'Host' }).click();
|
await gm.getByRole('button', { name: 'Host' }).click();
|
||||||
|
await gm.getByRole('button', { name: 'Start hosting' }).click();
|
||||||
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
|
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
|
||||||
expect(code).toHaveLength(6);
|
expect(code).toHaveLength(6);
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ test('a player claims a seat and manages their character; edits round-trip to th
|
|||||||
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
|
await gm.getByRole('button', { name: 'Load sample campaign' }).click();
|
||||||
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
await expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible();
|
||||||
await gm.getByRole('button', { name: 'Host' }).click();
|
await gm.getByRole('button', { name: 'Host' }).click();
|
||||||
|
await gm.getByRole('button', { name: 'Start hosting' }).click();
|
||||||
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
|
const code = (await gm.getByTestId('join-code').textContent())?.replace(/[^A-Z0-9]/g, '') ?? '';
|
||||||
expect(code).toHaveLength(6);
|
expect(code).toHaveLength(6);
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { newId } from '@/lib/ids';
|
|||||||
import { formatModifier } from '@/lib/format';
|
import { formatModifier } from '@/lib/format';
|
||||||
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
|
import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder';
|
||||||
import { briefOverview, dedupeByName } from './overview';
|
import { briefOverview, dedupeByName } from './overview';
|
||||||
|
import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Badge } from '@/components/ui/Codex';
|
import { Badge } from '@/components/ui/Codex';
|
||||||
@@ -69,12 +70,15 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl
|
|||||||
let on = true;
|
let on = true;
|
||||||
void loadClasses(system).then((c) => {
|
void loadClasses(system).then((c) => {
|
||||||
if (!on) return;
|
if (!on) return;
|
||||||
// The pf2e class data loads with caster:'none'; merge the curated caster
|
// The pf2e class data loads with caster:'none' and no subclasses; merge the
|
||||||
// ability so pf2e spellcasters get the Spells step + "Caster" tag like 5e.
|
// curated caster ability (so pf2e spellcasters get the Spells step + "Caster"
|
||||||
|
// tag) and the defining 1st-level choice list (instinct/doctrine/bloodline…).
|
||||||
const enriched = system === 'pf2e'
|
const enriched = system === 'pf2e'
|
||||||
? c.map((rc) => {
|
? c.map((rc) => {
|
||||||
const def = getClassDef('pf2e', rc.name);
|
const def = getClassDef('pf2e', rc.name);
|
||||||
return def && def.caster !== 'none' && def.spellAbility ? { ...rc, caster: def.spellAbility } : rc;
|
const subclasses = PF2E_SUBCLASSES[rc.name] ?? rc.subclasses;
|
||||||
|
const caster = def && def.caster !== 'none' && def.spellAbility ? def.spellAbility : rc.caster;
|
||||||
|
return { ...rc, caster, subclasses };
|
||||||
})
|
})
|
||||||
: c;
|
: c;
|
||||||
setClasses([...enriched].sort((a, b) => a.name.localeCompare(b.name)));
|
setClasses([...enriched].sort((a, b) => a.name.localeCompare(b.name)));
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { charactersRepo } from '@/lib/db/repositories';
|
|||||||
import { cloudUsername } from '@/lib/cloud/client';
|
import { cloudUsername } from '@/lib/cloud/client';
|
||||||
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
import { useActiveCampaign } from '@/features/campaigns/hooks';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
|
import { Modal } from '@/components/ui/Modal';
|
||||||
|
import { Input } from '@/components/ui/Input';
|
||||||
|
|
||||||
/** Header control: GM hosts a live session, shares the code, and approves seats. */
|
/** Header control: GM hosts a live session, shares the code, and approves seats. */
|
||||||
export function SessionControl() {
|
export function SessionControl() {
|
||||||
@@ -19,6 +21,8 @@ export function SessionControl() {
|
|||||||
const seatRequests = useSessionStore((s) => s.seatRequests);
|
const seatRequests = useSessionStore((s) => s.seatRequests);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [showSeats, setShowSeats] = useState(false);
|
const [showSeats, setShowSeats] = useState(false);
|
||||||
|
const [hostOpen, setHostOpen] = useState(false);
|
||||||
|
const [hostPw, setHostPw] = useState('');
|
||||||
|
|
||||||
if (role === 'player') return null; // players don't host
|
if (role === 'player') return null; // players don't host
|
||||||
|
|
||||||
@@ -33,12 +37,19 @@ export function SessionControl() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<Button size="sm" variant="ghost" title="Host a live session for players"
|
<>
|
||||||
onClick={() => {
|
<Button size="sm" variant="ghost" title="Host a live session for players" onClick={() => setHostOpen(true)}>
|
||||||
const pw = window.prompt('Optional player password (leave blank for none):')?.trim();
|
<RadioTower size={14} aria-hidden /> Host
|
||||||
hostSession(campaign.id, pw || undefined);
|
</Button>
|
||||||
}}
|
<Modal open={hostOpen} onClose={() => setHostOpen(false)} title="Host a live session" className="max-w-sm"
|
||||||
><RadioTower size={14} aria-hidden /> Host</Button>
|
footer={<>
|
||||||
|
<Button variant="ghost" onClick={() => setHostOpen(false)}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={() => { hostSession(campaign.id, hostPw.trim() || undefined); setHostOpen(false); setHostPw(''); }}>Start hosting</Button>
|
||||||
|
</>}>
|
||||||
|
<p className="mb-2 text-sm text-muted">Optionally require a password for players to join. Leave it blank for an open session.</p>
|
||||||
|
<Input type="password" autoFocus value={hostPw} onChange={(e) => setHostPw(e.target.value)} aria-label="Session password (optional)" placeholder="Password (optional)" />
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { useCalendar, useMaps } from '@/features/world/hooks';
|
|||||||
import { Maximize2 } from 'lucide-react';
|
import { Maximize2 } from 'lucide-react';
|
||||||
import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page';
|
import { Page, 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 { Input } from '@/components/ui/Input';
|
||||||
import { PlayerBoards } from './PlayerBoards';
|
import { PlayerBoards } from './PlayerBoards';
|
||||||
import { SeatClaimScreen } from './SeatClaimScreen';
|
import { SeatClaimScreen } from './SeatClaimScreen';
|
||||||
import { MyCharacterPanel } from './MyCharacterPanel';
|
import { MyCharacterPanel } from './MyCharacterPanel';
|
||||||
@@ -88,6 +90,7 @@ function NetworkedPlayerView() {
|
|||||||
const myCharacter = usePlayerSessionStore((s) => s.myCharacter);
|
const myCharacter = usePlayerSessionStore((s) => s.myCharacter);
|
||||||
const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
|
const seatStatus = usePlayerSessionStore((s) => s.seatStatus);
|
||||||
const [needPw, setNeedPw] = useState(false);
|
const [needPw, setNeedPw] = useState(false);
|
||||||
|
const [pwInput, setPwInput] = useState('');
|
||||||
|
|
||||||
// Locally-developed copies of party characters (for offline-edit handoff) PLUS
|
// Locally-developed copies of party characters (for offline-edit handoff) PLUS
|
||||||
// the player's own PCs, so they can push one the GM hasn't added yet.
|
// the player's own PCs, so they can push one the GM hasn't added yet.
|
||||||
@@ -97,12 +100,30 @@ function NetworkedPlayerView() {
|
|||||||
const localChars = new Map([...(localList ?? []), ...myPcs].map((c) => [c.id, c]));
|
const localChars = new Map([...(localList ?? []), ...myPcs].map((c) => [c.id, c]));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (error?.toLowerCase().includes('password') && !needPw) {
|
if (error?.toLowerCase().includes('password')) setNeedPw(true);
|
||||||
setNeedPw(true);
|
}, [error]);
|
||||||
const pw = window.prompt('This session needs a password:')?.trim();
|
const submitPassword = () => {
|
||||||
if (pw) { useSessionStore.getState().setJoinIntent({ joinCode: room, password: pw }); setNeedPw(false); }
|
const pw = pwInput.trim();
|
||||||
}
|
if (!pw) return;
|
||||||
}, [error, needPw, room]);
|
useSessionStore.getState().setJoinIntent({ joinCode: room, password: pw });
|
||||||
|
setPwInput('');
|
||||||
|
setNeedPw(false);
|
||||||
|
};
|
||||||
|
const passwordModal = needPw ? (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
onClose={() => setNeedPw(false)}
|
||||||
|
title="Session password"
|
||||||
|
className="max-w-sm"
|
||||||
|
footer={<>
|
||||||
|
<Button variant="ghost" onClick={() => setNeedPw(false)}>Cancel</Button>
|
||||||
|
<Button variant="primary" disabled={!pwInput.trim()} onClick={submitPassword}>Join</Button>
|
||||||
|
</>}
|
||||||
|
>
|
||||||
|
<p className="mb-2 text-sm text-muted">This session is password-protected. Enter the password your GM shared.</p>
|
||||||
|
<Input type="password" autoFocus value={pwInput} onChange={(e) => setPwInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submitPassword()} aria-label="Session password" placeholder="Password" />
|
||||||
|
</Modal>
|
||||||
|
) : null;
|
||||||
|
|
||||||
const handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId));
|
const handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId));
|
||||||
const handlePatch = (diff: Partial<Character>) => {
|
const handlePatch = (diff: Partial<Character>) => {
|
||||||
@@ -116,6 +137,7 @@ function NetworkedPlayerView() {
|
|||||||
return (
|
return (
|
||||||
<Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}>
|
<Shell title="Joining session…" subtitle={`Room ${room} · ${status}`}>
|
||||||
{error ? <EmptyState title="Couldn't join" hint={error} /> : <p className="text-sm text-muted">Connecting…</p>}
|
{error ? <EmptyState title="Couldn't join" hint={error} /> : <p className="text-sm text-muted">Connecting…</p>}
|
||||||
|
{passwordModal}
|
||||||
</Shell>
|
</Shell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ function CloudSync() {
|
|||||||
const [msg, setMsg] = useState<string | null>(null);
|
const [msg, setMsg] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [confirmPull, setConfirmPull] = useState(false);
|
const [confirmPull, setConfirmPull] = useState(false);
|
||||||
|
const [syncConflict, setSyncConflict] = useState(false);
|
||||||
|
|
||||||
const run = async (fn: () => Promise<void>) => {
|
const run = async (fn: () => Promise<void>) => {
|
||||||
setBusy(true); setMsg(null);
|
setBusy(true); setMsg(null);
|
||||||
@@ -199,9 +200,13 @@ function CloudSync() {
|
|||||||
const push = () => run(async () => {
|
const push = () => run(async () => {
|
||||||
const r = await pushBackup();
|
const r = await pushBackup();
|
||||||
if (r.ok) { setMsg(`Backed up to cloud (${Math.round(r.bytes / 1024)} KB).`); return; }
|
if (r.ok) { setMsg(`Backed up to cloud (${Math.round(r.bytes / 1024)} KB).`); return; }
|
||||||
// Conflict: the cloud changed on another device since this one last synced.
|
// Conflict: the cloud changed on another device since this one last synced —
|
||||||
const useCloud = window.confirm('The cloud copy was changed on another device since you last synced.\n\nOK — replace THIS device with the cloud copy.\nCancel — overwrite the CLOUD with this device.');
|
// surface a themed choice instead of a native confirm.
|
||||||
if (useCloud) { const ok = await pullBackup(); setMsg(ok ? 'Pulled the cloud copy — reloading…' : 'No cloud backup.'); if (ok) setTimeout(() => location.reload(), 600); }
|
setSyncConflict(true);
|
||||||
|
});
|
||||||
|
const resolveConflict = (which: 'cloud' | 'mine') => run(async () => {
|
||||||
|
setSyncConflict(false);
|
||||||
|
if (which === 'cloud') { const ok = await pullBackup(); setMsg(ok ? 'Pulled the cloud copy — reloading…' : 'No cloud backup.'); if (ok) setTimeout(() => location.reload(), 600); }
|
||||||
else { await pushBackup({ force: true }); setMsg('Overwrote the cloud with this device.'); }
|
else { await pushBackup({ force: true }); setMsg('Overwrote the cloud with this device.'); }
|
||||||
});
|
});
|
||||||
const pull = () => run(async () => { const ok = await pullBackup(); setMsg(ok ? 'Restored — reloading…' : 'No cloud backup yet.'); if (ok) setTimeout(() => location.reload(), 600); });
|
const pull = () => run(async () => { const ok = await pullBackup(); setMsg(ok ? 'Restored — reloading…' : 'No cloud backup yet.'); if (ok) setTimeout(() => location.reload(), 600); });
|
||||||
@@ -238,6 +243,15 @@ function CloudSync() {
|
|||||||
footer={<><Button variant="ghost" onClick={() => setConfirmPull(false)}>Cancel</Button><Button variant="danger" onClick={() => { setConfirmPull(false); void pull(); }}>Replace all data</Button></>}>
|
footer={<><Button variant="ghost" onClick={() => setConfirmPull(false)}>Cancel</Button><Button variant="danger" onClick={() => { setConfirmPull(false); void pull(); }}>Replace all data</Button></>}>
|
||||||
<p className="text-sm text-muted">This <strong className="text-ink">replaces all data on this device</strong> with your cloud backup.</p>
|
<p className="text-sm text-muted">This <strong className="text-ink">replaces all data on this device</strong> with your cloud backup.</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal open={syncConflict} onClose={() => setSyncConflict(false)} title="Cloud changed elsewhere"
|
||||||
|
footer={<>
|
||||||
|
<Button variant="ghost" onClick={() => setSyncConflict(false)}>Cancel</Button>
|
||||||
|
<Button variant="secondary" onClick={() => resolveConflict('cloud')}>Use cloud version</Button>
|
||||||
|
<Button variant="primary" onClick={() => resolveConflict('mine')}>Keep this device</Button>
|
||||||
|
</>}>
|
||||||
|
<p className="text-sm text-muted">The cloud copy was changed on another device since this one last synced. <strong className="text-ink">Use cloud version</strong> replaces this device with the cloud copy; <strong className="text-ink">Keep this device</strong> overwrites the cloud with this device.</p>
|
||||||
|
</Modal>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
const update = (patch: Partial<BattleMap>) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
|
const update = (patch: Partial<BattleMap>) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; });
|
||||||
|
|
||||||
const [tool, setTool] = useState<Tool>('move');
|
const [tool, setTool] = useState<Tool>('move');
|
||||||
|
const [textDraft, setTextDraft] = useState<MapDrawing['points'][number] | null>(null);
|
||||||
|
const [textValue, setTextValue] = useState('');
|
||||||
const [fogShape, setFogShape] = useState<FogShape>('brush');
|
const [fogShape, setFogShape] = useState<FogShape>('brush');
|
||||||
const [brush, setBrush] = useState(0);
|
const [brush, setBrush] = useState(0);
|
||||||
const [aoeShape, setAoeShape] = useState<AoeShape>('circle');
|
const [aoeShape, setAoeShape] = useState<AoeShape>('circle');
|
||||||
@@ -167,10 +169,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
}
|
}
|
||||||
if (tool === 'draw') {
|
if (tool === 'draw') {
|
||||||
if (drawKind === 'text') {
|
if (drawKind === 'text') {
|
||||||
if (phase === 'down') {
|
if (phase === 'down') setTextDraft(p.point);
|
||||||
const text = window.prompt('Label text:')?.trim();
|
|
||||||
if (text) update({ drawings: [...m.drawings, { id: newId(), kind: 'text', points: [p.point], color: drawColor, width: 4, text, gmOnly: gmDraw }] });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (phase === 'down') { draftPts.current = [p.point]; }
|
if (phase === 'down') { draftPts.current = [p.point]; }
|
||||||
@@ -222,6 +221,11 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false }] });
|
const addToken = () => update({ tokens: [...m.tokens, { id: newId(), label: String(m.tokens.length + 1), color: TOKEN_COLORS[m.tokens.length % TOKEN_COLORS.length]!, col: 0, row: 0, size: 1, kind: 'npc', conditions: [], gmOnly: false }] });
|
||||||
const addTokenSpec = (spec: TokenSpec) => update({ tokens: [...m.tokens, { id: newId(), ...spec }] });
|
const addTokenSpec = (spec: TokenSpec) => update({ tokens: [...m.tokens, { id: newId(), ...spec }] });
|
||||||
const addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); };
|
const addTokenSpecs = (specs: TokenSpec[]) => { if (specs.length) update({ tokens: [...m.tokens, ...specs.map((s) => ({ id: newId(), ...s }))] }); };
|
||||||
|
const addTextLabel = () => {
|
||||||
|
const t = textValue.trim();
|
||||||
|
if (t && textDraft) update({ drawings: [...m.drawings, { id: newId(), kind: 'text', points: [textDraft], color: drawColor, width: 4, text: t, gmOnly: gmDraw }] });
|
||||||
|
setTextDraft(null); setTextValue('');
|
||||||
|
};
|
||||||
const patchToken = (id: string, patch: Partial<MapToken>) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) });
|
const patchToken = (id: string, patch: Partial<MapToken>) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) });
|
||||||
const moveToken = (id: string, col: number, row: number) => setM((prev) => {
|
const moveToken = (id: string, col: number, row: number) => setM((prev) => {
|
||||||
const tokens = prev.tokens.map((t) => (t.id === id ? { ...t, col, row } : t));
|
const tokens = prev.tokens.map((t) => (t.id === id ? { ...t, col, row } : t));
|
||||||
@@ -402,6 +406,13 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
<Modal open={!!textDraft} onClose={() => { setTextDraft(null); setTextValue(''); }} title="Text label" className="max-w-sm"
|
||||||
|
footer={<>
|
||||||
|
<Button variant="ghost" onClick={() => { setTextDraft(null); setTextValue(''); }}>Cancel</Button>
|
||||||
|
<Button variant="primary" disabled={!textValue.trim()} onClick={addTextLabel}>Add</Button>
|
||||||
|
</>}>
|
||||||
|
<Input autoFocus value={textValue} onChange={(e) => setTextValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTextLabel()} aria-label="Label text" placeholder="Label text" />
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,3 +78,169 @@ export function pf2eBoostLevels(): number[] {
|
|||||||
export function pf2eSkillIncreaseLevels(): number[] {
|
export function pf2eSkillIncreaseLevels(): number[] {
|
||||||
return [3, 5, 7, 9, 11, 13, 15, 17, 19];
|
return [3, 5, 7, 9, 11, 13, 15, 17, 19];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The defining 1st-level choice for each pf2e class (instinct / doctrine /
|
||||||
|
* bloodline / …), so the builder can offer a subclass picker like 5e does.
|
||||||
|
* Curated — there is no class-feature data file on disk. Keyed by class name.
|
||||||
|
*/
|
||||||
|
export const PF2E_SUBCLASSES: Record<string, { name: string; desc: string }[]> = {
|
||||||
|
Alchemist: [
|
||||||
|
{ name: 'Bomber', desc: 'Throw alchemical bombs; splash and persistent damage are your trade.' },
|
||||||
|
{ name: 'Chirurgeon', desc: 'A battlefield medic whose elixirs and Crafting heal allies.' },
|
||||||
|
{ name: 'Mutagenist', desc: 'Drink your own mutagens to transform into a frontline brute.' },
|
||||||
|
{ name: 'Toxicologist', desc: 'Coat weapons with poisons and exploit creature weaknesses.' },
|
||||||
|
],
|
||||||
|
Animist: [
|
||||||
|
{ name: 'Practice of the Dead', desc: 'Commune with ancestral and psychopomp spirits.' },
|
||||||
|
{ name: 'Practice of the Wild', desc: 'Channel nature and beast apparitions.' },
|
||||||
|
{ name: 'Practice of Stars & Dreams', desc: 'Draw on cosmic and oneiric apparitions.' },
|
||||||
|
],
|
||||||
|
Barbarian: [
|
||||||
|
{ name: 'Animal Instinct', desc: 'Adopt an animal aspect; rage with natural unarmed attacks.' },
|
||||||
|
{ name: 'Dragon Instinct', desc: 'Breathe elemental damage and hit like a dragon.' },
|
||||||
|
{ name: 'Fury Instinct', desc: 'Pure unfocused rage — flexible, an extra class feat.' },
|
||||||
|
{ name: 'Giant Instinct', desc: 'Wield oversized weapons for the biggest raw damage.' },
|
||||||
|
{ name: 'Spirit Instinct', desc: 'Deal void/spirit damage; strong against undead.' },
|
||||||
|
{ name: 'Superstition Instinct', desc: 'Rage against magic — huge saves, but cannot be helped by spells.' },
|
||||||
|
],
|
||||||
|
Bard: [
|
||||||
|
{ name: 'Enigma', desc: 'A lore-master; True Strike and bonus knowledge.' },
|
||||||
|
{ name: 'Maestro', desc: 'A virtuoso of composition cantrips and inspire effects.' },
|
||||||
|
{ name: 'Polymath', desc: 'A versatile dabbler with a flexible repertoire.' },
|
||||||
|
{ name: 'Warrior', desc: 'A martial skald who fights on the front line.' },
|
||||||
|
],
|
||||||
|
Champion: [
|
||||||
|
{ name: 'Paladin (Holy)', desc: 'Retributive Strike protects allies who are struck.' },
|
||||||
|
{ name: 'Redeemer (Holy)', desc: 'Glimpse of Redemption shields and converts foes.' },
|
||||||
|
{ name: 'Liberator (Holy)', desc: 'Liberating Step frees allies from restraint.' },
|
||||||
|
{ name: 'Desecrator / Antipaladin (Unholy)', desc: 'Channel destructive reactions for an evil cause.' },
|
||||||
|
],
|
||||||
|
Cleric: [
|
||||||
|
{ name: 'Cloistered Cleric', desc: 'A full divine caster with a domain and font.' },
|
||||||
|
{ name: 'Warpriest', desc: 'A martial cleric with armor and weapon training.' },
|
||||||
|
],
|
||||||
|
Druid: [
|
||||||
|
{ name: 'Animal Order', desc: 'Gain an animal companion and beast magic.' },
|
||||||
|
{ name: 'Leaf Order', desc: 'A guardian of plants with a leshy familiar.' },
|
||||||
|
{ name: 'Storm Order', desc: 'Command wind and weather with storm magic.' },
|
||||||
|
{ name: 'Untamed (Wild) Order', desc: 'Shapeshift into wild forms with Untamed Form.' },
|
||||||
|
],
|
||||||
|
Exemplar: [
|
||||||
|
{ name: 'Ikon: Weapon', desc: 'Empower a signature weapon with your divine spark.' },
|
||||||
|
{ name: 'Ikon: Worn', desc: 'Channel the spark through worn regalia or armor.' },
|
||||||
|
{ name: 'Ikon: Body', desc: 'Make your own body the vessel of myth.' },
|
||||||
|
],
|
||||||
|
Gunslinger: [
|
||||||
|
{ name: 'Way of the Drifter', desc: 'A gun-and-blade skirmisher.' },
|
||||||
|
{ name: 'Way of the Pistolero', desc: 'A quick-draw duelist with one-handed firearms.' },
|
||||||
|
{ name: 'Way of the Sniper', desc: 'A patient marksman dealing huge aimed shots.' },
|
||||||
|
{ name: 'Way of the Vanguard', desc: 'A heavy-weapon bruiser with area shots.' },
|
||||||
|
{ name: 'Way of the Spellshot', desc: 'Fuse spells and bullets.' },
|
||||||
|
],
|
||||||
|
Inventor: [
|
||||||
|
{ name: 'Armor Innovation', desc: 'A walking tank in modified powered armor.' },
|
||||||
|
{ name: 'Construct Innovation', desc: 'Build a robotic companion that fights beside you.' },
|
||||||
|
{ name: 'Weapon Innovation', desc: 'A custom weapon you Overdrive for spikes of damage.' },
|
||||||
|
],
|
||||||
|
Investigator: [
|
||||||
|
{ name: 'Alchemical Sciences', desc: 'Brew quick alchemical items mid-case.' },
|
||||||
|
{ name: 'Empiricism', desc: 'Read situations with cold deduction.' },
|
||||||
|
{ name: 'Forensic Medicine', desc: 'A crime-scene healer who exploits wounds.' },
|
||||||
|
{ name: 'Interrogation', desc: 'Read and pressure people for the truth.' },
|
||||||
|
],
|
||||||
|
Kineticist: [
|
||||||
|
{ name: 'Air', desc: 'Lightning, wind, and mobility impulses.' },
|
||||||
|
{ name: 'Earth', desc: 'Stone and metal — durable and controlling.' },
|
||||||
|
{ name: 'Fire', desc: 'Raw blasting damage.' },
|
||||||
|
{ name: 'Metal', desc: 'Sharp, precise, armor-shredding impulses.' },
|
||||||
|
{ name: 'Water', desc: 'Cold and fluid control and healing.' },
|
||||||
|
{ name: 'Wood', desc: 'Plant growth, healing, and protection.' },
|
||||||
|
],
|
||||||
|
Magus: [
|
||||||
|
{ name: 'Inexorable Iron', desc: 'A two-handed weapon Spellstrike bruiser.' },
|
||||||
|
{ name: 'Laughing Shadow', desc: 'A mobile one-hand-free striker.' },
|
||||||
|
{ name: 'Sparkling Targe', desc: 'A sword-and-board magus with arcane shielding.' },
|
||||||
|
{ name: 'Starlit Span', desc: 'A ranged Spellstrike sniper.' },
|
||||||
|
{ name: 'Twisting Tree', desc: 'A staff/finesse martial artist.' },
|
||||||
|
],
|
||||||
|
Oracle: [
|
||||||
|
{ name: 'Ash', desc: 'Fire and ruin; the more cursed, the fiercer.' },
|
||||||
|
{ name: 'Battle', desc: 'A martial oracle who fights up close.' },
|
||||||
|
{ name: 'Bones', desc: 'Command death and the undead.' },
|
||||||
|
{ name: 'Cosmos', desc: 'Stars, gravity, and the void.' },
|
||||||
|
{ name: 'Flames', desc: 'Searing fire and light.' },
|
||||||
|
{ name: 'Life', desc: 'Healing magic at a mounting cost.' },
|
||||||
|
{ name: 'Tempest', desc: 'Storms, lightning, and the sea.' },
|
||||||
|
],
|
||||||
|
Psychic: [
|
||||||
|
{ name: 'Conscious Mind: Infinite Eye', desc: 'Divination and perception amps.' },
|
||||||
|
{ name: 'Conscious Mind: Oscillating Wave', desc: 'Fire and cold blasting amps.' },
|
||||||
|
{ name: 'Conscious Mind: Silent Whisper', desc: 'Mind-affecting force amps.' },
|
||||||
|
{ name: 'Subconscious Mind', desc: 'Your psyche source (Distant Grasp, Tangible Dream, …).' },
|
||||||
|
],
|
||||||
|
Ranger: [
|
||||||
|
{ name: "Flurry Edge", desc: 'Reduce multiple-attack penalty against your prey.' },
|
||||||
|
{ name: "Precision Edge", desc: 'Bonus damage on your first hit each round vs prey.' },
|
||||||
|
{ name: "Outwit Edge", desc: 'Skill and defensive bonuses against your prey.' },
|
||||||
|
],
|
||||||
|
Rogue: [
|
||||||
|
{ name: 'Ruffian', desc: 'A bruising rogue who sneak-attacks with simple weapons.' },
|
||||||
|
{ name: 'Scoundrel', desc: 'A face who feints foes Off-Guard.' },
|
||||||
|
{ name: 'Thief', desc: 'Adds Dexterity to damage; the classic nimble rogue.' },
|
||||||
|
{ name: 'Mastermind', desc: 'Recall Knowledge to make foes Off-Guard.' },
|
||||||
|
{ name: 'Eldritch Trickster', desc: 'A spellcasting multiclass rogue.' },
|
||||||
|
],
|
||||||
|
Sorcerer: [
|
||||||
|
{ name: 'Aberrant', desc: 'Occult magic of alien minds.' },
|
||||||
|
{ name: 'Angelic', desc: 'Divine magic of the heavens.' },
|
||||||
|
{ name: 'Demonic', desc: 'Divine magic of the Abyss.' },
|
||||||
|
{ name: 'Diabolic', desc: 'Divine magic of Hell — fire and contracts.' },
|
||||||
|
{ name: 'Draconic', desc: 'Arcane magic and a chosen dragon’s breath.' },
|
||||||
|
{ name: 'Elemental', desc: 'Primal magic of an element.' },
|
||||||
|
{ name: 'Fey', desc: 'Primal magic of the First World.' },
|
||||||
|
{ name: 'Imperial', desc: 'Arcane magic of mortal bloodlines.' },
|
||||||
|
{ name: 'Undead', desc: 'Divine magic touched by death.' },
|
||||||
|
],
|
||||||
|
Summoner: [
|
||||||
|
{ name: 'Beast Eidolon', desc: 'A bestial companion of fang and claw.' },
|
||||||
|
{ name: 'Construct Eidolon', desc: 'A made being of metal or stone.' },
|
||||||
|
{ name: 'Dragon Eidolon', desc: 'A draconic ally with a breath weapon.' },
|
||||||
|
{ name: 'Fey Eidolon', desc: 'A capricious First World spirit.' },
|
||||||
|
{ name: 'Plant Eidolon', desc: 'A verdant guardian.' },
|
||||||
|
{ name: 'Celestial / Fiend / Psychopomp', desc: 'An outsider bound to your soul.' },
|
||||||
|
],
|
||||||
|
Swashbuckler: [
|
||||||
|
{ name: 'Battledancer', desc: 'Gain panache by Performing.' },
|
||||||
|
{ name: 'Braggart', desc: 'Gain panache by Demoralizing foes.' },
|
||||||
|
{ name: 'Fencer', desc: 'Gain panache by Feinting.' },
|
||||||
|
{ name: 'Gymnast', desc: 'Gain panache by Tumbling and grappling.' },
|
||||||
|
{ name: 'Wit', desc: 'Gain panache by Bon Mot and clever talk.' },
|
||||||
|
],
|
||||||
|
Thaumaturge: [
|
||||||
|
{ name: 'Amulet', desc: 'Ward yourself and allies from harm.' },
|
||||||
|
{ name: 'Bell', desc: 'Disrupt and frighten foes with sound.' },
|
||||||
|
{ name: 'Chalice', desc: 'Heal and sustain by drinking from it.' },
|
||||||
|
{ name: 'Lantern', desc: 'Reveal the hidden and the invisible.' },
|
||||||
|
{ name: 'Mirror', desc: 'Send a duplicate to flank and confound.' },
|
||||||
|
{ name: 'Regalia', desc: 'Command presence that bolsters allies.' },
|
||||||
|
{ name: 'Tome', desc: 'Recall esoteric lore to exploit weaknesses.' },
|
||||||
|
{ name: 'Wand', desc: 'Blast foes with a stored spell.' },
|
||||||
|
{ name: 'Weapon', desc: 'A martial implement for esoteric strikes.' },
|
||||||
|
],
|
||||||
|
Witch: [
|
||||||
|
{ name: 'Patron: Faith’s Flamekeeper', desc: 'Divine lessons and fire.' },
|
||||||
|
{ name: 'Patron: Silence in Snow', desc: 'Primal cold lessons.' },
|
||||||
|
{ name: 'Patron: Spinner of Threads', desc: 'Occult fate lessons.' },
|
||||||
|
{ name: 'Patron: Starless Shadow', desc: 'Occult shadow lessons.' },
|
||||||
|
{ name: 'Patron: Wilding Steward', desc: 'Primal nature lessons.' },
|
||||||
|
{ name: 'Patron: The Resentment', desc: 'Occult curses and misfortune.' },
|
||||||
|
],
|
||||||
|
Wizard: [
|
||||||
|
{ name: 'Thesis: Improved Familiar Attunement', desc: 'A stronger familiar that grows with you.' },
|
||||||
|
{ name: 'Thesis: Metamagical Experimentation', desc: 'Swap in metamagic feats each day.' },
|
||||||
|
{ name: 'Thesis: Spell Blending', desc: 'Trade lower slots for higher-rank casting.' },
|
||||||
|
{ name: 'Thesis: Spell Substitution', desc: 'Re-prepare spells on the fly.' },
|
||||||
|
{ name: 'Thesis: Experimental Spellshaping', desc: 'Reshape spell areas and effects.' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user