Merge: perfection pass + backend security hardening + admin panel

This commit is contained in:
2026-06-10 20:55:58 +02:00
39 changed files with 1459 additions and 159 deletions
+10
View File
@@ -17,10 +17,20 @@ services:
- ALLOWED_ORIGINS=https://ttrpg.briggen.dev
# comma-separated usernames that can see the admin panel (set to your account)
- ADMIN_USERS=nilsb
# default per-user cloud storage cap (bytes); admins override per user in the dashboard
- DEFAULT_QUOTA_BYTES=31457280 # 30 MB
# hard ceiling on accounts so open registration can't fill the disk
- MAX_USERS=500
volumes:
- ttrpg-data:/data
security_opt:
- no-new-privileges:true
# Hard caps so a runaway session can't starve the host or the shared Traefik
# stack — the blast radius stays this container, then `restart: unless-stopped`.
mem_limit: 512m
memswap_limit: 512m
pids_limit: 200
cpus: 1.0
labels:
- "traefik.enable=true"
- "traefik.http.routers.ttrpg.rule=Host(`ttrpg.briggen.dev`)"
+52
View File
@@ -54,4 +54,56 @@ describe('AccountStore', () => {
expect(await adminStore.setQuota('ghost', 1)).toBe(false);
expect((await adminStore.listUsers()).find((u) => u.username === 'peon')?.quotaBytes).toBe(5_000_000_000);
});
it('quotaFor uses the override when set, else the default', async () => {
const r = await store.register('quotaUser', 'password123');
if (!r.ok) throw new Error('register failed');
const u = (await store.userByToken(r.token))!;
const def = store.quotaFor(u); // instance default
expect(def).toBeGreaterThan(0);
await store.setQuota('quotaUser', 99);
expect(store.quotaFor((await store.userByToken(r.token))!)).toBe(99);
});
it('refuses registration past the max-users cap', async () => {
const capped = new AccountStore(dir, undefined, [], 1);
expect((await capped.register('first', 'password123')).ok).toBe(true);
const second = await capped.register('second', 'password123');
expect(second.ok).toBe(false);
expect((second as { code: string }).code).toBe('closed');
});
it('revokes all tokens (log out everywhere)', async () => {
const r = await store.register('revokee', 'password123');
if (!r.ok) throw new Error('register failed');
expect(await store.userByToken(r.token)).toBeTruthy();
expect(await store.revokeTokens('revokee')).toBe(true);
expect(await store.userByToken(r.token)).toBeNull();
expect(await store.revokeTokens('ghost')).toBe(false);
expect((await store.login('revokee', 'password123')).ok).toBe(true); // password still works
});
it('deletes a user + their blob, but never an admin', async () => {
const s = new AccountStore(dir, undefined, ['boss2']);
await s.register('boss2', 'password123');
const r = await s.register('victim', 'password123');
if (!r.ok) throw new Error('register failed');
const u = (await s.userByToken(r.token))!;
await s.saveBlob(u.id, '{"x":1}');
expect(await s.deleteUser('boss2')).toBeNull(); // admins are protected
const removedId = await s.deleteUser('victim');
expect(removedId).toBe(u.id);
expect(await s.userByToken(r.token)).toBeNull(); // tokens dead
expect(await s.loadBlob(u.id)).toBeNull(); // blob gone
expect((await s.login('victim', 'password123')).ok).toBe(false); // account gone
});
it('lists detailed rows for the admin panel', async () => {
const r = await store.register('detailed', 'password123');
if (!r.ok) throw new Error('register failed');
const row = (await store.listUsersDetailed()).find((x) => x.username === 'detailed')!;
expect(row.tokenCount).toBe(1);
expect(row.admin).toBe(false);
expect(row.createdAt).toBeGreaterThan(0);
});
});
+84 -14
View File
@@ -1,7 +1,12 @@
import crypto from 'node:crypto';
import { promisify } from 'node:util';
import { promises as fs } from 'node:fs';
import path from 'node:path';
// Async scrypt so password hashing runs on the threadpool, NOT the single event
// loop — a burst of logins can't freeze the whole server (scryptSync did).
const scryptAsync = promisify(crypto.scrypt) as (pw: crypto.BinaryLike, salt: crypto.BinaryLike, keylen: number) => Promise<Buffer>;
/**
* 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-
@@ -23,9 +28,11 @@ interface User {
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
const MAX_TOKENS = 10;
/** Default per-user cloud storage cap when no admin override is set. */
export const DEFAULT_QUOTA_BYTES = Number(process.env.DEFAULT_QUOTA_BYTES) || 30 * 1024 * 1024;
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'); }
async function hashPassword(password: string, salt: string): Promise<string> { return (await scryptAsync(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);
@@ -37,20 +44,65 @@ 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 byToken = new Map<string, User>(); // sha256(token) → user, for O(1) auth lookups
private queue: Promise<unknown> = Promise.resolve();
private loaded = false;
constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = []) {}
constructor(private dir: string, private now: () => number = () => Date.now(), private admins: string[] = [], private maxUsers = Infinity) {}
private usersFile() { return path.join(this.dir, 'users.json'); }
private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); }
isAdmin(username: string): boolean { return this.admins.includes(username.toLowerCase()); }
/** This user's storage limit: their admin override, else the instance default. */
quotaFor(u: User): number { return u.quotaBytes && u.quotaBytes > 0 ? u.quotaBytes : DEFAULT_QUOTA_BYTES; }
/** Whether a new account can be created (registration is open under the cap). */
atCapacity(): boolean { return this.users.size >= this.maxUsers; }
async listUsers(): Promise<Array<{ id: string; username: string; quotaBytes?: number }>> {
await this.load();
return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) }));
}
/** Admin detail rows — everything the panel shows except usage (caller joins that). */
async listUsersDetailed(): Promise<Array<{ id: string; username: string; createdAt: number; updatedAt: number; quotaBytes: number; tokenCount: number; admin: boolean }>> {
await this.load();
return [...this.users.values()].map((u) => ({
id: u.id, username: u.username, createdAt: u.createdAt, updatedAt: u.updatedAt,
quotaBytes: u.quotaBytes ?? 0, tokenCount: u.tokens.length, admin: this.isAdmin(u.username),
}));
}
/** Invalidate every session token for a user ("log out everywhere"). */
async revokeTokens(username: string): Promise<boolean> {
await this.load();
const u = this.users.get(username.toLowerCase());
if (!u) return false;
for (const h of u.tokens) this.byToken.delete(h);
u.tokens = [];
u.updatedAt = this.now();
await this.persist();
return true;
}
/**
* Delete an account and its backup blob. Admin accounts can't be deleted from the
* panel (protects against lockout + a compromised admin nuking another admin).
* Returns the removed user's id so the caller can purge their cloud data too.
*/
async deleteUser(username: string): Promise<string | null> {
await this.load();
const u = this.users.get(username.toLowerCase());
if (!u || this.isAdmin(u.username)) return null;
for (const h of u.tokens) this.byToken.delete(h);
this.users.delete(u.username.toLowerCase());
this.byId.delete(u.id);
try { await fs.unlink(this.blobFile(u.id)); } catch { /* no blob */ }
await this.persist();
return u.id;
}
async setQuota(username: string, bytes: number): Promise<boolean> {
await this.load();
const u = this.users.get(username.toLowerCase());
@@ -72,8 +124,15 @@ export class AccountStore {
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 */ }
for (const u of arr) {
this.users.set(u.username.toLowerCase(), u);
this.byId.set(u.id, u);
for (const h of u.tokens) this.byToken.set(h, u);
}
} catch (e) {
// ENOENT on first boot is expected; anything else is a real problem worth seeing.
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[accounts] load failed', e);
}
}
private persist(): Promise<void> {
@@ -82,14 +141,19 @@ export class AccountStore {
const tmp = `${this.usersFile()}.tmp`;
await fs.writeFile(tmp, JSON.stringify([...this.users.values()]));
await fs.rename(tmp, this.usersFile());
}).catch(() => {});
}).catch((e) => { console.error('[accounts] persist failed — data may be lost on restart', e); });
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);
const h = sha256(token);
u.tokens.push(h);
this.byToken.set(h, u);
if (u.tokens.length > MAX_TOKENS) {
for (const stale of u.tokens.slice(0, u.tokens.length - MAX_TOKENS)) this.byToken.delete(stale);
u.tokens = u.tokens.slice(-MAX_TOKENS);
}
u.updatedAt = this.now();
return token;
}
@@ -99,8 +163,9 @@ export class AccountStore {
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.' };
if (this.atCapacity()) return { ok: false, code: 'closed', message: 'Registration is temporarily closed (instance at capacity).' };
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 u: User = { id: crypto.randomUUID(), username, salt, hash: await 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);
@@ -111,7 +176,11 @@ export class AccountStore {
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.' };
// Hash even when the user is unknown (against a throwaway salt) so the response
// time doesn't reveal whether a username exists.
const salt = u?.salt ?? 'absent';
const candidate = await hashPassword(password ?? '', salt);
if (!u || !timingEqual(u.hash, candidate)) 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 };
@@ -120,15 +189,16 @@ export class AccountStore {
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;
return this.byToken.get(sha256(token)) ?? null;
}
async logout(token: string): Promise<void> {
const u = await this.userByToken(token);
await this.load();
const h = sha256(token);
const u = this.byToken.get(h);
if (!u) return;
u.tokens = u.tokens.filter((t) => t !== sha256(token));
u.tokens = u.tokens.filter((t) => t !== h);
this.byToken.delete(h);
await this.persist();
}
+38 -1
View File
@@ -2,7 +2,7 @@ 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 { CloudStore } from './campaigns';
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
describe('CloudStore', () => {
let dir: string;
@@ -42,6 +42,43 @@ describe('CloudStore', () => {
expect(await store.usageBytes('p1')).toBeGreaterThan(0);
});
it('admin: lists, deletes campaigns, and purges a user wholesale', async () => {
const mine = await store.createCampaign('gm1', 'Mine', '5e');
const theirs = await store.createCampaign('gm2', 'Theirs', 'pf2e');
await store.joinByInvite('gm1', theirs.inviteCode); // gm1 is also a member elsewhere
await store.putCharacter('gm1', mine.id, { id: 'c1', name: 'A', data: '{"a":1}' });
await store.putCharacter('gm1', theirs.id, { id: 'c2', name: 'B', data: '{"b":2}' });
const rows = await store.adminList();
expect(rows).toHaveLength(2);
expect(rows.find((r) => r.id === mine.id)).toMatchObject({ characters: 1, members: 0 });
// Purging gm1 removes the campaign they OWN (+ its chars), their membership,
// and their characters published in other campaigns.
const purged = await store.purgeUser('gm1');
expect(purged).toEqual({ campaigns: 1, characters: 2 });
expect(store.isMember(theirs.id, 'gm1')).toBe(false);
expect(await store.joinByInvite('p9', mine.inviteCode)).toBeNull(); // invite dead
expect((await store.totals()).campaigns).toBe(1);
expect(await store.deleteCampaign(theirs.id)).toBe(true);
expect(await store.deleteCampaign(theirs.id)).toBe(false); // already gone
expect((await store.totals()).campaigns).toBe(0);
});
it('rejects an oversized character and reports usage deltas', async () => {
const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode);
const huge = 'x'.repeat(MAX_CHARACTER_BYTES + 1);
expect(await store.putCharacter('p1', c.id, { id: 'big', name: 'Big', data: huge })).toBeNull();
expect(await store.characterBytes('big')).toBe(0); // nothing stored
await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{"hp":1}' });
expect(store.ownsCharacter('p1', 'ch1')).toBe(true);
expect(store.ownsCharacter('p2', 'ch1')).toBe(false);
expect(await store.characterBytes('ch1')).toBe(Buffer.byteLength('{"hp":1}'));
});
it('lets the char owner or campaign owner delete; persists across instances', async () => {
const c = await store.createCampaign('gm1', 'C', '5e');
await store.joinByInvite('p1', c.inviteCode);
+72 -2
View File
@@ -32,6 +32,9 @@ export interface CloudCharacter {
}
const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
/** A single published character can't exceed this (a sheet with an embedded portrait
* is well under 2 MB); a hard stop before per-user quota even comes into play. */
export const MAX_CHARACTER_BYTES = 2 * 1024 * 1024;
export class CloudStore {
private campaigns = new Map<string, CloudCampaign>();
@@ -50,7 +53,9 @@ export class CloudStore {
const raw = JSON.parse(await fs.readFile(this.file(), 'utf8')) as { campaigns: CloudCampaign[]; characters: CloudCharacter[] };
for (const c of raw.campaigns ?? []) { this.campaigns.set(c.id, c); this.byInvite.set(c.inviteCode, c.id); }
for (const ch of raw.characters ?? []) this.characters.set(ch.id, ch);
} catch { /* no file yet */ }
} catch (e) {
if ((e as NodeJS.ErrnoException)?.code !== 'ENOENT') console.error('[cloud] load failed', e);
}
}
private persist(): Promise<void> {
@@ -59,7 +64,7 @@ export class CloudStore {
const tmp = `${this.file()}.tmp`;
await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] }));
await fs.rename(tmp, this.file());
}).catch(() => {});
}).catch((e) => { console.error('[cloud] persist failed — data may be lost on restart', e); });
return this.queue as Promise<void>;
}
@@ -140,6 +145,7 @@ export class CloudStore {
async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string }): Promise<CloudCharacter | null> {
await this.load();
if (!this.isMember(campaignId, userId)) return null;
if (Buffer.byteLength(char.data) > MAX_CHARACTER_BYTES) return null; // oversized blob — reject
const existing = this.characters.get(char.id);
if (existing && existing.ownerUserId !== userId) return null; // only the owner may update
const record: CloudCharacter = { id: char.id, campaignId, ownerUserId: existing?.ownerUserId ?? userId, name: char.name.slice(0, 120), data: char.data, updatedAt: this.now() };
@@ -163,6 +169,70 @@ export class CloudStore {
return true;
}
/** Byte size of one stored character's data (0 if it doesn't exist) — for quota deltas. */
async characterBytes(characterId: string): Promise<number> {
await this.load();
const ch = this.characters.get(characterId);
return ch ? Buffer.byteLength(ch.data) : 0;
}
/** Whether the user already owns a published character with this id. */
ownsCharacter(userId: string, characterId: string): boolean {
return this.characters.get(characterId)?.ownerUserId === userId;
}
/** Admin overview rows: every campaign with member/character counts + stored bytes. */
async adminList(): Promise<Array<{ id: string; name: string; system: string; ownerUserId: string; members: number; characters: number; bytes: number; createdAt: number; updatedAt: number }>> {
await this.load();
return [...this.campaigns.values()].map((c) => {
let characters = 0, bytes = 0;
for (const ch of this.characters.values()) if (ch.campaignId === c.id) { characters++; bytes += Buffer.byteLength(ch.data); }
return { id: c.id, name: c.name, system: c.system, ownerUserId: c.ownerUserId, members: c.members.length, characters, bytes, createdAt: c.createdAt, updatedAt: c.updatedAt };
});
}
/** Admin: delete a campaign and every character published into it. */
async deleteCampaign(campaignId: string): Promise<boolean> {
await this.load();
const c = this.campaigns.get(campaignId);
if (!c) return false;
this.byInvite.delete(c.inviteCode);
this.campaigns.delete(campaignId);
for (const [id, ch] of this.characters) if (ch.campaignId === campaignId) this.characters.delete(id);
await this.persist();
return true;
}
/**
* Admin: purge everything a deleted user left behind — campaigns they own (with
* those campaigns' characters), their memberships, and their published characters.
*/
async purgeUser(userId: string): Promise<{ campaigns: number; characters: number }> {
await this.load();
let campaigns = 0, characters = 0;
for (const [id, c] of [...this.campaigns]) {
if (c.ownerUserId === userId) {
campaigns++;
this.byInvite.delete(c.inviteCode);
this.campaigns.delete(id);
for (const [chId, ch] of [...this.characters]) if (ch.campaignId === id) { this.characters.delete(chId); characters++; }
} else if (c.members.includes(userId)) {
c.members = c.members.filter((m) => m !== userId);
}
}
for (const [chId, ch] of [...this.characters]) if (ch.ownerUserId === userId) { this.characters.delete(chId); characters++; }
await this.persist();
return { campaigns, characters };
}
/** Instance-wide totals for the admin overview. */
async totals(): Promise<{ campaigns: number; characters: number; bytes: number }> {
await this.load();
let bytes = 0;
for (const ch of this.characters.values()) bytes += Buffer.byteLength(ch.data);
return { campaigns: this.campaigns.size, characters: this.characters.size, bytes };
}
/** Total bytes a user is responsible for (their characters' data) — for usage tracking. */
async usageBytes(userId: string): Promise<number> {
await this.load();
+143 -23
View File
@@ -4,17 +4,22 @@ import fastifyWebsocket from '@fastify/websocket';
import fastifyStatic from '@fastify/static';
import type { FastifyRequest } from 'fastify';
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
import { promises as fs } from 'node:fs';
import { RoomHub, type Sender } from './rooms';
import { AccountStore } from './accounts';
import { CloudStore } from './campaigns';
import { AccountStore, DEFAULT_QUOTA_BYTES } from './accounts';
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
const PORT = Number(process.env.PORT ?? 8787);
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
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 BODY_LIMIT = 32 * 1024 * 1024; // 32 MB (cloud backup blobs; > the per-user quota)
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').map((s) => s.trim()).filter(Boolean);
const ADMIN_USERS = (process.env.ADMIN_USERS ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
const MAX_USERS = Number(process.env.MAX_USERS) || Infinity;
// Concurrent WebSocket connections allowed from one client IP — generous for a
// household sharing a NAT, but a hard stop on socket-spam room/image floods.
const MAX_WS_PER_IP = Number(process.env.MAX_WS_PER_IP) || 20;
const bearer = (req: FastifyRequest): string | undefined => {
const h = req.headers.authorization;
@@ -29,30 +34,49 @@ const RATE = { capacity: 80, refillMs: 10_000 };
const HEARTBEAT_MS = 30_000;
export function buildServer() {
const app = Fastify({ bodyLimit: BODY_LIMIT });
// trustProxy: behind Traefik the socket peer is the proxy, so without this every
// visitor shares one rate-limit bucket. Trust the proxy's X-Forwarded-For so
// req.ip is the real client. Only Traefik can reach the container, so this is safe.
const app = Fastify({ bodyLimit: BODY_LIMIT, trustProxy: true });
const hub = new RoomHub();
const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS);
const accounts = new AccountStore(DATA_DIR, undefined, ADMIN_USERS, MAX_USERS);
void accounts.load();
const cloud = new CloudStore(DATA_DIR);
void cloud.load();
const sweeper = setInterval(() => hub.sweep(), 5 * 60 * 1000);
const sweeper = setInterval(() => { hub.sweep(); pruneRateBuckets(); }, 5 * 60 * 1000);
app.addHook('onClose', async () => clearInterval(sweeper));
// Simple per-IP throttle for the account/cloud API.
// Per-IP throttle for the account/cloud API. Auth endpoints get a tighter bucket
// since each call does password hashing and is the target of credential-stuffing.
const ipHits = new Map<string, { n: number; reset: number }>();
const authHits = new Map<string, { n: number; reset: number }>();
const AUTH_PATHS = new Set(['/api/register', '/api/login']);
const bump = (map: Map<string, { n: number; reset: number }>, ip: string, limit: number, windowMs: number): boolean => {
const now = Date.now();
const e = map.get(ip);
if (!e || now > e.reset) { map.set(ip, { n: 1, reset: now + windowMs }); return true; }
return ++e.n <= limit;
};
const pruneRateBuckets = () => {
const now = Date.now();
for (const [ip, e] of ipHits) if (now > e.reset) ipHits.delete(ip);
for (const [ip, e] of authHits) if (now > e.reset) authHits.delete(ip);
};
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' });
if (!bump(ipHits, req.ip, 60, 60_000)) return reply.code(429).send({ error: 'rate' });
// Strip the querystring before matching the auth path.
const path0 = req.url.split('?')[0]!;
if (AUTH_PATHS.has(path0) && !bump(authHits, req.ip, 10, 60_000)) {
return reply.code(429).send({ error: 'rate', message: 'Too many attempts — wait a minute and try again.' });
}
});
// ---- 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 });
if (!r.ok) return reply.code(r.code === 'closed' ? 503 : 400).send({ error: r.code, message: r.message });
return { token: r.token, username: r.username };
});
app.post('/api/login', async (req, reply) => {
@@ -67,6 +91,11 @@ export function buildServer() {
if (!u) return reply.code(401).send({ error: 'unauthorized' });
const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {};
if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
// Quota: the backup blob plus the user's published characters must fit their limit.
const projected = Buffer.byteLength(body.blob) + (await cloud.usageBytes(u.id));
if (projected > accounts.quotaFor(u)) {
return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached. Trim old data or ask the admin to raise your quota.', limit: accounts.quotaFor(u) });
}
// Optimistic concurrency: if the caller names the version it last synced and
// the cloud has since moved on (another device pushed), refuse — no silent
// overwrite. The client then resolves (pull, or force-push).
@@ -121,8 +150,15 @@ export function buildServer() {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const { campaignId, character } = (req.body ?? {}) as { campaignId?: string; character?: { id?: string; name?: string; data?: string } };
if (!campaignId || !character?.id || typeof character.data !== 'string') return reply.code(400).send({ error: 'bad-request' });
if (Buffer.byteLength(character.data) > MAX_CHARACTER_BYTES) return reply.code(413).send({ error: 'too-large', message: 'That character is too large to publish.' });
// Quota: total usage after this upsert (replace the old copy's bytes with the new).
const delta = Buffer.byteLength(character.data) - (cloud.ownsCharacter(u.id, character.id) ? await cloud.characterBytes(character.id) : 0);
const projected = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id)) + delta;
if (projected > accounts.quotaFor(u)) {
return reply.code(413).send({ error: 'quota', message: 'Cloud storage limit reached.', limit: accounts.quotaFor(u) });
}
const r = await cloud.putCharacter(u.id, campaignId, { id: character.id, name: String(character.name ?? ''), data: character.data });
if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, or you do not own that character.' });
if (!r) return reply.code(403).send({ error: 'forbidden', message: 'Not a member, you do not own that character, or it is too large.' });
return { ok: true, updatedAt: r.updatedAt };
});
app.get('/api/campaigns/:id/characters', async (req, reply) => {
@@ -141,31 +177,110 @@ export function buildServer() {
app.get('/api/usage', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const bytes = (await accounts.blobBytes(u.id)) + (await cloud.usageBytes(u.id));
return { bytes, admin: accounts.isAdmin(u.username) };
return { bytes, quota: accounts.quotaFor(u), admin: accounts.isAdmin(u.username) };
});
// ---- admin panel API (accounts in ADMIN_USERS only) ----
const startedAt = Date.now();
const adminOf = async (req: FastifyRequest) => {
const u = await userOf(req);
return u && accounts.isAdmin(u.username) ? u : null;
};
/** Recursive byte total of the data dir (users.json + cloud.json + blobs). */
const dirBytes = async (dir: string): Promise<number> => {
let total = 0;
try {
for (const e of await fs.readdir(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) total += await dirBytes(p);
else total += (await fs.stat(p)).size.valueOf();
}
} catch { /* missing dir = 0 */ }
return total;
};
app.get('/api/admin/overview', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const mem = process.memoryUsage();
const roomStats = hub.stats();
const totals = await cloud.totals();
return {
uptimeMs: Date.now() - startedAt,
memory: { rss: mem.rss, heapUsed: mem.heapUsed },
dataDirBytes: await dirBytes(DATA_DIR),
users: { count: accounts.userCount(), max: Number.isFinite(MAX_USERS) ? MAX_USERS : null },
defaultQuotaBytes: DEFAULT_QUOTA_BYTES,
campaigns: totals.campaigns,
characters: totals.characters,
characterBytes: totals.bytes,
rooms: {
count: roomStats.length,
players: roomStats.reduce((n, r) => n + r.players, 0),
imageBytes: roomStats.reduce((n, r) => n + r.imageBytes, 0),
},
};
});
// ---- owner admin: usage overview + per-user quota override (tracked, not yet enforced) ----
app.get('/api/admin/users', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
const users = await accounts.listUsers();
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const users = await accounts.listUsersDetailed();
const rows = await Promise.all(users.map(async (x) => ({
username: x.username,
admin: x.admin,
createdAt: x.createdAt,
usageBytes: (await accounts.blobBytes(x.id)) + (await cloud.usageBytes(x.id)),
quotaBytes: x.quotaBytes ?? 0,
quotaBytes: x.quotaBytes,
effectiveQuota: x.quotaBytes > 0 ? x.quotaBytes : DEFAULT_QUOTA_BYTES,
tokenCount: x.tokenCount,
})));
return { users: rows.sort((a, b) => b.usageBytes - a.usageBytes) };
});
app.post('/api/admin/quota', async (req, reply) => {
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number };
const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0);
return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' });
});
app.post('/api/admin/users/:username/revoke', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const ok = await accounts.revokeTokens((req.params as { username: string }).username);
return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' });
});
app.delete('/api/admin/users/:username', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const name = (req.params as { username: string }).username;
const id = await accounts.deleteUser(name);
if (!id) return reply.code(400).send({ error: 'cannot-delete', message: 'No such user, or the account is an admin.' });
const purged = await cloud.purgeUser(id);
return { ok: true, purged };
});
app.get('/api/admin/campaigns', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const byId = new Map((await accounts.listUsers()).map((x) => [x.id, x.username]));
const rows = (await cloud.adminList()).map((c) => ({ ...c, owner: byId.get(c.ownerUserId) ?? '(deleted)' }));
return { campaigns: rows.sort((a, b) => b.updatedAt - a.updatedAt) };
});
app.delete('/api/admin/campaigns/:id', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
const ok = await cloud.deleteCampaign((req.params as { id: string }).id);
return ok ? { ok: true } : reply.code(404).send({ error: 'no-campaign' });
});
app.get('/api/admin/rooms', async (req, reply) => {
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
return { rooms: hub.stats() };
});
void app.register(fastifyWebsocket, { options: { maxPayload: MAX_PAYLOAD } });
// Live WebSocket connections per client IP — a hard cap on socket-spam.
const wsPerIp = new Map<string, number>();
void app.register(async (instance) => {
instance.get('/ws', { websocket: true }, (socket, req) => {
// Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured.
@@ -174,6 +289,11 @@ export function buildServer() {
socket.close(1008, 'origin');
return;
}
const ip = req.ip;
const live = wsPerIp.get(ip) ?? 0;
if (live >= MAX_WS_PER_IP) { socket.close(1013, 'too-many'); return; }
wsPerIp.set(ip, live + 1);
const releaseIp = () => { const n = (wsPerIp.get(ip) ?? 1) - 1; if (n <= 0) wsPerIp.delete(ip); else wsPerIp.set(ip, n); };
const sender: Sender = { send: (msg: ServerMessage) => { try { socket.send(JSON.stringify(msg)); } catch { /* closed */ } } };
let tokens = RATE.capacity;
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
@@ -209,11 +329,11 @@ export function buildServer() {
case 'setName': hub.setName(sender, m.name); break;
}
});
socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); hub.disconnect(sender); });
socket.on('close', () => { clearInterval(refill); clearInterval(heartbeat); releaseIp(); hub.disconnect(sender); });
});
});
app.get('/healthz', async () => ({ ok: true, rooms: hub.roomCount() }));
app.get('/healthz', async () => ({ ok: true }));
// Serve the built SPA with history fallback (so /play?room=... deep-links work).
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
+25
View File
@@ -92,6 +92,31 @@ describe('RoomHub', () => {
expect(hub.roomCount()).toBe(0);
});
it('caps the number of concurrent rooms', () => {
const hub = new RoomHub(() => 0, { maxRooms: 2 });
const a = fake(); hub.host(a);
const b = fake(); hub.host(b);
expect(hub.roomCount()).toBe(2);
const c = fake(); hub.host(c);
expect(hub.roomCount()).toBe(2); // refused
expect(lastOf(c, 'error')).toMatchObject({ code: 'busy' });
expect(lastOf(c, 'hosted')).toBeUndefined();
});
it('caps per-room image count and total bytes', () => {
const hub = new RoomHub(() => 0, { maxImagesPerRoom: 2, maxRoomImageBytes: 100 });
const gm = fake(); hub.host(gm);
const { gmSecret } = lastOf(gm, 'hosted') as Extract<ServerMessage, { t: 'hosted' }>;
hub.image(gm, gmSecret, 'm1', 'x'.repeat(40));
hub.image(gm, gmSecret, 'm2', 'x'.repeat(40)); // total 80 ≤ 100, count 2 ≤ 2
expect(lastOf(gm, 'error')).toBeUndefined();
hub.image(gm, gmSecret, 'm3', 'x'.repeat(10)); // 3rd distinct image → count cap
expect(lastOf(gm, 'error')).toMatchObject({ code: 'image-limit' });
// overwriting an existing id is allowed, but not past the byte budget
hub.image(gm, gmSecret, 'm1', 'x'.repeat(90)); // 90 + 40 = 130 > 100 → rejected
expect(lastOf(gm, 'error')).toMatchObject({ code: 'image-limit' });
});
it('routes a seat claim → GM grant → player gets their sheet', () => {
const hub = new RoomHub();
const { gm, player, secret } = hostAndJoin(hub);
+44 -2
View File
@@ -34,6 +34,8 @@ interface Room {
players: Set<Sender>;
snapshot: Snapshot | null;
images: Map<string, string>;
/** running total of bytes in `images`, kept so the cap is O(1) to check */
imageBytes: number;
/** granted seats keyed by playerId */
seats: Map<string, Seat>;
/** seat requests awaiting GM approval (re-sent when the GM reconnects) */
@@ -43,6 +45,13 @@ interface Room {
const JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars
const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle
// Memory guardrails so an anonymous GM can't balloon the (in-RAM) hub on a small box.
export interface RoomLimits { maxRooms: number; maxImagesPerRoom: number; maxRoomImageBytes: number }
const DEFAULT_LIMITS: RoomLimits = {
maxRooms: Number(process.env.MAX_ROOMS) || 200,
maxImagesPerRoom: 40,
maxRoomImageBytes: 40 * 1024 * 1024, // 40 MB of map images per room
};
function sha256(s: string): string {
return crypto.createHash('sha256').update(s).digest('hex');
@@ -62,7 +71,10 @@ export class RoomHub {
private rooms = new Map<string, Room>();
private byCode = new Map<string, string>();
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string; name?: string }>();
constructor(private now: () => number = () => Date.now()) {}
private limits: RoomLimits;
constructor(private now: () => number = () => Date.now(), limits: Partial<RoomLimits> = {}) {
this.limits = { ...DEFAULT_LIMITS, ...limits };
}
private mintJoinCode(): string {
for (let attempt = 0; attempt < 50; attempt++) {
@@ -90,13 +102,19 @@ export class RoomHub {
}
}
}
// Cap concurrent rooms so socket-spam can't exhaust memory. Idle rooms TTL out;
// a sweep runs first to reclaim any that just expired before we refuse.
if (this.rooms.size >= this.limits.maxRooms) {
this.sweep();
if (this.rooms.size >= this.limits.maxRooms) { socket.send({ t: 'error', code: 'busy', message: 'The server is at capacity — please try again shortly.' }); return; }
}
const roomId = crypto.randomUUID();
const joinCode = this.mintJoinCode();
const gmSecret = crypto.randomBytes(32).toString('base64url');
const room: Room = {
roomId, joinCode, gmSecretHash: sha256(gmSecret),
passwordHash: password ? sha256(password) : null,
gm: socket, players: new Set(), snapshot: null, images: new Map(),
gm: socket, players: new Set(), snapshot: null, images: new Map(), imageBytes: 0,
seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(),
};
this.rooms.set(roomId, room);
@@ -148,7 +166,17 @@ export class RoomHub {
image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void {
const room = this.gmRoom(socket, gmSecret);
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
// Bound per-room image memory: cap the count of distinct images and the total bytes.
const size = Buffer.byteLength(dataUrl);
const prev = room.images.get(id);
const prevSize = prev ? Buffer.byteLength(prev) : 0;
const isNew = prev === undefined;
if ((isNew && room.images.size >= this.limits.maxImagesPerRoom) || room.imageBytes - prevSize + size > this.limits.maxRoomImageBytes) {
socket.send({ t: 'error', code: 'image-limit', message: 'This session has reached its map-image limit.' });
return;
}
room.images.set(id, dataUrl);
room.imageBytes += size - prevSize;
room.lastActivity = this.now();
for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl });
}
@@ -319,6 +347,20 @@ export class RoomHub {
roomCount(): number { return this.rooms.size; }
/** Admin overview of live rooms. Deliberately excludes join codes and secrets —
* the admin can see load, not enter private games. */
stats(): Array<{ players: number; seats: number; images: number; imageBytes: number; idleMs: number; hasGm: boolean }> {
const now = this.now();
return [...this.rooms.values()].map((r) => ({
players: r.players.size,
seats: r.seats.size,
images: r.images.size,
imageBytes: r.imageBytes,
idleMs: Math.max(0, now - r.lastActivity),
hasGm: r.gm !== null,
}));
}
private gmRoom(socket: Sender, gmSecret: string): Room | null {
const conn = this.conns.get(socket);
if (!conn || conn.role !== 'gm') return null;
+8 -2
View File
@@ -3,7 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
import {
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2,
ScrollText, Drama, Target, CalendarDays, FlaskConical, Sparkles, Wand2, ShieldCheck,
type LucideIcon,
} from 'lucide-react';
import { useUiStore } from '@/stores/uiStore';
@@ -24,6 +24,7 @@ import { SignalsBell } from '@/features/assistant/SignalsBell';
import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator';
import { SessionSidebar } from '@/features/play/SessionSidebar';
import { useSessionStore } from '@/stores/sessionStore';
import { useIsAdmin } from '@/features/admin/useIsAdmin';
type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'world' | 'reference' };
const NAV: NavItem[] = [
@@ -100,6 +101,11 @@ export function RootLayout() {
useSessionBroadcaster(activeCampaign ?? null);
usePlayerConnection();
useCloudAutosave();
// Instance admins get an extra nav entry (server re-checks every /api/admin call).
const isAdmin = useIsAdmin();
const nav: NavItem[] = isAdmin
? [...NAV, { to: '/admin', label: 'Admin', icon: ShieldCheck, exact: false, group: 'reference' }]
: NAV;
// Global Ctrl/Cmd+K opens the command palette.
useEffect(() => {
@@ -148,7 +154,7 @@ export function RootLayout() {
{GROUPS.map((group) => (
<div key={group.id}>
{group.label && showLabel && <div className="smallcaps hidden px-6 pt-4 pb-1.5 sm:block" style={{ fontSize: 9.5 }}>{group.label}</div>}
{NAV.filter((n) => n.group === group.id).map((item) => {
{nav.filter((n) => n.group === group.id).map((item) => {
const active = item.exact ? pathname === item.to : pathname.startsWith(item.to);
const Ico = item.icon;
return (
+263
View File
@@ -0,0 +1,263 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { RefreshCw, ShieldCheck, Users, Database, RadioTower, Cpu, Star } from 'lucide-react';
import { cloudUsername } from '@/lib/cloud/client';
import {
adminOverview, adminUsers, adminCampaigns, adminRooms,
adminSetQuota, adminRevokeTokens, adminDeleteUser, adminDeleteCampaign,
type AdminOverview, type AdminUser, type AdminCampaign, type AdminRoom,
} from '@/lib/cloud/admin';
import { Page, PageHeader } from '@/components/ui/Page';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
function fmtBytes(b: number): string {
if (b >= 1e9) return `${(b / 1e9).toFixed(2)} GB`;
if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`;
return `${Math.max(0, Math.round(b / 1024))} KB`;
}
function fmtAgo(ms: number): string {
const m = Math.floor(ms / 60_000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
return h < 48 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d`;
}
const fmtDate = (t: number) => new Date(t).toLocaleDateString();
/**
* Instance administration: server health, every account (storage, quota,
* sessions), shared campaigns, and live rooms. Server-gated to ADMIN_USERS —
* this page only renders what /api/admin/* is willing to return.
*/
export function AdminPage() {
const username = cloudUsername();
const [overview, setOverview] = useState<AdminOverview | null>(null);
const [users, setUsers] = useState<AdminUser[]>([]);
const [campaigns, setCampaigns] = useState<AdminCampaign[]>([]);
const [rooms, setRooms] = useState<AdminRoom[]>([]);
const [denied, setDenied] = useState(false);
const [msg, setMsg] = useState('');
const refresh = useCallback(() => {
setMsg('');
adminOverview().then(setOverview).catch(() => setDenied(true));
adminUsers().then((r) => setUsers(r.users)).catch(() => {});
adminCampaigns().then((r) => setCampaigns(r.campaigns)).catch(() => {});
adminRooms().then((r) => setRooms(r.rooms)).catch(() => {});
}, []);
useEffect(() => { if (username) refresh(); }, [username, refresh]);
if (!username) {
return (
<Page>
<PageHeader title="Admin" subtitle="Sign in with the admin account to manage this instance." />
<p className="text-sm text-muted">Go to <Link to="/settings" className="text-accent underline">Settings</Link> and sign in to the cloud first.</p>
</Page>
);
}
if (denied) {
return (
<Page>
<PageHeader title="Admin" />
<p className="text-sm text-warning">This account isnt an instance admin. Admins are set with the <code className="rounded bg-elevated px-1">ADMIN_USERS</code> environment variable on the server.</p>
</Page>
);
}
return (
<Page>
<PageHeader
eyebrow="Instance"
title="Admin"
subtitle={`Signed in as ${username}`}
actions={<Button size="sm" variant="secondary" onClick={refresh}><RefreshCw size={14} aria-hidden /> Refresh</Button>}
/>
{/* Server health */}
{overview && (
<div className="mb-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard icon={Users} label="Accounts" value={`${overview.users.count}${overview.users.max ? ` / ${overview.users.max}` : ''}`}
hint={`default quota ${fmtBytes(overview.defaultQuotaBytes)}`} />
<StatCard icon={Database} label="Stored data" value={fmtBytes(overview.dataDirBytes)}
hint={`${overview.campaigns} campaigns · ${overview.characters} published characters`} />
<StatCard icon={RadioTower} label="Live rooms" value={String(overview.rooms.count)}
hint={`${overview.rooms.players} players · ${fmtBytes(overview.rooms.imageBytes)} images in RAM`} />
<StatCard icon={Cpu} label="Memory (RSS)" value={fmtBytes(overview.memory.rss)}
hint={`heap ${fmtBytes(overview.memory.heapUsed)} · up ${fmtAgo(overview.uptimeMs)}`} />
</div>
)}
{/* Accounts */}
<Section icon={ShieldCheck} title={`Accounts (${users.length})`}>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted">
<th className="py-1.5 font-medium">User</th>
<th className="font-medium">Created</th>
<th className="font-medium">Storage</th>
<th className="font-medium">Quota (MB)</th>
<th className="font-medium">Sessions</th>
<th className="font-medium" aria-label="Actions" />
</tr>
</thead>
<tbody>
{users.map((u) => (
<UserRow key={u.username} u={u} onChanged={refresh} onMsg={setMsg} />
))}
</tbody>
</table>
{users.length === 0 && <p className="py-2 text-sm text-muted">No accounts yet.</p>}
</Section>
{/* Campaigns */}
<Section icon={Database} title={`Shared campaigns (${campaigns.length})`}>
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted">
<th className="py-1.5 font-medium">Campaign</th>
<th className="font-medium">Owner</th>
<th className="font-medium">Members</th>
<th className="font-medium">Characters</th>
<th className="font-medium">Size</th>
<th className="font-medium">Updated</th>
<th className="font-medium" aria-label="Actions" />
</tr>
</thead>
<tbody>
{campaigns.map((c) => (
<CampaignRow key={c.id} c={c} onChanged={refresh} onMsg={setMsg} />
))}
</tbody>
</table>
{campaigns.length === 0 && <p className="py-2 text-sm text-muted">No shared campaigns yet.</p>}
</Section>
{/* Live rooms */}
<Section icon={RadioTower} title={`Live rooms (${rooms.length})`}>
{rooms.length === 0 ? (
<p className="py-2 text-sm text-muted">No active play sessions. Rooms expire after 6h idle.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted">
<th className="py-1.5 font-medium">GM</th>
<th className="font-medium">Players</th>
<th className="font-medium">Seats</th>
<th className="font-medium">Map images</th>
<th className="font-medium">Idle</th>
</tr>
</thead>
<tbody>
{rooms.map((r, i) => (
<tr key={i} className="border-t border-line">
<td className="py-1.5">{r.hasGm ? <span className="text-success">connected</span> : <span className="text-muted">away</span>}</td>
<td>{r.players}</td>
<td>{r.seats}</td>
<td className="text-muted">{r.images} · {fmtBytes(r.imageBytes)}</td>
<td className="text-muted">{fmtAgo(r.idleMs)}</td>
</tr>
))}
</tbody>
</table>
)}
<p className="mt-1 text-[11px] text-muted">Rooms are anonymous by design join codes and contents are never shown here.</p>
</Section>
{msg && <p className="mt-3 text-sm text-accent">{msg}</p>}
</Page>
);
}
function StatCard({ icon: Icon, label, value, hint }: { icon: typeof Users; label: string; value: string; hint: string }) {
return (
<div className="rounded-xl border border-line bg-panel p-4">
<div className="flex items-center gap-1.5 smallcaps"><Icon size={13} aria-hidden /> {label}</div>
<div className="mt-1 font-display text-2xl font-semibold text-ink">{value}</div>
<div className="mt-0.5 text-xs text-muted">{hint}</div>
</div>
);
}
function Section({ icon: Icon, title, children }: { icon: typeof Users; title: string; children: React.ReactNode }) {
return (
<section className="mb-6 rounded-xl border border-line bg-panel p-4">
<h2 className="mb-2 flex items-center gap-1.5 smallcaps"><Icon size={13} aria-hidden /> {title}</h2>
{children}
</section>
);
}
/** Destructive actions arm on the first click and fire on the second. */
function ConfirmButton({ label, confirmLabel, onConfirm }: { label: string; confirmLabel: string; onConfirm: () => void }) {
const [armed, setArmed] = useState(false);
useEffect(() => {
if (!armed) return;
const t = setTimeout(() => setArmed(false), 4000);
return () => clearTimeout(t);
}, [armed]);
return (
<Button size="sm" variant={armed ? 'danger' : 'ghost'} onClick={() => (armed ? onConfirm() : setArmed(true))}>
{armed ? confirmLabel : label}
</Button>
);
}
function UserRow({ u, onChanged, onMsg }: { u: AdminUser; onChanged: () => void; onMsg: (m: string) => void }) {
const [mb, setMb] = useState(u.quotaBytes ? String(Math.round(u.quotaBytes / 1e6)) : '');
const pct = Math.min(100, Math.round((u.usageBytes / Math.max(1, u.effectiveQuota)) * 100));
const act = (p: Promise<unknown>, done: string) => p.then(() => { onMsg(done); onChanged(); }).catch((e) => onMsg(e instanceof Error ? e.message : 'Failed.'));
return (
<tr className="border-t border-line align-middle">
<td className="py-1.5 text-ink">
{u.username}
{u.admin && <span className="ml-1 inline-flex items-center gap-0.5 rounded bg-accent/10 px-1 text-[10px] text-accent"><Star size={9} aria-hidden /> admin</span>}
</td>
<td className="text-muted">{fmtDate(u.createdAt)}</td>
<td>
<div className="flex items-center gap-2">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-elevated">
<div className={pct >= 90 ? 'h-full bg-warning' : 'h-full bg-accent'} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-muted">{fmtBytes(u.usageBytes)} / {fmtBytes(u.effectiveQuota)}</span>
</div>
</td>
<td>
<div className="flex items-center gap-1">
<Input className="w-20" value={mb} placeholder="default" aria-label={`Quota for ${u.username} in MB`} onChange={(e) => setMb(e.target.value)} />
<Button size="sm" variant="ghost" onClick={() => act(adminSetQuota(u.username, (Number(mb) || 0) * 1e6), `Quota for ${u.username} ${Number(mb) ? `set to ${mb} MB` : 'reset to default'}.`)}>Set</Button>
</div>
</td>
<td>
<div className="flex items-center gap-1 text-muted">
{u.tokenCount}
{u.tokenCount > 0 && (
<ConfirmButton label="Revoke" confirmLabel="Log out everywhere?" onConfirm={() => act(adminRevokeTokens(u.username), `${u.username} signed out everywhere.`)} />
)}
</div>
</td>
<td className="text-right">
{!u.admin && (
<ConfirmButton label="Delete" confirmLabel="Delete account + data?" onConfirm={() => act(adminDeleteUser(u.username), `${u.username} deleted (account, backup, campaigns, characters).`)} />
)}
</td>
</tr>
);
}
function CampaignRow({ c, onChanged, onMsg }: { c: AdminCampaign; onChanged: () => void; onMsg: (m: string) => void }) {
return (
<tr className="border-t border-line">
<td className="py-1.5 text-ink">{c.name} <span className="text-[10px] uppercase text-muted">{c.system}</span></td>
<td className="text-muted">{c.owner}</td>
<td>{c.members}</td>
<td>{c.characters}</td>
<td className="text-muted">{fmtBytes(c.bytes)}</td>
<td className="text-muted">{fmtDate(c.updatedAt)}</td>
<td className="text-right">
<ConfirmButton label="Delete" confirmLabel="Delete campaign + characters?" onConfirm={() =>
adminDeleteCampaign(c.id).then(() => { onMsg(`Campaign “${c.name}” deleted.`); onChanged(); }).catch((e) => onMsg(e instanceof Error ? e.message : 'Failed.'))} />
</td>
</tr>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react';
import { cloudUsername } from '@/lib/cloud/client';
import { getUsage } from '@/lib/cloud/campaigns';
// Cache across mounts so the sidebar doesn't re-ask /api/usage on every navigation.
let cached: boolean | null = null;
/** Whether the signed-in cloud account is an instance admin (false while unknown). */
export function useIsAdmin(): boolean {
const [isAdmin, setIsAdmin] = useState(cached === true);
useEffect(() => {
if (cached !== null || !cloudUsername()) return;
let on = true;
getUsage()
.then((u) => { cached = u.admin; if (on) setIsAdmin(u.admin); })
.catch(() => { /* signed out / offline — stay hidden */ });
return () => { on = false; };
}, []);
return isAdmin;
}
+20 -4
View File
@@ -187,6 +187,12 @@ export function CharacterSheet({ character }: { character: Character }) {
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
</Labeled>
{c.system === 'pf2e' && (
<Labeled label="Heritage">
{/* pre-migration rows lack the field — keep the input controlled */}
<Input value={c.heritage ?? ''} onChange={(e) => update({ heritage: e.target.value })} />
</Labeled>
)}
{c.system === '5e' ? (
<div className="sm:col-span-2"><ClassesEditor c={c} update={update} /></div>
) : (
@@ -231,7 +237,8 @@ export function CharacterSheet({ character }: { character: Character }) {
<section className="mb-6">
<div className="mb-3 flex items-center justify-between">
<SectionTitle>Ability Scores</SectionTitle>
<Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>
{/* The generator (array/point-buy/4d6) is a 5e concept; PF2e scores come from boosts. */}
{c.system === '5e' && <Button size="sm" variant="ghost" onClick={() => setGenScores(true)}>Generate</Button>}
</div>
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
{ABILITIES.map((a) => {
@@ -413,18 +420,27 @@ export function CharacterSheet({ character }: { character: Character }) {
function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
const [delta, setDelta] = useState(0);
const eff = deriveEffectiveMaxHp({ system: c.system, baseMaxHp: c.hp.max, level: c.level, exhaustion: c.defenses.exhaustion, conditions: c.conditions });
const effMax = eff.max;
const apply = (mode: 'damage' | 'heal') => {
if (!Number.isFinite(delta) || delta <= 0) return;
if (mode === 'damage') {
const absorbed = Math.min(c.hp.temp, delta);
update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } });
} else {
update({ hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + delta) } });
// Heal caps at the EFFECTIVE max (drained / exhaustion 4 reduce it).
const current = Math.min(effMax, c.hp.current + delta);
const patch: Partial<Character> = { hp: { ...c.hp, current } };
// pf2e: healing above 0 HP ends dying and wakes the character (pure bookkeeping).
// Losing the dying condition always increases wounded by 1.
if (c.system === 'pf2e' && c.hp.current <= 0 && current > 0) {
if (c.defenses.dying > 0) patch.defenses = { ...c.defenses, dying: 0, wounded: c.defenses.wounded + 1 };
patch.conditions = c.conditions.filter((x) => x.name.trim().toLowerCase() !== 'unconscious');
}
update(patch);
}
setDelta(0);
};
const eff = deriveEffectiveMaxHp({ system: c.system, baseMaxHp: c.hp.max, level: c.level, exhaustion: c.defenses.exhaustion, conditions: c.conditions });
const effMax = eff.max;
const ratio = effMax > 0 ? c.hp.current / effMax : 0;
return (
<div className="rounded-xl border border-line bg-panel p-4 text-center">
@@ -6,8 +6,10 @@ import { charactersRepo } from '@/lib/db/repositories';
import {
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw,
bumpRank, collectChoices,
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
} from '@/lib/rules';
import { pf2eSkillIncreaseLevels } from '@/lib/rules/pf2e/progression';
import { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
import type { RulesetClass } from '@/lib/ruleset/normalize';
@@ -179,6 +181,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const [classSlug, setClassSlug] = useState('');
const [subclass, setSubclass] = useState('');
const [ancestry, setAncestry] = useState('');
// PF2e heritage — chosen at level 1, refines the ancestry. Loaded lazily; the data
// carries no ancestry link, so we match by name ("Ancient-Blooded Dwarf") and always
// include versatile heritages (selectable by any ancestry).
const [heritage, setHeritage] = useState('');
const [allHeritages, setAllHeritages] = useState<{ name: string; summary: string; versatile: boolean }[]>([]);
useEffect(() => {
if (system !== 'pf2e' || allHeritages.length) return;
let on = true;
void loadPf2e('heritages').then((hs) => on && setAllHeritages(hs.map((h) => ({
name: String(h.name),
summary: briefOverview(String((h.summary ?? h.text) ?? '')),
versatile: /versatile heritage/i.test(String(h.text ?? h.summary ?? '')),
}))));
return () => { on = false; };
}, [system, allHeritages.length]);
const heritageOptions = useMemo(() => {
if (system !== 'pf2e' || !ancestry.trim()) return [];
const want = ancestry.trim().toLowerCase();
return allHeritages.filter((h) => h.name.toLowerCase().includes(want) || h.versatile);
}, [system, ancestry, allHeritages]);
useEffect(() => { setHeritage(''); }, [ancestry]); // a new ancestry invalidates the heritage
const [background, setBackground] = useState('');
const selectedClass = classes.find((c) => c.slug === classSlug) ?? null;
@@ -325,6 +348,43 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
// Drop any free pick that becomes granted (e.g. after switching background).
useEffect(() => { setSkills((prev) => prev.filter((k) => !grantedSkills.includes(k))); }, [grantedSkills]);
// Higher-level creation owes the picks earned along the way — collect them here
// rather than deferring to the level-up flow: PF2e skill increases (levels 3,5,7…)
// and 5e Expertise (Bard/Rogue, scaling with level).
const skillIncLevels = useMemo(
() => (system === 'pf2e' ? pf2eSkillIncreaseLevels().filter((l) => l <= level) : []),
[system, level],
);
const expertiseCount = useMemo(() => {
if (system !== '5e' || !selectedClass) return 0;
const ch = collectChoices('5e', [{ className: selectedClass.name, level }]).find((x) => x.key.endsWith(':expertise'));
return ch?.count ?? 0;
}, [system, selectedClass, level]);
const [skillIncPicks, setSkillIncPicks] = useState<string[]>([]);
const [expertisePicks, setExpertisePicks] = useState<string[]>([]);
useEffect(() => { setSkillIncPicks([]); setExpertisePicks([]); }, [classSlug, level, system]);
// Seed sensible defaults from the chosen/granted skills (kept once the user edits).
useEffect(() => {
setExpertisePicks((prev) => Array.from({ length: expertiseCount }, (_, i) => prev[i] || skills[i] || skills[0] || ''));
}, [expertiseCount, skills]);
useEffect(() => {
const pool = [...skills, ...grantedSkills];
setSkillIncPicks((prev) => skillIncLevels.map((_, i) => prev[i] || pool[i % Math.max(1, pool.length)] || ''));
}, [skillIncLevels, skills, grantedSkills]);
/** Skill ranks after base training + the first `uptoIdx` increases (for previews). */
const ranksAfterIncreases = (uptoIdx: number): Record<string, ProficiencyRank> => {
const r: Record<string, ProficiencyRank> = {};
for (const k of [...skills, ...grantedSkills]) r[k] = 'trained';
for (let i = 0; i < uptoIdx; i++) { const k = skillIncPicks[i]; if (k) r[k] = bumpRank(r[k] ?? 'untrained'); }
return r;
};
/** PF2e rank caps: master needs the increase earned at level 7+, legendary 15+. */
const incAllowed = (rank: ProficiencyRank, earnedAt: number): boolean =>
rank === 'untrained' || rank === 'trained' ? true
: rank === 'expert' ? earnedAt >= 7
: rank === 'master' ? earnedAt >= 15
: false;
// ---- spells ----
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
const [spellQuery, setSpellQuery] = useState('');
@@ -390,7 +450,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
Class: !!selectedClass,
Origin: true,
Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0,
Skills: skills.length === Math.min(skillCount, freeSkillOptions.length),
Skills: skills.length === Math.min(skillCount, freeSkillOptions.length)
&& skillIncPicks.every(Boolean)
&& expertisePicks.every(Boolean),
Spells: true,
Details: true,
Review: true,
@@ -408,13 +470,19 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
for (const [k, v] of Object.entries(selectedClass.saveRanks)) saveRanks[k] = v.toLowerCase() as ProficiencyRank;
}
const spells: SpellEntry[] = spellPicks.map((s) => newSpellEntry({ id: newId(), name: s.name, level: Math.min(10, Math.max(0, s.level)) }));
// Layer expertise (5e) and skill increases (pf2e) on top of the trained ranks.
const skillRanks: Record<string, ProficiencyRank> = { ...built.skillRanks };
for (const k of expertisePicks) if (k) skillRanks[k] = 'expert';
for (const k of skillIncPicks) if (k) skillRanks[k] = bumpRank(skillRanks[k] ?? 'untrained');
await charactersRepo.update(created.id, {
...built,
skillRanks,
abilityBuild: buildAbilityBuild(),
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
spellcasting: { ...built.spellcasting, spells },
...(background ? { background } : {}),
...(heritage ? { heritage } : {}),
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
...(personality.trim() ? { personality: personality.trim() } : {}),
@@ -540,6 +608,24 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
<OriginPicker title={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
</div>
{system === 'pf2e' && ancestry.trim() && heritageOptions.length > 0 && (
<div>
<Field label="Heritage (chosen at 1st level)">
<Select value={heritage} onChange={(e) => setHeritage(e.target.value)} aria-label="Heritage">
<option value=""> pick a heritage </option>
{heritageOptions.map((h) => <option key={h.name} value={h.name}>{h.name}{h.versatile ? ' (versatile)' : ''}</option>)}
</Select>
</Field>
{heritage && (
<p className="mt-1 text-xs text-muted">{heritageOptions.find((h) => h.name === heritage)?.summary}</p>
)}
</div>
)}
{!ancestry.trim() && (
<p className="text-xs text-warning">
No {system === 'pf2e' ? 'ancestry' : 'race'} selected you can continue (handy for homebrew), but its ability bonuses, HP, speed, and skills wont be applied.
</p>
)}
{selectedClass && selectedOrigin && (() => {
const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities);
if (!synergy) return null;
@@ -658,7 +744,17 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
const value = base + racial + asi;
const isKey = selectedClass?.keyAbilities.includes(a);
const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1;
const fMax = method === 'pointbuy' ? POINT_BUY_MAX : 30;
// Point-buy caps each field at what the remaining budget can afford,
// so the user can't overspend and wonder why Next is disabled.
const fMax = method === 'pointbuy'
? (() => {
let best = pb[i]!;
for (let cand = pb[i]! + 1; cand <= POINT_BUY_MAX; cand++) {
if (pointBuyRemaining(pb.map((x, j) => (j === i ? cand : x))) >= 0) best = cand; else break;
}
return best;
})()
: 30;
return (
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
<div className="flex items-center justify-center gap-0.5 smallcaps">{ABILITY_ABBR[a]}{isKey && <Star size={10} className="text-accent" aria-hidden />}</div>
@@ -694,7 +790,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
{stepName === 'Skills' && (
<div>
<p className="mb-1 text-sm text-muted">Choose <span className="font-semibold text-ink">{Math.min(skillCount, freeSkillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected).</p>
<p className="mb-1 text-sm text-muted">
Choose <span className="font-semibold text-ink">{Math.min(skillCount, freeSkillOptions.length)}</span> {system === 'pf2e' ? 'Trained' : 'proficient'} {skillCount === 1 ? 'skill' : 'skills'} ({skills.length} selected{grantedSkillSources.length > 0 ? `, plus ${grantedSkillSources.length} already granted below` : ''}).
</p>
<p className="mb-2 text-xs text-muted">{system === 'pf2e' ? 'These start at the Trained proficiency rank; you can raise ranks as you level up.' : 'You gain proficiency in these skills, adding your proficiency bonus to checks.'}</p>
{grantedSkillSources.length > 0 && (
<div className="mb-3 rounded-md border border-line bg-panel px-3 py-2">
@@ -708,13 +806,6 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
</div>
</div>
)}
{level > 1 && (
<p className="mb-2 text-xs text-warning">
{system === 'pf2e'
? 'Higher-level characters also gain skill increases at levels 3, 5, 7, 9… — apply those from the character sheets level-up flow.'
: 'Some classes gain extra skill/expertise picks as they level — apply those from the character sheets level-up flow.'}
</p>
)}
{selectedClass && (() => {
const tip = getClassTip(system, selectedClass.slug);
return tip?.skillSuggestions ? (
@@ -736,13 +827,70 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
);
})}
</div>
{skills.length < Math.min(skillCount, freeSkillOptions.length) && (
<p className="mt-2 text-xs text-warning">Select {Math.min(skillCount, freeSkillOptions.length) - skills.length} more {Math.min(skillCount, freeSkillOptions.length) - skills.length === 1 ? 'skill' : 'skills'} to continue.</p>
)}
{/* PF2e: skill increases earned by this level (3, 5, 7, …) — rank bumps. */}
{skillIncLevels.length > 0 && (
<div className="mt-4">
<p className="mb-1 text-sm text-ink">Skill increases <span className="text-muted">(earned at levels {skillIncLevels.join(', ')})</span></p>
<p className="mb-2 text-xs text-muted">Each increase raises one skill a rank (Master from level 7, Legendary from level 15).</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{skillIncLevels.map((earnedAt, i) => {
const ranks = ranksAfterIncreases(i);
return (
<label key={i} className="text-xs text-muted">
Level {earnedAt} increase
<Select className="mt-0.5" aria-label={`Skill increase at level ${earnedAt}`} value={skillIncPicks[i] ?? ''} onChange={(e) => setSkillIncPicks((p) => p.map((v, j) => (j === i ? e.target.value : v)))}>
{sys.skills.map((s) => {
const cur = ranks[s.key] ?? 'untrained';
const ok = incAllowed(cur, earnedAt);
return <option key={s.key} value={s.key} disabled={!ok}>{s.label}: {cur} {ok ? bumpRank(cur) : '(capped)'}</option>;
})}
</Select>
</label>
);
})}
</div>
</div>
)}
{/* 5e: Expertise picks (Bard/Rogue) — double proficiency on chosen skills. */}
{expertiseCount > 0 && (
<div className="mt-4">
<p className="mb-1 text-sm text-ink">Expertise <span className="text-muted">(choose {expertiseCount} proficiency doubled)</span></p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{Array.from({ length: expertiseCount }, (_, i) => (
<Select key={i} aria-label={`Expertise skill ${i + 1}`} value={expertisePicks[i] ?? ''} onChange={(e) => setExpertisePicks((p) => p.map((v, j) => (j === i ? e.target.value : v)))}>
<option value=""> pick a skill </option>
{[...skills, ...grantedSkills].map((k) => (
<option key={k} value={k} disabled={expertisePicks.includes(k) && expertisePicks[i] !== k}>{skillLabel(k)}</option>
))}
</Select>
))}
</div>
</div>
)}
</div>
)}
{stepName === 'Spells' && (
<div>
<p className="mb-1 text-sm text-muted">Pick your cantrips and a few starting spells about <span className="font-semibold text-ink">{suggestedSpells}</span> is a good start at level {level}. Search by name; you can always adjust on the sheet later.</p>
<p className="mb-1 text-xs text-muted">These are the spells you <span className="text-ink">{system === 'pf2e' ? 'know (your repertoire)' : 'know (the spells youve learned)'}</span>. You prepare or cast them from slots on the character sheet.</p>
<p className="mb-1 text-xs text-muted">
{(() => {
// Prepared casters re-select daily from a book/list; known casters have a
// fixed repertoire. Different mental model, so spell out which applies.
const prepared = (system === '5e' ? ['cleric', 'druid', 'paladin', 'wizard', 'artificer'] : ['wizard', 'cleric', 'druid', 'witch', 'magus', 'animist'])
.includes((selectedClass?.name ?? '').toLowerCase());
return prepared ? (
<>As a <span className="text-ink">prepared caster</span>, these go into your {system === 'pf2e' ? 'spellbook/list' : 'spellbook'} each day you prepare a subset on the sheet, so pick a versatile starting set.</>
) : (
<>These are the spells you <span className="text-ink">know (your repertoire)</span> a fixed list you cast from slots; you can swap picks when you level up.</>
);
})()}
</p>
<p className={cn('mb-2 text-xs', spellPicks.length > suggestedSpells + 4 ? 'text-warning' : 'text-muted')}>
{spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — thats quite a few; you can trim some later if you like.' : ''}
</p>
@@ -800,7 +948,11 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
</div>
<Review label="Abilities" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} />
{background && <Review label="Background" value={background} />}
<Review label="Trained skills" value={skills.map(skillLabel).join(', ') || '—'} />
{heritage && <Review label="Heritage" value={heritage} />}
<Review label="Trained skills" value={[
...skills.map(skillLabel),
...grantedSkillSources.map((g) => `${skillLabel(g.key)} (${g.source})`),
].join(', ') || '—'} />
{built.spellcasting.slots.length > 0 && <Review label="Spell slots" value={built.spellcasting.slots.map((s) => `${s.max}×L${s.level}`).join(' ')} />}
{spellPicks.length > 0 && <Review label="Spells" value={spellPicks.map((s) => s.name).join(', ')} />}
<p className="text-xs text-muted">You can fine-tune equipment, feats, and more on the sheet next.</p>
@@ -3,7 +3,7 @@ import { useState } from 'react';
import { Minus, Plus, Skull, Star } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { NumberField } from '@/components/ui/NumberField';
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery } from '@/lib/mechanics';
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue } from '@/lib/mechanics';
import type { Degree } from '@/lib/dice/check';
import type { Condition } from '@/lib/schemas/common';
import { SheetSection, type SectionProps } from './common';
@@ -39,17 +39,27 @@ function Pf2eDefenses({ c, update }: SectionProps) {
conds.filter((x) => x.name.trim().toLowerCase() !== 'unconscious');
const setDefenses = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
/** Set dying and keep the Unconscious condition in sync (dying>0 ⇒ unconscious). */
// You stay Unconscious at 0 HP even after recovering out of dying — only healing
// above 0 HP (or the GM) wakes you. So the sync is: dying>0 OR hp<=0 ⇒ unconscious.
const syncUnconscious = (dying: number, hpCurrent: number) =>
dying > 0 || hpCurrent <= 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions);
/** Set dying and keep the Unconscious condition in sync. */
const setDying = (dying: number) =>
update({ defenses: { ...d, dying }, conditions: dying > 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions) });
update({ defenses: { ...d, dying }, conditions: syncUnconscious(dying, c.hp.current) });
const doKnockOut = () => {
const { dying } = knockOut(d, fromCrit);
update({ defenses: { ...d, dying }, conditions: withUnconscious(c.conditions) });
// Knocked out = at 0 HP and dying; drop HP to 0 so the sheet state is coherent.
update({ defenses: { ...d, dying }, conditions: withUnconscious(c.conditions), hp: { ...c.hp, current: Math.min(0, c.hp.current) } });
};
const doRecover = (degree: Degree) => {
const res = applyRecovery(d, degree);
update({ defenses: { ...d, ...res }, conditions: res.dying > 0 ? withUnconscious(c.conditions) : withoutUnconscious(c.conditions) });
update({ defenses: { ...d, ...res }, conditions: syncUnconscious(res.dying, c.hp.current) });
};
const doHeroRescue = () => {
const res = heroPointRescue(d);
if (!res) return;
update({ defenses: { ...d, ...res }, conditions: syncUnconscious(0, c.hp.current) });
};
return (
@@ -98,6 +108,12 @@ function Pf2eDefenses({ c, update }: SectionProps) {
<Button size="sm" variant="secondary" onClick={() => doRecover('failure')}>Failure +1</Button>
<Button size="sm" variant="danger" onClick={() => doRecover('critical-failure')}>Crit fail +2</Button>
</div>
{d.heroPoints >= 1 && (
<div className="mt-2 flex items-center justify-between gap-2 border-t border-line pt-2">
<span className="text-[11px] text-muted">Heroic Recovery: spend ALL your Hero Points ({d.heroPoints}) to drop to Dying 0 and stabilize.</span>
<Button size="sm" variant="primary" onClick={doHeroRescue}>Spend &amp; stabilize</Button>
</div>
)}
</div>
)}
</div>
+108 -6
View File
@@ -1,17 +1,19 @@
import { useEffect, useMemo, useState } from 'react';
import type { Campaign, Character, ClassEntry, Spellcasting } from '@/lib/schemas';
import type { Campaign, Character, ClassEntry, Feat, Spellcasting } from '@/lib/schemas';
import { normalizeClassMirror, totalLevel } from '@/lib/schemas';
import {
abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
planLevelUp, applyIncreases, bumpRank, getClassDef,
planLevelUp, appendLevelIncreases, bumpRank, getClassDef, hitDiceResource,
collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature,
} from '@/lib/rules';
import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression';
import { loadPf2e } from '@/lib/compendium';
import { rollDice } from '@/lib/dice/notation';
import { createRng } from '@/lib/rng';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
import { FeatPickerModal } from './FeatPickerModal';
import { LevelUpAdvisor } from './LevelUpAdvisor';
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
@@ -54,6 +56,9 @@ export function LevelUpModal({ character, onApply, onClose }: {
const keyDefaults = def?.keyAbilities ?? ['str', 'dex'];
const [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi');
const [asiPicks, setAsiPicks] = useState<AbilityKey[]>([keyDefaults[0] ?? 'str', keyDefaults[1] ?? keyDefaults[0] ?? 'con']);
// Feat-instead-of-ASI (5e): picked right here, recorded onto the sheet on apply.
const [featPick, setFeatPick] = useState<Feat | null>(null);
const [featBrowse, setFeatBrowse] = useState(false);
// Boosts (pf2e): four distinct picks.
const [boostPicks, setBoostPicks] = useState<AbilityKey[]>(['str', 'dex', 'con', 'wis']);
// Skill increase (pf2e): one skill to bump.
@@ -109,6 +114,15 @@ export function LevelUpModal({ character, onApply, onClose }: {
const [choicePicks, setChoicePicks] = useState<Record<string, string[]>>({});
const [subclassPick, setSubclassPick] = useState<string>('');
// PF2e feat-slot inputs autocomplete against the compendium so names land typo-free.
const [pf2eFeatNames, setPf2eFeatNames] = useState<string[]>([]);
const wantsFeatSuggestions = !is5e && pendingChoices.some(({ choice }) => !choice.options && /-feat$/.test(choice.key.split(':')[1] ?? ''));
useEffect(() => {
if (!wantsFeatSuggestions || pf2eFeatNames.length) return;
let on = true;
void loadPf2e('feats').then((fs) => on && setPf2eFeatNames(fs.map((f) => String(f.name)).filter(Boolean)));
return () => { on = false; };
}, [wantsFeatSuggestions, pf2eFeatNames.length]);
const setChoicePick = (key: string, i: number, val: string) =>
setChoicePicks((p) => { const arr = [...(p[key] ?? [])]; arr[i] = val; return { ...p, [key]: arr }; });
// Seed defaults for option-based choices so an untouched picker still records its pick.
@@ -131,9 +145,16 @@ export function LevelUpModal({ character, onApply, onClose }: {
? plan.hpGainAverage
: hpMethod === 'average' ? plan.hpGainAverage : Math.max(1, rollDice(`1d${plan.hitDie}`, createRng()).total + conMod);
// Ability increases go through the build so the sheet's per-source breakdown
// stays in sync with the totals (the build is the single source of truth).
let abilities = character.abilities;
if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e');
if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e');
let abilityBuild = character.abilityBuild;
if (asi && asiMode === 'asi') {
({ build: abilityBuild, abilities } = appendLevelIncreases(abilityBuild, abilities, asiPicks, '5e', `ASI L${plan.nextLevel}`));
}
if (boosts) {
({ build: abilityBuild, abilities } = appendLevelIncreases(abilityBuild, abilities, boostPicks, 'pf2e', `L${plan.nextLevel} boost`));
}
// Resolve a subclass pick (5e prompt or a pf2e `:subclass` choice) onto the class entry.
const subFromChoice = Object.entries(choicePicks)
@@ -156,6 +177,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
const patch: Partial<Character> = {
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
abilities,
...(abilityBuild ? { abilityBuild } : {}),
classes: nextClassesFinal,
...normalizeClassMirror({ classes: nextClassesFinal }),
choices: merged,
@@ -180,6 +202,31 @@ export function LevelUpModal({ character, onApply, onClose }: {
const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained';
patch.skillRanks = { ...character.skillRanks, [skillKey]: bumpRank(current) };
}
// 5e Expertise picks aren't just recorded — they raise the skill rank to expert
// so the doubled proficiency actually lands in the math.
const expertisePicks = Object.entries(choicePicks).filter(([k, v]) => k.endsWith(':expertise') && v.some((x) => x.trim()));
if (expertisePicks.length) {
const ranks: Record<string, ProficiencyRank> = { ...(patch.skillRanks ?? character.skillRanks) as Record<string, ProficiencyRank> };
for (const [, vals] of expertisePicks) {
for (const label of vals) {
const want = label.trim().toLowerCase();
const key = sys.skills.find((s) => s.label.toLowerCase() === want || s.key === want)?.key;
if (key) ranks[key] = 'expert';
}
}
patch.skillRanks = ranks;
}
// 5e: each level grants a Hit Die — keep the tracked resource in step.
if (is5e) {
const newTotal = Math.min(20, total + 1);
const i = character.resources.findIndex((r) => r.name.trim().toLowerCase() === 'hit dice');
patch.resources = i >= 0
? character.resources.map((r, j) => (j === i ? { ...r, max: r.max + 1, current: Math.min(r.max + 1, r.current + 1), recoverStep: Math.ceil(newTotal / 2) } : r))
: [...character.resources, hitDiceResource(newTotal)];
}
if (asi && asiMode === 'feat' && featPick) {
patch.feats = [...character.feats, featPick];
}
onApply(patch);
onClose();
};
@@ -262,10 +309,18 @@ export function LevelUpModal({ character, onApply, onClose }: {
<span className="self-center text-xs text-muted">pick the same twice for +2</span>
</div>
) : (
<p className="text-sm text-muted">Add your chosen feat on the sheet after leveling.</p>
<div className="flex flex-wrap items-center gap-2">
{featPick ? (
<span className="rounded-md border border-accent/50 bg-accent/5 px-2 py-1 text-sm text-ink">{featPick.name}</span>
) : (
<span className="text-sm text-muted">No feat chosen yet.</span>
)}
<Button size="sm" variant="secondary" onClick={() => setFeatBrowse(true)}>{featPick ? 'Change feat…' : 'Browse feats…'}</Button>
</div>
)}
</section>
)}
{featBrowse && <FeatPickerModal onPick={(f) => setFeatPick(f)} onClose={() => setFeatBrowse(false)} />}
{/* Boosts (pf2e) */}
{boosts && (
@@ -335,7 +390,14 @@ export function LevelUpModal({ character, onApply, onClose }: {
{choice.options.map((o) => <option key={o} value={o}>{o}</option>)}
</Select>
) : (
<Input key={i} aria-label={`${choice.label} ${i + 1}`} placeholder="Type your choice…" value={choicePicks[choice.key]?.[i] ?? ''} onChange={(e) => setChoicePick(choice.key, i, e.target.value)} />
<SuggestInput
key={i}
ariaLabel={`${choice.label} ${i + 1}`}
placeholder="Type your choice…"
value={choicePicks[choice.key]?.[i] ?? ''}
onChange={(v) => setChoicePick(choice.key, i, v)}
suggestions={/-feat$/.test(choice.key.split(':')[1] ?? '') ? pf2eFeatNames : []}
/>
)
))}
</div>
@@ -352,3 +414,43 @@ export function LevelUpModal({ character, onApply, onClose }: {
</Modal>
);
}
/** Free-text input with a lightweight suggestion dropdown (used for PF2e feat names). */
function SuggestInput({ value, onChange, suggestions, ariaLabel, placeholder }: {
value: string;
onChange: (v: string) => void;
suggestions: string[];
ariaLabel: string;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const q = value.trim().toLowerCase();
const matches = q.length >= 2 ? suggestions.filter((n) => n.toLowerCase().includes(q) && n !== value).slice(0, 8) : [];
return (
<div className="relative">
<Input
value={value}
aria-label={ariaLabel}
{...(placeholder ? { placeholder } : {})}
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
/>
{open && matches.length > 0 && (
<div className="absolute z-20 mt-1 max-h-44 w-full overflow-y-auto rounded-md border border-line bg-panel shadow-xl">
{matches.map((n) => (
<button
key={n}
type="button"
className="block w-full px-2 py-1 text-left text-sm text-ink hover:bg-elevated"
// mousedown beats the input's blur so the click still lands
onMouseDown={(e) => { e.preventDefault(); onChange(n); setOpen(false); }}
>
{n}
</button>
))}
</div>
)}
</div>
);
}
@@ -17,6 +17,17 @@ const RECOVERY_LABEL: Record<CharacterResource['recovery'], string> = {
none: 'Manual',
};
/** Tooltip spelling out EVERYTHING the rest does, not just resource tags. */
function restTitle(system: string, optId: string, recovers: readonly string[]): string {
if (system === 'pf2e' && optId === 'rest') {
return 'Rest for the Night: restore HP (capped by Drained), refill spell slots, clear Wounded, reduce Drained and Doomed by 1, remove Fatigued.';
}
if (system === 'pf2e' && optId === 'refocus') return 'Refocus (10 min): regain 1 Focus Point.';
if (optId === 'long') return 'Long rest: restore HP, spell slots, and long-rest resources (Hit Dice recover half your level); exhaustion 1; death saves reset.';
if (optId === 'short') return 'Short rest: regain pact slots and short-rest resources; spend Hit Dice to heal.';
return `Restore: ${recovers.join(', ')}`;
}
export function ResourcesSection({ c, update }: SectionProps) {
const [name, setName] = useState('');
const sys = getSystem(c.system);
@@ -42,7 +53,7 @@ export function ResourcesSection({ c, update }: SectionProps) {
actions={
<div className="flex gap-2">
{sys.restOptions.map((o) => (
<Button key={o.id} size="sm" variant="secondary" onClick={() => rest(o.id)} title={`Restore: ${o.recovers.join(', ')}`}>
<Button key={o.id} size="sm" variant="secondary" onClick={() => rest(o.id)} title={restTitle(c.system, o.id, o.recovers)}>
{o.label}
</Button>
))}
+77 -10
View File
@@ -23,7 +23,7 @@ import { createRng } from '@/lib/rng';
import { rollDice } from '@/lib/dice/notation';
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget';
import { deriveState, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics';
import { deriveState, deriveEffectiveMaxHp, DAMAGE_TYPES, concentrationDC } from '@/lib/mechanics';
import { useCharacters, useAllPcs } from '@/features/characters/hooks';
import { useConditionGlossary } from './useConditionGlossary';
import {
@@ -82,6 +82,9 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
*/
const noteConcentrationCheck = (combatant: Combatant, damage: number) => {
if (damage <= 0) return;
// 5e only: damage forces a Con save to hold concentration. PF2e has no such save —
// sustained spells simply require the Sustain action, so there is nothing to roll.
if (campaign.system !== '5e') return;
// The GM-set combatant flag works for any creature (monster/NPC/PC); fall back to
// the linked Character's concentration (set when a seated player casts a spell).
const ch = pcCharacter(combatant);
@@ -92,12 +95,29 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
logEvent(e, `${combatant.name}: roll a DC ${dc} Constitution save or lose concentration on ${spellName}.`));
};
/** Effective max HP for a combatant — drained / exhaustion 4 reduce it. */
const effMaxOf = (c: Combatant) => deriveEffectiveMaxHp({
system: campaign.system,
baseMaxHp: c.hp.max,
level: c.level ?? 1,
exhaustion: c.conditions.find((x) => x.name.trim().toLowerCase() === 'exhaustion')?.value ?? 0,
conditions: c.conditions,
}).max;
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. */
const advanceTurn = () => {
mutate((e) => nextTurn(e, campaign.system));
const upcoming = currentCombatant(nextTurn(encounter, campaign.system));
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
void encountersRepo.mutate(encounter.id, (e) => logEvent(e, `${upcoming.name} is down — make a death saving throw.`));
const reminder = campaign.system === 'pf2e'
? (() => {
const dying = pcCharacter(upcoming)?.defenses.dying ?? 0;
return dying > 0
? `${upcoming.name} is dying — roll a flat recovery check (DC ${10 + dying}) on their sheet.`
: `${upcoming.name} is down — use “Knock out” on their sheet to start recovery checks.`;
})()
: `${upcoming.name} is down — make a death saving throw.`;
void encountersRepo.mutate(encounter.id, (e) => logEvent(e, reminder));
}
};
@@ -246,12 +266,18 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
: `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`;
// Surface (don't auto-apply) the death rules for downed PCs — the player
// owns their character's death-save count, so we remind rather than mutate it.
// owns their character's death state, so we remind rather than mutate it.
let reminder: string | null = null;
if (c.kind !== 'monster' && hpLoss > 0) {
if (isMassiveDamageDeath(c.hp.max, after.hp.current)) reminder = `${c.name} suffers massive damage and dies instantly — no death saves.`;
if (campaign.system === 'pf2e') {
if (c.hp.current <= 0) reminder = `${c.name} took damage while dying — increase Dying by 1 (2 on a critical hit).`;
else if (after.hp.current <= 0) reminder = `${c.name} drops to 0 HP — use “Knock out” on their sheet (Dying 1 + Wounded; 2 on a crit).`;
} else {
// Massive damage compares against the (possibly reduced) effective max.
if (isMassiveDamageDeath(effMaxOf(c), after.hp.current)) reminder = `${c.name} suffers massive damage and dies instantly — no death saves.`;
else if (c.hp.current <= 0) reminder = `${c.name} took damage while down — mark a death save failure (two on a critical hit).`;
}
}
mutate((e) => {
let next = logEvent(updateCombatant(e, c.id, { hp: after.hp }), note);
if (reminder) next = logEvent(next, reminder);
@@ -259,7 +285,20 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
});
noteConcentrationCheck(c, hpLoss);
}}
onHeal={(amt) => mutate((e) => logEvent(updateCombatant(e, c.id, { hp: applyHealing(c, amt).hp }), `${c.name} heals ${amt}`))}
onHeal={(amt) => {
// Cap at the effective max (drained / exhaustion 4), and for pf2e let
// healing above 0 do the wake-up bookkeeping (Dying ends → Wounded +1).
const healed = applyHealing(c, amt, effMaxOf(c));
const woke = campaign.system === 'pf2e' && c.hp.current <= 0 && healed.hp.current > 0;
const conditions = woke
? c.conditions.filter((x) => !['unconscious', 'dying'].includes(x.name.trim().toLowerCase()))
: c.conditions;
mutate((e) => {
let next = logEvent(updateCombatant(e, c.id, { hp: healed.hp, ...(woke ? { conditions } : {}) }), `${c.name} heals ${amt}`);
if (woke) next = logEvent(next, `${c.name} is back up — Dying ends (increase Wounded by 1 on their sheet) and they wake.`);
return next;
});
}}
onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))}
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
/>
@@ -489,7 +528,11 @@ function CombatantRow({
type="button"
onClick={() => onChange({ concentrating: c.concentrating ? null : 'a spell' })}
className={cn('rounded p-0.5', c.concentrating ? 'text-accent' : 'text-faint hover:text-muted')}
title={c.concentrating ? `Concentrating on ${c.concentrating} — click to clear` : 'Mark as concentrating (prompts a Con save when damaged)'}
title={c.concentrating
? `Concentrating on ${c.concentrating} — click to clear`
: system === 'pf2e'
? 'Mark as sustaining a spell (a reminder, in case they stop Sustaining)'
: 'Mark as concentrating (prompts a Con save when damaged)'}
aria-label={c.concentrating ? `${c.name} is concentrating; clear` : `Mark ${c.name} as concentrating`}
aria-pressed={!!c.concentrating}
>
@@ -513,17 +556,41 @@ function CombatantRow({
{c.conditions.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{c.conditions.map((cond, i) => (
<button
<span
key={`${cond.name}-${i}`}
className="inline-flex items-center gap-1 rounded-full border border-verdigris/45 px-2 py-0.5 text-xs font-medium text-verdigris transition-colors hover:line-through"
title={glossary.get(cond.name.toLowerCase()) || 'Click to remove'}
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
className="inline-flex items-center gap-1 rounded-full border border-verdigris/45 px-2 py-0.5 text-xs font-medium text-verdigris"
title={glossary.get(cond.name.toLowerCase()) || undefined}
>
{cond.name}
{cond.value ? ` ${cond.value}` : ''}
{cond.duration ? ` (${cond.duration}r)` : ''}
{/* Valued conditions (Frightened 2, Drained 3…) step in place — no delete-and-re-add. */}
{cond.value !== undefined && (
<>
<button
aria-label={`Decrease ${cond.name}`}
className="rounded px-0.5 leading-none hover:bg-verdigris/15"
onClick={() => onChange({
conditions: (cond.value ?? 1) <= 1
? c.conditions.filter((_, j) => j !== i)
: c.conditions.map((x, j) => (j === i ? { ...x, value: (x.value ?? 1) - 1 } : x)),
})}
></button>
<button
aria-label={`Increase ${cond.name}`}
className="rounded px-0.5 leading-none hover:bg-verdigris/15"
onClick={() => onChange({ conditions: c.conditions.map((x, j) => (j === i ? { ...x, value: (x.value ?? 0) + 1 } : x)) })}
>+</button>
</>
)}
<button
aria-label={`Remove ${cond.name}`}
className="transition-opacity hover:opacity-60"
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
>
<X size={11} aria-hidden />
</button>
</span>
))}
</div>
)}
+21 -33
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { Link } from '@tanstack/react-router';
import { useLiveQuery } from 'dexie-react-hooks';
import { db } from '@/lib/db/db';
import { cloudUsername, CloudError } from '@/lib/cloud/client';
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, getUsage, adminListUsers, adminSetQuota, type CloudCampaignInfo, type CloudCharInfo, type AdminUserRow } from '@/lib/cloud/campaigns';
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, getUsage, type CloudCampaignInfo, type CloudCharInfo } from '@/lib/cloud/campaigns';
import { useCampaigns } from '@/features/campaigns/hooks';
import { Button } from '@/components/ui/Button';
import { Input, Select } from '@/components/ui/Input';
@@ -21,13 +22,10 @@ export function CloudCampaigns() {
const [targetCloud, setTargetCloud] = useState('');
const [viewing, setViewing] = useState<string | null>(null);
const [party, setParty] = useState<CloudCharInfo[]>([]);
const [usage, setUsage] = useState<{ bytes: number; admin: boolean } | null>(null);
const [admins, setAdmins] = useState<AdminUserRow[]>([]);
const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null);
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]);
useEffect(() => { if (signedIn) getUsage().then(setUsage).catch(() => {}); }, [signedIn]);
const loadAdmin = () => adminListUsers().then((r) => setAdmins(r.users)).catch(() => {});
useEffect(() => { if (usage?.admin) loadAdmin(); }, [usage?.admin]);
const refresh = () => listCloudCampaigns().then(setList).catch(() => {});
const run = (fn: () => Promise<void>) => async () => {
@@ -122,24 +120,26 @@ export function CloudCampaigns() {
</div>
</div>
{usage && <p className="text-xs text-muted">Your cloud storage: <span className="text-ink">{fmtBytes(usage.bytes)}</span></p>}
{usage && (() => {
const pct = usage.quota > 0 ? Math.min(100, Math.round((usage.bytes / usage.quota) * 100)) : 0;
const near = pct >= 90;
return (
<div className="text-xs">
<p className={near ? 'text-warning' : 'text-muted'}>
Your cloud storage: <span className="text-ink">{fmtBytes(usage.bytes)}</span> of {fmtBytes(usage.quota)} ({pct}%)
{near && ' — almost full; trim old data or ask the admin to raise your quota.'}
</p>
<div className="mt-1 h-1.5 w-full max-w-xs overflow-hidden rounded-full bg-elevated">
<div className={near ? 'h-full bg-warning' : 'h-full bg-accent'} style={{ width: `${pct}%` }} />
</div>
</div>
);
})()}
{usage?.admin && (
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
<h3 className="mb-1 smallcaps">Admin · users &amp; storage</h3>
<table className="w-full text-xs">
<thead><tr className="text-left text-muted"><th className="py-1 font-medium">User</th><th className="font-medium">Used</th><th className="font-medium">Quota (GB, 0=default)</th></tr></thead>
<tbody>
{admins.map((u) => (
<tr key={u.username} className="border-t border-line">
<td className="py-1 text-ink">{u.username}</td>
<td className="py-1 text-muted">{fmtBytes(u.usageBytes)}</td>
<td className="py-1"><QuotaCell user={u} onSaved={loadAdmin} /></td>
</tr>
))}
</tbody>
</table>
<p className="mt-1 text-[11px] text-muted">Quotas are tracked but not yet enforced a safety valve for later.</p>
<div className="rounded-md border border-accent/30 bg-accent/5 p-2 text-sm">
Youre the instance admin manage accounts, storage, campaigns, and live rooms in the{' '}
<Link to="/admin" className="text-accent underline">Admin panel</Link>.
</div>
)}
@@ -154,15 +154,3 @@ function fmtBytes(b: number): string {
if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`;
return `${Math.max(0, Math.round(b / 1024))} KB`;
}
function QuotaCell({ user, onSaved }: { user: AdminUserRow; onSaved: () => void }) {
const [gb, setGb] = useState(String(user.quotaBytes ? (user.quotaBytes / 1e9).toFixed(0) : '0'));
const [saving, setSaving] = useState(false);
const save = async () => { setSaving(true); try { await adminSetQuota(user.username, (Number(gb) || 0) * 1e9); onSaved(); } catch { /* ignore */ } finally { setSaving(false); } };
return (
<span className="flex items-center gap-1">
<input value={gb} onChange={(e) => setGb(e.target.value.replace(/[^0-9]/g, ''))} className="w-12 rounded border border-line bg-surface px-1 py-0.5 text-xs" aria-label={`Quota GB for ${user.username}`} />
<Button size="sm" variant="ghost" disabled={saving} onClick={() => void save()}>Set</Button>
</span>
);
}
+58
View File
@@ -0,0 +1,58 @@
import { req } from './campaigns';
/** Typed client for the admin-panel API (accounts listed in ADMIN_USERS only). */
export interface AdminOverview {
uptimeMs: number;
memory: { rss: number; heapUsed: number };
dataDirBytes: number;
users: { count: number; max: number | null };
defaultQuotaBytes: number;
campaigns: number;
characters: number;
characterBytes: number;
rooms: { count: number; players: number; imageBytes: number };
}
export interface AdminUser {
username: string;
admin: boolean;
createdAt: number;
usageBytes: number;
quotaBytes: number;
effectiveQuota: number;
tokenCount: number;
}
export interface AdminCampaign {
id: string;
name: string;
system: string;
owner: string;
members: number;
characters: number;
bytes: number;
updatedAt: number;
}
export interface AdminRoom {
players: number;
seats: number;
images: number;
imageBytes: number;
idleMs: number;
hasGm: boolean;
}
export const adminOverview = () => req<AdminOverview>('/admin/overview');
export const adminUsers = () => req<{ users: AdminUser[] }>('/admin/users');
export const adminCampaigns = () => req<{ campaigns: AdminCampaign[] }>('/admin/campaigns');
export const adminRooms = () => req<{ rooms: AdminRoom[] }>('/admin/rooms');
export const adminSetQuota = (username: string, quotaBytes: number) =>
req<{ ok: boolean }>('/admin/quota', { method: 'POST', body: JSON.stringify({ username, quotaBytes }) });
export const adminRevokeTokens = (username: string) =>
req<{ ok: boolean }>(`/admin/users/${encodeURIComponent(username)}/revoke`, { method: 'POST' });
export const adminDeleteUser = (username: string) =>
req<{ ok: boolean; purged: { campaigns: number; characters: number } }>(`/admin/users/${encodeURIComponent(username)}`, { method: 'DELETE' });
export const adminDeleteCampaign = (id: string) =>
req<{ ok: boolean }>(`/admin/campaigns/${encodeURIComponent(id)}`, { method: 'DELETE' });
+4 -10
View File
@@ -12,7 +12,8 @@ function authHeaders(): Record<string, string> {
export interface CloudCampaignInfo { id: string; name: string; system: string; role: 'owner' | 'member'; inviteCode?: string }
export interface CloudCharInfo { id: string; name: string; ownerUserId: string; mine: boolean; data: string; updatedAt: number }
async function req<T>(path: string, init?: RequestInit): Promise<T> {
/** Authenticated same-origin /api request — shared by the campaign + admin clients. */
export async function req<T>(path: string, init?: RequestInit): Promise<T> {
let res: Response;
try {
res = await fetch(`${base()}${path}`, { ...init, headers: { 'content-type': 'application/json', ...authHeaders(), ...(init?.headers ?? {}) } });
@@ -52,13 +53,6 @@ export async function cloudUsageBytes(): Promise<number> {
return (await req<{ bytes: number }>('/usage')).bytes;
}
export interface AdminUserRow { username: string; usageBytes: number; quotaBytes: number }
export function getUsage(): Promise<{ bytes: number; admin: boolean }> {
return req<{ bytes: number; admin: boolean }>('/usage');
}
export function adminListUsers(): Promise<{ users: AdminUserRow[] }> {
return req<{ users: AdminUserRow[] }>('/admin/users');
}
export function adminSetQuota(username: string, quotaBytes: number): Promise<{ ok: boolean }> {
return req<{ ok: boolean }>('/admin/quota', { method: 'POST', body: JSON.stringify({ username, quotaBytes }) });
export function getUsage(): Promise<{ bytes: number; quota: number; admin: boolean }> {
return req<{ bytes: number; quota: number; admin: boolean }>('/usage');
}
+2
View File
@@ -60,6 +60,8 @@ export async function pushBackup(opts?: { force?: boolean }): Promise<PushResult
const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob, baseSavedAt }) });
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
if (res.status === 409) { const d = (await res.json().catch(() => ({}))) as { savedAt?: number }; return { ok: false, conflict: true, savedAt: Number(d.savedAt) || 0 }; }
if (res.status === 413) throw new CloudError('Cloud storage limit reached — trim old data or ask the admin to raise your quota.');
if (res.status === 429) throw new CloudError('Too many requests — please wait a moment and try again.');
if (!res.ok) throw new CloudError('Upload failed.');
const d = (await res.json().catch(() => ({}))) as { savedAt?: number };
if (typeof d.savedAt === 'number') setLastSyncedAt(d.savedAt);
+7
View File
@@ -189,6 +189,13 @@ describe('HP transitions', () => {
expect(applyDamage(im, 10, 'fire').hp.current).toBe(30); // immune to all → no change
expect(applyDamage(im, 10, 'cold').hp.current).toBe(30);
});
it('healing honors a reduced effective-max cap (drained / exhaustion 4)', () => {
const c = { ...mk('a', 0, 30), hp: { current: 10, max: 30, temp: 0 } };
expect(applyHealing(c, 25, 20).hp.current).toBe(20); // capped at effective max
expect(applyHealing(c, 25).hp.current).toBe(30); // no cap → stored max
expect(applyHealing(c, 5, 40).hp.current).toBe(15); // cap never exceeds stored max
});
});
describe('endEncounter', () => {
+8 -3
View File
@@ -267,10 +267,15 @@ export function isMassiveDamageDeath(hpMax: number, currentAfter: number): boole
return currentAfter <= 0 && -currentAfter >= hpMax;
}
/** Heal up to max. Does not touch temporary HP. */
export function applyHealing(c: Combatant, amount: number): Combatant {
/**
* Heal up to max. Does not touch temporary HP. `capMax` overrides the cap when the
* effective maximum is reduced by conditions (5e exhaustion 4, pf2e drained) pass
* `deriveEffectiveMaxHp(...).max` so healing can't exceed the reduced maximum.
*/
export function applyHealing(c: Combatant, amount: number, capMax?: number): Combatant {
if (!Number.isFinite(amount) || amount <= 0) return c;
return { ...c, hp: { ...c.hp, current: Math.min(c.hp.max, c.hp.current + amount) } };
const cap = capMax !== undefined ? Math.min(capMax, c.hp.max) : c.hp.max;
return { ...c, hp: { ...c.hp, current: Math.min(cap, c.hp.current + amount) } };
}
/** Set temporary HP — 5e/PF2e temp HP does not stack; take the higher value. */
+6 -1
View File
@@ -1,4 +1,5 @@
import type { Character } from '@/lib/schemas';
import { synthManualBuild } from '@/lib/rules/abilityBuild';
/** Convert a Pathbuilder 2e export (build JSON) into our PF2e character fields. */
@@ -43,10 +44,14 @@ export function pathbuilderToCharacterFields(b: PathbuilderBuild): Partial<Chara
ancestry: b.ancestry ?? '',
level,
abilities,
// Imports carry only finals — persist a manual build so the sheet stays consistent.
abilityBuild: synthManualBuild(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}` : ''}` } : {}),
// Background and heritage are first-class fields now — no more stuffing them in notes.
...(b.background ? { background: b.background } : {}),
...(b.heritage ? { heritage: b.heritage } : {}),
};
}
+7
View File
@@ -109,6 +109,13 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con
extraBadges.push('Quickened (+1 action)');
continue;
}
// --- pf2e Persistent Damage: the human rolls it; we surface the procedure. ---
if (system === 'pf2e' && name === 'persistent damage') {
extraBadges.push(cond.value
? `Persistent damage ${cond.value} (apply at end of turn, then DC 15 flat check to end)`
: 'Persistent damage (apply at end of turn, then DC 15 flat check to end)');
continue;
}
const eff = table[name];
if (!eff) continue;
+11 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, pf2eRestDecay } from './dying';
import { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue, pf2eRestDecay } from './dying';
import type { Condition } from '@/lib/schemas/common';
const cond = (name: string, value?: number): Condition => (value !== undefined ? { name, value } : { name });
@@ -50,6 +50,16 @@ describe('applyRecovery', () => {
});
});
describe('heroPointRescue', () => {
it('spends all hero points to drop to dying 0 and increases wounded', () => {
expect(heroPointRescue({ dying: 3, heroPoints: 2, wounded: 1 })).toEqual({ dying: 0, heroPoints: 0, wounded: 2 });
});
it('returns null when not dying or no points to spend', () => {
expect(heroPointRescue({ dying: 0, heroPoints: 2, wounded: 0 })).toBeNull();
expect(heroPointRescue({ dying: 2, heroPoints: 0, wounded: 0 })).toBeNull();
});
});
describe('pf2eRestDecay', () => {
it('decays doomed, clears wounded, decrements/removes drained, ends fatigued', () => {
const r = pf2eRestDecay({ doomed: 2, wounded: 3 }, [cond('Drained', 2), cond('Fatigued'), cond('Frightened', 1)]);
+10
View File
@@ -45,6 +45,16 @@ export function applyRecovery(d: Pick<Defenses, 'dying' | 'wounded'>, degree: De
return { dying, wounded };
}
/**
* Heroic Recovery: spend ALL remaining Hero Points (minimum 1) to avoid death
* dying drops to 0 and you stabilize (you stay unconscious at 0 HP until healed).
* Losing the dying condition always increases wounded by 1.
*/
export function heroPointRescue(d: Pick<Defenses, 'dying' | 'heroPoints' | 'wounded'>): { dying: number; heroPoints: number; wounded: number } | null {
if (d.heroPoints < 1 || d.dying <= 0) return null;
return { dying: 0, heroPoints: 0, wounded: d.wounded + 1 };
}
/**
* Condition decay from a full night's rest (Rest for the Night): doomed 1, wounded
* cleared (you wake at full HP), Drained 1, and Fatigued removed. Dying is assumed
+1 -1
View File
@@ -18,7 +18,7 @@ export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefens
export { castSpell, availableSlotLevels, dropConcentration, concentrationDC } from './cast';
export { spendResource, regainResource } from './resources';
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
export { maxDying, isDead, recoveryDc, knockOut, applyRecovery, pf2eRestDecay } from './dying';
export { maxDying, isDead, recoveryDc, knockOut, applyRecovery, heroPointRescue, pf2eRestDecay } from './dying';
export { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects';
export { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn, type MechanicalState, type DeriveContext } from './creatureState';
+34 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal } from './abilityBuild';
import { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal, appendLevelIncreases } from './abilityBuild';
import type { AbilityBuild } from '@/lib/schemas/character';
const base10 = { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
@@ -66,3 +66,36 @@ describe('setManualTotal', () => {
expect(computeAbilities(reset).str).toBe(12);
});
});
describe('appendLevelIncreases', () => {
it('5e ASI picks append as flat +1 each and totals match', () => {
const build: AbilityBuild = { base: { ...base10, str: 15 }, adjustments: [{ label: 'Race', ability: 'str', kind: 'flat', amount: 2 }] };
const current = computeAbilities(build); // str 17
const r = appendLevelIncreases(build, current, ['str', 'str'], '5e', 'ASI L4');
expect(r.abilities.str).toBe(19);
expect(computeAbilities(r.build)).toEqual(r.abilities); // breakdown stays in sync
expect(r.build.adjustments.filter((a) => a.label === 'ASI L4')).toHaveLength(2);
});
it('pf2e boosts append with the <18 (+2) / ≥18 (+1) rule', () => {
const build: AbilityBuild = { base: base10, adjustments: [
{ label: 'Ancestry', ability: 'str', kind: 'boost', amount: 0 },
{ label: 'Class', ability: 'str', kind: 'boost', amount: 0 },
{ label: 'Free', ability: 'str', kind: 'boost', amount: 0 },
] };
const current = computeAbilities(build); // str 16
const r = appendLevelIncreases(build, current, ['str', 'dex'], 'pf2e', 'L5 boost');
expect(r.abilities.str).toBe(18); // 16 → +2
expect(r.abilities.dex).toBe(12);
const r2 = appendLevelIncreases(r.build, r.abilities, ['str'], 'pf2e', 'L10 boost');
expect(r2.abilities.str).toBe(19); // 18 → +1
expect(computeAbilities(r2.build)).toEqual(r2.abilities);
});
it('synthesizes a manual build when the stored one is stale', () => {
const stale: AbilityBuild = { base: base10, adjustments: [] }; // says 10s
const current = { ...base10, str: 16 }; // but totals were edited elsewhere
const r = appendLevelIncreases(stale, current, ['str'], '5e', 'ASI L4');
expect(r.abilities.str).toBe(17); // 16 + 1, not 11
});
});
+29 -1
View File
@@ -1,4 +1,4 @@
import type { AbilityKey, AbilityScores } from './types';
import type { AbilityKey, AbilityScores, SystemId } from './types';
import { ABILITY_KEYS } from './types';
import type { AbilityBuild } from '@/lib/schemas/character';
@@ -69,3 +69,31 @@ export function setManualTotal(build: AbilityBuild, ability: AbilityKey, target:
export function setBuildBase(build: AbilityBuild, base: AbilityScores): AbilityBuild {
return { base: { ...base }, adjustments: build.adjustments };
}
function sameScores(a: AbilityScores, b: AbilityScores): boolean {
return ABILITY_KEYS.every((k) => a[k] === b[k]);
}
/**
* Append level-up ability increases to a build so the per-source breakdown stays in
* sync with the totals (a 5e ASI pick is flat +1; a PF2e pick is a boost). If the
* stored build no longer reproduces `current` (legacy/imported character), a manual
* build is synthesized from `current` first so nothing jumps. Callers patch BOTH
* `abilities` and `abilityBuild` from the result.
*/
export function appendLevelIncreases(
build: AbilityBuild | undefined,
current: AbilityScores,
picks: readonly AbilityKey[],
system: SystemId,
label: string,
): { build: AbilityBuild; abilities: AbilityScores } {
const base = build && sameScores(computeAbilities(build), current) ? build : synthManualBuild(current);
const added = picks.map((ability) => (
system === 'pf2e'
? { label, ability, kind: 'boost' as const, amount: 0 }
: { label, ability, kind: 'flat' as const, amount: 1 }
));
const next: AbilityBuild = { base: { ...base.base }, adjustments: [...base.adjustments, ...added] };
return { build: next, abilities: computeAbilities(next) };
}
+16
View File
@@ -106,4 +106,20 @@ describe('applyRest', () => {
// no concentration → no concentration key in the patch
expect('concentration' in applyRest(makeCharacter(), short)).toBe(false);
});
it('long rest caps restored HP at the effective max while exhaustion stays 4+', () => {
const c = { ...makeCharacter(), defenses: { ...makeCharacter().defenses, exhaustion: 5 } };
const long = dnd5e.restOptions.find((o) => o.id === 'long')!;
const patch = applyRest(c, long);
// exhaustion 5 → 4 after the rest; max HP still halved (30 → 15)
expect(patch.defenses!.exhaustion).toBe(4);
expect(patch.hp!.current).toBe(15);
});
it('a per-resource recoverStep restores by that amount, not to max (Hit Dice)', () => {
const base = makeCharacter();
const c = { ...base, resources: [{ id: 'hd', name: 'Hit Dice', current: 0, max: 5, recovery: 'long' as const, recoverStep: 3 }] };
const long = dnd5e.restOptions.find((o) => o.id === 'long')!;
expect(applyRest(c, long).resources![0]!.current).toBe(3); // 0 + ceil(5/2)=3, not 5
});
});
+1 -1
View File
@@ -20,7 +20,7 @@ export * from './types';
export * from './progression';
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/abilities';
export { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, type AbilityBreakdown } from './abilityBuild';
export { computeAbilities, abilityBreakdown, synthManualBuild, setManualTotal, setBuildBase, appendLevelIncreases, type AbilityBreakdown } from './abilityBuild';
export { applyRest } from './rest';
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
export { collectChoices, collectFeatures, collectChoices5e, collectFeatures5e, subclassPrompt } from './features/collect';
+18 -3
View File
@@ -1,5 +1,6 @@
import type { AbilityKey, AbilityScores, ProficiencyRank, SystemId } from './types';
import { abilityModifier } from './abilities';
import { newId } from '@/lib/ids';
import { DND5E_CLASSES, dnd5eSlots, dnd5ePact, dnd5eHp, dnd5eAsiLevels } from './dnd5e/progression';
import { PF2E_CLASSES, pf2eHp, pf2eSlots, pf2eBoostLevels, pf2eSkillIncreaseLevels } from './pf2e/progression';
@@ -57,6 +58,12 @@ export interface BuiltCharacter {
spellcastingAbility?: SpellAbility;
spellcastingRank?: ProficiencyRank;
spellcasting: { slots: SpellSlot[]; pact?: SpellSlot; spells: never[] };
resources: { id: string; name: string; current: number; max: number; recovery: 'short' | 'long' | 'daily' | 'none'; recoverStep?: number }[];
}
/** 5e Hit Dice as a tracked resource: one die per level; a long rest returns half (2014 PHB). */
export function hitDiceResource(level: number): BuiltCharacter['resources'][number] {
return { id: newId(), name: 'Hit Dice', current: level, max: level, recovery: 'long', recoverStep: Math.ceil(level / 2) };
}
const TABLES: Record<SystemId, ClassDef[]> = { '5e': DND5E_CLASSES, pf2e: PF2E_CLASSES };
@@ -123,6 +130,8 @@ export function buildCharacter(system: SystemId, choices: BuildChoices): BuiltCh
perceptionRank: system === 'pf2e' ? def?.perception ?? 'trained' : 'trained',
...(def?.spellAbility ? { spellcastingAbility: def.spellAbility, spellcastingRank: def.spellRank ?? 'trained' } : {}),
spellcasting: { slots, ...(pact ? { pact } : {}), spells: [] },
// 5e characters track Hit Dice from day one; PF2e has no equivalent pool.
resources: system === '5e' ? [hitDiceResource(choices.level)] : [],
};
}
@@ -159,7 +168,11 @@ export function planLevelUp(system: SystemId, className: string, currentLevel: n
if (dnd5eAsiLevels(className).includes(nextLevel)) {
choices.push({ kind: 'asi', label: 'Ability Score Improvement — raise abilities by +2 total, or take a feat instead.' });
}
if (slots.length) notes.push('Spell slots updated for the new level.');
// Proficiency bonus thresholds (PHB): +3 at 5, +4 at 9, +5 at 13, +6 at 17.
if ([5, 9, 13, 17].includes(nextLevel)) {
notes.push(`Proficiency bonus increases to +${2 + Math.floor((nextLevel - 1) / 4)} — attacks, saves, and trained skills all improve.`);
}
if (slots.length) notes.push('Spell slots updated — add any newly learned/prepared spells in the Spellcasting section.');
return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), ...(pact ? { pact } : {}), notes, choices };
}
@@ -172,8 +185,10 @@ export function planLevelUp(system: SystemId, className: string, currentLevel: n
if (pf2eSkillIncreaseLevels().includes(nextLevel)) {
choices.push({ kind: 'skill-increase', label: 'Skill increase — raise one skill to the next proficiency rank.' });
}
if (nextLevel % 2 === 0) choices.push({ kind: 'feat', label: 'Class feat — pick a new class feat for this level.' });
if (slots.length) notes.push('Spell slots updated for the new level.');
// (Feat slots are enumerated per type — class/skill/general/ancestry — by collectChoices,
// which the level-up modal renders with real owed counts; no duplicate entry here.)
notes.push('Proficiency rises with your level — every trained statistic improves by 1.');
if (slots.length) notes.push('Spell slots updated — add any newly learned/prepared spells in the Spellcasting section.');
return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), notes, choices };
}
+28 -15
View File
@@ -1,6 +1,7 @@
import type { Character } from '@/lib/schemas/character';
import type { RestOption } from './types';
import { pf2eRestDecay } from '@/lib/mechanics/dying';
import { deriveEffectiveMaxHp } from '@/lib/mechanics/creatureState';
/**
* Compute the character changes a rest produces. Pure returns a partial patch
@@ -16,15 +17,33 @@ export function applyRest(c: Character, opt: RestOption): Partial<Character> {
patch.hp = { ...c.hp, current: c.hp.max };
}
// Post-rest condition state, for capping restored HP at the EFFECTIVE max: a long
// rest steps 5e exhaustion down by 1 (level 4+ halves max); a pf2e night's rest
// decays drained by 1 (each remaining point still costs level HP off the max).
const restedExhaustion = opt.reduceExhaustion && c.defenses.exhaustion > 0 ? c.defenses.exhaustion - 1 : c.defenses.exhaustion;
const pf2eDecayed = c.system === 'pf2e' && opt.restoresHp ? pf2eRestDecay(c.defenses, c.conditions) : null;
if (opt.restoresHp && patch.hp) {
const eff = deriveEffectiveMaxHp({
system: c.system,
baseMaxHp: c.hp.max,
level: c.level,
exhaustion: restedExhaustion,
conditions: pf2eDecayed ? pf2eDecayed.conditions : c.conditions,
});
patch.hp = { ...patch.hp, current: Math.min(patch.hp.current, eff.max) };
}
// Any rest ends ongoing concentration (you stop holding the spell to rest).
if (c.concentration) patch.concentration = null;
// Resources refresh when their recovery tag is covered by this rest. A rest with
// `recoverStep` (PF2e Refocus) restores by that step, clamped to max, rather than
// refilling the whole pool — so Refocus returns one Focus Point, not all of them.
// Resources refresh when their recovery tag is covered by this rest. A step —
// per-resource (5e Hit Dice recover ceil(level/2)) or per-rest (PF2e Refocus
// returns one Focus Point) — restores by that amount, clamped to max; otherwise
// the pool refills entirely.
patch.resources = c.resources.map((r) => {
if (!opt.recovers.includes(r.recovery)) return r;
const next = opt.recoverStep !== undefined ? Math.min(r.max, r.current + opt.recoverStep) : r.max;
const step = r.recoverStep ?? opt.recoverStep;
const next = step !== undefined ? Math.min(r.max, r.current + step) : r.max;
return { ...r, current: next };
});
@@ -47,23 +66,17 @@ export function applyRest(c: Character, opt: RestOption): Partial<Character> {
// pf2e: a full night's rest decays doomed (1) and drained (1), clears wounded
// (you wake at full HP) and ends fatigued. Dying is assumed already 0.
if (c.system === 'pf2e') {
if (opt.restoresHp) {
const decay = pf2eRestDecay(c.defenses, c.conditions);
patch.defenses = { ...c.defenses, ...decay.defenses };
patch.conditions = decay.conditions;
if (pf2eDecayed) {
patch.defenses = { ...c.defenses, ...pf2eDecayed.defenses };
patch.conditions = pf2eDecayed.conditions;
}
return patch;
}
const exhaustion =
opt.reduceExhaustion && c.defenses.exhaustion > 0
? c.defenses.exhaustion - 1
: c.defenses.exhaustion;
if (opt.restoresHp || exhaustion !== c.defenses.exhaustion) {
if (opt.restoresHp || restedExhaustion !== c.defenses.exhaustion) {
patch.defenses = {
...c.defenses,
exhaustion,
exhaustion: restedExhaustion,
// recovering from 0 HP clears death saves (5e)
...(opt.restoresHp ? { deathSaves: { successes: 0, failures: 0 } } : {}),
};
+21
View File
@@ -168,3 +168,24 @@ describe('pf2e Refocus', () => {
expect(patch.resources?.[0]?.current).toBe(1); // +1, clamped to max — not 3
});
});
describe('pf2e Rest for the Night', () => {
it('decays drained/doomed, clears wounded/fatigued, and caps HP at the post-decay effective max', () => {
const rest = pf2e.restOptions.find((o) => o.id === 'rest')!;
const c = {
system: 'pf2e', level: 6,
resources: [],
hp: { current: 5, max: 50, temp: 0 },
concentration: null,
spellcasting: { slots: [], spells: [] },
conditions: [{ name: 'Drained', value: 2 }, { name: 'Fatigued' }],
defenses: { exhaustion: 0, deathSaves: { successes: 0, failures: 0 }, dying: 0, wounded: 2, doomed: 1, heroPoints: 1, inspiration: false },
} as unknown as Parameters<typeof applyRest>[0];
const patch = applyRest(c, rest);
expect(patch.conditions).toEqual([{ name: 'Drained', value: 1 }]); // drained 1, fatigued gone
expect(patch.defenses!.wounded).toBe(0);
expect(patch.defenses!.doomed).toBe(0);
// drained 1 remains after decay → effective max 50 6 = 44, not 50
expect(patch.hp!.current).toBe(44);
});
});
+7 -1
View File
@@ -34,6 +34,9 @@ export const resourceSchema = z.object({
current: int.min(0).default(0),
max: int.min(0).default(0),
recovery: recoverySchema.default('long'),
/** How much a matching rest restores. Omitted = refill to max. 5e Hit Dice use
* ceil(level/2) a long rest returns only half your level in dice (2014 PHB). */
recoverStep: int.positive().optional(),
});
export type CharacterResource = z.infer<typeof resourceSchema>;
@@ -173,6 +176,8 @@ export const characterSchema = z.object({
portrait: z.string().optional(),
/** ancestry (PF2e) / race (5e), free text for MVP */
ancestry: z.string().max(80).default(''),
/** PF2e heritage (chosen at level 1, refines the ancestry); '' for 5e. */
heritage: z.string().max(80).default(''),
/** Primary class name — a mirror of classes[0].className (kept for display/back-compat). */
className: z.string().max(80).default(''),
/** Total character level — a mirror of sum(classes[].level). */
@@ -277,12 +282,13 @@ export type CharacterDraft = z.infer<typeof characterDraftSchema>;
/** Default values for the Phase-1 depth fields. Used by create + DB migration. */
export function characterDefaults(): Pick<
Character,
'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' | 'background' | 'alignment' | 'appearance' | 'personality' | 'classes' | 'choices'
'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' | 'background' | 'heritage' | 'alignment' | 'appearance' | 'personality' | 'classes' | 'choices'
> {
return {
classes: [],
choices: [],
background: '',
heritage: '',
alignment: '',
appearance: '',
personality: '',
+2 -2
View File
@@ -130,8 +130,8 @@ export const clientMessageSchema = z.discriminatedUnion('t', [
z.object({ t: z.literal('host'), campaignId: z.string(), password: z.string().max(128).optional(), resume: z.string().optional() }),
z.object({ t: z.literal('join'), joinCode: z.string().max(16), password: z.string().max(128).optional(), playerId: z.string().max(64).optional() }),
z.object({ t: z.literal('state'), gmSecret: z.string(), snapshot: snapshotSchema }),
z.object({ t: z.literal('image'), gmSecret: z.string(), id: z.string(), dataUrl: z.string() }),
z.object({ t: z.literal('requestImage'), id: z.string() }),
z.object({ t: z.literal('image'), gmSecret: z.string(), id: z.string().max(128), dataUrl: z.string().max(10_000_000) }),
z.object({ t: z.literal('requestImage'), id: z.string().max(128) }),
// two-way play (player → server)
z.object({ t: z.literal('claimSeat'), characterId: z.string(), offlineSnapshot: characterSchema.optional() }),
z.object({ t: z.literal('playerPatch'), characterId: z.string(), diff: partialCharacterDiffSchema }),
+3
View File
@@ -19,6 +19,7 @@ import { HomebrewPage } from '@/features/world/HomebrewPage';
import { SettingsPage } from '@/features/settings/SettingsPage';
import { AssistantPage } from '@/features/assistant/AssistantPage';
import { DirectorPage } from '@/features/assistant/director/DirectorPage';
import { AdminPage } from '@/features/admin/AdminPage';
const rootRoute = createRootRoute({ component: RootLayout });
@@ -44,6 +45,7 @@ const homebrewRoute = createRoute({ getParentRoute: () => rootRoute, path: '/hom
const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage });
const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage });
const directorRoute = createRoute({ getParentRoute: () => rootRoute, path: '/director', component: DirectorPage });
const adminRoute = createRoute({ getParentRoute: () => rootRoute, path: '/admin', component: AdminPage });
const routeTree = rootRoute.addChildren([
indexRoute,
@@ -64,6 +66,7 @@ const routeTree = rootRoute.addChildren([
settingsRoute,
assistantRoute,
directorRoute,
adminRoute,
]);
export const router = createRouter({ routeTree, defaultPreload: 'intent', defaultNotFoundComponent: NotFound });
File diff suppressed because one or more lines are too long