From ba5ae3711166a8be2e3b0ce68a7dbe494c70ff5f Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Tue, 9 Jun 2026 10:34:29 +0200 Subject: [PATCH] V2 P5-backend + P7 + P8: conflict-safe sync, revocable invites, feats, multiclass, tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P5 backend (the deferred items): - conflict-aware cloud sync via optimistic concurrency: the server versions each backup blob (file mtime) and rejects a push whose base version is stale (409), so a second device can't silently overwrite a newer cloud copy. The client tracks its last-synced version; the SyncStatusIndicator surfaces a "Sync conflict" with one-click resolution (Use cloud / Keep this device), and the Settings push offers the same choice. - revocable invites: owner can rotate a campaign's invite code (kills the old one) and remove a member (drops them + deletes their published characters). (Skipped editor/player roles — no enforcement target in the current model.) - image storage: deliberately deferred (live images already stream de-duped on a separate channel; a backup blob-store is infra with no correctness gain). P7 — subclass/feat/multiclass depth: - feats are now first-class tracked records (name + effect text) in a Feats & Features sheet section, not buried in notes. Dexie v15 migration. - 5e multiclass spell-slot calculator: a pure multiclassSlots() (full ×1, half ÷2, third ÷3 → full-caster table) + a sheet control to fill the slot table for multiclass casters. (Honest scope: tracking + slots, not per-subclass feature automation.) P8 — test hardening: v2-surfaces e2e (feats, multiclass calc, signals bell, World nav) + server tests (blob versioning, invite rotation, member removal) + multiclass unit tests. Gate green: 283 unit + 35 e2e + 2 realtime. App + server build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/v2-surfaces.spec.ts | 42 ++++++++++++++++ server/src/accounts.test.ts | 6 ++- server/src/accounts.ts | 11 ++++- server/src/campaigns.test.ts | 21 ++++++++ server/src/campaigns.ts | 26 ++++++++++ server/src/index.ts | 29 +++++++++-- src/features/characters/CharacterSheet.tsx | 4 ++ .../characters/sheet/FeatsSection.tsx | 49 +++++++++++++++++++ .../characters/sheet/SpellcastingSection.tsx | 22 +++++++++ src/features/cloud/SyncStatusIndicator.tsx | 49 +++++++++++++++++-- src/features/cloud/useCloudAutosave.ts | 2 +- src/features/settings/CloudCampaigns.tsx | 16 +++++- src/features/settings/SettingsPage.tsx | 9 +++- src/lib/cloud/campaigns.ts | 8 +++ src/lib/cloud/client.ts | 28 +++++++++-- src/lib/db/db.ts | 8 +++ src/lib/rules/dnd5e/progression.ts | 17 +++++++ src/lib/rules/multiclass.test.ts | 29 +++++++++++ src/lib/schemas/character.ts | 14 +++++- src/stores/connectivityStore.ts | 2 +- tsconfig.app.tsbuildinfo | 2 +- 21 files changed, 372 insertions(+), 22 deletions(-) create mode 100644 e2e/v2-surfaces.spec.ts create mode 100644 src/features/characters/sheet/FeatsSection.tsx create mode 100644 src/lib/rules/multiclass.test.ts diff --git a/e2e/v2-surfaces.spec.ts b/e2e/v2-surfaces.spec.ts new file mode 100644 index 0000000..e102847 --- /dev/null +++ b/e2e/v2-surfaces.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from '@playwright/test'; +import { createCharacter } from './helpers'; + +test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(async () => { + indexedDB.deleteDatabase('ttrpg-manager'); + localStorage.clear(); + }); + await page.reload(); +}); + +test('v2 surfaces: feats tracking, multiclass slot calculator, signals bell, World nav', async ({ page }) => { + await page.getByRole('button', { name: '+ New campaign' }).first().click(); + await page.locator('input[data-autofocus]').fill('V2 Test'); + await page.getByRole('button', { name: 'Create' }).click(); + await page.getByLabel('Primary').getByRole('link', { name: 'Characters' }).click(); + await createCharacter(page, 'Vex'); + + // Feats: add one and confirm it becomes a tracked record. + await page.getByPlaceholder(/Great Weapon Master/).fill('Sentinel'); + await page.getByPlaceholder(/Great Weapon Master/).press('Enter'); + await expect(page.getByLabel('Feat name')).toHaveValue('Sentinel'); + + // Multiclass slot calculator: 5 full-caster levels → standard 4/3/2 table. + await page.getByText('Multiclass slot calculator').click(); + await page.getByLabel('Full caster levels').fill('5'); + await page.getByRole('button', { name: 'Apply slots' }).click(); + await expect(page.getByLabel('Level 1 max slots')).toHaveValue('4'); + await expect(page.getByLabel('Level 3 max slots')).toHaveValue('2'); + + // Ambient Signals bell opens and renders its panel. + await page.getByRole('button', { name: /Signals/ }).click(); + await expect(page.getByText('Signals', { exact: true })).toBeVisible(); + await page.keyboard.press('Escape'); + + // The previously-orphaned routes are now reachable from the rail's World group. + await page.getByLabel('Primary').getByRole('link', { name: 'Notes' }).click(); + await expect(page).toHaveURL(/\/notes/); + await page.getByLabel('Primary').getByRole('link', { name: 'Quests' }).click(); + await expect(page).toHaveURL(/\/quests/); +}); diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts index 73189ca..d098127 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -23,8 +23,12 @@ describe('AccountStore', () => { if (!r.ok) throw new Error('register failed'); const u = await store.userByToken(r.token); expect(u).toBeTruthy(); - await store.saveBlob(u!.id, '{"x":1}'); + const ver = await store.saveBlob(u!.id, '{"x":1}'); + expect(typeof ver).toBe('number'); expect(await store.loadBlob(u!.id)).toBe('{"x":1}'); + // the version token round-trips and is null for a user with no blob + expect(await store.blobSavedAt(u!.id)).toBe(ver); + expect(await store.blobSavedAt('no-such-user')).toBeNull(); expect(await store.userByToken('garbage-token')).toBeNull(); }); diff --git a/server/src/accounts.ts b/server/src/accounts.ts index e7fa987..9653c8b 100644 --- a/server/src/accounts.ts +++ b/server/src/accounts.ts @@ -132,16 +132,25 @@ export class AccountStore { await this.persist(); } - async saveBlob(id: string, blob: string): Promise { + /** Write the backup blob and return its version token (the file mtime, ms). */ + async saveBlob(id: string, blob: string): Promise { await fs.mkdir(path.join(this.dir, 'blobs'), { recursive: true }); const tmp = `${this.blobFile(id)}.tmp`; await fs.writeFile(tmp, blob); await fs.rename(tmp, this.blobFile(id)); + return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs); } async loadBlob(id: string): Promise { try { return await fs.readFile(this.blobFile(id), 'utf8'); } catch { return null; } } + /** Version token of the stored blob (file mtime, ms), or null if none. Used for + * optimistic-concurrency conflict detection so a second device can't silently + * overwrite a newer cloud copy. */ + async blobSavedAt(id: string): Promise { + try { return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs); } catch { return null; } + } + userCount(): number { return this.users.size; } } diff --git a/server/src/campaigns.test.ts b/server/src/campaigns.test.ts index 41b6ad8..6501b0d 100644 --- a/server/src/campaigns.test.ts +++ b/server/src/campaigns.test.ts @@ -55,4 +55,25 @@ describe('CloudStore', () => { expect((await store2.listForUser('gm1'))[0]?.id).toBe(c.id); expect(await store2.listCharacters(c.id)).toHaveLength(0); }); + + it('rotates the invite code (owner only) so the old code stops working', async () => { + const c = await store.createCampaign('gm1', 'C', '5e'); + const old = c.inviteCode; + expect(await store.rotateInvite('p1', c.id)).toBeNull(); // non-owner can't + const fresh = await store.rotateInvite('gm1', c.id); + expect(fresh).toBeTruthy(); + expect(fresh).not.toBe(old); + expect(await store.joinByInvite('p1', old)).toBeNull(); // old code is dead + expect((await store.joinByInvite('p1', fresh!))?.id).toBe(c.id); // new code works + }); + + it('removes a member and their characters (owner only)', async () => { + const c = await store.createCampaign('gm1', 'C', '5e'); + await store.joinByInvite('p1', c.inviteCode); + await store.putCharacter('p1', c.id, { id: 'ch1', name: 'Lia', data: '{}' }); + expect(await store.removeMember('p1', c.id, 'p1')).toBe(false); // non-owner can't + expect(await store.removeMember('gm1', c.id, 'p1')).toBe(true); + expect(store.isMember(c.id, 'p1')).toBe(false); + expect(await store.listCharacters(c.id)).toHaveLength(0); // their char is gone + }); }); diff --git a/server/src/campaigns.ts b/server/src/campaigns.ts index 4df43a4..bc03e21 100644 --- a/server/src/campaigns.ts +++ b/server/src/campaigns.ts @@ -83,6 +83,32 @@ export class CloudStore { return c; } + /** Owner-only: invalidate the current invite code and mint a new one, so a + * leaked/old code can no longer be used to join. Returns the new code. */ + async rotateInvite(ownerUserId: string, campaignId: string): Promise { + await this.load(); + const c = this.campaigns.get(campaignId); + if (!c || c.ownerUserId !== ownerUserId) return null; + this.byInvite.delete(c.inviteCode); + c.inviteCode = this.mintInvite(); + c.updatedAt = this.now(); + this.byInvite.set(c.inviteCode, c.id); + await this.persist(); + return c.inviteCode; + } + + /** Owner-only: remove a member and all of their published characters. */ + async removeMember(ownerUserId: string, campaignId: string, memberUserId: string): Promise { + await this.load(); + const c = this.campaigns.get(campaignId); + if (!c || c.ownerUserId !== ownerUserId || memberUserId === ownerUserId) return false; + c.members = c.members.filter((m) => m !== memberUserId); + c.updatedAt = this.now(); + for (const [id, ch] of this.characters) if (ch.campaignId === campaignId && ch.ownerUserId === memberUserId) this.characters.delete(id); + await this.persist(); + return true; + } + isMember(campaignId: string, userId: string): boolean { const c = this.campaigns.get(campaignId); return !!c && (c.ownerUserId === userId || c.members.includes(userId)); diff --git a/server/src/index.ts b/server/src/index.ts index 09dd82b..0e270e7 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -65,17 +65,25 @@ export function buildServer() { app.put('/api/save', async (req, reply) => { const u = await accounts.userByToken(bearer(req)); if (!u) return reply.code(401).send({ error: 'unauthorized' }); - const blob = (req.body as { blob?: string } | undefined)?.blob; - if (typeof blob !== 'string') return reply.code(400).send({ error: 'bad-blob' }); - await accounts.saveBlob(u.id, blob); - return { ok: true, size: blob.length }; + const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {}; + if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' }); + // Optimistic concurrency: if the caller names the version it last synced and + // the cloud has since moved on (another device pushed), refuse — no silent + // overwrite. The client then resolves (pull, or force-push). + const current = await accounts.blobSavedAt(u.id); + if (current !== null && body.baseSavedAt !== undefined && body.baseSavedAt !== null && body.baseSavedAt !== current) { + return reply.code(409).send({ error: 'conflict', savedAt: current }); + } + const savedAt = await accounts.saveBlob(u.id, body.blob); + return { ok: true, size: body.blob.length, savedAt }; }); app.get('/api/save', async (req, reply) => { const u = await accounts.userByToken(bearer(req)); if (!u) return reply.code(401).send({ error: 'unauthorized' }); const blob = await accounts.loadBlob(u.id); if (blob === null) return reply.code(404).send({ error: 'no-save' }); - return reply.header('content-type', 'application/json').send(blob); + const savedAt = await accounts.blobSavedAt(u.id); + return reply.header('content-type', 'application/json').header('x-saved-at', String(savedAt ?? '')).send(blob); }); // ---- shared cloud campaigns + member-owned characters ---- @@ -98,6 +106,17 @@ export function buildServer() { if (!c) return reply.code(404).send({ error: 'no-campaign', message: 'No campaign with that invite code.' }); return { id: c.id, name: c.name, system: c.system, role: c.ownerUserId === u.id ? 'owner' : 'member' }; }); + app.post('/api/campaigns/:id/invite', async (req, reply) => { + const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); + const code = await cloud.rotateInvite(u.id, (req.params as { id: string }).id); + return code ? { inviteCode: code } : reply.code(403).send({ error: 'forbidden', message: 'Only the campaign owner can rotate the invite.' }); + }); + app.delete('/api/campaigns/:id/members/:userId', async (req, reply) => { + const u = await userOf(req); if (!u) return reply.code(401).send({ error: 'unauthorized' }); + const p = req.params as { id: string; userId: string }; + const ok = await cloud.removeMember(u.id, p.id, p.userId); + return ok ? { ok: true } : reply.code(403).send({ error: 'forbidden' }); + }); app.put('/api/characters', async (req, reply) => { 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 } }; diff --git a/src/features/characters/CharacterSheet.tsx b/src/features/characters/CharacterSheet.tsx index a39ec29..be5455f 100644 --- a/src/features/characters/CharacterSheet.tsx +++ b/src/features/characters/CharacterSheet.tsx @@ -24,6 +24,7 @@ import { AttacksSection } from './sheet/AttacksSection'; import { ResourcesSection } from './sheet/ResourcesSection'; import { SpellcastingSection } from './sheet/SpellcastingSection'; import { DefensesSection } from './sheet/DefensesSection'; +import { FeatsSection } from './sheet/FeatsSection'; import { AbilityGenModal } from './sheet/AbilityGenModal'; import { LevelUpModal } from './sheet/LevelUpModal'; @@ -288,6 +289,9 @@ export function CharacterSheet({ character }: { character: Character }) { {/* Class resources + rest */} + {/* Feats & features */} + + {/* Inventory, currency, encumbrance */} diff --git a/src/features/characters/sheet/FeatsSection.tsx b/src/features/characters/sheet/FeatsSection.tsx new file mode 100644 index 0000000..dc923a4 --- /dev/null +++ b/src/features/characters/sheet/FeatsSection.tsx @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { newId } from '@/lib/ids'; +import type { Feat } from '@/lib/schemas'; +import { Button } from '@/components/ui/Button'; +import { Input, Textarea } from '@/components/ui/Input'; +import { SheetSection, type SectionProps } from './common'; + +/** Feats tracked as first-class records (name + effect) rather than buried in notes. */ +export function FeatsSection({ c, update }: SectionProps) { + const [name, setName] = useState(''); + + const add = () => { + if (name.trim() === '') return; + const feat: Feat = { id: newId(), name: name.trim(), source: '', description: '' }; + update({ feats: [...c.feats, feat] }); + setName(''); + }; + const patch = (id: string, p: Partial) => + update({ feats: c.feats.map((f) => (f.id === id ? { ...f, ...p } : f)) }); + const remove = (id: string) => update({ feats: c.feats.filter((f) => f.id !== id) }); + + return ( + +
+ + +
+ + {c.feats.length === 0 ? ( +

No feats or features tracked yet.

+ ) : ( +
    + {c.feats.map((f) => ( +
  • +
    + patch(f.id, { name: e.target.value })} aria-label="Feat name" /> + +
    +