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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/components/Rhombuses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 (
<Html
position={[0, isBattleMap ? 0.1 : (height * 0.8) + 4.4, 0]}
center
zIndexRange={[100, 0]}
occlude={!isBattleMap}
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
<div data-testid="map-conditions" style={{ display: 'flex', gap: '2px', transform: isBattleMap ? 'translateY(-56px)' : undefined }}>
{icons.map((c) => (
<span key={c.id} title={c.name} style={{
width: '16px', height: '16px', display: 'grid', placeItems: 'center', boxSizing: 'border-box',
border: '1px solid var(--warning)', color: 'var(--warning)', background: 'var(--black)',
}}>
<ConditionIcon icon={c.icon} size={11} title={c.name} />
</span>
))}
{more > 0 && (
<span title={moreNames.join(', ')} style={{
height: '16px', padding: '0 3px', display: 'grid', placeItems: 'center', boxSizing: 'border-box',
border: '1px solid var(--warning)', color: 'var(--warning)', background: 'var(--black)', fontFamily: 'monospace', fontSize: '9px',
}}>+{more}</span>
)}
</div>
</Html>
);
}

// 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) => {
Expand Down Expand Up @@ -333,6 +374,7 @@ export const EnemyRhombus = React.memo(({ gameSystem, location, onClick, isSelec
)}

<VehicleTag raw={location.vehicle_state} height={location.height} isBattleMap={isBattleMap} />
<ConditionIcons raw={location.conditions} gameSystem={gameSystem} height={location.height} isBattleMap={isBattleMap} />

{location.name && (isHovered || isSelected || (IS_SPECTATOR && streamerVis.showPlayerNames && streamerHovered)) && (
<Html position={[0, isBattleMap ? 0.1 : ((location.height * 0.8) + 3), 0]} center zIndexRange={[100, 0]} occlude={!isBattleMap} style={{ pointerEvents: 'none', userSelect: 'none' }}>
Expand Down Expand Up @@ -601,6 +643,7 @@ export const FriendlyRhombus = React.memo(({ gameSystem, location, onClick, isSe
)}

<VehicleTag raw={location.vehicle_state} height={location.height} isBattleMap={isBattleMap} />
<ConditionIcons raw={location.conditions} gameSystem={gameSystem} height={location.height} isBattleMap={isBattleMap} />

{location.name && (isHovered || isSelected || (IS_SPECTATOR && streamerVis.showPlayerNames && streamerHovered)) && (
<Html position={[0, isBattleMap ? 0.1 : ((location.height * 0.8) + 3), 0]} center zIndexRange={[100, 0]} occlude={!isBattleMap} style={{ pointerEvents: 'none', userSelect: 'none' }}>
Expand Down Expand Up @@ -915,6 +958,7 @@ export const PlayerRhombus = React.memo(({ gameSystem, location, onClick, isSele
)}

<VehicleTag raw={location.vehicle_state} height={location.height} isBattleMap={isBattleMap} />
<ConditionIcons raw={location.conditions} gameSystem={gameSystem} height={location.height} isBattleMap={isBattleMap} />

{location.name && (isHovered || isSelected || (IS_SPECTATOR && streamerVis.showPlayerNames && streamerHovered)) && (
<Html position={[0, isBattleMap ? 0.1 : ((location.height * 0.8) + 3), 0]} center zIndexRange={[100, 0]} occlude={!isBattleMap} style={{ pointerEvents: 'none', userSelect: 'none' }}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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: {} };
Expand Down
108 changes: 108 additions & 0 deletions frontend/src/components/__tests__/mapConditions.test.tsx
Original file line number Diff line number Diff line change
@@ -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) => <div>{children}</div> }));
vi.mock('../../HealthBar', () => ({ HealthBar: () => <div data-testid="health-bar" /> }));

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(<EnemyRhombus location={loc(1, 'enemy_rhombus', six)} token="" {...common()} />);
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(<>
<EnemyRhombus location={loc(1, 'enemy_rhombus', [{ id: 'prone' }])} token="" {...common()} />
<FriendlyRhombus location={loc(2, 'friendly_rhombus', [{ id: 'glitching' }])} token="" userName="vex" {...common()} />
<PlayerRhombus location={loc(3, 'rhombus', [{ id: 'bleeding', left: 2 }])} token="" userName="vex" activeUsers={[]} {...common()} />
</>);
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(<>
<EnemyRhombus location={loc(1, 'enemy_rhombus', [])} token="" {...common()} />
<EnemyRhombus location={loc(2, 'enemy_rhombus', [{ id: 'hungry' }])} token="" {...common()} />
</>);
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) => <EnemyRhombus key={id} location={loc(id, 'enemy_rhombus', [{ id: 'prone' }])} token="" {...common()} />)}
</>);
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(<EnemyRhombus location={loc(1, 'enemy_rhombus', [{ id: 'prone' }])} token="" {...common()} />);
await waitFor(() => expect(asked).toHaveLength(1));
expect(screen.queryByTestId('map-conditions')).toBeNull();
unmount();
fail = false;
render(<EnemyRhombus location={loc(1, 'enemy_rhombus', [{ id: 'prone' }])} token="" {...common()} />);
expect(await screen.findByTestId('map-conditions')).toBeTruthy();
expect(asked).toHaveLength(2);
});
});
Loading
Loading