Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
128 changes: 128 additions & 0 deletions apps/dm/src/composables/useAdventure.test.ts
Original file line number Diff line number Diff line change
@@ -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<T> 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);
});
});
106 changes: 95 additions & 11 deletions apps/dm/src/composables/useAdventure.ts
Original file line number Diff line number Diff line change
@@ -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<string>) {
const adventure = ref<Adventure>();
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<Adventure>).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<unknown>(
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.
}
Expand All @@ -33,20 +102,26 @@ export function useAdventure(id: Ref<string>) {
async (value) => {
await flush();
loading.value = true;
await load(value);
conflict.value = false;
await serially(() => load(value));
loading.value = false;
},
{ immediate: true },
);

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();
Expand All @@ -57,5 +132,14 @@ export function useAdventure(id: Ref<string>) {
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,
};
}
30 changes: 24 additions & 6 deletions apps/dm/src/composables/useAutosave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export function useAutosave<T>(source: Ref<T | undefined>, save: (value: T) => P
const pending = ref(false);
let timer: ReturnType<typeof setTimeout> | 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<void> = Promise.resolve();

Expand All @@ -20,16 +22,30 @@ export function useAutosave<T>(source: Ref<T | undefined>, 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;
}
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<void>): Promise<void> => {
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.
Expand All @@ -40,21 +56,23 @@ export function useAutosave<T>(source: Ref<T | undefined>, 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 },
Expand All @@ -67,5 +85,5 @@ export function useAutosave<T>(source: Ref<T | undefined>, save: (value: T) => P
void flush();
});

return { failed, pending, markSaved, flush };
return { failed, pending, markSaved, flush, serially, hasChanges };
}
Loading
Loading