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);