Adds wire schemas and error codes for the modifier-profile feature:
- ModifierProfileSchema mirrored in server/protocol.ts (server pins a different zod major, so the chess-side schema cannot be re-exported directly). A keyof parity check guards against drift.
- RoomCreatePayloadSchema gains an optional 'profile' field — additive, existing callers unaffected.
- modifier-profile.update (client->server) payload schema with roomCode, newProfile, and version (for optimistic-concurrency checks).
- modifier-profile.updated (server->client) broadcast payload interface, typed for future promotion to the discriminated union when the broadcast is wired through rooms.
- Four new error codes: MODIFIER_PROFILE_INVALID / NO_KING / INVULN_KING / DEADLOCK, exported as const literals alongside the enum.
- Client wire types (packages/chess/src/net/types.ts) mirror all of the above: ModifierProfileWire shape, optional 'profile' on Room{Create,Created,Joined} payloads, and the new modifier-profile.* client/server message envelopes.
- PROTOCOL.md documents the new request/response flow and extends the error-code table.
Tests: 20 new cases across ModifierProfileSchema, RoomCreate profile integration, ModifierProfileUpdatePayloadSchema, and error-code acceptance.
428 lines
14 KiB
Markdown
428 lines
14 KiB
Markdown
# Chess Server WebSocket Protocol v1
|
||
|
||
## Overview
|
||
|
||
All WebSocket messages are JSON. Every message includes:
|
||
|
||
- `v: 1` — protocol version; mismatch triggers hard disconnect
|
||
- `seq: number` — monotonic sequence number (client and server maintain separate counters; client acks server seq)
|
||
- `ts: number` — unix milliseconds timestamp of message creation
|
||
|
||
Maximum message size: 64KB. Exceeding this limit causes hard disconnect with `error` code `MSG_TOO_LARGE`.
|
||
|
||
The server is AUTHORITATIVE. Clients send INTENTS (what they want to do). The server validates, applies the intent to the engine session, and broadcasts FACTS (what changed) to all room members.
|
||
|
||
## Connection
|
||
|
||
WebSocket upgrade URL: `ws://{host}:{port}/ws`
|
||
|
||
Required query params on upgrade: none (auth happens via first message after connect).
|
||
|
||
Origin allow-list: Configurable via `ALLOWED_ORIGINS` env var (comma-separated). Requests from unlisted origins receive HTTP 403 before upgrade. Default: `http://localhost:5173` (dev).
|
||
|
||
## Message Envelope
|
||
|
||
Every message has this wrapper:
|
||
|
||
```json
|
||
{
|
||
"v": 1,
|
||
"seq": 42,
|
||
"ts": 1745000000000,
|
||
"type": "room.create",
|
||
"token": "optional-room-token",
|
||
"payload": {}
|
||
}
|
||
```
|
||
|
||
`token` is required on all messages AFTER the first `room.create` or `room.join`. The first message from a client need not include token.
|
||
|
||
## Auth & Rooms
|
||
|
||
- Room codes: 6 characters, uppercase `[A-Z0-9]`, randomly generated
|
||
- Room token: UUID v4, returned on `room.create`, required on subsequent messages
|
||
- Rooms are in-memory only (lost on server restart)
|
||
- Maximum 2 players per room
|
||
- 60-second reconnection grace window after disconnect
|
||
|
||
### Message: room.create
|
||
|
||
Direction: Client → Server
|
||
Purpose: Create a new game room. Server responds with room code and auth token.
|
||
|
||
Request payload:
|
||
|
||
```json
|
||
{
|
||
"rulesetIds": ["pawns-move-backward", "piece-hp"],
|
||
"layout": { "kind": "premade", "id": "dunsany" }
|
||
}
|
||
```
|
||
|
||
- `rulesetIds`: optional array of preset rule IDs to activate for this game
|
||
- `layout`: optional starting-layout selector. Discriminated union:
|
||
- `{ "kind": "premade", "id": "dunsany" }` — look up by registered id
|
||
(`classic`, `dunsany`, `monster`, `pawns-only`, `horde`,
|
||
`knightmate`, `chess960`, `empty`).
|
||
- `{ "kind": "fen", "fen": "rnbqkbnr/...", "name": "optional" }` — parse
|
||
the piece-placement field of a FEN string. Extra FEN fields
|
||
(side-to-move, castling, en-passant, clocks) are ignored.
|
||
- `{ "kind": "custom", "pieces": [...], "name": "optional" }` — direct
|
||
placements; each piece is `{ type, color, square, hasMoved? }`.
|
||
`square` is 0..63. Capped at 128 pieces.
|
||
- When `layout` is omitted, the server uses the Classic (FIDE) layout.
|
||
|
||
Response (Server → Client, type `room.created`):
|
||
|
||
```json
|
||
{
|
||
"v": 1, "seq": 1, "ts": 1745000000000,
|
||
"type": "room.created",
|
||
"payload": {
|
||
"code": "ABC123",
|
||
"token": "550e8400-e29b-41d4-a716-446655440000",
|
||
"color": "white",
|
||
"layout": {
|
||
"id": "dunsany",
|
||
"name": "Dunsany's Chess",
|
||
"pieces": [{ "type": "pawn", "color": "white", "square": 0 }, ...]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- `layout`: resolved starting layout echoed back. Present on all
|
||
`room.created` and `room.joined` responses from new servers; may be
|
||
absent on legacy (pre-layouts) servers.
|
||
- `profile`: resolved modifier profile echoed back. Same shape as the
|
||
request-side `profile` field. Omitted when the room was created
|
||
without a profile, or when the server is pre-modifiers.
|
||
|
||
Error cases:
|
||
|
||
- Server at capacity (too many rooms): `error` code `SERVER_FULL`
|
||
- Layout fails validation (bad king count, duplicate squares,
|
||
unknown premade id, malformed FEN): `error` code `LAYOUT_INVALID`.
|
||
Message field carries the human-readable reason.
|
||
- Modifier profile fails validation: `error` code
|
||
`MODIFIER_PROFILE_INVALID` (schema / descriptor value-schema
|
||
failure) or one of the three carved-out invariants:
|
||
`MODIFIER_PROFILE_NO_KING`, `MODIFIER_PROFILE_INVULN_KING`,
|
||
`MODIFIER_PROFILE_DEADLOCK`. The `message` field carries a
|
||
human-readable reason; clients may use the code for targeted UI
|
||
hints (e.g. "Your profile leaves white with no king — please
|
||
adjust the setup").
|
||
|
||
### Message: room.join
|
||
|
||
Direction: Client → Server
|
||
Purpose: Join an existing room as the second player.
|
||
|
||
Request payload:
|
||
|
||
```json
|
||
{
|
||
"code": "ABC123"
|
||
}
|
||
```
|
||
|
||
Response (Server → Client, type `room.joined`):
|
||
|
||
```json
|
||
{
|
||
"v": 1, "seq": 1, "ts": 1745000000000,
|
||
"type": "room.joined",
|
||
"payload": {
|
||
"code": "ABC123",
|
||
"token": "661f9500-f30c-52e5-b827-557766550111",
|
||
"color": "black",
|
||
"activeRules": ["pawns-move-backward"],
|
||
"layout": {
|
||
"id": "dunsany",
|
||
"name": "Dunsany's Chess",
|
||
"pieces": [{ "type": "pawn", "color": "white", "square": 0 }, ...]
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- `layout`: resolved layout used when the room was created. Late
|
||
joiners render the board from this. Optional for backward compat.
|
||
|
||
When second player joins, server broadcasts `game.state` to BOTH players (initial board state).
|
||
|
||
Error cases:
|
||
|
||
- Room not found: `error` code `ROOM_NOT_FOUND`
|
||
- Room full (2 players already): `error` code `ROOM_FULL`
|
||
- Wrong protocol version: disconnect + `error` code `VERSION_MISMATCH`
|
||
|
||
### Message: room.leave
|
||
|
||
Direction: Client → Server
|
||
Purpose: Voluntarily leave a room / concede.
|
||
|
||
Request payload: `{}`
|
||
|
||
Server broadcasts `game.end` with `reason: "player_left"` to remaining player.
|
||
|
||
### Message: game.move
|
||
|
||
Direction: Client → Server
|
||
Purpose: Express a move intent. Server validates and applies if legal.
|
||
|
||
Request payload:
|
||
|
||
```json
|
||
{
|
||
"from": "e2",
|
||
"to": "e4",
|
||
"promoteTo": "queen"
|
||
}
|
||
```
|
||
|
||
- `from`, `to`: algebraic square notation (a1–h8)
|
||
- `promoteTo`: optional, only relevant for pawn promotion; one of `"queen"`, `"rook"`, `"bishop"`, `"knight"`
|
||
|
||
On valid move: Server applies to engine, broadcasts `game.delta` to both players.
|
||
On invalid move: Server sends `error` (code `ILLEGAL_MOVE`) to mover only; no broadcast.
|
||
|
||
Error cases:
|
||
|
||
- Not your turn: `error` code `NOT_YOUR_TURN`
|
||
- Illegal move: `error` code `ILLEGAL_MOVE`
|
||
- Game already over: `error` code `GAME_OVER`
|
||
|
||
### Message: game.state
|
||
|
||
Direction: Server → Client
|
||
Purpose: Full board state snapshot. Sent on room join and on reconnect.
|
||
|
||
Payload:
|
||
|
||
```json
|
||
{
|
||
"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
|
||
"facts": [
|
||
{ "id": 1, "attr": "PieceType", "value": "pawn" },
|
||
{ "id": 1, "attr": "Color", "value": "white" },
|
||
{ "id": 1, "attr": "Position", "value": 12 }
|
||
],
|
||
"activeRules": ["pawns-move-backward"],
|
||
"turn": "black",
|
||
"lastSeq": 42,
|
||
"moveHistory": ["e2-e4"]
|
||
}
|
||
```
|
||
|
||
- `fen`: standard FEN string for UI rendering convenience
|
||
- `facts`: all non-derived working-memory facts
|
||
- `lastSeq`: highest server seq the client should ack from (used for reconnect delta)
|
||
|
||
### Message: game.delta
|
||
|
||
Direction: Server → Client
|
||
Purpose: Incremental fact changes after each move. Sent to BOTH players after valid move.
|
||
|
||
Payload:
|
||
|
||
```json
|
||
{
|
||
"inserted": [
|
||
{ "id": 1, "attr": "Position", "value": 28 }
|
||
],
|
||
"retracted": [
|
||
{ "id": 1, "attr": "Position", "value": 12 }
|
||
],
|
||
"moveNotation": "e2e4",
|
||
"turn": "black",
|
||
"gameOver": null
|
||
}
|
||
```
|
||
|
||
- `inserted`: facts added to working memory this tick
|
||
- `retracted`: facts removed this tick (identified by id+attr)
|
||
- `gameOver`: null if game continues; `{ winner: "white"|"black"|"draw", reason: "checkmate"|"stalemate"|"50-move"|"threefold"|"insufficient"|"player_left" }` when game ends
|
||
|
||
### Message: game.end
|
||
|
||
Direction: Server → Client
|
||
Purpose: Explicit game-over notification (also included in `game.delta` `gameOver` field, but sent separately for clarity).
|
||
|
||
Payload:
|
||
|
||
```json
|
||
{
|
||
"winner": "white",
|
||
"reason": "checkmate",
|
||
"finalFen": "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 0 3"
|
||
}
|
||
```
|
||
|
||
### Message: modifier-profile.update
|
||
|
||
Direction: Client → Server
|
||
Purpose: Replace the room's active modifier profile. Applied by the
|
||
server at the NEXT TURN BOUNDARY — never mid-move.
|
||
|
||
Request payload:
|
||
|
||
```json
|
||
{
|
||
"type": "modifier-profile.update",
|
||
"roomCode": "ABC123",
|
||
"newProfile": {
|
||
"id": "buff-rooks",
|
||
"name": "Rook Reach",
|
||
"description": "All rooks get +1 range.",
|
||
"layoutId": "classic",
|
||
"perType": [
|
||
{ "kind": "range-bonus", "pieceType": "rook", "color": "both", "value": 1 }
|
||
],
|
||
"perInstance": [],
|
||
"version": 1,
|
||
"source": "custom"
|
||
},
|
||
"version": 3
|
||
}
|
||
```
|
||
|
||
- `roomCode`: the code of the room whose profile is being swapped.
|
||
- `newProfile`: the full replacement `ModifierProfile` (same shape as
|
||
the `profile` field on `room.create`). Partial updates are not
|
||
supported — the server treats the incoming profile as authoritative.
|
||
- `version`: the PROFILE version the client last observed (starts at
|
||
`0` before any update and is incremented by the server on each
|
||
successful apply). Submitting a stale version triggers an `error`
|
||
with code `MODIFIER_PROFILE_INVALID` so concurrent edits from both
|
||
players never silently overwrite.
|
||
|
||
On successful apply, the server broadcasts `modifier-profile.updated`
|
||
to both players (see below), followed by the usual `game.state` /
|
||
`game.delta` reflecting any piece-attribute changes.
|
||
|
||
Error cases:
|
||
|
||
- Profile fails schema / value-schema validation:
|
||
`MODIFIER_PROFILE_INVALID`
|
||
- Profile would leave a side with no king: `MODIFIER_PROFILE_NO_KING`
|
||
- Profile would make a king invulnerable (damage-resistance ≥ 1.0):
|
||
`MODIFIER_PROFILE_INVULN_KING`
|
||
- Profile's combined effects make the current position a legal-move
|
||
deadlock (neither side has any move): `MODIFIER_PROFILE_DEADLOCK`
|
||
- Room not found / client not a member: `ROOM_NOT_FOUND`
|
||
|
||
### Message: modifier-profile.updated
|
||
|
||
Direction: Server → Client
|
||
Purpose: Broadcast to both players after a successful
|
||
`modifier-profile.update`. Clients apply the new profile to their
|
||
local engine at the next turn boundary.
|
||
|
||
Payload:
|
||
|
||
```json
|
||
{
|
||
"type": "modifier-profile.updated",
|
||
"profile": {
|
||
"id": "buff-rooks",
|
||
"name": "Rook Reach",
|
||
"description": "All rooks get +1 range.",
|
||
"layoutId": "classic",
|
||
"perType": [
|
||
{ "kind": "range-bonus", "pieceType": "rook", "color": "both", "value": 1 }
|
||
],
|
||
"perInstance": [],
|
||
"version": 1,
|
||
"source": "custom"
|
||
},
|
||
"version": 4,
|
||
"appliedAt": "turn-boundary"
|
||
}
|
||
```
|
||
|
||
- `profile`: the newly-active modifier profile (same shape as
|
||
`room.create.profile`).
|
||
- `version`: the monotonically-incremented profile version. Clients
|
||
echo this on subsequent `modifier-profile.update` requests.
|
||
- `appliedAt`: always `"turn-boundary"` in v1. Reserved for future
|
||
policies (e.g. `"immediate"` for settings that apply mid-move).
|
||
|
||
### Message: error
|
||
|
||
Direction: Server → Client
|
||
Purpose: Report validation failures, protocol errors, rate limit violations.
|
||
|
||
Payload:
|
||
|
||
```json
|
||
{
|
||
"code": "ILLEGAL_MOVE",
|
||
"message": "Move e2-e5 is not legal for white pawn on e2",
|
||
"fatal": false
|
||
}
|
||
```
|
||
|
||
- `fatal: true` means the connection will be closed immediately after this message (e.g., rate limit, version mismatch, bad auth)
|
||
- `fatal: false` means the connection stays open; client may retry
|
||
|
||
## Reconnection Flow
|
||
|
||
1. Client disconnects (network drop, tab sleep, etc.)
|
||
2. Server starts a 60-second grace timer for that client's slot in the room
|
||
3. During grace period: other player continues playing (server holds their moves but doesn't process if no opponent — or processes against a "ghost" state)
|
||
4. Client reconnects: sends `room.join` with same code and original token
|
||
5. Server validates token matches the disconnected slot
|
||
6. Server sends `game.state` (full snapshot) followed by all `game.delta` messages since the client's last acked `seq`
|
||
7. Client reconciles local state with the snapshot
|
||
8. After 60 seconds: server broadcasts `game.end` with `reason: "player_left"` to the remaining player; room is destroyed
|
||
|
||
Reconnect message flow:
|
||
|
||
```
|
||
Client Server
|
||
|-- room.join (code, token) -->|
|
||
|<-- game.state (full snap) ---|
|
||
|<-- game.delta (seq 43..50) --| (missed deltas since last ack)
|
||
| [game resumes] |
|
||
```
|
||
|
||
## Rate Limiting
|
||
|
||
Token-bucket algorithm per WebSocket connection:
|
||
|
||
- Capacity: 20 messages (burst)
|
||
- Refill rate: 100 messages per second
|
||
- When bucket is empty: connection is closed with `error` code `RATE_LIMIT`, `fatal: true`
|
||
|
||
Messages counted: all messages from client including heartbeats.
|
||
|
||
## Security
|
||
|
||
- **Origin**: Only connections from `ALLOWED_ORIGINS` (env var) are accepted
|
||
- **Message size**: Max 64KB; exceeded → disconnect + `error` code `MSG_TOO_LARGE`
|
||
- **JSON validation**: Every message validated against Zod schema; malformed JSON → disconnect
|
||
- **Token**: Room tokens are UUID v4; expire when room is destroyed; not reusable across rooms
|
||
- **No user auth**: v1 has no accounts; rooms are ephemeral; tokens are room-scoped only
|
||
- **No server-side persistence**: Game state lives in-memory; server restart destroys all rooms
|
||
|
||
## Error Codes
|
||
|
||
| Code | Fatal | Description |
|
||
|------|-------|-------------|
|
||
| `ILLEGAL_MOVE` | No | Move not in legal move set |
|
||
| `NOT_YOUR_TURN` | No | Move attempted when it's opponent's turn |
|
||
| `GAME_OVER` | No | Move attempted after game ended |
|
||
| `ROOM_NOT_FOUND` | No | Room code doesn't exist or expired |
|
||
| `ROOM_FULL` | No | Room already has 2 players |
|
||
| `SERVER_FULL` | No | Server at maximum room capacity |
|
||
| `VERSION_MISMATCH` | Yes | `v` field doesn't match server's protocol version |
|
||
| `RATE_LIMIT` | Yes | Message rate exceeded 100/sec |
|
||
| `MSG_TOO_LARGE` | Yes | Message exceeds 64KB |
|
||
| `BAD_TOKEN` | Yes | Token missing or invalid for room |
|
||
| `INVALID_MESSAGE` | Yes | JSON parse failure or schema validation failure |
|
||
| `LAYOUT_INVALID` | No | Starting layout failed validation |
|
||
| `MODIFIER_PROFILE_INVALID` | No | Modifier profile failed schema / descriptor validation, or stale `version` on an update |
|
||
| `MODIFIER_PROFILE_NO_KING` | No | Profile would leave a side without a king |
|
||
| `MODIFIER_PROFILE_INVULN_KING` | No | Profile would make a king invulnerable |
|
||
| `MODIFIER_PROFILE_DEADLOCK` | No | Profile would make the current position an unplayable deadlock |
|