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:
@@ -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 */}
|
||||
<ResourcesSection c={c} update={update} />
|
||||
|
||||
{/* Feats & features */}
|
||||
<FeatsSection c={c} update={update} />
|
||||
|
||||
{/* Inventory, currency, encumbrance */}
|
||||
<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 SpellEntry, newSpellEntry } from '@/lib/schemas';
|
||||
import { castSpell, dropConcentration } from '@/lib/mechanics';
|
||||
import { multiclassSlots } from '@/lib/rules/dnd5e/progression';
|
||||
import { formatModifier } from '@/lib/format';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
@@ -124,6 +125,7 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{c.system === '5e' && <MulticlassSlots onApply={setSlots} />}
|
||||
</div>
|
||||
|
||||
{/* Spell list */}
|
||||
@@ -171,3 +173,23 @@ export function SpellcastingSection({ c, update }: SectionProps) {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Cloud, CloudOff, RefreshCw, WifiOff } from 'lucide-react';
|
||||
import { cloudUsername } from '@/lib/cloud/client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Cloud, CloudOff, RefreshCw, WifiOff, AlertTriangle } from 'lucide-react';
|
||||
import { cloudUsername, pushBackup, pullBackup } from '@/lib/cloud/client';
|
||||
import { useConnectivityStore } from '@/stores/connectivityStore';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/**
|
||||
@@ -35,8 +36,11 @@ export function SyncStatusIndicator() {
|
||||
|
||||
if (!signedIn) return null;
|
||||
|
||||
if (cloudSync === 'conflict') return <ConflictResolver />;
|
||||
|
||||
const map = {
|
||||
idle: null,
|
||||
conflict: null, // handled above
|
||||
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.' },
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function useCloudAutosave(): void {
|
||||
const conn = useConnectivityStore.getState();
|
||||
conn.setCloudSync('saving');
|
||||
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
|
||||
}, 8000);
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useLiveQuery } from 'dexie-react-hooks';
|
||||
import { db } from '@/lib/db/db';
|
||||
import { cloudUsername, CloudError } from '@/lib/cloud/client';
|
||||
import { listCloudCampaigns, createCloudCampaign, joinCloudCampaign, publishCharacter, listCloudCharacters, 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 { Button } from '@/components/ui/Button';
|
||||
import { Input, Select } from '@/components/ui/Input';
|
||||
@@ -60,12 +60,24 @@ export function CloudCampaigns() {
|
||||
{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>
|
||||
)}
|
||||
{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>}
|
||||
</div>
|
||||
{viewing === c.id && (
|
||||
<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) => (
|
||||
<li key={p.id} className="flex justify-between"><span className="text-ink">{p.name}</span><span className="text-muted">{p.mine ? 'yours' : 'a player’s'}</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 player’s'}</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>
|
||||
)}
|
||||
|
||||
@@ -196,7 +196,14 @@ function CloudSync() {
|
||||
const r = await (which === 'in' ? cloudLogin(u, p) : cloudRegister(u, p));
|
||||
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 signOut = () => run(async () => { await cloudLogout(); setUser(null); setMsg(null); });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user