From 3a5ca25d0fe295ba47b156269566cc148f43df3e Mon Sep 17 00:00:00 2001 From: Lampir <37238543+mLampir@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:19:16 +0000 Subject: [PATCH 1/5] Refuse saves based on an older version and cache parsed files Every adventure file, the party library and the monster library now have a revision, a hash of the file's text. GET answers it as ETag; PUT accepts If-Match and answers 412 with the current version and its ETag when the file changed since, so an AI change, a phone sign-up and an edit in another tab no longer overwrite each other unseen. PUT without If-Match replaces as before, for imports and older clients. A successful PUT answers the new ETag. The store kept reading and checking every adventure file for every list, get and save, every session file for the current session and the party library for every phone poll. It now keeps parsed files by path and reads one again only when its time or size changed, and forgets a file it wrote itself. Broken files and files from a newer version are still listed as before. The adventure list also names each adventure's party, so the MCP server can list adventures without reading every one. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/server/src/server.ts | 74 ++++++++-- packages/server/src/store.ts | 220 ++++++++++++++++++++++------ packages/server/test/server.test.ts | 62 ++++++++ packages/server/test/store.test.ts | 69 ++++++++- 4 files changed, 368 insertions(+), 57 deletions(-) diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 8c15ce7..37e276c 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -25,7 +25,7 @@ import { import { type Auth, MAX_PASSWORD_LENGTH, passwordProblem, SESSION_DAYS } from './auth'; import { Attempts, clientAddress } from './limits'; import { type AuthorizationRequest, type OAuth, OAuthError } from './oauth'; -import type { Store } from './store'; +import { RevisionMismatch, type Store, type StoredAdventure } from './store'; import type { UpdateInfo } from './updates'; export { DEFAULT_SERVER_PORT }; @@ -511,11 +511,13 @@ export class LorewardenServer { if (origin) { res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Vary', 'Origin'); + res.setHeader('Access-Control-Expose-Headers', 'ETag'); } if (req.method === 'OPTIONS') { res.writeHead(204, { 'Access-Control-Allow-Methods': 'GET, PUT, POST, DELETE', - 'Access-Control-Allow-Headers': 'Content-Type, X-Lorewarden-Client, Authorization', + 'Access-Control-Allow-Headers': + 'Content-Type, X-Lorewarden-Client, Authorization, If-Match', // Lets a page on a public origin reach this server in browsers that ask first. 'Access-Control-Allow-Private-Network': 'true', }); @@ -554,17 +556,28 @@ export class LorewardenServer { return session ? sendJson(res, session) : void res.writeHead(404).end('No session'); } if (id && method === 'GET') { - const adventure = await store.get(id); - return adventure ? sendJson(res, adventure) : void res.writeHead(404).end('Not found'); + const stored = await store.read(id); + if (!stored) return void res.writeHead(404).end('Not found'); + return sendJson(res, stored.adventure, 200, etag(stored.revision)); } + // With If-Match, a save based on an older version is refused with the current one (412), so + // it cannot overwrite a change it has not seen. Without, the adventure is replaced. if (id && method === 'PUT') { const parsed = Adventure.safeParse(await readJson(req)); if (!parsed.success || parsed.data.id !== id) { return void res.writeHead(400).end('Invalid adventure'); } - await store.save(parsed.data); + let revision: string; + try { + revision = await store.save(parsed.data, ifMatch(req)); + } catch (error) { + if (!(error instanceof RevisionMismatch)) throw error; + const current = (error as RevisionMismatch).current; + if (!current) return void res.writeHead(412).end('Deleted meanwhile'); + return sendJson(res, current.adventure, 412, etag(current.revision)); + } this.broadcast('adventure', { id, client }); - return void res.writeHead(204).end(); + return void res.writeHead(204, etag(revision)).end(); } if (id && method === 'DELETE') { const deleted = await store.delete(id); @@ -573,7 +586,8 @@ export class LorewardenServer { } if (method === 'GET' && pathname === '/api/monsters') { - return sendJson(res, await store.monsters()); + const { revision, ...library } = await store.monsters(); + return sendJson(res, library, 200, etag(revision)); } const monsterMatch = /^\/api\/monsters\/([^/]+)$/.exec(pathname); const monsterId = monsterMatch?.[1] ? decodePart(monsterMatch[1]) : undefined; @@ -582,9 +596,16 @@ export class LorewardenServer { if (!parsed.success || parsed.data.id !== monsterId) { return void res.writeHead(400).end('Invalid monster'); } - await store.saveMonster(parsed.data); + let revision: string; + try { + revision = await store.saveMonster(parsed.data, ifMatch(req)); + } catch (error) { + if (!(error instanceof RevisionMismatch)) throw error; + const { revision: current, ...library } = await store.monsters(); + return sendJson(res, library, 412, etag(current)); + } this.broadcast('monsters', { client }); - return void res.writeHead(204).end(); + return void res.writeHead(204, etag(revision)).end(); } if (monsterId && method === 'DELETE') { const deleted = await store.deleteMonster(monsterId); @@ -592,8 +613,11 @@ export class LorewardenServer { return void res.writeHead(deleted ? 204 : 404).end(); } + // The parties share one revision, so a party saved from an older list cannot drop the + // characters players entered on their phones meanwhile. if (method === 'GET' && pathname === '/api/parties') { - return sendJson(res, await store.parties()); + const { revision, ...library } = await store.parties(); + return sendJson(res, library, 200, etag(revision)); } const partyMatch = /^\/api\/parties\/([^/]+)$/.exec(pathname); const partyId = partyMatch?.[1] ? decodePart(partyMatch[1]) : undefined; @@ -602,9 +626,16 @@ export class LorewardenServer { if (!parsed.success || parsed.data.id !== partyId) { return void res.writeHead(400).end('Invalid party'); } - await store.saveParty(parsed.data); + let revision: string; + try { + revision = await store.saveParty(parsed.data, ifMatch(req)); + } catch (error) { + if (!(error instanceof RevisionMismatch)) throw error; + const { revision: current, ...library } = await store.parties(); + return sendJson(res, library, 412, etag(current)); + } this.broadcast('parties', { client }); - return void res.writeHead(204).end(); + return void res.writeHead(204, etag(revision)).end(); } if (partyId && method === 'DELETE') { const deleted = await store.deleteParty(partyId); @@ -944,6 +975,17 @@ function header(req: IncomingMessage, name: string): string | undefined { return typeof value === 'string' ? value.slice(0, 64) : undefined; } +/** The ETag header for a stored revision. */ +function etag(revision: string): Record { + return { ETag: `"${revision}"` }; +} + +/** The If-Match header, if the client sent one. */ +function ifMatch(req: IncomingMessage): string | undefined { + const value = req.headers['if-match']; + return typeof value === 'string' ? value.slice(0, 1024) : undefined; +} + function sessionCookie(req: IncomingMessage): string | undefined { for (const part of (req.headers.cookie ?? '').split(';')) { const [name, ...value] = part.trim().split('='); @@ -993,12 +1035,18 @@ function redirect(res: ServerResponse, location: string): void { res.writeHead(302, { Location: location }).end(); } -function sendJson(res: ServerResponse, data: unknown, status = 200): void { +function sendJson( + res: ServerResponse, + data: unknown, + status = 200, + headers: Record = {}, +): void { const body = JSON.stringify(data); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store', + ...headers, }); res.end(body); } diff --git a/packages/server/src/store.ts b/packages/server/src/store.ts index cf9bc88..b16a331 100644 --- a/packages/server/src/store.ts +++ b/packages/server/src/store.ts @@ -1,4 +1,4 @@ -import type { Stats } from 'node:fs'; +import { createHash } from 'node:crypto'; import { constants, copyFile, @@ -38,6 +38,8 @@ export interface AdventureSummary { title: string; sceneCount: number; updatedAt: string; + /** Size and level of the party, and the game master's party it follows, if any. */ + party: Adventure['party']; } /** A file in the folder that is not a valid adventure, so the game master can be told. */ @@ -46,45 +48,109 @@ export interface BrokenFile { problems: string[]; } +/** An adventure as stored, with the revision of its file. */ +export interface StoredAdventure { + adventure: Adventure; + revision: string; +} + +/** + * Thrown when a write names a revision that is no longer the stored one, because something else + * saved in between. Carries what is stored now, if anything. + */ +export class RevisionMismatch extends Error { + constructor(readonly current: T | undefined) { + super('Changed meanwhile'); + } +} + interface Entry { adventure: Adventure; file: string; updatedAt: string; + revision: string; +} + +interface CachedFile { + mtimeMs: number; + size: number; + revision: string; + value: unknown; } /** * Keeps adventures as Lorewarden adventure files in one folder, the same files the app exports - * and imports, and the sessions played with them in a subfolder. The folder is read again for - * every list, so files copied in by hand appear without a restart. + * and imports, and the sessions played with them in a subfolder. The folder is looked at again + * for every list, so files copied in by hand appear without a restart; a file is read and checked + * again only when its time or size changed. Values handed out come from that cache and must not + * be changed. + * + * Every file has a revision, a hash of its text. A write may name the revision it is based on and + * is refused when the file changed since, so no change overwrites another one unseen. */ export class Store { private writes = Promise.resolve(); + /** Parsed files by path, valid while their time and size stay the same. */ + private readonly cache = new Map(); constructor(readonly dir: string) {} + /** + * Reads and parses a file, or takes it from the cache when it did not change. Time and size are + * looked at before the file is read, so a change in between is read again the next time. + */ + private async cached( + path: string, + parse: (text: string) => T, + ): Promise<{ value: T; revision: string; mtimeMs: number }> { + const info = await stat(path); + const hit = this.cache.get(path); + if (hit && hit.mtimeMs === info.mtimeMs && hit.size === info.size) { + return { value: hit.value as T, revision: hit.revision, mtimeMs: hit.mtimeMs }; + } + const text = await readFile(path, 'utf8'); + const value = parse(text); + const revision = revisionOf(text); + this.cache.set(path, { mtimeMs: info.mtimeMs, size: info.size, revision, value }); + return { value, revision, mtimeMs: info.mtimeMs }; + } + + /** Writes a file, forgets what was cached for it and returns its new revision. */ + private async write(path: string, text: string): Promise { + await writeAtomic(path, text); + this.cache.delete(path); + return revisionOf(text); + } + private async entries(): Promise<{ entries: Entry[]; broken: BrokenFile[] }> { await mkdir(this.dir, { recursive: true }); const files = (await readdir(this.dir)).filter((f) => f.endsWith(ADVENTURE_SUFFIX)).sort(); const entries: Entry[] = []; const broken: BrokenFile[] = []; + const seen = new Set(); for (const file of files) { const path = join(this.dir, file); - let text: string; - let info: Stats; + seen.add(path); + let read: { value: AdventureFileResult; revision: string; mtimeMs: number }; try { - [text, info] = await Promise.all([readFile(path, 'utf8'), stat(path)]); + read = await this.cached(path, readAdventureFile); } catch (error) { // A file that cannot be read, or vanished meanwhile, must not hide the others. broken.push({ file, problems: [`Cannot read the file: ${(error as Error).message}`] }); continue; } - const result = readAdventureFile(text); + const { value: result, revision, mtimeMs } = read; if (result.ok) { - entries.push({ adventure: result.adventure, file, updatedAt: info.mtime.toISOString() }); + const updatedAt = new Date(mtimeMs).toISOString(); + entries.push({ adventure: result.adventure, file, updatedAt, revision }); } else { broken.push({ file, problems: result.problems }); } } + // Files removed by hand are forgotten. + for (const path of this.cache.keys()) { + if (path.endsWith(ADVENTURE_SUFFIX) && !seen.has(path)) this.cache.delete(path); + } return { entries, broken }; } @@ -96,24 +162,37 @@ export class Store { title: adventure.title, sceneCount: adventure.scenes.length, updatedAt, + party: adventure.party, })) .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); return { adventures, broken }; } async get(id: string): Promise { + return (await this.read(id))?.adventure; + } + + /** An adventure with the revision of its file. */ + async read(id: string): Promise { const { entries } = await this.entries(); - return entries.find((e) => e.adventure.id === id)?.adventure; + return stored(entries.find((e) => e.adventure.id === id)); } - /** Saves an adventure, in its own file or in a new one named after its title. */ - async save(adventure: Adventure): Promise { - await this.serial(async () => { + /** + * Saves an adventure, in its own file or in a new one named after its title, and returns the new + * revision. With ifMatch, throws a RevisionMismatch when the stored revision is another one. + */ + async save(adventure: Adventure, ifMatch?: string): Promise { + return this.serial(async () => { const { entries } = await this.entries(); + const entry = entries.find((e) => e.adventure.id === adventure.id); + if (ifMatch !== undefined && !matches(ifMatch, entry?.revision)) { + throw new RevisionMismatch(stored(entry)); + } const file = - entries.find((e) => e.adventure.id === adventure.id)?.file ?? + entry?.file ?? (await this.freeName(slug(adventure.title) || 'adventure', ADVENTURE_SUFFIX)); - await writeAtomic(join(this.dir, file), serializeAdventure(adventure)); + return this.write(join(this.dir, file), serializeAdventure(adventure)); }); } @@ -138,11 +217,10 @@ export class Store { const sessions: { session: Session; savedAt: number }[] = []; for (const file of files) { try { - const path = join(dir, file); - const parsed = Session.safeParse(JSON.parse(await readFile(path, 'utf8'))); - if (!parsed.success) continue; - if (adventureId !== undefined && parsed.data.adventureId !== adventureId) continue; - sessions.push({ session: parsed.data, savedAt: (await stat(path)).mtimeMs }); + const { value: session, mtimeMs } = await this.cached(join(dir, file), readSessionFile); + if (!session) continue; + if (adventureId !== undefined && session.adventureId !== adventureId) continue; + sessions.push({ session, savedAt: mtimeMs }); } catch { // A broken session file is skipped; the adventure still plays. } @@ -165,22 +243,42 @@ export class Store { async saveSession(session: Session): Promise { await this.serial(async () => { await mkdir(join(this.dir, SESSION_DIR), { recursive: true }); - await writeAtomic(this.sessionPath(session.id), `${JSON.stringify(session, null, 2)}\n`); + await this.write(this.sessionPath(session.id), `${JSON.stringify(session, null, 2)}\n`); }); } + /** A library file next to the adventures with its revision; a missing file is an empty one. */ + private async library( + file: string, + parse: (text: string) => T, + empty: T, + ): Promise { + try { + const { value, revision } = await this.cached(join(this.dir, file), parse); + return { ...value, revision }; + } catch (error) { + // Only a file that is missing or cannot be read counts as empty, never one that is broken. + if ((error as NodeJS.ErrnoException).code === undefined) throw error; + return { ...empty, revision: revisionOf('') }; + } + } + /** The game master's own monsters, kept in one file next to the adventures. */ - async monsters(): Promise<{ monsters: Monster[]; problems: string[] }> { - const text = await readFile(join(this.dir, MONSTER_LIBRARY_FILE), 'utf8').catch( - () => undefined, - ); - return text === undefined ? { monsters: [], problems: [] } : parseMonsterLibrary(text); + async monsters(): Promise<{ monsters: Monster[]; problems: string[]; revision: string }> { + return this.library(MONSTER_LIBRARY_FILE, parseMonsterLibrary, { monsters: [], problems: [] }); } - async saveMonster(monster: Monster): Promise { - await this.serial(async () => { - const { monsters, problems } = await this.monsters(); - await this.writeMonsters(upsertLibraryMonster(monsters, monster), problems); + /** + * Adds a monster or replaces the one with the same id, and returns the library's new revision. + * With ifMatch, throws a RevisionMismatch when the library changed since. + */ + async saveMonster(monster: Monster, ifMatch?: string): Promise { + return this.serial(async () => { + const { monsters, problems, revision } = await this.monsters(); + if (ifMatch !== undefined && !matches(ifMatch, revision)) { + throw new RevisionMismatch(revision); + } + return this.writeMonsters(upsertLibraryMonster(monsters, monster), problems); }); } @@ -195,22 +293,29 @@ export class Store { }); } - private async writeMonsters(monsters: Monster[], problems: string[]): Promise { + private async writeMonsters(monsters: Monster[], problems: string[]): Promise { await mkdir(this.dir, { recursive: true }); await this.keepCopy(MONSTER_LIBRARY_FILE, problems); - await writeAtomic(join(this.dir, MONSTER_LIBRARY_FILE), serializeMonsterLibrary(monsters)); + return this.write(join(this.dir, MONSTER_LIBRARY_FILE), serializeMonsterLibrary(monsters)); } /** The game master's parties of player characters, kept in one file next to the adventures. */ - async parties(): Promise<{ parties: Party[]; problems: string[] }> { - const text = await readFile(join(this.dir, PARTY_LIBRARY_FILE), 'utf8').catch(() => undefined); - return text === undefined ? { parties: [], problems: [] } : parsePartyLibrary(text); + async parties(): Promise<{ parties: Party[]; problems: string[]; revision: string }> { + return this.library(PARTY_LIBRARY_FILE, parsePartyLibrary, { parties: [], problems: [] }); } - async saveParty(party: Party): Promise { - await this.serial(async () => { - const { parties, problems } = await this.parties(); - await this.writeParties(upsertParty(parties, party), problems); + /** + * Adds a party or replaces the one with the same id, and returns the library's new revision. + * With ifMatch, throws a RevisionMismatch when the library changed since, e.g. because a player + * entered a character on their phone. + */ + async saveParty(party: Party, ifMatch?: string): Promise { + return this.serial(async () => { + const { parties, problems, revision } = await this.parties(); + if (ifMatch !== undefined && !matches(ifMatch, revision)) { + throw new RevisionMismatch(revision); + } + return this.writeParties(upsertParty(parties, party), problems); }); } @@ -243,10 +348,10 @@ export class Store { }); } - private async writeParties(parties: Party[], problems: string[]): Promise { + private async writeParties(parties: Party[], problems: string[]): Promise { await mkdir(this.dir, { recursive: true }); await this.keepCopy(PARTY_LIBRARY_FILE, problems); - await writeAtomic(join(this.dir, PARTY_LIBRARY_FILE), serializePartyLibrary(parties)); + return this.write(join(this.dir, PARTY_LIBRARY_FILE), serializePartyLibrary(parties)); } /** @@ -282,13 +387,36 @@ export class Store { } } +function stored(entry: Entry | undefined): StoredAdventure | undefined { + return entry && { adventure: entry.adventure, revision: entry.revision }; +} + +/** A short hash of a file's text, which changes whenever the text does. */ +function revisionOf(text: string): string { + return createHash('sha256').update(text).digest('base64url').slice(0, 22); +} + +/** Whether an If-Match value names the stored revision; "*" matches any stored file. */ +function matches(ifMatch: string, revision: string | undefined): boolean { + if (revision === undefined) return false; + return ifMatch + .split(',') + .map((tag) => + tag + .trim() + .replace(/^W\//, '') + .replace(/^"(.*)"$/, '$1'), + ) + .some((tag) => tag === '*' || tag === revision); +} + +type AdventureFileResult = { ok: true; adventure: Adventure } | { ok: false; problems: string[] }; + /** * Reads an adventure file. Like the app's own storage it checks the shape only: an adventure the * game master is still editing may point to a scene that is not there yet, and must not vanish. */ -function readAdventureFile( - text: string, -): { ok: true; adventure: Adventure } | { ok: false; problems: string[] } { +function readAdventureFile(text: string): AdventureFileResult { let raw: unknown; try { raw = JSON.parse(text); @@ -317,6 +445,12 @@ function readAdventureFile( }; } +/** A session file, or undefined when it is not a valid session. */ +function readSessionFile(text: string): Session | undefined { + const parsed = Session.safeParse(JSON.parse(text)); + return parsed.success ? parsed.data : undefined; +} + /** Writes next to the file and renames, so a crash never leaves half a file behind. */ async function writeAtomic(path: string, text: string): Promise { const temp = `${path}.${process.pid}.tmp`; diff --git a/packages/server/test/server.test.ts b/packages/server/test/server.test.ts index 121095e..b5fd7e3 100644 --- a/packages/server/test/server.test.ts +++ b/packages/server/test/server.test.ts @@ -353,6 +353,68 @@ describe('Lorewarden server', () => { expect((await fetch(url, { method: 'DELETE' })).status).toBe(404); }); + it('refuses a save based on an older version of an adventure', async () => { + const adventure = loadExample(); + const url = `${base}/api/adventures/${adventure.id}`; + const put = (body: unknown, headers: Record = {}) => + fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); + // A new adventure, e.g. an import, needs no version. + const created = await put(adventure); + expect(created.status).toBe(204); + const first = (await fetch(url)).headers.get('ETag') ?? ''; + expect(first).toBe(created.headers.get('ETag')); + expect(first).toMatch(/^".+"$/); + + // Another program saves in between … + const ai = await put({ ...adventure, title: 'Changed by the AI' }, { 'If-Match': first }); + expect(ai.status).toBe(204); + const second = ai.headers.get('ETag') ?? ''; + expect(second).not.toBe(first); + + // … so a save based on the first version is refused with the current one. + const stale = await put({ ...adventure, title: 'Stale' }, { 'If-Match': first }); + expect(stale.status).toBe(412); + expect(stale.headers.get('ETag')).toBe(second); + expect(((await stale.json()) as Adventure).title).toBe('Changed by the AI'); + expect(((await (await fetch(url)).json()) as Adventure).title).toBe('Changed by the AI'); + + // Older clients without If-Match still replace it. + expect((await put({ ...adventure, title: 'Old client' })).status).toBe(204); + expect(((await (await fetch(url)).json()) as Adventure).title).toBe('Old client'); + + await fetch(url, { method: 'DELETE' }); + expect((await put(adventure, { 'If-Match': second })).status).toBe(412); + }); + + it('refuses a party saved from an older list, e.g. one without a sign-up', async () => { + const party = createParty('Tuesday group'); + const url = `${base}/api/parties/${party.id}`; + const put = (body: unknown, headers: Record = {}) => + fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(body), + }); + const empty = (await fetch(`${base}/api/parties`)).headers.get('ETag') ?? ''; + const created = await put(party, { 'If-Match': empty }); + expect(created.status).toBe(204); + const etag = created.headers.get('ETag') ?? ''; + expect((await fetch(`${base}/api/parties`)).headers.get('ETag')).toBe(etag); + + expect((await put({ ...party, name: 'Friday group' }, { 'If-Match': etag })).status).toBe(204); + const stale = await put({ ...party, name: 'Stale' }, { 'If-Match': etag }); + expect(stale.status).toBe(412); + const body = (await stale.json()) as { parties: { name: string }[] }; + expect(body.parties.map((p) => p.name)).toEqual(['Friday group']); + expect(stale.headers.get('ETag')).toBe( + (await fetch(`${base}/api/parties`)).headers.get('ETag'), + ); + }); + it('lets the phones pick a character of the party', async () => { const gm = await open('/api/gm/events'); await gm.next('table'); diff --git a/packages/server/test/store.test.ts b/packages/server/test/store.test.ts index 66ecbb4..6b2b633 100644 --- a/packages/server/test/store.test.ts +++ b/packages/server/test/store.test.ts @@ -9,7 +9,7 @@ import { startSession, } from '@lorewarden/core'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { Store, slug } from '../src/store'; +import { RevisionMismatch, Store, type StoredAdventure, slug } from '../src/store'; const example = new URL('../../../examples/lantern-in-the-marsh.adventure.json', import.meta.url); @@ -113,6 +113,73 @@ describe('store', () => { expect(readFileSync(join(dir, copy('parties.json')[0] ?? ''), 'utf8')).toBe(parties); }); + it('reads a file again when it changes on disk', async () => { + const adventure = await loadExample(); + await store.save(adventure); + const first = await store.read(adventure.id); + expect(await store.read(adventure.id)).toEqual(first); + + const [file = ''] = readdirSync(dir).filter((f) => f.endsWith('.adventure.json')); + const path = join(dir, file); + const text = readFileSync(path, 'utf8'); + writeFileSync(path, text.replace(adventure.title, 'Edited by hand')); + const second = await store.read(adventure.id); + expect(second?.adventure.title).toBe('Edited by hand'); + expect(second?.revision).not.toBe(first?.revision); + expect((await store.list()).adventures[0]?.title).toBe('Edited by hand'); + + // Broken by hand: listed as broken, and readable again once repaired. + writeFileSync(path, '{ nope'); + expect((await store.list()).broken).toEqual([{ file, problems: [expect.any(String)] }]); + expect(await store.get(adventure.id)).toBeUndefined(); + writeFileSync(path, text); + expect((await store.get(adventure.id))?.title).toBe(adventure.title); + + rmSync(path); + expect((await store.list()).adventures).toEqual([]); + }); + + it('refuses to save over a version it was not based on', async () => { + const adventure = await loadExample(); + const first = await store.save(adventure); + expect((await store.read(adventure.id))?.revision).toBe(first); + const second = await store.save({ ...adventure, title: 'By the AI' }, first); + expect(second).not.toBe(first); + + const stale = store.save({ ...adventure, title: 'Stale' }, first); + await expect(stale).rejects.toBeInstanceOf(RevisionMismatch); + const error = (await stale.catch((e: unknown) => e)) as RevisionMismatch; + expect(error.current?.adventure.title).toBe('By the AI'); + expect(error.current?.revision).toBe(second); + expect(await store.save({ ...adventure, title: 'Fresh' }, `W/"${second}"`)).toBeTypeOf( + 'string', + ); + expect((await store.get(adventure.id))?.title).toBe('Fresh'); + // An adventure that is not there has no version to match. + await expect(store.save({ ...adventure, id: 'gone' }, first)).rejects.toBeInstanceOf( + RevisionMismatch, + ); + }); + + it('gives the parties one revision that every change moves on', async () => { + const empty = (await store.parties()).revision; + const party = createParty('Tuesday group'); + const saved = await store.saveParty(party, empty); + expect((await store.parties()).revision).toBe(saved); + await store.changeParty( + (p) => p.id === party.id, + (p) => ({ ...p, name: 'Signed up' }), + ); + await expect(store.saveParty(party, saved)).rejects.toBeInstanceOf(RevisionMismatch); + expect((await store.parties()).parties[0]?.name).toBe('Signed up'); + + writeFileSync( + join(dir, 'parties.json'), + readFileSync(join(dir, 'parties.json'), 'utf8').replace('Signed up', 'By hand'), + ); + expect((await store.parties()).parties[0]?.name).toBe('By hand'); + }); + it('makes file names from titles', () => { expect(slug('Das Geheimnis der Mühle!')).toBe('das-geheimnis-der-muhle'); expect(slug('???')).toBe(''); From a5f06c7a570fdedf2c47a368ed9042c41756ae1b Mon Sep 17 00:00:00 2001 From: Lampir <37238543+mLampir@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:19:16 +0000 Subject: [PATCH 2/5] Decode images for the phones once per reveal Every phone asked for a shown image and the server decoded its data address each time, byte by byte through atob. It now decodes it with Node's base64 decoder once and keeps the bytes while the reveal shows the same image. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/server/src/server.ts | 40 +++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 37e276c..1933c1d 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -10,7 +10,6 @@ import { applySignUp, canSee, DEFAULT_SERVER_PORT, - decodeDataUrl, Monster, Party, Player, @@ -109,6 +108,8 @@ export class LorewardenServer { private settings: AiSettings = {}; private readonly gameMasters = new Set(); private readonly connections = new Set(); + /** Decoded images of the reveals, see decodeImage. */ + private readonly images = new Map(); private server: Server | undefined; private port = DEFAULT_SERVER_PORT; private update: UpdateInfo; @@ -693,6 +694,10 @@ export class LorewardenServer { setState(state: TableState): void { this.state = state; + // Images of reveals that are gone are no longer asked for. + for (const id of this.images.keys()) { + if (!state.reveals.some((r) => r.id === id)) this.images.delete(id); + } for (const connection of [...this.connections]) { if (connection.code === state.code) this.sendView(connection); else this.reject(connection); @@ -902,7 +907,8 @@ export class LorewardenServer { const rightCode = this.state?.code === (url.searchParams.get('code') ?? '').toUpperCase(); if (this.state && !rightCode) this.joins.fail(client); const allowed = reveal?.image && rightCode && canSee(reveal, id); - const image = allowed && reveal.image ? decodeDataUrl(reveal.image.src) : undefined; + const image = + allowed && reveal.image ? this.decodeImage(reveal.id, reveal.image.src) : undefined; if (!image) return void res.writeHead(404).end('Not found'); res.writeHead(200, { 'Content-Type': image.mimeType, @@ -915,6 +921,18 @@ export class LorewardenServer { res.end(image.bytes); } + /** + * The bytes of a shown image. Every phone asks for it, so it is decoded once per reveal and + * kept while the reveal shows the same image. + */ + private decodeImage(revealId: string, src: string): DecodedImage | undefined { + const cached = this.images.get(revealId); + if (cached?.src === src) return cached.image; + const image = decodeImage(src); + if (image) this.images.set(revealId, { src, image }); + return image; + } + // --- Apps ---------------------------------------------------------------------------------- private async serveStatic( @@ -1012,6 +1030,24 @@ function reserve( return attempts.reserve(client); } +interface DecodedImage { + mimeType: string; + bytes: Buffer; +} + +/** Decodes a data: address like decodeDataUrl in core, with Node's fast base64 decoder. */ +function decodeImage(src: string): DecodedImage | undefined { + const match = /^data:([^;,]+)(;base64)?,(.*)$/s.exec(src); + if (!match) return undefined; + const [, mimeType = '', base64, data = ''] = match; + try { + const bytes = base64 ? Buffer.from(data, 'base64') : Buffer.from(decodeURIComponent(data)); + return { mimeType, bytes }; + } catch { + return undefined; + } +} + /** Ends a request with an answer for the client; not an error of the server, so not logged. */ class HttpError extends Error { constructor( From 65b8254a67d3a52c5568127f8f8cd1792f7bedef Mon Sep 17 00:00:00 2001 From: Lampir <37238543+mLampir@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:21:16 +0000 Subject: [PATCH 3/5] Apply an MCP change again when the adventure changed meanwhile The MCP tools read an adventure, change it and save it. An edit the game master saved in between was overwritten. The save now sends the version it was based on; when the server refuses it, the change is applied to the newer version and saved again, up to four times. An adventure that keeps changing is not saved, and the assistant is told to call the tool again. list_adventures read every adventure in full through the server; it now uses the server's list, which names the party too. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/mcp/src/api.ts | 104 +++++++++++++++++++-------- packages/mcp/src/server.ts | 8 +-- packages/mcp/src/store.ts | 33 +++++++-- packages/server/test/mcp-api.test.ts | 65 ++++++++++++++++- 4 files changed, 167 insertions(+), 43 deletions(-) diff --git a/packages/mcp/src/api.ts b/packages/mcp/src/api.ts index 75fc3da..9d9af19 100644 --- a/packages/mcp/src/api.ts +++ b/packages/mcp/src/api.ts @@ -8,6 +8,7 @@ import { } from '@lorewarden/core'; import { AdventureError, + type AdventureSummary, type Adventures, type BrokenFile, type StoredAdventure, @@ -17,6 +18,17 @@ import { /** Marks the changes of the AI assistant, so the open app reloads them. */ const CLIENT = 'mcp'; +/** + * How often a change is applied again to a newer version when the adventure changed between + * reading and saving it, e.g. because the game master is typing in the app. + */ +const SAVE_ATTEMPTS = 4; + +/** An adventure read from the server with the ETag of its version. */ +interface Versioned { + adventure: Adventure; + etag: string | undefined; +} /** What is happening at the table right now. */ export interface TableState { @@ -50,18 +62,20 @@ export class ServerAdventures implements Adventures { : `${this.fallback.location}, because the Lorewarden server is not running`; } - async list(): Promise<{ adventures: StoredAdventure[]; broken: BrokenFile[] }> { + async list(): Promise<{ adventures: AdventureSummary[]; broken: BrokenFile[] }> { return this.withFallback( async () => { const body = (await this.json('/api/adventures')) as { - adventures: { id: string }[]; + adventures: { id: string; title: string; sceneCount: number; party?: unknown }[]; broken: BrokenFile[]; }; - const adventures: StoredAdventure[] = []; - for (const { id } of body.adventures) { - const adventure = await this.find(id); - if (adventure) adventures.push({ adventure }); - } + // The list names everything list_adventures shows, so no adventure is read in full. + const adventures = body.adventures.map(({ id, title, sceneCount, party }) => ({ + id, + title, + ...(party ? { party: party as Adventure['party'] } : {}), + scenes: sceneCount, + })); return { adventures, broken: body.broken }; }, (fallback) => fallback.list(), @@ -71,13 +85,13 @@ export class ServerAdventures implements Adventures { async get(id: string): Promise { return this.withFallback( async () => { - const adventure = await this.find(id); - if (!adventure) { + const found = await this.find(id); + if (!found) { throw new AdventureError( `No adventure with id "${id}". Call list_adventures to see them.`, ); } - return { adventure }; + return { adventure: found.adventure }; }, (fallback) => fallback.get(id), ); @@ -94,7 +108,7 @@ export class ServerAdventures implements Adventures { throw new AdventureError(`An adventure with id "${adventure.id}" already exists.`); } const valid = validate(adventure); - await this.save(valid); + await this.save(valid, undefined); return { adventure: valid }; }, (fallback) => fallback.add(adventure), @@ -111,16 +125,26 @@ export class ServerAdventures implements Adventures { ): Promise { return this.withFallback( async () => { - const current = await this.find(id); - if (!current) { - throw new AdventureError( - `No adventure with id "${id}". Call list_adventures to see them.`, - ); + // Saved only if nothing else saved the adventure since it was read; otherwise the change + // is applied again to the newer version, so the game master's edit is not lost. + let current = await this.find(id); + for (let attempt = 0; attempt < SAVE_ATTEMPTS; attempt++) { + if (!current) { + throw new AdventureError( + `No adventure with id "${id}". Call list_adventures to see them.`, + ); + } + const adventure = validate(change(structuredClone(current.adventure))); + if (adventure.id !== id) { + throw new AdventureError('The id of an adventure cannot change.'); + } + const result = await this.save(adventure, current.etag); + if (result.saved) return adventure; + current = result.current; } - const adventure = validate(change(structuredClone(current))); - if (adventure.id !== id) throw new AdventureError('The id of an adventure cannot change.'); - await this.save(adventure); - return adventure; + throw new AdventureError( + `The adventure "${id}" kept changing while this change was saved, probably because the game master is editing it. Nothing was saved; call the tool again.`, + ); }, (fallback) => fallback.update(id, change), ); @@ -169,8 +193,8 @@ export class ServerAdventures implements Adventures { try { const session = Session.safeParse(await this.json('/api/sessions/current')); if (!session.success) return undefined; - const adventure = await this.find(session.data.adventureId); - return adventure ? { adventure, session: session.data } : undefined; + const found = await this.find(session.data.adventureId); + return found ? { adventure: found.adventure, session: session.data } : undefined; } catch { return undefined; } @@ -205,19 +229,28 @@ export class ServerAdventures implements Adventures { } } - private async find(id: string): Promise { - const body = await this.json(`/api/adventures/${encodeURIComponent(id)}`); - if (body === undefined) return undefined; - const parsed = AdventureSchema.safeParse(body); - if (!parsed.success) throw new AdventureError(`The adventure "${id}" is not valid.`); - return parsed.data; + private async find(id: string): Promise { + const response = await this.request(`/api/adventures/${encodeURIComponent(id)}`); + if (response.status === 404) return undefined; + return versioned(id, response); } - private async save(adventure: Adventure): Promise { - await this.request(`/api/adventures/${encodeURIComponent(adventure.id)}`, { + /** + * Saves an adventure. With an ETag, the server refuses the save when the adventure changed + * since; then the current version is returned, or undefined when it was deleted. + */ + private async save( + adventure: Adventure, + etag: string | undefined, + ): Promise<{ saved: true } | { saved: false; current: Versioned | undefined }> { + const response = await this.request(`/api/adventures/${encodeURIComponent(adventure.id)}`, { method: 'PUT', body: JSON.stringify(adventure), + ...(etag ? { headers: { 'If-Match': etag } } : {}), }); + if (response.status !== 412) return { saved: true }; + const deleted = !(response.headers.get('Content-Type') ?? '').includes('json'); + return { saved: false, current: deleted ? undefined : await versioned(adventure.id, response) }; } /** The answer's JSON, or undefined if there is none. */ @@ -232,6 +265,7 @@ export class ServerAdventures implements Adventures { response = await fetch(`${this.url}${path}`, { ...init, headers: { + ...init.headers, 'Content-Type': 'application/json', 'X-Lorewarden-Client': CLIENT, ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), @@ -240,7 +274,8 @@ export class ServerAdventures implements Adventures { } catch { throw new Unreachable(); } - if (!response.ok && response.status !== 404) { + // 412: a save based on an older version, which the caller applies again. + if (!response.ok && response.status !== 404 && response.status !== 412) { throw new AdventureError( `The Lorewarden server answered ${response.status}: ${await response.text()}`, ); @@ -248,3 +283,10 @@ export class ServerAdventures implements Adventures { return response; } } + +/** Reads an adventure and its ETag from the server's answer. */ +async function versioned(id: string, response: Response): Promise { + const parsed = AdventureSchema.safeParse(await response.json()); + if (!parsed.success) throw new AdventureError(`The adventure "${id}" is not valid.`); + return { adventure: parsed.data, etag: response.headers.get('ETag') ?? undefined }; +} diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index e72829f..05fc34f 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -150,13 +150,7 @@ export function createServer(store: Adventures, options: ServerOptions = {}): Mc const { adventures, broken } = await store.list(); return text({ location: store.location, - adventures: adventures.map(({ adventure, file }) => ({ - id: adventure.id, - title: adventure.title, - party: adventure.party, - scenes: adventure.scenes.length, - file, - })), + adventures, ...(broken.length > 0 ? { unreadableFiles: broken } : {}), }); }), diff --git a/packages/mcp/src/store.ts b/packages/mcp/src/store.ts index 5128cfe..16f5578 100644 --- a/packages/mcp/src/store.ts +++ b/packages/mcp/src/store.ts @@ -25,6 +25,17 @@ export interface StoredAdventure { file?: string; } +/** What list_adventures shows of an adventure. */ +export interface AdventureSummary { + id: string; + title: string; + /** Size and level of the party; missing when an older Lorewarden server does not say. */ + party?: Adventure['party']; + scenes: number; + /** File name inside the adventure folder; missing for adventures in the app. */ + file?: string; +} + /** A file in the folder that is not a valid adventure, so the AI can tell the game master. */ export interface BrokenFile { file: string; @@ -51,7 +62,7 @@ export function serializer(): (task: () => Promise) => Promise { export interface Adventures { /** Says where the adventures are, so the AI can tell the game master. */ readonly location: string; - list(): Promise<{ adventures: StoredAdventure[]; broken: BrokenFile[] }>; + list(): Promise<{ adventures: AdventureSummary[]; broken: BrokenFile[] }>; get(id: string): Promise; /** Saves a new adventure. */ add(adventure: Adventure): Promise; @@ -78,7 +89,21 @@ export class AdventureStore implements Adventures { return `the folder ${this.dir}`; } - async list(): Promise<{ adventures: StoredAdventure[]; broken: BrokenFile[] }> { + async list(): Promise<{ adventures: AdventureSummary[]; broken: BrokenFile[] }> { + const { adventures, broken } = await this.all(); + return { + adventures: adventures.map(({ adventure, file }) => ({ + id: adventure.id, + title: adventure.title, + party: adventure.party, + scenes: adventure.scenes.length, + file, + })), + broken, + }; + } + + private async all(): Promise<{ adventures: StoredAdventure[]; broken: BrokenFile[] }> { await mkdir(this.dir, { recursive: true }); const files = (await readdir(this.dir)).filter((file) => file.endsWith(FILE_SUFFIX)).sort(); const adventures: StoredAdventure[] = []; @@ -94,7 +119,7 @@ export class AdventureStore implements Adventures { } async get(id: string): Promise { - const { adventures } = await this.list(); + const { adventures } = await this.all(); const found = adventures.find((stored) => stored.adventure.id === id); if (!found) { throw new AdventureError(`No adventure with id "${id}". Call list_adventures to see them.`); @@ -108,7 +133,7 @@ export class AdventureStore implements Adventures { } private async addNow(adventure: Adventure): Promise { - const { adventures } = await this.list(); + const { adventures } = await this.all(); if (adventures.some((stored) => stored.adventure.id === adventure.id)) { throw new AdventureError(`An adventure with id "${adventure.id}" already exists.`); } diff --git a/packages/server/test/mcp-api.test.ts b/packages/server/test/mcp-api.test.ts index ed8cdc2..211f338 100644 --- a/packages/server/test/mcp-api.test.ts +++ b/packages/server/test/mcp-api.test.ts @@ -1,3 +1,4 @@ +import { readFileSync, writeFileSync } from 'node:fs'; import { mkdtemp, readdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -7,7 +8,7 @@ import { createServer } from '@lorewarden/mcp/src/server'; import { AdventureStore } from '@lorewarden/mcp/src/store'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LorewardenServer } from '../src/server'; import { Store } from '../src/store'; @@ -15,6 +16,7 @@ let dir: string; let lorewarden: LorewardenServer; let base: string; let client: Client; +const original = globalThis.fetch; async function connect(adventures: ServerAdventures) { const server = createServer(adventures, { @@ -86,6 +88,67 @@ describe('MCP server with the Lorewarden server', () => { expect(listed.location).toContain(base); }); + it('lists the adventures without reading each one', async () => { + const adventures = new ServerAdventures(base); + await connect(adventures); + const { id } = await callJson('create_adventure', { + title: 'The Silent Mill', + partySize: 4, + partyLevel: 3, + }); + const fetched: string[] = []; + const spy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => { + fetched.push(String(input)); + return original(input, init); + }); + try { + const listed = await callJson('list_adventures'); + expect(listed.adventures).toEqual([ + { id, title: 'The Silent Mill', party: { size: 4, level: 3 }, scenes: 0 }, + ]); + } finally { + spy.mockRestore(); + } + expect(fetched).toEqual([`${base}/api/adventures`]); + }); + + it('applies a change again when the game master saved in between', async () => { + const adventures = new ServerAdventures(base); + await connect(adventures); + const { id } = await callJson('create_adventure', { + title: 'The Silent Mill', + partySize: 4, + partyLevel: 3, + }); + const file = join(dir, 'the-silent-mill.adventure.json'); + let calls = 0; + const saved = await adventures.update(id, (adventure) => { + calls++; + // The game master renames it in the app after the assistant read it. + if (calls === 1) { + const text = readFileSync(file, 'utf8'); + writeFileSync(file, text.replace('"title": "The Silent Mill"', '"title": "The Loud Mill"')); + } + return { ...adventure, summary: 'Fog lies over the mill.' }; + }); + expect(calls).toBe(2); + expect(saved).toMatchObject({ title: 'The Loud Mill', summary: 'Fog lies over the mill.' }); + const stored = (await (await fetch(`${base}/api/adventures/${id}`)).json()) as Adventure; + expect(stored).toMatchObject({ title: 'The Loud Mill', summary: 'Fog lies over the mill.' }); + + // An adventure that keeps changing is not saved, and the assistant is told to try again. + let attempt = 0; + const busy = adventures.update(id, (adventure) => { + attempt++; + const text = readFileSync(file, 'utf8'); + writeFileSync(file, text.replace(/"summary": "[^"]*"/, `"summary": "Edit ${attempt}"`)); + return { ...adventure, title: 'Never saved' }; + }); + await expect(busy).rejects.toThrow(/kept changing/); + const after = (await (await fetch(`${base}/api/adventures/${id}`)).json()) as Adventure; + expect(after.title).toBe('The Loud Mill'); + }); + it('writes new adventures in the languages chosen in the app', async () => { await connect(new ServerAdventures(base)); await fetch(`${base}/api/settings`, { From b5f1c9aab7d7aeaf5b68fbb600d929ff957ae611 Mon Sep 17 00:00:00 2001 From: Lampir <37238543+mLampir@users.noreply.github.com> Date: Tue, 13 Oct 2026 05:26:08 +0000 Subject: [PATCH 4/5] Keep edits in the app when an adventure or party changes elsewhere The app saved an adventure shortly after every change and replaced it with the newer version when an AI assistant or another tab changed it. A stale autosave could overwrite the other change, and a reload dropped what was typed in the last moment. The app now keeps the ETag of the version it shows and sends it with every save. When a notice says the adventure changed, pending edits are saved first. When the server answers that a newer version was saved meanwhile, the edits made here are combined with it field by field, and lists of scenes, NPCs and other entries entry by entry, then saved on top of it. Only where both sides changed the same field does the other change win; a short notice above the editor then asks the game master to check the last edit. This keeps both sides in the common case, where the assistant and the game master work on different things, without a dialog in the way. A party saved from an older list, e.g. while a player entered a character on their phone, now takes the characters from the phones in and is saved again instead of dropping them. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/dm/src/composables/useAdventure.test.ts | 128 +++++++++++++++++++ apps/dm/src/composables/useAdventure.ts | 106 +++++++++++++-- apps/dm/src/composables/useAutosave.ts | 30 ++++- apps/dm/src/composables/useParties.ts | 46 +++++-- apps/dm/src/lib/lib.test.ts | 55 ++++++++ apps/dm/src/lib/merge.ts | 101 +++++++++++++++ apps/dm/src/locales/de.json | 3 +- apps/dm/src/locales/en.json | 3 +- apps/dm/src/storage/database.ts | 106 ++++++++++++--- apps/dm/src/views/EditorView.vue | 35 ++++- 10 files changed, 569 insertions(+), 44 deletions(-) create mode 100644 apps/dm/src/composables/useAdventure.test.ts create mode 100644 apps/dm/src/lib/merge.ts 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 15bdb39..beb208e 100644 --- a/apps/dm/src/locales/de.json +++ b/apps/dm/src/locales/de.json @@ -116,7 +116,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 7381365..5b38bf3 100644 --- a/apps/dm/src/locales/en.json +++ b/apps/dm/src/locales/en.json @@ -116,7 +116,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 5c3517c..183eae1 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'; @@ -53,7 +55,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')); type Tab = 'scenes' | 'npcs' | 'items' | 'clues' | 'puzzles' | 'monsters' | 'adventure'; @@ -237,6 +239,18 @@ function exportAdventure() { +

+