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 @@ -53,6 +53,15 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Added

- **Lists inside lists, and lists every character has (7a5a2).** On the CHOICES page each list now
says how it is PICKED: ON ITS OWN, as before; THROUGH FEATURES, only under the features that open
it (a SOLDIER's SPECIALTY at level 3); BOTH, some at the start and more from features, like feats;
or EVERYONE HAS IT, nothing to pick, every character has its options (a requirement still deciding
who), their features unlocking by the character's level, for what no class gives. A list shows
which features open it (OPENED BY), and a feature opening a list says HOW MANY picks it gives. On the
sheet, the pick waits right under its feature, PICK A SPECIALTY, and the chosen one hangs there
with its own features. Lower the level and it is kept, greyed, until the feature unlocks again;
one left with nothing to wait under is listed at the foot for the GM. HELP on CHOICES says how.
- **A CHOICES list from a file (choices import).** A list with twelve classes and a hundred features
no longer has to be typed in by hand. IMPORT on a list opens a panel where you paste or upload a
table of its options (`name, description, requires`) and their features (`option, feature, level,
Expand Down
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -905,8 +905,10 @@ CITY_NET/
│ │ │ ├── RestsPage.test.tsx # Switches storing only "off", own rests added, opened and deleted after asking, stopping at 12, names and their placeholders, counting as rests that are on and never in a loop, refills added and fitted to what they name, from the previewed sheet or health alone, tracks, a refill naming something gone, nothing to refill, and what wears off ticked from the rest's side; every change held to the server's checks; a "?" beside REFILLS, and beside ON THE SAMPLE only with the server
│ │ │ ├── HouseRulesPage.test.tsx # The rules and how each starts, none yet explained, + RULE opening the new one, twenty at most, renamed (a blank name flagged), described and set to start on or off, what waits on each going to its page, DELETE asking and freeing what waited, and the "?" beside each setting
│ │ │ ├── ChoicesPage.test.tsx # The lists with their options, + LIST, renaming (blank needs a name), A CHARACTER PICKS one / several with how many / any (MORE THAN ONCE off with any), the two switches and what the list then means, DELETE asking first, the limit; options picked and made, a name, words and a requirement, the level name only in a levelled list, its features listed, deleting it; a "?" for every CHOICES hint
│ │ │ ├── ChoicesPage.nested.test.tsx # PICKED (on its own, through features, both, everyone has it) saying what each means and putting away what doesn't apply, OPENED BY and its nudge, HOW MANY on a feature (1 stored as nothing, the most kept to) with the warning when the list it opens is picked on its own, a list everyone has never offered to open; held to the server's checks (7a5a2)
│ │ │ ├── ChoicesImport.test.tsx # IMPORT over the columns, CLOSE and another list closing it, the two downloads, PREVIEW through the server's real reader with the warning, links selecting a line and the marked line numbers, APPLY into the draft with new options outlined, APPLY only for the text previewed, the server's refusal, no server, UPLOAD FILE and one too big
│ │ │ ├── ChoicesSection.test.tsx # The block drawn through the sheet: picks with levels, features, what they do and their words, the next to come, the total's breakdown on hover; a level up and down (never below 1); uses as pips spent and taken back; options left, one not met off with what it needs; a full list offering nothing; none picked; read-only changing nothing; the server's refusal; the builder's previews saying what will be there; the GM's DROP asked first, ADD ANYWAY, neither for a player; the badge on a field a bonus changed (7a3b2)
│ │ │ ├── ChoicesSection.nested.test.tsx # Lists inside lists on the sheet (7a5a2): a list picked through features only under the feature that opens it, picked there with the feature named, nothing under one still locked, its pick a card of its own, kept and greyed when it locks (the GM may drop it), KEPT, WAITING for one with nothing to wait under, read-only saying a pick waits; a list everyone has, nothing to pick or drop
│ │ │ ├── NpcSheetWindow.choices.test.tsx # The GM's window onto a player's sheet (7a3b2): DROP, ADD ANYWAY and a level asked for the player named, the server's refusal shown; an NPC's sheet asking nothing
│ │ │ ├── FeaturesEditor.test.tsx # Features grouped by level in order, tagged, none explained; + FEATURE at the highest level, opened; one open at a time; the limit; a level set on leaving the box or Enter (and a blank one put back); modifiers from the three groups, added and removed; uses on with a count and a rest, the GM's to refill with none, off; rolls given and a roll's change (another roll taking its own first change); a list opened, never its own; where to make what isn't there yet; DELETE asking first; every "?"
│ │ │ ├── PublishPreviewPage.test.tsx # The PUBLISH page on the server's own comparisons: BY CHANGE with each one's value and KEPT, BY CHARACTER with an NPC marked and NOTHING CHANGES, the SAMPLE with no tabs, a version that changes nothing a character sees, problems, and the buttons
Expand Down Expand Up @@ -1271,6 +1273,7 @@ CITY_NET/
│ ├── player-journey.spec.ts # A custom system played in two browsers: a player makes a character in its words and numbers; the GM types in a catalogue, gives them an item and calls a rest; the player sees both
│ ├── health-models.spec.ts # Every health model at the table: a player hurts and heals their token from HIT_POINTS under each of the seven, the GM sets a second track's maximum, another player watches from GLOBAL_CHAT and is sent no number or note (coverage family 2)
│ ├── sheet-choices.spec.ts # A custom system's CHOICES block at the table (7a3b1): a player's class not met off with what it needs, SOLDIER picked with its features and its bonus on Defense, raised to level 2 unlocking the next feature, a use ticked off and given back by a short rest the GM calls; as the server keeps it; the GM on their own sheet adding an option not met, the bonus named on Defense, and a pick taken away, asked first (7a3b2)
│ ├── sheet-nested-choices.spec.ts # Lists inside lists (7a5a2): in the builder SPECIALTIES (OPENED BY the Soldier's Specialty) set THROUGH FEATURES and COMMONS EVERYONE HAS IT; at the table a specialty picked under the feature at level 3, kept waiting at 2, back at 3; COMMONS every character's, its Improvement at level 2
│ ├── sheet-visibility.spec.ts # Who sees what on a customized sheet: the owner (the GM's field read-only), another player looking them up from GLOBAL_CHAT (public lines only, nothing else sent), the GM opening the whole sheet the same way and setting the GM's field (coverage family 1)
│ └── builder-smoke.spec.ts # The GM signs in, opens the System Builder, meets and skips the first-time tour (reset first), finds MY SYSTEMS, and goes back to the map
├── Dockerfile.backend
Expand Down
2 changes: 1 addition & 1 deletion backend/__tests__/system_builder_choices.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe('a system\'s choices', () => {
expect(at(1)).toEqual([]);
expect(at(LIMITS.level)).toEqual([]);
for (const n of [0, 2.5, LIMITS.level + 1, '3']) expect(at(n)).toEqual([`list specialties, option duelist, feature riposte, at: A level from 1 to ${LIMITS.level}`]);
expect(at(1, false)).toEqual(['list specialties, option duelist, feature riposte, at: Only a levelled list\'s features unlock at a level']);
expect(at(1, false)).toEqual(['list specialties, option duelist, feature riposte, at: Only the features of a levelled list, or one everyone has, unlock at a level']);
});

const feature = (f) => problems([CLASSES, SPECIALTIES, { ...FEATS, options: [{ id: 'tough', label: 'Tough', features: [{ id: 'grit', label: 'Grit', ...f }] }] }]);
Expand Down
65 changes: 64 additions & 1 deletion backend/__tests__/system_builder_nested_choices.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe('the checks', () => {
});

it('a list is picked on its own, through features or both; a feature opening a list says how many, 1 to 10', () => {
expect(problems(withList('origins', { picked: 'sometimes' }))).toEqual(['list origins, picked: own (on its own), features (through the features that open it) or both']);
expect(problems(withList('origins', { picked: 'sometimes' }))).toEqual(['list origins, picked: own (on its own), features (through the features that open it), both, or everyone (every character has it)']);
const count = (f) => ({ ...HEARTH, choices: HEARTH.choices.map((c) => (c.id === 'origins' ? { ...c, options: [{ id: 'port', label: 'Port', features: [f] }] } : c)) });
expect(problems(count({ id: 'dock', label: 'Dock', choice_count: 2 }))).toEqual(['list origins, option port, feature dock, choice_count: Only a feature that opens a list says how many']);
expect(problems(count({ id: 'dock', label: 'Dock', choice: 'feats', choice_count: 11 }))).toEqual(['list origins, option port, feature dock, choice_count: A number from 1 to 10']);
Expand Down Expand Up @@ -189,3 +189,66 @@ describe('picking under a feature', () => {
expect(pickAction(HEARTH, at(3), { kind: 'pick', list: 'specialties', option: 'vanguard', from: 'specialty' }, { gm: true }).ok).toBe(true);
});
});

describe('a list everyone has (7a5a2)', () => {
const COMMONS = { id: 'commons', one: 'Common', many: 'Commons', picked: 'everyone', options: [
{ id: 'all_breath', label: 'Second Wind', features: [
{ id: 'breath', label: 'Breath', uses: { count: '1', refill: 'short_rest' } },
{ id: 'knack', label: 'Knack', at: 2, choice: 'specialties' },
{ id: 'improve', label: 'Improvement', at: 4, modifiers: [{ target: 'defense', amount: '1' }] },
] },
{ id: 'strong', label: 'Strong Back', requires: '@int >= 13', features: [{ id: 'carry', label: 'Carry' }] },
] };
const EVERYONE = { ...HEARTH, choices: [...HEARTH.choices, COMMONS] };
const mine = (def, data) => heldOf(def, data).filter((p) => p.list.id === 'commons').map((p) => `${p.option.id}@${p.level}`);

it('checks clean, its features unlocking at a level though the list has none of its own', () => {
expect(problems(EVERYONE)).toEqual([]);
expect(problems({ ...EVERYONE, choices: [...HEARTH.choices, { ...COMMONS, levelled: true }] }))
.toEqual(['list commons, levelled: A list everyone has has no levels of its own: its features unlock by the character\'s level']);
});

it('every character has each option whose requirement they meet, at their total level', () => {
expect(mine(EVERYONE, sheet(soldierAt(3)))).toEqual(['all_breath@3']);
expect(mine(EVERYONE, sheet(soldierAt(3), { int: 14 }))).toEqual(['all_breath@3', 'strong@3']);
// No class yet: level 1, so what comes at the start counts; a system with no levels: everything.
expect(mine(EVERYONE, sheet({}))).toEqual(['all_breath@1']);
const flat = { format: 1, name: 'Flat', choices: [COMMONS] };
expect(mine(flat, sheet({}))).toEqual(['all_breath@null']);
expect(overlayOf({ ...flat, derived: HEARTH.derived }, sheet({})).add).toEqual({ defense: 1 });
expect(workedOf(EVERYONE, sheet(soldierAt(3))).unmet).toEqual(['vanguard', 'strong']);
});

it('a system with its own level stat: that is the level', () => {
const own = { ...EVERYONE, stats: [{ id: 'abilities', label: 'ABILITIES', stats: [{ id: 'int', label: 'Intellect' }, { id: 'level', label: 'Level' }] }] };
expect(problems(own)).toEqual([]);
expect(mine(own, sheet(soldierAt(1), { level: 7 }))).toEqual(['all_breath@7']);
expect(mine(own, sheet(soldierAt(1), { level: 0 }))).toEqual(['all_breath@1']);
});

it('its features count by that level: uses, a bonus at 4, a list opened at 2', () => {
expect(overlayOf(EVERYONE, sheet(soldierAt(3))).add).toEqual({});
expect(overlayOf(EVERYONE, sheet(soldierAt(4))).add).toEqual({ defense: 1 });
expect(workedOf(EVERYONE, sheet(soldierAt(1))).uses).toEqual({ breath: { max: 1, spent: 0 } });
const r = pickAction(EVERYONE, sheet(soldierAt(2)), { kind: 'pick', list: 'specialties', option: 'duelist', from: 'knack' });
expect(r.ok).toBe(true);
expect(ids(picksOf(EVERYONE, r.data))).toContain('specialties:duelist<knack');
expect(pickAction(EVERYONE, sheet(soldierAt(1)), { kind: 'pick', list: 'specialties', option: 'duelist', from: 'knack' }))
.toEqual({ ok: false, error: 'Nothing unlocked there opens the Specialties' });
});

it('nothing in it is picked, dropped or levelled, by anyone', () => {
for (const kind of ['pick', 'drop', 'level']) {
expect(pickAction(EVERYONE, sheet(soldierAt(3)), { kind, list: 'commons', option: 'all_breath', level: 2 }, { gm: true }))
.toEqual({ ok: false, error: 'Every character has the Commons: nothing to pick' });
}
});

it('is told to the sheet as a list everyone has, its picks at the character\'s level', () => {
const commons = choicesShown(EVERYONE).find((c) => c.id === 'commons');
expect(commons.picked).toBe('everyone');
// Its features keep their level, though the list has none of its own.
expect(commons.options[0].features.map((f) => [f.id, f.at])).toEqual([['breath', 1], ['knack', 2], ['improve', 4]]);
expect(workedOf(EVERYONE, sheet(soldierAt(3))).picks).toContainEqual({ list: 'commons', option: 'all_breath', level: 3 });
});
});
2 changes: 1 addition & 1 deletion backend/sockets/choices.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Picking on the sheet, over the socket (7a3a): `choicesAction` with { kind, list, option, level }
// Picking on the sheet, over the socket (7a3a): `choicesAction` with { kind, list, option, level, from }
// or { kind: 'use', feature, spent }, for the caller's own character; the GM may name another
// player's with `username`. What may be done, and by whom, is systemBuilder/picking.js's; this stores
// it through the sheet queue (sheets/mutate.js), works the numbers out again, and tells every screen
Expand Down
Loading
Loading