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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Under the hood

- **A custom system's words reach the shops and the bank button.** The shop's messages, receipt
and refusals, the steps for stocking an empty shop, and the SHOP, VIEW_BANK and SHOP_CATALOGUES
buttons use the words a custom system's GM chose for shops, money, the bank, the GM and
characters: a game that calls its shops MERCHANTS says "NOTHING HERE THIS MERCHANT WOULD BUY".
A term the system did not rename keeps today's text, and the built-in systems are unchanged.
Custom systems get shops of their own in a later piece.

- **A custom system's words show on the sheet, the token window and the HEALTH folder.** When the
GM who built a custom system gives hit points, the character, the GM or initiative a name of
their own, those places use it: a game that calls hit points VIGOR shows VIGOR where HIT_POINTS
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,7 @@ CITY_NET/
│ │ │ ├── DraggableWindow.test.tsx
│ │ │ ├── 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)
│ │ │ ├── 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 Expand Up @@ -722,7 +723,7 @@ CITY_NET/
│ │ │ └── useSocket.pendingRequests.test.ts # Pending edit-request state; regression for stale requests on newly-promoted temp admins
│ │ ├── data/
│ │ │ ├── buildingTypes.ts # What a building is for and which catalogues it sells, mirrored from the server, plus which systems have shops (every one with a sheet, held equal to the server's gate by a test) and what each game calls a storefront - same ids everywhere, so a Ripperdoc becomes a Street Doc when the system changes rather than disappearing
│ │ │ └── shopRules.ts # The overdraft house rule, the buy-back rate and how it resolves, and the words for every refusal. Mirrored from backend/shops and compared against it value for value, since the window quoting one price and the server paying another is the failure worth fearing
│ │ │ └── shopRules.ts # The overdraft house rule, the buy-back rate and how it resolves, and the words for every refusal (refusalText, in a custom system's own words for shops, money and characters). Mirrored from backend/shops and compared against it value for value, since the window quoting one price and the server paying another is the failure worth fearing
│ │ ├── sheets/
│ │ │ ├── types.ts # Sheet template type system (fields, sections, header, death saves, NPC tiers)
│ │ │ ├── index.ts # Template registry, getMaxPairs, GATED_TABS/hiddenTabsFor (house-rule-gated sheet tabs). getTemplate also answers for published custom systems
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
import rhombusIcon from './assets/rhombus.svg';
import { useMapData } from './hooks/useMapData';
import { useCustomTemplates } from './hooks/useCustomTemplates';
import { wordFor } from './sheets/words';
import { useSocket } from './hooks/useSocket';
import { THEMES, ThemeContext } from './theme/themes';
import type { ThemeName } from './theme/themes';
Expand Down Expand Up @@ -1861,6 +1862,7 @@ function App() {
))}
{token && showAdminPanel && (
<AdminPanel
gameSystem={gameSystem}
isAdmin={isAdmin}
setIsAdminPayOpen={setIsAdminPayOpen}
setIsAdminXpOpen={setIsAdminXpOpen}
Expand Down Expand Up @@ -2415,6 +2417,7 @@ function App() {
emit: (event, payload) => socketRef.current?.emit(event, payload),
someoneEditing: isSomeoneEditing,
notify: setNotification,
words: (term, form, builtIn) => wordFor(gameSystem, term, form, builtIn),
open: {
shop: (loc) => setShopLocation(loc),
battleMap: (id) => enterBattleMap(id),
Expand Down Expand Up @@ -2473,6 +2476,7 @@ function App() {
sheetLink: linked && tokenSheetLink ? { sheet_id: tokenSheetLink.sheet_id, npc_label: tokenSheetLink.npc_label } : null,
tier: tiers && tiers.length > 0 ? (genTier || tiers[0].id) : undefined,
isOwner,
words: (term, form, builtIn) => wordFor(gameSystem, term, form, builtIn),
open: {
ownSheet: () => setIsSheetOpen(true),
playerSheet: (owner) => setOpenPlayerSheetUser(owner),
Expand Down
7 changes: 6 additions & 1 deletion frontend/src/components/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +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';


// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -194,9 +195,13 @@ export function AdminPanel({
signs, fetchSigns, remoteFonts, setRemoteFonts, isPlacingSign, setIsPlacingSign, pendingSignPos, setPendingSignPos, selectedSignId, setSelectedSignId, signTransformMode, setSignTransformMode, signTransformActive, setSignTransformActive, handleUpdateSign, signMesh,
activeUsers, onGrantAccess, onRevokeAccess, onOpenNpcLibrary, onToggleHidden,
onExportPng, onStartRecording, onStopRecording, isRecording, isExporting, recordSecondsLeft,
gameSystem,
cityGenDrawMode, setCityGenDrawMode, genBoundaryTrail, setGenBoundaryTrail,
cityLayout, setCityLayout, citySeed, setCitySeed, lastCitySeed, setLastCitySeed, cityWater, setCityWater, cityParkPonds, setCityParkPonds, cityRoundabouts, setCityRoundabouts,
}: any) {
// A custom system's own words for the GAME tab; today's text otherwise. Before the early
// return below: it is a hook.
const word = useWords(gameSystem);
if (view === 'battle_map') {
return (
<BattleAdminPanel
Expand Down Expand Up @@ -1098,7 +1103,7 @@ export function AdminPanel({
onClick={() => setIsCatalogueOpen?.(true)}
className="utility-btn"
style={{ width: '100%', marginTop: '8px' }}
>SHOP_CATALOGUES</button>
>{asLabel(word('shop', 'singular', 'SHOP'))}_CATALOGUES</button>
</>
)}
<BankSoundsPanel token={token} globalSettings={globalSettings} fetchGlobalSettings={fetchGlobalSettings} />
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/EmptyShopSteps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
import { uploadedIn } from '../sheets/uploadedCatalogues';
import { BOOK_SYSTEM } from '../sheets/ownedItems';
import { columnsFor } from '../sheets/catalogueSchema';
import { useWords, asLabel } from '../sheets/words';

// What a GM is told when a shop has nothing to sell.
//
Expand Down Expand Up @@ -41,6 +42,9 @@ interface Props {

/** The steps, or nothing when this shop has something to sell. */
export function EmptyShopSteps({ buildingType, system, onOpenCatalogues }: Props) {
// A custom system's own word for shops; today's text otherwise. Before the early return:
// it is a hook.
const word = useWords(system);
const shelves = emptyShelves(buildingType, system);
if (!shelves.length) return null;

Expand Down Expand Up @@ -71,11 +75,11 @@ export function EmptyShopSteps({ buildingType, system, onOpenCatalogues }: Props
THIS {shop.toUpperCase()} HAS NOTHING TO SELL YET
</div>
<div style={{ color: 'var(--grid-section)', marginBottom: 6 }}>
Shops in this game sell only what you add. It sells {shelves.map((c) => catalogueLabel(c, system)).join(' and ')}.
{word('shop', 'plural', 'Shops')} in this game sell only what you add. It sells {shelves.map((c) => catalogueLabel(c, system)).join(' and ')}.
</div>
<ol style={{ margin: 0, paddingLeft: 18 }}>
<li>
Open <strong>SHOP_CATALOGUES</strong>, in the admin panel&apos;s <strong>GAME</strong> tab.
Open <strong>{asLabel(word('shop', 'singular', 'SHOP'))}_CATALOGUES</strong>, in the admin panel&apos;s <strong>GAME</strong> tab.
{onOpenCatalogues && (
<>
{' '}
Expand Down
45 changes: 27 additions & 18 deletions frontend/src/components/ShopWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
buildingTypeById, shelvedCatalogues, catalogueById, typeLabel, catalogueLabel, type ShopStock,
} from '../data/buildingTypes';
import {
SETTLE_BALANCE, SETTLE_DEBT, REFUSAL_TEXT, buybackValue,
SETTLE_BALANCE, SETTLE_DEBT, refusalText, buybackValue,
type Settle, type RefusalReason,
} from '../data/shopRules';
import { ownedItems, sellableAt, BOOK_SYSTEM } from '../sheets/ownedItems';
Expand All @@ -29,6 +29,7 @@ import {
} from '../sheets/inventory';
import { CWN_WEAPON_ROWS, CWN_VEHICLE_ROWS } from '../sheets/templates/cities_without_number';
import { usePlayerSheet } from '../hooks/usePlayerSheet';
import { useWords, asLabel, todaysWords, type WordLookup } from '../sheets/words';
import {
addBuy, stepBuy, sellCounts, cartTotals, groupSells, slotsWanted, cartCarry,
type CartBuy, type CartSell,
Expand Down Expand Up @@ -146,7 +147,7 @@ const stamp = (d: Date) => {
* A checkout, set out like a receipt: the shop and the time, every line, the total, and
* where the money went. Formal on purpose - it is the record of money changing hands.
*/
function Receipt({ receipt: r, shop }: { receipt: CartReceipt; shop: string }) {
function Receipt({ receipt: r, shop, word = todaysWords }: { receipt: CartReceipt; shop: string; word?: WordLookup }) {
const row: React.CSSProperties = { display: 'flex', justifyContent: 'space-between', gap: 12 };
const rule: React.CSSProperties = { borderTop: '1px dashed var(--dark-green)', margin: '6px 0' };
const where = r.net > 0
Expand All @@ -155,7 +156,7 @@ function Receipt({ receipt: r, shop }: { receipt: CartReceipt; shop: string }) {
return (
<div data-testid="cart-receipt" style={{ ...mono(10), letterSpacing: 0, border: '1px solid var(--green)', padding: '8px 10px', marginBottom: 10, color: 'var(--green)' }}>
<div style={{ ...row, fontWeight: 'bold' }}>
<span>RECEIPT · {(shop || 'SHOP').toUpperCase()}</span>
<span>RECEIPT · {(shop || word('shop', 'singular', 'SHOP')).toUpperCase()}</span>
<span>{stamp(r.at)}</span>
</div>
<div style={rule} />
Expand Down Expand Up @@ -299,6 +300,14 @@ export function ShopWindow({
const [pos, setPos] = useState({ x: 140, y: 90 });
const [tab, setTab] = useState<Tab>('buy');
const [filter, setFilter] = useState('');
// A custom system's own words for the shop, money, the GM and characters; today's text otherwise.
const word = useWords(system);
const gm = word('gm', 'short', 'GM');
const shopWord = word('shop', 'singular', 'shop');
const SHOP = word('shop', 'singular', 'SHOP').toUpperCase();
const moneyWord = word('money', 'plural', 'credits');
const CHARACTER = word('character', 'singular', 'CHARACTER').toUpperCase();
const characterWord = word('character', 'singular', 'character');
/** Narrows the sell list, the way the buy side's filter narrows a shelf. */
const [sellFilter, setSellFilter] = useState('');

Expand Down Expand Up @@ -548,7 +557,7 @@ export function ShopWindow({
const balance = account?.balance ?? 0;
if (net > balance && !settle) {
if (!overdraftAllowed) {
setRefused(`Not enough credits — the cart comes to ${money(net)} and you have ${money(balance)}.`);
setRefused(`Not enough ${moneyWord} — the cart comes to ${money(net)} and you have ${money(balance)}.`);
return;
}
// Asked rather than assumed. Debt and a negative balance are different problems.
Expand All @@ -568,7 +577,7 @@ export function ShopWindow({
checkoutTimer.current = setTimeout(() => {
checkoutTimer.current = null;
setBusy(false);
setRefused('The shop did not answer. Check your balance before trying again — if the server was just updated, it needs restarting.');
setRefused(`The ${shopWord} did not answer. Check your balance before trying again — if the server was just updated, it needs restarting.`);
}, CHECKOUT_TIMEOUT_MS);
socket?.emit('checkoutShop', {
locationId,
Expand Down Expand Up @@ -599,7 +608,7 @@ export function ShopWindow({
setRepriced(Number(res.net) || 0);
setRefused(`Prices changed while this sat in the cart — it now comes to ${money(Number(res.net) || 0)}. Check it, then CHECK OUT again.`);
} else {
setRefused(REFUSAL_TEXT[res.reason as RefusalReason] ?? 'The checkout did not go through. Nothing was charged.');
setRefused(refusalText(word)[res.reason as RefusalReason] ?? 'The checkout did not go through. Nothing was charged.');
}
return;
}
Expand Down Expand Up @@ -1377,7 +1386,7 @@ export function ShopWindow({
<div role="alert" style={{ ...mono(10), color: 'var(--danger)', marginBottom: 6, letterSpacing: 0 }}>{refused}</div>
)}

{receipt && <Receipt receipt={receipt} shop={name} />}
{receipt && <Receipt receipt={receipt} shop={name} word={word} />}

{cartCount === 0 ? (
!receipt && (
Expand Down Expand Up @@ -1463,7 +1472,7 @@ export function ShopWindow({
<span style={{ fontWeight: 'bold' }}>{money(net)}</span>
</div>
<div style={{ color: net < 0 ? 'var(--cyan)' : 'var(--green)', textAlign: 'right' }}>
{net > 0 ? `YOU PAY ${money(net)}` : net < 0 ? `THE SHOP PAYS YOU ${money(-net)}` : 'IT COMES OUT EVEN'}
{net > 0 ? `YOU PAY ${money(net)}` : net < 0 ? `THE ${SHOP} PAYS YOU ${money(-net)}` : 'IT COMES OUT EVEN'}
{account && ` · BALANCE AFTER ${money(account.balance - net)}`}
</div>
{repriced !== null && (
Expand All @@ -1474,7 +1483,7 @@ export function ShopWindow({
{asking && (
<div
role="alertdialog"
aria-label="Not enough credits"
aria-label={`Not enough ${moneyWord}`}
style={{ ...mono(10), letterSpacing: 0, marginTop: 8, padding: '6px 8px', border: '1px solid var(--warning)', color: 'var(--warning)' }}
>
<div style={{ marginBottom: 6 }}>
Expand Down Expand Up @@ -1528,7 +1537,7 @@ export function ShopWindow({
}}
panelMode="list"
width={980}
label={`${name || 'Shop'} shop`}
label={`${name || word('shop', 'singular', 'Shop')} ${shopWord}`}
header={(
<span style={{ color: 'var(--cyan)' }}>
{type ? typeLabel(type.id, system).toUpperCase() : 'UNKNOWN'} ·{' '}
Expand Down Expand Up @@ -1595,13 +1604,13 @@ export function ShopWindow({
}}
>
{!sheet
? 'NO CHARACTER SHEET LOADED — NOTHING TO BUY ONTO'
? `NO ${CHARACTER} SHEET LOADED — NOTHING TO BUY ONTO`
: shelf?.notice ?? ''}
</div>

{!shelf ? (
<div style={{ ...mono(10), color: 'var(--grid-section)', padding: '10px 0', letterSpacing: 0, lineHeight: 1.6 }}>
NO CATALOGUE FOR THIS SHOP YET.
NO CATALOGUE FOR THIS {SHOP} YET.
{/* Named, so the answer to "why is this empty" is on the screen. */}
{type?.sells.length
? ` The book has ${type.sells.map((s) => catalogueLabel(s, system)).join(' and ')} for this
Expand Down Expand Up @@ -1690,7 +1699,7 @@ export function ShopWindow({
className="utility-btn"
disabled={!sheet}
aria-label={`Add ${label} to the cart`}
title={sheet ? shelf.notice : 'No character sheet loaded'}
title={sheet ? shelf.notice : `No ${characterWord} sheet loaded`}
onClick={() => shelf.buy(row)}
style={{ padding: '1px 6px', fontSize: 9 }}
>+ CART{inCart ? ` ×${inCart}` : ''}</button>
Expand All @@ -1709,7 +1718,7 @@ export function ShopWindow({
{/* An empty shelf and a filter that matched nothing are different
answers, and only one of them is somebody else's job to fix. */}
{shelf.rows.length === 0
? 'Nothing on the shelves yet — the GM adds stock in SHOP_CATALOGUES.'
? `Nothing on the shelves yet — the ${gm} adds stock in ${asLabel(word('shop', 'singular', 'SHOP'))}_CATALOGUES.`
: 'NOTHING MATCHES THAT'}
</div>
)}
Expand All @@ -1726,7 +1735,7 @@ export function ShopWindow({
) : tab === 'sell' ? (
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<div style={{ ...mono(9), color: 'var(--grid-section)', marginBottom: 8, letterSpacing: 0 }}>
THIS SHOP PAYS {pct}% OF THE {book ? 'BOOK' : 'SHELF'} PRICE · + CART WHAT YOU WANT TO SELL, THEN CHECK OUT IN THE CART
THIS {SHOP} PAYS {pct}% OF THE {book ? 'BOOK' : 'SHELF'} PRICE · + CART WHAT YOU WANT TO SELL, THEN CHECK OUT IN THE CART
</div>

{refused && (
Expand All @@ -1738,8 +1747,8 @@ export function ShopWindow({
{sellable.length === 0 ? (
<div style={{ ...mono(11), color: 'var(--green)', padding: '10px 0', letterSpacing: 0, lineHeight: 1.6 }}>
{!sheet
? 'NO CHARACTER SHEET LOADED — NOTHING TO SELL.'
: 'NOTHING HERE THIS SHOP WOULD BUY. A shop only takes the kinds of thing it sells.'}
? `NO ${CHARACTER} SHEET LOADED — NOTHING TO SELL.`
: `NOTHING HERE THIS ${SHOP} WOULD BUY. A ${shopWord} only takes the kinds of thing it sells.`}
</div>
) : (
<>
Expand Down Expand Up @@ -1788,7 +1797,7 @@ export function ShopWindow({
{l.unitPrice === null && (
<span
style={{ color: 'var(--grid-section)' }}
title="Not on any shelf, so the shop cannot price it. Sells for nothing — settle up with your GM."
title={`Not on any shelf, so the ${shopWord} cannot price it. Sells for nothing — settle up with your ${gm}.`}
> ?</span>
)}
</td>
Expand Down
Loading
Loading