1073 lines
44 KiB
Markdown
1073 lines
44 KiB
Markdown
# Wire protocol v9
|
||
|
||
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
|
||
|
||
Most routes require a signed session cookie set by `POST /api/session`. Without it the server
|
||
returns `401`. Public exceptions: `GET /health` and the three `/api/session` routes.
|
||
|
||
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.
|
||
|
||
### `POST /api/session`
|
||
|
||
Alpha login. Body:
|
||
|
||
```json
|
||
{ "password": "alpha", "userName": "Leo" }
|
||
```
|
||
|
||
Success (`200`) sets an HttpOnly cookie (`SameSite=Lax`, `Path=/`) and returns:
|
||
|
||
```json
|
||
{ "userName": "Leo" }
|
||
```
|
||
|
||
The name is normalized like a school name (trim, no control characters, 1–40 chars). Occupancy is
|
||
case-insensitive: `Leo` and `leo` are the same person; the first spelling is kept in
|
||
`saves/users.json`.
|
||
|
||
| Status | `code` | When |
|
||
| --- | --- | --- |
|
||
| `401` | `bad-password` | Wrong alpha password |
|
||
| `400` | `invalid-name` | Name fails normalization |
|
||
| `409` | `name-online` | A live WebSocket already uses that name |
|
||
|
||
### `GET /api/session`
|
||
|
||
Returns `{ "userName": "Leo" }` when the cookie is valid, otherwise `401`.
|
||
|
||
### `DELETE /api/session`
|
||
|
||
Clears the session cookie. `204`.
|
||
|
||
The WebSocket at `/ws/game` uses the same cookie on upgrade. Without a valid cookie the server
|
||
closes the connection with a policy violation and never sends Welcome. A second socket for a name
|
||
that already has a live connection is closed the same way; `POST /api/session` for that name
|
||
returns `409` `name-online`. Hello is unchanged (version + locale only).
|
||
|
||
### `GET /api/changelog`
|
||
|
||
What landed in the running Server build since an earlier commit. Requires a session cookie.
|
||
`current` is the SHA baked at compile time (`git rev-parse HEAD` during that build), not the live
|
||
working tree. `commits` is `git log --first-parent` after `since`, oldest first, subjects that
|
||
match `Mark phase <digits>` omitted. Without `since`, with an unknown SHA, or when `since` equals
|
||
`current`, `commits` is empty — a first visit must not dump the whole history.
|
||
|
||
```json
|
||
{
|
||
"current": "0123456789abcdef0123456789abcdef01234567",
|
||
"commits": [
|
||
{ "sha": "89abcdef0123456789abcdef0123456789abcdef", "date": "2026-08-20T08:21:00+00:00", "subject": "Merge branch 'phase/53-weather-commute'" }
|
||
]
|
||
}
|
||
```
|
||
|
||
Optional `?since=` is a 40-character hex SHA from the client's `hschool.seen-rev` cookie. That
|
||
cookie is not HttpOnly and is not the session cookie. Protocol version is unchanged.
|
||
|
||
### `GET /api/schools`
|
||
|
||
Everything the main menu needs in one request. `schoolWeekDays` is 5–7 working days counted
|
||
from Monday (five is Mon–Fri; 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": 2,
|
||
"maxSchoolsTotal": 16,
|
||
"defaultStartDate": "2012-03-31T06:00:00Z",
|
||
"gameMinutesPerRealSecond": 1,
|
||
"schoolWeekDays": 5,
|
||
"schools": [
|
||
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1, "modIds": ["core"], "seed": 1847291, "mine": true }
|
||
],
|
||
"others": [
|
||
{ "id": 2, "name": "Лицей", "gameTime": "2012-03-31T06:00:00Z", "running": true, "speedIndex": 1, "modIds": ["core"], "seed": 9912, "owner": "Leo" }
|
||
]
|
||
}
|
||
```
|
||
|
||
`maxSchools` is how many schools **this player** may own; `maxSchoolsTotal` is how many workers the
|
||
process runs. `schools` lists yours (`mine: true` on each card). `others` lists every other save on
|
||
the server — `owner` is the display name, or `null` when the save has no owner (any logged-in player
|
||
may delete an ownerless card). The WebSocket welcome frame still carries one byte for
|
||
`maxSchools`; it now means the same per-player limit, not the process total.
|
||
|
||
### `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. `romance` is a
|
||
content pack on the same list; a school created without it has no orientations and no romantic
|
||
topics.
|
||
|
||
### `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.
|
||
|
||
`topics` is the list of placeable conversation subjects (`defName`, label, `tags`). Vanilla
|
||
ships study/games/food/family/sport/gossip/rude/appearance. A content pack may add more — the
|
||
`romance` pack adds crush/couple/tease topics and a `romance` tag; they are absent from a
|
||
catalog requested without that pack. HTTP JSON is additive — no protocol version bump.
|
||
|
||
`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,
|
||
"portraitSettings": 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.
|
||
`portraitSettings` is the SwarmUI preset file for **this** school (same shape as
|
||
`GET /api/settings/swarmui`). Omit it (or send `null`) to copy the server template at create.
|
||
Invalid presets return `400` `invalid-portrait-settings`. Generation later uses this copy, not
|
||
the global file, so a guest watching the school draws with the same model.
|
||
|
||
| 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` `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`. |
|
||
| `400` `invalid-portrait-settings` | `portraitSettings` failed validation (empty presets, bad age rule, out of range). |
|
||
| `409` `school-limit-reached` | This player already owns `maxSchools` schools. |
|
||
| `409` `server-full` | The process already runs `maxSchoolsTotal` schools. |
|
||
|
||
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, `403` `not-owner` when the school belongs to
|
||
another player. Ownerless saves may be deleted by any logged-in session. 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, needs, clothes and what they carry 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.
|
||
`talkCircleMemberIds` is the live circle (including self, sorted) or `[]`; `talkTopicId` is the
|
||
topic def id or `null` when not talking. Same ids as the presence frame; names are not repeated
|
||
here either — the Now tab uses the directory and locale, like the location panel.
|
||
|
||
`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.
|
||
|
||
`worn` is what is on the body right now: def, colour, the layers it occupies, `condition`
|
||
(0–1) and `conditionLabel` from the catalog bands (`целая` / `поношенная` / `порванная` /
|
||
`висит лохмотьями`). The client draws the bar and the caption; it does not compute thresholds.
|
||
`carried` is the bag — textbooks include `subject`. `carryMass` / `carryCapacity` are kilograms;
|
||
overload does not slow walking. `hasLocker` is true when the pupil has an assigned locker slot
|
||
(`lockerRoomId`) or anything sits in a locker; `homeCount`
|
||
is how many items remain at home, not the list. The people list does not include any of these
|
||
fields. Today's history is a separate GET.
|
||
|
||
`hasAvatar`, `hasCustom` and `hasFullBody` tell the client whether PNG files already exist on disk for this
|
||
person. `customPortraitPrompt` is the last saved user prompt for the custom variant (null when none).
|
||
They are filled on the HTTP thread after the worker returns the card; generation does
|
||
not happen on this request.
|
||
|
||
`connections` is the **Связи** tab: family links with `opinion` / `opinionLabel` from catalog bands,
|
||
plus `friends`, `enemies` and `others` (every non-family non-zero pair for this person only).
|
||
When a school was created with a pack that ships orientations, the card also carries
|
||
`orientation` (`defName` + label) and `connections.crushes` / `admirers` / `pair`. Without that
|
||
pack those fields are null or empty — the client hides the sympathy column. The client does not
|
||
compute thresholds. There is no school-wide opinions endpoint.
|
||
|
||
```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,
|
||
"talkCircleMemberIds": [],
|
||
"talkTopicId": null,
|
||
"family": {
|
||
"parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }],
|
||
"children": [],
|
||
"siblings": [{ "id": "f0.c1", "fullName": "Иванов Кирилл Петрович", "female": false }],
|
||
"partners": []
|
||
},
|
||
"worn": [
|
||
{
|
||
"defName": "Shirt",
|
||
"label": "Рубашка",
|
||
"color": "White",
|
||
"colorLabel": "Белый",
|
||
"layers": [{ "defName": "Top", "label": "Верх" }],
|
||
"condition": 1,
|
||
"conditionLabel": "целая"
|
||
}
|
||
],
|
||
"carried": [
|
||
{
|
||
"defName": "Textbook",
|
||
"label": "Учебник",
|
||
"color": null,
|
||
"colorLabel": null,
|
||
"subject": "Mathematics",
|
||
"subjectLabel": "Математика",
|
||
"mass": 0.4
|
||
}
|
||
],
|
||
"carryMass": 1.2,
|
||
"carryCapacity": 14,
|
||
"hasLocker": false,
|
||
"homeCount": 3,
|
||
"hasAvatar": false,
|
||
"hasCustom": false,
|
||
"hasFullBody": false,
|
||
"customPortraitPrompt": null,
|
||
"connections": {
|
||
"family": {
|
||
"parents": [
|
||
{
|
||
"id": "f0.p1",
|
||
"fullName": "Иванова Ольга Михайловна",
|
||
"female": true,
|
||
"opinion": 75,
|
||
"opinionLabel": "близкие друзья"
|
||
}
|
||
],
|
||
"children": [],
|
||
"siblings": [
|
||
{
|
||
"id": "f0.c1",
|
||
"fullName": "Иванов Кирилл Петрович",
|
||
"female": false,
|
||
"opinion": 45,
|
||
"opinionLabel": "друзья"
|
||
}
|
||
],
|
||
"partners": []
|
||
},
|
||
"friends": [],
|
||
"enemies": [],
|
||
"others": []
|
||
}
|
||
}
|
||
```
|
||
|
||
### `GET /api/schools/{id}/people/{personId}/log`
|
||
|
||
Today's history for one person: search, sort by time, page. Goes through the school's mailbox
|
||
because the log lives on the worker, not in `people.json` and not on a published snapshot.
|
||
Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`. This is
|
||
not a socket feed and not a school-wide event list.
|
||
|
||
The day boundary is six in the morning — the same hour skip lands on. Crossing that hour, or
|
||
skipping an empty night, drops yesterday's rows. `?lang=ru|en` labels the captions. `q` is a
|
||
substring of the caption or type. `sort` is `time`. `dir` is `asc` or `desc` (default `desc`).
|
||
`page` starts at 1; `pageSize` defaults to 20 and is at most 100. A thousand rows do not
|
||
arrive in one response.
|
||
|
||
Row `type` values: `action-started`, `action-ended`, `apparel-replaced` (morning issue, when
|
||
phase 34 appends it), `apparel-changed` (dressing, when phase 35 appends it),
|
||
`lesson-no-teacher` (the assigned teacher was not standing in the lesson room),
|
||
`lesson-cold` (warmth below the behaviour threshold during a lesson that otherwise taught),
|
||
`lesson-no-textbook` (the bag had no textbook for that lesson; locker and home do not count).
|
||
`thingDef` is the action, apparel or subject def the caption was built from.
|
||
|
||
```json
|
||
{
|
||
"total": 2,
|
||
"page": 1,
|
||
"pageSize": 20,
|
||
"entries": [
|
||
{
|
||
"time": "2012-04-03T12:00:00Z",
|
||
"type": "action-started",
|
||
"label": "начал: Обед",
|
||
"thingDef": "EatLunch"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### `GET /api/schools/{id}/people/{personId}/portrait`
|
||
|
||
Returns a generated PNG when one exists. Query `kind=avatar|custom|full` selects head-and-shoulders,
|
||
a custom prompt variant or full-body. Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`;
|
||
missing file is `404` `portrait-missing`. Invalid `kind` is `400` `invalid-query`. Content-Type
|
||
is `image/png`. Opening the card does not generate; use POST when the player asks.
|
||
|
||
### `POST /api/schools/{id}/people/{personId}/portrait`
|
||
|
||
Generates (or regenerates) a portrait through SwarmUI on the server. Same `kind` query as GET.
|
||
For `kind=custom` the body is `{ "promptExtra": "..." }` — appended after the shot type (and the
|
||
model/style layers), then the person's appearance and clothing; required, non-empty, at most 2000
|
||
characters. Avatar and full-body POST need no body.
|
||
Success is `201` with `{ "kind", "hasAvatar", "hasCustom", "hasFullBody", "customPortraitPrompt" }` and a `Location` header pointing
|
||
at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swarmui-not-configured`.
|
||
Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files
|
||
land under `saves/{id}.portraits/` and survive until the school is deleted.
|
||
|
||
### `GET /api/schools/{id}/people/{personId}/portrait/prompt`
|
||
|
||
Returns the positive and negative prompts SwarmUI would receive, without generating an image.
|
||
Same `kind` query as GET portrait. For `kind=custom`, optional query `promptExtra` is appended after
|
||
the shot type; when omitted, the last saved custom prompt is used if one exists. Configured
|
||
embeddings appear as `<embed:name>` (weight ≠ 1: `<embed:name:w>`); LoRA is not written into the
|
||
prompt. Unknown school is `404` `unknown-school`; unknown person is `404` `unknown-person`. Invalid
|
||
`kind` is `400` `invalid-query`; custom without a usable prompt is `400` `invalid-body`.
|
||
|
||
```json
|
||
{
|
||
"kind": "avatar",
|
||
"positive": "cinematic photo, …, close up, head and shoulders portrait, …, age 12, …",
|
||
"negative": "(low quality, worst quality:1.4), …",
|
||
"promptExtra": null,
|
||
"presetId": "default",
|
||
"presetLabel": "Default"
|
||
}
|
||
```
|
||
|
||
`GET /api/status` includes `swarmUiConfigured` and `swarmUiConnected` (`null` when not configured)
|
||
so the client can disable generate buttons and show reachability without trying POST first.
|
||
|
||
### `GET /api/settings/swarmui`
|
||
|
||
Returns the **default** SwarmUI template (`swarmui.json`): a `models` catalog (id, default
|
||
generation knobs, usually empty base positive/negative, LoRA and embedding lists), named presets
|
||
(model id, `style`, extra negative, optional generation overrides, per-kind size/`shotType`, LoRA
|
||
and embedding lists), `activePresetId` and `ageRules`. LoRA and embeddings on the model, preset and
|
||
shot kind concatenate at generate time.
|
||
The model picker in the UI is Swarm's list intersected with `models`. A new school copies this into
|
||
its save as `portraitSettings`. Living schools generate from that copy, not from this file. Older
|
||
saves without `models` lift on load (`positive` → `style`, kind `positive` → `shotType`).
|
||
|
||
### `PUT /api/settings/swarmui`
|
||
|
||
Replaces the default template after validation. Invalid model/preset ids, age rules or numeric ranges
|
||
return `400` `invalid-body`. Already-created schools keep the copy they were created with.
|
||
|
||
### `GET /api/settings/swarmui/discovery`
|
||
|
||
When SwarmUI is configured and reachable, proxies `ListT2IParams` and returns
|
||
`{ connected, models, loras, embeddings, samplers, schedulers }` for the settings UI. LoRA and
|
||
embeddings can be set on the model, the preset, and each shot kind; layers concatenate. The client
|
||
hides Swarm models that are missing from the template catalog. When SwarmUI is off or unreachable,
|
||
`connected` is false and the lists are empty; the picker then shows the catalog ids so fields can
|
||
be filled manually.
|
||
|
||
### `GET /api/schools/{id}/dress-rules`
|
||
|
||
Student and staff dress-code pairs for the school. Only the owner may read this (`403` `not-owner`).
|
||
Unknown `{id}` is `404` `unknown-school`.
|
||
|
||
`form` is one of `regular`, `short`, `strict`. `color` is one of `noBright`, `whiteTopBlackBottom`,
|
||
`free`. When a `POST` has been accepted but not yet applied, `pendingStudents` and/or `pendingStaff`
|
||
show what takes effect on the **next work morning** (six o'clock on a weekday outside holidays) —
|
||
not immediately.
|
||
|
||
```json
|
||
{
|
||
"students": { "form": "regular", "color": "noBright" },
|
||
"staff": { "form": "regular", "color": "noBright" },
|
||
"pendingStudents": null,
|
||
"pendingStaff": null
|
||
}
|
||
```
|
||
|
||
### `POST /api/schools/{id}/dress-rules`
|
||
|
||
Queues a change for the next work morning. Only the owner may post (`403` `not-owner`). Either or both of `students` and `staff` may be sent;
|
||
omitted sides keep their current rule. Unknown `{id}` is `404` `unknown-school`. Unknown `form` or
|
||
`color` is `400` `unknown-form` / `400` `unknown-color`. Response body matches `GET`.
|
||
|
||
```json
|
||
{ "students": { "form": "strict", "color": "noBright" } }
|
||
```
|
||
|
||
### `GET /api/schools/{id}/speech-rules`
|
||
|
||
Student and staff speech-topic policy for the school. Only the owner may read this (`403`
|
||
`not-owner`). Unknown `{id}` is `404` `unknown-school`.
|
||
|
||
Policy is one of `free`, `noRude`, `studyOnly`. When a `POST` has been accepted but not yet
|
||
applied, `pendingStudents` and/or `pendingStaff` show what takes effect on the **next work
|
||
morning** — the same morning as dress rules. Today's live circles keep their topic; the system
|
||
does not start a new circle whose topic is outside the live policy.
|
||
|
||
```json
|
||
{
|
||
"students": "free",
|
||
"staff": "free",
|
||
"pendingStudents": null,
|
||
"pendingStaff": null
|
||
}
|
||
```
|
||
|
||
### `POST /api/schools/{id}/speech-rules`
|
||
|
||
Queues a change for the next work morning. Only the owner may post (`403` `not-owner`). Either or
|
||
both of `students` and `staff` may be sent; omitted sides keep their current pending/live rule.
|
||
Unknown `{id}` is `404` `unknown-school`. Unknown policy is `400` `unknown-speech`. Response body
|
||
matches `GET`.
|
||
|
||
```json
|
||
{ "students": "studyOnly" }
|
||
```
|
||
|
||
### `GET /api/schools/{id}/staffing`
|
||
|
||
Money, uncovered subjects, the applicant pool and current staff. Only the owner may read this
|
||
(`403` `not-owner`). 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 every skill they have (the applicant window compares the whole row, not a
|
||
top-three summary); the person card lists the same set. `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 |
|
||
| --- | --- | --- |
|
||
| `403` | `not-owner` | The session is not the school's owner. |
|
||
| `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 `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 |
|
||
| `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 = ×5`, `4 = ×10`; out-of-range values are
|
||
ignored rather than fatal. The base rate is `gameMinutesPerRealSecond` (1), so ×1 is one game
|
||
minute 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 |
|
||
| `u8` | talk-circle member count, then that many person-id strings |
|
||
| string | topic id (empty when not in a circle) |
|
||
|
||
Member ids are the live circle, including self, sorted by id. Count `0` and an empty topic mean
|
||
the person is not talking — the same id/node/state as before the circle fields. Names are not
|
||
on this frame; the client builds «говорит с Машей о футболе» from the HTTP directory and locale.
|
||
|
||
## 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 v9 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.
|