From 1505cdac28c24c155c6884cbc60b818e09a911d9 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 11:38:15 -0500 Subject: [PATCH] feat(system-builder): SYSTEMS.EXE window and its GAME-tab button (4a1c1) components/SystemsWindow.tsx, a TerminalWindow with the SYSTEMS folder: every system with its badges, the picked one's facts (name, version, author, changed in local time, from), and RENAME (refused names stay in the open box with the reason, focused), DUPLICATE (picks the copy), EXPORT .CITYSYS (published only, downloads under the server's file name) and DELETE (off for the running system; asks first). The TTRPG_SYSTEM section gets a SYSTEMS.EXE button under the picker, passed only to the main admin (App's isPrimaryAdmin), and refetches the picker's list on SYSTEMS_CHANGED_EVENT, which the window sends after every change. systemsLibrary.ts gains changedFact and SYSTEMS_CHANGED_EVENT. --- CHANGELOG.md | 8 + frontend/src/App.tsx | 13 + frontend/src/components/AdminPanel.tsx | 29 +- frontend/src/components/SystemsWindow.tsx | 291 ++++++++++++++++++ .../__tests__/SystemsWindow.test.tsx | 278 +++++++++++++++++ .../sheets/__tests__/systemsLibrary.test.ts | 13 +- frontend/src/sheets/systemsLibrary.ts | 17 + 7 files changed, 642 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/SystemsWindow.tsx create mode 100644 frontend/src/components/__tests__/SystemsWindow.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 13f447b4..85af6abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **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, + 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. + - **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, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3094cd96..bb7749dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -61,6 +61,7 @@ import { useVehicleRoster } from './hooks/useVehicleRoster'; import { useEnemyVehicles } from './hooks/useEnemyVehicles'; import { hasVehicles } from './sheets/vehicleSystems'; import { NpcLibrary } from './components/NpcLibrary'; +import { SystemsWindow } from './components/SystemsWindow'; import { NpcSheetWindow } from './components/NpcSheetWindow'; import { TvPortrait } from './components/TvPortrait'; import { headshotsForShape } from './headshots'; @@ -298,6 +299,8 @@ function App() { const [isNpcLibraryOpen, setIsNpcLibraryOpen] = useState(false); const [npcLibraryPos, setNpcLibraryPos] = useState(() => ({ x: window.innerWidth / 2 - 150, y: window.innerHeight / 2 - 200 })); + const [isSystemsOpen, setIsSystemsOpen] = useState(false); + const [systemsPos, setSystemsPos] = useState(() => ({ x: window.innerWidth / 2 - 310, y: window.innerHeight / 2 - 260 })); const [openNpcSheet, setOpenNpcSheet] = useState<{ id: number; npc_label: string; token_shape?: string; locationId?: number } | null>(null); // NPC sheet linked to the currently selected token (admin) - drives // GENERATE_SHEET vs OPEN_SHEET on the token menu @@ -2038,6 +2041,7 @@ function App() { onGrantAccess={handleGrantAccess} onRevokeAccess={handleRevokeAccess} onOpenNpcLibrary={() => setIsNpcLibraryOpen(true)} + onOpenSystems={isPrimaryAdmin ? () => setIsSystemsOpen(true) : undefined} /> )} {adminBankPlayer && ( @@ -2319,6 +2323,15 @@ function App() { attachLocationId={selectedLocation && ['enemy_rhombus', 'friendly_rhombus'].includes(selectedLocation.shape) ? selectedLocation.id : null} /> )} + {isSystemsOpen && token && isPrimaryAdmin && ( + setIsSystemsOpen(false)} + /> + )} {openPlayerSheetUser && token && ( ('battle_map'); // A custom system with the bank off has no pay or currency (3b2b). @@ -141,7 +142,7 @@ function BattleAdminPanel({ } {tab === 'game' && <> - + {/* Money, which a custom system with the bank off does not have (3b2b). */} {bankOn && <> @@ -182,7 +183,7 @@ export function AdminPanel({ tempCityMapScale, setTempCityMapScale, globalSettings, fetchGlobalSettings, tempBattleMapScale, setTempBattleMapScale, activeBattleMapData, setIsAdminPayOpen, setIsAdminXpOpen, setIsCatalogueOpen, secureModeEnabled, currentLocBattleMaps, enterBattleMap, signs, fetchSigns, remoteFonts, setRemoteFonts, isPlacingSign, setIsPlacingSign, pendingSignPos, setPendingSignPos, selectedSignId, setSelectedSignId, signTransformMode, setSignTransformMode, signTransformActive, setSignTransformActive, handleUpdateSign, signMesh, - activeUsers, onGrantAccess, onRevokeAccess, onOpenNpcLibrary, onToggleHidden, + activeUsers, onGrantAccess, onRevokeAccess, onOpenNpcLibrary, onOpenSystems, onToggleHidden, onExportPng, onStartRecording, onStopRecording, isRecording, isExporting, recordSecondsLeft, gameSystem, cityGenDrawMode, setCityGenDrawMode, genBoundaryTrail, setGenBoundaryTrail, @@ -203,7 +204,7 @@ export function AdminPanel({ handleSaveDefault={handleSaveDefault} handleLoadDefault={handleLoadDefault} setIsAdminPayOpen={setIsAdminPayOpen} setIsAdminXpOpen={setIsAdminXpOpen} setIsCatalogueOpen={setIsCatalogueOpen} secureModeEnabled={secureModeEnabled} onLogout={onLogout} globalSettings={globalSettings} fetchGlobalSettings={fetchGlobalSettings} - onOpenNpcLibrary={onOpenNpcLibrary} activeUsers={activeUsers} + onOpenNpcLibrary={onOpenNpcLibrary} onOpenSystems={onOpenSystems} activeUsers={activeUsers} /> ); } @@ -1045,7 +1046,7 @@ export function AdminPanel({ {adminTab === 'game' && ( <> - + {/* Money, which a custom system with the bank off does not have (3b2b). */} {bankOn && <> @@ -2297,7 +2298,11 @@ const SR6_HOUSE_RULES: HouseRuleDef[] = [ }, ]; -function TTRPGSystemPanel({ token, onOpenNpcLibrary, activeUsers }: { token: string; onOpenNpcLibrary?: () => void; activeUsers?: any[] }) { +function TTRPGSystemPanel({ token, onOpenNpcLibrary, onOpenSystems, activeUsers }: { + token: string; onOpenNpcLibrary?: () => void; activeUsers?: any[]; + /** Opens SYSTEMS.EXE; the main admin's alone, so absent for a granted editor. */ + onOpenSystems?: () => void; +}) { const [open, setOpen] = useState(false); const [system, setSystem] = useState('generic'); const [systems, setSystems] = useState([]); @@ -2315,6 +2320,12 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, activeUsers }: { token: str }; useEffect(() => { if (open) refresh(); }, [open]); + // SYSTEMS.EXE renamed, copied or deleted one: the picker shows it at once. + useEffect(() => { + const again = () => refresh(); + window.addEventListener(SYSTEMS_CHANGED_EVENT, again); + return () => window.removeEventListener(SYSTEMS_CHANGED_EVENT, again); + }, []); /** Switch the game. The picker has already asked; this says whether the server agreed. */ const selectSystem = async (id: string): Promise => { @@ -2339,6 +2350,12 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, activeUsers }: { token: str

Each system keeps its own characters, banks and token health; switching back restores them.

+ {onOpenSystems && <> + +

+ Switching the game stays here, in the picker. SYSTEMS.EXE is for making and managing systems. +

+ } } defs={[ ...globalHouseRules(word, on, currenciesFor(system).length > 0), ...(system === 'cities_without_number' ? CWN_HOUSE_RULES : []), diff --git a/frontend/src/components/SystemsWindow.tsx b/frontend/src/components/SystemsWindow.tsx new file mode 100644 index 00000000..bdb2045f --- /dev/null +++ b/frontend/src/components/SystemsWindow.tsx @@ -0,0 +1,291 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +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, +} from '../sheets/systemsLibrary'; + +// SYSTEMS.EXE (4a1): the main admin's own game systems. Opened from one button in the GAME tab's +// TTRPG_SYSTEM section; switching which system the game runs stays in the picker there. +// Approved mockup: docs/mockups/systems-library.html. What it says is sheets/systemsLibrary.ts, +// 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). + +type FolderId = 'systems'; +const FOLDERS: TerminalFolder[] = [{ id: 'systems', label: 'SYSTEMS' }]; + +interface Props { + token: string; + /** The system the game runs. */ + running: string | null; + pos: { x: number; y: number }; + setPos: (p: { x: number; y: number }) => void; + onClose: () => void; + fetcher?: typeof fetch; +} + +const small: React.CSSProperties = { fontSize: 10, letterSpacing: 2, opacity: 0.75 }; +const why: React.CSSProperties = { fontSize: 11, opacity: 0.8, lineHeight: 1.45 }; +const btn: React.CSSProperties = { fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, padding: '5px 9px' }; + +const badgeColor: Record = { + run: 'var(--cyan)', plain: 'var(--dark-green)', warn: 'var(--warning)', bad: 'var(--danger)', +}; + +function Badges({ badges }: { badges: Badge[] }) { + return ( + + {badges.map((b) => ( + {b.text} + ))} + + ); +} + +export function SystemsWindow({ token, running, pos, setPos, onClose, fetcher }: Props) { + const api = useMemo(() => systemsApi(token, fetcher), [token, fetcher]); + const [folder, setFolder] = useFolder(FOLDERS, null); + const [systems, setSystems] = useState(null); + const [loadError, setLoadError] = useState(null); + const [picked, setPicked] = useState(null); + const [author, setAuthor] = useState(null); + const [renaming, setRenaming] = useState<{ text: string; error: string | null } | null>(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [busy, setBusy] = useState(false); + const [status, setStatus] = useState<{ text: string; bad?: boolean } | null>(null); + const renameRef = useRef(null); + + /** The list, picking `pick` (or keeping the pick, or the first) once it arrives. */ + const load = useCallback(async (pick?: string | null) => { + const r = await api.list(); + if (!r.ok) { setLoadError(r.error); return; } + setLoadError(null); + setSystems(r.value); + setPicked((was) => { + const want = pick !== undefined ? pick : was; + return r.value.some((s) => s.id === want) ? want : (r.value[0]?.id ?? null); + }); + }, [api]); + + useEffect(() => { load(); }, [load]); + + const system = systems?.find((s) => s.id === picked) ?? null; + + // The author lives in the definition, so it is read for the picked system alone. + useEffect(() => { + setAuthor(null); + if (!picked) return; + let live = true; + api.get(picked).then((r) => { if (live && r.ok) setAuthor(r.value.draft?.author?.trim() || null); }); + return () => { live = false; }; + }, [api, picked]); + + const pick = (id: string) => { + setPicked(id); + setRenaming(null); + setConfirmDelete(false); + setStatus(null); + }; + + /** After a change: the list again, and the picker beside this window told. */ + const changed = async (pickAfter?: string | null) => { + await load(pickAfter); + window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT)); + }; + + const saveRename = async () => { + if (!system || !renaming || !renaming.text.trim() || busy) return; + setBusy(true); + const r = await api.rename(system.id, renaming.text); + setBusy(false); + if (!r.ok) { + // Stays open, holding what was typed, with the reason beside it. + setRenaming({ text: renaming.text, error: r.error }); + renameRef.current?.focus(); + return; + } + setRenaming(null); + setStatus({ text: renamedMessage(r.value.name) }); + await changed(); + }; + + const duplicate = async () => { + if (!system || busy) return; + setBusy(true); + const r = await api.duplicate(system.id); + setBusy(false); + if (!r.ok) { setStatus({ text: r.error, bad: true }); return; } + await changed(r.value.id); + setRenaming(null); + setConfirmDelete(false); + setStatus({ text: duplicatedMessage(r.value.name) }); + }; + + const exportFile = async () => { + if (!system || busy) return; + setBusy(true); + const r = await api.exportFile(system.id); + setBusy(false); + if (!r.ok) { setStatus({ text: r.error, bad: true }); return; } + const url = URL.createObjectURL(new Blob([r.value.text], { type: 'application/json' })); + const a = document.createElement('a'); + a.href = url; + a.download = r.value.fileName; + a.click(); + URL.revokeObjectURL(url); + setStatus({ text: `Downloading ${r.value.fileName} (v${system.version}).` }); + }; + + const remove = async () => { + if (!system || busy) return; + setBusy(true); + const r = await api.remove(system.id); + setBusy(false); + setConfirmDelete(false); + if (!r.ok) { setStatus({ text: r.error, bad: true }); return; } + setStatus({ text: deletedMessage(system.name) }); + await changed(null); + }; + + const deleteWhy = system ? deleteBlocked(system, running) : null; + const exportWhy = system ? exportBlocked(system) : null; + + const list = systems && ( +
+ {systems.map((s) => ( + + ))} +
+ ); + + const detail = system && ( +
+
+
NAME
{system.name}
+
VERSION
{versionFact(system)}
+ {author && <>
AUTHOR
{author}
} +
CHANGED
{changedFact(system.updatedAt)}
+
FROM
{originFact(system)}
+
+ + {renaming && ( +
+
+ setRenaming({ text: e.target.value, error: null })} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); saveRename(); } + if (e.key === 'Escape') { e.preventDefault(); setRenaming(null); } + }} + style={{ + flex: 1, minWidth: 0, background: 'var(--black)', color: 'var(--green)', fontFamily: 'monospace', fontSize: 12, + padding: '5px 7px', border: `1px solid ${renaming.error ? 'var(--danger)' : 'var(--green)'}`, + }} + /> + + +
+ {renaming.error + ? {renaming.error} + : The new name shows everywhere at once: this list, the game-system picker and the running game.} +
+ )} + +
+ + + + +
+ {deleteWhy && DELETE: {deleteWhy}} + {exportWhy && EXPORT: {exportWhy}} + + {confirmDelete && ( +
+ DELETE {system.name.toUpperCase()}? + + It leaves every list and can't be run. Its characters, banks and token health are kept: installing its file again brings all of it back. + +
+ + +
+
+ )} +
+ ); + + return ( + + YOUR SYSTEMS{systems ? ` · ${systems.length}` : ''}MAIN ADMIN ONLY + } + > +
+ {loadError && {loadError}} + {!systems && !loadError && LOADING…} + {systems && systems.length === 0 && No systems of your own yet.} + {list} + {detail} +
+ {status?.text ?? ''} +
+
+
+ ); +} diff --git a/frontend/src/components/__tests__/SystemsWindow.test.tsx b/frontend/src/components/__tests__/SystemsWindow.test.tsx new file mode 100644 index 00000000..8913684d --- /dev/null +++ b/frontend/src/components/__tests__/SystemsWindow.test.tsx @@ -0,0 +1,278 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +vi.mock('../../utils/locationHelpers', () => ({ + isUserDefinedName: (name: string) => !!name && name.trim() !== '', + getStructLabel: (loc: any) => `STRUCT_${loc.id}`, +})); +vi.mock('../../assets/Credits.png', () => ({ default: 'credits.png' })); +vi.mock('../DraggableWindow', () => ({ + DraggableWindow: ({ children, title }: any) =>
{title}
{children}
, +})); + +import { SystemsWindow } from '../SystemsWindow'; +import { AdminPanel } from '../AdminPanel'; +import { SYSTEMS_CHANGED_EVENT, type LibrarySystem } from '../../sheets/systemsLibrary'; + +/** + * SYSTEMS.EXE (4a1c1): the main admin's systems, opened from the GAME tab. Decided with the user: + * a window opened from one button under the game-system picker, which stays outside it + * (2026-10-02); a rename refused for a taken name stays open with the reason (2026-10-06). + */ + +const HEARTH = 'sys_aaaaaaaaaaaaaaaa'; +const NEON = 'sys_bbbbbbbbbbbbbbbb'; +const VAULT = 'sys_cccccccccccccccc'; +const COPY = 'sys_dddddddddddddddd'; + +const row = (over: Partial): LibrarySystem => ({ + id: HEARTH, name: 'Hearth', version: 3, updatedAt: '2026-10-06 14:10:00', publishedAt: '2026-10-06 14:10:00', + published: true, unpublishedChanges: false, installed: false, problemCount: 0, ...over, +}); + +let systems: LibrarySystem[]; +let calls: { url: string; method: string; body: unknown }[]; +/** How the server answers a rename; a test changes it. */ +let renameAnswer: { status: number; body: unknown } | null; + +const fakeServer = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const method = init?.method ?? 'GET'; + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ url, method, body }); + const json = (status: number, b: unknown, headers: Record = {}) => ({ + 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); + const m = /^\/api\/systems\/(sys_[0-9a-f]+)(\/[a-z]+)?$/.exec(url); + if (m) { + const [, id, rest] = m; + const s = systems.find((x) => x.id === id)!; + if (!rest && method === 'GET') return json(200, { draft: { author: id === HEARTH ? 'Cody' : '' } }); + if (rest === '/name') { + if (renameAnswer) return json(renameAnswer.status, renameAnswer.body); + s.name = body.name.trim(); + return json(200, { name: s.name }); + } + if (rest === '/duplicate') { + systems = [row({ id: COPY, name: `${s.name} copy`, published: false, version: 0, unpublishedChanges: true }), ...systems]; + return json(200, { id: COPY, name: `${s.name} copy` }); + } + if (rest === '/export') return json(200, '{"citysys":1}', { 'Content-Disposition': 'attachment; filename="hearth.citysys"' }); + if (!rest && method === 'DELETE') { systems = systems.filter((x) => x.id !== id); return json(200, { deleted: true }); } + } + return json(404, { error: 'No such route' }); +}); + +beforeEach(() => { + calls = []; + renameAnswer = null; + systems = [ + row({}), + row({ id: NEON, name: 'Neon Exchange', version: 1, unpublishedChanges: true }), + row({ id: VAULT, name: 'Vault Knights', version: 0, published: false, unpublishedChanges: true, installed: true, problemCount: 3 }), + ]; +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +const open = (running: string | null = HEARTH) => render( + , +); +const listed = () => within(screen.getByRole('group', { name: 'Your systems' })).getAllByRole('button'); +const entry = (name: string) => listed().find((b) => b.textContent!.startsWith(name.toUpperCase()))!; + +describe('the list', () => { + it('shows every system with its badges, the first picked with its facts', async () => { + open(); + await waitFor(() => expect(listed()).toHaveLength(3)); + expect(listed().map((b) => b.textContent)).toEqual([ + 'HEARTHv3RUNNINGPUBLISHED v3', + 'NEON EXCHANGEv1PUBLISHED v1UNPUBLISHED CHANGES', + 'VAULT KNIGHTSDRAFTNEVER PUBLISHED3 PROBLEMSINSTALLED', + ]); + expect(entry('Hearth').getAttribute('aria-pressed')).toBe('true'); + expect(screen.getByTestId('window-title').textContent).toBe('SYSTEMS.EXE · HEARTH'); + expect(screen.getByText('YOUR SYSTEMS · 3')).toBeTruthy(); + expect(await screen.findByText('Cody')).toBeTruthy(); + expect(screen.getByText('v3 published')).toBeTruthy(); + expect(screen.getByText('Made here')).toBeTruthy(); + }); + + it('picks another, with its own facts and no author it hasn\'t got', async () => { + open(); + await waitFor(() => expect(listed()).toHaveLength(3)); + await userEvent.click(entry('Vault Knights')); + expect(screen.getByTestId('window-title').textContent).toBe('SYSTEMS.EXE · VAULT KNIGHTS'); + expect(screen.getByText('Never published')).toBeTruthy(); + expect(screen.getByText('Installed from a file')).toBeTruthy(); + await waitFor(() => expect(calls.some((c) => c.url === `/api/systems/${VAULT}`)).toBe(true)); + expect(screen.queryByText('AUTHOR')).toBeNull(); + }); + + 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(screen.getByTestId('window-title').textContent).toBe('SYSTEMS.EXE · LIBRARY'); + cleanup(); + render( { throw new TypeError('down'); }) as never} />); + expect((await screen.findByRole('alert')).textContent).toBe('Could not reach the server.'); + }); +}); + +describe('RENAME', () => { + it('renames at once, telling the picker beside it', async () => { + const told = vi.fn(); + window.addEventListener(SYSTEMS_CHANGED_EVENT, told); + open(); + await waitFor(() => expect(listed()).toHaveLength(3)); + await userEvent.click(screen.getByText('RENAME')); + const input = screen.getByLabelText('New name') as HTMLInputElement; + expect(input.value).toBe('Hearth'); + await userEvent.clear(input); + await userEvent.type(input, 'Emberhold{Enter}'); + expect(calls.find((c) => c.method === 'PUT')).toEqual({ url: `/api/systems/${HEARTH}/name`, method: 'PUT', body: { name: 'Emberhold' } }); + expect(await screen.findByText('Renamed to Emberhold.')).toBeTruthy(); + await waitFor(() => expect(entry('Emberhold')).toBeTruthy()); + expect(screen.queryByLabelText('New name')).toBeNull(); + expect(told).toHaveBeenCalledTimes(1); + window.removeEventListener(SYSTEMS_CHANGED_EVENT, told); + }); + + it('refused, stays open where it is with what was typed and why', async () => { + renameAnswer = { status: 409, body: { error: 'Another system is already called Neon Exchange.' } }; + open(); + await waitFor(() => expect(listed()).toHaveLength(3)); + await userEvent.click(screen.getByText('RENAME')); + const input = screen.getByLabelText('New name') as HTMLInputElement; + await userEvent.clear(input); + await userEvent.type(input, 'neon exchange'); + await userEvent.click(screen.getByText('SAVE')); + expect((await screen.findByRole('alert')).textContent).toBe('Another system is already called Neon Exchange.'); + expect((screen.getByLabelText('New name') as HTMLInputElement).value).toBe('neon exchange'); + expect(document.activeElement).toBe(screen.getByLabelText('New name')); + expect(screen.getByLabelText('New name').getAttribute('aria-invalid')).toBe('true'); + // Typing again clears the reason. + await userEvent.type(input, 's'); + expect(screen.queryByRole('alert')).toBeNull(); + }); + + it('won\'t save a blank name, and CANCEL or Esc puts it away', async () => { + open(); + await waitFor(() => expect(listed()).toHaveLength(3)); + await userEvent.click(screen.getByText('RENAME')); + await userEvent.clear(screen.getByLabelText('New name')); + expect((screen.getByText('SAVE') as HTMLButtonElement).disabled).toBe(true); + await userEvent.click(screen.getByText('CANCEL')); + expect(screen.queryByLabelText('New name')).toBeNull(); + await userEvent.click(screen.getByText('RENAME')); + await userEvent.type(screen.getByLabelText('New name'), '{Escape}'); + expect(screen.queryByLabelText('New name')).toBeNull(); + expect(calls.some((c) => c.method === 'PUT')).toBe(false); + }); +}); + +describe('DUPLICATE', () => { + it('makes the copy and picks it', async () => { + open(); + await waitFor(() => expect(listed()).toHaveLength(3)); + await userEvent.click(screen.getByText('DUPLICATE')); + expect(await screen.findByText('Copied as Hearth copy: a draft, its own system.')).toBeTruthy(); + await waitFor(() => expect(listed()).toHaveLength(4)); + expect(entry('Hearth copy').getAttribute('aria-pressed')).toBe('true'); + }); +}); + +describe('EXPORT', () => { + it('downloads a published system\'s file under its own name', async () => { + const created = vi.fn(() => 'blob:x'); + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: created, revokeObjectURL: vi.fn() })); + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + open(); + await waitFor(() => expect(listed()).toHaveLength(3)); + await userEvent.click(screen.getByText('EXPORT .CITYSYS')); + expect(await screen.findByText('Downloading hearth.citysys (v3).')).toBeTruthy(); + expect(click).toHaveBeenCalledTimes(1); + expect((click.mock.instances[0] as unknown as HTMLAnchorElement).download).toBe('hearth.citysys'); + click.mockRestore(); + }); + + it('is off for one never published, saying why', async () => { + open(); + await waitFor(() => expect(listed()).toHaveLength(3)); + await userEvent.click(entry('Vault Knights')); + expect((screen.getByText('EXPORT .CITYSYS') as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText('EXPORT: Publish it before sharing it. Only a published system is shared.')).toBeTruthy(); + }); +}); + +describe('DELETE', () => { + it('is off for the system the game runs, saying why', async () => { + open(HEARTH); + await waitFor(() => expect(listed()).toHaveLength(3)); + expect((screen.getByText('DELETE') as HTMLButtonElement).disabled).toBe(true); + expect(screen.getByText('DELETE: This is the system the game is running. Switch to another first.')).toBeTruthy(); + }); + + it('asks first; KEEP IT keeps it, DELETE hides it', async () => { + open('cities_without_number'); + await waitFor(() => expect(listed()).toHaveLength(3)); + await userEvent.click(entry('Neon Exchange')); + await userEvent.click(screen.getByText('DELETE')); + const ask = screen.getByRole('alertdialog', { name: 'Delete Neon Exchange' }); + await userEvent.click(within(ask).getByText('KEEP IT')); + expect(screen.queryByRole('alertdialog')).toBeNull(); + await userEvent.click(screen.getByText('DELETE')); + await userEvent.click(within(screen.getByRole('alertdialog')).getByText('DELETE')); + expect(await screen.findByText('Deleted Neon Exchange. Installing its file brings it back.')).toBeTruthy(); + await waitFor(() => expect(listed()).toHaveLength(2)); + expect(calls.find((c) => c.method === 'DELETE')!.url).toBe(`/api/systems/${NEON}`); + expect(entry('Hearth').getAttribute('aria-pressed')).toBe('true'); + }); +}); + +describe('the GAME tab', () => { + let pickerFetches = 0; + beforeEach(() => { + pickerFetches = 0; + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (String(url) === '/api/sheets/system') pickerFetches += 1; + return { ok: true, json: async () => (String(url) === '/api/sheets/system' ? { system: 'generic', systems: [] } : []) }; + })); + }); + const props = (onOpenSystems?: () => void): any => ({ + socketRef: { current: { emit: vi.fn(), on: vi.fn(), off: vi.fn() } }, token: 'admintoken', onLogout: vi.fn(), + refreshLocations: vi.fn(), refreshRoads: vi.fn(), locations: [], roads: [], editData: {}, setEditData: vi.fn(), + editId: null, setEditId: vi.fn(), view: 'list', setView: vi.fn(), pendingRequests: [], setPendingRequests: vi.fn(), + selectedIds: [], setSelectedIds: vi.fn(), districts: [], fetchDistricts: vi.fn(), districtConfig: {}, setDistrictConfig: vi.fn(), + fetchGlobalSettings: vi.fn(), activeUsers: [], setIsAdminPayOpen: vi.fn(), handleSaveDefault: vi.fn(), handleLoadDefault: vi.fn(), + globalSettings: { game_system: 'generic' }, gameSystem: 'generic', onOpenSystems, + }); + const ttrpg = async (onOpenSystems?: () => void) => { + render(); + await userEvent.click(screen.getByText('GAME')); + await userEvent.click(screen.getByText(/TTRPG_SYSTEM/)); + }; + + it('opens SYSTEMS.EXE from one button under the picker', async () => { + const opened = vi.fn(); + await ttrpg(opened); + await userEvent.click(screen.getByText('SYSTEMS.EXE')); + expect(opened).toHaveBeenCalledTimes(1); + }); + + it('has no button for anyone but the main admin', async () => { + await ttrpg(undefined); + expect(screen.queryByText('SYSTEMS.EXE')).toBeNull(); + }); + + it('fetches the picker\'s list again when SYSTEMS.EXE changes a system', async () => { + await ttrpg(vi.fn()); + await waitFor(() => expect(pickerFetches).toBe(1)); + window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT)); + await waitFor(() => expect(pickerFetches).toBe(2)); + }); +}); diff --git a/frontend/src/sheets/__tests__/systemsLibrary.test.ts b/frontend/src/sheets/__tests__/systemsLibrary.test.ts index dbf5c172..8f718d16 100644 --- a/frontend/src/sheets/__tests__/systemsLibrary.test.ts +++ b/frontend/src/sheets/__tests__/systemsLibrary.test.ts @@ -6,7 +6,7 @@ */ import { describe, it, expect } from 'vitest'; import { - badgesFor, versionLabel, versionFact, originFact, deleteBlocked, exportBlocked, insideBadges, installPlan, + badgesFor, versionLabel, versionFact, originFact, changedFact, deleteBlocked, exportBlocked, insideBadges, installPlan, installedMessage, renamedMessage, duplicatedMessage, deletedMessage, createdMessage, type LibrarySystem, type InstallPreview, } from '../systemsLibrary'; @@ -45,6 +45,17 @@ describe('facts', () => { expect(versionFact(sys({ published: false }))).toBe('Never published'); }); + it('when it changed, in the GM\'s own time; text it can\'t read as it came', () => { + const at = new Date(Date.UTC(2026, 9, 6, 14, 10)); + const two = (n: number) => String(n).padStart(2, '0'); + expect(changedFact('2026-10-06 14:10:00')) + .toBe(`${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`); + expect(changedFact('yesterday')).toBe('yesterday'); + expect(changedFact('2026-13-45 99:99:00')).toBe('2026-13-45 99:99:00'); + expect(changedFact('')).toBe(''); + expect(changedFact('2026-10-06')).toBe('2026-10-06'); + }); + it('where it came from', () => { expect(originFact(sys({ installed: true }))).toBe('Installed from a file'); expect(originFact(sys())).toBe('Made here'); diff --git a/frontend/src/sheets/systemsLibrary.ts b/frontend/src/sheets/systemsLibrary.ts index bcde4f8e..718ec576 100644 --- a/frontend/src/sheets/systemsLibrary.ts +++ b/frontend/src/sheets/systemsLibrary.ts @@ -49,6 +49,23 @@ export const versionFact = (s: LibrarySystem): string => { /** The FROM fact. */ export const originFact = (s: LibrarySystem): string => (s.installed ? 'Installed from a file' : 'Made here'); +/** + * The CHANGED fact, in the GM's own time: the server stores UTC as "2026-10-06 14:10:00". Text + * it can't read is shown as it came. + */ +export const changedFact = (updatedAt: string): string => { + const at = new Date(`${updatedAt.replace(' ', 'T')}Z`); + if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(updatedAt) || Number.isNaN(at.getTime())) return updatedAt; + const two = (n: number) => String(n).padStart(2, '0'); + return `${at.getFullYear()}-${two(at.getMonth() + 1)}-${two(at.getDate())} ${two(at.getHours())}:${two(at.getMinutes())}`; +}; + +/** + * Sent on the window after SYSTEMS.EXE changes a system, so the game-system picker beside it + * fetches the list again: a renamed, copied or deleted system shows there at once. + */ +export const SYSTEMS_CHANGED_EVENT = 'citynet:systems-changed'; + /** 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);