Files
h-school/docs/protocol.md
T
Leonid PershinandCursor 8e7ab46e79 Sample outdoor weather from the climate preset so people can freeze and the clock can show it.
Protocol v8 adds tenths of a °C and precipitation to the clock frame; warmth drains from insulation versus place temperature.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 03:49:40 +03:00

735 lines
29 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 v8
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.
`seed` is the roster generator seed: it is not the school id. Share it to recreate the same
people; it does not change on a living school.
```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, "modIds": ["core"], "seed": 1847291 }
]
}
```
### `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?lang=ru|en`
Folders under the server's `mods/` directory. `core` is always first and `required: true`; other
packs can be switched off in the create dialog. `lang` is the same value Hello carries — not
`Accept-Language`. Anything other than `en` is Russian.
Each pack carries a human label, a version string and the ids it `requires`. The label is the pack
id looked up in that pack's own `localizations/<lang>.jsonc`. A folder without `pack.jsonc` is
still a pack: the id stands in for the name, `version` is empty, `requires` is empty.
```json
{ "mods": [{ "id": "core", "required": true, "label": "Базовая игра", "version": "1.0", "requires": [] }] }
```
The repo also ships `example` next to `core` — a sample pack, not required. It appears in this
list with `required: false` and `requires: ["core"]`. Vanilla create omits it.
### `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` and then reorders extras so
`requires` load first, same as create. `mods` is a comma-separated list of extra pack ids; omit
it for vanilla. Unknown extras return `400` `unknown-mod`. A selected pack whose dependency was
not listed returns `400` `missing-mod`; a cycle returns `400` `mod-cycle`.
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.
`countries` is the list of placeable countries (`defName` + label + `nativeLanguages` +
`climatePresets`). Each `nativeLanguages` entry is a skill (`defName` + label). The create
dialog picks a country and, when that country lists more than one tongue, a native language;
the language list comes from that country (vanilla Russia: Russian, Belarusian, Ukrainian).
It is independent of the UI language. Everyone in the school is generated with that native.
Other languages of the same country often appear at a low skill — a Russian speaker who
understands Belarusian. Those rolls live on the nested names of the `CountryDef`
(`relatedLanguageChance` and neighbours), not on this catalog payload. `climatePresets` are
ids; the school rolls one at create from its seed and keeps it.
`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,
"countryId": "Russia",
"nativeLanguage": null,
"seed": null
}
```
`modIds` are extras; the server always prepends `core`, then **reorders** the selection so each
pack's `requires` load first (stable topological sort over the player's order). The resolved
order is returned as `modIds` on the created school and written to the save, so a restart loads
the same catalog. 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.
`countryId` is a `CountryDef`; omit it to use the first placeable country in the catalog (vanilla:
`Russia`). Unknown ids return `400` `unknown-country`. The old `nameSetId` field is not accepted.
`nativeLanguage` is a skill from that country's `nativeLanguages`. Omit it (or send `null`) to pick
one from the school seed. An id that is not in the country returns `400` `unknown-native-language`.
`seed` is an optional integer. Send it to reproduce a known school; omit it (or send `null`) and
the server rolls one. Existing saves keep the seed already stored in the people file.
| 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` `missing-mod` | A selected pack `requires` an id that was not selected. `missing` is that id. |
| `400` `mod-cycle` | Selected packs require each other in a cycle. |
| `400` `invalid-catalog` | The selected packs could not be loaded. |
| `400` `unknown-country` | `countryId` is not a placeable `CountryDef` in those packs. |
| `400` `unknown-native-language` | `nativeLanguage` is not in that country's `nativeLanguages`. |
| `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.
`activity` is the ActionDef name currently in progress, or `null` when idle. `activityLabel`
is that def in the request locale. HTTP JSON is additive — no protocol version bump.
`skills` lists only keys the person has, not every `SkillDef` in the catalog. A first-year has
no Chemistry; a related tongue from the name set may sit beside the native at a low value.
```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 }],
"activity": null,
"activityLabel": null,
"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. `teachersShort` is how many more people that subject still
needs — primary school on a vanilla map is three, and hiring the second teacher leaves it at
one.
```json
{
"allocated": 100000,
"payroll": 5000,
"remaining": 95000,
"uncovered": [
{
"defName": "Mathematics",
"label": "Математика",
"gradeMin": 5,
"gradeMax": 11,
"hoursPerWeek": 5,
"teachersShort": 1
}
],
"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 card lists every skill they
have, not the three strongest. `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`, so it takes the same `?lang=ru|en`.
`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,
and the same `?lang=ru|en`.
| 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. Same success
payload and `?lang=ru|en` as the other two. 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`.
## Dev endpoints
These exist only when `HSchool:AllowSaveReload` is true (headless AppHost tests). They are never
mapped in production by default. Same switch as `POST /api/dev/reload-schools`.
### `GET /api/dev/schools/{id}/dump`
Roster, live presence, live needs and the timetable as one JSON. The roster and lesson table come
from the published snapshots; nodes and needs go through that school's mailbox — HTTP does not
read the `World`. Unknown `{id}` is `404` `unknown-school`.
```json
{
"id": 1,
"name": "Гимназия №14",
"gameTime": "2012-04-03T10:20:00Z",
"running": true,
"people": [
{
"id": "f3.p1",
"fullName": "Иванова Ольга Михайловна",
"nodeId": "classroom-101",
"needs": { "Hunger": 0.92, "Toilet": 1, "Social": 0.8, "Sleep": 1 }
}
],
"now": [
{
"classId": "c5A",
"subject": "Mathematics",
"teacherId": "f3.p1",
"roomId": "classroom-101",
"day": 1,
"period": 3
}
],
"lessons": [
{
"classId": "c5A",
"subject": "Mathematics",
"teacherId": "f3.p1",
"roomId": "classroom-101",
"day": 1,
"period": 3
}
]
}
```
`nodeId` is null when the person is off campus. `now` is the lessons occurring at `gameTime`
(empty on a break, night, weekend or holiday). `lessons` is the published table.
## 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 — 27 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.
Temperature is outdoor tenths of a °C (`i16`, so 50 is 5.0 °C). Precipitation is `0` none,
`1` rain, `2` snow. The client must not derive weather from the month.
| 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 |
| 24 | `i16` | outdoor temperature, tenths of a °C |
| 26 | `u8` | precipitation: `0` none, `1` rain, `2` snow |
### `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 v8 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.