diff --git a/CHANGELOG.md b/CHANGELOG.md index 85af6abc..cdb12eae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,8 +76,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). has made or installed, with badges for the one running, its version, unpublished changes, problems and whether it was installed from a file. Beside the one picked are its facts and RENAME, DUPLICATE, EXPORT .CITYSYS and DELETE. A name already in use is refused right in the - rename box, which stays open to try another. The picker shows each change at once. Making a - new system and installing a file come next. + rename box, which stays open to try another. The picker shows each change at once. + +- **SYSTEMS.EXE makes and installs systems.** NEW makes a draft from a name, refusing one already + in use right where it was typed. INSTALL takes a .citysys file, chosen or dropped, and shows its + cover, what it holds and any problems before anything changes, then offers what fits: INSTALL, + BRING IT BACK for one deleted here, UPDATE for one already here, or REPLACE, which asks first + when the copy here was changed, always beside KEEP BOTH. A name already taken is shown as the + copy name it will get. - **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 diff --git a/frontend/src/components/SystemsWindow.tsx b/frontend/src/components/SystemsWindow.tsx index bdb2045f..c0cc8e98 100644 --- a/frontend/src/components/SystemsWindow.tsx +++ b/frontend/src/components/SystemsWindow.tsx @@ -3,8 +3,8 @@ import { TerminalWindow, useFolder, type TerminalFolder } from './TerminalWindow import { systemsApi } from '../sheets/systemsApi'; import { badgesFor, versionLabel, versionFact, originFact, changedFact, deleteBlocked, exportBlocked, - renamedMessage, duplicatedMessage, deletedMessage, SYSTEMS_CHANGED_EVENT, - type Badge, type LibrarySystem, + renamedMessage, duplicatedMessage, deletedMessage, createdMessage, insideBadges, installPlan, installedMessage, + SYSTEMS_CHANGED_EVENT, type Badge, type LibrarySystem, type InstallPreview, type InstallAction, } from '../sheets/systemsLibrary'; // SYSTEMS.EXE (4a1): the main admin's own game systems. Opened from one button in the GAME tab's @@ -13,11 +13,22 @@ import { // its requests sheets/systemsApi.ts. // // SYSTEMS lists them, with the picked one's facts and RENAME, DUPLICATE, EXPORT and DELETE. -// A rename refused because the name is taken keeps the box open with what was typed and the -// reason under it, so the GM fixes it right there (decided with the user, 2026-10-06). +// NEW makes one from a name; INSTALL previews a .citysys file, then installs it as it offers. +// A name refused because it is taken keeps its box open with what was typed and the reason +// under it, so the GM fixes it right there (decided with the user, 2026-10-06). A system already +// here is always offered UPDATE or KEEP BOTH, REPLACE asking first when it was changed here. -type FolderId = 'systems'; -const FOLDERS: TerminalFolder[] = [{ id: 'systems', label: 'SYSTEMS' }]; +type FolderId = 'systems' | 'new' | 'install'; +const FOLDERS: TerminalFolder[] = [ + { id: 'systems', label: 'SYSTEMS' }, + { id: 'new', label: 'NEW' }, + { id: 'install', label: 'INSTALL' }, +]; + +/** Larger than any system file can be (the server's limit is about half a megabyte). */ +const MAX_FILE_BYTES = 1024 * 1024; + +type Api = ReturnType; interface Props { token: string; @@ -53,6 +64,204 @@ function Badges({ badges }: { badges: Badge[] }) { ); } +const field: React.CSSProperties = { + flex: 1, minWidth: 0, background: 'var(--black)', color: 'var(--green)', fontFamily: 'monospace', fontSize: 12, padding: '5px 7px', +}; +const danger: React.CSSProperties = { borderColor: 'var(--danger)', color: 'var(--danger)' }; + +/** NEW: a system from a name. Example and genre starts come later (4d1, 4d3). */ +function NewPanel({ api, onMade }: { api: Api; onMade: (id: string, name: string) => void }) { + const [name, setName] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const inputRef = useRef(null); + + const create = async () => { + const wanted = name.trim(); + if (!wanted || busy) return; + setBusy(true); + const r = await api.create(wanted); + setBusy(false); + if (!r.ok) { setError(r.error); inputRef.current?.focus(); return; } + setName(''); + onMade(r.value.id, wanted); + }; + + const starts: [string, string, string, boolean][] = [ + ['blank', 'BLANK', 'A name and nothing else, to build up in the builder.', true], + ['example', 'A BUILT-IN EXAMPLE', 'Cities Without Number or Shadowrun as data, to change into your own. Coming later.', false], + ['starter', 'A GENRE STARTER', 'Fantasy, sci-fi, or a narrative one with no numbers. Coming later.', false], + ]; + + return ( +
+ + { setName(e.target.value); setError(null); }} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); create(); } }} + style={{ ...field, border: `1px solid ${error ? 'var(--danger)' : 'var(--green)'}` }} + /> + {error && {error}} + START FROM +
+ {starts.map(([id, label, what, can]) => ( + + ))} +
+
+ +
+ It's made as a draft: nobody plays it until it's published from the builder. +
+ ); +} + +/** INSTALL: a .citysys file, previewed first, then installed the way the GM picks. */ +function InstallPanel({ api, onInstalled }: { api: Api; onInstalled: (id: string, message: string) => void }) { + const [file, setFile] = useState<{ name: string; text: string } | null>(null); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(null); + const [busy, setBusy] = useState(false); + const [dragging, setDragging] = useState(false); + + const read = async (chosen: File | undefined) => { + if (!chosen) return; + setPreview(null); + setConfirming(null); + setError(null); + if (chosen.size > MAX_FILE_BYTES) { setFile(null); setError(`${chosen.name} is over 1 MB, too large to be a system file.`); return; } + const text = await chosen.text(); + setFile({ name: chosen.name, text }); + setBusy(true); + const r = await api.preview(text); + setBusy(false); + if (!r.ok) { setError(r.error); return; } + setPreview(r.value); + }; + + const install = async (action: InstallAction) => { + if (!file || !preview || busy) return; + setBusy(true); + const r = await api.install(file.text, action.mode, action.replaceChanges === true); + setBusy(false); + setConfirming(null); + if (!r.ok) { setError(r.error); return; } + const message = installedMessage(r.value, action.mode, preview.manifest.version); + setFile(null); + setPreview(null); + onInstalled(r.value.id, message); + }; + + const plan = preview ? installPlan(preview) : null; + const m = preview?.manifest; + + return ( +
+ + {busy && !preview && READING…} + {error && {error}} + + {preview && m && plan && <> +
+
+ {preview.name.toUpperCase()}v{m.version} +
+
+ {m.author && <>
AUTHOR
{m.author}
} + {m.license && <>
LICENSE
{m.license}
} + {m.builder && <>
MADE WITH
CITY_NET {m.builder}
} +
+ {insideBadges(preview.inside).length > 0 && <> + INSIDE + + {insideBadges(preview.inside).map((b) => ( + {b} + ))} + + } + {preview.problems.length > 0 && <> + + {preview.problems.length} PROBLEM{preview.problems.length === 1 ? '' : 'S'} + +
    + {preview.problems.map((p, i) =>
  • {p.where}: {p.message}
  • )} +
+ } +
+ + {plan.notices.map((n) => ( +
{n.text}
+ ))} + + {confirming ? ( +
+ {confirming.label}? + {confirming.confirm} +
+ + +
+
+ ) : ( +
+ {plan.actions.map((a) => ( + + ))} +
+ )} + {plan.actions.filter((a) => a.blocked).map((a) => {a.label}: {a.blocked}.)} + } +
+ ); +} + export function SystemsWindow({ token, running, pos, setPos, onClose, fetcher }: Props) { const api = useMemo(() => systemsApi(token, fetcher), [token, fetcher]); const [folder, setFolder] = useFolder(FOLDERS, null); @@ -158,6 +367,26 @@ export function SystemsWindow({ token, running, pos, setPos, onClose, fetcher }: await changed(null); }; + const openFolder = (id: FolderId) => { + setFolder(id); + setStatus(null); + setRenaming(null); + setConfirmDelete(false); + }; + + /** Made in NEW: shown picked in SYSTEMS. The builder opens it from here once it exists (4a2). */ + const made = async (id: string, name: string) => { + await changed(id); + setFolder('systems'); + setStatus({ text: createdMessage(name) }); + }; + + /** Installed: the line says what happened, and the list behind it has it. */ + const installed = async (id: string, message: string) => { + await changed(id); + setStatus({ text: message }); + }; + const deleteWhy = system ? deleteBlocked(system, running) : null; const exportWhy = system ? exportBlocked(system) : null; @@ -260,28 +489,38 @@ export function SystemsWindow({ token, running, pos, setPos, onClose, fetcher }: ); + const header = { + systems: + YOUR SYSTEMS{systems ? ` · ${systems.length}` : ''}MAIN ADMIN ONLY + , + new: 'NEW SYSTEM', + install: 'INSTALL A .CITYSYS FILE', + }[folder]; + return ( - YOUR SYSTEMS{systems ? ` · ${systems.length}` : ''}MAIN ADMIN ONLY - } + header={header} >
- {loadError && {loadError}} - {!systems && !loadError && LOADING…} - {systems && systems.length === 0 && No systems of your own yet.} - {list} - {detail} + {folder === 'systems' && <> + {loadError && {loadError}} + {!systems && !loadError && LOADING…} + {systems && systems.length === 0 && No systems of your own yet. Make one in NEW, or install a file in INSTALL.} + {list} + {detail} + } + {folder === 'new' && } + {folder === 'install' && }
{status?.text ?? ''}
diff --git a/frontend/src/components/__tests__/SystemsWindow.test.tsx b/frontend/src/components/__tests__/SystemsWindow.test.tsx index 8913684d..392db7ac 100644 --- a/frontend/src/components/__tests__/SystemsWindow.test.tsx +++ b/frontend/src/components/__tests__/SystemsWindow.test.tsx @@ -14,7 +14,7 @@ vi.mock('../DraggableWindow', () => ({ import { SystemsWindow } from '../SystemsWindow'; import { AdminPanel } from '../AdminPanel'; -import { SYSTEMS_CHANGED_EVENT, type LibrarySystem } from '../../sheets/systemsLibrary'; +import { SYSTEMS_CHANGED_EVENT, type LibrarySystem, type InstallPreview } from '../../sheets/systemsLibrary'; /** * SYSTEMS.EXE (4a1c1): the main admin's systems, opened from the GAME tab. Decided with the user: @@ -34,8 +34,17 @@ const row = (over: Partial): LibrarySystem => ({ let systems: LibrarySystem[]; let calls: { url: string; method: string; body: unknown }[]; -/** How the server answers a rename; a test changes it. */ +/** How the server answers a rename, a preview and an install; a test changes them. */ let renameAnswer: { status: number; body: unknown } | null; +let previewAnswer: { status: number; body: unknown }; +let installAnswer: { status: number; body: unknown }; + +const INSIDE = { words: 12, partsOff: 0, currencies: 2, derived: 0, lookups: 0, sheetFields: 41, npcTiers: 0, healthModel: null }; +const PREVIEW: InstallPreview = { + manifest: { name: 'Iron Sea', author: 'M. Okafor', license: 'CC BY 4.0', builder: '1.15.0', version: 4, origin: 'org_iron' }, + name: 'Iron Sea', inside: INSIDE, problems: [], installed: [], restores: null, + installsAs: { new: 'Iron Sea', update: null, keep_both: 'Iron Sea' }, +}; const fakeServer = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); @@ -46,6 +55,17 @@ const fakeServer = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => ok: status < 300, status, json: async () => b, text: async () => String(b), headers: { get: (k: string) => headers[k] ?? null }, }) as unknown as Response; if (url === '/api/systems' && method === 'GET') return json(200, systems); + if (url === '/api/systems' && method === 'POST') { + const taken = systems.find((s) => s.name.toLowerCase() === body.name.trim().toLowerCase()); + if (taken) return json(409, { error: `Another system is already called ${taken.name}.` }); + systems = [row({ id: COPY, name: body.name, published: false, version: 0, unpublishedChanges: true }), ...systems]; + return json(200, { id: COPY, problems: [] }); + } + if (url === '/api/systems/install/preview') return json(previewAnswer.status, previewAnswer.body); + if (url === '/api/systems/install') { + if (installAnswer.status === 200) systems = [row({ id: COPY, name: (installAnswer.body as { name: string }).name, installed: true }), ...systems]; + return json(installAnswer.status, installAnswer.body); + } const m = /^\/api\/systems\/(sys_[0-9a-f]+)(\/[a-z]+)?$/.exec(url); if (m) { const [, id, rest] = m; @@ -69,6 +89,8 @@ const fakeServer = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => beforeEach(() => { calls = []; renameAnswer = null; + previewAnswer = { status: 200, body: PREVIEW }; + installAnswer = { status: 200, body: { id: COPY, name: 'Iron Sea', published: true, problems: [] } }; systems = [ row({}), row({ id: NEON, name: 'Neon Exchange', version: 1, unpublishedChanges: true }), @@ -114,7 +136,7 @@ describe('the list', () => { it('says when there are none, and when the server can\'t be reached', async () => { systems = []; open(); - expect(await screen.findByText('No systems of your own yet.')).toBeTruthy(); + expect(await screen.findByText('No systems of your own yet. Make one in NEW, or install a file in INSTALL.')).toBeTruthy(); expect(screen.getByTestId('window-title').textContent).toBe('SYSTEMS.EXE · LIBRARY'); cleanup(); render( { }); }); +const folder = async (name: 'SYSTEMS' | 'NEW' | 'INSTALL') => { + await waitFor(() => expect(listed().length).toBeGreaterThan(0)); + await userEvent.click(screen.getByRole('tab', { name })); +}; + +describe('NEW', () => { + it('makes a draft from a name, then shows it picked in SYSTEMS', async () => { + const told = vi.fn(); + window.addEventListener(SYSTEMS_CHANGED_EVENT, told); + open(); + await folder('NEW'); + expect((screen.getByText('CREATE') as HTMLButtonElement).disabled).toBe(true); + await userEvent.type(screen.getByLabelText('NAME'), ' Tidewater {Enter}'); + expect(calls.find((c) => c.method === 'POST')).toEqual({ url: '/api/systems', method: 'POST', body: { name: 'Tidewater' } }); + expect(await screen.findByText('Made Tidewater as a draft.')).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'SYSTEMS' }).getAttribute('aria-selected')).toBe('true'); + await waitFor(() => expect(entry('Tidewater').getAttribute('aria-pressed')).toBe('true')); + expect(told).toHaveBeenCalledTimes(1); + window.removeEventListener(SYSTEMS_CHANGED_EVENT, told); + }); + + it('refuses a name in use where it was typed, keeping it to change', async () => { + open(); + await folder('NEW'); + await userEvent.type(screen.getByLabelText('NAME'), 'hearth'); + await userEvent.click(screen.getByText('CREATE')); + expect((await screen.findByRole('alert')).textContent).toBe('Another system is already called Hearth.'); + expect((screen.getByLabelText('NAME') as HTMLInputElement).value).toBe('hearth'); + expect(document.activeElement).toBe(screen.getByLabelText('NAME')); + await userEvent.type(screen.getByLabelText('NAME'), 'fire'); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('starts blank only, for now', async () => { + open(); + await folder('NEW'); + const starts = within(screen.getByRole('radiogroup', { name: 'Start from' })).getAllByRole('radio') as HTMLButtonElement[]; + expect(starts.map((b) => [b.textContent!.split('A name')[0].split('Cities')[0].split('Fantasy')[0], b.disabled, b.getAttribute('aria-checked')])).toEqual([ + ['BLANK', false, 'true'], ['A BUILT-IN EXAMPLE', true, 'false'], ['A GENRE STARTER', true, 'false'], + ]); + }); +}); + +describe('INSTALL', () => { + /** The INSTALL button, not the INSTALL folder. */ + const installButton = () => screen.getAllByRole('button', { name: 'INSTALL' }).find((b) => b.getAttribute('role') !== 'tab')!; + const choose = async (text = '{"citysys":1}', name = 'iron-sea.citysys') => { + await userEvent.upload(screen.getByLabelText('System file'), new File([text], name, { type: 'application/json' })); + }; + + it('previews a file, then installs it, saying so', async () => { + open(); + await folder('INSTALL'); + await choose(); + const cover = await screen.findByTestId('install-cover'); + expect(calls.find((c) => c.url === '/api/systems/install/preview')!.body).toEqual({ file: '{"citysys":1}' }); + expect(within(cover).getByText('IRON SEA')).toBeTruthy(); + expect(within(cover).getByText('M. Okafor')).toBeTruthy(); + expect(within(cover).getByText('CITY_NET 1.15.0')).toBeTruthy(); + expect(within(cover).getByText('12 WORDS RENAMED')).toBeTruthy(); + expect(screen.getByText('iron-sea.citysys · CHOOSE ANOTHER FILE')).toBeTruthy(); + expect(screen.getByText('Not installed here. It installs as a new system, published and ready to run.')).toBeTruthy(); + await userEvent.click(installButton()); + expect(calls.find((c) => c.url === '/api/systems/install')!.body).toEqual({ file: '{"citysys":1}', mode: 'new' }); + expect(await screen.findByText('Installed Iron Sea v4. It\'s in the list and the game-system picker.')).toBeTruthy(); + expect(screen.queryByTestId('install-cover')).toBeNull(); + await userEvent.click(screen.getByRole('tab', { name: 'SYSTEMS' })); + expect(entry('Iron Sea').getAttribute('aria-pressed')).toBe('true'); + }); + + it('asks before REPLACING changes made here, and sends the say-so only then', async () => { + previewAnswer = { status: 200, body: { + ...PREVIEW, installed: [{ id: HEARTH, name: 'Iron Sea (ours)', version: 3, edited: true }], + installsAs: { new: null, update: 'Iron Sea (ours)', keep_both: 'Iron Sea copy' }, + } }; + open(); + await folder('INSTALL'); + await choose(); + await userEvent.click(await screen.findByText('REPLACE WITH v4')); + const ask = screen.getByRole('alertdialog', { name: 'REPLACE WITH v4' }); + expect(within(ask).getByText('Your changes to Iron Sea (ours) since you installed it will be lost. Characters, banks and tokens are kept.')).toBeTruthy(); + await userEvent.click(within(ask).getByText('CANCEL')); + expect(calls.some((c) => c.url === '/api/systems/install')).toBe(false); + await userEvent.click(screen.getByText('REPLACE WITH v4')); + await userEvent.click(within(screen.getByRole('alertdialog')).getByText('REPLACE WITH v4')); + expect(calls.find((c) => c.url === '/api/systems/install')!.body).toEqual({ file: '{"citysys":1}', mode: 'update', replaceChanges: true }); + }); + + it('keeps both without asking, and won\'t update with a file that has problems', async () => { + previewAnswer = { status: 200, body: { + ...PREVIEW, problems: [{ where: 'derived armor', message: 'Depends on itself' }], + installed: [{ id: HEARTH, name: 'Iron Sea', version: 3, edited: false }], + installsAs: { new: null, update: 'Iron Sea', keep_both: 'Iron Sea copy' }, + } }; + open(); + await folder('INSTALL'); + await choose(); + expect(await screen.findByText('derived armor: Depends on itself')).toBeTruthy(); + expect((screen.getByText('UPDATE TO v4') as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText('UPDATE TO v4: The file has problems.')).toBeTruthy(); + await userEvent.click(screen.getByText('KEEP BOTH, AS A DRAFT')); + expect(calls.find((c) => c.url === '/api/systems/install')!.body).toEqual({ file: '{"citysys":1}', mode: 'keep_both' }); + }); + + it('says why a file can\'t be read, and refuses one far too large without sending it', async () => { + previewAnswer = { status: 400, body: { error: 'Not a CITY_NET system file' } }; + open(); + await folder('INSTALL'); + await choose('hello', 'notes.txt'); + expect((await screen.findByRole('alert')).textContent).toBe('Not a CITY_NET system file'); + expect(screen.queryByTestId('install-cover')).toBeNull(); + const before = calls.length; + await choose('x'.repeat(1024 * 1024 + 1), 'huge.citysys'); + expect((await screen.findByRole('alert')).textContent).toBe('huge.citysys is over 1 MB, too large to be a system file.'); + expect(calls.length).toBe(before); + }); + + it('shows a refused install where it happened', async () => { + installAnswer = { status: 409, body: { error: 'Already installed. Update it or keep both.' } }; + open(); + await folder('INSTALL'); + await choose(); + await screen.findByTestId('install-cover'); + await userEvent.click(installButton()); + expect((await screen.findByRole('alert')).textContent).toBe('Already installed. Update it or keep both.'); + expect(screen.getByTestId('install-cover')).toBeTruthy(); + }); +}); + describe('the GAME tab', () => { let pickerFetches = 0; beforeEach(() => {