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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Under the hood

- **Three genre starters for the system builder.** Ready-made starting points for a genre rather
than a game, each ready to publish as it stands: **Sword & Spell**, fantasy on a d20 (six
abilities with modifiers, proficiency by level, gold pieces, NPCs whose HP is rolled for their
level); **Starfarer**, sci-fi on 2d6 (every modifier read from one table, a count of three
wounds instead of HP); and **Story First**, a narrative one (six approaches rated 0 to 3, harm
levels, milestones, nothing worked out by formula, credited to Fate Accelerated under CC BY).
The server lists them apart from the built-in games and copies one like an example. The
builder's button for them comes next.

- **Cyberpunk RED and Generic as data, and Cyberpunk RED as an example to copy.** Cyberpunk
RED's current EMP (Humanity / 10, rounded down) and Generic's nothing-at-all are now written in
the builder's format and held to the games' own code over thousands of made-up sheets, like
Expand Down
89 changes: 85 additions & 4 deletions backend/__tests__/system_builder_examples.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const { EXAMPLES, exampleDefinition, exampleList } = require_('../systemBuilder/
const { CITIES_WITHOUT_NUMBER, SHADOWRUN_6E, CYBERPUNK_RED } = require_('../systemBuilder/definitions');
const { checkDefinition } = require_('../systemBuilder/definition');
const { previewDerived } = require_('../systemBuilder/derived');
const { rollTier } = require_('../systemBuilder/tierRolls');
const { TEMPLATES, applyDerived } = require_('../sheets/templates');
const { elevatedUsers } = require_('../middleware/auth');
const systemsRoute = require_('../routes/systems.js');
Expand All @@ -29,17 +30,19 @@ const PROVEN = {
sr6: [SHADOWRUN_6E, (sheet) => TEMPLATES.shadowrun_6e.recompute(sheet)],
};
const formulasOf = (derived) => derived.map(({ label, ...rest }) => rest);
/** The built-in games among them; the genre starters have tests of their own below. */
const GAMES = EXAMPLES.filter((e) => e.kind === 'example');

describe('the examples', () => {
it('are CWN, Cyberpunk RED and Shadowrun, each ready to publish as it stands', () => {
expect(EXAMPLES.map((e) => e.id)).toEqual(['cwn', 'cpr', 'sr6']);
for (const { definition } of EXAMPLES) {
expect(GAMES.map((e) => e.id)).toEqual(['cwn', 'cpr', 'sr6']);
for (const { definition } of GAMES) {
expect(checkDefinition(definition)).toEqual({ problems: [] });
}
});

it('carry the parity-tested formulas word for word, with names players see', () => {
for (const { id, definition } of EXAMPLES) {
for (const { id, definition } of GAMES) {
const [proven] = PROVEN[id];
expect(formulasOf(definition.derived)).toEqual(proven.derived);
expect(definition.lookups).toEqual(proven.lookups);
Expand All @@ -48,7 +51,7 @@ describe('the examples', () => {
});

it('work their sample character out as the built-in game does', () => {
for (const { id, definition } of EXAMPLES) {
for (const { id, definition } of GAMES) {
const [, workOut] = PROVEN[id];
const sheet = { ...definition.samples };
workOut(sheet);
Expand Down Expand Up @@ -94,6 +97,73 @@ describe('the examples', () => {
});
});

describe('the genre starters (4d3)', () => {
// Approved by the user 2026-10-09: fantasy on a d20, sci-fi on 2d6, a narrative one with nothing worked out.
const STARTERS = EXAMPLES.filter((e) => e.kind === 'starter');
const sampleValues = (id) => {
const d = exampleDefinition(id);
return previewDerived({ lookups: d.lookups, derived: d.derived }, d.samples);
};

it('are three, listed apart from the games, each ready to publish as it stands', () => {
expect(STARTERS.map((e) => e.id)).toEqual(['fantasy', 'scifi', 'narrative']);
expect(exampleList('starter').map((e) => e.name)).toEqual(['Sword & Spell', 'Starfarer', 'Story First']);
for (const { definition } of STARTERS) expect(checkDefinition(definition)).toEqual({ problems: [] });
for (const { definition } of STARTERS) expect(definition.description.length).toBeGreaterThan(40);
});

it('Sword & Spell: modifiers, proficiency by level, defense, in feet with gold pieces', () => {
const { values, problems } = sampleValues('fantasy');
expect(problems).toEqual([]);
expect(values).toEqual({
str_mod: 2, dex_mod: 2, con_mod: 1, int_mod: 1, wis_mod: 0, cha_mod: -1,
proficiency: 2, defense: 12, initiative: 2, passive_perception: 10,
});
const d = exampleDefinition('fantasy');
// Modifiers below 10 round down, and proficiency steps up every four levels.
expect(previewDerived(d, { ...d.samples, str: 9, level: 5 }).values).toMatchObject({ str_mod: -1, proficiency: 3 });
expect(previewDerived(d, { ...d.samples, str: 1, level: 17 }).values).toMatchObject({ str_mod: -5, proficiency: 6 });
const proficiencyAt = (level) => previewDerived(d, { ...d.samples, level }).values.proficiency;
expect([1, 4, 5, 8, 9, 12, 13, 16, 20].map(proficiencyAt)).toEqual([2, 2, 3, 3, 4, 4, 5, 5, 6]);
// Defense and initiative follow DEX alone, perception WIS.
expect(previewDerived(d, { ...d.samples, dex: 8, str: 18, wis: 16 }).values).toMatchObject({ defense: 9, initiative: -1, passive_perception: 13 });
expect(d.core).toMatchObject({ health: { model: 'pool' }, advancement: ['levels'], distance: 'feet' });
expect(d.words.money.short).toBe('gp');
expect(d.parts).toEqual({ vehicles: { on: false }, cyberware: { on: false } });
});

it('Sword & Spell: NPC HP rolled for the level, more for each tier', () => {
const tiers = exampleDefinition('fantasy').npc.tiers;
expect(tiers.map((t) => t.id)).toEqual(['minion', 'soldier', 'champion', 'boss']);
const lowest = (t) => rollTier(t, 4, new Map(), { hp: 9999, defense: 99 }, () => 0);
const highest = (t) => rollTier(t, 4, new Map(), { hp: 9999, defense: 99 }, () => 0.999999);
expect(tiers.map((t) => lowest(t).hp.value)).toEqual([4, 8, 12, 16]);
expect(tiers.map((t) => highest(t).hp.value)).toEqual([24, 36, 48, 60]);
expect(tiers.map((t) => lowest(t).defense.value)).toEqual([11, 14, 16, 18]);
});

it('Starfarer: every modifier read from one table, and a count of wounds instead of HP', () => {
const d = exampleDefinition('scifi');
const modFor = (x) => previewDerived(d, { ...d.samples, str: x }).values.str_mod;
expect([2, 3, 5, 6, 8, 9, 11, 12, 14, 15].map(modFor)).toEqual([-2, -1, -1, 0, 0, 1, 1, 2, 2, 3]);
expect(d.derived.every((x) => x.formula.startsWith('characteristic_mod(@'))).toBe(true);
expect(d.core.health).toEqual({ model: 'wounds', count: 3, penalty: -1 });
expect(d.npc.tiers.map((t) => [t.id, t.hp, t.values.end])).toEqual([['crew', 1, 6], ['veteran', 2, 8], ['elite', 3, 10]]);
});

it('Story First: approaches and harm, nothing worked out, credited to Fate Accelerated', () => {
const d = exampleDefinition('narrative');
expect(d.derived).toBeUndefined();
expect(d.npc).toBeUndefined();
expect(d.stats[0].stats.map((s) => [s.id, s.min, s.max])).toEqual(
['forceful', 'careful', 'clever', 'quick', 'flashy', 'sneaky'].map((id) => [id, 0, 3]),
);
expect(d.core.health.levels.map((l) => [l.label, l.slots])).toEqual([['LESSER', 2], ['MODERATE', 2], ['SEVERE', 1]]);
expect(d.core).toMatchObject({ advancement: ['milestone'], distance: 'zones' });
expect(d.license).toMatch(/Fate Accelerated.*Evil Hat Productions.*CC BY 3\.0/);
});
});

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');
Expand All @@ -119,6 +189,17 @@ describe('the routes', () => {
expect((await request(app).get('/api/systems/examples/nope').set(as())).status).toBe(404);
});

it('list the genre starters when asked for them, and copy one like an example', async () => {
const list = await request(app).get('/api/systems/examples?kind=starter').set(as());
expect(list.body).toEqual(exampleList('starter'));
expect((await request(app).get('/api/systems/examples?kind=example').set(as())).body).toEqual(exampleList());
expect(await request(app).get('/api/systems/examples?kind=secret').set(as())).toMatchObject({ status: 400, body: { error: 'Examples or starters only' } });
const made = await make({ name: 'Tavern Tales', example: 'fantasy' });
expect(made.body.problems).toEqual([]);
const got = await request(app).get(`/api/systems/${made.body.id}`).set(as());
expect(got.body.draft).toEqual({ ...exampleDefinition('fantasy'), name: 'Tavern Tales' });
});

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);
Expand Down
11 changes: 8 additions & 3 deletions backend/routes/systems.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +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');
const { exampleList, exampleDefinition, KINDS } = 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 @@ -72,8 +72,13 @@ 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()));
// The built-in examples (4d1) and genre starters (4d3, ?kind=starter): what the library lists,
// and one whole, to read. Copying either is POST / with `example`.
router.get('/examples', gm, (req, res) => {
const kind = req.query.kind === undefined ? 'example' : req.query.kind;
if (!KINDS.includes(kind)) return res.status(400).json({ error: 'Examples or starters only' });
res.json(exampleList(kind));
});
router.get('/examples/:id', gm, (req, res) => {
const definition = exampleDefinition(req.params.id);
if (!definition) return res.status(404).json({ error: 'No such example' });
Expand Down
113 changes: 105 additions & 8 deletions backend/systemBuilder/examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,20 +113,117 @@ const SR6 = {
},
};

/** The examples, in the order the builder lists them. */
// ─── Genre starters (4d3) ───────────────────────────────────────────────────
// Starting points for a genre rather than a game, approved by the user 2026-10-09. Each shows off
// a different part of the builder: formulas and NPC dice (fantasy), a lookup table and a wound
// count (sci-fi), harm levels with nothing worked out at all (narrative). Common mechanics under
// made-up names; nothing is taken from a published game's text.

const ABILITIES = [['str', 'Strength'], ['dex', 'Dexterity'], ['con', 'Constitution'], ['int', 'Intelligence'], ['wis', 'Wisdom'], ['cha', 'Charisma']];

const FANTASY = {
format: 1,
name: 'Sword & Spell',
description: 'A fantasy starter on a d20: six abilities with modifiers worked out from them, proficiency that grows '
+ 'with level, one pool of HP, and four NPC tiers whose HP is rolled for their level. Copy it to make it yours.',
stats: [
group('abilities', 'ABILITIES', ABILITIES.map(([id, label]) => stat(id, label, 1, 20))),
group('character', 'CHARACTER', [stat('level', 'Level', 1, 20)]),
],
samples: { str: 15, dex: 14, con: 13, int: 12, wis: 10, cha: 8, level: 3 },
derived: [
...ABILITIES.map(([id, label]) => ({ id: `${id}_mod`, label: `${label} mod`, formula: `floor((@${id} - 10) / 2)` })),
{ id: 'proficiency', label: 'Proficiency', formula: '2 + floor((@level - 1) / 4)' },
{ id: 'defense', label: 'Unarmored defense', formula: '10 + @dex_mod' },
{ id: 'initiative', label: 'Initiative', formula: '@dex_mod' },
{ id: 'passive_perception', label: 'Passive perception', formula: '10 + @wis_mod' },
],
words: { money: { singular: 'GOLD PIECE', plural: 'GOLD PIECES', short: 'gp' } },
parts: { vehicles: { on: false }, cyberware: { on: false } },
npc: {
tiers: [
{ id: 'minion', label: 'MINION', hp: '@level d6', defense: 11, values: { level: '@level' } },
{ id: 'soldier', label: 'SOLDIER', hp: '@level d8 + @level', defense: 14, values: { level: '@level' } },
{ id: 'champion', label: 'CHAMPION', hp: '@level d10 + @level * 2', defense: 16, values: { level: '@level' } },
{ id: 'boss', label: 'BOSS', hp: '@level d12 + @level * 3', defense: 18, values: { level: '@level' } },
],
},
core: { health: { model: 'pool' }, advancement: ['levels'], dice: ['d20', 'd4', 'd6', 'd8', 'd10', 'd12'], distance: 'feet' },
};

const CHARACTERISTICS = [['str', 'Strength'], ['dex', 'Dexterity'], ['end', 'Endurance'], ['int', 'Intellect'], ['edu', 'Education'], ['soc', 'Standing']];
const scifiTier = (id, label, hp, defense, stats) => ({
id, label, hp, defense, values: Object.fromEntries(CHARACTERISTICS.map(([s]) => [s, stats])),
});

const SCIFI = {
format: 1,
name: 'Starfarer',
description: 'A sci-fi starter on 2d6: six characteristics, each modifier read from one table, a count of wounds '
+ 'instead of HP, and crews to fight. Copy it to make it yours.',
stats: [group('characteristics', 'CHARACTERISTICS', CHARACTERISTICS.map(([id, label]) => stat(id, label, 2, 15)))],
samples: { str: 7, dex: 9, end: 8, int: 10, edu: 6, soc: 5 },
lookups: {
characteristic_mod: {
bands: [{ upTo: 2, value: -2 }, { upTo: 5, value: -1 }, { upTo: 8, value: 0 }, { upTo: 11, value: 1 }, { upTo: 14, value: 2 }, { value: 3 }],
},
},
derived: CHARACTERISTICS.map(([id, label]) => ({ id: `${id}_mod`, label: `${label} mod`, formula: `characteristic_mod(@${id})` })),
npc: {
// A wound count is the token's own number, so each tier's HP is the wounds it takes.
tiers: [scifiTier('crew', 'CREW', 1, 7, 6), scifiTier('veteran', 'VETERAN', 2, 8, 8), scifiTier('elite', 'ELITE', 3, 9, 10)],
},
core: { health: { model: 'wounds', count: 3, penalty: -1 }, advancement: ['spend'], dice: ['2d6', 'd6'], distance: 'meters' },
};

const APPROACHES = [['forceful', 'Forceful'], ['careful', 'Careful'], ['clever', 'Clever'], ['quick', 'Quick'], ['flashy', 'Flashy'], ['sneaky', 'Sneaky']];

const NARRATIVE = {
format: 1,
name: 'Story First',
description: 'A narrative starter: six approaches rated 0 to 3, harm levels instead of HP, milestones instead of XP, '
+ 'and nothing worked out by formula. Copy it to make it yours.',
license: 'Approaches from Fate Accelerated Edition (faterpg.com) by Evil Hat Productions, LLC, used under CC BY 3.0',
stats: [group('approaches', 'APPROACHES', APPROACHES.map(([id, label]) => stat(id, label, 0, 3)))],
samples: { forceful: 1, careful: 2, clever: 3, quick: 1, flashy: 0, sneaky: 2 },
parts: { vehicles: { on: false }, cyberware: { on: false } },
core: {
health: {
model: 'harm',
levels: [
{ id: 'lesser', label: 'LESSER', slots: 2, penalty: 'Less effect' },
{ id: 'moderate', label: 'MODERATE', slots: 2, penalty: '-1 die' },
{ id: 'severe', label: 'SEVERE', slots: 1, penalty: 'Needs help' },
],
},
advancement: ['milestone'],
dice: ['d6'],
distance: 'zones',
},
};

/** The examples and starters, in the order the builder lists them. */
const EXAMPLES = [
{ id: 'cwn', definition: CWN },
{ id: 'cpr', definition: CPR },
{ id: 'sr6', definition: SR6 },
{ id: 'cwn', kind: 'example', definition: CWN },
{ id: 'cpr', kind: 'example', definition: CPR },
{ id: 'sr6', kind: 'example', definition: SR6 },
{ id: 'fantasy', kind: 'starter', definition: FANTASY },
{ id: 'scifi', kind: 'starter', definition: SCIFI },
{ id: 'narrative', kind: 'starter', definition: NARRATIVE },
];

/** A fresh copy of one example's definition, or null for an id that isn't one. */
/** What the library can list: the built-in games, or the genre starters. */
const KINDS = ['example', 'starter'];

/** A fresh copy of one example's or starter'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 }));
/** What the library shows of each of one kind: no definitions, just enough to pick one. */
const exampleList = (kind = 'example') => EXAMPLES
.filter((e) => e.kind === kind)
.map(({ id, definition }) => ({ id, name: definition.name, description: definition.description }));

module.exports = { EXAMPLES, exampleDefinition, exampleList };
module.exports = { EXAMPLES, KINDS, exampleDefinition, exampleList };
Loading