diff --git a/README.md b/README.md index cb3ec6c9..6f008581 100644 --- a/README.md +++ b/README.md @@ -560,7 +560,8 @@ CITY_NET/ │ │ │ ├── PharmaSection.tsx # What is currently in the bloodstream, drawn in the sheet HEADER rather than a tab: a drug that wears off at the end of a scene and bills System Strain for it is not something to hide behind a tab somebody might not open. Draws nothing at all while a character is on nothing │ │ │ ├── XpWindow.tsx # AWARD_EXPERIENCE — points each rather than a pot to divide, with LEVEL_UP and LEVEL_DOWN for correcting a level on purpose │ │ │ ├── HitPoints.tsx # The HEALTH folder's two bodies: HitPointsPanel changes health (own token, or any for the GM) under a live heart monitor, with injuries and STIM_HEAL (CWN); HealthReviewPanel only watches someone else's - the monitor, a stun bar and the injury map, never a number - with STABILIZE for an ally on a mortal wound -│ │ │ ├── healthBands.ts # How hurt, as a band the heart monitor draws: its color and its rhythm (steady over half, twice as fast at half or less, fast, uneven and weakening at a quarter or less, flatline when down), on the same thresholds, for every system and the stream overlay +│ │ │ ├── healthBands.ts # How hurt, as a band the heart monitor draws: its color and its rhythm (steady over half, twice as fast at half or less, fast, uneven and weakening at a quarter or less, flatline when down), on the same thresholds, for every system and the stream overlay; harm levels read their worst level instead +│ │ │ ├── HealthModelPanels.tsx # The HEALTH folder under a custom system's health model (not one pool): each model's editor (track, damage type, harm level and hit location pickers, harm notes, wound pips and penalty, the GM's SET rows) saying what happened, and what other players see instead of numbers; HitPoints.tsx keeps the monitor, injury map and TEMP_HP around it │ │ │ ├── BankWindows.tsx # Player bank UI; the candle chart is nudged by balance changes on a log scale, so a fortune is a tall candle rather than a spike that flattens the rest │ │ │ ├── ChatWindow.tsx # In-game chat │ │ │ ├── DiceTray.tsx # Dice roller; SR6 pool results show a pulsing GLITCH / CRITICAL GLITCH banner; initiative rolls appear with full breakdown; `sidesForKey` picks the 3D shape (custom dice key results by name and carry their side count in `diceSides`) @@ -634,6 +635,7 @@ CITY_NET/ │ │ │ ├── DraggableWindow.test.tsx │ │ │ ├── BootScreen.test.tsx # Types out by itself and finishes, skips on SKIP and nothing else, reports lines and clicks │ │ │ ├── HitPoints.test.tsx +│ │ │ ├── HealthModelPanels.test.tsx # Each model's editor sending what the server expects and reporting it (spills, boxes turning heavier, harm moving up, out, refusals), the GM-only SET rows, other players seeing no numbers or notes, and the built-in systems' folder untouched and never asking │ │ │ ├── healthBands.test.tsx # The bands' thresholds and colors, each rhythm's beats (the steady one unchanged, beats inside their stretch, weak ones drawn smaller), reduced motion, and the same band in the editing panel, the review panel and the stream │ │ │ ├── MapElements.test.tsx │ │ │ ├── MeasurementTool.test.tsx @@ -698,6 +700,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 +│ │ │ ├── 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 │ │ │ ├── useEnemyVehicles.ts # The GM's enemy vehicles, and the tokens on the map level that could fill their seats. Asked for rather than pushed, and refused to anyone but the GM — so a player's client never holds enemy pools or armour at all, which is what keeps "what may players see" from being a question the feature has to answer diff --git a/backend/__tests__/system_builder_health.test.js b/backend/__tests__/system_builder_health.test.js index 290a0aa5..c5156508 100644 --- a/backend/__tests__/system_builder_health.test.js +++ b/backend/__tests__/system_builder_health.test.js @@ -143,6 +143,13 @@ describe('hit locations', () => { expect(applyHealthAction(LOCATIONS, token(7, 10), {}, damage(1)).sheetPatch).toEqual({}); }); + it('clears a location\'s note with a heal of nothing, leaving the pool, but wants an amount otherwise', () => { + expect(applyHealthAction(LOCATIONS, token(7, 10), { head: 'Graze' }, heal(0, { location: 'head' }))) + .toMatchObject({ ok: true, token: token(7, 10), sheetPatch: { head: '' } }); + expect(applyHealthAction(LOCATIONS, token(7, 10), {}, heal(0))).toEqual({ ok: false, error: 'An amount above 0' }); + expect(applyHealthAction(LOCATIONS, token(7, 10), {}, damage(0, { location: 'head' }))).toEqual({ ok: false, error: 'An amount above 0' }); + }); + it('clears the location it heals, and knows its own locations', () => { expect(applyHealthAction(LOCATIONS, token(7, 10), { head: 'Graze' }, heal(2, { location: 'head' }))) .toMatchObject({ token: token(9, 10), sheetPatch: { head: '' } }); diff --git a/backend/systemBuilder/health.js b/backend/systemBuilder/health.js index 06eec5c5..50d12132 100644 --- a/backend/systemBuilder/health.js +++ b/backend/systemBuilder/health.js @@ -179,8 +179,10 @@ const applyHealthAction = (health, token, sheet, action) => { // A second track's maximum lives on the sheet; every other maximum is the token's own. if (action.kind === 'set_max' && health.model !== 'tracks') return fail('This maximum is set on the token'); const amount = whole(action.amount); - // Harm is a note, not a number; every other model needs an amount. - if (health.model !== 'harm' && amount <= 0) return fail('An amount above 0'); + // Harm is a note, not a number; every other model needs an amount. Except clearing a hit + // location's note, which is a heal of nothing to the pool. + const clearingLocation = health.model === 'locations' && action.kind === 'heal' && action.location !== undefined; + if (health.model !== 'harm' && amount <= 0 && !clearingLocation) return fail('An amount above 0'); const a = { ...action, amount }; const t = { current: num(token && token.current), max: num(token && token.max), temp: whole(token && token.temp) }; const s = sheet && typeof sheet === 'object' ? sheet : {}; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bcd6c079..04ac256a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2502,6 +2502,7 @@ function App() { token={token} refreshLocations={fetchLocations} gameSystem={gameSystem} + socket={socketRef.current} onCreate={selectedLocation.id === -1 && selectedLocation.owner ? () => createPlayerTokenRow({ fetch: (url, init) => fetch(url, init), authToken: token, refreshLocations: fetchLocations }, selectedLocation.owner as string) : undefined} diff --git a/frontend/src/components/HealthModelPanels.tsx b/frontend/src/components/HealthModelPanels.tsx new file mode 100644 index 00000000..2738df36 --- /dev/null +++ b/frontend/src/components/HealthModelPanels.tsx @@ -0,0 +1,304 @@ +import React, { useState } from 'react'; +import type { Location } from '../types'; +import type { HealthView } from '../hooks/useHealthView'; +import { bandOf, harmBand, type HealthBand } from './healthBands'; + +// The HEALTH folder under a custom system's health model, as in the approved mockup +// (docs/mockups/health-windows.html). HitPoints.tsx keeps everything around it - the heart +// monitor, the injury map, STIM_HEAL for CWN - and the built-in systems and a custom one-pool +// system never come here. What each model does is the server's (systemBuilder/health.js); +// this only asks and shows. + +/** Send a health action; resolves with whether it worked and what the server said. */ +export type SendHealth = (action: string, extra?: Record) => Promise<{ ok: boolean; body: any }>; + +/** The monitor's band: from the token's HP, except harm, which has no pool and reads its worst level. */ +export const monitorBandFor = (view: HealthView, target: Pick): HealthBand => (view.model === 'harm' + ? harmBand(view.worst ?? -1, view.levels?.length ?? view.levelCount ?? 0, !!view.out) + : bandOf(target.hp_current ?? 0, target.hp_max ?? 0)); + +const label: React.CSSProperties = { fontSize: '0.6rem', letterSpacing: '2px', opacity: 0.75 }; +const readout: React.CSSProperties = { fontSize: '2rem', color: 'var(--green)', textShadow: 'var(--glow)', fontWeight: 'bold', textAlign: 'center' }; +const word: React.CSSProperties = { textAlign: 'center', fontSize: '0.72rem', letterSpacing: '2px' }; + +function Picker({ name, items, picked, onPick }: { name: string; items: { id: string; label: string }[]; picked: string; onPick: (id: string) => void }) { + return ( +
+ {items.map((it, i) => ( + + ))} +
+ ); +} + +function Meter({ fill, full, heavy }: { fill: number; full?: boolean; heavy?: number }) { + return ( +
+
+ {heavy !== undefined &&
} +
+ ); +} + +function SetRow({ name, value, onSet }: { name: string; value: number; onSet: (n: number) => void }) { + const [n, setN] = useState(0); + return ( +
+ +
+ setN(parseInt(e.target.value, 10) || 0)} style={{ flex: 1, minWidth: 0 }} /> + +
+
+ ); +} + +/** + * The editing half of the HEALTH folder for a custom model: pickers, the model's own readout, + * the buttons, and a line saying what happened. `gm` shows the maximum's SET row, as the + * built-in panel shows MAX_HP only to the GM. + */ +export function ModelHealthEditor({ view, target, send, gm }: { view: HealthView; target: Location; send: SendHealth; gm: boolean }) { + const [amount, setAmount] = useState(0); + const [note, setNote] = useState(''); + const [picked, setPicked] = useState(() => (view.model === 'tracks' ? view.tracks?.[0]?.id + : view.model === 'typed' ? view.types?.[0]?.id + : view.model === 'harm' ? view.levels?.[0]?.id + : '') ?? ''); + const [message, setMessage] = useState<{ text: string; bad: boolean } | null>(null); + + const say = (text: string, bad = false) => setMessage({ text, bad }); + const run = async (action: string, extra: Record, onOk: (body: any) => string) => { + const res = await send(action, extra); + if (!res.ok) return say(res.body?.error || 'Could not change health', true); + const text = onOk(res.body || {}); + return text ? say(text, !!res.body?.out) : setMessage(null); + }; + const cur = target.hp_current ?? 0; + const max = target.hp_max ?? 0; + const status =
{message?.text ?? ''}
; + const amountInput = ( + setAmount(parseInt(e.target.value, 10) || 0)} style={{ width: '100%', boxSizing: 'border-box' }} /> + ); + const buttons = (onHeal: () => void, onDamage: () => void, damageLabel = 'DAMAGE') => ( +
+ + +
+ ); + + if (view.model === 'tracks' && view.tracks && view.second) { + const [first, second] = view.tracks; + const s = view.second; + const secondFull = (s.max ?? 0) > 0 && (s.current ?? 0) >= (s.max ?? 0); + const act = (kind: 'heal' | 'damage') => run(kind, { amount, track: picked }, (b) => (b.overflow ? `${second.label} FULL · ${b.overflow} SPILLED INTO ${first.label}` : '')); + return ( + <> +
{cur} / {max}
{first.label}
+
+
+ {second.label}{s.current ?? 0} / {s.max ?? 0}{view.overflow ? ` · OVERFLOW → ${first.label}` : ''} +
+ 0 ? (s.current ?? 0) / (s.max ?? 1) : 0} full={secondFull} /> +
+ + {status} + {amountInput} + {buttons(() => act('heal'), () => act('damage'))} + {gm && ( + run('set_max', picked === second.id ? { track: second.id, amount: n } : { hp_max: n }, () => '')} /> + )} + + ); + } + + if (view.model === 'typed' && view.types) { + const types = view.types; + const boxes = view.boxes ?? max; + // Heaviest first, then lighter, then clear: / for the lightest, X for the heaviest. + const glyphs: { glyph: string; kind: number }[] = []; + types.map((_, i) => i).reverse().forEach((i) => { for (let n = 0; n < types[i].marks; n += 1) glyphs.push({ glyph: i === types.length - 1 ? 'X' : i === 0 ? '/' : '\\', kind: i }); }); + while (glyphs.length < boxes) glyphs.push({ glyph: '', kind: -1 }); + const act = (kind: 'heal' | 'damage') => run(kind, { amount, type: picked }, (b) => (b.out ? 'EVERY BOX TAKEN · OUT OF ACTION' + : b.turned ? `TRACK FULL · ${b.turned} BOX${b.turned > 1 ? 'ES' : ''} TURNED HEAVIER` : '')); + return ( + <> +
+ {glyphs.slice(0, boxes).map((g, i) => { + const heavy = g.kind === types.length - 1; + const color = g.kind < 0 ? 'var(--green)' : heavy ? 'var(--danger)' : 'var(--warning)'; + return {g.glyph}; + })} +
+
{Math.max(0, boxes - types.reduce((a, t) => a + t.marks, 0))} OF {boxes} BOXES CLEAR
+ + {status} + {amountInput} + {buttons(() => act('heal'), () => act('damage'))} + {gm && run('set_max', { hp_max: n }, () => '')} />} + + ); + } + + if (view.model === 'harm' && view.levels) { + const levels = view.levels; + const labelOf = (id: string) => levels.find((l) => l.id === id)?.label ?? id; + const take = () => run('damage', { level: picked, note }, (b) => { + setNote(''); + if (b.out) return `NO ROOM PAST ${levels[levels.length - 1].label} · OUT OF ACTION`; + return b.placed && b.placed !== picked ? `${labelOf(picked)} FULL · MOVED UP TO ${labelOf(b.placed)}` : ''; + }); + return ( + <> +
+ {levels.map((l) => ( +
+
{l.label}
{l.penalty}
+
+ {l.slots.map((text, i) => ( +
+ {text || '—'} + {text && ( + + )} +
+ ))} +
+
+ ))} +
+ + setNote(e.target.value)} style={{ width: '100%', boxSizing: 'border-box' }} /> + {status} + + + ); + } + + if (view.model === 'wounds') { + const act = (kind: 'heal' | 'damage') => run(kind, { amount }, (b) => (b.out ? 'OUT OF WOUNDS · INCAPACITATED' : '')); + return ( + <> +
+ {Array.from({ length: max }, (_, i) => ( + = cur ? 'var(--danger)' : 'var(--green)'}`, background: i >= cur ? 'var(--danger)' : 'transparent' }} /> + ))} +
+
+ {cur} OF {max} WOUNDS LEFT + PENALTY {view.penalty ?? 0} +
+ {status} + {amountInput} + {buttons(() => act('heal'), () => act('damage'), 'TAKE WOUND')} + {gm && run('set_max', { hp_max: n }, () => '')} />} + + ); + } + + if (view.model === 'locations' && view.locations) { + const act = (kind: 'heal' | 'damage') => run(kind, { amount, ...(picked ? { location: picked } : {}), ...(note ? { note } : {}) }, () => { setNote(''); return ''; }); + return ( + <> +
{cur} / {max}
HP
+
+ {view.locations.map((l) => ( +
+
+ {l.label} + {l.note && } +
+ {l.note &&
{l.note}
} +
+ ))} +
+ + setNote(e.target.value)} style={{ width: '100%', boxSizing: 'border-box' }} /> + {status} + {amountInput} + {buttons(() => act('heal'), () => act('damage'))} + {gm && run('set_max', { hp_max: n }, () => '')} />} + + ); + } + + return null; +} + +/** + * What another player sees of a custom model, under the heart monitor: never a number or a + * note. A second track as a fill, damage as light against heavy, the worst harm's name, + * WOUNDED, which locations are hurt. + */ +export function ModelHealthDescription({ view, target }: { view: HealthView; target: Pick }) { + if (view.model === 'tracks' && view.second && view.tracks) { + const s = view.second; + const fill = s.fill ?? ((s.max ?? 0) > 0 ? (s.current ?? 0) / (s.max ?? 1) : 0); + const full = s.full ?? ((s.max ?? 0) > 0 && (s.current ?? 0) >= (s.max ?? 0)); + return ( +
+
+ {s.label}{full ? (view.overflow ? ` — FULL · OVERFLOW → ${view.tracks[0].label}` : ' — FULL') : ''} +
+ +
+ ); + } + if (view.model === 'typed') { + const boxes = view.boxes ?? 0; + const marks = view.types?.map((t) => t.marks) ?? []; + const light = view.light ?? (boxes > 0 ? marks.slice(0, -1).reduce((a, b) => a + b, 0) / boxes : 0); + const heavy = view.heavy ?? (boxes > 0 ? (marks[marks.length - 1] ?? 0) / boxes : 0); + return ( +
+
+ DAMAGE TAKENLIGHT · HEAVY +
+ +
+ ); + } + if (view.model === 'harm') { + const worst = view.worstLabel !== undefined ? view.worstLabel : (view.worst ?? -1) >= 0 ? view.levels?.[view.worst as number]?.label ?? null : null; + const band = monitorBandFor(view, target); + return
{view.out ? 'OUT OF ACTION' : worst ? `${worst} HARM` : 'UNHARMED'}
; + } + if (view.model === 'wounds') { + const state = view.state ?? ((target.hp_current ?? 0) <= 0 ? 'incapacitated' : (target.hp_current ?? 0) < (target.hp_max ?? 0) ? 'wounded' : 'unhurt'); + return
{state.toUpperCase()}
; + } + if (view.model === 'locations' && view.locations) { + return ( +
+ {view.locations.map((l) => { + const hit = l.hit ?? !!l.note; + return
{l.label}
; + })} +
+ ); + } + return null; +} + +/** For a system that tracks harm as conditions: in place of the monitor and the buttons. */ +export function NoHealthNotice({ editing }: { editing: boolean }) { + return ( +
+ {editing ? 'THIS SYSTEM TRACKS HARM AS CONDITIONS, NOT HEALTH.' : 'NO HEALTH TRACKED.'} +
{editing ? 'Conditions arrive with the builder\'s conditions piece.' : 'Their conditions will show here.'}
+
+ ); +} diff --git a/frontend/src/components/HitPoints.tsx b/frontend/src/components/HitPoints.tsx index 04cec150..c92da4d8 100644 --- a/frontend/src/components/HitPoints.tsx +++ b/frontend/src/components/HitPoints.tsx @@ -1,6 +1,9 @@ import React, { useState, useEffect } from 'react'; import type { Location } from '../types'; import { bandOf, BAND_COLOR, BAND_WORDS, RHYTHM, beatPoints, type HealthBand } from './healthBands'; +import { useHealthView } from '../hooks/useHealthView'; +import { isCustomSystem } from '../sheets'; +import { ModelHealthEditor, ModelHealthDescription, NoHealthNotice, monitorBandFor, type SendHealth } from './HealthModelPanels'; // Inline SVGs so we can tint them with CSS `color` (currentColor) export const PersonSVG = ({ color = 'currentColor', style }: { color?: string; style?: React.CSSProperties }) => ( @@ -145,9 +148,11 @@ interface HitPointsPanelProps { gameSystem?: string; /** With no token on the map yet, the panel offers to make one - health needs a row. */ onCreate?: () => Promise | void; + /** For a custom system's health model: the detail comes over the socket (useHealthView). */ + socket?: any; } -export function HitPointsPanel({ target, token, refreshLocations, gameSystem, onCreate }: HitPointsPanelProps) { +export function HitPointsPanel({ target, token, refreshLocations, gameSystem, onCreate, socket }: HitPointsPanelProps) { const [actionAmount, setActionAmount] = useState(0); const [tempAmount, setTempAmount] = useState(0); const [maxAmount, setMaxAmount] = useState(0); @@ -155,6 +160,9 @@ export function HitPointsPanel({ target, token, refreshLocations, gameSystem, on const [injuries, setInjuries] = useState({}); const [healMsg, setHealMsg] = useState(null); const rawInjuries = (target as any)?.injuries; + // A custom system's health model, as the server lets this viewer see it. Null for the + // built-in systems, which keep the panel below exactly as it was. + const view = useHealthView(socket, target?.id, isCustomSystem(gameSystem)); useEffect(() => { setInjuries(parseInjuries(rawInjuries)); }, [target?.id, rawInjuries]); @@ -199,6 +207,60 @@ export function HitPointsPanel({ target, token, refreshLocations, gameSystem, on refreshLocations(); }; + /** A custom model's action, with what the server said back for the panel to report. */ + const sendHealth: SendHealth = async (action, extra = {}) => { + const res = await fetch(`/api/locations/${target.id}/health`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ action, ...extra }), + }); + const body = await res.json().catch(() => null); + refreshLocations(); + return { ok: res.ok, body }; + }; + + const injuryMap = injuriesOpen && ( + saveInjuries({ ...injuries, [key]: !injuries[key] })} + onClear={() => saveInjuries({})} + /> + ); + const tempRow = ( +
+ +
+ { let val = parseInt(e.target.value) || 0; if (val > 100) val = 100; setTempAmount(val); }} + style={{ flex: 1, minWidth: 0 }} + /> + +
+
+ ); + + // A custom model other than one pool: its own editor, as in the approved mockup. The heart + // monitor, the injury map and (where the model has a pool) TEMP_HP stay as everywhere else. + if (view && view.model !== 'pool') { + return ( +
+ {view.model === 'none' + ? + : } +
+ setInjuriesOpen((o) => !o)} size={18} /> +
+ {injuryMap} + + {(view.model === 'tracks' || view.model === 'locations') && ( +
{tempRow}
+ )} +
+ ); + } + return (
@@ -209,18 +271,16 @@ export function HitPointsPanel({ target, token, refreshLocations, gameSystem, on
setInjuriesOpen((o) => !o)} size={18} />
+ {/* A custom one-pool system's own word for health. */} + {view && view.label && ( +
{view.label}
+ )} {(target.hp_temp ?? 0) > 0 && (
+ {target.hp_temp} TEMP
)}
- {injuriesOpen && ( - saveInjuries({ ...injuries, [key]: !injuries[key] })} - onClear={() => saveInjuries({})} - /> - )} + {injuryMap}
setActionAmount(parseInt(e.target.value) || 0)} style={{ width: '100%', boxSizing: 'border-box' }} /> @@ -255,18 +315,7 @@ export function HitPointsPanel({ target, token, refreshLocations, gameSystem, on
)} -
- -
- { let val = parseInt(e.target.value) || 0; if (val > 100) val = 100; setTempAmount(val); }} - style={{ flex: 1, minWidth: 0 }} - /> - -
-
+ {tempRow} ); @@ -327,6 +376,8 @@ interface HealthReviewPanelProps { export function HealthReviewPanel({ location, socket, gameSystem, onRolled }: HealthReviewPanelProps) { const [injuriesOpen, setInjuriesOpen] = useState(false); const [stun, setStun] = useState<{ stun_current: number; stun_monitor: number } | null>(null); + // A custom system's health model, described: never a number or a note. + const view = useHealthView(socket, location.id, isCustomSystem(gameSystem)); // SR6: second damage track - Stun lives on the sheet backing this token. // Refetches on sheet/token updates so damage shows live. @@ -366,7 +417,12 @@ export function HealthReviewPanel({ location, socket, gameSystem, onRolled }: He {/* Heart monitor — flatlines at 0 HP */} - + {view?.model === 'none' + ? + : } + + {/* A custom model's own description: a second track, light and heavy, the worst harm... */} + {view && } {/* SR6 Stun track (Physical is the token HP above) */} {stun && (() => { diff --git a/frontend/src/components/__tests__/HealthModelPanels.test.tsx b/frontend/src/components/__tests__/HealthModelPanels.test.tsx new file mode 100644 index 00000000..5f7fc2a0 --- /dev/null +++ b/frontend/src/components/__tests__/HealthModelPanels.test.tsx @@ -0,0 +1,283 @@ +import React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor, act } from '@testing-library/react'; +import { HitPointsPanel, HealthReviewPanel } from '../HitPoints'; +import type { HealthView } from '../../hooks/useHealthView'; + +/** + * The HEALTH folder under a custom system's health model, as in the approved mockup + * (docs/mockups/health-windows.html): each model's editor sends what the server expects and + * says what happened; other players get a description with no numbers or notes; the built-in + * systems keep the folder exactly as it was. + */ + +const SYSTEM = 'sys_0123456789abcdef'; + +/** A socket that answers requestHealthView with the view given for that token. */ +const fakeSocket = (views: Record>) => { + const handlers: Record void)[]> = {}; + const emitted: { e: string; d: any }[] = []; + return { + emitted, + on: (e: string, fn: (d: any) => void) => { (handlers[e] ||= []).push(fn); }, + off: (e: string, fn: (d: any) => void) => { handlers[e] = (handlers[e] || []).filter((f) => f !== fn); }, + emit: (e: string, d: any) => { + emitted.push({ e, d }); + if (e === 'requestHealthView') { + const v = views[d.location_id] ?? { model: null }; + queueMicrotask(() => (handlers.healthView || []).forEach((fn) => fn({ location_id: d.location_id, ...v }))); + } + }, + fire: (e: string, d?: any) => act(() => { (handlers[e] || []).forEach((fn) => fn(d)); }), + listening: (e: string) => (handlers[e] || []).length, + }; +}; + +/** fetch that answers the health route with `reply`, and remembers what it was sent. */ +const stubFetch = (reply: { ok?: boolean; body?: any } = {}) => { + const sent: any[] = []; + vi.stubGlobal('fetch', vi.fn(async (_url: string, init?: RequestInit) => { + if (init?.body) sent.push(JSON.parse(String(init.body))); + return { ok: reply.ok ?? true, status: reply.ok === false ? 400 : 200, json: async () => reply.body ?? {} } as Response; + })); + return sent; +}; + +const token = (hp: number, max: number, extra = {}) => ({ id: 7, name: 'Razor', x: 0, y: 0, z: 0, shape: 'rhombus', owner: 'RAZOR', hp_current: hp, hp_max: max, hp_temp: 0, ...extra }) as never; + +const editor = (view: Partial, t = token(8, 10), gm = false) => { + const socket = fakeSocket({ 7: view }); + render( {}} gameSystem={SYSTEM} socket={socket} />); + return socket; +}; +const review = (view: Partial, t = token(8, 10)) => { + const socket = fakeSocket({ 7: view }); + const utils = render(); + return { socket, ...utils }; +}; + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +const TRACKS_FULL: Partial = { + model: 'tracks', full: true, overflow: true, + tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }], + second: { id: 'stun', label: 'STUN', current: 6, max: 9 }, +}; + +describe('asking the server', () => { + it('asks about this token, and again whenever a sheet or the map changes', async () => { + const socket = editor(TRACKS_FULL); + await screen.findByText('6 / 9 · OVERFLOW → PHYSICAL'); + expect(socket.emitted.filter((x) => x.e === 'requestHealthView')).toEqual([{ e: 'requestHealthView', d: { location_id: 7 } }]); + await socket.fire('sheetUpdated', { username: 'RAZOR' }); + await socket.fire('dataUpdated', {}); + expect(socket.emitted.filter((x) => x.e === 'requestHealthView')).toHaveLength(3); + }); + + it('ignores a view of another token, and stops listening when closed', async () => { + const socket = fakeSocket({ 7: TRACKS_FULL }); + const { unmount } = render( {}} gameSystem={SYSTEM} socket={socket} />); + await screen.findByRole('button', { name: 'PHYSICAL' }); + await socket.fire('healthView', { location_id: 99, model: 'none' }); + expect(screen.queryByText(/TRACKS HARM AS CONDITIONS/)).toBeNull(); + unmount(); + expect(socket.listening('healthView') + socket.listening('sheetUpdated') + socket.listening('dataUpdated')).toBe(0); + }); + + it('leaves a built-in system\'s folder exactly as it was, without asking', async () => { + const socket = fakeSocket({}); + render( {}} gameSystem="cities_without_number" socket={socket} />); + expect(screen.getByText('8 / 10')).toBeTruthy(); + expect(screen.getByText('STIM_HEAL (+1 STRAIN)')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'DAMAGE' })).toBeTruthy(); + expect(socket.emitted).toEqual([]); + }); +}); + +describe('the editor', () => { + it('one pool: today\'s folder, with the system\'s own word for health', async () => { + editor({ model: 'pool', full: true, label: 'VIGOR' }); + expect(await screen.findByText('VIGOR')).toBeTruthy(); + expect(screen.getByText('8 / 10')).toBeTruthy(); + expect(screen.getByRole('button', { name: 'DAMAGE' })).toBeTruthy(); + }); + + it('two tracks: damage goes to the track picked, and a spill is reported', async () => { + const sent = stubFetch({ body: { overflow: 3, out: false } }); + editor(TRACKS_FULL); + await screen.findByText('6 / 9 · OVERFLOW → PHYSICAL'); + expect(screen.getByText('8 / 10')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'STUN' })); + fireEvent.change(screen.getByLabelText('Amount'), { target: { value: '5' } }); + fireEvent.click(screen.getByRole('button', { name: 'DAMAGE' })); + await waitFor(() => expect(sent).toEqual([{ action: 'damage', amount: 5, track: 'stun' }])); + expect(await screen.findByText('STUN FULL · 3 SPILLED INTO PHYSICAL')).toBeTruthy(); + expect(screen.getByLabelText('Temp HP')).toBeTruthy(); + }); + + it('two tracks: the GM sets the picked track\'s maximum, and a player gets no SET', async () => { + const sent = stubFetch(); + editor(TRACKS_FULL, token(8, 10), true); + await screen.findByLabelText('MAX PHYSICAL'); + fireEvent.change(screen.getByLabelText('MAX PHYSICAL'), { target: { value: '12' } }); + fireEvent.click(screen.getAllByRole('button', { name: 'SET' })[0]); + fireEvent.click(screen.getByRole('button', { name: 'STUN' })); + fireEvent.change(screen.getByLabelText('MAX STUN'), { target: { value: '7' } }); + fireEvent.click(screen.getAllByRole('button', { name: 'SET' })[0]); + await waitFor(() => expect(sent).toEqual([{ action: 'set_max', hp_max: 12 }, { action: 'set_max', track: 'stun', amount: 7 }])); + cleanup(); + editor(TRACKS_FULL); + await screen.findByRole('button', { name: 'PHYSICAL' }); + expect(screen.queryByLabelText(/^MAX /)).toBeNull(); + }); + + it('damage types: boxes heaviest first, the type picked, and boxes turning heavier reported', async () => { + const sent = stubFetch({ body: { turned: 2, out: false } }); + editor({ model: 'typed', full: true, boxes: 5, types: [{ id: 'superficial', label: 'SUPERFICIAL', marks: 2 }, { id: 'aggravated', label: 'AGGRAVATED', marks: 1 }] }, token(2, 5)); + const boxes = await screen.findByRole('img', { name: 'Health boxes' }); + expect([...boxes.children].map((b) => b.textContent)).toEqual(['X', '/', '/', '', '']); + expect(screen.getByText('2 OF 5 BOXES CLEAR')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'AGGRAVATED' })); + fireEvent.change(screen.getByLabelText('Amount'), { target: { value: '3' } }); + fireEvent.click(screen.getByRole('button', { name: 'DAMAGE' })); + await waitFor(() => expect(sent).toEqual([{ action: 'damage', amount: 3, type: 'aggravated' }])); + expect(await screen.findByText('TRACK FULL · 2 BOXES TURNED HEAVIER')).toBeTruthy(); + expect(screen.queryByLabelText('Temp HP')).toBeNull(); + }); + + const HARM_FULL: Partial = { + model: 'harm', full: true, worst: 1, out: false, + levels: [ + { id: 'lesser', label: 'LESSER', penalty: 'Reduced effect', slots: ['Bruised ribs', ''] }, + { id: 'moderate', label: 'MODERATE', penalty: '-1d', slots: ['Twisted knee', 'Cut'] }, + { id: 'severe', label: 'SEVERE', penalty: 'Need help', slots: [''] }, + ], + }; + + it('harm levels: the notes and penalties, harm written at the level picked, and a move up reported', async () => { + const sent = stubFetch({ body: { placed: 'severe', out: false } }); + editor(HARM_FULL, token(2, 5)); + expect(await screen.findByText('Twisted knee')).toBeTruthy(); + expect(screen.getByText('-1d')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'MODERATE' })); + fireEvent.change(screen.getByLabelText('Harm note'), { target: { value: 'Broken arm' } }); + fireEvent.click(screen.getByRole('button', { name: 'TAKE HARM' })); + await waitFor(() => expect(sent).toEqual([{ action: 'damage', level: 'moderate', note: 'Broken arm' }])); + expect(await screen.findByText('MODERATE FULL · MOVED UP TO SEVERE')).toBeTruthy(); + expect((screen.getByLabelText('Harm note') as HTMLInputElement).value).toBe(''); + }); + + it('harm levels: × clears that slot, and out and refusals are said in red', async () => { + const sent = stubFetch({ body: { out: true } }); + editor(HARM_FULL, token(2, 5)); + fireEvent.click(await screen.findByRole('button', { name: 'Clear Cut' })); + await waitFor(() => expect(sent).toEqual([{ action: 'heal', level: 'moderate', slot: 2 }])); + fireEvent.click(screen.getByRole('button', { name: 'TAKE HARM' })); + const out = await screen.findByText('NO ROOM PAST SEVERE · OUT OF ACTION'); + expect(out.style.color).toBe('var(--danger)'); + cleanup(); + stubFetch({ ok: false, body: { error: 'Not one of this system\'s harm levels' } }); + editor(HARM_FULL, token(2, 5)); + fireEvent.click(await screen.findByRole('button', { name: 'TAKE HARM' })); + expect((await screen.findByText('Not one of this system\'s harm levels')).style.color).toBe('var(--danger)'); + }); + + it('wound count: pips, the penalty, and TAKE WOUND', async () => { + const sent = stubFetch({ body: { out: true } }); + editor({ model: 'wounds', full: true, penalty: -2 }, token(1, 3), true); + const pips = await screen.findByRole('img', { name: 'Wounds left' }); + expect(pips.children).toHaveLength(3); + expect(screen.getByText('1 OF 3 WOUNDS LEFT')).toBeTruthy(); + expect(screen.getByText('PENALTY -2')).toBeTruthy(); + fireEvent.change(screen.getByLabelText('Amount'), { target: { value: '1' } }); + fireEvent.click(screen.getByRole('button', { name: 'TAKE WOUND' })); + await waitFor(() => expect(sent).toEqual([{ action: 'damage', amount: 1 }])); + expect(await screen.findByText('OUT OF WOUNDS · INCAPACITATED')).toBeTruthy(); + expect(screen.getByLabelText('WOUNDS')).toBeTruthy(); + }); + + it('hit locations: where it hit and a note go with the damage, and × clears a location', async () => { + const sent = stubFetch(); + editor({ model: 'locations', full: true, locations: [{ id: 'head', label: 'HEAD', note: '' }, { id: 'body', label: 'BODY', note: 'Graze' }] }, token(9, 15)); + expect(await screen.findByText('Graze')).toBeTruthy(); + fireEvent.change(screen.getByLabelText('Where it hit'), { target: { value: 'head' } }); + fireEvent.change(screen.getByLabelText('Location note'), { target: { value: 'Grazed' } }); + fireEvent.change(screen.getByLabelText('Amount'), { target: { value: '2' } }); + fireEvent.click(screen.getByRole('button', { name: 'DAMAGE' })); + fireEvent.click(screen.getByRole('button', { name: 'Clear BODY' })); + await waitFor(() => expect(sent).toEqual([ + { action: 'damage', amount: 2, location: 'head', note: 'Grazed' }, + { action: 'heal', location: 'body', amount: 0 }, + ])); + expect(screen.getByLabelText('Temp HP')).toBeTruthy(); + }); + + it('none: says so, with no monitor and nothing to press', async () => { + editor({ model: 'none', full: true }); + expect(await screen.findByText('THIS SYSTEM TRACKS HARM AS CONDITIONS, NOT HEALTH.')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'DAMAGE' })).toBeNull(); + expect(screen.queryByRole('img', { name: /Heart monitor/ })).toBeNull(); + // The injury map is still there, as for every system. + expect(screen.getByRole('button', { name: 'Injuries' })).toBeTruthy(); + }); +}); + +describe('what other players see', () => { + /** Nothing another player sees may carry a number. (The monitor's own CSS is not seen.) */ + const noNumbers = (container: HTMLElement) => { + const seen = container.cloneNode(true) as HTMLElement; + seen.querySelectorAll('style').forEach((s) => s.remove()); + expect(seen.textContent).not.toMatch(/\d/); + }; + + it('two tracks: the second as a fill, and FULL with where it spills', async () => { + const { container } = review({ model: 'tracks', full: false, overflow: true, tracks: TRACKS_FULL.tracks, second: { label: 'STUN', fill: 1, full: true } }); + expect(await screen.findByText('STUN — FULL · OVERFLOW → PHYSICAL')).toBeTruthy(); + noNumbers(container); + }); + + it('damage types: light against heavy', async () => { + const { container } = review({ model: 'typed', full: false, light: 0.4, heavy: 0.2 }); + expect((await screen.findByTestId('heavy-fill')).style.width).toBe('20%'); + noNumbers(container); + }); + + it('harm levels: the worst level\'s name, the beat it sets, and out as a flatline', async () => { + // HP alone would read steady here: the beat is the worst harm's, not the HP's. + const { container } = review({ model: 'harm', full: false, levelCount: 3, worst: 1, worstLabel: 'MODERATE', out: false }, token(4, 5)); + expect(await screen.findByText('MODERATE HARM')).toBeTruthy(); + expect(container.querySelector('[data-band]')!.getAttribute('data-band')).toBe('fast'); + noNumbers(container); + cleanup(); + const down = review({ model: 'harm', full: false, levelCount: 3, worst: 2, worstLabel: 'SEVERE', out: true }, token(0, 5)); + expect(await screen.findByText('OUT OF ACTION')).toBeTruthy(); + expect(down.container.querySelector('[data-band]')!.getAttribute('data-band')).toBe('down'); + }); + + it('wound count: a word, never the pips', async () => { + const { container } = review({ model: 'wounds', full: false, state: 'wounded' }, token(1, 3)); + expect(await screen.findByText('WOUNDED')).toBeTruthy(); + expect(screen.queryByRole('img', { name: 'Wounds left' })).toBeNull(); + noNumbers(container); + }); + + it('hit locations: which are hurt, never how', async () => { + const { container } = review({ model: 'locations', full: false, locations: [{ id: 'head', label: 'HEAD', hit: false }, { id: 'body', label: 'BODY', hit: true }] }); + await screen.findByText('BODY'); + expect([...container.querySelectorAll('[data-hit]')].map((d) => d.getAttribute('data-hit'))).toEqual(['no', 'yes']); + noNumbers(container); + }); + + it('none: no monitor at all', async () => { + review({ model: 'none', full: false }); + expect(await screen.findByText('NO HEALTH TRACKED.')).toBeTruthy(); + expect(screen.queryByRole('img', { name: /Heart monitor/ })).toBeNull(); + }); + + it('a built-in system: the folder as it was, without asking', () => { + const socket = fakeSocket({}); + render(); + expect(screen.getByRole('img', { name: /Heart monitor/ })).toBeTruthy(); + expect(socket.emitted.some((x) => x.e === 'requestHealthView')).toBe(false); + }); +}); diff --git a/frontend/src/components/healthBands.ts b/frontend/src/components/healthBands.ts index 1fde336f..5f0ae4a2 100644 --- a/frontend/src/components/healthBands.ts +++ b/frontend/src/components/healthBands.ts @@ -15,6 +15,17 @@ export const bandOf = (current: number, max: number): HealthBand => { return pct > 0.5 ? 'steady' : pct > 0.25 ? 'fast' : 'critical'; }; +/** + * Harm levels have no pool: the band is the worst level taken, in thirds of the levels. + * With three levels, lesser is steady, moderate fast, severe critical; out is the flatline. + */ +export const harmBand = (worst: number, levels: number, out: boolean): HealthBand => { + if (out) return 'down'; + if (worst < 0 || levels <= 0) return 'steady'; + const pct = (worst + 1) / levels; + return pct <= 1 / 3 ? 'steady' : pct <= 2 / 3 ? 'fast' : 'critical'; +}; + export const BAND_COLOR: Record = { steady: 'var(--green)', fast: 'var(--warning)', diff --git a/frontend/src/hooks/useHealthView.ts b/frontend/src/hooks/useHealthView.ts new file mode 100644 index 00000000..3714f45c --- /dev/null +++ b/frontend/src/hooks/useHealthView.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; + +// A token's health under a custom system's model, as the server lets this viewer see it +// (backend/systemBuilder/healthView.js). The GM, a player granted editing and the token's owner +// get the full detail; everyone else a description with no numbers or notes. Asked over the +// socket, which is where the server knows who is asking, and asked again whenever a sheet or +// the map changes, since damage changes both. + +export type HealthModel = 'pool' | 'tracks' | 'typed' | 'harm' | 'wounds' | 'locations' | 'none'; + +export interface HealthView { + location_id: number; + /** Null for a built-in system, which draws its own health. */ + model: HealthModel | null; + full?: boolean; + /** pool: the system's own word for health. */ + label?: string; + /** tracks */ + tracks?: { id: string; label: string }[]; + overflow?: boolean; + second?: { id?: string; label: string; current?: number; max?: number; fill?: number; full?: boolean }; + /** typed: each type's marks in full; light and heavy shares otherwise. */ + boxes?: number; + types?: { id: string; label: string; marks: number }[]; + light?: number; + heavy?: number; + /** harm: the notes in full; the worst level's name otherwise. */ + levels?: { id: string; label: string; penalty: string; slots: string[] }[]; + levelCount?: number; + worst?: number; + worstLabel?: string | null; + out?: boolean; + /** wounds */ + penalty?: number; + state?: 'unhurt' | 'wounded' | 'incapacitated'; + /** locations: the notes in full; only which are hurt otherwise. */ + locations?: { id: string; label: string; note?: string; hit?: boolean }[]; +} + +/** The view of `locationId`'s health, or null while unknown, off, or for a built-in system. */ +export function useHealthView(socket: any, locationId: number | null | undefined, enabled: boolean): HealthView | null { + const [view, setView] = useState(null); + useEffect(() => { + setView(null); + if (!socket || !enabled || locationId === null || locationId === undefined || locationId < 0) return undefined; + const ask = () => socket.emit('requestHealthView', { location_id: locationId }); + const onView = (v: HealthView) => { if (v && v.location_id === locationId) setView(v); }; + socket.on('healthView', onView); + socket.on('sheetUpdated', ask); + socket.on('dataUpdated', ask); + ask(); + return () => { + socket.off('healthView', onView); + socket.off('sheetUpdated', ask); + socket.off('dataUpdated', ask); + }; + }, [socket, locationId, enabled]); + return view && view.model ? view : null; +}