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

### Under the hood

- **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.

- **The system builder opens.** OPEN IN BUILDER in SYSTEMS.EXE, or CREATE for a new system, opens
the builder over the whole window. Its sidebar is an icon rail like the map's, which widens to
show the names when you point at it: EXIT TO MAP where the power button sits, MY SYSTEMS, the
Expand Down
20 changes: 20 additions & 0 deletions backend/__tests__/system_builder_store.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,26 @@ describe('the systems routes', () => {
expect(res.body.problems).toEqual([]);
const [only] = await list();
expect(only).toMatchObject({ id: res.body.id, name: 'Vault Knights', version: 0, published: false, unpublishedChanges: true, installed: false, problemCount: 0 });
// Nothing yet to describe it, credit or play in it.
expect(only).toMatchObject({ description: '', author: '', characterCount: 0 });
});

it('says what each system\'s line shows: its draft\'s description and author, and its players\' characters', async () => {
const { id } = (await create({ definition: { format: 1, name: 'Hearth', description: ' Low fantasy by one fire. ', author: 'Cody' } })).body;
const other = (await create({ name: 'Ember' })).body.id;
const sheet = (username, system, isNpc) => run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, ?)', [username, system, '{}', isNpc]);
await sheet('vex', id, 0);
await sheet('rook', id, 0);
await sheet('gm', id, 1);
await sheet('vex', other, 0);
await sheet('vex', 'cities_without_number', 0);
await run(db, 'INSERT INTO character_sheets (username, system, data, is_npc) VALUES (?, ?, ?, NULL)', ['ghost', id, '{}']);
const byId = Object.fromEntries((await list()).map((s) => [s.id, s]));
expect(byId[id]).toMatchObject({ description: 'Low fantasy by one fire.', author: 'Cody', characterCount: 3 });
expect(byId[other]).toMatchObject({ description: '', author: '', characterCount: 1 });
// The draft's, not the published copy's: what the GM is working on.
await request(app).put(`/api/systems/${id}/draft`).set(bearer(GM)).send({ definition: { format: 1, name: 'Hearth', description: 'Rewritten.', author: 42 } });
expect((await list()).find((s) => s.id === id)).toMatchObject({ description: 'Rewritten.', author: '' });
});

it('refuses a name another system has, whatever its capitals, so the GM can pick another', async () => {
Expand Down
44 changes: 28 additions & 16 deletions backend/systemBuilder/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,30 +39,42 @@ const problemsOf = (draft) => {
return checked.fatal ? [{ where: 'definition', message: checked.fatal }] : checked.problems;
};

/** A definition's text field, or '' when it has none. */
const textOf = (definition, key) => (definition && typeof definition[key] === 'string' ? definition[key].trim() : '');

/**
* Every system, newest change first, without their definitions. Each says how many problems its
* draft has, so SYSTEMS.EXE can badge them without fetching every system.
* draft has, so the builder's MY SYSTEMS can badge them without fetching every system, and what
* its line shows (approved mockup builder-my-systems, 2026-10-06): the draft's description and
* author, and how many players' characters are played in it (NPC sheets are not counted).
*/
const listSystems = (db, cb) => {
db.all(
`SELECT id, name, version, draft, published, source_hash, updated_at, published_at
FROM custom_systems WHERE deleted_at IS NULL ORDER BY updated_at DESC, name`,
`SELECT s.id, s.name, s.version, s.draft, s.published, s.source_hash, s.updated_at, s.published_at,
(SELECT COUNT(*) FROM character_sheets c WHERE c.system = s.id AND COALESCE(c.is_npc, 0) = 0) AS character_count
FROM custom_systems s WHERE s.deleted_at IS NULL ORDER BY s.updated_at DESC, s.name`,
[],
(err, rows) => {
if (err) return cb(err);
cb(null, rows.map((r) => ({
id: r.id,
name: r.name,
version: r.version,
updatedAt: r.updated_at,
publishedAt: r.published_at,
published: r.published != null,
// Compared as stored text: the draft is written exactly as publishing copies it.
unpublishedChanges: r.published == null || r.draft !== r.published,
// Installed from a file, rather than made here: only an install records the file's hash.
installed: r.source_hash != null,
problemCount: problemsOf(parse(r.draft)).length,
})));
cb(null, rows.map((r) => {
const draft = parse(r.draft);
return {
id: r.id,
name: r.name,
version: r.version,
updatedAt: r.updated_at,
publishedAt: r.published_at,
published: r.published != null,
// Compared as stored text: the draft is written exactly as publishing copies it.
unpublishedChanges: r.published == null || r.draft !== r.published,
// Installed from a file, rather than made here: only an install records the file's hash.
installed: r.source_hash != null,
problemCount: problemsOf(draft).length,
description: textOf(draft, 'description'),
author: textOf(draft, 'author'),
characterCount: r.character_count,
};
}));
},
);
};
Expand Down
Loading