diff --git a/e2e-realtime/realtime.spec.ts b/e2e-realtime/realtime.spec.ts index 01aceed..d4eb5d7 100644 --- a/e2e-realtime/realtime.spec.ts +++ b/e2e-realtime/realtime.spec.ts @@ -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 expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); 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, '') ?? ''; expect(code).toHaveLength(6); diff --git a/e2e-realtime/seats.spec.ts b/e2e-realtime/seats.spec.ts index 39812b5..26c8773 100644 --- a/e2e-realtime/seats.spec.ts +++ b/e2e-realtime/seats.spec.ts @@ -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 expect(gm.getByRole('heading', { name: 'Sample: Lost Mine' })).toBeVisible(); 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, '') ?? ''; expect(code).toHaveLength(6); diff --git a/src/features/characters/builder/CreationWizard.tsx b/src/features/characters/builder/CreationWizard.tsx index 9f9dc8d..36f02ed 100644 --- a/src/features/characters/builder/CreationWizard.tsx +++ b/src/features/characters/builder/CreationWizard.tsx @@ -15,6 +15,7 @@ import { newId } from '@/lib/ids'; import { formatModifier } from '@/lib/format'; import { getClassTip, raceSynergyNote } from '@/lib/assistant/builder'; import { briefOverview, dedupeByName } from './overview'; +import { PF2E_SUBCLASSES } from '@/lib/rules/pf2e/progression'; import { Modal } from '@/components/ui/Modal'; import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Codex'; @@ -69,12 +70,15 @@ export function CreationWizard({ campaign, onClose }: { campaign: Campaign; onCl let on = true; void loadClasses(system).then((c) => { if (!on) return; - // The pf2e class data loads with caster:'none'; merge the curated caster - // ability so pf2e spellcasters get the Spells step + "Caster" tag like 5e. + // The pf2e class data loads with caster:'none' and no subclasses; merge the + // 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' ? c.map((rc) => { 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; setClasses([...enriched].sort((a, b) => a.name.localeCompare(b.name))); diff --git a/src/features/play/SessionControl.tsx b/src/features/play/SessionControl.tsx index f9f0368..d3b2e08 100644 --- a/src/features/play/SessionControl.tsx +++ b/src/features/play/SessionControl.tsx @@ -8,6 +8,8 @@ import { charactersRepo } from '@/lib/db/repositories'; import { cloudUsername } from '@/lib/cloud/client'; import { useActiveCampaign } from '@/features/campaigns/hooks'; 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. */ export function SessionControl() { @@ -19,6 +21,8 @@ export function SessionControl() { const seatRequests = useSessionStore((s) => s.seatRequests); const [copied, setCopied] = 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 @@ -33,12 +37,19 @@ export function SessionControl() { ); } return ( - + <> + + setHostOpen(false)} title="Host a live session" className="max-w-sm" + footer={<> + + + }> +

Optionally require a password for players to join. Leave it blank for an open session.

+ setHostPw(e.target.value)} aria-label="Session password (optional)" placeholder="Password (optional)" /> +
+ ); } diff --git a/src/features/player/PlayerViewPage.tsx b/src/features/player/PlayerViewPage.tsx index 34149d4..986ccc9 100644 --- a/src/features/player/PlayerViewPage.tsx +++ b/src/features/player/PlayerViewPage.tsx @@ -14,6 +14,8 @@ import { useCalendar, useMaps } from '@/features/world/hooks'; import { Maximize2 } from 'lucide-react'; import { Page, EmptyState, RequireCampaign } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; +import { Input } from '@/components/ui/Input'; import { PlayerBoards } from './PlayerBoards'; import { SeatClaimScreen } from './SeatClaimScreen'; import { MyCharacterPanel } from './MyCharacterPanel'; @@ -88,6 +90,7 @@ function NetworkedPlayerView() { const myCharacter = usePlayerSessionStore((s) => s.myCharacter); const seatStatus = usePlayerSessionStore((s) => s.seatStatus); const [needPw, setNeedPw] = useState(false); + const [pwInput, setPwInput] = useState(''); // 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. @@ -97,12 +100,30 @@ function NetworkedPlayerView() { const localChars = new Map([...(localList ?? []), ...myPcs].map((c) => [c.id, c])); useEffect(() => { - if (error?.toLowerCase().includes('password') && !needPw) { - setNeedPw(true); - const pw = window.prompt('This session needs a password:')?.trim(); - if (pw) { useSessionStore.getState().setJoinIntent({ joinCode: room, password: pw }); setNeedPw(false); } - } - }, [error, needPw, room]); + if (error?.toLowerCase().includes('password')) setNeedPw(true); + }, [error]); + const submitPassword = () => { + const pw = pwInput.trim(); + if (!pw) return; + useSessionStore.getState().setJoinIntent({ joinCode: room, password: pw }); + setPwInput(''); + setNeedPw(false); + }; + const passwordModal = needPw ? ( + setNeedPw(false)} + title="Session password" + className="max-w-sm" + footer={<> + + + } + > +

This session is password-protected. Enter the password your GM shared.

+ setPwInput(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && submitPassword()} aria-label="Session password" placeholder="Password" /> +
+ ) : null; const handleClaim = (characterId: string) => claimSeat(characterId, localChars.get(characterId)); const handlePatch = (diff: Partial) => { @@ -116,6 +137,7 @@ function NetworkedPlayerView() { return ( {error ? :

Connecting…

} + {passwordModal}
); } diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index 99a187e..b2aed89 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -187,6 +187,7 @@ function CloudSync() { const [msg, setMsg] = useState(null); const [busy, setBusy] = useState(false); const [confirmPull, setConfirmPull] = useState(false); + const [syncConflict, setSyncConflict] = useState(false); const run = async (fn: () => Promise) => { setBusy(true); setMsg(null); @@ -199,9 +200,13 @@ function CloudSync() { const push = () => run(async () => { const r = await pushBackup(); 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. - 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.'); - if (useCloud) { const ok = await pullBackup(); setMsg(ok ? 'Pulled the cloud copy — reloading…' : 'No cloud backup.'); if (ok) setTimeout(() => location.reload(), 600); } + // Conflict: the cloud changed on another device since this one last synced — + // surface a themed choice instead of a native confirm. + 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.'); } }); 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={<>}>

This replaces all data on this device with your cloud backup.

+ + setSyncConflict(false)} title="Cloud changed elsewhere" + footer={<> + + + + }> +

The cloud copy was changed on another device since this one last synced. Use cloud version replaces this device with the cloud copy; Keep this device overwrites the cloud with this device.

+
); } diff --git a/src/features/world/map/MapEditor.tsx b/src/features/world/map/MapEditor.tsx index c81cbce..b0ca9dd 100644 --- a/src/features/world/map/MapEditor.tsx +++ b/src/features/world/map/MapEditor.tsx @@ -53,6 +53,8 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig const update = (patch: Partial) => setM((prev) => { const next = { ...prev, ...patch }; save(next); return next; }); const [tool, setTool] = useState('move'); + const [textDraft, setTextDraft] = useState(null); + const [textValue, setTextValue] = useState(''); const [fogShape, setFogShape] = useState('brush'); const [brush, setBrush] = useState(0); const [aoeShape, setAoeShape] = useState('circle'); @@ -167,10 +169,7 @@ export function MapEditor({ map, campaign }: { map: BattleMap; campaign: Campaig } if (tool === 'draw') { if (drawKind === 'text') { - if (phase === 'down') { - 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 }] }); - } + if (phase === 'down') setTextDraft(p.point); return; } 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 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 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) => update({ tokens: m.tokens.map((t) => (t.id === id ? { ...t, ...patch } : t)) }); const moveToken = (id: string, col: number, row: number) => setM((prev) => { 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 )} + { setTextDraft(null); setTextValue(''); }} title="Text label" className="max-w-sm" + footer={<> + + + }> + setTextValue(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && addTextLabel()} aria-label="Label text" placeholder="Label text" /> + ); } diff --git a/src/lib/rules/pf2e/progression.ts b/src/lib/rules/pf2e/progression.ts index 1a7b737..f8c4ffd 100644 --- a/src/lib/rules/pf2e/progression.ts +++ b/src/lib/rules/pf2e/progression.ts @@ -78,3 +78,169 @@ export function pf2eBoostLevels(): number[] { export function pf2eSkillIncreaseLevels(): number[] { 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 = { + 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.' }, + ], +};