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>
209 lines
11 KiB
Markdown
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.
|