This commit is contained in:
Jeremy Letto 2026-04-25 10:01:48 -03:00 committed by GitHub
commit 765d668221
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
662 changed files with 49538 additions and 39867 deletions

View file

@ -0,0 +1,184 @@
---
applyTo: "webclient/**"
---
# Webclient instructions
Applies to the React/TypeScript SPA in `webclient/` (Webatrice) — a **browser port of the desktop Cockatrice client**. It connects to the **same Servatrice server** as desktop over a WebSocket. UI behavior, and especially how UI code drives the websocket layer (commands, response handling, event-driven state changes), **must match the desktop client** unless a scope reduction is explicitly agreed per milestone. Divergence is a defect by default — see [#desktop-parity-mandate](#desktop-parity-mandate). The package is otherwise self-contained; the only thing it shares with the rest of the repo (C++ desktop/server stack) is the protobuf protocol in `../libcockatrice_protocol/`, and anything outside `webclient/` is out of scope unless a task explicitly touches the protocol.
Canonical AI-tool instruction surface for this package — invariants, policy, and external facts. When a source comment ends with `See .github/instructions/webclient.instructions.md#<anchor>`, the section with that anchor lives here. Source comments tagged `// @critical` guard cross-file invariants; do not remove them without updating the relevant section. For commands, stack, and getting-started, see [webclient/README.md](../../webclient/README.md).
## Desktop parity mandate
The webclient is a port of the desktop Cockatrice C++ client — same server, same game, same users. This is a **hard baseline**, not an ambiguity tie-breaker. Every webclient behavior difference from desktop is treated as a defect unless it has been explicitly scoped out for the current milestone.
**UI ↔ websocket parity is the sharpest edge of this rule.** Command shapes, field-level defaults (what the client sends vs. omits), response/event handling, and the resulting state transitions must mirror desktop. A webclient that issues a subtly different command, or reacts differently to the same event, breaks multi-client play — a desktop player and a webclient player joined to the same Servatrice room must see consistent game state.
**Desktop is the spec.** The reference implementation lives at `../cockatrice/src/` (relative to the repo root). Before proposing any UX or websocket-interaction decision that isn't obvious from the webclient code, read the corresponding desktop source.
**Divergence protocol:**
1. If desktop behavior is expensive to replicate in the current milestone, propose a **scope reduction explicitly** (e.g. "M4 ships default red arrows; color picker defers to M6"). Get agreement before coding. Record deferred parity gaps in [webclient/plans/gameboard-deferrables.md](../../webclient/plans/gameboard-deferrables.md) as "parity gap — deferred to <milestone>".
2. Phase-end reviews treat Cockatrice-parity findings as **blockers** by default. Elevate them; don't defer unless the user has explicitly OK'd the gap.
3. The only categorically valid reasons to diverge without a scope-reduction sign-off are: a browser security constraint (e.g. no raw TCP), a fundamental input-model difference (touch vs. mouse), or an accessibility requirement desktop doesn't meet.
This section subsumes the one-line "matches the Cockatrice desktop client" note in [#startup--session-invariants](#startup--session-invariants); that remains as a concrete example of the rule, not a standalone source of truth.
## Architecture
### Protocol layer
`src/generated/proto/` is buf-generated from `../libcockatrice_protocol/` (gitignored, never hand-edit). Runtime is `@bufbuild/protobuf`. `src/types/` re-exports the bindings namespaced as `Data` (raw proto), `Enriched` (UI/domain composition — proto extended with client-only sibling fields), and `App` (pure client types; no proto dependency). Consumer pattern: `import { Data, Enriched, App } from '@app/types'` then `Data.ServerInfo_User`, `Enriched.GameEntry`, `App.RouteEnum`. **UI, store, hooks, and api code must import proto types through `@app/types`, never `@app/generated` directly. `src/websocket/` is the exception and imports `@app/generated` by design.**
Websocket protocol/transport types (`StatusEnum`, `WebSocketConnectReason`, the `*ConnectOptions` family, signal payload contexts `PendingActivationContext` / `LoginSuccessContext`, `GameEventMeta`, the `I*Request` / `I*Response` contracts, `WebClientConfig`) live separately under `@app/websocket/types` and are exposed as a single `WebsocketTypes` namespace: `import { WebsocketTypes } from '@app/websocket/types'` then `WebsocketTypes.StatusEnum`, `WebsocketTypes.LoginConnectOptions`, etc. This is the only public surface of the websocket layer's types — store, hooks, api, and UI code must access websocket types through this namespace. **Don't re-export websocket types through `Enriched`**; that namespace is strictly UI/domain composition. `@app/websocket` (the broader index) only exposes runtime values (`WebClient`, command groups, `setPendingOptions`, etc.) — not types. Inside `src/websocket/` use relative paths to specific files under `types/` (e.g. `from '../types/StatusEnum'`) rather than either alias.
### WebSocket layer (`src/websocket/`)
Outbound commands in `commands/<scope>/`, inbound handlers in `events/<scope>/`, transport in `services/`, type declarations in `types/` (request/response contracts, `StatusEnum`, `WebSocketConnectReason`, connect-options union, signal contexts — all exposed to outside consumers as the `WebsocketTypes` namespace via `@app/websocket/types`). `WebClient` is a singleton; `new WebClient(...)` is called only inside `WebClientProvider` ([webclient/src/hooks/useWebClient.tsx](../../webclient/src/hooks/useWebClient.tsx)), never at module load.
**Layering invariant (enforced, zero violations today — keep it that way):**
1. Containers and components call `useWebClient()` to get the `WebClient`, then `client.request.<scope>.<method>(…)`. Never import from `@app/websocket` in UI code (`@app/websocket/types` is fine — type-only); never call `new WebClient(...)` outside `WebClientProvider`.
2. `src/api/request/*RequestImpl` methods translate UI intent into `src/websocket/commands/*` calls. `src/api/response/*ResponseImpl` methods are invoked by command callbacks and event handlers and dispatch to the store.
3. Only `*.dispatch.ts` helpers inside `src/store/` and the `*ResponseImpl` classes may touch the Redux store.
If you find yourself wanting to skip a layer (dispatching from an event handler, calling `@app/websocket` from a container, reaching into `@app/generated` from a component/store), stop. `eslint.boundaries.mjs` enforces this via the element types `api` / `components` / `containers` / `hooks` / `services` / `store` / `types` / `websocket` / `websocket-types`; `websocket-types` is deliberately a narrower surface than `websocket` so UI/store can reach protocol types without pulling in transport internals.
### ProtobufService: request/response correlation
- Every outbound `CommandContainer` gets a monotonically increasing `cmdId` (cast to `BigInt` for the proto field — the wire type is `int64`). A `Map<number, callback>` stores the response handler keyed by that ID; `processServerResponse` looks up and invokes the callback on `RESPONSE`, then deletes the entry. The `number``BigInt` sides stay in sync because the counter never realistically exceeds `Number.MAX_SAFE_INTEGER`.
- **No timeout or retry** at the transport layer. `resetCommands()` (called on reconnect) zeros `cmdId` and clears the pending map, silently dropping any in-flight callbacks. Reconnection resilience is a caller concern.
- `sendCommand` is 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` / `processGameEvent` iterate the relevant registry array (entries built with `makeEntry(ext, handler)`) and invoke the first handler whose extension is set on the message. Adding a new handler means appending a `makeEntry(ExtSymbol, handler)` line to the relevant registry.
### 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 when `responseCode === RespOk`. If `responseExt` is 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 in `onResponseCode`.
- `onResponse?: (raw: Response) => void` — if set, handles the raw response and bypasses every other hook. Use when you need the full response object regardless of code.
If none of the hooks fire for a non-OK response, `handleResponse` logs via `console.error` with the command's proto type name. Practical rule: `onSuccess` funnels into a `*ResponseImpl` method, `onError` funnels into a `*ResponseImpl` method (usually to flip connection state or show a toast), `onResponse` is rare.
### Public API for UI (`src/api/`)
One `*RequestImpl` / `*ResponseImpl` class per scope (session / rooms / game / admin / moderator; plus `AuthenticationRequestImpl` — auth has no inbound events). Request methods return `void` — fire-and-forget; response flows back via `command-options` callbacks → `*ResponseImpl` → store. `*ResponseImpl` classes are the only place outside `src/store/*.dispatch.ts` that calls `*Dispatch` helpers. **UI code never imports from `src/api/` directly — use `useWebClient()`.** Never call `client.response.*` from UI.
### State (`src/store/`)
Slices: `server/`, `rooms/`, `game/`. Consumers import through the `@app/store` barrel (`GameSelectors`, `GameDispatch`, `GameTypes`, same for `Server`/`Rooms`). **Don't deep-import from `src/store/<slice>/*` — add the symbol to the barrel's `index.ts` instead.** This rule generalizes: deep paths through any `@app/*` barrel target are a smell.
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); `createSelector` is reserved for the few that build derived lists (e.g. `getActiveGameIds`).
- Selectors return module-scope `EMPTY_ARRAY` / `EMPTY_OBJECT` constants 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 denormalized `gameList` / `userList` arrays. Server updates often omit those lists, so the reducer merges new metadata while preserving the existing arrays. Standing TODO to clean this up.
- `server/` is mostly flat maps keyed by username (`messages`, `userInfo`, buddy/ignore lists) plus connection state.
### Local persistence
Dexie (IndexedDB) holds cards, sets, tokens, known hosts, and settings; separate from Redux (persists across reloads). Stubbed globally in `setupTests.ts` so unit specs never hit a real IndexedDB.
### UI
Route-level containers in `containers/` (one subdir per route plus `AppShell` root and shared `Layout`); routing in `containers/App/AppShellRoutes.tsx`. Two hooks are load-bearing: **`useWebClient`** (context accessor — the only way UI code is allowed to reach the server; see the Layering invariant) and **`useAutoLogin`** (owns the once-per-session gate; see [#startup--session-invariants](#startup--session-invariants)). `WebClientProvider` ([webclient/src/hooks/useWebClient.tsx](../../webclient/src/hooks/useWebClient.tsx)) owns the singleton; `WebClientContext` is exported so integration tests can inject a pre-built `WebClient`. UI kit: MUI v9 + `@emotion`; i18n via `react-i18next` + ICU (Transifex).
## Build pipeline and generated files
`npm start` / `npm run build` run `prestart`/`prebuild` hooks: `proto:generate` followed by `node prebuild.js`. `prebuild.js` writes `src/server-props.json` (git SHA), merges `src/**/*.i18n.json` into `src/i18n-default.json` (**throws on duplicate keys** — namespace your i18n keys), and copies country flags from `../cockatrice/resources/countries`.
| File | Tracked? | Regenerate with |
|---|---|---|
| `src/generated/proto/**` | Gitignored | `npm run proto:generate` |
| `src/server-props.json` | Gitignored | `npm start` / `npm run build` (prebuild writes it) |
| `src/i18n-default.json` | **Committed** | `npm run translate` (or the prebuild hook) |
`.env.development`, `.env.production`, `.env.test` exist but are empty. No `import.meta.env` configuration surface; server URLs resolve through the login UI / `server-props.json`.
## Testing
Vitest + Testing Library + jsdom. [webclient/src/setupTests.ts](../../webclient/src/setupTests.ts) registers jest-dom matchers and installs a global Dexie mock.
Unit specs run under [webclient/vite.config.ts](../../webclient/vite.config.ts) with `test.isolate: true`: every spec file gets a fresh module graph, but tests **within the same file share it**. `vi.clearAllMocks()` (clears call logs) runs in the global `afterEach` and is safe. **Never add `vi.resetAllMocks()` to `setupTests.ts`** — it resets `vi.fn()` instances created inside `vi.mock(...)` factories at file load, breaking any spec that mocks something once (e.g. `store.dispatch`) and expects it to persist across tests in the file.
Integration specs run under [webclient/vitest.integration.config.ts](../../webclient/vitest.integration.config.ts) via `npm run test:integration` — slower; exercise the wired-up `WebClient` against fakes in `src/__test-utils__/`.
**Globals that leak within a file.** `vi.restoreAllMocks()` only restores `vi.spyOn` targets. Bare `Object.defineProperty` writes (e.g. on `window.location`) and global reassignments (e.g. `globalThis.WebSocket = ...`) leak between tests in the same file — `setupTests.ts` does not auto-restore them. Use `withMockLocation` from [webclient/src/__test-utils__/globalGuards.ts](../../webclient/src/__test-utils__/globalGuards.ts) for scoped overrides that clean up after themselves.
**Shared scaffolding.** [webclient/src/__test-utils__/](../../webclient/src/__test-utils__/) provides render helpers, a mock-client builder, and global guards. Prefer these over hand-rolling providers — the integration suite depends on injecting pre-built `WebClient` instances through them. Store slices have co-located `__mocks__/fixtures.ts` files exposing `make*` factories that build protobuf messages via `create(Schema, overrides)`; reuse them instead of hand-rolling proto objects.
`npm run golden` (lint + unit + integration) is the CI gate — run it before declaring work done.
## Protocol changes
When a task edits `.proto` files in `../libcockatrice_protocol/`:
1. Run `npm run proto:generate`.
2. Update any command / event / `*RequestImpl` / `*ResponseImpl` code that consumes the changed messages.
3. Commit consumer changes only — `src/generated/proto/**` is gitignored and must not be committed.
---
## Domain Knowledge
Facts that can't be read off the code — external systems (Servatrice protocol, Protobuf-ES runtime, browser WebSocket semantics) and invariants the code relies on but cannot itself express.
### Initialization order
Protobuf-ES maps proto `int64` / `uint64` fields to native `BigInt`. `BigInt.prototype` has no `toJSON`, so `JSON.stringify` throws on any state that contains one — which Redux DevTools, structured logging, and React error-boundary dumps all do. [webclient/src/polyfills.ts](../../webclient/src/polyfills.ts) installs a `BigInt.prototype.toJSON` that returns `this.toString()`, coercing to string on serialize.
Coercion is one-way: `JSON.parse` does not round-trip back to `BigInt`. That is acceptable because in-memory state still holds real `BigInt`s; only serialized surfaces (devtools, logs) see the coerced form.
The polyfill must execute before any module creates the store, or the first devtools dump throws. Enforced by making `./polyfills` the first import in [webclient/src/index.tsx](../../webclient/src/index.tsx) and [webclient/src/setupTests.ts](../../webclient/src/setupTests.ts).
### Startup / session invariants
Product requirement: **auto-login runs at most once per JS session, and logout within the same session does NOT re-trigger it.** Only a full page refresh does. This matches the Cockatrice desktop client.
The gate lives at module scope in [webclient/src/hooks/useAutoLogin.ts](../../webclient/src/hooks/useAutoLogin.ts) as `autoLoginGate.hasChecked`. It flips to `true` after the startup check completes, regardless of whether the check actually fired a login — so a check that determined "don't auto-connect" (preference off, no saved password, etc.) still latches the gate. The gate is exported as a mutable object so integration tests can reset it without `vi.resetModules()`.
`useAutoLogin` consults settings via `getSettings()` (one-shot), not by subscribing to `settingsStore`. Editing the persisted auto-connect preference is a preference write, not a login signal.
### Data structure invariants
`Enriched.Room` and `Enriched.GameEntry` compose a raw proto (`info`) with client-side sibling fields. The TypeScript types cannot distinguish which fields stay fresh and which go stale, so this is a convention:
- **`info` is a wire snapshot at one point in time.** For `Room` it's the last `UPDATE_ROOMS` / `JOIN_ROOM` payload; for `GameEntry` it's the `Event_GameJoined` payload.
- **Fields on `info` that evolve via later events immediately go stale.** Read the sibling, never `info.*`:
| Type | Stale on `info` | Read instead |
|---|---|---|
| `Room` | `info.gameList` | `room.games` |
| `Room` | `info.userList` | `room.users` |
| `Room` | `info.gametypeList` | `room.gametypeMap` |
| `GameEntry` | `info.started` | `game.started` |
| `GameEntry` | `info.activePlayerId` etc. | top-level twin fields |
Adding a new field that updates via events means adding a top-level twin in [webclient/src/types/enriched.ts](../../webclient/src/types/enriched.ts) and never reading `info.<same-name>` after the initial snapshot.
### Reducer merge rules
Servatrice's `UPDATE_ROOMS` event carries room metadata only: the repeated `gameList` / `userList` / `gametypeList` collections on each `ServerInfo_Room` may be absent or stale. The reducer at [webclient/src/store/rooms/rooms.reducer.tsx](../../webclient/src/store/rooms/rooms.reducer.tsx) replaces `info`, `gametypeMap`, and `order` on existing rooms but preserves the normalized `games` and `users` maps, which are maintained by their own events (`updateGames`, `userJoined`, `userLeft`).
### Shared store pattern
`createSharedStore` in [webclient/src/hooks/useSharedStore.ts](../../webclient/src/hooks/useSharedStore.ts) exposes two surfaces with different semantics. Pick the right one per caller:
- **`subscribe` / `getSnapshot` (via `useSharedStore`)** — reactive. The component re-renders on every store update. Use from inside render.
- **`whenReady()`** — one-shot. Resolves with the first loaded value, then never fires again. Use from code that must read the loaded value exactly once and must NOT re-run on later updates (notably, startup orchestrators reading persisted preferences).
Subscribing in a startup orchestrator turns a later user action (ticking a preference) into a re-evaluation of startup logic, which is almost always wrong.
### Protocol quirks
Servatrice-side behavior the client has to accommodate:
- **`ServerOptions` is a bitmask.** [webclient/src/websocket/utils/passwordHasher.ts](../../webclient/src/websocket/utils/passwordHasher.ts) `passwordSaltSupported` uses `&`, not `===`. Don't "fix" it.
- **System-injected user messages can omit the username** (e.g. ban notifications where the target is the current user, or server announcements). [webclient/src/store/common/normalizers.ts](../../webclient/src/store/common/normalizers.ts) `normalizeUserMessage` handles this at the dispatch layer so the store always holds a clean user-facing string.
### WebSocket lifecycle
A failed `WebSocket` connect fires both `onerror` and `onclose`. `onerror` runs first with the richer status; [webclient/src/websocket/services/WebSocketService.ts](../../webclient/src/websocket/services/WebSocketService.ts) guards the `onclose` handler with `hasReportedError` so the generic "Connection Closed" doesn't overwrite the specific "Connection Failed". The flag clears on `onopen` and at the end of each `onclose` cycle.

View file

@ -30,7 +30,7 @@ jobs:
fail-fast: false
matrix:
node_version:
- 16
- 20
- lts/*
steps:

View file

@ -1 +1,2 @@
# Future template for server admin configuration
NODE_OPTIONS=--max-old-space-size=8192

View file

@ -1 +1 @@
ESLINT_NO_DEV_ERRORS=true

View file

@ -1 +1 @@
DISABLE_ESLINT_PLUGIN=true

View file

@ -1 +1 @@
CI=true

View file

@ -1,48 +0,0 @@
module.exports = {
"root": true,
"parser": "@typescript-eslint/parser",
"parserOptions": {"project": ["./tsconfig.json"]},
"plugins": [
"@typescript-eslint"
],
"ignorePatterns": ["node_modules/*", "build/*", "public/pb/*"],
"env": {
"jest": true
},
"rules": {
"array-bracket-spacing": ["error", "never"],
"arrow-spacing": ["error", {"before": true, "after": true}],
"block-spacing": ["error", "always"],
"brace-style": ["error", "1tbs", {"allowSingleLine": false}],
"comma-spacing": ["error", {"before": false, "after": true}],
"comma-style": ["error", "last"],
"computed-property-spacing": ["error", "never"],
"curly": ["error", "all"],
"dot-location": ["error", "property"],
"eol-last": ["error"],
"func-names": ["warn"],
"indent": ["error", 2, {"SwitchCase": 1}],
"key-spacing": ["error", {"beforeColon": false, "afterColon": true}],
"keyword-spacing": ["error"],
"linebreak-style": ["error", (process.platform === "win32" ? "windows" : "unix")],
"max-len": ["error", {"code": 140}],
"no-eq-null": ["off"],
"no-func-assign": ["error"],
"no-inline-comments": ["error"],
"no-mixed-spaces-and-tabs": ["error"],
"no-multi-spaces": ["error"],
"no-spaced-func": ["error"],
"no-trailing-spaces": ["error"],
"no-var": ["error"],
"object-curly-spacing": ["error", "always"],
"one-var": ["error", "never"],
"one-var-declaration-per-line": ["error"],
"quotes": ["error", "single"],
"semi-spacing": ["error", {"before": false, "after": true}],
"space-before-blocks": ["error"],
"space-before-function-paren": ["error", {"asyncArrow": "always", "anonymous": "never", "named": "never"}],
"space-in-parens": ["error", "never"],
"space-infix-ops": ["error"],
"space-unary-ops": ["error", {"words": true, "nonwords": false}]
}
}

View file

@ -5,9 +5,13 @@
/.pnp
.pnp.js
# AI generated docs
/plans
# generated ./src files
/src/proto-files.json
/src/server-props.json
/src/generated/
# testing
/coverage

View file

@ -1,73 +1,67 @@
# Webatrice
The Cockatrice web client — a React/TypeScript SPA that connects to a Servatrice server over a WebSocket.
## Application Architecture
![Application Architecture](architecture.png?raw=true "Application Architecture")
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
![Application Architecture](architecture/simple.png?raw=true "Application Architecture")
## Available Scripts
For the full set of diagrams (detailed layer map + command/response/event sequence) and the `npm run diagram` scripts that regenerate them, see [architecture/](architecture/). For prose — WebSocket layering, Redux store shape, test conventions — see [.github/instructions/webclient.instructions.md](../.github/instructions/webclient.instructions.md).
In the project directory, you can run:
## Stack
### `npm start`
React 19 + TypeScript, built with [Vite](https://vite.dev/) 8. State via Redux Toolkit + RxJS, UI via MUI v9, tests via Vitest, protobuf bindings generated by [buf](https://buf.build/) into Protobuf-ES.
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
## Prerequisites
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
- Node.js and npm
- Run every command below from the `webclient/` directory
### `npm test`
## Getting started
Launches the test runner in the interactive watch mode.<br />
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
```bash
npm install
npm start
```
### `npm run build`
`npm start` boots the Vite dev server and opens a browser tab at [http://localhost:5173](http://localhost:5173) automatically (configured via `server.open` in `vite.config.ts`). The first start runs `proto:generate` and `prebuild.js` via the `prestart` hook, so give it a moment.
Builds the app for production to the `build` folder.<br />
It correctly bundles React in production mode and optimizes the build for the best performance.
## Scripts
The build is minified and the filenames include the hashes.<br />
Your app is ready to be deployed!
### Dev & build
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
- `npm start` — start the Vite dev server (runs `proto:generate` + `prebuild.js` first via `prestart`)
- `npm run build` — production build into `build/` (also runs the prebuild hooks)
- `npm run preview` — serve the built `build/` output locally to smoke-test a production build
### `npm run eject`
### Tests
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
- `npm test` — one-shot Vitest run (unit specs)
- `npm run test:watch` — Vitest in watch mode
- `npm run test:integration` — integration specs via `vitest.integration.config.ts`
- `npm run test:coverage` / `npm run test:integration:coverage` — the above with v8 coverage
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
### Quality
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
- `npm run lint` / `npm run lint:fix` — ESLint over `src/`
- `npm run golden``lint` + `test` + `test:integration`; the CI-equivalent gate to run before declaring work done
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
### Codegen & i18n
## Learn More
- `npm run proto:generate` — regenerate Protobuf-ES bindings from `../libcockatrice_protocol` via `buf generate`
- `npm run translate` — re-run the i18n merge only (`prebuild.js -i18nOnly`)
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
## Generated files
To learn React, check out the [React documentation](https://reactjs.org/).
Produced by `proto:generate` and `prebuild.js` on every `npm start` / `npm run build`. Don't edit them by hand:
## To-Do List
| File | Tracked? | Notes |
|---|---|---|
| `src/generated/proto/**` | Gitignored | Protobuf bindings. Regenerate with `npm run proto:generate`; only appears after a first local run. |
| `src/server-props.json` | Gitignored | Build metadata including the current git SHA. Written by `prebuild.js`; only appears after a first local run. |
| `src/i18n-default.json` | **Committed** | Merged i18n catalog. Regenerate with `npm run translate` and commit whenever it changes. |
1) RefreshGuard modal
- there is no browser support for displaying custom output to window.onbeforeunload
- we should also display a custom modal explaining why they shouldnt refresh or navigate from the site
- ideally, the custom popup can be synced with the alert, so when the alert is closed, the modal closes too
## Further reading
2) Disable AutoScrollToBottom when the user has scrolled up
- when the user scrolls back to bottom, it should renable
- renable after a period of inactivity (3 minutes?)
3) Figure out how to type components w/ RouteComponentProps
- Component<RouteComponentProps<???, ???, ???>>
4) clear input onSubmit
5) figure out how to reflect server status changes in the ui
6) Account page
7) Register/Reset Password forms
8) Message User
9) Main Nav scheme
- [.github/instructions/webclient.instructions.md](../.github/instructions/webclient.instructions.md) — architecture deep dive, conventions, and domain-knowledge invariants for working in this directory (the canonical AI-tool instruction surface for this package)
- [Vite docs](https://vite.dev/guide/) · [React docs](https://react.dev/) · [Vitest docs](https://vitest.dev/)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View file

@ -0,0 +1,70 @@
# Webatrice architecture diagrams
Three views of the same architecture at different zoom levels. The `.mmd` files are the source of truth — edit them and re-render when the architecture changes. The `.png` files are committed so the README and GitHub file view render everywhere, including offline.
For the prose counterpart and conventions, see [../.github/instructions/webclient.instructions.md](../../.github/instructions/webclient.instructions.md) (the canonical AI-tool instruction surface for this package).
## When to look at which
| Diagram | Use it when |
|---|---|
| **[simple](simple.mmd)** | You need a mental model of the request/response loop in ten seconds. Good for onboarding. |
| **[detailed](detailed.mmd)** | You're making a structural change and want to see every module, which layer it belongs to, and how data moves between them. |
| **[flow](flow.mmd)** | You're debugging a specific round-trip and need to see the runtime order. Shows the `cmdId`-correlated response path vs. the extension-dispatched event path, plus the "no timeout, no retry" caveat. |
## Simple — high-level flow
![Simple architecture](simple.png)
Application on the left, Servatrice on the right, two-lane racetrack in between. The top lane is outbound (`client.request.*``Commands`), the bottom lane is inbound (`Events · Responses``client.response.*`), and both lanes ride the same WebSocket. Redux hangs off Application as its in-memory store; IndexedDB sits under Servatrice as the browser-side persistent store reached from hooks via Dexie. Both are stores, both sit outside the racetrack.
**Color = role:**
- Blue — application code (UI, hooks, API seams, WebClient)
- Purple — transport (WebSocket layer, services)
- Amber — state / data stores (Redux, protocol types)
- Gray — external systems (Servatrice, IndexedDB)
## Detailed — layers & dependencies
![Detailed architecture](detailed.png)
Every meaningful module in the webclient, arranged as a three-lane racetrack: outbound (`src/api/request/``commands/`) on top, transport (`WebClientProvider``WebClient``services/`) in the middle, inbound (`events/``src/api/response/`) on the bottom. Application bookend on the left holds UI, hooks, Redux store, and the Dexie persistence pair; Servatrice sits on the right. The protocol satellite (`src/types/` + `src/generated/proto/`) is drawn below with dashed edges up to the modules it types — it's cross-cutting, not on the flow path. Same four-role palette as the simple diagram.
Load-bearing invariants (enforced on `webclient-websocket-layer`; keep it that way):
- **UI never imports `@app/websocket` or `@app/api`** — always go through `useWebClient()`.
- **Only `src/types/` imports from `@app/generated`** — everywhere else uses `Data` / `Enriched` / `App`.
- **Only `*.dispatch.ts` helpers and `*ResponseImpl` classes call `store.dispatch`** — the API response layer is the single inbound seam into Redux.
## Flow — command → response → event round-trip
![Sequence: join room](flow.png)
Scenario: user joins a room. The sequence shows the outbound command path (steps 16), the correlated response path matched by `cmdId` in `ProtobufService`'s pending map (steps 710), and an unsolicited server event dispatched by proto-extension match against the event registry in `processRoomEvent` / `processSessionEvent` / `processGameEvent` (steps 1115).
Read the footnote: `ProtobufService` has no timeout and no retry, and `resetCommands()` on reconnect silently drops in-flight callbacks. Code that needs reconnection resilience has to handle it at a higher layer.
## Rendering
npm scripts are defined in [../package.json](../package.json) — no separate build step, no added runtime dependency (everything runs via `npx`).
```bash
# from the webclient/ directory:
npm run diagram # render all three (simple + detailed + flow)
npm run diagram:simple # render just simple.png
npm run diagram:detailed # render just detailed.png
npm run diagram:flow # render just flow.png
```
Under the hood each command is:
```bash
npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc \
-i architecture/<name>.mmd -o architecture/<name>.png -b white -s 2
```
`-s 2` renders at 2× scale so the PNG stays crisp on high-DPI displays; `-b white` gives the diagrams a light-mode background that looks right in both GitHub's light and dark themes.
If `mmdc` fails locally (it spawns headless Chromium — some sandboxed environments block that), paste the `.mmd` contents into [mermaid.live](https://mermaid.live) and export to PNG. The `.mmd` sources remain canonical either way.

View file

@ -0,0 +1,123 @@
---
config:
layout: elk
theme: base
themeVariables:
background: "#ffffff"
primaryColor: "#ffffff"
primaryBorderColor: "#1f2937"
primaryTextColor: "#0b1220"
lineColor: "#1f2937"
textColor: "#0b1220"
edgeLabelBackground: "#ffffff"
fontSize: "20px"
clusterBkg: "#fafafa"
clusterBorder: "#9ca3af"
flowchart:
htmlLabels: true
curve: basis
nodeSpacing: 60
rankSpacing: 90
---
flowchart LR
%% =========================================================
%% Left bookend — browser-side Application
%% =========================================================
subgraph LBE["<b>Application</b>"]
direction TB
UI["<b>UI</b><br/><span style='font-size:15px'>containers · components<br/>forms · dialogs</span>"]
Hooks["<b>hooks/</b><br/><span style='font-size:15px'>useWebClient · useAutoLogin<br/>useSettings · useKnownHosts</span>"]
Store[("<b>@app/store</b><br/><span style='font-size:15px'>server · rooms · game<br/>actions · common</span>")]
DTOs["<b>dexie DTOs</b><br/><span style='font-size:15px'>Card · Host · Set<br/>Setting · Token</span>"]
IDB[("<b>IndexedDB</b>")]
end
%% =========================================================
%% Racetrack — three lanes: outbound / transport / inbound
%% =========================================================
subgraph RACE[" "]
direction TB
subgraph TOP["<b>Outbound lane</b>"]
direction LR
Req["<b>src/api/request/</b><br/><span style='font-size:15px'>Authentication · Session · Rooms<br/>Game · Admin · Moderator</span>"]
Cmds["<b>commands/</b><br/><span style='font-size:15px'>session · room · game<br/>admin · moderator</span>"]
end
subgraph MID["<b>Transport</b>"]
direction LR
Provider["<b>WebClientProvider</b>"]
WC[["<b>WebClient</b><br/><span style='font-size:15px'>singleton · request · response</span>"]]
Svc["<b>services/</b><br/><span style='font-size:15px'>ProtobufService · WebSocketService<br/>KeepAliveService · command-options</span>"]
end
subgraph BOT["<b>Inbound lane</b>"]
direction LR
Evts["<b>events/</b><br/><span style='font-size:15px'>session · room · game</span>"]
Res["<b>src/api/response/</b><br/><span style='font-size:15px'>Session · Room · Game<br/>Admin · Moderator</span>"]
end
end
%% =========================================================
%% Right bookend — Servatrice
%% =========================================================
Srv[("<b>Servatrice</b>")]
%% =========================================================
%% Protocol satellite — cross-cutting types
%% =========================================================
subgraph PROTO["<b>Protocol (cross-cutting)</b>"]
direction LR
Types["<b>src/types/</b><br/><span style='font-size:15px'>Data · Enriched · App</span>"]
Gen["<b>src/generated/proto/</b><br/><span style='font-size:15px'>@bufbuild/protobuf</span>"]
end
%% =========================================================
%% UI-side wiring
%% =========================================================
UI --> Hooks
Hooks -- "useWebClient()" --> Provider
Provider --> WC
UI -- "selectors (read)" --> Store
Hooks --> DTOs
DTOs <--> IDB
%% =========================================================
%% Outbound — request goes up through the top lane to Srv
%% =========================================================
WC --> Req
Req --> Cmds
Cmds --> Svc
Svc -- "frames" --> Srv
%% =========================================================
%% Inbound — Srv comes back through services, splits to
%% cmdId response (direct) and event-registry dispatch
%% =========================================================
Srv -- "frames" --> Svc
Svc --> Evts
Svc -- "response by cmdId" --> Res
Evts --> Res
Res -- "dispatch" --> Store
%% =========================================================
%% Protocol edges — dashed, cross-cutting
%% =========================================================
Req -.-> Types
Res -.-> Types
Cmds -.-> Types
Evts -.-> Types
Types --> Gen
%% =========================================================
%% Palette — four roles
%% =========================================================
classDef app fill:#dbeafe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220
classDef transport fill:#ede9fe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220
classDef store fill:#fde68a,stroke:#1f2937,stroke-width:1.5px,color:#0b1220
classDef external fill:#e5e7eb,stroke:#1f2937,stroke-width:1.5px,color:#0b1220
class UI,Hooks,DTOs,Provider,WC app
class Req,Cmds,Svc,Evts,Res transport
class Store,Types,Gen store
class Srv,IDB external

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View file

@ -0,0 +1,41 @@
%%{init: {"theme": "base", "themeVariables": {"background": "#ffffff", "primaryColor": "#ffffff", "primaryBorderColor": "#1f2937", "primaryTextColor": "#0b1220", "lineColor": "#1f2937", "textColor": "#0b1220", "signalColor": "#1f2937", "signalTextColor": "#0b1220", "actorBkg": "#ffedd5", "actorBorder": "#1f2937", "actorTextColor": "#0b1220", "actorLineColor": "#1f2937", "sequenceNumberColor": "#ffffff", "noteBkgColor": "#fef3c7", "noteBorderColor": "#1f2937", "noteTextColor": "#0b1220", "labelBoxBkgColor": "#ffffff", "labelTextColor": "#0b1220"}, "sequence": {"showSequenceNumbers": true, "mirrorActors": false, "messageFontSize": "13px", "actorFontSize": "13px", "noteFontSize": "12px", "actorMargin": 14, "boxMargin": 6, "boxTextMargin": 3, "noteMargin": 8, "messageMargin": 48, "diagramMarginX": 8, "diagramMarginY": 8, "width": 120}}}%%
sequenceDiagram
autonumber
participant C as Application
participant RQ as API Request
participant CMD as Command
participant PB as Protobuf
participant WS as WebSocket
participant S as Servatrice
participant EV as Event
participant RS as API Response
rect rgb(219, 234, 254)
Note over C,S: Request — user action → command
C->>RQ: joinRoom(roomId)
RQ->>CMD: build RoomCommand
CMD->>PB: sendRoomCommand(cmd, onSuccess/onError)
PB->>PB: assign cmdId,<br/>register callback
PB->>WS: send(bytes)
WS->>S: Command
end
rect rgb(254, 243, 199)
Note over S,RS: Response — correlated by cmdId
S-->>WS: Response (cmdId)
WS-->>PB: processServerResponse
PB->>RS: onSuccess(response)
RS->>RS: dispatch → Redux
end
rect rgb(220, 252, 231)
Note over S,RS: Event — no cmdId — dispatched by extension
S-->>WS: Event
WS-->>PB: processRoomEvent
PB->>EV: pick handler by extension
EV->>RS: roomEvent(...)
RS->>RS: dispatch → Redux
end
Note over PB,WS: No timeout, no retry.<br/>resetCommands() on reconnect<br/>silently drops pending callbacks.

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View file

@ -0,0 +1,58 @@
---
config:
theme: base
themeVariables:
background: "#ffffff"
primaryColor: "#ffffff"
primaryBorderColor: "#1f2937"
primaryTextColor: "#0b1220"
lineColor: "#1f2937"
textColor: "#0b1220"
edgeLabelBackground: "#ffffff"
fontSize: "18px"
clusterBkg: "#ffffff"
clusterBorder: "#9ca3af"
flowchart:
htmlLabels: true
curve: basis
nodeSpacing: 55
rankSpacing: 90
---
flowchart LR
subgraph APP_COL[" "]
direction TB
App["<b>Application</b><br/><span style='font-size:13px'>containers · components · hooks</span>"]
Rdx[("<b>Redux</b><br/><span style='font-size:12px'>in-memory state</span>")]
end
subgraph SRV_COL[" "]
direction TB
Srv[("<b>Servatrice</b>")]
IDB[("<b>IndexedDB</b><br/><span style='font-size:12px'>local persistent store</span>")]
end
Req["client.request"]
Res["client.response"]
%% Outbound lane (top)
App -- "useWebClient()" --> Req
Req -- "Commands" --> Srv
%% Inbound lane (bottom)
Srv -- "Events · Responses" --> Res
Res -- "dispatch · rerender" --> App
%% Local stores — Application owns both; edges only to IndexedDB
%% (Redux state is implicit — reducers sit under dispatch, selectors under rerender)
App -. "Dexie: settings · hosts · cards" .-> IDB
%% Palette — four roles
classDef app fill:#dbeafe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220
classDef seam fill:#dbeafe,stroke:#1f2937,stroke-width:1.5px,color:#0b1220
classDef store fill:#fde68a,stroke:#1f2937,stroke-width:1.5px,color:#0b1220
classDef external fill:#e5e7eb,stroke:#1f2937,stroke-width:1.5px,color:#0b1220
class App app
class Req,Res seam
class Rdx store
class Srv,IDB external

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View file

@ -0,0 +1,165 @@
// @ts-check
/**
* Custom protoc-gen-es sibling plugin. Emits `src/generated/index.ts`, a
* single rollup that re-exports every generated `_pb` module and adds
* `MessageInitShape<T>` param aliases for every `Command_*` message.
*
* Wired into `buf.gen.yaml` as a second local plugin. Runs with the same
* descriptor set protoc-gen-es consumes, so output always tracks the protos.
*/
import { createEcmaScriptPlugin, runNodeJs } from '@bufbuild/protoplugin';
const HEADER = [
'// @generated by protoc-gen-data. DO NOT EDIT.',
'// Rollup of all proto modules + MessageInitShape param aliases for every Command_*,',
'// plus type maps for Response/Event extensions grouped by scope.',
'/* eslint-disable */',
'',
'',
].join('\n');
const inner = createEcmaScriptPlugin({
name: 'protoc-gen-data',
version: 'v0.1.0',
generateTs(schema) {
const f = schema.generateFile('index.ts');
const MessageInitShape = f.import('MessageInitShape', '@bufbuild/protobuf', true);
const MessageType = f.import('Message', '@bufbuild/protobuf', true);
const GenExtensionType = f.import('GenExtension', '@bufbuild/protobuf/codegenv2', true);
const sortedFiles = [...schema.files].sort((a, b) => a.name.localeCompare(b.name));
for (const file of sortedFiles) {
f.print('export * from ', f.string(`./proto/${file.name}_pb`), ';');
}
f.print();
const commandMessages = [];
for (const file of sortedFiles) {
for (const msg of file.messages) {
if (msg.name.startsWith('Command_')) {
commandMessages.push(msg);
}
}
}
commandMessages.sort((a, b) => a.name.localeCompare(b.name));
// importSchema() resolves paths relative to this plugin's `out` dir, which
// yields `./<name>_pb` — but the _pb files live under ./proto/ (protoc-gen-es's
// out). Build the import path explicitly so it points inside the proto subdir.
for (const msg of commandMessages) {
const alias = msg.name.slice('Command_'.length) + 'Params';
const schemaName = `${msg.name}Schema`;
const schemaSym = f.import(schemaName, `./proto/${msg.file.name}_pb`, true);
f.print('export type ', alias, ' = ', MessageInitShape, '<typeof ', schemaSym, '>;');
}
f.print();
// ── Type maps for Response/Event extensions, grouped by extendee ────────
//
// Scans all messages for nested `extend` declarations and groups them by
// which message they extend (Response, SessionEvent, RoomEvent, GameEvent).
// Emits one `interface *Map { TypeName: TypeName; ... }` per scope.
/** @type {Map<string, import('@bufbuild/protobuf').DescMessage[]>} */
const extendeeGroups = new Map();
for (const file of sortedFiles) {
for (const msg of file.messages) {
for (const ext of msg.nestedExtensions) {
const target = ext.extendee.name;
const group = extendeeGroups.get(target);
if (group) {
group.push(msg);
} else {
extendeeGroups.set(target, [msg]);
}
}
}
}
/** @type {[string, string, import('@bufbuild/protobuf').DescMessage | null][]} */
const maps = [
['ResponseMap', 'Response', null],
['SessionEventMap', 'SessionEvent', null],
['RoomEventMap', 'RoomEvent', null],
['GameEventMap', 'GameEvent', null],
];
// Resolve the base extendee message for maps that need the base type included
for (const file of sortedFiles) {
for (const msg of file.messages) {
for (const entry of maps) {
if (msg.name === entry[1]) {
entry[2] = msg;
}
}
}
}
for (const [mapName, extendeeName, baseMsg] of maps) {
const msgs = (extendeeGroups.get(extendeeName) || []).slice();
msgs.sort((a, b) => a.name.localeCompare(b.name));
if (msgs.length === 0 && !baseMsg) continue;
f.print('export interface ', mapName, ' {');
// Include the base extendee type itself (e.g. Response in ResponseMap)
if (baseMsg) {
const sym = f.import(baseMsg.name, `./proto/${baseMsg.file.name}_pb`, true);
f.print(' ', baseMsg.name, ': ', sym, ';');
}
for (const msg of msgs) {
const sym = f.import(msg.name, `./proto/${msg.file.name}_pb`, true);
f.print(' ', msg.name, ': ', sym, ';');
}
f.print('}');
f.print();
}
// Generic extension registry infrastructure. Consolidates the three
// near-duplicate registry types and helpers that used to live in
// src/websocket/services/protobuf-types.ts into one generic pair.
// Specialised aliases (Session/Room/Game) still live in protobuf-types.ts
// because GameExtensionRegistry needs GameEventMeta — a hand-written
// domain type whose import would create a generated/ ↔ types/ cycle.
f.print('export type RegistryEntry<V, T extends ', MessageType, ', M = unknown> = [');
f.print(' ', GenExtensionType, '<T, V>,');
f.print(' (value: V, meta: M) => void,');
f.print('];');
f.print();
// Return type widens V to `unknown` so the heterogeneous entries that
// callers build can be stored in a homogeneous `RegistryEntry<unknown, T, M>[]`
// array. This is the actual value-add over a bare tuple literal.
f.print('export function makeEntry<T extends ', MessageType, ', V, M = unknown>(');
f.print(' ext: ', GenExtensionType, '<T, V>,');
f.print(' handler: (value: V, meta: M) => void,');
f.print('): RegistryEntry<unknown, T, M> {');
f.print(' return [ext, handler] as unknown as RegistryEntry<unknown, T, M>;');
f.print('}');
},
});
// Skip f.preamble() above and inject a custom rollup-aware header here instead —
// preamble() would write "@generated from file X.proto" which is misleading for
// a rollup file built from every input proto.
/** @type {import('@bufbuild/protoplugin').Plugin} */
const plugin = {
name: inner.name,
version: inner.version,
run(request) {
const response = inner.run(request);
for (const file of response.file) {
if (file.name === 'index.ts' && typeof file.content === 'string') {
file.content = HEADER + file.content;
}
}
return response;
},
};
runNodeJs(plugin);

12
webclient/buf.gen.yaml Normal file
View file

@ -0,0 +1,12 @@
version: v2
inputs:
- directory: ../libcockatrice_protocol/libcockatrice/protocol/pb
plugins:
- local: protoc-gen-es
out: src/generated/proto
opt:
- target=ts
- local: [node, buf.gen.plugin.mjs]
out: src/generated
opt:
- target=ts

View file

@ -0,0 +1,71 @@
import boundaries from 'eslint-plugin-boundaries';
const elements = [
{ type: 'api', pattern: ['src/api/**'] },
{ type: 'components', pattern: ['src/components/**'] },
{ type: 'containers', pattern: ['src/containers/**'] },
{ type: 'dialogs', pattern: ['src/dialogs/**'] },
{ type: 'forms', pattern: ['src/forms/**'] },
{ type: 'generated', pattern: ['src/generated/**'] },
{ type: 'hooks', pattern: ['src/hooks/**'] },
{ type: 'images', pattern: ['src/images/**'] },
{ type: 'services', pattern: ['src/services/**'] },
{ type: 'store', pattern: ['src/store/**'] },
{ type: 'types', pattern: ['src/types/**'] },
{ type: 'utils', pattern: ['src/utils/**'] },
{ type: 'websocket-types', pattern: ['src/websocket/types/**'] },
{ type: 'websocket', pattern: ['src/websocket/**'] },
];
const types = (...types) => types.map((type) => ({ to: { type } }));
const rules = [
{ from: { type: 'generated' }, allow: [] },
{ from: { type: 'websocket-types' }, allow: types('generated') },
{ from: { type: 'websocket' }, allow: types('generated', 'websocket-types') },
{ from: { type: 'types' }, allow: types('generated') },
{ from: { type: 'utils' }, allow: types('types') },
{ from: { type: 'store' }, allow: types('types', 'utils', 'websocket-types') },
{ from: { type: 'api' }, allow: types('store', 'types', 'utils', 'websocket', 'websocket-types') },
{ from: { type: 'images' }, allow: types('types') },
{ from: { type: 'services' }, allow: types('api', 'store', 'types', 'utils') },
{ from: { type: 'hooks' }, allow: types('api', 'services', 'store', 'types', 'utils', 'websocket', 'websocket-types') },
{
from: { type: 'components' },
allow: types('api', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'utils', 'websocket-types')
},
{
from: { type: 'containers' },
allow: types('api', 'components', 'dialogs', 'forms', 'hooks', 'images', 'services', 'store', 'types', 'utils', 'websocket-types')
},
{ from: { type: 'dialogs' }, allow: types('components', 'forms', 'hooks', 'services', 'store', 'types', 'utils', 'websocket-types') },
{ from: { type: 'forms' }, allow: types('components', 'hooks', 'services', 'store', 'types', 'utils', 'websocket-types') },
];
export const boundariesConfig = [
{
plugins: { boundaries },
settings: {
'boundaries/elements': elements,
'import/resolver': {
typescript: {
alwaysTryTypes: true,
project: './tsconfig.json',
},
},
},
rules: {
'boundaries/dependencies': ['error', {
default: 'disallow',
rules,
}],
},
},
{
files: ['**/*.spec.*'],
rules: { 'boundaries/dependencies': 'off' },
},
];

View file

@ -0,0 +1,82 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import globals from 'globals';
import { boundariesConfig } from './eslint.boundaries.mjs';
export default tseslint.config(
// Global ignores
{ ignores: ['node_modules/**', 'build/**', 'public/pb/**', 'src/generated/**'] },
// Base JS recommended
js.configs.recommended,
// TypeScript recommended (sets up parser + plugin)
...tseslint.configs.recommended,
// Enforce module boundaries
...boundariesConfig,
// Project-specific config
{
languageOptions: {
ecmaVersion: 2020,
sourceType: 'module',
parserOptions: {
ecmaFeatures: { jsx: true },
},
globals: {
...globals.browser,
...globals.es2020,
},
},
rules: {
// TypeScript overrides
'@typescript-eslint/no-unused-vars': ['warn', { varsIgnorePattern: '^_', argsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unsafe-function-type': 'off',
// Disable new rules not in original config
'prefer-const': 'off',
'no-extra-boolean-cast': 'off',
'no-case-declarations': 'off',
'preserve-caught-error': 'off',
// Spacing / formatting
'array-bracket-spacing': ['error', 'never'],
'arrow-spacing': ['error', { before: true, after: true }],
'block-spacing': ['error', 'always'],
'brace-style': ['error', '1tbs', { allowSingleLine: false }],
'comma-spacing': ['error', { before: false, after: true }],
'comma-style': ['error', 'last'],
'computed-property-spacing': ['error', 'never'],
'curly': ['error', 'all'],
'dot-location': ['error', 'property'],
'eol-last': ['error'],
'func-names': ['warn'],
'indent': ['error', 2, { SwitchCase: 1 }],
'key-spacing': ['error', { beforeColon: false, afterColon: true }],
'keyword-spacing': ['error'],
'linebreak-style': ['error', process.platform === 'win32' ? 'windows' : 'unix'],
'max-len': ['error', { code: 140 }],
'no-eq-null': ['off'],
'no-func-assign': ['error'],
'no-inline-comments': ['error'],
'no-mixed-spaces-and-tabs': ['error'],
'no-multi-spaces': ['error'],
'no-trailing-spaces': ['error'],
'no-var': ['error'],
'object-curly-spacing': ['error', 'always'],
'one-var': ['error', 'never'],
'one-var-declaration-per-line': ['error'],
'quotes': ['error', 'single'],
'semi-spacing': ['error', { before: false, after: true }],
'space-before-blocks': ['error'],
'space-before-function-paren': ['error', { asyncArrow: 'always', anonymous: 'never', named: 'never' }],
'space-in-parens': ['error', 'never'],
'space-infix-ops': ['error'],
'space-unary-ops': ['error', { words: true, nonwords: false }],
},
},
);

22
webclient/index.html Normal file
View file

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" type="text/css" href="/reset.css">
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Webatrice: A Cockatrice Web Client"
/>
<link rel="apple-touch-icon" href="logo192.png" />
<link rel="manifest" href="/manifest.json" />
<title>Webatrice</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,443 @@
// Exercises the full Game container under the real Redux store + real
// reducers + real React chain. We dispatch game lifecycle events via
// GameDispatch (the same path real event handlers take) and assert the
// Game container's UI tracks state transitions.
import { act, fireEvent, waitFor, screen, within } from '@testing-library/react';
import { create } from '@bufbuild/protobuf';
import { useLocation } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Data } from '@app/types';
import { Command_GameSay_ext } from '@app/generated';
import { GameDispatch, ServerDispatch, store } from '@app/store';
import { WebsocketTypes } from '@app/websocket/types';
import Game from '../../../src/containers/Game/Game';
import { renderAppScreen } from './helpers';
import { findLastGameCommand } from '../helpers/command-capture';
import { connectRaw } from '../helpers/setup';
// Surfaces the current MemoryRouter pathname so navigate() side-effects
// (e.g. useGameLifecycle → /server on kick) can be asserted. Depends on
// `renderAppScreen` → `renderWithProviders` wrapping its tree in a
// `MemoryRouter`; if that harness ever moves the probe outside a Router,
// `useLocation()` will throw "useLocation() may be used only in the
// context of a <Router> component." — fix by adding an inline
// `<MemoryRouter>` around the probe OR by teaching the harness about it.
function LocationProbe() {
const location = useLocation();
return <span data-testid="app-location">{location.pathname}</span>;
}
function buildEventGameJoined(args: {
gameId: number;
localPlayerId: number;
hostId: number;
}): Data.Event_GameJoined {
return create(Data.Event_GameJoinedSchema, {
gameInfo: create(Data.ServerInfo_GameSchema, {
gameId: args.gameId,
roomId: 1,
description: 'Integration Test Game',
gameTypes: [],
started: false,
}),
hostId: args.hostId,
playerId: args.localPlayerId,
spectator: false,
judge: false,
resuming: false,
});
}
function buildEventGameStateChanged(
playerIds: number[],
localId: number,
): Data.Event_GameStateChanged {
return create(Data.Event_GameStateChangedSchema, {
gameStarted: true,
activePlayerId: localId,
activePhase: 0,
playerList: playerIds.map((pid) =>
create(Data.ServerInfo_PlayerSchema, {
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: pid,
userInfo: create(Data.ServerInfo_UserSchema, { name: `P${pid}` }),
spectator: false,
conceded: false,
readyStart: false,
judge: false,
}),
deckList: '',
zoneList: [
create(Data.ServerInfo_ZoneSchema, {
name: 'table',
type: 1,
withCoords: true,
cardCount: 0,
cardList: [],
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'hand',
type: 0,
withCoords: false,
cardCount: 0,
cardList: [],
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'deck',
type: 2,
withCoords: false,
cardCount: 40,
cardList: [],
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'grave',
type: 1,
withCoords: false,
cardCount: 0,
cardList: [],
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'rfg',
type: 1,
withCoords: false,
cardCount: 0,
cardList: [],
}),
],
counterList: [],
arrowList: [],
}),
),
});
}
function simulateConnected() {
act(() => {
ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.LOGGED_IN, null);
});
}
afterEach(() => {
act(() => {
for (const gameId of Object.keys(store.getState().games.games)) {
GameDispatch.gameLeft(Number(gameId));
}
ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.DISCONNECTED, null);
});
});
beforeEach(() => {
// Integration setup installs fake timers for KeepAliveService control;
// waitFor / React effects need real timers to run between dispatch and assert.
vi.useRealTimers();
simulateConnected();
});
describe('Game board integration', () => {
it('renders the empty-board placeholder until a game is joined', () => {
renderAppScreen(<Game />);
expect(screen.getByTestId('game-empty')).toBeInTheDocument();
expect(screen.getByTestId('phase-bar')).toBeInTheDocument();
expect(screen.getByTestId('right-panel')).toBeInTheDocument();
});
it('transitions from empty → active board when gameJoined + gameStateChanged fire', async () => {
renderAppScreen(<Game />);
expect(screen.getByTestId('game-empty')).toBeInTheDocument();
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.queryByTestId('game-empty')).not.toBeInTheDocument();
});
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
expect(screen.getByTestId('player-board-2')).toBeInTheDocument();
expect(screen.getByTestId('hand-zone')).toBeInTheDocument();
});
it('returns to the empty placeholder when gameLeft fires', async () => {
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
});
act(() => {
GameDispatch.gameLeft(42);
});
await waitFor(() => {
expect(screen.getByTestId('game-empty')).toBeInTheDocument();
});
expect(screen.queryByTestId('player-board-1')).not.toBeInTheDocument();
});
it('hides the opponent selector for 2-player games but shows it for 3+', async () => {
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
});
expect(screen.queryByTestId('opponent-selector')).not.toBeInTheDocument();
act(() => {
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2, 3], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('opponent-selector')).toBeInTheDocument();
});
});
it('mirrors the opponent board and leaves the local board upright', async () => {
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-board-2')).toHaveClass('player-board--mirrored');
});
expect(screen.getByTestId('player-board-1')).not.toHaveClass('player-board--mirrored');
});
it('renders the deck/graveyard/exile rail in desktop order (no stack in rail)', async () => {
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
});
const localBoard = screen.getByTestId('player-board-1');
const rail = within(localBoard).getByTestId('zone-rail');
const labels = Array.from(rail.querySelectorAll('.zone-stack__label')).map(
(n) => n.textContent,
);
expect(labels).toEqual(['Deck', 'Graveyard', 'Exile']);
expect(within(rail).queryByText('Stack')).not.toBeInTheDocument();
});
it('sends a game_say command through the socket when a chat message is submitted', async () => {
// Establish a real mock socket so the outbound CommandContainer is captured.
connectRaw();
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
// buildEventGameStateChanged sets gameStarted: true, suppressing the
// deck-select dialog which would otherwise block focus/interaction.
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByLabelText('game chat input')).not.toBeDisabled();
});
const input = screen.getByLabelText('game chat input') as HTMLInputElement;
fireEvent.change(input, { target: { value: 'gl hf' } });
fireEvent.submit(input.closest('form')!);
const captured = findLastGameCommand(Command_GameSay_ext);
expect(captured.value.message).toBe('gl hf');
expect(captured.gameId).toBe(42);
});
it('navigates to /server when the local user is kicked', async () => {
renderAppScreen(
<>
<Game />
<LocationProbe />
</>,
);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
});
act(() => {
GameDispatch.kicked(42);
});
await waitFor(() => {
expect(screen.getByTestId('app-location')).toHaveTextContent('/server');
});
});
it('navigates to /server when the game is closed by the host', async () => {
renderAppScreen(
<>
<Game />
<LocationProbe />
</>,
);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-board-1')).toBeInTheDocument();
});
act(() => {
GameDispatch.gameClosed(42);
});
await waitFor(() => {
expect(screen.getByTestId('app-location')).toHaveTextContent('/server');
});
});
it('reflects a host change through both PlayerList badge and PlayerInfoPanel', async () => {
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
buildEventGameStateChanged([1, 2], 1),
);
});
await waitFor(() => {
expect(screen.getByTestId('player-list-item-1')).toBeInTheDocument();
});
// Host starts as 1; badge should be on row 1.
expect(
screen.getByTestId('player-list-item-1').querySelector('.player-list__host-badge'),
).not.toBeNull();
expect(
screen.getByTestId('player-list-item-2').querySelector('.player-list__host-badge'),
).toBeNull();
// Host changes to player 2.
act(() => {
GameDispatch.gameHostChanged(42, 2);
});
await waitFor(() => {
expect(
screen.getByTestId('player-list-item-2').querySelector('.player-list__host-badge'),
).not.toBeNull();
});
expect(
screen.getByTestId('player-list-item-1').querySelector('.player-list__host-badge'),
).toBeNull();
});
it('auto-opens the DeckSelectDialog when a game is joined and not started', async () => {
renderAppScreen(<Game />);
act(() => {
GameDispatch.gameJoined(
buildEventGameJoined({ gameId: 42, localPlayerId: 1, hostId: 1 }),
);
GameDispatch.gameStateChanged(
42,
create(Data.Event_GameStateChangedSchema, {
gameStarted: false,
activePlayerId: 1,
activePhase: -1,
playerList: [1, 2].map((pid) =>
create(Data.ServerInfo_PlayerSchema, {
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: pid,
userInfo: create(Data.ServerInfo_UserSchema, { name: `P${pid}` }),
}),
deckList: '',
zoneList: [],
counterList: [],
arrowList: [],
}),
),
}),
);
});
await waitFor(() => {
expect(screen.getByLabelText('deck list')).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,33 @@
// Shared render helper for the app integration suite.
//
// Two non-obvious choices:
//
// 1. WebClientContext is provided directly (not via production
// <WebClientProvider>) because the shared integration setup.ts already
// instantiates the WebClient singleton in beforeEach. The production
// provider would `new WebClient(...)` a second time and throw.
//
// 2. We pass the REAL Redux store from @app/store — not renderWithProviders'
// default test-local store. The real WebClient dispatches against the
// real store (that's what createWebClientResponse wires to). Asserting
// against a different in-memory store would silently miss every
// dispatch. setup.ts's resetAll + afterEach clears the real store
// between tests, so each test still starts from a clean slate.
import { ReactElement } from 'react';
import { renderWithProviders } from '../../../src/__test-utils__';
import { store } from '@app/store';
import { WebClientContext } from '@app/hooks';
import { WebClient } from '@app/websocket';
export function renderAppScreen(ui: ReactElement) {
return renderWithProviders(
<WebClientContext.Provider value={WebClient.instance}>
{ui}
</WebClientContext.Provider>,
{ store }
);
}
export { store };

View file

@ -0,0 +1,192 @@
// Full-stack autoconnect integration. Only outbound surfaces are mocked
// (WebSocket via the shared setup, IndexedDB via fake-indexeddb in setup).
// Everything in between — Dexie, DTOs, useSettings/useKnownHosts, useAutoLogin,
// the Login container, WebClient, Redux — runs as shipped code.
//
// We assert auto-login via `connectionAttemptMade` on the real server slice,
// not via the WebSocket mock's call count: KnownHosts fires a testConnection
// on mount for the UX indicator, which also constructs sockets, so raw
// socket counts are noisy. Only the login path dispatches CONNECTION_ATTEMPTED.
import { act, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Store loads notify React subscribers synchronously when the Dexie
// promise resolves. Awaiting whenReady() directly would let those
// notifications fire outside an act() scope, which trips React's
// "update was not wrapped in act" warning. Wrapping here captures
// both the store resolution and any resulting component re-renders.
const flushStoresAndEffects = async (): Promise<void> => {
await act(async () => {
await settingsStore.whenReady();
await knownHostsStore.whenReady();
// Let dependent effects (host-sync, settings-sync) commit.
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
import { autoLoginGate } from '../../../src/hooks/useAutoLogin';
import { settingsStore } from '../../../src/hooks/useSettings';
import { knownHostsStore } from '../../../src/hooks/useKnownHosts';
import Login from '../../../src/containers/Login/Login';
import { HostDTO, SettingDTO } from '@app/services';
import { App } from '@app/types';
import { ServerSelectors, ServerDispatch } from '@app/store';
import { WebsocketTypes } from '@app/websocket/types';
import { resetDexie } from '../services/dexie/resetDexie';
import { renderAppScreen, store } from './helpers';
// Mimics the production "user logged out / connection dropped" transition:
// dispatching updateStatus(DISCONNECTED) is what the real reducer uses to
// clear connectionAttemptMade (clearStore intentionally preserves status).
const simulateLogout = () => {
ServerDispatch.updateStatus(WebsocketTypes.StatusEnum.DISCONNECTED, null);
};
const seedAutoConnect = async () => {
const setting = new SettingDTO(App.APP_USER);
setting.autoConnect = true;
await setting.save();
const id = (await HostDTO.add({
name: 'Test Server',
host: 'server.example',
port: '4748',
editable: false,
})) as number;
const host = (await HostDTO.get(id))!;
host.remember = true;
host.userName = 'alice';
host.hashedPassword = 'stored-hash';
host.lastSelected = true;
await host.save();
};
const attempted = (): boolean =>
ServerSelectors.getConnectionAttemptMade(store.getState());
afterEach(async () => {
// Absorb any state updates that lingered past the test body (stores
// resolving after unmount, trailing effect commits) so they're wrapped
// in act and don't trip React's warning during teardown.
await flushStoresAndEffects();
});
beforeEach(async () => {
// setup.ts's beforeEach installs fake timers and re-creates the WebClient
// singleton. Dexie + React async need real timers; module caches persist
// across tests and need explicit reset.
vi.useRealTimers();
await resetDexie();
// Reset the module-level caches that load from Dexie. Without this, a
// test would read the PREVIOUS test's snapshot (the Dexie clear only
// truncates storage, not the useSettings / useKnownHosts subscribers'
// cached values).
settingsStore.reset();
knownHostsStore.reset();
autoLoginGate.hasChecked = false;
});
describe('autoconnect — cold start', () => {
it('auto-logs in when Dexie has autoConnect=true + host with stored credentials', async () => {
await seedAutoConnect();
renderAppScreen(<Login />);
await waitFor(() => {
expect(attempted()).toBe(true);
});
});
it('does NOT attempt login when Dexie has no settings row', async () => {
renderAppScreen(<Login />);
await flushStoresAndEffects();
expect(attempted()).toBe(false);
});
it('does NOT attempt login when autoConnect=true but lastSelected host lacks credentials', async () => {
const setting = new SettingDTO(App.APP_USER);
setting.autoConnect = true;
await setting.save();
await HostDTO.add({
name: 'Unremembered',
host: 'server.example',
port: '4748',
editable: false,
lastSelected: true,
});
renderAppScreen(<Login />);
await flushStoresAndEffects();
expect(attempted()).toBe(false);
});
});
describe('autoconnect — logout cycle (same session)', () => {
it('does not auto-reconnect after first auto-login + logout within the same JS session', async () => {
await seedAutoConnect();
const first = renderAppScreen(<Login />);
await waitFor(() => {
expect(attempted()).toBe(true);
});
// Simulate "logged out and returned to /login": unmount, clear the
// store's connectionAttemptMade flag (the app-level equivalent of
// DISCONNECTED → status.connectionAttemptMade reset), remount.
first.unmount();
simulateLogout();
renderAppScreen(<Login />);
await flushStoresAndEffects();
// The session gate must have kept useAutoLogin silent; the flag stays
// false.
expect(attempted()).toBe(false);
});
it('does not auto-connect when the user enabled autoConnect mid-session and then logged out', async () => {
// First mount with autoConnect=false — gate latches without firing.
const first = renderAppScreen(<Login />);
await flushStoresAndEffects();
expect(attempted()).toBe(false);
first.unmount();
// Mid-session: user ticked the checkbox → Dexie flipped to autoConnect=true.
await seedAutoConnect();
// Remount (post-logout). The gate MUST keep useAutoLogin silent.
renderAppScreen(<Login />);
await flushStoresAndEffects();
expect(attempted()).toBe(false);
});
});
describe('autoconnect — refresh', () => {
it('auto-connects again after resetting the session gate (page-refresh equivalent)', async () => {
await seedAutoConnect();
const first = renderAppScreen(<Login />);
await waitFor(() => {
expect(attempted()).toBe(true);
});
first.unmount();
// Simulate a browser refresh: the session gate naturally resets on a
// fresh JS context, and the real connection flag resets too.
simulateLogout();
autoLoginGate.hasChecked = false;
renderAppScreen(<Login />);
await waitFor(() => {
expect(attempted()).toBe(true);
});
});
});

View file

@ -0,0 +1,158 @@
// Helpers for inspecting outbound commands. WebSocketService calls
// `this.socket.send(bytes)` with the encoded CommandContainer; the mock
// WebSocket records those calls on its `send` vi.fn. These helpers decode
// the bytes back into a CommandContainer so tests can assert on what was
// sent and extract the `cmdId` needed to build a correlated response.
import { fromBinary, getExtension, hasExtension } from '@bufbuild/protobuf';
import type { GenExtension } from '@bufbuild/protobuf/codegenv2';
import { Data } from '@app/types';
import { getMockWebSocket } from './setup';
/** The command scopes a CommandContainer can carry in practice. */
type SessionCmd = Data.SessionCommand;
type RoomCmd = Data.RoomCommand;
type GameCmd = Data.GameCommand;
type AdminCmd = Data.AdminCommand;
type ModeratorCmd = Data.ModeratorCommand;
/** Decode every CommandContainer sent through the mock socket so far. */
export function captureAllOutbound(): Data.CommandContainer[] {
const mock = getMockWebSocket();
return mock.send.mock.calls.map(([bytes]: [Uint8Array]) =>
fromBinary(Data.CommandContainerSchema, bytes)
);
}
/** Decode the most recent CommandContainer. Throws if none has been sent. */
export function captureLastOutbound(): Data.CommandContainer {
const all = captureAllOutbound();
if (all.length === 0) {
throw new Error('No outbound command has been sent through the mock WebSocket.');
}
return all[all.length - 1];
}
/** Numeric cmdId of the most recently sent command (the BigInt cast back to number). */
export function lastCmdId(): number {
return Number(captureLastOutbound().cmdId);
}
/**
* Find the most recently sent CommandContainer whose session-scope command
* carries the given extension, and return both the container and the
* unwrapped extension value. Handy for "the login() call fired grab its
* cmdId and the Command_Login payload it sent".
*/
export function findLastSessionCommand<V>(
ext: GenExtension<SessionCmd, V>
): { container: Data.CommandContainer; value: V; cmdId: number } {
const containers = captureAllOutbound();
for (let i = containers.length - 1; i >= 0; i--) {
const container = containers[i];
for (const sessionCmd of container.sessionCommand ?? []) {
if (hasExtension(sessionCmd, ext)) {
return {
container,
value: getExtension(sessionCmd, ext),
cmdId: Number(container.cmdId),
};
}
}
}
throw new Error(
`No outbound session command with extension ${ext.typeName} has been sent.`
);
}
/** Room-scoped equivalent of {@link findLastSessionCommand}. */
export function findLastRoomCommand<V>(
ext: GenExtension<RoomCmd, V>
): { container: Data.CommandContainer; value: V; cmdId: number; roomId: number } {
const containers = captureAllOutbound();
for (let i = containers.length - 1; i >= 0; i--) {
const container = containers[i];
for (const roomCmd of container.roomCommand ?? []) {
if (hasExtension(roomCmd, ext)) {
return {
container,
value: getExtension(roomCmd, ext),
cmdId: Number(container.cmdId),
roomId: container.roomId ?? 0,
};
}
}
}
throw new Error(
`No outbound room command with extension ${ext.typeName} has been sent.`
);
}
/** Game-scoped equivalent of {@link findLastSessionCommand}. */
export function findLastGameCommand<V>(
ext: GenExtension<GameCmd, V>
): { container: Data.CommandContainer; value: V; cmdId: number; gameId: number } {
const containers = captureAllOutbound();
for (let i = containers.length - 1; i >= 0; i--) {
const container = containers[i];
for (const gameCmd of container.gameCommand ?? []) {
if (hasExtension(gameCmd, ext)) {
return {
container,
value: getExtension(gameCmd, ext),
cmdId: Number(container.cmdId),
gameId: container.gameId ?? 0,
};
}
}
}
throw new Error(
`No outbound game command with extension ${ext.typeName} has been sent.`
);
}
/** Admin-scoped equivalent of {@link findLastSessionCommand}. */
export function findLastAdminCommand<V>(
ext: GenExtension<AdminCmd, V>
): { container: Data.CommandContainer; value: V; cmdId: number } {
const containers = captureAllOutbound();
for (let i = containers.length - 1; i >= 0; i--) {
const container = containers[i];
for (const adminCmd of container.adminCommand ?? []) {
if (hasExtension(adminCmd, ext)) {
return {
container,
value: getExtension(adminCmd, ext),
cmdId: Number(container.cmdId),
};
}
}
}
throw new Error(
`No outbound admin command with extension ${ext.typeName} has been sent.`
);
}
/** Moderator-scoped equivalent of {@link findLastSessionCommand}. */
export function findLastModeratorCommand<V>(
ext: GenExtension<ModeratorCmd, V>
): { container: Data.CommandContainer; value: V; cmdId: number } {
const containers = captureAllOutbound();
for (let i = containers.length - 1; i >= 0; i--) {
const container = containers[i];
for (const modCmd of container.moderatorCommand ?? []) {
if (hasExtension(modCmd, ext)) {
return {
container,
value: getExtension(modCmd, ext),
cmdId: Number(container.cmdId),
};
}
}
}
throw new Error(
`No outbound moderator command with extension ${ext.typeName} has been sent.`
);
}

View file

@ -0,0 +1,125 @@
// Factory helpers that build encoded `ServerMessage` binaries for the four
// top-level message types the client consumes (RESPONSE, SESSION_EVENT,
// ROOM_EVENT, GAME_EVENT_CONTAINER). Tests call these to simulate incoming
// server traffic and then hand the resulting bytes to `deliverMessage()`.
//
// No mocking of `@bufbuild/protobuf` — every builder uses the real `create`/
// `setExtension`/`toBinary` path so the bytes that land in ProtobufService
// are byte-for-byte identical to what a Servatrice would send.
import { create, setExtension, toBinary } from '@bufbuild/protobuf';
import type { GenExtension, GenMessage } from '@bufbuild/protobuf/codegenv2';
import type { MessageInitShape } from '@bufbuild/protobuf';
import { Data } from '@app/types';
import { getMockWebSocket } from './setup';
/**
* Convenience wrapper around `create` for schemas that accept an init shape.
* Mirrors the pattern used throughout the webclient codebase.
*/
export function make<S extends GenMessage<any>>(
schema: S,
init?: MessageInitShape<S>
): ReturnType<typeof create<S>> {
return create(schema, init);
}
/** Build a top-level ServerMessage wrapping a Response. */
export function buildResponseMessage(response: Data.Response): Uint8Array {
const msg = create(Data.ServerMessageSchema, {
messageType: Data.ServerMessage_MessageType.RESPONSE,
response,
});
return toBinary(Data.ServerMessageSchema, msg);
}
/**
* Build a Response with an optional response-payload extension attached.
* `cmdId` must match the outbound command the test is responding to
* callers typically read it from `captureOutbound()`.
*/
export function buildResponse<V>(params: {
cmdId: number;
responseCode?: Data.Response_ResponseCode;
ext?: GenExtension<Data.Response, V>;
value?: V;
}): Data.Response {
const response = create(Data.ResponseSchema, {
cmdId: BigInt(params.cmdId),
responseCode: params.responseCode ?? Data.Response_ResponseCode.RespOk,
});
if (params.ext && params.value !== undefined) {
setExtension(response, params.ext, params.value);
}
return response;
}
/** Build a top-level ServerMessage wrapping a SessionEvent with the given extension. */
export function buildSessionEventMessage<V>(
ext: GenExtension<Data.SessionEvent, V>,
value: V
): Uint8Array {
const sessionEvent = create(Data.SessionEventSchema);
setExtension(sessionEvent, ext, value);
const msg = create(Data.ServerMessageSchema, {
messageType: Data.ServerMessage_MessageType.SESSION_EVENT,
sessionEvent,
});
return toBinary(Data.ServerMessageSchema, msg);
}
/** Build a top-level ServerMessage wrapping a RoomEvent with the given extension. */
export function buildRoomEventMessage<V>(
roomId: number,
ext: GenExtension<Data.RoomEvent, V>,
value: V
): Uint8Array {
const roomEvent = create(Data.RoomEventSchema, { roomId });
setExtension(roomEvent, ext, value);
const msg = create(Data.ServerMessageSchema, {
messageType: Data.ServerMessage_MessageType.ROOM_EVENT,
roomEvent,
});
return toBinary(Data.ServerMessageSchema, msg);
}
/**
* Build a top-level ServerMessage wrapping a GameEventContainer whose
* `eventList` contains a single GameEvent with the given extension attached.
*/
export function buildGameEventMessage<V>(
params: {
gameId: number;
playerId?: number;
ext: GenExtension<Data.GameEvent, V>;
value: V;
}
): Uint8Array {
const gameEvent = create(Data.GameEventSchema, {
playerId: params.playerId ?? -1,
});
setExtension(gameEvent, params.ext, params.value);
const container = create(Data.GameEventContainerSchema, {
gameId: params.gameId,
eventList: [gameEvent],
});
const msg = create(Data.ServerMessageSchema, {
messageType: Data.ServerMessage_MessageType.GAME_EVENT_CONTAINER,
gameEventContainer: container,
});
return toBinary(Data.ServerMessageSchema, msg);
}
/**
* Deliver an encoded ServerMessage to the currently-connected mock socket.
* WebSocketService wires `onmessage` to push events into its RxJS subject,
* which ProtobufService subscribes to so this triggers the full inbound
* pipeline synchronously.
*/
export function deliverMessage(binary: Uint8Array): void {
const mock = getMockWebSocket();
const event = { data: binary.buffer } as MessageEvent;
mock.onmessage?.(event);
}

View file

@ -0,0 +1,205 @@
// Integration test setup — installs a mock WebSocket constructor, wires up
// fake timers for KeepAliveService control, and resets the webClient + Redux
// singletons between tests so real event handlers and reducers can run
// against a clean slate each time.
//
// Only `globalThis.WebSocket` is mocked. Everything downstream of it
// (ProtobufService, event registries, persistence, store, reducers) runs as
// real code, which is the whole point of the integration suite.
import '@testing-library/jest-dom/vitest';
import '../../../src/polyfills';
// fake-indexeddb polyfills globalThis.indexedDB. MUST be imported before any
// module that opens a Dexie database (Dexie opens on first table access).
// Harmless for the websocket suite, which doesn't touch Dexie.
import 'fake-indexeddb/auto';
import { create } from '@bufbuild/protobuf';
import { afterEach, beforeEach, vi } from 'vitest';
import { ServerDispatch, RoomsDispatch, GameDispatch } from '@app/store';
import { Data } from '@app/types';
import { WebClient, setPendingOptions } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
import { PROTOCOL_VERSION } from '../../../src/websocket/config';
import { createWebClientRequest, createWebClientResponse } from '@app/api';
import {
buildResponse,
buildResponseMessage,
buildSessionEventMessage,
deliverMessage,
} from './protobuf-builders';
import { findLastSessionCommand } from './command-capture';
export { setPendingOptions };
export interface MockWebSocketInstance {
send: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
readyState: number;
binaryType: BinaryType;
url: string;
onopen: ((ev?: Event) => void) | null;
onclose: ((ev?: CloseEvent) => void) | null;
onerror: ((ev?: Event) => void) | null;
onmessage: ((ev: MessageEvent) => void) | null;
}
let currentMockInstance: MockWebSocketInstance | null = null;
export function getMockWebSocket(): MockWebSocketInstance {
if (!currentMockInstance) {
throw new Error(
'No mock WebSocket has been constructed yet. Call webClient.connect(...) before reading the mock instance.'
);
}
return currentMockInstance;
}
function makeMockInstance(url: string): MockWebSocketInstance {
return {
send: vi.fn(),
close: vi.fn(function close(this: MockWebSocketInstance) {
this.readyState = 3; // CLOSED
this.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent);
}),
readyState: 0, // CONNECTING
binaryType: 'arraybuffer',
url,
onopen: null,
onclose: null,
onerror: null,
onmessage: null,
};
}
function installMockWebSocket(): void {
const MockWS = vi.fn(function MockWebSocket(url: string) {
currentMockInstance = makeMockInstance(url);
return currentMockInstance;
}) as unknown as typeof WebSocket;
(MockWS as unknown as { CONNECTING: number }).CONNECTING = 0;
(MockWS as unknown as { OPEN: number }).OPEN = 1;
(MockWS as unknown as { CLOSING: number }).CLOSING = 2;
(MockWS as unknown as { CLOSED: number }).CLOSED = 3;
globalThis.WebSocket = MockWS;
}
export function openMockWebSocket(): void {
const mock = getMockWebSocket();
mock.readyState = 1; // OPEN
mock.onopen?.(new Event('open'));
}
export function getWebClient(): WebClient {
return WebClient.instance;
}
function resetAll(): void {
const client = WebClient.instance;
if (currentMockInstance && currentMockInstance.readyState === 1) {
client.disconnect();
}
client.protobuf.resetCommands();
client.status = WebsocketTypes.StatusEnum.DISCONNECTED;
ServerDispatch.clearStore();
RoomsDispatch.clearStore();
GameDispatch.clearStore();
if (currentMockInstance) {
currentMockInstance.onopen = null;
currentMockInstance.onclose = null;
currentMockInstance.onerror = null;
currentMockInstance.onmessage = null;
currentMockInstance = null;
}
(WebClient as unknown as { _instance: WebClient | null })._instance = null;
}
// ── Shared connect helpers ──────────────────────────────────────────────────
const DEFAULT_LOGIN_OPTIONS: WebsocketTypes.WebSocketConnectOptions = {
reason: WebsocketTypes.WebSocketConnectReason.LOGIN,
host: 'localhost',
port: '4748',
userName: 'alice',
password: 'secret',
};
export function connectRaw(
overrides: Partial<WebsocketTypes.WebSocketConnectOptions> = {}
): void {
const opts = { ...DEFAULT_LOGIN_OPTIONS, ...overrides };
setPendingOptions(opts as WebsocketTypes.WebSocketConnectOptions);
getWebClient().connect({ host: opts.host, port: opts.port });
openMockWebSocket();
}
export function connectAndHandshake(
overrides: Partial<WebsocketTypes.WebSocketConnectOptions> = {}
): void {
connectRaw(overrides);
deliverMessage(buildSessionEventMessage(
Data.Event_ServerIdentification_ext,
create(Data.Event_ServerIdentificationSchema, {
serverName: 'TestServer',
serverVersion: '2.8.0',
protocolVersion: PROTOCOL_VERSION,
})
));
}
export function connectAndHandshakeWithSalt(
overrides: Partial<WebsocketTypes.WebSocketConnectOptions> = {}
): void {
connectRaw(overrides);
deliverMessage(buildSessionEventMessage(
Data.Event_ServerIdentification_ext,
create(Data.Event_ServerIdentificationSchema, {
serverName: 'TestServer',
serverVersion: '2.8.0',
protocolVersion: PROTOCOL_VERSION,
serverOptions: Data.Event_ServerIdentification_ServerOptions.SupportsPasswordHash,
})
));
}
export function connectAndLogin(userName: string = 'alice'): void {
connectAndHandshake({ userName });
const login = findLastSessionCommand(Data.Command_Login_ext);
const userInfo = create(Data.ServerInfo_UserSchema, {
name: userName,
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId: login.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_Login_ext,
value: create(Data.Response_LoginSchema, {
userInfo,
buddyList: [],
ignoreList: [],
}),
})));
}
// ── Lifecycle hooks ─────────────────────────────────────────────────────────
installMockWebSocket();
beforeEach(() => {
vi.useFakeTimers();
new WebClient(createWebClientRequest(), createWebClientResponse());
});
afterEach(() => {
resetAll();
vi.clearAllMocks();
vi.useRealTimers();
});

View file

@ -0,0 +1,91 @@
// Real round-trip tests for HostDTO through Dexie into fake-indexeddb.
// Exercises the full static method surface (add, get, getAll, bulkAdd,
// delete) plus instance save().
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { HostDTO } from '@app/services';
import type { App } from '@app/types';
import { resetDexie } from './resetDexie';
const makeRow = (overrides: Partial<App.Host> = {}): App.Host => ({
name: 'Test',
host: 'host.example',
port: '4747',
editable: false,
...overrides,
});
beforeEach(async () => {
// Shared setup.ts installs fake timers for the websocket suite's
// KeepAliveService; Dexie / fake-indexeddb need real timers.
vi.useRealTimers();
await resetDexie();
});
describe('HostDTO (real Dexie)', () => {
it('getAll returns empty on a fresh store', async () => {
const all = await HostDTO.getAll();
expect(all).toEqual([]);
});
it('add returns an auto-incremented id and makes the row retrievable by get(id)', async () => {
const id = (await HostDTO.add(makeRow({ name: 'A' }))) as number;
expect(typeof id).toBe('number');
const loaded = await HostDTO.get(id);
expect(loaded).toBeDefined();
expect(loaded!.name).toBe('A');
expect(loaded!.id).toBe(id);
expect(loaded).toBeInstanceOf(HostDTO);
});
it('bulkAdd seeds multiple rows and they are all retrievable via getAll', async () => {
await HostDTO.bulkAdd([
makeRow({ name: 'A' }),
makeRow({ name: 'B' }),
makeRow({ name: 'C' }),
]);
const all = await HostDTO.getAll();
expect(all.map((h) => h.name).sort()).toEqual(['A', 'B', 'C']);
});
it('save() on a loaded instance upserts the same row (does not duplicate)', async () => {
const id = (await HostDTO.add(makeRow({ name: 'A', remember: false }))) as number;
const loaded = await HostDTO.get(id);
loaded!.remember = true;
loaded!.userName = 'alice';
loaded!.hashedPassword = 'stored';
await loaded!.save();
const all = await HostDTO.getAll();
expect(all).toHaveLength(1);
expect(all[0].remember).toBe(true);
expect(all[0].userName).toBe('alice');
expect(all[0].hashedPassword).toBe('stored');
});
it('delete removes the row by id', async () => {
const idA = (await HostDTO.add(makeRow({ name: 'A' }))) as number;
await HostDTO.add(makeRow({ name: 'B' }));
await HostDTO.delete(idA as unknown as string);
const all = await HostDTO.getAll();
expect(all.map((h) => h.name)).toEqual(['B']);
});
it('lastSelected round-trips as a boolean column', async () => {
const idA = (await HostDTO.add(makeRow({ name: 'A', lastSelected: true }))) as number;
await HostDTO.add(makeRow({ name: 'B', lastSelected: false }));
const all = await HostDTO.getAll();
const selected = all.find((h) => h.id === idA)!;
expect(selected.lastSelected).toBe(true);
const other = all.find((h) => h.name === 'B')!;
expect(other.lastSelected).toBe(false);
});
});

View file

@ -0,0 +1,12 @@
// Clears every table the services suite touches so each test starts from
// empty storage. Dexie is a real singleton, the database a real (fake-
// indexeddb) instance, so state leaks between tests otherwise.
import { dexieService } from '@app/services';
export async function resetDexie(): Promise<void> {
await Promise.all([
dexieService.settings.clear(),
dexieService.hosts.clear(),
]);
}

View file

@ -0,0 +1,69 @@
// Real round-trip tests for SettingDTO through Dexie into fake-indexeddb.
// Nothing is mocked past the IndexedDB boundary — the DTO class, the Dexie
// schema, and the table's put/where/first pipeline all run as shipped code.
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { SettingDTO } from '@app/services';
import { App } from '@app/types';
import { resetDexie } from './resetDexie';
beforeEach(async () => {
// Shared setup.ts installs vi.useFakeTimers() for the websocket suite's
// KeepAliveService needs. Dexie + fake-indexeddb rely on real microtasks
// and will hang under fake timers, so flip back here.
vi.useRealTimers();
await resetDexie();
});
describe('SettingDTO (real Dexie)', () => {
it('returns undefined for a user with no row yet', async () => {
const loaded = await SettingDTO.get(App.APP_USER);
expect(loaded).toBeUndefined();
});
it('round-trips a fresh setting via save()', async () => {
const dto = new SettingDTO(App.APP_USER);
dto.autoConnect = true;
await dto.save();
const loaded = await SettingDTO.get(App.APP_USER);
expect(loaded).toBeDefined();
expect(loaded!.user).toBe(App.APP_USER);
expect(loaded!.autoConnect).toBe(true);
});
it('upserts on repeated save for the same user key', async () => {
const first = new SettingDTO(App.APP_USER);
first.autoConnect = false;
await first.save();
const loaded = await SettingDTO.get(App.APP_USER);
loaded!.autoConnect = true;
await loaded!.save();
const reloaded = await SettingDTO.get(App.APP_USER);
expect(reloaded!.autoConnect).toBe(true);
});
it('matches user lookups case-insensitively (equalsIgnoreCase in DTO.get)', async () => {
const dto = new SettingDTO(App.APP_USER);
await dto.save();
const loaded = await SettingDTO.get(App.APP_USER.toUpperCase());
expect(loaded).toBeDefined();
expect(loaded!.user).toBe(App.APP_USER);
});
it('preserves the SettingDTO class on load (mapToClass binding)', async () => {
const dto = new SettingDTO(App.APP_USER);
await dto.save();
const loaded = await SettingDTO.get(App.APP_USER);
expect(loaded).toBeInstanceOf(SettingDTO);
// The save() instance method must be present on the retrieved row so
// call sites (useSettings.update) can round-trip without reinstantiation.
expect(typeof loaded!.save).toBe('function');
});
});

View file

@ -0,0 +1,94 @@
// Admin command pipeline smoke test — validates that sendAdminCommand
// encodes, correlates, and persists correctly end-to-end.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { AdminCommands } from '@app/websocket';
import { connectAndLogin } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildSessionEventMessage,
deliverMessage,
} from '../helpers/protobuf-builders';
import { findLastAdminCommand } from '../helpers/command-capture';
describe('admin commands', () => {
it('adjustMod modifies the user level bitflags on success', () => {
connectAndLogin();
// Add bob to the user list so the reducer has a target
deliverMessage(buildSessionEventMessage(
Data.Event_UserJoined_ext,
create(Data.Event_UserJoinedSchema, {
userInfo: create(Data.ServerInfo_UserSchema, {
name: 'bob',
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
}),
})
));
expect(store.getState().server.users.bob).toBeDefined();
AdminCommands.adjustMod('bob', true, false);
const { cmdId, value } = findLastAdminCommand(Data.Command_AdjustMod_ext);
expect(value.userName).toBe('bob');
expect(value.shouldBeMod).toBe(true);
expect(value.shouldBeJudge).toBe(false);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
const bobLevel = store.getState().server.users.bob.userLevel;
expect(bobLevel & Data.ServerInfo_User_UserLevelFlag.IsModerator).toBeTruthy();
});
it('shutdownServer sends command and dispatches on success', () => {
connectAndLogin();
AdminCommands.shutdownServer('Scheduled maintenance', 10);
const { cmdId, value } = findLastAdminCommand(Data.Command_ShutdownServer_ext);
expect(value.reason).toBe('Scheduled maintenance');
expect(value.minutes).toBe(10);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
});
it('reloadConfig sends command and resolves on RespOk', () => {
connectAndLogin();
AdminCommands.reloadConfig();
const { cmdId } = findLastAdminCommand(Data.Command_ReloadConfig_ext);
expect(cmdId).toBeGreaterThan(0);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
});
it('updateServerMessage sends command and resolves on RespOk', () => {
connectAndLogin();
AdminCommands.updateServerMessage();
const { cmdId } = findLastAdminCommand(Data.Command_UpdateServerMessage_ext);
expect(cmdId).toBeGreaterThan(0);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
});
});

View file

@ -0,0 +1,177 @@
// Authentication scenarios — login success/failure, register, activate,
// and the hashed-password (salt) login path.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { WebsocketTypes } from '@app/websocket/types';
import { connectAndHandshake, connectAndHandshakeWithSalt } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from '../helpers/protobuf-builders';
import { findLastSessionCommand } from '../helpers/command-capture';
function makeUser(name: string): Data.ServerInfo_User {
return create(Data.ServerInfo_UserSchema, {
name,
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
});
}
describe('authentication', () => {
describe('login', () => {
it('drives LOGIN → LOGGED_IN and populates user info + buddy/ignore lists', () => {
connectAndHandshake({ userName: 'alice' });
const { cmdId, value } = findLastSessionCommand(Data.Command_Login_ext);
expect(value.userName).toBe('alice');
const loginPayload = create(Data.Response_LoginSchema, {
userInfo: makeUser('alice'),
buddyList: [makeUser('bob')],
ignoreList: [makeUser('mallory')],
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_Login_ext,
value: loginPayload,
})));
const state = store.getState().server;
expect(state.status.state).toBe(WebsocketTypes.StatusEnum.LOGGED_IN);
expect(state.status.description).toBe('Logged in.');
expect(state.user?.name).toBe('alice');
expect(Object.keys(state.buddyList)).toEqual(['bob']);
expect(Object.keys(state.ignoreList)).toEqual(['mallory']);
expect(() => findLastSessionCommand(Data.Command_ListUsers_ext)).not.toThrow();
expect(() => findLastSessionCommand(Data.Command_ListRooms_ext)).not.toThrow();
});
it('flips status to DISCONNECTED on RespWrongPassword', () => {
connectAndHandshake();
const { cmdId } = findLastSessionCommand(Data.Command_Login_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespWrongPassword,
})));
const state = store.getState().server;
expect(state.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
expect(state.user).toBeNull();
expect(state.buddyList).toEqual({});
});
});
describe('register', () => {
const registerOptions = {
reason: WebsocketTypes.WebSocketConnectReason.REGISTER as const,
host: 'localhost',
port: '4748',
userName: 'newbie',
password: 'hunter2',
email: 'newbie@example.com',
country: 'US',
realName: 'New Bie',
};
it('auto-logs-in on RespRegistrationAccepted', () => {
connectAndHandshake(registerOptions);
const register = findLastSessionCommand(Data.Command_Register_ext);
expect(register.value.userName).toBe('newbie');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: register.cmdId,
responseCode: Data.Response_ResponseCode.RespRegistrationAccepted,
})));
const login = findLastSessionCommand(Data.Command_Login_ext);
expect(login.value.userName).toBe('newbie');
expect(login.cmdId).toBeGreaterThan(register.cmdId);
});
it('parks registration in awaiting-activation on RespRegistrationAcceptedNeedsActivation', () => {
connectAndHandshake(registerOptions);
const register = findLastSessionCommand(Data.Command_Register_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: register.cmdId,
responseCode: Data.Response_ResponseCode.RespRegistrationAcceptedNeedsActivation,
})));
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
});
});
describe('activate', () => {
it('auto-logs-in on RespActivationAccepted', () => {
connectAndHandshake({
reason: WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT as const,
host: 'localhost',
port: '4748',
userName: 'alice',
token: 'abc-123',
password: 'secret',
});
const activate = findLastSessionCommand(Data.Command_Activate_ext);
expect(activate.value.userName).toBe('alice');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: activate.cmdId,
responseCode: Data.Response_ResponseCode.RespActivationAccepted,
})));
const login = findLastSessionCommand(Data.Command_Login_ext);
expect(login.value.userName).toBe('alice');
});
});
describe('hashed-password login (salt path)', () => {
it('requests salt then sends login with hashedPassword instead of plaintext', () => {
connectAndHandshakeWithSalt({ userName: 'alice', password: 'secret' });
// First command should be RequestPasswordSalt, not Login
const salt = findLastSessionCommand(Data.Command_RequestPasswordSalt_ext);
expect(salt.value.userName).toBe('alice');
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
// Deliver salt response
deliverMessage(buildResponseMessage(buildResponse({
cmdId: salt.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_PasswordSalt_ext,
value: create(Data.Response_PasswordSaltSchema, { passwordSalt: 'test-salt-value' }),
})));
// Now login should have been sent with hashedPassword
const login = findLastSessionCommand(Data.Command_Login_ext);
expect(login.value.userName).toBe('alice');
expect(login.value.hashedPassword).toBeTruthy();
expect(login.value.password).toBeFalsy();
// Complete login
deliverMessage(buildResponseMessage(buildResponse({
cmdId: login.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_Login_ext,
value: create(Data.Response_LoginSchema, {
userInfo: makeUser('alice'),
buddyList: [],
ignoreList: [],
}),
})));
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.LOGGED_IN);
});
});
});

View file

@ -0,0 +1,142 @@
// Connection-lifecycle scenarios. Exercises the full transport handshake
// from webClient.connect() through onopen, ServerIdentification, and
// disconnect — with only the browser WebSocket constructor mocked.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { WebsocketTypes } from '@app/websocket/types';
import { PROTOCOL_VERSION } from '../../../src/websocket/config';
import {
getMockWebSocket,
getWebClient,
openMockWebSocket,
setPendingOptions,
connectAndHandshake,
} from '../helpers/setup';
import {
buildSessionEventMessage,
deliverMessage,
} from '../helpers/protobuf-builders';
import { findLastSessionCommand } from '../helpers/command-capture';
function loginOptions(overrides: Partial<{ userName: string; password: string }> = {}): WebsocketTypes.WebSocketConnectOptions {
return {
reason: WebsocketTypes.WebSocketConnectReason.LOGIN,
host: 'localhost',
port: '4748',
userName: overrides.userName ?? 'alice',
password: overrides.password ?? 'secret',
};
}
function connectWithOptions(opts: WebsocketTypes.WebSocketConnectOptions): void {
setPendingOptions(opts);
getWebClient().connect({ host: opts.host, port: opts.port });
}
function serverIdentification(
protocolVersion = PROTOCOL_VERSION,
serverName = 'TestServer',
serverVersion = '2.8.0'
): Uint8Array {
const payload = create(Data.Event_ServerIdentificationSchema, {
serverName,
serverVersion,
protocolVersion,
serverOptions: Data.Event_ServerIdentification_ServerOptions.NoOptions,
});
return buildSessionEventMessage(Data.Event_ServerIdentification_ext, payload);
}
describe('connection lifecycle', () => {
it('flips status through CONNECTING → CONNECTED on socket open', () => {
connectWithOptions(loginOptions());
expect(store.getState().server.status.connectionAttemptMade).toBe(true);
openMockWebSocket();
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED);
expect(store.getState().server.status.description).toBe('Connected');
});
it('routes a matching ServerIdentification into LOGGING_IN and sends Command_Login', () => {
connectWithOptions(loginOptions({ userName: 'alice' }));
openMockWebSocket();
deliverMessage(serverIdentification());
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.LOGGING_IN);
expect(store.getState().server.info.name).toBe('TestServer');
expect(store.getState().server.info.version).toBe('2.8.0');
const { value, cmdId } = findLastSessionCommand(Data.Command_Login_ext);
expect(value.userName).toBe('alice');
expect(cmdId).toBeGreaterThan(0);
});
it('disconnects on protocol version mismatch without sending a login command', () => {
connectWithOptions(loginOptions());
openMockWebSocket();
deliverMessage(serverIdentification(PROTOCOL_VERSION + 1));
const mock = getMockWebSocket();
expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
expect(() => findLastSessionCommand(Data.Command_Login_ext)).toThrow();
});
it('times out when onopen never fires within the keepalive window', () => {
connectWithOptions(loginOptions());
const mock = getMockWebSocket();
expect(mock.close).not.toHaveBeenCalled();
vi.advanceTimersByTime(5000);
// Fire onclose the way a real browser would when the connection-attempt
// timer closes a still-connecting socket.
mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent);
expect(mock.close).toHaveBeenCalled();
// Never-opened sockets bypass reconnect and land on DISCONNECTED directly.
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
});
it('releases keep-alive ping loop on explicit disconnect', () => {
connectWithOptions(loginOptions());
openMockWebSocket();
deliverMessage(serverIdentification());
const mock = getMockWebSocket();
getWebClient().disconnect();
// The transport schedules close() synchronously; onclose follows in the
// browser event loop. Simulate it so the status transition fires.
mock.onclose?.({ code: 1000, reason: '', wasClean: true } as CloseEvent);
expect(mock.close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
});
it('enters RECONNECTING on unexpected socket close after a successful handshake', () => {
connectAndHandshake();
// A login command is now pending (sent during handshake)
expect(() => findLastSessionCommand(Data.Command_Login_ext)).not.toThrow();
// Simulate unexpected socket close
const mock = getMockWebSocket();
mock.readyState = 3;
mock.onclose?.({ code: 1006, reason: '', wasClean: false } as CloseEvent);
// With reconnect configured, a drop after a successful open enters the
// reconnect state machine rather than going straight to DISCONNECTED.
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.RECONNECTING);
});
});

View file

@ -0,0 +1,186 @@
// Deck and replay command round-trips — validates the session command pipeline
// for deck CRUD and replay operations end-to-end.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { SessionCommands } from '@app/websocket';
import { connectAndLogin } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from '../helpers/protobuf-builders';
import { findLastSessionCommand } from '../helpers/command-capture';
describe('deck operations', () => {
it('populates backendDecks from deckList response', () => {
connectAndLogin();
SessionCommands.deckList();
const { cmdId } = findLastSessionCommand(Data.Command_DeckList_ext);
const deckFile = create(Data.ServerInfo_DeckStorage_TreeItemSchema, {
id: 1,
name: 'MyDeck.cod',
file: create(Data.ServerInfo_DeckStorage_FileSchema, { creationTime: 1000 }),
});
const root = create(Data.ServerInfo_DeckStorage_FolderSchema, {
items: [deckFile],
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_DeckList_ext,
value: create(Data.Response_DeckListSchema, { root }),
})));
const backendDecks = store.getState().server.backendDecks;
expect(backendDecks).not.toBeNull();
expect(backendDecks?.root?.items).toHaveLength(1);
expect(backendDecks?.root?.items[0]?.name).toBe('MyDeck.cod');
});
it('populates downloadedDeck from deckDownload response', () => {
connectAndLogin();
SessionCommands.deckDownload(42);
const { cmdId } = findLastSessionCommand(Data.Command_DeckDownload_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_DeckDownload_ext,
value: create(Data.Response_DeckDownloadSchema, { deck: '4 Lightning Bolt\n20 Mountain' }),
})));
const downloaded = store.getState().server.downloadedDeck;
expect(downloaded).not.toBeNull();
expect(downloaded?.deckId).toBe(42);
expect(downloaded?.deck).toContain('Lightning Bolt');
});
it('deckUpload sends payload and dispatches uploadServerDeck on success', () => {
connectAndLogin();
SessionCommands.deckUpload('/folder', 0, '4 Counterspell\n20 Island');
const { cmdId, value } = findLastSessionCommand(Data.Command_DeckUpload_ext);
expect(value.path).toBe('/folder');
expect(value.deckList).toContain('Counterspell');
const newFile = create(Data.ServerInfo_DeckStorage_TreeItemSchema, {
id: 7,
name: 'CounterDeck.cod',
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_DeckUpload_ext,
value: create(Data.Response_DeckUploadSchema, { newFile }),
})));
// No state assertion: backendDecks is keyed by full tree, not single
// upload — the integration verifies the dispatcher is reached, not the
// tree-merge logic which lives in the reducer.
});
it('deckDel sends deckId and resolves on RespOk', () => {
connectAndLogin();
SessionCommands.deckDel(13);
const { cmdId, value } = findLastSessionCommand(Data.Command_DeckDel_ext);
expect(value.deckId).toBe(13);
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
});
it('deckNewDir sends path + dirName payload and resolves on RespOk', () => {
connectAndLogin();
SessionCommands.deckNewDir('/parent', 'NewFolder');
const { cmdId, value } = findLastSessionCommand(Data.Command_DeckNewDir_ext);
expect(value.path).toBe('/parent');
expect(value.dirName).toBe('NewFolder');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
});
it('deckDelDir sends path payload and resolves on RespOk', () => {
connectAndLogin();
SessionCommands.deckDelDir('/folder/to/remove');
const { cmdId, value } = findLastSessionCommand(Data.Command_DeckDelDir_ext);
expect(value.path).toBe('/folder/to/remove');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
});
});
describe('replay operations', () => {
it('populates replays from replayList response', () => {
connectAndLogin();
SessionCommands.replayList();
const { cmdId } = findLastSessionCommand(Data.Command_ReplayList_ext);
const match = create(Data.ServerInfo_ReplayMatchSchema, {
gameId: 99,
gameName: 'Casual Game',
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ReplayList_ext,
value: create(Data.Response_ReplayListSchema, { matchList: [match] }),
})));
const replays = store.getState().server.replays;
expect(replays[99]).toBeDefined();
expect(replays[99].gameName).toBe('Casual Game');
});
it('removes replay from state on replayDeleteMatch round-trip', () => {
connectAndLogin();
// First populate a replay
SessionCommands.replayList();
const list = findLastSessionCommand(Data.Command_ReplayList_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: list.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ReplayList_ext,
value: create(Data.Response_ReplayListSchema, {
matchList: [create(Data.ServerInfo_ReplayMatchSchema, { gameId: 99, gameName: 'Old Game' })],
}),
})));
expect(store.getState().server.replays[99]).toBeDefined();
// Now delete it
SessionCommands.replayDeleteMatch(99);
const del = findLastSessionCommand(Data.Command_ReplayDeleteMatch_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: del.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.replays[99]).toBeUndefined();
});
});

View file

@ -0,0 +1,435 @@
// Game scenarios — game join, state initialization, card operations,
// player counters, game chat, game close, and outbound game commands.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { GameCommands, RoomCommands } from '@app/websocket';
import { connectAndHandshake, connectAndLogin } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildSessionEventMessage,
buildRoomEventMessage,
buildGameEventMessage,
deliverMessage,
} from '../helpers/protobuf-builders';
import { findLastGameCommand, findLastRoomCommand, findLastSessionCommand } from '../helpers/command-capture';
function joinGame(gameId: number): void {
deliverMessage(buildSessionEventMessage(
Data.Event_GameJoined_ext,
create(Data.Event_GameJoinedSchema, {
gameInfo: create(Data.ServerInfo_GameSchema, {
gameId,
description: 'Test Game',
maxPlayers: 2,
playerCount: 1,
}),
playerId: 1,
hostId: 1,
spectator: false,
judge: false,
resuming: false,
})
));
}
function setupGameState(gameId: number): void {
const deckCard = create(Data.ServerInfo_CardSchema, { id: 100, name: 'Forest' });
const handCard = create(Data.ServerInfo_CardSchema, { id: 101, name: 'Lightning Bolt' });
const deckZone = create(Data.ServerInfo_ZoneSchema, {
name: 'deck',
type: Data.ServerInfo_Zone_ZoneType.HiddenZone,
cardList: [deckCard],
});
const handZone = create(Data.ServerInfo_ZoneSchema, {
name: 'hand',
type: Data.ServerInfo_Zone_ZoneType.HiddenZone,
cardList: [handCard],
});
const tableZone = create(Data.ServerInfo_ZoneSchema, {
name: 'table',
type: Data.ServerInfo_Zone_ZoneType.PublicZone,
withCoords: true,
cardList: [],
});
const player = create(Data.ServerInfo_PlayerSchema, {
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: 1,
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
}),
zoneList: [deckZone, handZone, tableZone],
counterList: [],
arrowList: [],
});
deliverMessage(buildGameEventMessage({
gameId,
playerId: -1,
ext: Data.Event_GameStateChanged_ext,
value: create(Data.Event_GameStateChangedSchema, {
playerList: [player],
gameStarted: true,
activePlayerId: 1,
activePhase: 0,
}),
}));
}
describe('game', () => {
it('initializes game state from Event_GameJoined + Event_GameStateChanged', () => {
connectAndLogin();
joinGame(42);
const game = store.getState().games.games[42];
expect(game).toBeDefined();
expect(game.info.description).toBe('Test Game');
expect(game.localPlayerId).toBe(1);
setupGameState(42);
const updated = store.getState().games.games[42];
expect(updated.started).toBe(true);
expect(updated.activePlayerId).toBe(1);
expect(updated.players[1]).toBeDefined();
expect(updated.players[1].zones.hand).toBeDefined();
expect(updated.players[1].zones.deck).toBeDefined();
expect(updated.players[1].zones.hand.order).toContain(101);
expect(updated.players[1].zones.deck.order).toContain(100);
});
it('draws cards from deck to hand on Event_DrawCards', () => {
connectAndLogin();
joinGame(42);
setupGameState(42);
const drawnCard = create(Data.ServerInfo_CardSchema, { id: 200, name: 'Mountain' });
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_DrawCards_ext,
value: create(Data.Event_DrawCardsSchema, {
number: 1,
cards: [drawnCard],
}),
}));
const player = store.getState().games.games[42].players[1];
expect(player.zones.hand.order).toContain(200);
expect(player.zones.hand.byId[200]?.name).toBe('Mountain');
});
it('appends chat messages on Event_GameSay', () => {
connectAndLogin();
joinGame(42);
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_GameSay_ext,
value: create(Data.Event_GameSaySchema, { message: 'good game' }),
}));
const messages = store.getState().games.games[42].messages;
expect(messages).toHaveLength(1);
expect(messages[0].message).toBe('good game');
expect(messages[0].playerId).toBe(1);
});
it('removes game from store on Event_GameClosed', () => {
connectAndLogin();
joinGame(42);
expect(store.getState().games.games[42]).toBeDefined();
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: -1,
ext: Data.Event_GameClosed_ext,
value: create(Data.Event_GameClosedSchema),
}));
expect(store.getState().games.games[42]).toBeUndefined();
});
it('sends outbound Command_GameSay with correct gameId and message', () => {
connectAndLogin();
joinGame(42);
GameCommands.gameSay(42, { message: 'hello opponent' });
const { value, cmdId } = findLastGameCommand(Data.Command_GameSay_ext);
expect(value.message).toBe('hello opponent');
expect(cmdId).toBeGreaterThan(0);
});
it('moves a card from hand to table on Event_MoveCard', () => {
connectAndLogin();
joinGame(42);
setupGameState(42);
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_MoveCard_ext,
value: create(Data.Event_MoveCardSchema, {
cardId: 101,
cardName: 'Lightning Bolt',
startPlayerId: 1,
startZone: 'hand',
targetPlayerId: 1,
targetZone: 'table',
x: 100,
y: 200,
faceDown: false,
newCardId: 101,
}),
}));
const player = store.getState().games.games[42].players[1];
expect(player.zones.hand.order).not.toContain(101);
expect(player.zones.table.order).toContain(101);
expect(player.zones.table.byId[101]?.name).toBe('Lightning Bolt');
expect(player.zones.table.byId[101]?.x).toBe(100);
});
it('creates and updates player counters', () => {
connectAndLogin();
joinGame(42);
setupGameState(42);
const counterInfo = create(Data.ServerInfo_CounterSchema, {
id: 1,
name: 'Life',
count: 20,
radius: 1,
});
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_CreateCounter_ext,
value: create(Data.Event_CreateCounterSchema, { counterInfo }),
}));
const player = store.getState().games.games[42].players[1];
expect(player.counters[1]).toBeDefined();
expect(player.counters[1].name).toBe('Life');
expect(player.counters[1].count).toBe(20);
deliverMessage(buildGameEventMessage({
gameId: 42,
playerId: 1,
ext: Data.Event_SetCounter_ext,
value: create(Data.Event_SetCounterSchema, { counterId: 1, value: 17 }),
}));
expect(store.getState().games.games[42].players[1].counters[1].count).toBe(17);
});
it('full lifecycle: create → join → deck select → draw → chat → discard → concede → leave', () => {
connectAndHandshake();
// ── Setup: join a room so we can create a game in it ──────────────────
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, {
roomList: [create(Data.ServerInfo_RoomSchema, { roomId: 1, autoJoin: true, gameList: [], userList: [], gametypeList: [] })],
})
));
const roomJoin = findLastSessionCommand(Data.Command_JoinRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: roomJoin.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_JoinRoom_ext,
value: create(Data.Response_JoinRoomSchema, {
roomInfo: create(Data.ServerInfo_RoomSchema, { roomId: 1, gameList: [], userList: [], gametypeList: [] }),
}),
})));
// ── 1. Create game ───────────────────────────────────────────────────
RoomCommands.createGame(1, { description: 'Ranked Match', maxPlayers: 2 });
const createCmd = findLastRoomCommand(Data.Command_CreateGame_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: createCmd.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
// ── 2. Join game ─────────────────────────────────────────────────────
RoomCommands.joinGame(1, { gameId: 99 });
const joinCmd = findLastRoomCommand(Data.Command_JoinGame_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: joinCmd.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().rooms.joinedGameIds[1]?.[99]).toBe(true);
// Server sends Event_GameJoined (session event)
deliverMessage(buildSessionEventMessage(
Data.Event_GameJoined_ext,
create(Data.Event_GameJoinedSchema, {
gameInfo: create(Data.ServerInfo_GameSchema, { gameId: 99, description: 'Ranked Match', maxPlayers: 2 }),
playerId: 1,
hostId: 1,
spectator: false,
judge: false,
resuming: false,
})
));
expect(store.getState().games.games[99]).toBeDefined();
// ── 3. Select deck ───────────────────────────────────────────────────
GameCommands.deckSelect(99, { deck: '4 Lightning Bolt\n20 Mountain\n4 Goblin Guide' });
const deckCmd = findLastGameCommand(Data.Command_DeckSelect_ext);
expect(deckCmd.value.deck).toContain('Lightning Bolt');
// Server responds with full game state (deck in zones)
const deckCards = [
create(Data.ServerInfo_CardSchema, { id: 1, name: 'Lightning Bolt' }),
create(Data.ServerInfo_CardSchema, { id: 2, name: 'Mountain' }),
create(Data.ServerInfo_CardSchema, { id: 3, name: 'Goblin Guide' }),
];
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: -1,
ext: Data.Event_GameStateChanged_ext,
value: create(Data.Event_GameStateChangedSchema, {
playerList: [create(Data.ServerInfo_PlayerSchema, {
properties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: 1,
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
}),
zoneList: [
create(Data.ServerInfo_ZoneSchema, {
name: 'deck', type: Data.ServerInfo_Zone_ZoneType.HiddenZone,
cardList: deckCards, cardCount: 3,
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'hand', type: Data.ServerInfo_Zone_ZoneType.HiddenZone,
cardList: [], cardCount: 0,
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'table', type: Data.ServerInfo_Zone_ZoneType.PublicZone,
withCoords: true, cardList: [], cardCount: 0,
}),
create(Data.ServerInfo_ZoneSchema, {
name: 'grave', type: Data.ServerInfo_Zone_ZoneType.PublicZone,
cardList: [], cardCount: 0,
}),
],
counterList: [],
arrowList: [],
})],
gameStarted: true,
activePlayerId: 1,
activePhase: 0,
}),
}));
const gameAfterDeck = store.getState().games.games[99];
expect(gameAfterDeck.players[1].zones.deck.order).toHaveLength(3);
expect(gameAfterDeck.players[1].zones.hand.order).toHaveLength(0);
// ── 4. Draw cards ────────────────────────────────────────────────────
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_DrawCards_ext,
value: create(Data.Event_DrawCardsSchema, {
number: 2,
cards: [
create(Data.ServerInfo_CardSchema, { id: 1, name: 'Lightning Bolt' }),
create(Data.ServerInfo_CardSchema, { id: 2, name: 'Mountain' }),
],
}),
}));
const afterDraw = store.getState().games.games[99].players[1];
expect(afterDraw.zones.hand.order).toHaveLength(2);
expect(afterDraw.zones.hand.order).toContain(1);
expect(afterDraw.zones.hand.order).toContain(2);
expect(afterDraw.zones.deck.cardCount).toBe(1);
// ── 5. Send game message ─────────────────────────────────────────────
GameCommands.gameSay(99, { message: 'good luck!' });
const sayCmd = findLastGameCommand(Data.Command_GameSay_ext);
expect(sayCmd.value.message).toBe('good luck!');
// Server echoes the message back
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_GameSay_ext,
value: create(Data.Event_GameSaySchema, { message: 'good luck!' }),
}));
// game.messages is a merged chat + event-log stream (matches desktop's
// MessageLogWidget). Earlier steps in this lifecycle (game-started,
// phase change, draw) also push event entries, so filter to chat.
const chatMessages = store
.getState()
.games.games[99].messages.filter((m) => m.kind === 'chat');
expect(chatMessages).toHaveLength(1);
expect(chatMessages[0].message).toBe('good luck!');
// ── 6. Discard (move card from hand to graveyard) ────────────────────
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_MoveCard_ext,
value: create(Data.Event_MoveCardSchema, {
cardId: 1,
cardName: 'Lightning Bolt',
startPlayerId: 1,
startZone: 'hand',
targetPlayerId: 1,
targetZone: 'grave',
faceDown: false,
newCardId: 1,
}),
}));
const afterDiscard = store.getState().games.games[99].players[1];
expect(afterDiscard.zones.hand.order).not.toContain(1);
expect(afterDiscard.zones.grave.order).toContain(1);
expect(afterDiscard.zones.grave.byId[1]?.name).toBe('Lightning Bolt');
// ── 7. Concede ───────────────────────────────────────────────────────
GameCommands.concede(99);
expect(() => findLastGameCommand(Data.Command_Concede_ext)).not.toThrow();
// Server confirms concession
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_PlayerPropertiesChanged_ext,
value: create(Data.Event_PlayerPropertiesChangedSchema, {
playerProperties: create(Data.ServerInfo_PlayerPropertiesSchema, {
playerId: 1,
conceded: true,
userInfo: create(Data.ServerInfo_UserSchema, { name: 'alice' }),
}),
}),
}));
expect(store.getState().games.games[99].players[1].properties.conceded).toBe(true);
// ── 8. Leave game ────────────────────────────────────────────────────
GameCommands.leaveGame(99);
expect(() => findLastGameCommand(Data.Command_LeaveGame_ext)).not.toThrow();
// Server confirms player left
deliverMessage(buildGameEventMessage({
gameId: 99,
playerId: 1,
ext: Data.Event_Leave_ext,
value: create(Data.Event_LeaveSchema, { reason: Data.Event_Leave_LeaveReason.USER_LEFT }),
}));
expect(store.getState().games.games[99].players[1]).toBeUndefined();
});
});

View file

@ -0,0 +1,66 @@
// KeepAliveService timing scenarios — ping loop, pong correlation, timeout.
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { WebsocketTypes } from '@app/websocket/types';
import { connectRaw, getMockWebSocket } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from '../helpers/protobuf-builders';
import { findLastSessionCommand } from '../helpers/command-capture';
describe('keep-alive', () => {
it('sends a Command_Ping on every keepalive interval tick', () => {
connectRaw();
expect(() => findLastSessionCommand(Data.Command_Ping_ext)).toThrow();
vi.advanceTimersByTime(5000);
const first = findLastSessionCommand(Data.Command_Ping_ext);
expect(first.cmdId).toBeGreaterThan(0);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: first.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
vi.advanceTimersByTime(5000);
const second = findLastSessionCommand(Data.Command_Ping_ext);
expect(second.cmdId).toBeGreaterThan(first.cmdId);
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED);
});
it('stays CONNECTED while pongs arrive before the next tick', () => {
connectRaw();
for (let i = 0; i < 3; i++) {
vi.advanceTimersByTime(5000);
const ping = findLastSessionCommand(Data.Command_Ping_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: ping.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
}
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED);
expect(getMockWebSocket().close).not.toHaveBeenCalled();
});
it('disconnects with a timeout status when a ping goes unanswered', () => {
connectRaw();
vi.advanceTimersByTime(5000);
expect(() => findLastSessionCommand(Data.Command_Ping_ext)).not.toThrow();
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.CONNECTED);
vi.advanceTimersByTime(5000);
expect(getMockWebSocket().close).toHaveBeenCalled();
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
});
});

View file

@ -0,0 +1,211 @@
// Moderator command pipeline smoke tests — validates that sendModeratorCommand
// encodes, correlates, and persists correctly end-to-end. One test per
// distinct response pattern (simple vs. extension-payload).
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { ModeratorCommands } from '@app/websocket';
import { connectAndLogin } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from '../helpers/protobuf-builders';
import { findLastModeratorCommand } from '../helpers/command-capture';
describe('moderator commands', () => {
it('getBanHistory populates server.banHistory on success', () => {
connectAndLogin();
ModeratorCommands.getBanHistory('baduser');
const { cmdId, value } = findLastModeratorCommand(Data.Command_GetBanHistory_ext);
expect(value.userName).toBe('baduser');
const banEntry = create(Data.ServerInfo_BanSchema, {
adminId: 'admin1',
adminName: 'Admin',
banTime: '2026-01-01',
banLength: '60',
visibleReason: 'spamming',
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_BanHistory_ext,
value: create(Data.Response_BanHistorySchema, { banList: [banEntry] }),
})));
const history = store.getState().server.banHistory.baduser;
expect(history).toHaveLength(1);
expect(history[0].visibleReason).toBe('spamming');
});
it('viewLogHistory populates server.logs on success', () => {
connectAndLogin();
ModeratorCommands.viewLogHistory({ dateRange: 30 });
const { cmdId } = findLastModeratorCommand(Data.Command_ViewLogHistory_ext);
const logMsg = create(Data.ServerInfo_ChatMessageSchema, {
senderName: 'alice',
message: 'test message',
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ViewLogHistory_ext,
value: create(Data.Response_ViewLogHistorySchema, { logMessage: [logMsg] }),
})));
const logs = store.getState().server.logs;
expect(Object.keys(logs).length).toBeGreaterThan(0);
});
it('warnUser sends command and updates state on success', () => {
connectAndLogin();
ModeratorCommands.warnUser('troublemaker', 'spamming chat');
const { cmdId, value } = findLastModeratorCommand(Data.Command_WarnUser_ext);
expect(value.userName).toBe('troublemaker');
expect(value.reason).toBe('spamming chat');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.warnUser).toBe('troublemaker');
});
it('banFromServer sends command and updates state on success', () => {
connectAndLogin();
ModeratorCommands.banFromServer(60, 'baduser', undefined, 'repeated offenses', 'rule violation');
const { cmdId, value } = findLastModeratorCommand(Data.Command_BanFromServer_ext);
expect(value.userName).toBe('baduser');
expect(value.minutes).toBe(60);
expect(value.visibleReason).toBe('rule violation');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.banUser).toBe('baduser');
});
it('getWarnHistory dispatches with the response warnList', () => {
connectAndLogin();
ModeratorCommands.getWarnHistory('baduser');
const { cmdId, value } = findLastModeratorCommand(Data.Command_GetWarnHistory_ext);
expect(value.userName).toBe('baduser');
const warning = create(Data.ServerInfo_WarningSchema, {
adminName: 'Admin',
reason: 'spamming',
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_WarnHistory_ext,
value: create(Data.Response_WarnHistorySchema, { warnList: [warning] }),
})));
const history = store.getState().server.warnHistory.baduser;
expect(history).toHaveLength(1);
expect(history[0].reason).toBe('spamming');
});
it('getWarnList dispatches the warn-list options on success', () => {
connectAndLogin();
ModeratorCommands.getWarnList('mod', 'troublemaker', 'client-1');
const { cmdId, value } = findLastModeratorCommand(Data.Command_GetWarnList_ext);
expect(value.modName).toBe('mod');
expect(value.userName).toBe('troublemaker');
expect(value.userClientid).toBe('client-1');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_WarnList_ext,
value: create(Data.Response_WarnListSchema, { warning: ['spam', 'abuse'] }),
})));
expect(store.getState().server.warnListOptions.length).toBeGreaterThan(0);
});
it('forceActivateUser sends command and resolves on RespOk', () => {
connectAndLogin();
ModeratorCommands.forceActivateUser('inactive', 'mod');
const { cmdId, value } = findLastModeratorCommand(Data.Command_ForceActivateUser_ext);
expect(value.usernameToActivate).toBe('inactive');
expect(value.moderatorName).toBe('mod');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
});
it('getAdminNotes populates server.adminNotes on success', () => {
connectAndLogin();
ModeratorCommands.getAdminNotes('subject');
const { cmdId, value } = findLastModeratorCommand(Data.Command_GetAdminNotes_ext);
expect(value.userName).toBe('subject');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_GetAdminNotes_ext,
value: create(Data.Response_GetAdminNotesSchema, { notes: 'prior offenses' }),
})));
expect(store.getState().server.adminNotes.subject).toBe('prior offenses');
});
it('updateAdminNotes sends notes payload and resolves on RespOk', () => {
connectAndLogin();
ModeratorCommands.updateAdminNotes('subject', 'updated notes text');
const { cmdId, value } = findLastModeratorCommand(Data.Command_UpdateAdminNotes_ext);
expect(value.userName).toBe('subject');
expect(value.notes).toBe('updated notes text');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
});
it('grantReplayAccess sends command and resolves on RespOk', () => {
connectAndLogin();
ModeratorCommands.grantReplayAccess(42, 'mod');
const { cmdId, value } = findLastModeratorCommand(Data.Command_GrantReplayAccess_ext);
expect(value.replayId).toBe(42);
expect(value.moderatorName).toBe('mod');
deliverMessage(buildResponseMessage(buildResponse({
cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
});
});

View file

@ -0,0 +1,86 @@
// Password-reset scenarios — the 3-step forgot-password flow. Each step
// is a separate connect → handshake → command → disconnect cycle.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { WebsocketTypes } from '@app/websocket/types';
import { connectAndHandshake } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
deliverMessage,
} from '../helpers/protobuf-builders';
import { findLastSessionCommand } from '../helpers/command-capture';
describe('password reset', () => {
it('forgotPasswordRequest sends command and disconnects on success', () => {
connectAndHandshake({
reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST as const,
host: 'localhost',
port: '4748',
userName: 'alice',
});
const req = findLastSessionCommand(Data.Command_ForgotPasswordRequest_ext);
expect(req.value.userName).toBe('alice');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: req.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ForgotPasswordRequest_ext,
value: create(Data.Response_ForgotPasswordRequestSchema, {
challengeEmail: 'a@example.com',
}),
})));
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
});
it('forgotPasswordChallenge sends command with userName and email', () => {
connectAndHandshake({
reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE as const,
host: 'localhost',
port: '4748',
userName: 'alice',
email: 'alice@example.com',
});
const challenge = findLastSessionCommand(Data.Command_ForgotPasswordChallenge_ext);
expect(challenge.value.userName).toBe('alice');
expect(challenge.value.email).toBe('alice@example.com');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: challenge.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
});
it('forgotPasswordReset sends command with userName, token, and newPassword', () => {
connectAndHandshake({
reason: WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET as const,
host: 'localhost',
port: '4748',
userName: 'alice',
token: 'reset-token-123',
newPassword: 'new-secret',
});
const reset = findLastSessionCommand(Data.Command_ForgotPasswordReset_ext);
expect(reset.value.userName).toBe('alice');
expect(reset.value.token).toBe('reset-token-123');
expect(reset.value.newPassword).toBe('new-secret');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: reset.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().server.status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
});
});

View file

@ -0,0 +1,233 @@
// Room scenarios — Event_ListRooms handling, auto-join, Response_JoinRoom,
// room chat (inbound + outbound), game list updates, and leaveRoom.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { RoomCommands } from '@app/websocket';
import { connectAndHandshake } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildRoomEventMessage,
buildSessionEventMessage,
deliverMessage,
} from '../helpers/protobuf-builders';
import { findLastSessionCommand, findLastRoomCommand, captureAllOutbound } from '../helpers/command-capture';
import { fromBinary, hasExtension, getExtension } from '@bufbuild/protobuf';
function makeRoom(overrides: Partial<{
roomId: number;
name: string;
autoJoin: boolean;
}> = {}): Data.ServerInfo_Room {
return create(Data.ServerInfo_RoomSchema, {
roomId: overrides.roomId ?? 1,
name: overrides.name ?? 'Lobby',
description: 'Test room',
gameCount: 0,
playerCount: 0,
autoJoin: overrides.autoJoin ?? false,
gameList: [],
userList: [],
gametypeList: [],
});
}
/** Deliver Event_ListRooms then join a single auto-join room, returning the roomId. */
function setupJoinedRoom(roomId = 1): void {
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, { roomList: [makeRoom({ roomId, autoJoin: true })] })
));
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: join.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_JoinRoom_ext,
value: create(Data.Response_JoinRoomSchema, { roomInfo: makeRoom({ roomId }) }),
})));
}
describe('rooms', () => {
it('populates rooms state from Event_ListRooms', () => {
connectAndHandshake();
const listRooms = create(Data.Event_ListRoomsSchema, {
roomList: [
makeRoom({ roomId: 1, name: 'Lobby' }),
makeRoom({ roomId: 2, name: 'Legacy' }),
],
});
deliverMessage(buildSessionEventMessage(Data.Event_ListRooms_ext, listRooms));
const { rooms } = store.getState().rooms;
expect(rooms[1]?.info?.name).toBe('Lobby');
expect(rooms[2]?.info?.name).toBe('Legacy');
});
it('auto-joins rooms flagged with autoJoin and flips joinedRoomIds on Response_JoinRoom', () => {
connectAndHandshake();
const listRooms = create(Data.Event_ListRoomsSchema, {
roomList: [
makeRoom({ roomId: 1, name: 'Lobby', autoJoin: true }),
makeRoom({ roomId: 2, name: 'Legacy', autoJoin: false }),
],
});
deliverMessage(buildSessionEventMessage(Data.Event_ListRooms_ext, listRooms));
const join = findLastSessionCommand(Data.Command_JoinRoom_ext);
expect(join.value.roomId).toBe(1);
const joined = create(Data.Response_JoinRoomSchema, {
roomInfo: makeRoom({ roomId: 1, name: 'Lobby' }),
});
deliverMessage(buildResponseMessage(buildResponse({
cmdId: join.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_JoinRoom_ext,
value: joined,
})));
expect(store.getState().rooms.joinedRoomIds[1]).toBe(true);
});
it('appends a room chat message on Event_RoomSay', () => {
connectAndHandshake();
setupJoinedRoom(1);
const say = create(Data.Event_RoomSaySchema, {
name: 'bob',
message: 'hello world',
messageType: Data.Event_RoomSay_RoomMessageType.UserMessage,
});
deliverMessage(buildRoomEventMessage(1, Data.Event_RoomSay_ext, say));
const messages = store.getState().rooms.messages[1];
expect(messages).toHaveLength(1);
expect(messages[0].message).toBe('bob: hello world');
expect(messages[0].name).toBe('bob');
});
it('updates the game list on Event_ListGames', () => {
connectAndHandshake();
setupJoinedRoom(1);
const game = create(Data.ServerInfo_GameSchema, {
gameId: 42,
description: 'Test Game',
maxPlayers: 4,
playerCount: 1,
startTime: 1,
});
const listGames = create(Data.Event_ListGamesSchema, { gameList: [game] });
deliverMessage(buildRoomEventMessage(1, Data.Event_ListGames_ext, listGames));
const roomGames = store.getState().rooms.rooms[1]?.games;
expect(roomGames).toBeDefined();
expect(roomGames?.[42]?.info?.description).toBe('Test Game');
expect(roomGames?.[42]?.info?.gameId).toBe(42);
});
it('auto-join filters correctly across multiple rooms', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ListRooms_ext,
create(Data.Event_ListRoomsSchema, {
roomList: [
makeRoom({ roomId: 1, name: 'Lobby', autoJoin: true }),
makeRoom({ roomId: 2, name: 'Legacy', autoJoin: false }),
makeRoom({ roomId: 3, name: 'Modern', autoJoin: true }),
],
})
));
// Count outbound JoinRoom commands
const containers = captureAllOutbound();
const joinCommands: number[] = [];
for (const container of containers) {
for (const cmd of container.sessionCommand ?? []) {
if (hasExtension(cmd, Data.Command_JoinRoom_ext)) {
joinCommands.push(getExtension(cmd, Data.Command_JoinRoom_ext).roomId);
}
}
}
expect(joinCommands).toHaveLength(2);
expect(joinCommands).toContain(1);
expect(joinCommands).toContain(3);
expect(joinCommands).not.toContain(2);
});
it('sends outbound Command_RoomSay with trimmed message', () => {
connectAndHandshake();
setupJoinedRoom(1);
RoomCommands.roomSay(1, ' hello ');
const { value } = findLastRoomCommand(Data.Command_RoomSay_ext);
expect(value.message).toBe('hello');
});
it('removes room from joinedRoomIds on leaveRoom round-trip', () => {
connectAndHandshake();
setupJoinedRoom(1);
expect(store.getState().rooms.joinedRoomIds[1]).toBe(true);
RoomCommands.leaveRoom(1);
const leave = findLastRoomCommand(Data.Command_LeaveRoom_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: leave.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().rooms.joinedRoomIds[1]).toBeUndefined();
});
it('tracks user join and leave within a room', () => {
connectAndHandshake();
setupJoinedRoom(1);
deliverMessage(buildRoomEventMessage(1, Data.Event_JoinRoom_ext, create(Data.Event_JoinRoomSchema, {
userInfo: create(Data.ServerInfo_UserSchema, { name: 'bob' }),
})));
expect(store.getState().rooms.rooms[1]?.users?.bob).toBeDefined();
deliverMessage(buildRoomEventMessage(1, Data.Event_LeaveRoom_ext, create(Data.Event_LeaveRoomSchema, {
name: 'bob',
})));
expect(store.getState().rooms.rooms[1]?.users?.bob).toBeUndefined();
});
it('tracks game creation and join within a room', () => {
connectAndHandshake();
setupJoinedRoom(1);
RoomCommands.createGame(1, { description: 'Casual', maxPlayers: 2 });
const create_ = findLastRoomCommand(Data.Command_CreateGame_ext);
expect(create_.value.description).toBe('Casual');
deliverMessage(buildResponseMessage(buildResponse({
cmdId: create_.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
RoomCommands.joinGame(1, { gameId: 99 });
const join = findLastRoomCommand(Data.Command_JoinGame_ext);
deliverMessage(buildResponseMessage(buildResponse({
cmdId: join.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
})));
expect(store.getState().rooms.joinedGameIds[1]?.[99]).toBe(true);
});
});

View file

@ -0,0 +1,106 @@
// Server-level session events — server message banner, shutdown schedule,
// user notifications, and connection-closed reason code mapping.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { WebsocketTypes } from '@app/websocket/types';
import { connectAndHandshake } from '../helpers/setup';
import {
buildSessionEventMessage,
deliverMessage,
} from '../helpers/protobuf-builders';
describe('server events', () => {
it('writes the server banner into server.info.message on Event_ServerMessage', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ServerMessage_ext,
create(Data.Event_ServerMessageSchema, { message: 'Welcome to TestServer!' })
));
expect(store.getState().server.info.message).toBe('Welcome to TestServer!');
});
it('stores the shutdown payload on Event_ServerShutdown', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ServerShutdown_ext,
create(Data.Event_ServerShutdownSchema, {
reason: 'Scheduled maintenance',
minutes: 5,
})
));
const shutdown = store.getState().server.serverShutdown;
expect(shutdown).not.toBeNull();
expect(shutdown?.reason).toBe('Scheduled maintenance');
expect(shutdown?.minutes).toBe(5);
});
it('appends a notification on Event_NotifyUser', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_NotifyUser_ext,
create(Data.Event_NotifyUserSchema, {
type: Data.Event_NotifyUser_NotificationType.PROMOTION,
customTitle: 'You have been promoted',
customContent: 'Now a judge',
})
));
const notifications = store.getState().server.notifications;
expect(notifications).toHaveLength(1);
expect(notifications[0].customTitle).toBe('You have been promoted');
});
describe('connection closed', () => {
it('prefers reasonStr when provided', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ConnectionClosed_ext,
create(Data.Event_ConnectionClosedSchema, {
reason: Data.Event_ConnectionClosed_CloseReason.OTHER,
reasonStr: 'kicked by admin',
})
));
const status = store.getState().server.status;
expect(status.state).toBe(WebsocketTypes.StatusEnum.DISCONNECTED);
expect(status.description).toBe('kicked by admin');
});
it('maps USER_LIMIT_REACHED to a capacity message', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ConnectionClosed_ext,
create(Data.Event_ConnectionClosedSchema, {
reason: Data.Event_ConnectionClosed_CloseReason.USER_LIMIT_REACHED,
})
));
expect(store.getState().server.status.description).toContain('maximum user capacity');
});
it('maps LOGGEDINELSEWERE to a multi-session message', () => {
connectAndHandshake();
deliverMessage(buildSessionEventMessage(
Data.Event_ConnectionClosed_ext,
create(Data.Event_ConnectionClosedSchema, {
reason: Data.Event_ConnectionClosed_CloseReason.LOGGEDINELSEWERE,
})
));
expect(store.getState().server.status.description).toContain('another location');
});
});
});

View file

@ -0,0 +1,120 @@
// User-list and social scenarios — user presence, buddy/ignore lists, DMs.
import { create } from '@bufbuild/protobuf';
import { describe, expect, it } from 'vitest';
import { Data } from '@app/types';
import { store } from '@app/store';
import { connectAndLogin } from '../helpers/setup';
import {
buildResponse,
buildResponseMessage,
buildSessionEventMessage,
deliverMessage,
} from '../helpers/protobuf-builders';
import { findLastSessionCommand } from '../helpers/command-capture';
function makeUser(name: string): Data.ServerInfo_User {
return create(Data.ServerInfo_UserSchema, {
name,
userLevel: Data.ServerInfo_User_UserLevelFlag.IsRegistered,
});
}
describe('users', () => {
it('populates state.users from the Response_ListUsers post-login', () => {
connectAndLogin();
const listUsers = findLastSessionCommand(Data.Command_ListUsers_ext);
const users = [makeUser('alice'), makeUser('bob'), makeUser('carol')];
deliverMessage(buildResponseMessage(buildResponse({
cmdId: listUsers.cmdId,
responseCode: Data.Response_ResponseCode.RespOk,
ext: Data.Response_ListUsers_ext,
value: create(Data.Response_ListUsersSchema, { userList: users }),
})));
expect(Object.keys(store.getState().server.users).sort()).toEqual(['alice', 'bob', 'carol']);
});
it('appends on Event_UserJoined and removes on Event_UserLeft', () => {
connectAndLogin();
deliverMessage(buildSessionEventMessage(
Data.Event_UserJoined_ext,
create(Data.Event_UserJoinedSchema, { userInfo: makeUser('bob') })
));
expect('bob' in store.getState().server.users).toBe(true);
deliverMessage(buildSessionEventMessage(
Data.Event_UserLeft_ext,
create(Data.Event_UserLeftSchema, { name: 'bob' })
));
expect('bob' in store.getState().server.users).toBe(false);
});
it('adds a user to buddyList on Event_AddToList with listName=buddy', () => {
connectAndLogin();
deliverMessage(buildSessionEventMessage(
Data.Event_AddToList_ext,
create(Data.Event_AddToListSchema, {
listName: 'buddy',
userInfo: makeUser('bob'),
})
));
expect('bob' in store.getState().server.buddyList).toBe(true);
expect(store.getState().server.ignoreList).toEqual({});
});
it('adds a user to ignoreList on Event_AddToList with listName=ignore', () => {
connectAndLogin();
deliverMessage(buildSessionEventMessage(
Data.Event_AddToList_ext,
create(Data.Event_AddToListSchema, {
listName: 'ignore',
userInfo: makeUser('mallory'),
})
));
expect('mallory' in store.getState().server.ignoreList).toBe(true);
expect(store.getState().server.buddyList).toEqual({});
});
it('files an incoming direct message under the sender', () => {
connectAndLogin('alice');
deliverMessage(buildSessionEventMessage(
Data.Event_UserMessage_ext,
create(Data.Event_UserMessageSchema, {
senderName: 'bob',
receiverName: 'alice',
message: 'hi alice',
})
));
const { messages } = store.getState().server;
expect(messages.bob).toHaveLength(1);
expect(messages.bob[0].message).toBe('hi alice');
});
it('files an outgoing direct message under the recipient', () => {
connectAndLogin('alice');
deliverMessage(buildSessionEventMessage(
Data.Event_UserMessage_ext,
create(Data.Event_UserMessageSchema, {
senderName: 'alice',
receiverName: 'bob',
message: 'hey bob',
})
));
const { messages } = store.getState().server;
expect(messages.bob).toHaveLength(1);
expect(messages.bob[0].message).toBe('hey bob');
});
});

View file

@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["./**/*.ts"]
}

36807
webclient/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,76 +3,90 @@
"version": "1.0.0",
"private": true,
"scripts": {
"prebuild": "node prebuild.js",
"prestart": "node prebuild.js",
"build": "react-scripts build",
"start": "react-scripts start",
"test": "react-scripts test",
"test:watch": "react-scripts test",
"eject": "react-scripts eject",
"lint": "eslint \"./**/*.{ts,tsx}\"",
"lint:fix": "eslint \"./**/*.{ts,tsx}\" --fix",
"golden": "npm run lint && npm run test",
"prepare": "cd .. && husky install",
"translate": "node prebuild.js -i18nOnly"
"prebuild": "npm run proto:generate && node prebuild.js",
"prestart": "npm run proto:generate && node prebuild.js",
"build": "vite build",
"start": "vite",
"preview": "vite preview",
"test": "vitest run",
"test:coverage": "npm run test -- --coverage",
"test:watch": "vitest",
"test:integration": "vitest run --config vitest.integration.config.ts",
"test:integration:coverage": "npm run test:integration -- --coverage",
"lint": "eslint src",
"lint:fix": "eslint src --fix",
"golden": "npm run lint && npm run test && npm run test:integration",
"golden:coverage": "npm run lint && npm run test:coverage && npm run test:integration:coverage",
"prepare": "cd .. && husky",
"translate": "node prebuild.js -i18nOnly",
"proto:generate": "npx buf generate",
"diagram": "npm run diagram:simple && npm run diagram:detailed && npm run diagram:flow",
"diagram:simple": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/simple.mmd -o architecture/simple.png -b white -s 2",
"diagram:detailed": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/detailed.mmd -o architecture/detailed.png -b white -s 2",
"diagram:flow": "npx -y -p @mermaid-js/mermaid-cli -p puppeteer mmdc -i architecture/flow.mmd -o architecture/flow.png -b white -s 2"
},
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.8.2",
"@emotion/styled": "^11.8.1",
"@mui/icons-material": "^5.5.1",
"@mui/material": "^5.5.1",
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"@reduxjs/toolkit": "^2.11.2",
"crypto-js": "^4.2.0",
"dexie": "^3.2.2",
"final-form": "^4.20.6",
"dexie": "^4.4.2",
"dompurify": "^3.4.0",
"final-form": "^5.0.0",
"final-form-set-field-touched": "^1.0.1",
"i18next": "^22.0.4",
"i18next-browser-languagedetector": "^7.0.0",
"i18next": "^26.0.5",
"i18next-browser-languagedetector": "^8.2.1",
"i18next-icu": "^2.0.3",
"intl-messageformat": "^10.2.1",
"lodash": "^4.17.21",
"intl-messageformat": "^11.2.1",
"prop-types": "^15.8.1",
"protobufjs": "^7.2.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-final-form": "^6.5.8",
"react-final-form-listeners": "^1.0.3",
"react-i18next": "^12.0.0",
"react-redux": "^8.0.4",
"react-router-dom": "^6.2.2",
"react-scripts": "5.0.1",
"react-virtualized-auto-sizer": "^1.0.6",
"react-window": "^1.8.6",
"redux": "^4.1.2",
"redux-form": "^8.3.8",
"redux-thunk": "^2.4.1",
"rxjs": "^7.5.4",
"sanitize-html": "^2.7.3"
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-final-form": "^7.0.0",
"react-final-form-listeners": "^3.0.0",
"react-i18next": "^17.0.3",
"react-redux": "^9.2.0",
"react-router-dom": "^7.14.1",
"react-virtualized-auto-sizer": "^2.0.3",
"react-window": "^2.2.7",
"rxjs": "^7.5.4"
},
"devDependencies": {
"@babel/core": "^7.17.5",
"@mui/types": "^7.1.3",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^13.4.0",
"@types/jest": "29.2.0",
"@types/jquery": "^3.5.14",
"@types/lodash": "^4.14.179",
"@types/node": "18.11.7",
"@bufbuild/buf": "^1.68.1",
"@bufbuild/protoc-gen-es": "^2.11.0",
"@eslint/js": "^10.0.1",
"@mui/types": "^9.0.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^16.3.2",
"@types/dompurify": "^3.0.5",
"@types/node": "^22.19.17",
"@types/prop-types": "^15.7.4",
"@types/react": "18.0.24",
"@types/react-dom": "18.0.8",
"@types/react-redux": "^7.1.23",
"@types/react-router-dom": "^5.3.3",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/redux-form": "^8.3.3",
"@typescript-eslint/eslint-plugin": "^5.14.0",
"@typescript-eslint/parser": "^5.14.0",
"fs-extra": "^10.0.1",
"husky": "^8.0.1",
"typescript": "^4.6.2"
},
"eslintConfig": {
"extends": "react-app"
"@typescript-eslint/eslint-plugin": "^8.58.2",
"@typescript-eslint/parser": "^8.58.2",
"@vitejs/plugin-react": "^6.0.1",
"@vitejs/plugin-react-swc": "^4.3.0",
"@vitest/coverage-v8": "^4.1.4",
"eslint": "^10.2.0",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-boundaries": "^6.0.2",
"fake-indexeddb": "^6.2.5",
"fs-extra": "^11.3.4",
"globals": "^17.5.0",
"husky": "^9.1.7",
"jsdom": "^29.0.2",
"typescript": "~6.0",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.8",
"vitest": "^4.1.4"
},
"browserslist": {
"production": [
@ -85,10 +99,5 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"jest": {
"moduleNameMapper": {
"\\.(css|less)$": "identity-obj-proxy"
}
}
}

View file

@ -6,13 +6,10 @@ const exec = util.promisify(require('child_process').exec);
const ROOT_DIR = './src';
const PUBLIC_DIR = './public';
const protoFilesDir = `${PUBLIC_DIR}/pb`;
const i18nDefaultFile = `${ROOT_DIR}/i18n-default.json`;
const serverPropsFile = `${ROOT_DIR}/server-props.json`;
const masterProtoFile = `${ROOT_DIR}/proto-files.json`;
const sharedFiles = [
['../libcockatrice_protocol/libcockatrice/protocol/pb', protoFilesDir],
['../cockatrice/resources/countries', `${ROOT_DIR}/images/countries`],
];
@ -26,10 +23,8 @@ const i18nOnly = process.argv.indexOf('-i18nOnly') > -1;
return;
}
// make sure these files finish copying before master file is created
await copySharedFiles();
await createMasterProtoFile();
await createServerProps();
await createI18NDefault();
})();
@ -43,19 +38,6 @@ async function copySharedFiles() {
}
}
async function createMasterProtoFile() {
try {
fse.readdir(protoFilesDir, (err, files) => {
if (err) throw err;
fse.outputFile(masterProtoFile, JSON.stringify(files.filter(file => /\.proto$/.test(file))));
});
} catch (e) {
console.error(e);
process.exitCode = 1;
}
}
async function createServerProps() {
try {
fse.outputFile(serverPropsFile, JSON.stringify({

View file

@ -1,44 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="stylesheet" type="text/css" href="%PUBLIC_URL%/reset.css">
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Webatrice: A Cockatrice Web Client"
/>
<link rel="apple-touch-icon" href="logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>Webatrice</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

View file

@ -1,6 +1,6 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "Webatrice",
"name": "Webatrice",
"icons": [
{
"src": "favicon.ico",

View file

@ -0,0 +1,22 @@
/**
* Temporarily override fields on `window.location` and return a restore fn.
*
* @critical `Object.defineProperty(window, 'location', ...)` isn't a vi.spyOn
* target, so `vi.restoreAllMocks()` will NOT undo it. Always invoke the
* returned restore callback.
*/
export function withMockLocation(overrides: Partial<Location>): () => void {
const originalDescriptor = Object.getOwnPropertyDescriptor(window, 'location');
Object.defineProperty(window, 'location', {
value: { ...window.location, ...overrides },
writable: true,
configurable: true,
});
return () => {
if (originalDescriptor) {
Object.defineProperty(window, 'location', originalDescriptor);
}
};
}

View file

@ -0,0 +1,10 @@
export { withMockLocation } from './globalGuards';
export { renderWithProviders } from './renderWithProviders';
export { createMockWebClient } from './mockWebClient';
export {
disconnectedState,
connectedState,
connectedWithRoomsState,
makeStoreState,
makeUser,
} from './storeFixtures';

View file

@ -0,0 +1,53 @@
import { ReactNode } from 'react';
import { Provider } from 'react-redux';
import { configureStore, Reducer } from '@reduxjs/toolkit';
import { WebClientContext } from '../hooks/useWebClient';
import type { WebClient } from '../websocket';
import { createMockWebClient } from './mockWebClient';
// Minimal Provider wrapper for hook-only tests. Use this instead of
// `renderWithProviders` when you need `renderHook` — the full provider tree
// auto-instantiates the singleton store via `@app/store`, which races with
// any test-local store you preload. Deep-import the reducer(s) you need and
// pass them here (see useCurrentGame.spec.tsx for the canonical pattern).
export function makeReduxHookWrapper<S>(
reducer: Reducer<S>,
preloadedState: S,
) {
const store = configureStore({
reducer,
preloadedState: preloadedState as Parameters<typeof configureStore>[0]['preloadedState'],
});
function Wrapper({ children }: { children: ReactNode }) {
return <Provider store={store}>{children}</Provider>;
}
return { Wrapper, store };
}
export interface MakeReduxWebClientHookWrapperOptions<S> {
reducer: Reducer<S>;
preloadedState: S;
webClient?: WebClient;
}
export function makeReduxWebClientHookWrapper<S>({
reducer,
preloadedState,
webClient,
}: MakeReduxWebClientHookWrapperOptions<S>) {
const store = configureStore({
reducer,
preloadedState: preloadedState as Parameters<typeof configureStore>[0]['preloadedState'],
});
const client = webClient ?? createMockWebClient();
function Wrapper({ children }: { children: ReactNode }) {
return (
<Provider store={store}>
<WebClientContext value={client}>{children}</WebClientContext>
</Provider>
);
}
return { Wrapper, store, webClient: client };
}

View file

@ -0,0 +1,90 @@
import type { WebClient } from '@app/websocket';
/**
* Creates a mock WebClient whose `request` property has vi.fn() stubs
* for every service method that containers/forms call. Inject via a
* vi.hoisted reference returned from a `vi.mock('@app/hooks', ...)` stub
* of `useWebClient`; see LoginForm.spec.tsx for the canonical pattern.
*/
export function createMockWebClient() {
return {
request: {
authentication: {
login: vi.fn(),
register: vi.fn(),
disconnect: vi.fn(),
activateAccount: vi.fn(),
resetPasswordRequest: vi.fn(),
resetPasswordChallenge: vi.fn(),
resetPassword: vi.fn(),
},
session: {
addToBuddyList: vi.fn(),
removeFromBuddyList: vi.fn(),
addToIgnoreList: vi.fn(),
removeFromIgnoreList: vi.fn(),
getUserInfo: vi.fn(),
accountEdit: vi.fn(),
accountPassword: vi.fn(),
accountImage: vi.fn(),
listUsers: vi.fn(),
},
rooms: {
joinRoom: vi.fn(),
leaveRoom: vi.fn(),
roomSay: vi.fn(),
createGame: vi.fn(),
joinGame: vi.fn(),
},
game: {
leaveGame: vi.fn(),
kickFromGame: vi.fn(),
gameSay: vi.fn(),
readyStart: vi.fn(),
concede: vi.fn(),
unconcede: vi.fn(),
judge: vi.fn(),
nextTurn: vi.fn(),
setActivePhase: vi.fn(),
reverseTurn: vi.fn(),
moveCard: vi.fn(),
flipCard: vi.fn(),
attachCard: vi.fn(),
createToken: vi.fn(),
setCardAttr: vi.fn(),
setCardCounter: vi.fn(),
incCardCounter: vi.fn(),
drawCards: vi.fn(),
undoDraw: vi.fn(),
createArrow: vi.fn(),
deleteArrow: vi.fn(),
createCounter: vi.fn(),
setCounter: vi.fn(),
incCounter: vi.fn(),
delCounter: vi.fn(),
shuffle: vi.fn(),
dumpZone: vi.fn(),
revealCards: vi.fn(),
changeZoneProperties: vi.fn(),
deckSelect: vi.fn(),
setSideboardPlan: vi.fn(),
setSideboardLock: vi.fn(),
mulligan: vi.fn(),
rollDie: vi.fn(),
},
admin: {
adjustMod: vi.fn(),
reloadConfig: vi.fn(),
shutdownServer: vi.fn(),
updateServerMessage: vi.fn(),
},
moderator: {
viewLogHistory: vi.fn(),
banFromServer: vi.fn(),
warnUser: vi.fn(),
warnHistory: vi.fn(),
banHistory: vi.fn(),
},
},
} as unknown as WebClient;
}

View file

@ -0,0 +1,132 @@
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { configureStore, EnhancedStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { I18nextProvider } from 'react-i18next';
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import { DndContext } from '@dnd-kit/core';
import { createTheme, ThemeProvider } from '@mui/material/styles';
// Disables MUI's ripple animation AND all component transitions in tests.
// The ripple fires a deferred state update after clicks/focus that would
// trigger a noisy "update to ForwardRef(TouchRipple) was not wrapped in
// act(...)" warning. Transitions (Grow/Fade/Slide used by Menu, Dialog,
// Popover, Tooltip) default to ~225ms, which is pure wait-time in jsdom
// — every portal open paid this cost before. Zeroing `transitions.duration`
// plus the per-component `transitionDuration: 0` override belt-and-braces
// covers the full v9 surface: styled transitions read the theme; component-
// level Transition props need the defaultProps override.
const testTheme = createTheme({
transitions: {
duration: {
shortest: 0, shorter: 0, short: 0,
standard: 0, complex: 0,
enteringScreen: 0, leavingScreen: 0,
},
create: () => 'none',
},
components: {
MuiButtonBase: { defaultProps: { disableRipple: true } },
MuiDialog: { defaultProps: { transitionDuration: 0 } },
MuiMenu: { defaultProps: { transitionDuration: 0 } },
MuiPopover: { defaultProps: { transitionDuration: 0 } },
MuiTooltip: { defaultProps: { enterDelay: 0, leaveDelay: 0 } },
},
});
import { WebClientContext } from '../hooks/useWebClient';
import type { WebClient } from '../websocket';
import rootReducer from '../store/rootReducer';
import { ToastProvider } from '../components/Toast/ToastContext';
import { storeMiddlewareOptions } from '../store/store';
import type { RootState } from '../store/store';
import { createMockWebClient } from './mockWebClient';
// Lazy-initialized per test file (vitest isolate: true re-evaluates module
// graph per file). Reused by every `renderWithProviders` call that doesn't
// inject its own webClient, so the ~65 vi.fn() allocations happen once per
// file instead of once per render. The global `afterEach` in setupTests.ts
// runs `vi.clearAllMocks()` which resets call history between tests without
// destroying the fn instances — exactly what we want here.
let defaultWebClient: WebClient | undefined;
function getDefaultWebClient(): WebClient {
if (!defaultWebClient) {
defaultWebClient = createMockWebClient();
}
return defaultWebClient;
}
// Non-empty `resources` registers en-US so `resolvedLanguage` is defined;
// without it MUI warns about out-of-range Select values.
const testI18n = i18n.createInstance();
testI18n.use(initReactI18next).init({
lng: 'en-US',
resources: { 'en-US': { translation: {} } },
fallbackLng: 'en-US',
interpolation: { escapeValue: false },
});
// `configureStore`'s `preloadedState` wants `PreloadedState<CombinedState<…>>`
// which narrows collection types past our slice interfaces. A single cast
// here keeps the test harness loose (each test injects only the slices it
// cares about) while specs themselves stay strict via `makeStoreState`.
function createTestStore(preloadedState?: Partial<RootState>) {
return configureStore({
reducer: rootReducer,
preloadedState: preloadedState as Parameters<typeof configureStore>[0]['preloadedState'],
// Share the production middleware config so the serializableCheck
// tolerates protobuf messages (isMessage) the same way the real store
// does — otherwise every proto-payload dispatch in tests spams stderr.
middleware: (getDefaultMiddleware) => getDefaultMiddleware(storeMiddlewareOptions),
});
}
interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
preloadedState?: Partial<RootState>;
store?: EnhancedStore;
route?: string;
webClient?: WebClient;
}
export function renderWithProviders(
ui: ReactElement,
{
preloadedState,
store = createTestStore(preloadedState),
route = '/',
webClient = getDefaultWebClient(),
...renderOptions
}: ExtendedRenderOptions = {},
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<Provider store={store}>
<I18nextProvider i18n={testI18n}>
<ThemeProvider theme={testTheme}>
<ToastProvider>
<MemoryRouter initialEntries={[route]}>
<WebClientContext value={webClient}>
<DndContext
accessibility={{
screenReaderInstructions: { draggable: '' },
}}
>
{children}
</DndContext>
</WebClientContext>
</MemoryRouter>
</ToastProvider>
</ThemeProvider>
</I18nextProvider>
</Provider>
);
}
return {
store,
webClient,
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
}

View file

@ -0,0 +1,153 @@
import { App, Data } from '@app/types';
import { WebsocketTypes } from '@app/websocket/types';
import type { RootState } from '../store/store';
/**
* Create a minimal ServerInfo_User object for testing.
*/
function makeUser(overrides: Partial<Data.ServerInfo_User> = {}): Data.ServerInfo_User {
return {
name: 'testUser',
realName: '',
country: 'us',
userLevel: 0,
avatarBmp: new Uint8Array(),
accountageSecs: BigInt(0),
$typeName: 'ServerInfo_User' as any,
$unknown: undefined,
gender: 0,
...overrides,
} as Data.ServerInfo_User;
}
/**
* A disconnected (default) store state. This is the state before any
* connection to a server has been made.
*/
export const disconnectedState: Partial<RootState> = {
server: {
initialized: false,
testConnectionStatus: null,
buddyList: {},
ignoreList: {},
status: {
connectionAttemptMade: false,
state: WebsocketTypes.StatusEnum.DISCONNECTED,
description: null,
},
info: { message: null, name: null, version: null },
logs: { room: [], game: [], chat: [] },
user: null,
users: {},
sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC },
messages: {},
userInfo: {},
notifications: [],
serverShutdown: null,
banUser: '',
banHistory: {},
warnHistory: {},
warnListOptions: [],
warnUser: '',
adminNotes: {},
replays: {},
backendDecks: null,
downloadedDeck: null,
downloadedReplay: null,
gamesOfUser: {},
registrationError: null,
},
rooms: {
rooms: {},
joinedRoomIds: {},
joinedGameIds: {},
messages: {},
sortGamesBy: { field: App.GameSortField.START_TIME, order: App.SortDirection.DESC },
sortUsersBy: { field: App.UserSortField.NAME, order: App.SortDirection.ASC },
selectedGameIds: {},
gameFilters: {},
joinGamePending: false,
joinGameError: null,
},
games: { games: {} },
action: { type: null, payload: null, meta: null, error: false, count: 0 },
};
/**
* A connected (logged-in) store state with a basic user and server info.
*/
export const connectedState: Partial<RootState> = {
...disconnectedState,
server: {
...(disconnectedState.server as any),
initialized: true,
status: {
connectionAttemptMade: true,
state: WebsocketTypes.StatusEnum.LOGGED_IN,
description: null,
},
info: {
message: '<b>Welcome</b>',
name: 'Test Server',
version: '1.0.0',
},
user: makeUser(),
users: {
testUser: makeUser(),
},
},
};
/**
* Connected state with rooms and a joined room containing games and users.
*/
export const connectedWithRoomsState: Partial<RootState> = {
...connectedState,
server: {
...(connectedState.server as any),
users: {
testUser: makeUser(),
otherUser: makeUser({ name: 'otherUser' }),
},
},
rooms: {
...(disconnectedState.rooms as any),
rooms: {
1: {
info: { roomId: 1, name: 'Main Room', description: 'The main room', autoJoin: true, permissionLevel: 0 },
gameList: [],
userList: [makeUser(), makeUser({ name: 'otherUser' })],
},
},
joinedRoomIds: { 1: true },
messages: {
1: [],
},
},
};
export { makeUser };
/**
* Deep-partial of a root state. Let specs pass partial slice shapes
* (typically just `games: { games: { ... } }`) without the ~60 fields of
* server/rooms that the test doesn't care about.
*/
type DeepPartial<T> = T extends object ? { [K in keyof T]?: DeepPartial<T[K]> } : T;
/**
* Wraps a partial root-state literal with a safe single `as`-cast so specs
* don't need to sprinkle `as any` on every `preloadedState` argument. The
* runtime value is the exact same literal; the only thing this helper buys
* is deleting the `as any` cast from call sites.
*
* @example
* renderWithProviders(<MyComponent />, {
* preloadedState: makeStoreState({
* games: { games: { 1: makeGameEntry({ ... }) } },
* }),
* });
*/
export function makeStoreState(partial: DeepPartial<RootState>): Partial<RootState> {
return partial as Partial<RootState>;
}

View file

@ -1,19 +0,0 @@
import { AdminCommands } from 'websocket';
export class AdminService {
static adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void {
AdminCommands.adjustMod(userName, shouldBeMod, shouldBeJudge);
}
static reloadConfig(): void {
AdminCommands.reloadConfig();
}
static shutdownServer(reason: string, minutes: number): void {
AdminCommands.shutdownServer(reason, minutes);
}
static updateServerMessage(): void {
AdminCommands.updateServerMessage();
}
}

View file

@ -1,55 +0,0 @@
import { StatusEnum, User, WebSocketConnectReason, WebSocketConnectOptions } from 'types';
import { SessionCommands, webClient } from 'websocket';
import { ProtoController } from 'websocket/services/ProtoController';
export class AuthenticationService {
static login(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.LOGIN);
}
static testConnection(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.TEST_CONNECTION);
}
static register(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.REGISTER);
}
static activateAccount(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.ACTIVATE_ACCOUNT);
}
static resetPasswordRequest(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.PASSWORD_RESET_REQUEST);
}
static resetPasswordChallenge(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.PASSWORD_RESET_CHALLENGE);
}
static resetPassword(options: WebSocketConnectOptions): void {
SessionCommands.connect(options, WebSocketConnectReason.PASSWORD_RESET);
}
static disconnect(): void {
SessionCommands.disconnect();
}
static isConnected(state: number): boolean {
return state === StatusEnum.LOGGED_IN;
}
static isModerator(user: User): boolean {
const moderatorLevel = ProtoController.root.ServerInfo_User.UserLevelFlag.IsModerator;
// @TODO tell cockatrice not to do this so shittily
return (user.userLevel & moderatorLevel) === moderatorLevel;
}
static isAdmin() {
}
static connectionAttemptMade() {
return webClient.connectionAttemptMade;
}
}

View file

@ -1,29 +0,0 @@
import { ModeratorCommands } from 'websocket';
import { LogFilters } from 'types';
export class ModeratorService {
static banFromServer(minutes: number, userName?: string, address?: string, reason?: string,
visibleReason?: string, clientid?: string, removeMessages?: number): void {
ModeratorCommands.banFromServer(minutes, userName, address, reason, visibleReason, clientid, removeMessages);
}
static getBanHistory(userName: string): void {
ModeratorCommands.getBanHistory(userName);
}
static getWarnHistory(userName: string): void {
ModeratorCommands.getWarnHistory(userName);
}
static getWarnList(modName: string, userName: string, userClientid: string): void {
ModeratorCommands.getWarnList(modName, userName, userClientid);
}
static viewLogHistory(filters: LogFilters): void {
ModeratorCommands.viewLogHistory(filters);
}
static warnUser(userName: string, reason: string, clientid?: string, removeMessages?: number): void {
ModeratorCommands.warnUser(userName, reason, clientid, removeMessages);
}
}

View file

@ -1,15 +0,0 @@
import { RoomCommands, SessionCommands } from 'websocket';
export class RoomsService {
static joinRoom(roomId: number): void {
SessionCommands.joinRoom(roomId);
}
static leaveRoom(roomId: number): void {
RoomCommands.leaveRoom(roomId);
}
static roomSay(roomId: number, message: string): void {
RoomCommands.roomSay(roomId, message);
}
}

View file

@ -1,43 +0,0 @@
import { SessionCommands } from 'websocket';
export class SessionService {
static addToBuddyList(userName: string) {
SessionCommands.addToBuddyList(userName);
}
static removeFromBuddyList(userName: string) {
SessionCommands.removeFromBuddyList(userName);
}
static addToIgnoreList(userName: string) {
SessionCommands.addToIgnoreList(userName);
}
static removeFromIgnoreList(userName: string) {
SessionCommands.removeFromIgnoreList(userName);
}
static changeAccountPassword(oldPassword: string, newPassword: string, hashedNewPassword?: string): void {
SessionCommands.accountPassword(oldPassword, newPassword, hashedNewPassword);
}
static changeAccountDetails(passwordCheck: string, realName?: string, email?: string, country?: string): void {
SessionCommands.accountEdit(passwordCheck, realName, email, country);
}
static changeAccountImage(image: Uint8Array): void {
SessionCommands.accountImage(image);
}
static sendDirectMessage(userName: string, message: string): void {
SessionCommands.message(userName, message);
}
static getUserInfo(userName: string): void {
SessionCommands.getUserInfo(userName);
}
static getUserGames(userName: string): void {
SessionCommands.getGamesOfUser(userName);
}
}

View file

@ -1,5 +1,2 @@
export { AdminService } from './AdminService';
export { AuthenticationService } from './AuthenticationService';
export { ModeratorService } from './ModeratorService';
export { RoomsService } from './RoomsService';
export { SessionService } from './SessionService';
export { createWebClientResponse } from './response';
export { createWebClientRequest } from './request';

View file

@ -0,0 +1,20 @@
import { AdminCommands } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
export class AdminRequestImpl implements WebsocketTypes.IAdminRequest {
adjustMod(userName: string, shouldBeMod?: boolean, shouldBeJudge?: boolean): void {
AdminCommands.adjustMod(userName, shouldBeMod, shouldBeJudge);
}
reloadConfig(): void {
AdminCommands.reloadConfig();
}
shutdownServer(reason: string, minutes: number): void {
AdminCommands.shutdownServer(reason, minutes);
}
updateServerMessage(): void {
AdminCommands.updateServerMessage();
}
}

View file

@ -0,0 +1,61 @@
import {
WebClient,
SessionCommands,
setPendingOptions,
} from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
interface AppAuthRequestOverrides extends WebsocketTypes.AuthRequestMap {
LoginParams: Omit<WebsocketTypes.LoginConnectOptions, 'reason'>;
ConnectTarget: Omit<WebsocketTypes.TestConnectionOptions, 'reason'>;
RegisterParams: Omit<WebsocketTypes.RegisterConnectOptions, 'reason'>;
ActivateParams: Omit<WebsocketTypes.ActivateConnectOptions, 'reason'>;
ForgotPasswordRequestParams: Omit<WebsocketTypes.PasswordResetRequestConnectOptions, 'reason'>;
ForgotPasswordChallengeParams: Omit<WebsocketTypes.PasswordResetChallengeConnectOptions, 'reason'>;
ForgotPasswordResetParams: Omit<WebsocketTypes.PasswordResetConnectOptions, 'reason'>;
}
const CONNECTING_STATUS_LABEL = 'Connecting...';
function beginConnect(
options: { host: string; port: string | number },
reason: WebsocketTypes.WebSocketConnectReason,
): void {
setPendingOptions({ ...options, reason });
SessionCommands.updateStatus(WebsocketTypes.StatusEnum.CONNECTING, CONNECTING_STATUS_LABEL);
WebClient.instance.connect({ host: options.host, port: options.port });
}
export class AuthenticationRequestImpl implements WebsocketTypes.IAuthenticationRequest<AppAuthRequestOverrides> {
login(options: Omit<WebsocketTypes.LoginConnectOptions, 'reason'>): void {
beginConnect(options, WebsocketTypes.WebSocketConnectReason.LOGIN);
}
testConnection(options: Omit<WebsocketTypes.TestConnectionOptions, 'reason'>): void {
WebClient.instance.testConnect({ host: options.host, port: options.port });
}
register(options: Omit<WebsocketTypes.RegisterConnectOptions, 'reason'>): void {
beginConnect(options, WebsocketTypes.WebSocketConnectReason.REGISTER);
}
activateAccount(options: Omit<WebsocketTypes.ActivateConnectOptions, 'reason'>): void {
beginConnect(options, WebsocketTypes.WebSocketConnectReason.ACTIVATE_ACCOUNT);
}
resetPasswordRequest(options: Omit<WebsocketTypes.PasswordResetRequestConnectOptions, 'reason'>): void {
beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_REQUEST);
}
resetPasswordChallenge(options: Omit<WebsocketTypes.PasswordResetChallengeConnectOptions, 'reason'>): void {
beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET_CHALLENGE);
}
resetPassword(options: Omit<WebsocketTypes.PasswordResetConnectOptions, 'reason'>): void {
beginConnect(options, WebsocketTypes.WebSocketConnectReason.PASSWORD_RESET);
}
disconnect(): void {
SessionCommands.disconnect();
}
}

View file

@ -0,0 +1,142 @@
import { GameCommands } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
import { Data } from '@app/types';
export class GameRequestImpl implements WebsocketTypes.IGameRequest {
leaveGame(gameId: number): void {
GameCommands.leaveGame(gameId);
}
kickFromGame(gameId: number, params: Data.KickFromGameParams): void {
GameCommands.kickFromGame(gameId, params);
}
gameSay(gameId: number, params: Data.GameSayParams): void {
GameCommands.gameSay(gameId, params);
}
readyStart(gameId: number, params: Data.ReadyStartParams): void {
GameCommands.readyStart(gameId, params);
}
concede(gameId: number): void {
GameCommands.concede(gameId);
}
unconcede(gameId: number): void {
GameCommands.unconcede(gameId);
}
judge(gameId: number, targetId: number, innerGameCommand: Data.GameCommand): void {
GameCommands.judge(gameId, targetId, innerGameCommand);
}
nextTurn(gameId: number): void {
GameCommands.nextTurn(gameId);
}
setActivePhase(gameId: number, params: Data.SetActivePhaseParams): void {
GameCommands.setActivePhase(gameId, params);
}
reverseTurn(gameId: number): void {
GameCommands.reverseTurn(gameId);
}
moveCard(gameId: number, params: Data.MoveCardParams): void {
GameCommands.moveCard(gameId, params);
}
flipCard(gameId: number, params: Data.FlipCardParams): void {
GameCommands.flipCard(gameId, params);
}
attachCard(gameId: number, params: Data.AttachCardParams): void {
GameCommands.attachCard(gameId, params);
}
createToken(gameId: number, params: Data.CreateTokenParams): void {
GameCommands.createToken(gameId, params);
}
setCardAttr(gameId: number, params: Data.SetCardAttrParams): void {
GameCommands.setCardAttr(gameId, params);
}
setCardCounter(gameId: number, params: Data.SetCardCounterParams): void {
GameCommands.setCardCounter(gameId, params);
}
incCardCounter(gameId: number, params: Data.IncCardCounterParams): void {
GameCommands.incCardCounter(gameId, params);
}
drawCards(gameId: number, params: Data.DrawCardsParams): void {
GameCommands.drawCards(gameId, params);
}
undoDraw(gameId: number): void {
GameCommands.undoDraw(gameId);
}
createArrow(gameId: number, params: Data.CreateArrowParams): void {
GameCommands.createArrow(gameId, params);
}
deleteArrow(gameId: number, params: Data.DeleteArrowParams): void {
GameCommands.deleteArrow(gameId, params);
}
createCounter(gameId: number, params: Data.CreateCounterParams): void {
GameCommands.createCounter(gameId, params);
}
setCounter(gameId: number, params: Data.SetCounterParams): void {
GameCommands.setCounter(gameId, params);
}
incCounter(gameId: number, params: Data.IncCounterParams): void {
GameCommands.incCounter(gameId, params);
}
delCounter(gameId: number, params: Data.DelCounterParams): void {
GameCommands.delCounter(gameId, params);
}
shuffle(gameId: number, params: Data.ShuffleParams): void {
GameCommands.shuffle(gameId, params);
}
dumpZone(gameId: number, params: Data.DumpZoneParams): void {
GameCommands.dumpZone(gameId, params);
}
revealCards(gameId: number, params: Data.RevealCardsParams): void {
GameCommands.revealCards(gameId, params);
}
changeZoneProperties(gameId: number, params: Data.ChangeZonePropertiesParams): void {
GameCommands.changeZoneProperties(gameId, params);
}
deckSelect(gameId: number, params: Data.DeckSelectParams): void {
GameCommands.deckSelect(gameId, params);
}
setSideboardPlan(gameId: number, params: Data.SetSideboardPlanParams): void {
GameCommands.setSideboardPlan(gameId, params);
}
setSideboardLock(gameId: number, params: Data.SetSideboardLockParams): void {
GameCommands.setSideboardLock(gameId, params);
}
mulligan(gameId: number, params: Data.MulliganParams): void {
GameCommands.mulligan(gameId, params);
}
rollDie(gameId: number, params: Data.RollDieParams): void {
GameCommands.rollDie(gameId, params);
}
}

View file

@ -0,0 +1,53 @@
import { Data } from '@app/types';
import { ModeratorCommands } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
export class ModeratorRequestImpl implements WebsocketTypes.IModeratorRequest {
banFromServer(
minutes: number,
userName?: string,
address?: string,
reason?: string,
visibleReason?: string,
clientid?: string,
removeMessages?: number
): void {
ModeratorCommands.banFromServer(minutes, userName, address, reason, visibleReason, clientid, removeMessages);
}
forceActivateUser(usernameToActivate: string, moderatorName: string): void {
ModeratorCommands.forceActivateUser(usernameToActivate, moderatorName);
}
getAdminNotes(userName: string): void {
ModeratorCommands.getAdminNotes(userName);
}
getBanHistory(userName: string): void {
ModeratorCommands.getBanHistory(userName);
}
getWarnHistory(userName: string): void {
ModeratorCommands.getWarnHistory(userName);
}
getWarnList(modName: string, userName: string, userClientid: string): void {
ModeratorCommands.getWarnList(modName, userName, userClientid);
}
grantReplayAccess(replayId: number, moderatorName: string): void {
ModeratorCommands.grantReplayAccess(replayId, moderatorName);
}
updateAdminNotes(userName: string, notes: string): void {
ModeratorCommands.updateAdminNotes(userName, notes);
}
viewLogHistory(filters: Data.ViewLogHistoryParams): void {
ModeratorCommands.viewLogHistory(filters);
}
warnUser(userName: string, reason: string, clientid?: string, removeMessages?: number): void {
ModeratorCommands.warnUser(userName, reason, clientid, removeMessages);
}
}

View file

@ -0,0 +1,25 @@
import { RoomCommands, SessionCommands } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
import type { App } from '@app/types';
export class RoomsRequestImpl implements WebsocketTypes.IRoomsRequest {
joinRoom(roomId: number): void {
SessionCommands.joinRoom(roomId);
}
leaveRoom(roomId: number): void {
RoomCommands.leaveRoom(roomId);
}
roomSay(roomId: number, message: string): void {
RoomCommands.roomSay(roomId, message);
}
createGame(roomId: number, params: App.CreateGameParams): void {
RoomCommands.createGame(roomId, params);
}
joinGame(roomId: number, params: App.JoinGameParams): void {
RoomCommands.joinGame(roomId, params);
}
}

View file

@ -0,0 +1,52 @@
import { SessionCommands } from '@app/websocket';
import { WebsocketTypes } from '@app/websocket/types';
export class SessionRequestImpl implements WebsocketTypes.ISessionRequest {
addToBuddyList(userName: string): void {
SessionCommands.addToBuddyList(userName);
}
removeFromBuddyList(userName: string): void {
SessionCommands.removeFromBuddyList(userName);
}
addToIgnoreList(userName: string): void {
SessionCommands.addToIgnoreList(userName);
}
removeFromIgnoreList(userName: string): void {
SessionCommands.removeFromIgnoreList(userName);
}
changeAccountPassword(oldPassword: string, newPassword: string, hashedNewPassword?: string): void {
SessionCommands.accountPassword(oldPassword, newPassword, hashedNewPassword);
}
changeAccountDetails(passwordCheck: string, realName?: string, email?: string, country?: string): void {
SessionCommands.accountEdit(passwordCheck, realName, email, country);
}
changeAccountImage(image: Uint8Array): void {
SessionCommands.accountImage(image);
}
sendDirectMessage(userName: string, message: string): void {
SessionCommands.message(userName, message);
}
getUserInfo(userName: string): void {
SessionCommands.getUserInfo(userName);
}
getUserGames(userName: string): void {
SessionCommands.getGamesOfUser(userName);
}
deckDownload(deckId: number): void {
SessionCommands.deckDownload(deckId);
}
replayDownload(replayId: number): void {
SessionCommands.replayDownload(replayId);
}
}

View file

@ -0,0 +1,21 @@
import { WebsocketTypes } from '@app/websocket/types';
import { AuthenticationRequestImpl } from './AuthenticationRequestImpl';
import { SessionRequestImpl } from './SessionRequestImpl';
import { RoomsRequestImpl } from './RoomsRequestImpl';
import { GameRequestImpl } from './GameRequestImpl';
import { AdminRequestImpl } from './AdminRequestImpl';
import { ModeratorRequestImpl } from './ModeratorRequestImpl';
export { AuthenticationRequestImpl, SessionRequestImpl, RoomsRequestImpl, GameRequestImpl, AdminRequestImpl, ModeratorRequestImpl };
export function createWebClientRequest(): WebsocketTypes.IWebClientRequest {
return {
authentication: new AuthenticationRequestImpl(),
session: new SessionRequestImpl(),
rooms: new RoomsRequestImpl(),
game: new GameRequestImpl(),
admin: new AdminRequestImpl(),
moderator: new ModeratorRequestImpl(),
};
}

View file

@ -0,0 +1,20 @@
import { WebsocketTypes } from '@app/websocket/types';
import { ServerDispatch } from '@app/store';
export class AdminResponseImpl implements WebsocketTypes.IAdminResponse {
adjustMod(userName: string, shouldBeMod: boolean, shouldBeJudge: boolean): void {
ServerDispatch.adjustMod(userName, shouldBeMod, shouldBeJudge);
}
reloadConfig(): void {
ServerDispatch.reloadConfig();
}
shutdownServer(): void {
ServerDispatch.shutdownServer();
}
updateServerMessage(): void {
ServerDispatch.updateServerMessage();
}
}

View file

@ -0,0 +1,125 @@
import { Data } from '@app/types';
import { WebsocketTypes } from '@app/websocket/types';
import { GameDispatch } from '@app/store';
export class GameResponseImpl implements WebsocketTypes.IGameResponse {
clearStore(): void {
GameDispatch.clearStore();
}
gameStateChanged(gameId: number, data: Data.Event_GameStateChanged): void {
GameDispatch.gameStateChanged(gameId, data);
}
playerJoined(gameId: number, playerProperties: Data.ServerInfo_PlayerProperties): void {
GameDispatch.playerJoined(gameId, playerProperties);
}
playerLeft(gameId: number, playerId: number, reason: number): void {
GameDispatch.playerLeft(gameId, playerId, reason);
}
playerPropertiesChanged(gameId: number, playerId: number, properties: Data.ServerInfo_PlayerProperties): void {
GameDispatch.playerPropertiesChanged(gameId, playerId, properties);
}
gameClosed(gameId: number): void {
GameDispatch.gameClosed(gameId);
}
gameHostChanged(gameId: number, hostId: number): void {
GameDispatch.gameHostChanged(gameId, hostId);
}
kicked(gameId: number): void {
GameDispatch.kicked(gameId);
}
gameSay(gameId: number, playerId: number, message: string, timeReceived: number): void {
GameDispatch.gameSay(gameId, playerId, message, timeReceived);
}
cardMoved(gameId: number, playerId: number, data: Data.Event_MoveCard): void {
GameDispatch.cardMoved(gameId, playerId, data);
}
cardFlipped(gameId: number, playerId: number, data: Data.Event_FlipCard): void {
GameDispatch.cardFlipped(gameId, playerId, data);
}
cardDestroyed(gameId: number, playerId: number, data: Data.Event_DestroyCard): void {
GameDispatch.cardDestroyed(gameId, playerId, data);
}
cardAttached(gameId: number, playerId: number, data: Data.Event_AttachCard): void {
GameDispatch.cardAttached(gameId, playerId, data);
}
tokenCreated(gameId: number, playerId: number, data: Data.Event_CreateToken): void {
GameDispatch.tokenCreated(gameId, playerId, data);
}
cardAttrChanged(gameId: number, playerId: number, data: Data.Event_SetCardAttr): void {
GameDispatch.cardAttrChanged(gameId, playerId, data);
}
cardCounterChanged(gameId: number, playerId: number, data: Data.Event_SetCardCounter): void {
GameDispatch.cardCounterChanged(gameId, playerId, data);
}
arrowCreated(gameId: number, playerId: number, data: Data.Event_CreateArrow): void {
GameDispatch.arrowCreated(gameId, playerId, data);
}
arrowDeleted(gameId: number, playerId: number, data: Data.Event_DeleteArrow): void {
GameDispatch.arrowDeleted(gameId, playerId, data);
}
counterCreated(gameId: number, playerId: number, data: Data.Event_CreateCounter): void {
GameDispatch.counterCreated(gameId, playerId, data);
}
counterSet(gameId: number, playerId: number, data: Data.Event_SetCounter): void {
GameDispatch.counterSet(gameId, playerId, data);
}
counterDeleted(gameId: number, playerId: number, data: Data.Event_DelCounter): void {
GameDispatch.counterDeleted(gameId, playerId, data);
}
cardsDrawn(gameId: number, playerId: number, data: Data.Event_DrawCards): void {
GameDispatch.cardsDrawn(gameId, playerId, data);
}
cardsRevealed(gameId: number, playerId: number, data: Data.Event_RevealCards): void {
GameDispatch.cardsRevealed(gameId, playerId, data);
}
zoneShuffled(gameId: number, playerId: number, data: Data.Event_Shuffle): void {
GameDispatch.zoneShuffled(gameId, playerId, data);
}
dieRolled(gameId: number, playerId: number, data: Data.Event_RollDie): void {
GameDispatch.dieRolled(gameId, playerId, data);
}
activePlayerSet(gameId: number, activePlayerId: number): void {
GameDispatch.activePlayerSet(gameId, activePlayerId);
}
activePhaseSet(gameId: number, phase: number): void {
GameDispatch.activePhaseSet(gameId, phase);
}
turnReversed(gameId: number, reversed: boolean): void {
GameDispatch.turnReversed(gameId, reversed);
}
zoneDumped(gameId: number, playerId: number, data: Data.Event_DumpZone): void {
GameDispatch.zoneDumped(gameId, playerId, data);
}
zonePropertiesChanged(gameId: number, playerId: number, data: Data.Event_ChangeZoneProperties): void {
GameDispatch.zonePropertiesChanged(gameId, playerId, data);
}
}

View file

@ -0,0 +1,45 @@
import { Data } from '@app/types';
import { WebsocketTypes } from '@app/websocket/types';
import { ServerDispatch } from '@app/store';
export class ModeratorResponseImpl implements WebsocketTypes.IModeratorResponse {
banFromServer(userName: string): void {
ServerDispatch.banFromServer(userName);
}
banHistory(userName: string, banHistory: Data.ServerInfo_Ban[]): void {
ServerDispatch.banHistory(userName, banHistory);
}
viewLogs(logs: Data.ServerInfo_ChatMessage[]): void {
ServerDispatch.viewLogs(logs);
}
warnHistory(userName: string, warnHistory: Data.ServerInfo_Warning[]): void {
ServerDispatch.warnHistory(userName, warnHistory);
}
warnListOptions(warnList: Data.Response_WarnList[]): void {
ServerDispatch.warnListOptions(warnList);
}
warnUser(userName: string): void {
ServerDispatch.warnUser(userName);
}
grantReplayAccess(replayId: number, moderatorName: string): void {
ServerDispatch.grantReplayAccess(replayId, moderatorName);
}
forceActivateUser(usernameToActivate: string, moderatorName: string): void {
ServerDispatch.forceActivateUser(usernameToActivate, moderatorName);
}
getAdminNotes(userName: string, notes: string): void {
ServerDispatch.getAdminNotes(userName, notes);
}
updateAdminNotes(userName: string, notes: string): void {
ServerDispatch.updateAdminNotes(userName, notes);
}
}

View file

@ -0,0 +1,59 @@
import { Data } from '@app/types';
import { WebsocketTypes } from '@app/websocket/types';
import { RoomsDispatch } from '@app/store';
type Message = WebsocketTypes.WebSocketRoomResponseOverrides['Event_RoomSay'];
export class RoomResponseImpl implements WebsocketTypes.IRoomResponse<WebsocketTypes.WebSocketRoomResponseOverrides> {
clearStore(): void {
RoomsDispatch.clearStore();
}
joinRoom(roomInfo: Data.ServerInfo_Room): void {
RoomsDispatch.joinRoom(roomInfo);
}
leaveRoom(roomId: number): void {
RoomsDispatch.leaveRoom(roomId);
}
updateRooms(rooms: Data.ServerInfo_Room[]): void {
RoomsDispatch.updateRooms(rooms);
}
updateGames(roomId: number, gameList: Data.ServerInfo_Game[]): void {
RoomsDispatch.updateGames(roomId, gameList);
}
addMessage(roomId: number, message: Message): void {
RoomsDispatch.addMessage(roomId, message);
}
userJoined(roomId: number, user: Data.ServerInfo_User): void {
RoomsDispatch.userJoined(roomId, user);
}
userLeft(roomId: number, name: string): void {
RoomsDispatch.userLeft(roomId, name);
}
removeMessages(roomId: number, name: string, amount: number): void {
RoomsDispatch.removeMessages(roomId, name, amount);
}
gameCreated(roomId: number): void {
RoomsDispatch.gameCreated(roomId);
}
joinedGame(roomId: number, gameId: number): void {
RoomsDispatch.joinedGame(roomId, gameId);
}
setJoinGamePending(pending: boolean): void {
RoomsDispatch.setJoinGamePending(pending);
}
setJoinGameError(code: number, message: string): void {
RoomsDispatch.setJoinGameError(code, message);
}
}

View file

@ -0,0 +1,52 @@
vi.mock('@app/store', () => ({
GameDispatch: { clearStore: vi.fn(), gameJoined: vi.fn(), playerPropertiesChanged: vi.fn() },
RoomsDispatch: { clearStore: vi.fn() },
ServerDispatch: {
initialized: vi.fn(),
clearStore: vi.fn(),
updateStatus: vi.fn(),
updateUser: vi.fn(),
},
}));
import { GameDispatch, RoomsDispatch, ServerDispatch } from '@app/store';
import { WebsocketTypes } from '@app/websocket/types';
import { SessionResponseImpl } from './SessionResponseImpl';
describe('SessionResponseImpl.updateStatus', () => {
let impl: SessionResponseImpl;
beforeEach(() => {
vi.clearAllMocks();
impl = new SessionResponseImpl();
});
it('clears game + rooms + server stores when transitioning to DISCONNECTED', () => {
impl.updateStatus(WebsocketTypes.StatusEnum.DISCONNECTED, 'gone');
expect(GameDispatch.clearStore).toHaveBeenCalledTimes(1);
expect(RoomsDispatch.clearStore).toHaveBeenCalledTimes(1);
expect(ServerDispatch.clearStore).toHaveBeenCalledTimes(1);
expect(ServerDispatch.updateStatus).toHaveBeenCalledWith(
WebsocketTypes.StatusEnum.DISCONNECTED,
'gone',
);
});
it('does NOT clear stores on non-DISCONNECTED transitions', () => {
impl.updateStatus(WebsocketTypes.StatusEnum.CONNECTED, 'connected');
expect(GameDispatch.clearStore).not.toHaveBeenCalled();
expect(RoomsDispatch.clearStore).not.toHaveBeenCalled();
expect(ServerDispatch.clearStore).not.toHaveBeenCalled();
expect(ServerDispatch.updateStatus).toHaveBeenCalledWith(
WebsocketTypes.StatusEnum.CONNECTED,
'connected',
);
});
it('does NOT clear stores on LOGGED_IN transition', () => {
impl.updateStatus(WebsocketTypes.StatusEnum.LOGGED_IN, 'in');
expect(GameDispatch.clearStore).not.toHaveBeenCalled();
expect(RoomsDispatch.clearStore).not.toHaveBeenCalled();
expect(ServerDispatch.clearStore).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,243 @@
import { Data } from '@app/types';
import { WebsocketTypes } from '@app/websocket/types';
import { GameDispatch, RoomsDispatch, ServerDispatch } from '@app/store';
type LoginSuccess = WebsocketTypes.LoginSuccessContext;
type PendingActivation = WebsocketTypes.PendingActivationContext;
export class SessionResponseImpl implements WebsocketTypes.ISessionResponse {
initialized(): void {
ServerDispatch.initialized();
}
connectionAttempted(): void {
ServerDispatch.connectionAttempted();
}
clearStore(): void {
ServerDispatch.clearStore();
}
loginSuccessful(options: LoginSuccess): void {
ServerDispatch.loginSuccessful(options);
}
loginFailed(): void {
ServerDispatch.loginFailed();
}
connectionFailed(): void {
ServerDispatch.connectionFailed();
}
testConnectionSuccessful(supportsHashedPassword: boolean): void {
ServerDispatch.testConnectionSuccessful(supportsHashedPassword);
}
testConnectionFailed(): void {
ServerDispatch.testConnectionFailed();
}
updateBuddyList(buddyList: Data.ServerInfo_User[]): void {
ServerDispatch.updateBuddyList(buddyList);
}
addToBuddyList(user: Data.ServerInfo_User): void {
ServerDispatch.addToBuddyList(user);
}
removeFromBuddyList(userName: string): void {
ServerDispatch.removeFromBuddyList(userName);
}
updateIgnoreList(ignoreList: Data.ServerInfo_User[]): void {
ServerDispatch.updateIgnoreList(ignoreList);
}
addToIgnoreList(user: Data.ServerInfo_User): void {
ServerDispatch.addToIgnoreList(user);
}
removeFromIgnoreList(userName: string): void {
ServerDispatch.removeFromIgnoreList(userName);
}
updateInfo(name: string, version: string): void {
ServerDispatch.updateInfo(name, version);
}
updateStatus(state: WebsocketTypes.StatusEnum, description: string): void {
if (state === WebsocketTypes.StatusEnum.DISCONNECTED) {
GameDispatch.clearStore();
RoomsDispatch.clearStore();
ServerDispatch.clearStore();
}
ServerDispatch.updateStatus(state, description);
}
updateUser(user: Data.ServerInfo_User): void {
ServerDispatch.updateUser(user);
}
updateUsers(users: Data.ServerInfo_User[]): void {
ServerDispatch.updateUsers(users);
}
userJoined(user: Data.ServerInfo_User): void {
ServerDispatch.userJoined(user);
}
userLeft(userName: string): void {
ServerDispatch.userLeft(userName);
}
serverMessage(message: string): void {
ServerDispatch.serverMessage(message);
}
accountAwaitingActivation(options: PendingActivation): void {
ServerDispatch.accountAwaitingActivation(options);
}
accountActivationSuccess(): void {
ServerDispatch.accountActivationSuccess();
}
accountActivationFailed(): void {
ServerDispatch.accountActivationFailed();
}
registrationRequiresEmail(): void {
ServerDispatch.registrationRequiresEmail();
}
registrationSuccess(): void {
ServerDispatch.registrationSuccess();
}
registrationFailed(reason: string, endTime?: number): void {
ServerDispatch.registrationFailed(reason, endTime);
}
registrationEmailError(error: string): void {
ServerDispatch.registrationEmailError(error);
}
registrationPasswordError(error: string): void {
ServerDispatch.registrationPasswordError(error);
}
registrationUserNameError(error: string): void {
ServerDispatch.registrationUserNameError(error);
}
resetPasswordChallenge(): void {
ServerDispatch.resetPasswordChallenge();
}
resetPassword(): void {
ServerDispatch.resetPassword();
}
resetPasswordSuccess(): void {
ServerDispatch.resetPasswordSuccess();
}
resetPasswordFailed(): void {
ServerDispatch.resetPasswordFailed();
}
accountPasswordChange(): void {
ServerDispatch.accountPasswordChange();
}
accountEditChanged(realName?: string, email?: string, country?: string): void {
ServerDispatch.accountEditChanged({ realName, email, country });
}
accountImageChanged(avatarBmp: Uint8Array): void {
ServerDispatch.accountImageChanged({ avatarBmp });
}
getUserInfo(userInfo: Data.ServerInfo_User): void {
ServerDispatch.getUserInfo(userInfo);
}
getGamesOfUser(userName: string, response: Data.Response_GetGamesOfUser): void {
ServerDispatch.gamesOfUser(userName, response);
}
gameJoined(gameJoinedData: Data.Event_GameJoined): void {
GameDispatch.gameJoined(gameJoinedData);
}
notifyUser(notification: Data.Event_NotifyUser): void {
ServerDispatch.notifyUser(notification);
}
playerPropertiesChanged(gameId: number, playerId: number, payload: Data.Event_PlayerPropertiesChanged): void {
if (payload.playerProperties) {
GameDispatch.playerPropertiesChanged(gameId, playerId, payload.playerProperties);
}
}
serverShutdown(data: Data.Event_ServerShutdown): void {
ServerDispatch.serverShutdown(data);
}
userMessage(messageData: Data.Event_UserMessage): void {
ServerDispatch.userMessage(messageData);
}
addToList(list: string, userName: string): void {
ServerDispatch.addToList(list, userName);
}
removeFromList(list: string, userName: string): void {
ServerDispatch.removeFromList(list, userName);
}
deleteServerDeck(deckId: number): void {
ServerDispatch.deckDelete(deckId);
}
updateServerDecks(deckList: Data.Response_DeckList): void {
ServerDispatch.backendDecks(deckList);
}
uploadServerDeck(path: string, treeItem: Data.ServerInfo_DeckStorage_TreeItem): void {
ServerDispatch.deckUpload(path, treeItem);
}
createServerDeckDir(path: string, dirName: string): void {
ServerDispatch.deckNewDir(path, dirName);
}
deleteServerDeckDir(path: string): void {
ServerDispatch.deckDelDir(path);
}
replayList(matchList: Data.ServerInfo_ReplayMatch[]): void {
ServerDispatch.replayList(matchList);
}
replayAdded(matchInfo: Data.ServerInfo_ReplayMatch): void {
ServerDispatch.replayAdded(matchInfo);
}
replayModifyMatch(gameId: number, doNotHide: boolean): void {
ServerDispatch.replayModifyMatch(gameId, doNotHide);
}
replayDeleteMatch(gameId: number): void {
ServerDispatch.replayDeleteMatch(gameId);
}
downloadServerDeck(deckId: number, response: Data.Response_DeckDownload): void {
ServerDispatch.deckDownloaded(deckId, response.deck);
}
replayDownloaded(replayId: number, response: Data.Response_ReplayDownload): void {
ServerDispatch.replayDownloaded(replayId, response.replayData);
}
}

View file

@ -0,0 +1,19 @@
import { WebsocketTypes } from '@app/websocket/types';
import { SessionResponseImpl } from './SessionResponseImpl';
import { RoomResponseImpl } from './RoomResponseImpl';
import { GameResponseImpl } from './GameResponseImpl';
import { AdminResponseImpl } from './AdminResponseImpl';
import { ModeratorResponseImpl } from './ModeratorResponseImpl';
export { SessionResponseImpl, RoomResponseImpl, GameResponseImpl, AdminResponseImpl, ModeratorResponseImpl };
export function createWebClientResponse(): WebsocketTypes.IWebClientResponse {
return {
session: new SessionResponseImpl(),
room: new RoomResponseImpl(),
game: new GameResponseImpl(),
admin: new AdminResponseImpl(),
moderator: new ModeratorResponseImpl(),
};
}

25
webclient/src/colors.css Normal file
View file

@ -0,0 +1,25 @@
/**
* Shared CSS custom properties. Declared at :root so any stylesheet can
* reference them via var(--name). Mirrors the constants in
* src/types/colors.ts keep both in sync when adding new entries.
*
* Loaded once from src/index.tsx alongside the top-level stylesheet.
*/
:root {
/* Arrow / modifier colors — paired with App.ArrowColor.* in TS. */
--color-arrow-red: #e04b3b;
--color-arrow-yellow: #f0c83c;
--color-arrow-blue: #89b8e0;
--color-arrow-green: #3da26b;
--color-arrow-red-glow: rgba(224, 75, 59, 0.55);
--color-arrow-green-glow: rgba(61, 162, 107, 0.55);
/* Highlight yellow: active turn indicator, host crown, focus ring,
chat author, PhaseBar selection border. Kept as a single shade for
visual consistency across the game surface. */
--color-highlight-yellow: #f7b01c;
--color-highlight-yellow-soft: rgba(247, 176, 28, 0.4);
--color-highlight-yellow-soft-alt: rgba(247, 176, 28, 0.55);
}

View file

@ -3,6 +3,7 @@
"language": "English",
"disconnect": "Disconnect",
"label": {
"confirmEmail": "Confirm Email",
"confirmPassword": "Confirm Password",
"confirmSure": "Are you sure?",
"country": "Country",
@ -19,6 +20,7 @@
"username": "Username"
},
"validation": {
"emailsMustMatch": "Emails don't match",
"minChars": "Minimum of {count} {count, plural, one {character} other {characters}} required",
"passwordsMustMatch": "Passwords don't match",
"required": "Required"

View file

@ -1,7 +1,4 @@
// eslint-disable-next-line
import React, { useMemo, useState } from 'react';
import { CardDTO } from 'services';
import { CardDTO } from '@app/services';
import './Card.css';
@ -10,11 +7,13 @@ interface CardProps {
}
const Card = ({ card }: CardProps) => {
const src = `https://api.scryfall.com/cards/${card?.identifiers?.scryfallId}?format=image`;
if (!card) {
return null;
}
return card && (
<img className="card" src={src} alt={card?.name} />
);
}
const src = `https://api.scryfall.com/cards/${card.identifiers?.scryfallId}?format=image`;
return <img className="card" src={src} alt={card.name} />;
};
export default Card;

View file

@ -1,7 +1,4 @@
// eslint-disable-next-line
import React, { useMemo, useState } from 'react';
import { CardDTO } from 'services';
import { CardDTO } from '@app/services';
import Card from '../Card/Card';
@ -11,7 +8,7 @@ interface CardProps {
card: CardDTO;
}
// @TODO: add missing fields (loyalty, hand, etc)
// @TODO add missing fields (loyalty, hand, etc)
const CardDetails = ({ card }: CardProps) => {
return (
@ -33,7 +30,7 @@ const CardDetails = ({ card }: CardProps) => {
(!card.power && !card.toughness) ? null : (
<div className='cardDetails-attribute'>
<span className='cardDetails-attribute__label'>P/T:</span>
<span className='cardDetails-attribute__value'>{card.power || 0}/{card.toughness || 0}</span>
<span className='cardDetails-attribute__value'>{card.power ?? 0}/{card.toughness ?? 0}</span>
</div>
)
}

View file

@ -1,21 +1,28 @@
import React from 'react';
import Checkbox from '@mui/material/Checkbox';
import Checkbox, { CheckboxProps } from '@mui/material/Checkbox';
import FormControlLabel from '@mui/material/FormControlLabel';
const CheckboxField = (props) => {
const { input: { value, onChange }, label, ...args } = props;
import type { FinalFormFieldProps } from '../fieldTypes';
type CheckboxFieldProps = FinalFormFieldProps<boolean, HTMLInputElement> & {
label?: string;
} & Omit<CheckboxProps, 'checked' | 'onChange' | 'onBlur' | 'onFocus' | 'name' | 'value'>;
const CheckboxField = ({ input, meta: _meta, label, ...args }: CheckboxFieldProps) => {
const { value, onChange, onBlur, onFocus, name } = input;
// @TODO this isnt unchecking properly
return (
<FormControlLabel
className="checkbox-field"
label={label}
label={label ?? ''}
control={
<Checkbox
{ ...args }
{...args}
className="checkbox-field__box"
checked={!!value}
onChange={(e, checked) => onChange(checked)}
name={name}
checked={Boolean(value)}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus}
color="primary"
/>
}

View file

@ -1,56 +1,54 @@
import { useEffect, useState } from 'react';
import { Select, MenuItem } from '@mui/material';
import FormControl from '@mui/material/FormControl';
import InputLabel from '@mui/material/InputLabel';
import { useTranslation } from 'react-i18next';
import { useLocaleSort } from 'hooks';
import { Images } from 'images/Images';
import { countryCodes } from 'types';
import { useLocaleSort } from '@app/hooks';
import { Images } from '@app/images';
import { App } from '@app/types';
import type { FinalFormFieldProps } from '../fieldTypes';
import './CountryDropdown.css';
const CountryDropdown = ({ input: { onChange } }) => {
const [value, setValue] = useState('');
type CountryDropdownProps = FinalFormFieldProps<string, HTMLElement>;
const CountryDropdown = ({ input }: CountryDropdownProps) => {
const { t } = useTranslation();
const currentValue = (input.value as string | undefined) ?? '';
useEffect(() => onChange(value), [value]);
const translateCountry = country => t(`Common.countries.${country}`);
const sortedCountries = useLocaleSort(countryCodes, translateCountry);
const translateCountry = (country: string) => t(`Common.countries.${country}`);
const sortedCountries = useLocaleSort(App.countryCodes, translateCountry);
return (
<FormControl size='small' variant='outlined' className='CountryDropdown'>
<InputLabel id='CountryDropdown-select'>Country</InputLabel>
<FormControl size="small" variant="outlined" className="CountryDropdown">
<InputLabel id="CountryDropdown-label">Country</InputLabel>
<Select
id='CountryDropdown-select'
labelId='CountryDropdown-label'
label='Country'
margin='dense'
value={value}
fullWidth={true}
onChange={e => setValue(e.target.value as string)}
id="CountryDropdown-select"
labelId="CountryDropdown-label"
label="Country"
margin="dense"
fullWidth
{...input}
value={currentValue}
>
<MenuItem value={''} key={-1}>
<MenuItem value="" key="none">
<div className="CountryDropdown-item">
<span className="CountryDropdown-item__label">None</span>
</div>
</MenuItem>
{
sortedCountries.map((country, index:number) => (
<MenuItem value={country} key={index}>
<div className="CountryDropdown-item">
<img className="CountryDropdown-item__image" src={Images.Countries[country.toLowerCase()]} />
<span className="CountryDropdown-item__label">{translateCountry(country)}</span>
</div>
</MenuItem>
))
}
{sortedCountries.map(country => (
<MenuItem value={country} key={country}>
<div className="CountryDropdown-item">
<img className="CountryDropdown-item__image" src={Images.Countries[country.toLowerCase()]} />
<span className="CountryDropdown-item__label">{translateCountry(country)}</span>
</div>
</MenuItem>
))}
</Select>
</FormControl>
)
);
};
export default CountryDropdown;

View file

@ -0,0 +1,29 @@
.battlefield {
display: flex;
flex-direction: column;
height: 100%;
background: #0f1c38;
border: 1px solid #1a2b52;
box-sizing: border-box;
}
.battlefield__row {
flex: 1 1 0;
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
padding: 4px 8px;
box-sizing: border-box;
overflow-x: auto;
overflow-y: hidden;
}
.battlefield__row + .battlefield__row {
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.battlefield__row--drop-over {
background: rgba(247, 176, 28, 0.08);
box-shadow: inset 0 0 0 2px rgba(247, 176, 28, 0.55);
}

View file

@ -0,0 +1,196 @@
import { screen } from '@testing-library/react';
import { App } from '@app/types';
vi.mock('../../../hooks/useSettings');
import { useSettings } from '../../../hooks/useSettings';
import { makeSettings, makeSettingsHook } from '../../../hooks/__mocks__/useSettings';
import { makeStoreState, renderWithProviders } from '../../../__test-utils__';
import {
makeCard,
makeGameEntry,
makePlayerEntry,
makeZoneEntry,
} from '../../../store/game/__mocks__/fixtures';
import Battlefield from './Battlefield';
function setInvert(invert: boolean) {
vi.mocked(useSettings).mockReturnValue(
makeSettingsHook({ value: makeSettings({ invertVerticalCoordinate: invert }) }),
);
}
function stateWithBattlefield(cards: ReturnType<typeof makeCard>[]) {
const table = makeZoneEntry({
name: App.ZoneName.TABLE,
type: 1,
withCoords: true,
cardCount: cards.length,
cards,
});
const player = makePlayerEntry({
zones: { [App.ZoneName.TABLE]: table },
});
const game = makeGameEntry({
localPlayerId: 1,
players: { 1: player },
});
return makeStoreState({ games: { games: { 1: game } } });
}
describe('Battlefield', () => {
beforeEach(() => {
vi.mocked(useSettings).mockReturnValue(makeSettingsHook());
});
it('renders three rows regardless of card count', () => {
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield([]),
});
expect(screen.getByTestId('battlefield-row-0')).toBeInTheDocument();
expect(screen.getByTestId('battlefield-row-1')).toBeInTheDocument();
expect(screen.getByTestId('battlefield-row-2')).toBeInTheDocument();
});
it('places cards into rows by y coordinate', () => {
const cards = [
makeCard({ id: 1, name: 'Top', x: 0, y: 0 }),
makeCard({ id: 2, name: 'Mid', x: 0, y: 1 }),
makeCard({ id: 3, name: 'Bot', x: 0, y: 2 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
expect(screen.getByTestId('battlefield-row-0').querySelector('img')?.alt).toBe('Top');
expect(screen.getByTestId('battlefield-row-1').querySelector('img')?.alt).toBe('Mid');
expect(screen.getByTestId('battlefield-row-2').querySelector('img')?.alt).toBe('Bot');
});
it('clamps out-of-range y values into the three-row space', () => {
const cards = [
makeCard({ id: 1, name: 'TooHigh', x: 0, y: -5 }),
makeCard({ id: 2, name: 'TooLow', x: 0, y: 99 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
expect(screen.getByTestId('battlefield-row-0').querySelector('img')?.alt).toBe('TooHigh');
expect(screen.getByTestId('battlefield-row-2').querySelector('img')?.alt).toBe('TooLow');
});
it('sorts cards within a row by x coordinate', () => {
const cards = [
makeCard({ id: 1, name: 'Right', x: 10, y: 0 }),
makeCard({ id: 2, name: 'Left', x: 0, y: 0 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
const row0 = screen.getByTestId('battlefield-row-0');
const imgs = Array.from(row0.querySelectorAll('img'));
expect(imgs.map((i) => i.alt)).toEqual(['Left', 'Right']);
});
it('renders rows top-to-bottom as 0,1,2 when not mirrored', () => {
const cards = [
makeCard({ id: 1, name: 'A', x: 0, y: 0 }),
makeCard({ id: 2, name: 'B', x: 0, y: 1 }),
makeCard({ id: 3, name: 'C', x: 0, y: 2 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
const rowsInOrder = Array.from(
screen.getByTestId('battlefield').querySelectorAll('.battlefield__row'),
);
expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['0', '1', '2']);
});
it('renders rows bottom-to-top as 2,1,0 when mirrored (opponent)', () => {
const cards = [
makeCard({ id: 1, name: 'A', x: 0, y: 0 }),
makeCard({ id: 2, name: 'B', x: 0, y: 1 }),
makeCard({ id: 3, name: 'C', x: 0, y: 2 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} mirrored />, {
preloadedState: stateWithBattlefield(cards),
});
const rowsInOrder = Array.from(
screen.getByTestId('battlefield').querySelectorAll('.battlefield__row'),
);
expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['2', '1', '0']);
});
it('passes inverted=true to every CardSlot when mirrored', () => {
const cards = [makeCard({ id: 1, name: 'A', x: 0, y: 0 })];
const { container } = renderWithProviders(
<Battlefield gameId={1} playerId={1} mirrored />,
{ preloadedState: stateWithBattlefield(cards) },
);
expect(container.querySelector('.card-slot--inverted')).not.toBeNull();
});
describe('invertVerticalCoordinate user setting', () => {
it('renders rows bottom-to-top when the setting is on and not mirrored (local player)', () => {
setInvert(true);
const cards = [
makeCard({ id: 1, name: 'A', x: 0, y: 0 }),
makeCard({ id: 2, name: 'B', x: 0, y: 1 }),
makeCard({ id: 3, name: 'C', x: 0, y: 2 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} />, {
preloadedState: stateWithBattlefield(cards),
});
const rowsInOrder = Array.from(
screen.getByTestId('battlefield').querySelectorAll('.battlefield__row'),
);
expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['2', '1', '0']);
});
it('restores top-to-bottom ordering when setting is on AND mirrored (XOR cancels)', () => {
setInvert(true);
const cards = [
makeCard({ id: 1, name: 'A', x: 0, y: 0 }),
makeCard({ id: 2, name: 'B', x: 0, y: 1 }),
];
renderWithProviders(<Battlefield gameId={1} playerId={1} mirrored />, {
preloadedState: stateWithBattlefield(cards),
});
const rowsInOrder = Array.from(
screen.getByTestId('battlefield').querySelectorAll('.battlefield__row'),
);
expect(rowsInOrder.map((r) => r.getAttribute('data-row'))).toEqual(['0', '1', '2']);
});
it('passes inverted=true to CardSlots when setting is on and not mirrored', () => {
setInvert(true);
const cards = [makeCard({ id: 1, name: 'A', x: 0, y: 0 })];
const { container } = renderWithProviders(
<Battlefield gameId={1} playerId={1} />,
{ preloadedState: stateWithBattlefield(cards) },
);
expect(container.querySelector('.card-slot--inverted')).not.toBeNull();
});
it('passes inverted=false to CardSlots when setting is on AND mirrored (XOR)', () => {
setInvert(true);
const cards = [makeCard({ id: 1, name: 'A', x: 0, y: 0 })];
const { container } = renderWithProviders(
<Battlefield gameId={1} playerId={1} mirrored />,
{ preloadedState: stateWithBattlefield(cards) },
);
expect(container.querySelector('.card-slot--inverted')).toBeNull();
});
});
});

View file

@ -0,0 +1,63 @@
import { App, Data } from '@app/types';
import CardSlot from '../CardSlot/CardSlot';
import { makeCardKey } from '../CardRegistry/CardRegistryContext';
import BattlefieldRow from './BattlefieldRow';
import { useBattlefield } from './useBattlefield';
import './Battlefield.css';
export interface BattlefieldProps {
gameId: number;
playerId: number;
mirrored?: boolean;
canAct?: boolean;
arrowSourceKey?: string | null;
onCardHover?: (card: Data.ServerInfo_Card) => void;
onCardClick?: (playerId: number, zone: string, card: Data.ServerInfo_Card) => void;
onCardContextMenu?: (card: Data.ServerInfo_Card, event: React.MouseEvent) => void;
onCardDoubleClick?: (card: Data.ServerInfo_Card) => void;
}
function Battlefield({
gameId,
playerId,
mirrored = false,
canAct = false,
arrowSourceKey = null,
onCardHover,
onCardClick,
onCardContextMenu,
onCardDoubleClick,
}: BattlefieldProps) {
const { rows, rowOrder, isInverted } = useBattlefield({ gameId, playerId, mirrored });
return (
<div className="battlefield" data-testid="battlefield">
{rowOrder.map((rowIdx) => (
<BattlefieldRow key={rowIdx} playerId={playerId} row={rowIdx}>
{rows[rowIdx].map((card) => {
const key = makeCardKey(playerId, App.ZoneName.TABLE, card.id);
return (
<CardSlot
key={card.id}
card={card}
inverted={isInverted}
draggable={canAct}
ownerPlayerId={playerId}
zone={App.ZoneName.TABLE}
isArrowSource={arrowSourceKey === key}
onMouseEnter={onCardHover}
onClick={(c) => onCardClick?.(playerId, App.ZoneName.TABLE, c)}
onContextMenu={onCardContextMenu}
onDoubleClick={onCardDoubleClick}
/>
);
})}
</BattlefieldRow>
))}
</div>
);
}
export default Battlefield;

View file

@ -0,0 +1,30 @@
import { ReactNode } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { App } from '@app/types';
import { cx } from '@app/utils';
export interface BattlefieldRowProps {
playerId: number;
row: number;
children: ReactNode;
}
function BattlefieldRow({ playerId, row, children }: BattlefieldRowProps) {
const { setNodeRef, isOver } = useDroppable({
id: `battlefield-${playerId}-${row}`,
data: { targetPlayerId: playerId, targetZone: App.ZoneName.TABLE, row },
});
return (
<div
ref={setNodeRef}
className={cx('battlefield__row', { 'battlefield__row--drop-over': isOver })}
data-row={row}
data-testid={`battlefield-row-${row}`}
>
{children}
</div>
);
}
export default BattlefieldRow;

View file

@ -0,0 +1,50 @@
import { useMemo } from 'react';
import { App, Data } from '@app/types';
import { GameSelectors, useAppSelector } from '@app/store';
import { useSettings } from '@app/hooks';
export interface Battlefield {
rows: Data.ServerInfo_Card[][];
rowOrder: number[];
isInverted: boolean;
}
export interface UseBattlefieldArgs {
gameId: number;
playerId: number;
mirrored: boolean;
}
const ROW_COUNT = 3;
function rowIndexFor(card: Data.ServerInfo_Card): number {
const y = card.y ?? 0;
return Math.max(0, Math.min(ROW_COUNT - 1, y));
}
export function useBattlefield({ gameId, playerId, mirrored }: UseBattlefieldArgs): Battlefield {
const cards = useAppSelector((state) =>
GameSelectors.getCards(state, gameId, playerId, App.ZoneName.TABLE),
);
const { value: settings } = useSettings();
const invertVerticalCoordinate = settings?.invertVerticalCoordinate ?? false;
// Mirrors desktop TableZone::isInverted() — XOR of per-player mirrored and
// the global invertVerticalCoordinate preference.
const isInverted = mirrored !== invertVerticalCoordinate;
const rows = useMemo<Data.ServerInfo_Card[][]>(() => {
const bucketed: Data.ServerInfo_Card[][] = Array.from({ length: ROW_COUNT }, () => []);
for (const card of cards) {
bucketed[rowIndexFor(card)].push(card);
}
for (const row of bucketed) {
row.sort((a, b) => (a.x ?? 0) - (b.x ?? 0));
}
return bucketed;
}, [cards]);
const rowOrder = isInverted ? [2, 1, 0] : [0, 1, 2];
return { rows, rowOrder, isInverted };
}

View file

@ -0,0 +1,3 @@
.card-context-menu .MuiPaper-root {
min-width: 220px;
}

View file

@ -0,0 +1,396 @@
import { screen, fireEvent } from '@testing-library/react';
import { App, Data } from '@app/types';
import { createMockWebClient, renderWithProviders } from '../../../__test-utils__';
import { makeCard } from '../../../store/game/__mocks__/fixtures';
import CardContextMenu from './CardContextMenu';
const defaultProps = {
isOpen: true,
anchorPosition: { top: 100, left: 100 },
gameId: 1,
localPlayerId: 1,
ownerPlayerId: 1,
sourceZone: App.ZoneName.TABLE,
onClose: () => {},
onRequestSetPT: () => {},
onRequestSetAnnotation: () => {},
onRequestSetCounter: () => {},
onRequestDrawArrow: () => {},
onRequestAttach: () => {},
onRequestMoveToLibraryAt: () => {},
};
describe('CardContextMenu', () => {
it('does not render when card is null', () => {
const { container } = renderWithProviders(
<CardContextMenu {...defaultProps} card={null} />,
);
expect(container.querySelector('[data-testid="card-context-menu"]')).toBeNull();
});
it('does not render when closed', () => {
renderWithProviders(
<CardContextMenu {...defaultProps} isOpen={false} card={makeCard()} />,
);
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
});
it('renders all expected menu items', () => {
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ tapped: false, faceDown: false })} />,
);
expect(screen.getByText('Flip')).toBeInTheDocument();
expect(screen.getByText('Tap')).toBeInTheDocument();
expect(screen.getByText('Face Down')).toBeInTheDocument();
expect(screen.getByText('Doesn\'t Untap')).toBeInTheDocument();
expect(screen.getByText('Set P/T…')).toBeInTheDocument();
expect(screen.getByText('Set Annotation…')).toBeInTheDocument();
expect(screen.getByText('Send to Hand')).toBeInTheDocument();
expect(screen.getByText('Send to Graveyard')).toBeInTheDocument();
expect(screen.getByText('Send to Exile')).toBeInTheDocument();
expect(screen.getByText('Send to Library (top)')).toBeInTheDocument();
expect(screen.getByText('Send to Library (bottom)')).toBeInTheDocument();
});
it('flips the card via flipCard and closes the menu', () => {
const webClient = createMockWebClient();
const onClose = vi.fn();
const card = makeCard({ id: 10, faceDown: false });
renderWithProviders(
<CardContextMenu {...defaultProps} card={card} onClose={onClose} />,
{ webClient },
);
fireEvent.click(screen.getByText('Flip'));
expect(webClient.request.game.flipCard).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 10,
faceDown: true,
});
expect(onClose).toHaveBeenCalled();
});
it('toggles tap via setCardAttr (untapped → tapped)', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 5, tapped: false })} />,
{ webClient },
);
fireEvent.click(screen.getByText('Tap'));
expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 5,
attribute: Data.CardAttribute.AttrTapped,
attrValue: '1',
});
});
it('shows Untap label and sends "0" when the card is already tapped', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 5, tapped: true })} />,
{ webClient },
);
expect(screen.getByText('Untap')).toBeInTheDocument();
fireEvent.click(screen.getByText('Untap'));
expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 5,
attribute: Data.CardAttribute.AttrTapped,
attrValue: '0',
});
});
it('toggles Face Down and shows Face Up when already face-down', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 5, faceDown: true })} />,
{ webClient },
);
expect(screen.getByText('Face Up')).toBeInTheDocument();
fireEvent.click(screen.getByText('Face Up'));
expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 5,
attribute: Data.CardAttribute.AttrFaceDown,
attrValue: '0',
});
});
it('toggles Doesn\'t Untap and shows Allow Untap when already set', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 5, doesntUntap: true })} />,
{ webClient },
);
expect(screen.getByText('Allow Untap')).toBeInTheDocument();
fireEvent.click(screen.getByText('Allow Untap'));
expect(webClient.request.game.setCardAttr).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 5,
attribute: Data.CardAttribute.AttrDoesntUntap,
attrValue: '0',
});
});
it('requests the PT prompt via parent callback', () => {
const onRequestSetPT = vi.fn();
const onClose = vi.fn();
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard()}
onRequestSetPT={onRequestSetPT}
onClose={onClose}
/>,
);
fireEvent.click(screen.getByText('Set P/T…'));
expect(onRequestSetPT).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('requests the Annotation prompt via parent callback', () => {
const onRequestSetAnnotation = vi.fn();
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard()}
onRequestSetAnnotation={onRequestSetAnnotation}
/>,
);
fireEvent.click(screen.getByText('Set Annotation…'));
expect(onRequestSetAnnotation).toHaveBeenCalled();
});
it('moves to hand via moveCard with x=-1 (append)', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 7 })} />,
{ webClient },
);
fireEvent.click(screen.getByText('Send to Hand'));
expect(webClient.request.game.moveCard).toHaveBeenCalledWith(1, {
startPlayerId: 1,
startZone: App.ZoneName.TABLE,
cardsToMove: { card: [{ cardId: 7 }] },
targetPlayerId: 1,
targetZone: App.ZoneName.HAND,
x: -1,
y: 0,
isReversed: false,
});
});
it('hides mutator items (tap, flip, move, counters, P/T) for opponent-owned cards (desktop parity)', () => {
renderWithProviders(
<CardContextMenu
{...defaultProps}
localPlayerId={1}
ownerPlayerId={2}
card={makeCard({ id: 7 })}
/>,
);
// Mutators gone:
expect(screen.queryByText('Flip')).not.toBeInTheDocument();
expect(screen.queryByText('Tap')).not.toBeInTheDocument();
expect(screen.queryByText('Set P/T…')).not.toBeInTheDocument();
expect(screen.queryByText('Set counter…')).not.toBeInTheDocument();
expect(screen.queryByText('Send to Hand')).not.toBeInTheDocument();
expect(screen.queryByText('Attach to card…')).not.toBeInTheDocument();
// Read-only stays:
expect(screen.getByText('Draw arrow from here')).toBeInTheDocument();
});
it('routes moves through the acting (local) player when invoked on an owned card', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu
{...defaultProps}
localPlayerId={1}
ownerPlayerId={1}
card={makeCard({ id: 7 })}
/>,
{ webClient },
);
fireEvent.click(screen.getByText('Send to Hand'));
expect(webClient.request.game.moveCard).toHaveBeenCalledWith(1, expect.objectContaining({
startPlayerId: 1,
targetPlayerId: 1,
}));
});
it('moves to library top vs bottom with distinct x values', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 7 })} />,
{ webClient },
);
fireEvent.click(screen.getByText('Send to Library (top)'));
expect(webClient.request.game.moveCard).toHaveBeenLastCalledWith(1, expect.objectContaining({
targetZone: App.ZoneName.DECK,
x: 0,
}));
fireEvent.click(screen.getByText('Send to Library (bottom)'));
expect(webClient.request.game.moveCard).toHaveBeenLastCalledWith(1, expect.objectContaining({
targetZone: App.ZoneName.DECK,
x: -1,
}));
});
it('adds a counter via incCardCounter (+1 on id 0)', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 9 })} />,
{ webClient },
);
fireEvent.click(screen.getByText('Add counter'));
expect(webClient.request.game.incCardCounter).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 9,
counterId: 0,
counterDelta: 1,
});
});
it('removes a counter via incCardCounter (-1 on id 0)', () => {
const webClient = createMockWebClient();
renderWithProviders(
<CardContextMenu {...defaultProps} card={makeCard({ id: 9 })} />,
{ webClient },
);
fireEvent.click(screen.getByText('Remove counter'));
expect(webClient.request.game.incCardCounter).toHaveBeenCalledWith(1, {
zone: App.ZoneName.TABLE,
cardId: 9,
counterId: 0,
counterDelta: -1,
});
});
it('defers "Set counter…" to the parent callback', () => {
const onRequestSetCounter = vi.fn();
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard()}
onRequestSetCounter={onRequestSetCounter}
/>,
);
fireEvent.click(screen.getByText('Set counter…'));
expect(onRequestSetCounter).toHaveBeenCalled();
});
it('defers "Draw arrow from here" to the parent callback', () => {
const onRequestDrawArrow = vi.fn();
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard()}
onRequestDrawArrow={onRequestDrawArrow}
/>,
);
fireEvent.click(screen.getByText('Draw arrow from here'));
expect(onRequestDrawArrow).toHaveBeenCalled();
});
describe('Attach / Unattach', () => {
it('defers "Attach to card…" to the parent callback', () => {
const onRequestAttach = vi.fn();
const onClose = vi.fn();
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard()}
onRequestAttach={onRequestAttach}
onClose={onClose}
/>,
);
fireEvent.click(screen.getByText('Attach to card…'));
expect(onRequestAttach).toHaveBeenCalled();
expect(onClose).toHaveBeenCalled();
});
it('does not show "Unattach" when the card is not attached (attachCardId = -1)', () => {
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard({ attachCardId: -1 })}
/>,
);
expect(screen.queryByText('Unattach')).not.toBeInTheDocument();
});
it('shows "Unattach" and dispatches attachCard with only startZone+cardId (desktop parity)', () => {
const webClient = createMockWebClient();
const onClose = vi.fn();
renderWithProviders(
<CardContextMenu
{...defaultProps}
card={makeCard({ id: 11, attachCardId: 99 })}
onClose={onClose}
/>,
{ webClient },
);
fireEvent.click(screen.getByText('Unattach'));
// Target fields are intentionally absent. The server uses proto2
// presence (`has_target_player_id()`) to detect "detach"; passing
// targetPlayerId: -1 would leave presence set and the server would
// treat the message as an attach with a missing player.
expect(webClient.request.game.attachCard).toHaveBeenCalledWith(1, {
startZone: App.ZoneName.TABLE,
cardId: 11,
});
expect(onClose).toHaveBeenCalled();
});
it('hides Attach / Unattach when the source card is not on the table', () => {
renderWithProviders(
<CardContextMenu
{...defaultProps}
sourceZone={App.ZoneName.HAND}
card={makeCard({ attachCardId: 99 })}
/>,
);
expect(screen.queryByText('Attach to card…')).not.toBeInTheDocument();
expect(screen.queryByText('Unattach')).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,107 @@
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Divider from '@mui/material/Divider';
import { Data } from '@app/types';
import { useCardContextMenu } from './useCardContextMenu';
import './CardContextMenu.css';
export interface CardContextMenuProps {
isOpen: boolean;
anchorPosition: { top: number; left: number } | null;
gameId: number;
localPlayerId: number | null;
card: Data.ServerInfo_Card | null;
ownerPlayerId: number | null;
sourceZone: string | null;
onClose: () => void;
onRequestSetPT: () => void;
onRequestSetAnnotation: () => void;
onRequestSetCounter: () => void;
onRequestDrawArrow: () => void;
onRequestAttach: () => void;
onRequestMoveToLibraryAt: () => void;
}
function CardContextMenu(props: CardContextMenuProps) {
const { isOpen, anchorPosition, card, onClose } = props;
const {
ready,
isOwnedByLocal,
canAttach,
isAttached,
moveTargets,
handleFlip,
handleTapToggle,
handleFaceDownToggle,
handleDoesntUntapToggle,
handleSetPT,
handleSetAnnotation,
handleCardCounterDelta,
handleSetCardCounter,
handleDrawArrow,
handleAttach,
handleUnattach,
handleMove,
handleMoveToLibraryAt,
} = useCardContextMenu(props);
if (!ready || !card) {
return null;
}
return (
<Menu
open={isOpen}
onClose={onClose}
anchorReference="anchorPosition"
anchorPosition={anchorPosition ?? undefined}
data-testid="card-context-menu"
className="card-context-menu"
>
{isOwnedByLocal && (
<>
<MenuItem onClick={handleFlip}>Flip</MenuItem>
<MenuItem onClick={handleTapToggle}>{card.tapped ? 'Untap' : 'Tap'}</MenuItem>
<MenuItem onClick={handleFaceDownToggle}>
{card.faceDown ? 'Face Up' : 'Face Down'}
</MenuItem>
<MenuItem onClick={handleDoesntUntapToggle}>
{card.doesntUntap ? 'Allow Untap' : 'Doesn\'t Untap'}
</MenuItem>
<MenuItem onClick={handleSetPT}>Set P/T</MenuItem>
<MenuItem onClick={handleSetAnnotation}>Set Annotation</MenuItem>
<Divider />
<MenuItem onClick={() => handleCardCounterDelta(+1)}>Add counter</MenuItem>
<MenuItem onClick={() => handleCardCounterDelta(-1)}>Remove counter</MenuItem>
<MenuItem onClick={handleSetCardCounter}>Set counter</MenuItem>
<Divider />
</>
)}
<MenuItem onClick={handleDrawArrow}>Draw arrow from here</MenuItem>
{isOwnedByLocal && canAttach && (
<MenuItem onClick={handleAttach}>Attach to card</MenuItem>
)}
{isOwnedByLocal && canAttach && isAttached && (
<MenuItem onClick={handleUnattach}>Unattach</MenuItem>
)}
{isOwnedByLocal && (
<>
<Divider />
{moveTargets.map((t) => (
<MenuItem key={t.label} onClick={() => handleMove(t)}>
{t.label}
</MenuItem>
))}
<MenuItem onClick={handleMoveToLibraryAt}>
Move to library at position
</MenuItem>
</>
)}
</Menu>
);
}
export default CardContextMenu;

View file

@ -0,0 +1,239 @@
import { useWebClient } from '@app/hooks';
import { App, Data } from '@app/types';
interface MoveTarget {
label: string;
zone: string;
x: number;
y: number;
}
// Mirrors desktop's cockatrice/src/game/player/menu/move_menu.cpp:32-42 —
// six fixed targets plus one prompt ("Move to library at position…") for the
// 7-entry parity. Note that desktop's "Send to Table" label maps to our
// "Send to Battlefield" (same wire semantics: zone=table, x=0, y=0); the
// label diverges but the command is identical.
export const CARD_MOVE_TARGETS: ReadonlyArray<MoveTarget> = [
{ label: 'Send to Hand', zone: App.ZoneName.HAND, x: -1, y: 0 },
{ label: 'Send to Battlefield', zone: App.ZoneName.TABLE, x: 0, y: 0 },
{ label: 'Send to Graveyard', zone: App.ZoneName.GRAVE, x: 0, y: 0 },
{ label: 'Send to Exile', zone: App.ZoneName.EXILE, x: 0, y: 0 },
{ label: 'Send to Library (top)', zone: App.ZoneName.DECK, x: 0, y: 0 },
{ label: 'Send to Library (bottom)', zone: App.ZoneName.DECK, x: -1, y: 0 },
];
export interface CardContextMenu {
ready: boolean;
isOwnedByLocal: boolean;
canAttach: boolean;
isAttached: boolean;
moveTargets: ReadonlyArray<MoveTarget>;
handleFlip: () => void;
handleTapToggle: () => void;
handleFaceDownToggle: () => void;
handleDoesntUntapToggle: () => void;
handleSetPT: () => void;
handleSetAnnotation: () => void;
handleCardCounterDelta: (delta: number) => void;
handleSetCardCounter: () => void;
handleDrawArrow: () => void;
handleAttach: () => void;
handleUnattach: () => void;
handleMove: (target: MoveTarget) => void;
handleMoveToLibraryAt: () => void;
}
export interface UseCardContextMenuArgs {
gameId: number;
localPlayerId: number | null;
card: Data.ServerInfo_Card | null;
ownerPlayerId: number | null;
sourceZone: string | null;
onClose: () => void;
onRequestSetPT: () => void;
onRequestSetAnnotation: () => void;
onRequestSetCounter: () => void;
onRequestDrawArrow: () => void;
onRequestAttach: () => void;
onRequestMoveToLibraryAt: () => void;
}
export function useCardContextMenu({
gameId,
localPlayerId,
card,
ownerPlayerId,
sourceZone,
onClose,
onRequestSetPT,
onRequestSetAnnotation,
onRequestSetCounter,
onRequestDrawArrow,
onRequestAttach,
onRequestMoveToLibraryAt,
}: UseCardContextMenuArgs): CardContextMenu {
const webClient = useWebClient();
const ready = card != null && ownerPlayerId != null && sourceZone != null && localPlayerId != null;
// Mutating actions (tap, flip, counters, attrs, P/T, annotation, attach,
// move) require ownership of the card — matches desktop's
// `card_menu.cpp:151-161` which drops all mutators when the menu target
// isn't getLocalOrJudge()-modifiable. Read-only actions (Draw arrow)
// stay available for planning/communication.
const isOwnedByLocal = ready && ownerPlayerId === localPlayerId;
const isAttached = ready && (card!.attachCardId ?? -1) >= 0;
// Desktop's actAttach is only available from a table card; other zones
// never expose the attach arrow.
const canAttach = ready && sourceZone === App.ZoneName.TABLE;
const setAttr = (attribute: Data.CardAttribute, value: string) => {
if (!ready) {
return;
}
webClient.request.game.setCardAttr(gameId, {
zone: sourceZone!,
cardId: card!.id,
attribute,
attrValue: value,
});
};
const handleFlip = () => {
if (!ready) {
return;
}
// TODO(card-db): desktop's Player::actCardMenuFlip reads the card's stored
// P/T and forwards it so the revealed side shows the correct stats
// (cockatrice/src/game/player/player_actions.cpp:1805-1810). We can't
// do that without a card-database-by-name lookup, which isn't wired in
// the webclient yet. The server re-derives PT from the card DB for known
// names, so omitting `pt` is harmless for non-custom cards.
webClient.request.game.flipCard(gameId, {
zone: sourceZone!,
cardId: card!.id,
faceDown: !card!.faceDown,
});
onClose();
};
const handleTapToggle = () => {
if (!ready) {
return;
}
setAttr(Data.CardAttribute.AttrTapped, card!.tapped ? '0' : '1');
onClose();
};
const handleFaceDownToggle = () => {
if (!ready) {
return;
}
setAttr(Data.CardAttribute.AttrFaceDown, card!.faceDown ? '0' : '1');
onClose();
};
const handleDoesntUntapToggle = () => {
if (!ready) {
return;
}
setAttr(Data.CardAttribute.AttrDoesntUntap, card!.doesntUntap ? '0' : '1');
onClose();
};
const handleSetPT = () => {
onRequestSetPT();
onClose();
};
const handleSetAnnotation = () => {
onRequestSetAnnotation();
onClose();
};
const handleCardCounterDelta = (delta: number) => {
if (!ready) {
return;
}
webClient.request.game.incCardCounter(gameId, {
zone: sourceZone!,
cardId: card!.id,
counterId: 0,
counterDelta: delta,
});
onClose();
};
const handleSetCardCounter = () => {
onRequestSetCounter();
onClose();
};
const handleDrawArrow = () => {
onRequestDrawArrow();
onClose();
};
const handleAttach = () => {
onRequestAttach();
onClose();
};
const handleUnattach = () => {
if (!ready) {
return;
}
// Desktop's actUnattach sends only start_zone + card_id; the server uses
// proto2 presence (`has_target_player_id()`) to detect "detach". Setting
// targetPlayerId: -1 here would leave presence set and trip the attach
// code path server-side. MessageInitShape makes these fields optional,
// so omitting them produces an unset wire field.
webClient.request.game.attachCard(gameId, { startZone: sourceZone!, cardId: card!.id });
onClose();
};
const handleMove = (target: MoveTarget) => {
if (!ready) {
return;
}
// targetPlayerId is the ACTING player (local), matching desktop's
// Player::actMoveCardTo* which uses playerInfo->getId().
webClient.request.game.moveCard(gameId, {
startPlayerId: ownerPlayerId!,
startZone: sourceZone!,
cardsToMove: { card: [{ cardId: card!.id }] },
targetPlayerId: localPlayerId!,
targetZone: target.zone,
x: target.x,
y: target.y,
isReversed: false,
});
onClose();
};
const handleMoveToLibraryAt = () => {
onRequestMoveToLibraryAt();
onClose();
};
return {
ready,
isOwnedByLocal,
canAttach,
isAttached,
moveTargets: CARD_MOVE_TARGETS,
handleFlip,
handleTapToggle,
handleFaceDownToggle,
handleDoesntUntapToggle,
handleSetPT,
handleSetAnnotation,
handleCardCounterDelta,
handleSetCardCounter,
handleDrawArrow,
handleAttach,
handleUnattach,
handleMove,
handleMoveToLibraryAt,
};
}

View file

@ -0,0 +1,24 @@
.card-drag-overlay {
width: 146px;
height: 204px;
border-radius: 6px;
overflow: hidden;
opacity: 0.85;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
pointer-events: none;
}
.card-drag-overlay__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.card-drag-overlay__back {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #2a1f3d 0%, #1a1028 60%, #0d0617 100%);
border: 1px solid #3a2d50;
box-sizing: border-box;
}

View file

@ -0,0 +1,20 @@
import { render, screen } from '@testing-library/react';
import { makeCard } from '../../../store/game/__mocks__/fixtures';
import CardDragOverlay from './CardDragOverlay';
describe('CardDragOverlay', () => {
it('renders the Scryfall image for a face-up card', () => {
render(<CardDragOverlay card={makeCard({ name: 'Lightning Bolt' })} />);
const img = screen.getByAltText('Lightning Bolt') as HTMLImageElement;
expect(img.src).toContain('Lightning%20Bolt');
expect(img.src).toContain('version=small');
});
it('renders the face-down placeholder for hidden cards', () => {
render(<CardDragOverlay card={makeCard({ faceDown: true })} />);
expect(screen.getByLabelText('face-down card')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,24 @@
import { useScryfallCard } from '@app/hooks';
import type { Data } from '@app/types';
import './CardDragOverlay.css';
export interface CardDragOverlayProps {
card: Data.ServerInfo_Card;
}
function CardDragOverlay({ card }: CardDragOverlayProps) {
const { smallUrl } = useScryfallCard(card);
return (
<div className="card-drag-overlay" data-testid="card-drag-overlay">
{card.faceDown || !smallUrl ? (
<div className="card-drag-overlay__back" aria-label="face-down card" />
) : (
<img className="card-drag-overlay__image" src={smallUrl} alt={card.name} />
)}
</div>
);
}
export default CardDragOverlay;

View file

@ -0,0 +1,44 @@
.card-preview {
height: 340px;
padding: 12px;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
background: #0a1225;
border-bottom: 1px solid #1a2b52;
}
.card-preview__empty {
color: #5a6a8a;
font-size: 12px;
font-style: italic;
text-align: center;
}
.card-preview__frame {
position: relative;
width: 100%;
max-width: 280px;
aspect-ratio: 488 / 680;
border-radius: 10px;
overflow: hidden;
background: #0d1930;
}
.card-preview__image {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.card-preview__image--normal {
opacity: 0;
transition: opacity 180ms ease-out;
}
.card-preview__image--normal.card-preview__image--loaded {
opacity: 1;
}

View file

@ -0,0 +1,55 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { makeCard } from '../../../store/game/__mocks__/fixtures';
import CardPreview from './CardPreview';
describe('CardPreview', () => {
it('shows an empty hint when no card is hovered', () => {
render(<CardPreview card={null} />);
expect(screen.getByText(/hover a card/i)).toBeInTheDocument();
});
it('renders the small image immediately on hover', () => {
const card = makeCard({ name: 'Lightning Bolt' });
render(<CardPreview card={card} />);
const small = document.querySelector('.card-preview__image--small') as HTMLImageElement;
expect(small).not.toBeNull();
expect(small.src).toContain('version=small');
expect(small.src).toContain('Lightning%20Bolt');
});
it('renders a normal image that stays transparent until it loads', () => {
const card = makeCard({ name: 'Lightning Bolt' });
render(<CardPreview card={card} />);
const normal = screen.getByTestId('card-preview-normal') as HTMLImageElement;
expect(normal.src).toContain('version=normal');
expect(normal).not.toHaveClass('card-preview__image--loaded');
});
it('reveals the normal image once onLoad fires', () => {
const card = makeCard({ name: 'Lightning Bolt' });
render(<CardPreview card={card} />);
const normal = screen.getByTestId('card-preview-normal');
fireEvent.load(normal);
expect(normal).toHaveClass('card-preview__image--loaded');
});
it('resets the loaded flag when the card changes', () => {
const a = makeCard({ id: 1, name: 'A' });
const b = makeCard({ id: 2, name: 'B' });
const { rerender } = render(<CardPreview card={a} />);
fireEvent.load(screen.getByTestId('card-preview-normal'));
expect(screen.getByTestId('card-preview-normal')).toHaveClass(
'card-preview__image--loaded',
);
rerender(<CardPreview card={b} />);
expect(screen.getByTestId('card-preview-normal')).not.toHaveClass(
'card-preview__image--loaded',
);
});
});

View file

@ -0,0 +1,49 @@
import { useEffect, useState } from 'react';
import type { Data } from '@app/types';
import { useScryfallCard } from '@app/hooks';
import './CardPreview.css';
export interface CardPreviewProps {
card: Data.ServerInfo_Card | null | undefined;
}
function CardPreview({ card }: CardPreviewProps) {
const { smallUrl, normalUrl, ready } = useScryfallCard(card ?? null);
const [normalLoaded, setNormalLoaded] = useState(false);
useEffect(() => {
setNormalLoaded(false);
}, [normalUrl]);
return (
<div className="card-preview" data-testid="card-preview">
{!ready && (
<div className="card-preview__empty">Hover a card to preview</div>
)}
{ready && smallUrl && (
<div className="card-preview__frame">
<img
className="card-preview__image card-preview__image--small"
src={smallUrl}
alt={card?.name ?? ''}
/>
{normalUrl && (
<img
className={
'card-preview__image card-preview__image--normal' +
(normalLoaded ? ' card-preview__image--loaded' : '')
}
src={normalUrl}
alt={card?.name ?? ''}
onLoad={() => setNormalLoaded(true)}
data-testid="card-preview-normal"
/>
)}
</div>
)}
</div>
);
}
export default CardPreview;

View file

@ -0,0 +1,64 @@
import { createContext, useCallback, useContext } from 'react';
export type CardKey = string;
export function makeCardKey(playerId: number, zone: string, cardId: number): CardKey {
return `${playerId}-${zone}-${cardId}`;
}
export interface CardRegistry {
register(key: CardKey, el: HTMLElement): void;
unregister(key: CardKey): void;
get(key: CardKey): HTMLElement | undefined;
subscribe(listener: () => void): () => void;
}
export const CardRegistryContext = createContext<CardRegistry | null>(null);
export function useCardRegistry(): CardRegistry | null {
return useContext(CardRegistryContext);
}
export function useRegisterCardRef(key: CardKey | null) {
const registry = useCardRegistry();
return useCallback(
(el: HTMLElement | null) => {
if (!registry || key == null) {
return;
}
if (el) {
registry.register(key, el);
} else {
registry.unregister(key);
}
},
[registry, key],
);
}
export function createCardRegistry(): CardRegistry {
const map = new Map<CardKey, HTMLElement>();
const listeners = new Set<() => void>();
const notify = () => {
listeners.forEach((l) => l());
};
return {
register(key, el) {
map.set(key, el);
notify();
},
unregister(key) {
map.delete(key);
notify();
},
get(key) {
return map.get(key);
},
subscribe(l) {
listeners.add(l);
return () => {
listeners.delete(l);
};
},
};
}

View file

@ -0,0 +1,153 @@
.card-slot {
position: relative;
width: 146px;
height: 204px;
border-radius: 6px;
overflow: hidden;
user-select: none;
cursor: pointer;
transition: transform 120ms ease-out;
}
.card-slot__image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/*
* Card-back art: layered radial + diamond SVG pattern.
* Best-effort stand-in until an MTG-style asset ships under src/images/
* (tracked in gameboard-deferrables.md M1).
*
* Layers (painted bottom-up via background: comma stack):
* 1. Outer radial gradient deep purple core fading to black edges
* 2. SVG diamond-lattice pattern embossed geometry
* 3. Inner vignette subtle darkening at the border
*/
.card-slot__back {
width: 100%;
height: 100%;
box-sizing: border-box;
border: 1px solid #3a2d50;
border-radius: inherit;
position: relative;
background:
radial-gradient(circle at 50% 45%,
rgba(120, 90, 180, 0.15) 0%,
rgba(30, 18, 48, 0.1) 40%,
rgba(0, 0, 0, 0.55) 100%),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'><path d='M20 0 L40 20 L20 40 L0 20 Z' fill='none' stroke='%236a4e9c' stroke-width='0.8' stroke-opacity='0.45'/><circle cx='20' cy='20' r='3' fill='%23826fb8' fill-opacity='0.25'/></svg>"),
linear-gradient(135deg, #2a1f3d 0%, #1a1028 55%, #0d0617 100%);
background-size: 100% 100%, 40px 40px, 100% 100%;
background-position: center center, center center, 0 0;
}
.card-slot__back::before {
content: '';
position: absolute;
inset: 4px;
border: 1px solid rgba(138, 118, 196, 0.35);
border-radius: 4px;
pointer-events: none;
}
.card-slot__back::after {
content: 'MTG';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #a190d6;
font-weight: 700;
letter-spacing: 6px;
font-size: 20px;
text-shadow: 0 0 8px rgba(162, 144, 214, 0.6);
opacity: 0.7;
pointer-events: none;
}
.card-slot--tapped {
transform: rotate(90deg);
}
.card-slot--inverted {
transform: rotate(180deg);
}
.card-slot--tapped.card-slot--inverted {
transform: rotate(270deg);
}
.card-slot--attacking {
outline: 2px solid var(--color-arrow-red);
outline-offset: -2px;
}
.card-slot--dragging {
opacity: 0.35;
}
.card-slot--arrow-source {
box-shadow: 0 0 0 3px var(--color-arrow-red), 0 0 16px var(--color-arrow-red-glow);
}
.card-slot--attach-over {
box-shadow: 0 0 0 3px var(--color-arrow-green), 0 0 16px var(--color-arrow-green-glow);
}
.card-slot__pt {
position: absolute;
right: 4px;
bottom: 4px;
padding: 2px 6px;
background: rgba(0, 0, 0, 0.72);
color: #fff;
font-size: 13px;
font-weight: 700;
border-radius: 4px;
pointer-events: none;
}
.card-slot__annotation {
position: absolute;
left: 4px;
top: 4px;
padding: 2px 6px;
max-width: 80%;
background: rgba(255, 235, 140, 0.92);
color: #2c2000;
font-size: 11px;
font-weight: 600;
border-radius: 3px;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-slot__counters {
position: absolute;
left: 4px;
bottom: 4px;
display: flex;
flex-direction: column;
gap: 2px;
pointer-events: none;
}
.card-slot__counter {
min-width: 18px;
height: 18px;
padding: 0 4px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #d48a00;
color: #000;
font-size: 11px;
font-weight: 700;
border-radius: 9px;
}

View file

@ -0,0 +1,126 @@
import { ReactElement } from 'react';
import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
import { create } from '@bufbuild/protobuf';
import { DndContext } from '@dnd-kit/core';
import { Data } from '@app/types';
import { makeCard } from '../../../store/game/__mocks__/fixtures';
import CardSlot from './CardSlot';
// useDraggable requires a DndContext ancestor; keep a lightweight wrapper
// for these leaf tests rather than paying for the full renderWithProviders.
const render = (ui: ReactElement) =>
rtlRender(<DndContext>{ui}</DndContext>);
describe('CardSlot', () => {
it('renders the Scryfall image for a normal card', () => {
const card = makeCard({ name: 'Lightning Bolt', id: 1 });
render(<CardSlot card={card} />);
const img = screen.getByAltText('Lightning Bolt') as HTMLImageElement;
expect(img.src).toContain('/cards/named');
expect(img.src).toContain('Lightning%20Bolt');
expect(img.src).toContain('version=small');
});
it('uses providerId over name when present', () => {
const card = makeCard({ name: 'Anything', providerId: 'abc-123', id: 1 });
render(<CardSlot card={card} />);
const img = screen.getByAltText('Anything') as HTMLImageElement;
expect(img.src).toContain('/cards/abc-123');
});
it('renders a face-down back and suppresses image/P-T/counters when faceDown', () => {
const card = makeCard({
name: 'Hidden',
faceDown: true,
pt: '3/3',
counterList: [create(Data.ServerInfo_CardCounterSchema, { id: 1, value: 2 })],
});
render(<CardSlot card={card} />);
expect(screen.getByLabelText('face-down card')).toBeInTheDocument();
expect(screen.queryByAltText('Hidden')).not.toBeInTheDocument();
expect(screen.queryByText('3/3')).not.toBeInTheDocument();
});
it('adds the tapped modifier when card.tapped is true', () => {
const card = makeCard({ tapped: true });
render(<CardSlot card={card} />);
expect(screen.getByTestId('card-slot')).toHaveClass('card-slot--tapped');
});
it('adds the inverted modifier when prop inverted is true', () => {
const card = makeCard();
render(<CardSlot card={card} inverted />);
expect(screen.getByTestId('card-slot')).toHaveClass('card-slot--inverted');
});
it('combines tapped and inverted classes so CSS can compose rotation', () => {
const card = makeCard({ tapped: true });
render(<CardSlot card={card} inverted />);
const el = screen.getByTestId('card-slot');
expect(el).toHaveClass('card-slot--tapped');
expect(el).toHaveClass('card-slot--inverted');
});
it('renders P/T overlay when pt is set', () => {
const card = makeCard({ pt: '5/5' });
render(<CardSlot card={card} />);
expect(screen.getByText('5/5')).toBeInTheDocument();
});
it('renders annotation overlay when annotation is set', () => {
const card = makeCard({ annotation: 'note' });
render(<CardSlot card={card} />);
expect(screen.getByText('note')).toBeInTheDocument();
});
it('renders a counter badge per card counter', () => {
const card = makeCard({
counterList: [
create(Data.ServerInfo_CardCounterSchema, { id: 1, value: 3 }),
create(Data.ServerInfo_CardCounterSchema, { id: 2, value: 7 }),
],
});
render(<CardSlot card={card} />);
expect(screen.getByText('3')).toBeInTheDocument();
expect(screen.getByText('7')).toBeInTheDocument();
});
it('adds the attacking modifier when card.attacking is true', () => {
const card = makeCard({ attacking: true });
render(<CardSlot card={card} />);
expect(screen.getByTestId('card-slot')).toHaveClass('card-slot--attacking');
});
it('invokes click handlers with the card payload', () => {
const card = makeCard();
const onClick = vi.fn();
const onDoubleClick = vi.fn();
const onContextMenu = vi.fn();
const onMouseEnter = vi.fn();
render(
<CardSlot
card={card}
onClick={onClick}
onDoubleClick={onDoubleClick}
onContextMenu={onContextMenu}
onMouseEnter={onMouseEnter}
/>,
);
const el = screen.getByTestId('card-slot');
fireEvent.click(el);
fireEvent.doubleClick(el);
fireEvent.contextMenu(el);
fireEvent.mouseEnter(el);
expect(onClick).toHaveBeenCalledWith(card);
expect(onDoubleClick).toHaveBeenCalledWith(card);
expect(onContextMenu).toHaveBeenCalled();
expect(onContextMenu.mock.calls[0][0]).toBe(card);
expect(onMouseEnter).toHaveBeenCalledWith(card);
});
});

View file

@ -0,0 +1,99 @@
import { memo } from 'react';
import type { Data } from '@app/types';
import { cx } from '@app/utils';
import { useCardSlot } from './useCardSlot';
import './CardSlot.css';
export interface CardSlotProps {
card: Data.ServerInfo_Card;
inverted?: boolean;
draggable?: boolean;
isArrowSource?: boolean;
/** The player that owns this card (matches desktop's `getOwner()`). Kept
* as `ownerPlayerId`, not `sourcePlayerId`, because it reflects the card
* in the game state rather than any drag origin. */
ownerPlayerId?: number;
zone?: string;
onClick?: (card: Data.ServerInfo_Card) => void;
onDoubleClick?: (card: Data.ServerInfo_Card) => void;
onContextMenu?: (card: Data.ServerInfo_Card, event: React.MouseEvent) => void;
onMouseEnter?: (card: Data.ServerInfo_Card) => void;
}
function CardSlot({
card,
inverted = false,
draggable = false,
isArrowSource = false,
ownerPlayerId,
zone,
onClick,
onDoubleClick,
onContextMenu,
onMouseEnter,
}: CardSlotProps) {
const { smallUrl, attributes, listeners, isDragging, isOver, rootRef } = useCardSlot({
card,
draggable,
ownerPlayerId,
zone,
});
const className = cx('card-slot', {
'card-slot--tapped': card.tapped,
'card-slot--inverted': inverted,
'card-slot--face-down': card.faceDown,
'card-slot--attacking': card.attacking,
'card-slot--dragging': isDragging,
'card-slot--arrow-source': isArrowSource,
'card-slot--attach-over': isOver,
});
return (
<div
ref={rootRef}
className={className}
onClick={() => onClick?.(card)}
onDoubleClick={() => onDoubleClick?.(card)}
onContextMenu={(e) => onContextMenu?.(card, e)}
onMouseEnter={() => onMouseEnter?.(card)}
data-testid="card-slot"
data-card-id={card.id}
data-card-owner={ownerPlayerId ?? ''}
data-card-zone={zone ?? ''}
{...(draggable ? attributes : {})}
{...(draggable ? listeners : {})}
>
{card.faceDown ? (
<div className="card-slot__back" aria-label="face-down card" />
) : (
smallUrl && (
<img className="card-slot__image" src={smallUrl} alt={card.name} />
)
)}
{card.annotation && !card.faceDown && (
<div className="card-slot__annotation">{card.annotation}</div>
)}
{card.pt && !card.faceDown && (
<div className="card-slot__pt">{card.pt}</div>
)}
{card.counterList.length > 0 && !card.faceDown && (
<div className="card-slot__counters">
{card.counterList.map((c) => (
<span key={c.id} className="card-slot__counter">
{c.value}
</span>
))}
</div>
)}
</div>
);
}
export default memo(CardSlot);

View file

@ -0,0 +1,89 @@
import { useCallback, useId } from 'react';
import {
useDraggable,
useDroppable,
type DraggableAttributes,
type DraggableSyntheticListeners,
} from '@dnd-kit/core';
import { useScryfallCard } from '@app/hooks';
import { App } from '@app/types';
import type { Data } from '@app/types';
import { makeCardKey, useRegisterCardRef } from '../CardRegistry/CardRegistryContext';
export interface CardSlot {
smallUrl: string | null | undefined;
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners;
isDragging: boolean;
isOver: boolean;
rootRef: (el: HTMLElement | null) => void;
}
export interface UseCardSlotArgs {
card: Data.ServerInfo_Card;
draggable: boolean;
ownerPlayerId: number | undefined;
zone: string | undefined;
}
export function useCardSlot({ card, draggable, ownerPlayerId, zone }: UseCardSlotArgs): CardSlot {
const { smallUrl } = useScryfallCard(card);
// React-stable id salts the dnd-kit IDs so even two disabled CardSlots
// rendering the same card (during state transitions / hidden-zone leaks)
// never collide. Without the salt, pre-owner/zone render cycles shared
// `card-x-x-<id>` and dnd-kit warned.
const instanceId = useId();
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `card-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: { card, sourcePlayerId: ownerPlayerId, sourceZone: zone },
disabled: !draggable || ownerPlayerId == null || zone == null,
});
// Cards on the battlefield double as drop targets for drag-to-attach.
// Other zones don't support attach (desktop's Player::actAttach rejects
// non-table targets), so the droppable is only live for TABLE.
const droppableEnabled =
ownerPlayerId != null && zone === App.ZoneName.TABLE;
const { setNodeRef: setDropRef, isOver } = useDroppable({
id: `card-drop-${ownerPlayerId ?? instanceId}-${zone ?? 'x'}-${card.id}`,
data: {
attachTarget: true,
targetPlayerId: ownerPlayerId,
targetZone: zone,
targetCardId: card.id,
},
disabled: !droppableEnabled,
});
const registryKey =
ownerPlayerId != null && zone != null
? makeCardKey(ownerPlayerId, zone, card.id)
: null;
const registerRef = useRegisterCardRef(registryKey);
const rootRef = useCallback(
(el: HTMLElement | null) => {
registerRef(el);
if (draggable) {
setNodeRef(el);
}
if (droppableEnabled) {
setDropRef(el);
}
},
[registerRef, setNodeRef, setDropRef, draggable, droppableEnabled],
);
return {
smallUrl,
attributes,
listeners,
isDragging,
isOver,
rootRef,
};
}

Some files were not shown because too many files have changed in this diff Show more