Files
h-school/docs/protocol.md
T
Leonid Pershin 3c54f981b7
ci / server (push) Failing after 3m44s
ci / client (push) Successful in 15s
Update wire protocol to version 7 and enhance presence management features
- Bumped the wire protocol version to 7, reflecting significant changes in the communication structure.
- Introduced a new presence message type for real-time occupancy updates, including node activity and individual presence states.
- Updated the API to include a directory endpoint for fetching short id→name mappings, improving client-side name resolution.
- Revised the map snapshot structure to be static, with people and current lessons now handled through the presence stream.
- Enhanced client-side handling of presence updates, including UI adjustments to display live occupancy and activity.
- Updated documentation to reflect the new protocol features and changes in presence management.
- Added tests to validate the new presence functionalities and ensure robust handling of real-time data.
2026-08-19 17:00:53 +03:00

637 lines
24 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 v7
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, reading a school's
roster (filtered list + one-person card), a short id→name directory, staffing, and the timetable. Those are request/response
by nature, so they are plain REST.
- **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz), a static map snapshot
sent once when a school is opened, and a presence stream (~2 Hz) of who is where.
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}/directory`
Short id→name directory for the presence stream. Reads the **published roster snapshot**; it
does not post to the worker. Unknown `{id}` is `404` `unknown-school`. `?lang=ru|en` is accepted
for symmetry with the other people endpoints — names are stored as written and not translated.
The client fetches this once on OpenSchool and again when a presence frame carries an unknown
id. Names do not ride the WebSocket.
```json
{
"people": [
{ "id": "f0.c0", "fullName": "Иванова Мария Петровна" }
]
}
```
Applicants are not in this list.
### `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. `classId` is the homeroom class for a pupil and `null` otherwise — the personal timetable
grid fetches `GET .../timetable?classId=` with it.
```json
{
"id": "f0.c0",
"fullName": "Иванова Мария Петровна",
"female": true,
"age": 12,
"birthDate": "2000-03-14T00:00:00Z",
"roles": ["student"],
"classYear": 5,
"classLetter": "А",
"classId": "class-classroom-105",
"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`. A staff member is paid
`hourlyWageAsk × weeklyHours × weeksPerMonth`, never less than one full rate
(`baseWeeklyHours`), and `payroll` is the sum over staff. The cap is checked when hiring or
assigning, not at month end; money itself does not move.
`weeklyHours` is not stored: it is the curriculum. For every subject assigned to a person,
`hoursPerWeek` of that subject across the classes that study it, divided between everyone
teaching it. So a second teacher of a subject halves what the first one carries — and costs.
A subject appears in `uncovered` when nobody teaches it **or** when the people who do cannot
between them carry its hours (`maxWeeklyHours` each). Both mean the same thing to a player:
those lessons will not happen.
```json
{
"allocated": 100000,
"payroll": 5000,
"remaining": 95000,
"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,
"weeklyHours": 35,
"monthlyPay": 7000,
"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`.
### `GET /api/schools/{id}/timetable`
The published lesson table and uncovered hours. Optional `?classId=` or `?personId=` filter
the lessons. `?lang=ru|en` labels subjects and rooms. Reads the snapshot — it does not post to the
worker. Unknown `{id}` is `404` `unknown-school`. `classes` and `rooms` are the full school lists
so a picker does not need a second request.
`day` is 0 = Monday. `period` is the 1-based lesson number from the day frame.
```json
{
"weekDays": 5,
"lessonCount": 7,
"lessons": [
{
"classId": "c5A",
"classYear": 5,
"classLetter": "A",
"subject": "Mathematics",
"subjectLabel": "Математика",
"teacherId": "f3.p1",
"teacherName": "Иванова Ольга Михайловна",
"roomId": "classroom-204",
"roomLabel": "Класс 204",
"day": 1,
"period": 3,
"locked": false
}
],
"uncovered": [
{
"classId": "c5A",
"classYear": 5,
"classLetter": "A",
"subject": "Informatics",
"subjectLabel": "Информатика",
"hours": 1
}
],
"classes": [{ "id": "c5A", "year": 5, "letter": "A" }],
"rooms": [{ "id": "classroom-204", "label": "Класс 204" }]
}
```
### `POST /api/schools/{id}/timetable/pin`
Body: `{ "classId", "subject", "roomId", "day", "period" }`. Pins a locked lesson there and
rebuilds the rest around it. Same success payload as GET timetable.
| Status | `code` | When |
| --- | --- | --- |
| `400` | `unknown-class` / `unknown-subject` / `unknown-room` | Not in this school. |
| `409` | `no-teacher` | Nobody is assigned that subject. |
| `409` | `pin-rejected` | The slot or room violates the four constraints. |
### `DELETE /api/schools/{id}/timetable/pin`
Query: `classId`, `subject`, `day`, `period`. Drops that lock and rebuilds. Unknown lock is
`404` `unknown-lesson`.
## 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 |
| `0x07` | C → S | SkipEmpty |
| `0x81` | S → C | Welcome |
| `0x82` | S → C | Pong |
| `0x83` | S → C | Clock |
| `0x84` | S → C | SchoolGone |
| `0x85` | S → C | MapSnapshot |
| `0x86` | S → C | Presence |
## 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
and presence 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.
### `0x07` SkipEmpty — 1 byte
Jump empty nights, weekends and holidays. The server re-checks both conditions (campus empty
**and** outside the day-frame work window) — a frame from the browser is untrusted. Ignored
when the skip is not legal; the calendar does not move.
Running, speed and skip are **separate messages on purpose**. A button that also resent a
neighbouring field would clobber it with a stale client copy.
## 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 — 24 bytes
Sent every tick to every connection that has a school open, and only to those.
`skipAllowed` is the server's verdict; the client must not recompute it.
`skipTargetUnixMs` is 0 when skip is refused.
| 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 |
| 15 | `u8` | `1` skip allowed, `0` refused |
| 16 | `i64` | skip target, milliseconds since the Unix epoch, UTC; `0` if refused |
### `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 when a school is opened, and on reconnect OpenSchool. Structure only — people and the
current lesson ride the presence stream. Labels are in the Hello locale.
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.
### `0x86` Presence — variable
Live occupancy of an open school, about twice a second, and also once on OpenSchool and after
a successful empty-time skip. Covers the whole map; the client filters to the selected tree
node. Off-campus people are omitted — a missing id means they are away. Walking people occupy
their **current** node. Names are resolved over HTTP, not on this frame.
Nodes listed are occupied **or** currently taught (count may be 0). Sorted by id. Labels are
in the Hello locale, encoded per client.
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x86` |
| 1 | `i32` | school id |
| 5 | `u16` | node count |
| 7… | | nodes, then `u16` person count, then people |
Each node:
| Type | Field |
| --- | --- |
| string | instance id |
| `u16` | headcount in this node |
| `u8` | `1` if a lesson is in this room right now, then subject label + class label strings; `0` if free |
Each person:
| Type | Field |
| --- | --- |
| string | person id |
| string | node id they occupy |
| `u8` | `1` here, `2` walking |
## 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`
or `Presence` frame of a large school legitimately exceeds it, and the server sizes those frames
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 and presence use a separate reliable queue so ticks cannot crowd them out.
## Not in v7 yet
Authentication, Sit orders, an event log, walk animation, and `OpenLocation` on the server —
the tree and the location panel are filtered on the client from the snapshot plus presence.