- Updated protocol documentation to include new `classId` and `roomLabel` fields in the timetable API responses. - Added classes and rooms to the timetable response structure, improving data accessibility for client applications. - Enhanced the UI components to display timetable information, including class and room details, in the management and people panels. - Implemented functionality to fetch and display personal timetables for individuals, ensuring a comprehensive view of schedules. - Revised localization strings to support new timetable features and improve user experience. - Added tests to validate the new timetable functionalities and ensure robustness in handling timetable data.
21 KiB
Wire protocol v6
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), 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) and the map snapshot sent when a school is opened and whenever the current lesson slot changes.
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
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.
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.
{
"maxSchools": 6,
"defaultStartDate": "2012-03-31T06:00:00Z",
"gameMinutesPerRealSecond": 5,
"schoolWeekDays": 5,
"schools": [
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1 }
]
}
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
Folders under the server's mods/ directory. core is always first and required: true; other
packs can be switched off in the create dialog.
{ "mods": [{ "id": "core", "required": true }] }
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. mods is a comma-separated
list of extra pack ids; omit it for vanilla. Unknown extras return 400 unknown-mod.
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.
nameSets is the list of placeable name packs (defName + label). The create dialog picks one;
it is independent of the UI language.
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,
"nameSetId": "Slavic"
}
modIds are extras; the server always prepends core. 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.
nameSetId is a NameSetDef; omit it to use the first placeable set in the catalog (vanilla:
Slavic). Unknown ids return 400 unknown-name-set.
| 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 invalid-catalog |
The selected packs could not be loaded. |
400 unknown-name-set |
nameSetId is not a placeable NameSetDef in those packs. |
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 and needs 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}/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.
{
"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 }],
"family": {
"parents": [{ "id": "f0.p1", "fullName": "Иванова Ольга Михайловна", "female": true }],
"children": [],
"siblings": [{ "id": "f0.c1", "fullName": "Иванов Кирилл Петрович", "female": false }],
"partners": []
}
}
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. payroll is the sum of each staff member's
monthly base (hourlyWageAsk × baseWeeklyHours × weeksPerMonth) plus
extraSubjectSurcharge of that base for every subject after the first. The cap is checked
when hiring or assigning, not at month end; money itself does not move.
{
"allocated": 10000,
"payroll": 5000,
"remaining": 5000,
"uncovered": [
{
"defName": "Mathematics",
"label": "Математика",
"gradeMin": 5,
"gradeMax": 11,
"hoursPerWeek": 5
}
],
"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,
"monthlyPay": 5000,
"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 full set is on the person
card. 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. 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.
| 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. 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.
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 |
0x81 |
S → C | Welcome |
0x82 |
S → C | Pong |
0x83 |
S → C | Clock |
0x84 |
S → C | SchoolGone |
0x85 |
S → C | MapSnapshot |
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. 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 = ×3, 4 = ×4; out-of-range values are
ignored rather than fatal. The base rate is gameMinutesPerRealSecond (5), so ×1 is five game
minutes per real second.
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 — 15 bytes
Sent every tick to every connection that has a school open, and only to those.
| 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 |
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, on reconnect OpenSchool, and again when the current lesson slot changes (a bell, a weekend, a holiday — not every tick, not on tree clicks). Labels are in the Hello locale. Occupancy is computed from the timetable and the clock; the client must not derive it.
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 |
u8 |
1 if a lesson is in this room right now, then subject label + class label strings; 0 if free |
u8 |
character count, then that many name strings (teacher and pupils of the lesson) |
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. The location panel draws activity and characters from the last two fields.
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. AMapSnapshotof a large school legitimately exceeds it, and the server sizes that frame 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 uses a separate reliable queue so ticks cannot crowd it out.
Not in v6 yet
Authentication, Sit orders, an event log, walking, and OpenLocation on the server — the tree is
filtered on the client from the snapshot.