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

### Under the hood

- **Rests for the examples and starters.** A copy of CWN takes 1 System Strain off on a long rest,
Cyberpunk RED gives LUCK back and Shadowrun gives Edge back each session, as their own buttons
do, each kept in a new RESOURCES stat. Sword & Spell, Starfarer and Story First get rests that
suit them. The built-in games themselves are unchanged.
- **Trying a draft's rest.** The server can run one of a draft's rests on a made-up character,
previewed or rolled, through the game's own rules, for TRY IT and the RESTS page to show next.
- **Calling a rest from the GAME tab.** Under a custom game, TTRPG_SYSTEM has a RESTS panel for the
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ CITY_NET/
│ │ ├── resting.js # What one rest does to one character: the rests it counts as first, then its own refills in order; health back through the system's own model (up by = its HEAL, to full the model's way: a pool to its max, a second track or marks cleared, harm slots emptied, wounds given back); sheet numbers held between 0 and their maximum; amounts read from the character as it stands; dice pending in a preview until something settles them; the conditions ending at any of those rests taken off. Pure: the route reads, calls it and writes. Also the rest's dice-log line and its dice, by sides
│ │ ├── tryHealth.js # TRY IT's health: a made-up character's damage and healing on a pretend token under a draft's own health model, through the same rules the game runs (health.js), drawn as the HEALTH folder draws it for the owner and for everyone else. Nothing read or written
│ │ ├── tryRest.js # TRY IT's rests: one of a draft's rests on a made-up character through the game's own rules (resting.js), its formulas worked out before and after (a draft whose formulas don't compile yet runs as typed), previewed with dice waiting or rolled, answering the character after and the dice-log line the game would write
│ │ ├── examples.js # The built-in games as whole systems to read and copy (Cities Without Number, Cyberpunk RED, Shadowrun, around their parity-tested formulas) and the genre starters (Sword & Spell, Starfarer, Story First), each ready to publish as it stands; listed by kind and copied under a new name. Generic has no example, since a copy would be BLANK
│ │ ├── examples.js # The built-in games as whole systems to read and copy (Cities Without Number, Cyberpunk RED, Shadowrun, around their parity-tested formulas) and the genre starters (Sword & Spell, Starfarer, Story First), each ready to publish as it stands; listed by kind and copied under a new name. Generic has no example, since a copy would be BLANK; each with rests: the games' copy their built-in rest buttons (CWN's long rest takes 1 System Strain, Cyberpunk RED's LUCK and Shadowrun's Edge come back each session, each kept in a RESOURCES stat), the starters suit their genre (a fantasy breather and night's sleep, a sci-fi wound back, a story's downtime with no rests by the clock)
│ │ ├── names.js # Whether two system names are the same name ("hearth " and "Hearth" are), and the next free "<name> copy", "copy 02"... for DUPLICATE and keep-both installs
│ │ └── store.js # `custom_systems`: a draft the builder edits and the published copy a game runs. Ids are sys_ + hex, never a built-in id; publishing refuses a draft with problems; the running system cannot be deleted, and deleting hides a system so reinstalling its file brings it back with its characters; export, preview and install (new, update when unchanged here, keep both with a new origin; never a merge)
│ ├── startup/
Expand Down Expand Up @@ -480,7 +480,7 @@ CITY_NET/
│ ├── signs.test.js # Sign API (GET / POST / PATCH / DELETE, auth, image-only, filter_intensity clamping, XSS)
│ ├── sheets.test.js # Sheet routes (system switch, admin access, portraits, derived fields, GET /own player self-fetch; the table-wide resets, CWN's long rest included, keeping an edit made at the same moment)
│ ├── system_builder_parity.test.js # CWN and Shadowrun as data against cwnRecompute and sr6Recompute over 3,000 seeded sheets each (blank, text, decimal, huge and stale values, broken JSON): same sheet, same changed fields, same order. Cyberpunk RED against applyDerived wherever Humanity is written as a number, its three differences pinned; Generic changing nothing
│ ├── system_builder_examples.test.js # The built-in examples ready to publish, their formulas the parity-tested ones and their samples worked out as each game's own code does; the genre starters' formulas, table, NPC rolls, health and credit; the routes listing each kind, opening one and copying it under a new name, main admin only
│ ├── system_builder_examples.test.js # The built-in examples ready to publish, their formulas the parity-tested ones and their samples worked out as each game's own code does; the genre starters' formulas, table, NPC rolls, health and credit; the routes listing each kind, opening one and copying it under a new name, main admin only; each one's rests tried on its own sample character
│ ├── system_builder_conditions.test.js # The standard set, a system's edits and its own resolved in order; every check (names, chip labels, icons drawn or uploaded, ends and rounds, modifiers on stats, formulas or all rolls, the limit of 60); the section in a definition; uploading a condition's icon, main admin only
│ ├── system_builder_table_conditions.test.js # A built-in game's own conditions: the checks (no standard ids, no modifiers or rounds, the condition checks and the limit), added after the standard ones and told to every screen, kept apart per game, taken by tokens and refused once gone, kept across a restart, a custom system refused, main admin only
│ ├── system_builder_stats.test.js # Stats in groups (ids, labels, ranges, ties to an ability), names for derived values, the sample character, and the starter sheet's sections from them
Expand Down
54 changes: 54 additions & 0 deletions backend/__tests__/system_builder_examples.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ const { CITIES_WITHOUT_NUMBER, SHADOWRUN_6E, CYBERPUNK_RED } = require_('../syst
const { checkDefinition } = require_('../systemBuilder/definition');
const { previewDerived } = require_('../systemBuilder/derived');
const { rollTier } = require_('../systemBuilder/tierRolls');
const { tryRest } = require_('../systemBuilder/tryRest');
const { restsOf } = require_('../systemBuilder/rests');
const { TEMPLATES, applyDerived } = require_('../sheets/templates');
const { elevatedUsers } = require_('../middleware/auth');
const systemsRoute = require_('../routes/systems.js');
Expand Down Expand Up @@ -164,6 +166,58 @@ describe('the genre starters (4d3)', () => {
});
});

/**
* Rests (4f6b). The examples' rests do what the built-in games' own rest buttons do; the starters'
* suit their genre. Each tried on its own sample character through TRY IT's rule (tryRest.js).
*/
describe('the examples\' and starters\' rests', () => {
const tried = (id, rest, over = {}) => {
const d = exampleDefinition(id);
return tryRest(d, { rest, sheet: d.samples, ...over });
};
const restIds = (id) => restsOf(exampleDefinition(id)).map((r) => r.id);

it('take a CWN character\'s System Strain down by 1 on a long rest, never below 0, as LONG_REST does', () => {
expect(tried('cwn', 'long_rest').changes).toEqual([{ what: 'system_strain', label: 'System strain', from: 2, to: 1 }]);
expect(tried('cwn', 'long_rest', { sheet: { ...exampleDefinition('cwn').samples, system_strain: 0 } }).changes).toEqual([]);
});

it('give a Cyberpunk RED character their LUCK back each session, as RESET_ALL_LUCK does', () => {
expect(tried('cpr', 'end_of_session').changes).toEqual([{ what: 'luck_points', label: 'LUCK points', from: 2, to: 5 }]);
});

it('give a Shadowrun character their Edge back each session, as REPLENISH ALL EDGE does', () => {
expect(tried('sr6', 'end_of_session').changes).toEqual([{ what: 'edge_points', label: 'Edge points', from: 1, to: 3 }]);
});

it('heal a die on a fantasy breather, and all of it and exhaustion with a night\'s sleep', () => {
const token = { current: 3, max: 20 };
expect(tried('fantasy', 'short_rest', { token }).changes).toEqual([{ what: 'health', label: 'HP', from: 3, to: null, pending: ['1d8 + @con_mod'] }]);
const night = tried('fantasy', 'long_rest', { token, conditions: [{ id: 'exhausted' }] });
expect(night.changes).toEqual([{ what: 'health', label: 'HP', from: 3, to: 20 }]);
expect(night.gone).toEqual(['Exhausted']);
});

it('give a starfarer a wound back after a breather, and all of them after proper rest', () => {
expect(tried('scifi', 'short_rest', { token: { current: 1, max: 3 } }).token.current).toBe(2);
expect(tried('scifi', 'long_rest', { token: { current: 0, max: 3 } }).token.current).toBe(3);
});

it('clear a story\'s harm in downtime and its fear at the end of a scene, with no rests by the clock', () => {
expect(restIds('narrative')).toEqual(['end_of_scene', 'end_of_session', 'downtime']);
const down = tried('narrative', 'downtime', { sheet: { lesser_1: 'Bruised', severe_1: 'Broken arm' }, token: { current: 3, max: 5 }, conditions: [{ id: 'exhausted' }, { id: 'frightened' }] });
expect(down.sheet).toMatchObject({ lesser_1: '', severe_1: '' });
expect(down.token.current).toBe(5);
expect(down.gone).toEqual(['Exhausted']);
expect(tried('narrative', 'end_of_scene', { conditions: [{ id: 'exhausted' }, { id: 'frightened' }] }).gone).toEqual(['Frightened']);
});

it('keep every example and starter one that publishes, with the four standard rests otherwise on', () => {
for (const { id, definition } of EXAMPLES) expect(checkDefinition(definition), id).toEqual({ problems: [] });
for (const id of ['cwn', 'cpr', 'sr6', 'fantasy', 'scifi']) expect(restIds(id), id).toEqual(['short_rest', 'long_rest', 'end_of_scene', 'end_of_session']);
});
});

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 Down
37 changes: 32 additions & 5 deletions backend/systemBuilder/examples.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
//
// 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,
// the names players see, the stats those formulas read, a sample character, the setup answers and
// a rest doing what the built-in game's own rest button does (4f6b) 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
Expand Down Expand Up @@ -36,10 +36,11 @@ const CWN = {
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)]),
group('resources', 'RESOURCES', [stat('system_strain', 'System strain', 0, 20)]),
],
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,
armor_soak: 0, armor_trauma_mod: 0, cast_skill: 0, summon_skill: 0, strain_mod: 0, move_mod: 0, system_strain: 2,
},
lookups: CITIES_WITHOUT_NUMBER.lookups,
derived: labelled(CITIES_WITHOUT_NUMBER.derived, {
Expand All @@ -51,6 +52,8 @@ const CWN = {
}),
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' },
// As the built-in LONG_REST button: 1 System Strain off, never below 0.
rests: { long_rest: { refills: [{ what: 'system_strain', how: 'by', amount: '-1' }] } },
};

/** The ten stats at one level, as the built-in NPC generator sets them (sheets/npcTiers.js cprTier). */
Expand All @@ -69,8 +72,9 @@ const CPR = {
stat('body', 'BODY', 1, 10), stat('emp_max', 'EMP', 1, 10),
]),
group('humanity', 'HUMANITY', [stat('humanity', 'Humanity', 0, 120)]),
group('resources', 'RESOURCES', [stat('luck_points', 'LUCK points', 0, 10)]),
],
samples: { int: 6, ref: 7, dex: 6, tech: 5, cool: 6, will: 6, luck: 5, move: 6, body: 6, emp_max: 5, humanity: 50 },
samples: { int: 6, ref: 7, dex: 6, tech: 5, cool: 6, will: 6, luck: 5, move: 6, body: 6, emp_max: 5, humanity: 50, luck_points: 2 },
derived: labelled(CYBERPUNK_RED.derived, { emp: 'Current EMP' }),
words: {
money: { singular: 'EURODOLLAR', plural: 'EURODOLLARS', short: 'eb' },
Expand All @@ -85,6 +89,8 @@ const CPR = {
],
},
core: { health: { model: 'pool' }, advancement: ['spend'], dice: ['d10', 'd6'], distance: 'meters' },
// As the built-in RESET_ALL_LUCK button: LUCK points back to the LUCK stat.
rests: { end_of_session: { refills: [{ what: 'luck_points', how: 'to', amount: '@luck' }] } },
};

const SR6 = {
Expand All @@ -98,8 +104,9 @@ const SR6 = {
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)]),
group('resources', 'RESOURCES', [stat('edge_points', 'Edge points', 0, 7)]),
],
samples: { body: 4, agility: 5, reaction: 4, strength: 3, willpower: 3, logic: 3, intuition: 4, charisma: 2, edge: 3, magic: 0 },
samples: { body: 4, agility: 5, reaction: 4, strength: 3, willpower: 3, logic: 3, intuition: 4, charisma: 2, edge: 3, magic: 0, edge_points: 1 },
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',
Expand All @@ -111,6 +118,8 @@ const SR6 = {
dice: ['d6'],
distance: 'meters',
},
// As the built-in REPLENISH ALL EDGE button: Edge points back to Edge.
rests: { end_of_session: { refills: [{ what: 'edge_points', how: 'to', amount: '@edge' }] } },
};

// ─── Genre starters (4d3) ───────────────────────────────────────────────────
Expand Down Expand Up @@ -149,6 +158,12 @@ const FANTASY = {
],
},
core: { health: { model: 'pool' }, advancement: ['levels'], dice: ['d20', 'd4', 'd6', 'd8', 'd10', 'd12'], distance: 'feet' },
// A breather heals a die's worth; a night's sleep heals the rest and shakes off exhaustion.
rests: {
short_rest: { refills: [{ what: 'health', how: 'by', amount: '1d8 + @con_mod' }] },
long_rest: { counts_as: ['short_rest'], refills: [{ what: 'health', how: 'full' }] },
},
conditions: { exhausted: { ends: 'rest', at: ['long_rest'] } },
};

const CHARACTERISTICS = [['str', 'Strength'], ['dex', 'Dexterity'], ['end', 'Endurance'], ['int', 'Intellect'], ['edu', 'Education'], ['soc', 'Standing']];
Expand All @@ -174,6 +189,11 @@ const SCIFI = {
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' },
// A wound back after a breather, all of them after proper rest.
rests: {
short_rest: { refills: [{ what: 'health', how: 'by', amount: '1' }] },
long_rest: { counts_as: ['short_rest'], refills: [{ what: 'health', how: 'full' }] },
},
};

const APPROACHES = [['forceful', 'Forceful'], ['careful', 'Careful'], ['clever', 'Clever'], ['quick', 'Quick'], ['flashy', 'Flashy'], ['sneaky', 'Sneaky']];
Expand All @@ -200,6 +220,13 @@ const NARRATIVE = {
dice: ['d6'],
distance: 'zones',
},
// No rests by the clock: harm clears in downtime, and fear passes when the scene does.
rests: {
short_rest: { on: false },
long_rest: { on: false },
downtime: { name: 'Downtime', refills: [{ what: 'health', how: 'full' }] },
},
conditions: { frightened: { ends: 'rest', at: ['end_of_scene'] }, exhausted: { ends: 'rest', at: ['downtime'] } },
};

/** The examples and starters, in the order the builder lists them. */
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/__tests__/MySystemsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ describe('+ NEW from a built-in example (4d1b)', () => {
it('shows a card for each, saying what it holds, the first picked and named on the button', async () => {
await pickExamples();
await screen.findByTestId('example-cwn');
expect(within(card('cwn')).getByText('13 in 5 groups')).toBeTruthy();
expect(within(card('cwn')).getByText('14 in 6 groups')).toBeTruthy();
expect(within(card('cwn')).getByText('One pool')).toBeTruthy();
expect(within(card('sr6')).getByText('Two tracks, overflow')).toBeTruthy();
expect(within(card('sr6')).getByText('Spend KARMA')).toBeTruthy();
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/sheets/__tests__/examples.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,13 @@ const { exampleDefinition } = createRequire(import.meta.url)('../../../../backen
describe('what an example\'s card says', () => {
it('reads the server\'s own examples', () => {
expect(exampleFacts(exampleDefinition('cwn'))).toEqual([
['HEALTH', 'One pool'], ['ADVANCES', 'XP levels'], ['STATS', '13 in 5 groups'], ['FORMULAS', '17'], ['DISTANCE', 'Meters'],
['HEALTH', 'One pool'], ['ADVANCES', 'XP levels'], ['STATS', '14 in 6 groups'], ['FORMULAS', '17'], ['DISTANCE', 'Meters'],
]);
expect(exampleFacts(exampleDefinition('cpr'))).toEqual([
['HEALTH', 'One pool'], ['ADVANCES', 'Spend IMPROVEMENT POINTS'], ['STATS', '11 in 2 groups'], ['FORMULAS', '1'], ['DISTANCE', 'Meters'],
['HEALTH', 'One pool'], ['ADVANCES', 'Spend IMPROVEMENT POINTS'], ['STATS', '12 in 3 groups'], ['FORMULAS', '1'], ['DISTANCE', 'Meters'],
]);
expect(exampleFacts(exampleDefinition('sr6'))).toEqual([
['HEALTH', 'Two tracks, overflow'], ['ADVANCES', 'Spend KARMA'], ['STATS', '10 in 2 groups'], ['FORMULAS', '6'], ['DISTANCE', 'Meters'],
['HEALTH', 'Two tracks, overflow'], ['ADVANCES', 'Spend KARMA'], ['STATS', '11 in 3 groups'], ['FORMULAS', '6'], ['DISTANCE', 'Meters'],
]);
});

Expand Down
Loading