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:
@@ -40,6 +40,14 @@ export function publishCharacter(campaignId: string, character: { id: string; na
|
||||
export function listCloudCharacters(campaignId: string): Promise<CloudCharInfo[]> {
|
||||
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> {
|
||||
return (await req<{ bytes: number }>('/usage')).bytes;
|
||||
}
|
||||
|
||||
+23
-5
@@ -8,12 +8,17 @@ import { buildBackup, restoreBackup } from '@/lib/io/backup';
|
||||
|
||||
const TOKEN_KEY = 'ttrpg-cloud-token';
|
||||
const USER_KEY = 'ttrpg-cloud-user';
|
||||
const SYNCED_KEY = 'ttrpg-cloud-synced-at';
|
||||
const base = () => `${location.origin}/api`;
|
||||
|
||||
export function cloudUsername(): string | null { return localStorage.getItem(USER_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 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 {}
|
||||
|
||||
@@ -39,15 +44,26 @@ export async function logout(): Promise<void> {
|
||||
clearSession();
|
||||
}
|
||||
|
||||
/** Upload this device's full backup to the cloud. */
|
||||
export async function pushBackup(): Promise<number> {
|
||||
export type PushResult = { ok: true; bytes: number } | { ok: false; conflict: true; savedAt: 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();
|
||||
if (!t) throw new CloudError('Not signed in.');
|
||||
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 === 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.');
|
||||
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. */
|
||||
@@ -58,6 +74,8 @@ export async function pullBackup(): Promise<boolean> {
|
||||
if (res.status === 404) return false;
|
||||
if (res.status === 401) { clearSession(); throw new CloudError('Session expired — sign in again.'); }
|
||||
if (!res.ok) throw new CloudError('Download failed.');
|
||||
const savedAt = Number(res.headers.get('x-saved-at'));
|
||||
await restoreBackup(await res.text());
|
||||
if (Number.isFinite(savedAt) && savedAt > 0) setLastSyncedAt(savedAt);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -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 = [];
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,23 @@ export function dnd5eSlots(caster: ClassDef['caster'], level: number): { level:
|
||||
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 {
|
||||
const p = PACT[clamp(level)];
|
||||
return p ? { level: p[1], max: p[0], current: p[0] } : undefined;
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -87,6 +87,16 @@ export const spellSlotSchema = z.object({
|
||||
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({
|
||||
slots: z.array(spellSlotSchema).default([]),
|
||||
/** warlock-style pact magic, refreshes on short rest */
|
||||
@@ -154,6 +164,7 @@ export const characterSchema = z.object({
|
||||
// --- Phase 1: character depth ---
|
||||
currency: currencySchema.default({ cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 }),
|
||||
inventory: z.array(inventoryItemSchema).default([]),
|
||||
feats: z.array(featSchema).default([]),
|
||||
attacks: z.array(attackSchema).default([]),
|
||||
resources: z.array(resourceSchema).default([]),
|
||||
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. */
|
||||
export function characterDefaults(): Pick<
|
||||
Character,
|
||||
'currency' | 'inventory' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions'
|
||||
'currency' | 'inventory' | 'feats' | 'attacks' | 'resources' | 'spellcasting' | 'concentration' | 'equippedArmor' | 'defenses' | 'conditions'
|
||||
> {
|
||||
return {
|
||||
currency: { cp: 0, sp: 0, ep: 0, gp: 0, pp: 0 },
|
||||
inventory: [],
|
||||
feats: [],
|
||||
attacks: [],
|
||||
resources: [],
|
||||
spellcasting: { slots: [], spells: [] },
|
||||
|
||||
Reference in New Issue
Block a user