diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d4a801..be98d3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,11 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). start after updating, every token's current values are saved for each system it could be shown in, so switching looks exactly as it does today for anyone with a character there. +- **The game system picker is a searchable list.** The row of system buttons in the admin + panel's GAME tab is now one dropdown: the built-in systems first, then any the GM has made, + with a search box and arrow keys. Choosing a different system now asks first, since it + changes the game for everyone online. + ### Security - **Players can no longer use the GM's tools.** A player's own login worked as a key to the GM's diff --git a/README.md b/README.md index dfb598da..34e0c9f9 100644 --- a/README.md +++ b/README.md @@ -546,6 +546,8 @@ CITY_NET/ │ │ │ └── useAmbientHum.ts # The ambient hum as one sound for the session: its first start eases in (slowly after the boot, quickly on a refresh); the volume slider and mute adjust it in place. Only a real 'playing' counts as started, so a start the browser holds back is asked again on the next key or press │ │ ├── components/ │ │ │ ├── AdminPanel.tsx # GM dashboard — CITY / EXPORT / GAME / PLAYERS tabs; CITY_GENERATOR delegates to cityGen/ and exposes LAYOUT, DRAG_RECT/DRAW_AREA bounds, OVERPASS_DENSITY, WATER, PARK_PONDS, an optional SEED and REGENERATE; CUSTOM type integrates into NEXT_STYLE cycle using cross-map custom_structure_library; data-driven HouseRulesPanel for CP:R, CWN, and SR6; SR6 Edge replenishment (reset all / give 1 to player) +│ │ │ ├── SystemPicker.tsx # GAME tab's system picker: a searchable dropdown, BUILT-IN then YOUR SYSTEMS, portalled into the theme, asking before it switches the game for everyone +│ │ │ ├── systemPickerRules.ts # The picker's rules on their own: grouping and order, search, arrow-key movement, what the button says │ │ │ ├── InventorySection.tsx # The inventory table, on every system. Its own file because SheetRenderer is long enough, and generic: which rows carry an extra button, and what pressing it does, is supplied from outside - so a drug offers CONSUME and a skillplug offers LOAD without either knowing about the other │ │ │ ├── PharmaSection.tsx # What is currently in the bloodstream, drawn in the sheet HEADER rather than a tab: a drug that wears off at the end of a scene and bills System Strain for it is not something to hide behind a tab somebody might not open. Draws nothing at all while a character is on nothing │ │ │ ├── XpWindow.tsx # AWARD_EXPERIENCE — points each rather than a pot to divide, with LEVEL_UP and LEVEL_DOWN for correcting a level on purpose @@ -608,6 +610,8 @@ CITY_NET/ │ │ │ ├── UpdateModal.tsx # Draggable update notification modal (shown on admin login when update available; Update Now / Remind Me Later / Skip Version; docker-aware) │ │ │ └── __tests__/ # Component unit tests (Vitest + Testing Library) │ │ │ ├── AdminPanel.test.tsx +│ │ │ ├── SystemPicker.test.tsx # The game-system dropdown: search, keys, the themed container, asking before a switch, nothing asked about the running system, and a refused switch said so +│ │ │ ├── systemPickerRules.test.ts # Built-ins in their fixed order then custom A to Z, empty groups left out, every-word search, wrapping arrow keys, the version tag │ │ │ ├── AttackAnimations.test.tsx │ │ │ ├── BankWindows.test.tsx │ │ │ ├── shopCart.test.ts # Cart lines and counts, totals both ways, sells grouped for the server, and the carry projection diff --git a/backend/__tests__/system_builder_runtime.test.js b/backend/__tests__/system_builder_runtime.test.js index d217ef41..6961cf1a 100644 --- a/backend/__tests__/system_builder_runtime.test.js +++ b/backend/__tests__/system_builder_runtime.test.js @@ -150,7 +150,7 @@ describe('a published system, in the running game', () => { await publish(); expect(templates.isValidSystem(id)).toBe(true); - expect((await request(app).get('/api/sheets/system')).body.systems).toContainEqual({ id, name: 'Vault Knights', custom: true }); + expect((await request(app).get('/api/sheets/system')).body.systems).toContainEqual({ id, name: 'Vault Knights', custom: true, version: 1 }); }); it('answers the same questions the built-in systems do', async () => { diff --git a/backend/__tests__/token_vitals.test.js b/backend/__tests__/token_vitals.test.js index b69b9b1f..f0d5dd11 100644 --- a/backend/__tests__/token_vitals.test.js +++ b/backend/__tests__/token_vitals.test.js @@ -231,8 +231,19 @@ describe('the real startup path', () => { // First open: a 1.14.4-era database is made, with a token carrying health. const first = require(${JSON.stringify(require_.resolve('../db.js'))}); // Its own startup work first (the one-time moves run after the tables exist), so - // nothing of it is still running when this connection closes. - require(${JSON.stringify(require_.resolve('../tokens/vitals.js'))}).whenReady().then(() => { + // nothing of it is still running when this connection closes. That includes the admin + // seed: it waits on a bcrypt hash, and on a slow runner its INSERT landed after close() + // and killed the process with "Database is closed". + const adminSeeded = () => new Promise((resolve, reject) => { + const started = Date.now(); + const poll = () => first.get('SELECT COUNT(*) AS n FROM admin', (err, row) => { + if (!err && row && row.n > 0) return resolve(); + if (Date.now() - started > 30000) return reject(new Error('admin was never seeded')); + setTimeout(poll, 20); + }); + poll(); + }); + require(${JSON.stringify(require_.resolve('../tokens/vitals.js'))}).whenReady().then(adminSeeded).then(() => { first.serialize(() => { first.run("DELETE FROM global_settings WHERE key = 'migration_token_vitals'"); first.run("INSERT OR REPLACE INTO global_settings (key, value) VALUES ('game_system', '${CWN}')"); diff --git a/backend/systemBuilder/runtime.js b/backend/systemBuilder/runtime.js index e53e8d3b..7b3315f2 100644 --- a/backend/systemBuilder/runtime.js +++ b/backend/systemBuilder/runtime.js @@ -53,35 +53,35 @@ const renderOf = (id, definition) => ({ sheet: effectiveSheet(definition), }); -const put = (id, publishedText) => { +const put = (id, publishedText, version) => { const definition = parse(publishedText); if (!definition) { loaded.delete(id); return; } - loaded.set(id, { name: definition.name, definition, meta: metaOf(definition), render: renderOf(id, definition) }); + loaded.set(id, { name: definition.name, version: version || 0, definition, meta: metaOf(definition), render: renderOf(id, definition) }); }; /** Load every published system. cb(err, count). */ const load = (db, cb = () => {}) => { - db.all('SELECT id, published FROM custom_systems WHERE published IS NOT NULL', [], (err, rows) => { + db.all('SELECT id, published, version FROM custom_systems WHERE published IS NOT NULL', [], (err, rows) => { if (err) { console.error('[systems] Could not load custom systems:', err.message); return cb(err); } loaded.clear(); - for (const r of rows) put(r.id, r.published); + for (const r of rows) put(r.id, r.published, r.version); cb(null, loaded.size); }); }; /** Reload one system after it is published or deleted. cb(err). */ const refresh = (db, id, cb = () => {}) => { - db.get('SELECT published FROM custom_systems WHERE id = ?', [id], (err, row) => { + db.get('SELECT published, version FROM custom_systems WHERE id = ?', [id], (err, row) => { if (err) return cb(err); - if (row && row.published) put(id, row.published); else loaded.delete(id); + if (row && row.published) put(id, row.published, row.version); else loaded.delete(id); cb(null); }); }; const meta = (id) => (loaded.has(id) ? loaded.get(id).meta : null); const render = (id) => (loaded.has(id) ? loaded.get(id).render : null); -/** Every published system, for the picker. */ -const list = () => [...loaded.entries()].map(([id, s]) => ({ id, name: s.name, custom: true })) +/** Every published system, for the picker, with the version that is running. */ +const list = () => [...loaded.entries()].map(([id, s]) => ({ id, name: s.name, custom: true, version: s.version })) .sort((a, b) => a.name.localeCompare(b.name)); templates.setCustomMeta(meta); diff --git a/frontend/src/App.css b/frontend/src/App.css index 98ec27f4..023ce010 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1457,3 +1457,22 @@ button:disabled, .utility-btn:disabled, .upload-btn:disabled { .crt-scroll::-webkit-scrollbar { width: 8px; height: 8px; } .crt-scroll::-webkit-scrollbar-track { background: var(--black); } .crt-scroll::-webkit-scrollbar-thumb { background: var(--dark-green); border: 1px solid var(--green); } + +/* The game-system picker's list (components/SystemPicker.tsx). It scrolls itself inside a + portalled box, so it needs the themed scrollbar of its own - a nested scroller otherwise + draws the platform's wide default. */ +.system-picker-list { + max-height: 320px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--dark-green) var(--black); + padding-bottom: 4px; +} +.system-picker-list::-webkit-scrollbar { width: 6px; } +.system-picker-list::-webkit-scrollbar-track { background: var(--black); } +.system-picker-list::-webkit-scrollbar-thumb { + background: var(--dark-green); + border: 1px solid var(--green); + border-radius: 3px; +} diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index fa5da9f7..464d9e9b 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -1,6 +1,8 @@ import React, { useState, useEffect } from 'react'; import { BUILDING_TYPES, shopsAvailable, isShop, typeLabel } from '../data/buildingTypes'; import { EmptyShopSteps } from './EmptyShopSteps'; +import { SystemPicker } from './SystemPicker'; +import type { PickerSystem } from './systemPickerRules'; import { BuildingExtrasEditor, type BuildingExtrasHandle } from './BuildingExtrasEditor'; import { OVERDRAFT_RULE, BUYBACK_SETTING, DEFAULT_BUYBACK_PCT } from '../data/shopRules'; import { xpAvailable } from './XpWindow'; @@ -2287,7 +2289,7 @@ const SR6_HOUSE_RULES: HouseRuleDef[] = [ function TTRPGSystemPanel({ token, onOpenNpcLibrary, activeUsers }: { token: string; onOpenNpcLibrary?: () => void; activeUsers?: any[] }) { const [open, setOpen] = useState(false); const [system, setSystem] = useState('generic'); - const [systems, setSystems] = useState<{ id: string; name: string }[]>([]); + const [systems, setSystems] = useState([]); const [luckResetMsg, setLuckResetMsg] = useState(null); const [edgeGrantTarget, setEdgeGrantTarget] = useState(''); @@ -2300,12 +2302,15 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, activeUsers }: { token: str useEffect(() => { if (open) refresh(); }, [open]); - const selectSystem = (id: string) => { - fetch('/api/sheets/system', { + /** Switch the game. The picker has already asked; this says whether the server agreed. */ + const selectSystem = async (id: string): Promise => { + const r = await fetch('/api/sheets/system', { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ system: id }), - }).then(r => { if (r.ok) setSystem(id); }); + }); + if (r.ok) setSystem(id); + return r.ok; }; return ( @@ -2316,20 +2321,9 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, activeUsers }: { token: str {open && (
-
- {systems.map(s => ( - - ))} -
+

- Player sheets for the current system are kept and restored if you switch back. + Each system keeps its own characters, banks and token health; switching back restores them.

Promise; +}) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [active, setActive] = useState(-1); + const [rect, setRect] = useState(null); + const [confirming, setConfirming] = useState(null); + const [switching, setSwitching] = useState(false); + const [error, setError] = useState(null); + const buttonRef = useRef(null); + const listRef = useRef(null); + + const groups = useMemo(() => groupSystems(systems, query), [systems, query]); + const flat = useMemo(() => flatten(groups), [groups]); + const shown = describe(systems.find((s) => s.id === current)); + + const close = () => { setOpen(false); setQuery(''); setActive(-1); }; + const toggle = () => { + if (open) return close(); + setRect(buttonRef.current?.getBoundingClientRect() ?? null); + setError(null); + setOpen(true); + }; + + // Close when clicking anywhere else; follow the button if the panel scrolls or resizes. + useEffect(() => { + if (!open) return undefined; + const onDown = (e: MouseEvent) => { + const t = e.target as Node; + if (listRef.current?.contains(t) || buttonRef.current?.contains(t)) return; + close(); + }; + const follow = () => setRect(buttonRef.current?.getBoundingClientRect() ?? null); + document.addEventListener('mousedown', onDown); + window.addEventListener('resize', follow); + window.addEventListener('scroll', follow, true); + return () => { + document.removeEventListener('mousedown', onDown); + window.removeEventListener('resize', follow); + window.removeEventListener('scroll', follow, true); + }; + }, [open]); + + const pick = (s: PickerSystem) => { + close(); + if (s.id !== current) setConfirming(s); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + setActive((i) => moveActive(i, e.key === 'ArrowDown' ? 1 : -1, flat.length)); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (active >= 0 && flat[active]) pick(flat[active]); + else if (flat.length === 1) pick(flat[0]); + } else if (e.key === 'Escape') { + e.preventDefault(); + e.stopPropagation(); + close(); + buttonRef.current?.focus(); + } + }; + + const confirmSwitch = async () => { + const target = confirming; + if (!target) return; + setSwitching(true); + let ok = false; + try { ok = await onSwitch(target.id); } catch { ok = false; } + setSwitching(false); + setConfirming(null); + if (!ok) setError(`Could not switch to ${target.name.toUpperCase()}. Nothing was changed.`); + }; + + const listId = 'system-picker-list'; + const width = Math.max(rect?.width ?? 260, 260); + + return ( + <> + + {error &&

{error}

} + + {open && rect && createPortal( +
+ = 0 && flat[active] ? `system-option-${flat[active].id}` : undefined} + placeholder="SEARCH SYSTEMS" + value={query} + onChange={(e) => { setQuery(e.target.value); setActive(-1); }} + onKeyDown={onKeyDown} + style={{ + width: '100%', boxSizing: 'border-box', padding: '6px 8px', fontFamily: 'monospace', fontSize: '0.75rem', + background: 'var(--black)', color: 'var(--green)', border: 'none', borderBottom: '1px solid var(--dark-green)', outline: 'none', + }} + /> +
+ {groups.length === 0 &&
No system matches.
} + {groups.map((g) => ( +
+
{g.label}
+ {g.items.map((s) => { + const index = flat.indexOf(s); + const isActive = index === active; + const isCurrent = s.id === current; + return ( +
setActive(index)} + onMouseDown={(e) => e.preventDefault()} + onClick={() => pick(s)} + style={{ + padding: '5px 10px', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', gap: '8px', + fontSize: '0.72rem', + background: isActive ? 'var(--dark-green)' : 'transparent', + color: isCurrent ? 'var(--green)' : 'var(--text)', + fontWeight: isCurrent ? 'bold' : 'normal', + }} + > + {isCurrent ? '▶ ' : ''}{s.name.toUpperCase()} + {s.custom && v{s.version ?? 0}} +
+ ); + })} +
+ ))} +
+
, + themeRoot(), + )} + + {confirming && createPortal( +
+
+

!! SWITCH GAME SYSTEM !!

+

Switch the game to [{confirming.name.toUpperCase()}]?

+

+ This changes the game for everyone online, and is meant for starting a new campaign. + Each system keeps its own characters, banks and token health, so switching back brings them back. +

+
+ + +
+
+
, + themeRoot(), + )} + + ); +} diff --git a/frontend/src/components/__tests__/SystemPicker.test.tsx b/frontend/src/components/__tests__/SystemPicker.test.tsx new file mode 100644 index 00000000..813458b4 --- /dev/null +++ b/frontend/src/components/__tests__/SystemPicker.test.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { SystemPicker } from '../SystemPicker'; +import type { PickerSystem } from '../systemPickerRules'; + +/** + * The game-system picker as a player of it sees it: a button, a searchable grouped list, and a + * confirmation before the game changes for everyone. + */ + +const SYSTEMS: PickerSystem[] = [ + { id: 'cities_without_number', name: 'Cities Without Number' }, + { id: 'cyberpunk_red', name: 'Cyberpunk RED' }, + { id: 'generic', name: 'Generic' }, + { id: 'sys_bbbbbbbbbbbbbbbb', name: 'Vault Knights', custom: true, version: 3 }, +]; + +afterEach(() => { cleanup(); document.body.innerHTML = ''; }); + +const show = (onSwitch = vi.fn(() => Promise.resolve(true)), current = 'cities_without_number') => { + render(); + return onSwitch; +}; +const openList = () => fireEvent.click(screen.getByRole('button', { name: /CITIES WITHOUT NUMBER|VAULT KNIGHTS/ })); +const search = () => screen.getByRole('combobox', { name: 'Search game systems' }); + +describe('the button', () => { + it('shows the running system, and a custom one\'s version', () => { + show(undefined, 'sys_bbbbbbbbbbbbbbbb'); + expect(screen.getByText('VAULT KNIGHTS')).toBeTruthy(); + expect(screen.getByText('CUSTOM · v3')).toBeTruthy(); + }); +}); + +describe('the list', () => { + it('opens with a search box and the two groups, marking the running system', () => { + show(); + openList(); + expect(search()).toBeTruthy(); + expect(screen.getByRole('group', { name: 'BUILT-IN' })).toBeTruthy(); + expect(screen.getByRole('group', { name: 'YOUR SYSTEMS' })).toBeTruthy(); + const running = screen.getByRole('option', { selected: true }); + expect(running.textContent).toContain('CITIES WITHOUT NUMBER'); + }); + + it('narrows as you type', () => { + show(); + openList(); + fireEvent.change(search(), { target: { value: 'vault' } }); + expect(screen.getAllByRole('option').map((o) => o.textContent)).toEqual(['VAULT KNIGHTSv3']); + fireEvent.change(search(), { target: { value: 'zzz' } }); + expect(screen.getByText('No system matches.')).toBeTruthy(); + }); + + it('closes on Escape without switching anything', () => { + const onSwitch = show(); + openList(); + fireEvent.keyDown(search(), { key: 'Escape' }); + expect(screen.queryByRole('listbox')).toBeNull(); + expect(onSwitch).not.toHaveBeenCalled(); + }); + + it('closes when clicking elsewhere', () => { + show(); + openList(); + fireEvent.mouseDown(document.body); + expect(screen.queryByRole('listbox')).toBeNull(); + }); + + it('lives in the themed container, so it wears the theme', () => { + const root = document.createElement('div'); + root.className = 'crt-container theme-vaporwave'; + document.body.appendChild(root); + show(); + openList(); + expect(root.contains(screen.getByRole('listbox'))).toBe(true); + }); +}); + +describe('switching', () => { + it('asks first, and switches only on SWITCH', async () => { + const onSwitch = show(); + openList(); + fireEvent.click(screen.getByRole('option', { name: /VAULT KNIGHTS/ })); + expect(screen.getByRole('dialog')).toBeTruthy(); + expect(screen.getByText(/changes the game for everyone online/)).toBeTruthy(); + expect(onSwitch).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'SWITCH' })); + await waitFor(() => expect(onSwitch).toHaveBeenCalledWith('sys_bbbbbbbbbbbbbbbb')); + await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull()); + }); + + it('does nothing when cancelled', () => { + const onSwitch = show(); + openList(); + fireEvent.click(screen.getByRole('option', { name: /CYBERPUNK RED/ })); + fireEvent.click(screen.getByRole('button', { name: 'CANCEL' })); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(onSwitch).not.toHaveBeenCalled(); + }); + + it('does not ask about the system already running', () => { + const onSwitch = show(); + openList(); + fireEvent.click(screen.getByRole('option', { name: /CITIES WITHOUT NUMBER/ })); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(onSwitch).not.toHaveBeenCalled(); + }); + + it('picks with the arrow keys and Enter', () => { + show(); + openList(); + fireEvent.keyDown(search(), { key: 'ArrowDown' }); + fireEvent.keyDown(search(), { key: 'ArrowDown' }); + fireEvent.keyDown(search(), { key: 'Enter' }); + expect(screen.getByRole('dialog').textContent).toContain('CYBERPUNK RED'); + }); + + it('picks the only match with Enter straight after typing', () => { + show(); + openList(); + fireEvent.change(search(), { target: { value: 'generic' } }); + fireEvent.keyDown(search(), { key: 'Enter' }); + expect(screen.getByRole('dialog').textContent).toContain('GENERIC'); + }); + + it('says so when the server refuses, and changes nothing', async () => { + const onSwitch = vi.fn(() => Promise.resolve(false)); + show(onSwitch); + openList(); + fireEvent.click(screen.getByRole('option', { name: /VAULT KNIGHTS/ })); + fireEvent.click(screen.getByRole('button', { name: 'SWITCH' })); + expect(await screen.findByRole('alert')).toBeTruthy(); + expect(screen.getByRole('alert').textContent).toContain('Nothing was changed'); + }); +}); diff --git a/frontend/src/components/__tests__/systemPickerRules.test.ts b/frontend/src/components/__tests__/systemPickerRules.test.ts new file mode 100644 index 00000000..3537c95d --- /dev/null +++ b/frontend/src/components/__tests__/systemPickerRules.test.ts @@ -0,0 +1,63 @@ +import { describe as group, it, expect } from 'vitest'; +import { groupSystems, flatten, moveActive, matches, describe, BUILT_IN_ORDER, type PickerSystem } from '../systemPickerRules'; + +/** The game-system picker's rules: two groups, a fixed built-in order, search, and the keys. */ + +const SYSTEMS: PickerSystem[] = [ + { id: 'generic', name: 'Generic' }, + { id: 'shadowrun_6e', name: 'Shadowrun 6E' }, + { id: 'sys_bbbbbbbbbbbbbbbb', name: 'Vault Knights', custom: true, version: 3 }, + { id: 'cities_without_number', name: 'Cities Without Number' }, + { id: 'sys_aaaaaaaaaaaaaaaa', name: 'Ashen Crowns', custom: true, version: 1 }, + { id: 'cyberpunk_red', name: 'Cyberpunk RED' }, +]; + +group('grouping', () => { + it('lists the built-in systems in a fixed order, then the GM\'s own A to Z', () => { + const groups = groupSystems(SYSTEMS); + expect(groups.map((g) => g.label)).toEqual(['BUILT-IN', 'YOUR SYSTEMS']); + expect(groups[0].items.map((s) => s.id)).toEqual(BUILT_IN_ORDER); + expect(groups[1].items.map((s) => s.name)).toEqual(['Ashen Crowns', 'Vault Knights']); + }); + + it('leaves out a group with nothing in it', () => { + expect(groupSystems(SYSTEMS.filter((s) => !s.custom)).map((g) => g.label)).toEqual(['BUILT-IN']); + expect(groupSystems(SYSTEMS, 'vault').map((g) => g.label)).toEqual(['YOUR SYSTEMS']); + expect(groupSystems(SYSTEMS, 'nothing like this')).toEqual([]); + }); + + it('puts a built-in system it does not know after the known ones', () => { + const groups = groupSystems([...SYSTEMS, { id: 'aaa_new', name: 'Aardvark' }]); + expect(groups[0].items.at(-1)!.id).toBe('aaa_new'); + }); +}); + +group('search', () => { + it('matches every word typed, anywhere in the name, ignoring case', () => { + const names = (q: string) => flatten(groupSystems(SYSTEMS, q)).map((s) => s.name); + expect(names('RED')).toEqual(['Cyberpunk RED']); + expect(names('without cities')).toEqual(['Cities Without Number']); + expect(names(' ')).toHaveLength(SYSTEMS.length); + expect(matches({ id: 'x', name: 'Vault Knights' }, 'knight vault')).toBe(true); + expect(matches({ id: 'x', name: 'Vault Knights' }, 'knight crown')).toBe(false); + }); +}); + +group('the arrow keys', () => { + it('move down and up, wrapping at either end, starting from the top or bottom', () => { + expect(moveActive(-1, 1, 5)).toBe(0); + expect(moveActive(-1, -1, 5)).toBe(4); + expect(moveActive(4, 1, 5)).toBe(0); + expect(moveActive(0, -1, 5)).toBe(4); + expect(moveActive(2, 1, 5)).toBe(3); + expect(moveActive(0, 1, 0)).toBe(-1); + }); +}); + +group('the button', () => { + it('names the system, and tags a custom one with its version', () => { + expect(describe(SYSTEMS[3])).toEqual({ name: 'CITIES WITHOUT NUMBER', tag: null }); + expect(describe(SYSTEMS[2])).toEqual({ name: 'VAULT KNIGHTS', tag: 'CUSTOM · v3' }); + expect(describe(undefined)).toEqual({ name: 'UNKNOWN SYSTEM', tag: null }); + }); +}); diff --git a/frontend/src/components/systemPickerRules.ts b/frontend/src/components/systemPickerRules.ts new file mode 100644 index 00000000..f7e77759 --- /dev/null +++ b/frontend/src/components/systemPickerRules.ts @@ -0,0 +1,62 @@ +// The game-system picker's rules, kept apart from its window so they can be tested alone. +// +// The picker was a row of buttons, which holds four systems and not forty. Now it is a +// searchable list in two groups: the systems CITY_NET ships with, in a fixed order, then the +// GM's own published ones, A to Z. + +export interface PickerSystem { + id: string; + name: string; + /** A GM-built system (backend/systemBuilder/runtime.js list()). */ + custom?: boolean; + /** The published version running, for a custom system. */ + version?: number; +} + +export interface PickerGroup { + label: string; + items: PickerSystem[]; +} + +/** The built-in systems' order in the list. Any built-in not named here follows, A to Z. */ +export const BUILT_IN_ORDER = ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic']; + +const rank = (id: string) => { + const i = BUILT_IN_ORDER.indexOf(id); + return i === -1 ? BUILT_IN_ORDER.length : i; +}; + +/** Does `system` match what has been typed? Every word typed must appear in its name. */ +export const matches = (system: PickerSystem, query: string): boolean => { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + const name = system.name.toLowerCase(); + return words.every((w) => name.includes(w)); +}; + +/** The list as shown: BUILT-IN then YOUR SYSTEMS, filtered by the query, empty groups left out. */ +export const groupSystems = (systems: PickerSystem[], query = ''): PickerGroup[] => { + const shown = systems.filter((s) => matches(s, query)); + const builtIn = shown.filter((s) => !s.custom) + .sort((a, b) => rank(a.id) - rank(b.id) || a.name.localeCompare(b.name)); + const custom = shown.filter((s) => s.custom).sort((a, b) => a.name.localeCompare(b.name)); + return [ + { label: 'BUILT-IN', items: builtIn }, + { label: 'YOUR SYSTEMS', items: custom }, + ].filter((g) => g.items.length > 0); +}; + +/** Every system in the order shown, for moving through with the arrow keys. */ +export const flatten = (groups: PickerGroup[]): PickerSystem[] => groups.flatMap((g) => g.items); + +/** The highlighted row after pressing up (-1) or down (+1), wrapping at either end. */ +export const moveActive = (index: number, delta: number, count: number): number => { + if (count <= 0) return -1; + if (index < 0) return delta > 0 ? 0 : count - 1; + return (index + delta + count) % count; +}; + +/** What the picker's button says about a system: its name, and its version when custom. */ +export const describe = (system: PickerSystem | undefined): { name: string; tag: string | null } => { + if (!system) return { name: 'UNKNOWN SYSTEM', tag: null }; + return { name: system.name.toUpperCase(), tag: system.custom ? `CUSTOM · v${system.version ?? 0}` : null }; +};