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 @@ -28,6 +28,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
with a search box and arrow keys. Choosing a different system now asks first, since it
changes the game for everyone online.

- **The heart monitor's beat follows how hurt someone is.** Its color always changed as a
character was hurt; now its rhythm does too. It stays steady over half health, beats twice as
fast at half or less, and turns fast, uneven and weak at a quarter or less, before the
flatline. It still shows no numbers, in the HEALTH folder and on the stream overlay alike. With
reduced motion turned on in the system settings, the trace holds still but keeps its shape.

### Security

- **Players can no longer use the GM's tools.** A player's own login worked as a key to the GM's
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ 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
│ │ │ ├── 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`)
Expand Down Expand Up @@ -631,6 +632,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
│ │ │ ├── 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
│ │ │ ├── SignRotation.test.tsx # LAY_FLAT / STAND_UP presets, per-axis sliders, all three axes reaching the PATCH body
Expand Down
33 changes: 17 additions & 16 deletions frontend/src/components/HitPoints.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import type { Location } from '../types';
import { bandOf, BAND_COLOR, BAND_WORDS, RHYTHM, beatPoints, type HealthBand } from './healthBands';
// Inline SVGs so we can tint them with CSS `color` (currentColor)
export const PersonSVG = ({ color = 'currentColor', style }: { color?: string; style?: React.CSSProperties }) => (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill={color} style={style}>
Expand Down Expand Up @@ -200,7 +201,7 @@ export function HitPointsPanel({ target, token, refreshLocations, gameSystem, on

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', textAlign: 'left' }}>
<HeartMonitor color={hpColorFor(target.hp_current ?? 0, target.hp_max ?? 0)} flatline={(target.hp_current ?? 0) <= 0} />
<HeartMonitor band={bandOf(target.hp_current ?? 0, target.hp_max ?? 0)} />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '10px' }}>
<div style={{ fontSize: '2rem', color: 'var(--green)', textShadow: 'var(--glow)', fontWeight: 'bold' }}>
Expand Down Expand Up @@ -273,39 +274,40 @@ export function HitPointsPanel({ target, token, refreshLocations, gameSystem, on

// ─── Looking at someone else's health ───────────────────────────────────────

export function HeartMonitor({ color, flatline }: { color: string; flatline: boolean }) {
/**
* The heart monitor: how badly hurt, as a picture. Its color and its rhythm both come from
* the band (healthBands.ts), so the beat quickens and falters as the color turns. With reduced
* motion the trace holds still, keeping the shape of its rhythm.
*/
export function HeartMonitor({ band }: { band: HealthBand }) {
const color = BAND_COLOR[band];
return (
<div style={{ width: '100%', height: '50px', overflow: 'hidden', background: 'var(--black)', borderRadius: '3px', border: '1px solid var(--dark-green)' }}>
<div role="img" aria-label={`Heart monitor: ${BAND_WORDS[band]}`} data-band={band}
style={{ width: '100%', height: '50px', overflow: 'hidden', background: 'var(--black)', borderRadius: '3px', border: '1px solid var(--dark-green)' }}>
<style>{`
@keyframes ekg-scroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }
.ekg-live { animation: ekg-scroll 2.4s linear infinite; width: 200%; display: block; }
.ekg-dead { width: 100%; display: block; }
@media (prefers-reduced-motion: reduce) { .ekg-live { animation: none; } }
`}</style>
{flatline ? (
{band === 'down' ? (
<svg className="ekg-dead" height="50" viewBox="0 0 280 50"
style={{ filter: `drop-shadow(0 0 2px ${color})` }}>
<line x1="0" y1="25" x2="280" y2="25" stroke={color} strokeWidth="1.5" />
</svg>
) : (
<svg className="ekg-live" height="50" viewBox="0 0 560 50" preserveAspectRatio="none"
style={{ filter: `drop-shadow(0 0 3px ${color})` }}>
<polyline points="0,25 27,25 34,20 42,25 49,25 56,5 60,45 64,5 68,25 80,30 87,25 127,25 280,25"
style={{ animationDuration: `${RHYTHM[band].seconds}s`, filter: `drop-shadow(0 0 3px ${color})` }}>
<polyline points={beatPoints(0, RHYTHM[band].beats)}
fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
<polyline points="280,25 307,25 314,20 322,25 329,25 336,5 340,45 344,5 348,25 360,30 367,25 407,25 560,25"
<polyline points={beatPoints(280, RHYTHM[band].beats)}
fill="none" stroke={color} strokeWidth="1.5" strokeLinejoin="round" />
</svg>
)}
</div>
);
}

/** The heart monitor's color: how badly hurt, without saying by how much. */
export const hpColorFor = (hpCurrent: number, hpMax: number) => {
const pct = hpMax > 0 ? Math.max(0, Math.min(1, hpCurrent / hpMax)) : 0;
if (hpCurrent <= 0) return 'var(--danger)';
return pct > 0.5 ? 'var(--green)' : pct > 0.25 ? 'var(--warning)' : 'var(--danger)';
};

interface HealthReviewPanelProps {
location: Location & { injuries?: string };
/** CWN: any player viewing a downed character can attempt to stabilize
Expand Down Expand Up @@ -356,7 +358,6 @@ export function HealthReviewPanel({ location, socket, gameSystem, onRolled }: He
const injuries = parseInjuries(location.injuries);
const hpCurrent = location.hp_current ?? 0;
const isDead = hpCurrent <= 0;
const hpColor = hpColorFor(hpCurrent, location.hp_max ?? 0);

return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', textAlign: 'left' }}>
Expand All @@ -365,7 +366,7 @@ export function HealthReviewPanel({ location, socket, gameSystem, onRolled }: He
</div>

{/* Heart monitor — flatlines at 0 HP */}
<HeartMonitor color={hpColor} flatline={isDead} />
<HeartMonitor band={bandOf(hpCurrent, location.hp_max ?? 0)} />

{/* SR6 Stun track (Physical is the token HP above) */}
{stun && (() => {
Expand Down
6 changes: 2 additions & 4 deletions frontend/src/components/StreamerOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import type { DirectorState, Location } from '../types';
import { HeartMonitor, PersonSVG, INJURY_ZONES } from './HitPoints';
import { bandOf } from './healthBands';

interface DiceEvent {
id: string;
Expand Down Expand Up @@ -117,17 +118,14 @@ export function StreamerOverlay({ socket, directorState, selectedLocation, battl
{isRhombus && (() => {
const hpCurrent = selectedLocation.hp_current ?? 0;
const hpMax = selectedLocation.hp_max ?? 0;
const isDead = hpCurrent <= 0;
const hpPct = hpMax > 0 ? Math.max(0, Math.min(1, hpCurrent / hpMax)) : 0;
const hpColor = isDead ? 'var(--danger)' : hpPct > 0.5 ? 'var(--green)' : hpPct > 0.25 ? 'var(--warning)' : 'var(--danger)';
const injuries: Record<string, boolean> = (() => {
try { return JSON.parse((selectedLocation as any).injuries || '{}'); } catch { return {}; }
})();
const hasInjuries = Object.values(injuries).some(Boolean);
return (
<>
<div style={{ borderTop: '1px solid #0a2a0a', paddingTop: '8px' }}>
<HeartMonitor color={hpColor} flatline={isDead} />
<HeartMonitor band={bandOf(hpCurrent, hpMax)} />
</div>
{hasInjuries && (
<div style={{ borderTop: '1px solid #0a2a0a', paddingTop: '8px' }}>
Expand Down
122 changes: 122 additions & 0 deletions frontend/src/components/__tests__/healthBands.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import React from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup } from '@testing-library/react';
import { bandOf, BAND_COLOR, RHYTHM, beatPoints } from '../healthBands';
import { HeartMonitor, HealthReviewPanel, HitPointsPanel } from '../HitPoints';
import { StreamerOverlay } from '../StreamerOverlay';

/**
* The heart monitor says how hurt someone is without a number: its color and its rhythm come
* from one band, on the same thresholds, so the beat quickens and falters as the color turns.
*/

afterEach(cleanup);

describe('the band', () => {
it('is steady over half, fast at half or less, critical at a quarter or less, and down at 0', () => {
expect(bandOf(20, 20)).toBe('steady');
expect(bandOf(11, 20)).toBe('steady');
expect(bandOf(10, 20)).toBe('fast');
expect(bandOf(6, 20)).toBe('fast');
expect(bandOf(5, 20)).toBe('critical');
expect(bandOf(1, 20)).toBe('critical');
expect(bandOf(0, 20)).toBe('down');
expect(bandOf(-3, 20)).toBe('down');
});

it('reads no maximum as badly hurt, as the color always has, and more than the maximum as whole', () => {
expect(bandOf(5, 0)).toBe('critical');
expect(bandOf(30, 20)).toBe('steady');
});

it('keeps the colors the monitor has always used', () => {
expect(BAND_COLOR).toEqual({ steady: 'var(--green)', fast: 'var(--warning)', critical: 'var(--danger)', down: 'var(--danger)' });
});
});

describe('the rhythm', () => {
it('over half is the one even beat the monitor has always drawn', () => {
expect(beatPoints(0, RHYTHM.steady.beats)).toBe('0,25 27,25 34,20 42,25 49,25 56,5 60,45 64,5 68,25 80,30 87,25 280,25');
expect(RHYTHM.steady.seconds).toBe(2.4);
});

it('beats more often as the band worsens, and weakens when critical', () => {
expect(RHYTHM.fast.beats.length).toBe(2 * RHYTHM.steady.beats.length);
expect(RHYTHM.critical.beats.length).toBeGreaterThan(RHYTHM.fast.beats.length);
expect(RHYTHM.critical.seconds).toBeLessThan(RHYTHM.fast.seconds);
// Uneven: not every gap the same, and some beats barely there.
const gaps = RHYTHM.critical.beats.slice(1).map(([x], i) => x - RHYTHM.critical.beats[i][0]);
expect(new Set(gaps).size).toBeGreaterThan(1);
expect(Math.min(...RHYTHM.critical.beats.map(([, s]) => s))).toBeLessThan(0.5);
});

it('draws a weak beat smaller, around the same baseline', () => {
expect(beatPoints(0, [[0, 0.5]])).toBe('0,25 0,25 7,22.5 15,25 22,25 29,15 33,35 37,15 41,25 53,27.5 60,25 280,25');
});

it('keeps every beat inside its stretch, so the trace repeats without a seam', () => {
for (const { beats } of Object.values(RHYTHM)) {
const xs = beatPoints(280, beats).split(' ').map((p) => Number(p.split(',')[0]));
expect(xs[0]).toBe(280);
expect(xs[xs.length - 1]).toBe(560);
xs.forEach((x, i) => { if (i) expect(x).toBeGreaterThanOrEqual(xs[i - 1]); });
const ys = beatPoints(0, beats).split(' ').map((p) => Number(p.split(',')[1]));
ys.forEach((y) => { expect(y).toBeGreaterThanOrEqual(0); expect(y).toBeLessThanOrEqual(50); });
}
});
});

describe('the monitor', () => {
it('draws each band in its color and at its speed, and a flatline when down', () => {
for (const band of ['steady', 'fast', 'critical'] as const) {
const { container } = render(<HeartMonitor band={band} />);
const svg = container.querySelector('svg.ekg-live') as SVGElement;
expect(svg.style.animationDuration).toBe(`${RHYTHM[band].seconds}s`);
expect(container.querySelector('polyline')!.getAttribute('stroke')).toBe(BAND_COLOR[band]);
expect(container.querySelector('polyline')!.getAttribute('points')).toBe(beatPoints(0, RHYTHM[band].beats));
cleanup();
}
const { container } = render(<HeartMonitor band="down" />);
expect(container.querySelector('svg.ekg-live')).toBeNull();
expect(container.querySelector('line')!.getAttribute('stroke')).toBe('var(--danger)');
});

it('holds still with reduced motion', () => {
const { container } = render(<HeartMonitor band="fast" />);
expect(container.querySelector('style')!.textContent).toMatch(/prefers-reduced-motion: reduce\) \{ \.ekg-live \{ animation: none; \}/);
});

it('tells a screen reader how it looks, never a number', () => {
render(<HeartMonitor band="critical" />);
expect(screen.getByRole('img', { name: 'Heart monitor: weak, uneven heartbeat' })).toBeTruthy();
});

it("follows another player's health as others see it", () => {
const token = { id: 1, name: 'Razor', x: 0, y: 0, z: 0, shape: 'rhombus', owner: 'RAZOR', hp_max: 20 } as never;
const shown = (hp: number) => {
const { container } = render(<HealthReviewPanel location={{ ...(token as object), hp_current: hp } as never} />);
const band = container.querySelector('[data-band]')!.getAttribute('data-band');
cleanup();
return band;
};
expect([shown(18), shown(9), shown(3), shown(0)]).toEqual(['steady', 'fast', 'critical', 'down']);
});

it('beats the same way in the editing panel and on the stream', () => {
const token = (hp: number) => ({ id: 1, name: 'Razor', x: 0, y: 0, z: 0, shape: 'rhombus', owner: 'RAZOR', hp_current: hp, hp_max: 20 } as never);
const inPanel = (hp: number) => {
const { container } = render(<HitPointsPanel target={token(hp)} token="" refreshLocations={() => {}} />);
const band = container.querySelector('[data-band]')!.getAttribute('data-band');
cleanup();
return band;
};
const onStream = (hp: number) => {
const { container } = render(<StreamerOverlay socket={null} directorState={{ letterbox: false } as never} selectedLocation={token(hp)} />);
const band = container.querySelector('[data-band]')!.getAttribute('data-band');
cleanup();
return band;
};
expect([inPanel(18), inPanel(9), inPanel(3), inPanel(0)]).toEqual(['steady', 'fast', 'critical', 'down']);
expect([onStream(18), onStream(9), onStream(3), onStream(0)]).toEqual(['steady', 'fast', 'critical', 'down']);
});
});
55 changes: 55 additions & 0 deletions frontend/src/components/healthBands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// How hurt a token is, as a band: the heart monitor's color AND its rhythm come from it.
//
// Other players never see a number for someone else's health (HealthReviewPanel), so the
// monitor has to say it: green and steady over half, yellow and twice as fast at half or
// less, red, fast and uneven at a quarter or less, a red flatline when down. Color and
// rhythm use the same thresholds, so they always agree. Every system's monitor draws from
// this, the built-in ones included.

export type HealthBand = 'steady' | 'fast' | 'critical' | 'down';

/** The band for a current and max. No max reads as badly hurt, as the color always has. */
export const bandOf = (current: number, max: number): HealthBand => {
if (current <= 0) return 'down';
const pct = max > 0 ? Math.max(0, Math.min(1, current / max)) : 0;
return pct > 0.5 ? 'steady' : pct > 0.25 ? 'fast' : 'critical';
};

export const BAND_COLOR: Record<HealthBand, string> = {
steady: 'var(--green)',
fast: 'var(--warning)',
critical: 'var(--danger)',
down: 'var(--danger)',
};

/** Words for a screen reader: what the picture shows, still without a number. */
export const BAND_WORDS: Record<HealthBand, string> = {
steady: 'steady heartbeat',
fast: 'fast heartbeat',
critical: 'weak, uneven heartbeat',
down: 'flatline',
};

/**
* The beats in one 280-wide stretch of the trace, as [where it starts, how strong]. The
* trace is two stretches scrolled by half its width, so what one holds repeats seamlessly.
* Over half: the one even beat the monitor has always drawn. Half or less: twice as many.
* A quarter or less: fast, uneven, and some barely there. Seconds is one full scroll.
*/
export const RHYTHM: Record<Exclude<HealthBand, 'down'>, { seconds: number; beats: [number, number][] }> = {
steady: { seconds: 2.4, beats: [[27, 1]] },
fast: { seconds: 2.4, beats: [[20, 1], [160, 1]] },
// A beat is 60 wide: each starts after the last has ended, and the last ends by 280.
critical: { seconds: 2, beats: [[10, 0.8], [78, 0.45], [150, 0.75], [214, 0.3]] },
};

/** One beat's shape, from where it starts: a small bump, the spike, a trailing wave. Baseline 25. */
const BEAT: [number, number][] = [[0, 25], [7, 20], [15, 25], [22, 25], [29, 5], [33, 45], [37, 5], [41, 25], [53, 30], [60, 25]];

const fmt = (n: number) => String(Number(n.toFixed(1)));

/** The polyline for one stretch starting at `offset`, with `beats` in it. */
export const beatPoints = (offset: number, beats: [number, number][]): string =>
[[offset, 25], ...beats.flatMap(([x, strength]) => BEAT.map(([bx, by]) => [offset + x + bx, 25 + (by - 25) * strength])), [offset + 280, 25]]
.map(([x, y]) => `${fmt(x)},${fmt(y)}`)
.join(' ');
Loading