From c21350a1a94c6d536d075750401f3bfe2c970aa9 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 13:39:28 -0500 Subject: [PATCH] feat(system-builder): the system list gives each one's description, author and characters (4a2c1) listSystems adds the draft's description and author (trimmed, '' when absent or not text) and characterCount, the players' sheets saved under the system (is_npc 0 or NULL; NPC sheets not counted), counted in the same query. For MY SYSTEMS' line items (approved mockup builder-my-systems, 2026-10-06). --- CHANGELOG.md | 4 ++ .../__tests__/system_builder_store.test.js | 20 +++++++++ backend/systemBuilder/store.js | 44 ++++++++++++------- 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5890e280..ccd67df8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/__tests__/system_builder_store.test.js b/backend/__tests__/system_builder_store.test.js index 27d9d937..94158ead 100644 --- a/backend/__tests__/system_builder_store.test.js +++ b/backend/__tests__/system_builder_store.test.js @@ -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 () => { diff --git a/backend/systemBuilder/store.js b/backend/systemBuilder/store.js index 366ea20b..057dfbe1 100644 --- a/backend/systemBuilder/store.js +++ b/backend/systemBuilder/store.js @@ -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, + }; + })); }, ); };