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
@@ -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>
);
}