From 6b375eb2d7908b7e4904726e7e2bd53faeabc70f Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 9 Oct 2026 13:21:32 -0500 Subject: [PATCH] feat(system-builder): condition icons over tokens on the map (4e2b2) From the approved builder-conditions mockup. Every token (enemy, friendly and player) draws its conditions as up to four icons above it, then +N; each icon is titled with its condition's name and +N lists the rest. Drawn for everyone, and whatever the game's token health, since a game without it may track harm in conditions alone. tokenConditions.ts mapIcons holds the four-then-+N rule. useConditionList now shares one request per game and login across every window and token, forgets a failed request so the next asks again, and forgets them all once (at module level, before any hook re-asks) when a published system changes, so a map of many tokens asks once. --- CHANGELOG.md | 6 + README.md | 7 +- frontend/src/components/Rhombuses.tsx | 44 +++++++ .../__tests__/TokenConditions.test.tsx | 4 +- .../__tests__/mapConditions.test.tsx | 108 ++++++++++++++++++ frontend/src/hooks/useConditionList.ts | 36 ++++-- .../sheets/__tests__/tokenConditions.test.ts | 18 ++- frontend/src/sheets/tokenConditions.ts | 13 +++ 8 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/__tests__/mapConditions.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f51e77..a9cf1b2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **Conditions on the map.** A token with conditions shows up to four of their icons above it, + then +N for the rest; pointing at an icon names it, and pointing at +N lists the others. Everyone + sees them, on every kind of token, including in a game that has token health turned off and + tracks harm through conditions alone. All the tokens on a map share one request for the game's + list of conditions. + - **Conditions in the HEALTH folder, and BLIND and BLEED become conditions.** A token's HEALTH folder now has a CONDITIONS section: every condition on the token as a chip with its icon, and below them what each one means. Whoever may change the token's health can put conditions on diff --git a/README.md b/README.md index 89269a9f..7e9c5947 100644 --- a/README.md +++ b/README.md @@ -657,7 +657,7 @@ CITY_NET/ │ │ │ ├── Buildings.tsx # 3D building meshes │ │ │ ├── Sidewalks.tsx # Road-flanking pavement strips (mitered quad ribbons, no geometry under roads) + neon curb line overlays │ │ │ ├── MapExportController.tsx # R3F bridge — renders null, lifts the export API out of the Canvas so AdminPanel buttons can drive it -│ │ │ ├── Rhombuses.tsx # Player token meshes; carries the vehicle badge, drawn always rather than on hover — cover you have to hover to discover is cover nobody accounts for +│ │ │ ├── Rhombuses.tsx # Player token meshes; carries the vehicle badge, drawn always rather than on hover — cover you have to hover to discover is cover nobody accounts for; and the token's conditions as up to four icons above it, then +N, for everyone │ │ │ ├── Overpasses.tsx # Elevated road meshes (deck tiles, ramps, pillars) + ghost OverpassPreview │ │ │ ├── MapElements.tsx # Roads, water, overlays; RoadEraser (segment/path delete with hover highlight) │ │ │ ├── Sidebar.tsx # Nav rail — controls, volume, help, geometry tools; initiative button blinks when a roll is needed; exports `hasSheetCombat` + `SheetAttackPanel` (system-agnostic via ATTACK_PANEL_CONFIG) @@ -788,6 +788,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 +│ │ │ ├── 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 │ │ │ ├── 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 @@ -865,7 +866,7 @@ CITY_NET/ │ │ │ ├── useApi.ts # Fetch helpers │ │ │ ├── useMapExport.ts # PNG/WebM city export — one cached off-screen renderer for the session, shared ortho camera, GPU size clamp, per-frame render loop for video, MediaRecorder with codec fallback; never touches the live camera │ │ │ ├── useCustomTemplates.ts # Redraws when a custom system's sheet template arrives, and fetches the running system's ahead of need -│ │ │ ├── useConditionList.ts # The running game's conditions (GET /api/systems/conditions/:system, modifiers only with the GM's login), asked again when a published system changes; and a token's rounds left and modifiers for whoever may see them (requestTokenConditions) +│ │ │ ├── useConditionList.ts # The running game's conditions (GET /api/systems/conditions/:system, modifiers only with the GM's login), one request shared by every window and token, asked again when a published system changes or a request failed; and a token's rounds left and modifiers for whoever may see them (requestTokenConditions) │ │ │ ├── useHealthView.ts # A token's health under a custom model, as the server lets this viewer see it: asked over the socket (requestHealthView), again on every sheet or map change │ │ │ ├── useMapData.ts # Location/district/road/overpass/water body/sign data fetching. Sends the GM's sign-in with the location list, held in a ref so signing in does not give fetchLocations a new identity │ │ │ ├── useCustomDice.ts # Custom dice state — fetches GM dice and the active system's built-ins, merges them (built-ins first, flagged `locked`), and applies `customDiceUpdated` broadcasts @@ -935,7 +936,7 @@ CITY_NET/ │ │ │ ├── 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 -│ │ │ ├── tokenConditions.ts # A token's conditions in the windows as logic: the column read as the server reads it, what to draw from the game's list (one it no longer has left out), rounds left and modifiers where sent, and putting one on or taking one off without resetting another's rounds +│ │ │ ├── tokenConditions.ts # A token's conditions in the windows as logic: the column read as the server reads it, what to draw from the game's list (one it no longer has left out), rounds left and modifiers where sent, putting one on or taking one off without resetting another's rounds, and the map's four icons then +N │ │ │ ├── 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) diff --git a/frontend/src/components/Rhombuses.tsx b/frontend/src/components/Rhombuses.tsx index 829af3a1..0df4fb44 100644 --- a/frontend/src/components/Rhombuses.tsx +++ b/frontend/src/components/Rhombuses.tsx @@ -8,6 +8,9 @@ import { IS_SPECTATOR } from '../streamerMode'; import { parseVehicleState } from '../types'; import { canMoveToken } from '../utils/tokenControl'; import { useParts } from '../sheets/parts'; +import { useConditionList } from '../hooks/useConditionList'; +import { parseOnToken, shownConditions, mapIcons } from '../sheets/tokenConditions'; +import { ConditionIcon } from './ConditionIcon'; /** * Marks a token as being inside a vehicle. @@ -46,6 +49,44 @@ function VehicleTag({ raw, height, isBattleMap }: { raw: string | null | undefin ); } +/** + * A token's conditions on the map (4e2b2; approved mockup docs/mockups/builder-conditions.html, + * 2026-10-09): up to four icons above it, then +N, pointing at one naming it. Drawn for everyone + * and whatever the game's health, since a game without token health may track harm in conditions + * alone. The game's list is one request shared by every token (useConditionList). + */ +function ConditionIcons({ raw, gameSystem, height, isBattleMap }: { raw: string | null | undefined; gameSystem?: string; height: number; isBattleMap: boolean }) { + const game = useConditionList(gameSystem); + const { icons, more, moreNames } = mapIcons(shownConditions(parseOnToken(raw), game)); + if (!icons.length) return null; + return ( + +
+ {icons.map((c) => ( + + + + ))} + {more > 0 && ( + +{more} + )} +
+ + ); +} + // Streamer name tags follow the admin's hover: the admin broadcasts hover // state per rhombus; the spectator shows the tag only for that rhombus. const useStreamerHover = (socket: any, locationId: number, isAdmin: boolean, isHovered: boolean) => { @@ -333,6 +374,7 @@ export const EnemyRhombus = React.memo(({ gameSystem, location, onClick, isSelec )} + {location.name && (isHovered || isSelected || (IS_SPECTATOR && streamerVis.showPlayerNames && streamerHovered)) && ( @@ -601,6 +643,7 @@ export const FriendlyRhombus = React.memo(({ gameSystem, location, onClick, isSe )} + {location.name && (isHovered || isSelected || (IS_SPECTATOR && streamerVis.showPlayerNames && streamerHovered)) && ( @@ -915,6 +958,7 @@ export const PlayerRhombus = React.memo(({ gameSystem, location, onClick, isSele )} + {location.name && (isHovered || isSelected || (IS_SPECTATOR && streamerVis.showPlayerNames && streamerHovered)) && ( diff --git a/frontend/src/components/__tests__/TokenConditions.test.tsx b/frontend/src/components/__tests__/TokenConditions.test.tsx index ad48b383..7b19a08b 100644 --- a/frontend/src/components/__tests__/TokenConditions.test.tsx +++ b/frontend/src/components/__tests__/TokenConditions.test.tsx @@ -7,7 +7,7 @@ import { createRequire } from 'module'; import { TokenConditions } from '../TokenConditions'; import { StreamerOverlay } from '../StreamerOverlay'; import { HealthReviewPanel } from '../HitPoints'; -import { CONDITIONS_CHANGED_EVENT } from '../../hooks/useConditionList'; +import { CONDITIONS_CHANGED_EVENT, forgetConditionLists } from '../../hooks/useConditionList'; import type { Location } from '../../types'; /** @@ -31,6 +31,8 @@ let putAnswer: { status: number; body: unknown }; let listAsked: { url: string; auth: string | null }[]; beforeEach(() => { + // The game's list is shared between windows: each case asks afresh. + forgetConditionLists(); puts = []; listAsked = []; putAnswer = { status: 200, body: {} }; diff --git a/frontend/src/components/__tests__/mapConditions.test.tsx b/frontend/src/components/__tests__/mapConditions.test.tsx new file mode 100644 index 00000000..ec7d9095 --- /dev/null +++ b/frontend/src/components/__tests__/mapConditions.test.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, within, waitFor } from '@testing-library/react'; +import { createRequire } from 'module'; + +vi.mock('@react-three/fiber', () => ({ + useFrame: vi.fn(), + useThree: () => ({ controls: { enabled: true }, raycaster: { ray: { intersectPlane: vi.fn() } } }), +})); +vi.mock('@react-three/drei', () => ({ Html: ({ children }: any) =>
{children}
})); +vi.mock('../../HealthBar', () => ({ HealthBar: () =>
})); + +import { EnemyRhombus, FriendlyRhombus, PlayerRhombus } from '../Rhombuses'; +import { forgetConditionLists, CONDITIONS_CHANGED_EVENT } from '../../hooks/useConditionList'; +import { registerCustomTemplate, clearCustomTemplates } from '../../sheets/customTemplates'; + +/** + * Conditions on the map (4e2b2). Approved mockup builder-conditions (2026-10-09): up to four icons + * above a token, then +N listing the rest when pointed at; for everyone, enemies included, and in + * a game with token health off too, since it may track harm in conditions alone. Every token draws + * from one shared request for the game's list. + */ + +const { conditionsOf } = createRequire(import.meta.url)('../../../../backend/systemBuilder/conditions.js'); +const HEARTH = 'sys_aaaaaaaaaaaaaaaa'; +const GAME = conditionsOf({ conditions: { glitching: { name: 'Glitching', icon: 'signal' } } }); + +let asked: string[]; +beforeEach(() => { + asked = []; + forgetConditionLists(); + clearCustomTemplates(); + // Token health off: conditions still show. + registerCustomTemplate({ id: HEARTH, name: 'Hearth', parts: { token_health: { on: false } }, derived: [], sheet: { sections: [] }, words: {} }); + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + asked.push(String(input)); + return { ok: true, status: 200, json: async () => GAME } as Response; + })); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); clearCustomTemplates(); }); + +const socket = () => ({ emit: vi.fn(), on: vi.fn(), off: vi.fn() }); +const loc = (id: number, shape: string, conditions: unknown[]): any => ({ + id, x: 0, y: 0, z: 0, width: 1, height: 1, depth: 1, name: 'X', color: '#00ff00', shape, hp_current: 10, hp_max: 10, hp_temp: 0, owner: 'GHOST', + conditions: JSON.stringify(conditions), +}); +const common = (gameSystem = HEARTH) => ({ onClick: vi.fn(), isSelected: false, setTargetObject: vi.fn(), refreshLocations: vi.fn(), setIsDragging: vi.fn(), + socket: socket(), roads: [], isBattleMap: false, measureMode: false, gameSystem }); + +describe('a token\'s condition icons', () => { + it('draws up to four, then +N naming the rest, each named when pointed at', async () => { + const six = ['blinded', 'bleeding', 'poisoned', 'prone', 'stunned', 'glitching'].map((id) => ({ id })); + render(); + const icons = await screen.findByTestId('map-conditions'); + const marks = within(icons).getAllByTitle(/./); + expect(marks.map((m) => m.getAttribute('title')).filter((t, i, all) => all.indexOf(t) === i)).toEqual(['Blinded', 'Bleeding', 'Poisoned', 'Prone', 'Stunned, Glitching']); + expect(within(icons).getByText('+2').getAttribute('title')).toBe('Stunned, Glitching'); + expect(icons.querySelectorAll('svg')).toHaveLength(4); + }); + + it('shows on every kind of token to anyone, enemies included, with token health off', async () => { + render(<> + + + + ); + await waitFor(() => expect(screen.getAllByTestId('map-conditions')).toHaveLength(3)); + expect(screen.queryAllByTestId('health-bar')).toHaveLength(0); + }); + + it('draws nothing for a token with none, or only ones the game no longer has', async () => { + render(<> + + + ); + await waitFor(() => expect(asked.length).toBeGreaterThan(0)); + await new Promise((r) => setTimeout(r, 10)); + expect(screen.queryAllByTestId('map-conditions')).toHaveLength(0); + }); + + it('asks for the game\'s list once for every token, and again when a published system changes', async () => { + render(<> + {[1, 2, 3, 4, 5].map((id) => )} + ); + await waitFor(() => expect(screen.getAllByTestId('map-conditions')).toHaveLength(5)); + expect(asked).toEqual([`/api/systems/conditions/${HEARTH}`]); + window.dispatchEvent(new Event(CONDITIONS_CHANGED_EVENT)); + await waitFor(() => expect(asked).toHaveLength(2)); + await new Promise((r) => setTimeout(r, 10)); + expect(asked).toHaveLength(2); + }); + + it('asks again after a request that failed, rather than keeping nothing', async () => { + let fail = true; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + asked.push(String(input)); + return (fail ? { ok: false, status: 500, json: async () => ({}) } : { ok: true, status: 200, json: async () => GAME }) as Response; + })); + const { unmount } = render(); + await waitFor(() => expect(asked).toHaveLength(1)); + expect(screen.queryByTestId('map-conditions')).toBeNull(); + unmount(); + fail = false; + render(); + expect(await screen.findByTestId('map-conditions')).toBeTruthy(); + expect(asked).toHaveLength(2); + }); +}); diff --git a/frontend/src/hooks/useConditionList.ts b/frontend/src/hooks/useConditionList.ts index 770b3ddc..350d28a4 100644 --- a/frontend/src/hooks/useConditionList.ts +++ b/frontend/src/hooks/useConditionList.ts @@ -7,23 +7,45 @@ import type { GameCondition, OnToken } from '../sheets/tokenConditions'; export const CONDITIONS_CHANGED_EVENT = 'citynet:conditions-changed'; +/** + * One request per game and login, shared: every token on the map draws its condition icons from + * the same list (4e2b2), so dozens of tokens ask once. A failed request isn't kept, so the next + * one asks again; a published system changing forgets them all. + */ +const asked = new Map>(); +const listFor = (system: string, authToken?: string) => { + const key = `${system}\n${authToken ?? ''}`; + if (!asked.has(key)) { + const request = fetch(`/api/systems/conditions/${encodeURIComponent(system)}`, authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : undefined) + .then((r) => { if (!r.ok) throw new Error(String(r.status)); return r.json(); }) + .then((body) => (Array.isArray(body) ? body as GameCondition[] : [])) + .catch(() => { asked.delete(key); return [] as GameCondition[]; }); + asked.set(key, request); + } + return asked.get(key)!; +}; + +/** Forget every list asked for (a system changed; and tests, between cases). */ +export const forgetConditionLists = () => asked.clear(); + +// Once, when this module loads: listening before any hook does, so the lists are forgotten before +// the hooks ask again and they all share the one new request. +if (typeof window !== 'undefined') window.addEventListener(CONDITIONS_CHANGED_EVENT, forgetConditionLists); + export function useConditionList(system: string | undefined, authToken?: string): GameCondition[] { const [list, setList] = useState([]); - const [asked, setAsked] = useState(0); + const [round, setRound] = useState(0); useEffect(() => { - const again = () => setAsked((n) => n + 1); + const again = () => setRound((n) => n + 1); window.addEventListener(CONDITIONS_CHANGED_EVENT, again); return () => window.removeEventListener(CONDITIONS_CHANGED_EVENT, again); }, []); useEffect(() => { if (!system) { setList([]); return undefined; } let live = true; - fetch(`/api/systems/conditions/${encodeURIComponent(system)}`, authToken ? { headers: { Authorization: `Bearer ${authToken}` } } : undefined) - .then((r) => (r.ok ? r.json() : [])) - .then((body) => { if (live) setList(Array.isArray(body) ? body : []); }) - .catch(() => { if (live) setList([]); }); + listFor(system, authToken).then((body) => { if (live) setList(body); }); return () => { live = false; }; - }, [system, authToken, asked]); + }, [system, authToken, round]); return list; } diff --git a/frontend/src/sheets/__tests__/tokenConditions.test.ts b/frontend/src/sheets/__tests__/tokenConditions.test.ts index f1bddfb7..bf479a06 100644 --- a/frontend/src/sheets/__tests__/tokenConditions.test.ts +++ b/frontend/src/sheets/__tests__/tokenConditions.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { createRequire } from 'module'; import { - parseOnToken, shownConditions, withPutOn, withTakenOff, notOnYet, modifierText, roundsText, type GameCondition, + parseOnToken, shownConditions, withPutOn, withTakenOff, notOnYet, modifierText, roundsText, mapIcons, MAP_ICONS, type GameCondition, } from '../tokenConditions'; /** @@ -73,6 +73,22 @@ describe('changing them', () => { }); }); +describe('on the map', () => { + const shown = (ids: string[]) => shownConditions(ids.map((id) => ({ id })), conditionsOf(null)); + + it('draws the first four icons, and counts and names the rest', () => { + expect(MAP_ICONS).toBe(4); + const six = mapIcons(shown(['blinded', 'bleeding', 'poisoned', 'prone', 'stunned', 'grappled'])); + expect(six.icons.map((c) => c.id)).toEqual(['blinded', 'bleeding', 'poisoned', 'prone']); + expect([six.more, six.moreNames]).toEqual([2, ['Stunned', 'Grappled']]); + }); + + it('has no +N up to four, and nothing at all for none', () => { + expect(mapIcons(shown(['prone', 'stunned', 'grappled', 'bleeding']))).toMatchObject({ more: 0, moreNames: [] }); + expect(mapIcons([])).toEqual({ icons: [], more: 0, moreNames: [] }); + }); +}); + describe('what it says', () => { it('says a modifier and the rounds left as the HEALTH folder does', () => { expect(modifierText({ target: 'all_rolls', amount: -1 })).toBe('ALL ROLLS −1'); diff --git a/frontend/src/sheets/tokenConditions.ts b/frontend/src/sheets/tokenConditions.ts index 68ddfae2..95b9d372 100644 --- a/frontend/src/sheets/tokenConditions.ts +++ b/frontend/src/sheets/tokenConditions.ts @@ -74,5 +74,18 @@ export const notOnYet = (onToken: OnToken[], game: GameCondition[]) => game.filt export const modifierText = (m: { target: string; amount: number }) => `${m.target === 'all_rolls' ? 'ALL ROLLS' : m.target.replace(/_/g, ' ').toUpperCase()} ${m.amount > 0 ? '+' : '−'}${Math.abs(m.amount)}`; +/** How many condition icons a token draws on the map before the rest become +N (4e2b2). */ +export const MAP_ICONS = 4; + +/** + * What a token draws on the map: its first conditions' icons, and how many more there are with + * their names, for the +N to list when pointed at. Everyone sees these, as with the HEALTH folder. + */ +export const mapIcons = (shown: Shown[], max = MAP_ICONS) => ({ + icons: shown.slice(0, max).map((s) => s.condition), + more: Math.max(0, shown.length - max), + moreNames: shown.slice(max).map((s) => s.condition.name), +}); + /** Rounds left as the chip says it. */ export const roundsText = (left: number) => `${left} ROUND${left === 1 ? '' : 'S'} LEFT`;