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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions backend/__tests__/system_builder_preview_sheet.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 13 additions & 0 deletions backend/routes/systems.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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) => {
Expand Down
9 changes: 8 additions & 1 deletion backend/systemBuilder/sheet.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
1 change: 1 addition & 0 deletions frontend/src/sheets/__tests__/systemsApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', {}],
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/sheets/systemsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -67,6 +68,12 @@ export const systemsApi = (token: string, fetcher: typeof fetch = fetch) => {
*/
previewValues: (definition: Definition) =>
call<{ values: Record<string, number>; 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 }),
Expand Down
Loading