diff --git a/README.md b/README.md index deca31d..016fdc5 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ Lorewarden comes with an [MCP](https://modelcontextprotocol.io) server, so an AI - **At the table:** the prompt "What now?" knows the scene you are playing, the session log and the running combat, and suggests three ways to react. - **Monsters:** the assistant picks SRD monsters and your own monsters that fit your party, and writes a full stat block for a creature it invents, so you have every stat block at the table. Ask it to save a monster to your monsters, and every adventure can use it. -The assistant uses the same Lorewarden server as the app, so everything it changes appears in the app right away. Your adventures stay on your computer; the assistant only sends its provider what it reads while it works with you. +The assistant uses the same Lorewarden server as the app, so everything it changes appears in the app right away. You can keep editing while it works: changes on both sides are kept, and if you both changed the same field, the app keeps the assistant's version and tells you to check your last edit. Your adventures stay on your computer; the assistant only sends its provider what it reads while it works with you. If you host Lorewarden on a server, you can skip the setup below and add it in Claude as a custom connector instead, see [AI assistants](docs/self-hosting.md#ai-assistants). diff --git a/apps/dm/src/composables/useAdventure.test.ts b/apps/dm/src/composables/useAdventure.test.ts new file mode 100644 index 0000000..70ef72e --- /dev/null +++ b/apps/dm/src/composables/useAdventure.test.ts @@ -0,0 +1,128 @@ +import { type Adventure, createAdventure } from '@lorewarden/core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick, ref } from 'vue'; + +/** A stand-in for the Lorewarden server: one adventure with a version counter as its ETag. */ +const server = vi.hoisted(() => ({ + adventure: undefined as unknown, + version: 1, + saves: [] as (string | undefined)[], + notify: (_change: { id: string; deleted: boolean }) => {}, +})); + +vi.mock('../lib/server', () => ({ + onAdventureChange: (listener: typeof server.notify) => { + server.notify = listener; + return () => {}; + }, + onConnected: () => () => {}, +})); + +vi.mock('../storage/database', () => { + class ChangedMeanwhile extends Error { + constructor(readonly current: { value: T; etag: string | undefined } | undefined) { + super('Changed meanwhile'); + } + } + const copy = () => JSON.parse(JSON.stringify(server.adventure)) as Adventure; + return { + ChangedMeanwhile, + loadAdventure: async () => ({ adventure: copy(), etag: String(server.version) }), + saveAdventure: async (adventure: Adventure, ifMatch?: string) => { + server.saves.push(ifMatch); + if (ifMatch !== undefined && ifMatch !== String(server.version)) { + throw new ChangedMeanwhile({ value: copy(), etag: String(server.version) }); + } + server.adventure = JSON.parse(JSON.stringify(adventure)); + server.version++; + return String(server.version); + }, + }; +}); + +const { useAdventure } = await import('./useAdventure'); + +/** Another program saves a change, and the app hears of it. */ +function changeElsewhere(change: (adventure: Adventure) => void) { + const adventure = server.adventure as Adventure; + change(adventure); + server.version++; + server.notify({ id: adventure.id, deleted: false }); +} + +async function settle() { + for (let i = 0; i < 5; i++) { + await vi.runAllTimersAsync(); + await nextTick(); + } +} + +describe('useAdventure', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('window', { addEventListener: () => {}, removeEventListener: () => {} }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + server.adventure = createAdventure({ title: 'The Mill', partySize: 4, partyLevel: 3 }); + server.version = 1; + server.saves = []; + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + async function open() { + const result = useAdventure(ref((server.adventure as Adventure).id)); + await settle(); + return result; + } + + it('keeps an edit typed just before another program saved', async () => { + const { adventure, conflict } = await open(); + if (!adventure.value) throw new Error('Not loaded'); + adventure.value.title = 'The Silent Mill'; + changeElsewhere((a) => { + a.summary = 'Written by the AI.'; + }); + await settle(); + + expect(adventure.value).toMatchObject({ + title: 'The Silent Mill', + summary: 'Written by the AI.', + }); + expect(server.adventure).toMatchObject({ + title: 'The Silent Mill', + summary: 'Written by the AI.', + }); + expect(conflict.value).toBe(false); + // The first save ran into the newer version, the combined one was saved on top of it. + expect(server.saves).toEqual(['1', '2']); + }); + + it('keeps the other change and says so when both changed the same field', async () => { + const { adventure, conflict } = await open(); + if (!adventure.value) throw new Error('Not loaded'); + adventure.value.title = 'Mine'; + changeElsewhere((a) => { + a.title = 'Theirs'; + }); + await settle(); + + expect(adventure.value.title).toBe('Theirs'); + expect((server.adventure as Adventure).title).toBe('Theirs'); + expect(conflict.value).toBe(true); + }); + + it('loads a change made elsewhere when nothing is edited here', async () => { + const { adventure, conflict } = await open(); + changeElsewhere((a) => { + a.summary = 'Written by the AI.'; + }); + await settle(); + expect(adventure.value?.summary).toBe('Written by the AI.'); + expect(server.saves).toEqual([]); + expect(conflict.value).toBe(false); + }); +}); diff --git a/apps/dm/src/composables/useAdventure.ts b/apps/dm/src/composables/useAdventure.ts index 4fbf21f..a283eab 100644 --- a/apps/dm/src/composables/useAdventure.ts +++ b/apps/dm/src/composables/useAdventure.ts @@ -1,28 +1,97 @@ -import type { Adventure } from '@lorewarden/core'; +import { Adventure } from '@lorewarden/core'; import { computed, onBeforeUnmount, type Ref, ref, watch } from 'vue'; +import { mergeChanges } from '../lib/merge'; import { onAdventureChange, onConnected } from '../lib/server'; -import { getAdventure, saveAdventure } from '../storage/database'; +import { ChangedMeanwhile, loadAdventure, saveAdventure } from '../storage/database'; import { useAutosave } from './useAutosave'; /** * Loads an adventure from the Lorewarden server and saves every change back. When an AI assistant * or another tab changes it, the new version is loaded. + * + * Every save names the version it is based on. When another one was saved meanwhile, or a + * notice says so, the edits made here that are not saved yet are combined with it (see + * mergeChanges) and saved on top, instead of one side overwriting the other. Where both changed + * the same field, the other change wins and conflict tells the game master to check their edit. */ export function useAdventure(id: Ref) { const adventure = ref(); const loading = ref(true); + /** An edit made here clashed with a change saved elsewhere, which was kept instead. */ + const conflict = ref(false); + /** The version on the server the edits here are based on, as JSON, and its ETag. */ + let base: string | undefined; + let etag: string | undefined; + const { failed: saveFailed, pending: savePending, markSaved, flush, - } = useAutosave(adventure, saveAdventure); + serially, + } = useAutosave(adventure, save); + + async function save(value: Adventure) { + const sent = JSON.stringify(value); + try { + etag = await saveAdventure(value, etag); + base = sent; + } catch (error) { + if (!(error instanceof ChangedMeanwhile)) throw error; + takeNewer((error as ChangedMeanwhile).current); + } + } + + /** Shows a newer version saved elsewhere, with the edits made here that it does not have. */ + function takeNewer(current: { value: Adventure; etag: string | undefined } | undefined) { + if (!current) { + // Deleted elsewhere. + base = undefined; + etag = undefined; + markSaved(undefined); + adventure.value = undefined; + return; + } + const local = adventure.value; + let next = current.value; + if (local && base !== undefined && JSON.stringify(local) !== base) { + const merged = mergeChanges( + JSON.parse(base), + JSON.parse(JSON.stringify(local)), + next, + ); + const parsed = Adventure.safeParse(merged.value); + if (parsed.success) next = parsed.data; + if (merged.conflict || !parsed.success) conflict.value = true; + } + base = JSON.stringify(current.value); + etag = current.etag; + // The newer version is what is saved; the combined edits are saved on top of it. + markSaved(current.value); + adventure.value = next; + } async function load(value: string) { try { - const loaded = await getAdventure(value); - markSaved(loaded); - adventure.value = loaded; + const loaded = await loadAdventure(value); + base = loaded && JSON.stringify(loaded.adventure); + etag = loaded?.etag; + markSaved(loaded?.adventure); + adventure.value = loaded?.adventure; + } catch { + // The server is not running; the banner says so. + } + } + + /** Loads the version saved elsewhere, if it is newer than the one the edits are based on. */ + async function refresh(value: string) { + if (value !== id.value) return; + try { + const loaded = await loadAdventure(value); + if (value !== id.value || (loaded && loaded.etag !== undefined && loaded.etag === etag)) { + return; + } + takeNewer(loaded && { value: loaded.adventure, etag: loaded.etag }); } catch { // The server is not running; the banner says so. } @@ -33,7 +102,8 @@ export function useAdventure(id: Ref) { async (value) => { await flush(); loading.value = true; - await load(value); + conflict.value = false; + await serially(() => load(value)); loading.value = false; }, { immediate: true }, @@ -41,12 +111,17 @@ export function useAdventure(id: Ref) { const stop = onAdventureChange((change) => { if (change.id !== id.value) return; - if (change.deleted) adventure.value = undefined; - else void load(change.id); + if (change.deleted) { + adventure.value = undefined; + return; + } + // Edits typed just now are saved first; if they clash, they are combined with the newer + // version right there, and loading it again finds nothing new. + void flush().then(() => serially(() => refresh(change.id))); }); // Opened while the server was not running: load once it is back. const stopConnected = onConnected(() => { - if (!adventure.value) void load(id.value); + if (!adventure.value) void serially(() => load(id.value)); }); onBeforeUnmount(() => { stop(); @@ -57,5 +132,14 @@ export function useAdventure(id: Ref) { const npcsById = computed(() => new Map(adventure.value?.npcs.map((n) => [n.id, n]))); const itemsById = computed(() => new Map(adventure.value?.items.map((i) => [i.id, i]))); - return { adventure, loading, saveFailed, savePending, scenesById, npcsById, itemsById }; + return { + adventure, + loading, + saveFailed, + savePending, + conflict, + scenesById, + npcsById, + itemsById, + }; } diff --git a/apps/dm/src/composables/useAutosave.ts b/apps/dm/src/composables/useAutosave.ts index 61aac5b..982416d 100644 --- a/apps/dm/src/composables/useAutosave.ts +++ b/apps/dm/src/composables/useAutosave.ts @@ -12,6 +12,8 @@ export function useAutosave(source: Ref, save: (value: T) => P const pending = ref(false); let timer: ReturnType | undefined; let lastSaved = ''; + /** Counts the calls of markSaved, so a save does not undo one made while it ran. */ + let marks = 0; /** The save in progress; the next one waits for it so an older value never lands last. */ let saving: Promise = Promise.resolve(); @@ -20,9 +22,12 @@ export function useAutosave(source: Ref, save: (value: T) => P pending.value = timer !== undefined; return; } + const before = marks; try { await save(value); - lastSaved = json; + // When the save marked another value as saved, e.g. the newer version it ran into, that + // one stays the one to compare with. + if (marks === before) lastSaved = json; failed.value = false; } catch { failed.value = true; @@ -30,6 +35,17 @@ export function useAutosave(source: Ref, save: (value: T) => P pending.value = timer !== undefined; }; + const unsaved = () => JSON.stringify(source.value) !== lastSaved; + + /** + * Runs a task after the save in progress, if any, and before the next one, e.g. loading a newer + * version, so it never mixes with a save. + */ + const serially = (task: () => Promise): Promise => { + saving = saving.then(task).catch(() => undefined); + return saving; + }; + /** * Saves the value as it is now, after the save in progress, if any, has finished. Saves run one * after another, so a timer and a closing view never race and the newest value lands last. @@ -40,21 +56,23 @@ export function useAutosave(source: Ref, save: (value: T) => P const value = source.value; if (value === undefined) return saving; const json = JSON.stringify(value); - saving = saving.then(() => saveNow(value, json)); - return saving; + return serially(() => saveNow(value, json)); }; /** Call after loading a value from storage so it is not written back unchanged. */ const markSaved = (value: T | undefined) => { + marks++; lastSaved = value === undefined ? '' : JSON.stringify(value); }; + /** Whether the value has changes that are not saved yet. */ + const hasChanges = () => source.value !== undefined && unsaved(); + watch( source, () => { clearTimeout(timer); - if (source.value !== undefined && JSON.stringify(source.value) !== lastSaved) - pending.value = true; + if (source.value !== undefined && unsaved()) pending.value = true; timer = setTimeout(flush, DELAY_MS); }, { deep: true }, @@ -67,5 +85,5 @@ export function useAutosave(source: Ref, save: (value: T) => P void flush(); }); - return { failed, pending, markSaved, flush }; + return { failed, pending, markSaved, flush, serially, hasChanges }; } diff --git a/apps/dm/src/composables/useParties.ts b/apps/dm/src/composables/useParties.ts index c601632..f0cbb5e 100644 --- a/apps/dm/src/composables/useParties.ts +++ b/apps/dm/src/composables/useParties.ts @@ -1,21 +1,32 @@ -import type { Party } from '@lorewarden/core'; +import { type Party, takeSignUps } from '@lorewarden/core'; import { shallowRef } from 'vue'; import { onConnected, onPartiesChange } from '../lib/server'; -import { deleteParty, listParties, saveParty } from '../storage/database'; +import { ChangedMeanwhile, deleteParty, loadParties, saveParty } from '../storage/database'; + +/** How often a save is tried again on a newer version of the parties. */ +const SAVE_ATTEMPTS = 3; const parties = shallowRef([]); const loaded = shallowRef(false); +/** The version of the parties shown, which saves name so they cannot overwrite a newer one. */ +let etag: string | undefined; let started = false; async function refresh() { try { - parties.value = await listParties(); + const result = await loadParties(); + parties.value = result.parties; + etag = result.etag; loaded.value = true; } catch { // The server is not running; the parties load once it is back. } } +function sorted(list: Party[]): Party[] { + return [...list].sort((a, b) => a.name.localeCompare(b.name)); +} + /** * The game master's parties, kept by the Lorewarden server next to the own monsters and shared by * every view. Changes another tab makes appear on their own. @@ -28,12 +39,31 @@ export function useParties() { onConnected(() => void refresh()); } + /** + * Saves a party. When the parties changed since they were loaded, e.g. because a player entered + * a character on their phone, what the phones entered is taken into the party and it is saved + * again, so neither the game master's edit nor the player's character is lost. + */ async function save(party: Party) { - await saveParty(party); - const others = parties.value.filter((p) => p.id !== party.id); - parties.value = [...others, JSON.parse(JSON.stringify(party)) as Party].sort((a, b) => - a.name.localeCompare(b.name), - ); + let next = JSON.parse(JSON.stringify(party)) as Party; + for (let attempt = 1; ; attempt++) { + try { + const sent = etag; + const saved = await saveParty(next, sent); + // Without a version to compare, the parties may hold changes not seen here yet. + if (sent !== undefined) etag = saved; + break; + } catch (error) { + if (!(error instanceof ChangedMeanwhile) || attempt >= SAVE_ATTEMPTS) throw error; + const current = (error as ChangedMeanwhile).current; + if (!current) throw error; + parties.value = sorted(current.value); + etag = current.etag; + const stored = current.value.find((p) => p.id === next.id); + if (stored) next = takeSignUps(next, stored); + } + } + parties.value = sorted([...parties.value.filter((p) => p.id !== next.id), next]); } async function remove(id: string) { diff --git a/apps/dm/src/lib/lib.test.ts b/apps/dm/src/lib/lib.test.ts index b506c79..6de9efa 100644 --- a/apps/dm/src/lib/lib.test.ts +++ b/apps/dm/src/lib/lib.test.ts @@ -4,6 +4,7 @@ import { extractJson } from './extract-json'; import { fillIfBlank } from './fallback'; import { fitWithin, titleFromFileName } from './image'; import { languageOptions } from './languages'; +import { mergeChanges } from './merge'; import { clampInteger, parseInteger } from './numbers'; import { difficultyLabel } from './review'; @@ -87,3 +88,57 @@ describe('difficultyLabel', () => { expect(difficultyLabel(t, 'deadly boss')).toBe('deadly boss'); }); }); + +describe('mergeChanges', () => { + const yard = { id: 'yard', title: 'Yard', tags: ['outside'] }; + const cellar = { id: 'cellar', title: 'Cellar', tags: [] as string[] }; + const base = { + title: 'The Mill', + summary: '', + scenes: [yard, cellar], + npcs: [{ id: 'miller', name: 'Miller' }], + }; + + it('keeps what each side changed', () => { + const local = { + ...base, + title: 'The Silent Mill', + npcs: [...base.npcs, { id: 'imp', name: 'Imp' }], + }; + const remote = { + ...base, + summary: 'A haunted mill.', + scenes: [{ ...yard, title: 'Mill yard' }, cellar], + }; + const { value, conflict } = mergeChanges(base, local, remote); + expect(conflict).toBe(false); + expect(value.title).toBe('The Silent Mill'); + expect(value.summary).toBe('A haunted mill.'); + expect(value.scenes.map((s) => s.title)).toEqual(['Mill yard', 'Cellar']); + expect(value.npcs.map((n) => n.id)).toEqual(['miller', 'imp']); + }); + + it('places entries added here after the entry before them and drops removed ones', () => { + const local = { ...base, scenes: [yard, { id: 'mill', title: 'Mill', tags: [] }, cellar] }; + const remote = { ...base, scenes: [yard, { id: 'attic', title: 'Attic', tags: [] }] }; + const { value, conflict } = mergeChanges(base, local, remote); + expect(conflict).toBe(false); + expect(value.scenes.map((s) => s.id)).toEqual(['yard', 'mill', 'attic']); + }); + + it('takes the change saved elsewhere where both changed the same field', () => { + const local = { ...base, title: 'Mine' }; + const remote = { ...base, title: 'Theirs', summary: 'Theirs too' }; + const { value, conflict } = mergeChanges(base, local, remote); + expect(conflict).toBe(true); + expect(value).toEqual(remote); + }); + + it('keeps an entry removed here that was changed elsewhere', () => { + const local = { ...base, npcs: [] }; + const remote = { ...base, npcs: [{ id: 'miller', name: 'Old miller' }] }; + const { value, conflict } = mergeChanges(base, local, remote); + expect(conflict).toBe(true); + expect(value.npcs).toEqual(remote.npcs); + }); +}); diff --git a/apps/dm/src/lib/merge.ts b/apps/dm/src/lib/merge.ts new file mode 100644 index 0000000..d116805 --- /dev/null +++ b/apps/dm/src/lib/merge.ts @@ -0,0 +1,101 @@ +/** + * Combines edits made here with a change saved elsewhere meanwhile (an AI assistant, a phone, + * another tab), both starting from the same saved version. What only one side changed is taken + * from that side. Lists of entries with an id (scenes, NPCs, …) are combined entry by entry, so + * an NPC added here and a scene the AI changed both stay. + * + * Where both sides changed the same field in different ways, the version saved elsewhere wins + * and conflict is true, so the game master can be told to check the last edit. + */ +export function mergeChanges(base: T, local: T, remote: T): { value: T; conflict: boolean } { + let conflict = false; + + function merge(b: unknown, l: unknown, r: unknown): unknown { + if (same(l, b)) return r; + if (same(r, b) || same(l, r)) return l; + if (isRecord(b) && isRecord(l) && isRecord(r)) return mergeRecords(b, l, r); + if (isEntryList(b) && isEntryList(l) && isEntryList(r)) return mergeEntries(b, l, r); + conflict = true; + return r; + } + + function mergeRecords( + b: Record, + l: Record, + r: Record, + ): Record { + const result: Record = {}; + for (const key of new Set([...Object.keys(r), ...Object.keys(l)])) { + const value = merge(b[key], l[key], r[key]); + if (value !== undefined) result[key] = value; + } + return result; + } + + /** Entries in the order saved elsewhere; entries added here follow the one before them. */ + function mergeEntries(b: Entry[], l: Entry[], r: Entry[]): Entry[] { + const inBase = byId(b); + const inLocal = byId(l); + const inRemote = byId(r); + const result: Entry[] = []; + for (const entry of r) { + const before = inBase.get(entry.id); + const here = inLocal.get(entry.id); + if (here) { + result.push(merge(before, here, entry) as Entry); + } else if (!before) { + result.push(entry); // Added elsewhere. + } else if (!same(before, entry)) { + // Removed here but changed elsewhere: kept, the game master may remove it again. + conflict = true; + result.push(entry); + } + } + l.forEach((entry, index) => { + if (inRemote.has(entry.id)) return; + const before = inBase.get(entry.id); + if (before) { + // Removed elsewhere; changed here too, it is gone all the same. + if (!same(before, entry)) conflict = true; + return; + } + // Added here: placed after the entry it follows here. + const previous = l[index - 1]; + const at = previous ? result.findIndex((e) => e.id === previous.id) : -1; + result.splice(at + 1, 0, entry); + }); + return result; + } + + const value = merge(base, local, remote) as T; + return { value, conflict }; +} + +interface Entry { + id: string; + [key: string]: unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isEntryList(value: unknown): value is Entry[] { + if (!Array.isArray(value)) return false; + const ids = new Set(); + for (const entry of value) { + if (!isRecord(entry) || typeof entry.id !== 'string' || ids.has(entry.id)) return false; + ids.add(entry.id); + } + return true; +} + +function byId(entries: Entry[]): Map { + return new Map(entries.map((entry) => [entry.id, entry])); +} + +function same(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + return JSON.stringify(a) === JSON.stringify(b); +} diff --git a/apps/dm/src/locales/de.json b/apps/dm/src/locales/de.json index 7185c66..a6b1e18 100644 --- a/apps/dm/src/locales/de.json +++ b/apps/dm/src/locales/de.json @@ -137,7 +137,8 @@ "problems": "Das Abenteuer hat kaputte Verweise:", "noProblems": "Alle Verweise in diesem Abenteuer sind gültig.", "saving": "Speichert…", - "saved": "Gespeichert" + "saved": "Gespeichert", + "changedElsewhere": "Deine letzte Änderung hat sich mit einer Änderung von anderswo (KI-Assistent oder ein anderer Tab) überschnitten; diese wurde behalten. Prüfe deine Änderung und mach sie bei Bedarf noch einmal." }, "scenes": { "graph": "Szenengraph", diff --git a/apps/dm/src/locales/en.json b/apps/dm/src/locales/en.json index 327b716..0e99cdb 100644 --- a/apps/dm/src/locales/en.json +++ b/apps/dm/src/locales/en.json @@ -137,7 +137,8 @@ "problems": "The adventure has broken references:", "noProblems": "All references in this adventure are valid.", "saving": "Saving…", - "saved": "Saved" + "saved": "Saved", + "changedElsewhere": "Your last edit clashed with a change made elsewhere (an AI assistant or another tab), which was kept. Check it and redo it if needed." }, "scenes": { "graph": "Scene graph", diff --git a/apps/dm/src/storage/database.ts b/apps/dm/src/storage/database.ts index f3a9787..5059e82 100644 --- a/apps/dm/src/storage/database.ts +++ b/apps/dm/src/storage/database.ts @@ -12,11 +12,31 @@ export interface AdventureSummary { updatedAt: string; } -async function request(path: string, init: RequestInit = {}): Promise { +/** + * Thrown when a save names the version it is based on and something else (an AI assistant, a + * phone, another tab) saved a newer one meanwhile. Carries the newer one, or undefined when the + * entry was deleted. + */ +export class ChangedMeanwhile extends Error { + constructor(readonly current: { value: T; etag: string | undefined } | undefined) { + super('Changed meanwhile'); + } +} + +async function request( + path: string, + init: RequestInit & { ifMatch?: string } = {}, +): Promise { + const { ifMatch, ...rest } = init; const response = await fetch(`/api${path}`, { - ...init, - headers: { 'Content-Type': 'application/json', 'X-Lorewarden-Client': clientId }, + ...rest, + headers: { + 'Content-Type': 'application/json', + 'X-Lorewarden-Client': clientId, + ...(ifMatch ? { 'If-Match': ifMatch } : {}), + }, }); + if (response.status === 412) return response; // Signed out meanwhile, e.g. in another tab: the app asks for the password again. if (response.status === 401) void loadAuthStatus(); // A missing entry is no error when reading or deleting it. A save that finds no route, e.g. on @@ -41,20 +61,42 @@ export async function listAdventures(): Promise { } export async function getAdventure(id: string): Promise { + return (await loadAdventure(id))?.adventure; +} + +/** An adventure with the ETag of its version, to save changes to it with saveAdventure. */ +export async function loadAdventure( + id: string, +): Promise<{ adventure: Adventure; etag: string | undefined } | undefined> { const response = await request(`/adventures/${encodeURIComponent(id)}`); if (response.status === 404) return undefined; const parsed = Adventure.safeParse(await response.json()); - return parsed.success ? parsed.data : undefined; -} - -/** Refuses to store an invalid adventure, so the last valid version is kept instead. */ -export async function saveAdventure(adventure: Adventure): Promise { + return parsed.success ? { adventure: parsed.data, etag: etagOf(response) } : undefined; +} + +/** + * Refuses to store an invalid adventure, so the last valid version is kept instead. With the ETag + * of the version the change is based on, throws ChangedMeanwhile when another one was saved + * since. Returns the ETag of the saved version. + */ +export async function saveAdventure( + adventure: Adventure, + ifMatch?: string, +): Promise { const parsed = Adventure.safeParse(plain(adventure)); if (!parsed.success) throw new Error('Adventure is not valid'); - await request(`/adventures/${encodeURIComponent(adventure.id)}`, { + const response = await request(`/adventures/${encodeURIComponent(adventure.id)}`, { method: 'PUT', body: JSON.stringify(parsed.data), + ifMatch, }); + if (response.status === 412) { + const current = isJson(response) ? Adventure.safeParse(await response.json()) : undefined; + throw new ChangedMeanwhile( + current?.success ? { value: current.data, etag: etagOf(response) } : undefined, + ); + } + return etagOf(response); } /** Deletes an adventure together with its sessions. */ @@ -108,22 +150,54 @@ export async function deleteLibraryMonster(id: string): Promise { /** The game master's parties of player characters, which every adventure can be played with. */ export async function listParties(): Promise { - const body = (await (await request('/parties')).json()) as { parties: unknown[] }; - return body.parties.flatMap((entry) => { - const parsed = Party.safeParse(entry); - return parsed.success ? [parsed.data] : []; - }); + return (await loadParties()).parties; } -export async function saveParty(party: Party): Promise { +/** The parties with the ETag of their version, which every change to one of them moves on. */ +export async function loadParties(): Promise<{ parties: Party[]; etag: string | undefined }> { + const response = await request('/parties'); + return { parties: readParties(await response.json()), etag: etagOf(response) }; +} + +/** + * Saves a party. With the ETag of the parties it was edited from, throws ChangedMeanwhile with + * the current parties when they changed since, e.g. because a player entered a character on their + * phone. Returns the ETag of the saved version. + */ +export async function saveParty(party: Party, ifMatch?: string): Promise { const parsed = Party.safeParse(plain(party)); if (!parsed.success) throw new Error('Party is not valid'); - await request(`/parties/${encodeURIComponent(party.id)}`, { + const response = await request(`/parties/${encodeURIComponent(party.id)}`, { method: 'PUT', body: JSON.stringify(parsed.data), + ifMatch, + }); + if (response.status === 412) { + throw new ChangedMeanwhile({ + value: readParties(await response.json()), + etag: etagOf(response), + }); + } + return etagOf(response); +} + +function readParties(body: unknown): Party[] { + const entries = (body as { parties?: unknown[] } | undefined)?.parties ?? []; + return entries.flatMap((entry) => { + const parsed = Party.safeParse(entry); + return parsed.success ? [parsed.data] : []; }); } +/** The version of what the server sent; an older server sends none. */ +function etagOf(response: Response): string | undefined { + return response.headers.get('ETag') ?? undefined; +} + +function isJson(response: Response): boolean { + return (response.headers.get('Content-Type') ?? '').includes('json'); +} + export async function deleteParty(id: string): Promise { await request(`/parties/${encodeURIComponent(id)}`, { method: 'DELETE' }); } diff --git a/apps/dm/src/views/EditorView.vue b/apps/dm/src/views/EditorView.vue index 0a84422..665ceed 100644 --- a/apps/dm/src/views/EditorView.vue +++ b/apps/dm/src/views/EditorView.vue @@ -28,9 +28,11 @@ import { Play, Plus, Puzzle, + RefreshCw, Search, Skull, Users, + X, } from '@lucide/vue'; import { type Component, computed, ref, toRef, watch } from 'vue'; import { useI18n } from 'vue-i18n'; @@ -55,7 +57,7 @@ import { describeHint as describeReviewHint } from '../lib/review'; const props = defineProps<{ id: string }>(); const { t, locale } = useI18n(); -const { adventure, loading, saveFailed, savePending, scenesById, npcsById, itemsById } = +const { adventure, loading, saveFailed, savePending, conflict, scenesById, npcsById, itemsById } = useAdventure(toRef(props, 'id')); useLeaveGuard(savePending); @@ -237,6 +239,18 @@ function exportAdventure() { +

+