From 174819377db944b38efde7949251983136ac9c6d Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 7 Oct 2026 10:00:58 -0500 Subject: [PATCH] feat(system-builder): preview a draft's sheet and its starter (4b3a) POST /api/systems/preview-sheet answers, for a draft not yet saved, the sheet it is drawn with (effectiveSheet) and the starter sheet CUSTOMIZE copies (sheet.starterSheet). The starter keeps fields for parts that are off, so turning a part back on brings them back on a customized sheet. Main admin only; systemsApi.previewSheet calls it. --- CHANGELOG.md | 6 ++ .../system_builder_preview_sheet.test.js | 86 +++++++++++++++++++ backend/routes/systems.js | 13 +++ backend/systemBuilder/sheet.js | 9 +- .../src/sheets/__tests__/systemsApi.test.ts | 1 + frontend/src/sheets/systemsApi.ts | 7 ++ 6 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 backend/__tests__/system_builder_preview_sheet.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 37d6836b..66002629 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **A draft's character sheet, shown without saving.** The server can now answer, for a draft + still being edited, the sheet it would be drawn with and the starter sheet it would get without + one of its own, for the builder's CHARACTER SHEET page to preview and to copy when you customize. + The starter keeps fields for parts that are off (cash with the bank off), so turning a part back + on brings them back on a customized sheet too. Nothing is saved and play never sees it. + - **The builder's STATS & RULES page: formulas.** FORMULAS lists each formula with the name players see, its formula written the rulebook way, and its value for the sample character, worked out by the game's own engine a moment after you stop typing. A mistake shows in red under diff --git a/backend/__tests__/system_builder_preview_sheet.test.js b/backend/__tests__/system_builder_preview_sheet.test.js new file mode 100644 index 00000000..575c63d6 --- /dev/null +++ b/backend/__tests__/system_builder_preview_sheet.test.js @@ -0,0 +1,86 @@ +/** + * The builder's CHARACTER SHEET page (4b3): what a draft's sheet looks like as it stands, and the + * starter sheet CUSTOMIZE copies. Decided with the user 2026-10-07 (mockup builder-sheet): a sheet + * is automatic until the GM customizes it, and customizing starts from the starter sheet. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import express from 'express'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { createRequire } from 'module'; +import { makeTestDb } from './helpers/testDb.js'; + +process.env.JWT_SECRET = 'test-secret'; +const require_ = createRequire(import.meta.url); +const { effectiveSheet, starterSheet, checkSheet } = require_('../systemBuilder/sheet'); +const { elevatedUsers } = require_('../middleware/auth'); +const systemsRoute = require_('../routes/systems.js'); + +const GM = jwt.sign({ id: 1, username: 'gm', role: 'admin', isTemporary: false }, 'test-secret'); +const EDITOR = jwt.sign({ username: 'ghost', isTemporary: true }, 'test-secret'); +const PLAYER = jwt.sign({ username: 'vex', role: 'player' }, 'test-secret'); + +const HEARTH = { + format: 1, + name: 'Hearth', + stats: [{ id: 'abilities', label: 'ABILITIES', stats: [{ id: 'str', label: 'Strength', min: 3, max: 18 }] }], + derived: [{ id: 'save', label: 'Save', formula: '16 - @str' }], +}; +const OWN = { sections: [{ id: 'mine', label: 'MINE', layout: 'list', fields: [{ id: 'str', label: 'Might', type: 'number' }] }] }; +const ids = (sheet) => sheet.sections.flatMap((s) => s.fields.map((f) => f.id)); + +describe('the starter sheet', () => { + it('is the automatic sheet of a system without one', () => { + expect(starterSheet(HEARTH)).toEqual(effectiveSheet(HEARTH)); + expect(ids(starterSheet(HEARTH))).toEqual(['name', 'concept', 'description', 'hp', 'hp_max', 'str', 'save', 'cash', 'notes']); + }); + + it('ignores a sheet the system already has', () => { + expect(starterSheet({ ...HEARTH, sheet: OWN })).toEqual(starterSheet(HEARTH)); + expect(effectiveSheet({ ...HEARTH, sheet: OWN })).toEqual(OWN); + }); + + it('keeps fields linked to a part that is off, which the drawn sheet leaves out', () => { + const noBank = { ...HEARTH, parts: { bank: { on: false } } }; + expect(ids(starterSheet(noBank))).toContain('cash'); + expect(ids(effectiveSheet(noBank))).not.toContain('cash'); + }); + + it('passes the server\'s own check, so CUSTOMIZE never starts with problems', () => { + const problems = []; + checkSheet(starterSheet(HEARTH), new Set(['save']), problems); + expect(problems).toEqual([]); + }); +}); + +describe('the route', () => { + let app; + beforeEach(async () => { + elevatedUsers.add('ghost'); + const db = await makeTestDb(); + app = express(); + app.use(express.json()); + app.use('/api/systems', systemsRoute(db)); + }); + afterEach(() => elevatedUsers.delete('ghost')); + const post = (definition, token = GM) => request(app).post('/api/systems/preview-sheet').set({ Authorization: `Bearer ${token}` }).send({ definition }); + + it('answers the sheet as drawn and the starter, for a draft never saved', async () => { + const res = await post({ ...HEARTH, sheet: OWN, parts: { bank: { on: false } } }); + expect(res.status).toBe(200); + expect(res.body).toEqual({ sheet: OWN, starter: starterSheet({ ...HEARTH, parts: { bank: { on: false } } }) }); + }); + + it('refuses what isn\'t a definition at all', async () => { + const res = await post('nope'); + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'A system definition must be an object' }); + }); + + it('is the main admin\'s alone', async () => { + expect((await post(HEARTH, EDITOR)).status).toBe(403); + expect((await post(HEARTH, PLAYER)).status).toBe(403); + expect((await request(app).post('/api/systems/preview-sheet').send({ definition: HEARTH })).status).toBe(401); + }); +}); diff --git a/backend/routes/systems.js b/backend/routes/systems.js index 993a5cf5..1b1fe068 100644 --- a/backend/routes/systems.js +++ b/backend/routes/systems.js @@ -9,6 +9,7 @@ const store = require('../systemBuilder/store'); const runtime = require('../systemBuilder/runtime'); const { checkDefinition } = require('../systemBuilder/definition'); const { previewDerived } = require('../systemBuilder/derived'); +const { effectiveSheet, starterSheet } = require('../systemBuilder/sheet'); /** What a currency's icon may be uploaded as (decided with the user, 2026-10-01). */ const ICON_EXT = new Set(['.png', '.webp', '.svg']); @@ -151,6 +152,18 @@ module.exports = (db, io = null) => { res.json(previewDerived({ lookups: definition.lookups, derived: definition.derived }, samples)); }); + /** + * The builder's CHARACTER SHEET page (4b3): the sheet a draft is drawn with as it stands (its own + * or the starter, without the parts it turned off) and the starter sheet CUSTOMIZE copies. + * Changes nothing. + */ + router.post('/preview-sheet', gm, (req, res) => { + const definition = req.body && req.body.definition; + const checked = checkDefinition(definition); + if (checked.fatal) return res.status(400).json({ error: checked.fatal }); + res.json({ sheet: effectiveSheet(definition), starter: starterSheet(definition) }); + }); + // What installing a file would do, changing nothing. The file arrives as text so its size is // checked before it is parsed. router.post('/install/preview', gm, (req, res) => { diff --git a/backend/systemBuilder/sheet.js b/backend/systemBuilder/sheet.js index 33bb5b6b..a4f11ff8 100644 --- a/backend/systemBuilder/sheet.js +++ b/backend/systemBuilder/sheet.js @@ -221,8 +221,15 @@ const designedOrStarter = (definition) => { return { tabs: ['STATS', 'GEAR', 'NOTES'], header: { nameField: 'name', subtitleFields: ['concept'], ...health.header }, sections }; }; +/** + * The starter sheet a definition would get with no sheet of its own: what the builder's CUSTOMIZE + * copies (4b3). Every linked field is on it, even one whose part is off, so turning that part back + * on later brings it back on a customized sheet as on an automatic one. + */ +const starterSheet = (definition) => designedOrStarter({ ...definition, sheet: undefined }); + /** Every field on a sheet, in order. */ const fieldsOf = (sheet) => (Array.isArray(sheet && sheet.sections) ? sheet.sections : []) .flatMap((s) => (s && Array.isArray(s.fields) ? s.fields : [])); -module.exports = { checkSheet, effectiveSheet, withoutOffParts, fieldsOf, LAYOUTS, TYPES, SOURCES, EDIT, LIMITS }; +module.exports = { checkSheet, effectiveSheet, starterSheet, withoutOffParts, fieldsOf, LAYOUTS, TYPES, SOURCES, EDIT, LIMITS }; diff --git a/frontend/src/sheets/__tests__/systemsApi.test.ts b/frontend/src/sheets/__tests__/systemsApi.test.ts index 253bf12e..564a00f5 100644 --- a/frontend/src/sheets/__tests__/systemsApi.test.ts +++ b/frontend/src/sheets/__tests__/systemsApi.test.ts @@ -29,6 +29,7 @@ describe('the requests', () => { [(a) => a.saveDraft(ID, { format: 1, name: 'Hearth' }), `/api/systems/${ID}/draft`, 'PUT', { definition: { format: 1, name: 'Hearth' } }], [(a) => a.publish(ID), `/api/systems/${ID}/publish`, 'POST', {}], [(a) => a.previewValues({ format: 1, name: 'H' }), '/api/systems/preview-values', 'POST', { definition: { format: 1, name: 'H' } }], + [(a) => a.previewSheet({ format: 1, name: 'H' }), '/api/systems/preview-sheet', 'POST', { definition: { format: 1, name: 'H' } }], [(a) => a.create('Hearth'), '/api/systems', 'POST', { name: 'Hearth' }], [(a) => a.rename(ID, 'Emberhold'), `/api/systems/${ID}/name`, 'PUT', { name: 'Emberhold' }], [(a) => a.duplicate(ID), `/api/systems/${ID}/duplicate`, 'POST', {}], diff --git a/frontend/src/sheets/systemsApi.ts b/frontend/src/sheets/systemsApi.ts index ad95bebe..911260a6 100644 --- a/frontend/src/sheets/systemsApi.ts +++ b/frontend/src/sheets/systemsApi.ts @@ -4,6 +4,7 @@ // What the answers mean is systemsLibrary.ts. import type { InstallMode, InstallPreview, InstallResult, LibrarySystem } from './systemsLibrary'; +import type { CustomRenderSheet } from './customTemplates'; type Problem = { where: string; message: string }; @@ -67,6 +68,12 @@ export const systemsApi = (token: string, fetcher: typeof fetch = fetch) => { */ previewValues: (definition: Definition) => call<{ values: Record; problems: Problem[] }>(fetcher, token, '/api/systems/preview-values', 'POST', { definition }), + /** + * A draft's sheet as it would be drawn (its own or the starter, without the parts it turned + * off), and the starter sheet CUSTOMIZE copies, without saving (CHARACTER SHEET). + */ + previewSheet: (definition: Definition) => + call<{ sheet: CustomRenderSheet; starter: CustomRenderSheet }>(fetcher, token, '/api/systems/preview-sheet', 'POST', { definition }), /** Make the stored draft what the game runs; refused, with `problems`, while it has any. */ publish: (systemId: string) => call<{ version: number }>(fetcher, token, `${id(systemId)}/publish`, 'POST', {}), create: (name: string) => call<{ id: string }>(fetcher, token, '/api/systems', 'POST', { name }),