14 KiB
CLAUDE.md
Guidance for Claude Code when working inside webclient/ — the React/TypeScript SPA (Webatrice) that connects to a Servatrice server over a WebSocket. It is a self-contained application; the only thing it shares with the rest of the repo (C++ desktop/server stack) is the protobuf protocol in ../libcockatrice_protocol/. Anything outside webclient/ is out-of-scope unless a task explicitly touches the protocol.
All commands below are run from this directory.
Commands
npm start # Vite dev server (runs proto:generate + prebuild.js first)
npm run build # production build (same prebuild hooks)
npm test # vitest run (one-shot)
npm run test:watch # vitest watch
npm run lint # eslint src/
npm run lint:fix
npm run golden # lint + test — the CI-equivalent gate to run before declaring work done
npm run proto:generate # `npx buf generate` — regenerates TS bindings into src/generated/proto
Single test file: npx vitest run path/to/file.spec.ts. Filter by name: npx vitest run -t "partial test name".
The dev server has server.open: true, so npm start pops a browser tab automatically.
Architecture
The webclient is a Redux Toolkit + RxJS app. Its defining abstraction is a layered WebSocket client that speaks the Cockatrice protobuf protocol to Servatrice. Understanding the layering is essential before editing anything under src/websocket/, src/api/, or src/store/.
Protocol layer
src/generated/proto/— auto-generated from../libcockatrice_protocol/**/*.protobybuf(seebuf.gen.yaml). Never edit by hand. Runtime is@bufbuild/protobuf(Protobuf-ES); the codebase was recently migrated off the olderprotobufjs, so if you find any stray references to the old runtime, they're bugs.src/types/is the only public surface for generated code.src/types/data.tshand-rolls anexport *barrel over every proto file that consumers use, andsrc/types/index.tsre-exports it asData, plusEnriched(protocol types extended with client-only fields) andApp(pure client types). Import asimport { Data, Enriched } from '@app/types'and useData.Command_Login,Data.ServerInfo_User, etc. Never import directly from@app/generated/proto/*outsidesrc/types/. When a new proto file starts being consumed, add anexport *line tosrc/types/data.ts— there is a standing TODO to replace this rollup with a protobuf-es plugin.
WebSocket layer (src/websocket/)
A strict inbound/outbound split sits on top of a transport core:
services/— transport:WebSocketService(socket lifecycle),ProtobufService(encode/decode + request/response correlation),KeepAliveService(ping/pong),command-options(per-command response config). This layer has no knowledge of Redux.commands/— outbound. Organised by scope (session/,room/,game/,admin/,moderator/). Each command builds a protobuf request and hands it toProtobufService.send{Session,Room,Game,Admin,Moderator}Commandalong with aCommandOptionsdescribing how to handle the response.events/— inbound. Handlers for server-pushed events, same scopes. They translate protobuf events into calls on the persistence layer.persistence/— the only bridge from the websocket layer into app state.SessionPersistence,RoomPersistence,GamePersistence,AdminPersistence,ModeratorPersistencedispatch Redux actions and/or write to Dexie.WebClient.ts— singleton facade that wires the services, commands, events, and persistence together.
Layering invariant (enforced on this branch, not aspirational):
- Containers and components call
src/api/*services — neversrc/websocket/*directly. - Commands and event handlers call
*Persistencemethods — neverstore.dispatchdirectly. - Only
*.dispatch.tshelpers insidesrc/store/and persistence code may touch the Redux store.
If you find yourself wanting to skip a layer (dispatching from an event handler, calling a command from a container, reaching into src/generated/proto/ from a component), stop — the refactor on webclient-websocket-layer exists precisely to eliminate those shortcuts. There are currently zero violations; keep it that way.
ProtobufService: request/response correlation
- Every outbound
CommandContainergets a monotonically increasingcmdId(cast toBigIntfor the proto field). AMap<number, callback>stores the response handler keyed by that ID; whenServerMessage.RESPONSEarrives,processServerResponselooks up and invokes the callback, then deletes the entry. - There is no timeout or retry.
resetCommands()(called on reconnect) zeroscmdIdand clears the pending map, silently dropping any in-flight callbacks. Code that needs reconnection resilience has to handle it at a higher layer. sendCommandis a no-op write if the transport isn't open — it still registers the callback, so a stale pending entry can accumulate until the next reset.- Inbound event dispatch is extension-based:
processRoomEvent/processSessionEvent/processGameEventiterateRoomEvents/SessionEvents/GameEvents(tuples of[extension, handler]) and invoke the first handler whose extension is set on the message. Adding a new event handler means appending to those arrays.
command-options contract (src/websocket/services/command-options.ts)
Every send*Command call accepts an optional CommandOptions<R>:
responseExt?: GenExtension<Response, R>— the response payload extension to unwrap on success.onSuccess?: (response: R, raw: Response) => void— called whenresponseCode === RespOk. IfresponseExtis absent, the overload becomes() => void.onResponseCode?: { [code: number]: (raw: Response) => void }— per-error-code handlers.onError?: (code: number, raw: Response) => void— fallback for codes not inonResponseCode.onResponse?: (raw: Response) => void— if set, it handles the raw response and bypasses every other hook. Use this when you need the full response object regardless of code.
If none of the hooks fire for a non-OK response, handleResponse logs the failure via console.error with the command's proto type name. The practical rule: onSuccess funnels into persistence, onError funnels into persistence (usually to flip connection state or show a toast), and onResponse is rare.
Public API for UI (src/api/)
Thin service wrappers (AuthenticationService, SessionService, RoomsService, GameService, ModeratorService, AdminService) that expose websocket commands to UI code. A few things to know:
- All command methods are
staticand returnvoid. They're fire-and-forget — the response flows back through thecommand-optionscallbacks plumbed inside the command itself, into persistence, into the store. Don't try to await them. - A handful of methods return
boolean(e.g.AuthenticationService.isConnected,isModerator) — those are pure sync predicates, not command sends. - Files use the
.tsxextension even though they contain no JSX. That's a leftover convention; don't "fix" it.
State (src/store/)
Redux Toolkit store (store.ts, rootReducer.ts) split by feature. Each slice follows the same file layout:
*.actions.ts— action creators*.reducer.ts— slice reducer*.selectors.ts— selectors (mostly plain getters;createSelectoronly for derived lists)*.dispatch.ts— dispatch helpers called by the persistence layer*.interfaces.ts/*.types.ts— state shape and enums
Slices: server/, rooms/, game/, plus shared actions/ and common/ helpers (SortUtil, normalizers). Consumers import through the @app/store barrel — GameSelectors, GameDispatch, GameTypes, and the same prefixed set for Server/Rooms. Don't deep-import from src/store/game/game.selectors.ts etc. — go through @app/store.
Shape notes worth knowing before you touch a reducer:
game/is deeply normalized:games[gameId].players[playerId].zones[zoneName].cards. Selectors are plain getters so lookups stay O(1);createSelectoris reserved for the few that build derived lists (e.g.getActiveGameIds).- Selectors return module-scope
EMPTY_ARRAY/EMPTY_OBJECTconstants for missing data to preserve referential equality and avoid spurious re-renders. rooms/is partially normalized: rooms are keyed by ID, but each room also carries denormalizedgameList/userListarrays. Server updates often omit those lists, so the reducer merges new metadata while preserving the existing arrays. There is a standing TODO to clean this up.server/is mostly flat maps keyed by username (messages,userInfo, buddy/ignore lists) plus connection state.
Local persistence (src/services/dexie/)
IndexedDB storage via Dexie for cards, sets, tokens, known hosts, and settings. DTOs live in DexieDTOs/. This is separate from the Redux store — used for data that should survive a reload (card database, user settings, host list). Dexie is not mocked in unit tests; code that writes to Dexie is typically exercised only in integration paths.
UI
containers/— route-level, Redux-connected. Top-level routes:App,Initialize,Login,Server,Room,Game,Player,Decks,Account,Logs,Layout,Unsupported. Routing lives incontainers/App/AppShellRoutes.tsx.components/— presentational, mostly unconnected.forms/—react-final-formforms (e.g.LoginForm).dialogs/— MUI dialogs.hooks/— shared hooks (e.g.useAutoConnect).i18n.ts/i18n-backend.ts—react-i18next+ ICU; translations managed via Transifex.- UI kit: MUI v7 (
@mui/material,@emotion).
Path aliases
tsconfig.json defines the following (resolved at build time by vite-tsconfig-paths):
@app/api @app/components @app/containers @app/dialogs
@app/forms @app/hooks @app/images @app/services
@app/store @app/types @app/websocket @app/generated/*
Prefer these in new code over relative imports when crossing top-level directory boundaries. Deep paths into a barrel target (e.g. @app/store/game/...) are a smell — add the symbol to the relevant index.ts barrel instead.
End-to-end data flow
User action in a container → src/api/*Service → src/websocket/commands/* → ProtobufService.send*Command → socket.
Server reply/event → src/websocket/events/* (or the command-options callback on the original command) → src/websocket/persistence/* → *.dispatch.ts helpers → Redux / Dexie → selectors → container re-render.
Build pipeline and generated files
npm start and npm run build both run prestart/prebuild hooks that invoke proto:generate and then node prebuild.js. prebuild.js does three things:
- Copies shared country flag assets from
../cockatrice/resources/countriesintosrc/images/countries. - Writes
src/server-props.jsoncontainingREACT_APP_VERSION= currentgit rev-parse HEAD. - Walks
src/**/*.i18n.json, merges them intosrc/i18n-default.json, and throws on duplicate keys (i18n key collision: ${key}). Namespace your i18n keys — collisions fail the build.
Files you should never edit by hand (all auto-generated, all committed):
src/generated/proto/**src/i18n-default.jsonsrc/server-props.json
If npm start seems to be ignoring a new .i18n.json file or a fresh proto, run npm run proto:generate && node prebuild.js directly — the hooks only fire on start/build, not on test or lint.
.env.development, .env.production, and .env.test exist but are empty. There is currently no env-var configuration surface; server URLs and the like are resolved through the login UI / server-props.json, not import.meta.env.
Testing
Vitest + Testing Library + jsdom; setupTests.ts registers jest-dom matchers.
Vitest runs with test.isolate: false. Every spec file in a worker shares the same module graph, so vi.mock(...) factories and any mocks they create persist across tests. Consequences:
- The global
afterEachinsetupTests.tscallsvi.clearAllMocks()+vi.restoreAllMocks()+vi.useRealTimers(). It deliberately does not callvi.resetAllMocks(), because that would reset the implementations ofvi.fn()instances created insidevi.mock(...)factories and break every spec that mocksstore.dispatchonce at file load. - A test that installs a custom
mockReturnValue/mockImplementationshould not assume the next test resets it — either overwrite it or rely onclearAllMockswiping only call histories. - Always use real timers at the end of a test that switched to fake ones; the global teardown will catch leaks, but relying on it is fragile across files.
Other conventions:
- Fixtures. Store slices have co-located
__mocks__/fixtures.tsfiles (notablysrc/store/game/__mocks__/fixtures.ts) exposing factories likemakeCard,makeGameEntry,makePlayerProperties,makeState. They build protobuf messages viacreate(Schema, overrides). Reuse them in new tests instead of hand-rolling proto objects. - Websocket mocks.
src/websocket/__mocks__/holds shared mock builders (e.g.makeMockWebSocket,makeWebClientMock,makeSessionPersistenceMock). Command and event specs install these withvi.mock(...)at the top of the file. - Slice tests are per-concern. Each slice ships parallel
*.actions.spec.ts,*.reducer.spec.ts,*.selectors.spec.ts, and*.dispatch.spec.tsfiles; tests don't cross concerns.
npm run golden (lint + test) is the CI gate — run it before declaring work done.
Protocol changes
When a task requires editing .proto files in ../libcockatrice_protocol/, run npm run proto:generate afterwards, and:
- If the change introduces a new proto file that code outside
src/types/needs to consume, add anexport *line for it insrc/types/data.ts. - Update any command/event/persistence code that consumes the changed messages.
- Commit the regenerated files under
src/generated/proto/.