Update wire protocol to version 7 and enhance presence management features
- 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:
@@ -12,33 +12,33 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Новое сообщение присутствия, ~2 раза в секунду, только по открытой школе: узлы со
|
- [x] Новое сообщение присутствия, ~2 раза в секунду, только по открытой школе: узлы со
|
||||||
счётчиками и люди с их узлом и состоянием
|
счётчиками и люди с их узлом и состоянием
|
||||||
- [ ] Снимок карты становится **статическим**: люди и текущий урок уезжают в присутствие,
|
- [x] Снимок карты становится **статическим**: люди и текущий урок уезжают в присутствие,
|
||||||
структура уходит один раз при открытии. Пересылка на смене слота убирается
|
структура уходит один раз при открытии. Пересылка на смене слота убирается
|
||||||
- [ ] `ProtocolCodec.cs`, `protocol.ts` и `docs/protocol.md` правятся тем же коммитом, версия
|
- [x] `ProtocolCodec.cs`, `protocol.ts` и `docs/protocol.md` правятся тем же коммитом, версия
|
||||||
бумпится до 7
|
бумпится до 7
|
||||||
- [ ] Справочник «id → имя» по HTTP: клиент забирает один раз при открытии школы и перечитывает,
|
- [x] Справочник «id → имя» по HTTP: клиент забирает один раз при открытии школы и перечитывает,
|
||||||
увидев незнакомый id. Имена в кадр присутствия не кладутся
|
увидев незнакомый id. Имена в кадр присутствия не кладутся
|
||||||
- [ ] Дерево: число людей рядом с узлом, живое. Пустой узел остаётся без числа
|
- [x] Дерево: число людей рядом с узлом, живое. Пустой узел остаётся без числа
|
||||||
- [ ] Панель локации: кто здесь сейчас, списком с именами; урок в комнате берётся из присутствия,
|
- [x] Панель локации: кто здесь сейчас, списком с именами; урок в комнате берётся из присутствия,
|
||||||
а не из снимка
|
а не из снимка
|
||||||
- [ ] Карточка человека: где он сейчас — узел, «в пути» или «вне школы»
|
- [x] Карточка человека: где он сейчас — узел, «в пути» или «вне школы»
|
||||||
- [ ] Присутствие приходит по всей школе, выбранный узел фильтрует клиент
|
- [x] Присутствие приходит по всей школе, выбранный узел фильтрует клиент
|
||||||
- [ ] Кадр часов несёт разрешение на пропуск; клиент это разрешение **не вычисляет**
|
- [x] Кадр часов несёт разрешение на пропуск; клиент это разрешение **не вычисляет**
|
||||||
- [ ] Намерение «пропустить» — своё сообщение, как пауза и скорость. Сервер проверяет условия
|
- [x] Намерение «пропустить» — своё сообщение, как пауза и скорость. Сервер проверяет условия
|
||||||
заново: кадр из браузера не доверенный
|
заново: кадр из браузера не доверенный
|
||||||
- [ ] Кнопка рядом с паузой и скоростями: появляется только когда сервер разрешил, подписана
|
- [x] Кнопка рядом с паузой и скоростями: появляется только когда сервер разрешил, подписана
|
||||||
датой, к которой перенесёт
|
датой, к которой перенесёт
|
||||||
- [ ] Строки через `t(...)`, обе локали
|
- [x] Строки через `t(...)`, обе локали
|
||||||
|
|
||||||
## Тесты, без которых фаза не закрыта
|
## Тесты, без которых фаза не закрыта
|
||||||
|
|
||||||
- [ ] Байтовая раскладка присутствия сходится с обеих сторон
|
- [x] Байтовая раскладка присутствия сходится с обеих сторон
|
||||||
- [ ] Круговой прогон сообщения: сервер → клиент → те же значения
|
- [x] Круговой прогон сообщения: сервер → клиент → те же значения
|
||||||
- [ ] Снимок карты больше не содержит людей и урока
|
- [x] Снимок карты больше не содержит людей и урока
|
||||||
- [ ] Кадр часов с разрешением на пропуск читается обеими сторонами одинаково
|
- [x] Кадр часов с разрешением на пропуск читается обеими сторонами одинаково
|
||||||
- [ ] Пропуск, присланный в учебное время, сервер отбрасывает, а не исполняет
|
- [x] Пропуск, присланный в учебное время, сервер отбрасывает, а не исполняет
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [18. Присутствие и ходьба](18-presence-walking.md) | ✅ | `HSchool.Ai`, место человека, маршруты, приход и уход |
|
| [18. Присутствие и ходьба](18-presence-walking.md) | ✅ | `HSchool.Ai`, место человека, маршруты, приход и уход |
|
||||||
| [19. Присутствие на экране](19-presence-screen.md) | ⬜ | Своё сообщение ~2 Гц, числа в дереве, статический снимок карты, пропуск пустого времени |
|
| [19. Присутствие на экране](19-presence-screen.md) | ✅ | Своё сообщение ~2 Гц, числа в дереве, статический снимок карты, пропуск пустого времени |
|
||||||
|
|
||||||
**Этап B — поведение.** Появляются нужды, действия и выбор между ними и обязанностью.
|
**Этап B — поведение.** Появляются нужды, действия и выбор между ними и обязанностью.
|
||||||
|
|
||||||
|
|||||||
+84
-19
@@ -1,13 +1,13 @@
|
|||||||
# Wire protocol v6
|
# Wire protocol v7
|
||||||
|
|
||||||
The client talks to the server two ways:
|
The client talks to the server two ways:
|
||||||
|
|
||||||
- **HTTP/JSON** for the main menu and the in-school people browser — listing, creating and
|
- **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
|
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.
|
by nature, so they are plain REST.
|
||||||
- **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz) and the map snapshot
|
- **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz), a static map snapshot
|
||||||
sent when a school is opened and whenever the current lesson slot changes.
|
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
|
This document covers both. One protocol message per WebSocket frame, no framing header beyond the
|
||||||
message id. **All multi-byte numbers are little-endian.**
|
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
|
They are not school staff until hired. A parent who is also looking for work keeps the same id
|
||||||
in both places.
|
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}`
|
### `GET /api/schools/{id}/people/{personId}`
|
||||||
|
|
||||||
One person's card. Goes through the school's mailbox because need values live on entities and
|
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 |
|
| `0x04` | C → S | CloseSchool |
|
||||||
| `0x05` | C → S | SetRunning |
|
| `0x05` | C → S | SetRunning |
|
||||||
| `0x06` | C → S | SetSpeed |
|
| `0x06` | C → S | SetSpeed |
|
||||||
|
| `0x07` | C → S | SkipEmpty |
|
||||||
| `0x81` | S → C | Welcome |
|
| `0x81` | S → C | Welcome |
|
||||||
| `0x82` | S → C | Pong |
|
| `0x82` | S → C | Pong |
|
||||||
| `0x83` | S → C | Clock |
|
| `0x83` | S → C | Clock |
|
||||||
| `0x84` | S → C | SchoolGone |
|
| `0x84` | S → C | SchoolGone |
|
||||||
| `0x85` | S → C | MapSnapshot |
|
| `0x85` | S → C | MapSnapshot |
|
||||||
|
| `0x86` | S → C | Presence |
|
||||||
|
|
||||||
## Client → server
|
## Client → server
|
||||||
|
|
||||||
@@ -443,8 +464,9 @@ The locale byte is the same language the catalog HTTP API takes as `?lang=`.
|
|||||||
|
|
||||||
### `0x03` OpenSchool — 5 bytes
|
### `0x03` OpenSchool — 5 bytes
|
||||||
|
|
||||||
Starts watching a school: a map snapshot in the Hello locale arrives once, then clock frames.
|
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.
|
and presence frames. It does not start the calendar — every school runs on its own from the
|
||||||
|
moment it is created.
|
||||||
|
|
||||||
| Offset | Type | Field |
|
| 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
|
ignored rather than fatal. The base rate is `gameMinutesPerRealSecond` (5), so ×1 is five game
|
||||||
minutes per real second.
|
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
|
## Server → client
|
||||||
|
|
||||||
### `0x81` Welcome — 4 bytes
|
### `0x81` Welcome — 4 bytes
|
||||||
@@ -499,9 +530,11 @@ The first frame the client receives.
|
|||||||
| 1 | `i64` | client clock, echoed unchanged |
|
| 1 | `i64` | client clock, echoed unchanged |
|
||||||
| 9 | `u32` | server tick when the ping was handled |
|
| 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.
|
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 |
|
| 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 |
|
| 5 | `i64` | in-game date, milliseconds since the Unix epoch, read as UTC |
|
||||||
| 13 | `u8` | `1` running, `0` paused |
|
| 13 | `u8` | `1` running, `0` paused |
|
||||||
| 14 | `u8` | speed index |
|
| 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
|
### `0x84` SchoolGone — 5 bytes
|
||||||
|
|
||||||
@@ -523,10 +558,8 @@ client returns to the menu.
|
|||||||
|
|
||||||
### `0x85` MapSnapshot — variable
|
### `0x85` MapSnapshot — variable
|
||||||
|
|
||||||
Sent when a school is opened, on reconnect OpenSchool, and again when the current lesson slot
|
Sent when a school is opened, and on reconnect OpenSchool. Structure only — people and the
|
||||||
changes (a bell, a weekend, a holiday — not every tick, not on tree clicks). Labels are in the
|
current lesson ride the presence stream. Labels are in the Hello locale.
|
||||||
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.
|
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. |
|
| `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` | item count, then that many records of: string name + `u8` count |
|
||||||
| `u8` | position count, then that many strings |
|
| `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
|
## Guarantees and limits
|
||||||
|
|
||||||
- **Inbound** frames larger than 8 KiB are refused with close status `1009 MessageTooBig`. That
|
- **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`
|
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`.
|
- 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
|
- Unknown message ids are ignored rather than fatal, so new ids can be added without breaking
|
||||||
older clients within the same protocol version.
|
older clients within the same protocol version.
|
||||||
- Clock delivery is lossy under back pressure: each connection buffers 32 clock frames and drops
|
- 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 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
|
Authentication, Sit orders, an event log, walk animation, and `OpenLocation` on the server —
|
||||||
filtered on the client from the snapshot.
|
the tree and the location panel are filtered on the client from the snapshot plus presence.
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ describe('t', () => {
|
|||||||
.toBe('Not enough money: 8 000 of 10 000 is committed, 2 000 free, 12 000 needed.');
|
.toBe('Not enough money: 8 000 of 10 000 is committed, 2 000 free, 12 000 needed.');
|
||||||
expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 5Б' }))
|
expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 5Б' }))
|
||||||
.toBe('Кабинет 204 (Математика · 5Б)');
|
.toBe('Кабинет 204 (Математика · 5Б)');
|
||||||
|
expect(t('mapHeadcount', { name: 'Коридор', count: 12 })).toBe('Коридор (12)');
|
||||||
|
expect(t('mapHeadcountActivity', { name: 'Класс 101', count: 18, activity: 'Математика · 5А' }))
|
||||||
|
.toBe('Класс 101 (18 · Математика · 5А)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,12 @@ const ru = {
|
|||||||
staffErrorSubject: 'Такого предмета нет.',
|
staffErrorSubject: 'Такого предмета нет.',
|
||||||
|
|
||||||
mapOccupancy: '{name} ({activity})',
|
mapOccupancy: '{name} ({activity})',
|
||||||
|
mapHeadcount: '{name} ({count})',
|
||||||
|
mapHeadcountActivity: '{name} ({count} · {activity})',
|
||||||
|
skipTo: 'Пропустить до {date}',
|
||||||
|
presenceAt: '{name}',
|
||||||
|
presenceWalking: 'в пути ({name})',
|
||||||
|
presenceAway: 'вне школы',
|
||||||
timetableTitle: 'Расписание',
|
timetableTitle: 'Расписание',
|
||||||
timetableClass: 'Класс',
|
timetableClass: 'Класс',
|
||||||
timetableEmpty: 'Нет уроков.',
|
timetableEmpty: 'Нет уроков.',
|
||||||
@@ -360,6 +366,12 @@ const en: Messages = {
|
|||||||
staffErrorSubject: 'That subject is not in the catalog.',
|
staffErrorSubject: 'That subject is not in the catalog.',
|
||||||
|
|
||||||
mapOccupancy: '{name} ({activity})',
|
mapOccupancy: '{name} ({activity})',
|
||||||
|
mapHeadcount: '{name} ({count})',
|
||||||
|
mapHeadcountActivity: '{name} ({count} · {activity})',
|
||||||
|
skipTo: 'Skip to {date}',
|
||||||
|
presenceAt: '{name}',
|
||||||
|
presenceWalking: 'walking ({name})',
|
||||||
|
presenceAway: 'off campus',
|
||||||
timetableTitle: 'Timetable',
|
timetableTitle: 'Timetable',
|
||||||
timetableClass: 'Class',
|
timetableClass: 'Class',
|
||||||
timetableEmpty: 'No lessons.',
|
timetableEmpty: 'No lessons.',
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function bootstrap(): void {
|
|||||||
onLeave: () => leaveSchool(),
|
onLeave: () => leaveSchool(),
|
||||||
onSetRunning: (running) => connection.setRunning(running),
|
onSetRunning: (running) => connection.setRunning(running),
|
||||||
onSetSpeed: (speedIndex) => connection.setSpeed(speedIndex),
|
onSetSpeed: (speedIndex) => connection.setSpeed(speedIndex),
|
||||||
|
onSkip: () => connection.skipEmpty(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const connection = new GameConnection(gameSocketUrl(), {
|
const connection = new GameConnection(gameSocketUrl(), {
|
||||||
@@ -49,6 +50,11 @@ function bootstrap(): void {
|
|||||||
game.applyMap(snapshot.schoolId, snapshot.nodes);
|
game.applyMap(snapshot.schoolId, snapshot.nodes);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onPresence: (presence) => {
|
||||||
|
if (openSchool?.id === presence.schoolId) {
|
||||||
|
game.applyPresence(presence.schoolId, presence);
|
||||||
|
}
|
||||||
|
},
|
||||||
onSchoolGone: (schoolId) => {
|
onSchoolGone: (schoolId) => {
|
||||||
// Deleted from another tab while we were inside it.
|
// Deleted from another tab while we were inside it.
|
||||||
if (openSchool?.id === schoolId) {
|
if (openSchool?.id === schoolId) {
|
||||||
|
|||||||
@@ -292,6 +292,19 @@ export async function fetchPerson(schoolId: number, personId: string, lang: stri
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DirectoryPerson {
|
||||||
|
readonly id: string;
|
||||||
|
readonly fullName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchDirectory(schoolId: number, lang: string): Promise<readonly DirectoryPerson[]> {
|
||||||
|
const params = new URLSearchParams({ lang });
|
||||||
|
const response = await request<{ people: readonly DirectoryPerson[] }>(
|
||||||
|
`/api/schools/${schoolId}/directory?${params.toString()}`,
|
||||||
|
);
|
||||||
|
return response.people;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StaffingSubject {
|
export interface StaffingSubject {
|
||||||
readonly defName: string;
|
readonly defName: string;
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
encodePing,
|
encodePing,
|
||||||
encodeSetRunning,
|
encodeSetRunning,
|
||||||
encodeSetSpeed,
|
encodeSetSpeed,
|
||||||
|
encodeSkipEmpty,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
type ClockMessage,
|
type ClockMessage,
|
||||||
type MapSnapshotMessage,
|
type MapSnapshotMessage,
|
||||||
|
type PresenceMessage,
|
||||||
type ServerMessage,
|
type ServerMessage,
|
||||||
type WelcomeMessage,
|
type WelcomeMessage,
|
||||||
} from './protocol.ts';
|
} from './protocol.ts';
|
||||||
@@ -20,6 +22,7 @@ export interface ConnectionHandlers {
|
|||||||
onWelcome?(message: WelcomeMessage): void;
|
onWelcome?(message: WelcomeMessage): void;
|
||||||
onClock?(message: ClockMessage): void;
|
onClock?(message: ClockMessage): void;
|
||||||
onMapSnapshot?(message: MapSnapshotMessage): void;
|
onMapSnapshot?(message: MapSnapshotMessage): void;
|
||||||
|
onPresence?(message: PresenceMessage): void;
|
||||||
/** The open school was deleted elsewhere; the UI has to leave it. */
|
/** The open school was deleted elsewhere; the UI has to leave it. */
|
||||||
onSchoolGone?(schoolId: number): void;
|
onSchoolGone?(schoolId: number): void;
|
||||||
/** Round-trip time in milliseconds. */
|
/** Round-trip time in milliseconds. */
|
||||||
@@ -118,6 +121,10 @@ export class GameConnection {
|
|||||||
this.send(encodeSetSpeed(speedIndex));
|
this.send(encodeSetSpeed(speedIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
skipEmpty(): void {
|
||||||
|
this.send(encodeSkipEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
this.closedByUs = true;
|
this.closedByUs = true;
|
||||||
this.stopTimers();
|
this.stopTimers();
|
||||||
@@ -162,6 +169,9 @@ export class GameConnection {
|
|||||||
case 'map-snapshot':
|
case 'map-snapshot':
|
||||||
this.handlers.onMapSnapshot?.(message);
|
this.handlers.onMapSnapshot?.(message);
|
||||||
break;
|
break;
|
||||||
|
case 'presence':
|
||||||
|
this.handlers.onPresence?.(message);
|
||||||
|
break;
|
||||||
case 'school-gone':
|
case 'school-gone':
|
||||||
if (this.openSchoolId === message.schoolId) {
|
if (this.openSchoolId === message.schoolId) {
|
||||||
this.openSchoolId = null;
|
this.openSchoolId = null;
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
encodePing,
|
encodePing,
|
||||||
encodeSetRunning,
|
encodeSetRunning,
|
||||||
encodeSetSpeed,
|
encodeSetSpeed,
|
||||||
|
encodeSkipEmpty,
|
||||||
MessageType,
|
MessageType,
|
||||||
|
PresenceState,
|
||||||
ProtocolError,
|
ProtocolError,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
} from './protocol.ts';
|
} from './protocol.ts';
|
||||||
@@ -66,6 +68,13 @@ describe('client encoders', () => {
|
|||||||
expect(view.getUint8(0)).toBe(MessageType.ClientSetSpeed);
|
expect(view.getUint8(0)).toBe(MessageType.ClientSetSpeed);
|
||||||
expect(view.getUint8(1)).toBe(4);
|
expect(view.getUint8(1)).toBe(4);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('writes a single-byte skip', () => {
|
||||||
|
const view = new DataView(encodeSkipEmpty());
|
||||||
|
|
||||||
|
expect(view.byteLength).toBe(1);
|
||||||
|
expect(view.getUint8(0)).toBe(MessageType.ClientSkipEmpty);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('decodeServerMessage', () => {
|
describe('decodeServerMessage', () => {
|
||||||
@@ -85,16 +94,19 @@ describe('decodeServerMessage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reads a clock frame as a UTC instant', () => {
|
it('reads a clock frame as a UTC instant, including skip permission', () => {
|
||||||
// 2012-04-03T06:00:00Z
|
// 2012-04-03T06:00:00Z → skip to 2012-04-04T06:00:00Z
|
||||||
const gameTimeMs = Date.UTC(2012, 3, 3, 6, 0, 0);
|
const gameTimeMs = Date.UTC(2012, 3, 3, 6, 0, 0);
|
||||||
const buffer = new ArrayBuffer(15);
|
const skipTargetMs = Date.UTC(2012, 3, 4, 6, 0, 0);
|
||||||
|
const buffer = new ArrayBuffer(24);
|
||||||
const view = new DataView(buffer);
|
const view = new DataView(buffer);
|
||||||
view.setUint8(0, MessageType.ServerClock);
|
view.setUint8(0, MessageType.ServerClock);
|
||||||
view.setInt32(1, 7, true);
|
view.setInt32(1, 7, true);
|
||||||
view.setBigInt64(5, BigInt(gameTimeMs), true);
|
view.setBigInt64(5, BigInt(gameTimeMs), true);
|
||||||
view.setUint8(13, 1);
|
view.setUint8(13, 1);
|
||||||
view.setUint8(14, 2);
|
view.setUint8(14, 2);
|
||||||
|
view.setUint8(15, 1);
|
||||||
|
view.setBigInt64(16, BigInt(skipTargetMs), true);
|
||||||
|
|
||||||
expect(decodeServerMessage(buffer)).toEqual({
|
expect(decodeServerMessage(buffer)).toEqual({
|
||||||
type: 'clock',
|
type: 'clock',
|
||||||
@@ -102,6 +114,8 @@ describe('decodeServerMessage', () => {
|
|||||||
gameTime: new Date(gameTimeMs),
|
gameTime: new Date(gameTimeMs),
|
||||||
running: true,
|
running: true,
|
||||||
speedIndex: 2,
|
speedIndex: 2,
|
||||||
|
skipAllowed: true,
|
||||||
|
skipTarget: new Date(skipTargetMs),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,7 +143,7 @@ describe('decodeServerMessage', () => {
|
|||||||
const id = encoder.encode('yard');
|
const id = encoder.encode('yard');
|
||||||
const parent = encoder.encode('');
|
const parent = encoder.encode('');
|
||||||
const name = encoder.encode('Двор');
|
const name = encoder.encode('Двор');
|
||||||
const buffer = new ArrayBuffer(7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 1 + 1 + 1);
|
const buffer = new ArrayBuffer(7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 1);
|
||||||
const view = new DataView(buffer);
|
const view = new DataView(buffer);
|
||||||
view.setUint8(0, MessageType.ServerMapSnapshot);
|
view.setUint8(0, MessageType.ServerMapSnapshot);
|
||||||
view.setInt32(1, 7, true);
|
view.setInt32(1, 7, true);
|
||||||
@@ -152,10 +166,6 @@ describe('decodeServerMessage', () => {
|
|||||||
view.setUint8(offset, 0);
|
view.setUint8(offset, 0);
|
||||||
offset += 1;
|
offset += 1;
|
||||||
view.setUint8(offset, 0);
|
view.setUint8(offset, 0);
|
||||||
offset += 1;
|
|
||||||
view.setUint8(offset, 0);
|
|
||||||
offset += 1;
|
|
||||||
view.setUint8(offset, 0);
|
|
||||||
|
|
||||||
expect(decodeServerMessage(buffer)).toEqual({
|
expect(decodeServerMessage(buffer)).toEqual({
|
||||||
type: 'map-snapshot',
|
type: 'map-snapshot',
|
||||||
@@ -169,9 +179,6 @@ describe('decodeServerMessage', () => {
|
|||||||
pupilSlots: 0,
|
pupilSlots: 0,
|
||||||
items: [],
|
items: [],
|
||||||
positions: [],
|
positions: [],
|
||||||
activitySubject: '',
|
|
||||||
activityClass: '',
|
|
||||||
characters: [],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -184,7 +191,7 @@ describe('decodeServerMessage', () => {
|
|||||||
const name = encoder.encode('Класс 1A');
|
const name = encoder.encode('Класс 1A');
|
||||||
const itemName = encoder.encode('Парта');
|
const itemName = encoder.encode('Парта');
|
||||||
const buffer = new ArrayBuffer(
|
const buffer = new ArrayBuffer(
|
||||||
7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 2 + itemName.length + 1 + 1 + 1 + 1,
|
7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 2 + itemName.length + 1 + 1,
|
||||||
);
|
);
|
||||||
const view = new DataView(buffer);
|
const view = new DataView(buffer);
|
||||||
view.setUint8(0, MessageType.ServerMapSnapshot);
|
view.setUint8(0, MessageType.ServerMapSnapshot);
|
||||||
@@ -216,10 +223,6 @@ describe('decodeServerMessage', () => {
|
|||||||
view.setUint8(offset, 16);
|
view.setUint8(offset, 16);
|
||||||
offset += 1;
|
offset += 1;
|
||||||
view.setUint8(offset, 0);
|
view.setUint8(offset, 0);
|
||||||
offset += 1;
|
|
||||||
view.setUint8(offset, 0);
|
|
||||||
offset += 1;
|
|
||||||
view.setUint8(offset, 0);
|
|
||||||
|
|
||||||
expect(decodeServerMessage(buffer)).toEqual({
|
expect(decodeServerMessage(buffer)).toEqual({
|
||||||
type: 'map-snapshot',
|
type: 'map-snapshot',
|
||||||
@@ -233,71 +236,41 @@ describe('decodeServerMessage', () => {
|
|||||||
pupilSlots: 16,
|
pupilSlots: 16,
|
||||||
items: [{ name: 'Парта', count: 16 }],
|
items: [{ name: 'Парта', count: 16 }],
|
||||||
positions: [],
|
positions: [],
|
||||||
activitySubject: '',
|
|
||||||
activityClass: '',
|
|
||||||
characters: [],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reads occupancy after positions', () => {
|
it('reads a presence frame with node activity and people', () => {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const id = encoder.encode('classroom-101');
|
const nodeId = encoder.encode('classroom-101');
|
||||||
const parent = encoder.encode('floor-1');
|
|
||||||
const name = encoder.encode('Класс 101');
|
|
||||||
const itemName = encoder.encode('Парта');
|
|
||||||
const subject = encoder.encode('Математика');
|
const subject = encoder.encode('Математика');
|
||||||
const schoolClass = encoder.encode('5А');
|
const schoolClass = encoder.encode('5А');
|
||||||
const teacher = encoder.encode('Иванова');
|
const personId = encoder.encode('f0.c0');
|
||||||
|
const personNode = encoder.encode('classroom-101');
|
||||||
const buffer = new ArrayBuffer(
|
const buffer = new ArrayBuffer(
|
||||||
7
|
7
|
||||||
+ 1
|
+ 2 + nodeId.length
|
||||||
+ 2 + id.length
|
|
||||||
+ 2 + parent.length
|
|
||||||
+ 2 + name.length
|
|
||||||
+ 2
|
+ 2
|
||||||
+ 1
|
+ 1
|
||||||
+ 2 + itemName.length
|
|
||||||
+ 1
|
|
||||||
+ 1
|
|
||||||
+ 1
|
|
||||||
+ 2 + subject.length
|
+ 2 + subject.length
|
||||||
+ 2 + schoolClass.length
|
+ 2 + schoolClass.length
|
||||||
+ 1
|
+ 2
|
||||||
+ 2 + teacher.length,
|
+ 2 + personId.length
|
||||||
|
+ 2 + personNode.length
|
||||||
|
+ 1,
|
||||||
);
|
);
|
||||||
const view = new DataView(buffer);
|
const view = new DataView(buffer);
|
||||||
view.setUint8(0, MessageType.ServerMapSnapshot);
|
view.setUint8(0, MessageType.ServerPresence);
|
||||||
view.setInt32(1, 3, true);
|
view.setInt32(1, 3, true);
|
||||||
view.setUint16(5, 1, true);
|
view.setUint16(5, 1, true);
|
||||||
let offset = 7;
|
let offset = 7;
|
||||||
view.setUint8(offset, 3);
|
view.setUint16(offset, nodeId.length, true);
|
||||||
offset += 1;
|
|
||||||
view.setUint16(offset, id.length, true);
|
|
||||||
offset += 2;
|
offset += 2;
|
||||||
new Uint8Array(buffer).set(id, offset);
|
new Uint8Array(buffer).set(nodeId, offset);
|
||||||
offset += id.length;
|
offset += nodeId.length;
|
||||||
view.setUint16(offset, parent.length, true);
|
view.setUint16(offset, 18, true);
|
||||||
offset += 2;
|
offset += 2;
|
||||||
new Uint8Array(buffer).set(parent, offset);
|
|
||||||
offset += parent.length;
|
|
||||||
view.setUint16(offset, name.length, true);
|
|
||||||
offset += 2;
|
|
||||||
new Uint8Array(buffer).set(name, offset);
|
|
||||||
offset += name.length;
|
|
||||||
view.setUint16(offset, 16, true);
|
|
||||||
offset += 2;
|
|
||||||
view.setUint8(offset, 1);
|
|
||||||
offset += 1;
|
|
||||||
view.setUint16(offset, itemName.length, true);
|
|
||||||
offset += 2;
|
|
||||||
new Uint8Array(buffer).set(itemName, offset);
|
|
||||||
offset += itemName.length;
|
|
||||||
view.setUint8(offset, 16);
|
|
||||||
offset += 1;
|
|
||||||
view.setUint8(offset, 0);
|
|
||||||
offset += 1;
|
|
||||||
view.setUint8(offset, 1);
|
view.setUint8(offset, 1);
|
||||||
offset += 1;
|
offset += 1;
|
||||||
view.setUint16(offset, subject.length, true);
|
view.setUint16(offset, subject.length, true);
|
||||||
@@ -308,29 +281,30 @@ describe('decodeServerMessage', () => {
|
|||||||
offset += 2;
|
offset += 2;
|
||||||
new Uint8Array(buffer).set(schoolClass, offset);
|
new Uint8Array(buffer).set(schoolClass, offset);
|
||||||
offset += schoolClass.length;
|
offset += schoolClass.length;
|
||||||
view.setUint8(offset, 1);
|
view.setUint16(offset, 1, true);
|
||||||
offset += 1;
|
|
||||||
view.setUint16(offset, teacher.length, true);
|
|
||||||
offset += 2;
|
offset += 2;
|
||||||
new Uint8Array(buffer).set(teacher, offset);
|
view.setUint16(offset, personId.length, true);
|
||||||
|
offset += 2;
|
||||||
|
new Uint8Array(buffer).set(personId, offset);
|
||||||
|
offset += personId.length;
|
||||||
|
view.setUint16(offset, personNode.length, true);
|
||||||
|
offset += 2;
|
||||||
|
new Uint8Array(buffer).set(personNode, offset);
|
||||||
|
offset += personNode.length;
|
||||||
|
view.setUint8(offset, PresenceState.Here);
|
||||||
|
|
||||||
expect(decodeServerMessage(buffer)).toEqual({
|
expect(decodeServerMessage(buffer)).toEqual({
|
||||||
type: 'map-snapshot',
|
type: 'presence',
|
||||||
schoolId: 3,
|
schoolId: 3,
|
||||||
nodes: [
|
nodes: [
|
||||||
{
|
{
|
||||||
kind: 3,
|
|
||||||
id: 'classroom-101',
|
id: 'classroom-101',
|
||||||
parentId: 'floor-1',
|
count: 18,
|
||||||
name: 'Класс 101',
|
|
||||||
pupilSlots: 16,
|
|
||||||
items: [{ name: 'Парта', count: 16 }],
|
|
||||||
positions: [],
|
|
||||||
activitySubject: 'Математика',
|
activitySubject: 'Математика',
|
||||||
activityClass: '5А',
|
activityClass: '5А',
|
||||||
characters: ['Иванова'],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
people: [{ id: 'f0.c0', nodeId: 'classroom-101', state: PresenceState.Here }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
|
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const PROTOCOL_VERSION = 6;
|
export const PROTOCOL_VERSION = 7;
|
||||||
|
|
||||||
export const MessageType = {
|
export const MessageType = {
|
||||||
ClientHello: 0x01,
|
ClientHello: 0x01,
|
||||||
@@ -14,11 +14,13 @@ export const MessageType = {
|
|||||||
ClientCloseSchool: 0x04,
|
ClientCloseSchool: 0x04,
|
||||||
ClientSetRunning: 0x05,
|
ClientSetRunning: 0x05,
|
||||||
ClientSetSpeed: 0x06,
|
ClientSetSpeed: 0x06,
|
||||||
|
ClientSkipEmpty: 0x07,
|
||||||
ServerWelcome: 0x81,
|
ServerWelcome: 0x81,
|
||||||
ServerPong: 0x82,
|
ServerPong: 0x82,
|
||||||
ServerClock: 0x83,
|
ServerClock: 0x83,
|
||||||
ServerSchoolGone: 0x84,
|
ServerSchoolGone: 0x84,
|
||||||
ServerMapSnapshot: 0x85,
|
ServerMapSnapshot: 0x85,
|
||||||
|
ServerPresence: 0x86,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Hello locale byte. Same mapping as `?lang=` on the catalog HTTP API. */
|
/** Hello locale byte. Same mapping as `?lang=` on the catalog HTTP API. */
|
||||||
@@ -55,6 +57,10 @@ export interface ClockMessage {
|
|||||||
readonly gameTime: Date;
|
readonly gameTime: Date;
|
||||||
readonly running: boolean;
|
readonly running: boolean;
|
||||||
readonly speedIndex: number;
|
readonly speedIndex: number;
|
||||||
|
/** Server verdict; the client must not recompute this. */
|
||||||
|
readonly skipAllowed: boolean;
|
||||||
|
/** UTC instant the skip would land on; null when skip is refused. */
|
||||||
|
readonly skipTarget: Date | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SchoolGoneMessage {
|
export interface SchoolGoneMessage {
|
||||||
@@ -82,9 +88,6 @@ export interface MapSnapshotNode {
|
|||||||
readonly pupilSlots: number;
|
readonly pupilSlots: number;
|
||||||
readonly items: readonly MapSnapshotItem[];
|
readonly items: readonly MapSnapshotItem[];
|
||||||
readonly positions: readonly string[];
|
readonly positions: readonly string[];
|
||||||
readonly activitySubject: string;
|
|
||||||
readonly activityClass: string;
|
|
||||||
readonly characters: readonly string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapSnapshotMessage {
|
export interface MapSnapshotMessage {
|
||||||
@@ -93,12 +96,38 @@ export interface MapSnapshotMessage {
|
|||||||
readonly nodes: readonly MapSnapshotNode[];
|
readonly nodes: readonly MapSnapshotNode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const PresenceState = {
|
||||||
|
Here: 1,
|
||||||
|
Walking: 2,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface PresenceNode {
|
||||||
|
readonly id: string;
|
||||||
|
readonly count: number;
|
||||||
|
readonly activitySubject: string;
|
||||||
|
readonly activityClass: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresencePerson {
|
||||||
|
readonly id: string;
|
||||||
|
readonly nodeId: string;
|
||||||
|
readonly state: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresenceMessage {
|
||||||
|
readonly type: 'presence';
|
||||||
|
readonly schoolId: number;
|
||||||
|
readonly nodes: readonly PresenceNode[];
|
||||||
|
readonly people: readonly PresencePerson[];
|
||||||
|
}
|
||||||
|
|
||||||
export type ServerMessage =
|
export type ServerMessage =
|
||||||
| WelcomeMessage
|
| WelcomeMessage
|
||||||
| PongMessage
|
| PongMessage
|
||||||
| ClockMessage
|
| ClockMessage
|
||||||
| SchoolGoneMessage
|
| SchoolGoneMessage
|
||||||
| MapSnapshotMessage;
|
| MapSnapshotMessage
|
||||||
|
| PresenceMessage;
|
||||||
|
|
||||||
/** Thrown when a frame is truncated or carries an unexpected message id. */
|
/** Thrown when a frame is truncated or carries an unexpected message id. */
|
||||||
export class ProtocolError extends Error {}
|
export class ProtocolError extends Error {}
|
||||||
@@ -165,6 +194,12 @@ export function encodeSetSpeed(speedIndex: number): ArrayBuffer {
|
|||||||
return buffer;
|
return buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function encodeSkipEmpty(): ArrayBuffer {
|
||||||
|
const buffer = new ArrayBuffer(1);
|
||||||
|
new DataView(buffer).setUint8(0, MessageType.ClientSkipEmpty);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
/** Decodes one server frame. Unknown message ids return `null` so new ids stay backwards compatible. */
|
/** Decodes one server frame. Unknown message ids return `null` so new ids stay backwards compatible. */
|
||||||
export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
|
export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
|
||||||
if (data.byteLength === 0) {
|
if (data.byteLength === 0) {
|
||||||
@@ -184,6 +219,8 @@ export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
|
|||||||
return decodeSchoolGone(view);
|
return decodeSchoolGone(view);
|
||||||
case MessageType.ServerMapSnapshot:
|
case MessageType.ServerMapSnapshot:
|
||||||
return decodeMapSnapshot(view);
|
return decodeMapSnapshot(view);
|
||||||
|
case MessageType.ServerPresence:
|
||||||
|
return decodePresence(view);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -211,7 +248,8 @@ function decodePong(view: DataView): PongMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function decodeClock(view: DataView): ClockMessage {
|
function decodeClock(view: DataView): ClockMessage {
|
||||||
ensure(view, 15);
|
ensure(view, 24);
|
||||||
|
const skipTargetMs = Number(view.getBigInt64(16, true));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'clock',
|
type: 'clock',
|
||||||
@@ -219,6 +257,8 @@ function decodeClock(view: DataView): ClockMessage {
|
|||||||
gameTime: new Date(Number(view.getBigInt64(5, true))),
|
gameTime: new Date(Number(view.getBigInt64(5, true))),
|
||||||
running: view.getUint8(13) !== 0,
|
running: view.getUint8(13) !== 0,
|
||||||
speedIndex: view.getUint8(14),
|
speedIndex: view.getUint8(14),
|
||||||
|
skipAllowed: view.getUint8(15) !== 0,
|
||||||
|
skipTarget: skipTargetMs === 0 ? null : new Date(skipTargetMs),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +308,34 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage {
|
|||||||
offset = value.next;
|
offset = value.next;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
kind,
|
||||||
|
id: id.text,
|
||||||
|
parentId: parentId.text,
|
||||||
|
name: name.text,
|
||||||
|
pupilSlots,
|
||||||
|
items,
|
||||||
|
positions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'map-snapshot', schoolId, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodePresence(view: DataView): PresenceMessage {
|
||||||
|
ensure(view, 7);
|
||||||
|
|
||||||
|
const schoolId = view.getInt32(1, true);
|
||||||
|
const nodeCount = view.getUint16(5, true);
|
||||||
|
let offset = 7;
|
||||||
|
const nodes: PresenceNode[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < nodeCount; i++) {
|
||||||
|
const id = readString(view, offset);
|
||||||
|
offset = id.next;
|
||||||
|
ensure(view, offset + 2);
|
||||||
|
const count = view.getUint16(offset, true);
|
||||||
|
offset += 2;
|
||||||
const hasActivity = readU8(view, offset);
|
const hasActivity = readU8(view, offset);
|
||||||
offset += 1;
|
offset += 1;
|
||||||
let activitySubject = '';
|
let activitySubject = '';
|
||||||
@@ -281,30 +349,24 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage {
|
|||||||
activityClass = schoolClass.text;
|
activityClass = schoolClass.text;
|
||||||
}
|
}
|
||||||
|
|
||||||
const characterCount = readU8(view, offset);
|
nodes.push({ id: id.text, count, activitySubject, activityClass });
|
||||||
offset += 1;
|
|
||||||
const characters: string[] = [];
|
|
||||||
for (let person = 0; person < characterCount; person++) {
|
|
||||||
const value = readString(view, offset);
|
|
||||||
characters.push(value.text);
|
|
||||||
offset = value.next;
|
|
||||||
}
|
|
||||||
|
|
||||||
nodes.push({
|
|
||||||
kind,
|
|
||||||
id: id.text,
|
|
||||||
parentId: parentId.text,
|
|
||||||
name: name.text,
|
|
||||||
pupilSlots,
|
|
||||||
items,
|
|
||||||
positions,
|
|
||||||
activitySubject,
|
|
||||||
activityClass,
|
|
||||||
characters,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { type: 'map-snapshot', schoolId, nodes };
|
ensure(view, offset + 2);
|
||||||
|
const personCount = view.getUint16(offset, true);
|
||||||
|
offset += 2;
|
||||||
|
const people: PresencePerson[] = [];
|
||||||
|
for (let i = 0; i < personCount; i++) {
|
||||||
|
const id = readString(view, offset);
|
||||||
|
offset = id.next;
|
||||||
|
const nodeId = readString(view, offset);
|
||||||
|
offset = nodeId.next;
|
||||||
|
const state = readU8(view, offset);
|
||||||
|
offset += 1;
|
||||||
|
people.push({ id: id.text, nodeId: nodeId.text, state });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'presence', schoolId, nodes, people };
|
||||||
}
|
}
|
||||||
|
|
||||||
function readU8(view: DataView, offset: number): number {
|
function readU8(view: DataView, offset: number): number {
|
||||||
|
|||||||
@@ -1,23 +1,34 @@
|
|||||||
import { CLOCK_SPEEDS, type ClockMessage, type MapSnapshotItem, type MapSnapshotNode } from '../net/protocol.ts';
|
import {
|
||||||
import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
|
CLOCK_SPEEDS,
|
||||||
|
PresenceState,
|
||||||
|
type ClockMessage,
|
||||||
|
type MapSnapshotItem,
|
||||||
|
type MapSnapshotNode,
|
||||||
|
type PresenceMessage,
|
||||||
|
type PresenceNode,
|
||||||
|
} from '../net/protocol.ts';
|
||||||
|
import { formatGameDate, formatGameDateTime, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
|
||||||
|
import { getLocale } from '../i18n/locale.ts';
|
||||||
import { t } from '../i18n/strings.ts';
|
import { t } from '../i18n/strings.ts';
|
||||||
import type { School } from '../net/api.ts';
|
import { fetchDirectory, type School } from '../net/api.ts';
|
||||||
import { clear, el } from './dom.ts';
|
import { clear, el } from './dom.ts';
|
||||||
import { ManagementPanel } from './managementPanel.ts';
|
import { ManagementPanel } from './managementPanel.ts';
|
||||||
import { PeoplePanel } from './peoplePanel.ts';
|
import { PeoplePanel } from './peoplePanel.ts';
|
||||||
|
import { formatPersonPlace } from './personCard.ts';
|
||||||
|
|
||||||
interface GameScreenOptions {
|
interface GameScreenOptions {
|
||||||
readonly onLeave: () => void;
|
readonly onLeave: () => void;
|
||||||
readonly onSetRunning: (running: boolean) => void;
|
readonly onSetRunning: (running: boolean) => void;
|
||||||
readonly onSetSpeed: (speedIndex: number) => void;
|
readonly onSetSpeed: (speedIndex: number) => void;
|
||||||
|
readonly onSkip: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4'];
|
const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The inside of a school: calendar controls plus the manager shell. The tree and location
|
* The inside of a school: calendar controls plus the manager shell. The tree comes from one map
|
||||||
* lists come from one map snapshot on OpenSchool; clicking a node only filters that snapshot
|
* snapshot on OpenSchool; presence (~2 Hz) paints headcount and who is here. Clicking a node
|
||||||
* on the client. The people panel loads its page over HTTP.
|
* only filters on the client. The people panel loads its page over HTTP.
|
||||||
*/
|
*/
|
||||||
export class GameScreen {
|
export class GameScreen {
|
||||||
private readonly root = el('section', { class: 'screen game' });
|
private readonly root = el('section', { class: 'screen game' });
|
||||||
@@ -27,6 +38,7 @@ export class GameScreen {
|
|||||||
private readonly date = el('p', { class: 'clock__date' });
|
private readonly date = el('p', { class: 'clock__date' });
|
||||||
private readonly weekday = el('p', { class: 'clock__weekday' });
|
private readonly weekday = el('p', { class: 'clock__weekday' });
|
||||||
private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
|
private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
|
||||||
|
private readonly skipButton = el('button', { class: 'button button--small', type: 'button' });
|
||||||
private readonly speedButtons: HTMLButtonElement[];
|
private readonly speedButtons: HTMLButtonElement[];
|
||||||
|
|
||||||
private readonly mapTab = el('button', { class: 'panel__tab', type: 'button' });
|
private readonly mapTab = el('button', { class: 'panel__tab', type: 'button' });
|
||||||
@@ -62,11 +74,16 @@ export class GameScreen {
|
|||||||
|
|
||||||
private readonly treeButtons = new Map<string, HTMLButtonElement>();
|
private readonly treeButtons = new Map<string, HTMLButtonElement>();
|
||||||
private nodes: readonly MapSnapshotNode[] = [];
|
private nodes: readonly MapSnapshotNode[] = [];
|
||||||
|
private presence: PresenceMessage | null = null;
|
||||||
|
private directory = new Map<string, string>();
|
||||||
|
private directoryToken = 0;
|
||||||
private selectedId: string | null = null;
|
private selectedId: string | null = null;
|
||||||
private schoolId: number | null = null;
|
private schoolId: number | null = null;
|
||||||
private running = false;
|
private running = false;
|
||||||
private lastGameTime: Date | null = null;
|
private lastGameTime: Date | null = null;
|
||||||
private lastSpeedIndex = 0;
|
private lastSpeedIndex = 0;
|
||||||
|
private skipAllowed = false;
|
||||||
|
private skipTarget: Date | null = null;
|
||||||
private inspected: 'location' | 'person' = 'location';
|
private inspected: 'location' | 'person' = 'location';
|
||||||
|
|
||||||
constructor(options: GameScreenOptions) {
|
constructor(options: GameScreenOptions) {
|
||||||
@@ -81,6 +98,8 @@ export class GameScreen {
|
|||||||
|
|
||||||
this.backButton.addEventListener('click', options.onLeave);
|
this.backButton.addEventListener('click', options.onLeave);
|
||||||
this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running));
|
this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running));
|
||||||
|
this.skipButton.addEventListener('click', () => options.onSkip());
|
||||||
|
this.skipButton.hidden = true;
|
||||||
|
|
||||||
this.root.append(
|
this.root.append(
|
||||||
el('header', { class: 'screen__header' }, this.backButton, this.schoolName),
|
el('header', { class: 'screen__header' }, this.backButton, this.schoolName),
|
||||||
@@ -93,7 +112,7 @@ export class GameScreen {
|
|||||||
this.time,
|
this.time,
|
||||||
el('div', { class: 'clockbar__labels' }, this.date, this.weekday),
|
el('div', { class: 'clockbar__labels' }, this.date, this.weekday),
|
||||||
),
|
),
|
||||||
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
|
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons, this.skipButton),
|
||||||
),
|
),
|
||||||
el('div', { class: 'mode-tabs' }, this.overviewTab, this.manageTab),
|
el('div', { class: 'mode-tabs' }, this.overviewTab, this.manageTab),
|
||||||
this.overview,
|
this.overview,
|
||||||
@@ -163,10 +182,14 @@ export class GameScreen {
|
|||||||
this.paintSelection();
|
this.paintSelection();
|
||||||
|
|
||||||
if (this.lastGameTime !== null) {
|
if (this.lastGameTime !== null) {
|
||||||
this.applyClock(this.lastGameTime, this.running, this.lastSpeedIndex);
|
this.applyClock(this.lastGameTime, this.running, this.lastSpeedIndex, this.skipAllowed, this.skipTarget);
|
||||||
} else {
|
} else {
|
||||||
this.playPauseButton.title = t('resume');
|
this.playPauseButton.title = t('resume');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.paintSkip();
|
||||||
|
this.people.setLocate((id) => this.placeOf(id));
|
||||||
|
this.management.setLocate((id) => this.placeOf(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called when the screen opens, before the first clock frame and snapshot arrive. */
|
/** Called when the screen opens, before the first clock frame and snapshot arrive. */
|
||||||
@@ -174,18 +197,25 @@ export class GameScreen {
|
|||||||
this.schoolId = school.id;
|
this.schoolId = school.id;
|
||||||
this.schoolName.textContent = school.name;
|
this.schoolName.textContent = school.name;
|
||||||
this.nodes = [];
|
this.nodes = [];
|
||||||
|
this.presence = null;
|
||||||
|
this.directory = new Map();
|
||||||
this.selectedId = null;
|
this.selectedId = null;
|
||||||
|
this.skipAllowed = false;
|
||||||
|
this.skipTarget = null;
|
||||||
this.rebuildTree();
|
this.rebuildTree();
|
||||||
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex);
|
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex, false, null);
|
||||||
this.people.show(school.id);
|
this.people.show(school.id);
|
||||||
|
this.people.setLocate((id) => this.placeOf(id));
|
||||||
|
this.management.setLocate((id) => this.placeOf(id));
|
||||||
this.showTab('map');
|
this.showTab('map');
|
||||||
this.inspect('location');
|
this.inspect('location');
|
||||||
this.showMode('overview');
|
this.showMode('overview');
|
||||||
|
void this.loadDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The server sends the tree on OpenSchool and again when the current lesson slot changes.
|
* The server sends the tree once on OpenSchool. Occupancy is on the presence stream; this
|
||||||
* Occupancy is on the snapshot; this screen must not derive who is where from the clock.
|
* screen must not derive who is where from the clock.
|
||||||
*/
|
*/
|
||||||
applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void {
|
applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void {
|
||||||
if (this.schoolId !== schoolId) {
|
if (this.schoolId !== schoolId) {
|
||||||
@@ -200,8 +230,23 @@ export class GameScreen {
|
|||||||
this.paintSelection();
|
this.paintSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyPresence(schoolId: number, message: PresenceMessage): void {
|
||||||
|
if (this.schoolId !== schoolId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.presence = message;
|
||||||
|
this.paintTreeLabels();
|
||||||
|
this.paintSelection();
|
||||||
|
this.people.setLocate((id) => this.placeOf(id));
|
||||||
|
this.management.setLocate((id) => this.placeOf(id));
|
||||||
|
if (message.people.some((person) => !this.directory.has(person.id))) {
|
||||||
|
void this.loadDirectory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
update(clock: ClockMessage): void {
|
update(clock: ClockMessage): void {
|
||||||
this.applyClock(clock.gameTime, clock.running, clock.speedIndex);
|
this.applyClock(clock.gameTime, clock.running, clock.speedIndex, clock.skipAllowed, clock.skipTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
private rebuildTree(): void {
|
private rebuildTree(): void {
|
||||||
@@ -216,7 +261,7 @@ export class GameScreen {
|
|||||||
const button = el('button', {
|
const button = el('button', {
|
||||||
class: 'tree__button',
|
class: 'tree__button',
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: treeLabel(node),
|
text: treeLabel(node, this.liveNode(node.id)),
|
||||||
onClick: () => this.select(node.id),
|
onClick: () => this.select(node.id),
|
||||||
});
|
});
|
||||||
button.style.paddingLeft = `${8 + depth * 14}px`;
|
button.style.paddingLeft = `${8 + depth * 14}px`;
|
||||||
@@ -282,29 +327,38 @@ export class GameScreen {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const node = this.nodes.find((candidate) => candidate.id === this.selectedId);
|
const node = this.nodes.find((candidate) => candidate.id === this.selectedId);
|
||||||
|
const live = this.liveNode(this.selectedId);
|
||||||
this.locationName.textContent = node?.name ?? '';
|
this.locationName.textContent = node?.name ?? '';
|
||||||
paintList(this.itemsList, this.itemsEmpty, (node?.items ?? []).map(formatItem));
|
paintList(this.itemsList, this.itemsEmpty, (node?.items ?? []).map(formatItem));
|
||||||
const pupilSlots = node?.pupilSlots ?? 0;
|
const pupilSlots = node?.pupilSlots ?? 0;
|
||||||
this.pupilSlotsLine.hidden = pupilSlots <= 0;
|
this.pupilSlotsLine.hidden = pupilSlots <= 0;
|
||||||
this.pupilSlotsLine.textContent = pupilSlots > 0 ? t('pupilSlots', { count: pupilSlots }) : '';
|
this.pupilSlotsLine.textContent = pupilSlots > 0 ? t('pupilSlots', { count: pupilSlots }) : '';
|
||||||
const activity = [];
|
const activity = [];
|
||||||
if (node?.activitySubject) {
|
if (live?.activitySubject) {
|
||||||
activity.push(node.activitySubject);
|
activity.push(live.activitySubject);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node?.activityClass) {
|
if (live?.activityClass) {
|
||||||
activity.push(node.activityClass);
|
activity.push(live.activityClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
paintList(this.activitiesList, this.activitiesEmpty, activity);
|
paintList(this.activitiesList, this.activitiesEmpty, activity);
|
||||||
paintList(this.charactersList, this.charactersEmpty, node?.characters ?? []);
|
paintList(this.charactersList, this.charactersEmpty, this.peopleAt(this.selectedId));
|
||||||
paintList(this.positionsList, this.positionsEmpty, node?.positions ?? []);
|
paintList(this.positionsList, this.positionsEmpty, node?.positions ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyClock(gameTime: Date, running: boolean, speedIndex: number): void {
|
private applyClock(
|
||||||
|
gameTime: Date,
|
||||||
|
running: boolean,
|
||||||
|
speedIndex: number,
|
||||||
|
skipAllowed: boolean,
|
||||||
|
skipTarget: Date | null,
|
||||||
|
): void {
|
||||||
this.running = running;
|
this.running = running;
|
||||||
this.lastGameTime = gameTime;
|
this.lastGameTime = gameTime;
|
||||||
this.lastSpeedIndex = speedIndex;
|
this.lastSpeedIndex = speedIndex;
|
||||||
|
this.skipAllowed = skipAllowed;
|
||||||
|
this.skipTarget = skipTarget;
|
||||||
|
|
||||||
this.time.textContent = formatGameTimeOfDay(gameTime);
|
this.time.textContent = formatGameTimeOfDay(gameTime);
|
||||||
this.date.textContent = formatGameDate(gameTime);
|
this.date.textContent = formatGameDate(gameTime);
|
||||||
@@ -316,6 +370,76 @@ export class GameScreen {
|
|||||||
this.speedButtons.forEach((button, index) => {
|
this.speedButtons.forEach((button, index) => {
|
||||||
button.classList.toggle('button--active', index === speedIndex);
|
button.classList.toggle('button--active', index === speedIndex);
|
||||||
});
|
});
|
||||||
|
this.paintSkip();
|
||||||
|
}
|
||||||
|
|
||||||
|
private paintSkip(): void {
|
||||||
|
const allowed = this.skipAllowed && this.skipTarget !== null;
|
||||||
|
this.skipButton.hidden = !allowed;
|
||||||
|
this.skipButton.textContent = allowed && this.skipTarget !== null
|
||||||
|
? t('skipTo', { date: formatGameDateTime(this.skipTarget) })
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private paintTreeLabels(): void {
|
||||||
|
for (const [id, button] of this.treeButtons) {
|
||||||
|
const node = this.nodes.find((candidate) => candidate.id === id);
|
||||||
|
if (node !== undefined) {
|
||||||
|
button.textContent = treeLabel(node, this.liveNode(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private liveNode(id: string | null): PresenceNode | undefined {
|
||||||
|
if (id === null || this.presence === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.presence.nodes.find((node) => node.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private peopleAt(id: string | null): string[] {
|
||||||
|
if (id === null || this.presence === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = this.presence.people
|
||||||
|
.filter((person) => person.nodeId === id)
|
||||||
|
.map((person) => this.directory.get(person.id) ?? person.id);
|
||||||
|
names.sort((left, right) => left.localeCompare(right));
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeOf(id: string): string {
|
||||||
|
const person = this.presence?.people.find((candidate) => candidate.id === id);
|
||||||
|
if (person === undefined) {
|
||||||
|
return formatPersonPlace('away');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeName = this.nodes.find((node) => node.id === person.nodeId)?.name ?? person.nodeId;
|
||||||
|
return formatPersonPlace(person.state === PresenceState.Walking ? 'walking' : 'here', nodeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadDirectory(): Promise<void> {
|
||||||
|
const schoolId = this.schoolId;
|
||||||
|
if (schoolId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = ++this.directoryToken;
|
||||||
|
try {
|
||||||
|
const people = await fetchDirectory(schoolId, getLocale());
|
||||||
|
if (token !== this.directoryToken || this.schoolId !== schoolId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.directory = new Map(people.map((person) => [person.id, person.fullName]));
|
||||||
|
this.paintSelection();
|
||||||
|
this.people.setLocate((id) => this.placeOf(id));
|
||||||
|
this.management.setLocate((id) => this.placeOf(id));
|
||||||
|
} catch {
|
||||||
|
// Names stay as ids until the next presence frame retries.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,8 +447,17 @@ function childrenOf(nodes: readonly MapSnapshotNode[], parentId: string): MapSna
|
|||||||
return nodes.filter((node) => node.parentId === parentId);
|
return nodes.filter((node) => node.parentId === parentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
function treeLabel(node: MapSnapshotNode): string {
|
function treeLabel(node: MapSnapshotNode, live: PresenceNode | undefined): string {
|
||||||
const activity = [node.activitySubject, node.activityClass].filter((part) => part.length > 0).join(' · ');
|
const count = live?.count ?? 0;
|
||||||
|
const activity = [live?.activitySubject ?? '', live?.activityClass ?? ''].filter((part) => part.length > 0).join(' · ');
|
||||||
|
if (count > 0 && activity.length > 0) {
|
||||||
|
return t('mapHeadcountActivity', { name: node.name, count, activity });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
return t('mapHeadcount', { name: node.name, count });
|
||||||
|
}
|
||||||
|
|
||||||
return activity.length > 0 ? t('mapOccupancy', { name: node.name, activity }) : node.name;
|
return activity.length > 0 ? t('mapOccupancy', { name: node.name, activity }) : node.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { getLocale, intlTag } from '../i18n/locale.ts';
|
import { getLocale, intlTag } from '../i18n/locale.ts';
|
||||||
import { t } from '../i18n/strings.ts';
|
import { t } from '../i18n/strings.ts';
|
||||||
import { clear, el } from './dom.ts';
|
import { clear, el } from './dom.ts';
|
||||||
import { renderPersonCard } from './personCard.ts';
|
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
|
||||||
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
||||||
|
|
||||||
const TEACHER = 'Teacher';
|
const TEACHER = 'Teacher';
|
||||||
@@ -67,6 +67,8 @@ export class ManagementPanel {
|
|||||||
private loadToken = 0;
|
private loadToken = 0;
|
||||||
private cardToken = 0;
|
private cardToken = 0;
|
||||||
private busy = false;
|
private busy = false;
|
||||||
|
private locate: ((id: string) => string) | null = null;
|
||||||
|
private painted: PersonCard | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.error.hidden = true;
|
this.error.hidden = true;
|
||||||
@@ -139,6 +141,11 @@ export class ManagementPanel {
|
|||||||
void this.reload();
|
void this.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLocate(locate: (id: string) => string): void {
|
||||||
|
this.locate = locate;
|
||||||
|
this.relocate();
|
||||||
|
}
|
||||||
|
|
||||||
private async reload(): Promise<void> {
|
private async reload(): Promise<void> {
|
||||||
const schoolId = this.schoolId;
|
const schoolId = this.schoolId;
|
||||||
if (schoolId === null) {
|
if (schoolId === null) {
|
||||||
@@ -401,16 +408,30 @@ export class ManagementPanel {
|
|||||||
|
|
||||||
private paintCard(card: PersonCard | null): void {
|
private paintCard(card: PersonCard | null): void {
|
||||||
clear(this.card);
|
clear(this.card);
|
||||||
|
this.painted = card;
|
||||||
if (card === null) {
|
if (card === null) {
|
||||||
this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') }));
|
this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPersonCard(this.card, card, (id) => void this.openRelative(id));
|
renderPersonCard(this.card, card, (id) => void this.openRelative(id), this.placeOf(card.id));
|
||||||
this.mountPersonTimetable(card);
|
this.mountPersonTimetable(card);
|
||||||
this.appendActions(card.id);
|
this.appendActions(card.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private relocate(): void {
|
||||||
|
const line = this.card.querySelector('.people__card-place');
|
||||||
|
if (!(line instanceof HTMLElement) || this.painted === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
line.textContent = this.placeOf(this.painted.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeOf(id: string): string {
|
||||||
|
return this.locate?.(id) ?? formatPersonPlace('away');
|
||||||
|
}
|
||||||
|
|
||||||
private mountPersonTimetable(card: PersonCard): void {
|
private mountPersonTimetable(card: PersonCard): void {
|
||||||
const schoolId = this.schoolId;
|
const schoolId = this.schoolId;
|
||||||
const query = personTimetableQuery(card);
|
const query = personTimetableQuery(card);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { fetchPeople, fetchPerson, fetchTimetable, type PeoplePage, type PersonC
|
|||||||
import { getLocale } from '../i18n/locale.ts';
|
import { getLocale } from '../i18n/locale.ts';
|
||||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||||
import { clear, el } from './dom.ts';
|
import { clear, el } from './dom.ts';
|
||||||
import { placement, renderPersonCard, roleLabels } from './personCard.ts';
|
import { formatPersonPlace, placement, renderPersonCard, roleLabels } from './personCard.ts';
|
||||||
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
||||||
|
|
||||||
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
|
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
|
||||||
@@ -59,6 +59,8 @@ export class PeoplePanel {
|
|||||||
private selectedId: string | null = null;
|
private selectedId: string | null = null;
|
||||||
private token = 0;
|
private token = 0;
|
||||||
private cardToken = 0;
|
private cardToken = 0;
|
||||||
|
private locate: ((id: string) => string) | null = null;
|
||||||
|
private painted: PersonCard | null = null;
|
||||||
|
|
||||||
constructor(private readonly options: PeoplePanelOptions) {
|
constructor(private readonly options: PeoplePanelOptions) {
|
||||||
this.ageMinInput.min = '0';
|
this.ageMinInput.min = '0';
|
||||||
@@ -170,6 +172,11 @@ export class PeoplePanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLocate(locate: (id: string) => string): void {
|
||||||
|
this.locate = locate;
|
||||||
|
this.relocate();
|
||||||
|
}
|
||||||
|
|
||||||
private onFilterChange(): void {
|
private onFilterChange(): void {
|
||||||
this.page = 1;
|
this.page = 1;
|
||||||
void this.reload();
|
void this.reload();
|
||||||
@@ -349,15 +356,29 @@ export class PeoplePanel {
|
|||||||
|
|
||||||
private paintCard(card: PersonCard | null): void {
|
private paintCard(card: PersonCard | null): void {
|
||||||
clear(this.card);
|
clear(this.card);
|
||||||
|
this.painted = card;
|
||||||
if (card === null) {
|
if (card === null) {
|
||||||
this.card.append(el('p', { class: 'panel__empty', text: t('peoplePickHint') }));
|
this.card.append(el('p', { class: 'panel__empty', text: t('peoplePickHint') }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
renderPersonCard(this.card, card, (id) => void this.openCard(id));
|
renderPersonCard(this.card, card, (id) => void this.openCard(id), this.placeOf(card.id));
|
||||||
this.mountPersonTimetable(card);
|
this.mountPersonTimetable(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private relocate(): void {
|
||||||
|
const line = this.card.querySelector('.people__card-place');
|
||||||
|
if (!(line instanceof HTMLElement) || this.painted === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
line.textContent = this.placeOf(this.painted.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private placeOf(id: string): string {
|
||||||
|
return this.locate?.(id) ?? formatPersonPlace('away');
|
||||||
|
}
|
||||||
|
|
||||||
private mountPersonTimetable(card: PersonCard): void {
|
private mountPersonTimetable(card: PersonCard): void {
|
||||||
const schoolId = this.schoolId;
|
const schoolId = this.schoolId;
|
||||||
const query = personTimetableQuery(card);
|
const query = personTimetableQuery(card);
|
||||||
|
|||||||
@@ -31,11 +31,15 @@ export function renderPersonCard(
|
|||||||
parent: HTMLElement,
|
parent: HTMLElement,
|
||||||
card: PersonCard,
|
card: PersonCard,
|
||||||
onRelative: (id: string) => void,
|
onRelative: (id: string) => void,
|
||||||
|
place?: string,
|
||||||
): void {
|
): void {
|
||||||
parent.append(
|
parent.append(
|
||||||
el('h3', { class: 'people__card-name', text: card.fullName }),
|
el('h3', { class: 'people__card-name', text: card.fullName }),
|
||||||
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
|
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
|
||||||
);
|
);
|
||||||
|
if (place !== undefined && place.length > 0) {
|
||||||
|
parent.append(el('p', { class: 'people__card-place', text: place }));
|
||||||
|
}
|
||||||
appendPairs(parent, t('peopleBody'), card.body);
|
appendPairs(parent, t('peopleBody'), card.body);
|
||||||
appendPairs(parent, t('peopleSkills'), card.skills);
|
appendPairs(parent, t('peopleSkills'), card.skills);
|
||||||
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
|
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
|
||||||
@@ -61,6 +65,18 @@ function cardMeta(card: PersonCard): string {
|
|||||||
return bits.join(' · ');
|
return bits.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatPersonPlace(kind: 'here' | 'walking' | 'away', nodeName = ''): string {
|
||||||
|
if (kind === 'away') {
|
||||||
|
return t('presenceAway');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'walking') {
|
||||||
|
return t('presenceWalking', { name: nodeName });
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('presenceAt', { name: nodeName });
|
||||||
|
}
|
||||||
|
|
||||||
function section(title: string): HTMLElement {
|
function section(title: string): HTMLElement {
|
||||||
return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title }));
|
return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ public enum MessageType : byte
|
|||||||
ClientCloseSchool = 0x04,
|
ClientCloseSchool = 0x04,
|
||||||
ClientSetRunning = 0x05,
|
ClientSetRunning = 0x05,
|
||||||
ClientSetSpeed = 0x06,
|
ClientSetSpeed = 0x06,
|
||||||
|
ClientSkipEmpty = 0x07,
|
||||||
|
|
||||||
ServerWelcome = 0x81,
|
ServerWelcome = 0x81,
|
||||||
ServerPong = 0x82,
|
ServerPong = 0x82,
|
||||||
ServerClock = 0x83,
|
ServerClock = 0x83,
|
||||||
ServerSchoolGone = 0x84,
|
ServerSchoolGone = 0x84,
|
||||||
ServerMapSnapshot = 0x85,
|
ServerMapSnapshot = 0x85,
|
||||||
|
ServerPresence = 0x86,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,16 @@ public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTi
|
|||||||
/// State of the open school's calendar, sent every tick.
|
/// State of the open school's calendar, sent every tick.
|
||||||
/// <paramref name="GameTimeUnixMs"/> is the in-game date as milliseconds since the Unix epoch,
|
/// <paramref name="GameTimeUnixMs"/> is the in-game date as milliseconds since the Unix epoch,
|
||||||
/// interpreted as UTC — the game calendar has no time zone.
|
/// interpreted as UTC — the game calendar has no time zone.
|
||||||
|
/// <paramref name="SkipAllowed"/> is the server's verdict; the client must not recompute it.
|
||||||
|
/// <paramref name="SkipTargetUnixMs"/> is 0 when skip is refused.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public readonly record struct ServerClockMessage(
|
public readonly record struct ServerClockMessage(
|
||||||
int SchoolId,
|
int SchoolId,
|
||||||
long GameTimeUnixMs,
|
long GameTimeUnixMs,
|
||||||
bool Running,
|
bool Running,
|
||||||
byte SpeedIndex);
|
byte SpeedIndex,
|
||||||
|
bool SkipAllowed = false,
|
||||||
|
long SkipTargetUnixMs = 0);
|
||||||
|
|
||||||
/// <summary>The open school no longer exists (deleted from another tab); the client returns to the menu.</summary>
|
/// <summary>The open school no longer exists (deleted from another tab); the client returns to the menu.</summary>
|
||||||
public readonly record struct ServerSchoolGoneMessage(int SchoolId);
|
public readonly record struct ServerSchoolGoneMessage(int SchoolId);
|
||||||
@@ -45,9 +49,7 @@ public readonly record struct ServerSchoolGoneMessage(int SchoolId);
|
|||||||
/// Tree node in a map snapshot. Kind is <c>0</c> territory, <c>1</c> building, <c>2</c> floor, <c>3</c> room.
|
/// Tree node in a map snapshot. Kind is <c>0</c> territory, <c>1</c> building, <c>2</c> floor, <c>3</c> room.
|
||||||
/// <paramref name="ParentId"/> is empty for the yard.
|
/// <paramref name="ParentId"/> is empty for the yard.
|
||||||
/// <paramref name="PupilSlots"/> is how many pupils can take a lesson here — summed from things
|
/// <paramref name="PupilSlots"/> is how many pupils can take a lesson here — summed from things
|
||||||
/// on the server, not by the client.
|
/// on the server, not by the client. Live occupancy lives on <see cref="ServerPresenceMessage"/>.
|
||||||
/// <paramref name="ActivitySubject"/> and <paramref name="ActivityClass"/> are empty when the
|
|
||||||
/// room is free. <paramref name="Characters"/> are the people the timetable puts there right now.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record MapSnapshotNode(
|
public sealed record MapSnapshotNode(
|
||||||
byte Kind,
|
byte Kind,
|
||||||
@@ -56,19 +58,42 @@ public sealed record MapSnapshotNode(
|
|||||||
string Name,
|
string Name,
|
||||||
ushort PupilSlots,
|
ushort PupilSlots,
|
||||||
IReadOnlyList<MapSnapshotItem> Items,
|
IReadOnlyList<MapSnapshotItem> Items,
|
||||||
IReadOnlyList<string> Positions,
|
IReadOnlyList<string> Positions);
|
||||||
string ActivitySubject = "",
|
|
||||||
string ActivityClass = "",
|
|
||||||
IReadOnlyList<string>? Characters = null)
|
|
||||||
{
|
|
||||||
public IReadOnlyList<string> Present => Characters ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>One stacked thing in a room. <paramref name="Count"/> is 1–255.</summary>
|
/// <summary>One stacked thing in a room. <paramref name="Count"/> is 1–255.</summary>
|
||||||
public sealed record MapSnapshotItem(string Name, byte Count);
|
public sealed record MapSnapshotItem(string Name, byte Count);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One school's map, labelled in the Hello locale. Sent when that school is opened and again
|
/// One school's map, labelled in the Hello locale. Sent once when that school is opened.
|
||||||
/// when the current lesson slot changes. Occupancy is computed from the timetable and the clock.
|
/// Structure only — people and the current lesson ride the presence stream.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList<MapSnapshotNode> Nodes);
|
public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList<MapSnapshotNode> Nodes);
|
||||||
|
|
||||||
|
/// <summary>Jump empty nights, weekends and holidays. The server re-checks the conditions.</summary>
|
||||||
|
public readonly record struct ClientSkipEmptyMessage;
|
||||||
|
|
||||||
|
/// <summary>Where people are: 1 in a node, 2 walking through it. Off campus is omitted.</summary>
|
||||||
|
public static class PresenceState
|
||||||
|
{
|
||||||
|
public const byte Here = 1;
|
||||||
|
public const byte Walking = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>One occupied (or currently taught) map node in a presence frame.</summary>
|
||||||
|
public sealed record PresenceNode(
|
||||||
|
string Id,
|
||||||
|
ushort Count,
|
||||||
|
string ActivitySubject = "",
|
||||||
|
string ActivityClass = "");
|
||||||
|
|
||||||
|
/// <summary>One on-campus person. Names are resolved over HTTP, not on this frame.</summary>
|
||||||
|
public sealed record PresencePerson(string Id, string NodeId, byte State);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live occupancy of an open school, about twice a second. Counts and people cover the whole
|
||||||
|
/// map; the client filters to the selected tree node.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ServerPresenceMessage(
|
||||||
|
int SchoolId,
|
||||||
|
IReadOnlyList<PresenceNode> Nodes,
|
||||||
|
IReadOnlyList<PresencePerson> People);
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ namespace HSchool.Protocol;
|
|||||||
public static class ProtocolCodec
|
public static class ProtocolCodec
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Largest <em>fixed-size</em> frame this codec produces. Variable map snapshots use
|
/// Largest <em>fixed-size</em> frame this codec produces. Variable map snapshots and
|
||||||
/// <see cref="ProtocolConstants.MaxMessageSize"/> instead.
|
/// presence frames use <see cref="ProtocolConstants.MaxMessageSize"/> instead.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const int MaxFrameSize = 16;
|
public const int MaxFrameSize = 24;
|
||||||
|
|
||||||
public static int WriteHello(Span<byte> destination, in ClientHelloMessage message)
|
public static int WriteHello(Span<byte> destination, in ClientHelloMessage message)
|
||||||
{
|
{
|
||||||
@@ -63,6 +63,13 @@ public static class ProtocolCodec
|
|||||||
return writer.Position;
|
return writer.Position;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static int WriteSkipEmpty(Span<byte> destination)
|
||||||
|
{
|
||||||
|
var writer = new PacketWriter(destination);
|
||||||
|
writer.WriteMessageType(MessageType.ClientSkipEmpty);
|
||||||
|
return writer.Position;
|
||||||
|
}
|
||||||
|
|
||||||
public static int WriteWelcome(Span<byte> destination, in ServerWelcomeMessage message)
|
public static int WriteWelcome(Span<byte> destination, in ServerWelcomeMessage message)
|
||||||
{
|
{
|
||||||
var writer = new PacketWriter(destination);
|
var writer = new PacketWriter(destination);
|
||||||
@@ -90,6 +97,8 @@ public static class ProtocolCodec
|
|||||||
writer.WriteInt64(message.GameTimeUnixMs);
|
writer.WriteInt64(message.GameTimeUnixMs);
|
||||||
writer.WriteByte(message.Running ? (byte)1 : (byte)0);
|
writer.WriteByte(message.Running ? (byte)1 : (byte)0);
|
||||||
writer.WriteByte(message.SpeedIndex);
|
writer.WriteByte(message.SpeedIndex);
|
||||||
|
writer.WriteByte(message.SkipAllowed ? (byte)1 : (byte)0);
|
||||||
|
writer.WriteInt64(message.SkipTargetUnixMs);
|
||||||
return writer.Position;
|
return writer.Position;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,18 +138,6 @@ public static class ProtocolCodec
|
|||||||
{
|
{
|
||||||
size += StringSize(position);
|
size += StringSize(position);
|
||||||
}
|
}
|
||||||
|
|
||||||
size += sizeof(byte);
|
|
||||||
if (HasActivity(node))
|
|
||||||
{
|
|
||||||
size += StringSize(node.ActivitySubject) + StringSize(node.ActivityClass);
|
|
||||||
}
|
|
||||||
|
|
||||||
size += sizeof(byte);
|
|
||||||
foreach (var person in node.Present)
|
|
||||||
{
|
|
||||||
size += StringSize(person);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return size;
|
return size;
|
||||||
@@ -160,9 +157,9 @@ public static class ProtocolCodec
|
|||||||
|
|
||||||
foreach (var node in message.Nodes)
|
foreach (var node in message.Nodes)
|
||||||
{
|
{
|
||||||
if (node.Items.Count > byte.MaxValue || node.Positions.Count > byte.MaxValue || node.Present.Count > byte.MaxValue)
|
if (node.Items.Count > byte.MaxValue || node.Positions.Count > byte.MaxValue)
|
||||||
{
|
{
|
||||||
throw new ProtocolException($"Map node '{node.Id}' has too many items, positions or people for a u8 count.");
|
throw new ProtocolException($"Map node '{node.Id}' has too many items or positions for a u8 count.");
|
||||||
}
|
}
|
||||||
|
|
||||||
writer.WriteByte(node.Kind);
|
writer.WriteByte(node.Kind);
|
||||||
@@ -182,8 +179,53 @@ public static class ProtocolCodec
|
|||||||
{
|
{
|
||||||
writer.WriteString(position);
|
writer.WriteString(position);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (HasActivity(node))
|
return writer.Position;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int PresenceSize(ServerPresenceMessage message)
|
||||||
|
{
|
||||||
|
var size = sizeof(byte) + sizeof(int) + sizeof(ushort);
|
||||||
|
foreach (var node in message.Nodes)
|
||||||
|
{
|
||||||
|
size += StringSize(node.Id) + sizeof(ushort) + sizeof(byte);
|
||||||
|
if (HasPresenceActivity(node))
|
||||||
|
{
|
||||||
|
size += StringSize(node.ActivitySubject) + StringSize(node.ActivityClass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
size += sizeof(ushort);
|
||||||
|
foreach (var person in message.People)
|
||||||
|
{
|
||||||
|
size += StringSize(person.Id) + StringSize(person.NodeId) + sizeof(byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int WritePresence(Span<byte> destination, ServerPresenceMessage message)
|
||||||
|
{
|
||||||
|
if (message.Nodes.Count > ushort.MaxValue)
|
||||||
|
{
|
||||||
|
throw new ProtocolException($"Presence has {message.Nodes.Count} nodes; u16 count cannot hold it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.People.Count > ushort.MaxValue)
|
||||||
|
{
|
||||||
|
throw new ProtocolException($"Presence has {message.People.Count} people; u16 count cannot hold it.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var writer = new PacketWriter(destination);
|
||||||
|
writer.WriteMessageType(MessageType.ServerPresence);
|
||||||
|
writer.WriteInt32(message.SchoolId);
|
||||||
|
writer.WriteUInt16((ushort)message.Nodes.Count);
|
||||||
|
foreach (var node in message.Nodes)
|
||||||
|
{
|
||||||
|
writer.WriteString(node.Id);
|
||||||
|
writer.WriteUInt16(node.Count);
|
||||||
|
if (HasPresenceActivity(node))
|
||||||
{
|
{
|
||||||
writer.WriteByte(1);
|
writer.WriteByte(1);
|
||||||
writer.WriteString(node.ActivitySubject);
|
writer.WriteString(node.ActivitySubject);
|
||||||
@@ -193,12 +235,14 @@ public static class ProtocolCodec
|
|||||||
{
|
{
|
||||||
writer.WriteByte(0);
|
writer.WriteByte(0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writer.WriteByte((byte)node.Present.Count);
|
writer.WriteUInt16((ushort)message.People.Count);
|
||||||
foreach (var person in node.Present)
|
foreach (var person in message.People)
|
||||||
{
|
{
|
||||||
writer.WriteString(person);
|
writer.WriteString(person.Id);
|
||||||
}
|
writer.WriteString(person.NodeId);
|
||||||
|
writer.WriteByte(person.State);
|
||||||
}
|
}
|
||||||
|
|
||||||
return writer.Position;
|
return writer.Position;
|
||||||
@@ -271,7 +315,9 @@ public static class ProtocolCodec
|
|||||||
var gameTime = reader.ReadInt64();
|
var gameTime = reader.ReadInt64();
|
||||||
var running = reader.ReadByte() != 0;
|
var running = reader.ReadByte() != 0;
|
||||||
var speedIndex = reader.ReadByte();
|
var speedIndex = reader.ReadByte();
|
||||||
return new ServerClockMessage(schoolId, gameTime, running, speedIndex);
|
var skipAllowed = reader.ReadByte() != 0;
|
||||||
|
var skipTarget = reader.ReadInt64();
|
||||||
|
return new ServerClockMessage(schoolId, gameTime, running, speedIndex, skipAllowed, skipTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ServerSchoolGoneMessage ReadSchoolGone(ReadOnlySpan<byte> source)
|
public static ServerSchoolGoneMessage ReadSchoolGone(ReadOnlySpan<byte> source)
|
||||||
@@ -312,6 +358,23 @@ public static class ProtocolCodec
|
|||||||
positions[position] = reader.ReadString();
|
positions[position] = reader.ReadString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nodes[i] = new MapSnapshotNode(kind, id, parentId, name, pupilSlots, items, positions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ServerMapSnapshotMessage(schoolId, nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ServerPresenceMessage ReadPresence(ReadOnlySpan<byte> source)
|
||||||
|
{
|
||||||
|
var reader = new PacketReader(source);
|
||||||
|
Expect(ref reader, MessageType.ServerPresence);
|
||||||
|
var schoolId = reader.ReadInt32();
|
||||||
|
var nodeCount = reader.ReadUInt16();
|
||||||
|
var nodes = new PresenceNode[nodeCount];
|
||||||
|
for (var i = 0; i < nodeCount; i++)
|
||||||
|
{
|
||||||
|
var id = reader.ReadString();
|
||||||
|
var count = reader.ReadUInt16();
|
||||||
var hasActivity = reader.ReadByte() != 0;
|
var hasActivity = reader.ReadByte() != 0;
|
||||||
var activitySubject = "";
|
var activitySubject = "";
|
||||||
var activityClass = "";
|
var activityClass = "";
|
||||||
@@ -321,30 +384,20 @@ public static class ProtocolCodec
|
|||||||
activityClass = reader.ReadString();
|
activityClass = reader.ReadString();
|
||||||
}
|
}
|
||||||
|
|
||||||
var characterCount = reader.ReadByte();
|
nodes[i] = new PresenceNode(id, count, activitySubject, activityClass);
|
||||||
var characters = new string[characterCount];
|
|
||||||
for (var person = 0; person < characterCount; person++)
|
|
||||||
{
|
|
||||||
characters[person] = reader.ReadString();
|
|
||||||
}
|
|
||||||
|
|
||||||
nodes[i] = new MapSnapshotNode(
|
|
||||||
kind,
|
|
||||||
id,
|
|
||||||
parentId,
|
|
||||||
name,
|
|
||||||
pupilSlots,
|
|
||||||
items,
|
|
||||||
positions,
|
|
||||||
activitySubject,
|
|
||||||
activityClass,
|
|
||||||
characters);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new ServerMapSnapshotMessage(schoolId, nodes);
|
var personCount = reader.ReadUInt16();
|
||||||
|
var people = new PresencePerson[personCount];
|
||||||
|
for (var i = 0; i < personCount; i++)
|
||||||
|
{
|
||||||
|
people[i] = new PresencePerson(reader.ReadString(), reader.ReadString(), reader.ReadByte());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ServerPresenceMessage(schoolId, nodes, people);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool HasActivity(MapSnapshotNode node) =>
|
private static bool HasPresenceActivity(PresenceNode node) =>
|
||||||
node.ActivitySubject.Length > 0 || node.ActivityClass.Length > 0;
|
node.ActivitySubject.Length > 0 || node.ActivityClass.Length > 0;
|
||||||
|
|
||||||
/// <summary>Matches <see cref="PacketWriter.WriteString"/>: a <c>u16</c> length plus UTF-8.</summary>
|
/// <summary>Matches <see cref="PacketWriter.WriteString"/>: a <c>u16</c> length plus UTF-8.</summary>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
|
|||||||
public static class ProtocolConstants
|
public static class ProtocolConstants
|
||||||
{
|
{
|
||||||
/// <summary>Bumped on every breaking change to the binary layout.</summary>
|
/// <summary>Bumped on every breaking change to the binary layout.</summary>
|
||||||
public const byte Version = 6;
|
public const byte Version = 7;
|
||||||
|
|
||||||
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
|
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
|
||||||
public const int MaxMessageSize = 8 * 1024;
|
public const int MaxMessageSize = 8 * 1024;
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ internal sealed record PersonFamilyResponse(
|
|||||||
|
|
||||||
internal sealed record PersonRelResponse(string Id, string FullName, bool Female);
|
internal sealed record PersonRelResponse(string Id, string FullName, bool Female);
|
||||||
|
|
||||||
|
internal sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People);
|
||||||
|
|
||||||
|
internal sealed record DirectoryPersonResponse(string Id, string FullName);
|
||||||
|
|
||||||
internal static class PeopleListMapper
|
internal static class PeopleListMapper
|
||||||
{
|
{
|
||||||
public static PeopleListResponse From(
|
public static PeopleListResponse From(
|
||||||
|
|||||||
@@ -134,6 +134,29 @@ internal static class SchoolEndpoints
|
|||||||
})
|
})
|
||||||
.WithName("GetSchoolPeople");
|
.WithName("GetSchoolPeople");
|
||||||
|
|
||||||
|
schools.MapGet("/{id:int}/directory", (int id, string? lang, GameLoopService loop) =>
|
||||||
|
{
|
||||||
|
var published = loop.FindPeople(id);
|
||||||
|
if (published is null)
|
||||||
|
{
|
||||||
|
return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist.");
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = ParseLocale(lang);
|
||||||
|
var roster = published.Roster;
|
||||||
|
if (roster is null)
|
||||||
|
{
|
||||||
|
return Results.Ok(new DirectoryResponse([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
var people = roster.People
|
||||||
|
.OrderBy(person => person.Id, StringComparer.Ordinal)
|
||||||
|
.Select(person => new DirectoryPersonResponse(person.Id, person.Name.Full))
|
||||||
|
.ToArray();
|
||||||
|
return Results.Ok(new DirectoryResponse(people));
|
||||||
|
})
|
||||||
|
.WithName("GetSchoolDirectory");
|
||||||
|
|
||||||
schools.MapGet("/{id:int}/people/{personId}", async (
|
schools.MapGet("/{id:int}/people/{personId}", async (
|
||||||
int id,
|
int id,
|
||||||
string personId,
|
string personId,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace HSchool.Server.Game;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Work item handed from a request or connection thread to the supervisor. Create, delete and
|
/// Work item handed from a request or connection thread to the supervisor. Create, delete and
|
||||||
/// name suggestions stay here; open/close/running/speed are forwarded to the school's worker.
|
/// name suggestions stay here; open/close/running/speed/skip are forwarded to the school's worker.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal abstract record GameCommand
|
internal abstract record GameCommand
|
||||||
{
|
{
|
||||||
@@ -36,6 +36,8 @@ internal abstract record GameCommand
|
|||||||
|
|
||||||
internal sealed record SetSpeed(uint PlayerId, byte SpeedIndex) : GameCommand;
|
internal sealed record SetSpeed(uint PlayerId, byte SpeedIndex) : GameCommand;
|
||||||
|
|
||||||
|
internal sealed record SkipEmpty(uint PlayerId) : GameCommand;
|
||||||
|
|
||||||
/// <summary>Stops every worker, re-reads the save directory, starts workers from those files.</summary>
|
/// <summary>Stops every worker, re-reads the save directory, starts workers from those files.</summary>
|
||||||
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
|
internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand;
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,10 @@ internal sealed class GameLoopService(
|
|||||||
RouteOpenSchool(setSpeed.PlayerId, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex));
|
RouteOpenSchool(setSpeed.PlayerId, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case GameCommand.SkipEmpty skipEmpty:
|
||||||
|
RouteOpenSchool(skipEmpty.PlayerId, new WorkerCommand.SkipEmpty());
|
||||||
|
break;
|
||||||
|
|
||||||
case GameCommand.ReloadSaves reload:
|
case GameCommand.ReloadSaves reload:
|
||||||
await HandleReloadAsync(reload).ConfigureAwait(false);
|
await HandleReloadAsync(reload).ConfigureAwait(false);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
using HSchool.Content;
|
|
||||||
using HSchool.People;
|
|
||||||
using HSchool.Protocol;
|
|
||||||
using HSchool.Schedule;
|
|
||||||
using HSchool.Simulation;
|
|
||||||
|
|
||||||
namespace HSchool.Server.Game;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Overlays the current lesson onto map-tree nodes. Occupancy is derived from the timetable and
|
|
||||||
/// the clock; it is not stored on the map.
|
|
||||||
/// </summary>
|
|
||||||
internal static class MapOccupancy
|
|
||||||
{
|
|
||||||
public static void Apply(
|
|
||||||
MapSnapshotNode[] nodes,
|
|
||||||
School school,
|
|
||||||
int weekDays,
|
|
||||||
string locale)
|
|
||||||
{
|
|
||||||
if (school.Timetable is null || school.Roster is null || school.Catalog is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var occurring = TimetableClock.OccurringAt(
|
|
||||||
school.Timetable,
|
|
||||||
school.Catalog,
|
|
||||||
school.Clock.Time,
|
|
||||||
weekDays);
|
|
||||||
if (occurring.Count == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var catalog = school.Catalog;
|
|
||||||
var classes = school.Roster.Classes.ToDictionary(item => item.Id, StringComparer.Ordinal);
|
|
||||||
var people = school.Roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
|
|
||||||
var byRoom = occurring.ToDictionary(lesson => lesson.RoomId, StringComparer.Ordinal);
|
|
||||||
|
|
||||||
for (var i = 0; i < nodes.Length; i++)
|
|
||||||
{
|
|
||||||
if (!byRoom.TryGetValue(nodes[i].Id, out var lesson))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
classes.TryGetValue(lesson.ClassId, out var schoolClass);
|
|
||||||
var subjectLabel = catalog.Subjects.TryGetValue(lesson.Subject, out var subject)
|
|
||||||
? catalog.Label(locale, subject)
|
|
||||||
: lesson.Subject;
|
|
||||||
var classLabel = schoolClass is null
|
|
||||||
? lesson.ClassId
|
|
||||||
: $"{schoolClass.Year}{schoolClass.Letter}";
|
|
||||||
|
|
||||||
nodes[i] = nodes[i] with
|
|
||||||
{
|
|
||||||
ActivitySubject = subjectLabel,
|
|
||||||
ActivityClass = classLabel,
|
|
||||||
Characters = NamesOf(lesson, schoolClass, people),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string[] NamesOf(
|
|
||||||
LessonPlacement lesson,
|
|
||||||
SchoolClass? schoolClass,
|
|
||||||
IReadOnlyDictionary<string, Person> people)
|
|
||||||
{
|
|
||||||
var names = new List<string>();
|
|
||||||
if (people.TryGetValue(lesson.TeacherId, out var teacher))
|
|
||||||
{
|
|
||||||
names.Add(teacher.Name.Full);
|
|
||||||
}
|
|
||||||
else if (lesson.TeacherId.Length > 0)
|
|
||||||
{
|
|
||||||
names.Add(lesson.TeacherId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schoolClass is not null)
|
|
||||||
{
|
|
||||||
foreach (var pupilId in schoolClass.PupilIds)
|
|
||||||
{
|
|
||||||
if (people.TryGetValue(pupilId, out var pupil))
|
|
||||||
{
|
|
||||||
names.Add(pupil.Name.Full);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return names.Count > byte.MaxValue ? [.. names.Take(byte.MaxValue)] : [.. names];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
using HSchool.Content;
|
||||||
|
using HSchool.People;
|
||||||
|
using HSchool.Protocol;
|
||||||
|
using HSchool.Schedule;
|
||||||
|
using HSchool.Simulation;
|
||||||
|
|
||||||
|
namespace HSchool.Server.Game;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a presence frame from stored places plus the current lesson. Occupancy is state;
|
||||||
|
/// the lesson labels are still derived from the timetable so an empty classroom during a period
|
||||||
|
/// still shows what should be happening there.
|
||||||
|
/// </summary>
|
||||||
|
internal static class PresenceFrame
|
||||||
|
{
|
||||||
|
public static ServerPresenceMessage Build(School school, int weekDays, string locale)
|
||||||
|
{
|
||||||
|
var people = new List<PresencePerson>();
|
||||||
|
var counts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
|
foreach (var row in school.CapturePresence())
|
||||||
|
{
|
||||||
|
if (row.NodeId is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var walking = row.Path.Count > 0 || row.RemainingMinutes > 0;
|
||||||
|
people.Add(new PresencePerson(
|
||||||
|
row.PersonId,
|
||||||
|
row.NodeId,
|
||||||
|
walking ? PresenceState.Walking : PresenceState.Here));
|
||||||
|
counts[row.NodeId] = counts.GetValueOrDefault(row.NodeId) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
people.Sort((left, right) => StringComparer.Ordinal.Compare(left.Id, right.Id));
|
||||||
|
|
||||||
|
var activity = ActivityByRoom(school, weekDays, locale);
|
||||||
|
var nodeIds = counts.Keys
|
||||||
|
.Concat(activity.Keys)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.OrderBy(id => id, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var nodes = new List<PresenceNode>();
|
||||||
|
foreach (var id in nodeIds)
|
||||||
|
{
|
||||||
|
var count = (ushort)Math.Min(counts.GetValueOrDefault(id), ushort.MaxValue);
|
||||||
|
var subject = "";
|
||||||
|
var classLabel = "";
|
||||||
|
if (activity.TryGetValue(id, out var labels))
|
||||||
|
{
|
||||||
|
subject = labels.Subject;
|
||||||
|
classLabel = labels.Class;
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.Add(new PresenceNode(id, count, subject, classLabel));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ServerPresenceMessage(school.Id, nodes, people);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Dictionary<string, (string Subject, string Class)> ActivityByRoom(
|
||||||
|
School school,
|
||||||
|
int weekDays,
|
||||||
|
string locale)
|
||||||
|
{
|
||||||
|
var labels = new Dictionary<string, (string Subject, string Class)>(StringComparer.Ordinal);
|
||||||
|
if (school.Timetable is null || school.Roster is null || school.Catalog is null)
|
||||||
|
{
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
var occurring = TimetableClock.OccurringAt(
|
||||||
|
school.Timetable,
|
||||||
|
school.Catalog,
|
||||||
|
school.Clock.Time,
|
||||||
|
weekDays);
|
||||||
|
if (occurring.Count == 0)
|
||||||
|
{
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
var catalog = school.Catalog;
|
||||||
|
var classes = school.Roster.Classes.ToDictionary(item => item.Id, StringComparer.Ordinal);
|
||||||
|
foreach (var lesson in occurring)
|
||||||
|
{
|
||||||
|
classes.TryGetValue(lesson.ClassId, out var schoolClass);
|
||||||
|
var subjectLabel = catalog.Subjects.TryGetValue(lesson.Subject, out var subject)
|
||||||
|
? catalog.Label(locale, subject)
|
||||||
|
: lesson.Subject;
|
||||||
|
var classLabel = schoolClass is null
|
||||||
|
? lesson.ClassId
|
||||||
|
: $"{schoolClass.Year}{schoolClass.Letter}";
|
||||||
|
labels[lesson.RoomId] = (subjectLabel, classLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ internal sealed class SchoolWorker
|
|||||||
private DefCatalog? _catalogSnapshot;
|
private DefCatalog? _catalogSnapshot;
|
||||||
private Timetable? _timetableSnapshot;
|
private Timetable? _timetableSnapshot;
|
||||||
private MapLayout? _mapSnapshot;
|
private MapLayout? _mapSnapshot;
|
||||||
private OccupancyKey _occupancyKey;
|
private int _presenceAge;
|
||||||
private School? _school;
|
private School? _school;
|
||||||
private Task? _run;
|
private Task? _run;
|
||||||
private bool _persistOnStop = true;
|
private bool _persistOnStop = true;
|
||||||
@@ -308,7 +308,7 @@ internal sealed class SchoolWorker
|
|||||||
{
|
{
|
||||||
PublishSnapshot();
|
PublishSnapshot();
|
||||||
BroadcastClock();
|
BroadcastClock();
|
||||||
MaybeBroadcastOccupancy(school);
|
MaybeBroadcastPresence(school);
|
||||||
}
|
}
|
||||||
|
|
||||||
FlushSettings();
|
FlushSettings();
|
||||||
@@ -381,6 +381,7 @@ internal sealed class SchoolWorker
|
|||||||
open.Client.OpenSchoolId = _id;
|
open.Client.OpenSchoolId = _id;
|
||||||
SendMapSnapshot(open.Client, school);
|
SendMapSnapshot(open.Client, school);
|
||||||
BroadcastClockTo(open.Client, school);
|
BroadcastClockTo(open.Client, school);
|
||||||
|
SendPresence(open.Client, school);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case WorkerCommand.Close close:
|
case WorkerCommand.Close close:
|
||||||
@@ -402,6 +403,10 @@ internal sealed class SchoolWorker
|
|||||||
dirty = true;
|
dirty = true;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case WorkerCommand.SkipEmpty:
|
||||||
|
ApplySkip(school);
|
||||||
|
break;
|
||||||
|
|
||||||
case WorkerCommand.GetPerson getPerson:
|
case WorkerCommand.GetPerson getPerson:
|
||||||
var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale);
|
var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale);
|
||||||
getPerson.Result.TrySetResult(
|
getPerson.Result.TrySetResult(
|
||||||
@@ -613,7 +618,6 @@ internal sealed class SchoolWorker
|
|||||||
PersistTimetable(school);
|
PersistTimetable(school);
|
||||||
}
|
}
|
||||||
|
|
||||||
RememberOccupancy(school);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -770,11 +774,7 @@ internal sealed class SchoolWorker
|
|||||||
PublishSnapshot();
|
PublishSnapshot();
|
||||||
if (broadcast)
|
if (broadcast)
|
||||||
{
|
{
|
||||||
MaybeBroadcastOccupancy(school, force: true);
|
BroadcastPresence();
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
RememberOccupancy(school);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -799,35 +799,41 @@ internal sealed class SchoolWorker
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RememberOccupancy(School school)
|
private void MaybeBroadcastPresence(School school)
|
||||||
{
|
{
|
||||||
if (school.Catalog is not null)
|
_presenceAge++;
|
||||||
|
var interval = Math.Max(1, _options.TickRate / 2);
|
||||||
|
if (_presenceAge < interval)
|
||||||
{
|
{
|
||||||
_occupancyKey = TimetableClock.Key(school.Catalog, school.Clock.Time, _options.SchoolWeekDays);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_presenceAge = 0;
|
||||||
|
BroadcastPresence();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void MaybeBroadcastOccupancy(School school, bool force = false)
|
private void ApplySkip(School school)
|
||||||
{
|
{
|
||||||
if (school.Catalog is null)
|
var result = school.TrySkipEmpty();
|
||||||
|
if (!result.Succeeded)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var key = TimetableClock.Key(school.Catalog, school.Clock.Time, _options.SchoolWeekDays);
|
if (result.PeopleChanged)
|
||||||
if (!force && key == _occupancyKey)
|
|
||||||
{
|
{
|
||||||
return;
|
PersistPeople();
|
||||||
}
|
if (school.TimetableDirty)
|
||||||
|
|
||||||
_occupancyKey = key;
|
|
||||||
foreach (var client in _clients.All)
|
|
||||||
{
|
|
||||||
if (client.IsReady && client.OpenSchoolId == _id)
|
|
||||||
{
|
{
|
||||||
SendMapSnapshot(client, school);
|
RebuildTimetable(school);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PublishSnapshot();
|
||||||
|
Persist();
|
||||||
|
BroadcastClock();
|
||||||
|
BroadcastPresence();
|
||||||
|
_presenceAge = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool InstallPeople(School school, DefCatalog catalog, MapLayout map)
|
private bool InstallPeople(School school, DefCatalog catalog, MapLayout map)
|
||||||
@@ -987,8 +993,6 @@ internal sealed class SchoolWorker
|
|||||||
node.Positions);
|
node.Positions);
|
||||||
}
|
}
|
||||||
|
|
||||||
MapOccupancy.Apply(nodes, school, _options.SchoolWeekDays, locale);
|
|
||||||
|
|
||||||
// Sized from the message, not from the inbound frame limit: a map the player enlarged in
|
// Sized from the message, not from the inbound frame limit: a map the player enlarged in
|
||||||
// the create editor outgrows 8 KiB somewhere past sixty furnished rooms.
|
// the create editor outgrows 8 KiB somewhere past sixty furnished rooms.
|
||||||
var message = new ServerMapSnapshotMessage(school.Id, nodes);
|
var message = new ServerMapSnapshotMessage(school.Id, nodes);
|
||||||
@@ -997,15 +1001,44 @@ internal sealed class SchoolWorker
|
|||||||
client.TrySendReliable(frame.AsMemory(0, length));
|
client.TrySendReliable(frame.AsMemory(0, length));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void BroadcastClockTo(GameClient client, School school)
|
private void BroadcastClockTo(GameClient client, School school)
|
||||||
{
|
{
|
||||||
|
var skip = school.PeekSkipEmpty();
|
||||||
var frame = new byte[ProtocolCodec.MaxFrameSize];
|
var frame = new byte[ProtocolCodec.MaxFrameSize];
|
||||||
var length = ProtocolCodec.WriteClock(frame, new ServerClockMessage(
|
var length = ProtocolCodec.WriteClock(frame, new ServerClockMessage(
|
||||||
school.Id,
|
school.Id,
|
||||||
new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(),
|
new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(),
|
||||||
school.Clock.IsRunning,
|
school.Clock.IsRunning,
|
||||||
(byte)school.Clock.SpeedIndex));
|
(byte)school.Clock.SpeedIndex,
|
||||||
|
skip.Allowed,
|
||||||
|
skip.Time is { } target ? new DateTimeOffset(target).ToUnixTimeMilliseconds() : 0));
|
||||||
|
|
||||||
client.TrySend(frame.AsMemory(0, length));
|
client.TrySend(frame.AsMemory(0, length));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void BroadcastPresence()
|
||||||
|
{
|
||||||
|
var school = _school;
|
||||||
|
if (school is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var client in _clients.All)
|
||||||
|
{
|
||||||
|
if (client.IsReady && client.OpenSchoolId == _id)
|
||||||
|
{
|
||||||
|
SendPresence(client, school);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SendPresence(GameClient client, School school)
|
||||||
|
{
|
||||||
|
var locale = ProtocolConstants.CatalogLocale(client.Locale);
|
||||||
|
var message = PresenceFrame.Build(school, _options.SchoolWeekDays, locale);
|
||||||
|
var frame = new byte[ProtocolCodec.PresenceSize(message)];
|
||||||
|
var length = ProtocolCodec.WritePresence(frame, message);
|
||||||
|
client.TrySendReliable(frame.AsMemory(0, length));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ internal abstract record WorkerCommand
|
|||||||
|
|
||||||
internal sealed record SetSpeed(byte SpeedIndex) : WorkerCommand;
|
internal sealed record SetSpeed(byte SpeedIndex) : WorkerCommand;
|
||||||
|
|
||||||
|
internal sealed record SkipEmpty : WorkerCommand;
|
||||||
|
|
||||||
internal sealed record GetPerson(
|
internal sealed record GetPerson(
|
||||||
string PersonId,
|
string PersonId,
|
||||||
string Locale,
|
string Locale,
|
||||||
|
|||||||
@@ -153,6 +153,10 @@ internal sealed class GameSocketHandler(
|
|||||||
commands.Enqueue(new GameCommand.SetSpeed(client.PlayerId, setSpeed.SpeedIndex));
|
commands.Enqueue(new GameCommand.SetSpeed(client.PlayerId, setSpeed.SpeedIndex));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case MessageType.ClientSkipEmpty:
|
||||||
|
commands.Enqueue(new GameCommand.SkipEmpty(client.PlayerId));
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
logger.LogDebug(
|
logger.LogDebug(
|
||||||
"Ignoring unexpected frame 0x{MessageType:X2} from client {PlayerId}.",
|
"Ignoring unexpected frame 0x{MessageType:X2} from client {PlayerId}.",
|
||||||
|
|||||||
@@ -138,6 +138,18 @@ public sealed class School : IDisposable
|
|||||||
|
|
||||||
public bool IsCampusEmpty() => PresenceSystem.IsEmpty(this);
|
public bool IsCampusEmpty() => PresenceSystem.IsEmpty(this);
|
||||||
|
|
||||||
|
public SkipEmptyPeek PeekSkipEmpty()
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
if (Catalog is null || !IsCampusEmpty() || SchoolDay.InWorkWindow(Catalog, Clock.Time, SchoolWeekDays))
|
||||||
|
{
|
||||||
|
return SkipEmptyPeek.Refused;
|
||||||
|
}
|
||||||
|
|
||||||
|
var next = SchoolDay.NextWorkMorning(Catalog, Clock.Time, SchoolWeekDays, MaxSkipDays);
|
||||||
|
return next is null ? SkipEmptyPeek.Refused : new SkipEmptyPeek(true, next.Value);
|
||||||
|
}
|
||||||
|
|
||||||
public SkipEmptyResult TrySkipEmpty()
|
public SkipEmptyResult TrySkipEmpty()
|
||||||
{
|
{
|
||||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
@@ -295,6 +307,11 @@ public enum SkipEmptyError
|
|||||||
NoMorning,
|
NoMorning,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public readonly record struct SkipEmptyPeek(bool Allowed, DateTime? Time)
|
||||||
|
{
|
||||||
|
public static SkipEmptyPeek Refused { get; } = new(false, null);
|
||||||
|
}
|
||||||
|
|
||||||
public readonly record struct SkipEmptyResult(SkipEmptyError Error, DateTime? Time, bool PeopleChanged)
|
public readonly record struct SkipEmptyResult(SkipEmptyError Error, DateTime? Time, bool PeopleChanged)
|
||||||
{
|
{
|
||||||
public bool Succeeded => Error == SkipEmptyError.None;
|
public bool Succeeded => Error == SkipEmptyError.None;
|
||||||
|
|||||||
@@ -111,8 +111,6 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
Assert.Equal(16, classroom.PupilSlots);
|
Assert.Equal(16, classroom.PupilSlots);
|
||||||
Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16);
|
Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16);
|
||||||
Assert.DoesNotContain(classroom.Items, item => item.Name == "Стул");
|
Assert.DoesNotContain(classroom.Items, item => item.Name == "Стул");
|
||||||
Assert.Equal("", classroom.ActivitySubject);
|
|
||||||
Assert.Empty(classroom.Present);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -161,13 +159,21 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpeningASchoolDuringAMathLesson_PutsOccupancyOnTheRoom()
|
public async Task OpeningASchoolDuringAMathLesson_PutsOccupancyOnPresenceNotTheSnapshot()
|
||||||
{
|
{
|
||||||
using var client = fixture.App.CreateHttpClient("server");
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
await SchoolApiTests.ResetAsync(client);
|
await SchoolApiTests.ResetAsync(client);
|
||||||
var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
|
var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
|
||||||
var school = await SchoolApiTests.CreateAsync(client, "Кто где сейчас", start);
|
var school = await SchoolApiTests.CreateAsync(client, "Кто где сейчас", start);
|
||||||
|
|
||||||
|
using var socket = await OpenSchoolAsync(school.Id);
|
||||||
|
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
||||||
|
Assert.Contains(snapshot.Nodes, node => node.Id == "classroom-101");
|
||||||
|
|
||||||
|
await SendAsync(socket, buffer =>
|
||||||
|
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
|
||||||
|
await ReceiveClockWhereAsync(socket, clock => !clock.Running);
|
||||||
|
|
||||||
var staffing = await client.GetFromJsonAsync<StaffingSnapshot>(
|
var staffing = await client.GetFromJsonAsync<StaffingSnapshot>(
|
||||||
$"/api/schools/{school.Id}/staffing",
|
$"/api/schools/{school.Id}/staffing",
|
||||||
TestContext.Current.CancellationToken);
|
TestContext.Current.CancellationToken);
|
||||||
@@ -184,14 +190,49 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
TestContext.Current.CancellationToken);
|
TestContext.Current.CancellationToken);
|
||||||
assign.EnsureSuccessStatusCode();
|
assign.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
using var socket = await OpenSchoolAsync(school.Id);
|
var presence = await ReceivePresenceWhereAsync(
|
||||||
var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot));
|
socket,
|
||||||
var occupied = Assert.Single(snapshot.Nodes, node => node.ActivitySubject.Length > 0);
|
frame => frame.Nodes.Any(node => node.ActivitySubject.Length > 0));
|
||||||
|
var occupied = Assert.Single(presence.Nodes, node => node.ActivitySubject.Length > 0);
|
||||||
|
|
||||||
Assert.Equal("Математика", occupied.ActivitySubject);
|
Assert.Equal("Математика", occupied.ActivitySubject);
|
||||||
Assert.NotEmpty(occupied.ActivityClass);
|
Assert.NotEmpty(occupied.ActivityClass);
|
||||||
Assert.Contains(applicant.FullName, occupied.Present);
|
|
||||||
Assert.True(occupied.Present.Count > 1);
|
var directory = await client.GetFromJsonAsync<DirectorySnapshot>(
|
||||||
|
$"/api/schools/{school.Id}/directory",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(directory);
|
||||||
|
Assert.Contains(directory.People, person => person.Id == applicant.Id && person.FullName == applicant.FullName);
|
||||||
|
|
||||||
|
await SendAsync(socket, buffer =>
|
||||||
|
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: true)));
|
||||||
|
presence = await ReceivePresenceWhereAsync(
|
||||||
|
socket,
|
||||||
|
frame => frame.People.Any(person => person.Id == applicant.Id));
|
||||||
|
Assert.Contains(presence.People, person => person.Id == applicant.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SkipEmpty_DuringWorkHours_IsIgnored()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var start = new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(client, "Пропуск в учебное время", start);
|
||||||
|
|
||||||
|
using var socket = await OpenSchoolAsync(school.Id);
|
||||||
|
await SendAsync(socket, buffer =>
|
||||||
|
ProtocolCodec.WriteSetRunning(buffer, new ClientSetRunningMessage(Running: false)));
|
||||||
|
var paused = await ReceiveClockWhereAsync(socket, clock => !clock.Running);
|
||||||
|
|
||||||
|
Assert.False(paused.SkipAllowed);
|
||||||
|
Assert.Equal(0, paused.SkipTargetUnixMs);
|
||||||
|
|
||||||
|
await SendAsync(socket, buffer => ProtocolCodec.WriteSkipEmpty(buffer));
|
||||||
|
var later = await ReceiveClockAfterAsync(socket, TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
Assert.Equal(paused.GameTimeUnixMs, later.GameTimeUnixMs);
|
||||||
|
Assert.False(later.SkipAllowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -465,6 +506,22 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
throw new InvalidOperationException("No clock frame matched within 40 frames.");
|
throw new InvalidOperationException("No clock frame matched within 40 frames.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<ServerPresenceMessage> ReceivePresenceWhereAsync(
|
||||||
|
WebSocket socket,
|
||||||
|
Func<ServerPresenceMessage, bool> predicate)
|
||||||
|
{
|
||||||
|
for (var attempt = 0; attempt < 40; attempt++)
|
||||||
|
{
|
||||||
|
var presence = ProtocolCodec.ReadPresence(await ReceiveUntilAsync(socket, MessageType.ServerPresence));
|
||||||
|
if (predicate(presence))
|
||||||
|
{
|
||||||
|
return presence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("No presence frame matched within 40 frames.");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Keeps reading clock frames for <paramref name="duration"/> and returns the last one.</summary>
|
/// <summary>Keeps reading clock frames for <paramref name="duration"/> and returns the last one.</summary>
|
||||||
private static async Task<ServerClockMessage> ReceiveClockAfterAsync(WebSocket socket, TimeSpan duration)
|
private static async Task<ServerClockMessage> ReceiveClockAfterAsync(WebSocket socket, TimeSpan duration)
|
||||||
{
|
{
|
||||||
@@ -523,4 +580,8 @@ public class GameSocketTests(AppHostFixture fixture)
|
|||||||
private sealed record StaffingSnapshot(IReadOnlyList<ApplicantSnapshot> Applicants);
|
private sealed record StaffingSnapshot(IReadOnlyList<ApplicantSnapshot> Applicants);
|
||||||
|
|
||||||
private sealed record ApplicantSnapshot(string Id, string FullName);
|
private sealed record ApplicantSnapshot(string Id, string FullName);
|
||||||
|
|
||||||
|
private sealed record DirectorySnapshot(IReadOnlyList<DirectoryPersonSnapshot> People);
|
||||||
|
|
||||||
|
private sealed record DirectoryPersonSnapshot(string Id, string FullName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,32 @@ public class PeopleApiTests(AppHostFixture fixture)
|
|||||||
Assert.Empty(staff.People);
|
Assert.Empty(staff.People);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Directory_ListsRosterIdsAndNames()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(client, "Справочник", Start);
|
||||||
|
|
||||||
|
var directory = await client.GetFromJsonAsync<DirectoryResponse>(
|
||||||
|
$"/api/schools/{school.Id}/directory",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(directory);
|
||||||
|
Assert.NotEmpty(directory.People);
|
||||||
|
Assert.All(directory.People, person =>
|
||||||
|
{
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(person.Id));
|
||||||
|
Assert.False(string.IsNullOrWhiteSpace(person.FullName));
|
||||||
|
});
|
||||||
|
|
||||||
|
var page = await GetPeopleAsync(client, school.Id, "pageSize=10");
|
||||||
|
Assert.Contains(directory.People, person => person.Id == page.People[0].Id && person.FullName == page.People[0].FullName);
|
||||||
|
|
||||||
|
using var missing = await client.GetAsync("/api/schools/999999/directory", TestContext.Current.CancellationToken);
|
||||||
|
Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode);
|
||||||
|
Assert.Equal("unknown-school", await ProblemCodeAsync(missing));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task List_UnknownSchool_IsNotFound()
|
public async Task List_UnknownSchool_IsNotFound()
|
||||||
{
|
{
|
||||||
@@ -204,4 +230,8 @@ public class PeopleApiTests(AppHostFixture fixture)
|
|||||||
IReadOnlyList<PersonRelResponse> Partners);
|
IReadOnlyList<PersonRelResponse> Partners);
|
||||||
|
|
||||||
private sealed record PersonRelResponse(string Id, string FullName, bool Female);
|
private sealed record PersonRelResponse(string Id, string FullName, bool Female);
|
||||||
|
|
||||||
|
private sealed record DirectoryResponse(IReadOnlyList<DirectoryPersonResponse> People);
|
||||||
|
|
||||||
|
private sealed record DirectoryPersonResponse(string Id, string FullName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,17 @@ public class ProtocolCodecTests
|
|||||||
Assert.Equal(message, ProtocolCodec.ReadSetSpeed(buffer[..length]));
|
Assert.Equal(message, ProtocolCodec.ReadSetSpeed(buffer[..length]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SkipEmpty_IsASingleByte()
|
||||||
|
{
|
||||||
|
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
|
||||||
|
|
||||||
|
var length = ProtocolCodec.WriteSkipEmpty(buffer);
|
||||||
|
|
||||||
|
Assert.Equal(1, length);
|
||||||
|
Assert.Equal(MessageType.ClientSkipEmpty, ProtocolCodec.PeekMessageType(buffer[..length]));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Welcome_RoundTripsAndIsFourBytes()
|
public void Welcome_RoundTripsAndIsFourBytes()
|
||||||
{
|
{
|
||||||
@@ -110,17 +121,44 @@ public class ProtocolCodecTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Clock_RoundTripsAndIsFifteenBytes()
|
public void Clock_RoundTripsAndIsTwentyFourBytes()
|
||||||
{
|
{
|
||||||
var message = new ServerClockMessage(7, 1_333_432_800_000, Running: true, SpeedIndex: 2);
|
var message = new ServerClockMessage(
|
||||||
|
7,
|
||||||
|
1_333_432_800_000,
|
||||||
|
Running: true,
|
||||||
|
SpeedIndex: 2,
|
||||||
|
SkipAllowed: true,
|
||||||
|
SkipTargetUnixMs: 1_333_516_800_000);
|
||||||
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
|
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
|
||||||
|
|
||||||
var length = ProtocolCodec.WriteClock(buffer, message);
|
var length = ProtocolCodec.WriteClock(buffer, message);
|
||||||
|
|
||||||
Assert.Equal(15, length);
|
Assert.Equal(24, length);
|
||||||
|
Assert.Equal((byte)MessageType.ServerClock, buffer[0]);
|
||||||
|
Assert.Equal(7, BitConverter.ToInt32(buffer[1..5]));
|
||||||
|
Assert.Equal(1_333_432_800_000, BitConverter.ToInt64(buffer[5..13]));
|
||||||
|
Assert.Equal(1, buffer[13]);
|
||||||
|
Assert.Equal(2, buffer[14]);
|
||||||
|
Assert.Equal(1, buffer[15]);
|
||||||
|
Assert.Equal(1_333_516_800_000, BitConverter.ToInt64(buffer[16..24]));
|
||||||
Assert.Equal(message, ProtocolCodec.ReadClock(buffer[..length]));
|
Assert.Equal(message, ProtocolCodec.ReadClock(buffer[..length]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clock_WithoutSkip_WritesZeroTarget()
|
||||||
|
{
|
||||||
|
var message = new ServerClockMessage(1, 0, Running: false, SpeedIndex: 1);
|
||||||
|
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
|
||||||
|
|
||||||
|
var length = ProtocolCodec.WriteClock(buffer, message);
|
||||||
|
var read = ProtocolCodec.ReadClock(buffer[..length]);
|
||||||
|
|
||||||
|
Assert.Equal(24, length);
|
||||||
|
Assert.False(read.SkipAllowed);
|
||||||
|
Assert.Equal(0, read.SkipTargetUnixMs);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SchoolGone_RoundTripsAndIsFiveBytes()
|
public void SchoolGone_RoundTripsAndIsFiveBytes()
|
||||||
{
|
{
|
||||||
@@ -164,34 +202,45 @@ public class ProtocolCodecTests
|
|||||||
Assert.Equal(0, read.Nodes[0].PupilSlots);
|
Assert.Equal(0, read.Nodes[0].PupilSlots);
|
||||||
Assert.Equal([new MapSnapshotItem("Стул", 2)], read.Nodes[1].Items);
|
Assert.Equal([new MapSnapshotItem("Стул", 2)], read.Nodes[1].Items);
|
||||||
Assert.Equal(["Директор"], read.Nodes[1].Positions);
|
Assert.Equal(["Директор"], read.Nodes[1].Positions);
|
||||||
Assert.Equal("", read.Nodes[0].ActivitySubject);
|
|
||||||
Assert.Empty(read.Nodes[0].Present);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MapSnapshot_RoundTripsOccupancyAfterPositions()
|
public void Presence_RoundTripsAndWritesHeaderOffsets()
|
||||||
{
|
{
|
||||||
var message = new ServerMapSnapshotMessage(3, [
|
var message = new ServerPresenceMessage(
|
||||||
new MapSnapshotNode(
|
7,
|
||||||
3,
|
[
|
||||||
"classroom-101",
|
new PresenceNode("classroom-101", 18, "Математика", "5А"),
|
||||||
"floor-1",
|
new PresenceNode("corridor-1", 4),
|
||||||
"Класс 101",
|
],
|
||||||
16,
|
[
|
||||||
[new MapSnapshotItem("Парта", 16)],
|
new PresencePerson("f0.c0", "classroom-101", PresenceState.Here),
|
||||||
[],
|
new PresencePerson("f3.p1", "corridor-1", PresenceState.Walking),
|
||||||
"Математика",
|
]);
|
||||||
"5А",
|
var buffer = new byte[ProtocolCodec.PresenceSize(message)];
|
||||||
["Иванова Ольга Михайловна", "Соколов Иван Петрович"]),
|
|
||||||
]);
|
|
||||||
var buffer = new byte[ProtocolCodec.MapSnapshotSize(message)];
|
|
||||||
|
|
||||||
var length = ProtocolCodec.WriteMapSnapshot(buffer, message);
|
var length = ProtocolCodec.WritePresence(buffer, message);
|
||||||
var read = ProtocolCodec.ReadMapSnapshot(buffer.AsSpan(0, length));
|
|
||||||
|
|
||||||
|
Assert.Equal((byte)MessageType.ServerPresence, buffer[0]);
|
||||||
|
Assert.Equal(7, BitConverter.ToInt32(buffer.AsSpan(1, 4)));
|
||||||
|
Assert.Equal((ushort)2, BitConverter.ToUInt16(buffer.AsSpan(5, 2)));
|
||||||
|
Assert.Equal(length, ProtocolCodec.PresenceSize(message));
|
||||||
|
|
||||||
|
var read = ProtocolCodec.ReadPresence(buffer.AsSpan(0, length));
|
||||||
|
Assert.Equal(message.SchoolId, read.SchoolId);
|
||||||
|
Assert.Equal(2, read.Nodes.Count);
|
||||||
|
Assert.Equal("classroom-101", read.Nodes[0].Id);
|
||||||
|
Assert.Equal(18, read.Nodes[0].Count);
|
||||||
Assert.Equal("Математика", read.Nodes[0].ActivitySubject);
|
Assert.Equal("Математика", read.Nodes[0].ActivitySubject);
|
||||||
Assert.Equal("5А", read.Nodes[0].ActivityClass);
|
Assert.Equal("5А", read.Nodes[0].ActivityClass);
|
||||||
Assert.Equal(["Иванова Ольга Михайловна", "Соколов Иван Петрович"], read.Nodes[0].Present);
|
Assert.Equal("corridor-1", read.Nodes[1].Id);
|
||||||
|
Assert.Equal(4, read.Nodes[1].Count);
|
||||||
|
Assert.Equal("", read.Nodes[1].ActivitySubject);
|
||||||
|
Assert.Equal(2, read.People.Count);
|
||||||
|
Assert.Equal("f0.c0", read.People[0].Id);
|
||||||
|
Assert.Equal("classroom-101", read.People[0].NodeId);
|
||||||
|
Assert.Equal(PresenceState.Here, read.People[0].State);
|
||||||
|
Assert.Equal(PresenceState.Walking, read.People[1].State);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -287,7 +336,9 @@ public class ProtocolCodecTests
|
|||||||
{
|
{
|
||||||
// Handlers size clock/welcome/pong buffers from this constant; map snapshots use MaxMessageSize.
|
// Handlers size clock/welcome/pong buffers from this constant; map snapshots use MaxMessageSize.
|
||||||
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
|
Span<byte> buffer = stackalloc byte[ProtocolCodec.MaxFrameSize];
|
||||||
var clock = ProtocolCodec.WriteClock(buffer, new ServerClockMessage(1, long.MaxValue, true, 4));
|
var clock = ProtocolCodec.WriteClock(
|
||||||
|
buffer,
|
||||||
|
new ServerClockMessage(1, long.MaxValue, true, 4, SkipAllowed: true, SkipTargetUnixMs: long.MaxValue));
|
||||||
|
|
||||||
Assert.True(clock <= ProtocolCodec.MaxFrameSize);
|
Assert.True(clock <= ProtocolCodec.MaxFrameSize);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user