Files
h-school/docs/protocol.md
T

498 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. `schoolWeekDays` is 57 working days counted
from Monday (five is MonFri; six adds Saturday). It is a school rule, not a catalog def.
```json
{
"maxSchools": 6,
"defaultStartDate": "2012-03-31T06:00:00Z",
"gameMinutesPerRealSecond": 5,
"schoolWeekDays": 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`, `skills` with shares, and optional `room`). `room` is the RoomDef the lesson
needs — PE uses a gym, informatics a computer lab; omit it and the class homeroom is used.
`dayFrame` is the one concrete bell schedule (`firstLesson` as `HH:mm`, lesson count and
lengths, which break is the long one). `holidays` are month-day ranges that repeat every
academic year; a range whose start is after its end wraps across 1 January.
The assignment form in a later phase reads subjects; the timetable grid reads the day frame.
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 19002999. |
| `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,
"skills": [{ "id": "Mathematics", "label": "Математика", "value": "72" }]
}
],
"staff": [
{
"id": "f3.p1",
"fullName": "Иванова Ольга Михайловна",
"female": true,
"age": 41,
"isParent": true,
"position": "Teacher",
"positionLabel": "Учитель",
"hourlyWageAsk": 50,
"monthlyPay": 5000,
"subjects": [{ "defName": "Mathematics", "label": "Математика" }]
}
],
"positions": [{ "defName": "Teacher", "label": "Учитель" }],
"subjects": [
{
"defName": "Mathematics",
"label": "Математика",
"gradeMin": 5,
"gradeMax": 11,
"hoursPerWeek": 5
}
]
}
```
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. `skills` on an
applicant are the three strongest, for the management list; the full set is on the person
card. `positions` and `subjects` are the school's catalog, so the hire and assign pickers
do not need a second request.
`GET /api/schools/{id}/people/{personId}` also opens a card for someone who is only in the
applicant pool (needs are the frozen snapshot — they are not in the World yet). Unknown
ids that are in neither place stay `404` `unknown-person`.
### `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 `0x000x7F`, server-to-client ids in `0x800xFF`, 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.