Update wire protocol to version 6 and enhance timetable functionality
ci / server (push) Failing after 3m53s
ci / client (push) Successful in 15s

- 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.
This commit is contained in:
Leonid Pershin
2026-08-19 10:05:05 +03:00
parent 2011d12b1d
commit cad3068bac
30 changed files with 1621 additions and 43 deletions
+8 -8
View File
@@ -11,17 +11,17 @@
## Задачи ## Задачи
- [ ] Расписание пересобирается при найме, увольнении и изменении назначений — **не по тику** - [x] Расписание пересобирается при найме, увольнении и изменении назначений — **не по тику**
- [ ] Хранится в сейве рядом с составом; пишется при изменении, не по таймеру. Закреплённые - [x] Хранится в сейве рядом с составом; пишется при изменении, не по таймеру. Закреплённые
игроком уроки сохраняются вместе с ним игроком уроки сохраняются вместе с ним
- [ ] Воркер публикует расписание снимком, как ростер - [x] Воркер публикует расписание снимком, как ростер
- [ ] «Кто где сейчас» **вычисляется** из расписания и часов, а не хранится вторым состоянием - [x] «Кто где сейчас» **вычисляется** из расписания и часов, а не хранится вторым состоянием
- [ ] Команда закрепления и снятия закрепления урока — через мейлбокс воркера - [x] Команда закрепления и снятия закрепления урока — через мейлбокс воркера
- [ ] `GET /api/schools/{id}/timetable` — расписание класса и расписание человека; читает - [x] `GET /api/schools/{id}/timetable` — расписание класса и расписание человека; читает
опубликованный снимок опубликованный снимок
- [ ] Снимок карты отдаёт по узлу, что там сейчас идёт и у кого; протокол и `docs/protocol.md` - [x] Снимок карты отдаёт по узлу, что там сейчас идёт и у кого; протокол и `docs/protocol.md`
правятся тем же коммитом, версия бумпится правятся тем же коммитом, версия бумпится
- [ ] Панель локации получает данные для «Сейчас» и «Персонажей» - [x] Панель локации получает данные для «Сейчас» и «Персонажей»
## Критерий готовности ## Критерий готовности
+1 -1
View File
@@ -66,5 +66,5 @@
| --- | --- | --- | | --- | --- | --- |
| [14. Каркас дня и каникулы](14-school-calendar.md) | ✅ | Звонки, длина недели, каникулы | | [14. Каркас дня и каникулы](14-school-calendar.md) | ✅ | Звонки, длина недели, каникулы |
| [15. Планировщик](15-timetable-planner.md) | ✅ | Раскладка часов по слотам, четыре запрета | | [15. Планировщик](15-timetable-planner.md) | ✅ | Раскладка часов по слотам, четыре запрета |
| [16. Расписание в школе](16-timetable-in-school.md) | | Сейв, снимок, «кто где сейчас» | | [16. Расписание в школе](16-timetable-in-school.md) | | Сейв, снимок, «кто где сейчас» |
| [17. Расписание на экране](17-timetable-screen.md) | ⬜ | Скобки в дереве, сетка класса, расписание учителя | | [17. Расписание на экране](17-timetable-screen.md) | ⬜ | Скобки в дереве, сетка класса, расписание учителя |
+72 -13
View File
@@ -1,13 +1,13 @@
# Wire protocol v5 # Wire protocol v6
The client talks to the server two ways: The client talks to the server two ways:
- **HTTP/JSON** for the main menu and the in-school people browser — listing, creating and - **HTTP/JSON** for the main menu and the in-school people browser — listing, creating and
deleting schools, listing mods, loading a catalog for the create editor, and reading a school's deleting schools, listing mods, loading a catalog for the create editor, reading a school's
roster (filtered list + one-person card). Those are request/response by nature, so they are roster (filtered list + one-person card), staffing, and the timetable. Those are request/response
plain REST. by nature, so they are plain REST.
- **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz) and the one-shot map - **A binary WebSocket at `/ws/game`** for the school calendar (20 Hz) and the map snapshot
snapshot sent when a school is opened. 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 This document covers both. One protocol message per WebSocket frame, no framing header beyond the
message id. **All multi-byte numbers are little-endian.** message id. **All multi-byte numbers are little-endian.**
@@ -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 Removes one assignment. Payroll drops when the subject was not the only one. Unknown
assignment is `404` `unknown-assignment`. 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 ## WebSocket message ids
Client-to-server ids live in `0x000x7F`, server-to-client ids in `0x800xFF`, so a misrouted Client-to-server ids live in `0x000x7F`, server-to-client ids in `0x800xFF`, so a misrouted
@@ -452,9 +508,10 @@ client returns to the menu.
### `0x85` MapSnapshot — variable ### `0x85` MapSnapshot — variable
Sent once when a school is opened (and again on reconnect OpenSchool). Not every tick, not on Sent when a school is opened, on reconnect OpenSchool, and again when the current lesson slot
tree clicks. Labels are in the Hello locale. People and in-place activities are omitted — the changes (a bell, a weekend, a holiday — not every tick, not on tree clicks). Labels are in the
client keeps those sections empty. Hello locale. Occupancy is computed from the timetable and the clock; the client must not
derive it.
Strings are `u16` byte length + UTF-8. Empty string is a zero length. Strings are `u16` byte length + UTF-8. Empty string is a zero length.
@@ -476,8 +533,10 @@ Each node:
| `u16` | pupil slots — how many pupils can take a lesson here. Summed from things on the server. | | `u16` | pupil slots — how many pupils can take a lesson here. Summed from things on the server. |
| `u8` | item count, then that many records of: string name + `u8` count | | `u8` | item count, then that many records of: string name + `u8` count |
| `u8` | position count, then that many strings | | `u8` | position count, then that many strings |
| `u8` | `1` if a lesson is in this room right now, then subject label + class label strings; `0` if free |
| `u8` | character count, then that many name strings (teacher and pupils of the lesson) |
Item `count` is how many of that thing stand in the room (`Парта ×16` is one record, not sixteen). The client must not recompute pupil slots from items. 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 ## 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 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 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 Authentication, Sit orders, an event log, walking, and `OpenLocation` on the server — the tree is
on the client from the snapshot. The school's ECS world is created but still empty. filtered on the client from the snapshot.
+77 -2
View File
@@ -1,6 +1,6 @@
/** /**
* HTTP side of the server: the main menu, the in-school people list/card, and staffing. The * HTTP side of the server: the main menu, the in-school people list/card, staffing and the
* realtime clock arrives over the WebSocket instead — see `connection.ts`. * timetable. The realtime clock arrives over the WebSocket instead — see `connection.ts`.
*/ */
export interface School { 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<Timetable> {
const params = new URLSearchParams({ lang });
if (filters.classId) {
params.set('classId', filters.classId);
}
if (filters.personId) {
params.set('personId', filters.personId);
}
return request<Timetable>(`/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<Timetable> {
const params = new URLSearchParams({ lang });
return request<Timetable>(`/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<Timetable> {
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<Timetable>(`/api/schools/${schoolId}/timetable/pin?${params.toString()}`, {
method: 'DELETE',
});
}
async function request<T>( async function request<T>(
url: string, url: string,
init?: RequestInit, init?: RequestInit,
+118 -3
View File
@@ -129,7 +129,7 @@ describe('decodeServerMessage', () => {
const id = encoder.encode('yard'); const id = encoder.encode('yard');
const parent = encoder.encode(''); const parent = encoder.encode('');
const name = encoder.encode('Двор'); const name = encoder.encode('Двор');
const buffer = new ArrayBuffer(7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 1); 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); const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerMapSnapshot); view.setUint8(0, MessageType.ServerMapSnapshot);
view.setInt32(1, 7, true); view.setInt32(1, 7, true);
@@ -152,12 +152,27 @@ describe('decodeServerMessage', () => {
view.setUint8(offset, 0); view.setUint8(offset, 0);
offset += 1; offset += 1;
view.setUint8(offset, 0); view.setUint8(offset, 0);
offset += 1;
view.setUint8(offset, 0);
offset += 1;
view.setUint8(offset, 0);
expect(decodeServerMessage(buffer)).toEqual({ expect(decodeServerMessage(buffer)).toEqual({
type: 'map-snapshot', type: 'map-snapshot',
schoolId: 7, schoolId: 7,
nodes: [ 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 name = encoder.encode('Класс 1A');
const itemName = encoder.encode('Парта'); const itemName = encoder.encode('Парта');
const buffer = new ArrayBuffer( const buffer = new ArrayBuffer(
7 + 1 + 2 + id.length + 2 + parent.length + 2 + name.length + 2 + 1 + 2 + itemName.length + 1 + 1, 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); const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerMapSnapshot); view.setUint8(0, MessageType.ServerMapSnapshot);
@@ -201,6 +216,10 @@ describe('decodeServerMessage', () => {
view.setUint8(offset, 16); view.setUint8(offset, 16);
offset += 1; offset += 1;
view.setUint8(offset, 0); view.setUint8(offset, 0);
offset += 1;
view.setUint8(offset, 0);
offset += 1;
view.setUint8(offset, 0);
expect(decodeServerMessage(buffer)).toEqual({ expect(decodeServerMessage(buffer)).toEqual({
type: 'map-snapshot', type: 'map-snapshot',
@@ -214,6 +233,102 @@ describe('decodeServerMessage', () => {
pupilSlots: 16, pupilSlots: 16,
items: [{ name: 'Парта', count: 16 }], items: [{ name: 'Парта', count: 16 }],
positions: [], 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: ['Иванова'],
}, },
], ],
}); });
+29 -1
View File
@@ -5,7 +5,7 @@
* changed together and documented in `docs/protocol.md`. All numbers are little-endian. * changed together and documented in `docs/protocol.md`. All numbers are little-endian.
*/ */
export const PROTOCOL_VERSION = 5; export const PROTOCOL_VERSION = 6;
export const MessageType = { export const MessageType = {
ClientHello: 0x01, ClientHello: 0x01,
@@ -82,6 +82,9 @@ export interface MapSnapshotNode {
readonly pupilSlots: number; readonly pupilSlots: number;
readonly items: readonly MapSnapshotItem[]; readonly items: readonly MapSnapshotItem[];
readonly positions: readonly string[]; readonly positions: readonly string[];
readonly activitySubject: string;
readonly activityClass: string;
readonly characters: readonly string[];
} }
export interface MapSnapshotMessage { export interface MapSnapshotMessage {
@@ -265,6 +268,28 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage {
offset = value.next; 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({ nodes.push({
kind, kind,
id: id.text, id: id.text,
@@ -273,6 +298,9 @@ function decodeMapSnapshot(view: DataView): MapSnapshotMessage {
pupilSlots, pupilSlots,
items, items,
positions, positions,
activitySubject,
activityClass,
characters,
}); });
} }
+14 -2
View File
@@ -43,8 +43,10 @@ export class GameScreen {
private readonly pupilSlotsLine = el('p', { class: 'panel__meta' }); private readonly pupilSlotsLine = el('p', { class: 'panel__meta' });
private readonly charactersHeading = el('h3', { class: 'panel__section-title' }); private readonly charactersHeading = el('h3', { class: 'panel__section-title' });
private readonly charactersEmpty = el('p', { class: 'panel__empty' }); 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 activitiesHeading = el('h3', { class: 'panel__section-title' });
private readonly activitiesEmpty = el('p', { class: 'panel__empty' }); 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 positionsHeading = el('h3', { class: 'panel__section-title' });
private readonly positionsEmpty = el('p', { class: 'panel__empty' }); private readonly positionsEmpty = el('p', { class: 'panel__empty' });
private readonly positionsList = el('ul', { class: 'panel__list' }); private readonly positionsList = el('ul', { class: 'panel__list' });
@@ -118,8 +120,8 @@ export class GameScreen {
this.locationBody.append( this.locationBody.append(
this.locationName, this.locationName,
el('div', { class: 'panel__section' }, this.itemsHeading, this.itemsEmpty, this.itemsList, this.pupilSlotsLine), 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.charactersHeading, this.charactersEmpty, this.charactersList),
el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty), el('div', { class: 'panel__section' }, this.activitiesHeading, this.activitiesEmpty, this.activitiesList),
el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList), el('div', { class: 'panel__section' }, this.positionsHeading, this.positionsEmpty, this.positionsList),
); );
@@ -181,6 +183,10 @@ export class GameScreen {
this.showMode('overview'); 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 { applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void {
if (this.schoolId !== schoolId) { if (this.schoolId !== schoolId) {
return; return;
@@ -281,6 +287,12 @@ export class GameScreen {
const pupilSlots = node?.pupilSlots ?? 0; const pupilSlots = node?.pupilSlots ?? 0;
this.pupilSlotsLine.hidden = pupilSlots <= 0; this.pupilSlotsLine.hidden = pupilSlots <= 0;
this.pupilSlotsLine.textContent = pupilSlots > 0 ? t('pupilSlots', { count: pupilSlots }) : ''; this.pupilSlotsLine.textContent = pupilSlots > 0 ? t('pupilSlots', { count: pupilSlots }) : '';
const activity =
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 ?? []); paintList(this.positionsList, this.positionsEmpty, node?.positions ?? []);
} }
+4
View File
@@ -102,6 +102,10 @@ public static class SchoolDay
return mondayBased < weekDays; return mondayBased < weekDays;
} }
/// <summary>Monday = 0 … Sunday = 6, same numbering the timetable uses.</summary>
public static int WeekdayIndex(DateTime time) =>
((int)DateTime.SpecifyKind(time, DateTimeKind.Utc).DayOfWeek + 6) % 7;
private static bool IsHoliday(DefCatalog catalog, DateTime time) private static bool IsHoliday(DefCatalog catalog, DateTime time)
{ {
foreach (var holiday in catalog.Holidays.Values) foreach (var holiday in catalog.Holidays.Values)
+11 -3
View File
@@ -46,6 +46,8 @@ public readonly record struct ServerSchoolGoneMessage(int SchoolId);
/// <paramref name="ParentId"/> is empty for the yard. /// <paramref name="ParentId"/> is empty for the yard.
/// <paramref name="PupilSlots"/> is how many pupils can take a lesson here — summed from things /// <paramref name="PupilSlots"/> is how many pupils can take a lesson here — summed from things
/// on the server, not by the client. /// on the server, not by the client.
/// <paramref name="ActivitySubject"/> and <paramref name="ActivityClass"/> are empty when the
/// room is free. <paramref name="Characters"/> are the people the timetable puts there right now.
/// </summary> /// </summary>
public sealed record MapSnapshotNode( public sealed record MapSnapshotNode(
byte Kind, byte Kind,
@@ -54,13 +56,19 @@ public sealed record MapSnapshotNode(
string Name, string Name,
ushort PupilSlots, ushort PupilSlots,
IReadOnlyList<MapSnapshotItem> Items, IReadOnlyList<MapSnapshotItem> Items,
IReadOnlyList<string> Positions); IReadOnlyList<string> Positions,
string ActivitySubject = "",
string ActivityClass = "",
IReadOnlyList<string>? Characters = null)
{
public IReadOnlyList<string> Present => Characters ?? [];
}
/// <summary>One stacked thing in a room. <paramref name="Count"/> is 1255.</summary> /// <summary>One stacked thing in a room. <paramref name="Count"/> is 1255.</summary>
public sealed record MapSnapshotItem(string Name, byte Count); public sealed record MapSnapshotItem(string Name, byte Count);
/// <summary> /// <summary>
/// One school's map, labelled in the Hello locale. Sent once when that school is opened, not every tick. /// One school's map, labelled in the Hello locale. Sent when that school is opened and again
/// People and in-place activities are omitted — the client keeps those sections empty. /// when the current lesson slot changes. Occupancy is computed from the timetable and the clock.
/// </summary> /// </summary>
public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList<MapSnapshotNode> Nodes); public sealed record ServerMapSnapshotMessage(int SchoolId, IReadOnlyList<MapSnapshotNode> Nodes);
+61 -3
View File
@@ -129,6 +129,18 @@ public static class ProtocolCodec
{ {
size += StringSize(position); size += StringSize(position);
} }
size += sizeof(byte);
if (HasActivity(node))
{
size += StringSize(node.ActivitySubject) + StringSize(node.ActivityClass);
}
size += sizeof(byte);
foreach (var person in node.Present)
{
size += StringSize(person);
}
} }
return size; return size;
@@ -148,9 +160,9 @@ public static class ProtocolCodec
foreach (var node in message.Nodes) foreach (var node in message.Nodes)
{ {
if (node.Items.Count > byte.MaxValue || node.Positions.Count > byte.MaxValue) 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); writer.WriteByte(node.Kind);
@@ -170,6 +182,23 @@ public static class ProtocolCodec
{ {
writer.WriteString(position); 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; return writer.Position;
@@ -283,12 +312,41 @@ public static class ProtocolCodec
positions[position] = reader.ReadString(); 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); return new ServerMapSnapshotMessage(schoolId, nodes);
} }
private static bool HasActivity(MapSnapshotNode node) =>
node.ActivitySubject.Length > 0 || node.ActivityClass.Length > 0;
/// <summary>Matches <see cref="PacketWriter.WriteString"/>: a <c>u16</c> length plus UTF-8.</summary> /// <summary>Matches <see cref="PacketWriter.WriteString"/>: a <c>u16</c> length plus UTF-8.</summary>
private static int StringSize(string value) => sizeof(ushort) + Encoding.UTF8.GetByteCount(value); private static int StringSize(string value) => sizeof(ushort) + Encoding.UTF8.GetByteCount(value);
+1 -1
View File
@@ -4,7 +4,7 @@ namespace HSchool.Protocol;
public static class ProtocolConstants public static class ProtocolConstants
{ {
/// <summary>Bumped on every breaking change to the binary layout.</summary> /// <summary>Bumped on every breaking change to the binary layout.</summary>
public const byte Version = 5; public const byte Version = 6;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary> /// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 8 * 1024; public const int MaxMessageSize = 8 * 1024;
+38
View File
@@ -0,0 +1,38 @@
using HSchool.Content;
namespace HSchool.Schedule;
/// <summary>
/// Which lessons are happening at a game instant. Breaks, nights, weekends and holidays are empty
/// — occupancy is derived, never stored.
/// </summary>
public static class TimetableClock
{
public static IReadOnlyList<LessonPlacement> 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);
@@ -8,6 +8,7 @@ namespace HSchool.Server.Api;
/// <summary> /// <summary>
/// The main menu talks to these: list, create, delete. People list and staffing read published /// 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. /// 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.
/// </summary> /// </summary>
internal static class SchoolEndpoints internal static class SchoolEndpoints
{ {
@@ -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<TimetableOutcome>());
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<TimetableOutcome>());
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<T> NewCompletion<T>() =>
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<string, object?> { ["code"] = code });
}
}
+134
View File
@@ -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<TimetableLessonResponse> Lessons,
IReadOnlyList<UncoveredLessonResponse> 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<string, SchoolClass> classes,
IReadOnlyDictionary<string, Person> 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<string, SchoolClass> 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);
}
}
+17
View File
@@ -69,4 +69,21 @@ internal abstract record GameCommand
string PersonId, string PersonId,
string Subject, string Subject,
TaskCompletionSource<StaffingOutcome> Result) : GameCommand; TaskCompletionSource<StaffingOutcome> Result) : GameCommand;
internal sealed record PinLesson(
int SchoolId,
string ClassId,
string Subject,
string RoomId,
int Day,
int Period,
TaskCompletionSource<TimetableOutcome> Result) : GameCommand;
internal sealed record UnpinLesson(
int SchoolId,
string ClassId,
string Subject,
int Day,
int Period,
TaskCompletionSource<TimetableOutcome> Result) : GameCommand;
} }
+35 -2
View File
@@ -1,6 +1,7 @@
using HSchool.Content; using HSchool.Content;
using HSchool.People; using HSchool.People;
using HSchool.Protocol; using HSchool.Protocol;
using HSchool.Schedule;
using HSchool.Server.Api; using HSchool.Server.Api;
using HSchool.Server.Net; using HSchool.Server.Net;
using HSchool.Simulation; using HSchool.Simulation;
@@ -64,7 +65,12 @@ internal sealed class GameLoopService(
{ {
if (worker.Id == schoolId) 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), new WorkerCommand.UnassignSubject(unassign.PersonId, unassign.Subject, unassign.Result),
unassign.Result); unassign.Result);
break; 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<TimetableOutcome> result)
{
if (!_workers.TryGetValue(schoolId, out var worker) || !worker.Post(command))
{
result.TrySetResult(TimetableOutcome.Fail(TimetableError.UnknownSchool));
}
}
/// <summary> /// <summary>
/// A school's thread died. Drop it from the table so the menu stops drawing a card whose clock /// 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 /// 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);
+93
View File
@@ -0,0 +1,93 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Protocol;
using HSchool.Schedule;
using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>
/// Overlays the current lesson onto map-tree nodes. Occupancy is derived from the timetable and
/// the clock; it is not stored on the map.
/// </summary>
internal static class MapOccupancy
{
public static void Apply(
MapSnapshotNode[] nodes,
School school,
int weekDays,
string locale)
{
if (school.Timetable is null || school.Roster is null || school.Catalog is null)
{
return;
}
var occurring = TimetableClock.OccurringAt(
school.Timetable,
school.Catalog,
school.Clock.Time,
weekDays);
if (occurring.Count == 0)
{
return;
}
var catalog = school.Catalog;
var classes = school.Roster.Classes.ToDictionary(item => item.Id, StringComparer.Ordinal);
var people = school.Roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
var byRoom = occurring.ToDictionary(lesson => lesson.RoomId, StringComparer.Ordinal);
for (var i = 0; i < nodes.Length; i++)
{
if (!byRoom.TryGetValue(nodes[i].Id, out var lesson))
{
continue;
}
classes.TryGetValue(lesson.ClassId, out var schoolClass);
var subjectLabel = catalog.Subjects.TryGetValue(lesson.Subject, out var subject)
? catalog.Label(locale, subject)
: lesson.Subject;
var classLabel = schoolClass is null
? lesson.ClassId
: $"{schoolClass.Year}{schoolClass.Letter}";
nodes[i] = nodes[i] with
{
ActivitySubject = subjectLabel,
ActivityClass = classLabel,
Characters = NamesOf(lesson, schoolClass, people),
};
}
}
private static string[] NamesOf(
LessonPlacement lesson,
SchoolClass? schoolClass,
IReadOnlyDictionary<string, Person> people)
{
var names = new List<string>();
if (people.TryGetValue(lesson.TeacherId, out var teacher))
{
names.Add(teacher.Name.Full);
}
else if (lesson.TeacherId.Length > 0)
{
names.Add(lesson.TeacherId);
}
if (schoolClass is not null)
{
foreach (var pupilId in schoolClass.PupilIds)
{
if (people.TryGetValue(pupilId, out var pupil))
{
names.Add(pupil.Name.Full);
}
}
}
return names.Count > byte.MaxValue ? [.. names.Take(byte.MaxValue)] : [.. names];
}
}
+37 -1
View File
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using HSchool.Content; using HSchool.Content;
using HSchool.People; using HSchool.People;
using HSchool.Schedule;
using HSchool.Simulation; using HSchool.Simulation;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -104,7 +105,8 @@ internal sealed class SchoolStore
{ {
var fileName = Path.GetFileName(path); var fileName = Path.GetFileName(path);
if (string.Equals(fileName, IndexFileName, StringComparison.OrdinalIgnoreCase) 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; continue;
} }
@@ -188,6 +190,12 @@ internal sealed class SchoolStore
{ {
File.Delete(people); File.Delete(people);
} }
var timetable = TimetablePath(id);
if (File.Exists(timetable))
{
File.Delete(timetable);
}
} }
public RosterDocument? TryReadPeople(int id) public RosterDocument? TryReadPeople(int id)
@@ -215,10 +223,38 @@ internal sealed class SchoolStore
WriteAtomic(PeoplePath(id), document, RosterJson.Options); 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<Timetable>(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 SchoolPath(int id) => Path.Combine(DirectoryPath, $"{id}.json");
private string PeoplePath(int id) => Path.Combine(DirectoryPath, $"{id}.people.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 string IndexPath() => Path.Combine(DirectoryPath, IndexFileName);
private static void WriteAtomic<T>(string path, T value, JsonSerializerOptions? options = null) private static void WriteAtomic<T>(string path, T value, JsonSerializerOptions? options = null)
+270 -1
View File
@@ -3,6 +3,7 @@ using System.Threading.Channels;
using HSchool.Content; using HSchool.Content;
using HSchool.People; using HSchool.People;
using HSchool.Protocol; using HSchool.Protocol;
using HSchool.Schedule;
using HSchool.Server.Api; using HSchool.Server.Api;
using HSchool.Server.Net; using HSchool.Server.Net;
using HSchool.Simulation; using HSchool.Simulation;
@@ -44,6 +45,8 @@ internal sealed class SchoolWorker
private Roster? _rosterSnapshot; private Roster? _rosterSnapshot;
private ApplicantPool? _applicantSnapshot; private ApplicantPool? _applicantSnapshot;
private DefCatalog? _catalogSnapshot; private DefCatalog? _catalogSnapshot;
private Timetable? _timetableSnapshot;
private OccupancyKey _occupancyKey;
private School? _school; private School? _school;
private Task? _run; private Task? _run;
private bool _persistOnStop = true; private bool _persistOnStop = true;
@@ -103,6 +106,9 @@ internal sealed class SchoolWorker
/// <summary>Frozen catalog for this school. Safe to read from HTTP; it never mutates after load.</summary> /// <summary>Frozen catalog for this school. Safe to read from HTTP; it never mutates after load.</summary>
public DefCatalog? CatalogSnapshot => Volatile.Read(ref _catalogSnapshot); public DefCatalog? CatalogSnapshot => Volatile.Read(ref _catalogSnapshot);
/// <summary>Last built timetable. Published like the roster — HTTP never reads the live school.</summary>
public Timetable? TimetableSnapshot => Volatile.Read(ref _timetableSnapshot);
public void Start() public void Start()
{ {
_run = Task.Factory.StartNew( _run = Task.Factory.StartNew(
@@ -284,12 +290,17 @@ internal sealed class SchoolWorker
if (peopleChanged) if (peopleChanged)
{ {
PersistPeople(); PersistPeople();
if (school.TimetableDirty)
{
RebuildTimetable(school);
}
} }
if (steps > 0) if (steps > 0)
{ {
PublishSnapshot(); PublishSnapshot();
BroadcastClock(); BroadcastClock();
MaybeBroadcastOccupancy(school);
} }
FlushSettings(); FlushSettings();
@@ -402,6 +413,16 @@ internal sealed class SchoolWorker
case WorkerCommand.UnassignSubject unassign: case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject)); unassign.Result.TrySetResult(ApplyUnassign(school, unassign.PersonId, unassign.Subject));
break; 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) catch (Exception ex)
@@ -443,6 +464,12 @@ internal sealed class SchoolWorker
case WorkerCommand.UnassignSubject unassign: case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetResult(Staffing.UnknownSchool()); unassign.Result.TrySetResult(Staffing.UnknownSchool());
break; 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: case WorkerCommand.UnassignSubject unassign:
unassign.Result.TrySetException(exception); unassign.Result.TrySetException(exception);
break; 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); school.ApplyStaffing(outcome.Roster, outcome.Pool);
PersistPeople(); PersistPeople();
PublishSnapshot(); RebuildTimetable(school);
} }
return outcome; return outcome;
@@ -531,6 +564,7 @@ internal sealed class SchoolWorker
(byte)school.Clock.SpeedIndex)); (byte)school.Clock.SpeedIndex));
Volatile.Write(ref _rosterSnapshot, school.Roster); Volatile.Write(ref _rosterSnapshot, school.Roster);
Volatile.Write(ref _applicantSnapshot, school.Applicants); Volatile.Write(ref _applicantSnapshot, school.Applicants);
Volatile.Write(ref _timetableSnapshot, school.Timetable);
} }
/// <summary> /// <summary>
@@ -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);
}
}
/// <summary>
/// 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.
/// </summary>
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) private bool InstallPeople(School school, DefCatalog catalog, MapLayout map)
{ {
var nameSetId = ResolveNameSetId(catalog, _nameSetId); var nameSetId = ResolveNameSetId(catalog, _nameSetId);
@@ -609,6 +875,7 @@ internal sealed class SchoolWorker
} }
school.InstallPeople(roster, seed, nameSetId, applicants); school.InstallPeople(roster, seed, nameSetId, applicants);
InstallTimetable(school);
return generated; return generated;
} }
@@ -708,6 +975,8 @@ internal sealed class SchoolWorker
node.Positions); node.Positions);
} }
MapOccupancy.Apply(nodes, school, _options.SchoolWeekDays, locale);
// Sized from the message, not from the inbound frame limit: a map the player enlarged in // Sized from the message, not from the inbound frame limit: a map the player enlarged in
// the create editor outgrows 8 KiB somewhere past sixty furnished rooms. // the create editor outgrows 8 KiB somewhere past sixty furnished rooms.
var message = new ServerMapSnapshotMessage(school.Id, nodes); var message = new ServerMapSnapshotMessage(school.Id, nodes);
+15
View File
@@ -37,4 +37,19 @@ internal abstract record WorkerCommand
string PersonId, string PersonId,
string Subject, string Subject,
TaskCompletionSource<StaffingOutcome> Result) : WorkerCommand; TaskCompletionSource<StaffingOutcome> Result) : WorkerCommand;
internal sealed record PinLesson(
string ClassId,
string Subject,
string RoomId,
int Day,
int Period,
TaskCompletionSource<TimetableOutcome> Result) : WorkerCommand;
internal sealed record UnpinLesson(
string ClassId,
string Subject,
int Day,
int Period,
TaskCompletionSource<TimetableOutcome> Result) : WorkerCommand;
} }
+1
View File
@@ -13,6 +13,7 @@
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" /> <ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" /> <ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
<ProjectReference Include="..\HSchool.People\HSchool.People.csproj" /> <ProjectReference Include="..\HSchool.People\HSchool.People.csproj" />
<ProjectReference Include="..\HSchool.Schedule\HSchool.Schedule.csproj" />
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" /> <ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
</ItemGroup> </ItemGroup>
+1
View File
@@ -51,6 +51,7 @@ app.UseWebSockets(new WebSocketOptions
}); });
app.MapSchoolEndpoints(); app.MapSchoolEndpoints();
app.MapTimetableEndpoints();
app.MapModEndpoints(); app.MapModEndpoints();
app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) => app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) =>
@@ -12,6 +12,7 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" /> <ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
<ProjectReference Include="..\HSchool.People\HSchool.People.csproj" /> <ProjectReference Include="..\HSchool.People\HSchool.People.csproj" />
<ProjectReference Include="..\HSchool.Schedule\HSchool.Schedule.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+17
View File
@@ -1,6 +1,7 @@
using Arch.Core; using Arch.Core;
using HSchool.Content; using HSchool.Content;
using HSchool.People; using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation; namespace HSchool.Simulation;
@@ -76,6 +77,12 @@ public sealed class School : IDisposable
/// <summary>Name pack used to generate this school's people. Needed again on 1 September.</summary> /// <summary>Name pack used to generate this school's people. Needed again on 1 September.</summary>
public string? NameSetId { get; private set; } public string? NameSetId { get; private set; }
/// <summary>Last built table. Null until the worker installs people.</summary>
public Timetable? Timetable { get; private set; }
/// <summary>True after yearly intake until the worker rebuilds around remaining locks.</summary>
public bool TimetableDirty { get; private set; }
/// <summary> /// <summary>
/// Installs a roster that already matches the map. Spawns entities; does not write to disk. /// Installs a roster that already matches the map. Spawns entities; does not write to disk.
/// </summary> /// </summary>
@@ -104,6 +111,15 @@ public sealed class School : IDisposable
Roster = roster; Roster = roster;
Applicants = applicants; Applicants = applicants;
RosterSpawner.Replace(World, roster); RosterSpawner.Replace(World, roster);
TimetableDirty = true;
}
public void SetTimetable(Timetable timetable)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentNullException.ThrowIfNull(timetable);
Timetable = timetable;
TimetableDirty = false;
} }
/// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay.</summary> /// <summary>Runs one fixed step of the school: calendar, yearly intake, applicant refresh, then need decay.</summary>
@@ -145,6 +161,7 @@ public sealed class School : IDisposable
if (changed) if (changed)
{ {
RosterSpawner.Replace(World, Roster); RosterSpawner.Replace(World, Roster);
TimetableDirty = true;
} }
return changed; return changed;
@@ -0,0 +1,31 @@
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
namespace HSchool.Simulation;
/// <summary>Turns a school's roster into planner input and back. The worker calls this, not the tick.</summary>
public static class SchoolTimetables
{
public static Timetable Build(
DefCatalog catalog,
MapLayout map,
Roster roster,
IReadOnlyList<LessonPlacement>? 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);
}
}
+51 -2
View File
@@ -1,3 +1,4 @@
using System.Net.Http.Json;
using System.Net.WebSockets; using System.Net.WebSockets;
using HSchool.Protocol; using HSchool.Protocol;
@@ -110,6 +111,8 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.Equal(16, classroom.PupilSlots); Assert.Equal(16, classroom.PupilSlots);
Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16); Assert.Contains(classroom.Items, item => item.Name == "Парта" && item.Count == 16);
Assert.DoesNotContain(classroom.Items, item => item.Name == "Стул"); Assert.DoesNotContain(classroom.Items, item => item.Name == "Стул");
Assert.Equal("", classroom.ActivitySubject);
Assert.Empty(classroom.Present);
} }
[Fact] [Fact]
@@ -157,6 +160,40 @@ public class GameSocketTests(AppHostFixture fixture)
Assert.DoesNotContain(snapshot.Nodes, node => node.Id == "corridor-1"); 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<StaffingSnapshot>(
$"/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] [Fact]
public async Task Pausing_FreezesTheClock() public async Task Pausing_FreezesTheClock()
{ {
@@ -448,7 +485,8 @@ public class GameSocketTests(AppHostFixture fixture)
using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
cts.CancelAfter(timeout ?? DefaultTimeout); cts.CancelAfter(timeout ?? DefaultTimeout);
var buffer = new byte[ProtocolConstants.MaxMessageSize]; var buffer = new byte[64 * 1024];
var chunks = new List<byte>();
while (true) while (true)
{ {
@@ -467,11 +505,22 @@ public class GameSocketTests(AppHostFixture fixture)
throw new InvalidOperationException($"Socket closed while waiting for {expected}: {socket.CloseStatus}."); 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) if (ProtocolCodec.PeekMessageType(frame) == expected)
{ {
return frame; return frame;
} }
} }
} }
private sealed record StaffingSnapshot(IReadOnlyList<ApplicantSnapshot> Applicants);
private sealed record ApplicantSnapshot(string Id, string FullName);
} }
@@ -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<string> 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<TimetableResponse> GetTimetableAsync(HttpClient client, int schoolId)
{
var table = await client.GetFromJsonAsync<TimetableResponse>(
$"/api/schools/{schoolId}/timetable?lang=ru",
TestContext.Current.CancellationToken);
Assert.NotNull(table);
return table;
}
private static async Task<StaffingResponse> GetStaffingAsync(HttpClient client, int schoolId)
{
var staffing = await client.GetFromJsonAsync<StaffingResponse>(
$"/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<string?> ProblemCodeAsync(HttpResponseMessage response)
{
var problem = await response.Content.ReadFromJsonAsync<ProblemResponse>(TestContext.Current.CancellationToken);
return problem?.Code;
}
private sealed record ProblemResponse(string? Code);
private sealed record TimetableResponse(
int WeekDays,
int LessonCount,
IReadOnlyList<LessonResponse> Lessons,
IReadOnlyList<UncoveredResponse> 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<ApplicantResponse> Applicants,
IReadOnlyList<StaffMemberResponse> Staff);
private sealed record ApplicantResponse(string Id);
private sealed record StaffMemberResponse(string Id);
}
@@ -164,6 +164,34 @@ public class ProtocolCodecTests
Assert.Equal(0, read.Nodes[0].PupilSlots); Assert.Equal(0, read.Nodes[0].PupilSlots);
Assert.Equal([new MapSnapshotItem("Стул", 2)], read.Nodes[1].Items); Assert.Equal([new MapSnapshotItem("Стул", 2)], read.Nodes[1].Items);
Assert.Equal(["Директор"], read.Nodes[1].Positions); Assert.Equal(["Директор"], read.Nodes[1].Positions);
Assert.Equal("", read.Nodes[0].ActivitySubject);
Assert.Empty(read.Nodes[0].Present);
}
[Fact]
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] [Fact]
@@ -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);
}
}