From 9a1ee0166bab325e6034ba17b739cc5797106f5a Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 9 Oct 2026 10:22:15 -0500 Subject: [PATCH] feat(system-builder): the builder's CONDITIONS page (4e1b) From the approved builder-conditions mockup. A new rail page between NPCS and TRY IT: the ten standard conditions with on/off switches, the system's own below them (+ CONDITION up to 60 in all, DELETE after asking), and for the one picked its name (the standard name as placeholder), chip label, icon, description, how it ends (when removed, or after rounds; a refresh event shown as coming) and its modifiers on all rolls, a stat or a formula. Typed numbers keep a lone minus sign. Locked like every other page on a built-in example. sheets/conditions.ts mirrors the server's conditions.js and is tested against it: a standard condition stores only what differs, so putting it back leaves nothing; one of the system's own keeps its name as typed, so a blank one shows as a problem rather than vanishing. The 24 icons are drawn for CITY_NET in sheets/conditionIcons.ts as path data, stroked in currentColor and never filled. The four first sketched too close to Feather's (eye, music note, bolt, anchor) are redrawn, and a test checks none of Feather's paths appear. ConditionIcon draws an uploaded one through only. uploadIcon takes 'condition' for the conditions' own upload route. --- CHANGELOG.md | 8 + README.md | 7 + frontend/src/components/BuilderScreen.tsx | 4 + frontend/src/components/ConditionIcon.tsx | 20 ++ frontend/src/components/ConditionsPage.tsx | 214 ++++++++++++++++++ .../__tests__/BuilderScreen.test.tsx | 17 +- .../__tests__/ConditionsPage.test.tsx | 190 ++++++++++++++++ .../sheets/__tests__/builderSession.test.ts | 2 +- .../sheets/__tests__/conditionIcons.test.tsx | 85 +++++++ .../src/sheets/__tests__/conditions.test.ts | 140 ++++++++++++ .../src/sheets/__tests__/systemsApi.test.ts | 7 + frontend/src/sheets/builderSession.ts | 3 +- frontend/src/sheets/conditionIcons.ts | 55 +++++ frontend/src/sheets/conditions.ts | 157 +++++++++++++ frontend/src/sheets/systemsApi.ts | 9 +- 15 files changed, 911 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/ConditionIcon.tsx create mode 100644 frontend/src/components/ConditionsPage.tsx create mode 100644 frontend/src/components/__tests__/ConditionsPage.test.tsx create mode 100644 frontend/src/sheets/__tests__/conditionIcons.test.tsx create mode 100644 frontend/src/sheets/__tests__/conditions.test.ts create mode 100644 frontend/src/sheets/conditionIcons.ts create mode 100644 frontend/src/sheets/conditions.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dec4588..393ebdd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **The builder's CONDITIONS page.** A new page between NPCS and TRY IT lists the ten standard + conditions with on/off switches and the system's own below them. Each can be renamed (the + standard name shows until you type one), given a short label for its chip, an icon (24 drawn for + CITY_NET that follow the theme, or a picture you upload), a description, an end (when removed, or + after a number of rounds) and modifiers on all rolls, a stat or a formula. + CONDITION adds one of + your own, up to 60 in all, and deleting one asks first. Putting a standard condition back as it + was saves nothing, as with WORDS. Conditions on tokens come next. + - **Conditions as part of a game system.** A custom system now carries its conditions: ten standard ones every system starts with (Blinded, Bleeding, Poisoned, Prone, Stunned, Grappled, Frightened, Unconscious, Restrained, Exhausted), each of which can be renamed, described, given diff --git a/README.md b/README.md index cb3267e3..e52a3af3 100644 --- a/README.md +++ b/README.md @@ -709,6 +709,8 @@ 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 (24 drawn, or UPLOAD), description, how it ends (when removed or after rounds) and modifiers on all rolls, a stat or a formula, typed numbers kept as typed +│ │ │ ├── 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 │ │ │ ├── TryItPage.tsx # TRY IT: the draft as a player would meet it, unsaved changes included. A made-up character on its own sheet with formulas worked out as you type, its health on a pretend token in the HEALTH folder's own panels with what others see, and an NPC rolled from a tier. Saves nothing but SAVE AS THE SAMPLE CHARACTER │ │ │ ├── ControlledNpcSheetWindow.tsx # A friendly NPC's sheet for the player the GM gave it to: read-only, with rolls made for the NPC through the server │ │ │ ├── UpdateModal.tsx # Draggable update notification modal (shown on admin login when update available; Update Now / Remind Me Later / Skip Version; docker-aware) @@ -780,6 +782,7 @@ CITY_NET/ │ │ │ ├── StatsRulesPage.test.tsx # Stats in groups with their SAMPLE column, formulas with live values and mistakes, and tables with TRY IT │ │ │ ├── SheetPage.test.tsx # Automatic or customized, the tree of tabs, sections and fields, moving and removing them (sections going to a tab you choose, the last tab making one page), and each field's settings │ │ │ ├── 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 +│ │ │ ├── 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 │ │ │ ├── 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 │ │ │ ├── ControlledNpcSheetWindow.test.tsx # A friendly NPC's sheet read-only for the player it was given to, fetched with their own login, rolling as the NPC │ │ │ ├── SheetAttackPanel.attackAs.test.tsx # ATTACK AS: a player with a friendly NPC given to them, the GM with an NPC near the target, its weapons and LUCK, firing as it @@ -923,6 +926,8 @@ CITY_NET/ │ │ │ ├── sheetDesigner.ts # CHARACTER SHEET as logic: a system's own sheet read from its definition and written back - tabs, sections, fields, placement, the tray, and the problems that stop it saving │ │ │ ├── publicLines.ts # What everyone else sees of a custom system's character: the sheet's public lines, for ID.EXE's INFO and the sheet preview's EVERYONE ELSE │ │ │ ├── npcs.ts # NPCS as logic: the stat block (shared or its own), tiers and their boxes (number, formula with @level, or dice), the server's limits, and a rolled box as text +│ │ │ ├── conditions.ts # CONDITIONS as logic: the standard set and a system's own read from a definition and written back storing only what differs (a standard one put back leaves nothing), the count against 60, new and deleted ones, and what a modifier may name; mirrors the server's conditions.js +│ │ │ ├── conditionIcons.ts # The 24 condition icons drawn for CITY_NET as path data on a 24-pixel grid, ours to use (the four first sketched too close to Feather's redrawn), and telling a drawn icon from an uploaded one │ │ │ ├── tryIt.ts # TRY IT as logic: the made-up character from the sample, typed values, SAVE AS THE SAMPLE, the pretend token and its maximum │ │ │ ├── examples.ts # The examples' and starters' cards (health, advancement in the system's own XP word, stats, formulas, distance), the copy's suggested name, and locking a page while one is looked at (every control but tabs, things opening out and marked views) │ │ │ ├── templates/ @@ -942,6 +947,8 @@ CITY_NET/ │ │ │ ├── statsRules.test.ts # Stats, samples, formulas and tables written back, ids from first names and kept after, held to the server's check and engine │ │ │ ├── sheetDesigner.test.ts # The sheet designer's every step (customize, the tray, moving and removing tabs, sections and fields, attack numbers never EVERYONE), held to the server's check │ │ │ ├── npcs.test.ts # The stat block and tiers written back, held to the server's check and rolled by its tierRolls +│ │ │ ├── conditions.test.ts # The standard set and what the game offers held to the server's own conditionsOf, each change storing only what differs, a system's own added, renamed and deleted, the limit of 60, and what a modifier may name +│ │ │ ├── conditionIcons.test.tsx # The 24 icons are the server's, path data alone, none of Feather's paths, stroked in currentColor and never filled; an upload drawn through ; anything unknown drawn as the target │ │ │ ├── tryIt.test.ts # The made-up character, typed values, SAVE AS THE SAMPLE and RESET, the pretend token and its maximum │ │ │ ├── examples.test.ts # The examples' and starters' card facts read off the server's own definitions, XP in the system's word, and locking a page but for browsing │ │ │ ├── customTemplates.test.tsx # A published custom system's sheet drawn by the ordinary renderer from the server's render copy, the generic template standing in until it loads diff --git a/frontend/src/components/BuilderScreen.tsx b/frontend/src/components/BuilderScreen.tsx index 678aee9e..21ae4c2c 100644 --- a/frontend/src/components/BuilderScreen.tsx +++ b/frontend/src/components/BuilderScreen.tsx @@ -13,6 +13,8 @@ import { FeaturesPage } from './FeaturesPage'; import { StatsRulesPage } from './StatsRulesPage'; import { SheetPage } from './SheetPage'; import { NpcsPage } from './NpcsPage'; +import { ConditionsPage } from './ConditionsPage'; +import { CONDITION_ICONS } from '../sheets/conditionIcons'; import { TryItPage } from './TryItPage'; // The system builder (4a2b): it takes over the whole window, with no map. A sidebar down the left @@ -115,6 +117,7 @@ const Icons: Record} {ready && page === 'sheet' && } {ready && page === 'npcs' && } + {ready && page === 'conditions' && } {ready && page === 'try' && } diff --git a/frontend/src/components/ConditionIcon.tsx b/frontend/src/components/ConditionIcon.tsx new file mode 100644 index 00000000..06c7d239 --- /dev/null +++ b/frontend/src/components/ConditionIcon.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { CONDITION_ICONS, isDrawnIcon, isUploadedIcon } from '../sheets/conditionIcons'; + +// A condition's icon (4e1b): one drawn for CITY_NET, stroked in the theme's color, or a picture the +// GM uploaded, drawn through only, which is what makes an uploaded SVG safe (as currency +// icons and battle maps). Anything else draws the target, so a broken reference still shows a mark. + +export function ConditionIcon({ icon, size = 18, title }: { icon: string; size?: number; title?: string }) { + if (isUploadedIcon(icon)) { + return {title; + } + const paths = CONDITION_ICONS[isDrawnIcon(icon) ? icon : 'target']; + return ( + + {paths.map((d) => )} + + ); +} diff --git a/frontend/src/components/ConditionsPage.tsx b/frontend/src/components/ConditionsPage.tsx new file mode 100644 index 00000000..3550c36e --- /dev/null +++ b/frontend/src/components/ConditionsPage.tsx @@ -0,0 +1,214 @@ +import React, { useEffect, useRef, useState } from 'react'; +import type { Definition, systemsApi } from '../sheets/systemsApi'; +import { + conditionList, conditionCount, withCondition, withNewCondition, withoutCondition, modifierTargets, + LIMITS, ALL_ROLLS, STANDARD, type Listed, +} from '../sheets/conditions'; +import { CONDITION_ICON_IDS, isUploadedIcon } from '../sheets/conditionIcons'; +import { ConditionIcon } from './ConditionIcon'; + +// 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 +// is sheets/conditions.ts; every change goes through the builder's `edit`. + +interface Props { + definition: Definition; + edit: (next: Definition) => void; + /** For uploading an icon; without it only the drawn icons are offered. */ + api?: ReturnType; +} + +const small: React.CSSProperties = { fontSize: 10, letterSpacing: 2, opacity: 0.75 }; +const why: React.CSSProperties = { fontSize: 12, lineHeight: 1.45, opacity: 0.85, margin: 0 }; +const btn: React.CSSProperties = { fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, padding: '4px 9px' }; +const input: React.CSSProperties = { + background: 'var(--black)', color: 'var(--green)', border: '1px solid var(--green)', fontFamily: 'monospace', fontSize: 12, padding: '4px 6px', minWidth: 0, boxSizing: 'border-box', +}; +const row: React.CSSProperties = { display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }; + +/** + * A whole number box that keeps what is typed: a lone minus sign, or a field cleared to type + * again, stays as typed rather than becoming 0 under the cursor. + */ +function WholeBox({ aria, value, min, max, width = 64, onChange }: { aria: string; value: number; min: number; max: number; width?: number; onChange: (n: number) => void }) { + const [text, setText] = useState(String(value)); + useEffect(() => { setText((t) => (Number(t) === value ? t : String(value))); }, [value]); + return ( + { + setText(e.target.value); + if (/^-?\d+$/.test(e.target.value.trim())) onChange(Math.max(min, Math.min(max, parseInt(e.target.value, 10)))); + }} /> + ); +} + +function Switch({ on, label, onChange }: { on: boolean; label: string; onChange: (on: boolean) => void }) { + return ( + + ); +} + +function Detail({ c, definition, edit, api }: Props & { c: Listed }) { + const [confirm, setConfirm] = useState(false); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const fileRef = useRef(null); + const base = STANDARD.find((s) => s.id === c.id); + const targets = modifierTargets(definition); + const set = (patch: Parameters[2]) => edit(withCondition(definition, c.id, patch)); + const stored = (definition.conditions as Record> | undefined)?.[c.id] ?? {}; + const typed = (key: 'name' | 'short') => (typeof stored[key] === 'string' ? stored[key] as string : (base ? '' : c[key])); + + const upload = async (file: File | undefined) => { + if (!file || !api) return; + setUploading(true); + const r = await api.uploadIcon(file, 'condition'); + setUploading(false); + if (!r.ok) { setUploadError(r.error); return; } + setUploadError(null); + set({ icon: r.value.icon }); + }; + + return ( +
+
+ {base ? 'STANDARD CONDITION' : 'ONE OF THIS SYSTEM\'S OWN'} + {base + ? {c.on ? 'On: offered for tokens.' : 'Off: not offered for tokens.'} + : confirm + ? + Delete {c.name || 'this condition'}? + + + + : } +
+
+ + set({ name: e.target.value })} /> + + set({ short: e.target.value.toUpperCase() })} /> + ICON +
+
+ {CONDITION_ICON_IDS.map((id) => ( + + ))} +
+
+ {isUploadedIcon(c.icon) && ( + + + + )} + {api && <> + + { upload(e.target.files?.[0]); e.target.value = ''; }} /> + PNG, WebP or SVG, a quarter of a megabyte. Kept in its own colors; the drawn ones follow the theme. + } +
+ {uploadError && {uploadError}} +
+ +