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
31 changes: 25 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,18 @@ jobs:

# The browser journeys (e2e/, 4l): the real backend and frontend started on a throwaway database,
# driven in Playwright's own headless Chromium. On a developer's machine they run only when asked
# (e2e/README.md); here, on every pull request.
test-e2e:
name: Browser Journeys
# (e2e/README.md); here, on every pull request, split across four machines at once (Playwright's
# --shard), each with its own servers and database. Split test by test (--fully-parallel; each
# machine still runs one at a time) so the long graph file is shared out too: about 12 minutes in
# place of 27.
test-e2e-part:
name: Browser Journeys (${{ matrix.part }}/4)
runs-on: ubuntu-latest
strategy:
# Every part runs to the end, so one failure never hides another's.
fail-fast: false
matrix:
part: [1, 2, 3, 4]
steps:
- name: Checkout Code
uses: actions/checkout@v4
Expand Down Expand Up @@ -226,15 +234,26 @@ jobs:

- name: Run the journeys
working-directory: ./e2e
run: npx playwright test
run: npx playwright test --shard=${{ matrix.part }}/4 --fully-parallel

# What failed and why: screenshots, a trace of every step, and the HTML report.
# What failed and why: screenshots, a trace of every step, and the HTML report, one per part.
- name: Keep the report of a failed run
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-report
name: e2e-report-${{ matrix.part }}
path: |
e2e/playwright-report
e2e/test-results
retention-days: 14

# The one check named Browser Journeys, as before the split: it passes only when every part did,
# so a branch rule asking for that name keeps working.
test-e2e:
name: Browser Journeys
runs-on: ubuntu-latest
needs: test-e2e-part
if: always()
steps:
- name: Every part passed
run: test "${{ needs.test-e2e-part.result }}" = "success"
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **The formula box on ROLLS and RESTS.** A roll's ADD and AGAINST, and a rest's refill amounts,
now get the same box as CHOICES. A line under each says it in words with what it comes to for
the sample character: "against your rolled stat · sample, rolling Strength, rolled stat 16:
target 16", "1d8 + CON modifier · sample CON modifier 2: 3 to 10". A roll's box rolls the first
stat in ROLLED BY. Dice in a rest are said as the least and most they can come to. A misspelt
name shows in red with a fix (`@thsi` offers USE @this). A typo in ADD or AGAINST used to count
as 0 at the table without a word. The formula button writes common ones for you: THE STAT
ROLLED and ITS MODIFIER on a roll, DICE and DICE AND A STAT on a rest (pick how many, which die
and which stat or formula). INSERT lists what each box can read, the stat being rolled first on
a roll.
- **Subclasses follow their class's level.** A list a feature opens, like Fighter's Martial
Archetype opening the archetypes at 3, now takes the level of the pick that opened it. Champion's
features get AT LEVEL on CHOICES, grouped by level, and unlock by the Fighter's level: Improved
Expand Down Expand Up @@ -683,6 +693,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Under the hood

- **The browser journeys run in four parts at once on CI.** Each pull request's journeys were run
one after another on one machine, about 27 minutes. They now run on four machines at once, each with
its own servers and database and a share of the tests, about 12 minutes. The check is still called
Browser Journeys and passes only when all four parts do; a failed part uploads its own report
(e2e-report-1 to 4). Nothing changes when they are run on a developer's machine. The builder's
journey now finds PUBLISH's message by its words, as the formula box added a second status line.
- **Browser tests.** The first tests in a real browser (e2e/, Playwright): the real app started on a throwaway
database, the GM signing in, building a system through the builder's screens, publishing it twice and switching
the game to it; and a custom game played in two browsers at once, a player making a character in its words
Expand Down
10 changes: 5 additions & 5 deletions README.md

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions backend/__tests__/system_builder_formula_preview.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,56 @@ describe('a formula read for the box', () => {
});
});

describe('a roll\'s ADD and AGAINST, and a rest\'s amount (formula box 2)', () => {
const ROLLING = {
...DEF,
stats: [...DEF.stats, { id: 'skills', label: 'SKILLS', stats: [{ id: 'athletics', label: 'Athletics', tie: 'str' }] }],
samples: { ...DEF.samples, athletics: 2 },
derived: [...DEF.derived, { id: 'str_mod', label: 'STR modifier', formula: 'mod(@str)' }],
rolls: [
{ id: 'save', label: 'Save', dice: [{ count: '1', sides: 20 }], add: '@this_mod', by: ['con'] },
{ id: 'skill', label: 'Skill', dice: [{ count: '2', sides: 6 }], add: '@this + @tie_mod', by: ['skills'] },
],
};

it('reads the first stat a roll is ROLLED BY as @this, with its modifier, and a skill\'s tie', () => {
expect(previewFormula(ROLLING, '@this_mod + 1', 'add', undefined, 'save')).toEqual({
ok: true, value: 3, reads: [{ id: 'this_mod', label: 'rolled stat\'s modifier', value: 2 }], rolling: 'Constitution',
});
expect(previewFormula(ROLLING, '@this + @tie_mod', 'add', undefined, 'skill')).toEqual({
ok: true, value: 4, reads: [{ id: 'this', label: 'rolled stat', value: 2 }, { id: 'tie_mod', label: 'tied stat\'s modifier', value: 2 }], rolling: 'Athletics',
});
expect(previewFormula(ROLLING, '@tie', 'against', undefined, 'skill').value).toBe(16);
// Stats, formulas and levels read as in any formula.
expect(previewFormula(ROLLING, '@str_mod + @level', 'add', undefined, 'save').value).toBe(5);
});

it('knows a roll\'s own names only in a roll, and samples nothing for a roll it can\'t find', () => {
expect(previewFormula(ROLLING, '@thsi', 'against', undefined, 'save')).toEqual({ ok: false, problem: '@thsi isn\'t one of this system\'s names', name: 'thsi', suggest: 'this' });
expect(previewFormula(ROLLING, '@this', 'amount')).toMatchObject({ ok: false, name: 'this' });
expect(previewFormula(ROLLING, '@this + 13', 'against', undefined, 'gone')).toEqual({ ok: true, value: 13, reads: [{ id: 'this', label: 'rolled stat', value: 0 }] });
});

it('works a rest\'s amount out with its dice as the least and the most, naming the character\'s own numbers', () => {
expect(previewFormula(ROLLING, '1d8 + @con_mod', 'refill')).toEqual({ ok: true, value: 3, most: 10, reads: [{ id: 'con_mod', label: 'CON modifier', value: 2 }] });
expect(previewFormula(ROLLING, '2d6', 'refill')).toEqual({ ok: true, value: 2, most: 12, reads: [] });
expect(previewFormula(ROLLING, '@athletics d4', 'refill')).toMatchObject({ value: 2, most: 8 });
expect(previewFormula(ROLLING, '5', 'refill')).toEqual({ ok: true, value: 5, reads: [] });
// A sheet field reads by its label too.
expect(previewFormula(ROLLING, '@hp_max', 'refill').reads).toEqual([{ id: 'hp_max', label: expect.any(String), value: 0 }]);
});

it('says a rest amount\'s mistakes: a name it hasn\'t got, a table, dice that don\'t read', () => {
expect(previewFormula(ROLLING, '@con_md', 'refill')).toEqual({ ok: false, problem: '@con_md isn\'t one of this system\'s names', name: 'con_md', suggest: 'con_mod' });
expect(previewFormula(ROLLING, 'mod(@con)', 'refill').ok).toBe(false);
expect(previewFormula(ROLLING, '1d1', 'refill')).toEqual({ ok: false, problem: 'A die has 2 to 1000 sides' });
});

it('takes the new kinds and a roll in a request', () => {
expect(previewFormulas(ROLLING, [{ text: '@this', kind: 'add', roll: 'save' }, { text: '1d4', kind: 'refill' }]).map((r) => [r.value, r.most])).toEqual([[14, undefined], [1, 4]]);
});
});

describe('the route', () => {
let app;
beforeEach(async () => {
Expand Down
79 changes: 67 additions & 12 deletions backend/systemBuilder/formulaPreview.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,30 @@
//
// The sample character has stats only (stats.js `samples`); every level name reads SAMPLE_LEVEL,
// so a formula on levels shows something worth reading.
//
// Formula box 2 adds a roll's ADD and AGAINST, which also read the stat being rolled (@this,
// @this_mod, @tie, @tie_mod, tableRolls.js): the sample rolls the first stat the roll is ROLLED BY.
// And a rest's refill amounts (rests.js), which name the character's own numbers and may hold dice:
// what dice decide is said as a range, the least to the most they can come to.

const { parse, evaluate, references } = require('./expression');
const { ruleValue } = require('./rules');
const { tablesOf, lookupIn, previewDerived } = require('./derived');
const { namesOf, levelNamesOf } = require('./choices');
const { fieldsRolledBy, fieldsFor } = require('./tableRolls');
const { reachOf } = require('./rests');
const { prepare } = require('./tierRolls');
const { effectiveSheet, fieldsOf } = require('./sheet');

/** What every level name reads for the sample character. */
const SAMPLE_LEVEL = 3;
/** What a formula box holds: a modifier's amount, an option's requirement, a feature's uses. */
const KINDS = new Set(['amount', 'requires', 'uses']);
/**
* What a formula box holds: a modifier's amount, an option's requirement, a feature's uses; a roll's
* ADD and AGAINST; a rest's refill amount.
*/
const KINDS = new Set(['amount', 'requires', 'uses', 'add', 'against', 'refill']);
/** What a roll's own names are called in words. */
const ROLL_OWN = new Map([['this', 'rolled stat'], ['this_mod', 'rolled stat\'s modifier'], ['tie', 'tied stat'], ['tie_mod', 'tied stat\'s modifier']]);
/** The most formulas one request reads. */
const MOST = 20;

Expand Down Expand Up @@ -60,43 +74,84 @@ const sampleOf = (definition) => {
return { ...samples, ...values, ...levels };
};

/** A name the box doesn't know, as its mistake, with the nearest it does. */
const unknownName = (name, known) => {
const suggest = nearest(name, known);
return { ok: false, problem: `@${name} isn't one of this system's names`, name, ...(suggest ? { suggest } : {}) };
};

const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };

/**
* A rest's refill amount: a number, a formula or dice, naming the character's own numbers and no
* tables. With dice, `value` is the least it comes to and `most` the most.
*/
const previewRefill = (definition, text, sample) => {
const reach = reachOf(definition);
const written = [...new Set([...text.matchAll(/@([a-zA-Z_][a-zA-Z0-9_]*)/g)].map((m) => m[1]))];
const unknown = written.find((n) => !reach.names.has(n));
if (unknown) return unknownName(unknown, reach.names);
let prepared;
try { prepared = prepare(text, { known: (n) => reach.names.has(n), refused: (n) => `@${n} isn't one of this system's names` }); } catch (e) { return { ok: false, problem: e.message }; }
const counts = prepared.dice.map((d) => (typeof d.count === 'object' ? Math.max(0, Math.round(num(sample[d.count.name]))) : d.count));
const at = (face) => evaluate(prepared.tree, {
field: (n) => { const m = /^tier_die_(\d+)$/.exec(n); return m ? counts[Number(m[1])] * face(prepared.dice[Number(m[1])].sides) : num(sample[n]); },
rule: () => 0, table: () => 0,
});
const labels = new Map([...fieldsOf(effectiveSheet(definition)).filter((f) => f && typeof f.id === 'string').map((f) => [f.id, f.label || f.id]), ...namesOf(definition)]);
const least = at(() => 1);
const most = at((sides) => sides);
return {
ok: true, value: least, ...(prepared.dice.length ? { most } : {}),
reads: written.map((id) => ({ id, label: labels.get(id) || id, value: num(sample[id]) })),
};
};

/**
* One formula read against `definition`: `{ ok: false, problem, suggest? }` when it can't be used,
* else `{ ok: true, value, reads }`, `reads` naming each @name it reads with its label and value.
* A roll's box (`kind` add or against) names its roll in `roll`; it says the stat it samples in
* `rolling`.
*/
const previewFormula = (definition, text, kind, sample = sampleOf(definition)) => {
const previewFormula = (definition, text, kind, sample = sampleOf(definition), roll = null) => {
if (typeof text !== 'string' || !text.trim()) return { ok: false, problem: 'Empty' };
if (kind === 'refill') return previewRefill(definition, text, sample);
const tables = tablesOf(isPlainObject(definition.lookups) ? definition.lookups : undefined);
const isTable = (name) => own(tables, name);
let tree;
try { tree = parse(text, isTable); } catch (e) { return { ok: false, problem: e.message }; }
const names = namesOf(definition);
const rolled = kind === 'add' || kind === 'against';
const names = new Map([...namesOf(definition), ...(rolled ? ROLL_OWN : [])]);
const fields = [...references(tree).fields];
const unknown = fields.find((n) => !names.has(n));
if (unknown) {
const suggest = nearest(unknown, names.keys());
return { ok: false, problem: `@${unknown} isn't one of this system's names`, name: unknown, ...(suggest ? { suggest } : {}) };
}
if (unknown) return unknownName(unknown, names.keys());
if (kind === 'amount') {
const formulas = new Set((Array.isArray(definition.derived) ? definition.derived : []).filter(isPlainObject).map((d) => d.id));
const levels = levelNamesOf(definition);
const formula = fields.find((n) => formulas.has(n) && !levels.has(n));
if (formula) return { ok: false, problem: `@${formula} is a formula: an amount reads stats as typed and levels` };
}
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
// A roll's box reads the first stat it is ROLLED BY as @this, as the table would.
const found = rolled && Array.isArray(definition.rolls) ? definition.rolls.find((r) => isPlainObject(r) && r.id === roll) : null;
const stat = found ? fieldsRolledBy(definition, found)[0] : undefined;
const field = rolled ? fieldsFor(definition, sample, stat) : (name) => num(sample[name]);
const value = evaluate(tree, {
field: (name) => num(sample[name]),
field,
rule: (name) => ruleValue(name, sample),
table: (name, x) => lookupIn(isTable(name) ? tables[name] : [], x),
});
return { ok: true, value, reads: fields.map((id) => ({ id, label: names.get(id), value: num(sample[id]) })) };
return {
ok: true, value, reads: fields.map((id) => ({ id, label: names.get(id), value: field(id) })),
...(stat ? { rolling: names.get(stat) || stat } : {}),
};
};

/** Up to MOST formulas, `{ text, kind }` each, read against one sample: the box's request. */
/** Up to MOST formulas, `{ text, kind, roll? }` each, read against one sample: the box's request. */
const previewFormulas = (definition, formulas) => {
const sample = sampleOf(definition);
return (Array.isArray(formulas) ? formulas : []).slice(0, MOST).map((f) => previewFormula(
definition, isPlainObject(f) ? f.text : undefined, isPlainObject(f) && KINDS.has(f.kind) ? f.kind : 'requires', sample,
isPlainObject(f) && typeof f.roll === 'string' ? f.roll : null,
));
};

Expand Down
2 changes: 1 addition & 1 deletion backend/systemBuilder/tierRolls.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,4 @@ const rollTier = (tier, level, fields, limits, rng = cryptoRng) => {
return { level: levelOf(level), hp, defense, values };
};

module.exports = { rollBox, rollAmount, rollTier, boxProblem, levelOf, LIMITS };
module.exports = { rollBox, rollAmount, rollTier, boxProblem, levelOf, prepare, LIMITS };
2 changes: 1 addition & 1 deletion e2e/COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ panel), so a control that looks fine but does nothing is caught for all of them.
| A bonus named on the field it changes | a stat as typed and with it; a formula with it | `choices_numbers` | `sheet-choices` (10 → 11, Guard Stance +1) | **AT THE TABLE** |
| A rest refilling a feature's uses | the rest itself; one it counts as | `picking` | `sheet-choices` (a short rest) | **AT THE TABLE** (the rest itself) |
| A list filled from a file | IMPORT: new and changed options and features, empty cells and `-`, skipped lines, same-named features | `choices_import` | `builder-choices-import` (this list downloaded, changed with one wrong line, uploaded, warned of, applied; played on a sheet) | **AT THE TABLE** |
| Writing a formula | the formula box on REQUIRES, modifier amounts and USES: words and the sample's value, a mistake with its fix, SHAPE, INSERT | `formula_preview` | `builder-formula-box` | **BUILDER ONLY** (a builder aid: what it writes is played at the table by `sheet-choices` and `sheet-token-max`) |
| Writing a formula | the formula box on REQUIRES, modifier amounts and USES, a roll's ADD and AGAINST, and a rest's amounts: words and the sample's value, a mistake with its fix, SHAPE, INSERT | `formula_preview` | `builder-formula-box` | **BUILDER ONLY** (a builder aid: what it writes is played at the table by `sheet-choices`, `sheet-token-max`, `sheet-rolls` and the rests' own journeys) |

## Sheets and NPCs

Expand Down
Loading
Loading