From 956535f4ab92750f0f3ec188bce6929b0773d05d Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 14:03:04 -0500 Subject: [PATCH 1/2] feat(system-builder): the builder's MY SYSTEMS page, as a component (4a2c2a) components/MySystemsPage.tsx, from the approved mockup builder-my-systems (2026-10-06): tabs YOUR SYSTEMS / + NEW / INSTALL A FILE; each system a line item (name, OPEN NOW or version, description or 'No description yet', lineFacts, badges) opening out in place with OPEN, RENAME (refusals kept in the box), DUPLICATE (opens the copy out), EXPORT and DELETE (confirmed; off for the running system and the one open in the builder). + NEW opens what it makes on SETUP; INSTALL A FILE offers OPEN IT. NEW and INSTALL move from SystemsWindow into components/SystemsPanels.tsx unchanged, shared by both until SYSTEMS.EXE is retired. systemsLibrary gains the list's description / author / characterCount, lineFacts, NO_DESCRIPTION, and deleteBlocked's open-in-builder reason. Not wired into the builder yet (4a2c2b). --- CHANGELOG.md | 7 + frontend/src/components/MySystemsPage.tsx | 253 +++++++++++++++++ frontend/src/components/SystemsPanels.tsx | 235 +++++++++++++++ frontend/src/components/SystemsWindow.tsx | 231 +-------------- .../__tests__/MySystemsPage.test.tsx | 267 ++++++++++++++++++ .../__tests__/SystemsWindow.test.tsx | 3 +- .../sheets/__tests__/systemsLibrary.test.ts | 16 +- frontend/src/sheets/systemsLibrary.ts | 26 +- 8 files changed, 804 insertions(+), 234 deletions(-) create mode 100644 frontend/src/components/MySystemsPage.tsx create mode 100644 frontend/src/components/SystemsPanels.tsx create mode 100644 frontend/src/components/__tests__/MySystemsPage.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd67df8..09ae729a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **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, + DUPLICATE, EXPORT and DELETE. + NEW makes a system and opens it on SETUP; INSTALL A FILE offers + OPEN IT once it's in. DELETE is off for the system open in the builder as well as the one the + game runs. The builder starts using it in the next update. + - **The system list says more about each system.** Each one now comes with its description, its author and how many players' characters are played in it (NPCs aren't counted), for the builder's MY SYSTEMS page to show under each name. diff --git a/frontend/src/components/MySystemsPage.tsx b/frontend/src/components/MySystemsPage.tsx new file mode 100644 index 00000000..a271603f --- /dev/null +++ b/frontend/src/components/MySystemsPage.tsx @@ -0,0 +1,253 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Badges, NewPanel, InstallPanel, small, why, btn, danger } from './SystemsPanels'; +import type { systemsApi } from '../sheets/systemsApi'; +import type { BuilderPage } from '../sheets/builderSession'; +import { + badgesFor, versionLabel, lineFacts, NO_DESCRIPTION, deleteBlocked, exportBlocked, + renamedMessage, duplicatedMessage, deletedMessage, SYSTEMS_CHANGED_EVENT, type LibrarySystem, +} from '../sheets/systemsLibrary'; + +// The builder's MY SYSTEMS page (4a2c2a): everything SYSTEMS.EXE did, without leaving the builder +// (approved mockup docs/mockups/builder-my-systems.html, 2026-10-06). Three tabs: the systems as +// line items (name, version, description, facts, badges), each opening out in place with OPEN, +// RENAME, DUPLICATE, EXPORT and DELETE; + NEW, which makes one and opens it on SETUP; and +// INSTALL A FILE, which offers OPEN IT once installed. A name refused because it is taken keeps +// its box open with the reason (decided with the user, 2026-10-06). + +type Tab = 'list' | 'new' | 'install'; + +interface Props { + api: ReturnType; + /** The system open in the builder, if any. */ + openId: string | null; + /** The system the game runs. */ + running: string | null; + /** Open a system in the builder at a page; the builder saves the one it has first. */ + onOpen: (id: string, page: BuilderPage) => void; + /** A line for the builder's status bar. */ + say: (text: string, bad?: boolean) => void; +} + +const tabStyle = (on: boolean): React.CSSProperties => ({ + background: on ? 'color-mix(in srgb, var(--green) 10%, transparent)' : 'none', border: 0, + borderBottom: `2px solid ${on ? 'var(--green)' : 'transparent'}`, cursor: 'pointer', padding: '7px 14px', + color: on ? 'var(--green)' : 'color-mix(in srgb, var(--green) 55%, transparent)', fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, +}); + +export function MySystemsPage({ api, openId, running, onOpen, say }: Props) { + const [tab, setTab] = useState('list'); + const [systems, setSystems] = useState(null); + const [loadError, setLoadError] = useState(null); + const [picked, setPicked] = useState(openId); + const [renaming, setRenaming] = useState<{ text: string; error: string | null } | null>(null); + const [confirmDelete, setConfirmDelete] = useState(false); + const [busy, setBusy] = useState(false); + const [installed, setInstalled] = useState<{ id: string; message: string } | null>(null); + const renameRef = useRef(null); + + const load = useCallback(async () => { + const r = await api.list(); + if (!r.ok) { setLoadError(r.error); return; } + setLoadError(null); + setSystems(r.value); + }, [api]); + + useEffect(() => { load(); }, [load]); + + /** After a change: the list again, and the game-system picker told. */ + const changed = async () => { + await load(); + window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT)); + }; + + const pick = (id: string) => { + setPicked(id === picked ? null : id); + setRenaming(null); + setConfirmDelete(false); + }; + + const run = async (work: () => Promise) => { + if (busy) return undefined; + setBusy(true); + try { return await work(); } finally { setBusy(false); } + }; + + const saveRename = (s: LibrarySystem) => run(async () => { + if (!renaming || !renaming.text.trim()) return; + const r = await api.rename(s.id, renaming.text); + if (!r.ok) { + setRenaming({ text: renaming.text, error: r.error }); + renameRef.current?.focus(); + return; + } + setRenaming(null); + say(renamedMessage(r.value.name)); + await changed(); + }); + + const duplicate = (s: LibrarySystem) => run(async () => { + const r = await api.duplicate(s.id); + if (!r.ok) { say(r.error, true); return; } + say(duplicatedMessage(r.value.name)); + setPicked(r.value.id); + setRenaming(null); + await changed(); + }); + + const exportFile = (s: LibrarySystem) => run(async () => { + const r = await api.exportFile(s.id); + if (!r.ok) { say(r.error, 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); + say(`Downloading ${r.value.fileName} (v${s.version}).`); + }); + + const remove = (s: LibrarySystem) => run(async () => { + const r = await api.remove(s.id); + setConfirmDelete(false); + if (!r.ok) { say(r.error, true); return; } + say(deletedMessage(s.name)); + setPicked(null); + await changed(); + }); + + const openOut = (s: LibrarySystem) => { + const isOpen = s.id === openId; + const deleteWhy = deleteBlocked(s, running, openId); + const exportWhy = exportBlocked(s); + return ( +
+ {renaming && ( +
+
+ setRenaming({ text: e.target.value, error: null })} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); saveRename(s); } + 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.} +
+ )} +
+ {isOpen + ? OPEN NOW IN THE BUILDER + : } + + + + +
+ {!isOpen && openId && OPEN saves the one you have open first.} + {deleteWhy && DELETE: {deleteWhy}} + {exportWhy && EXPORT: {exportWhy}} + {confirmDelete && ( +
+ DELETE {s.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. + +
+ + +
+
+ )} +
+ ); + }; + + const tabs: [Tab, string][] = [['list', `YOUR SYSTEMS${systems ? ` · ${systems.length}` : ''}`], ['new', '+ NEW'], ['install', 'INSTALL A FILE']]; + + return ( +
+
+ {tabs.map(([id, label]) => ( + + ))} +
+ + {tab === 'list' && <> + {loadError &&

{loadError}

} + {!systems && !loadError &&

LOADING…

} + {systems && systems.length === 0 &&

No systems of your own yet. Make one in + NEW, or install a file.

} + {systems && systems.length > 0 && ( +
+ {systems.map((s) => { + const isPicked = s.id === picked; + return ( +
+ + {isPicked && openOut(s)} +
+ ); + })} +
+ )} + } + + {tab === 'new' && { window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT)); onOpen(id, 'setup'); }} />} + + {tab === 'install' && <> + {installed && ( +
+ {installed.message} + +
+ )} + { setInstalled({ id, message }); await changed(); }} /> + } +
+ ); +} diff --git a/frontend/src/components/SystemsPanels.tsx b/frontend/src/components/SystemsPanels.tsx new file mode 100644 index 00000000..efb40ae9 --- /dev/null +++ b/frontend/src/components/SystemsPanels.tsx @@ -0,0 +1,235 @@ +import React, { useRef, useState } from 'react'; +import type { systemsApi } from '../sheets/systemsApi'; +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. + +/** Larger than any system file can be (the server's limit is about half a megabyte). */ +export const MAX_FILE_BYTES = 1024 * 1024; + +export type Api = ReturnType; + +export const small: React.CSSProperties = { fontSize: 10, letterSpacing: 2, opacity: 0.75 }; +export const why: React.CSSProperties = { fontSize: 11, opacity: 0.8, lineHeight: 1.45 }; +export const btn: React.CSSProperties = { fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, padding: '5px 9px' }; + +export const badgeColor: Record = { + run: 'var(--cyan)', plain: 'var(--dark-green)', warn: 'var(--warning)', bad: 'var(--danger)', +}; + +export function Badges({ badges }: { badges: Badge[] }) { + return ( + + {badges.map((b) => ( + {b.text} + ))} + + ); +} + +export const field: React.CSSProperties = { + flex: 1, minWidth: 0, background: 'var(--black)', color: 'var(--green)', fontFamily: 'monospace', fontSize: 12, padding: '5px 7px', +}; +export const danger: React.CSSProperties = { borderColor: 'var(--danger)', color: 'var(--danger)' }; + +/** NEW: a system from a name. Example and genre starts come later (4d1, 4d3). */ +export function NewPanel({ api, onMade }: { api: Api; onMade: (id: string, name: string) => void }) { + const [name, setName] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const inputRef = useRef(null); + + const create = async () => { + const wanted = name.trim(); + if (!wanted || busy) return; + setBusy(true); + const r = await api.create(wanted); + setBusy(false); + if (!r.ok) { setError(r.error); inputRef.current?.focus(); return; } + setName(''); + onMade(r.value.id, wanted); + }; + + const starts: [string, string, string, boolean][] = [ + ['blank', 'BLANK', 'A name and nothing else, to build up in the builder.', true], + ['example', 'A BUILT-IN EXAMPLE', 'Cities Without Number or Shadowrun as data, to change into your own. Coming later.', false], + ['starter', 'A GENRE STARTER', 'Fantasy, sci-fi, or a narrative one with no numbers. Coming later.', false], + ]; + + return ( +
+ + { setName(e.target.value); setError(null); }} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); create(); } }} + style={{ ...field, border: `1px solid ${error ? 'var(--danger)' : 'var(--green)'}` }} + /> + {error && {error}} + START FROM +
+ {starts.map(([id, label, what, can]) => ( + + ))} +
+
+ +
+ It's made as a draft: nobody plays it until it's published from the builder. +
+ ); +} + +/** INSTALL: a .citysys file, previewed first, then installed the way the GM picks. */ +export function InstallPanel({ api, onInstalled }: { api: Api; onInstalled: (id: string, message: string) => void }) { + const [file, setFile] = useState<{ name: string; text: string } | null>(null); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(null); + const [busy, setBusy] = useState(false); + const [dragging, setDragging] = useState(false); + + const read = async (chosen: File | undefined) => { + if (!chosen) return; + setPreview(null); + setConfirming(null); + setError(null); + if (chosen.size > MAX_FILE_BYTES) { setFile(null); setError(`${chosen.name} is over 1 MB, too large to be a system file.`); return; } + const text = await chosen.text(); + setFile({ name: chosen.name, text }); + setBusy(true); + const r = await api.preview(text); + setBusy(false); + if (!r.ok) { setError(r.error); return; } + setPreview(r.value); + }; + + const install = async (action: InstallAction) => { + if (!file || !preview || busy) return; + setBusy(true); + const r = await api.install(file.text, action.mode, action.replaceChanges === true); + setBusy(false); + setConfirming(null); + if (!r.ok) { setError(r.error); return; } + const message = installedMessage(r.value, action.mode, preview.manifest.version); + setFile(null); + setPreview(null); + onInstalled(r.value.id, message); + }; + + const plan = preview ? installPlan(preview) : null; + const m = preview?.manifest; + + return ( +
+ + {busy && !preview && READING…} + {error && {error}} + + {preview && m && plan && <> +
+
+ {preview.name.toUpperCase()}v{m.version} +
+
+ {m.author && <>
AUTHOR
{m.author}
} + {m.license && <>
LICENSE
{m.license}
} + {m.builder && <>
MADE WITH
CITY_NET {m.builder}
} +
+ {insideBadges(preview.inside).length > 0 && <> + INSIDE + + {insideBadges(preview.inside).map((b) => ( + {b} + ))} + + } + {preview.problems.length > 0 && <> + + {preview.problems.length} PROBLEM{preview.problems.length === 1 ? '' : 'S'} + +
    + {preview.problems.map((p, i) =>
  • {p.where}: {p.message}
  • )} +
+ } +
+ + {plan.notices.map((n) => ( +
{n.text}
+ ))} + + {confirming ? ( +
+ {confirming.label}? + {confirming.confirm} +
+ + +
+
+ ) : ( +
+ {plan.actions.map((a) => ( + + ))} +
+ )} + {plan.actions.filter((a) => a.blocked).map((a) => {a.label}: {a.blocked}.)} + } +
+ ); +} diff --git a/frontend/src/components/SystemsWindow.tsx b/frontend/src/components/SystemsWindow.tsx index 852030bf..c74491b0 100644 --- a/frontend/src/components/SystemsWindow.tsx +++ b/frontend/src/components/SystemsWindow.tsx @@ -2,10 +2,10 @@ 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, insideBadges, installPlan, installedMessage, - SYSTEMS_CHANGED_EVENT, type Badge, type LibrarySystem, type InstallPreview, type InstallAction, + 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 @@ -26,11 +26,6 @@ const FOLDERS: TerminalFolder[] = [ { id: 'install', label: 'INSTALL' }, ]; -/** Larger than any system file can be (the server's limit is about half a megabyte). */ -const MAX_FILE_BYTES = 1024 * 1024; - -type Api = ReturnType; - interface Props { token: string; /** The system the game runs. */ @@ -43,228 +38,6 @@ interface Props { 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} - ))} - - ); -} - -const field: React.CSSProperties = { - flex: 1, minWidth: 0, background: 'var(--black)', color: 'var(--green)', fontFamily: 'monospace', fontSize: 12, padding: '5px 7px', -}; -const danger: React.CSSProperties = { borderColor: 'var(--danger)', color: 'var(--danger)' }; - -/** NEW: a system from a name. Example and genre starts come later (4d1, 4d3). */ -function NewPanel({ api, onMade }: { api: Api; onMade: (id: string, name: string) => void }) { - const [name, setName] = useState(''); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - const inputRef = useRef(null); - - const create = async () => { - const wanted = name.trim(); - if (!wanted || busy) return; - setBusy(true); - const r = await api.create(wanted); - setBusy(false); - if (!r.ok) { setError(r.error); inputRef.current?.focus(); return; } - setName(''); - onMade(r.value.id, wanted); - }; - - const starts: [string, string, string, boolean][] = [ - ['blank', 'BLANK', 'A name and nothing else, to build up in the builder.', true], - ['example', 'A BUILT-IN EXAMPLE', 'Cities Without Number or Shadowrun as data, to change into your own. Coming later.', false], - ['starter', 'A GENRE STARTER', 'Fantasy, sci-fi, or a narrative one with no numbers. Coming later.', false], - ]; - - return ( -
- - { setName(e.target.value); setError(null); }} - onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); create(); } }} - style={{ ...field, border: `1px solid ${error ? 'var(--danger)' : 'var(--green)'}` }} - /> - {error && {error}} - START FROM -
- {starts.map(([id, label, what, can]) => ( - - ))} -
-
- -
- It's made as a draft: nobody plays it until it's published from the builder. -
- ); -} - -/** INSTALL: a .citysys file, previewed first, then installed the way the GM picks. */ -function InstallPanel({ api, onInstalled }: { api: Api; onInstalled: (id: string, message: string) => void }) { - const [file, setFile] = useState<{ name: string; text: string } | null>(null); - const [preview, setPreview] = useState(null); - const [error, setError] = useState(null); - const [confirming, setConfirming] = useState(null); - const [busy, setBusy] = useState(false); - const [dragging, setDragging] = useState(false); - - const read = async (chosen: File | undefined) => { - if (!chosen) return; - setPreview(null); - setConfirming(null); - setError(null); - if (chosen.size > MAX_FILE_BYTES) { setFile(null); setError(`${chosen.name} is over 1 MB, too large to be a system file.`); return; } - const text = await chosen.text(); - setFile({ name: chosen.name, text }); - setBusy(true); - const r = await api.preview(text); - setBusy(false); - if (!r.ok) { setError(r.error); return; } - setPreview(r.value); - }; - - const install = async (action: InstallAction) => { - if (!file || !preview || busy) return; - setBusy(true); - const r = await api.install(file.text, action.mode, action.replaceChanges === true); - setBusy(false); - setConfirming(null); - if (!r.ok) { setError(r.error); return; } - const message = installedMessage(r.value, action.mode, preview.manifest.version); - setFile(null); - setPreview(null); - onInstalled(r.value.id, message); - }; - - const plan = preview ? installPlan(preview) : null; - const m = preview?.manifest; - - return ( -
- - {busy && !preview && READING…} - {error && {error}} - - {preview && m && plan && <> -
-
- {preview.name.toUpperCase()}v{m.version} -
-
- {m.author && <>
AUTHOR
{m.author}
} - {m.license && <>
LICENSE
{m.license}
} - {m.builder && <>
MADE WITH
CITY_NET {m.builder}
} -
- {insideBadges(preview.inside).length > 0 && <> - INSIDE - - {insideBadges(preview.inside).map((b) => ( - {b} - ))} - - } - {preview.problems.length > 0 && <> - - {preview.problems.length} PROBLEM{preview.problems.length === 1 ? '' : 'S'} - -
    - {preview.problems.map((p, i) =>
  • {p.where}: {p.message}
  • )} -
- } -
- - {plan.notices.map((n) => ( -
{n.text}
- ))} - - {confirming ? ( -
- {confirming.label}? - {confirming.confirm} -
- - -
-
- ) : ( -
- {plan.actions.map((a) => ( - - ))} -
- )} - {plan.actions.filter((a) => a.blocked).map((a) => {a.label}: {a.blocked}.)} - } -
- ); -} - export function SystemsWindow({ token, running, pos, setPos, onClose, onOpenBuilder, fetcher }: Props) { const api = useMemo(() => systemsApi(token, fetcher), [token, fetcher]); const [folder, setFolder] = useFolder(FOLDERS, null); diff --git a/frontend/src/components/__tests__/MySystemsPage.test.tsx b/frontend/src/components/__tests__/MySystemsPage.test.tsx new file mode 100644 index 00000000..97e6e8da --- /dev/null +++ b/frontend/src/components/__tests__/MySystemsPage.test.tsx @@ -0,0 +1,267 @@ +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'; + +import { MySystemsPage } from '../MySystemsPage'; +import { systemsApi } from '../../sheets/systemsApi'; +import { SYSTEMS_CHANGED_EVENT, changedFact, type LibrarySystem, type InstallPreview } from '../../sheets/systemsLibrary'; + +/** + * The builder's MY SYSTEMS page (4a2c2a): everything SYSTEMS.EXE did, inside the builder. Approved + * mockup builder-my-systems (2026-10-06): line items opening out in place; + NEW opens what it + * makes on SETUP; INSTALL A FILE offers OPEN IT. A taken name is refused where it was typed. + */ + +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, +}); +const AT = changedFact('2026-10-06 14:10:00'); + +let systems: LibrarySystem[]; +let calls: { url: string; method: string; body: unknown }[]; +let renameAnswer: { status: number; body: unknown } | null; + +const PREVIEW: InstallPreview = { + manifest: { name: 'Iron Sea', author: 'M. Okafor', license: '', builder: '1.15.0', version: 4, origin: 'org_iron' }, + name: 'Iron Sea', inside: { words: 0, partsOff: 0, currencies: 0, derived: 0, lookups: 0, sheetFields: 0, npcTiers: 0, healthModel: null }, + 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 }), ...systems]; + return json(200, { id: COPY, problems: [] }); + } + if (url === '/api/systems/install/preview') return json(200, PREVIEW); + if (url === '/api/systems/install') { + systems = [row({ id: COPY, name: 'Iron Sea', installed: true }), ...systems]; + return json(200, { id: COPY, name: 'Iron Sea', published: true, problems: [] }); + } + 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 === '/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 }), ...systems]; + return json(200, { id: COPY, name: `${s.name} copy` }); + } + if (rest === '/export') return json(200, '{"citysys":1}', { 'Content-Disposition': 'attachment; filename="neon-exchange.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({ description: 'Low fantasy around one village fire.', author: 'Cody', characterCount: 5 }), + row({ id: NEON, name: 'Neon Exchange', version: 1, unpublishedChanges: true, characterCount: 1 }), + row({ id: VAULT, name: 'Vault Knights', version: 0, published: false, unpublishedChanges: true, installed: true, problemCount: 3, author: 'R. Ade' }), + ]; +}); +afterEach(() => cleanup()); + +const open = ({ openId = HEARTH as string | null, running = HEARTH as string | null } = {}) => { + const onOpen = vi.fn(); + const say = vi.fn(); + render(); + return { onOpen, say }; +}; +const line = (id: string) => screen.getByTestId(`system-${id}`); +const header = (id: string) => within(line(id)).getAllByRole('button')[0]; +const ready = () => waitFor(() => expect(screen.getByRole('group', { name: 'Your systems' })).toBeTruthy()); + +describe('the line items', () => { + it('show each system\'s name, version, description, facts and badges, the open one opened out', async () => { + open(); + await ready(); + expect(header(HEARTH).textContent).toBe(`HEARTHOPEN NOWLow fantasy around one village fire.BY CODYCHANGED ${AT}MADE HERE5 CHARACTERSRUNNINGPUBLISHED v3`); + expect(header(NEON).textContent).toBe(`NEON EXCHANGEv1No description yet. SETUP asks for one.CHANGED ${AT}MADE HERE1 CHARACTERPUBLISHED v1UNPUBLISHED CHANGES`); + expect(header(VAULT).textContent).toBe(`VAULT KNIGHTSDRAFTNo description yet. SETUP asks for one.BY R. ADECHANGED ${AT}INSTALLED FROM A FILE0 CHARACTERSNEVER PUBLISHED3 PROBLEMSINSTALLED`); + expect(header(HEARTH).getAttribute('aria-expanded')).toBe('true'); + expect(within(line(HEARTH)).getByText('OPEN NOW IN THE BUILDER')).toBeTruthy(); + expect(screen.getByRole('tab', { name: 'YOUR SYSTEMS · 3' }).getAttribute('aria-selected')).toBe('true'); + }); + + it('open out one at a time, and close again', async () => { + open(); + await ready(); + await userEvent.click(header(NEON)); + expect(header(NEON).getAttribute('aria-expanded')).toBe('true'); + expect(header(HEARTH).getAttribute('aria-expanded')).toBe('false'); + expect(within(line(HEARTH)).queryByText('RENAME')).toBeNull(); + await userEvent.click(header(NEON)); + expect(header(NEON).getAttribute('aria-expanded')).toBe('false'); + expect(screen.queryByText('RENAME')).toBeNull(); + }); + + it('OPEN opens another in the builder, which saves the open one first', async () => { + const { onOpen } = open(); + await ready(); + await userEvent.click(header(NEON)); + expect(within(line(NEON)).getByText('OPEN saves the one you have open first.')).toBeTruthy(); + await userEvent.click(within(line(NEON)).getByText('OPEN')); + expect(onOpen).toHaveBeenCalledWith(NEON, 'setup'); + }); + + it('with nothing open, nothing is opened out and OPEN needs no warning', async () => { + open({ openId: null }); + await ready(); + expect(screen.queryByText('RENAME')).toBeNull(); + await userEvent.click(header(NEON)); + expect(within(line(NEON)).queryByText('OPEN saves the one you have open first.')).toBeNull(); + }); + + it('say when there are none, and when the list can\'t be read', async () => { + systems = []; + open(); + expect(await screen.findByText('No systems of your own yet. Make one in + NEW, or install a file.')).toBeTruthy(); + cleanup(); + render( { throw new TypeError('down'); }) as never)} openId={null} running={null} onOpen={vi.fn()} say={vi.fn()} />); + expect((await screen.findByRole('alert')).textContent).toBe('Could not reach the server.'); + }); +}); + +describe('RENAME, DUPLICATE, EXPORT, DELETE', () => { + it('RENAME renames at once and tells the picker', async () => { + const told = vi.fn(); + window.addEventListener(SYSTEMS_CHANGED_EVENT, told); + const { say } = open(); + await ready(); + await userEvent.click(within(line(HEARTH)).getByText('RENAME')); + await userEvent.clear(screen.getByLabelText('New name')); + await userEvent.type(screen.getByLabelText('New name'), 'Emberhold{Enter}'); + await waitFor(() => expect(say).toHaveBeenCalledWith('Renamed to Emberhold.')); + await waitFor(() => expect(header(HEARTH).textContent).toMatch(/^EMBERHOLD/)); + expect(told).toHaveBeenCalledTimes(1); + window.removeEventListener(SYSTEMS_CHANGED_EVENT, told); + }); + + it('a refused name stays in the box, with why, to change it there', async () => { + renameAnswer = { status: 409, body: { error: 'Another system is already called Neon Exchange.' } }; + open(); + await ready(); + await userEvent.click(within(line(HEARTH)).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(input.value).toBe('neon exchange'); + expect(document.activeElement).toBe(input); + await userEvent.type(input, 's'); + expect(screen.queryByRole('alert')).toBeNull(); + await userEvent.type(input, '{Escape}'); + expect(screen.queryByLabelText('New name')).toBeNull(); + }); + + it('DUPLICATE makes the copy and opens it out', async () => { + const { say } = open(); + await ready(); + await userEvent.click(within(line(HEARTH)).getByText('DUPLICATE')); + await waitFor(() => expect(say).toHaveBeenCalledWith('Copied as Hearth copy: a draft, its own system.')); + await waitFor(() => expect(header(COPY).getAttribute('aria-expanded')).toBe('true')); + }); + + it('EXPORT downloads a published one, and is off for one never published', async () => { + URL.createObjectURL = vi.fn(() => 'blob:x'); + URL.revokeObjectURL = vi.fn(); + const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + const { say } = open(); + await ready(); + await userEvent.click(header(NEON)); + await userEvent.click(within(line(NEON)).getByText('EXPORT .CITYSYS')); + await waitFor(() => expect(say).toHaveBeenCalledWith('Downloading neon-exchange.citysys (v1).')); + expect((click.mock.instances[0] as unknown as HTMLAnchorElement).download).toBe('neon-exchange.citysys'); + click.mockRestore(); + await userEvent.click(header(VAULT)); + expect((within(line(VAULT)).getByText('EXPORT .CITYSYS') as HTMLButtonElement).disabled).toBe(true); + expect(within(line(VAULT)).getByText('EXPORT: Publish it before sharing it. Only a published system is shared.')).toBeTruthy(); + }); + + it('DELETE is off for the running system and the one open here, saying why', async () => { + open({ openId: NEON, running: HEARTH }); + await ready(); + await userEvent.click(header(HEARTH)); + expect((within(line(HEARTH)).getByText('DELETE') as HTMLButtonElement).disabled).toBe(true); + expect(within(line(HEARTH)).getByText('DELETE: This is the system the game is running. Switch to another first.')).toBeTruthy(); + await userEvent.click(header(NEON)); + expect(within(line(NEON)).getByText('DELETE: It\'s open in the builder. Open another first.')).toBeTruthy(); + }); + + it('DELETE asks first; KEEP IT keeps it, DELETE hides it', async () => { + const { say } = open(); + await ready(); + await userEvent.click(header(VAULT)); + await userEvent.click(within(line(VAULT)).getByText('DELETE')); + await userEvent.click(within(screen.getByRole('alertdialog', { name: 'Delete Vault Knights' })).getByText('KEEP IT')); + expect(screen.queryByRole('alertdialog')).toBeNull(); + await userEvent.click(within(line(VAULT)).getByText('DELETE')); + await userEvent.click(within(screen.getByRole('alertdialog')).getByText('DELETE')); + await waitFor(() => expect(say).toHaveBeenCalledWith('Deleted Vault Knights. Installing its file brings it back.')); + await waitFor(() => expect(screen.queryByTestId(`system-${VAULT}`)).toBeNull()); + expect(calls.find((c) => c.method === 'DELETE')!.url).toBe(`/api/systems/${VAULT}`); + }); +}); + +describe('+ NEW and INSTALL A FILE', () => { + it('+ NEW makes one and opens it on SETUP, telling the picker', async () => { + const told = vi.fn(); + window.addEventListener(SYSTEMS_CHANGED_EVENT, told); + const { onOpen } = open(); + await ready(); + await userEvent.click(screen.getByRole('tab', { name: '+ NEW' })); + await userEvent.type(screen.getByLabelText('NAME'), 'Tidewater{Enter}'); + await waitFor(() => expect(onOpen).toHaveBeenCalledWith(COPY, 'setup')); + expect(told).toHaveBeenCalledTimes(1); + window.removeEventListener(SYSTEMS_CHANGED_EVENT, told); + }); + + it('+ NEW refuses a name in use where it was typed', async () => { + const { onOpen } = open(); + await ready(); + await userEvent.click(screen.getByRole('tab', { name: '+ NEW' })); + await userEvent.type(screen.getByLabelText('NAME'), 'hearth{Enter}'); + expect((await screen.findByRole('alert')).textContent).toBe('Another system is already called Hearth.'); + expect(onOpen).not.toHaveBeenCalled(); + }); + + it('INSTALL A FILE installs, then offers OPEN IT', async () => { + const { onOpen } = open(); + await ready(); + await userEvent.click(screen.getByRole('tab', { name: 'INSTALL A FILE' })); + await userEvent.upload(screen.getByLabelText('System file'), new File(['{"citysys":1}'], 'iron-sea.citysys')); + await screen.findByTestId('install-cover'); + await userEvent.click(screen.getAllByRole('button', { name: 'INSTALL' })[0]); + expect(await screen.findByText('Installed Iron Sea v4. It\'s in the list and the game-system picker.')).toBeTruthy(); + await userEvent.click(screen.getByText('OPEN IT')); + expect(onOpen).toHaveBeenCalledWith(COPY, 'setup'); + // Back on the list, it is there. + await userEvent.click(screen.getByRole('tab', { name: 'YOUR SYSTEMS · 4' })); + expect(screen.getByTestId(`system-${COPY}`)).toBeTruthy(); + }); +}); diff --git a/frontend/src/components/__tests__/SystemsWindow.test.tsx b/frontend/src/components/__tests__/SystemsWindow.test.tsx index d2a0b311..ba373f30 100644 --- a/frontend/src/components/__tests__/SystemsWindow.test.tsx +++ b/frontend/src/components/__tests__/SystemsWindow.test.tsx @@ -29,7 +29,8 @@ 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, + published: true, unpublishedChanges: false, installed: false, problemCount: 0, + description: '', author: '', characterCount: 0, ...over, }); let systems: LibrarySystem[]; diff --git a/frontend/src/sheets/__tests__/systemsLibrary.test.ts b/frontend/src/sheets/__tests__/systemsLibrary.test.ts index 8f718d16..1eb6da39 100644 --- a/frontend/src/sheets/__tests__/systemsLibrary.test.ts +++ b/frontend/src/sheets/__tests__/systemsLibrary.test.ts @@ -6,14 +6,15 @@ */ import { describe, it, expect } from 'vitest'; import { - badgesFor, versionLabel, versionFact, originFact, changedFact, deleteBlocked, exportBlocked, insideBadges, installPlan, + badgesFor, versionLabel, versionFact, originFact, changedFact, lineFacts, NO_DESCRIPTION, deleteBlocked, exportBlocked, insideBadges, installPlan, installedMessage, renamedMessage, duplicatedMessage, deletedMessage, createdMessage, type LibrarySystem, type InstallPreview, } from '../systemsLibrary'; const sys = (over: Partial = {}): LibrarySystem => ({ id: 'sys_aaaaaaaaaaaaaaaa', name: 'Hearth', version: 3, updatedAt: '2026-10-06 08:00:00', publishedAt: '2026-10-05 20:00:00', - published: true, unpublishedChanges: false, installed: false, problemCount: 0, ...over, + published: true, unpublishedChanges: false, installed: false, problemCount: 0, + description: '', author: '', characterCount: 0, ...over, }); const text = (s: LibrarySystem, running: string | null = null) => badgesFor(s, running).map((b) => `${b.text}/${b.tone}`); @@ -61,9 +62,20 @@ describe('facts', () => { expect(originFact(sys())).toBe('Made here'); }); + it('the facts on its line in MY SYSTEMS', () => { + const at = changedFact('2026-10-06 08:00:00'); + expect(lineFacts(sys({ author: 'Cody', characterCount: 5 }))).toEqual(['BY CODY', `CHANGED ${at}`, 'MADE HERE', '5 CHARACTERS']); + expect(lineFacts(sys({ installed: true, characterCount: 1 }))).toEqual([`CHANGED ${at}`, 'INSTALLED FROM A FILE', '1 CHARACTER']); + expect(lineFacts(sys())).toEqual([`CHANGED ${at}`, 'MADE HERE', '0 CHARACTERS']); + expect(NO_DESCRIPTION).toBe('No description yet. SETUP asks for one.'); + }); + it('why DELETE and EXPORT are off', () => { expect(deleteBlocked(sys(), 'sys_aaaaaaaaaaaaaaaa')).toBe('This is the system the game is running. Switch to another first.'); expect(deleteBlocked(sys(), 'cities_without_number')).toBeNull(); + expect(deleteBlocked(sys(), null, 'sys_aaaaaaaaaaaaaaaa')).toBe('It\'s open in the builder. Open another first.'); + expect(deleteBlocked(sys(), 'sys_aaaaaaaaaaaaaaaa', 'sys_aaaaaaaaaaaaaaaa')).toBe('This is the system the game is running. Switch to another first.'); + expect(deleteBlocked(sys(), null, 'sys_bbbbbbbbbbbbbbbb')).toBeNull(); expect(exportBlocked(sys({ published: false }))).toBe('Publish it before sharing it. Only a published system is shared.'); expect(exportBlocked(sys())).toBeNull(); }); diff --git a/frontend/src/sheets/systemsLibrary.ts b/frontend/src/sheets/systemsLibrary.ts index 718ec576..52851913 100644 --- a/frontend/src/sheets/systemsLibrary.ts +++ b/frontend/src/sheets/systemsLibrary.ts @@ -19,8 +19,27 @@ export interface LibrarySystem { unpublishedChanges: boolean; installed: boolean; problemCount: number; + /** The draft's description and author, '' when it has none. */ + description: string; + author: string; + /** Players' characters saved under it; NPCs aren't counted. */ + characterCount: number; } +/** + * The facts on a system's line in MY SYSTEMS, in order (approved mockup builder-my-systems, + * 2026-10-06): who made it, when it changed, where it came from, how many characters play it. + */ +export const lineFacts = (s: LibrarySystem): string[] => [ + ...(s.author ? [`BY ${s.author.toUpperCase()}`] : []), + `CHANGED ${changedFact(s.updatedAt)}`, + s.installed ? 'INSTALLED FROM A FILE' : 'MADE HERE', + plural(s.characterCount, 'CHARACTER'), +]; + +/** What a line says when the system has no description yet. */ +export const NO_DESCRIPTION = 'No description yet. SETUP asks for one.'; + export type BadgeTone = 'run' | 'plain' | 'warn' | 'bad'; export interface Badge { text: string; tone: BadgeTone } @@ -67,8 +86,11 @@ export const changedFact = (updatedAt: string): string => { 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); +export const deleteBlocked = (s: LibrarySystem, running: string | null, openInBuilder: string | null = null): string | null => { + if (s.id === running) return 'This is the system the game is running. Switch to another first.'; + if (s.id === openInBuilder) return 'It\'s open in the builder. Open another first.'; + return null; +}; export const exportBlocked = (s: LibrarySystem): string | null => (s.published ? null : 'Publish it before sharing it. Only a published system is shared.'); From 73cc95c8b5b9c868d0389684a6b4db139f2d982f Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 14:10:33 -0500 Subject: [PATCH 2/2] test: wait for the pay and the GM's edit in the bank-on money test system_builder_parts_money's 'moves money and tells balances as before' waited 8 database rounds for adminPayPlayers before adminUpdateBank. On a slow CI runner the pay landed after them and its write overtook the edit (ROOK 250, not 9999), failing this PR's CI. It now waits with untilValue for the pay to reach ROOK, then for the edit to land. --- CHANGELOG.md | 4 ++++ .../__tests__/system_builder_parts_money.test.js | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09ae729a..8d179230 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). server image linked to the container's root folder. Both are gone; nothing the game does changes. +- **A bank test no longer fails on a slow machine.** It waited a fixed number of steps for a + payment before the GM's own edit of the same account; on a slow test machine the payment landed + later and overwrote the edit. It now waits for each to land. Nothing in the game changes. + ### Changed - **Each game system has its own bank.** A character's money now belongs to the game it was diff --git a/backend/__tests__/system_builder_parts_money.test.js b/backend/__tests__/system_builder_parts_money.test.js index 80e11c78..70381680 100644 --- a/backend/__tests__/system_builder_parts_money.test.js +++ b/backend/__tests__/system_builder_parts_money.test.js @@ -16,7 +16,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import jwt from 'jsonwebtoken'; import { createRequire } from 'module'; import { makeTestDb, get, run } from './helpers/testDb.js'; -import { drain } from './helpers/until.js'; +import { drain, untilValue } from './helpers/until.js'; process.env.JWT_SECRET = 'test-secret'; const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); @@ -73,8 +73,14 @@ beforeEach(async () => { await new Promise((resolve) => runtime.load(db, resolve)); }); -/** Every way money moves or is told, run once. */ -const everything = async ({ handlers }) => { +/** + * Every way money moves or is told, run once. Where the bank is on (`system` given), it waits for + * the pay to reach ROOK (50 + 200) before the GM's own edit, and for the edit to land, rather than + * a fixed number of rounds: on a slow CI machine the pay once landed after them, its write + * overtaking the edit (2026-10-06). + */ +const everything = async ({ handlers }, system = null) => { + const rook = () => account('ROOK', system); handlers.withdrawFunds({ amount: 100 }); handlers.borrowFunds({ amount: 100 }); handlers.payDebt({ amount: 100 }); @@ -83,10 +89,12 @@ const everything = async ({ handlers }) => { handlers.adminPayPlayers({ token: GM, usernames: ['GHOST', 'ROOK'], totalAmount: 400 }); // The pay lands before the GM's own edit of ROOK, so the edit is the last word. await drain(db); + if (system) await untilValue(rook, (a) => a && a.balance === 250, { label: 'the pay reaching ROOK' }); handlers.adminUpdateBank({ token: GM, username: 'ROOK', balance: 9999, debt: 0 }); handlers.requestBankBalance({ username: 'GHOST' }); handlers.checkoutShop({ locationId: 1, buys: [] }); await drain(db); + if (system) await untilValue(rook, (a) => a && a.balance === 9999, { label: 'the GM\'s edit of ROOK' }); }; describe('a custom system with the bank off', () => { @@ -116,7 +124,7 @@ describe('the bank, where it is on', () => { for (const system of [BANKED, 'cities_without_number', 'generic']) { it(`moves money and tells balances as before (${system})`, async () => { const booted = await running(system); - await everything(booted); + await everything(booted, system); // 1000 - 100 withdrawn, + 100 borrowed onto the debt, 100 of it paid back, + 200 of the pay. expect(await account('GHOST', system)).toEqual({ balance: 1000, debt: 500, first_pay_done: 1, high_roller_done: 1 }); expect(await account('ROOK', system)).toMatchObject({ balance: 9999 });