diff --git a/CHANGELOG.md b/CHANGELOG.md index 525097e..34e9930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -107,6 +107,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **Calling a rest from the GAME tab.** Under a custom game, TTRPG_SYSTEM has a RESTS panel for the + GM and granted editors: pick a rest, rest every player character, the NPCs on the map too, or + the ones ticked, see what each will get, and CALL it. Everyone sees a dice-log line each. - **Conditions that end at a rest.** The builder's CONDITIONS page can end a condition AT A REST, with the rests that end it ticked beside it; the RESTS page's WEARS OFF is the same setting. - **The builder's RESTS page.** Between CONDITIONS and TRY IT: switch the standard rests on or off, diff --git a/README.md b/README.md index 3696bb1..fc2fb1c 100644 --- a/README.md +++ b/README.md @@ -648,7 +648,7 @@ CITY_NET/ │ │ │ ├── bootSounds.test.ts # No audio at all being harmless, since there are no speakers to check the rest by │ │ │ └── useAmbientHum.test.tsx # One hum per session: easing in once, the slider and mute adjusting it in place, and a start the browser holds back not taken for a start │ │ ├── components/ -│ │ │ ├── AdminPanel.tsx # GM dashboard — CITY / EXPORT / GAME / PLAYERS tabs; CITY_GENERATOR delegates to cityGen/ and exposes LAYOUT, DRAG_RECT/DRAW_AREA bounds, OVERPASS_DENSITY, WATER, PARK_PONDS, an optional SEED and REGENERATE; CUSTOM type integrates into NEXT_STYLE cycle using cross-map custom_structure_library; data-driven HouseRulesPanel for CP:R, CWN, and SR6; SR6 Edge replenishment (reset all / give 1 to player); a built-in game's own CONDITIONS for the main admin (TableConditionsPanel) +│ │ │ ├── AdminPanel.tsx # GM dashboard — CITY / EXPORT / GAME / PLAYERS tabs; CITY_GENERATOR delegates to cityGen/ and exposes LAYOUT, DRAG_RECT/DRAW_AREA bounds, OVERPASS_DENSITY, WATER, PARK_PONDS, an optional SEED and REGENERATE; CUSTOM type integrates into NEXT_STYLE cycle using cross-map custom_structure_library; data-driven HouseRulesPanel for CP:R, CWN, and SR6; SR6 Edge replenishment (reset all / give 1 to player); a built-in game's own CONDITIONS for the main admin (TableConditionsPanel); a custom game's RESTS for the GM and editors (GameRestsPanel), with the NPCs on the map being viewed │ │ │ ├── SystemPicker.tsx # GAME tab's system picker: a searchable dropdown, BUILT-IN then YOUR SYSTEMS, portalled into the theme, asking before it switches the game for everyone │ │ │ ├── systemPickerRules.ts # The picker's rules on their own: grouping and order, search, arrow-key movement, what the button says │ │ │ ├── InventorySection.tsx # The inventory table, on every system. Its own file because SheetRenderer is long enough, and generic: which rows carry an extra button, and what pressing it does, is supplied from outside - so a drug offers CONSUME and a skillplug offers LOAD without either knowing about the other @@ -728,6 +728,7 @@ CITY_NET/ │ │ │ ├── ConditionIcon.tsx # A condition's icon: one drawn for CITY_NET, stroked in the theme's color, or an uploaded picture through only; anything unknown draws the target │ │ │ ├── ConditionIconPicker.tsx # A condition's icon picked: the 24 drawn ones, or UPLOAD for a small PNG, WebP or SVG (a refusal said); the builder's CONDITIONS page and the GAME tab's both use it │ │ │ ├── TableConditionsPanel.tsx # The GAME tab's CONDITIONS under a built-in game, main admin only: the standard set (four shown, the rest folded) and the table's own, added with a name, icon and description (no modifiers or rounds) or removed after asking; the server's refusal said, and every screen asking again once saved +│ │ │ ├── GameRestsPanel.tsx # The GAME tab's RESTS under a custom game, for the GM and granted editors: one of the game's rests, who rests (every player character, the NPCs on the map being viewed too, or ticked by hand), what each one will get (dice still to roll shown as such, conditions wearing off), and CALL; everyone previewed once so ticking asks nothing more, previewed again after a call; a refusal said │ │ │ ├── TryItPage.tsx # TRY IT: the draft as a player would meet it, unsaved changes included. A made-up character on its own sheet with formulas worked out as you type, its health on a pretend token in the HEALTH folder's own panels with what others see, and an NPC rolled from a tier. Saves nothing but SAVE AS THE SAMPLE CHARACTER │ │ │ ├── ControlledNpcSheetWindow.tsx # A friendly NPC's sheet for the player the GM gave it to: read-only, with rolls made for the NPC through the server │ │ │ ├── UpdateModal.tsx # Draggable update notification modal (shown on admin login when update available; Update Now / Remind Me Later / Skip Version; docker-aware) @@ -804,6 +805,7 @@ CITY_NET/ │ │ │ ├── ConditionsPage.test.tsx # Switches storing only "off", renaming and putting back, icons drawn or uploaded (a refusal said), ends and rounds, ending at the rests ticked (one turned off offered only to untick), modifiers typed negative, a system's own added, named, deleted after asking, and + CONDITION stopping at 60 │ │ │ ├── RestsPage.test.tsx # Switches storing only "off", own rests added, opened and deleted after asking, stopping at 12, names and their placeholders, counting as rests that are on and never in a loop, refills added and fitted to what they name, from the previewed sheet or health alone, tracks, a refill naming something gone, nothing to refill, and what wears off ticked from the rest's side; every change held to the server's checks │ │ │ ├── TableConditionsPanel.test.tsx # The GAME tab's conditions against a fake server running the real checks: standard ones folded, the table's own listed, one added with a drawn or uploaded icon and sent whole with the GM's login, removed only once asked, a refusal said with the typing kept, + CONDITION stopping at 60, and the panel only for the main admin under a built-in game +│ │ │ ├── GameRestsPanel.test.tsx # The GAME tab's rests against a fake server: the game's rests offered, everyone previewed once with the map's NPCs, another rest previewed again, who rests (players, the map's NPCs too, ticked, starting with the players), CALL for each with the right request and a count, nobody chosen, a refusal, an answer that isn't a list, and the panel only under a custom game (a built-in one not even asked) │ │ │ ├── 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 │ │ │ ├── ControlledNpcSheetWindow.test.tsx # A friendly NPC's sheet read-only for the player it was given to, fetched with their own login, rolling as the NPC │ │ │ ├── SheetAttackPanel.attackAs.test.tsx # ATTACK AS: a player with a friendly NPC given to them, the GM with an NPC near the target, its weapons and LUCK, firing as it @@ -953,6 +955,7 @@ CITY_NET/ │ │ │ ├── rests.ts # RESTS as logic: the standard four and a system's own read from a definition and written back storing only what differs, the count against 12, new and deleted ones (nothing left counting as or ending at a deleted one), what each counts as and loops, what a rest can refill under the health model and the sheet it plays with, refills added, fitted to what they name, changed and removed, and the conditions it wears off set from either side; mirrors the server's rests.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, putting one on or taking one off without resetting another's rounds, and the map's four icons then +N │ │ │ ├── tableConditions.ts # The GAME tab's CONDITIONS as logic: the table's own from the game's list, sent back whole, one added under an id no standard or other one has (name and description trimmed, a blank description not sent), one removed, the limit of 60 counting the standard set +│ │ │ ├── restCalls.ts # Calling a rest from the GAME tab: the two routes (GET /api/sheets/rests, POST /api/sheets/rest, preview or call), the NPC tokens on the map being viewed (a battle map's floor, or the city), who is chosen, what the server is asked for, and how a change reads ("HP 9 → 22", or "HP 9 + 1d8" before the dice) │ │ │ ├── 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) @@ -976,6 +979,7 @@ CITY_NET/ │ │ │ ├── conditions.test.ts # The standard set and what the game offers held to the server's own conditionsOf, each change storing only what differs, a system's own added, renamed and deleted, the limit of 60, and what a modifier may name │ │ │ ├── rests.test.ts # RESTS logic held to the server's own rests.js, definition checks and conditionsOf: rests listed and offered, counting order and loops, edits stored as changes only, own ones added to 12 and deleted without leaving anything pointing at them, refill targets per health model and sheet, refills described, fitted and edited, and what wears off from either side │ │ │ ├── tableConditions.test.ts # The GAME tab's conditions held to the server's own checks and conditionsOf: the table's own read and sent back, ids that never clash, blank descriptions not sent, removing, needing a name, and the limit of 60 +│ │ │ ├── restCalls.test.ts # The NPCs on the map being viewed (city, a battle map's floor, a leftover battle map), who rests and what the server is asked, changes before and after the dice, the requests with the GM's login and refusals in the server's words │ │ │ ├── tokenConditions.test.ts # Reading a token's conditions as the server does, what is drawn (in order, rounds and modifiers where sent, one the game lacks left out), putting on and taking off keeping the others' rounds, and the words for modifiers and rounds │ │ │ ├── conditionIcons.test.tsx # The 24 icons are the server's, path data alone, none of Feather's paths, stroked in currentColor and never filled; an upload drawn through ; anything unknown drawn as the target │ │ │ ├── tryIt.test.ts # The made-up character, typed values, SAVE AS THE SAMPLE and RESET, the pretend token and its maximum diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 741d2c4..dc136a5 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -49,6 +49,8 @@ import { useParts, partOn, type PartLookup } from '../sheets/parts'; import { currenciesFor, isUploadedIcon, BUILT_IN_ICONS, type Currency } from '../sheets/currencies'; import { useCustomTemplate, refreshCustomTemplate, isCustomSystem } from '../sheets/customTemplates'; import { TableConditionsPanel } from './TableConditionsPanel'; +import { GameRestsPanel } from './GameRestsPanel'; +import { npcsOnMap } from '../sheets/restCalls'; import { shortfallRule } from '../sheets/moneyText'; import { SYSTEMS_CHANGED_EVENT } from '../sheets/systemsLibrary'; @@ -143,7 +145,8 @@ function BattleAdminPanel({ } {tab === 'game' && <> - + {/* Money, which a custom system with the bank off does not have (3b2b). */} {bankOn && <> @@ -1047,7 +1050,8 @@ export function AdminPanel({ {adminTab === 'game' && ( <> - + {/* Money, which a custom system with the bank off does not have (3b2b). */} {bankOn && <> @@ -2299,8 +2303,10 @@ const SR6_HOUSE_RULES: HouseRuleDef[] = [ }, ]; -function TTRPGSystemPanel({ token, mainAdmin, onOpenNpcLibrary, onOpenSystems, activeUsers }: { +function TTRPGSystemPanel({ token, mainAdmin, onOpenNpcLibrary, onOpenSystems, activeUsers, npcs }: { token: string; onOpenNpcLibrary?: () => void; activeUsers?: any[]; + /** The NPC tokens on the map being viewed, for a custom game's rests (4f5). */ + npcs?: { id: number; name: string }[]; /** The main admin: changes a built-in game's own conditions (the server's route is theirs alone). */ mainAdmin?: boolean; /** Opens the system builder on MY SYSTEMS; the main admin's alone, so absent for a granted editor. */ @@ -2367,6 +2373,8 @@ function TTRPGSystemPanel({ token, mainAdmin, onOpenNpcLibrary, onOpenSystems, a ]} /> {/* A built-in game's own conditions (4e2c2); a custom system's are edited in the builder. */} {mainAdmin && !isCustomSystem(system) && } + {/* A custom game's rests (4f5); a built-in game's are its own buttons below. */} + {isCustomSystem(system) && } {system === 'shadowrun_6e' && (() => { const onlinePlayers = (activeUsers || []).filter((u: any) => !u.isAdmin && !u.isNPC).map((u: any) => u.userName); return ( diff --git a/frontend/src/components/GameRestsPanel.tsx b/frontend/src/components/GameRestsPanel.tsx new file mode 100644 index 0000000..80822a9 --- /dev/null +++ b/frontend/src/components/GameRestsPanel.tsx @@ -0,0 +1,135 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + restCalls, chosenOf, requestFor, changeText, keyOf, WHO_LABELS, type Rested, type Who, +} from '../sheets/restCalls'; + +// The GAME tab's RESTS panel under a custom game (4f5; approved mockup docs/mockups/builder-rests.html, +// 2026-10-09, stage 3): pick one of the game's rests and who rests, see what each one will get, and +// CALL it. The server rolls, writes and logs (backend/sheets/rests.js); everyone sees one dice-log +// line per character. Everyone the panel could choose is previewed once, so ticking who rests asks +// the server nothing. The GM and granted editors, as the route allows. + +const label: React.CSSProperties = { fontSize: '0.65rem', opacity: 0.7, letterSpacing: '1px' }; +const why: React.CSSProperties = { fontSize: '0.6rem', opacity: 0.6, margin: 0 }; +const btn: React.CSSProperties = { fontSize: '0.65rem' }; +const select: React.CSSProperties = { fontSize: '0.65rem', background: 'var(--black)', color: 'var(--green)', border: '1px solid var(--green)', padding: '3px 4px' }; + +export function GameRestsPanel({ token, system, npcs }: { + token: string; + /** The running game, so the list is asked for again when it changes. */ + system: string; + /** The NPC tokens on the map being viewed (restCalls.npcsOnMap). */ + npcs: { id: number; name: string }[]; +}) { + const api = useMemo(() => restCalls(token), [token]); + const [rests, setRests] = useState<{ id: string; name: string }[]>([]); + const [restId, setRestId] = useState(''); + const [who, setWho] = useState('players'); + const [ticked, setTicked] = useState>(new Set()); + const [everyone, setEveryone] = useState([]); + const [round, setRound] = useState(0); + const [busy, setBusy] = useState(false); + const [said, setSaid] = useState(null); + const [error, setError] = useState(null); + const npcIds = npcs.map((n) => n.id); + const npcKey = npcIds.join(','); + + useEffect(() => { + let live = true; + api.list().then((r) => { + if (!live || !r.ok) return; + // An answer without a list (an older server, a proxy's page) is no rests, not a crash. + const list = r.value && Array.isArray(r.value.rests) ? r.value.rests : []; + setRests(list); + setRestId((id) => (list.some((x) => x.id === id) ? id : list[0]?.id ?? '')); + }); + return () => { live = false; }; + }, [api, system]); + + // Everyone the panel could choose, previewed: every player character and the map's NPCs. + useEffect(() => { + if (!restId) { setEveryone([]); return undefined; } + let live = true; + api.preview(restId, true, npcIds).then((r) => { + if (!live) return; + if (r.ok) { setEveryone(r.value.characters); setError(null); } else setError(r.error); + }); + return () => { live = false; }; + }, [api, restId, npcKey, round]); // eslint-disable-line react-hooks/exhaustive-deps + + if (!rests.length) return null; + const rest = rests.find((r) => r.id === restId) ?? rests[0]; + const chosen = chosenOf(everyone, who, ticked); + + const pickWho = (next: Who) => { + // CHOOSE starts with every player character ticked, as the default rests them. + if (next === 'choose' && who !== 'choose') setTicked(new Set(everyone.filter((r) => r.kind === 'player').map(keyOf))); + setWho(next); + }; + const call = async () => { + setBusy(true); + setSaid(null); + const { players, npcs: ids } = requestFor(who, chosen, npcIds); + const r = await api.call(rest.id, players, ids); + setBusy(false); + if (!r.ok) { setError(r.error); return; } + setError(null); + const n = r.value.characters.length; + setSaid(`${rest.name.toUpperCase()} CALLED · ${n} CHARACTER${n === 1 ? '' : 'S'}`); + setRound((x) => x + 1); + }; + + return ( +
+ + +
+ {(Object.keys(WHO_LABELS) as Who[]).map((w) => ( + + ))} +
+ {who === 'choose' && ( +
+ {everyone.map((r) => { + const k = keyOf(r); + const on = ticked.has(k); + return ( + + ); + })} +
+ )} + WHAT EACH ONE GETS +
    + {chosen.map((r) => ( +
  • +
    + {r.name.toUpperCase()} + {r.kind === 'npc' ? 'NPC' : 'PLAYER'} +
    +
    + {r.changes.map((c) => {changeText(c)})} + {r.gone.map((g) => {g.toUpperCase()} WEARS OFF)} + {!r.changes.length && !r.gone.length && Nothing changes.} +
    +
  • + ))} +
+ {!chosen.length &&

Nobody chosen.

} + +

Dice are rolled for each character when it is called; everyone sees a line each in the dice log.

+ {said && {said}} + {error && {error}} +
+ ); +} diff --git a/frontend/src/components/__tests__/GameRestsPanel.test.tsx b/frontend/src/components/__tests__/GameRestsPanel.test.tsx new file mode 100644 index 0000000..4ffb313 --- /dev/null +++ b/frontend/src/components/__tests__/GameRestsPanel.test.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { render, screen, cleanup, within, waitFor, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { GameRestsPanel } from '../GameRestsPanel'; +import { AdminPanel } from '../AdminPanel'; + +/** + * The GAME tab's RESTS panel under a custom game (4f5). Approved mockup builder-rests, stage 3 + * (2026-10-09): one of the game's rests, who rests (every player character, the map's NPCs too, or + * chosen), what each will get, and CALL. Everyone is previewed once; ticking who rests asks nothing + * more. A fake server answers as backend/sheets/rests.js does. + */ + +const HEARTH = 'sys_aaaaaaaaaaaaaaaa'; +const RESTS = [{ id: 'short_rest', name: 'Short rest' }, { id: 'long_rest', name: 'Long rest' }]; +const person = (kind: 'player' | 'npc', id: string | number, name: string, changes: unknown[] = [], gone: string[] = []) => + (kind === 'player' ? { kind, username: id, name, changes, gone } : { kind, location_id: id, name, changes, gone }); + +let asked: { url: string; body: any }[]; +let refuseCall: string | null; +let rests: typeof RESTS; +let running: string; + +beforeEach(() => { + asked = []; + refuseCall = null; + rests = RESTS; + running = HEARTH; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + asked.push({ url, body }); + const answer = (status: number, json: unknown) => ({ ok: status < 300, status, json: async () => json }) as Response; + if (url === '/api/sheets/rests') return answer(200, { system: running, rests: running === HEARTH ? rests : [] }); + if (url === '/api/sheets/rest') { + if (!body.preview && refuseCall) return answer(400, { error: refuseCall }); + const players = body.players === true ? ['ash', 'vex'] : body.players || []; + const characters = [ + ...players.map((p: string) => person('player', p, p === 'vex' ? 'Vex' : 'Ash', + p === 'vex' ? [{ what: 'health', label: 'HP', from: 9, to: body.rest === 'long_rest' ? 22 : null, pending: body.rest === 'long_rest' ? undefined : ['1d8 + @con_mod'] }] : [], + p === 'vex' && body.rest === 'long_rest' ? ['Exhausted'] : [])), + ...body.npcs.map((id: number) => person('npc', id, id === 7 ? 'GANGER' : 'DOG')), + ]; + return answer(200, { rest: RESTS.find((r) => r.id === body.rest), preview: !!body.preview, characters: body.preview ? characters : characters.map((c) => ({ ...c, line: `${body.rest} · ${c.name}` })) }); + } + if (url.includes('/api/sheets/system')) return answer(200, { system: running, systems: [{ id: HEARTH, name: 'Hearth', custom: true, version: 1 }, { id: 'cities_without_number', name: 'Cities Without Number' }] }); + return answer(200, []); + })); +}); +afterEach(() => { cleanup(); vi.unstubAllGlobals(); }); + +const NPCS = [{ id: 7, name: 'GANGER' }, { id: 9, name: 'DOG' }]; +const open = async (npcs = NPCS) => { + render(); + await screen.findByRole('region', { name: 'Rests' }); + // The preview has come back. + await waitFor(() => expect(gets().length).toBeGreaterThan(0)); +}; +const previews = () => asked.filter((a) => a.url === '/api/sheets/rest' && a.body.preview); +const calls = () => asked.filter((a) => a.url === '/api/sheets/rest' && !a.body.preview); +const gets = () => within(screen.getByRole('list', { name: 'What each one gets' })).queryAllByRole('listitem') + .map((li) => ({ name: li.querySelector('b')!.textContent, text: li.textContent })); + +describe('the rests panel', () => { + it('offers the game\'s rests and previews every player character and the map\'s NPCs once', async () => { + await open(); + expect(within(screen.getByLabelText('Rest')).getAllByRole('option').map((o) => o.textContent)).toEqual(['SHORT REST', 'LONG REST']); + await waitFor(() => expect(gets().map((g) => g.name)).toEqual(['ASH', 'VEX'])); + expect(previews().map((p) => p.body)).toEqual([{ rest: 'short_rest', players: true, npcs: [7, 9], preview: true }]); + expect(gets()[1].text).toContain('HP 9 + 1d8 + @con_mod'); + expect(gets()[0].text).toContain('Nothing changes.'); + }); + + it('previews again for another rest, with what wears off', async () => { + await open(); + fireEvent.change(screen.getByLabelText('Rest'), { target: { value: 'long_rest' } }); + await waitFor(() => expect(gets()[1].text).toContain('HP 9 → 22')); + expect(gets()[1].text).toContain('EXHAUSTED WEARS OFF'); + expect(screen.getByRole('button', { name: 'CALL LONG REST' })).toBeTruthy(); + expect(previews()).toHaveLength(2); + }); + + it('shows the map\'s NPCs too, or only those ticked, asking the server nothing more', async () => { + await open(); + await waitFor(() => expect(gets()).toHaveLength(2)); + await userEvent.click(screen.getByRole('radio', { name: /PLAYERS AND THE NPCS ON THIS MAP/ })); + expect(gets().map((g) => g.name)).toEqual(['ASH', 'VEX', 'GANGER', 'DOG']); + await userEvent.click(screen.getByRole('radio', { name: /CHOOSE/ })); + const ticks = within(screen.getByRole('group', { name: 'Characters' })); + // Every player character starts ticked. + expect(ticks.getAllByRole('checkbox').filter((t) => t.getAttribute('aria-checked') === 'true').map((t) => t.textContent)).toEqual(['ASH', 'VEX']); + await userEvent.click(ticks.getByRole('checkbox', { name: 'ASH' })); + await userEvent.click(ticks.getByRole('checkbox', { name: 'DOG' })); + expect(gets().map((g) => g.name)).toEqual(['VEX', 'DOG']); + expect(previews()).toHaveLength(1); + }); + + it('calls the rest for who is chosen, says so, and previews again', async () => { + await open(); + await waitFor(() => expect(gets()).toHaveLength(2)); + await userEvent.click(screen.getByRole('button', { name: 'CALL SHORT REST' })); + expect(await screen.findByRole('status')).toHaveTextContent('SHORT REST CALLED · 2 CHARACTERS'); + expect(calls().map((c) => c.body)).toEqual([{ rest: 'short_rest', players: true, npcs: [] }]); + await waitFor(() => expect(previews()).toHaveLength(2)); + + await userEvent.click(screen.getByRole('radio', { name: /CHOOSE/ })); + await userEvent.click(within(screen.getByRole('group', { name: 'Characters' })).getByRole('checkbox', { name: 'ASH' })); + await userEvent.click(within(screen.getByRole('group', { name: 'Characters' })).getByRole('checkbox', { name: 'GANGER' })); + await userEvent.click(screen.getByRole('button', { name: 'CALL SHORT REST' })); + await waitFor(() => expect(calls()).toHaveLength(2)); + expect(calls()[1].body).toEqual({ rest: 'short_rest', players: ['vex'], npcs: [7] }); + expect(await screen.findByText('SHORT REST CALLED · 2 CHARACTERS')).toBeTruthy(); + await userEvent.click(within(screen.getByRole('group', { name: 'Characters' })).getByRole('checkbox', { name: 'GANGER' })); + await userEvent.click(screen.getByRole('button', { name: 'CALL SHORT REST' })); + expect(await screen.findByText('SHORT REST CALLED · 1 CHARACTER')).toBeTruthy(); + + await userEvent.click(screen.getByRole('radio', { name: /PLAYERS AND THE NPCS ON THIS MAP/ })); + await userEvent.click(screen.getByRole('button', { name: 'CALL SHORT REST' })); + await waitFor(() => expect(calls()).toHaveLength(4)); + expect(calls()[3].body).toEqual({ rest: 'short_rest', players: true, npcs: [7, 9] }); + expect(await screen.findByRole('status')).toHaveTextContent('SHORT REST CALLED · 4 CHARACTERS'); + }); + + it('can\'t call a rest for nobody, and says what the server refused', async () => { + await open(); + await waitFor(() => expect(gets()).toHaveLength(2)); + await userEvent.click(screen.getByRole('radio', { name: /CHOOSE/ })); + for (const name of ['ASH', 'VEX']) await userEvent.click(within(screen.getByRole('group', { name: 'Characters' })).getByRole('checkbox', { name })); + expect(screen.getByText('Nobody chosen.')).toBeTruthy(); + expect((screen.getByRole('button', { name: 'CALL SHORT REST' }) as HTMLButtonElement).disabled).toBe(true); + await userEvent.click(within(screen.getByRole('group', { name: 'Characters' })).getByRole('checkbox', { name: 'VEX' })); + refuseCall = 'Not one of this game\'s rests'; + await userEvent.click(screen.getByRole('button', { name: 'CALL SHORT REST' })); + expect(await screen.findByRole('alert')).toHaveTextContent('Not one of this game\'s rests'); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('shows nothing for an answer that isn\'t a list of rests', async () => { + vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => [] }) as unknown as Response)); + render(); + await waitFor(() => expect(fetch).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 0)); + expect(screen.queryByRole('region', { name: 'Rests' })).toBeNull(); + }); + + it('shows nothing for a game without rests here', async () => { + rests = []; + render(); + await waitFor(() => expect(asked.some((a) => a.url === '/api/sheets/rests')).toBe(true)); + expect(screen.queryByRole('region', { name: 'Rests' })).toBeNull(); + }); +}); + +describe('the GAME tab', () => { + const props = (system: string, view = 'list'): any => ({ + socketRef: { current: { emit: vi.fn(), on: vi.fn(), off: vi.fn() } }, token: 'admintoken', onLogout: vi.fn(), + refreshLocations: vi.fn(), refreshRoads: vi.fn(), editData: {}, setEditData: vi.fn(), + editId: null, setEditId: vi.fn(), view, setView: vi.fn(), pendingRequests: [], setPendingRequests: vi.fn(), + selectedIds: [], setSelectedIds: vi.fn(), districts: [], fetchDistricts: vi.fn(), districtConfig: {}, setDistrictConfig: vi.fn(), + fetchGlobalSettings: vi.fn(), activeUsers: [], setIsAdminPayOpen: vi.fn(), handleSaveDefault: vi.fn(), handleLoadDefault: vi.fn(), + globalSettings: { game_system: system }, gameSystem: system, isPrimaryAdmin: false, roads: [], + locations: [ + { id: 7, name: 'GANGER', shape: 'enemy_rhombus', battle_map_id: null }, + { id: 8, name: 'GUARD', shape: 'enemy_rhombus', battle_map_id: 40, floor_index: 0 }, + { id: 3, name: 'VEX', shape: 'rhombus', battle_map_id: null }, + ], + activeBattleMapData: { locationId: 40, currentFloorIndex: 0, maps: [] }, + }); + const gameTab = async (system: string, view?: string) => { + running = system; + render(); + await userEvent.click(screen.getByText('GAME')); + await userEvent.click(screen.getByText(/TTRPG_SYSTEM$/)); + }; + + it('has the rests under a custom game, for a granted editor too, with the city map\'s NPCs', async () => { + await gameTab(HEARTH); + expect(await screen.findByRole('region', { name: 'Rests' })).toBeTruthy(); + await waitFor(() => expect(previews().at(-1)?.body.npcs).toEqual([7])); + }); + + it('has none under a built-in game, whose rests are its own buttons', async () => { + await gameTab('cities_without_number'); + await screen.findByText('LONG_REST (STRAIN −1)'); + expect(screen.queryByRole('region', { name: 'Rests' })).toBeNull(); + // Not even asked for. + expect(asked.some((a) => a.url === '/api/sheets/rests')).toBe(false); + }); +}); diff --git a/frontend/src/sheets/__tests__/restCalls.test.ts b/frontend/src/sheets/__tests__/restCalls.test.ts new file mode 100644 index 0000000..efa009c --- /dev/null +++ b/frontend/src/sheets/__tests__/restCalls.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi } from 'vitest'; +import { restCalls, npcsOnMap, chosenOf, requestFor, changeText, keyOf, type Rested } from '../restCalls'; + +/** + * Calling a rest from the GAME tab, as logic and requests (4f5). Approved mockup builder-rests, stage 3 + * (2026-10-09): who is on the map being viewed, who is chosen, what the server is asked, and how a + * change reads before and after the dice. + */ + +const VEX: Rested = { kind: 'player', username: 'vex', name: 'Vex', changes: [], gone: [] }; +const ASH: Rested = { kind: 'player', username: 'ash', name: 'Ash', changes: [], gone: [] }; +const GANGER: Rested = { kind: 'npc', location_id: 7, name: 'GANGER', changes: [], gone: [] }; +const EVERYONE = [ASH, VEX, GANGER]; + +describe('the NPCs on the map being viewed', () => { + const LOCATIONS = [ + { id: 1, name: 'GANGER', shape: 'enemy_rhombus', battle_map_id: null }, + { id: 2, name: 'DOG', shape: 'friendly_rhombus' }, + { id: 3, name: 'VEX', shape: 'rhombus', battle_map_id: null }, + { id: 4, name: 'SHOP', shape: 'box', battle_map_id: null }, + { id: 5, name: 'GUARD', shape: 'enemy_rhombus', battle_map_id: 40, floor_index: 0 }, + { id: 6, name: 'BOSS', shape: 'enemy_rhombus', battle_map_id: 40, floor_index: 1 }, + { id: 7, shape: 'friendly_rhombus', battle_map_id: 40, floor_index: 1 }, + { id: 8, name: 'ELSEWHERE', shape: 'enemy_rhombus', battle_map_id: 41, floor_index: 1 }, + ]; + + it('are the enemy and friendly tokens on the city map when no battle map is open', () => { + expect(npcsOnMap(LOCATIONS, 'list', null)).toEqual([{ id: 1, name: 'GANGER' }, { id: 2, name: 'DOG' }]); + // A battle-map view with no battle map loaded yet is the city; so is the city with one still loaded. + expect(npcsOnMap(LOCATIONS, 'battle_map', null)).toEqual([{ id: 1, name: 'GANGER' }, { id: 2, name: 'DOG' }]); + expect(npcsOnMap(LOCATIONS, 'list', { locationId: 40, currentFloorIndex: 1 })).toEqual([{ id: 1, name: 'GANGER' }, { id: 2, name: 'DOG' }]); + }); + + it('are the ones on the floor shown of the battle map open, a nameless one called NPC', () => { + expect(npcsOnMap(LOCATIONS, 'battle_map', { locationId: 40, currentFloorIndex: 1 })).toEqual([{ id: 6, name: 'BOSS' }, { id: 7, name: 'NPC' }]); + expect(npcsOnMap(LOCATIONS, 'battle_map', { locationId: 40, currentFloorIndex: 0 })).toEqual([{ id: 5, name: 'GUARD' }]); + }); +}); + +describe('who rests', () => { + it('is every player character, them and the map\'s NPCs, or the ones ticked', () => { + expect(chosenOf(EVERYONE, 'players', new Set())).toEqual([ASH, VEX]); + expect(chosenOf(EVERYONE, 'map', new Set())).toEqual(EVERYONE); + expect(chosenOf(EVERYONE, 'choose', new Set(['player:vex', 'npc:7']))).toEqual([VEX, GANGER]); + expect(chosenOf(EVERYONE, 'choose', new Set())).toEqual([]); + expect(keyOf(VEX)).toBe('player:vex'); + expect(keyOf(GANGER)).toBe('npc:7'); + }); + + it('is asked for by name only when chosen by hand', () => { + expect(requestFor('players', [ASH, VEX], [7, 9])).toEqual({ players: true, npcs: [] }); + expect(requestFor('map', EVERYONE, [7, 9])).toEqual({ players: true, npcs: [7, 9] }); + expect(requestFor('choose', [VEX, GANGER], [7, 9])).toEqual({ players: ['vex'], npcs: [7] }); + expect(requestFor('choose', [GANGER], [7])).toEqual({ players: false, npcs: [7] }); + }); +}); + +describe('a change', () => { + it('reads as a value moved, or one still waiting on dice', () => { + expect(changeText({ what: 'health', label: 'HP', from: 9, to: 22 })).toBe('HP 9 → 22'); + expect(changeText({ what: 'slots', label: 'Spell slots', from: 0, to: 4 })).toBe('SPELL SLOTS 0 → 4'); + expect(changeText({ what: 'health', label: 'HP', from: 9, to: null, pending: ['1d8 + @con_mod', '-1'] })).toBe('HP 9 + 1d8 + @con_mod + -1'); + expect(changeText({ what: 'health', label: 'HP', from: 9, to: null })).toBe('HP 9 + '); + }); +}); + +describe('the requests', () => { + const answering = (status: number, body: unknown) => vi.fn(async () => ({ ok: status < 300, status, json: async () => body }) as Response); + + it('list, preview and call the routes with the GM\'s login', async () => { + const fetcher = answering(200, { ok: 1 }); + const api = restCalls('admintoken', fetcher); + await api.list(); + await api.preview('long_rest', true, [7]); + await api.call('long_rest', ['vex'], []); + expect(fetcher.mock.calls).toEqual([ + ['/api/sheets/rests', { headers: { Authorization: 'Bearer admintoken' } }], + ['/api/sheets/rest', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admintoken' }, body: JSON.stringify({ rest: 'long_rest', players: true, npcs: [7], preview: true }) }], + ['/api/sheets/rest', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admintoken' }, body: JSON.stringify({ rest: 'long_rest', players: ['vex'], npcs: [] }) }], + ]); + expect(await api.list()).toEqual({ ok: true, value: { ok: 1 } }); + }); + + it('give the server\'s own words for a refusal, and say when it can\'t be reached', async () => { + expect(await restCalls('t', answering(400, { error: 'Not one of this game\'s rests' })).call('nap', true, [])) + .toEqual({ ok: false, status: 400, error: 'Not one of this game\'s rests' }); + expect(await restCalls('t', answering(500, null)).list()).toEqual({ ok: false, status: 500, error: 'Could not reach the server.' }); + expect(await restCalls('t', vi.fn(async () => { throw new Error('down'); })).list()).toEqual({ ok: false, status: 0, error: 'Could not reach the server.' }); + }); +}); diff --git a/frontend/src/sheets/restCalls.ts b/frontend/src/sheets/restCalls.ts new file mode 100644 index 0000000..00fe4fd --- /dev/null +++ b/frontend/src/sheets/restCalls.ts @@ -0,0 +1,96 @@ +// Calling a rest from the GAME tab, as logic and requests (4f5; approved mockup +// docs/mockups/builder-rests.html, 2026-10-09, stage 3). The server works each character out and +// writes it (backend/sheets/rests.js, GET /api/sheets/rests and POST /api/sheets/rest); this says +// who is on the map, who is chosen, and how a change reads. + +import type { Answer } from './systemsApi'; + +/** One value a rest changes: to a number, or still waiting on dice in a preview. */ +export interface RestChange { what: string; label: string; from: number; to: number | null; pending?: string[] } +/** One character a rest reaches, as the server answers. */ +export interface Rested { + kind: 'player' | 'npc'; + username?: string; + location_id?: number; + name: string; + changes: RestChange[]; + /** The names of the conditions that wear off. */ + gone: string[]; + /** The dice-log line, once called. */ + line?: string; +} +export interface RestAnswer { rest: { id: string; name: string }; preview: boolean; characters: Rested[] } + +/** Who rests: every player character; them and the NPCs on the map being viewed; or the ones ticked. */ +export type Who = 'players' | 'map' | 'choose'; +export const WHO_LABELS: Record = { + players: 'EVERY PLAYER CHARACTER', + map: 'PLAYERS AND THE NPCS ON THIS MAP', + choose: 'CHOOSE', +}; + +const UNREACHABLE = 'Could not reach the server.'; + +export const restCalls = (token: string, fetcher: typeof fetch = fetch) => { + const ask = async (url: string, body?: unknown): Promise> => { + let res: Response; + try { + res = await fetcher(url, body === undefined + ? { headers: { Authorization: `Bearer ${token}` } } + : { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify(body) }); + } catch { + return { ok: false, error: UNREACHABLE, status: 0 }; + } + const json = await res.json().catch(() => null); + if (res.ok) return { ok: true, value: json as T }; + return { ok: false, status: res.status, error: json && typeof json.error === 'string' ? json.error : UNREACHABLE }; + }; + return { + /** The running game's rests that are on; none for a built-in game. */ + list: () => ask<{ system: string; rests: { id: string; name: string }[] }>('/api/sheets/rests'), + /** What a rest would do: nothing rolled or written. */ + preview: (rest: string, players: true | string[] | false, npcs: number[]) => ask('/api/sheets/rest', { rest, players, npcs, preview: true }), + /** Call it: rolled, written, and one dice-log line each. */ + call: (rest: string, players: true | string[] | false, npcs: number[]) => ask('/api/sheets/rest', { rest, players, npcs }), + }; +}; + +const NPC_SHAPES = ['enemy_rhombus', 'friendly_rhombus']; + +/** + * The NPC tokens on the map being viewed: on a battle map, the ones on its floor shown; otherwise + * those on the city map. The same rule the map's own controls use (Sidebar's own token). + */ +export const npcsOnMap = ( + locations: { id: number; name?: string; shape?: string; battle_map_id?: number | null; floor_index?: number | null }[], + view: string | undefined, + battle: { locationId: number; currentFloorIndex: number } | null | undefined, +): { id: number; name: string }[] => locations + .filter((l) => NPC_SHAPES.includes(String(l.shape))) + .filter((l) => (view === 'battle_map' && battle + ? Number(l.battle_map_id) === Number(battle.locationId) && Number(l.floor_index ?? 0) === Number(battle.currentFloorIndex) + : l.battle_map_id == null)) + .map((l) => ({ id: l.id, name: l.name || 'NPC' })); + +/** A character's key, for ticking and lists. */ +export const keyOf = (r: Pick) => (r.kind === 'player' ? `player:${r.username}` : `npc:${r.location_id}`); + +/** The characters `who` picks out of everyone the panel previewed. */ +export const chosenOf = (everyone: Rested[], who: Who, ticked: Set): Rested[] => { + if (who === 'players') return everyone.filter((r) => r.kind === 'player'); + if (who === 'map') return everyone; + return everyone.filter((r) => ticked.has(keyOf(r))); +}; + +/** What the server is asked for, for `who`: players by name only when chosen by hand. */ +export const requestFor = (who: Who, chosen: Rested[], mapNpcs: number[]): { players: true | string[] | false; npcs: number[] } => { + if (who === 'players') return { players: true, npcs: [] }; + if (who === 'map') return { players: true, npcs: mapNpcs }; + const names = chosen.filter((r) => r.kind === 'player').map((r) => r.username!); + return { players: names.length ? names : false, npcs: chosen.filter((r) => r.kind === 'npc').map((r) => r.location_id!) }; +}; + +/** A change as the panel says it: "HP 9 → 22", or "HP 9 + 1d8 + @con_mod" before the dice. */ +export const changeText = (c: RestChange) => (c.to === null + ? `${c.label.toUpperCase()} ${c.from} + ${(c.pending ?? []).join(' + ')}` + : `${c.label.toUpperCase()} ${c.from} → ${c.to}`);