From 741e109c3edfb5be16af011cf058f680ffa1737e Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 11 Oct 2026 16:23:02 -0500 Subject: [PATCH] feat(system-builder): CALL A REST in TRY IT, and ON THE SAMPLE (4f6c) Approved mockup docs/mockups/builder-rests.html (2026-10-09), decisions 7 and the RESTS page's sample box. Completes 4f. - TRY IT: A REST, under HEALTH. One of the draft's rests on the made-up character and its pretend token, dice rolled (POST /try-rest, roll: true); the line the game would log goes in the health log, and the health views are asked for again so the model's panel and what others see follow the rest. - RESTS: ON THE SAMPLE. The picked rest on the sample character, its token hurt to half so "to full" shows, worked out by the server a moment after each edit with dice left to roll; an off rest says to turn it on. - sheets/tryIt.ts: afterRest (refilled numbers back into the character, the health model's own fields into the health sheet, formulas left to the server) and hurtToken. systemsApi.tryRest and TriedRest. --- CHANGELOG.md | 3 + README.md | 6 +- frontend/src/components/RestsPage.tsx | 54 ++++++++++++-- frontend/src/components/TryItPage.tsx | 40 +++++++++- .../components/__tests__/RestsPage.test.tsx | 40 +++++++++- .../components/__tests__/TryItPage.test.tsx | 74 +++++++++++++++++++ frontend/src/sheets/__tests__/tryIt.test.ts | 39 +++++++++- frontend/src/sheets/systemsApi.ts | 21 ++++++ frontend/src/sheets/tryIt.ts | 40 +++++++++- 9 files changed, 302 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7219cda4..504b6146 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 +- **Rests in TRY IT, and on the sample.** TRY IT can CALL A REST on its made-up character, dice + rolled, with the line the game would log. The RESTS page shows what each rest does to the sample + character, hurt to half health, as you edit it. Rests are complete. - **Rests for the examples and starters.** A copy of CWN takes 1 System Strain off on a long rest, Cyberpunk RED gives LUCK back and Shadowrun gives Edge back each session, as their own buttons do, each kept in a new RESOURCES stat. Sword & Spell, Starfarer and Story First get rests that diff --git a/README.md b/README.md index d1cdb239..4873b501 100644 --- a/README.md +++ b/README.md @@ -726,12 +726,12 @@ CITY_NET/ │ │ │ ├── SheetPreview.tsx # The sheet designer's live preview, drawn by the real SheetRenderer, as its owner, the GM and everyone else see it │ │ │ ├── NpcsPage.tsx # NPCS: the stat block (the character sheet, or one of its own) and TIERS, each box a number, a formula with @level or dice, typed text kept as typed, a TRY IT roll at a level │ │ │ ├── ConditionsPage.tsx # CONDITIONS: the standard set with switches and a system's own (+ CONDITION, DELETE after asking), up to 60; each one's name (the standard name as placeholder), chip label, icon (ConditionIconPicker), description, how it ends (when removed, after rounds, or at the rests ticked, the same setting as RESTS' WEARS OFF) and modifiers on all rolls, a stat or a formula, typed numbers kept as typed -│ │ │ ├── RestsPage.tsx # RESTS: the standard four with switches and a system's own (+ REST, DELETE after asking), up to 12; each one's name (the standard name as placeholder), ALSO COUNTS AS (a rest that would loop greyed), refills in order (what from the sheet the server previews, how, an amount, a track in a two-track system; one naming something gone said so), the refills of rests it counts as shown above its own, and WEARS OFF (the conditions, rounds ones greyed, one ending through a counted rest said so) +│ │ │ ├── RestsPage.tsx # RESTS: the standard four with switches and a system's own (+ REST, DELETE after asking), up to 12; each one's name (the standard name as placeholder), ALSO COUNTS AS (a rest that would loop greyed), refills in order (what from the sheet the server previews, how, an amount, a track in a two-track system; one naming something gone said so), the refills of rests it counts as shown above its own, and WEARS OFF (the conditions, rounds ones greyed, one ending through a counted rest said so), and ON THE SAMPLE (the rest on the sample character, its token hurt to half, worked out by the server a moment after each edit) │ │ │ ├── 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 +│ │ │ ├── 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, an NPC rolled from a tier, and CALL A REST: one of the draft's rests rolled on the character and its token, the game's line in the log. 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) │ │ │ └── __tests__/ # Component unit tests (Vitest + Testing Library) @@ -959,7 +959,7 @@ CITY_NET/ │ │ │ ├── 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 +│ │ │ ├── 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; a rest put back into TRY IT (refilled numbers into the character, the health model's own fields into the health sheet) and the token a rest is shown on, hurt to half │ │ │ ├── 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) │ │ │ ├── templates/ │ │ │ │ ├── generic.ts # Minimal fallback template diff --git a/frontend/src/components/RestsPage.tsx b/frontend/src/components/RestsPage.tsx index 1a1f4db3..5b01c843 100644 --- a/frontend/src/components/RestsPage.tsx +++ b/frontend/src/components/RestsPage.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import type { Definition, systemsApi } from '../sheets/systemsApi'; +import type { Definition, systemsApi, TriedRest } from '../sheets/systemsApi'; import type { CustomRenderSheet } from '../sheets/customTemplates'; import { restList, restCount, withRest, withNewRest, withoutRest, countedAs, makesLoop, refillTargets, describeRefill, @@ -9,18 +9,21 @@ import { conditionsOf } from '../sheets/conditions'; import { ConditionIcon } from './ConditionIcon'; import { Switch } from './ConditionsPage'; import { TRY_DELAY_MS } from './NpcsPage'; +import { sampleCharacter, hurtToken } from '../sheets/tryIt'; +import { changeText } from '../sheets/restCalls'; // The builder's RESTS page (4f4a; approved mockup docs/mockups/builder-rests.html, 2026-10-09): the // moments the game refills things and wears things off. The standard four, each with a switch, // renamed or turned off; the system's own, added and deleted; each one's name, the rests it also -// counts as, its refills in order, and the conditions it wears off. What it reads and writes is +// counts as, its refills in order, the conditions it wears off, and what it does to the sample +// character (4f6c, the server running it as the game would). What it reads and writes is // sheets/rests.ts; every change goes through the builder's `edit`. interface Props { definition: Definition; edit: (next: Definition) => void; - /** For the sheet the system plays with, which says what a rest can refill. */ - api?: Pick, 'previewSheet'>; + /** For the sheet the system plays with (what a rest can refill), and ON THE SAMPLE. */ + api?: Pick, 'previewSheet' | 'tryRest'>; } const small: React.CSSProperties = { fontSize: 10, letterSpacing: 2, opacity: 0.75 }; @@ -95,7 +98,41 @@ function Refills({ rest, definition, edit, targets }: { rest: Rest; definition: ); } -function Detail({ rest, definition, edit, targets }: { rest: Rest; definition: Definition; edit: Props['edit']; targets: Target[] }) { +/** + * ON THE SAMPLE (4f6c): the rest on the sample character, its token hurt to half so "to full" shows, + * worked out by the server as the game would and dice left to roll. Asked again a moment after the + * last change; a late answer never replaces a newer one. + */ +function Sample({ rest, definition, api }: { rest: Rest; definition: Definition; api: NonNullable }) { + const [tried, setTried] = useState(null); + const [error, setError] = useState(null); + const first = React.useRef(true); + useEffect(() => { + let live = true; + const timer = setTimeout(() => { + api.tryRest(definition, { rest: rest.id, sheet: sampleCharacter(definition), token: hurtToken(definition) }).then((r) => { + if (!live) return; + if (r.ok) { setTried(r.value); setError(null); } else { setTried(null); setError(r.error); } + }); + }, first.current ? 0 : TRY_DELAY_MS); + first.current = false; + return () => { live = false; clearTimeout(timer); }; + }, [api, definition, rest.id]); + const token = hurtToken(definition); + return ( +
+ THE SAMPLE CHARACTER · HP {token.current}/{token.max} + {!rest.on ? Turn it on to see what it does. + : error ? {error} + : !tried ? WORKING IT OUT… + : tried.changes.length + ? tried.changes.map((c) => {changeText(c)}) + : Nothing changes for the sample.} +
+ ); +} + +function Detail({ rest, definition, edit, targets, api }: { rest: Rest; definition: Definition; edit: Props['edit']; targets: Target[]; api?: Props['api'] }) { const [confirm, setConfirm] = useState(false); const others = restList(definition).filter((r) => r.id !== rest.id && r.on); const order = new Set([...countedAs(definition, rest.id), rest.id]); @@ -168,6 +205,11 @@ function Detail({ rest, definition, edit, targets }: { rest: Rest; definition: D

The same switch as AT A REST on the CONDITIONS page. A condition that ends after rounds keeps counting them.

+ + {api && <> + ON THE SAMPLE + + } ); @@ -224,7 +266,7 @@ export function RestsPage({ definition, edit, api }: Props) { ))} - {current && } + {current && } ); } diff --git a/frontend/src/components/TryItPage.tsx b/frontend/src/components/TryItPage.tsx index acf3f968..b2defff6 100644 --- a/frontend/src/components/TryItPage.tsx +++ b/frontend/src/components/TryItPage.tsx @@ -6,8 +6,9 @@ import { fieldsOn } from '../sheets/sheetDesigner'; import { tierList, settableFields, triedText } from '../sheets/npcs'; import { sampleCharacter, withTyped, withCharacterAsData, differsFromSample, withCharacterAsSample, - startingToken, asLocation, withMax, type Character, type PretendToken, + startingToken, asLocation, withMax, afterRest, type Character, type PretendToken, } from '../sheets/tryIt'; +import { restsOf } from '../sheets/rests'; import { SheetRenderer } from './SheetRenderer'; import { HeartMonitor } from './HitPoints'; import { ModelHealthEditor, ModelHealthDescription, NoHealthNotice, monitorBandFor, type SendHealth } from './HealthModelPanels'; @@ -17,7 +18,8 @@ import type { HealthView } from '../hooks/useHealthView'; // the draft as a player would meet it, unsaved changes included. A made-up character on the // draft's own sheet, its formulas worked out by the server from what is typed; its health on a // pretend token through the game's own rules, in the HEALTH folder's own panels; and an NPC made -// from a tier at a level. Nothing is saved, except SAVE AS THE SAMPLE CHARACTER, which writes the +// from a tier at a level; and CALL A REST (4f6c), one of the draft's rests on the character and its +// token, rolled. Nothing is saved, except SAVE AS THE SAMPLE CHARACTER, which writes the // typed stats back to STATS & RULES. The logic is sheets/tryIt.ts. interface Props { @@ -75,6 +77,8 @@ export function TryItPage({ definition, edit, api }: Props) { const [level, setLevel] = useState(3); const [npc, setNpc] = useState(null); const [npcError, setNpcError] = useState(null); + const [restPick, setRestPick] = useState(''); + const [restError, setRestError] = useState(null); const first = useRef(true); // The sheet as the server draws it, and the formulas from what is typed: at once the first time, @@ -151,6 +155,24 @@ export function TryItPage({ definition, edit, api }: Props) { }; const npcNumbers = settableFields(definition, sheet).filter((f) => f.type === 'number' && npc?.values[f.id]); + const rests = restsOf(definition); + const pickedRest = rests.some((r) => r.id === restPick) ? restPick : rests[0]?.id ?? ''; + /** One of the draft's rests on the character and its token, rolled; the game's line into the log. */ + const callRest = async () => { + if (!api || !pickedRest) return; + const r = await api.tryRest(definition, { rest: pickedRest, sheet: { ...character, ...healthSheet }, token, roll: true }); + if (!r.ok) { setRestError(r.error); return; } + setRestError(null); + const after = afterRest(definition, character, healthSheet, r.value); + const nextToken = after.token ?? token; + setCharacter(after.character); + setHealthSheet(after.healthSheet); + setToken(nextToken); + setLog((l) => [...l, r.value.line].slice(-8)); + const h = await api.tryHealth(definition, { token: nextToken, sheet: after.healthSheet }); + if (h.ok) setHealth(h.value); + }; + return (
@@ -205,6 +227,20 @@ export function TryItPage({ definition, edit, api }: Props) { )} +
+ A REST + {rests.length === 0 ?

No rest is on: turn one on in RESTS.

: ( +
+ + +
+ )} + {restError &&

{restError}

} +

On this character and its token, through the game's own rules, dice rolled. The line the game would log goes in the log above.

+
+
AN NPC TO FIGHT {tiers.length === 0 ?

No tiers yet: make one in NPCS.

: ( diff --git a/frontend/src/components/__tests__/RestsPage.test.tsx b/frontend/src/components/__tests__/RestsPage.test.tsx index c8934726..26c6d535 100644 --- a/frontend/src/components/__tests__/RestsPage.test.tsx +++ b/frontend/src/components/__tests__/RestsPage.test.tsx @@ -18,6 +18,7 @@ import type { Definition } from '../../sheets/systemsApi'; const req = createRequire(import.meta.url); const { checkDefinition } = req('../../../../backend/systemBuilder/definition.js'); const { effectiveSheet } = req('../../../../backend/systemBuilder/sheet.js'); +const { tryRest } = req('../../../../backend/systemBuilder/tryRest.js'); afterEach(cleanup); const H: Definition = { @@ -40,7 +41,13 @@ const H: Definition = { rests: { short_rest: { refills: [{ what: 'health', how: 'by', amount: '1d8 + @con_mod' }] }, long_rest: { counts_as: ['short_rest'] } }, conditions: { exhausted: { ends: 'rest', at: ['short_rest'] }, stunned: { ends: 'rounds', rounds: 1 } }, }; -const serverApi = () => ({ previewSheet: vi.fn(async (def: Definition) => ({ ok: true as const, value: { sheet: effectiveSheet(def), starter: effectiveSheet(def) } })) }); +const serverApi = () => ({ + previewSheet: vi.fn(async (def: Definition) => ({ ok: true as const, value: { sheet: effectiveSheet(def), starter: effectiveSheet(def) } })), + tryRest: vi.fn(async (def: Definition, state: object) => { + const r = tryRest(def, state); + return r.ok ? { ok: true as const, value: r } : { ok: false as const, error: r.error, status: 400 }; + }), +}); /** `api` null for a page with no server to ask (a default only fills in for undefined). */ const open = (start: Definition = H, api: ReturnType | null = serverApi()) => { @@ -218,3 +225,34 @@ describe('what wears off', () => { expect((last().conditions as Record).poisoned).toBeUndefined(); }); }); + +describe('on the sample', () => { + const sample = () => within(screen.getByTestId('rest-sample')); + + it('shows what the rest does to the sample character, its token hurt to half, dice left to roll', async () => { + const { api } = open({ ...H, samples: { con_mod: 1 }, rests: { ...(H.rests as object), long_rest: { counts_as: ['short_rest'], refills: [{ what: 'health', how: 'full' }, { what: 'fatigue', how: 'to', amount: '2' }] } } }); + expect(sample().getByText('THE SAMPLE CHARACTER · HP 5/10')).toBeTruthy(); + expect(await sample().findByText('HP 5 → 10')).toBeTruthy(); + expect(sample().getByText('FATIGUE 0 → 2')).toBeTruthy(); + expect(api!.tryRest.mock.calls[0][1]).toEqual({ rest: 'long_rest', sheet: { name: 'Sample character', con_mod: 1 }, token: { current: 5, max: 10, temp: 0 } }); + await userEvent.click(list().getByRole('button', { name: 'SHORT REST' })); + expect(await sample().findByText('HP 5 + 1d8 + @con_mod')).toBeTruthy(); + }); + + it('follows an edit a moment later', async () => { + open(); + await userEvent.click(list().getByRole('button', { name: 'END OF SESSION' })); + expect(await sample().findByText('Nothing changes for the sample.')).toBeTruthy(); + await userEvent.click(detail('End of session rest').getByRole('button', { name: '+ REFILL' })); + expect(await sample().findByText('HP 5 → 10', {}, { timeout: 2000 })).toBeTruthy(); + }); + + it('says to turn an off rest on, and isn\'t there without the server', async () => { + open(); + await userEvent.click(list().getByRole('switch', { name: 'Long rest on' })); + expect(await sample().findByText('Turn it on to see what it does.')).toBeTruthy(); + cleanup(); + open(H, null); + expect(screen.queryByTestId('rest-sample')).toBeNull(); + }); +}); diff --git a/frontend/src/components/__tests__/TryItPage.test.tsx b/frontend/src/components/__tests__/TryItPage.test.tsx index e11fa080..04948742 100644 --- a/frontend/src/components/__tests__/TryItPage.test.tsx +++ b/frontend/src/components/__tests__/TryItPage.test.tsx @@ -20,6 +20,7 @@ const { previewDerived } = req('../../../../backend/systemBuilder/derived.js'); const { tryHealth } = req('../../../../backend/systemBuilder/tryHealth.js'); const { npcSheetOf } = req('../../../../backend/systemBuilder/npc.js'); const { rollTier } = req('../../../../backend/systemBuilder/tierRolls.js'); +const { tryRest } = req('../../../../backend/systemBuilder/tryRest.js'); afterEach(() => { cleanup(); vi.useRealTimers(); }); @@ -47,6 +48,11 @@ const serverApi = () => ({ const fields = new Map(fieldsOf(npcSheetOf(def)).map((f: { id: string }) => [f.id, f])); return { ok: true as const, value: rollTier(t, level, fields, { hp: 9999, defense: 99 }) }; }), + // Every die shows its highest face, so a rolled rest is known. + tryRest: vi.fn(async (def: Definition, state: object) => { + const r = tryRest(def, state, () => 0.999999); + return r.ok ? { ok: true as const, value: r } : { ok: false as const, error: r.error, status: 400 }; + }), }); const open = (start: Definition = H, api: ReturnType | null = serverApi()) => { @@ -63,6 +69,74 @@ const health = () => within(screen.getByRole('region', { name: 'Health' })); const npcBox = () => within(screen.getByRole('region', { name: 'An NPC to fight' })); const valueOf = (label: string) => (character().getByLabelText(label) as HTMLInputElement).value; +/** HEARTH with rests (4f6c): a breather rolls, the end of a session sets a stat. */ +const RESTED: Definition = { ...H, rests: { + short_rest: { refills: [{ what: 'health', how: 'by', amount: '1d6 + @level' }] }, + end_of_session: { refills: [{ what: 'str', how: 'to', amount: '@level' }] }, + end_of_scene: { on: false }, +} }; +const restBox = () => within(screen.getByRole('region', { name: 'A rest' })); + +describe('CALL A REST', () => { + it('rolls one of the draft\'s rests on the pretend token, logging the line the game would', async () => { + const { api } = open(RESTED); + await health().findByRole('button', { name: 'DAMAGE' }); + fireEvent.change(health().getByLabelText('Amount'), { target: { value: '8' } }); + await userEvent.click(health().getByRole('button', { name: 'DAMAGE' })); + await waitFor(() => expect(health().getByText(/^2 \/ 10/)).toBeTruthy()); + expect(health().getByRole('img', { name: /Heart monitor/ }).getAttribute('data-band')).not.toBe('steady'); + expect(within(restBox().getByLabelText('Rest')).getAllByRole('option').map((o) => o.textContent)).toEqual(['SHORT REST', 'LONG REST', 'END OF SESSION']); + await userEvent.click(restBox().getByRole('button', { name: 'CALL A REST' })); + // 2 + a 6 + level 3, held to the maximum of 10. + await waitFor(() => expect(health().getByText(/^10 \/ 10/)).toBeTruthy()); + expect(screen.getByTestId('health-log').textContent).toContain('SHORT REST · Sample: HP 2 → 10; rolled 1d6 + @level = 9 (1d6: 6)'); + expect(api!.tryRest.mock.calls[0][1]).toMatchObject({ rest: 'short_rest', roll: true, token: { current: 2, max: 10 } }); + // What others see is asked for again, for the token as the rest left it: a steady heartbeat. + await waitFor(() => expect(api!.tryHealth).toHaveBeenLastCalledWith(expect.anything(), { token: { current: 10, max: 10, temp: 0 }, sheet: {} })); + await waitFor(() => expect(health().getByRole('img', { name: /Heart monitor/ }).getAttribute('data-band')).toBe('steady')); + }); + + it('puts what a rest refilled back into the character, its formulas worked out again', async () => { + open(RESTED); + await waitFor(() => expect(valueOf('Save')).toBe('11')); + fireEvent.change(restBox().getByLabelText('Rest'), { target: { value: 'end_of_session' } }); + await userEvent.click(restBox().getByRole('button', { name: 'CALL A REST' })); + await waitFor(() => expect(valueOf('Strength')).toBe('3')); + await waitFor(() => expect(valueOf('Save')).toBe('10')); + expect(screen.getByTestId('health-log').textContent).toContain('END OF SESSION · Sample: Strength 2 → 3'); + }); + + it('says when no rest is on, and what the server refused', async () => { + open({ ...H, rests: { short_rest: { on: false }, long_rest: { on: false }, end_of_scene: { on: false }, end_of_session: { on: false } } }); + expect(await restBox().findByText('No rest is on: turn one on in RESTS.')).toBeTruthy(); + cleanup(); + const api = serverApi(); + api.tryRest.mockResolvedValueOnce({ ok: false as const, error: 'Not one of this system\'s rests', status: 400 } as never); + open(RESTED, api); + await userEvent.click(await restBox().findByRole('button', { name: 'CALL A REST' })); + expect(await restBox().findByRole('alert')).toHaveTextContent('Not one of this system\'s rests'); + }); + + it('redraws a custom model\'s panel from what the rest left: the second track cleared', async () => { + open({ ...TRACKS, rests: { long_rest: { refills: [{ what: 'health', how: 'full' }] } } }); + await userEvent.click(within(await health().findByRole('group', { name: /track/i })).getByRole('button', { name: 'STUN' })); + fireEvent.change(health().getByLabelText('MAX STUN'), { target: { value: '6' } }); + await userEvent.click(health().getByRole('button', { name: 'SET' })); + fireEvent.change(health().getByLabelText('Amount'), { target: { value: '6' } }); + await userEvent.click(health().getByRole('button', { name: 'DAMAGE' })); + // What others see: the stun track full. + await waitFor(() => expect(health().getByTestId('second-track').textContent).toContain('STUN — FULL')); + fireEvent.change(restBox().getByLabelText('Rest'), { target: { value: 'long_rest' } }); + await userEvent.click(restBox().getByRole('button', { name: 'CALL A REST' })); + await waitFor(() => expect(health().getByTestId('second-track').textContent).toBe('STUN')); + }); + + it('can\'t be called without the server', () => { + open(RESTED, null); + expect((restBox().getByRole('button', { name: 'CALL A REST' }) as HTMLButtonElement).disabled).toBe(true); + }); +}); + describe('the made-up character', () => { it('starts as the sample on the draft\'s own sheet, its formulas worked out by the server', async () => { open(); diff --git a/frontend/src/sheets/__tests__/tryIt.test.ts b/frontend/src/sheets/__tests__/tryIt.test.ts index 6605934d..7fda0cc8 100644 --- a/frontend/src/sheets/__tests__/tryIt.test.ts +++ b/frontend/src/sheets/__tests__/tryIt.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect } from 'vitest'; import { createRequire } from 'module'; import { sampleCharacter, withTyped, withCharacterAsData, differsFromSample, withCharacterAsSample, - startingToken, asLocation, withMax, SAMPLE_NAME, + startingToken, asLocation, withMax, afterRest, hurtToken, SAMPLE_NAME, } from '../tryIt'; import type { Definition } from '../systemsApi'; @@ -15,6 +15,7 @@ const req = createRequire(import.meta.url); const { checkDefinition } = req('../../../../backend/systemBuilder/definition.js'); const { previewDerived } = req('../../../../backend/systemBuilder/derived.js'); const { tryHealth } = req('../../../../backend/systemBuilder/tryHealth.js'); +const { tryRest } = req('../../../../backend/systemBuilder/tryRest.js'); const H: Definition = { format: 1, @@ -100,3 +101,39 @@ describe('the pretend token', () => { expect(r.token).toEqual({ current: 6, max: 10, temp: 0 }); }); }); + +describe('after a rest (4f6c)', () => { + const TRACKS: Definition = { + format: 1, name: 'T', + stats: [{ id: 'abilities', label: 'ABILITIES', stats: [{ id: 'level', label: 'Level' }, { id: 'fatigue', label: 'Fatigue' }] }], + derived: [{ id: 'ward', label: 'Ward', formula: '@fatigue + 10' }], + core: { health: { model: 'tracks', tracks: [{ id: 'physical', label: 'PHYSICAL' }, { id: 'stun', label: 'STUN' }] } }, + rests: { long_rest: { refills: [{ what: 'health', how: 'full' }, { what: 'fatigue', how: 'by', amount: '-1' }] } }, + }; + + it('puts refilled numbers back into the character, the health model\'s own into the health sheet, formulas left out', () => { + const character = { name: 'Sample character', level: 2, fatigue: 3 }; + const healthSheet = { stun: 4, stun_max: 6 }; + const tried = tryRest(TRACKS, { rest: 'long_rest', sheet: { ...character, ...healthSheet }, token: { current: 3, max: 9 } }); + const after = afterRest(TRACKS, character, healthSheet, tried); + expect(after.character).toEqual({ name: 'Sample character', level: 2, fatigue: 2 }); + expect(after.healthSheet).toEqual({ stun: 0, stun_max: 6 }); + expect(after.token).toEqual({ current: 9, max: 9, temp: 0 }); + }); + + it('fills a number the character never typed, and keeps no token where there was none', () => { + const SET: Definition = { ...TRACKS, rests: { long_rest: { refills: [{ what: 'fatigue', how: 'to', amount: '@level' }] } } }; + const tried = tryRest(SET, { rest: 'long_rest', sheet: { level: 2 } }); + const after = afterRest(SET, { level: 2 }, {}, tried); + expect(after.character).toEqual({ level: 2, fatigue: 2 }); + // Nothing moved (fatigue already none), nothing put back. + expect(afterRest(TRACKS, { level: 2 }, {}, tryRest(TRACKS, { rest: 'long_rest', sheet: { level: 2 } })).character).toEqual({ level: 2 }); + expect(after.healthSheet).toEqual({}); + expect(after.token).toBeNull(); + }); + + it('shows a rest on a token hurt to half, so "to full" has something to show', () => { + expect(hurtToken({ format: 1, name: 'P' })).toEqual({ current: 5, max: 10, temp: 0 }); + expect(hurtToken({ format: 1, name: 'W', core: { health: { model: 'wounds', count: 3 } } })).toEqual({ current: 1, max: 3, temp: 0 }); + }); +}); diff --git a/frontend/src/sheets/systemsApi.ts b/frontend/src/sheets/systemsApi.ts index 769ff37c..edaa19e4 100644 --- a/frontend/src/sheets/systemsApi.ts +++ b/frontend/src/sheets/systemsApi.ts @@ -33,6 +33,21 @@ export type TriedBox = /** A tier worked out for a level (tierRolls.js rollTier). */ export interface TriedTier { level: number; hp: TriedBox; defense: TriedBox; values: Record } +/** A draft's rest tried on a made-up character (backend/systemBuilder/tryRest.js, 4f6a). */ +export interface TriedRest { + ok: true; + rest: { id: string; name: string }; + /** The character after the rest, its formulas worked out again. */ + sheet: Record; + token: { current: number; max: number; temp: number } | null; + conditions: { id: string; left?: number }[]; + changes: { what: string; label: string; from: number; to: number | null; pending?: string[] }[]; + gone: string[]; + rolls: { amount: string; value: number; dice: { count: number; sides: number; rolls: number[] }[] }[]; + /** The dice-log line the game would write. */ + line: string; +} + /** Health tried on a pretend token (backend/systemBuilder/tryHealth.js). */ export interface TriedHealth { model: string; @@ -111,6 +126,12 @@ export const systemsApi = (token: string, fetcher: typeof fetch = fetch) => { * any, applied to a pretend token and sheet by the game's own rules, and the HEALTH folder's * views of the result for the owner (`full`) and for everyone else (`others`). */ + /** + * One of a draft's rests on a made-up character (4f6a): previewed with dice waiting, or rolled + * when `roll` (TRY IT's CALL A REST). Changes nothing. + */ + tryRest: (definition: Definition, state: { rest: string; sheet?: Record; token?: { current: number; max: number; temp?: number } | null; conditions?: { id: string }[]; roll?: boolean }) => + call(fetcher, token, '/api/systems/try-rest', 'POST', { definition, ...state }), tryHealth: (definition: Definition, state: { token: { current: number; max: number; temp?: number }; sheet?: Record; action?: Record }) => call(fetcher, token, '/api/systems/try-health', 'POST', { definition, ...state }), /** Make the stored draft what the game runs; refused, with `problems`, while it has any. */ diff --git a/frontend/src/sheets/tryIt.ts b/frontend/src/sheets/tryIt.ts index 72f54155..8a23fc52 100644 --- a/frontend/src/sheets/tryIt.ts +++ b/frontend/src/sheets/tryIt.ts @@ -5,9 +5,9 @@ // (POST /api/systems/preview-values, the typed numbers as the sample); health is tried on a pretend // token by the game's own rules (POST /api/systems/try-health). -import type { Definition } from './systemsApi'; +import type { Definition, TriedRest } from './systemsApi'; import type { CustomRenderField } from './customTemplates'; -import { allStats, sampleOf, withSample } from './statsRules'; +import { allStats, sampleOf, withSample, formulaList } from './statsRules'; /** A made-up character: field id to what is in it. */ export type Character = Record; @@ -67,6 +67,42 @@ export const startingToken = (def: Definition): PretendToken => { return { current: max, max, temp: 0 }; }; +/** + * The pretend token a rest is shown on (the RESTS page's ON THE SAMPLE, 4f6c): the starting one hurt + * to half, so "to full" has something to show. + */ +export const hurtToken = (def: Definition): PretendToken => { + const t = startingToken(def); + return { ...t, current: Math.floor(t.max / 2) }; +}; + +/** + * TRY IT after a rest (4f6c): what a refill changed goes back into the typed character, the health + * model's own fields into the health sheet, and the token is the rest's. Formulas are left out: + * the server works them out again from what is typed. + */ +export const afterRest = ( + def: Definition, + character: Character, + healthSheet: Record, + tried: Pick, +): { character: Character; healthSheet: Record; token: PretendToken | null } => { + const formulas = new Set(formulaList(def).map((f) => f.id)); + const refilled = new Set(tried.changes.filter((c) => !c.what.startsWith('health')).map((c) => c.what)); + const next: Character = { ...character }; + for (const id of refilled) { + const v = tried.sheet[id]; + if (typeof v === 'number') next[id] = v; + } + // What is left is the health model's own: refills change only refillable numbers, and the rest + // was sent the character and the health sheet alone. + const health: Record = { ...healthSheet }; + for (const [id, v] of Object.entries(tried.sheet)) { + if (!refilled.has(id) && !(id in character) && !formulas.has(id)) health[id] = v; + } + return { character: next, healthSheet: health, token: tried.token ? { current: tried.token.current, max: tried.token.max, temp: tried.token.temp } : null }; +}; + /** The pretend token as the HEALTH folder's panels read a token. */ export const asLocation = (token: PretendToken) => ({ id: -1, hp_current: token.current, hp_max: token.max, hp_temp: token.temp });