Files
Wizard/docs/06-frontend.md
T
marvinandClaude Opus 5.5 87dfcd55b7 Add resource limits, abuse protection and overload handling
Running games always continue; new work is refused when there is no
capacity; abuse is rejected immediately.

- Docker: CPU/memory/pids caps per container (overridable in .env);
  uvicorn message size, queue and concurrency limits
- Server abuse limits: connections total/per IP (close 1013), message
  rate per connection (token bucket, close 1008 on floods), lobby
  creation rate per IP
- Load monitor: event loop lag, CPU of the own cgroup quota and
  throttling, with hysteresis (normal/busy/overloaded, severe lag
  overloads immediately)
- Admission control: create_lobby and start_game refused while
  overloaded or MAX_ACTIVE_GAMES are running; server_status message
  broadcast on changes; /health reports load but stays ok
- AI bridge: timeout sent with requests, in-flight cap, circuit breaker
- AI service: bounded workers and queue ("overloaded"), drops expired
  requests, degrades to the heuristic agent when busy or too slow
- Website: busy notices, Create/Start disabled when not accepting
  games, slow retry when the server is full
- Tests for limiters, load levels, admission control and AI service
  limits; docs updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 12:51:46 +02:00

209 lines
11 KiB
Markdown

# 6. Frontend (`wizard_website`)
React 19 + TypeScript, built with Vite, styled with CSS modules, served by
nginx. No router and no state library: the server state is the app state.
## Structure
```
src/
├── main.tsx # mounts <App/> in StrictMode
├── App.tsx # picks the screen, connection banner, toasts, URL sync
├── App.module.css # design tokens (CSS variables) + global resets
├── api/
│ ├── types.ts # TypeScript mirror of the protocol and engine JSON
│ └── useWizard.ts # THE connection hook: websocket, state, actions
├── components/ # reusable, game-agnostic-ish UI
│ ├── Button/ # variants primary | secondary | ghost | danger, sizes sm | md | lg
│ ├── PlayingCard/ # PlayingCard (face) and CardBack
│ ├── Modal/ # dialog with backdrop, Escape to close
│ └── Toasts/ # error messages bottom right
├── screens/
│ ├── HomeScreen/ # name, create lobby, join by code
│ ├── LobbyScreen/ # code, players, AI, options, start
│ └── GameScreen/ # the table and its parts (see below)
└── util/cards.ts # suit names, card labels, hand sorting, legal-action helpers
```
Every component has its own `*.module.css` next to it.
## Data flow
```mermaid
flowchart LR
WS[(WebSocket)] -- state / welcome / error --> H[useWizard]
H -- lobby, game, completedTrick, toasts --> App
App --> Home[HomeScreen]
App --> Lobby[LobbyScreen]
App --> Game[GameScreen]
Home -- actions.createLobby / joinLobby --> H
Lobby -- actions.addAI / startGame / … --> H
Game -- actions.play({type:'bid'|'play'}) --> H
H -- JSON messages --> WS
```
Components never talk to the websocket directly. They receive data as props
and call functions from `actions`.
## `useWizard` (`src/api/useWizard.ts`)
The only stateful piece of the app.
**State it exposes**
| Value | Meaning |
|-------|---------|
| `status` | `connecting` \| `open` \| `closed` (will retry) \| `replaced` (opened in another tab, no retry) \| `busy` (server full, retries slowly) |
| `serverStatus` | last `server_status` message: `{ load, accepting_games }` |
| `playerId` | from `welcome` |
| `lobby`, `game` | exactly what the last `state` message contained |
| `completedTrick` | the last `trick_completed` event, kept for 2.2 s so the trick stays visible |
| `toasts`, `dismissToast` | error messages, auto-removed after 4 s |
| `actions` | `createLobby`, `joinLobby`, `leaveLobby`, `addAI`, `removePlayer`, `setOptions`, `startGame`, `play(action)`, `returnToLobby` |
**Connection behaviour**
- URL: `VITE_WS_URL` if set at build time, otherwise `new URL('../api/ws',
location.href)` with `ws:`/`wss:`. On
`https://game.marvinkrausser.com/wizard/website/` this gives
`wss://game.marvinkrausser.com/wizard/api/ws`; in development
(`http://localhost:5173/`) it gives `ws://localhost:5173/api/ws`.
- On open it sends `hello` with the token from `sessionStorage`
(key `wizard.token`); the `welcome` reply stores the (possibly new) token.
Because `sessionStorage` is per tab, a reload resumes the same seat, while a
second tab is a different player.
- On close it reconnects with exponential backoff (1, 2, 4, 8, 10, 10 … s),
except after close code 4000.
- If the server refuses the connection because it is full (an `error` with
code `busy` before `welcome`, then close code 1013), the status becomes
`busy` and it retries slowly (15 s, 30 s, 60 s, …) so waiting clients don't
add load.
- `send()` shows a toast if the socket is not open.
## Screen selection (`App.tsx`)
| Condition | Screen |
|-----------|--------|
| no lobby (or no player id yet) | `HomeScreen` |
| lobby `waiting` or no game | `LobbyScreen` |
| otherwise | `GameScreen` |
`App` also keeps `?lobby=CODE` in the address bar while you are in a lobby
(so the URL is an invite link) and removes it when you are not. A red banner
shows the connection status when it is not `open`.
## Screens
### HomeScreen
- Name input (max 20 chars), remembered in `localStorage` (`wizard.name`).
- "Create lobby" → `actions.createLobby(name)`.
- Code input + "Join" → `actions.joinLobby(code, name)`. The code is prefilled
from `?lobby=` so invite links work.
- Buttons are disabled while disconnected or without a name.
- While `serverStatus.accepting_games` is false, "Create lobby" is disabled and
a notice explains that the server is busy (joining still works).
### LobbyScreen
- Big lobby code with "Copy code" / "Copy invite link" (clipboard API).
- Player list with badges (Host, AI, offline, "(you)") and empty "Free seat"
rows up to 6.
- Host only: "+ Add AI player", "Remove" per player, the "Hidden bids"
checkbox, and "Start game" (disabled with "Need N more players" until 3
seats are filled). Others see the options read-only and "Waiting for the
host…".
- Shows a warning if AI players are present but the AI service is offline.
- While `serverStatus.accepting_games` is false, "Start game" shows
"Server busy" (disabled) with a notice; the lobby stays open.
- Explains the fixed bidding rule (bids may not add up to the cards in hand).
### GameScreen (`screens/GameScreen/`)
```
┌──────────────────────────────────────────────────────────────┐
│ Round 3 / 20 Lobby 7C7TAJ [Scoreboard] [Leave]│ top bar
│ ┌ Round 2 complete ┐ │ RoundSummary (while bidding)
│ [PlayerSeat] [PlayerSeat] … │ opponents in play order
│ ┌────────────────────────────────────────────────────────┐ │
│ │ [trump card] [TrickArea: cards + owner names] │ │ table
│ │ Trump: Blue │ │
│ └────────────────────────────────────────────────────────┘ │
│ Your turn to bid │ status line
│ [BidPanel 0 1 2 3] │ only when you bid
│ [your PlayerSeat] [Hand: clickable cards] │
└──────────────────────────────────────────────────────────────┘
```
| Part | File | Details |
|------|------|---------|
| `GameScreen` | `GameScreen.tsx` | layout, status text, trump display, which panels/modals to show |
| `PlayerSeat` | `PlayerSeat.tsx` | name, AI/dealer(D)/offline chips, score, "tricks / bid" (green when on target, red when over), card backs for the hand size; gold glow when it is that player's turn |
| `TrickArea` | `TrickArea.tsx` | cards of the current trick with owner names; right after a trick is complete it shows the completed trick with the winner highlighted and "X wins the trick" |
| `Hand` | `Hand.tsx` | your cards sorted (wizards, trump suit, other suits, jesters); clickable only on your turn in the playing phase; illegal cards are dimmed and disabled |
| `BidPanel` | `BidPanel.tsx` | buttons 0…round number; illegal bids disabled; shows the running sum of bids and explains a forbidden value |
| `RoundSummary` | `RoundSummary.tsx` | result of the previous round (tricks/bid and score change per player) |
| `Scoreboard` | `Scoreboard.tsx` | modal "block of truth": every round with total, change and tricks/bid, plus the current round |
| `GameOver` | `GameOver.tsx` | ranking, winner, "Back to lobby" (host), "Leave", "Scoreboard" |
Derived display logic worth knowing (all in `GameScreen.tsx`):
- **Completed trick**: `TrickArea` shows `completedTrick` while the table is
empty and the 2.2 s timer has not expired.
- **Trump display**: normally `observation.trump_card`. While the last trick of
the *previous* round is still displayed (`completedTrick.round_number !==
round_number`), the trick's own `trump_card` is shown instead, so the table
never shows a trick next to the wrong trump. The trump suit is derived with
the same rule as the engine (only number cards define a suit), and the label
says "Wizard turned up — no trump", "Jester turned up — no trump" or
"Last round — no trump" when there is none.
- **Round summary**: shown while `phase === 'bidding'` and the last entry of
`round_history` is the previous round — i.e. from the start of a round until
the last bid is placed. Derived from server state, so it survives reloads.
- **Leaving** asks for confirmation (an AI takes the seat mid-game).
## Styling
- **CSS modules only.** Each component imports `styles` from its
`*.module.css`; class names are scoped automatically.
- **Design tokens** are CSS variables on the `.app` root element in
`App.module.css` (`--bg`, `--surface`, `--accent`, `--gold`, `--suit-red`, …).
All components use them, so the theme can be changed in one place.
- The only global rules (`box-sizing`, `body { margin: 0 }`) are written with
`:global(...)` inside `App.module.css`.
- Responsive breakpoint: 720 px (cards overlap in the hand, the round summary
moves into the page flow, the table stacks vertically).
- Card corners are **not** rotated, so 6 and 9 cannot be confused.
## Build and configuration
| File | Purpose |
|------|---------|
| `vite.config.ts` | `base: './'` → relative asset URLs, so the build works under any path prefix; dev server proxies `/api/*` to `WIZARD_SERVER_URL` (default `http://localhost:8000`) and strips `/api`, like Caddy does in production |
| `tsconfig.app.json` | strict TypeScript for `src/` (no unused locals/params, erasable syntax only → no enums) |
| `eslint.config.js` | typescript-eslint + react-hooks + react-refresh |
| `Dockerfile` | `node:22-alpine` runs `npm ci && npm run build` (optional build arg `VITE_WS_URL`), then `nginx:1.27-alpine` serves `dist/` |
| `nginx.conf` | listens on **8082**; `/assets/` cached forever (hashed names), everything else falls back to `index.html` with `no-cache` |
Commands (in `wizard_website/`):
```bash
npm install
npm run dev # http://localhost:5173
npm run build # type-check (tsc -b) + production build into dist/
npm run lint
```
## Keeping types in sync
`src/api/types.ts` mirrors:
- `wizard_server/app/protocol.py` (client messages),
- `Lobby.view()` / `Lobby.state_for()` in `wizard_server/app/lobby.py` (server
messages),
- the `to_dict()` methods in `wizard_game` (cards, actions, observation,
events).
When you change one of those, update `types.ts`; `npm run build` then shows
every place in the UI that needs to follow.