Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <img> 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)
Expand Down Expand Up @@ -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
Expand Down
54 changes: 48 additions & 6 deletions frontend/src/components/RestsPage.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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<ReturnType<typeof systemsApi>, 'previewSheet'>;
/** For the sheet the system plays with (what a rest can refill), and ON THE SAMPLE. */
api?: Pick<ReturnType<typeof systemsApi>, 'previewSheet' | 'tryRest'>;
}

const small: React.CSSProperties = { fontSize: 10, letterSpacing: 2, opacity: 0.75 };
Expand Down Expand Up @@ -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<Props['api']> }) {
const [tried, setTried] = useState<TriedRest | null>(null);
const [error, setError] = useState<string | null>(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 (
<div data-testid="rest-sample" style={{ border: '1px dashed var(--dark-green)', padding: '8px 10px', display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<span style={small}>THE SAMPLE CHARACTER · HP {token.current}/{token.max}</span>
{!rest.on ? <span style={why}>Turn it on to see what it does.</span>
: error ? <span role="alert" style={{ ...why, color: 'var(--danger)' }}>{error}</span>
: !tried ? <span style={why}>WORKING IT OUT…</span>
: tried.changes.length
? tried.changes.map((c) => <span key={c.what} style={{ fontSize: 12, color: 'var(--cyan)' }}>{changeText(c)}</span>)
: <span style={why}>Nothing changes for the sample.</span>}
</div>
);
}

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]);
Expand Down Expand Up @@ -168,6 +205,11 @@ function Detail({ rest, definition, edit, targets }: { rest: Rest; definition: D
</div>
<p style={{ ...why, fontSize: 11 }}>The same switch as AT A REST on the CONDITIONS page. A condition that ends after rounds keeps counting them.</p>
</div>

{api && <>
<span style={{ ...small, textAlign: 'right', paddingTop: 6 }}>ON THE SAMPLE</span>
<Sample rest={rest} definition={definition} api={api} />
</>}
</div>
</section>
);
Expand Down Expand Up @@ -224,7 +266,7 @@ export function RestsPage({ definition, edit, api }: Props) {
))}
</ul>
</div>
{current && <Detail key={current.id} rest={current} definition={definition} edit={edit} targets={targets} />}
{current && <Detail key={current.id} rest={current} definition={definition} edit={edit} targets={targets} api={api} />}
</div>
);
}
40 changes: 38 additions & 2 deletions frontend/src/components/TryItPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand Down Expand Up @@ -75,6 +77,8 @@ export function TryItPage({ definition, edit, api }: Props) {
const [level, setLevel] = useState(3);
const [npc, setNpc] = useState<TriedTier | null>(null);
const [npcError, setNpcError] = useState<string | null>(null);
const [restPick, setRestPick] = useState('');
const [restError, setRestError] = useState<string | null>(null);
const first = useRef(true);

// The sheet as the server draws it, and the formulas from what is typed: at once the first time,
Expand Down Expand Up @@ -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 (
<div data-testid="try-it-page" style={{ display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 1200 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', padding: '8px 12px', border: '1px solid var(--dark-green)' }}>
Expand Down Expand Up @@ -205,6 +227,20 @@ export function TryItPage({ definition, edit, api }: Props) {
)}
</section>

<section aria-label="A rest" style={box}>
<span style={small}>A REST</span>
{rests.length === 0 ? <p style={{ ...why, margin: 0 }}>No rest is on: turn one on in RESTS.</p> : (
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<select aria-label="Rest" value={pickedRest} style={input} onChange={(e) => setRestPick(e.target.value)}>
{rests.map((r) => <option key={r.id} value={r.id}>{r.name.toUpperCase()}</option>)}
</select>
<button type="button" className="utility-btn" style={{ ...btn, background: 'var(--green)', color: 'var(--black)' }} disabled={!api} onClick={callRest}>CALL A REST</button>
</div>
)}
{restError && <p role="alert" style={{ color: 'var(--danger)', fontSize: 11, margin: 0 }}>{restError}</p>}
<p style={{ ...why, margin: 0, fontSize: 11 }}>On this character and its token, through the game&apos;s own rules, dice rolled. The line the game would log goes in the log above.</p>
</section>

<section aria-label="An NPC to fight" style={box}>
<span style={small}>AN NPC TO FIGHT</span>
{tiers.length === 0 ? <p style={{ ...why, margin: 0 }}>No tiers yet: make one in NPCS.</p> : (
Expand Down
Loading
Loading