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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
135 changes: 135 additions & 0 deletions backend/__tests__/system_builder_examples.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 16 additions & 2 deletions backend/routes/systems.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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)));
Expand Down
95 changes: 95 additions & 0 deletions backend/systemBuilder/examples.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading