# Wire protocol v8 The client talks to the server two ways: - **HTTP/JSON** for the main menu and the in-school people browser — listing, creating and deleting schools, listing mods, loading a catalog for the create editor, reading a school's roster (filtered list + one-person card), a short id→name directory, staffing, and the timetable. Those are request/response by nature, so they are plain REST. - **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz), a static map snapshot sent once when a school is opened, and a presence stream (~2 Hz) of who is where. This document covers both. One protocol message per WebSocket frame, no framing header beyond the message id. **All multi-byte numbers are little-endian.** Three files must stay in sync — change them in the same commit: | Where | File | | --- | --- | | Server codec | [`src/HSchool.Protocol/ProtocolCodec.cs`](../src/HSchool.Protocol/ProtocolCodec.cs) | | Client codec | [`src/HSchool.Client/src/net/protocol.ts`](../src/HSchool.Client/src/net/protocol.ts) | | This document | `docs/protocol.md` | Any change to a layout below bumps `ProtocolConstants.Version` / `PROTOCOL_VERSION`. The server closes connections whose hello carries a different version with `1002 ProtocolError`. ## HTTP API 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. Hello is unchanged (version + locale only). ### `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. ### `GET /api/catalog?lang=ru|en&mods=addon1,addon2` Placeable (non-abstract) types plus labels in `lang`, and the last-wins `maps/default.jsonc` for `core` plus the listed extras. The server always prepends `core` and then reorders extras so `requires` load first, same as create. `mods` is a comma-separated list of extra pack ids; omit it for vanilla. Unknown extras return `400` `unknown-mod`. A selected pack whose dependency was not listed returns `400` `missing-mod`; a cycle returns `400` `mod-cycle`. Room defs that are homerooms carry `homeroom`, `seatThing` and `defaultSeats` instead of a slot table. A map classroom stores `seats` — how many of that thing occupy the room. Capacity is still `ThingDef.pupilSlots × seats`. Named slots remain on rooms whose furnishing actually varies (cafeteria, medical office, principal's office, library). The create editor shows a single seats field on a homeroom and the old slot rows on everything else. Things carry `pupilSlots` — how many pupils that thing hosts. The snapshot's per-node pupil-slot total is computed on the server. `countries` is the list of placeable countries (`defName` + label + `nativeLanguages` + `climatePresets`). Each `nativeLanguages` entry is a skill (`defName` + label). The create dialog picks a country and, when that country lists more than one tongue, a native language; the language list comes from that country (vanilla Russia: Russian, Belarusian, Ukrainian). It is independent of the UI language. Everyone in the school is generated with that native. Other languages of the same country often appear at a low skill — a Russian speaker who understands Belarusian. Those rolls live on the nested names of the `CountryDef` (`relatedLanguageChance` and neighbours), not on this catalog payload. `climatePresets` are ids; the school rolls one at create from its seed and keeps it. `subjects` is the list of placeable subjects (`defName`, label, `gradeMin`/`gradeMax`, `hoursPerWeek`, `skills` with shares, and optional `room`). `room` is the RoomDef the lesson needs — PE uses a gym, informatics a computer lab; omit it and the class homeroom is used. `dayFrame` is the one concrete bell schedule (`firstLesson` as `HH:mm`, lesson count and lengths, which break is the long one). `holidays` are month-day ranges that repeat every academic year; a range whose start is after its end wraps across 1 January. The assignment form in a later phase reads subjects; the timetable grid reads the day frame. The create editor does not. `lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is Russian. ### `POST /api/schools` Body: ```json { "name": "Гимназия №14", "startDate": "2012-03-31T06:00:00Z", "modIds": [], "map": null, "countryId": "Russia", "nativeLanguage": null, "seed": null, "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. `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). 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, "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). `thingDef` is the action or apparel 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 to the base SwarmUI prompt and the person's body/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 to the base prompt; when omitted, the last saved custom prompt is used if one exists. 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 preset template (`swarmui.json`): named presets (model, steps, sampler, LoRA lists, per-kind sizes/prompts), `activePresetId` and `ageRules`. A new school copies this into its save as `portraitSettings`. Living schools generate from that copy, not from this file. ### `PUT /api/settings/swarmui` Replaces the default template after validation. Invalid 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, samplers, schedulers }` for the settings UI comboboxes. When SwarmUI is off or unreachable, `connected` is false and the lists are empty. ### `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}/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 the three strongest, for the management list; the card lists every skill they have, not the three strongest. `positions` and `subjects` are the school's catalog, so the hire and assign pickers do not need a second request. `GET /api/schools/{id}/people/{personId}` also opens a card for someone who is only in the applicant pool (needs are the frozen snapshot — they are not in the World yet). Unknown ids that are in neither place stay `404` `unknown-person`. ### `POST /api/schools/{id}/staff/hire` Body: `{ "personId": "a0.p0", "position": "Teacher" }`. Goes through the school's mailbox. On success returns the same payload as `GET .../staffing`, so it takes the same `?lang=ru|en`. `Teacher` needs no room opening; other positions fill the first free `RoomDef.positions` slot of that kind. | Status | `code` | When | | --- | --- | --- | | `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 | ## Guarantees and limits - **Inbound** frames larger than 8 KiB are refused with close status `1009 MessageTooBig`. That limit is about what the server reads; it does not bound what the server sends. A `MapSnapshot` or `Presence` frame of a large school legitimately exceeds it, and the server sizes those frames from the message. - A malformed frame closes the connection with `1007 InvalidPayloadData`. - Unknown message ids are ignored rather than fatal, so new ids can be added without breaking older clients within the same protocol version. - Clock delivery is lossy under back pressure: each connection buffers 32 clock frames and drops the oldest, because a stale clock is worthless once a newer one exists. - The map snapshot and presence use a separate reliable queue so ticks cannot crowd them out. ## Not in v8 yet Authentication, Sit orders, an event log, walk animation, and `OpenLocation` on the server — the tree and the location panel are filtered on the client from the snapshot plus presence.