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

- Bumped the wire protocol version to 7, reflecting significant changes in the communication structure.
- Introduced a new presence message type for real-time occupancy updates, including node activity and individual presence states.
- Updated the API to include a directory endpoint for fetching short id→name mappings, improving client-side name resolution.
- Revised the map snapshot structure to be static, with people and current lessons now handled through the presence stream.
- Enhanced client-side handling of presence updates, including UI adjustments to display live occupancy and activity.
- Updated documentation to reflect the new protocol features and changes in presence management.
- Added tests to validate the new presence functionalities and ensure robust handling of real-time data.
This commit is contained in:
Leonid Pershin
2026-08-19 17:00:53 +03:00
parent 4137400621
commit 3c54f981b7
31 changed files with 1025 additions and 373 deletions
+17 -17
View File
@@ -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] Пропуск, присланный в учебное время, сервер отбрасывает, а не исполняет
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -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
View File
@@ -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А)');
}); });
}); });
+12
View File
@@ -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.',
+6
View File
@@ -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) {
+13
View File
@@ -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;
+10
View File
@@ -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;
+46 -72
View File
@@ -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 }],
}); });
}); });
+89 -27
View File
@@ -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 });
}
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; offset += 1;
const characters: string[] = []; people.push({ id: id.text, nodeId: nodeId.text, state });
for (let person = 0; person < characterCount; person++) {
const value = readString(view, offset);
characters.push(value.text);
offset = value.next;
} }
nodes.push({ return { type: 'presence', schoolId, nodes, people };
kind,
id: id.text,
parentId: parentId.text,
name: name.text,
pupilSlots,
items,
positions,
activitySubject,
activityClass,
characters,
});
}
return { type: 'map-snapshot', schoolId, nodes };
} }
function readU8(view: DataView, offset: number): number { function readU8(view: DataView, offset: number): number {
+154 -21
View File
@@ -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;
} }
+23 -2
View File
@@ -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);
+23 -2
View File
@@ -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);
+16
View File
@@ -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 }));
} }
+2
View File
@@ -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,
} }
+38 -13
View File
@@ -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 1255.</summary> /// <summary>One stacked thing in a room. <paramref name="Count"/> is 1255.</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);
+96 -43
View File
@@ -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);
foreach (var person in node.Present)
{
writer.WriteString(person);
} }
writer.WriteUInt16((ushort)message.People.Count);
foreach (var person in message.People)
{
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++)
var personCount = reader.ReadUInt16();
var people = new PresencePerson[personCount];
for (var i = 0; i < personCount; i++)
{ {
characters[person] = reader.ReadString(); people[i] = new PresencePerson(reader.ReadString(), reader.ReadString(), reader.ReadByte());
} }
nodes[i] = new MapSnapshotNode( return new ServerPresenceMessage(schoolId, nodes, people);
kind,
id,
parentId,
name,
pupilSlots,
items,
positions,
activitySubject,
activityClass,
characters);
} }
return new ServerMapSnapshotMessage(schoolId, nodes); private static bool HasPresenceActivity(PresenceNode node) =>
}
private static bool HasActivity(MapSnapshotNode 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>
+1 -1
View File
@@ -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;
+4
View File
@@ -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(
+23
View File
@@ -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,
+3 -1
View File
@@ -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;
-93
View File
@@ -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];
}
}
+98
View File
@@ -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;
}
}
+61 -28
View File
@@ -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);
_occupancyKey = TimetableClock.Key(school.Catalog, school.Clock.Time, _options.SchoolWeekDays); if (_presenceAge < interval)
}
}
private void MaybeBroadcastOccupancy(School school, bool force = false)
{
if (school.Catalog is null)
{ {
return; return;
} }
var key = TimetableClock.Key(school.Catalog, school.Clock.Time, _options.SchoolWeekDays); _presenceAge = 0;
if (!force && key == _occupancyKey) BroadcastPresence();
}
private void ApplySkip(School school)
{
var result = school.TrySkipEmpty();
if (!result.Succeeded)
{ {
return; return;
} }
_occupancyKey = key; if (result.PeopleChanged)
foreach (var client in _clients.All)
{ {
if (client.IsReady && client.OpenSchoolId == _id) PersistPeople();
if (school.TimetableDirty)
{ {
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));
}
} }
+2
View File
@@ -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}.",
+17
View File
@@ -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;
+69 -8
View File
@@ -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.MapSnapshotSize(message)]; var buffer = new byte[ProtocolCodec.PresenceSize(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);
} }