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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> 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
Expand Down Expand Up @@ -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
Expand Down
40 changes: 37 additions & 3 deletions frontend/src/components/ConditionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, flexBasis: '100%', minWidth: 0 }}>
<div role="group" aria-label="Ends at" style={{ ...row, gap: 4 }}>
{offered.map((r) => {
const on = at.includes(r.id);
return (
<button key={r.id} type="button" role="checkbox" aria-checked={on}
onClick={() => set({ ends: 'rest', at: on ? at.filter((a) => a !== r.id) : [...at, r.id] })}
style={{ ...btn, cursor: 'pointer', fontWeight: 'normal', opacity: r.on ? 1 : 0.5,
border: `1px solid ${on ? 'var(--warning)' : 'var(--dark-green)'}`, color: on ? 'var(--warning)' : 'var(--green)',
background: on ? 'color-mix(in srgb, var(--warning) 10%, transparent)' : 'none' }}>
{(r.name || r.id).toUpperCase()}{!r.on && ' (OFF)'}
</button>
);
})}
</div>
<span style={{ ...why, fontSize: 11, ...(at.length ? {} : { color: 'var(--warning)' }) }}>
{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.'}
</span>
</div>
);
}

function Detail({ c, definition, edit, api }: Props & { c: Listed }) {
const [confirm, setConfirm] = useState(false);
const base = STANDARD.find((s) => s.id === c.id);
Expand Down Expand Up @@ -95,12 +128,13 @@ function Detail({ c, definition, edit, api }: Props & { c: Listed }) {
<select id={`cond-ends-${c.id}`} value={c.ends} style={input} onChange={(e) => set({ ends: e.target.value as Listed['ends'] })}>
<option value="removed">WHEN REMOVED</option>
<option value="rounds">AFTER ROUNDS</option>
<option value="refresh" disabled>AT A REFRESH EVENT (COMING)</option>
<option value="rest">AT A REST</option>
</select>
{c.ends === 'rounds' && <>
<WholeBox aria="Rounds" value={c.rounds ?? 1} min={1} max={LIMITS.rounds} onChange={(n) => set({ rounds: n })} />
<span style={{ ...why, fontSize: 11 }}>rounds, unless changed when it&apos;s put on</span>
</>}
{c.ends === 'rest' && <EndsAt c={c} definition={definition} set={set} />}
</div>
<span style={{ ...small, textAlign: 'right' }}>MODIFIERS</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
Expand Down
34 changes: 33 additions & 1 deletion frontend/src/components/__tests__/ConditionsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading