Files
h-school/docs/protocol.md
T

19 KiB
Raw Blame History

Wire protocol v5

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, and reading a school's roster (filtered list + one-person card). Those are request/response by nature, so they are plain REST.
  • A binary WebSocket at /ws/game for the school calendar (20 Hz) and the one-shot map snapshot sent when a school is opened.

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 57 working days counted from Monday (five is MonFri; 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 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 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.

{
  "id": "f0.c0",
  "fullName": "Иванова Мария Петровна",
  "female": true,
  "age": 12,
  "birthDate": "2000-03-14T00:00:00Z",
  "roles": ["student"],
  "classYear": 5,
  "classLetter": "А",
  "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.

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
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 once when a school is opened (and again on reconnect OpenSchool). Not every tick, not on tree clicks. Labels are in the Hello locale. People and in-place activities are omitted — the client keeps those sections empty.

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.

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 of 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 v5 yet

Authentication, Sit orders, an event log, and OpenLocation on the server — the tree is filtered on the client from the snapshot. The school's ECS world is created but still empty.