From 078a1e2eeca729ae59028edc96c256aec2c92e02 Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 9 Oct 2026 21:45:13 -0500 Subject: [PATCH] feat(system-builder): conditions that end at a rest (4f4b) The CONDITIONS page's ENDS gains AT A REST (in place of the disabled 'coming' option), with the rests that end the condition ticked beside it: the rests that are on, and a picked one since turned off, marked OFF so it can be unticked. 'Pick at least one rest' until one is. The same setting as WEARS OFF on the RESTS page. --- CHANGELOG.md | 2 + README.md | 4 +- frontend/src/components/ConditionsPage.tsx | 40 +++++++++++++++++-- .../__tests__/ConditionsPage.test.tsx | 34 +++++++++++++++- 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e62e75e4..525097e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **Conditions that end at a rest.** The builder's CONDITIONS page can end a condition AT A REST, + with the rests that end it ticked beside it; the RESTS page's WEARS OFF is the same setting. - **The builder's RESTS page.** Between CONDITIONS and TRY IT: switch the standard rests on or off, rename them, add the system's own, say which other rests each one counts as, what it refills (health, a number on the sheet, or a whole section, with amounts as numbers, formulas or dice), diff --git a/README.md b/README.md index bccb91f8..3696bb15 100644 --- a/README.md +++ b/README.md @@ -723,7 +723,7 @@ CITY_NET/ │ │ │ ├── SheetPage.tsx # CHARACTER SHEET: automatic until CUSTOMIZE copies the starter; a tree of tabs, sections and fields to move and edit, who sees and who changes each field, a tray of stats not placed yet, and a live preview. Also edits NPCS' stat block of its own (GM-only preview) │ │ │ ├── SheetPreview.tsx # The sheet designer's live preview, drawn by the real SheetRenderer, as its owner, the GM and everyone else see it │ │ │ ├── NpcsPage.tsx # NPCS: the stat block (the character sheet, or one of its own) and TIERS, each box a number, a formula with @level or dice, typed text kept as typed, a TRY IT roll at a level -│ │ │ ├── ConditionsPage.tsx # CONDITIONS: the standard set with switches and a system's own (+ CONDITION, DELETE after asking), up to 60; each one's name (the standard name as placeholder), chip label, icon (ConditionIconPicker), description, how it ends (when removed or after rounds) and modifiers on all rolls, a stat or a formula, typed numbers kept as typed +│ │ │ ├── ConditionsPage.tsx # CONDITIONS: the standard set with switches and a system's own (+ CONDITION, DELETE after asking), up to 60; each one's name (the standard name as placeholder), chip label, icon (ConditionIconPicker), description, how it ends (when removed, after rounds, or at the rests ticked, the same setting as RESTS' WEARS OFF) and modifiers on all rolls, a stat or a formula, typed numbers kept as typed │ │ │ ├── RestsPage.tsx # RESTS: the standard four with switches and a system's own (+ REST, DELETE after asking), up to 12; each one's name (the standard name as placeholder), ALSO COUNTS AS (a rest that would loop greyed), refills in order (what from the sheet the server previews, how, an amount, a track in a two-track system; one naming something gone said so), the refills of rests it counts as shown above its own, and WEARS OFF (the conditions, rounds ones greyed, one ending through a counted rest said so) │ │ │ ├── ConditionIcon.tsx # A condition's icon: one drawn for CITY_NET, stroked in the theme's color, or an uploaded picture through only; anything unknown draws the target │ │ │ ├── ConditionIconPicker.tsx # A condition's icon picked: the 24 drawn ones, or UPLOAD for a small PNG, WebP or SVG (a refusal said); the builder's CONDITIONS page and the GAME tab's both use it @@ -801,7 +801,7 @@ CITY_NET/ │ │ │ ├── NpcsPage.test.tsx # The stat block shared or its own, tiers whose boxes keep what was typed, and TRY IT rolling one, all through the server's own code │ │ │ ├── mapConditions.test.tsx # Condition icons over tokens: four then +N naming the rest, on every kind of token for anyone with token health off too, nothing for none, and one shared request for the game's list (again after a system changes or a request fails) │ │ │ ├── TokenConditions.test.tsx # The HEALTH folder's conditions: chips with rounds, descriptions and modifiers for whoever may change them, putting one on and taking one off (a refusal said), nothing but which and what for everyone else, nothing drawn for a token with none, the list asked again when a system changes, and the stream overlay showing them -│ │ │ ├── ConditionsPage.test.tsx # Switches storing only "off", renaming and putting back, icons drawn or uploaded (a refusal said), ends and rounds, modifiers typed negative, a system's own added, named, deleted after asking, and + CONDITION stopping at 60 +│ │ │ ├── ConditionsPage.test.tsx # Switches storing only "off", renaming and putting back, icons drawn or uploaded (a refusal said), ends and rounds, ending at the rests ticked (one turned off offered only to untick), modifiers typed negative, a system's own added, named, deleted after asking, and + CONDITION stopping at 60 │ │ │ ├── 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 │ │ │ ├── TableConditionsPanel.test.tsx # The GAME tab's conditions against a fake server running the real checks: standard ones folded, the table's own listed, one added with a drawn or uploaded icon and sent whole with the GM's login, removed only once asked, a refusal said with the typing kept, + CONDITION stopping at 60, and the panel only for the main admin under a built-in game │ │ │ ├── TryItPage.test.tsx # The made-up character and its formulas, SAVE AS THE SAMPLE (absent with nowhere to save), RESET, health on a pretend token under each model, both maximums, and an NPC rolled from a tier diff --git a/frontend/src/components/ConditionsPage.tsx b/frontend/src/components/ConditionsPage.tsx index 68107b85..0718b111 100644 --- a/frontend/src/components/ConditionsPage.tsx +++ b/frontend/src/components/ConditionsPage.tsx @@ -2,15 +2,17 @@ import React, { useEffect, useState } from 'react'; import type { Definition, systemsApi } from '../sheets/systemsApi'; import { conditionList, conditionCount, withCondition, withNewCondition, withoutCondition, modifierTargets, - LIMITS, ALL_ROLLS, STANDARD, type Listed, + LIMITS, ALL_ROLLS, STANDARD, type Listed, type ConditionPatch, } from '../sheets/conditions'; +import { restList } from '../sheets/rests'; import { ConditionIcon } from './ConditionIcon'; import { ConditionIconPicker } from './ConditionIconPicker'; // The builder's CONDITIONS page (4e1b; approved mockup docs/mockups/builder-conditions.html, // 2026-10-09): what can happen to a character besides losing health. The standard set, each with // a switch, edited or turned off; the system's own, added and deleted; each one's name, chip label, -// icon (drawn, or uploaded), description, how it ends, and its modifiers. What it reads and writes +// icon (drawn, or uploaded), description, how it ends (when removed, after rounds, or at a rest, +// 4f4b), and its modifiers. What it reads and writes // is sheets/conditions.ts; every change goes through the builder's `edit`. interface Props { @@ -55,6 +57,37 @@ export function Switch({ on, label, onChange }: { on: boolean; label: string; on ); } +/** + * AT A REST (4f4b; approved mockup builder-rests, 2026-10-09): the rests that end a condition, ticked + * here or as WEARS OFF on the RESTS page, the same setting. The rests that are on, and any picked one + * since turned off, so it can be unticked. + */ +function EndsAt({ c, definition, set }: { c: Listed; definition: Definition; set: (patch: ConditionPatch) => void }) { + const at = c.at ?? []; + const offered = restList(definition).filter((r) => r.on || at.includes(r.id)); + return ( +
+
+ {offered.map((r) => { + const on = at.includes(r.id); + return ( + + ); + })} +
+ + {at.length ? 'Comes off at any of these, or a rest that counts as one. The RESTS page shows the same choice.' : 'Pick at least one rest.'} + +
+ ); +} + function Detail({ c, definition, edit, api }: Props & { c: Listed }) { const [confirm, setConfirm] = useState(false); const base = STANDARD.find((s) => s.id === c.id); @@ -95,12 +128,13 @@ function Detail({ c, definition, edit, api }: Props & { c: Listed }) { {c.ends === 'rounds' && <> set({ rounds: n })} /> rounds, unless changed when it's put on } + {c.ends === 'rest' && } MODIFIERS
diff --git a/frontend/src/components/__tests__/ConditionsPage.test.tsx b/frontend/src/components/__tests__/ConditionsPage.test.tsx index 9aa2e427..09d6206f 100644 --- a/frontend/src/components/__tests__/ConditionsPage.test.tsx +++ b/frontend/src/components/__tests__/ConditionsPage.test.tsx @@ -96,7 +96,39 @@ describe('a standard condition', () => { expect(stored(last())).toEqual({ stunned: { ends: 'rounds', rounds: 4 } }); fireEvent.change(detail('Stunned condition').getByLabelText('ENDS'), { target: { value: 'removed' } }); expect(last()).toEqual(H); - expect((detail('Stunned condition').getByRole('option', { name: 'AT A REFRESH EVENT (COMING)' }) as HTMLOptionElement).disabled).toBe(true); + }); + + it('ends at a rest, the rests ticked, the same setting as the RESTS page', async () => { + // 4f4b. A rest turned off isn't offered, unless already picked, so it can be unticked. + const start: Definition = { ...H, rests: { end_of_scene: { on: false }, downtime: { name: 'Downtime' } }, conditions: { stunned: { ends: 'rest', at: ['end_of_scene'] } } }; + const { last } = open(start); + await userEvent.click(list().getByRole('button', { name: 'EXHAUSTED' })); + fireEvent.change(detail('Exhausted condition').getByLabelText('ENDS'), { target: { value: 'rest' } }); + expect(stored(last())!.exhausted).toEqual({ ends: 'rest', at: [] }); + const ticks = () => within(detail('Exhausted condition').getByRole('group', { name: 'Ends at' })); + expect(ticks().getAllByRole('checkbox').map((t) => t.textContent)).toEqual(['SHORT REST', 'LONG REST', 'END OF SESSION', 'DOWNTIME']); + expect(detail('Exhausted condition').getByText('Pick at least one rest.')).toBeTruthy(); + expect(checkDefinition(last()).problems).toEqual([{ where: 'condition exhausted, at', message: 'Name the rests it wears off at' }]); + await userEvent.click(ticks().getByRole('checkbox', { name: 'LONG REST' })); + await userEvent.click(ticks().getByRole('checkbox', { name: 'DOWNTIME' })); + expect(stored(last())!.exhausted).toEqual({ ends: 'rest', at: ['long_rest', 'downtime'] }); + expect(ticks().getByRole('checkbox', { name: 'LONG REST' }).getAttribute('aria-checked')).toBe('true'); + expect(detail('Exhausted condition').queryByText('Pick at least one rest.')).toBeNull(); + expect(detail('Exhausted condition').getByText(/Comes off at any of these/)).toBeTruthy(); + expect(checkDefinition(last()).problems).toEqual([]); + await userEvent.click(ticks().getByRole('checkbox', { name: 'LONG REST' })); + expect(stored(last())!.exhausted).toEqual({ ends: 'rest', at: ['downtime'] }); + // Switched to another ending, the rests go with it. + fireEvent.change(detail('Exhausted condition').getByLabelText('ENDS'), { target: { value: 'rounds' } }); + expect(stored(last())!.exhausted).toEqual({ ends: 'rounds', rounds: 1 }); + expect(detail('Exhausted condition').queryByRole('group', { name: 'Ends at' })).toBeNull(); + // The picked rest that is off is still there to untick. + await userEvent.click(list().getByRole('button', { name: 'STUNNED' })); + const off = within(detail('Stunned condition').getByRole('group', { name: 'Ends at' })).getByRole('checkbox', { name: 'END OF SCENE (OFF)' }); + expect(off.getAttribute('aria-checked')).toBe('true'); + await userEvent.click(off); + expect(stored(last())!.stunned).toEqual({ ends: 'rest', at: [] }); + expect(within(detail('Stunned condition').getByRole('group', { name: 'Ends at' })).queryByRole('checkbox', { name: /END OF SCENE/ })).toBeNull(); }); it('gets modifiers on all rolls or the system\'s stats and formulas, a negative typed sign first', async () => {