From c79aad942d1fe16e4a2a6f75eb9290b8fcce580d Mon Sep 17 00:00:00 2001 From: Developer Date: Thu, 8 Oct 2026 20:24:21 -0500 Subject: [PATCH] feat(system-builder): CWN and SR6 as examples to copy (4d1a) Cities Without Number and Shadowrun 6E as whole definitions in the builder's format: stats, labels, a sample character and setup answers around the parity-tested formulas in definitions.js, which they reuse word for word. Both check clean, so a copy is publishable as it stands, and their samples work out the same as the built-in recompute functions. GET /api/systems/examples lists them, GET /api/systems/examples/:id opens one, and POST /api/systems with { name, example } copies one into a new draft. Main admin only, like every builder route. The built-in games keep their own code. --- CHANGELOG.md | 8 ++ .../__tests__/system_builder_examples.test.js | 135 ++++++++++++++++++ backend/routes/systems.js | 18 ++- backend/systemBuilder/examples.js | 95 ++++++++++++ 4 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 backend/__tests__/system_builder_examples.test.js create mode 100644 backend/systemBuilder/examples.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f4231b10..c97ba20b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **Cities Without Number and Shadowrun as examples to copy.** Both are now whole systems in the + builder's format, ready to publish as they stand: their stats, the names players see, a sample + character, and their setup answers (one pool in meters for CWN; physical and stun with overflow, + karma and nuyen for Shadowrun), around the same formulas already proven against the built-in + games' own code. The server can list them, open one, and copy one into a new draft under a new + name. The games themselves don't change: CWN and Shadowrun still run their own code. The + builder's buttons for this come next. + - **The builder's TRY IT page.** A GM can now try a draft as a player would meet it, unsaved changes included: a made-up character on the draft's own sheet, its formulas worked out by the server a moment after typing; its health on a pretend token in the HEALTH folder's own panels, diff --git a/backend/__tests__/system_builder_examples.test.js b/backend/__tests__/system_builder_examples.test.js new file mode 100644 index 00000000..e7c7cfb8 --- /dev/null +++ b/backend/__tests__/system_builder_examples.test.js @@ -0,0 +1,135 @@ +/** + * The built-in examples (4d1): Cities Without Number and Shadowrun 6E as whole definitions, to + * read and to copy as the start of a GM's own system. Their formulas are the parity-tested ones, + * word for word; the live games never run them. + */ + +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 { EXAMPLES, exampleDefinition, exampleList } = require_('../systemBuilder/examples'); +const { CITIES_WITHOUT_NUMBER, SHADOWRUN_6E } = require_('../systemBuilder/definitions'); +const { checkDefinition } = require_('../systemBuilder/definition'); +const { previewDerived } = require_('../systemBuilder/derived'); +const { TEMPLATES } = require_('../sheets/templates'); +const { elevatedUsers } = require_('../middleware/auth'); +const systemsRoute = require_('../routes/systems.js'); + +const PROVEN = { cwn: [CITIES_WITHOUT_NUMBER, TEMPLATES.cities_without_number], sr6: [SHADOWRUN_6E, TEMPLATES.shadowrun_6e] }; +const formulasOf = (derived) => derived.map(({ label, ...rest }) => rest); + +describe('the examples', () => { + it('are CWN and Shadowrun, each ready to publish as it stands', () => { + expect(EXAMPLES.map((e) => e.id)).toEqual(['cwn', 'sr6']); + for (const { definition } of EXAMPLES) { + expect(checkDefinition(definition)).toEqual({ problems: [] }); + } + }); + + it('carry the parity-tested formulas word for word, with names players see', () => { + for (const { id, definition } of EXAMPLES) { + const [proven] = PROVEN[id]; + expect(formulasOf(definition.derived)).toEqual(proven.derived); + expect(definition.lookups).toEqual(proven.lookups); + for (const d of definition.derived) expect(typeof d.label).toBe('string'); + } + }); + + it('work their sample character out as the built-in game does', () => { + for (const { id, definition } of EXAMPLES) { + const [, template] = PROVEN[id]; + const sheet = { ...definition.samples }; + template.recompute(sheet); + const { values, problems } = previewDerived({ lookups: definition.lookups, derived: definition.derived }, definition.samples); + expect(problems).toEqual([]); + for (const d of definition.derived) expect([d.id, values[d.id]]).toEqual([d.id, sheet[d.id]]); + } + }); + + it('give Shadowrun its two tracks and its own words, CWN one pool in meters', () => { + expect(exampleDefinition('sr6').core.health).toMatchObject({ model: 'tracks', overflow: true }); + expect(exampleDefinition('sr6').words.xp.singular).toBe('KARMA'); + expect(exampleDefinition('cwn').core).toMatchObject({ health: { model: 'pool' }, distance: 'meters' }); + }); + + it('hand out a copy, never the example itself', () => { + const copy = exampleDefinition('cwn'); + copy.derived[0].formula = '1'; + copy.stats[0].stats.pop(); + expect(exampleDefinition('cwn').derived[0].formula).toBe('attribute_mod(@str)'); + expect(exampleDefinition('cwn').stats[0].stats).toHaveLength(6); + expect(exampleDefinition('nope')).toBeNull(); + expect(exampleDefinition('constructor')).toBeNull(); + }); + + it('are listed by name and description, without their definitions', () => { + expect(exampleList()).toEqual([ + { id: 'cwn', name: 'Cities Without Number', description: expect.any(String) }, + { id: 'sr6', name: 'Shadowrun 6E', description: expect.any(String) }, + ]); + }); +}); + +describe('the routes', () => { + 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'); + 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 as = (token = GM) => ({ Authorization: `Bearer ${token}` }); + const make = (body, token = GM) => request(app).post('/api/systems').set(as(token)).send(body); + + it('list the examples and open one whole', async () => { + const list = await request(app).get('/api/systems/examples').set(as()); + expect(list.status).toBe(200); + expect(list.body).toEqual(exampleList()); + const one = await request(app).get('/api/systems/examples/sr6').set(as()); + expect(one.body).toEqual({ id: 'sr6', definition: exampleDefinition('sr6') }); + expect((await request(app).get('/api/systems/examples/nope').set(as())).status).toBe(404); + }); + + it('copy an example into a new draft under the name given, the GM\'s own', async () => { + const made = await make({ name: ' Neon Nights ', example: 'cwn' }); + expect(made.status).toBe(200); + expect(made.body.problems).toEqual([]); + const got = await request(app).get(`/api/systems/${made.body.id}`).set(as()); + expect(got.body).toMatchObject({ name: 'Neon Nights', version: 0, published: null }); + expect(got.body.draft).toEqual({ ...exampleDefinition('cwn'), name: 'Neon Nights' }); + }); + + it('refuse an unknown example, no name, or a name already taken', async () => { + expect(await make({ name: 'X', example: 'dnd' })).toMatchObject({ status: 404, body: { error: 'No such example' } }); + expect(await make({ name: ' ', example: 'cwn' })).toMatchObject({ status: 400, body: { error: 'A system needs a name' } }); + expect((await make({ example: 'cwn' })).status).toBe(400); + expect((await make({ name: 'Runners', example: 'sr6' })).status).toBe(200); + expect((await make({ name: 'runners', example: 'cwn' })).status).toBe(409); + }); + + it('still make a blank system from a name', async () => { + const made = await make({ name: 'Plain' }); + const got = await request(app).get(`/api/systems/${made.body.id}`).set(as()); + expect(got.body.draft.derived).toBeUndefined(); + }); + + it('are the main admin\'s alone', async () => { + for (const token of [EDITOR, PLAYER]) { + expect((await request(app).get('/api/systems/examples').set(as(token))).status).toBe(403); + expect((await request(app).get('/api/systems/examples/cwn').set(as(token))).status).toBe(403); + expect((await make({ name: 'X', example: 'cwn' }, token)).status).toBe(403); + } + expect((await request(app).get('/api/systems/examples')).status).toBe(401); + }); +}); diff --git a/backend/routes/systems.js b/backend/routes/systems.js index dd24f466..9c940c18 100644 --- a/backend/routes/systems.js +++ b/backend/routes/systems.js @@ -13,6 +13,7 @@ const { effectiveSheet, starterSheet, fieldsOf } = require('../systemBuilder/she const { npcSheetOf, LIMITS: NPC_LIMITS } = require('../systemBuilder/npc'); const { rollTier } = require('../systemBuilder/tierRolls'); const { tryHealth } = require('../systemBuilder/tryHealth'); +const { exampleList, exampleDefinition } = require('../systemBuilder/examples'); /** What a currency's icon may be uploaded as (decided with the user, 2026-10-01). */ const ICON_EXT = new Set(['.png', '.webp', '.svg']); @@ -71,9 +72,22 @@ module.exports = (db, io = null) => { res.json({ icon: `/uploads/currency_icons/${filename}` }); }); + // The built-in examples (4d1): what the library lists, and one whole, to read. + router.get('/examples', gm, (req, res) => res.json(exampleList())); + router.get('/examples/:id', gm, (req, res) => { + const definition = exampleDefinition(req.params.id); + if (!definition) return res.status(404).json({ error: 'No such example' }); + res.json({ id: req.params.id, definition }); + }); + + /** A new system: blank from a name, a whole definition, or a copy of an example under a new name. */ router.post('/', gm, (req, res) => { - const { name, definition } = req.body || {}; - store.createSystem(db, { name, definition }, (err, made) => answer(res, err, made)); + const { name, definition, example } = req.body || {}; + if (example === undefined) return store.createSystem(db, { name, definition }, (err, made) => answer(res, err, made)); + const copy = exampleDefinition(example); + if (!copy) return res.status(404).json({ error: 'No such example' }); + if (typeof name !== 'string' || !name.trim()) return res.status(400).json({ error: 'A system needs a name' }); + store.createSystem(db, { definition: { ...copy, name: name.trim() } }, (err, made) => answer(res, err, made)); }); router.get('/:id', gm, (req, res) => store.getSystem(db, req.params.id, (err, sys) => answer(res, err, sys))); diff --git a/backend/systemBuilder/examples.js b/backend/systemBuilder/examples.js new file mode 100644 index 00000000..0f82d374 --- /dev/null +++ b/backend/systemBuilder/examples.js @@ -0,0 +1,95 @@ +// Built-in systems as examples (4d1): Cities Without Number and Shadowrun 6E as whole system +// definitions, for a GM to read and to copy as the start of their own. +// +// Their formulas are the ones in definitions.js, word for word, which the parity test +// (__tests__/system_builder_parity.test.js) holds to the hand-written recompute functions. Only +// the names players see, the stats those formulas read, a sample character and the setup answers +// are added here. Mechanics and structure only, never a book's wording or item lists (the plan, +// "Built-in systems as examples"). +// +// NOT what the built-in games run. A game running CWN keeps running CWN's code; these are copies +// to change, and a copy has no link back. + +const { CITIES_WITHOUT_NUMBER, SHADOWRUN_6E } = require('./definitions'); + +/** A derived list with the names players see, formulas untouched. */ +const labelled = (derived, labels) => derived.map((d) => (labels[d.id] ? { ...d, label: labels[d.id] } : { ...d })); + +const group = (id, label, stats) => ({ id, label, stats }); +const stat = (id, label, min, max, tie) => ({ id, label, min, max, ...(tie ? { tie } : {}) }); + +const CWN = { + format: 1, + name: 'Cities Without Number', + description: 'The built-in Cities Without Number rules as data: attribute modifiers from a table, ' + + 'saves from level and the better modifier, system strain from CON, and the Deluxe effort pools. ' + + 'Copy it to change anything.', + license: 'Mechanics from the Cities Without Number Quick Reference v2.2, CC BY-NC 4.0', + stats: [ + group('attributes', 'ATTRIBUTES', [ + stat('str', 'Strength', 3, 18), stat('dex', 'Dexterity', 3, 18), stat('con', 'Constitution', 3, 18), + stat('int', 'Intelligence', 3, 18), stat('wis', 'Wisdom', 3, 18), stat('cha', 'Charisma', 3, 18), + ]), + group('character', 'CHARACTER', [stat('level', 'Level', 1, 10)]), + group('armor', 'ARMOR', [stat('armor_soak', 'Damage soak', 0, 99), stat('armor_trauma_mod', 'Trauma target mod', -10, 10)]), + group('magic', 'MAGIC', [stat('cast_skill', 'Cast skill', 0, 4), stat('summon_skill', 'Summon skill', 0, 4)]), + group('adjustments', 'TABLE ADJUSTMENTS', [stat('strain_mod', 'Strain adjustment', -10, 10), stat('move_mod', 'Move adjustment', -99, 99)]), + ], + samples: { + str: 14, dex: 12, con: 13, int: 10, wis: 9, cha: 11, level: 3, + armor_soak: 0, armor_trauma_mod: 0, cast_skill: 0, summon_skill: 0, strain_mod: 0, move_mod: 0, + }, + lookups: CITIES_WITHOUT_NUMBER.lookups, + derived: labelled(CITIES_WITHOUT_NUMBER.derived, { + str_mod: 'Strength mod', dex_mod: 'Dexterity mod', con_mod: 'Constitution mod', + int_mod: 'Intelligence mod', wis_mod: 'Wisdom mod', cha_mod: 'Charisma mod', + save_physical: 'Physical save', save_evasion: 'Evasion save', save_mental: 'Mental save', save_luck: 'Luck save', + system_strain_max: 'System strain max', trauma_target: 'Trauma target', armor_soak_total: 'Soak', + move: 'Move', mage_effort_max: 'Mage effort', spells_prepared_max: 'Spells prepared', summoner_effort_max: 'Summoner effort', + }), + npc: { tiers: [{ id: 'by_hit_dice', label: 'BY HIT DICE', hp: '@level d8', values: { level: '@level' } }] }, + core: { health: { model: 'pool' }, advancement: ['levels'], dice: ['d20', '2d6'], distance: 'meters' }, +}; + +const SR6 = { + format: 1, + name: 'Shadowrun 6E', + description: 'The built-in Shadowrun rules as data: physical and stun monitors from Body and Willpower, ' + + 'initiative and composure from attribute pairs, and an adept\'s power points. Copy it to change anything.', + stats: [ + group('attributes', 'ATTRIBUTES', [ + stat('body', 'Body', 1, 9), stat('agility', 'Agility', 1, 9), stat('reaction', 'Reaction', 1, 9), stat('strength', 'Strength', 1, 9), + stat('willpower', 'Willpower', 1, 9), stat('logic', 'Logic', 1, 9), stat('intuition', 'Intuition', 1, 9), stat('charisma', 'Charisma', 1, 9), + ]), + group('special', 'SPECIAL', [stat('edge', 'Edge', 1, 7), stat('magic', 'Magic', 0, 9)]), + ], + samples: { body: 4, agility: 5, reaction: 4, strength: 3, willpower: 3, logic: 3, intuition: 4, charisma: 2, edge: 3, magic: 0 }, + derived: labelled(SHADOWRUN_6E.derived, { + physical_monitor: 'Physical monitor', stun_monitor: 'Stun monitor', initiative_score: 'Initiative', + composure: 'Composure', power_points_spent: 'Power points spent', power_points_remaining: 'Power points left', + }), + words: { xp: { singular: 'KARMA', plural: 'KARMA', short: 'KARMA' }, money: { singular: 'NUYEN', plural: 'NUYEN', short: 'NY' } }, + core: { + health: { model: 'tracks', tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }], overflow: true }, + advancement: ['spend'], + dice: ['d6'], + distance: 'meters', + }, +}; + +/** The examples, in the order the builder lists them. */ +const EXAMPLES = [ + { id: 'cwn', definition: CWN }, + { id: 'sr6', definition: SR6 }, +]; + +/** A fresh copy of one example's definition, or null for an id that isn't one. */ +const exampleDefinition = (id) => { + const found = EXAMPLES.find((e) => e.id === id); + return found ? JSON.parse(JSON.stringify(found.definition)) : null; +}; + +/** What the library shows of each: no definitions, just enough to pick one. */ +const exampleList = () => EXAMPLES.map(({ id, definition }) => ({ id, name: definition.name, description: definition.description })); + +module.exports = { EXAMPLES, exampleDefinition, exampleList };