From 3c54f981b797bba0af32c400a44d46bfb2b2e881 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 19 Aug 2026 17:00:53 +0300 Subject: [PATCH] Update wire protocol to version 7 and enhance presence management features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- docs/phases/19-presence-screen.md | 34 ++-- docs/phases/README.md | 2 +- docs/protocol.md | 103 +++++++++-- src/HSchool.Client/src/i18n/strings.test.ts | 3 + src/HSchool.Client/src/i18n/strings.ts | 12 ++ src/HSchool.Client/src/main.ts | 6 + src/HSchool.Client/src/net/api.ts | 13 ++ src/HSchool.Client/src/net/connection.ts | 10 + src/HSchool.Client/src/net/protocol.test.ts | 118 +++++------- src/HSchool.Client/src/net/protocol.ts | 118 +++++++++--- src/HSchool.Client/src/ui/gameScreen.ts | 175 +++++++++++++++--- src/HSchool.Client/src/ui/managementPanel.ts | 25 ++- src/HSchool.Client/src/ui/peoplePanel.ts | 25 ++- src/HSchool.Client/src/ui/personCard.ts | 16 ++ src/HSchool.Protocol/MessageType.cs | 2 + src/HSchool.Protocol/Messages.cs | 51 +++-- src/HSchool.Protocol/ProtocolCodec.cs | 141 +++++++++----- src/HSchool.Protocol/ProtocolConstants.cs | 2 +- src/HSchool.Server/Api/PeopleModels.cs | 4 + src/HSchool.Server/Api/SchoolEndpoints.cs | 23 +++ src/HSchool.Server/Game/GameCommand.cs | 4 +- src/HSchool.Server/Game/GameLoopService.cs | 4 + src/HSchool.Server/Game/MapOccupancy.cs | 93 ---------- src/HSchool.Server/Game/PresenceFrame.cs | 98 ++++++++++ src/HSchool.Server/Game/SchoolWorker.cs | 87 ++++++--- src/HSchool.Server/Game/WorkerCommand.cs | 2 + src/HSchool.Server/Net/GameSocketHandler.cs | 4 + src/HSchool.Simulation/School.cs | 17 ++ .../HSchool.AppHost.Tests/GameSocketTests.cs | 77 +++++++- tests/HSchool.AppHost.Tests/PeopleApiTests.cs | 30 +++ .../ProtocolCodecTests.cs | 99 +++++++--- 31 files changed, 1025 insertions(+), 373 deletions(-) delete mode 100644 src/HSchool.Server/Game/MapOccupancy.cs create mode 100644 src/HSchool.Server/Game/PresenceFrame.cs diff --git a/docs/phases/19-presence-screen.md b/docs/phases/19-presence-screen.md index eaea2dd..d5cea6d 100644 --- a/docs/phases/19-presence-screen.md +++ b/docs/phases/19-presence-screen.md @@ -12,33 +12,33 @@ ## Задачи -- [ ] Новое сообщение присутствия, ~2 раза в секунду, только по открытой школе: узлы со +- [x] Новое сообщение присутствия, ~2 раза в секунду, только по открытой школе: узлы со счётчиками и люди с их узлом и состоянием -- [ ] Снимок карты становится **статическим**: люди и текущий урок уезжают в присутствие, +- [x] Снимок карты становится **статическим**: люди и текущий урок уезжают в присутствие, структура уходит один раз при открытии. Пересылка на смене слота убирается -- [ ] `ProtocolCodec.cs`, `protocol.ts` и `docs/protocol.md` правятся тем же коммитом, версия +- [x] `ProtocolCodec.cs`, `protocol.ts` и `docs/protocol.md` правятся тем же коммитом, версия бумпится до 7 -- [ ] Справочник «id → имя» по HTTP: клиент забирает один раз при открытии школы и перечитывает, +- [x] Справочник «id → имя» по HTTP: клиент забирает один раз при открытии школы и перечитывает, увидев незнакомый id. Имена в кадр присутствия не кладутся -- [ ] Дерево: число людей рядом с узлом, живое. Пустой узел остаётся без числа -- [ ] Панель локации: кто здесь сейчас, списком с именами; урок в комнате берётся из присутствия, +- [x] Дерево: число людей рядом с узлом, живое. Пустой узел остаётся без числа +- [x] Панель локации: кто здесь сейчас, списком с именами; урок в комнате берётся из присутствия, а не из снимка -- [ ] Карточка человека: где он сейчас — узел, «в пути» или «вне школы» -- [ ] Присутствие приходит по всей школе, выбранный узел фильтрует клиент -- [ ] Кадр часов несёт разрешение на пропуск; клиент это разрешение **не вычисляет** -- [ ] Намерение «пропустить» — своё сообщение, как пауза и скорость. Сервер проверяет условия +- [x] Карточка человека: где он сейчас — узел, «в пути» или «вне школы» +- [x] Присутствие приходит по всей школе, выбранный узел фильтрует клиент +- [x] Кадр часов несёт разрешение на пропуск; клиент это разрешение **не вычисляет** +- [x] Намерение «пропустить» — своё сообщение, как пауза и скорость. Сервер проверяет условия заново: кадр из браузера не доверенный -- [ ] Кнопка рядом с паузой и скоростями: появляется только когда сервер разрешил, подписана +- [x] Кнопка рядом с паузой и скоростями: появляется только когда сервер разрешил, подписана датой, к которой перенесёт -- [ ] Строки через `t(...)`, обе локали +- [x] Строки через `t(...)`, обе локали ## Тесты, без которых фаза не закрыта -- [ ] Байтовая раскладка присутствия сходится с обеих сторон -- [ ] Круговой прогон сообщения: сервер → клиент → те же значения -- [ ] Снимок карты больше не содержит людей и урока -- [ ] Кадр часов с разрешением на пропуск читается обеими сторонами одинаково -- [ ] Пропуск, присланный в учебное время, сервер отбрасывает, а не исполняет +- [x] Байтовая раскладка присутствия сходится с обеих сторон +- [x] Круговой прогон сообщения: сервер → клиент → те же значения +- [x] Снимок карты больше не содержит людей и урока +- [x] Кадр часов с разрешением на пропуск читается обеими сторонами одинаково +- [x] Пропуск, присланный в учебное время, сервер отбрасывает, а не исполняет ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index 6d6ddc2..3e863fe 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -83,7 +83,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | | [18. Присутствие и ходьба](18-presence-walking.md) | ✅ | `HSchool.Ai`, место человека, маршруты, приход и уход | -| [19. Присутствие на экране](19-presence-screen.md) | ⬜ | Своё сообщение ~2 Гц, числа в дереве, статический снимок карты, пропуск пустого времени | +| [19. Присутствие на экране](19-presence-screen.md) | ✅ | Своё сообщение ~2 Гц, числа в дереве, статический снимок карты, пропуск пустого времени | **Этап B — поведение.** Появляются нужды, действия и выбор между ними и обязанностью. diff --git a/docs/protocol.md b/docs/protocol.md index 9668ca0..395a6ad 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,13 +1,13 @@ -# Wire protocol v6 +# Wire protocol v7 The client talks to the server two ways: - **HTTP/JSON** for the main menu and the in-school people browser — listing, creating and deleting schools, listing mods, loading a catalog for the create editor, reading a school's - roster (filtered list + one-person card), staffing, and the timetable. Those are request/response + roster (filtered list + one-person card), a short id→name directory, staffing, and the timetable. Those are request/response by nature, so they are plain REST. -- **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz) and the map snapshot - sent when a school is opened and whenever the current lesson slot changes. +- **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz), a static map snapshot + sent once when a school is opened, and a presence stream (~2 Hz) of who is where. This document covers both. One protocol message per WebSocket frame, no framing header beyond the message id. **All multi-byte numbers are little-endian.** @@ -188,6 +188,25 @@ Applicants live in `saves/{id}.people.json` next to the roster and are **not** i They are not school staff until hired. A parent who is also looking for work keeps the same id in both places. +### `GET /api/schools/{id}/directory` + +Short id→name directory for the presence stream. Reads the **published roster snapshot**; it +does not post to the worker. Unknown `{id}` is `404` `unknown-school`. `?lang=ru|en` is accepted +for symmetry with the other people endpoints — names are stored as written and not translated. + +The client fetches this once on OpenSchool and again when a presence frame carries an unknown +id. Names do not ride the WebSocket. + +```json +{ + "people": [ + { "id": "f0.c0", "fullName": "Иванова Мария Петровна" } + ] +} +``` + +Applicants are not in this list. + ### `GET /api/schools/{id}/people/{personId}` One person's card. Goes through the school's mailbox because need values live on entities and @@ -415,11 +434,13 @@ frame is obvious at a glance. | `0x04` | C → S | CloseSchool | | `0x05` | C → S | SetRunning | | `0x06` | C → S | SetSpeed | +| `0x07` | C → S | SkipEmpty | | `0x81` | S → C | Welcome | | `0x82` | S → C | Pong | | `0x83` | S → C | Clock | | `0x84` | S → C | SchoolGone | | `0x85` | S → C | MapSnapshot | +| `0x86` | S → C | Presence | ## Client → server @@ -443,8 +464,9 @@ The locale byte is the same language the catalog HTTP API takes as `?lang=`. ### `0x03` OpenSchool — 5 bytes -Starts watching a school: a map snapshot in the Hello locale arrives once, then clock frames. -It does not start the calendar — every school runs on its own from the moment it is created. +Starts watching a school: a map snapshot in the Hello locale arrives once, then clock frames +and presence frames. It does not start the calendar — every school runs on its own from the +moment it is created. | Offset | Type | Field | | --- | --- | --- | @@ -478,6 +500,15 @@ Speed indexes are `0 = ×½`, `1 = ×1`, `2 = ×2`, `3 = ×3`, `4 = ×4`; out-of ignored rather than fatal. The base rate is `gameMinutesPerRealSecond` (5), so ×1 is five game minutes per real second. +### `0x07` SkipEmpty — 1 byte + +Jump empty nights, weekends and holidays. The server re-checks both conditions (campus empty +**and** outside the day-frame work window) — a frame from the browser is untrusted. Ignored +when the skip is not legal; the calendar does not move. + +Running, speed and skip are **separate messages on purpose**. A button that also resent a +neighbouring field would clobber it with a stale client copy. + ## Server → client ### `0x81` Welcome — 4 bytes @@ -499,9 +530,11 @@ The first frame the client receives. | 1 | `i64` | client clock, echoed unchanged | | 9 | `u32` | server tick when the ping was handled | -### `0x83` Clock — 15 bytes +### `0x83` Clock — 24 bytes Sent every tick to every connection that has a school open, and only to those. +`skipAllowed` is the server's verdict; the client must not recompute it. +`skipTargetUnixMs` is 0 when skip is refused. | Offset | Type | Field | | --- | --- | --- | @@ -510,6 +543,8 @@ Sent every tick to every connection that has a school open, and only to those. | 5 | `i64` | in-game date, milliseconds since the Unix epoch, read as UTC | | 13 | `u8` | `1` running, `0` paused | | 14 | `u8` | speed index | +| 15 | `u8` | `1` skip allowed, `0` refused | +| 16 | `i64` | skip target, milliseconds since the Unix epoch, UTC; `0` if refused | ### `0x84` SchoolGone — 5 bytes @@ -523,10 +558,8 @@ client returns to the menu. ### `0x85` MapSnapshot — variable -Sent when a school is opened, on reconnect OpenSchool, and again when the current lesson slot -changes (a bell, a weekend, a holiday — not every tick, not on tree clicks). Labels are in the -Hello locale. Occupancy is computed from the timetable and the clock; the client must not -derive it. +Sent when a school is opened, and on reconnect OpenSchool. Structure only — people and the +current lesson ride the presence stream. Labels are in the Hello locale. Strings are `u16` byte length + UTF-8. Empty string is a zero length. @@ -548,24 +581,56 @@ Each node: | `u16` | pupil slots — how many pupils can take a lesson here. Summed from things on the server. | | `u8` | item count, then that many records of: string name + `u8` count | | `u8` | position count, then that many strings | -| `u8` | `1` if a lesson is in this room right now, then subject label + class label strings; `0` if free | -| `u8` | character count, then that many name strings (teacher and pupils of the lesson) | -Item `count` is how many of that thing stand in the room (`Парта ×16` is one record, not sixteen). The client must not recompute pupil slots from items. The location panel draws activity and characters from the last two fields. +Item `count` is how many of that thing stand in the room (`Парта ×16` is one record, not sixteen). The client must not recompute pupil slots from items. + +### `0x86` Presence — variable + +Live occupancy of an open school, about twice a second, and also once on OpenSchool and after +a successful empty-time skip. Covers the whole map; the client filters to the selected tree +node. Off-campus people are omitted — a missing id means they are away. Walking people occupy +their **current** node. Names are resolved over HTTP, not on this frame. + +Nodes listed are occupied **or** currently taught (count may be 0). Sorted by id. Labels are +in the Hello locale, encoded per client. + +| Offset | Type | Field | +| --- | --- | --- | +| 0 | `u8` | `0x86` | +| 1 | `i32` | school id | +| 5 | `u16` | node count | +| 7… | | nodes, then `u16` person count, then people | + +Each node: + +| Type | Field | +| --- | --- | +| string | instance id | +| `u16` | headcount in this node | +| `u8` | `1` if a lesson is in this room right now, then subject label + class label strings; `0` if free | + +Each person: + +| Type | Field | +| --- | --- | +| string | person id | +| string | node id they occupy | +| `u8` | `1` here, `2` walking | ## Guarantees and limits - **Inbound** frames larger than 8 KiB are refused with close status `1009 MessageTooBig`. That limit is about what the server reads; it does not bound what the server sends. A `MapSnapshot` - of a large school legitimately exceeds it, and the server sizes that frame from the message. + or `Presence` frame of a large school legitimately exceeds it, and the server sizes those frames + from the message. - A malformed frame closes the connection with `1007 InvalidPayloadData`. - Unknown message ids are ignored rather than fatal, so new ids can be added without breaking older clients within the same protocol version. - Clock delivery is lossy under back pressure: each connection buffers 32 clock frames and drops the oldest, because a stale clock is worthless once a newer one exists. -- The map snapshot uses a separate reliable queue so ticks cannot crowd it out. +- The map snapshot and presence use a separate reliable queue so ticks cannot crowd them out. -## Not in v6 yet +## Not in v7 yet -Authentication, Sit orders, an event log, walking, and `OpenLocation` on the server — the tree is -filtered on the client from the snapshot. +Authentication, Sit orders, an event log, walk animation, and `OpenLocation` on the server — +the tree and the location panel are filtered on the client from the snapshot plus presence. diff --git a/src/HSchool.Client/src/i18n/strings.test.ts b/src/HSchool.Client/src/i18n/strings.test.ts index 46bef30..bb5717b 100644 --- a/src/HSchool.Client/src/i18n/strings.test.ts +++ b/src/HSchool.Client/src/i18n/strings.test.ts @@ -26,6 +26,9 @@ describe('t', () => { .toBe('Not enough money: 8 000 of 10 000 is committed, 2 000 free, 12 000 needed.'); expect(t('mapOccupancy', { name: 'Кабинет 204', activity: 'Математика · 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А)'); }); }); diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 9872f57..d56dccc 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -169,6 +169,12 @@ const ru = { staffErrorSubject: 'Такого предмета нет.', mapOccupancy: '{name} ({activity})', + mapHeadcount: '{name} ({count})', + mapHeadcountActivity: '{name} ({count} · {activity})', + skipTo: 'Пропустить до {date}', + presenceAt: '{name}', + presenceWalking: 'в пути ({name})', + presenceAway: 'вне школы', timetableTitle: 'Расписание', timetableClass: 'Класс', timetableEmpty: 'Нет уроков.', @@ -360,6 +366,12 @@ const en: Messages = { staffErrorSubject: 'That subject is not in the catalog.', mapOccupancy: '{name} ({activity})', + mapHeadcount: '{name} ({count})', + mapHeadcountActivity: '{name} ({count} · {activity})', + skipTo: 'Skip to {date}', + presenceAt: '{name}', + presenceWalking: 'walking ({name})', + presenceAway: 'off campus', timetableTitle: 'Timetable', timetableClass: 'Class', timetableEmpty: 'No lessons.', diff --git a/src/HSchool.Client/src/main.ts b/src/HSchool.Client/src/main.ts index c83245c..3e67a1f 100644 --- a/src/HSchool.Client/src/main.ts +++ b/src/HSchool.Client/src/main.ts @@ -32,6 +32,7 @@ function bootstrap(): void { onLeave: () => leaveSchool(), onSetRunning: (running) => connection.setRunning(running), onSetSpeed: (speedIndex) => connection.setSpeed(speedIndex), + onSkip: () => connection.skipEmpty(), }); const connection = new GameConnection(gameSocketUrl(), { @@ -49,6 +50,11 @@ function bootstrap(): void { game.applyMap(snapshot.schoolId, snapshot.nodes); } }, + onPresence: (presence) => { + if (openSchool?.id === presence.schoolId) { + game.applyPresence(presence.schoolId, presence); + } + }, onSchoolGone: (schoolId) => { // Deleted from another tab while we were inside it. if (openSchool?.id === schoolId) { diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 9d9bbde..5faae53 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -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 { + const params = new URLSearchParams({ lang }); + const response = await request<{ people: readonly DirectoryPerson[] }>( + `/api/schools/${schoolId}/directory?${params.toString()}`, + ); + return response.people; +} + export interface StaffingSubject { readonly defName: string; readonly label: string; diff --git a/src/HSchool.Client/src/net/connection.ts b/src/HSchool.Client/src/net/connection.ts index 9753cae..472f211 100644 --- a/src/HSchool.Client/src/net/connection.ts +++ b/src/HSchool.Client/src/net/connection.ts @@ -6,9 +6,11 @@ import { encodePing, encodeSetRunning, encodeSetSpeed, + encodeSkipEmpty, ProtocolError, type ClockMessage, type MapSnapshotMessage, + type PresenceMessage, type ServerMessage, type WelcomeMessage, } from './protocol.ts'; @@ -20,6 +22,7 @@ export interface ConnectionHandlers { onWelcome?(message: WelcomeMessage): void; onClock?(message: ClockMessage): void; onMapSnapshot?(message: MapSnapshotMessage): void; + onPresence?(message: PresenceMessage): void; /** The open school was deleted elsewhere; the UI has to leave it. */ onSchoolGone?(schoolId: number): void; /** Round-trip time in milliseconds. */ @@ -118,6 +121,10 @@ export class GameConnection { this.send(encodeSetSpeed(speedIndex)); } + skipEmpty(): void { + this.send(encodeSkipEmpty()); + } + close(): void { this.closedByUs = true; this.stopTimers(); @@ -162,6 +169,9 @@ export class GameConnection { case 'map-snapshot': this.handlers.onMapSnapshot?.(message); break; + case 'presence': + this.handlers.onPresence?.(message); + break; case 'school-gone': if (this.openSchoolId === message.schoolId) { this.openSchoolId = null; diff --git a/src/HSchool.Client/src/net/protocol.test.ts b/src/HSchool.Client/src/net/protocol.test.ts index 99eb29d..a5971c7 100644 --- a/src/HSchool.Client/src/net/protocol.test.ts +++ b/src/HSchool.Client/src/net/protocol.test.ts @@ -8,7 +8,9 @@ import { encodePing, encodeSetRunning, encodeSetSpeed, + encodeSkipEmpty, MessageType, + PresenceState, ProtocolError, PROTOCOL_VERSION, } from './protocol.ts'; @@ -66,6 +68,13 @@ describe('client encoders', () => { expect(view.getUint8(0)).toBe(MessageType.ClientSetSpeed); 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', () => { @@ -85,16 +94,19 @@ describe('decodeServerMessage', () => { }); }); - it('reads a clock frame as a UTC instant', () => { - // 2012-04-03T06:00:00Z + it('reads a clock frame as a UTC instant, including skip permission', () => { + // 2012-04-03T06:00:00Z → skip to 2012-04-04T06:00:00Z 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); view.setUint8(0, MessageType.ServerClock); view.setInt32(1, 7, true); view.setBigInt64(5, BigInt(gameTimeMs), true); view.setUint8(13, 1); view.setUint8(14, 2); + view.setUint8(15, 1); + view.setBigInt64(16, BigInt(skipTargetMs), true); expect(decodeServerMessage(buffer)).toEqual({ type: 'clock', @@ -102,6 +114,8 @@ describe('decodeServerMessage', () => { gameTime: new Date(gameTimeMs), running: true, speedIndex: 2, + skipAllowed: true, + skipTarget: new Date(skipTargetMs), }); }); @@ -129,7 +143,7 @@ describe('decodeServerMessage', () => { const id = encoder.encode('yard'); const parent = 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); view.setUint8(0, MessageType.ServerMapSnapshot); view.setInt32(1, 7, true); @@ -152,10 +166,6 @@ describe('decodeServerMessage', () => { view.setUint8(offset, 0); offset += 1; view.setUint8(offset, 0); - offset += 1; - view.setUint8(offset, 0); - offset += 1; - view.setUint8(offset, 0); expect(decodeServerMessage(buffer)).toEqual({ type: 'map-snapshot', @@ -169,9 +179,6 @@ describe('decodeServerMessage', () => { pupilSlots: 0, items: [], positions: [], - activitySubject: '', - activityClass: '', - characters: [], }, ], }); @@ -184,7 +191,7 @@ describe('decodeServerMessage', () => { const name = encoder.encode('Класс 1A'); const itemName = encoder.encode('Парта'); 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); view.setUint8(0, MessageType.ServerMapSnapshot); @@ -216,10 +223,6 @@ describe('decodeServerMessage', () => { view.setUint8(offset, 16); offset += 1; view.setUint8(offset, 0); - offset += 1; - view.setUint8(offset, 0); - offset += 1; - view.setUint8(offset, 0); expect(decodeServerMessage(buffer)).toEqual({ type: 'map-snapshot', @@ -233,71 +236,41 @@ describe('decodeServerMessage', () => { pupilSlots: 16, items: [{ name: 'Парта', count: 16 }], positions: [], - activitySubject: '', - activityClass: '', - characters: [], }, ], }); }); - it('reads occupancy after positions', () => { + it('reads a presence frame with node activity and people', () => { const encoder = new TextEncoder(); - const id = encoder.encode('classroom-101'); - const parent = encoder.encode('floor-1'); - const name = encoder.encode('Класс 101'); - const itemName = encoder.encode('Парта'); + const nodeId = encoder.encode('classroom-101'); const subject = encoder.encode('Математика'); 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( 7 - + 1 - + 2 + id.length - + 2 + parent.length - + 2 + name.length + + 2 + nodeId.length + 2 + 1 - + 2 + itemName.length - + 1 - + 1 - + 1 + 2 + subject.length + 2 + schoolClass.length - + 1 - + 2 + teacher.length, + + 2 + + 2 + personId.length + + 2 + personNode.length + + 1, ); const view = new DataView(buffer); - view.setUint8(0, MessageType.ServerMapSnapshot); + view.setUint8(0, MessageType.ServerPresence); view.setInt32(1, 3, true); view.setUint16(5, 1, true); let offset = 7; - view.setUint8(offset, 3); - offset += 1; - view.setUint16(offset, id.length, true); + view.setUint16(offset, nodeId.length, true); offset += 2; - new Uint8Array(buffer).set(id, offset); - offset += id.length; - view.setUint16(offset, parent.length, true); + new Uint8Array(buffer).set(nodeId, offset); + offset += nodeId.length; + view.setUint16(offset, 18, true); 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); offset += 1; view.setUint16(offset, subject.length, true); @@ -308,29 +281,30 @@ describe('decodeServerMessage', () => { offset += 2; new Uint8Array(buffer).set(schoolClass, offset); offset += schoolClass.length; - view.setUint8(offset, 1); - offset += 1; - view.setUint16(offset, teacher.length, true); + view.setUint16(offset, 1, true); 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({ - type: 'map-snapshot', + type: 'presence', schoolId: 3, nodes: [ { - kind: 3, id: 'classroom-101', - parentId: 'floor-1', - name: 'Класс 101', - pupilSlots: 16, - items: [{ name: 'Парта', count: 16 }], - positions: [], + count: 18, activitySubject: 'Математика', activityClass: '5А', - characters: ['Иванова'], }, ], + people: [{ id: 'f0.c0', nodeId: 'classroom-101', state: PresenceState.Here }], }); }); diff --git a/src/HSchool.Client/src/net/protocol.ts b/src/HSchool.Client/src/net/protocol.ts index a33bd79..607ec6c 100644 --- a/src/HSchool.Client/src/net/protocol.ts +++ b/src/HSchool.Client/src/net/protocol.ts @@ -5,7 +5,7 @@ * 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 = { ClientHello: 0x01, @@ -14,11 +14,13 @@ export const MessageType = { ClientCloseSchool: 0x04, ClientSetRunning: 0x05, ClientSetSpeed: 0x06, + ClientSkipEmpty: 0x07, ServerWelcome: 0x81, ServerPong: 0x82, ServerClock: 0x83, ServerSchoolGone: 0x84, ServerMapSnapshot: 0x85, + ServerPresence: 0x86, } as const; /** Hello locale byte. Same mapping as `?lang=` on the catalog HTTP API. */ @@ -55,6 +57,10 @@ export interface ClockMessage { readonly gameTime: Date; readonly running: boolean; 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 { @@ -82,9 +88,6 @@ export interface MapSnapshotNode { readonly pupilSlots: number; readonly items: readonly MapSnapshotItem[]; readonly positions: readonly string[]; - readonly activitySubject: string; - readonly activityClass: string; - readonly characters: readonly string[]; } export interface MapSnapshotMessage { @@ -93,12 +96,38 @@ export interface MapSnapshotMessage { 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 = | WelcomeMessage | PongMessage | ClockMessage | SchoolGoneMessage - | MapSnapshotMessage; + | MapSnapshotMessage + | PresenceMessage; /** Thrown when a frame is truncated or carries an unexpected message id. */ export class ProtocolError extends Error {} @@ -165,6 +194,12 @@ export function encodeSetSpeed(speedIndex: number): ArrayBuffer { 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. */ export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null { if (data.byteLength === 0) { @@ -184,6 +219,8 @@ export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null { return decodeSchoolGone(view); case MessageType.ServerMapSnapshot: return decodeMapSnapshot(view); + case MessageType.ServerPresence: + return decodePresence(view); default: return null; } @@ -211,7 +248,8 @@ function decodePong(view: DataView): PongMessage { } function decodeClock(view: DataView): ClockMessage { - ensure(view, 15); + ensure(view, 24); + const skipTargetMs = Number(view.getBigInt64(16, true)); return { type: 'clock', @@ -219,6 +257,8 @@ function decodeClock(view: DataView): ClockMessage { gameTime: new Date(Number(view.getBigInt64(5, true))), running: view.getUint8(13) !== 0, 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; } + 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); offset += 1; let activitySubject = ''; @@ -281,30 +349,24 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage { activityClass = schoolClass.text; } - const characterCount = readU8(view, offset); - offset += 1; - const characters: string[] = []; - for (let person = 0; person < characterCount; person++) { - const value = readString(view, offset); - characters.push(value.text); - offset = value.next; - } - - nodes.push({ - kind, - id: id.text, - parentId: parentId.text, - name: name.text, - pupilSlots, - items, - positions, - activitySubject, - activityClass, - characters, - }); + nodes.push({ id: id.text, count, activitySubject, activityClass }); } - return { type: 'map-snapshot', schoolId, nodes }; + ensure(view, offset + 2); + const personCount = view.getUint16(offset, true); + offset += 2; + const people: PresencePerson[] = []; + for (let i = 0; i < personCount; i++) { + const id = readString(view, offset); + offset = id.next; + const nodeId = readString(view, offset); + offset = nodeId.next; + const state = readU8(view, offset); + offset += 1; + people.push({ id: id.text, nodeId: nodeId.text, state }); + } + + return { type: 'presence', schoolId, nodes, people }; } function readU8(view: DataView, offset: number): number { diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index 1e86b3a..5a874aa 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -1,23 +1,34 @@ -import { CLOCK_SPEEDS, type ClockMessage, type MapSnapshotItem, type MapSnapshotNode } from '../net/protocol.ts'; -import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts'; +import { + 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 type { School } from '../net/api.ts'; +import { fetchDirectory, type School } from '../net/api.ts'; import { clear, el } from './dom.ts'; import { ManagementPanel } from './managementPanel.ts'; import { PeoplePanel } from './peoplePanel.ts'; +import { formatPersonPlace } from './personCard.ts'; interface GameScreenOptions { readonly onLeave: () => void; readonly onSetRunning: (running: boolean) => void; readonly onSetSpeed: (speedIndex: number) => void; + readonly onSkip: () => void; } const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4']; /** - * The inside of a school: calendar controls plus the manager shell. The tree and location - * lists come from one map snapshot on OpenSchool; clicking a node only filters that snapshot - * on the client. The people panel loads its page over HTTP. + * The inside of a school: calendar controls plus the manager shell. The tree comes from one map + * snapshot on OpenSchool; presence (~2 Hz) paints headcount and who is here. Clicking a node + * only filters on the client. The people panel loads its page over HTTP. */ export class GameScreen { 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 weekday = el('p', { class: 'clock__weekday' }); 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 mapTab = el('button', { class: 'panel__tab', type: 'button' }); @@ -62,11 +74,16 @@ export class GameScreen { private readonly treeButtons = new Map(); private nodes: readonly MapSnapshotNode[] = []; + private presence: PresenceMessage | null = null; + private directory = new Map(); + private directoryToken = 0; private selectedId: string | null = null; private schoolId: number | null = null; private running = false; private lastGameTime: Date | null = null; private lastSpeedIndex = 0; + private skipAllowed = false; + private skipTarget: Date | null = null; private inspected: 'location' | 'person' = 'location'; constructor(options: GameScreenOptions) { @@ -81,6 +98,8 @@ export class GameScreen { this.backButton.addEventListener('click', options.onLeave); this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running)); + this.skipButton.addEventListener('click', () => options.onSkip()); + this.skipButton.hidden = true; this.root.append( el('header', { class: 'screen__header' }, this.backButton, this.schoolName), @@ -93,7 +112,7 @@ export class GameScreen { this.time, 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), this.overview, @@ -163,10 +182,14 @@ export class GameScreen { this.paintSelection(); 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 { 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. */ @@ -174,18 +197,25 @@ export class GameScreen { this.schoolId = school.id; this.schoolName.textContent = school.name; this.nodes = []; + this.presence = null; + this.directory = new Map(); this.selectedId = null; + this.skipAllowed = false; + this.skipTarget = null; 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.setLocate((id) => this.placeOf(id)); + this.management.setLocate((id) => this.placeOf(id)); this.showTab('map'); this.inspect('location'); this.showMode('overview'); + void this.loadDirectory(); } /** - * The server sends the tree on OpenSchool and again when the current lesson slot changes. - * Occupancy is on the snapshot; this screen must not derive who is where from the clock. + * The server sends the tree once on OpenSchool. Occupancy is on the presence stream; this + * screen must not derive who is where from the clock. */ applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void { if (this.schoolId !== schoolId) { @@ -200,8 +230,23 @@ export class GameScreen { 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 { - this.applyClock(clock.gameTime, clock.running, clock.speedIndex); + this.applyClock(clock.gameTime, clock.running, clock.speedIndex, clock.skipAllowed, clock.skipTarget); } private rebuildTree(): void { @@ -216,7 +261,7 @@ export class GameScreen { const button = el('button', { class: 'tree__button', type: 'button', - text: treeLabel(node), + text: treeLabel(node, this.liveNode(node.id)), onClick: () => this.select(node.id), }); 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 live = this.liveNode(this.selectedId); this.locationName.textContent = node?.name ?? ''; paintList(this.itemsList, this.itemsEmpty, (node?.items ?? []).map(formatItem)); const pupilSlots = node?.pupilSlots ?? 0; this.pupilSlotsLine.hidden = pupilSlots <= 0; this.pupilSlotsLine.textContent = pupilSlots > 0 ? t('pupilSlots', { count: pupilSlots }) : ''; const activity = []; - if (node?.activitySubject) { - activity.push(node.activitySubject); + if (live?.activitySubject) { + activity.push(live.activitySubject); } - if (node?.activityClass) { - activity.push(node.activityClass); + if (live?.activityClass) { + activity.push(live.activityClass); } 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 ?? []); } - 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.lastGameTime = gameTime; this.lastSpeedIndex = speedIndex; + this.skipAllowed = skipAllowed; + this.skipTarget = skipTarget; this.time.textContent = formatGameTimeOfDay(gameTime); this.date.textContent = formatGameDate(gameTime); @@ -316,6 +370,76 @@ export class GameScreen { this.speedButtons.forEach((button, index) => { 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 { + 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); } -function treeLabel(node: MapSnapshotNode): string { - const activity = [node.activitySubject, node.activityClass].filter((part) => part.length > 0).join(' · '); +function treeLabel(node: MapSnapshotNode, live: PresenceNode | undefined): string { + 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; } diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index 075eefa..77defe6 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -15,7 +15,7 @@ import { import { getLocale, intlTag } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; import { clear, el } from './dom.ts'; -import { renderPersonCard } from './personCard.ts'; +import { formatPersonPlace, renderPersonCard } from './personCard.ts'; import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts'; const TEACHER = 'Teacher'; @@ -67,6 +67,8 @@ export class ManagementPanel { private loadToken = 0; private cardToken = 0; private busy = false; + private locate: ((id: string) => string) | null = null; + private painted: PersonCard | null = null; constructor() { this.error.hidden = true; @@ -139,6 +141,11 @@ export class ManagementPanel { void this.reload(); } + setLocate(locate: (id: string) => string): void { + this.locate = locate; + this.relocate(); + } + private async reload(): Promise { const schoolId = this.schoolId; if (schoolId === null) { @@ -401,16 +408,30 @@ export class ManagementPanel { private paintCard(card: PersonCard | null): void { clear(this.card); + this.painted = card; if (card === null) { this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') })); 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.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 { const schoolId = this.schoolId; const query = personTimetableQuery(card); diff --git a/src/HSchool.Client/src/ui/peoplePanel.ts b/src/HSchool.Client/src/ui/peoplePanel.ts index 95a49f7..de428aa 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.ts @@ -2,7 +2,7 @@ import { fetchPeople, fetchPerson, fetchTimetable, type PeoplePage, type PersonC import { getLocale } from '../i18n/locale.ts'; import { t, type MessageKey } from '../i18n/strings.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'; const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [ @@ -59,6 +59,8 @@ export class PeoplePanel { private selectedId: string | null = null; private token = 0; private cardToken = 0; + private locate: ((id: string) => string) | null = null; + private painted: PersonCard | null = null; constructor(private readonly options: PeoplePanelOptions) { 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 { this.page = 1; void this.reload(); @@ -349,15 +356,29 @@ export class PeoplePanel { private paintCard(card: PersonCard | null): void { clear(this.card); + this.painted = card; if (card === null) { this.card.append(el('p', { class: 'panel__empty', text: t('peoplePickHint') })); 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); } + 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 { const schoolId = this.schoolId; const query = personTimetableQuery(card); diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts index b3dc4af..8ba9c65 100644 --- a/src/HSchool.Client/src/ui/personCard.ts +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -31,11 +31,15 @@ export function renderPersonCard( parent: HTMLElement, card: PersonCard, onRelative: (id: string) => void, + place?: string, ): void { parent.append( el('h3', { class: 'people__card-name', text: card.fullName }), 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('peopleSkills'), card.skills); appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label)); @@ -61,6 +65,18 @@ function cardMeta(card: PersonCard): string { 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 { return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title })); } diff --git a/src/HSchool.Protocol/MessageType.cs b/src/HSchool.Protocol/MessageType.cs index 5520d9f..92296fa 100644 --- a/src/HSchool.Protocol/MessageType.cs +++ b/src/HSchool.Protocol/MessageType.cs @@ -14,10 +14,12 @@ public enum MessageType : byte ClientCloseSchool = 0x04, ClientSetRunning = 0x05, ClientSetSpeed = 0x06, + ClientSkipEmpty = 0x07, ServerWelcome = 0x81, ServerPong = 0x82, ServerClock = 0x83, ServerSchoolGone = 0x84, ServerMapSnapshot = 0x85, + ServerPresence = 0x86, } diff --git a/src/HSchool.Protocol/Messages.cs b/src/HSchool.Protocol/Messages.cs index 73d13db..cbc203b 100644 --- a/src/HSchool.Protocol/Messages.cs +++ b/src/HSchool.Protocol/Messages.cs @@ -31,12 +31,16 @@ public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTi /// State of the open school's calendar, sent every tick. /// is the in-game date as milliseconds since the Unix epoch, /// interpreted as UTC — the game calendar has no time zone. +/// is the server's verdict; the client must not recompute it. +/// is 0 when skip is refused. /// public readonly record struct ServerClockMessage( int SchoolId, long GameTimeUnixMs, bool Running, - byte SpeedIndex); + byte SpeedIndex, + bool SkipAllowed = false, + long SkipTargetUnixMs = 0); /// The open school no longer exists (deleted from another tab); the client returns to the menu. 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 0 territory, 1 building, 2 floor, 3 room. /// is empty for the yard. /// is how many pupils can take a lesson here — summed from things -/// on the server, not by the client. -/// and are empty when the -/// room is free. are the people the timetable puts there right now. +/// on the server, not by the client. Live occupancy lives on . /// public sealed record MapSnapshotNode( byte Kind, @@ -56,19 +58,42 @@ public sealed record MapSnapshotNode( string Name, ushort PupilSlots, IReadOnlyList Items, - IReadOnlyList Positions, - string ActivitySubject = "", - string ActivityClass = "", - IReadOnlyList? Characters = null) -{ - public IReadOnlyList Present => Characters ?? []; -} + IReadOnlyList Positions); /// One stacked thing in a room. is 1–255. public sealed record MapSnapshotItem(string Name, byte Count); /// -/// One school's map, labelled in the Hello locale. Sent when that school is opened and again -/// when the current lesson slot changes. Occupancy is computed from the timetable and the clock. +/// One school's map, labelled in the Hello locale. Sent once when that school is opened. +/// Structure only — people and the current lesson ride the presence stream. /// public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList Nodes); + +/// Jump empty nights, weekends and holidays. The server re-checks the conditions. +public readonly record struct ClientSkipEmptyMessage; + +/// Where people are: 1 in a node, 2 walking through it. Off campus is omitted. +public static class PresenceState +{ + public const byte Here = 1; + public const byte Walking = 2; +} + +/// One occupied (or currently taught) map node in a presence frame. +public sealed record PresenceNode( + string Id, + ushort Count, + string ActivitySubject = "", + string ActivityClass = ""); + +/// One on-campus person. Names are resolved over HTTP, not on this frame. +public sealed record PresencePerson(string Id, string NodeId, byte State); + +/// +/// 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. +/// +public sealed record ServerPresenceMessage( + int SchoolId, + IReadOnlyList Nodes, + IReadOnlyList People); diff --git a/src/HSchool.Protocol/ProtocolCodec.cs b/src/HSchool.Protocol/ProtocolCodec.cs index ca94853..fefd8a9 100644 --- a/src/HSchool.Protocol/ProtocolCodec.cs +++ b/src/HSchool.Protocol/ProtocolCodec.cs @@ -10,10 +10,10 @@ namespace HSchool.Protocol; public static class ProtocolCodec { /// - /// Largest fixed-size frame this codec produces. Variable map snapshots use - /// instead. + /// Largest fixed-size frame this codec produces. Variable map snapshots and + /// presence frames use instead. /// - public const int MaxFrameSize = 16; + public const int MaxFrameSize = 24; public static int WriteHello(Span destination, in ClientHelloMessage message) { @@ -63,6 +63,13 @@ public static class ProtocolCodec return writer.Position; } + public static int WriteSkipEmpty(Span destination) + { + var writer = new PacketWriter(destination); + writer.WriteMessageType(MessageType.ClientSkipEmpty); + return writer.Position; + } + public static int WriteWelcome(Span destination, in ServerWelcomeMessage message) { var writer = new PacketWriter(destination); @@ -90,6 +97,8 @@ public static class ProtocolCodec writer.WriteInt64(message.GameTimeUnixMs); writer.WriteByte(message.Running ? (byte)1 : (byte)0); writer.WriteByte(message.SpeedIndex); + writer.WriteByte(message.SkipAllowed ? (byte)1 : (byte)0); + writer.WriteInt64(message.SkipTargetUnixMs); return writer.Position; } @@ -129,18 +138,6 @@ public static class ProtocolCodec { 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; @@ -160,9 +157,9 @@ public static class ProtocolCodec 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); @@ -182,8 +179,53 @@ public static class ProtocolCodec { 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 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.WriteString(node.ActivitySubject); @@ -193,12 +235,14 @@ public static class ProtocolCodec { 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; @@ -271,7 +315,9 @@ public static class ProtocolCodec var gameTime = reader.ReadInt64(); var running = reader.ReadByte() != 0; 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 source) @@ -312,6 +358,23 @@ public static class ProtocolCodec 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 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 activitySubject = ""; var activityClass = ""; @@ -321,30 +384,20 @@ public static class ProtocolCodec activityClass = reader.ReadString(); } - var characterCount = reader.ReadByte(); - var characters = new string[characterCount]; - for (var person = 0; person < characterCount; person++) - { - characters[person] = reader.ReadString(); - } - - nodes[i] = new MapSnapshotNode( - kind, - id, - parentId, - name, - pupilSlots, - items, - positions, - activitySubject, - activityClass, - characters); + nodes[i] = new PresenceNode(id, count, activitySubject, activityClass); } - return new ServerMapSnapshotMessage(schoolId, nodes); + var personCount = reader.ReadUInt16(); + var people = new PresencePerson[personCount]; + for (var i = 0; i < personCount; i++) + { + people[i] = new PresencePerson(reader.ReadString(), reader.ReadString(), reader.ReadByte()); + } + + return new ServerPresenceMessage(schoolId, nodes, people); } - private static bool HasActivity(MapSnapshotNode node) => + private static bool HasPresenceActivity(PresenceNode node) => node.ActivitySubject.Length > 0 || node.ActivityClass.Length > 0; /// Matches : a u16 length plus UTF-8. diff --git a/src/HSchool.Protocol/ProtocolConstants.cs b/src/HSchool.Protocol/ProtocolConstants.cs index 9501587..a2f053f 100644 --- a/src/HSchool.Protocol/ProtocolConstants.cs +++ b/src/HSchool.Protocol/ProtocolConstants.cs @@ -4,7 +4,7 @@ namespace HSchool.Protocol; public static class ProtocolConstants { /// Bumped on every breaking change to the binary layout. - public const byte Version = 6; + public const byte Version = 7; /// Upper bound for a single WebSocket frame accepted by the server. public const int MaxMessageSize = 8 * 1024; diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 11ea19d..feea4c1 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -73,6 +73,10 @@ internal sealed record PersonFamilyResponse( internal sealed record PersonRelResponse(string Id, string FullName, bool Female); +internal sealed record DirectoryResponse(IReadOnlyList People); + +internal sealed record DirectoryPersonResponse(string Id, string FullName); + internal static class PeopleListMapper { public static PeopleListResponse From( diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 51691ac..2beae24 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -134,6 +134,29 @@ internal static class SchoolEndpoints }) .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 ( int id, string personId, diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index ef5e02e..9362f58 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -7,7 +7,7 @@ namespace HSchool.Server.Game; /// /// 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. /// internal abstract record GameCommand { @@ -36,6 +36,8 @@ internal abstract record GameCommand internal sealed record SetSpeed(uint PlayerId, byte SpeedIndex) : GameCommand; + internal sealed record SkipEmpty(uint PlayerId) : GameCommand; + /// Stops every worker, re-reads the save directory, starts workers from those files. internal sealed record ReloadSaves(TaskCompletionSource Result) : GameCommand; diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index b66ebdb..0b4cb10 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -167,6 +167,10 @@ internal sealed class GameLoopService( RouteOpenSchool(setSpeed.PlayerId, new WorkerCommand.SetSpeed(setSpeed.SpeedIndex)); break; + case GameCommand.SkipEmpty skipEmpty: + RouteOpenSchool(skipEmpty.PlayerId, new WorkerCommand.SkipEmpty()); + break; + case GameCommand.ReloadSaves reload: await HandleReloadAsync(reload).ConfigureAwait(false); break; diff --git a/src/HSchool.Server/Game/MapOccupancy.cs b/src/HSchool.Server/Game/MapOccupancy.cs deleted file mode 100644 index 2a57388..0000000 --- a/src/HSchool.Server/Game/MapOccupancy.cs +++ /dev/null @@ -1,93 +0,0 @@ -using HSchool.Content; -using HSchool.People; -using HSchool.Protocol; -using HSchool.Schedule; -using HSchool.Simulation; - -namespace HSchool.Server.Game; - -/// -/// Overlays the current lesson onto map-tree nodes. Occupancy is derived from the timetable and -/// the clock; it is not stored on the map. -/// -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 people) - { - var names = new List(); - 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]; - } -} diff --git a/src/HSchool.Server/Game/PresenceFrame.cs b/src/HSchool.Server/Game/PresenceFrame.cs new file mode 100644 index 0000000..badd455 --- /dev/null +++ b/src/HSchool.Server/Game/PresenceFrame.cs @@ -0,0 +1,98 @@ +using HSchool.Content; +using HSchool.People; +using HSchool.Protocol; +using HSchool.Schedule; +using HSchool.Simulation; + +namespace HSchool.Server.Game; + +/// +/// 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. +/// +internal static class PresenceFrame +{ + public static ServerPresenceMessage Build(School school, int weekDays, string locale) + { + var people = new List(); + var counts = new Dictionary(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(); + 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 ActivityByRoom( + School school, + int weekDays, + string locale) + { + var labels = new Dictionary(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; + } +} diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 163d8b6..15f0219 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -48,7 +48,7 @@ internal sealed class SchoolWorker private DefCatalog? _catalogSnapshot; private Timetable? _timetableSnapshot; private MapLayout? _mapSnapshot; - private OccupancyKey _occupancyKey; + private int _presenceAge; private School? _school; private Task? _run; private bool _persistOnStop = true; @@ -308,7 +308,7 @@ internal sealed class SchoolWorker { PublishSnapshot(); BroadcastClock(); - MaybeBroadcastOccupancy(school); + MaybeBroadcastPresence(school); } FlushSettings(); @@ -381,6 +381,7 @@ internal sealed class SchoolWorker open.Client.OpenSchoolId = _id; SendMapSnapshot(open.Client, school); BroadcastClockTo(open.Client, school); + SendPresence(open.Client, school); break; case WorkerCommand.Close close: @@ -402,6 +403,10 @@ internal sealed class SchoolWorker dirty = true; break; + case WorkerCommand.SkipEmpty: + ApplySkip(school); + break; + case WorkerCommand.GetPerson getPerson: var card = PersonCardReader.Read(school, getPerson.PersonId, getPerson.Locale); getPerson.Result.TrySetResult( @@ -613,7 +618,6 @@ internal sealed class SchoolWorker PersistTimetable(school); } - RememberOccupancy(school); return; } } @@ -770,11 +774,7 @@ internal sealed class SchoolWorker PublishSnapshot(); if (broadcast) { - MaybeBroadcastOccupancy(school, force: true); - } - else - { - RememberOccupancy(school); + BroadcastPresence(); } } @@ -799,35 +799,41 @@ internal sealed class SchoolWorker } } - private void RememberOccupancy(School school) + private void MaybeBroadcastPresence(School school) { - if (school.Catalog is not null) + _presenceAge++; + var interval = Math.Max(1, _options.TickRate / 2); + if (_presenceAge < interval) { - _occupancyKey = TimetableClock.Key(school.Catalog, school.Clock.Time, _options.SchoolWeekDays); + return; } + + _presenceAge = 0; + BroadcastPresence(); } - private void MaybeBroadcastOccupancy(School school, bool force = false) + private void ApplySkip(School school) { - if (school.Catalog is null) + var result = school.TrySkipEmpty(); + if (!result.Succeeded) { return; } - var key = TimetableClock.Key(school.Catalog, school.Clock.Time, _options.SchoolWeekDays); - if (!force && key == _occupancyKey) + if (result.PeopleChanged) { - return; - } - - _occupancyKey = key; - 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) @@ -987,8 +993,6 @@ internal sealed class SchoolWorker 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 // the create editor outgrows 8 KiB somewhere past sixty furnished rooms. var message = new ServerMapSnapshotMessage(school.Id, nodes); @@ -997,15 +1001,44 @@ internal sealed class SchoolWorker 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 length = ProtocolCodec.WriteClock(frame, new ServerClockMessage( school.Id, new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(), 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)); } + + 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)); + } } diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index 527fbd9..17b2068 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -18,6 +18,8 @@ internal abstract record WorkerCommand internal sealed record SetSpeed(byte SpeedIndex) : WorkerCommand; + internal sealed record SkipEmpty : WorkerCommand; + internal sealed record GetPerson( string PersonId, string Locale, diff --git a/src/HSchool.Server/Net/GameSocketHandler.cs b/src/HSchool.Server/Net/GameSocketHandler.cs index c933a4e..fe3844c 100644 --- a/src/HSchool.Server/Net/GameSocketHandler.cs +++ b/src/HSchool.Server/Net/GameSocketHandler.cs @@ -153,6 +153,10 @@ internal sealed class GameSocketHandler( commands.Enqueue(new GameCommand.SetSpeed(client.PlayerId, setSpeed.SpeedIndex)); break; + case MessageType.ClientSkipEmpty: + commands.Enqueue(new GameCommand.SkipEmpty(client.PlayerId)); + break; + default: logger.LogDebug( "Ignoring unexpected frame 0x{MessageType:X2} from client {PlayerId}.", diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 627679c..9084a3f 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -138,6 +138,18 @@ public sealed class School : IDisposable 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() { ObjectDisposedException.ThrowIf(_disposed, this); @@ -295,6 +307,11 @@ public enum SkipEmptyError 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 bool Succeeded => Error == SkipEmptyError.None; diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index 5f5b1b7..a8bea35 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -111,8 +111,6 @@ public class GameSocketTests(AppHostFixture fixture) Assert.Equal(16, classroom.PupilSlots); Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16); Assert.DoesNotContain(classroom.Items, item => item.Name == "Стул"); - Assert.Equal("", classroom.ActivitySubject); - Assert.Empty(classroom.Present); } [Fact] @@ -161,13 +159,21 @@ public class GameSocketTests(AppHostFixture fixture) } [Fact] - public async Task OpeningASchoolDuringAMathLesson_PutsOccupancyOnTheRoom() + public async Task OpeningASchoolDuringAMathLesson_PutsOccupancyOnPresenceNotTheSnapshot() { 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); + 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( $"/api/schools/{school.Id}/staffing", TestContext.Current.CancellationToken); @@ -184,14 +190,49 @@ public class GameSocketTests(AppHostFixture fixture) TestContext.Current.CancellationToken); assign.EnsureSuccessStatusCode(); - using var socket = await OpenSchoolAsync(school.Id); - var snapshot = ProtocolCodec.ReadMapSnapshot(await ReceiveUntilAsync(socket, MessageType.ServerMapSnapshot)); - var occupied = Assert.Single(snapshot.Nodes, node => node.ActivitySubject.Length > 0); + var presence = await ReceivePresenceWhereAsync( + socket, + 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.NotEmpty(occupied.ActivityClass); - Assert.Contains(applicant.FullName, occupied.Present); - Assert.True(occupied.Present.Count > 1); + + var directory = await client.GetFromJsonAsync( + $"/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] @@ -465,6 +506,22 @@ public class GameSocketTests(AppHostFixture fixture) throw new InvalidOperationException("No clock frame matched within 40 frames."); } + private static async Task ReceivePresenceWhereAsync( + WebSocket socket, + Func 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."); + } + /// Keeps reading clock frames for and returns the last one. private static async Task ReceiveClockAfterAsync(WebSocket socket, TimeSpan duration) { @@ -523,4 +580,8 @@ public class GameSocketTests(AppHostFixture fixture) private sealed record StaffingSnapshot(IReadOnlyList Applicants); private sealed record ApplicantSnapshot(string Id, string FullName); + + private sealed record DirectorySnapshot(IReadOnlyList People); + + private sealed record DirectoryPersonSnapshot(string Id, string FullName); } diff --git a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs index 57e2746..18ce108 100644 --- a/tests/HSchool.AppHost.Tests/PeopleApiTests.cs +++ b/tests/HSchool.AppHost.Tests/PeopleApiTests.cs @@ -81,6 +81,32 @@ public class PeopleApiTests(AppHostFixture fixture) 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( + $"/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] public async Task List_UnknownSchool_IsNotFound() { @@ -204,4 +230,8 @@ public class PeopleApiTests(AppHostFixture fixture) IReadOnlyList Partners); private sealed record PersonRelResponse(string Id, string FullName, bool Female); + + private sealed record DirectoryResponse(IReadOnlyList People); + + private sealed record DirectoryPersonResponse(string Id, string FullName); } diff --git a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs index 0fe4e6e..4fd326a 100644 --- a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs +++ b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs @@ -85,6 +85,17 @@ public class ProtocolCodecTests Assert.Equal(message, ProtocolCodec.ReadSetSpeed(buffer[..length])); } + [Fact] + public void SkipEmpty_IsASingleByte() + { + Span buffer = stackalloc byte[ProtocolCodec.MaxFrameSize]; + + var length = ProtocolCodec.WriteSkipEmpty(buffer); + + Assert.Equal(1, length); + Assert.Equal(MessageType.ClientSkipEmpty, ProtocolCodec.PeekMessageType(buffer[..length])); + } + [Fact] public void Welcome_RoundTripsAndIsFourBytes() { @@ -110,17 +121,44 @@ public class ProtocolCodecTests } [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 buffer = stackalloc byte[ProtocolCodec.MaxFrameSize]; 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])); } + [Fact] + public void Clock_WithoutSkip_WritesZeroTarget() + { + var message = new ServerClockMessage(1, 0, Running: false, SpeedIndex: 1); + Span 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] public void SchoolGone_RoundTripsAndIsFiveBytes() { @@ -164,34 +202,45 @@ public class ProtocolCodecTests Assert.Equal(0, read.Nodes[0].PupilSlots); Assert.Equal([new MapSnapshotItem("Стул", 2)], read.Nodes[1].Items); Assert.Equal(["Директор"], read.Nodes[1].Positions); - Assert.Equal("", read.Nodes[0].ActivitySubject); - Assert.Empty(read.Nodes[0].Present); } [Fact] - public void MapSnapshot_RoundTripsOccupancyAfterPositions() + public void Presence_RoundTripsAndWritesHeaderOffsets() { - var message = new ServerMapSnapshotMessage(3, [ - new MapSnapshotNode( - 3, - "classroom-101", - "floor-1", - "Класс 101", - 16, - [new MapSnapshotItem("Парта", 16)], - [], - "Математика", - "5А", - ["Иванова Ольга Михайловна", "Соколов Иван Петрович"]), - ]); - var buffer = new byte[ProtocolCodec.MapSnapshotSize(message)]; + var message = new ServerPresenceMessage( + 7, + [ + new PresenceNode("classroom-101", 18, "Математика", "5А"), + new PresenceNode("corridor-1", 4), + ], + [ + new PresencePerson("f0.c0", "classroom-101", PresenceState.Here), + new PresencePerson("f3.p1", "corridor-1", PresenceState.Walking), + ]); + var buffer = new byte[ProtocolCodec.PresenceSize(message)]; - var length = ProtocolCodec.WriteMapSnapshot(buffer, message); - var read = ProtocolCodec.ReadMapSnapshot(buffer.AsSpan(0, length)); + var length = ProtocolCodec.WritePresence(buffer, message); + 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("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] @@ -287,7 +336,9 @@ public class ProtocolCodecTests { // Handlers size clock/welcome/pong buffers from this constant; map snapshots use MaxMessageSize. Span 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); }