From f1d7515869eca311c7832912f6f833d798a67640 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 9 Oct 2026 00:31:13 -0500 Subject: [PATCH] feat(system-builder): three genre starters as data (4d3a) As approved on 2026-10-09: - Sword & Spell: d20 fantasy. Six abilities with floor((x - 10) / 2) modifiers, proficiency by level, unarmored defense, initiative, passive perception, gold pieces, vehicles and cyberware off, four NPC tiers whose HP is rolled for their level. - Starfarer: 2d6 sci-fi. Six characteristics, every modifier read from one lookup table, a wound count (3, -1 each), three NPC tiers. - Story First: narrative. Six approaches rated 0 to 3, harm levels, milestones, zones, no formulas; credited to Fate Accelerated Edition under CC BY 3.0. Each entry in EXAMPLES now has a kind. GET /api/systems/examples lists the games as before; ?kind=starter lists the starters, and anything else is a 400. Copying a starter is POST /api/systems with { name, example } like a game. --- CHANGELOG.md | 9 ++ .../__tests__/system_builder_examples.test.js | 89 +++++++++++++- backend/routes/systems.js | 11 +- backend/systemBuilder/examples.js | 113 ++++++++++++++++-- 4 files changed, 207 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a541d0a3..2d02852f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/backend/__tests__/system_builder_examples.test.js b/backend/__tests__/system_builder_examples.test.js index 20866251..e9f5a756 100644 --- a/backend/__tests__/system_builder_examples.test.js +++ b/backend/__tests__/system_builder_examples.test.js @@ -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'); @@ -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); @@ -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); @@ -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'); @@ -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); diff --git a/backend/routes/systems.js b/backend/routes/systems.js index 9c940c18..46a98c63 100644 --- a/backend/routes/systems.js +++ b/backend/routes/systems.js @@ -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']); @@ -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' }); diff --git a/backend/systemBuilder/examples.js b/backend/systemBuilder/examples.js index b3306dc6..714b1b04 100644 --- a/backend/systemBuilder/examples.js +++ b/backend/systemBuilder/examples.js @@ -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 };