diff --git a/CHANGELOG.md b/CHANGELOG.md index 3500f2e2..7d5de81c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 000f9edc..30a7a582 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c46f4e5c..f0e0aab4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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'; @@ -1861,6 +1862,7 @@ function App() { ))} {token && showAdminPanel && ( 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), @@ -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), diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 464d9e9b..cba5ecce 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -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'; // ───────────────────────────────────────────────────────────────────────────── @@ -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 ( setIsCatalogueOpen?.(true)} className="utility-btn" style={{ width: '100%', marginTop: '8px' }} - >SHOP_CATALOGUES + >{asLabel(word('shop', 'singular', 'SHOP'))}_CATALOGUES )} diff --git a/frontend/src/components/EmptyShopSteps.tsx b/frontend/src/components/EmptyShopSteps.tsx index 657f94a1..bd956b83 100644 --- a/frontend/src/components/EmptyShopSteps.tsx +++ b/frontend/src/components/EmptyShopSteps.tsx @@ -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. // @@ -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; @@ -71,11 +75,11 @@ export function EmptyShopSteps({ buildingType, system, onOpenCatalogues }: Props THIS {shop.toUpperCase()} HAS NOTHING TO SELL YET
- 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 ')}.
  1. - Open SHOP_CATALOGUES, in the admin panel's GAME tab. + Open {asLabel(word('shop', 'singular', 'SHOP'))}_CATALOGUES, in the admin panel's GAME tab. {onOpenCatalogues && ( <> {' '} diff --git a/frontend/src/components/ShopWindow.tsx b/frontend/src/components/ShopWindow.tsx index 1edca9d9..62e4b3b1 100644 --- a/frontend/src/components/ShopWindow.tsx +++ b/frontend/src/components/ShopWindow.tsx @@ -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'; @@ -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, @@ -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 @@ -155,7 +156,7 @@ function Receipt({ receipt: r, shop }: { receipt: CartReceipt; shop: string }) { return (
    - RECEIPT · {(shop || 'SHOP').toUpperCase()} + RECEIPT · {(shop || word('shop', 'singular', 'SHOP')).toUpperCase()} {stamp(r.at)}
    @@ -299,6 +300,14 @@ export function ShopWindow({ const [pos, setPos] = useState({ x: 140, y: 90 }); const [tab, setTab] = useState('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(''); @@ -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. @@ -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, @@ -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; } @@ -1377,7 +1386,7 @@ export function ShopWindow({
    {refused}
    )} - {receipt && } + {receipt && } {cartCount === 0 ? ( !receipt && ( @@ -1463,7 +1472,7 @@ export function ShopWindow({ {money(net)}
    - {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)}`}
    {repriced !== null && ( @@ -1474,7 +1483,7 @@ export function ShopWindow({ {asking && (
    @@ -1528,7 +1537,7 @@ export function ShopWindow({ }} panelMode="list" width={980} - label={`${name || 'Shop'} shop`} + label={`${name || word('shop', 'singular', 'Shop')} ${shopWord}`} header={( {type ? typeLabel(type.id, system).toUpperCase() : 'UNKNOWN'} ·{' '} @@ -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 ?? ''}
    {!shelf ? (
    - 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 @@ -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}` : ''} @@ -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'}
    )} @@ -1726,7 +1735,7 @@ export function ShopWindow({ ) : tab === 'sell' ? (
    - 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
    {refused && ( @@ -1738,8 +1747,8 @@ export function ShopWindow({ {sellable.length === 0 ? (
    {!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.`}
    ) : ( <> @@ -1788,7 +1797,7 @@ export function ShopWindow({ {l.unitPrice === null && ( ? )} diff --git a/frontend/src/components/__tests__/AdminPanel.test.tsx b/frontend/src/components/__tests__/AdminPanel.test.tsx index 4fe12731..bd247169 100644 --- a/frontend/src/components/__tests__/AdminPanel.test.tsx +++ b/frontend/src/components/__tests__/AdminPanel.test.tsx @@ -10,6 +10,7 @@ vi.mock('../../utils/locationHelpers', () => ({ })); import { AdminPanel } from '../AdminPanel'; +import { registerCustomTemplate, clearCustomTemplates } from '../../sheets/customTemplates'; const makeSocketRef = () => ({ current: { emit: vi.fn(), on: vi.fn(), off: vi.fn() } }); @@ -140,6 +141,30 @@ describe('AdminPanel list view', () => { expect(screen.getByText('PAY_PLAYERS')).toBeInTheDocument(); }); + it('names the catalogues button in a custom system\'s own word for shops, and as today otherwise', async () => { + const catalogues = async (gameSystem?: string) => { + // The shop settings show only where shops are on, which today means a built-in system; + // a custom system's shops arrive with its parts (3b3). Shown as a built-in game shows + // them, with the running system's words, this pins the button's name for then. + const { unmount } = render(); + await userEvent.click(screen.getByText('GAME')); + // By text: a role lookup copies every button on this tab, and jsdom cannot parse one + // of their background styles. + const label = screen.getByText(/^[A-Z_]+_CATALOGUES$/).textContent; + unmount(); + return label; + }; + clearCustomTemplates(); + registerCustomTemplate({ id: 'sys_aaaaaaaaaaaaaaaa', name: 'Hearth', parts: {}, derived: [], sheet: { sections: [] }, + words: { shop: { singular: 'MERCHANT', plural: 'MERCHANTS', short: 'MERCHANT' } } }); + registerCustomTemplate({ id: 'sys_bbbbbbbbbbbbbbbb', name: 'Plain', parts: {}, derived: [], sheet: { sections: [] }, words: {} }); + for (const system of [undefined, 'cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic', 'sys_bbbbbbbbbbbbbbbb']) { + expect(await catalogues(system), String(system)).toBe('SHOP_CATALOGUES'); + } + expect(await catalogues('sys_aaaaaaaaaaaaaaaa')).toBe('MERCHANT_CATALOGUES'); + clearCustomTemplates(); + }); + it('calls setIsAdminPayOpen when PAY_PLAYERS is clicked', async () => { const props = baseProps(); render(); diff --git a/frontend/src/components/__tests__/ShopWindow.test.tsx b/frontend/src/components/__tests__/ShopWindow.test.tsx index b104e42e..6ffaefca 100644 --- a/frontend/src/components/__tests__/ShopWindow.test.tsx +++ b/frontend/src/components/__tests__/ShopWindow.test.tsx @@ -14,7 +14,7 @@ import React from 'react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createRequire } from 'module'; -import { render, screen, within, act } from '@testing-library/react'; +import { render, screen, within, act, cleanup } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { ShopWindow, CHECKOUT_TIMEOUT_MS } from '../ShopWindow'; @@ -42,6 +42,8 @@ import { import { CWN_WEAPON_ROWS } from '../../sheets/templates/cities_without_number'; import { CWN_CYBERWARE } from '../../sheets/cwnCyberwarePresets'; import { loadUploaded, clearUploaded } from '../../sheets/uploadedCatalogues'; +import { registerCustomTemplate, clearCustomTemplates } from '../../sheets/customTemplates'; +import { refusalText } from '../../data/shopRules'; /** * A socket that answers the way the server does. @@ -2047,3 +2049,148 @@ describe('an empty shop, opened by the GM', () => { expect(screen.getByText(/the GM adds stock in SHOP_CATALOGUES/)).toBeInTheDocument(); }); }); + +describe('a custom system\'s own words', () => { + /** + * The shop in a custom system that calls shops MERCHANTS, money GOLD, the GM the WARDEN + * and a character an OPERATIVE (3a4). Everything above is the built-in systems, whose + * wording these tests show unchanged. + */ + const HEARTH = 'sys_cccccccccccccccc'; + const all = (w: string, plural = w) => ({ singular: w, plural, short: w }); + const ROPE = { id: 'rope', name: 'Rope', price: 100, fields: {} }; + + beforeEach(() => { + clearCustomTemplates(); + clearUploaded(); + registerCustomTemplate({ + id: HEARTH, name: 'Hearth', parts: {}, derived: [], sheet: { sections: [] }, + words: { shop: all('MERCHANT', 'MERCHANTS'), money: all('GOLD'), gm: all('WARDEN'), character: all('OPERATIVE') }, + }); + sheetState.sheet = { system: HEARTH, data: {} }; + }); + afterEach(() => { clearCustomTemplates(); clearUploaded(); }); + + it('says who stocks the shelves, and where', () => { + show('general_store', 'Vic', HEARTH); + expect(screen.getByText('Nothing on the shelves yet — the WARDEN adds stock in MERCHANT_CATALOGUES.')).toBeInTheDocument(); + }); + + it('names the window by its own word', () => { + const { container } = show('general_store', 'Vic', HEARTH); + expect(container.querySelector('[aria-label="Vic MERCHANT"]')).not.toBeNull(); + }); + + it('says what is missing to buy or sell with, in its own words', async () => { + sheetState.sheet = null; + loadUploaded({ gear: [ROPE] }); + show('general_store', 'Vic', HEARTH); + expect(screen.getByText('NO OPERATIVE SHEET LOADED — NOTHING TO BUY ONTO')).toBeInTheDocument(); + await openFolder('SELL'); + expect(screen.getByText('NO OPERATIVE SHEET LOADED — NOTHING TO SELL.')).toBeInTheDocument(); + }); + + it('says when it would buy nothing you have', async () => { + show('general_store', 'Vic', HEARTH); + await openFolder('SELL'); + expect(screen.getByText('NOTHING HERE THIS MERCHANT WOULD BUY. A MERCHANT only takes the kinds of thing it sells.')).toBeInTheDocument(); + }); + + it('says you are short of money before sending, or asks how to cover it', async () => { + bank.balance = 10; + loadUploaded({ gear: [ROPE] }); + show('general_store', 'Vic', HEARTH); + await userEvent.click(screen.getByRole('button', { name: 'Add Rope to the cart' })); + await openFolder('CART'); + await userEvent.click(screen.getByRole('button', { name: 'CHECK OUT' })); + expect(screen.getByText(/^Not enough GOLD — the cart comes to/)).toBeInTheDocument(); + expect(checkouts).toHaveLength(0); + cleanup(); + // With the overdraft house rule on, the shop asks instead. + sheetState.overdraftAllowed = true; + show('general_store', 'Vic', HEARTH); + await userEvent.click(screen.getByRole('button', { name: 'Add Rope to the cart' })); + await openFolder('CART'); + await userEvent.click(screen.getByRole('button', { name: 'CHECK OUT' })); + expect(screen.getByRole('alertdialog', { name: 'Not enough GOLD' })).toBeInTheDocument(); + }); + + it('words the server\'s refusals the same way', async () => { + loadUploaded({ gear: [ROPE] }); + refuseWith = 'funds'; + show('general_store', 'Vic', HEARTH); + await buyNow('Rope'); + expect(screen.getByText('Not enough GOLD.')).toBeInTheDocument(); + }); + + it('names the shop on a receipt when it has no name of its own', async () => { + loadUploaded({ gear: [ROPE] }); + show('general_store', '', HEARTH); + await buyNow('Rope'); + expect(screen.getByTestId('cart-receipt')).toHaveTextContent(/RECEIPT · MERCHANT/); + }); + + it('says what it pays when you sell, and what it cannot price', async () => { + loadUploaded({ gear: [ROPE] }); + sheetState.sheet = { system: HEARTH, data: { inventory: JSON.stringify([{ name: 'Rope', qty: 1 }, { name: 'Mystery', qty: 1 }]) } }; + const { container } = show('general_store', 'Vic', HEARTH); + await openFolder('SELL'); + expect(screen.getByText(/^THIS MERCHANT PAYS 45% OF THE SHELF PRICE/)).toBeInTheDocument(); + expect(container.querySelector('[title="Not on any shelf, so the MERCHANT cannot price it. Sells for nothing — settle up with your WARDEN."]')).not.toBeNull(); + await userEvent.click(screen.getByRole('button', { name: 'Add Rope to the cart' })); + await openFolder('CART'); + expect(screen.getByTestId('cart-total')).toHaveTextContent(/THE MERCHANT PAYS YOU/); + }); + + it('says when a building has no catalogue, and why you cannot buy without a sheet', () => { + show('bar', 'Vic', HEARTH); + expect(screen.getByText(/NO CATALOGUE FOR THIS MERCHANT YET\./)).toBeInTheDocument(); + cleanup(); + sheetState.sheet = null; + loadUploaded({ gear: [ROPE] }); + show('general_store', 'Vic', HEARTH); + expect(screen.getByRole('button', { name: 'Add Rope to the cart' })).toHaveAttribute('title', 'No OPERATIVE sheet loaded'); + }); + + it('says when the shop did not answer', async () => { + silentServer = true; + loadUploaded({ gear: [ROPE] }); + show('general_store', 'Vic', HEARTH); + await userEvent.click(screen.getByRole('button', { name: 'Add Rope to the cart' })); + await openFolder('CART'); + vi.useFakeTimers(); + try { + act(() => { screen.getByRole('button', { name: 'CHECK OUT' }).click(); }); + act(() => { vi.advanceTimersByTime(CHECKOUT_TIMEOUT_MS); }); + expect(screen.getByText(/^The MERCHANT did not answer/)).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('the shop\'s refusals', () => { + it('read exactly as today with no system\'s words, and in a system\'s own words', () => { + const today = refusalText(); + expect(today).toMatchObject({ + no_shop: 'This building is not a shop any more.', + not_sold: 'This shop does not sell that.', + funds: 'Not enough credits.', + needs_choice: 'The shop did not ask how to cover this. Nothing was bought.', + no_sheet: 'No character sheet to sell from.', + qty: 'A quantity in the cart is more than the shop will sell at once. Nothing was charged.', + }); + const words: Record = { shop: 'merchant', money: 'gold', character: 'operative' }; + const theirs = refusalText((term, _form, builtIn) => words[term] ?? builtIn); + expect(theirs).toMatchObject({ + no_shop: 'This building is not a merchant any more.', + not_sold: 'This merchant does not sell that.', + funds: 'Not enough gold.', + needs_choice: 'The merchant did not ask how to cover this. Nothing was bought.', + no_sheet: 'No operative sheet to sell from.', + qty: 'A quantity in the cart is more than the merchant will sell at once. Nothing was charged.', + }); + // Reasons with no term in them read the same everywhere. + for (const reason of ['price', 'no_account', 'write', 'empty', 'not_owned', 'no_system'] as const) expect(theirs[reason]).toBe(today[reason]); + }); +}); diff --git a/frontend/src/components/__tests__/wordsInBankAndShops.test.tsx b/frontend/src/components/__tests__/wordsInBankAndShops.test.tsx new file mode 100644 index 00000000..8a0f089d --- /dev/null +++ b/frontend/src/components/__tests__/wordsInBankAndShops.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { buildBuildingActions, type BuildingViewer } from '../buildingActions'; +import { buildTokenActions, type TokenViewer } from '../tokenActions'; +import { EmptyShopSteps } from '../EmptyShopSteps'; +import { registerCustomTemplate, clearCustomTemplates } from '../../sheets/customTemplates'; +import { wordFor } from '../../sheets/words'; + +/** + * The glossary in the bank and shop menus and the empty-shop steps (3a4). Each place reads + * exactly as today under every built-in system and a custom system that renamed nothing, and + * in a custom system's own words where it renamed shops or the bank. The shop window itself + * is tested in ShopWindow.test.tsx. + */ + +const BUILT_INS = ['cities_without_number', 'cyberpunk_red', 'shadowrun_6e', 'generic']; +const RENAMED = 'sys_aaaaaaaaaaaaaaaa'; +const PLAIN = 'sys_bbbbbbbbbbbbbbbb'; +const all = (w: string, plural = w) => ({ singular: w, plural, short: w }); +const custom = (id: string, words: Record>) => registerCustomTemplate({ + id, name: id, parts: {}, derived: [], sheet: { sections: [] }, words, +}); +const lookup = (system: string) => (term: Parameters[1], form: Parameters[2], builtIn: string) => wordFor(system, term, form, builtIn); + +beforeEach(() => { + clearCustomTemplates(); + custom(RENAMED, { shop: all('MERCHANT', 'MERCHANTS'), bank: all('COIN PURSE') }); + custom(PLAIN, {}); +}); +afterEach(() => { cleanup(); clearCustomTemplates(); }); + +describe('the building menu', () => { + const viewer: BuildingViewer = { shopHere: true, hasBattleMaps: false, isAdmin: true, systemHasVehicles: false }; + const shopLabel = (words?: ReturnType) => buildBuildingActions(viewer, { + location: { id: 1, name: 'Vic' }, userName: 'gm', emit: vi.fn(), someoneEditing: false, notify: vi.fn(), words, + open: { shop: vi.fn(), battleMap: vi.fn(), enemyVehicles: vi.fn() }, ping: vi.fn(), broadcast: vi.fn(), + }).find((a) => a.key === 'shop')!.label; + + it('reads SHOP with no words, under every built-in system, and a custom one that renamed nothing', () => { + expect(shopLabel()).toBe('SHOP'); + for (const system of [...BUILT_INS, PLAIN]) expect(shopLabel(lookup(system)), system).toBe('SHOP'); + }); + + it('uses a custom system\'s own word for its shops', () => { + expect(shopLabel(lookup(RENAMED))).toBe('MERCHANT'); + }); +}); + +describe('the token menu', () => { + const viewer: TokenViewer = { + isAdmin: true, isPrimaryAdmin: true, isOwner: false, isLoggedIn: true, isPlayerToken: true, hasOwner: true, + sheetHere: false, linked: false, attackPending: false, sheetCombat: false, canManage: true, + hasRoster: false, systemHasVehicles: false, hasBattleMaps: false, + }; + const bankLabel = (words?: ReturnType) => buildTokenActions(viewer, { + location: { id: 42, name: 'GHOST', shape: 'rhombus', owner: 'ghost' }, authToken: 'tok', emit: vi.fn(), + fetch: vi.fn(async () => ({ ok: true })), refreshLocations: vi.fn(), sheetLink: null, tier: undefined, isOwner: false, words, + open: { ownSheet: vi.fn(), playerSheet: vi.fn(), npcSheet: vi.fn(), editLocation: vi.fn(), vehicles: vi.fn(), bank: vi.fn(), enemyVehicles: vi.fn(), battleMap: vi.fn() }, + ping: vi.fn(), broadcast: vi.fn(), clearSelection: vi.fn(), + }).find((a) => a.key === 'bank')!.label; + + it('reads VIEW_BANK with no words, under every built-in system, and a custom one that renamed nothing', () => { + expect(bankLabel()).toBe('VIEW_BANK'); + for (const system of [...BUILT_INS, PLAIN]) expect(bankLabel(lookup(system)), system).toBe('VIEW_BANK'); + }); + + it('uses a custom system\'s own word for the bank, in the label style', () => { + expect(bankLabel(lookup(RENAMED))).toBe('VIEW_COIN_PURSE'); + }); +}); + +describe('the empty-shop steps', () => { + const steps = (system: string) => { + render(); + const note = screen.getByRole('note', { name: 'How to stock this shop' }).textContent ?? ''; + cleanup(); + return note; + }; + + it('read as today outside CWN, and under a custom system that renamed nothing', () => { + for (const system of ['cyberpunk_red', 'shadowrun_6e', 'generic', PLAIN]) { + const note = steps(system); + expect(note, system).toContain('Shops in this game sell only what you add.'); + expect(note, system).toContain('Open SHOP_CATALOGUES, in the admin panel'); + } + }); + + it('use a custom system\'s own word for its shops', () => { + const note = steps(RENAMED); + expect(note).toContain('MERCHANTS in this game sell only what you add.'); + expect(note).toContain('Open MERCHANT_CATALOGUES, in the admin panel'); + }); +}); diff --git a/frontend/src/components/buildingActions.ts b/frontend/src/components/buildingActions.ts index ae8e9358..d9882dce 100644 --- a/frontend/src/components/buildingActions.ts +++ b/frontend/src/components/buildingActions.ts @@ -4,6 +4,8 @@ // and press every button - the same arrangement as tokenActions.ts. The conditions are the // old window's, carried over one for one. +import { todaysWords, type WordLookup } from '../sheets/words'; + export type BuildingActionKey = 'shop' | 'battle' | 'ping' | 'broadcast' | 'enemy-vehicles' | 'request-edit'; export interface BuildingViewer { @@ -36,6 +38,8 @@ export interface BuildingActionContext { /** Someone already holds editing rights, so a request would only be refused. */ someoneEditing: boolean; notify: (message: string) => void; + /** The running system's words (sheets/words.ts), for SHOP; today's text when not given. */ + words?: WordLookup; open: { shop: (location: any) => void; battleMap: (locationId: number) => void; @@ -74,5 +78,8 @@ export function buildBuildingActions(viewer: BuildingViewer, c: BuildingActionCo }, }, }; - return buildingActionKeys(viewer).map((key) => ({ key, label: LABEL[key], ...does[key] })); + // A custom system's own word for its shops; every other label is the app's own. + const word = c.words ?? todaysWords; + const label = (key: BuildingActionKey) => (key === 'shop' ? word('shop', 'singular', 'SHOP').toUpperCase() : LABEL[key]); + return buildingActionKeys(viewer).map((key) => ({ key, label: label(key), ...does[key] })); } diff --git a/frontend/src/components/tokenActions.ts b/frontend/src/components/tokenActions.ts index 67e65d1e..87950928 100644 --- a/frontend/src/components/tokenActions.ts +++ b/frontend/src/components/tokenActions.ts @@ -6,6 +6,8 @@ // open a sheet, and a test holds each of them. Health moved from two buttons into a folder, // with the same split: the old UPDATE_HEALTH viewers change it, CHECK_HEALTH viewers watch it. +import { asLabel, todaysWords, type WordLookup } from '../sheets/words'; + export type TokenActionKey = | 'attack' | 'melee' | 'ranged' | 'player-sheet' | 'npc-sheet' | 'generate-sheet' | 'edit' @@ -113,6 +115,8 @@ export interface TokenActionContext { /** The tier GENERATE_SHEET asks for, already resolved; undefined where the system has none. */ tier: string | undefined; isOwner: boolean; + /** The running system's words (sheets/words.ts), for VIEW_BANK; today's text when not given. */ + words?: WordLookup; open: { ownSheet: () => void; playerSheet: (owner: string) => void; @@ -177,8 +181,13 @@ export function buildTokenActions(viewer: TokenViewer, c: TokenActionContext): B }; // Worded like the sidebar's own remove buttons: your token is yours to take off the map. - const label = (key: TokenActionKey) => - key === 'purge' && viewer.isPlayerToken && viewer.isOwner ? 'REMOVE_MY_TOKEN' : LABEL[key]; + // VIEW_BANK in a custom system's own word for the bank (VIEW_COIN_PURSE). + const word = c.words ?? todaysWords; + const label = (key: TokenActionKey) => { + if (key === 'purge' && viewer.isPlayerToken && viewer.isOwner) return 'REMOVE_MY_TOKEN'; + if (key === 'bank') return `VIEW_${asLabel(word('bank', 'singular', 'BANK'))}`; + return LABEL[key]; + }; return tokenActionKeys(viewer).map((key) => ({ key, label: label(key), ...does[key] })); } diff --git a/frontend/src/data/shopRules.ts b/frontend/src/data/shopRules.ts index 46f0583f..1dea4d2a 100644 --- a/frontend/src/data/shopRules.ts +++ b/frontend/src/data/shopRules.ts @@ -8,6 +8,8 @@ // change already, so the shop knows before it sends whether a purchase will come up short // and can ask how to cover it, rather than sending, being refused, and asking afterwards. +import { todaysWords, type WordLookup } from '../sheets/words'; + /** * The house rule that lets a player buy something they cannot afford. * @@ -91,24 +93,30 @@ export type RefusalReason = * `needs_choice` is deliberately phrased as a fault on this side rather than theirs: the * window is supposed to ask before sending, so seeing it means the shop failed to. */ -export const REFUSAL_TEXT: Record = { - no_shop: 'This building is not a shop any more.', - not_sold: 'This shop does not sell that.', - price: 'That is not on the shelf.', - funds: 'Not enough credits.', - needs_choice: 'The shop did not ask how to cover this. Nothing was bought.', - no_account: 'Could not read your account.', - write: 'The payment did not go through. Nothing was bought.', - empty: 'Nothing on the sell list.', - /** - * The sale was checked against the sheet and came up short. - * - * Reachable without anybody cheating: a sheet edited in another window while the sell - * list sat here is enough. So it reads as something to look at again rather than as an - * accusation. - */ - not_owned: 'You do not have all of that any more. Nothing was sold — check the list.', - no_sheet: 'No character sheet to sell from.', - no_system: 'Could not tell which game is running.', - qty: 'A quantity in the cart is more than the shop will sell at once. Nothing was charged.', +export const refusalText = (word: WordLookup = todaysWords): Record => { + // A custom system's own words for shops, money and characters; today's text otherwise. + const shop = word('shop', 'singular', 'shop'); + const credits = word('money', 'plural', 'credits'); + const character = word('character', 'singular', 'character'); + return { + no_shop: `This building is not a ${shop} any more.`, + not_sold: `This ${shop} does not sell that.`, + price: 'That is not on the shelf.', + funds: `Not enough ${credits}.`, + needs_choice: `The ${shop} did not ask how to cover this. Nothing was bought.`, + no_account: 'Could not read your account.', + write: 'The payment did not go through. Nothing was bought.', + empty: 'Nothing on the sell list.', + /** + * The sale was checked against the sheet and came up short. + * + * Reachable without anybody cheating: a sheet edited in another window while the sell + * list sat here is enough. So it reads as something to look at again rather than as an + * accusation. + */ + not_owned: 'You do not have all of that any more. Nothing was sold — check the list.', + no_sheet: `No ${character} sheet to sell from.`, + no_system: 'Could not tell which game is running.', + qty: `A quantity in the cart is more than the ${shop} will sell at once. Nothing was charged.`, + }; };