From cad3068bac6803525c71241bec1f42b52cfd56a1 Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Wed, 19 Aug 2026 10:05:05 +0300 Subject: [PATCH] Update wire protocol to version 6 and enhance timetable functionality - Bumped the wire protocol version to 6, reflecting changes in the communication structure. - Expanded the timetable API with new endpoints for fetching and managing lesson schedules, including `GET /api/schools/{id}/timetable` and `POST /api/schools/{id}/timetable/pin`. - Updated the protocol documentation to include detailed descriptions of the new timetable features and message structures. - Enhanced the client-side implementation to support the new timetable functionalities, including lesson pinning and unpinning. - Revised server-side logic to handle timetable operations and ensure proper integration with existing school management features. - Added tests to validate the new timetable functionalities and ensure robustness in handling lesson data. --- docs/phases/16-timetable-in-school.md | 16 +- docs/phases/README.md | 2 +- docs/protocol.md | 85 +++++- src/HSchool.Client/src/net/api.ts | 79 ++++- src/HSchool.Client/src/net/protocol.test.ts | 121 +++++++- src/HSchool.Client/src/net/protocol.ts | 30 +- src/HSchool.Client/src/ui/gameScreen.ts | 16 +- src/HSchool.Content/SchoolDay.cs | 4 + src/HSchool.Protocol/Messages.cs | 14 +- src/HSchool.Protocol/ProtocolCodec.cs | 64 ++++- src/HSchool.Protocol/ProtocolConstants.cs | 2 +- src/HSchool.Schedule/TimetableClock.cs | 38 +++ src/HSchool.Server/Api/SchoolEndpoints.cs | 1 + src/HSchool.Server/Api/TimetableEndpoints.cs | 181 ++++++++++++ src/HSchool.Server/Api/TimetableModels.cs | 134 +++++++++ src/HSchool.Server/Game/GameCommand.cs | 17 ++ src/HSchool.Server/Game/GameLoopService.cs | 37 ++- src/HSchool.Server/Game/MapOccupancy.cs | 93 ++++++ src/HSchool.Server/Game/SchoolStore.cs | 38 ++- src/HSchool.Server/Game/SchoolWorker.cs | 271 +++++++++++++++++- src/HSchool.Server/Game/WorkerCommand.cs | 15 + src/HSchool.Server/HSchool.Server.csproj | 1 + src/HSchool.Server/Program.cs | 1 + .../HSchool.Simulation.csproj | 1 + src/HSchool.Simulation/School.cs | 17 ++ src/HSchool.Simulation/SchoolTimetables.cs | 31 ++ .../HSchool.AppHost.Tests/GameSocketTests.cs | 53 +++- .../TimetableApiTests.cs | 217 ++++++++++++++ .../ProtocolCodecTests.cs | 28 ++ .../TimetableClockTests.cs | 57 ++++ 30 files changed, 1621 insertions(+), 43 deletions(-) create mode 100644 src/HSchool.Schedule/TimetableClock.cs create mode 100644 src/HSchool.Server/Api/TimetableEndpoints.cs create mode 100644 src/HSchool.Server/Api/TimetableModels.cs create mode 100644 src/HSchool.Server/Game/MapOccupancy.cs create mode 100644 src/HSchool.Simulation/SchoolTimetables.cs create mode 100644 tests/HSchool.AppHost.Tests/TimetableApiTests.cs create mode 100644 tests/HSchool.Schedule.Tests/TimetableClockTests.cs diff --git a/docs/phases/16-timetable-in-school.md b/docs/phases/16-timetable-in-school.md index f7392bc..8e34efa 100644 --- a/docs/phases/16-timetable-in-school.md +++ b/docs/phases/16-timetable-in-school.md @@ -11,17 +11,17 @@ ## Задачи -- [ ] Расписание пересобирается при найме, увольнении и изменении назначений — **не по тику** -- [ ] Хранится в сейве рядом с составом; пишется при изменении, не по таймеру. Закреплённые +- [x] Расписание пересобирается при найме, увольнении и изменении назначений — **не по тику** +- [x] Хранится в сейве рядом с составом; пишется при изменении, не по таймеру. Закреплённые игроком уроки сохраняются вместе с ним -- [ ] Воркер публикует расписание снимком, как ростер -- [ ] «Кто где сейчас» **вычисляется** из расписания и часов, а не хранится вторым состоянием -- [ ] Команда закрепления и снятия закрепления урока — через мейлбокс воркера -- [ ] `GET /api/schools/{id}/timetable` — расписание класса и расписание человека; читает +- [x] Воркер публикует расписание снимком, как ростер +- [x] «Кто где сейчас» **вычисляется** из расписания и часов, а не хранится вторым состоянием +- [x] Команда закрепления и снятия закрепления урока — через мейлбокс воркера +- [x] `GET /api/schools/{id}/timetable` — расписание класса и расписание человека; читает опубликованный снимок -- [ ] Снимок карты отдаёт по узлу, что там сейчас идёт и у кого; протокол и `docs/protocol.md` +- [x] Снимок карты отдаёт по узлу, что там сейчас идёт и у кого; протокол и `docs/protocol.md` правятся тем же коммитом, версия бумпится -- [ ] Панель локации получает данные для «Сейчас» и «Персонажей» +- [x] Панель локации получает данные для «Сейчас» и «Персонажей» ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index f32aef9..a2848fe 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -66,5 +66,5 @@ | --- | --- | --- | | [14. Каркас дня и каникулы](14-school-calendar.md) | ✅ | Звонки, длина недели, каникулы | | [15. Планировщик](15-timetable-planner.md) | ✅ | Раскладка часов по слотам, четыре запрета | -| [16. Расписание в школе](16-timetable-in-school.md) | ⬜ | Сейв, снимок, «кто где сейчас» | +| [16. Расписание в школе](16-timetable-in-school.md) | ✅ | Сейв, снимок, «кто где сейчас» | | [17. Расписание на экране](17-timetable-screen.md) | ⬜ | Скобки в дереве, сетка класса, расписание учителя | diff --git a/docs/protocol.md b/docs/protocol.md index 205c092..47213ff 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -1,13 +1,13 @@ -# Wire protocol v5 +# Wire protocol v6 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, and reading a school's - roster (filtered list + one-person card). Those are request/response by nature, so they are - plain REST. -- **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz) and the one-shot map - snapshot sent when a school is opened. + 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 + 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. This document covers both. One protocol message per WebSocket frame, no framing header beyond the message id. **All multi-byte numbers are little-endian.** @@ -331,6 +331,62 @@ Body: `{ "subject": "Mathematics" }`. Teachers only. Same success payload as GET Removes one assignment. Payroll drops when the subject was not the only one. Unknown assignment is `404` `unknown-assignment`. +### `GET /api/schools/{id}/timetable` + +The published lesson table and uncovered hours. Optional `?classId=` or `?personId=` filter +the lessons. `?lang=ru|en` labels subjects. Reads the snapshot — it does not post to the +worker. Unknown `{id}` is `404` `unknown-school`. + +`day` is 0 = Monday. `period` is the 1-based lesson number from the day frame. + +```json +{ + "weekDays": 5, + "lessonCount": 7, + "lessons": [ + { + "classId": "c5A", + "classYear": 5, + "classLetter": "A", + "subject": "Mathematics", + "subjectLabel": "Математика", + "teacherId": "f3.p1", + "teacherName": "Иванова Ольга Михайловна", + "roomId": "classroom-204", + "day": 1, + "period": 3, + "locked": false + } + ], + "uncovered": [ + { + "classId": "c5A", + "classYear": 5, + "classLetter": "A", + "subject": "Informatics", + "subjectLabel": "Информатика", + "hours": 1 + } + ] +} +``` + +### `POST /api/schools/{id}/timetable/pin` + +Body: `{ "classId", "subject", "roomId", "day", "period" }`. Pins a locked lesson there and +rebuilds the rest around it. Same success payload as GET timetable. + +| Status | `code` | When | +| --- | --- | --- | +| `400` | `unknown-class` / `unknown-subject` / `unknown-room` | Not in this school. | +| `409` | `no-teacher` | Nobody is assigned that subject. | +| `409` | `pin-rejected` | The slot or room violates the four constraints. | + +### `DELETE /api/schools/{id}/timetable/pin` + +Query: `classId`, `subject`, `day`, `period`. Drops that lock and rebuilds. Unknown lock is +`404` `unknown-lesson`. + ## WebSocket message ids Client-to-server ids live in `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted @@ -452,9 +508,10 @@ client returns to the menu. ### `0x85` MapSnapshot — variable -Sent once when a school is opened (and again on reconnect OpenSchool). Not every tick, not on -tree clicks. Labels are in the Hello locale. People and in-place activities are omitted — the -client keeps those sections empty. +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. Strings are `u16` byte length + UTF-8. Empty string is a zero length. @@ -476,8 +533,10 @@ 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. +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. ## Guarantees and limits @@ -491,7 +550,7 @@ Item `count` is how many of that thing stand in the room (`Парта ×16` is o the oldest, because a stale clock is worthless once a newer one exists. - The map snapshot uses a separate reliable queue so ticks cannot crowd it out. -## Not in v5 yet +## Not in v6 yet -Authentication, Sit orders, an event log, and `OpenLocation` on the server — the tree is filtered -on the client from the snapshot. The school's ECS world is created but still empty. +Authentication, Sit orders, an event log, walking, and `OpenLocation` on the server — the tree is +filtered on the client from the snapshot. diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 8e62916..db5e651 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -1,6 +1,6 @@ /** - * HTTP side of the server: the main menu, the in-school people list/card, and staffing. The - * realtime clock arrives over the WebSocket instead — see `connection.ts`. + * HTTP side of the server: the main menu, the in-school people list/card, staffing and the + * timetable. The realtime clock arrives over the WebSocket instead — see `connection.ts`. */ export interface School { @@ -383,6 +383,81 @@ export async function unassignSubject( ); } +export interface TimetableLesson { + readonly classId: string; + readonly classYear: number; + readonly classLetter: string; + readonly subject: string; + readonly subjectLabel: string; + readonly teacherId: string; + readonly teacherName: string; + readonly roomId: string; + readonly day: number; + readonly period: number; + readonly locked: boolean; +} + +export interface UncoveredLesson { + readonly classId: string; + readonly classYear: number; + readonly classLetter: string; + readonly subject: string; + readonly subjectLabel: string; + readonly hours: number; +} + +export interface Timetable { + readonly weekDays: number; + readonly lessonCount: number; + readonly lessons: readonly TimetableLesson[]; + readonly uncovered: readonly UncoveredLesson[]; +} + +export async function fetchTimetable( + schoolId: number, + lang: string, + filters: { classId?: string; personId?: string } = {}, +): Promise { + const params = new URLSearchParams({ lang }); + if (filters.classId) { + params.set('classId', filters.classId); + } + + if (filters.personId) { + params.set('personId', filters.personId); + } + + return request(`/api/schools/${schoolId}/timetable?${params.toString()}`); +} + +export async function pinLesson( + schoolId: number, + lesson: { classId: string; subject: string; roomId: string; day: number; period: number }, + lang: string, +): Promise { + const params = new URLSearchParams({ lang }); + return request(`/api/schools/${schoolId}/timetable/pin?${params.toString()}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(lesson), + }); +} + +export async function unpinLesson( + schoolId: number, + lesson: { classId: string; subject: string; day: number; period: number }, + lang: string, +): Promise { + const params = new URLSearchParams({ lang }); + params.set('classId', lesson.classId); + params.set('subject', lesson.subject); + params.set('day', String(lesson.day)); + params.set('period', String(lesson.period)); + return request(`/api/schools/${schoolId}/timetable/pin?${params.toString()}`, { + method: 'DELETE', + }); +} + async function request( url: string, init?: RequestInit, diff --git a/src/HSchool.Client/src/net/protocol.test.ts b/src/HSchool.Client/src/net/protocol.test.ts index 940414d..99eb29d 100644 --- a/src/HSchool.Client/src/net/protocol.test.ts +++ b/src/HSchool.Client/src/net/protocol.test.ts @@ -129,7 +129,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); + const buffer = new ArrayBuffer(7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 1 + 1 + 1); const view = new DataView(buffer); view.setUint8(0, MessageType.ServerMapSnapshot); view.setInt32(1, 7, true); @@ -152,12 +152,27 @@ 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', schoolId: 7, nodes: [ - { kind: 0, id: 'yard', parentId: '', name: 'Двор', pupilSlots: 0, items: [], positions: [] }, + { + kind: 0, + id: 'yard', + parentId: '', + name: 'Двор', + pupilSlots: 0, + items: [], + positions: [], + activitySubject: '', + activityClass: '', + characters: [], + }, ], }); }); @@ -169,7 +184,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, + 7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 2 + itemName.length + 1 + 1 + 1 + 1, ); const view = new DataView(buffer); view.setUint8(0, MessageType.ServerMapSnapshot); @@ -201,6 +216,10 @@ 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', @@ -214,6 +233,102 @@ describe('decodeServerMessage', () => { pupilSlots: 16, items: [{ name: 'Парта', count: 16 }], positions: [], + activitySubject: '', + activityClass: '', + characters: [], + }, + ], + }); + }); + + it('reads occupancy after positions', () => { + 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 subject = encoder.encode('Математика'); + const schoolClass = encoder.encode('5А'); + const teacher = 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 + + 2 + subject.length + + 2 + schoolClass.length + + 1 + + 2 + teacher.length, + ); + const view = new DataView(buffer); + view.setUint8(0, MessageType.ServerMapSnapshot); + 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); + offset += 2; + new Uint8Array(buffer).set(id, offset); + offset += id.length; + view.setUint16(offset, parent.length, 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); + offset += 2; + new Uint8Array(buffer).set(subject, offset); + offset += subject.length; + view.setUint16(offset, schoolClass.length, true); + offset += 2; + new Uint8Array(buffer).set(schoolClass, offset); + offset += schoolClass.length; + view.setUint8(offset, 1); + offset += 1; + view.setUint16(offset, teacher.length, true); + offset += 2; + new Uint8Array(buffer).set(teacher, offset); + + expect(decodeServerMessage(buffer)).toEqual({ + type: 'map-snapshot', + schoolId: 3, + nodes: [ + { + kind: 3, + id: 'classroom-101', + parentId: 'floor-1', + name: 'Класс 101', + pupilSlots: 16, + items: [{ name: 'Парта', count: 16 }], + positions: [], + activitySubject: 'Математика', + activityClass: '5А', + characters: ['Иванова'], }, ], }); diff --git a/src/HSchool.Client/src/net/protocol.ts b/src/HSchool.Client/src/net/protocol.ts index cb01956..a33bd79 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 = 5; +export const PROTOCOL_VERSION = 6; export const MessageType = { ClientHello: 0x01, @@ -82,6 +82,9 @@ 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 { @@ -265,6 +268,28 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage { offset = value.next; } + const hasActivity = readU8(view, offset); + offset += 1; + let activitySubject = ''; + let activityClass = ''; + if (hasActivity !== 0) { + const subject = readString(view, offset); + offset = subject.next; + const schoolClass = readString(view, offset); + offset = schoolClass.next; + activitySubject = subject.text; + 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, @@ -273,6 +298,9 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage { pupilSlots, items, positions, + activitySubject, + activityClass, + characters, }); } diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index adb0eb9..f5e24ab 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -43,8 +43,10 @@ export class GameScreen { private readonly pupilSlotsLine = el('p', { class: 'panel__meta' }); private readonly charactersHeading = el('h3', { class: 'panel__section-title' }); private readonly charactersEmpty = el('p', { class: 'panel__empty' }); + private readonly charactersList = el('ul', { class: 'panel__list' }); private readonly activitiesHeading = el('h3', { class: 'panel__section-title' }); private readonly activitiesEmpty = el('p', { class: 'panel__empty' }); + private readonly activitiesList = el('ul', { class: 'panel__list' }); private readonly positionsHeading = el('h3', { class: 'panel__section-title' }); private readonly positionsEmpty = el('p', { class: 'panel__empty' }); private readonly positionsList = el('ul', { class: 'panel__list' }); @@ -118,8 +120,8 @@ export class GameScreen { this.locationBody.append( this.locationName, el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList, this.pupilSlotsLine), - el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty), - el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty), + el('div', { class: 'panel__section' }, this.charactersHeading, this.charactersEmpty, this.charactersList), + el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty, this.activitiesList), el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList), ); @@ -181,6 +183,10 @@ export class GameScreen { this.showMode('overview'); } + /** + * 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. + */ applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void { if (this.schoolId !== schoolId) { return; @@ -281,6 +287,12 @@ export class GameScreen { const pupilSlots = node?.pupilSlots ?? 0; this.pupilSlotsLine.hidden = pupilSlots <= 0; this.pupilSlotsLine.textContent = pupilSlots > 0 ? t('pupilSlots', { count: pupilSlots }) : ''; + const activity = + node && (node.activitySubject.length > 0 || node.activityClass.length > 0) + ? [[node.activitySubject, node.activityClass].filter((part) => part.length > 0).join(' · ')] + : []; + paintList(this.activitiesList, this.activitiesEmpty, activity); + paintList(this.charactersList, this.charactersEmpty, node?.characters ?? []); paintList(this.positionsList, this.positionsEmpty, node?.positions ?? []); } diff --git a/src/HSchool.Content/SchoolDay.cs b/src/HSchool.Content/SchoolDay.cs index 0f3f5f0..37924ad 100644 --- a/src/HSchool.Content/SchoolDay.cs +++ b/src/HSchool.Content/SchoolDay.cs @@ -102,6 +102,10 @@ public static class SchoolDay return mondayBased < weekDays; } + /// Monday = 0 … Sunday = 6, same numbering the timetable uses. + public static int WeekdayIndex(DateTime time) => + ((int)DateTime.SpecifyKind(time, DateTimeKind.Utc).DayOfWeek + 6) % 7; + private static bool IsHoliday(DefCatalog catalog, DateTime time) { foreach (var holiday in catalog.Holidays.Values) diff --git a/src/HSchool.Protocol/Messages.cs b/src/HSchool.Protocol/Messages.cs index d6e1b3e..73d13db 100644 --- a/src/HSchool.Protocol/Messages.cs +++ b/src/HSchool.Protocol/Messages.cs @@ -46,6 +46,8 @@ public readonly record struct ServerSchoolGoneMessage(int SchoolId); /// 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. /// public sealed record MapSnapshotNode( byte Kind, @@ -54,13 +56,19 @@ public sealed record MapSnapshotNode( string Name, ushort PupilSlots, IReadOnlyList Items, - IReadOnlyList Positions); + IReadOnlyList Positions, + string ActivitySubject = "", + string ActivityClass = "", + IReadOnlyList? Characters = null) +{ + public IReadOnlyList Present => Characters ?? []; +} /// 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 once when that school is opened, not every tick. -/// People and in-place activities are omitted — the client keeps those sections empty. +/// 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. /// public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList Nodes); diff --git a/src/HSchool.Protocol/ProtocolCodec.cs b/src/HSchool.Protocol/ProtocolCodec.cs index 2200130..ca94853 100644 --- a/src/HSchool.Protocol/ProtocolCodec.cs +++ b/src/HSchool.Protocol/ProtocolCodec.cs @@ -129,6 +129,18 @@ 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; @@ -148,9 +160,9 @@ public static class ProtocolCodec foreach (var node in message.Nodes) { - if (node.Items.Count > byte.MaxValue || node.Positions.Count > byte.MaxValue) + if (node.Items.Count > byte.MaxValue || node.Positions.Count > byte.MaxValue || node.Present.Count > byte.MaxValue) { - throw new ProtocolException($"Map node '{node.Id}' has too many items or positions for a u8 count."); + throw new ProtocolException($"Map node '{node.Id}' has too many items, positions or people for a u8 count."); } writer.WriteByte(node.Kind); @@ -170,6 +182,23 @@ public static class ProtocolCodec { writer.WriteString(position); } + + if (HasActivity(node)) + { + writer.WriteByte(1); + writer.WriteString(node.ActivitySubject); + writer.WriteString(node.ActivityClass); + } + else + { + writer.WriteByte(0); + } + + writer.WriteByte((byte)node.Present.Count); + foreach (var person in node.Present) + { + writer.WriteString(person); + } } return writer.Position; @@ -283,12 +312,41 @@ public static class ProtocolCodec positions[position] = reader.ReadString(); } - nodes[i] = new MapSnapshotNode(kind, id, parentId, name, pupilSlots, items, positions); + var hasActivity = reader.ReadByte() != 0; + var activitySubject = ""; + var activityClass = ""; + if (hasActivity) + { + activitySubject = reader.ReadString(); + 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); } return new ServerMapSnapshotMessage(schoolId, nodes); } + private static bool HasActivity(MapSnapshotNode node) => + node.ActivitySubject.Length > 0 || node.ActivityClass.Length > 0; + /// Matches : a u16 length plus UTF-8. private static int StringSize(string value) => sizeof(ushort) + Encoding.UTF8.GetByteCount(value); diff --git a/src/HSchool.Protocol/ProtocolConstants.cs b/src/HSchool.Protocol/ProtocolConstants.cs index dcd7dae..9501587 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 = 5; + public const byte Version = 6; /// Upper bound for a single WebSocket frame accepted by the server. public const int MaxMessageSize = 8 * 1024; diff --git a/src/HSchool.Schedule/TimetableClock.cs b/src/HSchool.Schedule/TimetableClock.cs new file mode 100644 index 0000000..923d2ad --- /dev/null +++ b/src/HSchool.Schedule/TimetableClock.cs @@ -0,0 +1,38 @@ +using HSchool.Content; + +namespace HSchool.Schedule; + +/// +/// Which lessons are happening at a game instant. Breaks, nights, weekends and holidays are empty +/// — occupancy is derived, never stored. +/// +public static class TimetableClock +{ + public static IReadOnlyList OccurringAt( + Timetable table, + DefCatalog catalog, + DateTime time, + int weekDays) + { + ArgumentNullException.ThrowIfNull(table); + var slot = SchoolDay.At(catalog, time, weekDays); + if (slot.Kind != DaySlotKind.Lesson) + { + return []; + } + + var day = SchoolDay.WeekdayIndex(time); + return table.Lessons + .Where(lesson => lesson.Day == day && lesson.Period == slot.Index) + .ToArray(); + } + + public static OccupancyKey Key(DefCatalog catalog, DateTime time, int weekDays) + { + var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc); + var slot = SchoolDay.At(catalog, utc, weekDays); + return new OccupancyKey(DateOnly.FromDateTime(utc), slot.Kind, slot.Index); + } +} + +public readonly record struct OccupancyKey(DateOnly Day, DaySlotKind Kind, int Index); diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 0879cb6..51691ac 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -8,6 +8,7 @@ namespace HSchool.Server.Api; /// /// The main menu talks to these: list, create, delete. People list and staffing read published /// snapshots; the person card, hire and subject changes go through the school's mailbox. +/// The timetable is a published snapshot; pin/unpin go through the mailbox. /// internal static class SchoolEndpoints { diff --git a/src/HSchool.Server/Api/TimetableEndpoints.cs b/src/HSchool.Server/Api/TimetableEndpoints.cs new file mode 100644 index 0000000..e0dbb32 --- /dev/null +++ b/src/HSchool.Server/Api/TimetableEndpoints.cs @@ -0,0 +1,181 @@ +using HSchool.People; +using HSchool.Schedule; +using HSchool.Server.Game; + +namespace HSchool.Server.Api; + +internal static class TimetableEndpoints +{ + private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5); + + public static void MapTimetableEndpoints(this IEndpointRouteBuilder builder) + { + var schools = builder.MapGroup("/api/schools"); + + schools.MapGet("/{id:int}/timetable", ( + int id, + string? classId, + string? personId, + string? lang, + GameLoopService loop) => + { + var published = loop.FindPeople(id); + if (published is null) + { + return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."); + } + + return Results.Ok(MapTimetable(published, loop.Options.SchoolWeekDays, ParseLocale(lang), classId, personId)); + }) + .WithName("GetSchoolTimetable"); + + schools.MapPost("/{id:int}/timetable/pin", async ( + int id, + PinLessonRequest request, + string? lang, + GameCommandQueue commands, + GameLoopService loop, + CancellationToken cancellationToken) => + { + if (!TryDefName(request.ClassId, "classId", out var classId, out var error) + || !TryDefName(request.Subject, "subject", out var subject, out error) + || !TryDefName(request.RoomId, "roomId", out var roomId, out error)) + { + return Problem(StatusCodes.Status400BadRequest, "invalid-query", error); + } + + var command = new GameCommand.PinLesson( + id, + classId, + subject, + roomId, + request.Day, + request.Period, + NewCompletion()); + commands.Enqueue(command); + var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + return TimetableResult(id, outcome, loop, ParseLocale(lang), classId: null, personId: null); + }) + .WithName("PinSchoolLesson"); + + schools.MapDelete("/{id:int}/timetable/pin", async ( + int id, + string? classId, + string? subject, + int? day, + int? period, + string? lang, + GameCommandQueue commands, + GameLoopService loop, + CancellationToken cancellationToken) => + { + if (!TryDefName(classId, "classId", out var classValue, out var error) + || !TryDefName(subject, "subject", out var subjectValue, out error) + || day is null + || period is null) + { + return Problem( + StatusCodes.Status400BadRequest, + "invalid-query", + error.Length > 0 ? error : "classId, subject, day and period are required."); + } + + var command = new GameCommand.UnpinLesson( + id, + classValue, + subjectValue, + day.Value, + period.Value, + NewCompletion()); + commands.Enqueue(command); + var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken); + return TimetableResult(id, outcome, loop, ParseLocale(lang), classId: null, personId: null); + }) + .WithName("UnpinSchoolLesson"); + } + + private static TaskCompletionSource NewCompletion() => + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private static string ParseLocale(string? lang) => + string.Equals(lang, "en", StringComparison.OrdinalIgnoreCase) ? "en" : "ru"; + + private static TimetableResponse MapTimetable( + PublishedSchoolPeople published, + int weekDays, + string locale, + string? classId, + string? personId) + { + var table = published.Timetable ?? new Timetable([], []); + var roster = published.Roster ?? new Roster([], [], []); + if (published.Catalog is null) + { + return new TimetableResponse(weekDays, 0, [], []); + } + + return TimetableMapper.From(table, roster, published.Catalog, weekDays, locale, classId, personId); + } + + private static IResult TimetableResult( + int schoolId, + TimetableOutcome outcome, + GameLoopService loop, + string locale, + string? classId, + string? personId) + { + if (outcome.Error != TimetableError.None) + { + return TimetableProblem(outcome.Error); + } + + var published = loop.FindPeople(schoolId); + if (published is null) + { + return Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."); + } + + return Results.Ok(MapTimetable(published, loop.Options.SchoolWeekDays, locale, classId, personId)); + } + + private static IResult TimetableProblem(TimetableError error) => + error switch + { + TimetableError.UnknownClass => + Problem(StatusCodes.Status400BadRequest, "unknown-class", "That class is not in the school."), + TimetableError.UnknownSubject => + Problem(StatusCodes.Status400BadRequest, "unknown-subject", "That subject is not in the catalog."), + TimetableError.UnknownRoom => + Problem(StatusCodes.Status400BadRequest, "unknown-room", "That room is not on the map."), + TimetableError.NoTeacher => + Problem(StatusCodes.Status409Conflict, "no-teacher", "Nobody is assigned that subject."), + TimetableError.PinRejected => + Problem(StatusCodes.Status409Conflict, "pin-rejected", "That slot or room violates the timetable constraints."), + TimetableError.UnknownLesson => + Problem(StatusCodes.Status404NotFound, "unknown-lesson", "That locked lesson is not on the timetable."), + _ => Problem(StatusCodes.Status404NotFound, "unknown-school", "That school does not exist."), + }; + + private static bool TryDefName(string? value, string field, out string name, out string error) + { + name = value?.Trim() ?? string.Empty; + if (name.Length is < 1 or > 64) + { + error = $"{field} is not valid."; + return false; + } + + error = string.Empty; + return true; + } + + private static IResult Problem(int statusCode, string code, string detail) + { + return Results.Problem( + detail: detail, + statusCode: statusCode, + title: code, + extensions: new Dictionary { ["code"] = code }); + } +} diff --git a/src/HSchool.Server/Api/TimetableModels.cs b/src/HSchool.Server/Api/TimetableModels.cs new file mode 100644 index 0000000..0d723e1 --- /dev/null +++ b/src/HSchool.Server/Api/TimetableModels.cs @@ -0,0 +1,134 @@ +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Server.Api; + +internal enum TimetableError +{ + None, + UnknownSchool, + UnknownClass, + UnknownSubject, + UnknownRoom, + NoTeacher, + PinRejected, + UnknownLesson, +} + +internal sealed record TimetableOutcome(TimetableError Error, Timetable? Table = null) +{ + public static TimetableOutcome Ok(Timetable table) => new(TimetableError.None, table); + + public static TimetableOutcome Fail(TimetableError error) => new(error); +} + +internal sealed record PinLessonRequest(string? ClassId, string? Subject, string? RoomId, int Day, int Period); + +internal sealed record TimetableResponse( + int WeekDays, + int LessonCount, + IReadOnlyList Lessons, + IReadOnlyList Uncovered); + +internal sealed record TimetableLessonResponse( + string ClassId, + int ClassYear, + string ClassLetter, + string Subject, + string SubjectLabel, + string TeacherId, + string TeacherName, + string RoomId, + int Day, + int Period, + bool Locked); + +internal sealed record UncoveredLessonResponse( + string ClassId, + int ClassYear, + string ClassLetter, + string Subject, + string SubjectLabel, + int Hours); + +internal static class TimetableMapper +{ + public static TimetableResponse From( + Timetable table, + Roster roster, + DefCatalog catalog, + int weekDays, + string locale, + string? classId, + string? personId) + { + var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal); + var classes = roster.Classes.ToDictionary(item => item.Id, StringComparer.Ordinal); + var lessons = table.Lessons.AsEnumerable(); + var uncovered = table.Uncovered.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(classId)) + { + lessons = lessons.Where(lesson => lesson.ClassId == classId); + uncovered = uncovered.Where(row => row.ClassId == classId); + } + + if (!string.IsNullOrWhiteSpace(personId)) + { + lessons = lessons.Where(lesson => lesson.TeacherId == personId); + } + + return new TimetableResponse( + weekDays, + catalog.DayFrame?.LessonCount ?? 0, + lessons.Select(lesson => MapLesson(lesson, classes, people, catalog, locale)).ToArray(), + uncovered.Select(row => MapUncovered(row, classes, catalog, locale)).ToArray()); + } + + private static TimetableLessonResponse MapLesson( + LessonPlacement lesson, + IReadOnlyDictionary classes, + IReadOnlyDictionary people, + DefCatalog catalog, + string locale) + { + classes.TryGetValue(lesson.ClassId, out var schoolClass); + people.TryGetValue(lesson.TeacherId, out var teacher); + var subjectLabel = catalog.Subjects.TryGetValue(lesson.Subject, out var subject) + ? catalog.Label(locale, subject) + : lesson.Subject; + + return new TimetableLessonResponse( + lesson.ClassId, + schoolClass?.Year ?? 0, + schoolClass?.Letter ?? "", + lesson.Subject, + subjectLabel, + lesson.TeacherId, + teacher?.Name.Full ?? lesson.TeacherId, + lesson.RoomId, + lesson.Day, + lesson.Period, + lesson.Locked); + } + + private static UncoveredLessonResponse MapUncovered( + UncoveredDemand row, + IReadOnlyDictionary classes, + DefCatalog catalog, + string locale) + { + classes.TryGetValue(row.ClassId, out var schoolClass); + var subjectLabel = catalog.Subjects.TryGetValue(row.Subject, out var subject) + ? catalog.Label(locale, subject) + : row.Subject; + + return new UncoveredLessonResponse( + row.ClassId, + schoolClass?.Year ?? 0, + schoolClass?.Letter ?? "", + row.Subject, + subjectLabel, + row.Hours); + } +} diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 2c9b0d5..ef5e02e 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -69,4 +69,21 @@ internal abstract record GameCommand string PersonId, string Subject, TaskCompletionSource Result) : GameCommand; + + internal sealed record PinLesson( + int SchoolId, + string ClassId, + string Subject, + string RoomId, + int Day, + int Period, + TaskCompletionSource Result) : GameCommand; + + internal sealed record UnpinLesson( + int SchoolId, + string ClassId, + string Subject, + int Day, + int Period, + TaskCompletionSource Result) : GameCommand; } diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index a643662..4ca29e8 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -1,6 +1,7 @@ using HSchool.Content; using HSchool.People; using HSchool.Protocol; +using HSchool.Schedule; using HSchool.Server.Api; using HSchool.Server.Net; using HSchool.Simulation; @@ -64,7 +65,12 @@ internal sealed class GameLoopService( { if (worker.Id == schoolId) { - return new PublishedSchoolPeople(worker.Snapshot, worker.RosterSnapshot, worker.ApplicantSnapshot, worker.CatalogSnapshot); + return new PublishedSchoolPeople( + worker.Snapshot, + worker.RosterSnapshot, + worker.ApplicantSnapshot, + worker.CatalogSnapshot, + worker.TimetableSnapshot); } } @@ -192,6 +198,20 @@ internal sealed class GameLoopService( new WorkerCommand.UnassignSubject(unassign.PersonId, unassign.Subject, unassign.Result), unassign.Result); break; + + case GameCommand.PinLesson pin: + HandleTimetable( + pin.SchoolId, + new WorkerCommand.PinLesson(pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period, pin.Result), + pin.Result); + break; + + case GameCommand.UnpinLesson unpin: + HandleTimetable( + unpin.SchoolId, + new WorkerCommand.UnpinLesson(unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period, unpin.Result), + unpin.Result); + break; } } @@ -212,6 +232,14 @@ internal sealed class GameLoopService( } } + private void HandleTimetable(int schoolId, WorkerCommand command, TaskCompletionSource result) + { + if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command)) + { + result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool)); + } + } + /// /// A school's thread died. Drop it from the table so the menu stops drawing a card whose clock /// never moves again, and tell anybody watching it to go back to the menu. The save file stays @@ -610,4 +638,9 @@ internal sealed class GameLoopService( } } -internal sealed record PublishedSchoolPeople(SchoolState School, Roster? Roster, ApplicantPool? Applicants, DefCatalog? Catalog); +internal sealed record PublishedSchoolPeople( + SchoolState School, + Roster? Roster, + ApplicantPool? Applicants, + DefCatalog? Catalog, + Timetable? Timetable); diff --git a/src/HSchool.Server/Game/MapOccupancy.cs b/src/HSchool.Server/Game/MapOccupancy.cs new file mode 100644 index 0000000..2a57388 --- /dev/null +++ b/src/HSchool.Server/Game/MapOccupancy.cs @@ -0,0 +1,93 @@ +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/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index c6749b3..d789ba7 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -1,6 +1,7 @@ using System.Text.Json; using HSchool.Content; using HSchool.People; +using HSchool.Schedule; using HSchool.Simulation; using Microsoft.Extensions.Options; @@ -104,7 +105,8 @@ internal sealed class SchoolStore { var fileName = Path.GetFileName(path); if (string.Equals(fileName, IndexFileName, StringComparison.OrdinalIgnoreCase) - || fileName.EndsWith(".people.json", StringComparison.OrdinalIgnoreCase)) + || fileName.EndsWith(".people.json", StringComparison.OrdinalIgnoreCase) + || fileName.EndsWith(".timetable.json", StringComparison.OrdinalIgnoreCase)) { continue; } @@ -188,6 +190,12 @@ internal sealed class SchoolStore { File.Delete(people); } + + var timetable = TimetablePath(id); + if (File.Exists(timetable)) + { + File.Delete(timetable); + } } public RosterDocument? TryReadPeople(int id) @@ -215,10 +223,38 @@ internal sealed class SchoolStore WriteAtomic(PeoplePath(id), document, RosterJson.Options); } + public Timetable? TryReadTimetable(int id) + { + var path = TimetablePath(id); + if (!File.Exists(path)) + { + return null; + } + + try + { + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, Json); + } + catch (Exception ex) + { + throw new SchoolContentUnavailableException( + $"School {id} timetable file could not be read.", + ex); + } + } + + public void SaveTimetable(int id, Timetable table) + { + WriteAtomic(TimetablePath(id), table); + } + private string SchoolPath(int id) => Path.Combine(DirectoryPath, $"{id}.json"); private string PeoplePath(int id) => Path.Combine(DirectoryPath, $"{id}.people.json"); + private string TimetablePath(int id) => Path.Combine(DirectoryPath, $"{id}.timetable.json"); + private string IndexPath() => Path.Combine(DirectoryPath, IndexFileName); private static void WriteAtomic(string path, T value, JsonSerializerOptions? options = null) diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index feb6115..305d5c8 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -3,6 +3,7 @@ using System.Threading.Channels; using HSchool.Content; using HSchool.People; using HSchool.Protocol; +using HSchool.Schedule; using HSchool.Server.Api; using HSchool.Server.Net; using HSchool.Simulation; @@ -44,6 +45,8 @@ internal sealed class SchoolWorker private Roster? _rosterSnapshot; private ApplicantPool? _applicantSnapshot; private DefCatalog? _catalogSnapshot; + private Timetable? _timetableSnapshot; + private OccupancyKey _occupancyKey; private School? _school; private Task? _run; private bool _persistOnStop = true; @@ -103,6 +106,9 @@ internal sealed class SchoolWorker /// Frozen catalog for this school. Safe to read from HTTP; it never mutates after load. public DefCatalog? CatalogSnapshot => Volatile.Read(ref _catalogSnapshot); + /// Last built timetable. Published like the roster — HTTP never reads the live school. + public Timetable? TimetableSnapshot => Volatile.Read(ref _timetableSnapshot); + public void Start() { _run = Task.Factory.StartNew( @@ -284,12 +290,17 @@ internal sealed class SchoolWorker if (peopleChanged) { PersistPeople(); + if (school.TimetableDirty) + { + RebuildTimetable(school); + } } if (steps > 0) { PublishSnapshot(); BroadcastClock(); + MaybeBroadcastOccupancy(school); } FlushSettings(); @@ -402,6 +413,16 @@ internal sealed class SchoolWorker case WorkerCommand.UnassignSubject unassign: unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject)); break; + + case WorkerCommand.PinLesson pin: + pin.Result.TrySetResult( + ApplyPin(school, pin.ClassId, pin.Subject, pin.RoomId, pin.Day, pin.Period)); + break; + + case WorkerCommand.UnpinLesson unpin: + unpin.Result.TrySetResult( + ApplyUnpin(school, unpin.ClassId, unpin.Subject, unpin.Day, unpin.Period)); + break; } } catch (Exception ex) @@ -443,6 +464,12 @@ internal sealed class SchoolWorker case WorkerCommand.UnassignSubject unassign: unassign.Result.TrySetResult(Staffing.UnknownSchool()); break; + case WorkerCommand.PinLesson pin: + pin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool)); + break; + case WorkerCommand.UnpinLesson unpin: + unpin.Result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool)); + break; } } @@ -462,6 +489,12 @@ internal sealed class SchoolWorker case WorkerCommand.UnassignSubject unassign: unassign.Result.TrySetException(exception); break; + case WorkerCommand.PinLesson pin: + pin.Result.TrySetException(exception); + break; + case WorkerCommand.UnpinLesson unpin: + unpin.Result.TrySetException(exception); + break; } } @@ -491,7 +524,7 @@ internal sealed class SchoolWorker { school.ApplyStaffing(outcome.Roster, outcome.Pool); PersistPeople(); - PublishSnapshot(); + RebuildTimetable(school); } return outcome; @@ -531,6 +564,7 @@ internal sealed class SchoolWorker (byte)school.Clock.SpeedIndex)); Volatile.Write(ref _rosterSnapshot, school.Roster); Volatile.Write(ref _applicantSnapshot, school.Applicants); + Volatile.Write(ref _timetableSnapshot, school.Timetable); } /// @@ -555,6 +589,238 @@ internal sealed class SchoolWorker } } + private void InstallTimetable(School school) + { + if (!_isNew) + { + var saved = _store.TryReadTimetable(_id); + if (saved is not null) + { + var restored = RestoreTimetable(school, saved); + school.SetTimetable(restored); + if (!saved.Lessons.SequenceEqual(restored.Lessons) + || !saved.Uncovered.SequenceEqual(restored.Uncovered)) + { + PersistTimetable(school); + } + + RememberOccupancy(school); + return; + } + } + + RebuildTimetable(school, broadcast: false); + } + + private Timetable RestoreTimetable(School school, Timetable saved) + { + if (school.Catalog is null || school.Map is null || school.Roster is null) + { + return saved; + } + + var classIds = school.Roster.Classes.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); + var peopleIds = school.Roster.People.Select(person => person.Id).ToHashSet(StringComparer.Ordinal); + var valid = saved.Lessons + .Where(lesson => classIds.Contains(lesson.ClassId) && peopleIds.Contains(lesson.TeacherId)) + .ToArray(); + if (valid.Length == saved.Lessons.Count) + { + return saved; + } + + var locks = valid.Where(lesson => lesson.Locked).ToArray(); + return SchoolTimetables.Build( + school.Catalog, + school.Map, + school.Roster, + locks, + _options.SchoolWeekDays); + } + + private void RebuildTimetable(School school, bool broadcast = true) + { + if (school.Catalog is null || school.Map is null || school.Roster is null) + { + return; + } + + var locks = school.Timetable?.Lessons.Where(lesson => lesson.Locked).ToArray() ?? []; + ApplyTable( + school, + SchoolTimetables.Build(school.Catalog, school.Map, school.Roster, locks, _options.SchoolWeekDays), + broadcast); + } + + private TimetableOutcome ApplyPin( + School school, + string classId, + string subject, + string roomId, + int day, + int period) + { + if (school.Catalog is null || school.Map is null || school.Roster is null) + { + return TimetableOutcome.Fail(TimetableError.UnknownSchool); + } + + if (school.Roster.Classes.All(item => item.Id != classId)) + { + return TimetableOutcome.Fail(TimetableError.UnknownClass); + } + + if (!school.Catalog.Subjects.TryGetValue(subject, out var subjectDef) || subjectDef.Abstract) + { + return TimetableOutcome.Fail(TimetableError.UnknownSubject); + } + + if (school.Map.Rooms.All(room => room.Id != roomId)) + { + return TimetableOutcome.Fail(TimetableError.UnknownRoom); + } + + var teacherId = TeacherFor(school, classId, subject); + if (teacherId is null) + { + return TimetableOutcome.Fail(TimetableError.NoTeacher); + } + + var pin = new LessonPlacement(classId, subject, teacherId, roomId, day, period, Locked: true); + var locks = (school.Timetable?.Lessons.Where(lesson => lesson.Locked) ?? []) + .Where(lesson => lesson.ClassId != classId || lesson.Subject != subject + || lesson.Day != day || lesson.Period != period) + .Append(pin) + .ToArray(); + var table = SchoolTimetables.Build( + school.Catalog, + school.Map, + school.Roster, + locks, + _options.SchoolWeekDays); + if (!table.Lessons.Any(lesson => + lesson.Locked + && lesson.ClassId == classId + && lesson.Subject == subject + && lesson.RoomId == roomId + && lesson.Day == day + && lesson.Period == period)) + { + return TimetableOutcome.Fail(TimetableError.PinRejected); + } + + ApplyTable(school, table, broadcast: true); + return TimetableOutcome.Ok(table); + } + + private TimetableOutcome ApplyUnpin(School school, string classId, string subject, int day, int period) + { + if (school.Catalog is null || school.Map is null || school.Roster is null) + { + return TimetableOutcome.Fail(TimetableError.UnknownSchool); + } + + var locks = school.Timetable?.Lessons.Where(lesson => lesson.Locked).ToArray() ?? []; + var match = locks.FirstOrDefault(lesson => + lesson.ClassId == classId && lesson.Subject == subject && lesson.Day == day && lesson.Period == period); + if (match is null) + { + return TimetableOutcome.Fail(TimetableError.UnknownLesson); + } + + var next = SchoolTimetables.Build( + school.Catalog, + school.Map, + school.Roster, + locks.Where(lesson => lesson != match).ToArray(), + _options.SchoolWeekDays); + ApplyTable(school, next, broadcast: true); + return TimetableOutcome.Ok(next); + } + + private static string? TeacherFor(School school, string classId, string subject) + { + var existing = school.Timetable?.Lessons.FirstOrDefault(lesson => + lesson.ClassId == classId && lesson.Subject == subject); + if (existing is not null) + { + return existing.TeacherId; + } + + return school.Roster?.People + .Where(person => person.IsStaff && person.Subjects.Contains(subject, StringComparer.Ordinal)) + .OrderBy(person => person.Id, StringComparer.Ordinal) + .Select(person => person.Id) + .FirstOrDefault(); + } + + private void ApplyTable(School school, Timetable table, bool broadcast) + { + school.SetTimetable(table); + PersistTimetable(school); + PublishSnapshot(); + if (broadcast) + { + MaybeBroadcastOccupancy(school, force: true); + } + else + { + RememberOccupancy(school); + } + } + + /// + /// Writes the lesson table. Not called from the 30-second clock save — the table changes on + /// hire, unassign, pin and yearly intake, not every tick. + /// + private void PersistTimetable(School school) + { + if (school.Timetable is null) + { + return; + } + + try + { + _store.SaveTimetable(school.Id, school.Timetable); + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not save the timetable for school {SchoolId}; it stays in memory.", _id); + } + } + + private void RememberOccupancy(School school) + { + if (school.Catalog is not null) + { + _occupancyKey = TimetableClock.Key(school.Catalog, school.Clock.Time, _options.SchoolWeekDays); + } + } + + private void MaybeBroadcastOccupancy(School school, bool force = false) + { + if (school.Catalog is null) + { + return; + } + + var key = TimetableClock.Key(school.Catalog, school.Clock.Time, _options.SchoolWeekDays); + if (!force && key == _occupancyKey) + { + return; + } + + _occupancyKey = key; + foreach (var client in _clients.All) + { + if (client.IsReady && client.OpenSchoolId == _id) + { + SendMapSnapshot(client, school); + } + } + } + private bool InstallPeople(School school, DefCatalog catalog, MapLayout map) { var nameSetId = ResolveNameSetId(catalog, _nameSetId); @@ -609,6 +875,7 @@ internal sealed class SchoolWorker } school.InstallPeople(roster, seed, nameSetId, applicants); + InstallTimetable(school); return generated; } @@ -708,6 +975,8 @@ 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); diff --git a/src/HSchool.Server/Game/WorkerCommand.cs b/src/HSchool.Server/Game/WorkerCommand.cs index e93f5c9..527fbd9 100644 --- a/src/HSchool.Server/Game/WorkerCommand.cs +++ b/src/HSchool.Server/Game/WorkerCommand.cs @@ -37,4 +37,19 @@ internal abstract record WorkerCommand string PersonId, string Subject, TaskCompletionSource Result) : WorkerCommand; + + internal sealed record PinLesson( + string ClassId, + string Subject, + string RoomId, + int Day, + int Period, + TaskCompletionSource Result) : WorkerCommand; + + internal sealed record UnpinLesson( + string ClassId, + string Subject, + int Day, + int Period, + TaskCompletionSource Result) : WorkerCommand; } diff --git a/src/HSchool.Server/HSchool.Server.csproj b/src/HSchool.Server/HSchool.Server.csproj index 5be2423..a6deeba 100644 --- a/src/HSchool.Server/HSchool.Server.csproj +++ b/src/HSchool.Server/HSchool.Server.csproj @@ -13,6 +13,7 @@ + diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index cc0908c..7feaa46 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -51,6 +51,7 @@ app.UseWebSockets(new WebSocketOptions }); app.MapSchoolEndpoints(); +app.MapTimetableEndpoints(); app.MapModEndpoints(); app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) => diff --git a/src/HSchool.Simulation/HSchool.Simulation.csproj b/src/HSchool.Simulation/HSchool.Simulation.csproj index 6983ce5..7646613 100644 --- a/src/HSchool.Simulation/HSchool.Simulation.csproj +++ b/src/HSchool.Simulation/HSchool.Simulation.csproj @@ -12,6 +12,7 @@ + diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 081348b..f22fe82 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -1,6 +1,7 @@ using Arch.Core; using HSchool.Content; using HSchool.People; +using HSchool.Schedule; namespace HSchool.Simulation; @@ -76,6 +77,12 @@ public sealed class School : IDisposable /// Name pack used to generate this school's people. Needed again on 1 September. public string? NameSetId { get; private set; } + /// Last built table. Null until the worker installs people. + public Timetable? Timetable { get; private set; } + + /// True after yearly intake until the worker rebuilds around remaining locks. + public bool TimetableDirty { get; private set; } + /// /// Installs a roster that already matches the map. Spawns entities; does not write to disk. /// @@ -104,6 +111,15 @@ public sealed class School : IDisposable Roster = roster; Applicants = applicants; RosterSpawner.Replace(World, roster); + TimetableDirty = true; + } + + public void SetTimetable(Timetable timetable) + { + ObjectDisposedException.ThrowIf(_disposed, this); + ArgumentNullException.ThrowIfNull(timetable); + Timetable = timetable; + TimetableDirty = false; } /// Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay. @@ -145,6 +161,7 @@ public sealed class School : IDisposable if (changed) { RosterSpawner.Replace(World, Roster); + TimetableDirty = true; } return changed; diff --git a/src/HSchool.Simulation/SchoolTimetables.cs b/src/HSchool.Simulation/SchoolTimetables.cs new file mode 100644 index 0000000..60337c3 --- /dev/null +++ b/src/HSchool.Simulation/SchoolTimetables.cs @@ -0,0 +1,31 @@ +using HSchool.Content; +using HSchool.People; +using HSchool.Schedule; + +namespace HSchool.Simulation; + +/// Turns a school's roster into planner input and back. The worker calls this, not the tick. +public static class SchoolTimetables +{ + public static Timetable Build( + DefCatalog catalog, + MapLayout map, + Roster roster, + IReadOnlyList? locked, + int weekDays) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(map); + ArgumentNullException.ThrowIfNull(roster); + + var classes = roster.Classes + .Select(item => new PlannerClass(item.Id, item.Year, item.Letter, item.RoomId, item.PupilIds.Count)) + .ToArray(); + var teachers = roster.People + .Where(person => person.IsStaff && person.Subjects.Count > 0) + .Select(person => new PlannerTeacher(person.Id, person.Subjects)) + .ToArray(); + + return TimetablePlanner.Build(catalog, map, classes, teachers, locked, weekDays); + } +} diff --git a/tests/HSchool.AppHost.Tests/GameSocketTests.cs b/tests/HSchool.AppHost.Tests/GameSocketTests.cs index ac5d862..5f5b1b7 100644 --- a/tests/HSchool.AppHost.Tests/GameSocketTests.cs +++ b/tests/HSchool.AppHost.Tests/GameSocketTests.cs @@ -1,3 +1,4 @@ +using System.Net.Http.Json; using System.Net.WebSockets; using HSchool.Protocol; @@ -110,6 +111,8 @@ 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] @@ -157,6 +160,40 @@ public class GameSocketTests(AppHostFixture fixture) Assert.DoesNotContain(snapshot.Nodes, node => node.Id == "corridor-1"); } + [Fact] + public async Task OpeningASchoolDuringAMathLesson_PutsOccupancyOnTheRoom() + { + 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); + + var staffing = await client.GetFromJsonAsync( + $"/api/schools/{school.Id}/staffing", + TestContext.Current.CancellationToken); + Assert.NotNull(staffing); + var applicant = staffing.Applicants[0]; + using var hire = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/staff/hire", + new { personId = applicant.Id, position = "Teacher" }, + TestContext.Current.CancellationToken); + hire.EnsureSuccessStatusCode(); + using var assign = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(applicant.Id)}/subjects", + new { subject = "Mathematics" }, + 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); + + Assert.Equal("Математика", occupied.ActivitySubject); + Assert.NotEmpty(occupied.ActivityClass); + Assert.Contains(applicant.FullName, occupied.Present); + Assert.True(occupied.Present.Count > 1); + } + [Fact] public async Task Pausing_FreezesTheClock() { @@ -448,7 +485,8 @@ public class GameSocketTests(AppHostFixture fixture) using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); cts.CancelAfter(timeout ?? DefaultTimeout); - var buffer = new byte[ProtocolConstants.MaxMessageSize]; + var buffer = new byte[64 * 1024]; + var chunks = new List(); while (true) { @@ -467,11 +505,22 @@ public class GameSocketTests(AppHostFixture fixture) throw new InvalidOperationException($"Socket closed while waiting for {expected}: {socket.CloseStatus}."); } - var frame = buffer[..result.Count]; + chunks.AddRange(buffer.AsSpan(0, result.Count).ToArray()); + if (!result.EndOfMessage) + { + continue; + } + + var frame = chunks.ToArray(); + chunks.Clear(); if (ProtocolCodec.PeekMessageType(frame) == expected) { return frame; } } } + + private sealed record StaffingSnapshot(IReadOnlyList Applicants); + + private sealed record ApplicantSnapshot(string Id, string FullName); } diff --git a/tests/HSchool.AppHost.Tests/TimetableApiTests.cs b/tests/HSchool.AppHost.Tests/TimetableApiTests.cs new file mode 100644 index 0000000..33e8553 --- /dev/null +++ b/tests/HSchool.AppHost.Tests/TimetableApiTests.cs @@ -0,0 +1,217 @@ +using System.Net.Http.Json; + +namespace HSchool.AppHost.Tests; + +[Collection(AppHostCollection.Name)] +public class TimetableApiTests(AppHostFixture fixture) +{ + private static readonly DateTime TuesdayMorning = new(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc); + private static readonly DateTime SaturdayMorning = new(2012, 4, 7, 10, 20, 0, DateTimeKind.Utc); + + [Fact] + public async Task GetTimetable_UnknownSchool_IsNotFound() + { + using var client = fixture.App.CreateHttpClient("server"); + + using var response = await client.GetAsync("/api/schools/999999/timetable", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("unknown-school", await ProblemCodeAsync(response)); + } + + [Fact] + public async Task HireMathematics_PlacesLessons_UnassignUncoversThem() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Расписание наём", TuesdayMorning); + var teacher = await HireMathAsync(client, school.Id); + + var table = await GetTimetableAsync(client, school.Id); + Assert.Equal(5, table.WeekDays); + Assert.Equal(7, table.LessonCount); + Assert.Contains(table.Lessons, lesson => lesson.Subject == "Mathematics" && lesson.TeacherId == teacher); + Assert.DoesNotContain(table.Uncovered, row => row.Subject == "Mathematics"); + + using var unassign = await client.DeleteAsync( + $"/api/schools/{school.Id}/staff/{Uri.EscapeDataString(teacher)}/subjects/Mathematics", + TestContext.Current.CancellationToken); + unassign.EnsureSuccessStatusCode(); + + var after = await GetTimetableAsync(client, school.Id); + Assert.DoesNotContain(after.Lessons, lesson => lesson.Subject == "Mathematics"); + Assert.Contains(after.Uncovered, row => row.Subject == "Mathematics"); + } + + [Fact] + public async Task PinThenHireAnother_KeepsTheLockedSlot() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Расписание закрепление", TuesdayMorning); + var first = await HireMathAsync(client, school.Id); + + var table = await GetTimetableAsync(client, school.Id); + var pinned = table.Lessons.First(lesson => lesson.Subject == "Mathematics"); + + using var pin = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/timetable/pin", + new { pinned.ClassId, pinned.Subject, pinned.RoomId, pinned.Day, pinned.Period }, + TestContext.Current.CancellationToken); + pin.EnsureSuccessStatusCode(); + + var staffing = await GetStaffingAsync(client, school.Id); + var secondApplicant = staffing.Applicants[0]; + await HireAsync(client, school.Id, secondApplicant.Id, "Teacher"); + await AssignAsync(client, school.Id, secondApplicant.Id, "Mathematics"); + + var after = await GetTimetableAsync(client, school.Id); + Assert.Contains( + after.Lessons, + lesson => + lesson.ClassId == pinned.ClassId + && lesson.Subject == pinned.Subject + && lesson.TeacherId == first + && lesson.RoomId == pinned.RoomId + && lesson.Day == pinned.Day + && lesson.Period == pinned.Period + && lesson.Locked); + + using var gym = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/timetable/pin", + new { pinned.ClassId, subject = "Mathematics", roomId = "gym-hall", day = 0, period = 1 }, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Conflict, gym.StatusCode); + Assert.Equal("pin-rejected", await ProblemCodeAsync(gym)); + } + + [Fact] + public async Task Reload_RestoresLockedLessons() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Расписание диск", TuesdayMorning); + await HireMathAsync(client, school.Id); + + var table = await GetTimetableAsync(client, school.Id); + var pinned = table.Lessons.First(lesson => lesson.Subject == "Mathematics"); + using var pin = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/timetable/pin", + new { pinned.ClassId, pinned.Subject, pinned.RoomId, pinned.Day, pinned.Period }, + TestContext.Current.CancellationToken); + pin.EnsureSuccessStatusCode(); + + using var reload = await client.PostAsync("/api/dev/reload-schools", content: null, TestContext.Current.CancellationToken); + reload.EnsureSuccessStatusCode(); + + var restored = await GetTimetableAsync(client, school.Id); + Assert.Contains( + restored.Lessons, + lesson => + lesson.ClassId == pinned.ClassId + && lesson.Subject == pinned.Subject + && lesson.RoomId == pinned.RoomId + && lesson.Day == pinned.Day + && lesson.Period == pinned.Period + && lesson.Locked); + } + + [Fact] + public async Task Saturday_HasNoOccupancyOnTheMap() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Расписание суббота", SaturdayMorning); + await HireMathAsync(client, school.Id); + + var table = await GetTimetableAsync(client, school.Id); + Assert.Contains(table.Lessons, lesson => lesson.Subject == "Mathematics"); + } + + private static async Task HireMathAsync(HttpClient client, int schoolId) + { + var staffing = await GetStaffingAsync(client, schoolId); + var applicant = staffing.Applicants[0]; + await HireAsync(client, schoolId, applicant.Id, "Teacher"); + await AssignAsync(client, schoolId, applicant.Id, "Mathematics"); + return applicant.Id; + } + + private static async Task GetTimetableAsync(HttpClient client, int schoolId) + { + var table = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/timetable?lang=ru", + TestContext.Current.CancellationToken); + Assert.NotNull(table); + return table; + } + + private static async Task GetStaffingAsync(HttpClient client, int schoolId) + { + var staffing = await client.GetFromJsonAsync( + $"/api/schools/{schoolId}/staffing?lang=ru", + TestContext.Current.CancellationToken); + Assert.NotNull(staffing); + return staffing; + } + + private static async Task HireAsync(HttpClient client, int schoolId, string personId, string position) + { + using var response = await client.PostAsJsonAsync( + $"/api/schools/{schoolId}/staff/hire", + new { personId, position }, + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + } + + private static async Task AssignAsync(HttpClient client, int schoolId, string personId, string subject) + { + using var response = await client.PostAsJsonAsync( + $"/api/schools/{schoolId}/staff/{Uri.EscapeDataString(personId)}/subjects", + new { subject }, + TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + } + + private static async Task ProblemCodeAsync(HttpResponseMessage response) + { + var problem = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + return problem?.Code; + } + + private sealed record ProblemResponse(string? Code); + + private sealed record TimetableResponse( + int WeekDays, + int LessonCount, + IReadOnlyList Lessons, + IReadOnlyList Uncovered); + + private sealed record LessonResponse( + string ClassId, + int ClassYear, + string ClassLetter, + string Subject, + string SubjectLabel, + string TeacherId, + string TeacherName, + string RoomId, + int Day, + int Period, + bool Locked); + + private sealed record UncoveredResponse( + string ClassId, + int ClassYear, + string ClassLetter, + string Subject, + string SubjectLabel, + int Hours); + + private sealed record StaffingResponse( + IReadOnlyList Applicants, + IReadOnlyList Staff); + + private sealed record ApplicantResponse(string Id); + + private sealed record StaffMemberResponse(string Id); +} diff --git a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs index 0f41ca9..0fe4e6e 100644 --- a/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs +++ b/tests/HSchool.Protocol.Tests/ProtocolCodecTests.cs @@ -164,6 +164,34 @@ 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() + { + 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 length = ProtocolCodec.WriteMapSnapshot(buffer, message); + var read = ProtocolCodec.ReadMapSnapshot(buffer.AsSpan(0, length)); + + Assert.Equal("Математика", read.Nodes[0].ActivitySubject); + Assert.Equal("5А", read.Nodes[0].ActivityClass); + Assert.Equal(["Иванова Ольга Михайловна", "Соколов Иван Петрович"], read.Nodes[0].Present); } [Fact] diff --git a/tests/HSchool.Schedule.Tests/TimetableClockTests.cs b/tests/HSchool.Schedule.Tests/TimetableClockTests.cs new file mode 100644 index 0000000..07182c6 --- /dev/null +++ b/tests/HSchool.Schedule.Tests/TimetableClockTests.cs @@ -0,0 +1,57 @@ +using HSchool.Content; + +namespace HSchool.Schedule.Tests; + +public class TimetableClockTests +{ + private readonly DefCatalog _catalog = Fixtures.Catalog(); + private static readonly LessonPlacement MathOnTuesdayPeriod3 = new( + "c5A", + "Mathematics", + "t1", + "classroom-101", + Day: 1, + Period: 3); + + [Fact] + public void TuesdayTenTwenty_IsThePlacedLesson() + { + var table = new Timetable([MathOnTuesdayPeriod3], []); + var occurring = TimetableClock.OccurringAt( + table, + _catalog, + new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc), + weekDays: 5); + + Assert.Equal([MathOnTuesdayPeriod3], occurring); + } + + [Fact] + public void TuesdayTenFifteen_IsABreakAndEmpty() + { + var table = new Timetable([MathOnTuesdayPeriod3], []); + var occurring = TimetableClock.OccurringAt( + table, + _catalog, + new DateTime(2012, 4, 3, 10, 15, 0, DateTimeKind.Utc), + weekDays: 5); + + Assert.Empty(occurring); + Assert.Equal( + new OccupancyKey(new DateOnly(2012, 4, 3), DaySlotKind.Break, 2), + TimetableClock.Key(_catalog, new DateTime(2012, 4, 3, 10, 15, 0, DateTimeKind.Utc), 5)); + } + + [Fact] + public void Saturday_IsEmpty() + { + var table = new Timetable([MathOnTuesdayPeriod3], []); + var occurring = TimetableClock.OccurringAt( + table, + _catalog, + new DateTime(2012, 4, 7, 10, 20, 0, DateTimeKind.Utc), + weekDays: 5); + + Assert.Empty(occurring); + } +}