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
14 changes: 12 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Under the hood (tidying)

- **The README's map of the code is complete again, and kept that way.** Its Project Structure had
fallen behind: 202 source and test files weren't in it, and 28 lines put a file in a folder it
wasn't in. Every file now has a line saying what it is for, the misplaced ones are where their
files are, and several out-of-date descriptions are corrected. A new test reads the tree and
fails if a file is added without a line, or a line names a file that has gone, so it can't fall
behind again unnoticed.

- **A token-sharing test no longer fails on a busy machine.** It checked a moved token's position
after waiting a fixed number of database rounds, but the database doesn't always answer in the
order asked, so under load it sometimes read the position before the move was written. Tests
Expand Down Expand Up @@ -88,14 +95,17 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Under the hood

- **Start a system from a genre starter.** In the builder's + NEW, A GENRE STARTER now works like
A BUILT-IN EXAMPLE: a card for Sword & Spell, Starfarer and Story First saying what each holds,
COPY under your own name, and LOOK FIRST to read one locked, marked as a starter.

- **Three genre starters for the system builder.** Ready-made starting points for a genre rather
than a game, each ready to publish as it stands: **Sword & Spell**, fantasy on a d20 (six
abilities with modifiers, proficiency by level, gold pieces, NPCs whose HP is rolled for their
level); **Starfarer**, sci-fi on 2d6 (every modifier read from one table, a count of three
wounds instead of HP); and **Story First**, a narrative one (six approaches rated 0 to 3, harm
levels, milestones, nothing worked out by formula, credited to Fate Accelerated under CC BY).
The server lists them apart from the built-in games and copies one like an example. The
builder's button for them comes next.
The server lists them apart from the built-in games and copies one like an example.

- **Cyberpunk RED and Generic as data, and Cyberpunk RED as an example to copy.** Cyberpunk
RED's current EMP (Humanity / 10, rounded down) and Generic's nothing-at-all are now written in
Expand Down
260 changes: 219 additions & 41 deletions README.md

Large diffs are not rendered by default.

148 changes: 148 additions & 0 deletions backend/__tests__/readme_structure.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { describe, it, expect } from 'vitest';
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
import { join, resolve, relative, sep } from 'path';

/**
* README.md's Project Structure, held to the files on disk (4d3b).
*
* Every piece is meant to add its README lines (docs/system-builder-plan.md), and that lapsed for
* a whole phase: on 2026-10-09, 202 files were missing from the tree and 28 lines put a file in a
* folder it wasn't in. Nothing said so until the user asked. This does: a source or test
* file the tree doesn't name fails here, as does a tree line naming a file that no longer exists.
*
* The tree is read by its own indentation (four characters a level), so each line becomes a full
* path: backend/systemBuilder/examples.js and frontend/src/sheets/examples.ts are two entries,
* and one can't stand in for the other.
*/

const REPO = resolve(__dirname, '../..');

/** Where the tree must be complete, and the kinds of file it must name. */
const ROOTS = ['backend', 'frontend/src'];
const CODE = /\.(js|ts|tsx)$/;
/** Never ours to describe: installed packages, uploads and data, generated output. */
const SKIP_DIRS = new Set(['node_modules', 'uploads', 'data', 'dist', 'coverage', 'fixtures']);

/** The Project Structure block's lines. */
const treeLines = (readme) => {
const start = readme.indexOf('## Project Structure');
const open = readme.indexOf('```', start);
const close = readme.indexOf('```', open + 3);
return readme.slice(open + 3, close).split(/\r?\n/);
};

/**
* Every path the tree names, from repo root: each line's depth is where its ├── or └── sits, its
* name what follows up to the comment. "App.css / index.css" names two files.
*/
const treePaths = (readme) => {
const stack = [];
const paths = [];
for (const line of treeLines(readme)) {
const at = line.search(/[├└]── /);
if (at < 0) continue;
const depth = at / 4;
const name = line.slice(at + 4).split(/\s{2,}#|\s#\s|\s+#$/)[0].trim();
if (!name) continue;
stack.length = depth;
const dir = stack.join('');
for (const part of name.split(' / ')) {
const clean = part.trim();
if (clean.endsWith('/')) stack[depth] = clean;
else paths.push(dir + clean);
}
}
return paths;
};

/** Files on disk the tree doesn't name. */
const missingFrom = (named, onDisk) => onDisk.filter((f) => !named.has(f));

/** Code files under the roots that the tree names but `exists` can't find. */
const goneFrom = (named, exists) => [...named].filter((p) => CODE.test(p) && ROOTS.some((r) => p.startsWith(`${r}/`)) && !exists(p));

/** Every source and test file under the roots, from repo root, with forward slashes. */
const filesOnDisk = () => {
const out = [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
if (SKIP_DIRS.has(entry) || entry.startsWith('.')) continue;
const full = join(dir, entry);
if (statSync(full).isDirectory()) walk(full);
else if (CODE.test(entry)) out.push(relative(REPO, full).split(sep).join('/'));
}
};
ROOTS.forEach((r) => walk(join(REPO, r)));
return out.sort();
};

const README = readFileSync(join(REPO, 'README.md'), 'utf8');

describe('README.md\'s Project Structure', () => {
const named = new Set(treePaths(README));
const onDisk = filesOnDisk();

it('is found, and reads as paths', () => {
expect(named.size).toBeGreaterThan(400);
expect(named.has('backend/server.js')).toBe(true);
expect(named.has('frontend/src/App.tsx')).toBe(true);
expect(onDisk.length).toBeGreaterThan(500);
});

it('names every source and test file', () => {
const missing = missingFrom(named, onDisk);
// Named rather than counted, so the failure says what to add: a line in the tree for each,
// saying what the file is for, in the place its folder is.
expect(missing, 'Add a line to README.md\'s Project Structure for each').toEqual([]);
});

it('names no file that no longer exists', () => {
const gone = goneFrom(named, (p) => existsSync(join(REPO, p)));
expect(gone, 'Remove or rename these lines in README.md\'s Project Structure').toEqual([]);
});
});

describe('reading the tree', () => {
const sample = [
'## Project Structure', '', '```', 'CITY_NET/',
'├── backend/',
'│ ├── server.js # Entry # with a hash inside',
'│ ├── sheets/',
'│ │ └── examples.js # One',
'│ └── __tests__/',
'│ ├── setup/noNetwork.js # Nested by name',
'│ └── a.test.js',
'├── frontend/',
'│ └── src/',
'│ ├── App.css / index.css # Two in one line',
'│ └── sheets/',
'│ └── examples.ts',
'```',
].join('\n');

// The tree is complete today, so the two checks above pass whether or not they look. These are
// what make them able to fail.
it('says which files on disk it doesn\'t name', () => {
const named = new Set(treePaths(sample));
expect(missingFrom(named, ['backend/server.js', 'backend/sheets/new.js', 'frontend/src/App.css']))
.toEqual(['backend/sheets/new.js']);
});

it('says which of its code files are gone, ignoring folders, other files and other places', () => {
const named = new Set([...treePaths(sample), 'docs/plan.js', 'frontend/src/logo.svg']);
const there = new Set(['backend/server.js', 'backend/__tests__/a.test.js', 'frontend/src/sheets/examples.ts']);
expect(goneFrom(named, (p) => there.has(p))).toEqual(['backend/sheets/examples.js', 'backend/__tests__/setup/noNetwork.js']);
});

it('turns each line into its full path, by indentation', () => {
expect(treePaths(sample)).toEqual([
'backend/server.js',
'backend/sheets/examples.js',
'backend/__tests__/setup/noNetwork.js',
'backend/__tests__/a.test.js',
'frontend/src/App.css',
'frontend/src/index.css',
'frontend/src/sheets/examples.ts',
]);
});
});
3 changes: 2 additions & 1 deletion backend/__tests__/system_builder_examples.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,8 @@ describe('the routes', () => {
expect(list.status).toBe(200);
expect(list.body).toEqual(exampleList());
const one = await request(app).get('/api/systems/examples/sr6').set(as());
expect(one.body).toEqual({ id: 'sr6', definition: exampleDefinition('sr6') });
expect(one.body).toEqual({ id: 'sr6', kind: 'example', definition: exampleDefinition('sr6') });
expect((await request(app).get('/api/systems/examples/narrative').set(as())).body.kind).toBe('starter');
expect((await request(app).get('/api/systems/examples/nope').set(as())).status).toBe(404);
});

Expand Down
4 changes: 2 additions & 2 deletions backend/routes/systems.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const { effectiveSheet, starterSheet, fieldsOf } = require('../systemBuilder/she
const { npcSheetOf, LIMITS: NPC_LIMITS } = require('../systemBuilder/npc');
const { rollTier } = require('../systemBuilder/tierRolls');
const { tryHealth } = require('../systemBuilder/tryHealth');
const { exampleList, exampleDefinition, KINDS } = require('../systemBuilder/examples');
const { exampleList, exampleDefinition, exampleKind, KINDS } = require('../systemBuilder/examples');

/** What a currency's icon may be uploaded as (decided with the user, 2026-10-01). */
const ICON_EXT = new Set(['.png', '.webp', '.svg']);
Expand Down Expand Up @@ -82,7 +82,7 @@ module.exports = (db, io = null) => {
router.get('/examples/:id', gm, (req, res) => {
const definition = exampleDefinition(req.params.id);
if (!definition) return res.status(404).json({ error: 'No such example' });
res.json({ id: req.params.id, definition });
res.json({ id: req.params.id, kind: exampleKind(req.params.id), definition });
});

/** A new system: blank from a name, a whole definition, or a copy of an example under a new name. */
Expand Down
8 changes: 7 additions & 1 deletion backend/systemBuilder/examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -221,9 +221,15 @@ const exampleDefinition = (id) => {
return found ? JSON.parse(JSON.stringify(found.definition)) : null;
};

/** Whether an id is a built-in game or a genre starter, or null for neither. */
const exampleKind = (id) => {
const found = EXAMPLES.find((e) => e.id === id);
return found ? found.kind : null;
};

/** What the library shows of each of one kind: no definitions, just enough to pick one. */
const exampleList = (kind = 'example') => EXAMPLES
.filter((e) => e.kind === kind)
.map(({ id, definition }) => ({ id, name: definition.name, description: definition.description }));

module.exports = { EXAMPLES, KINDS, exampleDefinition, exampleList };
module.exports = { EXAMPLES, KINDS, exampleDefinition, exampleKind, exampleList };
8 changes: 5 additions & 3 deletions frontend/src/components/BuilderScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
// With no system open, MY SYSTEMS is the only page there is.
const [page, setPage] = useState<Page>(systemId || looking ? startPage : 'systems');
const [copy, setCopy] = useState<{ name: string; error: string | null; busy: boolean }>({ name: '', error: null, busy: false });
/** Whether the one looked at is a genre starter (4d3b) rather than a built-in game. */
const [starter, setStarter] = useState(false);
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);
Expand Down Expand Up @@ -206,7 +208,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
let live = true;
api.example(example).then((r) => {
if (!live) return;
if (r.ok) setDefinition(r.value.definition); else setLoadError(r.error);
if (r.ok) { setDefinition(r.value.definition); setStarter(r.value.kind === 'starter'); } else setLoadError(r.error);
});
return () => { live = false; };
}, [api, looking, example]);
Expand Down Expand Up @@ -343,7 +345,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
}}>
<span data-testid="builder-system" style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<b style={{ color: 'var(--green)', letterSpacing: 1 }}>{systemId || looking ? (name.toUpperCase() || '…') : 'NO SYSTEM OPEN'}</b>
{looking && <span style={badge('var(--cyan)', 'var(--cyan)')}>EXAMPLE · READ ONLY</span>}
{looking && <span style={badge('var(--cyan)', 'var(--cyan)')}>{starter ? 'STARTER' : 'EXAMPLE'} · READ ONLY</span>}
{system && (system.published
? <span style={badge('color-mix(in srgb, var(--green) 50%, transparent)')}>PUBLISHED v{system.version}</span>
: <span style={badge('var(--warning)', 'var(--warning)')}>NEVER PUBLISHED</span>)}
Expand Down Expand Up @@ -372,7 +374,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
)}
{looking && ready && page !== 'systems' && (
<div role="region" aria-label="Example" style={{ border: '1px solid var(--cyan)', padding: '10px 12px', marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 900 }}>
<span style={{ color: 'var(--cyan)', fontSize: 10, letterSpacing: 2 }}>A BUILT-IN EXAMPLE</span>
<span style={{ color: 'var(--cyan)', fontSize: 10, letterSpacing: 2 }}>{starter ? 'A GENRE STARTER' : 'A BUILT-IN EXAMPLE'}</span>
<span style={{ fontSize: 12, lineHeight: 1.45 }}>
Look around and try it. Nothing here can be changed. To make it your own, copy it under a name of yours: the copy opens on SETUP.
</span>
Expand Down
Loading
Loading