Update wire protocol to version 7 and enhance presence management features
ci / server (push) Failing after 3m44s
ci / client (push) Successful in 15s

- Bumped the wire protocol version to 7, reflecting significant changes in the communication structure.
- Introduced a new presence message type for real-time occupancy updates, including node activity and individual presence states.
- Updated the API to include a directory endpoint for fetching short id→name mappings, improving client-side name resolution.
- Revised the map snapshot structure to be static, with people and current lessons now handled through the presence stream.
- Enhanced client-side handling of presence updates, including UI adjustments to display live occupancy and activity.
- Updated documentation to reflect the new protocol features and changes in presence management.
- Added tests to validate the new presence functionalities and ensure robust handling of real-time data.
This commit is contained in:
Leonid Pershin
2026-08-19 17:00:53 +03:00
parent 4137400621
commit 3c54f981b7
31 changed files with 1025 additions and 373 deletions
+84 -19
View File
@@ -1,13 +1,13 @@
# Wire protocol v6
# Wire protocol v7
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
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) and the map snapshot
sent when a school is opened and whenever the current lesson slot changes.
- **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz), a static map snapshot
sent once when a school is opened, and a presence stream (~2 Hz) of who is where.
This document covers both. One protocol message per WebSocket frame, no framing header beyond the
message id. **All multi-byte numbers are little-endian.**
@@ -188,6 +188,25 @@ Applicants live in `saves/{id}.people.json` next to the roster and are **not** i
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.
```json
{
"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
@@ -415,11 +434,13 @@ frame is obvious at a glance.
| `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
@@ -443,8 +464,9 @@ The locale byte is the same language the catalog HTTP API takes as `?lang=`.
### `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.
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 |
| --- | --- | --- |
@@ -478,6 +500,15 @@ Speed indexes are `0 = ×½`, `1 = ×1`, `2 = ×2`, `3 = ×3`, `4 = ×4`; out-of
ignored rather than fatal. The base rate is `gameMinutesPerRealSecond` (5), so ×1 is five game
minutes 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
@@ -499,9 +530,11 @@ The first frame the client receives.
| 1 | `i64` | client clock, echoed unchanged |
| 9 | `u32` | server tick when the ping was handled |
### `0x83` Clock — 15 bytes
### `0x83` Clock — 24 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.
| Offset | Type | Field |
| --- | --- | --- |
@@ -510,6 +543,8 @@ Sent every tick to every connection that has a school open, and only to those.
| 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 |
### `0x84` SchoolGone — 5 bytes
@@ -523,10 +558,8 @@ client returns to the menu.
### `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.
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.
@@ -548,24 +581,56 @@ Each node:
| `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.
Item `count` is how many of that thing stand in the room (`Парта ×16` is one record, not sixteen). The client must not recompute pupil slots from items.
### `0x86` Presence — variable
Live occupancy of an open school, about twice a second, and also once on OpenSchool and after
a successful empty-time skip. Covers the whole map; the client filters to the selected tree
node. Off-campus people are omitted — a missing id means they are away. Walking people occupy
their **current** node. Names are resolved over HTTP, not on this frame.
Nodes listed are occupied **or** currently taught (count may be 0). Sorted by id. Labels are
in the Hello locale, encoded per client.
| Offset | Type | Field |
| --- | --- | --- |
| 0 | `u8` | `0x86` |
| 1 | `i32` | school id |
| 5 | `u16` | node count |
| 7… | | nodes, then `u16` person count, then people |
Each node:
| Type | Field |
| --- | --- |
| string | instance id |
| `u16` | headcount in this node |
| `u8` | `1` if a lesson is in this room right now, then subject label + class label strings; `0` if free |
Each person:
| Type | Field |
| --- | --- |
| string | person id |
| string | node id they occupy |
| `u8` | `1` here, `2` walking |
## Guarantees and limits
- **Inbound** frames larger than 8 KiB are refused with close status `1009 MessageTooBig`. That
limit is about what the server reads; it does not bound what the server sends. A `MapSnapshot`
of a large school legitimately exceeds it, and the server sizes that frame from the message.
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 uses a separate reliable queue so ticks cannot crowd it out.
- The map snapshot and presence use a separate reliable queue so ticks cannot crowd them out.
## Not in v6 yet
## Not in v7 yet
Authentication, Sit orders, an event log, walking, and `OpenLocation` on the server — the tree is
filtered on the client from the snapshot.
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.