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, + }; + })); }, ); };