# Wire protocol v10 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, a presence stream (~2 Hz) of who is where, and one-shot notice frames when the world raises an event. 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 ` 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/.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. `offenses` is the short recent-misconduct list on the person (quarrel / fight / reprimand). Same order as in the world and in `people.json`. Each row has `kind`, `kindLabel` (mod locale), `time` (UTC game clock), and optional `otherPersonId` / `otherFullName`. Empty is `[]`. Ceiling and which kinds write live on `BehaviorDef` (`offenseMemoryMax`, `offenseMemoryKinds`), not in the client. HTTP JSON is additive — no protocol version bump. ```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, "offenses": [ { "kind": "fight", "kindLabel": "драка", "time": "2012-04-03T11:20:00Z", "otherPersonId": "f0.c1", "otherFullName": "Иванов Кирилл Петрович" } ], "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. POST generate uses this same assembly (scene included) before calling Swarm. 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 `` (weight ≠ 1: ``); LoRA is not written into the prompt. Scene fragments from the person's room, map fill, worn/held defs and outdoor weather follow clothing, capped by `Simulation.PortraitSceneFragmentLimit` (default 6). Scene LoRA is merged into Swarm `loras` / `loraweights` (default 2); scene embeddings use the same `` tags (default 2). Empty `prompt` blocks are silent. 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": "…, Head and shoulders., A teenage girl, 12 years old, with black hair, wearing a white shirt, a classroom blackboard with chalk notes", "negative": "…", "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 vanilla catalog is Krea 2 only: Muse GGUF (`museByStableYogi_v30TurboGguf.gguf`) and Realism v2.5 INT8 (`realismByStableYogi_v25INT8Turbo.safetensors`), both 8 / CFG 1 / euler / simple. The four SDXL Lightning checkpoints (babes, DreamShaper, epicrealism, lustify) are not in the template. Presets `default` and `child` both use Realism v2.5; `ageRules` still send ages 6–11 to `child`. 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 — a save that still lists SDXL models keeps them. 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`. ### `POST /api/schools/{id}/notices/{noticeId}/dismiss` Owner-only close of a pausing notice. `204` even when that id is already gone. Guests get `403` `not-owner` — the same code as a guest hire. Info is not stored, so this path is for sticky warning/error. Socket dismiss (`0x08`) is the live intent; this HTTP route is the same right check for clients that are not on the socket. ### `POST /api/schools/{id}/notices/{noticeId}/generate` Owner-only portrait from a sticky notice whose `action` is `generateImage`. Uses the same `GetPortraitBuildInput` assembly as the person card (scene included). Kind is the one stored on the notice when Swarm failed, or `full`. Success is the same `201` body as `POST /api/schools/{id}/people/{personId}/portrait`; Swarm down is the same `503` / `502` / `504`. The notice stays on the board — a PNG does not dismiss it or start the clock. | Status | `code` | When | | --- | --- | --- | | `400` | `notice-cannot-generate` | No person on the notice, or action is not `generateImage`. Swarm is not called. | | `403` | `not-owner` | Guest. | | `404` | `unknown-school` / `unknown-notice` | Missing school or that id is not a sticky notice. | ## 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. ### `POST /api/dev/schools/{id}/notices` Posts a catalog `EventDef` onto that school's board without going through Swarm. Body: `{ "defName": "GenerationFailed", "personId": 0, "person": "a0.p0", "kind": "full" }`. `person` is the roster id to put on the notice (empty/omitted = nobody in frame). `kind` is the portrait kind to retry (`avatar` / `custom` / `full`). Success is `{ "id", "defName", "pause" }`. Unknown `{id}` is `404` `unknown-school`. Unknown def or a full sticky queue is `409` `notice-rejected`. Tests use this; a live Swarm failure posts `GenerationFailed` itself. ## 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 | | `0x08` | C → S | DismissNotice | | `0x81` | S → C | Welcome | | `0x82` | S → C | Pong | | `0x83` | S → C | Clock | | `0x84` | S → C | SchoolGone | | `0x85` | S → C | MapSnapshot | | `0x86` | S → C | Presence | | `0x87` | S → C | Notice | ## 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. `SetRunning(true)` is ignored (not fatal, same as an illegal SkipEmpty) while the school has at least one **pausing** notice. The clocks stay paused until the owner dismisses those notices **and** presses Play — dismiss itself does not start the calendar. 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. ### `0x08` DismissNotice — 5 bytes Closes one notice by id. Info toasts are not stored on the server; the frame still travels so a click is one intent. **Pausing** dismiss is owner-only: a guest's frame is ignored, like a guest `SetRunning`. After the last pausing notice is gone the clocks stay paused until Play. | Offset | Type | Field | | --- | --- | --- | | 0 | `u8` | `0x08` | | 1 | `u32` | notice id | ## 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. ### `0x87` Notice — variable One school event for every connection that has that school open. Not glued to the clock frame. Info is not written to the save and is not resent on OpenSchool. Warning/error with `pause=1` stop the clock, stay in the save (at most eight), and are sent again on Open. The ninth sticky notice is not emitted. `personId` is `0` when nobody is in frame. `action` is the catalog value (`none` or `generateImage`); the generate button is owner-only HTTP, not a new socket id. Layout extra after the v10 personId field; protocol version stays 10. | Offset | Type | Field | | --- | --- | --- | | 0 | `u8` | `0x87` | | 1 | `u32` | notice id | | 5 | string | `defName` | | … | `u8` | severity: `0` info, `1` warning, `2` error | | … | `u8` | `1` pause (clocks stop until dismiss + Play), `0` clocks keep running | | … | `u32` | `ttlMs`; `0` stays until dismiss | | … | `u32` | `personId`; `0` = none | | … | string | `action`; `none` or `generateImage` | ## 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, presence and notices use a separate reliable queue so ticks cannot crowd them out. ## Not in v10 yet Authentication, Sit orders, a year-long 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.