From a66e8958df22bd6f2220653a44d678b226d4c936 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 11:03:55 -0500 Subject: [PATCH] feat(system-builder): SYSTEMS.EXE logic and requests (4a1b2) sheets/systemsLibrary.ts (pure): badgesFor (RUNNING, PUBLISHED vN / NEVER PUBLISHED, UNPUBLISHED CHANGES, N PROBLEMS, INSTALLED), versionLabel/versionFact/originFact, deleteBlocked/exportBlocked, insideBadges, installPlan (notices and buttons for a new system, one brought back, and one already here: UPDATE when unchanged, REPLACE with a confirm when changed here, blocked when the file has problems, always KEEP BOTH under its copy name), installedMessage and the other status lines. sheets/systemsApi.ts: list, get, create, rename, duplicate, remove, preview, install (replaceChanges), exportFile, each answering { ok, value } or { ok: false, error } in the server's own words, with `changed` and `problems` passed on. --- CHANGELOG.md | 6 + .../src/sheets/__tests__/systemsApi.test.ts | 97 ++++++++++ .../sheets/__tests__/systemsLibrary.test.ts | 169 +++++++++++++++++ frontend/src/sheets/systemsApi.ts | 67 +++++++ frontend/src/sheets/systemsLibrary.ts | 171 ++++++++++++++++++ 5 files changed, 510 insertions(+) create mode 100644 frontend/src/sheets/__tests__/systemsApi.test.ts create mode 100644 frontend/src/sheets/__tests__/systemsLibrary.test.ts create mode 100644 frontend/src/sheets/systemsApi.ts create mode 100644 frontend/src/sheets/systemsLibrary.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a7dc5a00..13f447b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **What the SYSTEMS.EXE window will say.** The badges on each system (RUNNING, PUBLISHED v3, + UNPUBLISHED CHANGES, a problem count, INSTALLED), the facts beside the one picked, and, for a file + about to be installed, what it holds and which buttons it offers: INSTALL, BRING IT BACK, UPDATE, + or REPLACE with a warning when the system was changed here, always beside KEEP BOTH. Also the + requests the window sends. Nothing on screen uses it yet: the window comes next. + - **A new system can't take a name already in use, and an installed system can always be updated.** Creating a system with the name of another is refused, as renaming is. Installing a newer file of a system already here now always offers to update it. If it was changed here diff --git a/frontend/src/sheets/__tests__/systemsApi.test.ts b/frontend/src/sheets/__tests__/systemsApi.test.ts new file mode 100644 index 00000000..d74db669 --- /dev/null +++ b/frontend/src/sheets/__tests__/systemsApi.test.ts @@ -0,0 +1,97 @@ +/** + * SYSTEMS.EXE's requests (4a1b2): each goes to backend/routes/systems.js with the GM's token, and + * every answer comes back as { ok, value } or { ok: false, error } in the server's own words. + */ +import { describe, it, expect, vi } from 'vitest'; +import { systemsApi } from '../systemsApi'; + +const ID = 'sys_aaaaaaaaaaaaaaaa'; + +/** A fetch that answers `body` with `status`, recording what was asked. */ +const server = (status = 200, body: unknown = {}, headers: Record = {}) => vi.fn(async () => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + headers: { get: (k: string) => headers[k] ?? null }, +}) as unknown as Response); + +const asked = (f: ReturnType) => { + const [url, init] = f.mock.calls[0] as unknown as [string, RequestInit]; + return { url, method: init?.method ?? 'GET', headers: init?.headers, body: init?.body ? JSON.parse(String(init.body)) : undefined }; +}; + +describe('the requests', () => { + it('each goes where the server expects, with the token', async () => { + 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.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', {}], + [(a) => a.remove(ID), `/api/systems/${ID}`, 'DELETE', undefined], + [(a) => a.preview('{"citysys":1}'), '/api/systems/install/preview', 'POST', { file: '{"citysys":1}' }], + [(a) => a.install('{}', 'keep_both'), '/api/systems/install', 'POST', { file: '{}', mode: 'keep_both' }], + [(a) => a.install('{}', 'update', true), '/api/systems/install', 'POST', { file: '{}', mode: 'update', replaceChanges: true }], + ]; + for (const [run, url, method, body] of cases) { + const f = server(); + await run(systemsApi('tok', f)); + const got = asked(f); + expect({ url: got.url, method: got.method, body: got.body }, url).toEqual({ url, method, body }); + expect(got.headers, url).toMatchObject({ Authorization: 'Bearer tok' }); + expect(Object.keys(got.headers as object).includes('Content-Type'), url).toBe(body !== undefined); + } + }); + + it('escapes an id it is given', async () => { + const f = server(); + await systemsApi('tok', f).get('a/b'); + expect(asked(f).url).toBe('/api/systems/a%2Fb'); + }); + + it('answers with what the server sent', async () => { + const list = [{ id: ID, name: 'Hearth' }]; + expect(await systemsApi('tok', server(200, list)).list()).toEqual({ ok: true, value: list }); + }); + + it('passes on a refusal in the server\'s words, with what came with it', async () => { + expect(await systemsApi('tok', server(409, { error: 'Another system is already called Ember.' })).rename(ID, 'Ember')) + .toEqual({ ok: false, status: 409, error: 'Another system is already called Ember.' }); + const changed = await systemsApi('tok', server(409, { error: 'Changed here.', changed: true })).install('{}', 'update'); + expect(changed).toEqual({ ok: false, status: 409, error: 'Changed here.', changed: true }); + const problems = [{ where: 'derived a', message: 'Depends on itself' }]; + expect(await systemsApi('tok', server(409, { error: 'Fix these', problems })).install('{}', 'update')) + .toEqual({ ok: false, status: 409, error: 'Fix these', problems }); + }); + + it('says the server could not be reached when it wasn\'t, or answered nothing readable', async () => { + const down = vi.fn(async () => { throw new TypeError('Failed to fetch'); }); + expect(await systemsApi('tok', down as never).list()).toEqual({ ok: false, status: 0, error: 'Could not reach the server.' }); + const garbled = vi.fn(async () => ({ ok: false, status: 502, json: async () => { throw new Error('not json'); } }) as unknown as Response); + expect(await systemsApi('tok', garbled).list()).toEqual({ ok: false, status: 502, error: 'Could not reach the server.' }); + expect(await systemsApi('tok', server(500, { error: 42, changed: 'yes', problems: 'many' })).list()) + .toEqual({ ok: false, status: 500, error: 'Could not reach the server.' }); + }); +}); + +describe('exporting', () => { + it('fetches the file with the token and names it as the server does', async () => { + const f = server(200, '{"citysys":1}', { 'Content-Disposition': 'attachment; filename="vault-knights.citysys"' }); + expect(await systemsApi('tok', f).exportFile(ID)).toEqual({ ok: true, value: { fileName: 'vault-knights.citysys', text: '{"citysys":1}' } }); + expect(asked(f)).toMatchObject({ url: `/api/systems/${ID}/export`, method: 'GET', headers: { Authorization: 'Bearer tok' } }); + }); + + it('falls back to a plain name', async () => { + expect((await systemsApi('tok', server(200, 'x')).exportFile(ID))).toEqual({ ok: true, value: { fileName: 'system.citysys', text: 'x' } }); + }); + + it('passes on a refusal, and an unreachable server', async () => { + expect(await systemsApi('tok', server(409, { error: 'Publish the system before sharing it' })).exportFile(ID)) + .toEqual({ ok: false, status: 409, error: 'Publish the system before sharing it' }); + const down = vi.fn(async () => { throw new TypeError('Failed to fetch'); }); + expect(await systemsApi('tok', down as never).exportFile(ID)).toEqual({ ok: false, status: 0, error: 'Could not reach the server.' }); + const garbled = vi.fn(async () => ({ ok: false, status: 500, json: async () => { throw new Error('x'); } }) as unknown as Response); + expect(await systemsApi('tok', garbled).exportFile(ID)).toEqual({ ok: false, status: 500, error: 'Could not reach the server.' }); + }); +}); diff --git a/frontend/src/sheets/__tests__/systemsLibrary.test.ts b/frontend/src/sheets/__tests__/systemsLibrary.test.ts new file mode 100644 index 00000000..dbf5c172 --- /dev/null +++ b/frontend/src/sheets/__tests__/systemsLibrary.test.ts @@ -0,0 +1,169 @@ +/** + * What SYSTEMS.EXE says (4a1b2): badges, facts, the install preview's notices and buttons, and the + * line after each action. Decided with the user 2026-10-06: an INSTALLED tag; a system already + * here is always offered UPDATE or KEEP BOTH, REPLACE (with a warning) when it was changed here, + * and an update keeps its name here. + */ +import { describe, it, expect } from 'vitest'; +import { + badgesFor, versionLabel, versionFact, originFact, deleteBlocked, exportBlocked, insideBadges, installPlan, + installedMessage, renamedMessage, duplicatedMessage, deletedMessage, createdMessage, + type LibrarySystem, type InstallPreview, +} from '../systemsLibrary'; + +const sys = (over: Partial = {}): LibrarySystem => ({ + id: 'sys_aaaaaaaaaaaaaaaa', name: 'Hearth', version: 3, updatedAt: '2026-10-06 08:00:00', publishedAt: '2026-10-05 20:00:00', + published: true, unpublishedChanges: false, installed: false, problemCount: 0, ...over, +}); +const text = (s: LibrarySystem, running: string | null = null) => badgesFor(s, running).map((b) => `${b.text}/${b.tone}`); + +describe('badges', () => { + it('a published system, as it is', () => { + expect(text(sys())).toEqual(['PUBLISHED v3/plain']); + }); + + it('every badge at once, in order', () => { + expect(text(sys({ unpublishedChanges: true, problemCount: 2, installed: true }), 'sys_aaaaaaaaaaaaaaaa')) + .toEqual(['RUNNING/run', 'PUBLISHED v3/plain', 'UNPUBLISHED CHANGES/warn', '2 PROBLEMS/bad', 'INSTALLED/plain']); + }); + + it('one never published: no unpublished-changes badge, which it always has', () => { + expect(text(sys({ published: false, version: 0, unpublishedChanges: true }))).toEqual(['NEVER PUBLISHED/warn']); + }); + + it('one problem, singular; running only for the system the game runs', () => { + expect(text(sys({ problemCount: 1 }), 'sys_bbbbbbbbbbbbbbbb')).toEqual(['PUBLISHED v3/plain', '1 PROBLEM/bad']); + }); +}); + +describe('facts', () => { + it('version beside the name, and in the facts', () => { + expect(versionLabel(sys())).toBe('v3'); + expect(versionLabel(sys({ published: false }))).toBe('DRAFT'); + expect(versionFact(sys())).toBe('v3 published'); + expect(versionFact(sys({ unpublishedChanges: true }))).toBe('v3 published, with changes not yet published'); + expect(versionFact(sys({ published: false }))).toBe('Never published'); + }); + + it('where it came from', () => { + expect(originFact(sys({ installed: true }))).toBe('Installed from a file'); + expect(originFact(sys())).toBe('Made here'); + }); + + it('why DELETE and EXPORT are off', () => { + expect(deleteBlocked(sys(), 'sys_aaaaaaaaaaaaaaaa')).toBe('This is the system the game is running. Switch to another first.'); + expect(deleteBlocked(sys(), 'cities_without_number')).toBeNull(); + expect(exportBlocked(sys({ published: false }))).toBe('Publish it before sharing it. Only a published system is shared.'); + expect(exportBlocked(sys())).toBeNull(); + }); +}); + +const INSIDE: InstallPreview['inside'] = { words: 0, partsOff: 0, currencies: 0, derived: 0, lookups: 0, sheetFields: 0, npcTiers: 0, healthModel: null }; + +describe('what the file holds', () => { + it('counts each, singular or plural, leaving out what it lacks', () => { + expect(insideBadges(INSIDE)).toEqual([]); + expect(insideBadges({ words: 12, partsOff: 1, currencies: 2, derived: 1, lookups: 3, sheetFields: 41, npcTiers: 1, healthModel: 'wounds' })) + .toEqual(['12 WORDS RENAMED', '1 PART OFF', '2 CURRENCIES', '1 FORMULA', '3 TABLES', '41 SHEET FIELDS', '1 NPC TIER', 'HEALTH: WOUNDS']); + expect(insideBadges({ ...INSIDE, words: 1, partsOff: 2, currencies: 1, derived: 2, lookups: 1, sheetFields: 1, npcTiers: 3 })) + .toEqual(['1 WORD RENAMED', '2 PARTS OFF', '1 CURRENCY', '2 FORMULAS', '1 TABLE', '1 SHEET FIELD', '3 NPC TIERS']); + }); +}); + +const preview = (over: Partial = {}): InstallPreview => ({ + manifest: { name: 'Vault Knights', author: 'R. Ade', license: '', builder: '1.15.0', version: 4, origin: 'org_vault' }, + name: 'Vault Knights', inside: INSIDE, problems: [], installed: [], restores: null, + installsAs: { new: 'Vault Knights', update: null, keep_both: 'Vault Knights' }, ...over, +}); +const PROBLEM = { where: 'derived armor', message: 'Depends on itself' }; +const HERE = { id: 'sys_cccccccccccccccc', name: 'Vault Knights', version: 3, edited: false }; +const installedHere = (over: Partial = {}, rest: Partial = {}) => preview({ + installed: [{ ...HERE, ...over }], installsAs: { new: null, update: over.name ?? 'Vault Knights', keep_both: 'Vault Knights copy' }, ...rest, +}); + +describe('the install preview', () => { + it('a new system: one INSTALL', () => { + expect(installPlan(preview())).toEqual({ + notices: [{ tone: 'good', text: 'Not installed here. It installs as a new system, published and ready to run.' }], + actions: [{ label: 'INSTALL', mode: 'new', installsAs: 'Vault Knights', primary: true }], + }); + }); + + it('a new system whose name is taken: says the name it goes in under', () => { + const plan = installPlan(preview({ installsAs: { new: 'Vault Knights copy', update: null, keep_both: 'Vault Knights copy' } })); + expect(plan.notices[1]).toEqual({ tone: 'warn', text: 'Another system is called Vault Knights, so this one installs as Vault Knights copy.' }); + expect(plan.actions).toEqual([{ label: 'INSTALL', mode: 'new', installsAs: 'Vault Knights copy', primary: true }]); + }); + + it('a new system with problems: a draft to fix', () => { + const plan = installPlan(preview({ problems: [PROBLEM, PROBLEM] })); + expect(plan.notices).toEqual([{ tone: 'warn', text: 'It has 2 problems, so it installs as a draft to fix in the builder. No game can run it until it\'s fixed and published.' }]); + expect(plan.actions[0].label).toBe('INSTALL AS A DRAFT'); + expect(installPlan(preview({ problems: [PROBLEM] })).notices[0].text).toMatch(/^It has 1 problem, so/); + }); + + it('one deleted here: brings it back, warning when that replaces changes made here', () => { + const back = preview({ restores: { id: HERE.id, name: 'Vault Knights', replacesChanges: false } }); + expect(installPlan(back)).toEqual({ + notices: [{ tone: 'good', text: 'Vault Knights was deleted here. Installing brings it back under its old id, with every character, bank and token played in it.' }], + actions: [{ label: 'BRING IT BACK', mode: 'new', installsAs: 'Vault Knights', primary: true }], + }); + const lost = installPlan(preview({ restores: { id: HERE.id, name: 'Vault Knights', replacesChanges: true } })); + expect(lost.notices[1]).toEqual({ tone: 'warn', text: 'The deleted copy held changes made here that this file doesn\'t have. They are replaced.' }); + // Brought back, even with problems: the label says what happens. + expect(installPlan(preview({ problems: [PROBLEM], restores: { id: HERE.id, name: 'Vault Knights', replacesChanges: false } })).actions[0].label).toBe('BRING IT BACK'); + }); + + it('installed and unchanged: UPDATE first, or KEEP BOTH as a copy', () => { + expect(installPlan(installedHere())).toEqual({ + notices: [ + { tone: 'good', text: 'Vault Knights v3 is installed here and unchanged since. Updating replaces it with v4, keeping its name, characters, banks and tokens.' }, + { tone: 'good', text: 'Keeping both installs it beside Vault Knights as Vault Knights copy.' }, + ], + actions: [ + { label: 'UPDATE TO v4', mode: 'update', installsAs: 'Vault Knights', primary: true }, + { label: 'KEEP BOTH', mode: 'keep_both', installsAs: 'Vault Knights copy' }, + ], + }); + }); + + it('changed here: REPLACE, red, asking first; nothing pre-picked', () => { + const plan = installPlan(installedHere({ edited: true, name: 'Vault Knights (ours)' })); + expect(plan.notices[0]).toEqual({ tone: 'warn', text: 'Vault Knights (ours) has been changed here since it was installed. Replacing it with v4 loses those changes; keeping both installs the file beside it.' }); + expect(plan.actions[0]).toEqual({ + label: 'REPLACE WITH v4', mode: 'update', installsAs: 'Vault Knights (ours)', replaceChanges: true, + confirm: 'Your changes to Vault Knights (ours) since you installed it will be lost. Characters, banks and tokens are kept.', + }); + expect(plan.actions[1]).toEqual({ label: 'KEEP BOTH', mode: 'keep_both', installsAs: 'Vault Knights copy' }); + }); + + it('installed, and the file has problems: UPDATE is off, saying why; KEEP BOTH as a draft', () => { + const plan = installPlan(installedHere({ edited: true }, { problems: [PROBLEM] })); + expect(plan.notices[0]).toEqual({ tone: 'warn', text: 'Vault Knights v3 is installed here. This file has 1 problem, so it can\'t replace a system a game may be running. Keep both, and fix the copy in the builder.' }); + expect(plan.actions).toEqual([ + { label: 'UPDATE TO v4', mode: 'update', installsAs: 'Vault Knights', blocked: 'The file has problems' }, + { label: 'KEEP BOTH, AS A DRAFT', mode: 'keep_both', installsAs: 'Vault Knights copy' }, + ]); + }); +}); + +describe('the line after each action', () => { + const result = (over = {}) => ({ id: HERE.id, name: 'Vault Knights', published: true, problems: [], ...over }); + + it('after an install', () => { + expect(installedMessage(result(), 'new', 4)).toBe('Installed Vault Knights v4. It\'s in the list and the game-system picker.'); + expect(installedMessage(result({ published: false, problems: [PROBLEM, PROBLEM] }), 'new', 4)).toBe('Installed Vault Knights as a draft with 2 problems to fix.'); + expect(installedMessage(result({ restored: true }), 'new', 4)).toBe('Vault Knights is back, with its characters.'); + expect(installedMessage(result(), 'update', 4)).toBe('Updated Vault Knights to v4. Its characters, banks and tokens are untouched.'); + expect(installedMessage(result({ name: 'Vault Knights copy' }), 'keep_both', 4)).toBe('Installed a second copy, Vault Knights copy, beside the first.'); + expect(installedMessage(result({ name: 'Vault Knights copy', published: false, problems: [PROBLEM] }), 'keep_both', 4)) + .toBe('Installed a second copy, Vault Knights copy, beside the first as a draft with 1 problem to fix.'); + }); + + it('after the others', () => { + expect(renamedMessage('Emberhold')).toBe('Renamed to Emberhold.'); + expect(duplicatedMessage('Hearth copy')).toBe('Copied as Hearth copy: a draft, its own system.'); + expect(deletedMessage('Hearth')).toBe('Deleted Hearth. Installing its file brings it back.'); + expect(createdMessage('Hearth')).toBe('Made Hearth as a draft.'); + }); +}); diff --git a/frontend/src/sheets/systemsApi.ts b/frontend/src/sheets/systemsApi.ts new file mode 100644 index 00000000..4741887a --- /dev/null +++ b/frontend/src/sheets/systemsApi.ts @@ -0,0 +1,67 @@ +// SYSTEMS.EXE's requests to the server (4a1b2): backend/routes/systems.js, main admin only. Each +// answers { ok: true, value } or { ok: false, error }, the error being the server's own words +// ("Another system is already called Ember.") so the window can show them where they apply. +// What the answers mean is systemsLibrary.ts. + +import type { InstallMode, InstallPreview, InstallResult, LibrarySystem } from './systemsLibrary'; + +export type Answer = + | { ok: true; value: T } + | { ok: false; error: string; status: number; changed?: boolean; problems?: { where: string; message: string }[] }; + +const UNREACHABLE = 'Could not reach the server.'; + +/** One request, its answer read whatever happened. `status` 0 when the server was never reached. */ +const call = async (fetcher: typeof fetch, token: string, url: string, method = 'GET', body?: unknown): Promise> => { + let res: Response; + try { + res = await fetcher(url, { + method, + headers: { ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), Authorization: `Bearer ${token}` }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + } catch { + return { ok: false, error: UNREACHABLE, status: 0 }; + } + const json = await res.json().catch(() => null); + if (res.ok) return { ok: true, value: json as T }; + return { + ok: false, + status: res.status, + error: json && typeof json.error === 'string' ? json.error : UNREACHABLE, + ...(json && json.changed === true ? { changed: true } : {}), + ...(json && Array.isArray(json.problems) ? { problems: json.problems } : {}), + }; +}; + +/** The requests, for one GM's token. */ +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)), + 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', {}), + remove: (systemId: string) => call<{ deleted: true }>(fetcher, token, id(systemId), 'DELETE'), + preview: (file: string) => call(fetcher, token, '/api/systems/install/preview', 'POST', { file }), + install: (file: string, mode: InstallMode, replaceChanges = false) => + call(fetcher, token, '/api/systems/install', 'POST', { file, mode, ...(replaceChanges ? { replaceChanges: true } : {}) }), + /** A published system as its .citysys file: its name and text, for the window to save. */ + exportFile: async (systemId: string): Promise> => { + let res: Response; + try { + res = await fetcher(`${id(systemId)}/export`, { headers: { Authorization: `Bearer ${token}` } }); + } catch { + return { ok: false, error: UNREACHABLE, status: 0 }; + } + if (!res.ok) { + const json = await res.json().catch(() => null); + return { ok: false, status: res.status, error: json && typeof json.error === 'string' ? json.error : UNREACHABLE }; + } + const named = /filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') || ''); + return { ok: true, value: { fileName: named ? named[1] : 'system.citysys', text: await res.text() } }; + }, + }; +}; diff --git a/frontend/src/sheets/systemsLibrary.ts b/frontend/src/sheets/systemsLibrary.ts new file mode 100644 index 00000000..bcde4f8e --- /dev/null +++ b/frontend/src/sheets/systemsLibrary.ts @@ -0,0 +1,171 @@ +// What SYSTEMS.EXE says about a GM's own game systems (4a1b2): the badges in its list, the facts +// beside the one picked, what an install preview offers, and the line after each action. Pure, so +// the window only draws it. The server's answers are shaped by backend/systemBuilder/store.js +// (listSystems, previewInstall, installSystem); its requests are in systemsApi.ts. +// +// Decided with the user: a system installed from a file is tagged INSTALLED (2026-10-06). A +// system already here is always offered UPDATE or KEEP BOTH; one changed here since it was +// installed is REPLACED only after a warning that those changes go, and an update keeps the name +// the system has here (2026-10-06). + +/** One system in the list (GET /api/systems). */ +export interface LibrarySystem { + id: string; + name: string; + version: number; + updatedAt: string; + publishedAt: string | null; + published: boolean; + unpublishedChanges: boolean; + installed: boolean; + problemCount: number; +} + +export type BadgeTone = 'run' | 'plain' | 'warn' | 'bad'; +export interface Badge { text: string; tone: BadgeTone } + +const plural = (n: number, one: string, many = `${one}S`) => `${n} ${n === 1 ? one : many}`; + +/** The badges under a system's name, in this order. `running` is the system the game runs. */ +export const badgesFor = (s: LibrarySystem, running: string | null): Badge[] => { + const badges: Badge[] = []; + if (s.id === running) badges.push({ text: 'RUNNING', tone: 'run' }); + badges.push(s.published ? { text: `PUBLISHED v${s.version}`, tone: 'plain' } : { text: 'NEVER PUBLISHED', tone: 'warn' }); + if (s.published && s.unpublishedChanges) badges.push({ text: 'UNPUBLISHED CHANGES', tone: 'warn' }); + if (s.problemCount > 0) badges.push({ text: plural(s.problemCount, 'PROBLEM'), tone: 'bad' }); + if (s.installed) badges.push({ text: 'INSTALLED', tone: 'plain' }); + return badges; +}; + +/** Beside the name in the list: its version, or DRAFT before it is ever published. */ +export const versionLabel = (s: LibrarySystem): string => (s.published ? `v${s.version}` : 'DRAFT'); + +/** The VERSION fact. */ +export const versionFact = (s: LibrarySystem): string => { + if (!s.published) return 'Never published'; + return `v${s.version} published${s.unpublishedChanges ? ', with changes not yet published' : ''}`; +}; + +/** The FROM fact. */ +export const originFact = (s: LibrarySystem): string => (s.installed ? 'Installed from a file' : 'Made here'); + +/** Why a button can't be used now, or null when it can. */ +export const deleteBlocked = (s: LibrarySystem, running: string | null): string | null => + (s.id === running ? 'This is the system the game is running. Switch to another first.' : null); +export const exportBlocked = (s: LibrarySystem): string | null => + (s.published ? null : 'Publish it before sharing it. Only a published system is shared.'); + +// ─── Installing a file ────────────────────────────────────────────────────── + +export type InstallMode = 'new' | 'update' | 'keep_both'; + +/** What the server says about a file before it is installed (POST /api/systems/install/preview). */ +export interface InstallPreview { + /** The file's cover, as backend/systemBuilder/citysys.js readFile keeps it; text is '' when absent. */ + manifest: { name: string; author: string; license: string; builder: string; version: number; origin: string }; + name: string; + inside: { + words: number; partsOff: number; currencies: number; derived: number; lookups: number; + sheetFields: number; npcTiers: number; healthModel: string | null; + }; + problems: { where: string; message: string }[]; + installed: { id: string; name: string; version: number; edited: boolean }[]; + restores: { id: string; name: string; replacesChanges: boolean } | null; + installsAs: Record; +} + +/** The INSIDE badges: what the file holds, counted, leaving out what it doesn't have. */ +export const insideBadges = (inside: InstallPreview['inside']): string[] => { + const counts: [number, string, string?][] = [ + [inside.words, 'WORD RENAMED', 'WORDS RENAMED'], + [inside.partsOff, 'PART OFF', 'PARTS OFF'], + [inside.currencies, 'CURRENCY', 'CURRENCIES'], + [inside.derived, 'FORMULA'], + [inside.lookups, 'TABLE'], + [inside.sheetFields, 'SHEET FIELD'], + [inside.npcTiers, 'NPC TIER'], + ]; + const badges = counts.filter(([n]) => n > 0).map(([n, one, many]) => plural(n, one, many)); + if (inside.healthModel) badges.push(`HEALTH: ${inside.healthModel.toUpperCase()}`); + return badges; +}; + +export interface Notice { tone: 'good' | 'warn'; text: string } + +/** One button under the preview. */ +export interface InstallAction { + label: string; + mode: InstallMode; + /** The name the system goes in under. */ + installsAs: string; + /** Sent with an update that replaces changes made here. */ + replaceChanges?: true; + primary?: true; + /** Red, and asks this first. */ + confirm?: string; + /** Why it can't be used, when it can't. */ + blocked?: string; +} + +export interface InstallPlan { notices: Notice[]; actions: InstallAction[] } + +/** What the preview says, and the buttons it offers. */ +export const installPlan = (p: InstallPreview): InstallPlan => { + const v = p.manifest.version; + const hasProblems = p.problems.length > 0; + const here = p.installed[0]; + const notices: Notice[] = []; + const keepBoth = (): InstallAction => ({ + label: hasProblems ? 'KEEP BOTH, AS A DRAFT' : 'KEEP BOTH', mode: 'keep_both', installsAs: p.installsAs.keep_both!, + }); + + if (here) { + const update: InstallAction = { label: `UPDATE TO v${v}`, mode: 'update', installsAs: p.installsAs.update! }; + if (hasProblems) { + notices.push({ tone: 'warn', text: `${here.name} v${here.version} is installed here. This file has ${plural(p.problems.length, 'problem').toLowerCase()}, so it can't replace a system a game may be running. Keep both, and fix the copy in the builder.` }); + update.blocked = 'The file has problems'; + } else if (here.edited) { + notices.push({ tone: 'warn', text: `${here.name} has been changed here since it was installed. Replacing it with v${v} loses those changes; keeping both installs the file beside it.` }); + update.label = `REPLACE WITH v${v}`; + update.replaceChanges = true; + update.confirm = `Your changes to ${here.name} since you installed it will be lost. Characters, banks and tokens are kept.`; + } else { + notices.push({ tone: 'good', text: `${here.name} v${here.version} is installed here and unchanged since. Updating replaces it with v${v}, keeping its name, characters, banks and tokens.` }); + update.primary = true; + } + notices.push({ tone: 'good', text: `Keeping both installs it beside ${here.name} as ${p.installsAs.keep_both}.` }); + return { notices, actions: [update, keepBoth()] }; + } + + const name = p.installsAs.new!; + if (p.restores) { + notices.push({ tone: 'good', text: `${p.restores.name} was deleted here. Installing brings it back under its old id, with every character, bank and token played in it.` }); + if (p.restores.replacesChanges) notices.push({ tone: 'warn', text: 'The deleted copy held changes made here that this file doesn\'t have. They are replaced.' }); + } else if (hasProblems) { + notices.push({ tone: 'warn', text: `It has ${plural(p.problems.length, 'problem').toLowerCase()}, so it installs as a draft to fix in the builder. No game can run it until it's fixed and published.` }); + } else { + notices.push({ tone: 'good', text: 'Not installed here. It installs as a new system, published and ready to run.' }); + } + if (name !== p.name) notices.push({ tone: 'warn', text: `Another system is called ${p.name}, so this one installs as ${name}.` }); + const label = p.restores ? 'BRING IT BACK' : hasProblems ? 'INSTALL AS A DRAFT' : 'INSTALL'; + return { notices, actions: [{ label, mode: 'new', installsAs: name, primary: true }] }; +}; + +/** What the server answers after an install (POST /api/systems/install). */ +export interface InstallResult { id: string; name: string; published: boolean; restored?: boolean; problems: unknown[] } + +/** The line under the preview after an install. `version` is the file's. */ +export const installedMessage = (r: InstallResult, mode: InstallMode, version: number): string => { + if (r.restored) return `${r.name} is back, with its characters.`; + if (mode === 'update') return `Updated ${r.name} to v${version}. Its characters, banks and tokens are untouched.`; + const draft = r.published ? '' : ` as a draft with ${plural(r.problems.length, 'problem').toLowerCase()} to fix`; + if (mode === 'keep_both') return `Installed a second copy, ${r.name}, beside the first${draft}.`; + return r.published ? `Installed ${r.name} v${version}. It's in the list and the game-system picker.` : `Installed ${r.name}${draft}.`; +}; + +// ─── The other actions ────────────────────────────────────────────────────── + +export const renamedMessage = (name: string) => `Renamed to ${name}.`; +export const duplicatedMessage = (name: string) => `Copied as ${name}: a draft, its own system.`; +export const deletedMessage = (name: string) => `Deleted ${name}. Installing its file brings it back.`; +export const createdMessage = (name: string) => `Made ${name} as a draft.`;