From e397a6d187516039da7de88973b2cba4d4c0c9d0 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 15:11:16 -0500 Subject: [PATCH] feat(system-builder): the SETUP page as logic (4a3a) sheets/setup.ts: setupOf reads the cover fields and core answers with their defaults (one pool, no advancement, no dice, feet); withCover (blank left out) and withCore write them back; defaultHealth gives each model's usual shape; idFor makes ids for new entries; toggleAdvancement, toggleDie and addDie (up to 8, server's die pattern); healthLayout for the starter-sheet preview and tokenNote for what the monitor shows. The test holds HEALTH_MODELS, ADVANCEMENT, DISTANCE, every default shape, the list limits, the dice and healthLayout (every model and edge case) to backend core.js and definition.js. Approved mockup builder-setup, 2026-10-06. --- CHANGELOG.md | 7 + frontend/src/sheets/__tests__/setup.test.ts | 165 ++++++++++++++ frontend/src/sheets/setup.ts | 225 ++++++++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 frontend/src/sheets/__tests__/setup.test.ts create mode 100644 frontend/src/sheets/setup.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 800deebd..83e0217b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **What the builder's SETUP page will ask and show.** The core questions read from a system and + written back: its description, author and license; how characters get hurt, with each health + model's usual starting shape; how they grow; the dice it rolls most (up to eight); and how + distance is measured. Also the preview of the HEALTH section a character sheet starts with, + checked against the server so it never shows something the game wouldn't build. The page itself + comes next. + - **One place for your systems: the builder.** The GAME tab's SYSTEMS.EXE button is now SYSTEM BUILDER, and opens the builder on MY SYSTEMS, with the system the game runs already open when it's one of yours. Everything SYSTEMS.EXE did happens there, so the SYSTEMS.EXE window is gone. diff --git a/frontend/src/sheets/__tests__/setup.test.ts b/frontend/src/sheets/__tests__/setup.test.ts new file mode 100644 index 00000000..d38da0ff --- /dev/null +++ b/frontend/src/sheets/__tests__/setup.test.ts @@ -0,0 +1,165 @@ +/** + * The SETUP page as logic (4a3a). Approved mockup builder-setup (2026-10-06): five questions on one + * page, a live preview of the starter sheet's HEALTH section. What it offers and previews is held + * here to the server's own (backend/systemBuilder/core.js), which checks and builds the real thing. + */ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'module'; +import { + HEALTH_MODELS, ADVANCEMENT, DISTANCE, COMMON_DICE, MAX_DICE, LIST_LIMITS, defaultHealth, idFor, setupOf, withCover, withCore, + toggleAdvancement, toggleDie, addDie, healthLayout, tokenNote, type Health, +} from '../setup'; + +const core = createRequire(import.meta.url)('../../../../backend/systemBuilder/core.js'); +const { checkDefinition } = createRequire(import.meta.url)('../../../../backend/systemBuilder/definition.js'); + +const ok = (def: object) => checkDefinition(def).problems; + +describe('held to the server', () => { + it('offers the server\'s health models, advancement kinds and units', () => { + expect(HEALTH_MODELS).toEqual(core.HEALTH_MODELS); + expect(ADVANCEMENT).toEqual(core.ADVANCEMENT); + // Every unit, feet first since it is what a system says nothing about. + expect([...DISTANCE.map((d) => d.id)].sort()).toEqual([...core.DISTANCE].sort()); + expect(DISTANCE[0].id).toBe('feet'); + }); + + it('every model\'s starting shape is one the server accepts', () => { + for (const m of HEALTH_MODELS) { + expect(ok({ format: 1, name: 'Hearth', core: { health: defaultHealth(m.id) } }), m.id).toEqual([]); + } + }); + + it('previews exactly the health sections the server builds, for every model and its edge cases', () => { + const cases: unknown[] = [ + ...HEALTH_MODELS.map((m) => defaultHealth(m.id)), + { model: 'pool', label: 'VIGOR' }, { model: 'pool', label: ' ' }, undefined, null, 'pool', { model: 'nonsense' }, + { model: 'tracks', tracks: [{ id: 'only', label: 'ONLY' }] }, + { model: 'typed', types: [{ id: 'a', label: 'A' }, 'junk'] }, + { model: 'harm', levels: [{ id: 'bad', label: 'BAD', slots: 'two' }, { id: 'worse', label: 'WORSE', slots: 3 }] }, + { model: 'wounds', count: 'many' }, { model: 'wounds', count: 4 }, + { model: 'locations', locations: [] }, + ]; + for (const h of cases) { + expect(healthLayout(h), JSON.stringify(h)).toEqual(core.healthLayout(h)); + expect(healthLayout(h, 'VIGOR'), JSON.stringify(h)).toEqual(core.healthLayout(h, 'VIGOR')); + } + }); + + it('every die it offers, and every one addDie lets in, is one the server accepts', () => { + expect(ok({ format: 1, name: 'Hearth', core: { dice: COMMON_DICE.slice(0, MAX_DICE) } })).toEqual([]); + for (const typed of ['d7', '3d8', 'd100', 'dF', '99d2']) { + const r = addDie([], typed); + expect('dice' in r, typed).toBe(true); + if ('dice' in r) expect(ok({ format: 1, name: 'H', core: { dice: r.dice } }), typed).toEqual([]); + } + for (const typed of ['d1', 'd101', '0d6', '100d6', 'd', 'six']) { + expect(addDie([], typed), typed).toEqual({ error: `${typed} is not a die (d2 to d100, or dF, with a count: 2d6)` }); + expect(ok({ format: 1, name: 'H', core: { dice: [typed] } }).length, typed).toBeGreaterThan(0); + } + }); + + it('keeps each model\'s list within the server\'s limits', () => { + const named = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `e${i}`, label: `E${i}` })); + const shapes: Record object> = { + tracks: (n) => ({ model: 'tracks', tracks: named(n) }), + types: (n) => ({ model: 'typed', types: named(n) }), + levels: (n) => ({ model: 'harm', levels: named(n).map((l) => ({ ...l, slots: 1 })) }), + locations: (n) => ({ model: 'locations', locations: named(n) }), + }; + for (const [key, [min, max]] of Object.entries(LIST_LIMITS) as [keyof typeof LIST_LIMITS, readonly [number, number]][]) { + expect(ok({ format: 1, name: 'H', core: { health: shapes[key](min) } }), `${key} ${min}`).toEqual([]); + expect(ok({ format: 1, name: 'H', core: { health: shapes[key](max) } }), `${key} ${max}`).toEqual([]); + expect(ok({ format: 1, name: 'H', core: { health: shapes[key](max + 1) } }).length, `${key} ${max + 1}`).toBeGreaterThan(0); + if (min > 1) expect(ok({ format: 1, name: 'H', core: { health: shapes[key](min - 1) } }).length, `${key} ${min - 1}`).toBeGreaterThan(0); + } + }); +}); + +describe('ids for new entries', () => { + it('come from the label, as the server allows, and never repeat', () => { + expect(idFor('LEFT ARM', [])).toBe('left_arm'); + expect(idFor('LEFT - ARM', [])).toBe('left_arm'); + expect(idFor(' Stun!! ', [])).toBe('stun'); + expect(idFor('2nd wind', [])).toBe('x2nd_wind'); + expect(idFor('', [])).toBe('x'); + expect(idFor('Head', ['head'])).toBe('head_2'); + expect(idFor('Head', ['head', 'head_2'])).toBe('head_3'); + expect(idFor('A'.repeat(60), []).length).toBe(36); + for (const label of ['LEFT ARM', '2nd wind', '', '!!!', 'Ünïcode']) { + expect(idFor(label, []), label).toMatch(/^[a-z][a-z0-9_]{0,39}$/); + } + }); +}); + +describe('reading a system', () => { + it('reads what the definition says', () => { + const def = { + format: 1, name: 'Hearth', description: 'By one fire.', author: 'Cody', license: 'CC BY 4.0', + core: { health: defaultHealth('wounds'), advancement: ['milestone'], dice: ['d6', '2d6'], distance: 'meters' }, + }; + expect(setupOf(def)).toEqual({ + description: 'By one fire.', author: 'Cody', license: 'CC BY 4.0', + health: defaultHealth('wounds'), advancement: ['milestone'], dice: ['d6', '2d6'], distance: 'meters', + }); + }); + + it('fills in what it leaves out: one pool, no advancement, no dice, feet', () => { + const blank = { description: '', author: '', license: '', health: { model: 'pool' }, advancement: [], dice: [], distance: 'feet' }; + expect(setupOf({ format: 1, name: 'Hearth' })).toEqual(blank); + expect(setupOf(null)).toEqual(blank); + expect(setupOf({ format: 1, name: 'H', description: 4, core: { health: { model: 'tentacles' }, advancement: 'levels', dice: ['d6', 7], distance: 'leagues' } })) + .toEqual({ ...blank, dice: ['d6'] }); + expect(setupOf({ format: 1, name: 'H', core: 'yes' })).toEqual(blank); + expect(setupOf({ format: 1, name: 'H', core: { advancement: ['levels', 3, null] } }).advancement).toEqual(['levels']); + }); +}); + +describe('writing it back', () => { + const def = { format: 1, name: 'Hearth', core: { dice: ['d20'] } }; + + it('sets a cover field, and leaves a blank one out', () => { + expect(withCover(def, 'description', 'By one fire.')).toEqual({ ...def, description: 'By one fire.' }); + expect(withCover({ ...def, author: 'Cody' }, 'author', ' ')).toEqual(def); + expect(def).toEqual({ format: 1, name: 'Hearth', core: { dice: ['d20'] } }); + }); + + it('replaces some core answers, keeping the rest', () => { + expect(withCore(def, { distance: 'zones' })).toEqual({ ...def, core: { dice: ['d20'], distance: 'zones' } }); + expect(withCore({ format: 1, name: 'H' }, { advancement: [] })).toEqual({ format: 1, name: 'H', core: { advancement: [] } }); + expect(def.core).toEqual({ dice: ['d20'] }); + }); + + it('turns advancement on and off, in the page\'s order', () => { + expect(toggleAdvancement([], 'spend')).toEqual(['spend']); + expect(toggleAdvancement(['spend'], 'levels')).toEqual(['levels', 'spend']); + expect(toggleAdvancement(['levels', 'spend'], 'levels')).toEqual(['spend']); + }); + + it('turns dice on and off, up to eight', () => { + expect(toggleDie(['d20'], 'd6')).toEqual(['d20', 'd6']); + expect(toggleDie(['d20', 'd6'], 'd20')).toEqual(['d6']); + const eight = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100', '2d6']; + expect(toggleDie(eight, '3d6')).toEqual(eight); + expect(addDie(eight, 'd7')).toEqual({ error: 'Up to 8 dice' }); + expect(addDie(['d7'], ' d7 ')).toEqual({ dice: ['d7'] }); + }); +}); + +describe('what the token shows', () => { + it('says it for every model', () => { + const notes = Object.fromEntries(HEALTH_MODELS.map((m) => [m.id, tokenNote(defaultHealth(m.id))])); + expect(notes).toEqual({ + pool: 'The token\'s monitor shows HP.', + tracks: 'The token\'s monitor shows PHYSICAL; STUN lives on the sheet. A full STUN spills into PHYSICAL.', + typed: 'The token\'s monitor shows HEALTH; heavier damage turns lighter boxes over.', + harm: 'No number on the token: it shows the worst level taken.', + wounds: 'Out after 3 wounds, each giving -1 to rolls.', + locations: 'Injuries are noted per location, under the health pool.', + none: 'No health section. Consequences are written in as conditions.', + }); + expect(tokenNote({ model: 'pool', label: 'VIGOR' })).toBe('The token\'s monitor shows VIGOR.'); + expect(tokenNote({ ...(defaultHealth('tracks') as Extract), overflow: false })).toBe('The token\'s monitor shows PHYSICAL; STUN lives on the sheet.'); + expect(tokenNote({ model: 'wounds', count: 4 })).toBe('Out after 4 wounds.'); + }); +}); diff --git a/frontend/src/sheets/setup.ts b/frontend/src/sheets/setup.ts new file mode 100644 index 00000000..9a70784d --- /dev/null +++ b/frontend/src/sheets/setup.ts @@ -0,0 +1,225 @@ +// The builder's SETUP page as logic (4a3a): the core rules questions read from a system's +// definition and written back, and the starter sheet's HEALTH section the page previews. Pure, +// so the page only draws it. Approved mockup docs/mockups/builder-setup.html (2026-10-06). +// +// The answers are the definition's `core` (backend/systemBuilder/core.js checks them) and its +// cover fields (description, author, license). The lists of models, advancement kinds and units +// mirror core.js's, and the preview mirrors its healthLayout: a test holds each to the server's. + +import type { Definition } from './systemsApi'; + +export type HealthModel = 'pool' | 'tracks' | 'typed' | 'harm' | 'wounds' | 'locations' | 'none'; +export interface Named { id: string; label: string } +export interface HarmLevel extends Named { slots: number; penalty?: string } +export type Health = + | { model: 'pool'; label?: string } + | { model: 'tracks'; tracks: Named[]; overflow?: boolean } + | { model: 'typed'; label?: string; types: Named[] } + | { model: 'harm'; levels: HarmLevel[] } + | { model: 'wounds'; count: number; penalty?: number } + | { model: 'locations'; label?: string; locations: Named[] } + | { model: 'none' }; + +/** The health models, as the SETUP page offers them (core.js HEALTH_MODELS). */ +export const HEALTH_MODELS: { id: HealthModel; label: string; worksLike: string; examples: string }[] = [ + { id: 'pool', label: 'One pool', worksLike: 'A number that goes down', examples: 'D&D, CWN, Cyberpunk RED' }, + { id: 'tracks', label: 'Two tracks', worksLike: 'Two pools, filled by different damage', examples: 'Shadowrun physical and stun; Genesys wounds and strain' }, + { id: 'typed', label: 'Damage types on one track', worksLike: 'Boxes marked lightly or heavily', examples: 'Vampire superficial and aggravated' }, + { id: 'harm', label: 'Harm levels', worksLike: 'Named severities, each with its penalty', examples: 'Blades lesser, moderate, severe' }, + { id: 'wounds', label: 'Wound count', worksLike: 'A few wounds, then out', examples: 'Savage Worlds' }, + { id: 'locations', label: 'Hit locations', worksLike: 'Damage per location, with critical injuries', examples: 'WFRP' }, + { id: 'none', label: 'None', worksLike: 'Consequences are conditions', examples: 'Some narrative games' }, +]; + +/** How characters advance: any of these, or none (core.js ADVANCEMENT). */ +export const ADVANCEMENT: { id: string; label: string; examples: string }[] = [ + { id: 'levels', label: 'XP levels', examples: 'D&D, CWN' }, + { id: 'milestone', label: 'Milestone', examples: 'The GM levels the group up' }, + { id: 'spend', label: 'Spend XP', examples: 'Shadowrun karma, World of Darkness, Genesys' }, + { id: 'use', label: 'Improve by use', examples: 'Call of Cthulhu' }, +]; + +/** Distance units, each with what the map's ruler does with it (3d; core.js DISTANCE). */ +export const DISTANCE: { id: string; what: string }[] = [ + { id: 'feet', what: 'The map\'s own unit, as every built-in system' }, + { id: 'meters', what: 'Converted from the map\'s feet' }, + { id: 'yards', what: 'Converted from the map\'s feet' }, + { id: 'squares', what: 'Counts the map\'s squares' }, + { id: 'hexes', what: 'Counts the map\'s squares as hexes' }, + { id: 'zones', what: 'The ruler draws its line with no number' }, +]; + +/** The dice offered as toggles; any other can be added (core.js DIE). */ +export const COMMON_DICE = ['d4', 'd6', 'd8', 'd10', 'd12', 'd20', 'd100', '2d6', '3d6', '4dF']; +export const MAX_DICE = 8; +const DIE = /^([1-9][0-9]?)?d([2-9]|[1-9][0-9]|100|F)$/; + +/** How many entries each model's list may have (core.js checkHealth). */ +export const LIST_LIMITS = { tracks: [2, 2], types: [2, 4], levels: [1, 6], locations: [1, 12] } as const; + +const isObject = (v: unknown): v is Record => !!v && typeof v === 'object' && !Array.isArray(v); + +/** What a model starts as when the GM picks it: its usual shape, ready to rename. */ +export const defaultHealth = (model: HealthModel): Health => { + switch (model) { + case 'tracks': return { model, tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }], overflow: true }; + case 'typed': return { model, label: 'HEALTH', types: [{ id: 'superficial', label: 'SUPERFICIAL' }, { id: 'aggravated', label: 'AGGRAVATED' }] }; + case 'harm': return { model, levels: [ + { id: 'lesser', label: 'LESSER', slots: 2, penalty: 'Less effect' }, + { id: 'moderate', label: 'MODERATE', slots: 2, penalty: '-1d' }, + { id: 'severe', label: 'SEVERE', slots: 1, penalty: 'Need help' }, + ] }; + case 'wounds': return { model, count: 3, penalty: -1 }; + case 'locations': return { model, label: 'HP', locations: ['HEAD', 'BODY', 'LEFT ARM', 'RIGHT ARM', 'LEFT LEG', 'RIGHT LEG'].map((label) => ({ id: idFor(label, []), label })) }; + case 'none': return { model }; + default: return { model: 'pool' }; + } +}; + +/** + * An id for a new entry from its label: lowercase letters, digits and _, starting with a letter, + * not one already `taken` (core.js NAME). An entry keeps its id when its label changes later, so + * what characters have in it stays theirs. + */ +export const idFor = (label: string, taken: string[]): string => { + let base = label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 36); + if (!/^[a-z]/.test(base)) base = `x${base}`.slice(0, 36); + let id = base; + for (let n = 2; taken.includes(id); n += 1) id = `${base}_${n}`; + return id; +}; + +/** Every answer SETUP shows, from a definition: what it says, or the defaults where it says nothing. */ +export const setupOf = (def: Definition | null) => { + const core = isObject(def?.core) ? def!.core as Record : {}; + const text = (v: unknown) => (typeof v === 'string' ? v : ''); + const health = isObject(core.health) && HEALTH_MODELS.some((m) => m.id === (core.health as { model?: string }).model) + ? core.health as Health : { model: 'pool' as const }; + return { + description: text(def?.description), + author: text(def?.author), + license: text(def?.license), + health, + advancement: Array.isArray(core.advancement) ? core.advancement.filter((a): a is string => typeof a === 'string') : [], + dice: Array.isArray(core.dice) ? core.dice.filter((d): d is string => typeof d === 'string') : [], + distance: typeof core.distance === 'string' && DISTANCE.some((d) => d.id === core.distance) ? core.distance : 'feet', + }; +}; + +/** The definition with a cover field set; a blank one is left out rather than stored empty. */ +export const withCover = (def: Definition, key: 'description' | 'author' | 'license', value: string): Definition => { + const next: Definition = { ...def }; + if (value.trim()) next[key] = value; else delete next[key]; + return next; +}; + +/** The definition with some of its core answers replaced. */ +export const withCore = (def: Definition, patch: Record): Definition => ({ + ...def, + core: { ...(isObject(def.core) ? def.core : {}), ...patch }, +}); + +/** Advancement with one kind turned on or off, kept in the page's order. */ +export const toggleAdvancement = (chosen: string[], id: string): string[] => + ADVANCEMENT.map((a) => a.id).filter((a) => (a === id ? !chosen.includes(id) : chosen.includes(a))); + +/** The dice with one toggled; a die past the limit is not added. */ +export const toggleDie = (dice: string[], die: string): string[] => + (dice.includes(die) ? dice.filter((d) => d !== die) : dice.length >= MAX_DICE ? dice : [...dice, die]); + +/** A die typed in: added, or why not. */ +export const addDie = (dice: string[], typed: string): { dice: string[] } | { error: string } => { + const die = typed.trim(); + if (!DIE.test(die)) return { error: `${die} is not a die (d2 to d100, or dF, with a count: 2d6)` }; + if (dice.includes(die)) return { dice }; + if (dice.length >= MAX_DICE) return { error: `Up to ${MAX_DICE} dice` }; + return { dice: [...dice, die] }; +}; + +// ─── The preview ──────────────────────────────────────────────────────────── + +export interface PreviewField { id: string; label: string; type: string; source?: string; maxField?: string; hint?: string } +export interface PreviewSection { id: string; label: string; layout: string; tab: string; columns?: number; fields: PreviewField[] } + +/** The starter sheet's health sections for a model, exactly as core.js healthLayout builds them. */ +export const healthLayout = (health: unknown, hpWord = 'HP'): { sections: PreviewSection[]; header: { hpField?: string; hpMaxField?: string } } => { + const h = (isObject(health) ? health : { model: 'pool' }) as Record; + const grid = (fields: PreviewField[]): PreviewSection => ({ id: 'health', label: 'HEALTH', layout: 'grid', tab: 'STATS', columns: 2, fields }); + const pool = (id: string, name: string): PreviewField[] => [ + { id, label: name, type: 'number', source: 'token_hp', maxField: `${id}_max` }, + { id: `${id}_max`, label: `${name} MAX`, type: 'number', source: 'token_hp_max' }, + ]; + const text = (v: unknown, fallback: string) => (typeof v === 'string' && v.trim() ? v : fallback); + const list = (v: unknown) => (Array.isArray(v) ? v.filter(isObject) as Record[] : []); + switch (h.model) { + case 'tracks': { + const [first, second] = list(h.tracks) as unknown as Named[]; + if (!first || !second) return healthLayout({ model: 'pool' }, hpWord); + return { + sections: [grid([ + ...pool(first.id, first.label), + { id: second.id, label: second.label, type: 'number', maxField: `${second.id}_max` }, + { id: `${second.id}_max`, label: `${second.label} MAX`, type: 'number' }, + ])], + header: { hpField: first.id, hpMaxField: `${first.id}_max` }, + }; + } + case 'typed': + return { + sections: [grid([ + ...pool('health', text(h.label, 'HEALTH')), + ...list(h.types).map((t) => ({ id: t.id as string, label: t.label as string, type: 'number', hint: 'Boxes marked with this kind of damage' })), + ])], + header: { hpField: 'health', hpMaxField: 'health_max' }, + }; + case 'harm': + return { + sections: [{ id: 'harm', label: 'HARM', layout: 'list', tab: 'STATS', fields: list(h.levels).flatMap((level) => + Array.from({ length: Number.isInteger(level.slots) ? level.slots as number : 1 }, (_, i) => ({ + id: `${level.id}_${i + 1}`, + label: i === 0 ? level.label as string : `${level.label} ${i + 1}`, + type: 'text', + ...(level.penalty ? { hint: level.penalty as string } : {}), + }))) }], + header: {}, + }; + case 'wounds': + return { + sections: [grid([ + { id: 'wounds', label: 'WOUNDS LEFT', type: 'number', source: 'token_hp', maxField: 'wounds_max' }, + { id: 'wounds_max', label: 'WOUNDS', type: 'number', source: 'token_hp_max', + ...(Number.isInteger(h.count) ? { hint: `Out after ${h.count}` } : {}) }, + ])], + header: { hpField: 'wounds', hpMaxField: 'wounds_max' }, + }; + case 'locations': + return { + sections: [ + grid(pool('hp', text(h.label, hpWord))), + { id: 'injuries', label: 'INJURIES', layout: 'list', tab: 'STATS', + fields: list(h.locations).map((l) => ({ id: l.id as string, label: l.label as string, type: 'text' })) }, + ], + header: { hpField: 'hp', hpMaxField: 'hp_max' }, + }; + case 'none': + return { sections: [], header: {} }; + default: + return { sections: [grid(pool('hp', text(h.label, hpWord)))], header: { hpField: 'hp', hpMaxField: 'hp_max' } }; + } +}; + +/** What the preview says the token's health monitor shows under a model. */ +export const tokenNote = (health: Health): string => { + switch (health.model) { + case 'tracks': { + const [a, b] = health.tracks; + return `The token's monitor shows ${a?.label ?? 'the first track'}; ${b?.label ?? 'the second'} lives on the sheet.${health.overflow ? ` A full ${b?.label ?? 'second track'} spills into ${a?.label ?? 'the first'}.` : ''}`; + } + case 'typed': return `The token's monitor shows ${health.label || 'HEALTH'}; heavier damage turns lighter boxes over.`; + case 'harm': return 'No number on the token: it shows the worst level taken.'; + case 'wounds': return `Out after ${health.count} wounds${health.penalty ? `, each giving ${health.penalty} to rolls` : ''}.`; + case 'locations': return 'Injuries are noted per location, under the health pool.'; + case 'none': return 'No health section. Consequences are written in as conditions.'; + default: return `The token's monitor shows ${health.label || 'HP'}.`; + } +};