Files
h-school/docs/protocol.md
T
Leonid Pershin bb0b799d41 Merge branch 'phase/69-offense-memory'
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	src/HSchool.Server/Api/PeopleModels.cs
#	src/HSchool.Server/Game/PersonCardReader.cs
2026-08-21 09:58:31 +03:00

51 KiB
Raw Blame History

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
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. A second socket for a name that already has a live connection is closed the same way; POST /api/session for that name returns 409 name-online. Hello is unchanged (version + locale only).

GET /api/changelog

What landed in the running Server build since an earlier commit. Requires a session cookie. current is the SHA baked at compile time (git rev-parse HEAD during that build), not the live working tree. commits is git log --first-parent after since, oldest first, subjects that match Mark phase <digits> omitted. Without since, with an unknown SHA, or when since equals current, commits is empty — a first visit must not dump the whole history.

{
  "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 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": 2,
  "maxSchoolsTotal": 16,
  "defaultStartDate": "2012-03-31T06:00:00Z",
  "gameMinutesPerRealSecond": 1,
  "schoolWeekDays": 5,
  "schools": [
    { "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1, "modIds": ["core"], "seed": 1847291, "mine": true }
  ],
  "others": [
    { "id": 2, "name": "Лицей", "gameTime": "2012-03-31T06:00:00Z", "running": true, "speedIndex": 1, "modIds": ["core"], "seed": 9912, "owner": "Leo" }
  ]
}

maxSchools is how many schools this player may own; maxSchoolsTotal is how many workers the process runs. schools lists yours (mine: true on each card). others lists every other save on the server — owner is the display name, or null when the save has no owner (any logged-in player may delete an ownerless card). The WebSocket welcome frame still carries one byte for maxSchools; it now means the same per-player limit, not the process total.

GET /api/schools/random-name

{ "name": "Лицей «Северная»" } — a suggestion that is not already taken.

Optional ?lang=en draws from the English word list (Northern Academy); any other value, or none, stays Russian. The client sends the active UI language. Names the player types are not translated — they are saved as written.

GET /api/mods?lang=ru|en

Folders under the server's mods/ directory. core is always first and required: true; other packs can be switched off in the create dialog. lang is the same value Hello carries — not Accept-Language. Anything other than en is Russian.

Each pack carries a human label, a version string and the ids it requires. The label is the pack id looked up in that pack's own localizations/<lang>.jsonc. A folder without pack.jsonc is still a pack: the id stands in for the name, version is empty, requires is empty.

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

{
  "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 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.
{
  "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. classTeacherId / classTeacherName come from that class's slot when a class teacher is assigned; both are null when the slot is empty or the person has no class.

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 (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). 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.

{
  "id": "f0.c0",
  "fullName": "Иванова Мария Петровна",
  "female": true,
  "age": 12,
  "birthDate": "2000-03-14T00:00:00Z",
  "roles": ["student"],
  "classYear": 5,
  "classLetter": "А",
  "classId": "class-classroom-105",
  "classTeacherId": "f3.p1",
  "classTeacherName": "Иванова Ольга Михайловна",
  "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.

{
  "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 <embed:name> (weight ≠ 1: <embed:name:w>); 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 <embed:…> 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.

{
  "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 611 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 (positivestyle, kind positiveshotType).

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.

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

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

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

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

{
  "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
    }
  ],
  "classes": [
    {
      "id": "c5A",
      "year": 5,
      "letter": "А",
      "classTeacherId": "f3.p1",
      "classTeacherName": "Иванова Ольга Михайловна"
    }
  ]
}

classes lists every homeroom with an optional class-teacher slot. classTeacherId / classTeacherName are null when the slot is empty. Assign and clear (below) return this same payload so the Management panel can refresh the slots without a second GET.

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.

PUT /api/schools/{id}/classes/{classId}/class-teacher

Body: { "personId": "f3.p1" }. Sets the class-teacher slot on that class. Only a hired person with position Teacher is accepted — not a new PositionDef. One teacher may cover several classes. Goes through the school's mailbox; only the owner may call it (403 not-owner). Success returns the same payload as GET .../staffing (including classes), so it takes the same ?lang=ru|en.

Status code When
403 not-owner The session is not the school's owner.
404 unknown-school No school with that id.
404 unknown-class classId is not in the roster.
404 unknown-person personId is not on the roster.
400 not-teacher Person is not hired staff with position Teacher.

DELETE /api/schools/{id}/classes/{classId}/class-teacher

Clears the class-teacher slot. An empty slot is valid — the class lives as before; parent meetings (later) simply do not start. Same success payload and owner gate as the PUT.

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.

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.

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