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) <noreply@anthropic.com>
This commit is contained in:
2026-06-08 16:04:28 +02:00
parent f01e8ffe32
commit 76efc459bb
10 changed files with 448 additions and 5 deletions
+3 -1
View File
@@ -15,7 +15,9 @@ COPY server/package.json ./
RUN npm install --omit=dev --no-audit --no-fund RUN npm install --omit=dev --no-audit --no-fund
COPY --from=build /app/server/dist ./dist COPY --from=build /app/server/dist ./dist
COPY --from=build /app/dist /app/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 USER node
EXPOSE 8787 EXPOSE 8787
CMD ["node", "dist/index.js"] CMD ["node", "dist/index.js"]
+6
View File
@@ -13,7 +13,10 @@ services:
- PORT=8787 - PORT=8787
- STATIC_DIR=/app/dist - STATIC_DIR=/app/dist
- NODE_ENV=production - NODE_ENV=production
- DATA_DIR=/data
- ALLOWED_ORIGINS=https://ttrpg.briggen.dev - ALLOWED_ORIGINS=https://ttrpg.briggen.dev
volumes:
- ttrpg-data:/data
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
labels: labels:
@@ -25,6 +28,9 @@ services:
networks: networks:
- proxy - proxy
volumes:
ttrpg-data:
networks: networks:
proxy: proxy:
external: true external: true
+41
View File
@@ -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);
});
});
+124
View File
@@ -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<string, User>(); // key: lowercased username
private byId = new Map<string, User>();
private queue: Promise<unknown> = 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<void> {
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<void> {
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<void>;
}
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<AuthResult | AuthError> {
await this.load();
if (!USERNAME_RE.test(username)) return { ok: false, code: 'bad-username', message: 'Username must be 332 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<AuthResult | AuthError> {
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<User | null> {
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<void> {
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<void> {
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<string | null> {
try { return await fs.readFile(this.blobFile(id), 'utf8'); } catch { return null; }
}
userCount(): number { return this.users.size; }
}
+53 -2
View File
@@ -2,23 +2,74 @@ import path from 'node:path';
import Fastify from 'fastify'; import Fastify from 'fastify';
import fastifyWebsocket from '@fastify/websocket'; import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static'; import fastifyStatic from '@fastify/static';
import type { FastifyRequest } from 'fastify';
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages'; import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
import { RoomHub, type Sender } from './rooms'; import { RoomHub, type Sender } from './rooms';
import { AccountStore } from './accounts';
const PORT = Number(process.env.PORT ?? 8787); const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist')); 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 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 // per-socket token bucket: 80 messages / 10s
const RATE = { capacity: 80, refillMs: 10_000 }; const RATE = { capacity: 80, refillMs: 10_000 };
export function buildServer() { export function buildServer() {
const app = Fastify({ bodyLimit: MAX_PAYLOAD }); const app = Fastify({ bodyLimit: BODY_LIMIT });
const hub = new RoomHub(); const hub = new RoomHub();
const accounts = new AccountStore(DATA_DIR);
void accounts.load();
const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000); const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000);
app.addHook('onClose', async () => clearInterval(sweeper)); app.addHook('onClose', async () => clearInterval(sweeper));
// Simple per-IP throttle for the account/cloud API.
const ipHits = new Map<string, { n: number; reset: number }>();
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(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
void app.register(async (instance) => { void app.register(async (instance) => {
+55
View File
@@ -4,10 +4,12 @@ import { useUiStore, type Theme } from '@/stores/uiStore';
import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup'; import { exportBackup, restoreBackup, clearAllData, BackupError } from '@/lib/io/backup';
import { pickTextFile } from '@/lib/io/file'; import { pickTextFile } from '@/lib/io/file';
import { seedSampleCampaign } from '@/lib/sample'; 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 { AssistantSettings } from './AssistantSettings';
import { Page, PageHeader } from '@/components/ui/Page'; import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/Button';
import { Modal } from '@/components/ui/Modal'; import { Modal } from '@/components/ui/Modal';
import { Input } from '@/components/ui/Input';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
export function SettingsPage() { export function SettingsPage() {
@@ -67,6 +69,8 @@ export function SettingsPage() {
<AssistantSettings /> <AssistantSettings />
<CloudSync />
<section className="rounded-lg border border-line bg-panel p-4"> <section className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data sources &amp; licenses</h2> <h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Data sources &amp; licenses</h2>
<ul className="space-y-1 text-sm text-muted"> <ul className="space-y-1 text-sm text-muted">
@@ -112,3 +116,54 @@ export function SettingsPage() {
</Page> </Page>
); );
} }
function CloudSync() {
const [user, setUser] = useState<string | null>(cloudUsername());
const [u, setU] = useState('');
const [p, setP] = useState('');
const [msg, setMsg] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [confirmPull, setConfirmPull] = useState(false);
const run = async (fn: () => Promise<void>) => {
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 (
<section className="rounded-lg border border-line bg-panel p-4">
<h2 className="mb-2 text-xs font-semibold uppercase tracking-wide text-muted">Cloud sync (optional)</h2>
<p className="mb-3 text-sm text-muted">Sign in to back up your campaigns and sync them across devices. Local-first stays the default nothing leaves this device until you push.</p>
{user ? (
<div className="space-y-2">
<p className="text-sm text-ink">Signed in as <span className="font-semibold">{user}</span></p>
<div className="flex flex-wrap gap-2">
<Button variant="primary" disabled={busy} onClick={push}> Back up to cloud</Button>
<Button variant="secondary" disabled={busy} onClick={() => setConfirmPull(true)}> Restore from cloud</Button>
<Button variant="ghost" disabled={busy} onClick={signOut}>Sign out</Button>
</div>
</div>
) : (
<div className="flex flex-wrap items-center gap-2">
<Input className="max-w-40" placeholder="Username" value={u} onChange={(e) => setU(e.target.value)} aria-label="Cloud username" />
<Input className="max-w-40" type="password" placeholder="Password" value={p} onChange={(e) => setP(e.target.value)} aria-label="Cloud password" />
<Button variant="primary" disabled={busy || !u || !p} onClick={() => auth('in')}>Sign in</Button>
<Button variant="secondary" disabled={busy || !u || !p} onClick={() => auth('up')}>Create account</Button>
</div>
)}
{msg && <p className="mt-2 text-sm text-accent">{msg}</p>}
<Modal open={confirmPull} onClose={() => setConfirmPull(false)} title="Restore from cloud?"
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>
</Modal>
</section>
);
}
+63
View File
@@ -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<void> {
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<number> {
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<boolean> {
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;
}
+13 -2
View File
@@ -1,6 +1,7 @@
import { characterSchema, type Character } from '@/lib/schemas'; import { characterSchema, type Character } from '@/lib/schemas';
import { newId } from '@/lib/ids'; import { newId } from '@/lib/ids';
import { downloadJson, safeFilename } from './file'; import { downloadJson, safeFilename } from './file';
import { isPathbuilder, pathbuilderToCharacterFields } from './pathbuilder';
const FORMAT = 'ttrpg-manager:character'; const FORMAT = 'ttrpg-manager:character';
const VERSION = 1; const VERSION = 1;
@@ -33,6 +34,18 @@ export function parseCharacterImport(text: string, campaignId: string): Characte
throw new CharacterImportError('That file is not valid JSON.'); 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 = const candidate =
raw && typeof raw === 'object' && 'character' in raw raw && typeof raw === 'object' && 'character' in raw
? (raw as { character: unknown }).character ? (raw as { character: unknown }).character
@@ -41,8 +54,6 @@ export function parseCharacterImport(text: string, campaignId: string): Characte
if (!candidate || typeof candidate !== 'object') { if (!candidate || typeof candidate !== 'object') {
throw new CharacterImportError('No character data found in that file.'); throw new CharacterImportError('No character data found in that file.');
} }
const now = new Date().toISOString();
const result = characterSchema.safeParse({ const result = characterSchema.safeParse({
...(candidate as Record<string, unknown>), ...(candidate as Record<string, unknown>),
id: newId(), id: newId(),
+38
View File
@@ -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);
});
});
+52
View File
@@ -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<number, Character['perceptionRank']> = { 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<string, number>;
attributes?: { ancestryhp?: number; classhp?: number; bonushp?: number; bonushpPerLevel?: number };
proficiencies?: Record<string, number>;
}
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<Character> {
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<string, Character['perceptionRank']> = {};
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<string, Character['perceptionRank']> = {};
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}` : ''}` } : {}),
};
}