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:
+3
-1
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 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<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
@@ -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<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(async (instance) => {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
<AssistantSettings />
|
||||
|
||||
<CloudSync />
|
||||
|
||||
<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 & licenses</h2>
|
||||
<ul className="space-y-1 text-sm text-muted">
|
||||
@@ -112,3 +116,54 @@ export function SettingsPage() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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<string, unknown>),
|
||||
id: newId(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}` : ''}` } : {}),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user