From 76efc459bb00985ed900e37433145112873aaa53 Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Mon, 8 Jun 2026 16:04:28 +0200 Subject: [PATCH] Phase 10: accounts + cloud sync + Pathbuilder import - Lean file-backed accounts (server/src/accounts.ts): scrypt-hashed passwords, hashed bearer tokens, per-user backup blob; persisted under DATA_DIR. /api routes (register/login/logout, PUT/GET /save) with per-IP throttle + 48MB body limit. - Client cloud lib + Settings "Cloud sync": sign up / sign in, back up this device, restore from cloud (whole-backup push/pull, last-write-wins). Local-first default; the assistant key (localStorage) is never part of the synced Dexie backup. - Pathbuilder 2e import: the existing character import now detects a Pathbuilder build JSON and converts it to a PF2e character (abilities/HP/saves/skills). - Dockerfile creates a node-owned /data; compose mounts a named ttrpg-data volume. - (Spectator links = existing /play?room=CODE; PDF export = existing Print.) Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 4 +- deploy/ttrpg.compose.yml | 6 ++ server/src/accounts.test.ts | 41 ++++++++ server/src/accounts.ts | 124 +++++++++++++++++++++++++ server/src/index.ts | 55 ++++++++++- src/features/settings/SettingsPage.tsx | 55 +++++++++++ src/lib/cloud/client.ts | 63 +++++++++++++ src/lib/io/character.ts | 15 ++- src/lib/io/pathbuilder.test.ts | 38 ++++++++ src/lib/io/pathbuilder.ts | 52 +++++++++++ 10 files changed, 448 insertions(+), 5 deletions(-) create mode 100644 server/src/accounts.test.ts create mode 100644 server/src/accounts.ts create mode 100644 src/lib/cloud/client.ts create mode 100644 src/lib/io/pathbuilder.test.ts create mode 100644 src/lib/io/pathbuilder.ts diff --git a/Dockerfile b/Dockerfile index 2031747..638c256 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,9 @@ COPY server/package.json ./ RUN npm install --omit=dev --no-audit --no-fund COPY --from=build /app/server/dist ./dist COPY --from=build /app/dist /app/dist -RUN chown -R node:node /app +# Cloud-sync data dir; create it node-owned so a fresh named volume mounts writable. +ENV DATA_DIR=/data +RUN mkdir -p /data && chown -R node:node /app /data USER node EXPOSE 8787 CMD ["node", "dist/index.js"] diff --git a/deploy/ttrpg.compose.yml b/deploy/ttrpg.compose.yml index 7241e51..6b2d421 100644 --- a/deploy/ttrpg.compose.yml +++ b/deploy/ttrpg.compose.yml @@ -13,7 +13,10 @@ services: - PORT=8787 - STATIC_DIR=/app/dist - NODE_ENV=production + - DATA_DIR=/data - ALLOWED_ORIGINS=https://ttrpg.briggen.dev + volumes: + - ttrpg-data:/data security_opt: - no-new-privileges:true labels: @@ -25,6 +28,9 @@ services: networks: - proxy +volumes: + ttrpg-data: + networks: proxy: external: true diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts new file mode 100644 index 0000000..e485383 --- /dev/null +++ b/server/src/accounts.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { AccountStore } from './accounts'; + +describe('AccountStore', () => { + let dir: string; + let store: AccountStore; + beforeEach(async () => { dir = await fs.mkdtemp(path.join(os.tmpdir(), 'acct-')); store = new AccountStore(dir); }); + + it('registers, logs in, and rejects duplicates / bad input / bad creds', async () => { + expect((await store.register('alice', 'password123')).ok).toBe(true); + expect((await store.register('alice', 'password123')).ok).toBe(false); // taken + expect((await store.register('ab', 'password123')).ok).toBe(false); // username too short + expect((await store.register('bob', 'short')).ok).toBe(false); // weak password + expect((await store.login('alice', 'password123')).ok).toBe(true); + expect((await store.login('alice', 'nope')).ok).toBe(false); + }); + + it('stores + serves a per-user blob behind a bearer token', async () => { + const r = await store.register('carol', 'password123'); + if (!r.ok) throw new Error('register failed'); + const u = await store.userByToken(r.token); + expect(u).toBeTruthy(); + await store.saveBlob(u!.id, '{"x":1}'); + expect(await store.loadBlob(u!.id)).toBe('{"x":1}'); + expect(await store.userByToken('garbage-token')).toBeNull(); + }); + + it('persists users across instances + logout invalidates the token', async () => { + const r = await store.register('dave', 'password123'); + if (!r.ok) throw new Error('register failed'); + await store.logout(r.token); + expect(await store.userByToken(r.token)).toBeNull(); + + const store2 = new AccountStore(dir); + await store2.load(); + expect((await store2.login('dave', 'password123')).ok).toBe(true); + }); +}); diff --git a/server/src/accounts.ts b/server/src/accounts.ts new file mode 100644 index 0000000..d2f7161 --- /dev/null +++ b/server/src/accounts.ts @@ -0,0 +1,124 @@ +import crypto from 'node:crypto'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +/** + * Lean file-backed accounts + cloud-backup store. No external DB — a single + * users.json plus one blob file per user under DATA_DIR. Passwords are scrypt- + * hashed with a per-user salt; bearer tokens are stored hashed. Single-process + * only (writes are serialised through a promise queue). Good enough for a small + * self-hosted instance; the protocol leaves room to swap in a real DB later. + */ + +interface User { + id: string; + username: string; // display + salt: string; + hash: string; + tokens: string[]; // sha256(token) + createdAt: number; + updatedAt: number; +} + +const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/; +const MAX_TOKENS = 10; + +function sha256(s: string): string { return crypto.createHash('sha256').update(s).digest('hex'); } +function hashPassword(password: string, salt: string): string { return crypto.scryptSync(password, salt, 64).toString('hex'); } +function timingEqual(a: string, b: string): boolean { + const ab = Buffer.from(a), bb = Buffer.from(b); + return ab.length === bb.length && crypto.timingSafeEqual(ab, bb); +} + +export interface AuthResult { ok: true; token: string; username: string } +export interface AuthError { ok: false; code: string; message: string } + +export class AccountStore { + private users = new Map(); // key: lowercased username + private byId = new Map(); + private queue: Promise = Promise.resolve(); + private loaded = false; + constructor(private dir: string, private now: () => number = () => Date.now()) {} + + private usersFile() { return path.join(this.dir, 'users.json'); } + private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); } + + async load(): Promise { + if (this.loaded) return; + this.loaded = true; + try { + const raw = await fs.readFile(this.usersFile(), 'utf8'); + const arr = JSON.parse(raw) as User[]; + for (const u of arr) { this.users.set(u.username.toLowerCase(), u); this.byId.set(u.id, u); } + } catch { /* no file yet */ } + } + + private persist(): Promise { + this.queue = this.queue.then(async () => { + await fs.mkdir(this.dir, { recursive: true }); + const tmp = `${this.usersFile()}.tmp`; + await fs.writeFile(tmp, JSON.stringify([...this.users.values()])); + await fs.rename(tmp, this.usersFile()); + }).catch(() => {}); + return this.queue as Promise; + } + + private issueToken(u: User): string { + const token = crypto.randomBytes(32).toString('base64url'); + u.tokens.push(sha256(token)); + if (u.tokens.length > MAX_TOKENS) u.tokens = u.tokens.slice(-MAX_TOKENS); + u.updatedAt = this.now(); + return token; + } + + async register(username: string, password: string): Promise { + await this.load(); + if (!USERNAME_RE.test(username)) return { ok: false, code: 'bad-username', message: 'Username must be 3–32 letters, digits, . _ or -.' }; + if (typeof password !== 'string' || password.length < 8) return { ok: false, code: 'weak-password', message: 'Password must be at least 8 characters.' }; + if (this.users.has(username.toLowerCase())) return { ok: false, code: 'taken', message: 'That username is taken.' }; + const salt = crypto.randomBytes(16).toString('hex'); + const u: User = { id: crypto.randomUUID(), username, salt, hash: hashPassword(password, salt), tokens: [], createdAt: this.now(), updatedAt: this.now() }; + const token = this.issueToken(u); + this.users.set(username.toLowerCase(), u); + this.byId.set(u.id, u); + await this.persist(); + return { ok: true, token, username: u.username }; + } + + async login(username: string, password: string): Promise { + await this.load(); + const u = this.users.get((username ?? '').toLowerCase()); + if (!u || !timingEqual(u.hash, hashPassword(password ?? '', u.salt))) return { ok: false, code: 'bad-credentials', message: 'Wrong username or password.' }; + const token = this.issueToken(u); + await this.persist(); + return { ok: true, token, username: u.username }; + } + + async userByToken(token: string | undefined): Promise { + if (!token) return null; + await this.load(); + const h = sha256(token); + for (const u of this.users.values()) if (u.tokens.includes(h)) return u; + return null; + } + + async logout(token: string): Promise { + const u = await this.userByToken(token); + if (!u) return; + u.tokens = u.tokens.filter((t) => t !== sha256(token)); + await this.persist(); + } + + async saveBlob(id: string, blob: string): Promise { + await fs.mkdir(path.join(this.dir, 'blobs'), { recursive: true }); + const tmp = `${this.blobFile(id)}.tmp`; + await fs.writeFile(tmp, blob); + await fs.rename(tmp, this.blobFile(id)); + } + + async loadBlob(id: string): Promise { + try { return await fs.readFile(this.blobFile(id), 'utf8'); } catch { return null; } + } + + userCount(): number { return this.users.size; } +} diff --git a/server/src/index.ts b/server/src/index.ts index 7db1f3d..730ec23 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2,23 +2,74 @@ import path from 'node:path'; import Fastify from 'fastify'; import fastifyWebsocket from '@fastify/websocket'; import fastifyStatic from '@fastify/static'; +import type { FastifyRequest } from 'fastify'; import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages'; import { RoomHub, type Sender } from './rooms'; +import { AccountStore } from './accounts'; const PORT = Number(process.env.PORT ?? 8787); const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist')); -const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images) +const DATA_DIR = path.resolve(process.env.DATA_DIR ?? path.join(process.cwd(), 'data')); +const MAX_PAYLOAD = 12 * 1024 * 1024; // 12 MB (map images over WS) +const BODY_LIMIT = 48 * 1024 * 1024; // 48 MB (cloud backup blobs) const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean); +const bearer = (req: FastifyRequest): string | undefined => { + const h = req.headers.authorization; + return h?.startsWith('Bearer ') ? h.slice(7) : undefined; +}; + // per-socket token bucket: 80 messages / 10s const RATE = { capacity: 80, refillMs: 10_000 }; export function buildServer() { - const app = Fastify({ bodyLimit: MAX_PAYLOAD }); + const app = Fastify({ bodyLimit: BODY_LIMIT }); const hub = new RoomHub(); + const accounts = new AccountStore(DATA_DIR); + void accounts.load(); const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000); app.addHook('onClose', async () => clearInterval(sweeper)); + // Simple per-IP throttle for the account/cloud API. + const ipHits = new Map(); + app.addHook('onRequest', async (req, reply) => { + if (!req.url.startsWith('/api/')) return; + const now = Date.now(); + const e = ipHits.get(req.ip); + if (!e || now > e.reset) ipHits.set(req.ip, { n: 1, reset: now + 60_000 }); + else if (++e.n > 60) await reply.code(429).send({ error: 'rate' }); + }); + + // ---- accounts + cloud backup sync ---- + app.post('/api/register', async (req, reply) => { + const { username, password } = (req.body ?? {}) as { username?: string; password?: string }; + const r = await accounts.register(String(username ?? ''), String(password ?? '')); + if (!r.ok) return reply.code(400).send({ error: r.code, message: r.message }); + return { token: r.token, username: r.username }; + }); + app.post('/api/login', async (req, reply) => { + const { username, password } = (req.body ?? {}) as { username?: string; password?: string }; + const r = await accounts.login(String(username ?? ''), String(password ?? '')); + if (!r.ok) return reply.code(401).send({ error: r.code, message: r.message }); + return { token: r.token, username: r.username }; + }); + app.post('/api/logout', async (req) => { const t = bearer(req); if (t) await accounts.logout(t); return { ok: true }; }); + app.put('/api/save', async (req, reply) => { + const u = await accounts.userByToken(bearer(req)); + if (!u) return reply.code(401).send({ error: 'unauthorized' }); + const blob = (req.body as { blob?: string } | undefined)?.blob; + if (typeof blob !== 'string') return reply.code(400).send({ error: 'bad-blob' }); + await accounts.saveBlob(u.id, blob); + return { ok: true, size: blob.length }; + }); + app.get('/api/save', async (req, reply) => { + const u = await accounts.userByToken(bearer(req)); + if (!u) return reply.code(401).send({ error: 'unauthorized' }); + const blob = await accounts.loadBlob(u.id); + if (blob === null) return reply.code(404).send({ error: 'no-save' }); + return reply.header('content-type', 'application/json').send(blob); + }); + void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } }); void app.register(async (instance) => { diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index d5ff615..0b03635 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -4,10 +4,12 @@ import { useUiStore, type Theme } from '@/stores/uiStore'; import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup'; import { pickTextFile } from '@/lib/io/file'; import { seedSampleCampaign } from '@/lib/sample'; +import { cloudUsername, register as cloudRegister, login as cloudLogin, logout as cloudLogout, pushBackup, pullBackup, CloudError } from '@/lib/cloud/client'; import { AssistantSettings } from './AssistantSettings'; import { Page, PageHeader } from '@/components/ui/Page'; import { Button } from '@/components/ui/Button'; import { Modal } from '@/components/ui/Modal'; +import { Input } from '@/components/ui/Input'; import { cn } from '@/lib/cn'; export function SettingsPage() { @@ -67,6 +69,8 @@ export function SettingsPage() { + +

Data sources & licenses

    @@ -112,3 +116,54 @@ export function SettingsPage() { ); } + +function CloudSync() { + const [user, setUser] = useState(cloudUsername()); + const [u, setU] = useState(''); + const [p, setP] = useState(''); + const [msg, setMsg] = useState(null); + const [busy, setBusy] = useState(false); + const [confirmPull, setConfirmPull] = useState(false); + + const run = async (fn: () => Promise) => { + setBusy(true); setMsg(null); + try { await fn(); } catch (e) { setMsg(e instanceof CloudError ? e.message : 'Something went wrong.'); } finally { setBusy(false); } + }; + const auth = (which: 'in' | 'up') => run(async () => { + const r = await (which === 'in' ? cloudLogin(u, p) : cloudRegister(u, p)); + setUser(r.username); setP(''); setMsg(which === 'up' ? 'Account created.' : 'Signed in.'); + }); + const push = () => run(async () => { const n = await pushBackup(); setMsg(`Backed up to cloud (${Math.round(n / 1024)} KB).`); }); + const pull = () => run(async () => { const ok = await pullBackup(); setMsg(ok ? 'Restored — reloading…' : 'No cloud backup yet.'); if (ok) setTimeout(() => location.reload(), 600); }); + const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); }); + + return ( +
    +

    Cloud sync (optional)

    +

    Sign in to back up your campaigns and sync them across devices. Local-first stays the default — nothing leaves this device until you push.

    + {user ? ( +
    +

    Signed in as {user}

    +
    + + + +
    +
    + ) : ( +
    + setU(e.target.value)} aria-label="Cloud username" /> + setP(e.target.value)} aria-label="Cloud password" /> + + +
    + )} + {msg &&

    {msg}

    } + + setConfirmPull(false)} title="Restore from cloud?" + footer={<>}> +

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

    +
    +
    + ); +} diff --git a/src/lib/cloud/client.ts b/src/lib/cloud/client.ts new file mode 100644 index 0000000..19c9dcf --- /dev/null +++ b/src/lib/cloud/client.ts @@ -0,0 +1,63 @@ +import { buildBackup, restoreBackup } from '@/lib/io/backup'; + +/** + * Optional cloud account + backup sync against the same-origin server (/api). + * Local-first stays the default: this only does anything when the user signs in. + * Sync is a whole-backup push/pull (last-write-wins), not a live merge. + */ + +const TOKEN_KEY = 'ttrpg-cloud-token'; +const USER_KEY = 'ttrpg-cloud-user'; +const base = () => `${location.origin}/api`; + +export function cloudUsername(): string | null { return localStorage.getItem(USER_KEY); } +function token(): string | null { return localStorage.getItem(TOKEN_KEY); } +function setSession(t: string, username: string): void { localStorage.setItem(TOKEN_KEY, t); localStorage.setItem(USER_KEY, username); } +function clearSession(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); } + +export class CloudError extends Error {} + +async function authPost(path: string, body: unknown): Promise<{ token: string; username: string }> { + let res: Response; + try { + res = await fetch(`${base()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }); + } catch { + throw new CloudError('Could not reach the server.'); + } + const data = (await res.json().catch(() => ({}))) as { token?: string; username?: string; message?: string }; + if (!res.ok || !data.token) throw new CloudError(data.message ?? 'Request failed.'); + setSession(data.token, data.username ?? ''); + return { token: data.token, username: data.username ?? '' }; +} + +export function register(username: string, password: string) { return authPost('/register', { username, password }); } +export function login(username: string, password: string) { return authPost('/login', { username, password }); } + +export async function logout(): Promise { + const t = token(); + if (t) { try { await fetch(`${base()}/logout`, { method: 'POST', headers: { authorization: `Bearer ${t}` } }); } catch { /* ignore */ } } + clearSession(); +} + +/** Upload this device's full backup to the cloud. */ +export async function pushBackup(): Promise { + const t = token(); + if (!t) throw new CloudError('Not signed in.'); + const blob = JSON.stringify(await buildBackup()); + const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob }) }); + if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); } + if (!res.ok) throw new CloudError('Upload failed.'); + return blob.length; +} + +/** Replace this device's data with the cloud copy. Returns false if there's no cloud save yet. */ +export async function pullBackup(): Promise { + const t = token(); + if (!t) throw new CloudError('Not signed in.'); + const res = await fetch(`${base()}/save`, { headers: { authorization: `Bearer ${t}` } }); + if (res.status === 404) return false; + if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); } + if (!res.ok) throw new CloudError('Download failed.'); + await restoreBackup(await res.text()); + return true; +} diff --git a/src/lib/io/character.ts b/src/lib/io/character.ts index 6dce007..f9a0c90 100644 --- a/src/lib/io/character.ts +++ b/src/lib/io/character.ts @@ -1,6 +1,7 @@ import { characterSchema, type Character } from '@/lib/schemas'; import { newId } from '@/lib/ids'; import { downloadJson, safeFilename } from './file'; +import { isPathbuilder, pathbuilderToCharacterFields } from './pathbuilder'; const FORMAT = 'ttrpg-manager:character'; const VERSION = 1; @@ -33,6 +34,18 @@ export function parseCharacterImport(text: string, campaignId: string): Characte throw new CharacterImportError('That file is not valid JSON.'); } + const now = new Date().toISOString(); + + // Pathbuilder 2e export → convert to our PF2e character shape. + if (isPathbuilder(raw)) { + const result = characterSchema.safeParse({ + ...pathbuilderToCharacterFields(raw.build), + id: newId(), campaignId, createdAt: now, updatedAt: now, + }); + if (!result.success) throw new CharacterImportError(`Pathbuilder import failed: ${result.error.issues[0]?.message ?? 'unknown error'}`); + return result.data; + } + const candidate = raw && typeof raw === 'object' && 'character' in raw ? (raw as { character: unknown }).character @@ -41,8 +54,6 @@ export function parseCharacterImport(text: string, campaignId: string): Characte if (!candidate || typeof candidate !== 'object') { throw new CharacterImportError('No character data found in that file.'); } - - const now = new Date().toISOString(); const result = characterSchema.safeParse({ ...(candidate as Record), id: newId(), diff --git a/src/lib/io/pathbuilder.test.ts b/src/lib/io/pathbuilder.test.ts new file mode 100644 index 0000000..cae9bd2 --- /dev/null +++ b/src/lib/io/pathbuilder.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { isPathbuilder, pathbuilderToCharacterFields } from './pathbuilder'; +import { parseCharacterImport } from './character'; + +const file = { + build: { + name: 'Valeros', class: 'Fighter', level: 3, ancestry: 'Human', background: 'Soldier', + abilities: { str: 18, dex: 14, con: 14, int: 10, wis: 12, cha: 10 }, + attributes: { ancestryhp: 8, classhp: 10, bonushp: 0, bonushpPerLevel: 0 }, + proficiencies: { perception: 4, fortitude: 4, reflex: 4, will: 2, athletics: 4, intimidation: 2 }, + }, +}; + +describe('pathbuilder import', () => { + it('detects and converts a build', () => { + expect(isPathbuilder(file)).toBe(true); + expect(isPathbuilder({ build: {} })).toBe(false); + const f = pathbuilderToCharacterFields(file.build); + expect(f.system).toBe('pf2e'); + expect(f.name).toBe('Valeros'); + expect(f.className).toBe('Fighter'); + expect(f.level).toBe(3); + expect(f.abilities!.str).toBe(18); + expect(f.hp!.max).toBe(44); // 8 ancestry + (10 class + 2 con)*3 + expect(f.saveRanks!.con).toBe('expert'); + expect(f.saveRanks!.wis).toBe('trained'); + expect(f.skillRanks!.athletics).toBe('expert'); + expect(f.perceptionRank).toBe('expert'); + }); + + it('parseCharacterImport handles a Pathbuilder file end-to-end', () => { + const c = parseCharacterImport(JSON.stringify(file), 'cmp1'); + expect(c.name).toBe('Valeros'); + expect(c.system).toBe('pf2e'); + expect(c.campaignId).toBe('cmp1'); + expect(c.hp.max).toBe(44); + }); +}); diff --git a/src/lib/io/pathbuilder.ts b/src/lib/io/pathbuilder.ts new file mode 100644 index 0000000..a69a236 --- /dev/null +++ b/src/lib/io/pathbuilder.ts @@ -0,0 +1,52 @@ +import type { Character } from '@/lib/schemas'; + +/** Convert a Pathbuilder 2e export (build JSON) into our PF2e character fields. */ + +const RANK: Record = { 0: 'untrained', 2: 'trained', 4: 'expert', 6: 'master', 8: 'legendary' }; +const PF2E_SKILLS = ['acrobatics', 'arcana', 'athletics', 'crafting', 'deception', 'diplomacy', 'intimidation', 'medicine', 'nature', 'occultism', 'performance', 'religion', 'society', 'stealth', 'survival', 'thievery']; + +export interface PathbuilderBuild { + name?: string; class?: string; level?: number; ancestry?: string; heritage?: string; background?: string; + abilities?: Record; + attributes?: { ancestryhp?: number; classhp?: number; bonushp?: number; bonushpPerLevel?: number }; + proficiencies?: Record; +} + +export function isPathbuilder(raw: unknown): raw is { build: PathbuilderBuild } { + return !!raw && typeof raw === 'object' && 'build' in raw && !!(raw as { build?: { class?: unknown } }).build?.class; +} + +const rank = (v: number | undefined): Character['perceptionRank'] => RANK[v ?? 0] ?? 'untrained'; + +export function pathbuilderToCharacterFields(b: PathbuilderBuild): Partial { + const a = b.abilities ?? {}; + const abilities = { str: a.str ?? 10, dex: a.dex ?? 10, con: a.con ?? 10, int: a.int ?? 10, wis: a.wis ?? 10, cha: a.cha ?? 10 }; + const level = Math.max(1, Math.min(20, b.level ?? 1)); + const conMod = Math.floor((abilities.con - 10) / 2); + const at = b.attributes ?? {}; + const hpMax = Math.max(1, (at.ancestryhp ?? 0) + (at.bonushp ?? 0) + ((at.classhp ?? 8) + (at.bonushpPerLevel ?? 0) + conMod) * level); + const prof = b.proficiencies ?? {}; + + const saveRanks: Record = {}; + if (prof.fortitude) saveRanks.con = rank(prof.fortitude); + if (prof.reflex) saveRanks.dex = rank(prof.reflex); + if (prof.will) saveRanks.wis = rank(prof.will); + + const skillRanks: Record = {}; + for (const s of PF2E_SKILLS) if (prof[s]) skillRanks[s] = rank(prof[s]); + + return { + system: 'pf2e', + kind: 'pc', + name: b.name?.trim() || 'Imported Character', + className: b.class ?? '', + ancestry: b.ancestry ?? '', + level, + abilities, + hp: { current: hpMax, max: hpMax, temp: 0 }, + saveRanks: saveRanks as Character['saveRanks'], + skillRanks: skillRanks as Character['skillRanks'], + perceptionRank: rank(prof.perception) === 'untrained' ? 'trained' : rank(prof.perception), + ...(b.background ? { notes: `Background: ${b.background}${b.heritage ? `\nHeritage: ${b.heritage}` : ''}` } : {}), + }; +}