diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d179230..800deebd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -75,6 +75,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
### Under the hood
+- **One place for your systems: the builder.** The GAME tab's SYSTEMS.EXE button is now SYSTEM
+ BUILDER, and opens the builder on MY SYSTEMS, with the system the game runs already open when
+ it's one of yours. Everything SYSTEMS.EXE did happens there, so the SYSTEMS.EXE window is gone.
+ With no system open yet, MY SYSTEMS is the only page, and SAVE and PUBLISH wait until you open
+ or make one.
+
- **The builder's new MY SYSTEMS page, ready to be put in.** Every system as one line: its name and
version, its description, who made it, when it changed, where it came from and how many
characters are played in it, then its badges. Clicking a line opens it out with OPEN, RENAME,
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 43e46a7a..59d0ca20 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -61,8 +61,8 @@ 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 { BuilderScreen } from './components/BuilderScreen';
+import { isCustomSystem } from './sheets/customTemplates';
import type { BuilderPage } from './sheets/builderSession';
import { NpcSheetWindow } from './components/NpcSheetWindow';
import { TvPortrait } from './components/TvPortrait';
@@ -301,10 +301,11 @@ 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);
- /** The system the builder is open on, taking over the window (4a2b), and the page it opened at. */
- const [builder, setBuilder] = useState<{ id: string; page: BuilderPage } | null>(null);
- const [systemsPos, setSystemsPos] = useState(() => ({ x: window.innerWidth / 2 - 310, y: window.innerHeight / 2 - 260 }));
+ /**
+ * The builder, taking over the window while open (4a2b): the system open in it (none until the
+ * GM picks one in MY SYSTEMS) and the page it opened at.
+ */
+ const [builder, setBuilder] = useState<{ id: string | null; page: BuilderPage | 'systems' } | null>(null);
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
@@ -2045,7 +2046,10 @@ function App() {
onGrantAccess={handleGrantAccess}
onRevokeAccess={handleRevokeAccess}
onOpenNpcLibrary={() => setIsNpcLibraryOpen(true)}
- onOpenSystems={isPrimaryAdmin ? () => setIsSystemsOpen(true) : undefined}
+ onOpenSystems={isPrimaryAdmin
+ // On MY SYSTEMS, with the running system open when it is one of the GM's own.
+ ? () => setBuilder({ id: isCustomSystem(gameSystem) ? gameSystem : null, page: 'systems' })
+ : undefined}
/>
)}
{adminBankPlayer && (
@@ -2327,26 +2331,15 @@ function App() {
attachLocationId={selectedLocation && ['enemy_rhombus', 'friendly_rhombus'].includes(selectedLocation.shape) ? selectedLocation.id : null}
/>
)}
- {isSystemsOpen && token && isPrimaryAdmin && (
- setIsSystemsOpen(false)}
- onOpenBuilder={(id, page) => { setIsSystemsOpen(false); setBuilder({ id, page }); }}
- />
- )}
{builder && token && isPrimaryAdmin && (
setBuilder(null)}
- onOpenSystem={(id) => setBuilder({ id, page: 'setup' })}
- onManageSystems={() => { setBuilder(null); setIsSystemsOpen(true); }}
+ onOpenSystem={(id, page) => setBuilder({ id, page })}
/>
)}
{openPlayerSheetUser && token && (
diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx
index 455ff329..c39479d8 100644
--- a/frontend/src/components/AdminPanel.tsx
+++ b/frontend/src/components/AdminPanel.tsx
@@ -2300,7 +2300,7 @@ const SR6_HOUSE_RULES: HouseRuleDef[] = [
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. */
+ /** Opens the system builder on MY SYSTEMS; the main admin's alone, so absent for a granted editor. */
onOpenSystems?: () => void;
}) {
const [open, setOpen] = useState(false);
@@ -2320,7 +2320,7 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, onOpenSystems, activeUsers
};
useEffect(() => { if (open) refresh(); }, [open]);
- // SYSTEMS.EXE renamed, copied or deleted one: the picker shows it at once.
+ // The builder renamed, copied, installed or deleted one: the picker shows it at once.
useEffect(() => {
const again = () => refresh();
window.addEventListener(SYSTEMS_CHANGED_EVENT, again);
@@ -2351,9 +2351,9 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, onOpenSystems, activeUsers
Each system keeps its own characters, banks and token health; switching back restores them.
{onOpenSystems && <>
- SYSTEMS.EXE
+ SYSTEM BUILDER
- Switching the game stays here, in the picker. SYSTEMS.EXE is for making and managing systems.
+ Switching the game stays here, in the picker. The builder is for making and managing systems.
>}
} defs={[
diff --git a/frontend/src/components/BuilderScreen.tsx b/frontend/src/components/BuilderScreen.tsx
index bfdefd9f..80dc8332 100644
--- a/frontend/src/components/BuilderScreen.tsx
+++ b/frontend/src/components/BuilderScreen.tsx
@@ -4,7 +4,8 @@ import {
createAutosave, saveStatus, exitWarning, leaveNeedsAsking, publishBlocked, publishedMessage,
BUILDER_PAGES, type Autosave, type BuilderPage,
} from '../sheets/builderSession';
-import { SYSTEMS_CHANGED_EVENT, badgesFor, versionLabel, type Badge, type LibrarySystem } from '../sheets/systemsLibrary';
+import { SYSTEMS_CHANGED_EVENT } from '../sheets/systemsLibrary';
+import { MySystemsPage } from './MySystemsPage';
// The system builder (4a2b): it takes over the whole window, with no map. A sidebar down the left
// holds the system's name, its pages, SAVE and PUBLISH, and EXIT TO MAP; the open page fills the
@@ -18,23 +19,23 @@ import { SYSTEMS_CHANGED_EVENT, badgesFor, versionLabel, type Badge, type Librar
interface Props {
token: string;
- systemId: string;
- /** The page it opens on: SETUP for a system just made. */
- startPage?: BuilderPage;
+ /** The system open in it; none when the GM has yet to pick one, and MY SYSTEMS is all there is. */
+ systemId: string | null;
+ /** The page it opens on: SETUP for a system just made, MY SYSTEMS from the GAME tab. */
+ startPage?: BuilderPage | 'systems';
/** The system the game runs, so PUBLISH can say whether the game now runs the new version. */
running: string | null;
/** Back to the map. */
onExit: () => void;
- /** Another system, opened here in its place (MY SYSTEMS). */
- onOpenSystem: (id: string) => void;
- /** Back to SYSTEMS.EXE on the map, to rename, copy, share or install. */
- onManageSystems: () => void;
+ /** Another system, opened here in its place at a page (MY SYSTEMS), once the open one is saved. */
+ onOpenSystem: (id: string, page: BuilderPage) => void;
fetcher?: typeof fetch;
}
/**
- * MY SYSTEMS is the builder's own list of systems, not a way out of it: picking one opens it here
- * (the user asked, 2026-10-06, after it first went back to SYSTEMS.EXE on the map).
+ * MY SYSTEMS is the builder's own page for every system: picking, making, copying, sharing and
+ * installing them without leaving (MySystemsPage; approved mockup builder-my-systems, 2026-10-06,
+ * which retired the SYSTEMS.EXE window).
*/
const MY_SYSTEMS = { id: 'systems' as const, label: 'MY SYSTEMS', what: 'Every system you have made or installed. Pick one to work on it.' };
type Page = BuilderPage | 'systems';
@@ -62,10 +63,6 @@ const ExitIcon = () => (
/** How wide the rail grows to show the names. */
const RAIL_OPEN = 220;
-const BADGE_BORDER: Record = {
- run: 'var(--cyan)', plain: 'var(--dark-green)', warn: 'var(--warning)', bad: 'var(--danger)',
-};
-
const badge = (border: string, color?: string): React.CSSProperties => ({
fontSize: 10, letterSpacing: 1, padding: '1px 5px', border: `1px solid ${border}`, ...(color ? { color } : {}),
});
@@ -128,12 +125,12 @@ function RailButton({ icon: glyph, label, onClick, title, active, disabled, coun
);
}
-export function BuilderScreen({ token, systemId, startPage = 'setup', running, onExit, onOpenSystem, onManageSystems, fetcher }: Props) {
+export function BuilderScreen({ token, systemId, startPage = 'setup', running, onExit, onOpenSystem, fetcher }: Props) {
const api = useMemo(() => systemsApi(token, fetcher), [token, fetcher]);
const [system, setSystem] = useState(null);
const [loadError, setLoadError] = useState(null);
- const [page, setPage] = useState(startPage);
- const [library, setLibrary] = useState<{ systems: LibrarySystem[] | null; error: string | null }>({ systems: null, error: null });
+ // With no system open, MY SYSTEMS is the only page there is.
+ const [page, setPage] = useState(systemId ? startPage : 'systems');
const [status, setStatus] = useState<{ text: string; bad?: boolean } | null>(null);
const [exitError, setExitError] = useState<{ error: string; then: () => void } | null>(null);
const [busy, setBusy] = useState(false);
@@ -142,6 +139,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
const autosave = useRef | null>(null);
const load = useCallback(async () => {
+ if (!systemId) return null;
const r = await api.get(systemId);
if (!r.ok) { setLoadError(r.error); return null; }
setSystem(r.value);
@@ -151,7 +149,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
useEffect(() => {
let live = true;
load().then((sys) => {
- if (!live || !sys) return;
+ if (!live || !sys || !systemId) return;
autosave.current = createAutosave({
save: async (definition) => {
const r = await api.saveDraft(systemId, definition);
@@ -200,7 +198,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
if (!saved) { setBusy(false); return; }
const blocked = publishBlocked(autosave.current.problems);
if (blocked) { setBusy(false); setPage('problems'); setStatus({ text: blocked, bad: true }); return; }
- const r = await api.publish(systemId);
+ const r = await api.publish(systemId!);
setBusy(false);
if (!r.ok) {
if (r.problems) setPage('problems');
@@ -212,14 +210,6 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT));
};
- // MY SYSTEMS reads the list each time it opens, so it shows what was published or renamed since.
- useEffect(() => {
- if (page !== 'systems') return;
- let live = true;
- api.list().then((r) => { if (live) setLibrary(r.ok ? { systems: r.value, error: null } : { systems: null, error: r.error }); });
- return () => { live = false; };
- }, [api, page]);
-
const current = page === 'systems' ? MY_SYSTEMS : BUILDER_PAGES.find((p) => p.id === page)!;
const saveLine = state ? saveStatus(state) : null;
@@ -246,7 +236,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
}}
>
- setPage('setup')} />
+ setPage(systemId ? 'setup' : 'systems')} />
} label="EXIT TO MAP" aria="Exit the builder and go back to the map" onClick={() => leave(onExit)} />
@@ -259,7 +249,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
key={p.id}
icon={Icons[p.id]}
label={p.label}
- title={p.what}
+ title={systemId ? p.what : 'Open a system first, in MY SYSTEMS.'}
+ disabled={!systemId}
active={p.id === page}
count={p.id === 'problems' && problems.length > 0 ? problems.length : undefined}
onClick={() => { setPage(p.id); setStatus(null); }}
@@ -281,7 +272,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
minWidth: 0,
}}>
- {name.toUpperCase() || '…'}
+ {systemId ? (name.toUpperCase() || '…') : 'NO SYSTEM OPEN'}
{system && (system.published
? PUBLISHED v{system.version}
: NEVER PUBLISHED )}
@@ -296,7 +287,16 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
{loadError && {loadError}
}
- {!system && !loadError && LOADING…
}
+ {systemId && !system && !loadError && LOADING…
}
+ {page === 'systems' && (systemId === null || system) && (
+ leave(() => onOpenSystem(id, at))}
+ say={(text, bad) => setStatus({ text, bad })}
+ />
+ )}
{system && page === 'problems' && (problems.length === 0
? No problems. It can be published.
: (
@@ -304,51 +304,6 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
{problems.map((p, i) => {p.where} : {p.message} )}
))}
- {system && page === 'systems' && (
-
- {library.error &&
{library.error}
}
- {!library.systems && !library.error &&
LOADING…
}
- {library.systems && (
-
- {library.systems.map((s) => {
- const here = s.id === systemId;
- return (
- leave(() => onOpenSystem(s.id))}
- style={{
- ...mono, display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', gap: '4px 12px', padding: '9px 12px',
- border: 0, borderBottom: '1px solid var(--dark-green)', textAlign: 'left', fontSize: 12, color: 'var(--green)',
- cursor: here ? 'default' : 'pointer',
- background: here ? 'color-mix(in srgb, var(--green) 14%, transparent)' : 'none',
- boxShadow: here ? 'inset 3px 0 0 var(--green)' : 'none',
- }}
- >
- {s.name.toUpperCase()}
- {here ? 'OPEN NOW' : versionLabel(s)}
-
- {badgesFor(s, running).map((b) => (
- {b.text}
- ))}
-
-
- );
- })}
-
- )}
-
- To rename, copy, share or install a system, use SYSTEMS.EXE on the map.
-
-
- leave(onManageSystems)}>OPEN SYSTEMS.EXE
-
-
- )}
{system && page !== 'problems' && page !== 'systems' && (
{current.label}
diff --git a/frontend/src/components/SystemsPanels.tsx b/frontend/src/components/SystemsPanels.tsx
index efb40ae9..a5527246 100644
--- a/frontend/src/components/SystemsPanels.tsx
+++ b/frontend/src/components/SystemsPanels.tsx
@@ -4,8 +4,8 @@ import {
insideBadges, installPlan, installedMessage, type Badge, type InstallPreview, type InstallAction,
} from '../sheets/systemsLibrary';
-// The parts of managing a GM's systems that SYSTEMS.EXE and the builder's MY SYSTEMS page share
-// (4a2c2a): NEW, INSTALL, the badges, and their look. What they say is sheets/systemsLibrary.ts.
+// The builder MY SYSTEMS page's parts (4a2c2a; first built for the SYSTEMS.EXE window, retired in
+// 4a2c2b): NEW, INSTALL, the badges, and their look. What they say is sheets/systemsLibrary.ts.
/** Larger than any system file can be (the server's limit is about half a megabyte). */
export const MAX_FILE_BYTES = 1024 * 1024;
diff --git a/frontend/src/components/SystemsWindow.tsx b/frontend/src/components/SystemsWindow.tsx
deleted file mode 100644
index c74491b0..00000000
--- a/frontend/src/components/SystemsWindow.tsx
+++ /dev/null
@@ -1,314 +0,0 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { TerminalWindow, useFolder, type TerminalFolder } from './TerminalWindow';
-import type { BuilderPage } from '../sheets/builderSession';
-import { systemsApi } from '../sheets/systemsApi';
-import { Badges, NewPanel, InstallPanel, small, why, btn } from './SystemsPanels';
-import {
- badgesFor, versionLabel, versionFact, originFact, changedFact, deleteBlocked, exportBlocked,
- renamedMessage, duplicatedMessage, deletedMessage, createdMessage, SYSTEMS_CHANGED_EVENT, 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.
-// 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' | 'new' | 'install';
-const FOLDERS: TerminalFolder
[] = [
- { id: 'systems', label: 'SYSTEMS' },
- { id: 'new', label: 'NEW' },
- { id: 'install', label: 'INSTALL' },
-];
-
-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;
- /** Opens the builder on a system, at a page: OPEN IN BUILDER, and CREATE (on SETUP). */
- onOpenBuilder?: (id: string, page: BuilderPage) => void;
- fetcher?: typeof fetch;
-}
-
-export function SystemsWindow({ token, running, pos, setPos, onClose, onOpenBuilder, 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 openFolder = (id: FolderId) => {
- setFolder(id);
- setStatus(null);
- setRenaming(null);
- setConfirmDelete(false);
- };
-
- /** Made in NEW: the builder opens it on SETUP (decided 2026-10-03); without one, it is shown picked. */
- const made = async (id: string, name: string) => {
- await changed(id);
- if (onOpenBuilder) { onOpenBuilder(id, 'setup'); return; }
- 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;
-
- const list = systems && (
-
- {systems.map((s) => (
- pick(s.id)}
- style={{
- display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', gap: '2px 10px', padding: '7px 9px',
- border: 0, borderBottom: '1px solid var(--dark-green)', textAlign: 'left', cursor: 'pointer',
- fontFamily: 'monospace', fontSize: 12, color: 'var(--green)',
- background: s.id === picked ? 'color-mix(in srgb, var(--green) 14%, transparent)' : 'none',
- boxShadow: s.id === picked ? 'inset 3px 0 0 var(--green)' : 'none',
- }}
- >
- {s.name.toUpperCase()}
- {versionLabel(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)'}`,
- }}
- />
- SAVE
- setRenaming(null)}>CANCEL
-
- {renaming.error
- ?
{renaming.error}
- :
The new name shows everywhere at once: this list, the game-system picker and the running game. }
-
- )}
-
-
- {onOpenBuilder && (
- onOpenBuilder(system.id, 'setup')}>
- OPEN IN BUILDER
-
- )}
- { setRenaming({ text: system.name, error: null }); setConfirmDelete(false); setStatus(null); }}
- >RENAME
- DUPLICATE
- EXPORT .CITYSYS
- { setConfirmDelete(true); setRenaming(null); }}
- >DELETE
-
- {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.
-
-
- DELETE
- setConfirmDelete(false)}>KEEP IT
-
-
- )}
-
- );
-
- const header = {
- systems:
- YOUR SYSTEMS{systems ? ` · ${systems.length}` : ''} MAIN ADMIN ONLY
- ,
- new: 'NEW SYSTEM',
- install: 'INSTALL A .CITYSYS FILE',
- }[folder];
-
- return (
-
-
- {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' &&
}
- {/* Always there, so a screen reader hears each new line; with nothing to say it takes
- no room, gap included, so what is above it sits centered in the panel. */}
-
- {status?.text ?? ''}
-
-
-
- );
-}
diff --git a/frontend/src/components/__tests__/BuilderScreen.test.tsx b/frontend/src/components/__tests__/BuilderScreen.test.tsx
index 07b85458..96375883 100644
--- a/frontend/src/components/__tests__/BuilderScreen.test.tsx
+++ b/frontend/src/components/__tests__/BuilderScreen.test.tsx
@@ -3,7 +3,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, waitFor, within, fireEvent, act } 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) => ,
+}));
+
import { BuilderScreen } from '../BuilderScreen';
+import { AdminPanel } from '../AdminPanel';
import { SYSTEMS_CHANGED_EVENT } from '../../sheets/systemsLibrary';
import type { SystemCopies } from '../../sheets/systemsApi';
@@ -19,7 +29,7 @@ const PROBLEM = { where: 'derived armor', message: 'Depends on itself: armor →
const EMBER = 'sys_bbbbbbbbbbbbbbbb';
const row = (id: string, name: string, over = {}) => ({
id, name, version: 2, updatedAt: '2026-10-06 14:10:00', publishedAt: null, published: true,
- unpublishedChanges: false, installed: false, problemCount: 0, ...over,
+ unpublishedChanges: false, installed: false, problemCount: 0, description: '', author: '', characterCount: 0, ...over,
});
const LIBRARY = [row(HEARTH, 'Hearth', { version: 3 }), row(EMBER, 'Ember', { installed: true, problemCount: 1 })];
@@ -54,10 +64,9 @@ afterEach(() => cleanup());
const open = (over: Partial> = {}) => {
const onExit = vi.fn();
const onOpenSystem = vi.fn();
- const onManageSystems = vi.fn();
render( );
- return { onExit, onOpenSystem, onManageSystems };
+ fetcher={fakeServer as typeof fetch} {...over} />);
+ return { onExit, onOpenSystem };
};
const sidebar = () => screen.getByRole('navigation', { name: 'Builder' });
const ready = () => waitFor(() => expect(screen.getByTestId('save-status')).toBeTruthy());
@@ -195,63 +204,79 @@ describe('PUBLISH', () => {
describe('leaving', () => {
it('EXIT TO MAP goes straight back when nothing is unsaved', async () => {
- const { onExit, onManageSystems, onOpenSystem } = open();
+ const { onExit, onOpenSystem } = open();
await ready();
await userEvent.click(screen.getByLabelText('Exit the builder and go back to the map'));
expect(onExit).toHaveBeenCalledTimes(1);
- expect(onManageSystems).not.toHaveBeenCalled();
expect(onOpenSystem).not.toHaveBeenCalled();
});
});
describe('MY SYSTEMS', () => {
- const openList = async (over = {}) => {
- const props = open(over);
+ it('is the builder\'s page for every system, the open one opened out', async () => {
+ const { onExit } = open({ running: HEARTH });
await ready();
await userEvent.click(within(sidebar()).getByLabelText('MY SYSTEMS'));
await waitFor(() => expect(screen.getByRole('group', { name: 'Your systems' })).toBeTruthy());
- return props;
- };
- const entries = () => within(screen.getByRole('group', { name: 'Your systems' })).getAllByRole('button') as HTMLButtonElement[];
-
- it('is a page of the builder, listing every system with its badges, this one marked', async () => {
- const { onExit, onManageSystems } = await openList({ running: HEARTH });
expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('MY SYSTEMS');
expect(within(sidebar()).getByLabelText('MY SYSTEMS').getAttribute('aria-current')).toBe('page');
- expect(entries().map((b) => b.textContent)).toEqual([
- 'HEARTHOPEN NOWRUNNINGPUBLISHED v3',
- 'EMBERv2PUBLISHED v21 PROBLEMINSTALLED',
- ]);
- expect(entries()[0].disabled).toBe(true);
+ expect(within(screen.getByTestId(`system-${HEARTH}`)).getByText('OPEN NOW IN THE BUILDER')).toBeTruthy();
+ expect(screen.queryByText('OPEN SYSTEMS.EXE')).toBeNull();
expect(onExit).not.toHaveBeenCalled();
- expect(onManageSystems).not.toHaveBeenCalled();
});
- it('opens another system here, saving first', async () => {
- const { onOpenSystem, onExit } = await openList();
- await userEvent.click(entries()[1]);
- expect(onOpenSystem).toHaveBeenCalledWith(EMBER);
+ it('OPEN on another system opens it here, on SETUP', async () => {
+ const { onOpenSystem, onExit } = open();
+ await ready();
+ await userEvent.click(within(sidebar()).getByLabelText('MY SYSTEMS'));
+ await waitFor(() => expect(screen.getByTestId(`system-${EMBER}`)).toBeTruthy());
+ await userEvent.click(within(screen.getByTestId(`system-${EMBER}`)).getAllByRole('button')[0]);
+ await userEvent.click(within(screen.getByTestId(`system-${EMBER}`)).getByText('OPEN'));
+ await waitFor(() => expect(onOpenSystem).toHaveBeenCalledWith(EMBER, 'setup'));
expect(onExit).not.toHaveBeenCalled();
});
- it('sends renaming, copying, sharing and installing to SYSTEMS.EXE', async () => {
- const { onManageSystems, onOpenSystem } = await openList();
- await userEvent.click(screen.getByText('OPEN SYSTEMS.EXE'));
- expect(onManageSystems).toHaveBeenCalledTimes(1);
- expect(onOpenSystem).not.toHaveBeenCalled();
- });
-
- it('says when the list can\'t be read', async () => {
- const down = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => (String(input) === '/api/systems'
- ? { ok: false, status: 500, json: async () => ({ error: 'Could not reach the systems store' }) } as unknown as Response
- : fakeServer(input, init)));
- open({ fetcher: down as typeof fetch });
+ it('says what it did on the builder\'s status line', async () => {
+ open();
await ready();
await userEvent.click(within(sidebar()).getByLabelText('MY SYSTEMS'));
- expect((await screen.findByRole('alert')).textContent).toBe('Could not reach the systems store');
+ await waitFor(() => expect(screen.getByTestId(`system-${EMBER}`)).toBeTruthy());
+ await userEvent.click(within(screen.getByTestId(`system-${EMBER}`)).getAllByRole('button')[0]);
+ await userEvent.click(within(screen.getByTestId(`system-${EMBER}`)).getByText('DUPLICATE'));
+ // The fake server has no duplicate route, so the refusal is what the status line shows.
+ await waitFor(() => expect(screen.getByRole('status').textContent).toBe('No such route'));
+ });
+});
+
+describe('with no system open', () => {
+ it('opens on MY SYSTEMS, the only page there is, with nothing to save or publish', async () => {
+ const { onOpenSystem } = open({ systemId: null, startPage: 'setup' });
+ await waitFor(() => expect(screen.getByRole('group', { name: 'Your systems' })).toBeTruthy());
+ expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('MY SYSTEMS');
+ expect(within(screen.getByTestId('builder-system')).getByText('NO SYSTEM OPEN')).toBeTruthy();
+ for (const label of ['SETUP', 'WORDS', 'PROBLEMS', 'SAVE', 'PUBLISH']) {
+ expect((within(sidebar()).getByLabelText(label) as HTMLButtonElement).disabled, label).toBe(true);
+ }
+ expect(within(sidebar()).getByLabelText('SETUP').title).toBe('Open a system first, in MY SYSTEMS.');
+ expect(screen.queryByTestId('save-status')).toBeNull();
+ // Only the list is read: there is no system to fetch.
+ expect(calls.map((c) => c.url)).toEqual(['/api/systems']);
+ expect(screen.queryByRole('alert')).toBeNull();
+ // Nothing is open, so OPEN goes straight there.
+ await userEvent.click(within(screen.getByTestId(`system-${EMBER}`)).getAllByRole('button')[0]);
+ await userEvent.click(within(screen.getByTestId(`system-${EMBER}`)).getByText('OPEN'));
+ await waitFor(() => expect(onOpenSystem).toHaveBeenCalledWith(EMBER, 'setup'));
+ });
+
+ it('EXIT TO MAP still leaves', async () => {
+ const { onExit } = open({ systemId: null });
+ await userEvent.click(screen.getByLabelText('Exit the builder and go back to the map'));
+ expect(onExit).toHaveBeenCalledTimes(1);
});
+});
- it('closing the browser tab doesn\'t ask while everything is saved', async () => {
+describe('closing the browser tab', () => {
+ it('doesn\'t ask while everything is saved', async () => {
open();
await ready();
const e = new Event('beforeunload', { cancelable: true });
@@ -259,3 +284,48 @@ describe('MY SYSTEMS', () => {
expect(e.defaultPrevented).toBe(false);
});
});
+
+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: [] } : []) };
+ }));
+ });
+ afterEach(() => vi.unstubAllGlobals());
+ 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 the builder from one SYSTEM BUILDER button under the picker', async () => {
+ const opened = vi.fn();
+ await ttrpg(opened);
+ await userEvent.click(screen.getByText('SYSTEM BUILDER'));
+ expect(opened).toHaveBeenCalledTimes(1);
+ expect(screen.queryByText('SYSTEMS.EXE')).toBeNull();
+ });
+
+ it('has no button for anyone but the main admin', async () => {
+ await ttrpg(undefined);
+ expect(screen.queryByText('SYSTEM BUILDER')).toBeNull();
+ });
+
+ it('fetches the picker\'s list again when the builder 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/components/__tests__/SystemsWindow.test.tsx b/frontend/src/components/__tests__/SystemsWindow.test.tsx
deleted file mode 100644
index ba373f30..00000000
--- a/frontend/src/components/__tests__/SystemsWindow.test.tsx
+++ /dev/null
@@ -1,455 +0,0 @@
-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) => ,
-}));
-
-import { SystemsWindow } from '../SystemsWindow';
-import { AdminPanel } from '../AdminPanel';
-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:
- * 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,
- description: '', author: '', characterCount: 0, ...over,
-});
-
-let systems: LibrarySystem[];
-let calls: { url: string; method: string; body: unknown }[];
-/** 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);
- 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);
- 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;
- 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;
- 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 }),
- 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. Make one in NEW, or install a file in INSTALL.')).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');
- });
-});
-
-const folder = async (name: 'SYSTEMS' | 'NEW' | 'INSTALL') => {
- await waitFor(() => expect(listed().length).toBeGreaterThan(0));
- await userEvent.click(screen.getByRole('tab', { name }));
-};
-
-describe('the builder', () => {
- it('OPEN IN BUILDER opens the picked system there', async () => {
- const onOpenBuilder = vi.fn();
- render( );
- await waitFor(() => expect(listed()).toHaveLength(3));
- await userEvent.click(entry('Neon Exchange'));
- await userEvent.click(screen.getByText('OPEN IN BUILDER'));
- expect(onOpenBuilder).toHaveBeenCalledWith(NEON, 'setup');
- });
-
- it('CREATE opens the new system there, on SETUP', async () => {
- const onOpenBuilder = vi.fn();
- render( );
- await folder('NEW');
- await userEvent.type(screen.getByLabelText('NAME'), 'Tidewater{Enter}');
- await waitFor(() => expect(onOpenBuilder).toHaveBeenCalledWith(COPY, 'setup'));
- });
-
- it('has no OPEN IN BUILDER without a builder to open', async () => {
- open();
- await waitFor(() => expect(listed()).toHaveLength(3));
- expect(screen.queryByText('OPEN IN BUILDER')).toBeNull();
- });
-});
-
-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(() => {
- 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/systemsApi.ts b/frontend/src/sheets/systemsApi.ts
index 9783df29..95558cb5 100644
--- a/frontend/src/sheets/systemsApi.ts
+++ b/frontend/src/sheets/systemsApi.ts
@@ -1,4 +1,4 @@
-// SYSTEMS.EXE's requests to the server (4a1b2): backend/routes/systems.js, main admin only. Each
+// The system builder'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.
diff --git a/frontend/src/sheets/systemsLibrary.ts b/frontend/src/sheets/systemsLibrary.ts
index 52851913..91326a8a 100644
--- a/frontend/src/sheets/systemsLibrary.ts
+++ b/frontend/src/sheets/systemsLibrary.ts
@@ -1,4 +1,5 @@
-// What SYSTEMS.EXE says about a GM's own game systems (4a1b2): the badges in its list, the facts
+// What the builder's MY SYSTEMS says about a GM's own game systems (4a1b2, first for the SYSTEMS.EXE
+// window, retired in 4a2c2b): 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.
@@ -80,7 +81,7 @@ export const changedFact = (updatedAt: string): string => {
};
/**
- * Sent on the window after SYSTEMS.EXE changes a system, so the game-system picker beside it
+ * Sent on the window after the builder changes a system, so the game-system picker
* fetches the list again: a renamed, copied or deleted system shows there at once.
*/
export const SYSTEMS_CHANGED_EVENT = 'citynet:systems-changed';