198 lines
6.4 KiB
Markdown
198 lines
6.4 KiB
Markdown
# Wire protocol v3
|
||
|
||
The client talks to the server two ways:
|
||
|
||
- **HTTP/JSON** for the main menu — listing, creating and deleting schools. Those are
|
||
request/response by nature, so they are plain REST.
|
||
- **A binary WebSocket at `/ws/game`** for the school calendar, which changes 20 times a second.
|
||
|
||
This document covers both. One protocol message per WebSocket frame, no framing header beyond the
|
||
message id. **All multi-byte numbers are little-endian.**
|
||
|
||
Three files must stay in sync — change them in the same commit:
|
||
|
||
| Where | File |
|
||
| --- | --- |
|
||
| Server codec | [`src/HSchool.Protocol/ProtocolCodec.cs`](../src/HSchool.Protocol/ProtocolCodec.cs) |
|
||
| Client codec | [`src/HSchool.Client/src/net/protocol.ts`](../src/HSchool.Client/src/net/protocol.ts) |
|
||
| This document | `docs/protocol.md` |
|
||
|
||
Any change to a layout below bumps `ProtocolConstants.Version` / `PROTOCOL_VERSION`. The server
|
||
closes connections whose hello carries a different version with `1002 ProtocolError`.
|
||
|
||
## HTTP API
|
||
|
||
Game dates are ISO-8601 UTC instants. The in-game calendar has no time zone — UTC is only used so
|
||
the wire format is unambiguous, and the client formats it back in UTC.
|
||
|
||
### `GET /api/schools`
|
||
|
||
Everything the main menu needs in one request.
|
||
|
||
```json
|
||
{
|
||
"maxSchools": 6,
|
||
"defaultStartDate": "2012-04-03T06:00:00Z",
|
||
"gameMinutesPerRealSecond": 5,
|
||
"schools": [
|
||
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-04-03T07:35:00Z", "running": false, "speedIndex": 1 }
|
||
]
|
||
}
|
||
```
|
||
|
||
### `GET /api/schools/random-name`
|
||
|
||
`{ "name": "Лицей «Северная»" }` — a suggestion that is not already taken.
|
||
|
||
Optional `?lang=en` draws from the English word list (`Northern Academy`); any other value, or
|
||
none, stays Russian. The client sends the active UI language. Names the player types are not
|
||
translated — they are saved as written.
|
||
|
||
### `POST /api/schools`
|
||
|
||
Body: `{ "name": "Гимназия №14", "startDate": "2012-04-03T06:00:00Z" }`
|
||
|
||
| Status | Meaning |
|
||
| --- | --- |
|
||
| `201` | Created; body is the school. |
|
||
| `400` `invalid-name` | Blank, or longer than 40 characters. |
|
||
| `400` `invalid-start-date` | Outside 1900–2999. |
|
||
| `409` `school-limit-reached` | `maxSchools` schools already exist. |
|
||
|
||
Failures are RFC 7807 problem details with an extra `code` field — that is what the UI switches on.
|
||
|
||
### `DELETE /api/schools/{id}`
|
||
|
||
`204` when deleted, `404` when the id is unknown. Anyone watching that school over a WebSocket
|
||
gets a `SchoolGone` frame.
|
||
|
||
## WebSocket message ids
|
||
|
||
Client-to-server ids live in `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted
|
||
frame is obvious at a glance.
|
||
|
||
| Id | Direction | Message |
|
||
| --- | --- | --- |
|
||
| `0x01` | C → S | Hello |
|
||
| `0x02` | C → S | Ping |
|
||
| `0x03` | C → S | OpenSchool |
|
||
| `0x04` | C → S | CloseSchool |
|
||
| `0x05` | C → S | SetRunning |
|
||
| `0x06` | C → S | SetSpeed |
|
||
| `0x81` | S → C | Welcome |
|
||
| `0x82` | S → C | Pong |
|
||
| `0x83` | S → C | Clock |
|
||
| `0x84` | S → C | SchoolGone |
|
||
|
||
## Client → server
|
||
|
||
### `0x01` Hello — 2 bytes
|
||
|
||
Must be the first frame; the server drops the connection if it does not arrive within 5 seconds.
|
||
|
||
| Offset | Type | Field |
|
||
| --- | --- | --- |
|
||
| 0 | `u8` | `0x01` |
|
||
| 1 | `u8` | protocol version |
|
||
|
||
### `0x02` Ping — 9 bytes
|
||
|
||
| Offset | Type | Field |
|
||
| --- | --- | --- |
|
||
| 0 | `u8` | `0x02` |
|
||
| 1 | `i64` | client clock in milliseconds |
|
||
|
||
### `0x03` OpenSchool — 5 bytes
|
||
|
||
Starts watching a school: clock frames for it begin to arrive. It does not start the calendar —
|
||
every school runs on its own from the moment it is created.
|
||
|
||
| Offset | Type | Field |
|
||
| --- | --- | --- |
|
||
| 0 | `u8` | `0x03` |
|
||
| 1 | `i32` | school id |
|
||
|
||
### `0x04` CloseSchool — 1 byte
|
||
|
||
Back to the menu: the clock frames stop. The school keeps running — only `SetRunning` pauses it,
|
||
and that pause survives leaving and reconnecting.
|
||
|
||
### `0x05` SetRunning — 2 bytes
|
||
|
||
| Offset | Type | Field |
|
||
| --- | --- | --- |
|
||
| 0 | `u8` | `0x05` |
|
||
| 1 | `u8` | `1` running, `0` paused |
|
||
|
||
### `0x06` SetSpeed — 2 bytes
|
||
|
||
| Offset | Type | Field |
|
||
| --- | --- | --- |
|
||
| 0 | `u8` | `0x06` |
|
||
| 1 | `u8` | speed index |
|
||
|
||
Running and speed are **separate messages on purpose**. A single "set clock" message forces each
|
||
button to resend the other field from the client's own copy of the state, which is always at least
|
||
one tick stale — pressing play and then a speed button would pause the school again.
|
||
|
||
Speed indexes are `0 = ×½`, `1 = ×1`, `2 = ×2`, `3 = ×3`, `4 = ×4`; out-of-range values are
|
||
ignored rather than fatal. The base rate is `gameMinutesPerRealSecond` (5), so ×1 is five game
|
||
minutes per real second.
|
||
|
||
## Server → client
|
||
|
||
### `0x81` Welcome — 4 bytes
|
||
|
||
The first frame the client receives.
|
||
|
||
| Offset | Type | Field |
|
||
| --- | --- | --- |
|
||
| 0 | `u8` | `0x81` |
|
||
| 1 | `u8` | protocol version |
|
||
| 2 | `u8` | tick rate in Hz |
|
||
| 3 | `u8` | maximum number of schools |
|
||
|
||
### `0x82` Pong — 13 bytes
|
||
|
||
| Offset | Type | Field |
|
||
| --- | --- | --- |
|
||
| 0 | `u8` | `0x82` |
|
||
| 1 | `i64` | client clock, echoed unchanged |
|
||
| 9 | `u32` | server tick when the ping was handled |
|
||
|
||
### `0x83` Clock — 15 bytes
|
||
|
||
Sent every tick to every connection that has a school open, and only to those.
|
||
|
||
| Offset | Type | Field |
|
||
| --- | --- | --- |
|
||
| 0 | `u8` | `0x83` |
|
||
| 1 | `i32` | school id |
|
||
| 5 | `i64` | in-game date, milliseconds since the Unix epoch, read as UTC |
|
||
| 13 | `u8` | `1` running, `0` paused |
|
||
| 14 | `u8` | speed index |
|
||
|
||
### `0x84` SchoolGone — 5 bytes
|
||
|
||
The open school no longer exists — deleted from the menu in another tab, or never existed. The
|
||
client returns to the menu.
|
||
|
||
| Offset | Type | Field |
|
||
| --- | --- | --- |
|
||
| 0 | `u8` | `0x84` |
|
||
| 1 | `i32` | school id |
|
||
|
||
## Guarantees and limits
|
||
|
||
- Frames larger than 8 KiB are refused with close status `1009 MessageTooBig`.
|
||
- A malformed frame closes the connection with `1007 InvalidPayloadData`.
|
||
- Unknown message ids are ignored rather than fatal, so new ids can be added without breaking
|
||
older clients within the same protocol version.
|
||
- Clock delivery is lossy under back pressure: each connection buffers 32 frames and drops the
|
||
oldest, because a stale clock is worthless once a newer one exists.
|
||
|
||
## Not in v3 yet
|
||
|
||
Saving schools to disk (they live in server memory), authentication, and any game state beyond the
|
||
calendar — the school's ECS world is created but still empty.
|