Swarm loads LoRAs via loras/loraweights; embeddings go in the prompt as embed tags, not lora tags. Co-authored-by: Cursor <cursoragent@cursor.com>
43 KiB
Wire protocol v9
The client talks to the server two ways:
- HTTP/JSON for the main menu and the in-school people browser — listing, creating and deleting schools, listing mods, loading a catalog for the create editor, reading a school's roster (filtered list + one-person card), a short id→name directory, staffing, and the timetable. Those are request/response by nature, so they are plain REST.
- A binary WebSocket at
/ws/gamefor 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, 1–40 chars). Occupancy is
case-insensitive: Leo and leo are the same person; the first spelling is kept in
saves/users.json.
| Status | code |
When |
|---|---|---|
401 |
bad-password |
Wrong alpha password |
400 |
invalid-name |
Name fails normalization |
409 |
name-online |
A live WebSocket already uses that name |
GET /api/session
Returns { "userName": "Leo" } when the cookie is valid, otherwise 401.
DELETE /api/session
Clears the session cookie. 204.
The WebSocket at /ws/game uses the same cookie on upgrade. Without a valid cookie the server
closes the connection with a policy violation and never sends Welcome. A second socket for a name
that already has a live connection is closed the same way; POST /api/session for that name
returns 409 name-online. Hello is unchanged (version + locale only).
GET /api/changelog
What landed in the running Server build since an earlier commit. Requires a session cookie.
current is the SHA baked at compile time (git rev-parse HEAD during that build), not the live
working tree. commits is git log --first-parent after since, oldest first, subjects that
match Mark phase <digits> omitted. Without since, with an unknown SHA, or when since equals
current, commits is empty — a first visit must not dump the whole history.
{
"current": "0123456789abcdef0123456789abcdef01234567",
"commits": [
{ "sha": "89abcdef0123456789abcdef0123456789abcdef", "date": "2026-08-20T08:21:00+00:00", "subject": "Merge branch 'phase/53-weather-commute'" }
]
}
Optional ?since= is a 40-character hex SHA from the client's hschool.seen-rev cookie. That
cookie is not HttpOnly and is not the session cookie. Protocol version is unchanged.
GET /api/schools
Everything the main menu needs in one request. schoolWeekDays is 5–7 working days counted
from Monday (five is Mon–Fri; six adds Saturday). It is a school rule, not a catalog def.
seed is the roster generator seed: it is not the school id. Share it to recreate the same
people; it does not change on a living school.
{
"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 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. |
{
"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.
talkCircleMemberIds is the live circle (including self, sorted) or []; talkTopicId is the
topic def id or null when not talking. Same ids as the presence frame; names are not repeated
here either — the Now tab uses the directory and locale, like the location panel.
skills lists only keys the person has, not every SkillDef in the catalog. A first-year has
no Chemistry; a related tongue from the name set may sit beside the native at a low value.
worn is what is on the body right now: def, colour, the layers it occupies, condition
(0–1) and conditionLabel from the catalog bands (целая / поношенная / порванная /
висит лохмотьями). The client draws the bar and the caption; it does not compute thresholds.
carried is the bag — textbooks include subject. carryMass / carryCapacity are kilograms;
overload does not slow walking. hasLocker is true when the pupil has an assigned locker slot
(lockerRoomId) or anything sits in a locker; homeCount
is how many items remain at home, not the list. The people list does not include any of these
fields. Today's history is a separate GET.
hasAvatar, hasCustom and hasFullBody tell the client whether PNG files already exist on disk for this
person. customPortraitPrompt is the last saved user prompt for the custom variant (null when none).
They are filled on the HTTP thread after the worker returns the card; generation does
not happen on this request.
connections is the Связи tab: family links with opinion / opinionLabel from catalog bands,
plus friends, enemies and others (every non-family non-zero pair for this person only).
When a school was created with a pack that ships orientations, the card also carries
orientation (defName + label) and connections.crushes / admirers / pair. Without that
pack those fields are null or empty — the client hides the sympathy column. The client does not
compute thresholds. There is no school-wide opinions endpoint.
{
"id": "f0.c0",
"fullName": "Иванова Мария Петровна",
"female": true,
"age": 12,
"birthDate": "2000-03-14T00:00:00Z",
"roles": ["student"],
"classYear": 5,
"classLetter": "А",
"classId": "class-classroom-105",
"body": [{ "id": "Height", "label": "Рост", "value": "164" }],
"skills": [{ "id": "Math", "label": "Математика", "value": "62" }],
"traits": [{ "defName": "Diligent", "label": "Усидчивый" }],
"needs": [{ "id": "Sleep", "label": "Сон", "value": 1 }],
"activity": null,
"activityLabel": null,
"talkCircleMemberIds": [],
"talkTopicId": null,
"family": {
"parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }],
"children": [],
"siblings": [{ "id": "f0.c1", "fullName": "Иванов Кирилл Петрович", "female": false }],
"partners": []
},
"worn": [
{
"defName": "Shirt",
"label": "Рубашка",
"color": "White",
"colorLabel": "Белый",
"layers": [{ "defName": "Top", "label": "Верх" }],
"condition": 1,
"conditionLabel": "целая"
}
],
"carried": [
{
"defName": "Textbook",
"label": "Учебник",
"color": null,
"colorLabel": null,
"subject": "Mathematics",
"subjectLabel": "Математика",
"mass": 0.4
}
],
"carryMass": 1.2,
"carryCapacity": 14,
"hasLocker": false,
"homeCount": 3,
"hasAvatar": false,
"hasCustom": false,
"hasFullBody": false,
"customPortraitPrompt": null,
"connections": {
"family": {
"parents": [
{
"id": "f0.p1",
"fullName": "Иванова Ольга Михайловна",
"female": true,
"opinion": 75,
"opinionLabel": "близкие друзья"
}
],
"children": [],
"siblings": [
{
"id": "f0.c1",
"fullName": "Иванов Кирилл Петрович",
"female": false,
"opinion": 45,
"opinionLabel": "друзья"
}
],
"partners": []
},
"friends": [],
"enemies": [],
"others": []
}
}
GET /api/schools/{id}/people/{personId}/log
Today's history for one person: search, sort by time, page. Goes through the school's mailbox
because the log lives on the worker, not in people.json and not on a published snapshot.
Unknown school is 404 unknown-school; unknown person is 404 unknown-person. This is
not a socket feed and not a school-wide event list.
The day boundary is six in the morning — the same hour skip lands on. Crossing that hour, or
skipping an empty night, drops yesterday's rows. ?lang=ru|en labels the captions. q is a
substring of the caption or type. sort is time. dir is asc or desc (default desc).
page starts at 1; pageSize defaults to 20 and is at most 100. A thousand rows do not
arrive in one response.
Row type values: action-started, action-ended, apparel-replaced (morning issue, when
phase 34 appends it), apparel-changed (dressing, when phase 35 appends it),
lesson-no-teacher (the assigned teacher was not standing in the lesson room),
lesson-cold (warmth below the behaviour threshold during a lesson that otherwise taught),
lesson-no-textbook (the bag had no textbook for that lesson; locker and home do not count).
thingDef is the action, apparel or subject def the caption was built from.
{
"total": 2,
"page": 1,
"pageSize": 20,
"entries": [
{
"time": "2012-04-03T12:00:00Z",
"type": "action-started",
"label": "начал: Обед",
"thingDef": "EatLunch"
}
]
}
GET /api/schools/{id}/people/{personId}/portrait
Returns a generated PNG when one exists. Query kind=avatar|custom|full selects head-and-shoulders,
a custom prompt variant or full-body. Unknown school is 404 unknown-school; unknown person is 404 unknown-person;
missing file is 404 portrait-missing. Invalid kind is 400 invalid-query. Content-Type
is image/png. Opening the card does not generate; use POST when the player asks.
POST /api/schools/{id}/people/{personId}/portrait
Generates (or regenerates) a portrait through SwarmUI on the server. Same kind query as GET.
For kind=custom the body is { "promptExtra": "..." } — appended after the shot type (and the
model/style layers), then the person's appearance and clothing; required, non-empty, at most 2000
characters. Avatar and full-body POST need no body.
Success is 201 with { "kind", "hasAvatar", "hasCustom", "hasFullBody", "customPortraitPrompt" } and a Location header pointing
at GET. SwarmUI is not configured when SwarmUi:BaseUrl is empty — 503 swarmui-not-configured.
Swarm errors are 502 swarmui-unavailable; a slow backend is 504 swarmui-timeout. Files
land under saves/{id}.portraits/ and survive until the school is deleted.
GET /api/schools/{id}/people/{personId}/portrait/prompt
Returns the positive and negative prompts SwarmUI would receive, without generating an image.
Same kind query as GET portrait. For kind=custom, optional query promptExtra is appended after
the shot type; when omitted, the last saved custom prompt is used if one exists. Configured
embeddings appear as <embed:name> (weight ≠ 1: <embed:name:w>); LoRA is not written into the
prompt. Unknown school is 404 unknown-school; unknown person is 404 unknown-person. Invalid
kind is 400 invalid-query; custom without a usable prompt is 400 invalid-body.
{
"kind": "avatar",
"positive": "cinematic photo, …, close up, head and shoulders portrait, …, age 12, …",
"negative": "(low quality, worst quality:1.4), …",
"promptExtra": null,
"presetId": "default",
"presetLabel": "Default"
}
GET /api/status includes swarmUiConfigured and swarmUiConnected (null when not configured)
so the client can disable generate buttons and show reachability without trying POST first.
GET /api/settings/swarmui
Returns the default SwarmUI template (swarmui.json): a models catalog (id, default
generation knobs, usually empty base positive/negative, LoRA and embedding lists), named presets
(model id, style, extra negative, optional generation overrides, per-kind size/shotType, LoRA
and embedding lists), activePresetId and ageRules. LoRA and embeddings on the model, preset and
shot kind concatenate at generate time.
The model picker in the UI is Swarm's list intersected with models. A new school copies this into
its save as portraitSettings. Living schools generate from that copy, not from this file. Older
saves without models lift on load (positive → style, kind positive → shotType).
PUT /api/settings/swarmui
Replaces the default template after validation. Invalid model/preset ids, age rules or numeric ranges
return 400 invalid-body. Already-created schools keep the copy they were created with.
GET /api/settings/swarmui/discovery
When SwarmUI is configured and reachable, proxies ListT2IParams and returns
{ connected, models, loras, embeddings, samplers, schedulers } for the settings UI. LoRA and
embeddings can be set on the model, the preset, and each shot kind; layers concatenate. The client
hides Swarm models that are missing from the template catalog. When SwarmUI is off or unreachable,
connected is false and the lists are empty; the picker then shows the catalog ids so fields can
be filled manually.
GET /api/schools/{id}/dress-rules
Student and staff dress-code pairs for the school. Only the owner may read this (403 not-owner).
Unknown {id} is 404 unknown-school.
form is one of regular, short, strict. color is one of noBright, whiteTopBlackBottom,
free. When a POST has been accepted but not yet applied, pendingStudents and/or pendingStaff
show what takes effect on the next work morning (six o'clock on a weekday outside holidays) —
not immediately.
{
"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}/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
}
]
}
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.
{
"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 0x00–0x7F, server-to-client ids in 0x80–0xFF, so a misrouted
frame is obvious at a glance.
| Id | Direction | Message |
|---|---|---|
0x01 |
C → S | Hello |
0x02 |
C → S | Ping |
0x03 |
C → S | OpenSchool |
0x04 |
C → S | CloseSchool |
0x05 |
C → S | SetRunning |
0x06 |
C → S | SetSpeed |
0x07 |
C → S | SkipEmpty |
0x81 |
S → C | Welcome |
0x82 |
S → C | Pong |
0x83 |
S → C | Clock |
0x84 |
S → C | SchoolGone |
0x85 |
S → C | MapSnapshot |
0x86 |
S → C | Presence |
Client → server
0x01 Hello — 3 bytes
Must be the first frame; the server drops the connection if it does not arrive within 5 seconds.
The locale byte is the same language the catalog HTTP API takes as ?lang=.
| Offset | Type | Field |
|---|---|---|
| 0 | u8 |
0x01 |
| 1 | u8 |
protocol version |
| 2 | u8 |
locale: 0 Russian, 1 English; any other value is treated as Russian |
0x02 Ping — 9 bytes
| Offset | Type | Field |
|---|---|---|
| 0 | u8 |
0x02 |
| 1 | i64 |
client clock in milliseconds |
0x03 OpenSchool — 5 bytes
Starts watching a school: a map snapshot in the Hello locale arrives once, then clock frames and presence frames. It does not start the calendar — every school runs on its own from the moment it is created.
| Offset | Type | Field |
|---|---|---|
| 0 | u8 |
0x03 |
| 1 | i32 |
school id |
0x04 CloseSchool — 1 byte
Back to the menu: the clock frames stop. The school keeps running — only SetRunning pauses it,
and that pause survives leaving and reconnecting.
0x05 SetRunning — 2 bytes
| Offset | Type | Field |
|---|---|---|
| 0 | u8 |
0x05 |
| 1 | u8 |
1 running, 0 paused |
0x06 SetSpeed — 2 bytes
| Offset | Type | Field |
|---|---|---|
| 0 | u8 |
0x06 |
| 1 | u8 |
speed index |
Running and speed are separate messages on purpose. A single "set clock" message forces each button to resend the other field from the client's own copy of the state, which is always at least one tick stale — pressing play and then a speed button would pause the school again.
Speed indexes are 0 = ×½, 1 = ×1, 2 = ×2, 3 = ×5, 4 = ×10; out-of-range values are
ignored rather than fatal. The base rate is gameMinutesPerRealSecond (1), so ×1 is one game
minute per real second.
0x07 SkipEmpty — 1 byte
Jump empty nights, weekends and holidays. The server re-checks both conditions (campus empty and outside the day-frame work window) — a frame from the browser is untrusted. Ignored when the skip is not legal; the calendar does not move.
Running, speed and skip are separate messages on purpose. A button that also resent a neighbouring field would clobber it with a stale client copy.
Server → client
0x81 Welcome — 4 bytes
The first frame the client receives.
| Offset | Type | Field |
|---|---|---|
| 0 | u8 |
0x81 |
| 1 | u8 |
protocol version |
| 2 | u8 |
tick rate in Hz |
| 3 | u8 |
maximum number of schools |
0x82 Pong — 13 bytes
| Offset | Type | Field |
|---|---|---|
| 0 | u8 |
0x82 |
| 1 | i64 |
client clock, echoed unchanged |
| 9 | u32 |
server tick when the ping was handled |
0x83 Clock — 27 bytes
Sent every tick to every connection that has a school open, and only to those.
skipAllowed is the server's verdict; the client must not recompute it.
skipTargetUnixMs is 0 when skip is refused.
Temperature is outdoor tenths of a °C (i16, so −50 is −5.0 °C). Precipitation is 0 none,
1 rain, 2 snow. The client must not derive weather from the month.
| Offset | Type | Field |
|---|---|---|
| 0 | u8 |
0x83 |
| 1 | i32 |
school id |
| 5 | i64 |
in-game date, milliseconds since the Unix epoch, read as UTC |
| 13 | u8 |
1 running, 0 paused |
| 14 | u8 |
speed index |
| 15 | u8 |
1 skip allowed, 0 refused |
| 16 | i64 |
skip target, milliseconds since the Unix epoch, UTC; 0 if refused |
| 24 | i16 |
outdoor temperature, tenths of a °C |
| 26 | u8 |
precipitation: 0 none, 1 rain, 2 snow |
0x84 SchoolGone — 5 bytes
The open school no longer exists — deleted from the menu in another tab, or never existed. The client returns to the menu.
| Offset | Type | Field |
|---|---|---|
| 0 | u8 |
0x84 |
| 1 | i32 |
school id |
0x85 MapSnapshot — variable
Sent when a school is opened, and on reconnect OpenSchool. Structure only — people and the current lesson ride the presence stream. Labels are in the Hello locale.
Strings are u16 byte length + UTF-8. Empty string is a zero length.
| Offset | Type | Field |
|---|---|---|
| 0 | u8 |
0x85 |
| 1 | i32 |
school id |
| 5 | u16 |
node count |
| 7… | nodes |
Each node:
| Type | Field |
|---|---|
u8 |
kind: 0 territory, 1 building, 2 floor, 3 room |
| string | instance id |
| string | parent id (empty for the yard) |
| string | display name |
u16 |
pupil slots — how many pupils can take a lesson here. Summed from things on the server. |
u8 |
item count, then that many records of: string name + u8 count |
u8 |
position count, then that many strings |
Item count is how many of that thing stand in the room (Парта ×16 is one record, not sixteen). The client must not recompute pupil slots from items.
0x86 Presence — variable
Live occupancy of an open school, about twice a second, and also once on OpenSchool and after a successful empty-time skip. Covers the whole map; the client filters to the selected tree node. Off-campus people are omitted — a missing id means they are away. Walking people occupy their current node. Names are resolved over HTTP, not on this frame.
Nodes listed are occupied or currently taught (count may be 0). Sorted by id. Labels are in the Hello locale, encoded per client.
| Offset | Type | Field |
|---|---|---|
| 0 | u8 |
0x86 |
| 1 | i32 |
school id |
| 5 | u16 |
node count |
| 7… | nodes, then u16 person count, then people |
Each node:
| Type | Field |
|---|---|
| string | instance id |
u16 |
headcount in this node |
u8 |
1 if a lesson is in this room right now, then subject label + class label strings; 0 if free |
Each person:
| Type | Field |
|---|---|
| string | person id |
| string | node id they occupy |
u8 |
1 here, 2 walking |
u8 |
talk-circle member count, then that many person-id strings |
| string | topic id (empty when not in a circle) |
Member ids are the live circle, including self, sorted by id. Count 0 and an empty topic mean
the person is not talking — the same id/node/state as before the circle fields. Names are not
on this frame; the client builds «говорит с Машей о футболе» from the HTTP directory and locale.
Guarantees and limits
- Inbound frames larger than 8 KiB are refused with close status
1009 MessageTooBig. That limit is about what the server reads; it does not bound what the server sends. AMapSnapshotorPresenceframe of a large school legitimately exceeds it, and the server sizes those frames from the message. - A malformed frame closes the connection with
1007 InvalidPayloadData. - Unknown message ids are ignored rather than fatal, so new ids can be added without breaking older clients within the same protocol version.
- Clock delivery is lossy under back pressure: each connection buffers 32 clock frames and drops the oldest, because a stale clock is worthless once a newer one exists.
- The map snapshot and presence use a separate reliable queue so ticks cannot crowd them out.
Not in v9 yet
Authentication, Sit orders, an event log, walk animation, and OpenLocation on the server —
the tree and the location panel are filtered on the client from the snapshot plus presence.