From a31d27d6218d33714d89881ec047149d8e14cc9b Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 12:36:51 -0500 Subject: [PATCH] feat(system-builder): the builder's saving and sidebar, as logic (4a2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sheets/builderSession.ts: createAutosave (saves the latest edit 5s after editing stops; flush saves now and keeps going until nothing is unsaved; an edit made during a save is kept and saved after it; never two saves at once; a failed save keeps the edit and is retried), the status line (DRAFT · SAVED 14:02 / UNSAVED CHANGES / SAVING… / NOT SAVED: why), the exit warning used only when the save on leaving failed, leaveNeedsAsking for the browser tab, publishBlocked / publishedMessage, and BUILDER_PAGES with a line each. systemsApi gains saveDraft and publish, and get's full answer type. The publish route now emits customSystemChanged, so open browsers fetch the new version. Decided with the user 2026-10-06: two save layers (autosave writes the draft; SAVE saves now; PUBLISH goes live); EXIT TO MAP warns only when a save failed. --- CHANGELOG.md | 7 + .../system_builder_duplicate.test.js | 15 ++ backend/routes/systems.js | 6 +- .../sheets/__tests__/builderSession.test.ts | 212 ++++++++++++++++++ .../src/sheets/__tests__/systemsApi.test.ts | 2 + frontend/src/sheets/builderSession.ts | 158 +++++++++++++ frontend/src/sheets/systemsApi.ts | 28 ++- 7 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 frontend/src/sheets/__tests__/builderSession.test.ts create mode 100644 frontend/src/sheets/builderSession.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cdb12eae..0fc696a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, diff --git a/backend/__tests__/system_builder_duplicate.test.js b/backend/__tests__/system_builder_duplicate.test.js index 8f68c525..d3dad60e 100644 --- a/backend/__tests__/system_builder_duplicate.test.js +++ b/backend/__tests__/system_builder_duplicate.test.js @@ -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(); diff --git a/backend/routes/systems.js b/backend/routes/systems.js index 4d72901a..c58a6b1f 100644 --- a/backend/routes/systems.js +++ b/backend/routes/systems.js @@ -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); + }); }); }); diff --git a/frontend/src/sheets/__tests__/builderSession.test.ts b/frontend/src/sheets/__tests__/builderSession.test.ts new file mode 100644 index 00000000..c5cb9a0a --- /dev/null +++ b/frontend/src/sheets/__tests__/builderSession.test.ts @@ -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((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); + }); +}); diff --git a/frontend/src/sheets/__tests__/systemsApi.test.ts b/frontend/src/sheets/__tests__/systemsApi.test.ts index d74db669..df8d1b9d 100644 --- a/frontend/src/sheets/__tests__/systemsApi.test.ts +++ b/frontend/src/sheets/__tests__/systemsApi.test.ts @@ -26,6 +26,8 @@ describe('the requests', () => { const cases: [(api: ReturnType) => Promise, 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', {}], diff --git a/frontend/src/sheets/builderSession.ts b/frontend/src/sheets/builderSession.ts new file mode 100644 index 00000000..6a8e1324 --- /dev/null +++ b/frontend/src/sheets/builderSession.ts @@ -0,0 +1,158 @@ +// The builder's saving, and what its sidebar holds (4a2a). Pure apart from the timer it is +// given, so the builder screen only draws it. +// +// Decided with the user, 2026-10-06: saving has two layers. Autosave writes the system's draft +// about five seconds after editing stops, and always on leaving; SAVE only saves now; PUBLISH +// makes the draft what the game runs. EXIT TO MAP leaves at once, saving first, and warns only +// when that save failed. There is no "autosaved but not saved" copy. (Autosave timing decided +// 2026-10-03; sidebar names proposed then, kept until the user has clicked around in them.) + +/** How long after the last edit an autosave waits. */ +export const AUTOSAVE_DELAY_MS = 5000; + +export interface Problem { where: string; message: string } + +/** Where the draft stands. */ +export type SaveState = + | { kind: 'saved'; at: Date } + | { kind: 'pending' } + | { kind: 'saving' } + | { kind: 'failed'; error: string }; + +/** What a save answers: the server's problem list for the draft, or why it wasn't saved. */ +export type SaveAnswer = { ok: true; problems: Problem[] } | { ok: false; error: string }; + +export interface Autosave { + /** The definition changed: saved once editing pauses. */ + edit(definition: D): void; + /** Save now if anything is unsaved, or a save failed. True once nothing is left unsaved. */ + flush(): Promise; + readonly state: SaveState; + /** The draft's problems as of the last save. */ + readonly problems: Problem[]; + /** Stops the timer; an edit still unsaved stays unsaved. */ + dispose(): void; +} + +interface Timers { set: (fn: () => void, ms: number) => unknown; clear: (handle: unknown) => void } +const realTimers: Timers = { set: (fn, ms) => setTimeout(fn, ms), clear: (h) => clearTimeout(h as ReturnType) }; + +/** + * Autosave for one system. `save` writes a definition; `onChange` hears every change of state + * or problems. An edit made while a save is under way is saved straight after it, so the last + * edit is always the one that ends up stored. + */ +export function createAutosave({ + save, onChange = () => {}, initialProblems = [], delay = AUTOSAVE_DELAY_MS, now = () => new Date(), timers = realTimers, +}: { + save: (definition: D) => Promise; + onChange?: () => void; + initialProblems?: Problem[]; + delay?: number; + now?: () => Date; + timers?: Timers; +}): Autosave { + let state: SaveState = { kind: 'saved', at: now() }; + let problems = initialProblems; + let latest: D | undefined; + /** Whether `latest` is not yet stored. */ + let unsaved = false; + let timer: unknown = null; + let running: Promise | null = null; + + const set = (next: SaveState) => { state = next; onChange(); }; + const stopTimer = () => { if (timer !== null) { timers.clear(timer); timer = null; } }; + + /** One save of the latest definition, after any save already on its way. */ + const run = async (): Promise => { + if (running) await running; + // Several may have waited on that save; the first to wake takes the edit, the rest find none. + if (!unsaved) return; + stopTimer(); + const sending = latest as D; + unsaved = false; + set({ kind: 'saving' }); + const failed = (error: string) => { unsaved = true; set({ kind: 'failed', error }); }; + running = save(sending).then((answer) => { + running = null; + if (!answer.ok) return failed(answer.error); + problems = answer.problems; + // Edited again while this one was on its way: that edit still waits for its pause. + set(unsaved ? { kind: 'pending' } : { kind: 'saved', at: now() }); + }, () => { running = null; failed('Could not reach the server.'); }); + await running; + }; + + return { + edit(definition) { + latest = definition; + unsaved = true; + stopTimer(); + timer = timers.set(() => { timer = null; void run(); }, delay); + if (state.kind !== 'saving') set({ kind: 'pending' }); + }, + async flush() { + // Until nothing is left: an edit made during a save is saved straight after it. + for (;;) { + await run(); + if (state.kind === 'failed') return false; + if (!unsaved && !running) return true; + } + }, + get state() { return state; }, + get problems() { return problems; }, + dispose: stopTimer, + }; +} + +const two = (n: number) => String(n).padStart(2, '0'); + +/** The status line's words for the draft, and how loud they are. */ +export const saveStatus = (s: SaveState): { text: string; tone: 'quiet' | 'warn' | 'bad' } => { + switch (s.kind) { + case 'saved': return { text: `DRAFT · SAVED ${two(s.at.getHours())}:${two(s.at.getMinutes())}`, tone: 'quiet' }; + case 'pending': return { text: 'DRAFT · UNSAVED CHANGES', tone: 'warn' }; + case 'saving': return { text: 'DRAFT · SAVING…', tone: 'warn' }; + case 'failed': return { text: `NOT SAVED: ${s.error}`, tone: 'bad' }; + } +}; + +/** The words when EXIT TO MAP couldn't save first. */ +export const exitWarning = (systemName: string, error: string) => ({ + title: 'EXIT.EXE · UNSAVED WORK', + text: `Your latest changes to ${systemName} couldn't be saved: ${error}`, + hint: 'Leaving now loses them. Try again, or stay and keep working.', +}); + +/** Whether closing or reloading the browser tab should ask first. */ +export const leaveNeedsAsking = (s: SaveState): boolean => s.kind !== 'saved'; + +// ─── Publishing ───────────────────────────────────────────────────────────── + +/** What PUBLISH says before it is pressed: why it can't yet, or null when it can. */ +export const publishBlocked = (problems: Problem[]): string | null => + (problems.length === 0 ? null : `Fix ${problems.length === 1 ? 'this problem' : `these ${problems.length} problems`} before publishing.`); + +/** The line after PUBLISH. `running` says whether the game runs this system now. */ +export const publishedMessage = (version: number, running: boolean): string => + (running ? `Published v${version}. The game runs it from now on.` : `Published v${version}. It's ready to pick in the game-system picker.`); + +// ─── The sidebar ──────────────────────────────────────────────────────────── + +export type BuilderPage = 'setup' | 'words' | 'features' | 'rules' | 'sheet' | 'npcs' | 'try' | 'problems'; + +/** + * The builder's pages, in the sidebar's order, each with the line that says what it is for + * (the user found the design's PARTS / VALUES / CHECK unclear, 2026-10-03). MY SYSTEMS, SAVE / + * PUBLISH and EXIT TO MAP are the sidebar's own buttons, not pages. + */ +export const BUILDER_PAGES: { id: BuilderPage; label: string; what: string }[] = [ + { id: 'setup', label: 'SETUP', what: 'The questions a new system starts with.' }, + { id: 'words', label: 'WORDS', what: 'What the game calls things: HP, credits, skills.' }, + { id: 'features', label: 'FEATURES', what: 'Which parts of the app this game uses.' }, + { id: 'rules', label: 'STATS & RULES', what: 'Stats, formulas and tables.' }, + { id: 'sheet', label: 'CHARACTER SHEET', what: 'How a character sheet is laid out.' }, + { id: 'npcs', label: 'NPCS', what: 'NPC tiers and their sheets.' }, + { id: 'try', label: 'TRY IT', what: 'A test character, to see the rules work.' }, + { id: 'problems', label: 'PROBLEMS', what: 'What to fix before publishing.' }, +]; diff --git a/frontend/src/sheets/systemsApi.ts b/frontend/src/sheets/systemsApi.ts index 4741887a..9783df29 100644 --- a/frontend/src/sheets/systemsApi.ts +++ b/frontend/src/sheets/systemsApi.ts @@ -5,9 +5,26 @@ import type { InstallMode, InstallPreview, InstallResult, LibrarySystem } from './systemsLibrary'; +type Problem = { where: string; message: string }; + +/** A system's definition as stored: the builder edits it whole (backend/systemBuilder/definition.js). */ +export type Definition = { format: number; name: string; author?: string; [section: string]: unknown }; + +/** One system as GET /api/systems/:id answers (store.getSystem). */ +export interface SystemCopies { + id: string; + name: string; + version: number; + updatedAt: string; + publishedAt: string | null; + draft: Definition | null; + published: Definition | null; + problems: Problem[]; +} + export type Answer = | { ok: true; value: T } - | { ok: false; error: string; status: number; changed?: boolean; problems?: { where: string; message: string }[] }; + | { ok: false; error: string; status: number; changed?: boolean; problems?: Problem[] }; const UNREACHABLE = 'Could not reach the server.'; @@ -39,8 +56,13 @@ export const systemsApi = (token: string, fetcher: typeof fetch = fetch) => { const id = (systemId: string) => `/api/systems/${encodeURIComponent(systemId)}`; return { list: () => call(fetcher, token, '/api/systems'), - /** One system with both copies: the facts panel reads the draft's author. */ - get: (systemId: string) => call<{ draft: { author?: string } | null }>(fetcher, token, id(systemId)), + /** One system with both copies and the draft's problems: the facts panel and the builder. */ + get: (systemId: string) => call(fetcher, token, id(systemId)), + /** Store the builder's draft; its problems come back, stored either way. */ + saveDraft: (systemId: string, definition: Definition) => + call<{ problems: Problem[] }>(fetcher, token, `${id(systemId)}/draft`, 'PUT', { definition }), + /** Make the stored draft what the game runs; refused, with `problems`, while it has any. */ + publish: (systemId: string) => call<{ version: number }>(fetcher, token, `${id(systemId)}/publish`, 'POST', {}), create: (name: string) => call<{ id: string }>(fetcher, token, '/api/systems', 'POST', { name }), rename: (systemId: string, name: string) => call<{ name: string }>(fetcher, token, `${id(systemId)}/name`, 'PUT', { name }), duplicate: (systemId: string) => call<{ id: string; name: string }>(fetcher, token, `${id(systemId)}/duplicate`, 'POST', {}),