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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Under the hood

- **A custom system's words reach the building GM notes and the admin GAME tab.** The GM NOTES
folder, its header and editor, BANK SOUNDS and the INITIATIVE FOLLOWS BUILDING house rule use the
words a custom system's GM chose for the GM, the bank and initiative. The built-in systems keep
their wording exactly. The XP window, the vehicles window and the shop buy-back rate keep theirs
until custom systems can have those parts.

- **A custom system's words reach the initiative tracker and the dice log.** START, JOIN and END
INITIATIVE, the INIT score, the turn counter and the dice log's "rolled INITIATIVE" lines use the
words a custom system's GM chose for initiative and the turn. The built-in systems keep their
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,7 @@ CITY_NET/
│ │ │ ├── BootScreen.test.tsx # Types out by itself and finishes, skips on SKIP and nothing else, reports lines and clicks
│ │ │ ├── HitPoints.test.tsx
│ │ │ ├── wordsInBankAndShops.test.tsx # The glossary in the SHOP and VIEW_BANK menu buttons and the empty-shop steps: today's text under every built-in system and an unrenamed custom one, a custom system's own words where it renamed them (the shop window's own are in ShopWindow.test.tsx)
│ │ │ ├── wordsInGmNotesAndAdmin.test.tsx # The glossary in the building GM notes (window, notes box, admin editor) and the GAME tab (BANK SOUNDS, INITIATIVE FOLLOWS BUILDING): today's text under every built-in system and an unrenamed custom one, a custom system's own words (capitals in labels, as written in sentences) where it renamed them
│ │ │ ├── wordsInWindows.test.tsx # The glossary in the sidebar, sheet header, HEALTH folder and token window: exactly today's text under every built-in system and under a custom system that renamed nothing; a custom system's own words, in the label style, where it did
│ │ │ ├── HealthModelPanels.test.tsx # Each model's editor sending what the server expects and reporting it (spills, boxes turning heavier, harm moving up, out, refusals), the GM-only SET rows, other players seeing no numbers or notes, and the built-in systems' folder untouched and never asking
│ │ │ ├── healthBands.test.tsx # The bands' thresholds and colors, each rhythm's beats (the steady one unchanged, beats inside their stretch, weak ones drawn smaller), reduced motion, and the same band in the editing panel, the review panel and the stream
Expand Down
19 changes: 12 additions & 7 deletions frontend/src/components/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { BUILTIN_FONTS, type RemoteFont } from '../utils/fontLoader';
import { PNG_EXPORT_PRESETS, DEFAULT_PNG_EXPORT_WIDTH } from '../utils/mapExportBounds';
import { RECORD_DURATIONS, MAX_RECORD_SECONDS } from '../hooks/useMapExport';
import { parseGrant, describeGrant } from '../utils/tokenControl';
import { useWords, asLabel } from '../sheets/words';
import { useWords, asLabel, type WordLookup } from '../sheets/words';


// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1989,7 +1989,7 @@ export function AdminPanel({
neither has anything to do with shops; main admin only, since a granted
editor sees this view too and the server refuses them both. */}
{isAdmin && isPrimaryAdmin && !['enemy_rhombus', 'friendly_rhombus', 'rhombus', 'none'].includes(editData.shape) && (
<BuildingExtrasEditor ref={extrasRef} locationId={editId ?? null} token={token} photoUrl={editData.photo_url} />
<BuildingExtrasEditor ref={extrasRef} locationId={editId ?? null} token={token} photoUrl={editData.photo_url} gameSystem={gameSystem} />
)}

{editData.shape === 'friendly_rhombus' && (() => {
Expand Down Expand Up @@ -2216,11 +2216,12 @@ function HouseRulesPanel({ token, defs }: { token: string; defs: HouseRuleDef[]
);
}

const GLOBAL_HOUSE_RULES: HouseRuleDef[] = [
/** The house rules every system has. A function of the system's words: the first names initiative. */
const globalHouseRules = (word: WordLookup): HouseRuleDef[] => [
{
settingKey: 'initiative_follows_building',
label: 'INITIATIVE FOLLOWS BUILDING (ALL FLOORS SHARE ONE TRACKER)',
title: 'When enabled, all floors of the same building share a single initiative tracker. Players moving between floors stay in the same combat order. Each building and the city map still have their own separate initiatives.',
label: `${word('initiative', 'singular', 'INITIATIVE').toUpperCase()} FOLLOWS BUILDING (ALL FLOORS SHARE ONE TRACKER)`,
title: `When enabled, all floors of the same building share a single ${word('initiative', 'singular', 'initiative')} tracker. Players moving between floors stay in the same combat order. Each building and the city map still have their own separate ${word('initiative', 'plural', 'initiatives')}.`,
},
{
// Universal rather than per-system: every ruleset in the app has money, and whether
Expand Down Expand Up @@ -2297,6 +2298,8 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, activeUsers }: { token: str
const [systems, setSystems] = useState<PickerSystem[]>([]);
const [luckResetMsg, setLuckResetMsg] = useState<string | null>(null);
const [edgeGrantTarget, setEdgeGrantTarget] = useState<string>('');
// The picked system's own words for the house rules every system shares.
const word = useWords(system);

const refresh = () => {
fetch('/api/sheets/system').then(r => r.json()).then(d => {
Expand Down Expand Up @@ -2331,7 +2334,7 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, activeUsers }: { token: str
Each system keeps its own characters, banks and token health; switching back restores them.
</p>
<HouseRulesPanel token={token} defs={[
...GLOBAL_HOUSE_RULES,
...globalHouseRules(word),
...(system === 'cities_without_number' ? CWN_HOUSE_RULES : []),
...(system === 'cyberpunk_red' ? CPR_HOUSE_RULES : []),
...(system === 'shadowrun_6e' ? SR6_HOUSE_RULES : []),
Expand Down Expand Up @@ -2558,6 +2561,8 @@ function BankSoundsPanel({ token, globalSettings, fetchGlobalSettings }: { token
cashregister: 1, debtpaid: 1, highroller: 1, firstpay: 1, overdraft: 1,
});
const [saving, setSaving] = useState(false);
// A custom system's own word for the bank; today's BANK otherwise.
const bank = useWords(globalSettings?.game_system)('bank', 'singular', 'BANK').toUpperCase();

useEffect(() => {
if (!globalSettings) return;
Expand Down Expand Up @@ -2590,7 +2595,7 @@ function BankSoundsPanel({ token, globalSettings, fetchGlobalSettings }: { token
style={{ width: '100%', textAlign: 'left', display: 'flex', justifyContent: 'space-between' }}
onClick={() => setOpen(o => !o)}
>
<span>BANK SOUNDS</span>
<span>{bank} SOUNDS</span>
<span>{open ? '▲' : '▼'}</span>
</button>
{open && (
Expand Down
18 changes: 11 additions & 7 deletions frontend/src/components/BuildingExtrasEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { useWords } from '../sheets/words';

// The building photo and the GM's notes, in the admin edit view.
//
Expand Down Expand Up @@ -33,6 +34,8 @@ interface Props {
locationId: number | null;
token: string;
photoUrl?: string | null;
/** The running game system, for a custom system's own word for the GM. */
gameSystem?: string;
}

const label: React.CSSProperties = { display: 'block', marginBottom: 5 };
Expand All @@ -43,9 +46,10 @@ const hint: React.CSSProperties = { fontSize: '0.65rem', opacity: 0.8, marginTop
const STALE = 'If the backend was just updated, restart it.';

export const BuildingExtrasEditor = forwardRef<BuildingExtrasHandle, Props>(function BuildingExtrasEditor(
{ locationId, token, photoUrl },
{ locationId, token, photoUrl, gameSystem },
ref,
) {
const gm = useWords(gameSystem)('gm', 'short', 'GM');
/** A photo chosen but not yet uploaded, and the local preview of it. */
const [pending, setPending] = useState<{ file: File; preview: string } | null>(null);
/** The current photo marked for removal. */
Expand Down Expand Up @@ -115,21 +119,21 @@ export const BuildingExtrasEditor = forwardRef<BuildingExtrasHandle, Props>(func
body: JSON.stringify({ notes }),
});
const body = await r.json().catch(() => ({}));
if (!r.ok) problems.push(body.error || 'The GM notes were not saved.');
else if (typeof body.notes !== 'string') problems.push(`The server did not take the GM notes. ${STALE}`);
if (!r.ok) problems.push(body.error || `The ${gm} notes were not saved.`);
else if (typeof body.notes !== 'string') problems.push(`The server did not take the ${gm} notes. ${STALE}`);
else setSavedNotes(body.notes);
} catch {
problems.push('The GM notes were not saved.');
problems.push(`The ${gm} notes were not saved.`);
}
}
return problems;
},
}), [locationId, token, pending, removing, notes, savedNotes]);
}), [locationId, token, pending, removing, notes, savedNotes, gm]);

if (locationId == null) {
return (
<div style={{ marginTop: 10, fontSize: '0.7rem', opacity: 0.8 }}>
Save the building first to give it a photo or GM notes.
Save the building first to give it a photo or {gm} notes.
</div>
);
}
Expand Down Expand Up @@ -185,7 +189,7 @@ export const BuildingExtrasEditor = forwardRef<BuildingExtrasHandle, Props>(func
</div>

<div>
<label style={label} htmlFor="gm-notes-box">GM NOTES</label>
<label style={label} htmlFor="gm-notes-box">{gm.toUpperCase()} NOTES</label>
<textarea
id="gm-notes-box"
value={notes}
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/components/BuildingWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { TerminalWindow, TERMINAL_PREVIEW, useFolder, type TerminalAction, type
import { BuildingPreview } from './BuildingPreview';
import { buildingTypeById, typeLabel } from '../data/buildingTypes';
import { GmNotes } from './GmNotes';
import { useWords } from '../sheets/words';

// A building's info window, drawn as a terminal: folders down the left, the open one's
// text on the right, and the building itself in the corner.
Expand Down Expand Up @@ -41,10 +42,12 @@ interface Props {
export function BuildingWindow({
location, parts = [], title, gameSystem, pos, setPos, onClose, actions, isPrimaryAdmin, token,
}: Props) {
// A custom system's own word for the GM; today's GM otherwise.
const gm = useWords(gameSystem)('gm', 'short', 'GM');
const folders: TerminalFolder<BuildingTab>[] = [
{ id: 'info', label: 'INFO' },
{ id: 'residents', label: 'RESIDENTS' },
...(isPrimaryAdmin ? [{ id: 'gm' as const, label: 'GM NOTES' }] : []),
...(isPrimaryAdmin ? [{ id: 'gm' as const, label: `${gm.toUpperCase()} NOTES` }] : []),
];
// Opening another building starts at its INFO, not wherever the last one was left.
const [open, setOpen] = useFolder(folders, location?.id);
Expand All @@ -71,13 +74,13 @@ export function BuildingWindow({
</>
)}
{open === 'residents' && 'KNOWN RESIDENTS'}
{open === 'gm' && 'GM ONLY · PLAYERS NEVER SEE THIS'}
{open === 'gm' && `${gm.toUpperCase()} ONLY · PLAYERS NEVER SEE THIS`}
</>
)}
>
{open === 'info' && (location?.description || 'NO_DATA')}
{open === 'residents' && (location?.npcs || 'UNKNOWN')}
{open === 'gm' && <GmNotes locationId={location?.id} token={token} />}
{open === 'gm' && <GmNotes locationId={location?.id} token={token} gm={gm} />}
</TerminalWindow>
);
}
9 changes: 7 additions & 2 deletions frontend/src/components/GmNotes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ import React, { useEffect, useRef, useState } from 'react';
* carried with the building - they never leave the server except through a request that
* checks for the main admin.
*/
export function GmNotes({ locationId, token }: { locationId: number | undefined; token: string }) {
export function GmNotes({ locationId, token, gm }: {
locationId: number | undefined;
token: string;
/** What the running system calls the GM (today's GM unless a custom system renamed it), for the screen-reader label. */
gm: string;
}) {
const [notes, setNotes] = useState<string | null>(null);
const [draft, setDraft] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -64,7 +69,7 @@ export function GmNotes({ locationId, token }: { locationId: number | undefined;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, height: '100%' }}>
<textarea
aria-label="GM notes"
aria-label={`${gm} notes`}
value={draft}
onChange={(e) => setDraft(e.target.value)}
autoFocus
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/TokenWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export function TokenWindow({
</>
)}
{open === 'quick' && quickActions}
{open === 'gm' && gmNotesToken && <GmNotes locationId={location?.id} token={gmNotesToken} />}
{open === 'gm' && gmNotesToken && <GmNotes locationId={location?.id} token={gmNotesToken} gm={word('gm', 'short', 'GM')} />}
</TerminalWindow>
);
}
Expand Down
Loading
Loading