471 lines
17 KiB
Markdown
471 lines
17 KiB
Markdown
# Wire protocol v5
|
||
|
||
The client talks to the server two ways:
|
||
|
||
- **HTTP/JSON** for the main menu and the in-school people browser — listing, creating and
|
||
deleting schools, listing mods, loading a catalog for the create editor, and reading a school's
|
||
roster (filtered list + one-person card). Those are request/response by nature, so they are
|
||
plain REST.
|
||
- **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz) and the one-shot map
|
||
snapshot sent when a school is opened.
|
||
|
||
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-03-31T06:00:00Z",
|
||
"gameMinutesPerRealSecond": 5,
|
||
"schools": [
|
||
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07: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.
|
||
|
||
### `GET /api/mods`
|
||
|
||
Folders under the server's `mods/` directory. `core` is always first and `required: true`; other
|
||
packs can be switched off in the create dialog.
|
||
|
||
```json
|
||
{ "mods": [{ "id": "core", "required": true }] }
|
||
```
|
||
|
||
### `GET /api/catalog?lang=ru|en&mods=addon1,addon2`
|
||
|
||
Placeable (non-abstract) types plus labels in `lang`, and the last-wins `maps/default.jsonc` for
|
||
`core` plus the listed extras. The server always prepends `core`. `mods` is a comma-separated
|
||
list of extra pack ids; omit it for vanilla. Unknown extras return `400` `unknown-mod`.
|
||
|
||
Room defs that are homerooms carry `homeroom`, `seatThing` and `defaultSeats` instead of a
|
||
slot table. A map classroom stores `seats` — how many of that thing occupy the room. Capacity is
|
||
still `ThingDef.pupilSlots × seats`. Named slots remain on rooms whose furnishing actually
|
||
varies (cafeteria, medical office, principal's office, library). The create editor shows a
|
||
single seats field on a homeroom and the old slot rows on everything else.
|
||
|
||
Things carry `pupilSlots` — how many pupils that thing hosts. The snapshot's per-node pupil-slot
|
||
total is computed on the server.
|
||
|
||
`nameSets` is the list of placeable name packs (`defName` + label). The create dialog picks one;
|
||
it is independent of the UI language.
|
||
|
||
`subjects` is the list of placeable subjects (`defName`, label, `gradeMin`/`gradeMax`,
|
||
`hoursPerWeek`, and `skills` with shares). The assignment form in a later phase reads it; the
|
||
create editor does not.
|
||
|
||
`lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is
|
||
Russian.
|
||
|
||
### `POST /api/schools`
|
||
|
||
Body:
|
||
|
||
```json
|
||
{
|
||
"name": "Гимназия №14",
|
||
"startDate": "2012-03-31T06:00:00Z",
|
||
"modIds": [],
|
||
"map": null,
|
||
"nameSetId": "Slavic"
|
||
}
|
||
```
|
||
|
||
`modIds` are extras; the server always prepends `core`. Omit `map` (or send `null`) to use that
|
||
pack set's default layout. A supplied map is validated as a connected yard-and-rooms graph.
|
||
`nameSetId` is a `NameSetDef`; omit it to use the first placeable set in the catalog (vanilla:
|
||
`Slavic`). Unknown ids return `400` `unknown-name-set`.
|
||
|
||
| Status | Meaning |
|
||
| --- | --- |
|
||
| `201` | Created; body is the school. |
|
||
| `400` `invalid-name` | Blank, or longer than 40 characters. |
|
||
| `400` `invalid-start-date` | Outside 1900–2999. |
|
||
| `400` `invalid-map` | Missing yard, no rooms, unknown def, or a disconnected graph. |
|
||
| `400` `unknown-mod` | An extra pack id is missing under `mods/`. |
|
||
| `400` `invalid-catalog` | The selected packs could not be loaded. |
|
||
| `400` `unknown-name-set` | `nameSetId` is not a placeable `NameSetDef` in those packs. |
|
||
| `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.
|
||
|
||
### `GET /api/schools/{id}/people`
|
||
|
||
The in-school people list. Reads the **published roster snapshot** and that school's last clock;
|
||
it does not post to the worker. Unknown `{id}` is `404` `unknown-school`. Bad query parameters
|
||
are `400` `invalid-query`. `lang` is `ru` or `en`, same as the catalog — anything other than
|
||
`en` is Russian.
|
||
|
||
| Query | Meaning |
|
||
| --- | --- |
|
||
| `role` | `student`, `staff` or `parent`. A staff parent matches `parent`. |
|
||
| `year` | Class parallel (`5` for fifth year). |
|
||
| `letter` | Class letter as stored on the roster (`А`, not a room number). |
|
||
| `position` | Staff `PositionDef` name (`Teacher`). |
|
||
| `sex` | `male` or `female`. |
|
||
| `ageMin` / `ageMax` | Inclusive age in full years at the school's current game time. |
|
||
| `sort` | `surname` (default), `age`, `year`, `position`. |
|
||
| `dir` | `asc` (default) or `desc`. |
|
||
| `page` | 1-based. Default `1`. `0` is invalid. A page past the end is empty and still reports `total`. |
|
||
| `pageSize` | Default `50`, max `100`. |
|
||
| `lang` | Label language for positions and filter options. |
|
||
|
||
```json
|
||
{
|
||
"total": 512,
|
||
"page": 1,
|
||
"pageSize": 50,
|
||
"people": [
|
||
{
|
||
"id": "f0.c0",
|
||
"fullName": "Иванова Мария Петровна",
|
||
"surname": "Иванова",
|
||
"given": "Мария",
|
||
"patronymic": "Петровна",
|
||
"female": true,
|
||
"age": 12,
|
||
"roles": ["student"],
|
||
"classYear": 5,
|
||
"classLetter": "А",
|
||
"position": null,
|
||
"positionLabel": null
|
||
}
|
||
],
|
||
"filters": {
|
||
"years": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
|
||
"letters": ["А"],
|
||
"positions": [{ "defName": "Teacher", "label": "Учитель" }]
|
||
}
|
||
}
|
||
```
|
||
|
||
The list row is identity only — skills, traits and needs stay on the card. Age is computed from
|
||
`birthDate` and the published game time, not from the World.
|
||
|
||
Applicants live in `saves/{id}.people.json` next to the roster and are **not** in this list.
|
||
They are not school staff until hired. A parent who is also looking for work keeps the same id
|
||
in both places.
|
||
|
||
### `GET /api/schools/{id}/people/{personId}`
|
||
|
||
One person's card. Goes through the school's mailbox because need values live on entities and
|
||
tick with the clock. Unknown school is `404` `unknown-school`; unknown person is `404`
|
||
`unknown-person`. `?lang=ru|en` labels body, skills, traits, needs and the position.
|
||
|
||
Family links are other members of the same family: parents and siblings for a child, children
|
||
and partners for a parent. The client opens another card by id; this response does not nest
|
||
cards.
|
||
|
||
```json
|
||
{
|
||
"id": "f0.c0",
|
||
"fullName": "Иванова Мария Петровна",
|
||
"female": true,
|
||
"age": 12,
|
||
"birthDate": "2000-03-14T00:00:00Z",
|
||
"roles": ["student"],
|
||
"classYear": 5,
|
||
"classLetter": "А",
|
||
"body": [{ "id": "Height", "label": "Рост", "value": "164" }],
|
||
"skills": [{ "id": "Math", "label": "Математика", "value": "62" }],
|
||
"traits": [{ "defName": "Diligent", "label": "Усидчивый" }],
|
||
"needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }],
|
||
"family": {
|
||
"parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }],
|
||
"children": [],
|
||
"siblings": [{ "id": "f0.c1", "fullName": "Иванов Кирилл Петрович", "female": false }],
|
||
"partners": []
|
||
}
|
||
}
|
||
```
|
||
|
||
### `GET /api/schools/{id}/staffing`
|
||
|
||
Money, uncovered subjects, the applicant pool and current staff. Reads the **published**
|
||
roster, applicant snapshot and catalog — it does not post to the worker. Unknown `{id}` is
|
||
`404` `unknown-school`. `?lang=ru|en` labels subjects and positions.
|
||
|
||
`allocated` is `Simulation:MonthlyPayrollCap`. `payroll` is the sum of each staff member's
|
||
monthly base (`hourlyWageAsk × baseWeeklyHours × weeksPerMonth`) plus
|
||
`extraSubjectSurcharge` of that base for every subject after the first. The cap is checked
|
||
when hiring or assigning, not at month end; money itself does not move.
|
||
|
||
```json
|
||
{
|
||
"allocated": 10000,
|
||
"payroll": 5000,
|
||
"remaining": 5000,
|
||
"uncovered": [
|
||
{
|
||
"defName": "Mathematics",
|
||
"label": "Математика",
|
||
"gradeMin": 5,
|
||
"gradeMax": 11,
|
||
"hoursPerWeek": 5
|
||
}
|
||
],
|
||
"applicants": [
|
||
{
|
||
"id": "a0.p0",
|
||
"fullName": "Соколов Иван Петрович",
|
||
"female": false,
|
||
"age": 34,
|
||
"isParent": false,
|
||
"hourlyWageAsk": 50,
|
||
"monthlyBase": 4000
|
||
}
|
||
],
|
||
"staff": [
|
||
{
|
||
"id": "f3.p1",
|
||
"fullName": "Иванова Ольга Михайловна",
|
||
"female": true,
|
||
"age": 41,
|
||
"isParent": true,
|
||
"position": "Teacher",
|
||
"positionLabel": "Учитель",
|
||
"hourlyWageAsk": 50,
|
||
"monthlyPay": 5000,
|
||
"subjects": [{ "defName": "Mathematics", "label": "Математика" }]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same
|
||
id on the roster; hiring them sets `isStaff` on that person and does not create a second
|
||
entity. Generated candidates (`aN.p0`) join the roster only when hired.
|
||
|
||
### `POST /api/schools/{id}/staff/hire`
|
||
|
||
Body: `{ "personId": "a0.p0", "position": "Teacher" }`. Goes through the school's mailbox.
|
||
On success returns the same payload as `GET .../staffing`. `Teacher` needs no room opening;
|
||
other positions fill the first free `RoomDef.positions` slot of that kind.
|
||
|
||
| Status | `code` | When |
|
||
| --- | --- | --- |
|
||
| `404` | `unknown-school` | No school with that id. |
|
||
| `404` | `unknown-applicant` | `personId` is not in the pool. |
|
||
| `409` | `already-hired` | That person is already staff. |
|
||
| `400` | `unknown-position` | Not a concrete `PositionDef`. |
|
||
| `409` | `no-opening` | Every opening of that position is filled. |
|
||
| `409` | `payroll-exceeded` | Hire would take `payroll` past `allocated`. |
|
||
|
||
`payroll-exceeded` includes `allocated`, `payroll` (current), `remaining` and `attempted`
|
||
(what payroll would become). Same RFC 7807 `code` field as the other errors.
|
||
|
||
### `POST /api/schools/{id}/staff/{personId}/subjects`
|
||
|
||
Body: `{ "subject": "Mathematics" }`. Teachers only. Same success payload as GET staffing.
|
||
|
||
| Status | `code` | When |
|
||
| --- | --- | --- |
|
||
| `400` | `not-staff` | Person is not staff. |
|
||
| `400` | `not-teacher` | Position is not `Teacher`. |
|
||
| `400` | `unknown-subject` | Not a concrete `SubjectDef`. |
|
||
| `409` | `already-assigned` | Already on this person. |
|
||
| `409` | `payroll-exceeded` | Extra subject would exceed the cap. |
|
||
|
||
### `DELETE /api/schools/{id}/staff/{personId}/subjects/{subject}`
|
||
|
||
Removes one assignment. Payroll drops when the subject was not the only one. Unknown
|
||
assignment is `404` `unknown-assignment`.
|
||
|
||
## 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 |
|
||
| `0x85` | S → C | MapSnapshot |
|
||
|
||
## Client → server
|
||
|
||
### `0x01` Hello — 3 bytes
|
||
|
||
Must be the first frame; the server drops the connection if it does not arrive within 5 seconds.
|
||
The locale byte is the same language the catalog HTTP API takes as `?lang=`.
|
||
|
||
| Offset | Type | Field |
|
||
| --- | --- | --- |
|
||
| 0 | `u8` | `0x01` |
|
||
| 1 | `u8` | protocol version |
|
||
| 2 | `u8` | locale: `0` Russian, `1` English; any other value is treated as Russian |
|
||
|
||
### `0x02` Ping — 9 bytes
|
||
|
||
| Offset | Type | Field |
|
||
| --- | --- | --- |
|
||
| 0 | `u8` | `0x02` |
|
||
| 1 | `i64` | client clock in milliseconds |
|
||
|
||
### `0x03` OpenSchool — 5 bytes
|
||
|
||
Starts watching a school: a map snapshot in the Hello locale arrives once, then clock frames.
|
||
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 |
|
||
|
||
### `0x85` MapSnapshot — variable
|
||
|
||
Sent once when a school is opened (and again on reconnect OpenSchool). Not every tick, not on
|
||
tree clicks. Labels are in the Hello locale. People and in-place activities are omitted — the
|
||
client keeps those sections empty.
|
||
|
||
Strings are `u16` byte length + UTF-8. Empty string is a zero length.
|
||
|
||
| Offset | Type | Field |
|
||
| --- | --- | --- |
|
||
| 0 | `u8` | `0x85` |
|
||
| 1 | `i32` | school id |
|
||
| 5 | `u16` | node count |
|
||
| 7… | | nodes |
|
||
|
||
Each node:
|
||
|
||
| Type | Field |
|
||
| --- | --- |
|
||
| `u8` | kind: `0` territory, `1` building, `2` floor, `3` room |
|
||
| string | instance id |
|
||
| string | parent id (empty for the yard) |
|
||
| string | display name |
|
||
| `u16` | pupil slots — how many pupils can take a lesson here. Summed from things on the server. |
|
||
| `u8` | item count, then that many records of: string name + `u8` count |
|
||
| `u8` | position count, then that many strings |
|
||
|
||
Item `count` is how many of that thing stand in the room (`Парта ×16` is one record, not sixteen). The client must not recompute pupil slots from items.
|
||
|
||
## Guarantees and limits
|
||
|
||
- **Inbound** frames larger than 8 KiB are refused with close status `1009 MessageTooBig`. That
|
||
limit is about what the server reads; it does not bound what the server sends. A `MapSnapshot`
|
||
of a large school legitimately exceeds it, and the server sizes that frame from the message.
|
||
- 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 clock frames and drops
|
||
the oldest, because a stale clock is worthless once a newer one exists.
|
||
- The map snapshot uses a separate reliable queue so ticks cannot crowd it out.
|
||
|
||
## Not in v5 yet
|
||
|
||
Authentication, Sit orders, an event log, and `OpenLocation` on the server — the tree is filtered
|
||
on the client from the snapshot. The school's ECS world is created but still empty.
|