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.
This commit is contained in:
@@ -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] Панель локации получает данные для «Сейчас» и «Персонажей»
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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 `0x00–0x7F`, server-to-client ids in `0x80–0xFF`, so a misrouted
|
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
|
### `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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: ['Иванова'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 1–255.</summary>
|
/// <summary>One stacked thing in a room. <paramref name="Count"/> is 1–255.</summary>
|
||||||
public sealed record MapSnapshotItem(string Name, byte Count);
|
public sealed record MapSnapshotItem(string Name, byte Count);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One school's map, labelled in the Hello locale. Sent 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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user