Files
h-school/docs/protocol.md
T
Leonid PershinandCursor 1b3479f984 Halve lesson gain when the bag has no textbook for the subject.
Locker and home do not count. A pack without the field keeps vanilla 0.5.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 09:27:46 +03:00

39 KiB
Raw Blame History

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
Client codec 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:

{ "password": "alpha", "userName": "Leo" }

Success (200) sets an HttpOnly cookie (SameSite=Lax, Path=/) and returns:

{ "userName": "Leo" }

The name is normalized like a school name (trim, no control characters, 140 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 57 working days counted from Monday (five is MonFri; six adds Saturday). It is a school rule, not a catalog def. seed is the roster generator seed: it is not the school id. Share it to recreate the same people; it does not change on a living school.

{
  "maxSchools": 6,
  "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 }
  ]
}

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.

{ "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:

{
  "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 19002999.
400 invalid-map Missing yard, no rooms, unknown def, or a disconnected graph.
400 unknown-mod An extra pack id is missing under mods/.
400 missing-mod A selected pack requires an id that was not selected. missing is that id.
400 mod-cycle Selected packs require each other in a cycle.
400 invalid-catalog The selected packs could not be loaded.
400 unknown-country countryId is not a placeable CountryDef in those packs.
400 unknown-native-language nativeLanguage is not in that country's nativeLanguages.
400 invalid-portrait-settings portraitSettings failed validation (empty presets, bad age rule, out of range).
409 school-limit-reached maxSchools schools already exist.

Failures are RFC 7807 problem details with an extra code field — that is what the UI switches on.

DELETE /api/schools/{id}

204 when deleted, 404 when the id is unknown. Anyone watching that school over a WebSocket gets a SchoolGone frame.

GET /api/schools/{id}/people

The in-school people list. Reads the published roster snapshot and that school's last clock; it does not post to the worker. Unknown {id} is 404 unknown-school. Bad query parameters are 400 invalid-query. lang is ru or en, same as the catalog — anything other than en is Russian.

Query Meaning
role student, staff or parent. A staff parent matches parent.
year Class parallel (5 for fifth year).
letter Class letter as stored on the roster (А, not a room number).
position Staff PositionDef name (Teacher).
sex male or female.
ageMin / ageMax Inclusive age in full years at the school's current game time.
sort surname (default), age, year, position.
dir asc (default) or desc.
page 1-based. Default 1. 0 is invalid. A page past the end is empty and still reports total.
pageSize Default 50, max 100.
lang Label language for positions and filter options.
{
  "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.

{
  "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 (01) 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.

{
  "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), 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.

{
  "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.

{
  "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. 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.

{
  "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. 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.

{ "students": { "form": "strict", "color": "noBright" } }

GET /api/schools/{id}/staffing

Money, uncovered subjects, the applicant pool and current staff. Reads the published roster, applicant snapshot and catalog — it does not post to the worker. Unknown {id} is 404 unknown-school. ?lang=ru|en labels subjects and positions.

allocated is Simulation:MonthlyPayrollCap. A staff member is paid hourlyWageAsk × weeklyHours × weeksPerMonth, never less than one full rate (baseWeeklyHours), and payroll is the sum over staff. The cap is checked when hiring or assigning, not at month end; money itself does not move.

weeklyHours is not stored: it is the curriculum. For every subject assigned to a person, hoursPerWeek of that subject across the classes that study it, divided between everyone teaching it. So a second teacher of a subject halves what the first one carries — and costs.

A subject appears in uncovered when nobody teaches it or when the people who do cannot between them carry its hours (maxWeeklyHours each). Both mean the same thing to a player: those lessons will not happen. teachersShort is how many more people that subject still needs — primary school on a vanilla map is three, and hiring the second teacher leaves it at one.

{
  "allocated": 100000,
  "payroll": 5000,
  "remaining": 95000,
  "uncovered": [
    {
      "defName": "Mathematics",
      "label": "Математика",
      "gradeMin": 5,
      "gradeMax": 11,
      "hoursPerWeek": 5,
      "teachersShort": 1
    }
  ],
  "applicants": [
    {
      "id": "a0.p0",
      "fullName": "Соколов Иван Петрович",
      "female": false,
      "age": 34,
      "isParent": false,
      "hourlyWageAsk": 50,
      "monthlyBase": 4000,
      "skills": [{ "id": "Mathematics", "label": "Математика", "value": "72" }]
    }
  ],
  "staff": [
    {
      "id": "f3.p1",
      "fullName": "Иванова Ольга Михайловна",
      "female": true,
      "age": 41,
      "isParent": true,
      "position": "Teacher",
      "positionLabel": "Учитель",
      "hourlyWageAsk": 50,
      "weeklyHours": 35,
      "monthlyPay": 7000,
      "subjects": [{ "defName": "Mathematics", "label": "Математика" }]
    }
  ],
  "positions": [{ "defName": "Teacher", "label": "Учитель" }],
  "subjects": [
    {
      "defName": "Mathematics",
      "label": "Математика",
      "gradeMin": 5,
      "gradeMax": 11,
      "hoursPerWeek": 5
    }
  ]
}

Applicants here are the same people as in saves/{id}.people.json. A parent keeps the same id on the roster; hiring them sets isStaff on that person and does not create a second entity. Generated candidates (aN.p0) join the roster only when hired. skills on an applicant are the three strongest, for the management list; the card lists every skill they have, not the three strongest. positions and subjects are the school's catalog, so the hire and assign pickers do not need a second request.

GET /api/schools/{id}/people/{personId} also opens a card for someone who is only in the applicant pool (needs are the frozen snapshot — they are not in the World yet). Unknown ids that are in neither place stay 404 unknown-person.

POST /api/schools/{id}/staff/hire

Body: { "personId": "a0.p0", "position": "Teacher" }. Goes through the school's mailbox. On success returns the same payload as GET .../staffing, so it takes the same ?lang=ru|en. Teacher needs no room opening; other positions fill the first free RoomDef.positions slot of that kind.

Status code When
404 unknown-school No school with that id.
404 unknown-applicant personId is not in the pool.
409 already-hired That person is already staff.
400 unknown-position Not a concrete PositionDef.
409 no-opening Every opening of that position is filled.
409 payroll-exceeded Hire would take payroll past allocated.

payroll-exceeded includes allocated, payroll (current), remaining and attempted (what payroll would become). Same RFC 7807 code field as the other errors.

POST /api/schools/{id}/staff/{personId}/subjects

Body: { "subject": "Mathematics" }. Teachers only. Same success payload as GET staffing, and the same ?lang=ru|en.

Status code When
400 not-staff Person is not staff.
400 not-teacher Position is not Teacher.
400 unknown-subject Not a concrete SubjectDef.
409 already-assigned Already on this person.
409 payroll-exceeded Extra subject would exceed the cap.

DELETE /api/schools/{id}/staff/{personId}/subjects/{subject}

Removes one assignment. Payroll drops when the subject was not the only one. Same success payload and ?lang=ru|en as the other two. Unknown assignment is 404 unknown-assignment.

GET /api/schools/{id}/timetable

The published lesson table and uncovered hours. Optional ?classId= or ?personId= filter the lessons. ?lang=ru|en labels subjects and rooms. Reads the snapshot — it does not post to the worker. Unknown {id} is 404 unknown-school. classes and rooms are the full school lists so a picker does not need a second request.

day is 0 = Monday. period is the 1-based lesson number from the day frame.

{
  "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.

{
  "id": 1,
  "name": "Гимназия №14",
  "gameTime": "2012-04-03T10:20:00Z",
  "running": true,
  "people": [
    {
      "id": "f3.p1",
      "fullName": "Иванова Ольга Михайловна",
      "nodeId": "classroom-101",
      "needs": { "Hunger": 0.92, "Toilet": 1, "Social": 0.8, "Sleep": 1 }
    }
  ],
  "now": [
    {
      "classId": "c5A",
      "subject": "Mathematics",
      "teacherId": "f3.p1",
      "roomId": "classroom-101",
      "day": 1,
      "period": 3
    }
  ],
  "lessons": [
    {
      "classId": "c5A",
      "subject": "Mathematics",
      "teacherId": "f3.p1",
      "roomId": "classroom-101",
      "day": 1,
      "period": 3
    }
  ]
}

nodeId is null when the person is off campus. now is the lessons occurring at gameTime (empty on a break, night, weekend or holiday). lessons is the published table.

WebSocket message ids

Client-to-server ids live in 0x000x7F, server-to-client ids in 0x800xFF, so a misrouted frame is obvious at a glance.

Id Direction Message
0x01 C → S Hello
0x02 C → S Ping
0x03 C → S OpenSchool
0x04 C → S CloseSchool
0x05 C → S SetRunning
0x06 C → S SetSpeed
0x07 C → S SkipEmpty
0x81 S → C Welcome
0x82 S → C Pong
0x83 S → C Clock
0x84 S → C SchoolGone
0x85 S → C MapSnapshot
0x86 S → C Presence

Client → server

0x01 Hello — 3 bytes

Must be the first frame; the server drops the connection if it does not arrive within 5 seconds. The locale byte is the same language the catalog HTTP API takes as ?lang=.

Offset Type Field
0 u8 0x01
1 u8 protocol version
2 u8 locale: 0 Russian, 1 English; any other value is treated as Russian

0x02 Ping — 9 bytes

Offset Type Field
0 u8 0x02
1 i64 client clock in milliseconds

0x03 OpenSchool — 5 bytes

Starts watching a school: a map snapshot in the Hello locale arrives once, then clock frames and presence frames. It does not start the calendar — every school runs on its own from the moment it is created.

Offset Type Field
0 u8 0x03
1 i32 school id

0x04 CloseSchool — 1 byte

Back to the menu: the clock frames stop. The school keeps running — only SetRunning pauses it, and that pause survives leaving and reconnecting.

0x05 SetRunning — 2 bytes

Offset Type Field
0 u8 0x05
1 u8 1 running, 0 paused

0x06 SetSpeed — 2 bytes

Offset Type Field
0 u8 0x06
1 u8 speed index

Running and speed are separate messages on purpose. A single "set clock" message forces each button to resend the other field from the client's own copy of the state, which is always at least one tick stale — pressing play and then a speed button would pause the school again.

Speed indexes are 0 = ×½, 1 = ×1, 2 = ×2, 3 = ×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.