mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-04-27 07:48:01 -07:00
Merge 88489ea2eb into b1fe4c85d3
This commit is contained in:
commit
765d668221
662 changed files with 49538 additions and 39867 deletions
184
.github/instructions/webclient.instructions.md
vendored
Normal file
184
.github/instructions/webclient.instructions.md
vendored
Normal 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.
|
||||
2
.github/workflows/web-build.yml
vendored
2
.github/workflows/web-build.yml
vendored
|
|
@ -30,7 +30,7 @@ jobs:
|
|||
fail-fast: false
|
||||
matrix:
|
||||
node_version:
|
||||
- 16
|
||||
- 20
|
||||
- lts/*
|
||||
|
||||
steps:
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
# Future template for server admin configuration
|
||||
NODE_OPTIONS=--max-old-space-size=8192
|
||||
|
|
@ -1 +1 @@
|
|||
ESLINT_NO_DEV_ERRORS=true
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
DISABLE_ESLINT_PLUGIN=true
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
CI=true
|
||||
|
||||
|
|
|
|||
|
|
@ -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}]
|
||||
}
|
||||
}
|
||||
4
webclient/.gitignore
vendored
4
webclient/.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,73 +1,67 @@
|
|||
# Webatrice
|
||||
|
||||
The Cockatrice web client — a React/TypeScript SPA that connects to a Servatrice server over a WebSocket.
|
||||
|
||||
## Application Architecture
|
||||

|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||

|
||||
|
||||
## 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 can’t 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 aren’t 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 you’re 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 don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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 |
70
webclient/architecture/README.md
Normal file
70
webclient/architecture/README.md
Normal 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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
Scenario: user joins a room. The sequence shows the outbound command path (steps 1–6), the correlated response path matched by `cmdId` in `ProtobufService`'s pending map (steps 7–10), and an unsolicited server event dispatched by proto-extension match against the event registry in `processRoomEvent` / `processSessionEvent` / `processGameEvent` (steps 11–15).
|
||||
|
||||
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.
|
||||
123
webclient/architecture/detailed.mmd
Normal file
123
webclient/architecture/detailed.mmd
Normal 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
|
||||
BIN
webclient/architecture/detailed.png
Normal file
BIN
webclient/architecture/detailed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
41
webclient/architecture/flow.mmd
Normal file
41
webclient/architecture/flow.mmd
Normal 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.
|
||||
BIN
webclient/architecture/flow.png
Normal file
BIN
webclient/architecture/flow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
58
webclient/architecture/simple.mmd
Normal file
58
webclient/architecture/simple.mmd
Normal 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
|
||||
BIN
webclient/architecture/simple.png
Normal file
BIN
webclient/architecture/simple.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
165
webclient/buf.gen.plugin.mjs
Normal file
165
webclient/buf.gen.plugin.mjs
Normal 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
12
webclient/buf.gen.yaml
Normal 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
|
||||
71
webclient/eslint.boundaries.mjs
Normal file
71
webclient/eslint.boundaries.mjs
Normal 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' },
|
||||
},
|
||||
];
|
||||
82
webclient/eslint.config.mjs
Normal file
82
webclient/eslint.config.mjs
Normal 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
22
webclient/index.html
Normal 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>
|
||||
443
webclient/integration/src/app/game-board.spec.tsx
Normal file
443
webclient/integration/src/app/game-board.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
33
webclient/integration/src/app/helpers.tsx
Normal file
33
webclient/integration/src/app/helpers.tsx
Normal 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 };
|
||||
192
webclient/integration/src/app/login-autoconnect.spec.tsx
Normal file
192
webclient/integration/src/app/login-autoconnect.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
158
webclient/integration/src/helpers/command-capture.ts
Normal file
158
webclient/integration/src/helpers/command-capture.ts
Normal 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.`
|
||||
);
|
||||
}
|
||||
125
webclient/integration/src/helpers/protobuf-builders.ts
Normal file
125
webclient/integration/src/helpers/protobuf-builders.ts
Normal 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);
|
||||
}
|
||||
205
webclient/integration/src/helpers/setup.ts
Normal file
205
webclient/integration/src/helpers/setup.ts
Normal 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();
|
||||
});
|
||||
91
webclient/integration/src/services/dexie/hosts.spec.ts
Normal file
91
webclient/integration/src/services/dexie/hosts.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
12
webclient/integration/src/services/dexie/resetDexie.ts
Normal file
12
webclient/integration/src/services/dexie/resetDexie.ts
Normal 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(),
|
||||
]);
|
||||
}
|
||||
69
webclient/integration/src/services/dexie/settings.spec.ts
Normal file
69
webclient/integration/src/services/dexie/settings.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
94
webclient/integration/src/websocket/admin.spec.ts
Normal file
94
webclient/integration/src/websocket/admin.spec.ts
Normal 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,
|
||||
})));
|
||||
});
|
||||
});
|
||||
177
webclient/integration/src/websocket/authentication.spec.ts
Normal file
177
webclient/integration/src/websocket/authentication.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
142
webclient/integration/src/websocket/connection.spec.ts
Normal file
142
webclient/integration/src/websocket/connection.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
186
webclient/integration/src/websocket/deck.spec.ts
Normal file
186
webclient/integration/src/websocket/deck.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
435
webclient/integration/src/websocket/game.spec.ts
Normal file
435
webclient/integration/src/websocket/game.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
66
webclient/integration/src/websocket/keep-alive.spec.ts
Normal file
66
webclient/integration/src/websocket/keep-alive.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
211
webclient/integration/src/websocket/moderator.spec.ts
Normal file
211
webclient/integration/src/websocket/moderator.spec.ts
Normal 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,
|
||||
})));
|
||||
});
|
||||
});
|
||||
86
webclient/integration/src/websocket/password-reset.spec.ts
Normal file
86
webclient/integration/src/websocket/password-reset.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
233
webclient/integration/src/websocket/rooms.spec.ts
Normal file
233
webclient/integration/src/websocket/rooms.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
106
webclient/integration/src/websocket/server-events.spec.ts
Normal file
106
webclient/integration/src/websocket/server-events.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
120
webclient/integration/src/websocket/users.spec.ts
Normal file
120
webclient/integration/src/websocket/users.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
7
webclient/integration/tsconfig.json
Normal file
7
webclient/integration/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
36807
webclient/package-lock.json
generated
36807
webclient/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"short_name": "Webatrice",
|
||||
"name": "Webatrice",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
|
|
|
|||
22
webclient/src/__test-utils__/globalGuards.ts
Normal file
22
webclient/src/__test-utils__/globalGuards.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
10
webclient/src/__test-utils__/index.ts
Normal file
10
webclient/src/__test-utils__/index.ts
Normal 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';
|
||||
53
webclient/src/__test-utils__/makeHookWrapper.tsx
Normal file
53
webclient/src/__test-utils__/makeHookWrapper.tsx
Normal 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 };
|
||||
}
|
||||
90
webclient/src/__test-utils__/mockWebClient.ts
Normal file
90
webclient/src/__test-utils__/mockWebClient.ts
Normal 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;
|
||||
}
|
||||
132
webclient/src/__test-utils__/renderWithProviders.tsx
Normal file
132
webclient/src/__test-utils__/renderWithProviders.tsx
Normal 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 }),
|
||||
};
|
||||
}
|
||||
153
webclient/src/__test-utils__/storeFixtures.ts
Normal file
153
webclient/src/__test-utils__/storeFixtures.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
20
webclient/src/api/request/AdminRequestImpl.ts
Normal file
20
webclient/src/api/request/AdminRequestImpl.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
61
webclient/src/api/request/AuthenticationRequestImpl.ts
Normal file
61
webclient/src/api/request/AuthenticationRequestImpl.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
142
webclient/src/api/request/GameRequestImpl.ts
Normal file
142
webclient/src/api/request/GameRequestImpl.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
53
webclient/src/api/request/ModeratorRequestImpl.ts
Normal file
53
webclient/src/api/request/ModeratorRequestImpl.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
25
webclient/src/api/request/RoomsRequestImpl.ts
Normal file
25
webclient/src/api/request/RoomsRequestImpl.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
52
webclient/src/api/request/SessionRequestImpl.ts
Normal file
52
webclient/src/api/request/SessionRequestImpl.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
webclient/src/api/request/index.ts
Normal file
21
webclient/src/api/request/index.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
20
webclient/src/api/response/AdminResponseImpl.ts
Normal file
20
webclient/src/api/response/AdminResponseImpl.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
125
webclient/src/api/response/GameResponseImpl.ts
Normal file
125
webclient/src/api/response/GameResponseImpl.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
45
webclient/src/api/response/ModeratorResponseImpl.ts
Normal file
45
webclient/src/api/response/ModeratorResponseImpl.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
59
webclient/src/api/response/RoomResponseImpl.ts
Normal file
59
webclient/src/api/response/RoomResponseImpl.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
52
webclient/src/api/response/SessionResponseImpl.spec.ts
Normal file
52
webclient/src/api/response/SessionResponseImpl.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
243
webclient/src/api/response/SessionResponseImpl.ts
Normal file
243
webclient/src/api/response/SessionResponseImpl.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
19
webclient/src/api/response/index.ts
Normal file
19
webclient/src/api/response/index.ts
Normal 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
25
webclient/src/colors.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
29
webclient/src/components/Game/Battlefield/Battlefield.css
Normal file
29
webclient/src/components/Game/Battlefield/Battlefield.css
Normal 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);
|
||||
}
|
||||
196
webclient/src/components/Game/Battlefield/Battlefield.spec.tsx
Normal file
196
webclient/src/components/Game/Battlefield/Battlefield.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
63
webclient/src/components/Game/Battlefield/Battlefield.tsx
Normal file
63
webclient/src/components/Game/Battlefield/Battlefield.tsx
Normal 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;
|
||||
30
webclient/src/components/Game/Battlefield/BattlefieldRow.tsx
Normal file
30
webclient/src/components/Game/Battlefield/BattlefieldRow.tsx
Normal 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;
|
||||
50
webclient/src/components/Game/Battlefield/useBattlefield.ts
Normal file
50
webclient/src/components/Game/Battlefield/useBattlefield.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
.card-context-menu .MuiPaper-root {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
44
webclient/src/components/Game/CardPreview/CardPreview.css
Normal file
44
webclient/src/components/Game/CardPreview/CardPreview.css
Normal 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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
49
webclient/src/components/Game/CardPreview/CardPreview.tsx
Normal file
49
webclient/src/components/Game/CardPreview/CardPreview.tsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
153
webclient/src/components/Game/CardSlot/CardSlot.css
Normal file
153
webclient/src/components/Game/CardSlot/CardSlot.css
Normal 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;
|
||||
}
|
||||
126
webclient/src/components/Game/CardSlot/CardSlot.spec.tsx
Normal file
126
webclient/src/components/Game/CardSlot/CardSlot.spec.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
99
webclient/src/components/Game/CardSlot/CardSlot.tsx
Normal file
99
webclient/src/components/Game/CardSlot/CardSlot.tsx
Normal 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);
|
||||
89
webclient/src/components/Game/CardSlot/useCardSlot.ts
Normal file
89
webclient/src/components/Game/CardSlot/useCardSlot.ts
Normal 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
Loading…
Add table
Add a link
Reference in a new issue