V2 P5-backend + P7 + P8: conflict-safe sync, revocable invites, feats, multiclass, tests

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) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 10:34:29 +02:00
parent dc0e8f701c
commit ba5ae37111
21 changed files with 372 additions and 22 deletions
+42
View File
@@ -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/);
});
+5 -1
View File
@@ -23,8 +23,12 @@ describe('AccountStore', () => {
if (!r.ok) throw new Error('register failed'); if (!r.ok) throw new Error('register failed');
const u = await store.userByToken(r.token); const u = await store.userByToken(r.token);
expect(u).toBeTruthy(); 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}'); 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(); expect(await store.userByToken('garbage-token')).toBeNull();
}); });
+10 -1
View File
@@ -132,16 +132,25 @@ export class AccountStore {
await this.persist(); await this.persist();
} }
async saveBlob(id: string, blob: string): Promise<void> { /** Write the backup blob and return its version token (the file mtime, ms). */
async saveBlob(id: string, blob: string): Promise<number> {
await fs.mkdir(path.join(this.dir, 'blobs'), { recursive: true }); await fs.mkdir(path.join(this.dir, 'blobs'), { recursive: true });
const tmp = `${this.blobFile(id)}.tmp`; const tmp = `${this.blobFile(id)}.tmp`;
await fs.writeFile(tmp, blob); await fs.writeFile(tmp, blob);
await fs.rename(tmp, this.blobFile(id)); await fs.rename(tmp, this.blobFile(id));
return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs);
} }
async loadBlob(id: string): Promise<string | null> { async loadBlob(id: string): Promise<string | null> {
try { return await fs.readFile(this.blobFile(id), 'utf8'); } catch { return null; } 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<number | null> {
try { return Math.floor((await fs.stat(this.blobFile(id))).mtimeMs); } catch { return null; }
}
userCount(): number { return this.users.size; } userCount(): number { return this.users.size; }
} }
+21
View File
@@ -55,4 +55,25 @@ describe('CloudStore', () => {
expect((await store2.listForUser('gm1'))[0]?.id).toBe(c.id); expect((await store2.listForUser('gm1'))[0]?.id).toBe(c.id);
expect(await store2.listCharacters(c.id)).toHaveLength(0); 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
});
}); });
+26
View File
@@ -83,6 +83,32 @@ export class CloudStore {
return c; 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<string | null> {
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<boolean> {
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 { isMember(campaignId: string, userId: string): boolean {
const c = this.campaigns.get(campaignId); const c = this.campaigns.get(campaignId);
return !!c && (c.ownerUserId === userId || c.members.includes(userId)); return !!c && (c.ownerUserId === userId || c.members.includes(userId));
+24 -5
View File
@@ -65,17 +65,25 @@ export function buildServer() {
app.put('/api/save', async (req, reply) => { app.put('/api/save', async (req, reply) => {
const u = await accounts.userByToken(bearer(req)); const u = await accounts.userByToken(bearer(req));
if (!u) return reply.code(401).send({ error: 'unauthorized' }); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const blob = (req.body as { blob?: string } | undefined)?.blob; const body = (req.body as { blob?: string; baseSavedAt?: number | null } | undefined) ?? {};
if (typeof blob !== 'string') return reply.code(400).send({ error: 'bad-blob' }); if (typeof body.blob !== 'string') return reply.code(400).send({ error: 'bad-blob' });
await accounts.saveBlob(u.id, blob); // Optimistic concurrency: if the caller names the version it last synced and
return { ok: true, size: blob.length }; // 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) => { app.get('/api/save', async (req, reply) => {
const u = await accounts.userByToken(bearer(req)); const u = await accounts.userByToken(bearer(req));
if (!u) return reply.code(401).send({ error: 'unauthorized' }); if (!u) return reply.code(401).send({ error: 'unauthorized' });
const blob = await accounts.loadBlob(u.id); const blob = await accounts.loadBlob(u.id);
if (blob === null) return reply.code(404).send({ error: 'no-save' }); 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 ---- // ---- 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.' }); 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' }; 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) => { app.put('/api/characters', 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 { 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 } };
@@ -24,6 +24,7 @@ import { AttacksSection } from './sheet/AttacksSection';
import { ResourcesSection } from './sheet/ResourcesSection'; import { ResourcesSection } from './sheet/ResourcesSection';
import { SpellcastingSection } from './sheet/SpellcastingSection'; import { SpellcastingSection } from './sheet/SpellcastingSection';
import { DefensesSection } from './sheet/DefensesSection'; import { DefensesSection } from './sheet/DefensesSection';
import { FeatsSection } from './sheet/FeatsSection';
import { AbilityGenModal } from './sheet/AbilityGenModal'; import { AbilityGenModal } from './sheet/AbilityGenModal';
import { LevelUpModal } from './sheet/LevelUpModal'; import { LevelUpModal } from './sheet/LevelUpModal';
@@ -288,6 +289,9 @@ export function CharacterSheet({ character }: { character: Character }) {
{/* Class resources + rest */} {/* Class resources + rest */}
<ResourcesSection c={c} update={update} /> <ResourcesSection c={c} update={update} />
{/* Feats & features */}
<FeatsSection c={c} update={update} />
{/* Inventory, currency, encumbrance */} {/* Inventory, currency, encumbrance */}
<InventorySection c={c} update={update} /> <InventorySection c={c} update={update} />
@@ -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<Feat>) =>
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 (
<SheetSection title="Feats & Features">
<div className="mb-3 flex items-end gap-2">
<label className="flex-1 min-w-40 text-xs text-muted">
Add feat / feature
<Input value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} placeholder="Great Weapon Master, Oath of Devotion…" />
</label>
<Button variant="primary" onClick={add}>Add</Button>
</div>
{c.feats.length === 0 ? (
<p className="text-sm text-muted">No feats or features tracked yet.</p>
) : (
<ul className="space-y-2">
{c.feats.map((f) => (
<li key={f.id} className="rounded-md border border-line bg-panel p-2">
<div className="flex items-center gap-2">
<Input className="h-8 flex-1 font-medium" value={f.name} onChange={(e) => patch(f.id, { name: e.target.value })} aria-label="Feat name" />
<Button size="icon" variant="ghost" className="text-danger" onClick={() => remove(f.id)} aria-label={`Remove ${f.name}`}></Button>
</div>
<Textarea className="mt-1 text-xs" rows={2} value={f.description} onChange={(e) => patch(f.id, { description: e.target.value })} placeholder="What it does — kept handy at the table." aria-label={`${f.name} description`} />
</li>
))}
</ul>
)}
</SheetSection>
);
}
@@ -5,6 +5,7 @@ import { getSystem } from '@/lib/rules';
import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules'; import type { AbilityKey, CharacterRulesInput, ProficiencyRank } from '@/lib/rules';
import { type SpellEntry, newSpellEntry } from '@/lib/schemas'; import { type SpellEntry, newSpellEntry } from '@/lib/schemas';
import { castSpell, dropConcentration } from '@/lib/mechanics'; import { castSpell, dropConcentration } from '@/lib/mechanics';
import { multiclassSlots } from '@/lib/rules/dnd5e/progression';
import { formatModifier } from '@/lib/format'; import { formatModifier } from '@/lib/format';
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';
@@ -124,6 +125,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
))} ))}
</div> </div>
)} )}
{c.system === '5e' && <MulticlassSlots onApply={setSlots} />}
</div> </div>
{/* Spell list */} {/* Spell list */}
@@ -171,3 +173,23 @@ export function SpellcastingSection({ c, update }: SectionProps) {
</SheetSection> </SheetSection>
); );
} }
/** PHB multiclass spell-slot calculator: enter your class levels by caster type
* and it fills the slot table correctly (full ×1, half ÷2, third ÷3). */
function MulticlassSlots({ onApply }: { onApply: (slots: { level: number; max: number; current: number }[]) => void }) {
const [full, setFull] = useState(0);
const [half, setHalf] = useState(0);
const [third, setThird] = useState(0);
return (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-muted hover:text-ink">Multiclass slot calculator</summary>
<div className="mt-2 flex flex-wrap items-end gap-2 rounded-md border border-line bg-panel-2 p-2 text-xs text-muted">
<label>Full-caster lvls<NumberField className="ml-1 w-14" value={full} min={0} max={20} onChange={setFull} aria-label="Full caster levels" /></label>
<label>Half-caster lvls<NumberField className="ml-1 w-14" value={half} min={0} max={20} onChange={setHalf} aria-label="Half caster levels" /></label>
<label>Third-caster lvls<NumberField className="ml-1 w-14" value={third} min={0} max={20} onChange={setThird} aria-label="Third caster levels" /></label>
<Button size="sm" variant="secondary" onClick={() => onApply(multiclassSlots({ full, half, third }))}>Apply slots</Button>
<span className="w-full text-[11px] text-faint">Bard/Cleric/Druid/Sorcerer/Wizard = full; Paladin/Ranger = half; Eldritch Knight/Arcane Trickster = third. Warlock pact slots are separate.</span>
</div>
</details>
);
}
+46 -3
View File
@@ -1,7 +1,8 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { Cloud, CloudOff, RefreshCw, WifiOff } from 'lucide-react'; import { Cloud, CloudOff, RefreshCw, WifiOff, AlertTriangle } from 'lucide-react';
import { cloudUsername } from '@/lib/cloud/client'; import { cloudUsername, pushBackup, pullBackup } from '@/lib/cloud/client';
import { useConnectivityStore } from '@/stores/connectivityStore'; import { useConnectivityStore } from '@/stores/connectivityStore';
import { Button } from '@/components/ui/Button';
import { cn } from '@/lib/cn'; import { cn } from '@/lib/cn';
/** /**
@@ -35,8 +36,11 @@ export function SyncStatusIndicator() {
if (!signedIn) return null; if (!signedIn) return null;
if (cloudSync === 'conflict') return <ConflictResolver />;
const map = { const map = {
idle: null, idle: null,
conflict: null, // handled above
saving: { Icon: RefreshCw, text: 'Saving…', cls: 'text-muted', spin: true, title: 'Saving your campaign to the cloud…' }, saving: { Icon: RefreshCw, text: 'Saving…', cls: 'text-muted', spin: true, title: 'Saving your campaign to the cloud…' },
saved: { Icon: Cloud, text: 'Saved', cls: 'text-success', spin: false, title: 'Your campaign is backed up to the cloud.' }, saved: { Icon: Cloud, text: 'Saved', cls: 'text-success', spin: false, title: 'Your campaign is backed up to the cloud.' },
error: { Icon: CloudOff, text: 'Sync failed', cls: 'text-danger', spin: false, title: 'Cloud sync failed — your data is safe locally and will retry on the next change.' }, error: { Icon: CloudOff, text: 'Sync failed', cls: 'text-danger', spin: false, title: 'Cloud sync failed — your data is safe locally and will retry on the next change.' },
@@ -51,3 +55,42 @@ export function SyncStatusIndicator() {
</span> </span>
); );
} }
/**
* Cloud changed on another device since this one last synced — resolve in one
* click rather than silently clobbering. "Use cloud" pulls (overwrites local),
* "Keep this device" force-pushes (overwrites cloud).
*/
function ConflictResolver() {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const setCloudSync = useConnectivityStore((s) => s.setCloudSync);
const useCloud = async () => {
setBusy(true);
try { if (await pullBackup()) { setCloudSync('saved'); setTimeout(() => location.reload(), 400); } }
finally { setBusy(false); setOpen(false); }
};
const keepMine = async () => {
setBusy(true);
try { await pushBackup({ force: true }); setCloudSync('saved'); }
finally { setBusy(false); setOpen(false); }
};
return (
<div className="relative">
<button onClick={() => setOpen((o) => !o)} className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-warning hover:bg-elevated" title="The cloud copy changed on another device — click to resolve.">
<AlertTriangle size={14} aria-hidden /> <span className="hidden sm:inline">Sync conflict</span>
</button>
{open && (
<div className="absolute right-0 top-9 z-50 w-64 rounded-xl border border-line bg-panel p-3 text-sm shadow-xl">
<p className="mb-2 text-muted">The cloud copy was changed on another device since this one last synced.</p>
<div className="flex flex-col gap-1.5">
<Button size="sm" variant="secondary" disabled={busy} onClick={() => void useCloud()}>Use cloud version (reload)</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => void keepMine()}>Keep this device (overwrite cloud)</Button>
</div>
</div>
)}
</div>
);
}
+1 -1
View File
@@ -35,7 +35,7 @@ export function useCloudAutosave(): void {
const conn = useConnectivityStore.getState(); const conn = useConnectivityStore.getState();
conn.setCloudSync('saving'); conn.setCloudSync('saving');
void pushBackup() void pushBackup()
.then(() => useConnectivityStore.getState().setCloudSync('saved')) .then((r) => useConnectivityStore.getState().setCloudSync(r.ok ? 'saved' : 'conflict'))
.catch(() => useConnectivityStore.getState().setCloudSync('error')); // offline / transient — next change retries .catch(() => useConnectivityStore.getState().setCloudSync('error')); // offline / transient — next change retries
}, 8000); }, 8000);
+14 -2
View File
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
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, getUsage, adminListUsers, adminSetQuota, type CloudCampaignInfo, type CloudCharInfo, type AdminUserRow } from '@/lib/cloud/campaigns'; import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, rotateInvite, removeCloudMember, getUsage, adminListUsers, adminSetQuota, type CloudCampaignInfo, type CloudCharInfo, type AdminUserRow } 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';
@@ -60,12 +60,24 @@ export function CloudCampaigns() {
{c.inviteCode && ( {c.inviteCode && (
<button className="font-mono text-xs text-accent" title="Copy invite code" onClick={() => void navigator.clipboard?.writeText(c.inviteCode!).then(() => setMsg(`Invite code ${c.inviteCode} copied.`))}>invite: {c.inviteCode}</button> <button className="font-mono text-xs text-accent" title="Copy invite code" onClick={() => void navigator.clipboard?.writeText(c.inviteCode!).then(() => setMsg(`Invite code ${c.inviteCode} copied.`))}>invite: {c.inviteCode}</button>
)} )}
{c.role === 'owner' && c.inviteCode && (
<Button size="sm" variant="ghost" title="Invalidate the old code and mint a new one" onClick={run(async () => { const r = await rotateInvite(c.id); setMsg(`New invite code: ${r.inviteCode}`); refresh(); })}>Rotate invite</Button>
)}
{c.role === 'owner' && <Button size="sm" variant="ghost" onClick={run(async () => { setViewing(c.id); setParty(await listCloudCharacters(c.id)); })}>View party</Button>} {c.role === 'owner' && <Button size="sm" variant="ghost" onClick={run(async () => { setViewing(c.id); setParty(await listCloudCharacters(c.id)); })}>View party</Button>}
</div> </div>
{viewing === c.id && ( {viewing === c.id && (
<ul className="mt-1 space-y-0.5 border-t border-line pt-1 text-xs"> <ul className="mt-1 space-y-0.5 border-t border-line pt-1 text-xs">
{party.length === 0 ? <li className="text-muted">No characters published yet.</li> : party.map((p) => ( {party.length === 0 ? <li className="text-muted">No characters published yet.</li> : party.map((p) => (
<li key={p.id} className="flex justify-between"><span className="text-ink">{p.name}</span><span className="text-muted">{p.mine ? 'yours' : 'a players'}</span></li> <li key={p.id} className="flex items-center justify-between gap-2">
<span className="text-ink">{p.name}</span>
<span className="flex items-center gap-2">
<span className="text-muted">{p.mine ? 'yours' : 'a players'}</span>
{!p.mine && (
<button className="text-danger hover:underline" title="Remove this player and their characters from the campaign"
onClick={run(async () => { await removeCloudMember(c.id, p.ownerUserId); setMsg('Player removed.'); setParty(await listCloudCharacters(c.id)); })}>remove</button>
)}
</span>
</li>
))} ))}
</ul> </ul>
)} )}
+8 -1
View File
@@ -196,7 +196,14 @@ function CloudSync() {
const r = await (which === 'in' ? cloudLogin(u, p) : cloudRegister(u, p)); const r = await (which === 'in' ? cloudLogin(u, p) : cloudRegister(u, p));
setUser(r.username); setP(''); setMsg(which === 'up' ? 'Account created.' : 'Signed in.'); setUser(r.username); setP(''); setMsg(which === 'up' ? 'Account created.' : 'Signed in.');
}); });
const push = () => run(async () => { const n = await pushBackup(); setMsg(`Backed up to cloud (${Math.round(n / 1024)} KB).`); }); const push = () => run(async () => {
const r = await pushBackup();
if (r.ok) { setMsg(`Backed up to cloud (${Math.round(r.bytes / 1024)} KB).`); return; }
// Conflict: the cloud changed on another device since this one last synced.
const useCloud = window.confirm('The cloud copy was changed on another device since you last synced.\n\nOK — replace THIS device with the cloud copy.\nCancel — overwrite the CLOUD with this device.');
if (useCloud) { const ok = await pullBackup(); setMsg(ok ? 'Pulled the cloud copy — reloading…' : 'No cloud backup.'); if (ok) setTimeout(() => location.reload(), 600); }
else { await pushBackup({ force: true }); setMsg('Overwrote the cloud with this device.'); }
});
const pull = () => run(async () => { const ok = await pullBackup(); setMsg(ok ? 'Restored — reloading…' : 'No cloud backup yet.'); if (ok) setTimeout(() => location.reload(), 600); }); const pull = () => run(async () => { const ok = await pullBackup(); setMsg(ok ? 'Restored — reloading…' : 'No cloud backup yet.'); if (ok) setTimeout(() => location.reload(), 600); });
const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); }); const signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
+8
View File
@@ -40,6 +40,14 @@ export function publishCharacter(campaignId: string, character: { id: string; na
export function listCloudCharacters(campaignId: string): Promise<CloudCharInfo[]> { export function listCloudCharacters(campaignId: string): Promise<CloudCharInfo[]> {
return req<CloudCharInfo[]>(`/campaigns/${campaignId}/characters`); return req<CloudCharInfo[]>(`/campaigns/${campaignId}/characters`);
} }
/** Owner-only: invalidate the old invite code and get a fresh one. */
export function rotateInvite(campaignId: string): Promise<{ inviteCode: string }> {
return req<{ inviteCode: string }>(`/campaigns/${campaignId}/invite`, { method: 'POST' });
}
/** Owner-only: remove a player and their published characters from the campaign. */
export function removeCloudMember(campaignId: string, userId: string): Promise<{ ok: boolean }> {
return req<{ ok: boolean }>(`/campaigns/${campaignId}/members/${userId}`, { method: 'DELETE' });
}
export async function cloudUsageBytes(): Promise<number> { export async function cloudUsageBytes(): Promise<number> {
return (await req<{ bytes: number }>('/usage')).bytes; return (await req<{ bytes: number }>('/usage')).bytes;
} }
+23 -5
View File
@@ -8,12 +8,17 @@ import { buildBackup, restoreBackup } from '@/lib/io/backup';
const TOKEN_KEY = 'ttrpg-cloud-token'; const TOKEN_KEY = 'ttrpg-cloud-token';
const USER_KEY = 'ttrpg-cloud-user'; const USER_KEY = 'ttrpg-cloud-user';
const SYNCED_KEY = 'ttrpg-cloud-synced-at';
const base = () => `${location.origin}/api`; const base = () => `${location.origin}/api`;
export function cloudUsername(): string | null { return localStorage.getItem(USER_KEY); } export function cloudUsername(): string | null { return localStorage.getItem(USER_KEY); }
function token(): string | null { return localStorage.getItem(TOKEN_KEY); } function token(): string | null { return localStorage.getItem(TOKEN_KEY); }
function setSession(t: string, username: string): void { localStorage.setItem(TOKEN_KEY, t); localStorage.setItem(USER_KEY, username); } function setSession(t: string, username: string): void { localStorage.setItem(TOKEN_KEY, t); localStorage.setItem(USER_KEY, username); }
function clearSession(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); } function clearSession(): void { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); localStorage.removeItem(SYNCED_KEY); }
/** Version token of the cloud copy this device last synced with (for conflict detection). */
export function cloudLastSyncedAt(): number | null { const v = localStorage.getItem(SYNCED_KEY); return v ? Number(v) : null; }
function setLastSyncedAt(n: number): void { localStorage.setItem(SYNCED_KEY, String(n)); }
export class CloudError extends Error {} export class CloudError extends Error {}
@@ -39,15 +44,26 @@ export async function logout(): Promise<void> {
clearSession(); clearSession();
} }
/** Upload this device's full backup to the cloud. */ export type PushResult = { ok: true; bytes: number } | { ok: false; conflict: true; savedAt: number };
export async function pushBackup(): Promise<number> {
/**
* Upload this device's full backup. Uses optimistic concurrency: unless `force`,
* it tells the server which version we last synced; if the cloud has moved on
* (another device pushed), the server returns a conflict and we DON'T overwrite —
* the caller resolves it (pull the cloud, or force-push to win).
*/
export async function pushBackup(opts?: { force?: boolean }): Promise<PushResult> {
const t = token(); const t = token();
if (!t) throw new CloudError('Not signed in.'); if (!t) throw new CloudError('Not signed in.');
const blob = JSON.stringify(await buildBackup()); const blob = JSON.stringify(await buildBackup());
const res = await fetch(`${base()}/save`, { method: 'PUT', headers: { 'content-type': 'application/json', authorization: `Bearer ${t}` }, body: JSON.stringify({ blob }) }); const baseSavedAt = opts?.force ? undefined : cloudLastSyncedAt();
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.ok) throw new CloudError('Upload failed.'); if (!res.ok) throw new CloudError('Upload failed.');
return blob.length; const d = (await res.json().catch(() => ({}))) as { savedAt?: number };
if (typeof d.savedAt === 'number') setLastSyncedAt(d.savedAt);
return { ok: true, bytes: blob.length };
} }
/** Replace this device's data with the cloud copy. Returns false if there's no cloud save yet. */ /** Replace this device's data with the cloud copy. Returns false if there's no cloud save yet. */
@@ -58,6 +74,8 @@ export async function pullBackup(): Promise<boolean> {
if (res.status === 404) return false; if (res.status === 404) return false;
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.ok) throw new CloudError('Download failed.'); if (!res.ok) throw new CloudError('Download failed.');
const savedAt = Number(res.headers.get('x-saved-at'));
await restoreBackup(await res.text()); await restoreBackup(await res.text());
if (Number.isFinite(savedAt) && savedAt > 0) setLastSyncedAt(savedAt);
return true; return true;
} }
+8
View File
@@ -143,6 +143,14 @@ export class TtrpgDatabase extends Dexie {
} }
}); });
}); });
// v15 — V2 Phase 7: characters gain a structured `feats` array (was free text
// in notes). Defaulted, so backfill is belt-and-suspenders.
this.version(15).stores({}).upgrade(async (tx) => {
await tx.table('characters').toCollection().modify((c: Record<string, unknown>) => {
if (c.feats === undefined) c.feats = [];
});
});
} }
} }
+17
View File
@@ -62,6 +62,23 @@ export function dnd5eSlots(caster: ClassDef['caster'], level: number): { level:
return table.map((max, i) => ({ level: i + 1, max, current: max })); return table.map((max, i) => ({ level: i + 1, max, current: max }));
} }
/**
* PHB multiclass spellcaster level: full-caster levels count fully, half-casters
* halve (floored), third-casters third (floored). Warlock pact magic is separate
* and excluded. The combined level reads the standard full-caster slot table —
* so e.g. Paladin 6 / Sorcerer 1 = level (3 + 1) = 4 → 4/3 first-/second slots.
*/
export function multiclassCasterLevel(parts: { full?: number; half?: number; third?: number }): number {
return (parts.full ?? 0) + Math.floor((parts.half ?? 0) / 2) + Math.floor((parts.third ?? 0) / 3);
}
export function multiclassSlots(parts: { full?: number; half?: number; third?: number }): { level: number; max: number; current: number }[] {
const lvl = multiclassCasterLevel(parts);
if (lvl < 1) return []; // a single half-caster level (e.g. Paladin 1) grants no slots
const table = FULL[Math.min(20, lvl)];
if (!table) return [];
return table.map((max, i) => ({ level: i + 1, max, current: max }));
}
export function dnd5ePact(level: number): { level: number; max: number; current: number } | undefined { export function dnd5ePact(level: number): { level: number; max: number; current: number } | undefined {
const p = PACT[clamp(level)]; const p = PACT[clamp(level)];
return p ? { level: p[1], max: p[0], current: p[0] } : undefined; return p ? { level: p[1], max: p[0], current: p[0] } : undefined;
+29
View File
@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { multiclassCasterLevel, multiclassSlots } from './dnd5e/progression';
describe('multiclass spell slots (5e PHB)', () => {
it('combines caster levels: full ×1, half ÷2, third ÷3 (floored)', () => {
expect(multiclassCasterLevel({ full: 5 })).toBe(5);
expect(multiclassCasterLevel({ full: 1, half: 6 })).toBe(1 + 3); // Sorc1/Pal6 = 4
expect(multiclassCasterLevel({ half: 5, third: 4 })).toBe(2 + 1); // Pal5/EK4 = 3
});
it('reads the full-caster table at the combined level', () => {
// caster level 4 → [4,3] (four 1st, three 2nd)
expect(multiclassSlots({ full: 1, half: 6 })).toEqual([
{ level: 1, max: 4, current: 4 },
{ level: 2, max: 3, current: 3 },
]);
});
it('grants no slots at combined caster level 0 (a single half-caster level)', () => {
expect(multiclassSlots({ half: 1 })).toEqual([]); // Paladin 1 alone — no slots
expect(multiclassSlots({ third: 2 })).toEqual([]); // EK 2 — no slots yet
expect(multiclassSlots({})).toEqual([]);
});
it('caps at character level 20', () => {
const s = multiclassSlots({ full: 25 });
expect(s.length).toBe(9); // 9 spell levels at the cap
});
});
+13 -1
View File
@@ -87,6 +87,16 @@ export const spellSlotSchema = z.object({
current: int.min(0).default(0), current: int.min(0).default(0),
}); });
/** A feat (5e) or class/ancestry feat (pf2e), tracked with its effect text. */
export const featSchema = z.object({
id: z.string(),
name: z.string().min(1).max(120),
source: z.string().max(120).default(''),
description: z.string().max(4000).default(''),
compendiumRef: z.string().optional(),
});
export type Feat = z.infer<typeof featSchema>;
export const spellcastingSchema = z.object({ export const spellcastingSchema = z.object({
slots: z.array(spellSlotSchema).default([]), slots: z.array(spellSlotSchema).default([]),
/** warlock-style pact magic, refreshes on short rest */ /** warlock-style pact magic, refreshes on short rest */
@@ -154,6 +164,7 @@ export const characterSchema = z.object({
// --- Phase 1: character depth --- // --- Phase 1: character depth ---
currency: currencySchema.default({ cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 }), currency: currencySchema.default({ cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 }),
inventory: z.array(inventoryItemSchema).default([]), inventory: z.array(inventoryItemSchema).default([]),
feats: z.array(featSchema).default([]),
attacks: z.array(attackSchema).default([]), attacks: z.array(attackSchema).default([]),
resources: z.array(resourceSchema).default([]), resources: z.array(resourceSchema).default([]),
spellcasting: spellcastingSchema.default({ slots: [], spells: [] }), spellcasting: spellcastingSchema.default({ slots: [], spells: [] }),
@@ -193,11 +204,12 @@ 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' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions' 'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions'
> { > {
return { return {
currency: { cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 }, currency: { cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 },
inventory: [], inventory: [],
feats: [],
attacks: [], attacks: [],
resources: [], resources: [],
spellcasting: { slots: [], spells: [] }, spellcasting: { slots: [], spells: [] },
+1 -1
View File
@@ -1,6 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
export type CloudSync = 'idle' | 'saving' | 'saved' | 'error'; export type CloudSync = 'idle' | 'saving' | 'saved' | 'error' | 'conflict';
interface ConnectivityState { interface ConnectivityState {
/** browser online/offline (navigator.onLine + events) */ /** browser online/offline (navigator.onLine + events) */
File diff suppressed because one or more lines are too long