Merge: perfection pass + backend security hardening + admin panel
This commit is contained in:
@@ -17,10 +17,20 @@ services:
|
|||||||
- ALLOWED_ORIGINS=https://ttrpg.briggen.dev
|
- ALLOWED_ORIGINS=https://ttrpg.briggen.dev
|
||||||
# comma-separated usernames that can see the admin panel (set to your account)
|
# comma-separated usernames that can see the admin panel (set to your account)
|
||||||
- ADMIN_USERS=nilsb
|
- 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:
|
volumes:
|
||||||
- ttrpg-data:/data
|
- ttrpg-data:/data
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- 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:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.ttrpg.rule=Host(`ttrpg.briggen.dev`)"
|
- "traefik.http.routers.ttrpg.rule=Host(`ttrpg.briggen.dev`)"
|
||||||
|
|||||||
@@ -54,4 +54,56 @@ describe('AccountStore', () => {
|
|||||||
expect(await adminStore.setQuota('ghost', 1)).toBe(false);
|
expect(await adminStore.setQuota('ghost', 1)).toBe(false);
|
||||||
expect((await adminStore.listUsers()).find((u) => u.username === 'peon')?.quotaBytes).toBe(5_000_000_000);
|
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
@@ -1,7 +1,12 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
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
|
* 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-
|
* 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 USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
|
||||||
const MAX_TOKENS = 10;
|
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 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 {
|
function timingEqual(a: string, b: string): boolean {
|
||||||
const ab = Buffer.from(a), bb = Buffer.from(b);
|
const ab = Buffer.from(a), bb = Buffer.from(b);
|
||||||
return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
|
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 {
|
export class AccountStore {
|
||||||
private users = new Map<string, User>(); // key: lowercased username
|
private users = new Map<string, User>(); // key: lowercased username
|
||||||
private byId = new Map<string, User>();
|
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 queue: Promise<unknown> = Promise.resolve();
|
||||||
private loaded = false;
|
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 usersFile() { return path.join(this.dir, 'users.json'); }
|
||||||
private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); }
|
private blobFile(id: string) { return path.join(this.dir, 'blobs', `${id}.json`); }
|
||||||
|
|
||||||
isAdmin(username: string): boolean { return this.admins.includes(username.toLowerCase()); }
|
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 }>> {
|
async listUsers(): Promise<Array<{ id: string; username: string; quotaBytes?: number }>> {
|
||||||
await this.load();
|
await this.load();
|
||||||
return [...this.users.values()].map((u) => ({ id: u.id, username: u.username, ...(u.quotaBytes ? { quotaBytes: u.quotaBytes } : {}) }));
|
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> {
|
async setQuota(username: string, bytes: number): Promise<boolean> {
|
||||||
await this.load();
|
await this.load();
|
||||||
const u = this.users.get(username.toLowerCase());
|
const u = this.users.get(username.toLowerCase());
|
||||||
@@ -72,8 +124,15 @@ export class AccountStore {
|
|||||||
try {
|
try {
|
||||||
const raw = await fs.readFile(this.usersFile(), 'utf8');
|
const raw = await fs.readFile(this.usersFile(), 'utf8');
|
||||||
const arr = JSON.parse(raw) as User[];
|
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); }
|
for (const u of arr) {
|
||||||
} catch { /* no file yet */ }
|
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> {
|
private persist(): Promise<void> {
|
||||||
@@ -82,14 +141,19 @@ export class AccountStore {
|
|||||||
const tmp = `${this.usersFile()}.tmp`;
|
const tmp = `${this.usersFile()}.tmp`;
|
||||||
await fs.writeFile(tmp, JSON.stringify([...this.users.values()]));
|
await fs.writeFile(tmp, JSON.stringify([...this.users.values()]));
|
||||||
await fs.rename(tmp, this.usersFile());
|
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>;
|
return this.queue as Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
private issueToken(u: User): string {
|
private issueToken(u: User): string {
|
||||||
const token = crypto.randomBytes(32).toString('base64url');
|
const token = crypto.randomBytes(32).toString('base64url');
|
||||||
u.tokens.push(sha256(token));
|
const h = sha256(token);
|
||||||
if (u.tokens.length > MAX_TOKENS) u.tokens = u.tokens.slice(-MAX_TOKENS);
|
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();
|
u.updatedAt = this.now();
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
@@ -99,8 +163,9 @@ export class AccountStore {
|
|||||||
if (!USERNAME_RE.test(username)) return { ok: false, code: 'bad-username', message: 'Username must be 3–32 letters, digits, . _ or -.' };
|
if (!USERNAME_RE.test(username)) return { ok: false, code: 'bad-username', message: 'Username must be 3–32 letters, digits, . _ or -.' };
|
||||||
if (typeof password !== 'string' || password.length < 8) return { ok: false, code: 'weak-password', message: 'Password must be at least 8 characters.' };
|
if (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.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 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);
|
const token = this.issueToken(u);
|
||||||
this.users.set(username.toLowerCase(), u);
|
this.users.set(username.toLowerCase(), u);
|
||||||
this.byId.set(u.id, u);
|
this.byId.set(u.id, u);
|
||||||
@@ -111,7 +176,11 @@ export class AccountStore {
|
|||||||
async login(username: string, password: string): Promise<AuthResult | AuthError> {
|
async login(username: string, password: string): Promise<AuthResult | AuthError> {
|
||||||
await this.load();
|
await this.load();
|
||||||
const u = this.users.get((username ?? '').toLowerCase());
|
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);
|
const token = this.issueToken(u);
|
||||||
await this.persist();
|
await this.persist();
|
||||||
return { ok: true, token, username: u.username };
|
return { ok: true, token, username: u.username };
|
||||||
@@ -120,15 +189,16 @@ export class AccountStore {
|
|||||||
async userByToken(token: string | undefined): Promise<User | null> {
|
async userByToken(token: string | undefined): Promise<User | null> {
|
||||||
if (!token) return null;
|
if (!token) return null;
|
||||||
await this.load();
|
await this.load();
|
||||||
const h = sha256(token);
|
return this.byToken.get(sha256(token)) ?? null;
|
||||||
for (const u of this.users.values()) if (u.tokens.includes(h)) return u;
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async logout(token: string): Promise<void> {
|
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;
|
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();
|
await this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest';
|
|||||||
import { promises as fs } from 'node:fs';
|
import { promises as fs } from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import { CloudStore } from './campaigns';
|
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
|
||||||
|
|
||||||
describe('CloudStore', () => {
|
describe('CloudStore', () => {
|
||||||
let dir: string;
|
let dir: string;
|
||||||
@@ -42,6 +42,43 @@ describe('CloudStore', () => {
|
|||||||
expect(await store.usageBytes('p1')).toBeGreaterThan(0);
|
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 () => {
|
it('lets the char owner or campaign owner delete; persists across instances', async () => {
|
||||||
const c = await store.createCampaign('gm1', 'C', '5e');
|
const c = await store.createCampaign('gm1', 'C', '5e');
|
||||||
await store.joinByInvite('p1', c.inviteCode);
|
await store.joinByInvite('p1', c.inviteCode);
|
||||||
|
|||||||
+72
-2
@@ -32,6 +32,9 @@ export interface CloudCharacter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const INVITE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
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 {
|
export class CloudStore {
|
||||||
private campaigns = new Map<string, CloudCampaign>();
|
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[] };
|
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 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);
|
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> {
|
private persist(): Promise<void> {
|
||||||
@@ -59,7 +64,7 @@ export class CloudStore {
|
|||||||
const tmp = `${this.file()}.tmp`;
|
const tmp = `${this.file()}.tmp`;
|
||||||
await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] }));
|
await fs.writeFile(tmp, JSON.stringify({ campaigns: [...this.campaigns.values()], characters: [...this.characters.values()] }));
|
||||||
await fs.rename(tmp, this.file());
|
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>;
|
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> {
|
async putCharacter(userId: string, campaignId: string, char: { id: string; name: string; data: string }): Promise<CloudCharacter | null> {
|
||||||
await this.load();
|
await this.load();
|
||||||
if (!this.isMember(campaignId, userId)) return null;
|
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);
|
const existing = this.characters.get(char.id);
|
||||||
if (existing && existing.ownerUserId !== userId) return null; // only the owner may update
|
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() };
|
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;
|
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. */
|
/** Total bytes a user is responsible for (their characters' data) — for usage tracking. */
|
||||||
async usageBytes(userId: string): Promise<number> {
|
async usageBytes(userId: string): Promise<number> {
|
||||||
await this.load();
|
await this.load();
|
||||||
|
|||||||
+143
-23
@@ -4,17 +4,22 @@ import fastifyWebsocket from '@fastify/websocket';
|
|||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import type { FastifyRequest } from 'fastify';
|
import type { FastifyRequest } from 'fastify';
|
||||||
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
|
import { clientMessageSchema, type ServerMessage } from '@/lib/sync/messages';
|
||||||
|
import { promises as fs } from 'node:fs';
|
||||||
import { RoomHub, type Sender } from './rooms';
|
import { RoomHub, type Sender } from './rooms';
|
||||||
import { AccountStore } from './accounts';
|
import { AccountStore, DEFAULT_QUOTA_BYTES } from './accounts';
|
||||||
import { CloudStore } from './campaigns';
|
import { CloudStore, MAX_CHARACTER_BYTES } from './campaigns';
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 8787);
|
const PORT = Number(process.env.PORT ?? 8787);
|
||||||
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
|
const STATIC_DIR = path.resolve(process.env.STATIC_DIR ?? path.join(process.cwd(), 'dist'));
|
||||||
const DATA_DIR = path.resolve(process.env.DATA_DIR ?? path.join(process.cwd(), 'data'));
|
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 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 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 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 bearer = (req: FastifyRequest): string | undefined => {
|
||||||
const h = req.headers.authorization;
|
const h = req.headers.authorization;
|
||||||
@@ -29,30 +34,49 @@ const RATE = { capacity: 80, refillMs: 10_000 };
|
|||||||
const HEARTBEAT_MS = 30_000;
|
const HEARTBEAT_MS = 30_000;
|
||||||
|
|
||||||
export function buildServer() {
|
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 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();
|
void accounts.load();
|
||||||
const cloud = new CloudStore(DATA_DIR);
|
const cloud = new CloudStore(DATA_DIR);
|
||||||
void cloud.load();
|
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));
|
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 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) => {
|
app.addHook('onRequest', async (req, reply) => {
|
||||||
if (!req.url.startsWith('/api/')) return;
|
if (!req.url.startsWith('/api/')) return;
|
||||||
const now = Date.now();
|
if (!bump(ipHits, req.ip, 60, 60_000)) return reply.code(429).send({ error: 'rate' });
|
||||||
const e = ipHits.get(req.ip);
|
// Strip the querystring before matching the auth path.
|
||||||
if (!e || now > e.reset) ipHits.set(req.ip, { n: 1, reset: now + 60_000 });
|
const path0 = req.url.split('?')[0]!;
|
||||||
else if (++e.n > 60) await reply.code(429).send({ error: 'rate' });
|
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 ----
|
// ---- accounts + cloud backup sync ----
|
||||||
app.post('/api/register', async (req, reply) => {
|
app.post('/api/register', async (req, reply) => {
|
||||||
const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
|
const { username, password } = (req.body ?? {}) as { username?: string; password?: string };
|
||||||
const r = await accounts.register(String(username ?? ''), String(password ?? ''));
|
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 };
|
return { token: r.token, username: r.username };
|
||||||
});
|
});
|
||||||
app.post('/api/login', async (req, reply) => {
|
app.post('/api/login', async (req, reply) => {
|
||||||
@@ -67,6 +91,11 @@ export function buildServer() {
|
|||||||
if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
||||||
const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {};
|
const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {};
|
||||||
if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
|
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
|
// Optimistic concurrency: if the caller names the version it last synced and
|
||||||
// the cloud has since moved on (another device pushed), refuse — no silent
|
// the cloud has since moved on (another device pushed), refuse — no silent
|
||||||
// overwrite. The client then resolves (pull, or force-push).
|
// 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 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 } };
|
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 (!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 });
|
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 };
|
return { ok: true, updatedAt: r.updatedAt };
|
||||||
});
|
});
|
||||||
app.get('/api/campaigns/:id/characters', async (req, reply) => {
|
app.get('/api/campaigns/:id/characters', async (req, reply) => {
|
||||||
@@ -141,31 +177,110 @@ export function buildServer() {
|
|||||||
app.get('/api/usage', async (req, reply) => {
|
app.get('/api/usage', async (req, reply) => {
|
||||||
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
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));
|
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) => {
|
app.get('/api/admin/users', async (req, reply) => {
|
||||||
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
|
||||||
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
|
const users = await accounts.listUsersDetailed();
|
||||||
const users = await accounts.listUsers();
|
|
||||||
const rows = await Promise.all(users.map(async (x) => ({
|
const rows = await Promise.all(users.map(async (x) => ({
|
||||||
username: x.username,
|
username: x.username,
|
||||||
|
admin: x.admin,
|
||||||
|
createdAt: x.createdAt,
|
||||||
usageBytes: (await accounts.blobBytes(x.id)) + (await cloud.usageBytes(x.id)),
|
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) };
|
return { users: rows.sort((a, b) => b.usageBytes - a.usageBytes) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/admin/quota', async (req, reply) => {
|
app.post('/api/admin/quota', async (req, reply) => {
|
||||||
const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' });
|
if (!await adminOf(req)) return reply.code(403).send({ error: 'forbidden' });
|
||||||
if (!accounts.isAdmin(u.username)) return reply.code(403).send({ error: 'forbidden' });
|
|
||||||
const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number };
|
const { username, quotaBytes } = (req.body ?? {}) as { username?: string; quotaBytes?: number };
|
||||||
const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0);
|
const ok = await accounts.setQuota(String(username ?? ''), Number(quotaBytes) || 0);
|
||||||
return ok ? { ok: true } : reply.code(404).send({ error: 'no-user' });
|
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 } });
|
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) => {
|
void app.register(async (instance) => {
|
||||||
instance.get('/ws', { websocket: true }, (socket, req) => {
|
instance.get('/ws', { websocket: true }, (socket, req) => {
|
||||||
// Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured.
|
// Anti-CSWSH: reject cross-origin upgrades when an allowlist is configured.
|
||||||
@@ -174,6 +289,11 @@ export function buildServer() {
|
|||||||
socket.close(1008, 'origin');
|
socket.close(1008, 'origin');
|
||||||
return;
|
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 */ } } };
|
const sender: Sender = { send: (msg: ServerMessage) => { try { socket.send(JSON.stringify(msg)); } catch { /* closed */ } } };
|
||||||
let tokens = RATE.capacity;
|
let tokens = RATE.capacity;
|
||||||
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
|
const refill = setInterval(() => { tokens = RATE.capacity; }, RATE.refillMs);
|
||||||
@@ -209,11 +329,11 @@ export function buildServer() {
|
|||||||
case 'setName': hub.setName(sender, m.name); break;
|
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).
|
// Serve the built SPA with history fallback (so /play?room=... deep-links work).
|
||||||
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
|
void app.register(fastifyStatic, { root: STATIC_DIR, wildcard: false });
|
||||||
|
|||||||
@@ -92,6 +92,31 @@ describe('RoomHub', () => {
|
|||||||
expect(hub.roomCount()).toBe(0);
|
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', () => {
|
it('routes a seat claim → GM grant → player gets their sheet', () => {
|
||||||
const hub = new RoomHub();
|
const hub = new RoomHub();
|
||||||
const { gm, player, secret } = hostAndJoin(hub);
|
const { gm, player, secret } = hostAndJoin(hub);
|
||||||
|
|||||||
+44
-2
@@ -34,6 +34,8 @@ interface Room {
|
|||||||
players: Set<Sender>;
|
players: Set<Sender>;
|
||||||
snapshot: Snapshot | null;
|
snapshot: Snapshot | null;
|
||||||
images: Map<string, string>;
|
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 */
|
/** granted seats keyed by playerId */
|
||||||
seats: Map<string, Seat>;
|
seats: Map<string, Seat>;
|
||||||
/** seat requests awaiting GM approval (re-sent when the GM reconnects) */
|
/** 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 JOIN_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no ambiguous chars
|
||||||
const ROOM_TTL_MS = 6 * 60 * 60 * 1000; // 6h idle
|
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 {
|
function sha256(s: string): string {
|
||||||
return crypto.createHash('sha256').update(s).digest('hex');
|
return crypto.createHash('sha256').update(s).digest('hex');
|
||||||
@@ -62,7 +71,10 @@ export class RoomHub {
|
|||||||
private rooms = new Map<string, Room>();
|
private rooms = new Map<string, Room>();
|
||||||
private byCode = new Map<string, string>();
|
private byCode = new Map<string, string>();
|
||||||
private conns = new Map<Sender, { roomId: string; role: 'gm' | 'player'; playerId: string; name?: 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 {
|
private mintJoinCode(): string {
|
||||||
for (let attempt = 0; attempt < 50; attempt++) {
|
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 roomId = crypto.randomUUID();
|
||||||
const joinCode = this.mintJoinCode();
|
const joinCode = this.mintJoinCode();
|
||||||
const gmSecret = crypto.randomBytes(32).toString('base64url');
|
const gmSecret = crypto.randomBytes(32).toString('base64url');
|
||||||
const room: Room = {
|
const room: Room = {
|
||||||
roomId, joinCode, gmSecretHash: sha256(gmSecret),
|
roomId, joinCode, gmSecretHash: sha256(gmSecret),
|
||||||
passwordHash: password ? sha256(password) : null,
|
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(),
|
seats: new Map(), pendingSeatRequests: [], lastActivity: this.now(),
|
||||||
};
|
};
|
||||||
this.rooms.set(roomId, room);
|
this.rooms.set(roomId, room);
|
||||||
@@ -148,7 +166,17 @@ export class RoomHub {
|
|||||||
image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void {
|
image(socket: Sender, gmSecret: string, id: string, dataUrl: string): void {
|
||||||
const room = this.gmRoom(socket, gmSecret);
|
const room = this.gmRoom(socket, gmSecret);
|
||||||
if (!room) { socket.send({ t: 'error', code: 'forbidden', message: 'Not the GM of this room.' }); return; }
|
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.images.set(id, dataUrl);
|
||||||
|
room.imageBytes += size - prevSize;
|
||||||
room.lastActivity = this.now();
|
room.lastActivity = this.now();
|
||||||
for (const p of room.players) p.send({ t: 'mapImage', id, dataUrl });
|
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; }
|
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 {
|
private gmRoom(socket: Sender, gmSecret: string): Room | null {
|
||||||
const conn = this.conns.get(socket);
|
const conn = this.conns.get(socket);
|
||||||
if (!conn || conn.role !== 'gm') return null;
|
if (!conn || conn.role !== 'gm') return null;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link, Outlet, useRouterState } from '@tanstack/react-router';
|
|||||||
import {
|
import {
|
||||||
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
|
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
|
||||||
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
|
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,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
@@ -24,6 +24,7 @@ import { SignalsBell } from '@/features/assistant/SignalsBell';
|
|||||||
import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator';
|
import { SyncStatusIndicator } from '@/features/cloud/SyncStatusIndicator';
|
||||||
import { SessionSidebar } from '@/features/play/SessionSidebar';
|
import { SessionSidebar } from '@/features/play/SessionSidebar';
|
||||||
import { useSessionStore } from '@/stores/sessionStore';
|
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' };
|
type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'world' | 'reference' };
|
||||||
const NAV: NavItem[] = [
|
const NAV: NavItem[] = [
|
||||||
@@ -100,6 +101,11 @@ export function RootLayout() {
|
|||||||
useSessionBroadcaster(activeCampaign ?? null);
|
useSessionBroadcaster(activeCampaign ?? null);
|
||||||
usePlayerConnection();
|
usePlayerConnection();
|
||||||
useCloudAutosave();
|
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.
|
// Global Ctrl/Cmd+K opens the command palette.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -148,7 +154,7 @@ export function RootLayout() {
|
|||||||
{GROUPS.map((group) => (
|
{GROUPS.map((group) => (
|
||||||
<div key={group.id}>
|
<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>}
|
{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 active = item.exact ? pathname === item.to : pathname.startsWith(item.to);
|
||||||
const Ico = item.icon;
|
const Ico = item.icon;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 isn’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -187,6 +187,12 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
|
<Labeled label={c.system === 'pf2e' ? 'Ancestry' : 'Race'}>
|
||||||
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
|
<Input value={c.ancestry} onChange={(e) => update({ ancestry: e.target.value })} />
|
||||||
</Labeled>
|
</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' ? (
|
{c.system === '5e' ? (
|
||||||
<div className="sm:col-span-2"><ClassesEditor c={c} update={update} /></div>
|
<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">
|
<section className="mb-6">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<SectionTitle>Ability Scores</SectionTitle>
|
<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>
|
||||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
|
<div className="grid grid-cols-3 gap-3 sm:grid-cols-6">
|
||||||
{ABILITIES.map((a) => {
|
{ABILITIES.map((a) => {
|
||||||
@@ -413,18 +420,27 @@ export function CharacterSheet({ character }: { character: Character }) {
|
|||||||
|
|
||||||
function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
|
function HpCard({ c, update }: { c: Character; update: (p: Partial<Character>) => void }) {
|
||||||
const [delta, setDelta] = useState(0);
|
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') => {
|
const apply = (mode: 'damage' | 'heal') => {
|
||||||
if (!Number.isFinite(delta) || delta <= 0) return;
|
if (!Number.isFinite(delta) || delta <= 0) return;
|
||||||
if (mode === 'damage') {
|
if (mode === 'damage') {
|
||||||
const absorbed = Math.min(c.hp.temp, delta);
|
const absorbed = Math.min(c.hp.temp, delta);
|
||||||
update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } });
|
update({ hp: { ...c.hp, temp: c.hp.temp - absorbed, current: c.hp.current - (delta - absorbed) } });
|
||||||
} else {
|
} 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);
|
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;
|
const ratio = effMax > 0 ? c.hp.current / effMax : 0;
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-line bg-panel p-4 text-center">
|
<div className="rounded-xl border border-line bg-panel p-4 text-center">
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { charactersRepo } from '@/lib/db/repositories';
|
|||||||
import {
|
import {
|
||||||
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
|
getSystem, ABILITY_ABBR, abilityModifier, buildCharacter, getClassDef, SYSTEM_OPTIONS,
|
||||||
pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw,
|
pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw,
|
||||||
|
bumpRank, collectChoices,
|
||||||
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
|
type AbilityKey, type AbilityScores, type ProficiencyRank, type SystemId,
|
||||||
} from '@/lib/rules';
|
} 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 { STANDARD_ARRAY, rollAbilityScores, pointBuyRemaining, POINT_BUY_MIN, POINT_BUY_MAX } from '@/lib/rules/abilityGen';
|
||||||
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
|
import { loadClasses, loadRaces5e, loadBackgrounds5e, loadSpells, loadPf2e } from '@/lib/compendium';
|
||||||
import type { RulesetClass } from '@/lib/ruleset/normalize';
|
import type { RulesetClass } from '@/lib/ruleset/normalize';
|
||||||
@@ -179,6 +181,27 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
|||||||
const [classSlug, setClassSlug] = useState('');
|
const [classSlug, setClassSlug] = useState('');
|
||||||
const [subclass, setSubclass] = useState('');
|
const [subclass, setSubclass] = useState('');
|
||||||
const [ancestry, setAncestry] = 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 [background, setBackground] = useState('');
|
||||||
|
|
||||||
const selectedClass = classes.find((c) => c.slug === classSlug) ?? null;
|
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).
|
// Drop any free pick that becomes granted (e.g. after switching background).
|
||||||
useEffect(() => { setSkills((prev) => prev.filter((k) => !grantedSkills.includes(k))); }, [grantedSkills]);
|
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 ----
|
// ---- spells ----
|
||||||
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
|
const [spellPicks, setSpellPicks] = useState<SpellOpt[]>([]);
|
||||||
const [spellQuery, setSpellQuery] = useState('');
|
const [spellQuery, setSpellQuery] = useState('');
|
||||||
@@ -390,7 +450,9 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
|||||||
Class: !!selectedClass,
|
Class: !!selectedClass,
|
||||||
Origin: true,
|
Origin: true,
|
||||||
Abilities: system === 'pf2e' ? true : poolValid && pbValid && asiRemaining >= 0,
|
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,
|
Spells: true,
|
||||||
Details: true,
|
Details: true,
|
||||||
Review: 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;
|
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)) }));
|
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, {
|
await charactersRepo.update(created.id, {
|
||||||
...built,
|
...built,
|
||||||
|
skillRanks,
|
||||||
abilityBuild: buildAbilityBuild(),
|
abilityBuild: buildAbilityBuild(),
|
||||||
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
|
...(Object.keys(saveRanks).length ? { saveRanks } : {}),
|
||||||
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
|
classes: [{ className: selectedClass.name, level, ...(subclass ? { subclass } : {}) }],
|
||||||
spellcasting: { ...built.spellcasting, spells },
|
spellcasting: { ...built.spellcasting, spells },
|
||||||
...(background ? { background } : {}),
|
...(background ? { background } : {}),
|
||||||
|
...(heritage ? { heritage } : {}),
|
||||||
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
|
...(alignment.trim() ? { alignment: alignment.trim() } : {}),
|
||||||
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
|
...(appearance.trim() ? { appearance: appearance.trim() } : {}),
|
||||||
...(personality.trim() ? { personality: personality.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={system === 'pf2e' ? 'Ancestry' : 'Race'} options={origins} value={ancestry} onPick={setAncestry} />
|
||||||
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
|
<OriginPicker title="Background" options={backgrounds} value={background} onPick={setBackground} />
|
||||||
</div>
|
</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 won’t be applied.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{selectedClass && selectedOrigin && (() => {
|
{selectedClass && selectedOrigin && (() => {
|
||||||
const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities);
|
const synergy = raceSynergyNote(selectedOrigin.meta ?? '', selectedClass.keyAbilities);
|
||||||
if (!synergy) return null;
|
if (!synergy) return null;
|
||||||
@@ -658,7 +744,17 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
|||||||
const value = base + racial + asi;
|
const value = base + racial + asi;
|
||||||
const isKey = selectedClass?.keyAbilities.includes(a);
|
const isKey = selectedClass?.keyAbilities.includes(a);
|
||||||
const fMin = method === 'pointbuy' ? POINT_BUY_MIN : 1;
|
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 (
|
return (
|
||||||
<div key={a} className={cn('rounded-lg border bg-surface p-3 text-center', isKey ? 'border-accent/60' : 'border-line')}>
|
<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>
|
<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' && (
|
{stepName === 'Skills' && (
|
||||||
<div>
|
<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>
|
<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 && (
|
{grantedSkillSources.length > 0 && (
|
||||||
<div className="mb-3 rounded-md border border-line bg-panel px-3 py-2">
|
<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>
|
||||||
</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 sheet’s level-up flow.'
|
|
||||||
: 'Some classes gain extra skill/expertise picks as they level — apply those from the character sheet’s level-up flow.'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{selectedClass && (() => {
|
{selectedClass && (() => {
|
||||||
const tip = getClassTip(system, selectedClass.slug);
|
const tip = getClassTip(system, selectedClass.slug);
|
||||||
return tip?.skillSuggestions ? (
|
return tip?.skillSuggestions ? (
|
||||||
@@ -736,13 +827,70 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{stepName === 'Spells' && (
|
{stepName === 'Spells' && (
|
||||||
<div>
|
<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-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 you’ve 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')}>
|
<p className={cn('mb-2 text-xs', spellPicks.length > suggestedSpells + 4 ? 'text-warning' : 'text-muted')}>
|
||||||
{spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — that’s quite a few; you can trim some later if you like.' : ''}
|
{spellPicks.length} selected{spellPicks.length > suggestedSpells + 4 ? ' — that’s quite a few; you can trim some later if you like.' : ''}
|
||||||
</p>
|
</p>
|
||||||
@@ -800,7 +948,11 @@ export function CreationWizard({ campaign, onClose }: { campaign?: Campaign | un
|
|||||||
</div>
|
</div>
|
||||||
<Review label="Abilities" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} />
|
<Review label="Abilities" value={ABILITIES.map((a) => `${ABILITY_ABBR[a]} ${abilities[a]}`).join(' ')} />
|
||||||
{background && <Review label="Background" value={background} />}
|
{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(' ')} />}
|
{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(', ')} />}
|
{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>
|
<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 { Minus, Plus, Skull, Star } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { NumberField } from '@/components/ui/NumberField';
|
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 { Degree } from '@/lib/dice/check';
|
||||||
import type { Condition } from '@/lib/schemas/common';
|
import type { Condition } from '@/lib/schemas/common';
|
||||||
import { SheetSection, type SectionProps } from './common';
|
import { SheetSection, type SectionProps } from './common';
|
||||||
@@ -39,17 +39,27 @@ function Pf2eDefenses({ c, update }: SectionProps) {
|
|||||||
conds.filter((x) => x.name.trim().toLowerCase() !== 'unconscious');
|
conds.filter((x) => x.name.trim().toLowerCase() !== 'unconscious');
|
||||||
|
|
||||||
const setDefenses = (p: Partial<typeof d>) => update({ defenses: { ...d, ...p } });
|
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) =>
|
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 doKnockOut = () => {
|
||||||
const { dying } = knockOut(d, fromCrit);
|
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 doRecover = (degree: Degree) => {
|
||||||
const res = applyRecovery(d, 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 (
|
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="secondary" onClick={() => doRecover('failure')}>Failure +1</Button>
|
||||||
<Button size="sm" variant="danger" onClick={() => doRecover('critical-failure')}>Crit fail +2</Button>
|
<Button size="sm" variant="danger" onClick={() => doRecover('critical-failure')}>Crit fail +2</Button>
|
||||||
</div>
|
</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 & stabilize</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { normalizeClassMirror, totalLevel } from '@/lib/schemas';
|
||||||
import {
|
import {
|
||||||
abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
|
abilityModifier, getSystem, getClassNames, ABILITY_ABBR, type AbilityKey, type ProficiencyRank,
|
||||||
planLevelUp, applyIncreases, bumpRank, getClassDef,
|
planLevelUp, appendLevelIncreases, bumpRank, getClassDef, hitDiceResource,
|
||||||
collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature,
|
collectChoices, collectFeatures, subclassPrompt, type UnlockedFeature,
|
||||||
} from '@/lib/rules';
|
} from '@/lib/rules';
|
||||||
import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression';
|
import { dnd5eClassesSlots } from '@/lib/rules/dnd5e/progression';
|
||||||
|
import { loadPf2e } from '@/lib/compendium';
|
||||||
import { rollDice } from '@/lib/dice/notation';
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
import { createRng } from '@/lib/rng';
|
import { createRng } from '@/lib/rng';
|
||||||
import { Modal } from '@/components/ui/Modal';
|
import { Modal } from '@/components/ui/Modal';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
|
import { FeatPickerModal } from './FeatPickerModal';
|
||||||
import { LevelUpAdvisor } from './LevelUpAdvisor';
|
import { LevelUpAdvisor } from './LevelUpAdvisor';
|
||||||
|
|
||||||
const ABILITIES: AbilityKey[] = ['str', 'dex', 'con', 'int', 'wis', 'cha'];
|
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 keyDefaults = def?.keyAbilities ?? ['str', 'dex'];
|
||||||
const [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi');
|
const [asiMode, setAsiMode] = useState<'asi' | 'feat'>('asi');
|
||||||
const [asiPicks, setAsiPicks] = useState<AbilityKey[]>([keyDefaults[0] ?? 'str', keyDefaults[1] ?? keyDefaults[0] ?? 'con']);
|
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.
|
// Boosts (pf2e): four distinct picks.
|
||||||
const [boostPicks, setBoostPicks] = useState<AbilityKey[]>(['str', 'dex', 'con', 'wis']);
|
const [boostPicks, setBoostPicks] = useState<AbilityKey[]>(['str', 'dex', 'con', 'wis']);
|
||||||
// Skill increase (pf2e): one skill to bump.
|
// 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 [choicePicks, setChoicePicks] = useState<Record<string, string[]>>({});
|
||||||
const [subclassPick, setSubclassPick] = useState<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) =>
|
const setChoicePick = (key: string, i: number, val: string) =>
|
||||||
setChoicePicks((p) => { const arr = [...(p[key] ?? [])]; arr[i] = val; return { ...p, [key]: arr }; });
|
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.
|
// 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
|
? plan.hpGainAverage
|
||||||
: hpMethod === 'average' ? plan.hpGainAverage : Math.max(1, rollDice(`1d${plan.hitDie}`, createRng()).total + conMod);
|
: 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;
|
let abilities = character.abilities;
|
||||||
if (asi && asiMode === 'asi') abilities = applyIncreases(abilities, asiPicks, '5e');
|
let abilityBuild = character.abilityBuild;
|
||||||
if (boosts) abilities = applyIncreases(abilities, boostPicks, 'pf2e');
|
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.
|
// Resolve a subclass pick (5e prompt or a pf2e `:subclass` choice) onto the class entry.
|
||||||
const subFromChoice = Object.entries(choicePicks)
|
const subFromChoice = Object.entries(choicePicks)
|
||||||
@@ -156,6 +177,7 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
const patch: Partial<Character> = {
|
const patch: Partial<Character> = {
|
||||||
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
|
hp: { ...character.hp, max: character.hp.max + gain, current: character.hp.current + gain },
|
||||||
abilities,
|
abilities,
|
||||||
|
...(abilityBuild ? { abilityBuild } : {}),
|
||||||
classes: nextClassesFinal,
|
classes: nextClassesFinal,
|
||||||
...normalizeClassMirror({ classes: nextClassesFinal }),
|
...normalizeClassMirror({ classes: nextClassesFinal }),
|
||||||
choices: merged,
|
choices: merged,
|
||||||
@@ -180,6 +202,31 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained';
|
const current = (character.skillRanks[skillKey] as ProficiencyRank) ?? 'untrained';
|
||||||
patch.skillRanks = { ...character.skillRanks, [skillKey]: bumpRank(current) };
|
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);
|
onApply(patch);
|
||||||
onClose();
|
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>
|
<span className="self-center text-xs text-muted">pick the same twice for +2</span>
|
||||||
</div>
|
</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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
{featBrowse && <FeatPickerModal onPick={(f) => setFeatPick(f)} onClose={() => setFeatBrowse(false)} />}
|
||||||
|
|
||||||
{/* Boosts (pf2e) */}
|
{/* Boosts (pf2e) */}
|
||||||
{boosts && (
|
{boosts && (
|
||||||
@@ -335,7 +390,14 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
{choice.options.map((o) => <option key={o} value={o}>{o}</option>)}
|
{choice.options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
@@ -352,3 +414,43 @@ export function LevelUpModal({ character, onApply, onClose }: {
|
|||||||
</Modal>
|
</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',
|
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) {
|
export function ResourcesSection({ c, update }: SectionProps) {
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const sys = getSystem(c.system);
|
const sys = getSystem(c.system);
|
||||||
@@ -42,7 +53,7 @@ export function ResourcesSection({ c, update }: SectionProps) {
|
|||||||
actions={
|
actions={
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{sys.restOptions.map((o) => (
|
{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}
|
{o.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { createRng } from '@/lib/rng';
|
|||||||
import { rollDice } from '@/lib/dice/notation';
|
import { rollDice } from '@/lib/dice/notation';
|
||||||
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
|
import { getSystem, getConditions, type SystemId } from '@/lib/rules';
|
||||||
import { computeBudget, DIFFICULTY_COLOR } from '@/lib/combat/budget';
|
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 { useCharacters, useAllPcs } from '@/features/characters/hooks';
|
||||||
import { useConditionGlossary } from './useConditionGlossary';
|
import { useConditionGlossary } from './useConditionGlossary';
|
||||||
import {
|
import {
|
||||||
@@ -82,6 +82,9 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
*/
|
*/
|
||||||
const noteConcentrationCheck = (combatant: Combatant, damage: number) => {
|
const noteConcentrationCheck = (combatant: Combatant, damage: number) => {
|
||||||
if (damage <= 0) return;
|
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 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).
|
// the linked Character's concentration (set when a seated player casts a spell).
|
||||||
const ch = pcCharacter(combatant);
|
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}.`));
|
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. */
|
/** Advance the turn; remind (don't roll) when a downed PC's turn begins. */
|
||||||
const advanceTurn = () => {
|
const advanceTurn = () => {
|
||||||
mutate((e) => nextTurn(e, campaign.system));
|
mutate((e) => nextTurn(e, campaign.system));
|
||||||
const upcoming = currentCombatant(nextTurn(encounter, campaign.system));
|
const upcoming = currentCombatant(nextTurn(encounter, campaign.system));
|
||||||
if (upcoming && upcoming.kind !== 'monster' && upcoming.hp.current <= 0 && pcCharacter(upcoming)) {
|
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,11 +266,17 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
|
? `${c.name} takes ${amt}${type ? ` ${type}` : ''} damage`
|
||||||
: `${c.name} takes ${hpLoss}${type ? ` ${type}` : ''} damage (${amt} before ${hpLoss < amt ? 'resistance' : 'vulnerability'})`;
|
: `${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
|
// 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;
|
let reminder: string | null = null;
|
||||||
if (c.kind !== 'monster' && hpLoss > 0) {
|
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') {
|
||||||
else if (c.hp.current <= 0) reminder = `${c.name} took damage while down — mark a death save failure (two on a critical hit).`;
|
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) => {
|
mutate((e) => {
|
||||||
let next = logEvent(updateCombatant(e, c.id, { hp: after.hp }), note);
|
let next = logEvent(updateCombatant(e, c.id, { hp: after.hp }), note);
|
||||||
@@ -259,7 +285,20 @@ export function EncounterTracker({ encounter, campaign }: { encounter: Encounter
|
|||||||
});
|
});
|
||||||
noteConcentrationCheck(c, hpLoss);
|
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))}
|
onMove={(dir) => mutate((e) => moveCombatant(e, c.id, dir))}
|
||||||
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
|
onRemove={() => mutate((e) => logEvent(removeCombatant(e, c.id), `${c.name} removed`))}
|
||||||
/>
|
/>
|
||||||
@@ -489,7 +528,11 @@ function CombatantRow({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange({ concentrating: c.concentrating ? null : 'a spell' })}
|
onClick={() => onChange({ concentrating: c.concentrating ? null : 'a spell' })}
|
||||||
className={cn('rounded p-0.5', c.concentrating ? 'text-accent' : 'text-faint hover:text-muted')}
|
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-label={c.concentrating ? `${c.name} is concentrating; clear` : `Mark ${c.name} as concentrating`}
|
||||||
aria-pressed={!!c.concentrating}
|
aria-pressed={!!c.concentrating}
|
||||||
>
|
>
|
||||||
@@ -513,17 +556,41 @@ function CombatantRow({
|
|||||||
{c.conditions.length > 0 && (
|
{c.conditions.length > 0 && (
|
||||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{c.conditions.map((cond, i) => (
|
{c.conditions.map((cond, i) => (
|
||||||
<button
|
<span
|
||||||
key={`${cond.name}-${i}`}
|
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"
|
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()) || 'Click to remove'}
|
title={glossary.get(cond.name.toLowerCase()) || undefined}
|
||||||
onClick={() => onChange({ conditions: c.conditions.filter((_, j) => j !== i) })}
|
|
||||||
>
|
>
|
||||||
{cond.name}
|
{cond.name}
|
||||||
{cond.value ? ` ${cond.value}` : ''}
|
{cond.value ? ` ${cond.value}` : ''}
|
||||||
{cond.duration ? ` (${cond.duration}r)` : ''}
|
{cond.duration ? ` (${cond.duration}r)` : ''}
|
||||||
<X size={11} aria-hidden />
|
{/* Valued conditions (Frightened 2, Drained 3…) step in place — no delete-and-re-add. */}
|
||||||
</button>
|
{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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
import { useLiveQuery } from 'dexie-react-hooks';
|
import { useLiveQuery } from 'dexie-react-hooks';
|
||||||
import { db } from '@/lib/db/db';
|
import { db } from '@/lib/db/db';
|
||||||
import { cloudUsername, CloudError } from '@/lib/cloud/client';
|
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 { useCampaigns } from '@/features/campaigns/hooks';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Input, Select } from '@/components/ui/Input';
|
import { Input, Select } from '@/components/ui/Input';
|
||||||
@@ -21,13 +22,10 @@ export function CloudCampaigns() {
|
|||||||
const [targetCloud, setTargetCloud] = useState('');
|
const [targetCloud, setTargetCloud] = useState('');
|
||||||
const [viewing, setViewing] = useState<string | null>(null);
|
const [viewing, setViewing] = useState<string | null>(null);
|
||||||
const [party, setParty] = useState<CloudCharInfo[]>([]);
|
const [party, setParty] = useState<CloudCharInfo[]>([]);
|
||||||
const [usage, setUsage] = useState<{ bytes: number; admin: boolean } | null>(null);
|
const [usage, setUsage] = useState<{ bytes: number; quota: number; admin: boolean } | null>(null);
|
||||||
const [admins, setAdmins] = useState<AdminUserRow[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]);
|
useEffect(() => { if (signedIn) listCloudCampaigns().then(setList).catch(() => {}); }, [signedIn]);
|
||||||
useEffect(() => { if (signedIn) getUsage().then(setUsage).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 refresh = () => listCloudCampaigns().then(setList).catch(() => {});
|
||||||
|
|
||||||
const run = (fn: () => Promise<void>) => async () => {
|
const run = (fn: () => Promise<void>) => async () => {
|
||||||
@@ -122,24 +120,26 @@ export function CloudCampaigns() {
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{usage?.admin && (
|
||||||
<div className="rounded-md border border-accent/30 bg-accent/5 p-2">
|
<div className="rounded-md border border-accent/30 bg-accent/5 p-2 text-sm">
|
||||||
<h3 className="mb-1 smallcaps">Admin · users & storage</h3>
|
You’re the instance admin — manage accounts, storage, campaigns, and live rooms in the{' '}
|
||||||
<table className="w-full text-xs">
|
<Link to="/admin" className="text-accent underline">Admin panel</Link>.
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -154,15 +154,3 @@ function fmtBytes(b: number): string {
|
|||||||
if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`;
|
if (b >= 1e6) return `${(b / 1e6).toFixed(1)} MB`;
|
||||||
return `${Math.max(0, Math.round(b / 1024))} KB`;
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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' });
|
||||||
@@ -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 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 }
|
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;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`${base()}${path}`, { ...init, headers: { 'content-type': 'application/json', ...authHeaders(), ...(init?.headers ?? {}) } });
|
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;
|
return (await req<{ bytes: number }>('/usage')).bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUserRow { username: string; usageBytes: number; quotaBytes: number }
|
export function getUsage(): Promise<{ bytes: number; quota: number; admin: boolean }> {
|
||||||
export function getUsage(): Promise<{ bytes: number; admin: boolean }> {
|
return req<{ bytes: number; quota: number; admin: boolean }>('/usage');
|
||||||
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 }) });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) });
|
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 === 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 === 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.');
|
if (!res.ok) throw new CloudError('Upload failed.');
|
||||||
const d = (await res.json().catch(() => ({}))) as { savedAt?: number };
|
const d = (await res.json().catch(() => ({}))) as { savedAt?: number };
|
||||||
if (typeof d.savedAt === 'number') setLastSyncedAt(d.savedAt);
|
if (typeof d.savedAt === 'number') setLastSyncedAt(d.savedAt);
|
||||||
|
|||||||
@@ -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, 'fire').hp.current).toBe(30); // immune to all → no change
|
||||||
expect(applyDamage(im, 10, 'cold').hp.current).toBe(30);
|
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', () => {
|
describe('endEncounter', () => {
|
||||||
|
|||||||
@@ -267,10 +267,15 @@ export function isMassiveDamageDeath(hpMax: number, currentAfter: number): boole
|
|||||||
return currentAfter <= 0 && -currentAfter >= hpMax;
|
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;
|
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. */
|
/** Set temporary HP — 5e/PF2e temp HP does not stack; take the higher value. */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Character } from '@/lib/schemas';
|
import type { Character } from '@/lib/schemas';
|
||||||
|
import { synthManualBuild } from '@/lib/rules/abilityBuild';
|
||||||
|
|
||||||
/** Convert a Pathbuilder 2e export (build JSON) into our PF2e character fields. */
|
/** 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 ?? '',
|
ancestry: b.ancestry ?? '',
|
||||||
level,
|
level,
|
||||||
abilities,
|
abilities,
|
||||||
|
// Imports carry only finals — persist a manual build so the sheet stays consistent.
|
||||||
|
abilityBuild: synthManualBuild(abilities),
|
||||||
hp: { current: hpMax, max: hpMax, temp: 0 },
|
hp: { current: hpMax, max: hpMax, temp: 0 },
|
||||||
saveRanks: saveRanks as Character['saveRanks'],
|
saveRanks: saveRanks as Character['saveRanks'],
|
||||||
skillRanks: skillRanks as Character['skillRanks'],
|
skillRanks: skillRanks as Character['skillRanks'],
|
||||||
perceptionRank: rank(prof.perception) === 'untrained' ? 'trained' : rank(prof.perception),
|
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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,13 @@ export function deriveState(system: SystemId, baseSpeed: number, conditions: Con
|
|||||||
extraBadges.push('Quickened (+1 action)');
|
extraBadges.push('Quickened (+1 action)');
|
||||||
continue;
|
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];
|
const eff = table[name];
|
||||||
if (!eff) continue;
|
if (!eff) continue;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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';
|
import type { Condition } from '@/lib/schemas/common';
|
||||||
|
|
||||||
const cond = (name: string, value?: number): Condition => (value !== undefined ? { name, value } : { name });
|
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', () => {
|
describe('pf2eRestDecay', () => {
|
||||||
it('decays doomed, clears wounded, decrements/removes drained, ends fatigued', () => {
|
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)]);
|
const r = pf2eRestDecay({ doomed: 2, wounded: 3 }, [cond('Drained', 2), cond('Fatigued'), cond('Frightened', 1)]);
|
||||||
|
|||||||
@@ -45,6 +45,16 @@ export function applyRecovery(d: Pick<Defenses, 'dying' | 'wounded'>, degree: De
|
|||||||
return { dying, wounded };
|
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
|
* 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
|
* cleared (you wake at full HP), Drained −1, and Fatigued removed. Dying is assumed
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export { normalizeMonsterDefenses, normalizeMonsterDefensesPf2e, type Pf2eDefens
|
|||||||
export { castSpell, availableSlotLevels, dropConcentration, concentrationDC } from './cast';
|
export { castSpell, availableSlotLevels, dropConcentration, concentrationDC } from './cast';
|
||||||
export { spendResource, regainResource } from './resources';
|
export { spendResource, regainResource } from './resources';
|
||||||
export { rollDeathSave, type DeathSaveOutcome } from './deathSaves';
|
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 { conditionEffects, CONDITION_EFFECTS_5E, CONDITION_EFFECTS_PF2E, type ConditionEffect, type PenaltyTarget } from './conditionEffects';
|
||||||
export { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn, type MechanicalState, type DeriveContext } from './creatureState';
|
export { deriveState, deriveEffectiveMaxHp, tickConditionsEndOfTurn, type MechanicalState, type DeriveContext } from './creatureState';
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
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';
|
import type { AbilityBuild } from '@/lib/schemas/character';
|
||||||
|
|
||||||
const base10 = { str: 10, dex: 10, con: 10, int: 10, wis: 10, cha: 10 };
|
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);
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AbilityKey, AbilityScores } from './types';
|
import type { AbilityKey, AbilityScores, SystemId } from './types';
|
||||||
import { ABILITY_KEYS } from './types';
|
import { ABILITY_KEYS } from './types';
|
||||||
import type { AbilityBuild } from '@/lib/schemas/character';
|
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 {
|
export function setBuildBase(build: AbilityBuild, base: AbilityScores): AbilityBuild {
|
||||||
return { base: { ...base }, adjustments: build.adjustments };
|
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) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -106,4 +106,20 @@ describe('applyRest', () => {
|
|||||||
// no concentration → no concentration key in the patch
|
// no concentration → no concentration key in the patch
|
||||||
expect('concentration' in applyRest(makeCharacter(), short)).toBe(false);
|
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
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export * from './types';
|
|||||||
export * from './progression';
|
export * from './progression';
|
||||||
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
|
export { abilityModifier, ABILITY_ABBR, ABILITY_LABELS, formatDamage } from './abilities';
|
||||||
export { pf2eApplyBoosts, parseAncestryBoosts, parseBackgroundBoosts, parseFlaw, abilityWord } from './pf2e/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 { applyRest } from './rest';
|
||||||
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
|
export { getConditions, CONDITIONS_5E, CONDITIONS_PF2E, type ConditionDef } from './conditions';
|
||||||
export { collectChoices, collectFeatures, collectChoices5e, collectFeatures5e, subclassPrompt } from './features/collect';
|
export { collectChoices, collectFeatures, collectChoices5e, collectFeatures5e, subclassPrompt } from './features/collect';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { AbilityKey, AbilityScores, ProficiencyRank, SystemId } from './types';
|
import type { AbilityKey, AbilityScores, ProficiencyRank, SystemId } from './types';
|
||||||
import { abilityModifier } from './abilities';
|
import { abilityModifier } from './abilities';
|
||||||
|
import { newId } from '@/lib/ids';
|
||||||
import { DND5E_CLASSES, dnd5eSlots, dnd5ePact, dnd5eHp, dnd5eAsiLevels } from './dnd5e/progression';
|
import { DND5E_CLASSES, dnd5eSlots, dnd5ePact, dnd5eHp, dnd5eAsiLevels } from './dnd5e/progression';
|
||||||
import { PF2E_CLASSES, pf2eHp, pf2eSlots, pf2eBoostLevels, pf2eSkillIncreaseLevels } from './pf2e/progression';
|
import { PF2E_CLASSES, pf2eHp, pf2eSlots, pf2eBoostLevels, pf2eSkillIncreaseLevels } from './pf2e/progression';
|
||||||
|
|
||||||
@@ -57,6 +58,12 @@ export interface BuiltCharacter {
|
|||||||
spellcastingAbility?: SpellAbility;
|
spellcastingAbility?: SpellAbility;
|
||||||
spellcastingRank?: ProficiencyRank;
|
spellcastingRank?: ProficiencyRank;
|
||||||
spellcasting: { slots: SpellSlot[]; pact?: SpellSlot; spells: never[] };
|
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 };
|
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',
|
perceptionRank: system === 'pf2e' ? def?.perception ?? 'trained' : 'trained',
|
||||||
...(def?.spellAbility ? { spellcastingAbility: def.spellAbility, spellcastingRank: def.spellRank ?? 'trained' } : {}),
|
...(def?.spellAbility ? { spellcastingAbility: def.spellAbility, spellcastingRank: def.spellRank ?? 'trained' } : {}),
|
||||||
spellcasting: { slots, ...(pact ? { pact } : {}), spells: [] },
|
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)) {
|
if (dnd5eAsiLevels(className).includes(nextLevel)) {
|
||||||
choices.push({ kind: 'asi', label: 'Ability Score Improvement — raise abilities by +2 total, or take a feat instead.' });
|
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 };
|
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)) {
|
if (pf2eSkillIncreaseLevels().includes(nextLevel)) {
|
||||||
choices.push({ kind: 'skill-increase', label: 'Skill increase — raise one skill to the next proficiency rank.' });
|
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.' });
|
// (Feat slots are enumerated per type — class/skill/general/ancestry — by collectChoices,
|
||||||
if (slots.length) notes.push('Spell slots updated for the new level.');
|
// 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 };
|
return { nextLevel, hpGainAverage, hitDie: die, ...(slots.length ? { slots } : {}), notes, choices };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+28
-15
@@ -1,6 +1,7 @@
|
|||||||
import type { Character } from '@/lib/schemas/character';
|
import type { Character } from '@/lib/schemas/character';
|
||||||
import type { RestOption } from './types';
|
import type { RestOption } from './types';
|
||||||
import { pf2eRestDecay } from '@/lib/mechanics/dying';
|
import { pf2eRestDecay } from '@/lib/mechanics/dying';
|
||||||
|
import { deriveEffectiveMaxHp } from '@/lib/mechanics/creatureState';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the character changes a rest produces. Pure — returns a partial patch
|
* 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 };
|
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).
|
// Any rest ends ongoing concentration (you stop holding the spell to rest).
|
||||||
if (c.concentration) patch.concentration = null;
|
if (c.concentration) patch.concentration = null;
|
||||||
|
|
||||||
// Resources refresh when their recovery tag is covered by this rest. A rest with
|
// Resources refresh when their recovery tag is covered by this rest. A step —
|
||||||
// `recoverStep` (PF2e Refocus) restores by that step, clamped to max, rather than
|
// per-resource (5e Hit Dice recover ceil(level/2)) or per-rest (PF2e Refocus
|
||||||
// refilling the whole pool — so Refocus returns one Focus Point, not all of them.
|
// returns one Focus Point) — restores by that amount, clamped to max; otherwise
|
||||||
|
// the pool refills entirely.
|
||||||
patch.resources = c.resources.map((r) => {
|
patch.resources = c.resources.map((r) => {
|
||||||
if (!opt.recovers.includes(r.recovery)) return 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 };
|
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
|
// 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.
|
// (you wake at full HP) and ends fatigued. Dying is assumed already 0.
|
||||||
if (c.system === 'pf2e') {
|
if (c.system === 'pf2e') {
|
||||||
if (opt.restoresHp) {
|
if (pf2eDecayed) {
|
||||||
const decay = pf2eRestDecay(c.defenses, c.conditions);
|
patch.defenses = { ...c.defenses, ...pf2eDecayed.defenses };
|
||||||
patch.defenses = { ...c.defenses, ...decay.defenses };
|
patch.conditions = pf2eDecayed.conditions;
|
||||||
patch.conditions = decay.conditions;
|
|
||||||
}
|
}
|
||||||
return patch;
|
return patch;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exhaustion =
|
if (opt.restoresHp || restedExhaustion !== c.defenses.exhaustion) {
|
||||||
opt.reduceExhaustion && c.defenses.exhaustion > 0
|
|
||||||
? c.defenses.exhaustion - 1
|
|
||||||
: c.defenses.exhaustion;
|
|
||||||
|
|
||||||
if (opt.restoresHp || exhaustion !== c.defenses.exhaustion) {
|
|
||||||
patch.defenses = {
|
patch.defenses = {
|
||||||
...c.defenses,
|
...c.defenses,
|
||||||
exhaustion,
|
exhaustion: restedExhaustion,
|
||||||
// recovering from 0 HP clears death saves (5e)
|
// recovering from 0 HP clears death saves (5e)
|
||||||
...(opt.restoresHp ? { deathSaves: { successes: 0, failures: 0 } } : {}),
|
...(opt.restoresHp ? { deathSaves: { successes: 0, failures: 0 } } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -168,3 +168,24 @@ describe('pf2e Refocus', () => {
|
|||||||
expect(patch.resources?.[0]?.current).toBe(1); // +1, clamped to max — not 3
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ export const resourceSchema = z.object({
|
|||||||
current: int.min(0).default(0),
|
current: int.min(0).default(0),
|
||||||
max: int.min(0).default(0),
|
max: int.min(0).default(0),
|
||||||
recovery: recoverySchema.default('long'),
|
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>;
|
export type CharacterResource = z.infer<typeof resourceSchema>;
|
||||||
|
|
||||||
@@ -173,6 +176,8 @@ export const characterSchema = z.object({
|
|||||||
portrait: z.string().optional(),
|
portrait: z.string().optional(),
|
||||||
/** ancestry (PF2e) / race (5e), free text for MVP */
|
/** ancestry (PF2e) / race (5e), free text for MVP */
|
||||||
ancestry: z.string().max(80).default(''),
|
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). */
|
/** Primary class name — a mirror of classes[0].className (kept for display/back-compat). */
|
||||||
className: z.string().max(80).default(''),
|
className: z.string().max(80).default(''),
|
||||||
/** Total character level — a mirror of sum(classes[].level). */
|
/** 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. */
|
/** Default values for the Phase-1 depth fields. Used by create + DB migration. */
|
||||||
export function characterDefaults(): Pick<
|
export function characterDefaults(): Pick<
|
||||||
Character,
|
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 {
|
return {
|
||||||
classes: [],
|
classes: [],
|
||||||
choices: [],
|
choices: [],
|
||||||
background: '',
|
background: '',
|
||||||
|
heritage: '',
|
||||||
alignment: '',
|
alignment: '',
|
||||||
appearance: '',
|
appearance: '',
|
||||||
personality: '',
|
personality: '',
|
||||||
|
|||||||
@@ -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('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('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('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('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() }),
|
z.object({ t: z.literal('requestImage'), id: z.string().max(128) }),
|
||||||
// two-way play (player → server)
|
// two-way play (player → server)
|
||||||
z.object({ t: z.literal('claimSeat'), characterId: z.string(), offlineSnapshot: characterSchema.optional() }),
|
z.object({ t: z.literal('claimSeat'), characterId: z.string(), offlineSnapshot: characterSchema.optional() }),
|
||||||
z.object({ t: z.literal('playerPatch'), characterId: z.string(), diff: partialCharacterDiffSchema }),
|
z.object({ t: z.literal('playerPatch'), characterId: z.string(), diff: partialCharacterDiffSchema }),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { HomebrewPage } from '@/features/world/HomebrewPage';
|
|||||||
import { SettingsPage } from '@/features/settings/SettingsPage';
|
import { SettingsPage } from '@/features/settings/SettingsPage';
|
||||||
import { AssistantPage } from '@/features/assistant/AssistantPage';
|
import { AssistantPage } from '@/features/assistant/AssistantPage';
|
||||||
import { DirectorPage } from '@/features/assistant/director/DirectorPage';
|
import { DirectorPage } from '@/features/assistant/director/DirectorPage';
|
||||||
|
import { AdminPage } from '@/features/admin/AdminPage';
|
||||||
|
|
||||||
const rootRoute = createRootRoute({ component: RootLayout });
|
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 settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: '/settings', component: SettingsPage });
|
||||||
const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage });
|
const assistantRoute = createRoute({ getParentRoute: () => rootRoute, path: '/assistant', component: AssistantPage });
|
||||||
const directorRoute = createRoute({ getParentRoute: () => rootRoute, path: '/director', component: DirectorPage });
|
const directorRoute = createRoute({ getParentRoute: () => rootRoute, path: '/director', component: DirectorPage });
|
||||||
|
const adminRoute = createRoute({ getParentRoute: () => rootRoute, path: '/admin', component: AdminPage });
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
@@ -64,6 +66,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
settingsRoute,
|
settingsRoute,
|
||||||
assistantRoute,
|
assistantRoute,
|
||||||
directorRoute,
|
directorRoute,
|
||||||
|
adminRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({ routeTree, defaultPreload: 'intent', defaultNotFoundComponent: NotFound });
|
export const router = createRouter({ routeTree, defaultPreload: 'intent', defaultNotFoundComponent: NotFound });
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user