Enhance school creation and map management features by updating the API to support mod packs and map layouts. Introduce a new map snapshot protocol for efficient data handling during school sessions. Revise documentation to reflect these changes, including updates to the protocol and architecture documents. Improve UI components for mod selection and map editing, ensuring a better user experience. Update tests to validate new functionalities and ensure robustness.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 14s

This commit is contained in:
Leonid Pershin
2026-08-18 15:15:49 +03:00
parent 1bc75244e8
commit 30cc937069
36 changed files with 1876 additions and 171 deletions
+9 -9
View File
@@ -87,8 +87,8 @@ produces the same date.
**Every school runs on its own.** A new school starts living immediately and keeps going whether
or not anybody is looking at it; only the player's pause button stops one, and that pause sticks
until they press play again. Opening a school subscribes the connection to its clock frames and
nothing more. One school's pause cannot stall another's calendar, because they do not share a
thread.
sends one map snapshot labelled in the Hello locale. One school's pause cannot stall another's
calendar, because they do not share a thread.
The main menu therefore re-reads `GET /api/schools` once a second while it is on screen — that is
how the cards tick. It patches the cards it already has instead of rebuilding them, so a refresh
@@ -105,14 +105,15 @@ mod folder or a map that no longer validates leaves the file in place and that s
## Connection lifetime
1. The browser opens `/ws/game`; `ClientRegistry` assigns a client id.
2. The client sends `Hello`; a version mismatch closes the socket.
2. The client sends `Hello` (version + UI locale); a version mismatch closes the socket.
3. `Welcome` goes out with the tick rate and the school limit, and the client is marked ready.
4. Opening a school enqueues `OpenSchool`; the worker starts pushing clock frames.
4. Opening a school enqueues `OpenSchool`; the worker sends a map snapshot then clock frames.
5. `SetRunning` and `SetSpeed` go to that school's mailbox; `CloseSchool` goes back to the menu.
6. On disconnect the client is removed; the school it was watching keeps running.
Outbound frames go through a bounded channel per connection (32 frames, drop-oldest). A client
that cannot keep up loses intermediate clock frames instead of stalling a worker.
Clock frames go through a bounded channel per connection (32 frames, drop-oldest). A client
that cannot keep up loses intermediate clock frames instead of stalling a worker. The map
snapshot uses a separate reliable queue so it cannot be dropped for a newer tick.
## Where to add things next
@@ -120,6 +121,5 @@ that cannot keep up loses intermediate clock frames instead of stalling a worker
`School.Tick`, and unit-test them against `School` directly — no server needed.
- **More state on the cards**: extend `SchoolState` and the JSON response; the menu reloads from
the server after every change, so nothing else has to know.
- **Create editor and the map snapshot**: the catalog and vanilla map exist; the player still
cannot pick mods or see the tree from the server. That work lives in
[`phases/04-create-editor.md`](phases/04-create-editor.md).
- **Create editor and the map snapshot**: done in this slice. Next game verbs (Sit) and the
event log are out of scope here.
+9 -9
View File
@@ -12,15 +12,15 @@
## Задачи
- [ ] `GET` списка модов: `core` как обязательный, остальные папки `mods/`
- [ ] `GET` каталога с `?lang=ru|en` (типы + локали) для `core` + выбранных id
- [ ] Hello несёт тот же locale; снимок карты при OpenSchool на этом языке
- [ ] `POST /api/schools` принимает доп. моды и раскладку; сервер всегда подставляет `core` первым и валидирует
- [ ] В диалоге создания: чекбоксы модов (`core` нельзя снять), редактор дерева/связей/слотов или сброс к дефолту
- [ ] При `OpenSchool` — один снимок карты (дерево + локации: имя, предметы, пустые персонажи и действия на месте, должности). Не на каждый клик, не 20 Гц
- [ ] Клиент фильтрует выбранный узел; часы как сейчас
- [ ] Протокол/HTTP описать в `docs/protocol.md` в том же коммите, что кодек
- [ ] Тесты API: create с картой, отказ на дырявый граф, открытие отдаёт снимок
- [x] `GET` списка модов: `core` как обязательный, остальные папки `mods/`
- [x] `GET` каталога с `?lang=ru|en` (типы + локали) для `core` + выбранных id
- [x] Hello несёт тот же locale; снимок карты при OpenSchool на этом языке
- [x] `POST /api/schools` принимает доп. моды и раскладку; сервер всегда подставляет `core` первым и валидирует
- [x] В диалоге создания: чекбоксы модов (`core` нельзя снять), редактор дерева/связей/слотов или сброс к дефолту
- [x] При `OpenSchool` — один снимок карты (дерево + локации: имя, предметы, пустые персонажи и действия на месте, должности). Не на каждый клик, не 20 Гц
- [x] Клиент фильтрует выбранный узел; часы как сейчас
- [x] Протокол/HTTP описать в `docs/protocol.md` в том же коммите, что кодек
- [x] Тесты API: create с картой, отказ на дырявый граф, открытие отдаёт снимок
## Критерий готовности
+1 -1
View File
@@ -15,4 +15,4 @@
| [1. Оболочка менеджера](01-manager-shell.md) | ✅ | Панели с секциями среза, пока без данных |
| [2. Работник школы и диск](02-school-worker.md) | ✅ | Поток + World + сейв — основа |
| [3. Каталог def и карта](03-defs-map.md) | ✅ | JSONC, core, валидация раскладки |
| [4. Моды и редактор в create](04-create-editor.md) | | Выбор модов, карта в POST, снимок при открытии |
| [4. Моды и редактор в create](04-create-editor.md) | | Выбор модов, карта в POST, снимок при открытии |
+78 -13
View File
@@ -1,10 +1,12 @@
# Wire protocol v3
# Wire protocol v4
The client talks to the server two ways:
- **HTTP/JSON** for the main menu — listing, creating and deleting schools. Those are
request/response by nature, so they are plain REST.
- **A binary WebSocket at `/ws/game`** for the school calendar, which changes 20 times a second.
- **HTTP/JSON** for the main menu — listing, creating and deleting schools, listing mods and
loading a catalog for the create editor. 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.**
@@ -48,15 +50,48 @@ Optional `?lang=en` draws from the English word list (`Northern Academy`); any o
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.
```json
{ "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`.
`lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is
Russian.
### `POST /api/schools`
Body: `{ "name": "Гимназия №14", "startDate": "2012-04-03T06:00:00Z" }`
Body:
```json
{
"name": "Гимназия №14",
"startDate": "2012-04-03T06:00:00Z",
"modIds": [],
"map": null
}
```
`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.
| 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. |
| `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.
@@ -83,17 +118,20 @@ frame is obvious at a glance.
| `0x82` | S → C | Pong |
| `0x83` | S → C | Clock |
| `0x84` | S → C | SchoolGone |
| `0x85` | S → C | MapSnapshot |
## Client → server
### `0x01` Hello — 2 bytes
### `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
@@ -104,8 +142,8 @@ Must be the first frame; the server drops the connection if it does not arrive w
### `0x03` OpenSchool — 5 bytes
Starts watching a school: clock frames for it begin to arrive. 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.
It does not start the calendar — every school runs on its own from the moment it is created.
| Offset | Type | Field |
| --- | --- | --- |
@@ -182,16 +220,43 @@ client returns to the menu.
| 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 |
| `u8` | item count, then that many strings |
| `u8` | position count, then that many strings |
## Guarantees and limits
- Frames larger than 8 KiB are refused with close status `1009 MessageTooBig`.
- 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 frames and drops the
oldest, because a stale clock is worthless once a newer one exists.
- 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 v3 yet
## Not in v4 yet
Saving schools to disk (they live in server memory), authentication, and any game state beyond the
calendar — the school's ECS world is created but still empty.
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.