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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Under the hood

- **How the builder will save.** Changes are saved by themselves about five seconds after you
stop editing, and always when you leave; SAVE saves at once and PUBLISH makes the system what
the game runs. Leaving the builder only warns you when a save failed. An edit made while a save
is under way is never lost, and two saves never run at once. Publishing a system now tells every
open browser, so sheets drawn from the old version update straight away. The builder screen
itself comes next.

- **SYSTEMS.EXE, the GM's own game systems in one window.** A SYSTEMS.EXE button under the game
system picker, in the GAME tab, opens it, for the main admin only. It lists every system the GM
has made or installed, with badges for the one running, its version, unpublished changes,
Expand Down
15 changes: 15 additions & 0 deletions backend/__tests__/system_builder_duplicate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ afterEach(() => elevatedUsers.delete('ghost'));
const duplicate = (id = HEARTH, token = GM) => request(app).post(`/api/systems/${id}/duplicate`).set(bearer(token));
const count = async () => (await get(db, 'SELECT COUNT(*) AS n FROM custom_systems')).n;

describe('publishing a system', () => {
it('tells every browser, so a sheet drawn from the old version is fetched again', async () => {
const res = await request(app).post(`/api/systems/${HEARTH}/publish`).set(bearer(GM));
expect(res.body).toEqual({ version: 4 });
expect(JSON.parse((await get(db, 'SELECT published FROM custom_systems WHERE id = ?', [HEARTH])).published)).toEqual(DRAFT);
expect(emitted).toEqual([{ event: 'customSystemChanged', data: { id: HEARTH } }]);
});

it('tells nobody when it is refused', async () => {
await run(db, 'UPDATE custom_systems SET draft = ? WHERE id = ?', [JSON.stringify({ ...DRAFT, derived: [{ id: 'a', formula: '@a' }] }), HEARTH]);
expect((await request(app).post(`/api/systems/${HEARTH}/publish`).set(bearer(GM))).status).toBe(409);
expect(emitted).toEqual([]);
});
});

describe('duplicating a system', () => {
it('makes a new, unpublished system from the draft, named as a copy, made here', async () => {
const res = await duplicate();
Expand Down
6 changes: 5 additions & 1 deletion backend/routes/systems.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,14 @@ module.exports = (db, io = null) => {
});

// Publishing and deleting change what the game can run, so the running copy is reloaded.
// Every browser drawing it fetches the new version (customSystemChanged).
router.post('/:id/publish', gm, (req, res) => {
store.publishSystem(db, req.params.id, (err, done) => {
if (err) return answer(res, err);
runtime.refresh(db, req.params.id, () => answer(res, null, done));
runtime.refresh(db, req.params.id, () => {
if (io) io.emit('customSystemChanged', { id: req.params.id });
answer(res, null, done);
});
});
});

Expand Down
212 changes: 212 additions & 0 deletions frontend/src/sheets/__tests__/builderSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
/**
* The builder's saving (4a2a). Decided with the user 2026-10-06: two layers. Autosave writes the
* draft about five seconds after editing stops and always on leaving; SAVE saves now; EXIT TO MAP
* warns only when a save failed.
*/
import { describe, it, expect, vi } from 'vitest';
import {
createAutosave, saveStatus, exitWarning, leaveNeedsAsking, publishBlocked, publishedMessage,
BUILDER_PAGES, AUTOSAVE_DELAY_MS, type SaveAnswer,
} from '../builderSession';

/** Timers the test runs by hand. `pending` is the last one waiting; `fire` runs every one. */
const fakeTimers = () => {
let waiting: { fn: () => void; ms: number }[] = [];
return {
timers: {
set: (fn: () => void, ms: number) => { const t = { fn, ms }; waiting.push(t); return t; },
clear: (h: unknown) => { waiting = waiting.filter((t) => t !== h); },
},
get pending() { return waiting.at(-1) ?? null; },
get count() { return waiting.length; },
fire() { const all = waiting; waiting = []; all.forEach((t) => t.fn()); },
};
};

/** A save the test answers by hand. */
const deferredSave = () => {
const calls: { definition: unknown; answer: (a: SaveAnswer) => void; fail: () => void }[] = [];
const save = vi.fn((definition: unknown) => new Promise<SaveAnswer>((resolve, reject) => {
calls.push({ definition, answer: resolve, fail: () => reject(new Error('network')) });
}));
return { save, calls };
};

const tick = () => new Promise((r) => setTimeout(r, 0));
const AT = new Date(2026, 9, 6, 14, 2);
const PROBLEM = { where: 'derived a', message: 'Depends on itself' };

const setup = () => {
const t = fakeTimers();
const s = deferredSave();
const changes = vi.fn();
const autosave = createAutosave<{ n: number }>({ save: s.save, onChange: changes, now: () => AT, timers: t.timers, initialProblems: [PROBLEM] });
return { t, s, changes, autosave };
};

describe('autosave', () => {
it('starts saved, with the problems it was given', () => {
const { autosave } = setup();
expect(autosave.state).toEqual({ kind: 'saved', at: AT });
expect(autosave.problems).toEqual([PROBLEM]);
});

it('saves the latest edit once editing pauses for five seconds', async () => {
const { t, s, autosave, changes } = setup();
autosave.edit({ n: 1 });
expect(autosave.state).toEqual({ kind: 'pending' });
expect(t.pending!.ms).toBe(AUTOSAVE_DELAY_MS);
expect(AUTOSAVE_DELAY_MS).toBe(5000);
autosave.edit({ n: 2 });
// Each edit starts the wait over: one timer, never one per edit.
expect(t.count).toBe(1);
expect(s.save).not.toHaveBeenCalled();
t.fire();
await tick();
expect(s.calls.map((c) => c.definition)).toEqual([{ n: 2 }]);
expect(autosave.state).toEqual({ kind: 'saving' });
s.calls[0].answer({ ok: true, problems: [] });
await tick();
expect(autosave.state).toEqual({ kind: 'saved', at: AT });
expect(autosave.problems).toEqual([]);
expect(changes).toHaveBeenCalled();
});

it('keeps an edit made during a save, and saves it at its own pause', async () => {
const { t, s, autosave } = setup();
autosave.edit({ n: 1 });
t.fire();
await tick();
autosave.edit({ n: 2 });
expect(autosave.state).toEqual({ kind: 'saving' });
s.calls[0].answer({ ok: true, problems: [] });
await tick();
expect(autosave.state).toEqual({ kind: 'pending' });
t.fire();
await tick();
expect(s.calls.map((c) => c.definition)).toEqual([{ n: 1 }, { n: 2 }]);
});

it('waits for a save already on its way before the next, never two at once', async () => {
const { t, s, autosave } = setup();
autosave.edit({ n: 1 });
t.fire();
await tick();
autosave.edit({ n: 2 });
t.fire();
await tick();
expect(s.calls).toHaveLength(1);
s.calls[0].answer({ ok: true, problems: [] });
await tick();
await tick();
expect(s.calls.map((c) => c.definition)).toEqual([{ n: 1 }, { n: 2 }]);
});

it('several waiting on one save start one more after it, not one each', async () => {
const { t, s, autosave } = setup();
autosave.edit({ n: 1 });
const a = autosave.flush();
await tick();
autosave.edit({ n: 2 });
const b = autosave.flush();
t.fire();
await tick();
s.calls[0].answer({ ok: true, problems: [] });
await tick();
await tick();
expect(s.calls).toHaveLength(2);
s.calls[1].answer({ ok: true, problems: [] });
expect([await a, await b]).toEqual([true, true]);
expect(s.calls).toHaveLength(2);
});

it('flush saves at once, stops the timer, and says when all is saved', async () => {
const { t, s, autosave } = setup();
expect(await autosave.flush()).toBe(true);
expect(s.save).not.toHaveBeenCalled();
autosave.edit({ n: 1 });
const done = autosave.flush();
await tick();
expect(t.pending).toBeNull();
s.calls[0].answer({ ok: true, problems: [PROBLEM] });
expect(await done).toBe(true);
expect(autosave.problems).toEqual([PROBLEM]);
});

it('flush also saves an edit made while it waited', async () => {
const { s, autosave } = setup();
autosave.edit({ n: 1 });
const done = autosave.flush();
await tick();
autosave.edit({ n: 2 });
s.calls[0].answer({ ok: true, problems: [] });
await tick();
await tick();
s.calls[1].answer({ ok: true, problems: [] });
expect(await done).toBe(true);
expect(s.calls.map((c) => c.definition)).toEqual([{ n: 1 }, { n: 2 }]);
expect(autosave.state).toEqual({ kind: 'saved', at: AT });
});

it('a refused or unreachable save says why, keeps the edit, and tries again', async () => {
const { s, autosave } = setup();
autosave.edit({ n: 1 });
const first = autosave.flush();
await tick();
s.calls[0].answer({ ok: false, error: 'Larger than 512 KB' });
expect(await first).toBe(false);
expect(autosave.state).toEqual({ kind: 'failed', error: 'Larger than 512 KB' });
expect(autosave.problems).toEqual([PROBLEM]);
const second = autosave.flush();
await tick();
s.calls[1].fail();
expect(await second).toBe(false);
expect(autosave.state).toEqual({ kind: 'failed', error: 'Could not reach the server.' });
const third = autosave.flush();
await tick();
s.calls[2].answer({ ok: true, problems: [] });
expect(await third).toBe(true);
expect(s.calls.map((c) => c.definition)).toEqual([{ n: 1 }, { n: 1 }, { n: 1 }]);
});

it('dispose stops a waiting autosave', () => {
const { t, autosave } = setup();
autosave.edit({ n: 1 });
autosave.dispose();
expect(t.pending).toBeNull();
});
});

describe('what it says', () => {
it('the status line', () => {
expect(saveStatus({ kind: 'saved', at: new Date(2026, 9, 6, 9, 5) })).toEqual({ text: 'DRAFT · SAVED 09:05', tone: 'quiet' });
expect(saveStatus({ kind: 'pending' })).toEqual({ text: 'DRAFT · UNSAVED CHANGES', tone: 'warn' });
expect(saveStatus({ kind: 'saving' })).toEqual({ text: 'DRAFT · SAVING…', tone: 'warn' });
expect(saveStatus({ kind: 'failed', error: 'Could not reach the server.' })).toEqual({ text: 'NOT SAVED: Could not reach the server.', tone: 'bad' });
});

it('the warning when leaving couldn\'t save first, and when the browser tab should ask', () => {
expect(exitWarning('Hearth', 'Could not reach the server.')).toEqual({
title: 'EXIT.EXE · UNSAVED WORK',
text: 'Your latest changes to Hearth couldn\'t be saved: Could not reach the server.',
hint: 'Leaving now loses them. Try again, or stay and keep working.',
});
expect(leaveNeedsAsking({ kind: 'saved', at: AT })).toBe(false);
for (const kind of ['pending', 'saving'] as const) expect(leaveNeedsAsking({ kind })).toBe(true);
expect(leaveNeedsAsking({ kind: 'failed', error: 'x' })).toBe(true);
});

it('PUBLISH, before and after', () => {
expect(publishBlocked([])).toBeNull();
expect(publishBlocked([PROBLEM])).toBe('Fix this problem before publishing.');
expect(publishBlocked([PROBLEM, PROBLEM])).toBe('Fix these 2 problems before publishing.');
expect(publishedMessage(4, true)).toBe('Published v4. The game runs it from now on.');
expect(publishedMessage(1, false)).toBe('Published v1. It\'s ready to pick in the game-system picker.');
});

it('the sidebar\'s pages, in order, each saying what it is for', () => {
expect(BUILDER_PAGES.map((p) => p.label)).toEqual(['SETUP', 'WORDS', 'FEATURES', 'STATS & RULES', 'CHARACTER SHEET', 'NPCS', 'TRY IT', 'PROBLEMS']);
for (const p of BUILDER_PAGES) expect(p.what, p.label).toMatch(/\.$/);
expect(new Set(BUILDER_PAGES.map((p) => p.id)).size).toBe(BUILDER_PAGES.length);
});
});
2 changes: 2 additions & 0 deletions frontend/src/sheets/__tests__/systemsApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ describe('the requests', () => {
const cases: [(api: ReturnType<typeof systemsApi>) => Promise<unknown>, string, string, unknown][] = [
[(a) => a.list(), '/api/systems', 'GET', undefined],
[(a) => a.get(ID), `/api/systems/${ID}`, 'GET', undefined],
[(a) => a.saveDraft(ID, { format: 1, name: 'Hearth' }), `/api/systems/${ID}/draft`, 'PUT', { definition: { format: 1, name: 'Hearth' } }],
[(a) => a.publish(ID), `/api/systems/${ID}/publish`, 'POST', {}],
[(a) => a.create('Hearth'), '/api/systems', 'POST', { name: 'Hearth' }],
[(a) => a.rename(ID, 'Emberhold'), `/api/systems/${ID}/name`, 'PUT', { name: 'Emberhold' }],
[(a) => a.duplicate(ID), `/api/systems/${ID}/duplicate`, 'POST', {}],
Expand Down
Loading
Loading