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:
@@ -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