Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion backend/__tests__/system_builder_runtime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
15 changes: 13 additions & 2 deletions backend/__tests__/token_vitals.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}')");
Expand Down
16 changes: 8 additions & 8 deletions backend/systemBuilder/runtime.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
28 changes: 11 additions & 17 deletions frontend/src/components/AdminPanel.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string>('generic');
const [systems, setSystems] = useState<{ id: string; name: string }[]>([]);
const [systems, setSystems] = useState<PickerSystem[]>([]);
const [luckResetMsg, setLuckResetMsg] = useState<string | null>(null);
const [edgeGrantTarget, setEdgeGrantTarget] = useState<string>('');

Expand All @@ -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<boolean> => {
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 (
Expand All @@ -2316,20 +2321,9 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, activeUsers }: { token: str
{open && (
<div style={{ marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '8px' }}>
<label style={{ fontSize: '0.7rem' }}>GAME SYSTEM</label>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{systems.map(s => (
<button
key={s.id}
className={`utility-btn ${system === s.id ? 'active' : ''}`}
style={{ padding: '4px 10px', fontSize: '0.65rem' }}
onClick={() => selectSystem(s.id)}
>
{s.name.toUpperCase()}
</button>
))}
</div>
<SystemPicker systems={systems} current={system} onSwitch={selectSystem} />
<p style={{ fontSize: '0.6rem', opacity: 0.6, margin: 0 }}>
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.
</p>
<HouseRulesPanel token={token} defs={[
...GLOBAL_HOUSE_RULES,
Expand Down
Loading
Loading