Enhance school simulation management by introducing a new SchoolWeekDays option, allowing configuration of the number of working days in a week. Update the API to include school week days in responses and enhance documentation to reflect these changes. Revise UI components to support the new management features, ensuring a seamless user experience when hiring and assigning staff. Update localization strings for improved clarity and consistency across the application.
This commit is contained in:
@@ -120,6 +120,7 @@ Simulation tunables live under the `Simulation` section of
|
|||||||
| `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick |
|
| `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick |
|
||||||
| `MinSaveIntervalMilliseconds` | 1000 | shortest gap between saves caused by pause or speed |
|
| `MinSaveIntervalMilliseconds` | 1000 | shortest gap between saves caused by pause or speed |
|
||||||
| `MonthlyPayrollCap` | 10000 | monthly payroll the player may commit; hire and extra subjects that would exceed it are rejected |
|
| `MonthlyPayrollCap` | 10000 | monthly payroll the player may commit; hire and extra subjects that would exceed it are rejected |
|
||||||
|
| `SchoolWeekDays` | 5 | working days from Monday (5 is Mon–Fri; 6 adds Saturday) |
|
||||||
|
|
||||||
## What is deliberately missing
|
## What is deliberately missing
|
||||||
|
|
||||||
|
|||||||
@@ -11,16 +11,16 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Под часами появляются два верхних раздела: «Обзор» и «Управление». В «Обзоре» — всё, что
|
- [x] Под часами появляются два верхних раздела: «Обзор» и «Управление». В «Обзоре» — всё, что
|
||||||
есть сейчас; часы и дата видны в обоих
|
есть сейчас; часы и дата видны в обоих
|
||||||
- [ ] Строка денег: выделено, расписано, свободно
|
- [x] Строка денег: выделено, расписано, свободно
|
||||||
- [ ] Список непокрытых предметов — что школа не может преподавать
|
- [x] Список непокрытых предметов — что школа не может преподавать
|
||||||
- [ ] Список соискателей: имя, запрашиваемая ставка, сильные навыки; выбор показывает карточку
|
- [x] Список соискателей: имя, запрашиваемая ставка, сильные навыки; выбор показывает карточку
|
||||||
человека справа — ту же, что в «Людях»
|
человека справа — ту же, что в «Людях»
|
||||||
- [ ] Кнопка найма; отказ по пределу показывается текстом, а не молчанием
|
- [x] Кнопка найма; отказ по пределу показывается текстом, а не молчанием
|
||||||
- [ ] Список штата: кто нанят, какие предметы ведёт, сколько стоит
|
- [x] Список штата: кто нанят, какие предметы ведёт, сколько стоит
|
||||||
- [ ] Назначение и снятие предмета с карточки нанятого
|
- [x] Назначение и снятие предмета с карточки нанятого
|
||||||
- [ ] Строки через `t(...)`, обе локали
|
- [x] Строки через `t(...)`, обе локали
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -11,15 +11,15 @@
|
|||||||
|
|
||||||
## Задачи
|
## Задачи
|
||||||
|
|
||||||
- [ ] Каркас дня в `core` дефом: начало первого урока, число уроков, длина урока, длины перемен
|
- [x] Каркас дня в `core` дефом: начало первого урока, число уроков, длина урока, длины перемен
|
||||||
и какая из них большая
|
и какая из них большая
|
||||||
- [ ] Длина учебной недели — в `SimulationOptions`, пять дней по умолчанию
|
- [x] Длина учебной недели — в `SimulationOptions`, пять дней по умолчанию
|
||||||
- [ ] Каникулы дефами: осенние, зимние, весенние, лето — диапазонами дат внутри учебного года
|
- [x] Каникулы дефами: осенние, зимние, весенние, лето — диапазонами дат внутри учебного года
|
||||||
- [ ] Запрос «какой сейчас слот»: номер урока или перемена, или «вне учебного дня» — по игровому
|
- [x] Запрос «какой сейчас слот»: номер урока или перемена, или «вне учебного дня» — по игровому
|
||||||
времени, выходным и каникулам
|
времени, выходным и каникулам
|
||||||
- [ ] Требование к типу помещения у `SubjectDef`: физкультура в спортзале, информатика в
|
- [x] Требование к типу помещения у `SubjectDef`: физкультура в спортзале, информатика в
|
||||||
компьютерном классе; без требования — кабинет класса
|
компьютерном классе; без требования — кабинет класса
|
||||||
- [ ] `GET /api/catalog` отдаёт каркас дня — он понадобится сетке расписания
|
- [x] `GET /api/catalog` отдаёт каркас дня — он понадобится сетке расписания
|
||||||
|
|
||||||
## Критерий готовности
|
## Критерий готовности
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
| [10. Предметы и мебель](10-subjects.md) | ✅ | `SubjectDef`, одна учительская должность, кабинет как число мест |
|
| [10. Предметы и мебель](10-subjects.md) | ✅ | `SubjectDef`, одна учительская должность, кабинет как число мест |
|
||||||
| [11. Пустая школа и пул](11-applicants.md) | ✅ | Школа без сотрудников, соискатели с запросом по зарплате |
|
| [11. Пустая школа и пул](11-applicants.md) | ✅ | Школа без сотрудников, соискатели с запросом по зарплате |
|
||||||
| [12. Наём и бюджет](12-hiring-budget.md) | ✅ | Наём, назначение предметов, предел фонда оплаты |
|
| [12. Наём и бюджет](12-hiring-budget.md) | ✅ | Наём, назначение предметов, предел фонда оплаты |
|
||||||
| [13. Раздел «Управление»](13-management-tab.md) | ⬜ | Деньги, соискатели, штат и назначения на экране |
|
| [13. Раздел «Управление»](13-management-tab.md) | ✅ | Деньги, соискатели, штат и назначения на экране |
|
||||||
|
|
||||||
## Срез 4. Расписание
|
## Срез 4. Расписание
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
|
|
||||||
| Фаза | Статус | Зачем |
|
| Фаза | Статус | Зачем |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [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) | ⬜ | Скобки в дереве, сетка класса, расписание учителя |
|
||||||
|
|||||||
+32
-5
@@ -30,13 +30,15 @@ the wire format is unambiguous, and the client formats it back in UTC.
|
|||||||
|
|
||||||
### `GET /api/schools`
|
### `GET /api/schools`
|
||||||
|
|
||||||
Everything the main menu needs in one request.
|
Everything the main menu needs in one request. `schoolWeekDays` is 5–7 working days counted
|
||||||
|
from Monday (five is Mon–Fri; six adds Saturday). It is a school rule, not a catalog def.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"maxSchools": 6,
|
"maxSchools": 6,
|
||||||
"defaultStartDate": "2012-03-31T06:00:00Z",
|
"defaultStartDate": "2012-03-31T06:00:00Z",
|
||||||
"gameMinutesPerRealSecond": 5,
|
"gameMinutesPerRealSecond": 5,
|
||||||
|
"schoolWeekDays": 5,
|
||||||
"schools": [
|
"schools": [
|
||||||
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1 }
|
{ "id": 1, "name": "Гимназия №14", "gameTime": "2012-03-31T07:35:00Z", "running": false, "speedIndex": 1 }
|
||||||
]
|
]
|
||||||
@@ -79,8 +81,15 @@ total is computed on the server.
|
|||||||
it is independent of the UI language.
|
it is independent of the UI language.
|
||||||
|
|
||||||
`subjects` is the list of placeable subjects (`defName`, label, `gradeMin`/`gradeMax`,
|
`subjects` is the list of placeable subjects (`defName`, label, `gradeMin`/`gradeMax`,
|
||||||
`hoursPerWeek`, and `skills` with shares). The assignment form in a later phase reads it; the
|
`hoursPerWeek`, `skills` with shares, and optional `room`). `room` is the RoomDef the lesson
|
||||||
create editor does not.
|
needs — PE uses a gym, informatics a computer lab; omit it and the class homeroom is used.
|
||||||
|
|
||||||
|
`dayFrame` is the one concrete bell schedule (`firstLesson` as `HH:mm`, lesson count and
|
||||||
|
lengths, which break is the long one). `holidays` are month-day ranges that repeat every
|
||||||
|
academic year; a range whose start is after its end wraps across 1 January.
|
||||||
|
|
||||||
|
The assignment form in a later phase reads subjects; the timetable grid reads the day frame.
|
||||||
|
The create editor does not.
|
||||||
|
|
||||||
`lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is
|
`lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is
|
||||||
Russian.
|
Russian.
|
||||||
@@ -245,7 +254,8 @@ when hiring or assigning, not at month end; money itself does not move.
|
|||||||
"age": 34,
|
"age": 34,
|
||||||
"isParent": false,
|
"isParent": false,
|
||||||
"hourlyWageAsk": 50,
|
"hourlyWageAsk": 50,
|
||||||
"monthlyBase": 4000
|
"monthlyBase": 4000,
|
||||||
|
"skills": [{ "id": "Mathematics", "label": "Математика", "value": "72" }]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"staff": [
|
"staff": [
|
||||||
@@ -261,13 +271,30 @@ when hiring or assigning, not at month end; money itself does not move.
|
|||||||
"monthlyPay": 5000,
|
"monthlyPay": 5000,
|
||||||
"subjects": [{ "defName": "Mathematics", "label": "Математика" }]
|
"subjects": [{ "defName": "Mathematics", "label": "Математика" }]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"positions": [{ "defName": "Teacher", "label": "Учитель" }],
|
||||||
|
"subjects": [
|
||||||
|
{
|
||||||
|
"defName": "Mathematics",
|
||||||
|
"label": "Математика",
|
||||||
|
"gradeMin": 5,
|
||||||
|
"gradeMax": 11,
|
||||||
|
"hoursPerWeek": 5
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same
|
Applicants here are the same people as in `saves/{id}.people.json`. A parent keeps the same
|
||||||
id on the roster; hiring them sets `isStaff` on that person and does not create a second
|
id on the roster; hiring them sets `isStaff` on that person and does not create a second
|
||||||
entity. Generated candidates (`aN.p0`) join the roster only when hired.
|
entity. Generated candidates (`aN.p0`) join the roster only when hired. `skills` on an
|
||||||
|
applicant are the three strongest, for the management list; the full set is on the person
|
||||||
|
card. `positions` and `subjects` are the school's catalog, so the hire and assign pickers
|
||||||
|
do not need a second request.
|
||||||
|
|
||||||
|
`GET /api/schools/{id}/people/{personId}` also opens a card for someone who is only in the
|
||||||
|
applicant pool (needs are the frozen snapshot — they are not in the World yet). Unknown
|
||||||
|
ids that are in neither place stay `404` `unknown-person`.
|
||||||
|
|
||||||
### `POST /api/schools/{id}/staff/hire`
|
### `POST /api/schools/{id}/staff/hire`
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ describe('t', () => {
|
|||||||
expect(t('schoolCount', { current: 2, max: 6 })).toBe('Schools: 2 of 6.');
|
expect(t('schoolCount', { current: 2, max: 6 })).toBe('Schools: 2 of 6.');
|
||||||
expect(t('pupilSlots', { count: 16 })).toBe('Pupil places: 16');
|
expect(t('pupilSlots', { count: 16 })).toBe('Pupil places: 16');
|
||||||
expect(t('peoplePager', { page: 2, pages: 10, total: 512 })).toBe('Page 2 of 10 · 512');
|
expect(t('peoplePager', { page: 2, pages: 10, total: 512 })).toBe('Page 2 of 10 · 512');
|
||||||
|
expect(t('staffErrorPayroll', { allocated: '10 000', payroll: '8 000', remaining: '2 000', attempted: '12 000' }))
|
||||||
|
.toBe('Not enough money: 8 000 of 10 000 is committed, 2 000 free, 12 000 needed.');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,46 @@ const ru = {
|
|||||||
peopleChildren: 'Дети',
|
peopleChildren: 'Дети',
|
||||||
peopleSiblings: 'Братья и сёстры',
|
peopleSiblings: 'Братья и сёстры',
|
||||||
peoplePartners: 'Супруг(а)',
|
peoplePartners: 'Супруг(а)',
|
||||||
|
|
||||||
|
modeOverview: 'Обзор',
|
||||||
|
modeManage: 'Управление',
|
||||||
|
staffAllocated: 'Выделено',
|
||||||
|
staffPayroll: 'Расписано',
|
||||||
|
staffRemaining: 'Свободно',
|
||||||
|
staffUncovered: 'Не покрыто',
|
||||||
|
staffUncoveredEmpty: 'Все предметы существующих параллелей кем-то ведутся.',
|
||||||
|
staffApplicants: 'Соискатели',
|
||||||
|
staffApplicantsEmpty: 'Пул пуст.',
|
||||||
|
staffHired: 'Штат',
|
||||||
|
staffHiredEmpty: 'Никого не наняли.',
|
||||||
|
staffColName: 'ФИО',
|
||||||
|
staffColAsk: 'Ставка',
|
||||||
|
staffColSkills: 'Навыки',
|
||||||
|
staffColPosition: 'Должность',
|
||||||
|
staffColSubjects: 'Предметы',
|
||||||
|
staffColPay: 'В месяц',
|
||||||
|
staffAsk: '{hourly}/ч · {monthly}/мес',
|
||||||
|
staffSubjectRange: '{label} ({min}–{max})',
|
||||||
|
staffParent: 'родитель',
|
||||||
|
staffPickHint: 'Выберите соискателя или работника в списке.',
|
||||||
|
staffHireTitle: 'Наём',
|
||||||
|
staffHire: 'Нанять',
|
||||||
|
staffPosition: 'Должность',
|
||||||
|
staffSubjectsTitle: 'Предметы',
|
||||||
|
staffNoSubjects: 'Предметы ещё не назначены.',
|
||||||
|
staffSubject: 'Предмет',
|
||||||
|
staffAssign: 'Назначить',
|
||||||
|
staffUnassign: 'Снять',
|
||||||
|
staffLoadFailed: 'Не удалось загрузить управление штатом.',
|
||||||
|
staffActionFailed: 'Не удалось выполнить действие.',
|
||||||
|
staffErrorPayroll: 'Не хватает денег: расписано {payroll} из {allocated}, свободно {remaining}, нужно {attempted}.',
|
||||||
|
staffErrorHired: 'Этот человек уже в штате.',
|
||||||
|
staffErrorNoOpening: 'Нет свободного места на эту должность.',
|
||||||
|
staffErrorUnknownApplicant: 'Этого человека нет в пуле соискателей.',
|
||||||
|
staffErrorPosition: 'Такой должности нет.',
|
||||||
|
staffErrorNotTeacher: 'Предмет можно назначить только учителю.',
|
||||||
|
staffErrorAssigned: 'Этот предмет уже назначен.',
|
||||||
|
staffErrorSubject: 'Такого предмета нет.',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type Messages = { [K in keyof typeof ru]: string };
|
type Messages = { [K in keyof typeof ru]: string };
|
||||||
@@ -254,6 +294,46 @@ const en: Messages = {
|
|||||||
peopleChildren: 'Children',
|
peopleChildren: 'Children',
|
||||||
peopleSiblings: 'Siblings',
|
peopleSiblings: 'Siblings',
|
||||||
peoplePartners: 'Spouse',
|
peoplePartners: 'Spouse',
|
||||||
|
|
||||||
|
modeOverview: 'Overview',
|
||||||
|
modeManage: 'Management',
|
||||||
|
staffAllocated: 'Allocated',
|
||||||
|
staffPayroll: 'Committed',
|
||||||
|
staffRemaining: 'Remaining',
|
||||||
|
staffUncovered: 'Uncovered',
|
||||||
|
staffUncoveredEmpty: 'Every subject in the existing year-groups has a teacher.',
|
||||||
|
staffApplicants: 'Applicants',
|
||||||
|
staffApplicantsEmpty: 'The pool is empty.',
|
||||||
|
staffHired: 'Staff',
|
||||||
|
staffHiredEmpty: 'Nobody hired yet.',
|
||||||
|
staffColName: 'Name',
|
||||||
|
staffColAsk: 'Ask',
|
||||||
|
staffColSkills: 'Skills',
|
||||||
|
staffColPosition: 'Position',
|
||||||
|
staffColSubjects: 'Subjects',
|
||||||
|
staffColPay: 'Monthly',
|
||||||
|
staffAsk: '{hourly}/h · {monthly}/mo',
|
||||||
|
staffSubjectRange: '{label} ({min}–{max})',
|
||||||
|
staffParent: 'parent',
|
||||||
|
staffPickHint: 'Select an applicant or a staff member in the list.',
|
||||||
|
staffHireTitle: 'Hire',
|
||||||
|
staffHire: 'Hire',
|
||||||
|
staffPosition: 'Position',
|
||||||
|
staffSubjectsTitle: 'Subjects',
|
||||||
|
staffNoSubjects: 'No subjects assigned yet.',
|
||||||
|
staffSubject: 'Subject',
|
||||||
|
staffAssign: 'Assign',
|
||||||
|
staffUnassign: 'Remove',
|
||||||
|
staffLoadFailed: 'Could not load staffing.',
|
||||||
|
staffActionFailed: 'That action failed.',
|
||||||
|
staffErrorPayroll: 'Not enough money: {payroll} of {allocated} is committed, {remaining} free, {attempted} needed.',
|
||||||
|
staffErrorHired: 'That person is already on staff.',
|
||||||
|
staffErrorNoOpening: 'There is no free opening for that position.',
|
||||||
|
staffErrorUnknownApplicant: 'That person is not in the applicant pool.',
|
||||||
|
staffErrorPosition: 'That position is not in the catalog.',
|
||||||
|
staffErrorNotTeacher: 'Only a teacher can be assigned a subject.',
|
||||||
|
staffErrorAssigned: 'That subject is already assigned.',
|
||||||
|
staffErrorSubject: 'That subject is not in the catalog.',
|
||||||
};
|
};
|
||||||
|
|
||||||
const catalogs: Record<Locale, Messages> = { ru, en };
|
const catalogs: Record<Locale, Messages> = { ru, en };
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* HTTP side of the server: the main menu and the in-school people list/card. The realtime clock
|
* HTTP side of the server: the main menu, the in-school people list/card, and staffing. The
|
||||||
* arrives over the WebSocket instead — see `connection.ts`.
|
* realtime clock arrives over the WebSocket instead — see `connection.ts`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface School {
|
export interface School {
|
||||||
@@ -16,6 +16,7 @@ export interface SchoolsResponse {
|
|||||||
readonly maxSchools: number;
|
readonly maxSchools: number;
|
||||||
readonly defaultStartDate: string;
|
readonly defaultStartDate: string;
|
||||||
readonly gameMinutesPerRealSecond: number;
|
readonly gameMinutesPerRealSecond: number;
|
||||||
|
readonly schoolWeekDays: number;
|
||||||
readonly schools: readonly School[];
|
readonly schools: readonly School[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,6 +26,10 @@ export class ApiError extends Error {
|
|||||||
readonly status: number,
|
readonly status: number,
|
||||||
readonly code: string,
|
readonly code: string,
|
||||||
message: string,
|
message: string,
|
||||||
|
readonly allocated?: number,
|
||||||
|
readonly payroll?: number,
|
||||||
|
readonly remaining?: number,
|
||||||
|
readonly attempted?: number,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
@@ -104,6 +109,25 @@ export interface SubjectInfo {
|
|||||||
readonly gradeMax: number;
|
readonly gradeMax: number;
|
||||||
readonly hoursPerWeek: number;
|
readonly hoursPerWeek: number;
|
||||||
readonly skills: readonly SubjectSkillShare[];
|
readonly skills: readonly SubjectSkillShare[];
|
||||||
|
readonly room: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DayFrameInfo {
|
||||||
|
readonly defName: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly firstLesson: string;
|
||||||
|
readonly lessonCount: number;
|
||||||
|
readonly lessonMinutes: number;
|
||||||
|
readonly breakMinutes: number;
|
||||||
|
readonly longBreakAfter: number;
|
||||||
|
readonly longBreakMinutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HolidayInfo {
|
||||||
|
readonly defName: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly start: { readonly month: number; readonly day: number };
|
||||||
|
readonly end: { readonly month: number; readonly day: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MapLayout {
|
export interface MapLayout {
|
||||||
@@ -131,6 +155,8 @@ export interface CatalogResponse {
|
|||||||
readonly defaultMap: MapLayout;
|
readonly defaultMap: MapLayout;
|
||||||
readonly nameSets: readonly DefInfo[];
|
readonly nameSets: readonly DefInfo[];
|
||||||
readonly subjects: readonly SubjectInfo[];
|
readonly subjects: readonly SubjectInfo[];
|
||||||
|
readonly dayFrame: DayFrameInfo | null;
|
||||||
|
readonly holidays: readonly HolidayInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMods(): Promise<readonly ModInfo[]> {
|
export async function fetchMods(): Promise<readonly ModInfo[]> {
|
||||||
@@ -265,6 +291,98 @@ export async function fetchPerson(schoolId: number, personId: string, lang: stri
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StaffingSubject {
|
||||||
|
readonly defName: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly gradeMin: number;
|
||||||
|
readonly gradeMax: number;
|
||||||
|
readonly hoursPerWeek: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaffingApplicant {
|
||||||
|
readonly id: string;
|
||||||
|
readonly fullName: string;
|
||||||
|
readonly female: boolean;
|
||||||
|
readonly age: number;
|
||||||
|
readonly isParent: boolean;
|
||||||
|
readonly hourlyWageAsk: number;
|
||||||
|
readonly monthlyBase: number;
|
||||||
|
readonly skills: readonly LabeledStat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StaffMember {
|
||||||
|
readonly id: string;
|
||||||
|
readonly fullName: string;
|
||||||
|
readonly female: boolean;
|
||||||
|
readonly age: number;
|
||||||
|
readonly isParent: boolean;
|
||||||
|
readonly position: string;
|
||||||
|
readonly positionLabel: string;
|
||||||
|
readonly hourlyWageAsk: number;
|
||||||
|
readonly monthlyPay: number;
|
||||||
|
readonly subjects: readonly DefLabel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Staffing {
|
||||||
|
readonly allocated: number;
|
||||||
|
readonly payroll: number;
|
||||||
|
readonly remaining: number;
|
||||||
|
readonly uncovered: readonly StaffingSubject[];
|
||||||
|
readonly applicants: readonly StaffingApplicant[];
|
||||||
|
readonly staff: readonly StaffMember[];
|
||||||
|
readonly positions: readonly DefLabel[];
|
||||||
|
readonly subjects: readonly StaffingSubject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchStaffing(schoolId: number, lang: string): Promise<Staffing> {
|
||||||
|
const params = new URLSearchParams({ lang });
|
||||||
|
return request<Staffing>(`/api/schools/${schoolId}/staffing?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hireStaff(
|
||||||
|
schoolId: number,
|
||||||
|
personId: string,
|
||||||
|
position: string,
|
||||||
|
lang: string,
|
||||||
|
): Promise<Staffing> {
|
||||||
|
const params = new URLSearchParams({ lang });
|
||||||
|
return request<Staffing>(`/api/schools/${schoolId}/staff/hire?${params.toString()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ personId, position }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignSubject(
|
||||||
|
schoolId: number,
|
||||||
|
personId: string,
|
||||||
|
subject: string,
|
||||||
|
lang: string,
|
||||||
|
): Promise<Staffing> {
|
||||||
|
const params = new URLSearchParams({ lang });
|
||||||
|
return request<Staffing>(
|
||||||
|
`/api/schools/${schoolId}/staff/${encodeURIComponent(personId)}/subjects?${params.toString()}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ subject }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function unassignSubject(
|
||||||
|
schoolId: number,
|
||||||
|
personId: string,
|
||||||
|
subject: string,
|
||||||
|
lang: string,
|
||||||
|
): Promise<Staffing> {
|
||||||
|
const params = new URLSearchParams({ lang });
|
||||||
|
return request<Staffing>(
|
||||||
|
`/api/schools/${schoolId}/staff/${encodeURIComponent(personId)}/subjects/${encodeURIComponent(subject)}?${params.toString()}`,
|
||||||
|
{ method: 'DELETE' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
url: string,
|
url: string,
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
@@ -286,11 +404,23 @@ async function request<T>(
|
|||||||
async function toApiError(response: Response): Promise<ApiError> {
|
async function toApiError(response: Response): Promise<ApiError> {
|
||||||
try {
|
try {
|
||||||
// ASP.NET Core problem details; `code` is added by the server for cases the UI reacts to.
|
// ASP.NET Core problem details; `code` is added by the server for cases the UI reacts to.
|
||||||
const problem = (await response.json()) as { code?: string; detail?: string; title?: string };
|
const problem = (await response.json()) as {
|
||||||
|
code?: string;
|
||||||
|
detail?: string;
|
||||||
|
title?: string;
|
||||||
|
allocated?: number;
|
||||||
|
payroll?: number;
|
||||||
|
remaining?: number;
|
||||||
|
attempted?: number;
|
||||||
|
};
|
||||||
return new ApiError(
|
return new ApiError(
|
||||||
response.status,
|
response.status,
|
||||||
problem.code ?? 'unknown',
|
problem.code ?? 'unknown',
|
||||||
problem.detail ?? problem.title ?? response.statusText,
|
problem.detail ?? problem.title ?? response.statusText,
|
||||||
|
problem.allocated,
|
||||||
|
problem.payroll,
|
||||||
|
problem.remaining,
|
||||||
|
problem.attempted,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return new ApiError(response.status, 'unknown', response.statusText);
|
return new ApiError(response.status, 'unknown', response.statusText);
|
||||||
|
|||||||
@@ -254,12 +254,42 @@ body {
|
|||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mode-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tab {
|
||||||
|
padding: 10px 18px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tab:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mode-tab--active {
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* Manager */
|
/* Manager */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Two panels, both full height. Left is a list — the map tree or the people table, whichever tab
|
* Two panels, both full height. Overview: map/people on the left, room or person on the right.
|
||||||
* is up. Right inspects whatever was picked last, a room or a person. Nothing lives below the
|
* Management: money and lists on the left, the same person card on the right. Mode tabs sit
|
||||||
* fold: the people list used to sit in a bottom row that had to be scrolled to.
|
* under the clock so the calendar stays visible while hiring.
|
||||||
*/
|
*/
|
||||||
.manager {
|
.manager {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -671,6 +701,69 @@ body {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.staffing__money {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staffing__money-item dt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staffing__money-item dd {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 22px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staffing__error {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staffing__badge {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staffing__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.staffing__assigned {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staffing__assigned-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.staffing__list-section .people__table-wrap {
|
||||||
|
max-height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Below three columns the panels stop competing for height and the page scrolls instead. */
|
/* Below three columns the panels stop competing for height and the page scrolls instead. */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
#app {
|
#app {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../forma
|
|||||||
import { t } from '../i18n/strings.ts';
|
import { t } from '../i18n/strings.ts';
|
||||||
import type { School } from '../net/api.ts';
|
import type { School } from '../net/api.ts';
|
||||||
import { clear, el } from './dom.ts';
|
import { clear, el } from './dom.ts';
|
||||||
|
import { ManagementPanel } from './managementPanel.ts';
|
||||||
import { PeoplePanel } from './peoplePanel.ts';
|
import { PeoplePanel } from './peoplePanel.ts';
|
||||||
|
|
||||||
interface GameScreenOptions {
|
interface GameScreenOptions {
|
||||||
@@ -49,6 +50,13 @@ export class GameScreen {
|
|||||||
private readonly positionsList = el('ul', { class: 'panel__list' });
|
private readonly positionsList = el('ul', { class: 'panel__list' });
|
||||||
|
|
||||||
private readonly people = new PeoplePanel({ onSelect: () => this.inspect('person') });
|
private readonly people = new PeoplePanel({ onSelect: () => this.inspect('person') });
|
||||||
|
private readonly management = new ManagementPanel();
|
||||||
|
private readonly overviewTab = el('button', { class: 'mode-tab', type: 'button' });
|
||||||
|
private readonly manageTab = el('button', { class: 'mode-tab', type: 'button' });
|
||||||
|
private readonly overview = el('div', { class: 'manager' });
|
||||||
|
private readonly manage = el('div', { class: 'manager' });
|
||||||
|
private readonly manageListTitle = el('h2', { class: 'panel__title' });
|
||||||
|
private readonly manageCardTitle = el('h2', { class: 'panel__title' });
|
||||||
|
|
||||||
private readonly treeButtons = new Map<string, HTMLButtonElement>();
|
private readonly treeButtons = new Map<string, HTMLButtonElement>();
|
||||||
private nodes: readonly MapSnapshotNode[] = [];
|
private nodes: readonly MapSnapshotNode[] = [];
|
||||||
@@ -85,18 +93,24 @@ export class GameScreen {
|
|||||||
),
|
),
|
||||||
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
|
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
|
||||||
),
|
),
|
||||||
|
el('div', { class: 'mode-tabs' }, this.overviewTab, this.manageTab),
|
||||||
|
this.overview,
|
||||||
|
this.manage,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.overview.append(
|
||||||
el(
|
el(
|
||||||
'div',
|
'section',
|
||||||
{ class: 'manager' },
|
{ class: 'panel' },
|
||||||
el(
|
el('div', { class: 'panel__tabs' }, this.mapTab, this.peopleTab),
|
||||||
'section',
|
this.mapBody,
|
||||||
{ class: 'panel' },
|
this.peopleBody,
|
||||||
el('div', { class: 'panel__tabs' }, this.mapTab, this.peopleTab),
|
|
||||||
this.mapBody,
|
|
||||||
this.peopleBody,
|
|
||||||
),
|
|
||||||
el('section', { class: 'panel' }, this.inspectTitle, this.locationBody, this.people.cardElement),
|
|
||||||
),
|
),
|
||||||
|
el('section', { class: 'panel' }, this.inspectTitle, this.locationBody, this.people.cardElement),
|
||||||
|
);
|
||||||
|
this.manage.append(
|
||||||
|
el('section', { class: 'panel' }, this.manageListTitle, this.management.listElement),
|
||||||
|
el('section', { class: 'panel' }, this.manageCardTitle, this.management.cardElement),
|
||||||
);
|
);
|
||||||
|
|
||||||
this.mapBody.append(this.tree);
|
this.mapBody.append(this.tree);
|
||||||
@@ -111,8 +125,11 @@ export class GameScreen {
|
|||||||
|
|
||||||
this.mapTab.addEventListener('click', () => this.showTab('map'));
|
this.mapTab.addEventListener('click', () => this.showTab('map'));
|
||||||
this.peopleTab.addEventListener('click', () => this.showTab('people'));
|
this.peopleTab.addEventListener('click', () => this.showTab('people'));
|
||||||
|
this.overviewTab.addEventListener('click', () => this.showMode('overview'));
|
||||||
|
this.manageTab.addEventListener('click', () => this.showMode('manage'));
|
||||||
this.showTab('map');
|
this.showTab('map');
|
||||||
this.inspect('location');
|
this.inspect('location');
|
||||||
|
this.showMode('overview');
|
||||||
|
|
||||||
this.localize();
|
this.localize();
|
||||||
}
|
}
|
||||||
@@ -123,6 +140,10 @@ export class GameScreen {
|
|||||||
|
|
||||||
localize(): void {
|
localize(): void {
|
||||||
this.backButton.textContent = t('backToMenu');
|
this.backButton.textContent = t('backToMenu');
|
||||||
|
this.overviewTab.textContent = t('modeOverview');
|
||||||
|
this.manageTab.textContent = t('modeManage');
|
||||||
|
this.manageListTitle.textContent = t('modeManage');
|
||||||
|
this.manageCardTitle.textContent = t('personTitle');
|
||||||
this.mapTab.textContent = t('mapTitle');
|
this.mapTab.textContent = t('mapTitle');
|
||||||
this.peopleTab.textContent = t('peopleTitle');
|
this.peopleTab.textContent = t('peopleTitle');
|
||||||
this.paintInspectTitle();
|
this.paintInspectTitle();
|
||||||
@@ -136,6 +157,7 @@ export class GameScreen {
|
|||||||
this.positionsEmpty.textContent = t('positionsEmpty');
|
this.positionsEmpty.textContent = t('positionsEmpty');
|
||||||
|
|
||||||
this.people.localize();
|
this.people.localize();
|
||||||
|
this.management.localize();
|
||||||
this.paintSelection();
|
this.paintSelection();
|
||||||
|
|
||||||
if (this.lastGameTime !== null) {
|
if (this.lastGameTime !== null) {
|
||||||
@@ -156,6 +178,7 @@ export class GameScreen {
|
|||||||
this.people.show(school.id);
|
this.people.show(school.id);
|
||||||
this.showTab('map');
|
this.showTab('map');
|
||||||
this.inspect('location');
|
this.inspect('location');
|
||||||
|
this.showMode('overview');
|
||||||
}
|
}
|
||||||
|
|
||||||
applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void {
|
applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void {
|
||||||
@@ -211,6 +234,20 @@ export class GameScreen {
|
|||||||
this.paintSelection();
|
this.paintSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private showMode(mode: 'overview' | 'manage'): void {
|
||||||
|
this.overviewTab.classList.toggle('mode-tab--active', mode === 'overview');
|
||||||
|
this.manageTab.classList.toggle('mode-tab--active', mode === 'manage');
|
||||||
|
this.overview.hidden = mode !== 'overview';
|
||||||
|
this.manage.hidden = mode !== 'manage';
|
||||||
|
if (mode === 'manage' && this.schoolId !== null) {
|
||||||
|
this.management.show(this.schoolId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'overview') {
|
||||||
|
this.people.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private showTab(tab: 'map' | 'people'): void {
|
private showTab(tab: 'map' | 'people'): void {
|
||||||
this.mapTab.classList.toggle('panel__tab--active', tab === 'map');
|
this.mapTab.classList.toggle('panel__tab--active', tab === 'map');
|
||||||
this.peopleTab.classList.toggle('panel__tab--active', tab === 'people');
|
this.peopleTab.classList.toggle('panel__tab--active', tab === 'people');
|
||||||
|
|||||||
@@ -0,0 +1,593 @@
|
|||||||
|
import {
|
||||||
|
ApiError,
|
||||||
|
assignSubject,
|
||||||
|
fetchPerson,
|
||||||
|
fetchStaffing,
|
||||||
|
hireStaff,
|
||||||
|
unassignSubject,
|
||||||
|
type PersonCard,
|
||||||
|
type Staffing,
|
||||||
|
type StaffingApplicant,
|
||||||
|
type StaffMember,
|
||||||
|
} from '../net/api.ts';
|
||||||
|
import { getLocale, intlTag } from '../i18n/locale.ts';
|
||||||
|
import { t } from '../i18n/strings.ts';
|
||||||
|
import { clear, el } from './dom.ts';
|
||||||
|
import { renderPersonCard } from './personCard.ts';
|
||||||
|
|
||||||
|
const TEACHER = 'Teacher';
|
||||||
|
|
||||||
|
type Selection =
|
||||||
|
| { readonly kind: 'applicant'; readonly id: string }
|
||||||
|
| { readonly kind: 'staff'; readonly id: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Left: money, coverage, applicants, staff. Right: the same person card as People, plus hire
|
||||||
|
* and subject actions. HTTP only — the clock stays on the bar above.
|
||||||
|
*/
|
||||||
|
export class ManagementPanel {
|
||||||
|
readonly listElement: HTMLElement;
|
||||||
|
readonly cardElement: HTMLElement;
|
||||||
|
|
||||||
|
private readonly error = el('p', { class: 'staffing__error' });
|
||||||
|
private readonly money = el('dl', { class: 'staffing__money' });
|
||||||
|
private readonly uncoveredTitle = el('h3', { class: 'panel__section-title' });
|
||||||
|
private readonly uncovered = el('div', { class: 'people__tags' });
|
||||||
|
private readonly uncoveredEmpty = el('p', { class: 'panel__empty' });
|
||||||
|
private readonly applicantsTitle = el('h3', { class: 'panel__section-title' });
|
||||||
|
private readonly applicantsTable = el('table', { class: 'people__table' });
|
||||||
|
private readonly applicantsEmpty = el('p', { class: 'panel__empty' });
|
||||||
|
private readonly staffTitle = el('h3', { class: 'panel__section-title' });
|
||||||
|
private readonly staffTable = el('table', { class: 'people__table' });
|
||||||
|
private readonly staffEmpty = el('p', { class: 'panel__empty' });
|
||||||
|
private readonly card = el('aside', { class: 'panel__body people__card' });
|
||||||
|
private readonly positionSelect = el('select', { class: 'input people__input' });
|
||||||
|
private readonly subjectSelect = el('select', { class: 'input people__input' });
|
||||||
|
|
||||||
|
private schoolId: number | null = null;
|
||||||
|
private staffing: Staffing | null = null;
|
||||||
|
private selection: Selection | null = null;
|
||||||
|
private loadToken = 0;
|
||||||
|
private cardToken = 0;
|
||||||
|
private busy = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.error.hidden = true;
|
||||||
|
this.listElement = el(
|
||||||
|
'div',
|
||||||
|
{ class: 'panel__body staffing' },
|
||||||
|
this.error,
|
||||||
|
this.money,
|
||||||
|
el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered),
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'panel__section staffing__list-section' },
|
||||||
|
this.applicantsTitle,
|
||||||
|
el('div', { class: 'people__table-wrap' }, this.applicantsTable, this.applicantsEmpty),
|
||||||
|
),
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'panel__section staffing__list-section' },
|
||||||
|
this.staffTitle,
|
||||||
|
el('div', { class: 'people__table-wrap' }, this.staffTable, this.staffEmpty),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.cardElement = this.card;
|
||||||
|
this.localize();
|
||||||
|
}
|
||||||
|
|
||||||
|
localize(): void {
|
||||||
|
this.uncoveredTitle.textContent = t('staffUncovered');
|
||||||
|
this.applicantsTitle.textContent = t('staffApplicants');
|
||||||
|
this.staffTitle.textContent = t('staffHired');
|
||||||
|
this.uncoveredEmpty.textContent = t('staffUncoveredEmpty');
|
||||||
|
this.applicantsEmpty.textContent = t('staffApplicantsEmpty');
|
||||||
|
this.staffEmpty.textContent = t('staffHiredEmpty');
|
||||||
|
this.paint();
|
||||||
|
if (this.selection !== null) {
|
||||||
|
void this.openCard(this.selection);
|
||||||
|
} else {
|
||||||
|
this.paintCard(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
show(schoolId: number): void {
|
||||||
|
const switched = this.schoolId !== schoolId;
|
||||||
|
this.schoolId = schoolId;
|
||||||
|
if (switched) {
|
||||||
|
this.selection = null;
|
||||||
|
this.staffing = null;
|
||||||
|
this.clearError();
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reload(): Promise<void> {
|
||||||
|
const schoolId = this.schoolId;
|
||||||
|
if (schoolId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = ++this.loadToken;
|
||||||
|
try {
|
||||||
|
const staffing = await fetchStaffing(schoolId, getLocale());
|
||||||
|
if (token !== this.loadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.staffing = staffing;
|
||||||
|
this.syncSelection();
|
||||||
|
this.paint();
|
||||||
|
if (this.selection !== null) {
|
||||||
|
void this.openCard(this.selection);
|
||||||
|
} else {
|
||||||
|
this.paintCard(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (token !== this.loadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showError(t('staffLoadFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncSelection(): void {
|
||||||
|
const staffing = this.staffing;
|
||||||
|
const selected = this.selection;
|
||||||
|
if (staffing === null || selected === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.kind === 'applicant' && staffing.applicants.some((row) => row.id === selected.id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staffing.staff.some((row) => row.id === selected.id)) {
|
||||||
|
this.selection = { kind: 'staff', id: selected.id };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private paint(): void {
|
||||||
|
const staffing = this.staffing;
|
||||||
|
this.paintMoney(staffing);
|
||||||
|
this.paintUncovered(staffing);
|
||||||
|
this.paintApplicants(staffing);
|
||||||
|
this.paintStaff(staffing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private paintMoney(staffing: Staffing | null): void {
|
||||||
|
clear(this.money);
|
||||||
|
const allocated = staffing?.allocated ?? 0;
|
||||||
|
const payroll = staffing?.payroll ?? 0;
|
||||||
|
const remaining = staffing?.remaining ?? 0;
|
||||||
|
for (const [label, amount] of [
|
||||||
|
[t('staffAllocated'), allocated],
|
||||||
|
[t('staffPayroll'), payroll],
|
||||||
|
[t('staffRemaining'), remaining],
|
||||||
|
] as const) {
|
||||||
|
this.money.append(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'staffing__money-item' },
|
||||||
|
el('dt', { text: label }),
|
||||||
|
el('dd', { text: formatMoney(amount) }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private paintUncovered(staffing: Staffing | null): void {
|
||||||
|
clear(this.uncovered);
|
||||||
|
const rows = staffing?.uncovered ?? [];
|
||||||
|
this.uncoveredEmpty.hidden = rows.length > 0;
|
||||||
|
this.uncovered.hidden = rows.length === 0;
|
||||||
|
for (const subject of rows) {
|
||||||
|
this.uncovered.append(
|
||||||
|
el('span', {
|
||||||
|
class: 'people__tag',
|
||||||
|
text: t('staffSubjectRange', {
|
||||||
|
label: subject.label,
|
||||||
|
min: subject.gradeMin,
|
||||||
|
max: subject.gradeMax,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private paintApplicants(staffing: Staffing | null): void {
|
||||||
|
const rows = staffing?.applicants ?? [];
|
||||||
|
this.applicantsEmpty.hidden = rows.length > 0;
|
||||||
|
this.applicantsTable.hidden = rows.length === 0;
|
||||||
|
clear(this.applicantsTable);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = el('tbody');
|
||||||
|
this.applicantsTable.append(
|
||||||
|
el(
|
||||||
|
'thead',
|
||||||
|
{},
|
||||||
|
el(
|
||||||
|
'tr',
|
||||||
|
{},
|
||||||
|
el('th', { text: t('staffColName') }),
|
||||||
|
el('th', { text: t('staffColAsk') }),
|
||||||
|
el('th', { text: t('staffColSkills') }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const applicant of rows) {
|
||||||
|
const selected = this.selection?.kind === 'applicant' && this.selection.id === applicant.id;
|
||||||
|
const row = el('tr', {
|
||||||
|
class: selected ? 'people__row people__row--active' : 'people__row',
|
||||||
|
dataset: { personId: applicant.id },
|
||||||
|
onClick: () => void this.select({ kind: 'applicant', id: applicant.id }),
|
||||||
|
});
|
||||||
|
const ask = el('td', {
|
||||||
|
text: t('staffAsk', {
|
||||||
|
hourly: formatMoney(applicant.hourlyWageAsk),
|
||||||
|
monthly: formatMoney(applicant.monthlyBase),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const name = el('td', { text: applicant.fullName });
|
||||||
|
if (applicant.isParent) {
|
||||||
|
name.append(el('span', { class: 'staffing__badge', text: t('staffParent') }));
|
||||||
|
}
|
||||||
|
|
||||||
|
row.append(
|
||||||
|
name,
|
||||||
|
ask,
|
||||||
|
el('td', { text: applicant.skills.map((skill) => skill.label).join(', ') || '—' }),
|
||||||
|
);
|
||||||
|
body.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private paintStaff(staffing: Staffing | null): void {
|
||||||
|
const rows = staffing?.staff ?? [];
|
||||||
|
this.staffEmpty.hidden = rows.length > 0;
|
||||||
|
this.staffTable.hidden = rows.length === 0;
|
||||||
|
clear(this.staffTable);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = el('tbody');
|
||||||
|
this.staffTable.append(
|
||||||
|
el(
|
||||||
|
'thead',
|
||||||
|
{},
|
||||||
|
el(
|
||||||
|
'tr',
|
||||||
|
{},
|
||||||
|
el('th', { text: t('staffColName') }),
|
||||||
|
el('th', { text: t('staffColPosition') }),
|
||||||
|
el('th', { text: t('staffColSubjects') }),
|
||||||
|
el('th', { text: t('staffColPay') }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const member of rows) {
|
||||||
|
const selected = this.selection?.kind === 'staff' && this.selection.id === member.id;
|
||||||
|
const row = el('tr', {
|
||||||
|
class: selected ? 'people__row people__row--active' : 'people__row',
|
||||||
|
dataset: { personId: member.id },
|
||||||
|
onClick: () => void this.select({ kind: 'staff', id: member.id }),
|
||||||
|
});
|
||||||
|
const name = el('td', { text: member.fullName });
|
||||||
|
if (member.isParent) {
|
||||||
|
name.append(el('span', { class: 'staffing__badge', text: t('staffParent') }));
|
||||||
|
}
|
||||||
|
|
||||||
|
row.append(
|
||||||
|
name,
|
||||||
|
el('td', { text: member.positionLabel }),
|
||||||
|
el('td', { text: member.subjects.map((subject) => subject.label).join(', ') || '—' }),
|
||||||
|
el('td', { text: formatMoney(member.monthlyPay) }),
|
||||||
|
);
|
||||||
|
body.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async select(selection: Selection): Promise<void> {
|
||||||
|
this.selection = selection;
|
||||||
|
this.clearError();
|
||||||
|
this.paint();
|
||||||
|
await this.openCard(selection);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openCard(selection: Selection): Promise<void> {
|
||||||
|
const schoolId = this.schoolId;
|
||||||
|
if (schoolId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = ++this.cardToken;
|
||||||
|
try {
|
||||||
|
const card = await fetchPerson(schoolId, selection.id, getLocale());
|
||||||
|
if (token !== this.cardToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.paintCard(card);
|
||||||
|
} catch {
|
||||||
|
if (token !== this.cardToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(this.card);
|
||||||
|
this.card.append(el('p', { class: 'panel__empty', text: t('peopleCardFailed') }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private paintCard(card: PersonCard | null): void {
|
||||||
|
clear(this.card);
|
||||||
|
if (card === null) {
|
||||||
|
this.card.append(el('p', { class: 'panel__empty', text: t('staffPickHint') }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPersonCard(this.card, card, (id) => void this.openRelative(id));
|
||||||
|
this.appendActions(card.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openRelative(personId: string): Promise<void> {
|
||||||
|
const staffing = this.staffing;
|
||||||
|
if (staffing !== null && staffing.staff.some((row) => row.id === personId)) {
|
||||||
|
await this.select({ kind: 'staff', id: personId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (staffing !== null && staffing.applicants.some((row) => row.id === personId)) {
|
||||||
|
await this.select({ kind: 'applicant', id: personId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.select({ kind: 'staff', id: personId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendActions(personId: string): void {
|
||||||
|
const staffing = this.staffing;
|
||||||
|
if (staffing === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const applicant = staffing.applicants.find((row) => row.id === personId);
|
||||||
|
if (applicant !== undefined) {
|
||||||
|
this.appendHire(applicant);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = staffing.staff.find((row) => row.id === personId);
|
||||||
|
if (member !== undefined && member.position === TEACHER) {
|
||||||
|
this.appendSubjects(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendHire(applicant: StaffingApplicant): void {
|
||||||
|
const staffing = this.staffing;
|
||||||
|
if (staffing === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fillSelect(
|
||||||
|
this.positionSelect,
|
||||||
|
staffing.positions.map((row) => ({ value: row.defName, label: row.label })),
|
||||||
|
TEACHER,
|
||||||
|
);
|
||||||
|
|
||||||
|
const actions = el('div', { class: 'staffing__actions' });
|
||||||
|
actions.append(
|
||||||
|
el('h4', { class: 'people__section-title', text: t('staffHireTitle') }),
|
||||||
|
el('label', { class: 'people__field' }, el('span', { class: 'people__label', text: t('staffPosition') }), this.positionSelect),
|
||||||
|
el('button', {
|
||||||
|
class: 'button',
|
||||||
|
type: 'button',
|
||||||
|
text: t('staffHire'),
|
||||||
|
disabled: this.busy,
|
||||||
|
onClick: () => void this.hire(applicant.id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.card.append(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private appendSubjects(member: StaffMember): void {
|
||||||
|
const staffing = this.staffing;
|
||||||
|
if (staffing === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assigned = new Set(member.subjects.map((row) => row.defName));
|
||||||
|
const available = staffing.subjects
|
||||||
|
.filter((subject) => !assigned.has(subject.defName))
|
||||||
|
.map((subject) => ({ value: subject.defName, label: subject.label }));
|
||||||
|
fillSelect(this.subjectSelect, available, available[0]?.value ?? '');
|
||||||
|
|
||||||
|
const actions = el('div', { class: 'staffing__actions' });
|
||||||
|
actions.append(el('h4', { class: 'people__section-title', text: t('staffSubjectsTitle') }));
|
||||||
|
|
||||||
|
if (member.subjects.length > 0) {
|
||||||
|
const list = el('ul', { class: 'staffing__assigned' });
|
||||||
|
for (const subject of member.subjects) {
|
||||||
|
list.append(
|
||||||
|
el(
|
||||||
|
'li',
|
||||||
|
{ class: 'staffing__assigned-row' },
|
||||||
|
el('span', { text: subject.label }),
|
||||||
|
el('button', {
|
||||||
|
class: 'button button--small',
|
||||||
|
type: 'button',
|
||||||
|
text: t('staffUnassign'),
|
||||||
|
disabled: this.busy,
|
||||||
|
onClick: () => void this.unassign(member.id, subject.defName),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
actions.append(list);
|
||||||
|
} else {
|
||||||
|
actions.append(el('p', { class: 'panel__empty', text: t('staffNoSubjects') }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (available.length > 0) {
|
||||||
|
actions.append(
|
||||||
|
el('label', { class: 'people__field' }, el('span', { class: 'people__label', text: t('staffSubject') }), this.subjectSelect),
|
||||||
|
el('button', {
|
||||||
|
class: 'button',
|
||||||
|
type: 'button',
|
||||||
|
text: t('staffAssign'),
|
||||||
|
disabled: this.busy,
|
||||||
|
onClick: () => void this.assign(member.id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.card.append(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async hire(personId: string): Promise<void> {
|
||||||
|
const schoolId = this.schoolId;
|
||||||
|
if (schoolId === null || this.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.busy = true;
|
||||||
|
this.clearError();
|
||||||
|
try {
|
||||||
|
const staffing = await hireStaff(schoolId, personId, this.positionSelect.value, getLocale());
|
||||||
|
this.staffing = staffing;
|
||||||
|
this.selection = { kind: 'staff', id: personId };
|
||||||
|
} catch (error) {
|
||||||
|
this.showError(actionError(error));
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
this.paint();
|
||||||
|
if (this.selection !== null) {
|
||||||
|
await this.openCard(this.selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assign(personId: string): Promise<void> {
|
||||||
|
const schoolId = this.schoolId;
|
||||||
|
const subject = this.subjectSelect.value;
|
||||||
|
if (schoolId === null || this.busy || subject.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.busy = true;
|
||||||
|
this.clearError();
|
||||||
|
try {
|
||||||
|
this.staffing = await assignSubject(schoolId, personId, subject, getLocale());
|
||||||
|
this.selection = { kind: 'staff', id: personId };
|
||||||
|
} catch (error) {
|
||||||
|
this.showError(actionError(error));
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
this.paint();
|
||||||
|
if (this.selection !== null) {
|
||||||
|
await this.openCard(this.selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async unassign(personId: string, subject: string): Promise<void> {
|
||||||
|
const schoolId = this.schoolId;
|
||||||
|
if (schoolId === null || this.busy) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.busy = true;
|
||||||
|
this.clearError();
|
||||||
|
try {
|
||||||
|
this.staffing = await unassignSubject(schoolId, personId, subject, getLocale());
|
||||||
|
this.selection = { kind: 'staff', id: personId };
|
||||||
|
} catch (error) {
|
||||||
|
this.showError(actionError(error));
|
||||||
|
} finally {
|
||||||
|
this.busy = false;
|
||||||
|
this.paint();
|
||||||
|
if (this.selection !== null) {
|
||||||
|
await this.openCard(this.selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private showError(message: string): void {
|
||||||
|
this.error.hidden = false;
|
||||||
|
this.error.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearError(): void {
|
||||||
|
this.error.hidden = true;
|
||||||
|
this.error.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillSelect(
|
||||||
|
select: HTMLSelectElement,
|
||||||
|
items: readonly { value: string; label: string }[],
|
||||||
|
preferred: string,
|
||||||
|
): void {
|
||||||
|
const current = select.value;
|
||||||
|
select.replaceChildren();
|
||||||
|
for (const item of items) {
|
||||||
|
const option = el('option', { text: item.label });
|
||||||
|
option.value = item.value;
|
||||||
|
select.append(option);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.some((item) => item.value === current)) {
|
||||||
|
select.value = current;
|
||||||
|
} else if (items.some((item) => item.value === preferred)) {
|
||||||
|
select.value = preferred;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(value: number): string {
|
||||||
|
return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 2 }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionError(error: unknown): string {
|
||||||
|
if (!(error instanceof ApiError)) {
|
||||||
|
return t('staffActionFailed');
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (error.code) {
|
||||||
|
case 'payroll-exceeded':
|
||||||
|
return t('staffErrorPayroll', {
|
||||||
|
allocated: formatMoney(error.allocated ?? 0),
|
||||||
|
payroll: formatMoney(error.payroll ?? 0),
|
||||||
|
remaining: formatMoney(error.remaining ?? 0),
|
||||||
|
attempted: formatMoney(error.attempted ?? 0),
|
||||||
|
});
|
||||||
|
case 'already-hired':
|
||||||
|
return t('staffErrorHired');
|
||||||
|
case 'no-opening':
|
||||||
|
return t('staffErrorNoOpening');
|
||||||
|
case 'unknown-applicant':
|
||||||
|
return t('staffErrorUnknownApplicant');
|
||||||
|
case 'unknown-position':
|
||||||
|
return t('staffErrorPosition');
|
||||||
|
case 'not-teacher':
|
||||||
|
return t('staffErrorNotTeacher');
|
||||||
|
case 'already-assigned':
|
||||||
|
return t('staffErrorAssigned');
|
||||||
|
case 'unknown-subject':
|
||||||
|
return t('staffErrorSubject');
|
||||||
|
default:
|
||||||
|
return error.message.length > 0 ? error.message : t('staffActionFailed');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,20 +3,13 @@ import {
|
|||||||
fetchPerson,
|
fetchPerson,
|
||||||
type PeoplePage,
|
type PeoplePage,
|
||||||
type PersonCard,
|
type PersonCard,
|
||||||
type PersonListItem,
|
|
||||||
type PersonRel,
|
|
||||||
type PersonRole,
|
type PersonRole,
|
||||||
type PersonSort,
|
type PersonSort,
|
||||||
} from '../net/api.ts';
|
} from '../net/api.ts';
|
||||||
import { getLocale } from '../i18n/locale.ts';
|
import { getLocale } from '../i18n/locale.ts';
|
||||||
import { t, type MessageKey } from '../i18n/strings.ts';
|
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||||
import { clear, el } from './dom.ts';
|
import { clear, el } from './dom.ts';
|
||||||
|
import { placement, renderPersonCard, roleLabels } from './personCard.ts';
|
||||||
const ROLE_KEYS: Record<PersonRole, MessageKey> = {
|
|
||||||
student: 'peopleRoleStudent',
|
|
||||||
staff: 'peopleRoleStaff',
|
|
||||||
parent: 'peopleRoleParent',
|
|
||||||
};
|
|
||||||
|
|
||||||
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
|
const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [
|
||||||
{ sort: 'surname', label: 'peopleColName' },
|
{ sort: 'surname', label: 'peopleColName' },
|
||||||
@@ -173,6 +166,12 @@ export class PeoplePanel {
|
|||||||
void this.reload();
|
void this.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refresh(): void {
|
||||||
|
if (this.schoolId !== null) {
|
||||||
|
void this.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private onFilterChange(): void {
|
private onFilterChange(): void {
|
||||||
this.page = 1;
|
this.page = 1;
|
||||||
void this.reload();
|
void this.reload();
|
||||||
@@ -357,23 +356,7 @@ export class PeoplePanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.card.append(
|
renderPersonCard(this.card, card, (id) => void this.openCard(id));
|
||||||
el('h3', { class: 'people__card-name', text: card.fullName }),
|
|
||||||
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
|
|
||||||
);
|
|
||||||
appendPairs(this.card, t('peopleBody'), card.body);
|
|
||||||
appendPairs(this.card, t('peopleSkills'), card.skills);
|
|
||||||
appendTags(this.card, t('peopleTraits'), card.traits.map((row) => row.label));
|
|
||||||
appendNeeds(this.card, t('peopleNeeds'), card.needs);
|
|
||||||
|
|
||||||
const family = section(t('peopleFamily'));
|
|
||||||
appendRelatives(family, t('peopleParents'), card.family.parents, (id) => void this.openCard(id));
|
|
||||||
appendRelatives(family, t('peopleChildren'), card.family.children, (id) => void this.openCard(id));
|
|
||||||
appendRelatives(family, t('peopleSiblings'), card.family.siblings, (id) => void this.openCard(id));
|
|
||||||
appendRelatives(family, t('peoplePartners'), card.family.partners, (id) => void this.openCard(id));
|
|
||||||
if (family.childElementCount > 1) {
|
|
||||||
this.card.append(family);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,139 +394,3 @@ function parseOptionalInt(value: string): number | undefined {
|
|||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
return Number.isInteger(parsed) ? parsed : undefined;
|
return Number.isInteger(parsed) ? parsed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function placement(person: PersonListItem): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (person.classYear !== null && person.classLetter !== null) {
|
|
||||||
parts.push(`${person.classYear}${person.classLetter}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (person.positionLabel !== null && person.positionLabel.length > 0) {
|
|
||||||
parts.push(person.positionLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.length > 0 ? parts.join(' · ') : '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
function roleLabels(roles: readonly string[]): string {
|
|
||||||
return roles
|
|
||||||
.map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role))
|
|
||||||
.join(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function cardMeta(card: PersonCard): string {
|
|
||||||
const bits = [
|
|
||||||
roleLabels(card.roles),
|
|
||||||
card.female ? t('peopleFemale') : t('peopleMale'),
|
|
||||||
String(card.age),
|
|
||||||
placement(card),
|
|
||||||
].filter((bit) => bit.length > 0 && bit !== '—');
|
|
||||||
return bits.join(' · ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function section(title: string): HTMLElement {
|
|
||||||
return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Name and value in two columns, several columns per row. Fourteen skills as a bulleted list ran
|
|
||||||
* the card past the fold while three quarters of its width sat empty.
|
|
||||||
*/
|
|
||||||
function appendPairs(
|
|
||||||
parent: HTMLElement,
|
|
||||||
title: string,
|
|
||||||
rows: readonly { readonly label: string; readonly value: string }[],
|
|
||||||
): void {
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const grid = el('dl', { class: 'people__pairs' });
|
|
||||||
for (const row of rows) {
|
|
||||||
grid.append(
|
|
||||||
el(
|
|
||||||
'div',
|
|
||||||
{ class: 'people__pair' },
|
|
||||||
el('dt', { text: row.label }),
|
|
||||||
el('dd', { text: row.value }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
parent.append(section(title), grid);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void {
|
|
||||||
if (values.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags = el('div', { class: 'people__tags' });
|
|
||||||
for (const value of values) {
|
|
||||||
tags.append(el('span', { class: 'people__tag', text: value }));
|
|
||||||
}
|
|
||||||
|
|
||||||
parent.append(section(title), tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendNeeds(
|
|
||||||
parent: HTMLElement,
|
|
||||||
title: string,
|
|
||||||
rows: readonly { readonly label: string; readonly value: number }[],
|
|
||||||
): void {
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const grid = el('div', { class: 'people__needs' });
|
|
||||||
for (const row of rows) {
|
|
||||||
const share = Math.max(0, Math.min(1, row.value));
|
|
||||||
const fill = el('span', { class: 'people__need-fill' });
|
|
||||||
fill.style.width = `${Math.round(share * 100)}%`;
|
|
||||||
fill.classList.toggle('people__need-fill--low', share < 0.25);
|
|
||||||
|
|
||||||
grid.append(
|
|
||||||
el(
|
|
||||||
'div',
|
|
||||||
{ class: 'people__need' },
|
|
||||||
el('span', { class: 'people__need-label', text: row.label }),
|
|
||||||
el('span', { class: 'people__need-value', text: `${Math.round(share * 100)}%` }),
|
|
||||||
el('span', { class: 'people__need-track' }, fill),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
parent.append(section(title), grid);
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendRelatives(
|
|
||||||
parent: HTMLElement,
|
|
||||||
title: string,
|
|
||||||
relatives: readonly PersonRel[],
|
|
||||||
open: (id: string) => void,
|
|
||||||
): void {
|
|
||||||
if (relatives.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = el('span', { class: 'people__rel-list' });
|
|
||||||
for (const relative of relatives) {
|
|
||||||
list.append(
|
|
||||||
el('button', {
|
|
||||||
class: 'people__link',
|
|
||||||
type: 'button',
|
|
||||||
text: relative.fullName,
|
|
||||||
onClick: () => open(relative.id),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
parent.append(
|
|
||||||
el(
|
|
||||||
'div',
|
|
||||||
{ class: 'people__rel' },
|
|
||||||
el('span', { class: 'people__rel-title', text: title }),
|
|
||||||
list,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import type { PersonCard, PersonListItem, PersonRel, PersonRole } from '../net/api.ts';
|
||||||
|
import { t, type MessageKey } from '../i18n/strings.ts';
|
||||||
|
import { el } from './dom.ts';
|
||||||
|
|
||||||
|
const ROLE_KEYS: Record<PersonRole, MessageKey> = {
|
||||||
|
student: 'peopleRoleStudent',
|
||||||
|
staff: 'peopleRoleStaff',
|
||||||
|
parent: 'peopleRoleParent',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function roleLabels(roles: readonly string[]): string {
|
||||||
|
return roles
|
||||||
|
.map((role) => (role in ROLE_KEYS ? t(ROLE_KEYS[role as PersonRole]) : role))
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placement(person: Pick<PersonListItem, 'classYear' | 'classLetter' | 'positionLabel'>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (person.classYear !== null && person.classLetter !== null) {
|
||||||
|
parts.push(`${person.classYear}${person.classLetter}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (person.positionLabel !== null && person.positionLabel.length > 0) {
|
||||||
|
parts.push(person.positionLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join(' · ') : '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPersonCard(
|
||||||
|
parent: HTMLElement,
|
||||||
|
card: PersonCard,
|
||||||
|
onRelative: (id: string) => void,
|
||||||
|
): void {
|
||||||
|
parent.append(
|
||||||
|
el('h3', { class: 'people__card-name', text: card.fullName }),
|
||||||
|
el('p', { class: 'people__card-meta', text: cardMeta(card) }),
|
||||||
|
);
|
||||||
|
appendPairs(parent, t('peopleBody'), card.body);
|
||||||
|
appendPairs(parent, t('peopleSkills'), card.skills);
|
||||||
|
appendTags(parent, t('peopleTraits'), card.traits.map((row) => row.label));
|
||||||
|
appendNeeds(parent, t('peopleNeeds'), card.needs);
|
||||||
|
|
||||||
|
const family = section(t('peopleFamily'));
|
||||||
|
appendRelatives(family, t('peopleParents'), card.family.parents, onRelative);
|
||||||
|
appendRelatives(family, t('peopleChildren'), card.family.children, onRelative);
|
||||||
|
appendRelatives(family, t('peopleSiblings'), card.family.siblings, onRelative);
|
||||||
|
appendRelatives(family, t('peoplePartners'), card.family.partners, onRelative);
|
||||||
|
if (family.childElementCount > 1) {
|
||||||
|
parent.append(family);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardMeta(card: PersonCard): string {
|
||||||
|
const bits = [
|
||||||
|
roleLabels(card.roles),
|
||||||
|
card.female ? t('peopleFemale') : t('peopleMale'),
|
||||||
|
String(card.age),
|
||||||
|
placement(card),
|
||||||
|
].filter((bit) => bit.length > 0 && bit !== '—');
|
||||||
|
return bits.join(' · ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title: string): HTMLElement {
|
||||||
|
return el('div', { class: 'people__section' }, el('h4', { class: 'people__section-title', text: title }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name and value in two columns, several columns per row. Fourteen skills as a bulleted list ran
|
||||||
|
* the card past the fold while three quarters of its width sat empty.
|
||||||
|
*/
|
||||||
|
function appendPairs(
|
||||||
|
parent: HTMLElement,
|
||||||
|
title: string,
|
||||||
|
rows: readonly { readonly label: string; readonly value: string }[],
|
||||||
|
): void {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = el('dl', { class: 'people__pairs' });
|
||||||
|
for (const row of rows) {
|
||||||
|
grid.append(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'people__pair' },
|
||||||
|
el('dt', { text: row.label }),
|
||||||
|
el('dd', { text: row.value }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.append(section(title), grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendTags(parent: HTMLElement, title: string, values: readonly string[]): void {
|
||||||
|
if (values.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = el('div', { class: 'people__tags' });
|
||||||
|
for (const value of values) {
|
||||||
|
tags.append(el('span', { class: 'people__tag', text: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.append(section(title), tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendNeeds(
|
||||||
|
parent: HTMLElement,
|
||||||
|
title: string,
|
||||||
|
rows: readonly { readonly label: string; readonly value: number }[],
|
||||||
|
): void {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grid = el('div', { class: 'people__needs' });
|
||||||
|
for (const row of rows) {
|
||||||
|
const share = Math.max(0, Math.min(1, row.value));
|
||||||
|
const fill = el('span', { class: 'people__need-fill' });
|
||||||
|
fill.style.width = `${Math.round(share * 100)}%`;
|
||||||
|
fill.classList.toggle('people__need-fill--low', share < 0.25);
|
||||||
|
|
||||||
|
grid.append(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'people__need' },
|
||||||
|
el('span', { class: 'people__need-label', text: row.label }),
|
||||||
|
el('span', { class: 'people__need-value', text: `${Math.round(share * 100)}%` }),
|
||||||
|
el('span', { class: 'people__need-track' }, fill),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.append(section(title), grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendRelatives(
|
||||||
|
parent: HTMLElement,
|
||||||
|
title: string,
|
||||||
|
relatives: readonly PersonRel[],
|
||||||
|
open: (id: string) => void,
|
||||||
|
): void {
|
||||||
|
if (relatives.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = el('span', { class: 'people__rel-list' });
|
||||||
|
for (const relative of relatives) {
|
||||||
|
list.append(
|
||||||
|
el('button', {
|
||||||
|
class: 'people__link',
|
||||||
|
type: 'button',
|
||||||
|
text: relative.fullName,
|
||||||
|
onClick: () => open(relative.id),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.append(
|
||||||
|
el(
|
||||||
|
'div',
|
||||||
|
{ class: 'people__rel' },
|
||||||
|
el('span', { class: 'people__rel-title', text: title }),
|
||||||
|
list,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
/// <summary>Bells and breaks of one school day. A catalog may have only one concrete frame.</summary>
|
||||||
|
public sealed class DayFrameDef : Def
|
||||||
|
{
|
||||||
|
/// <summary>Local game-clock time of the first lesson, <c>HH:mm</c>.</summary>
|
||||||
|
public string FirstLesson { get; init; } = "08:30";
|
||||||
|
|
||||||
|
public int LessonCount { get; init; }
|
||||||
|
|
||||||
|
public int LessonMinutes { get; init; }
|
||||||
|
|
||||||
|
public int BreakMinutes { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The long break follows this 1-based lesson. Zero means every break is short.</summary>
|
||||||
|
public int LongBreakAfter { get; init; }
|
||||||
|
|
||||||
|
public int LongBreakMinutes { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class MonthDay
|
||||||
|
{
|
||||||
|
public int Month { get; init; }
|
||||||
|
|
||||||
|
public int Day { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A holiday range in month-day, repeating every academic year. When start is after end
|
||||||
|
/// (winter), the range wraps across 1 January.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HolidayDef : Def
|
||||||
|
{
|
||||||
|
public MonthDay Start { get; init; } = new();
|
||||||
|
|
||||||
|
public MonthDay End { get; init; } = new();
|
||||||
|
}
|
||||||
@@ -309,6 +309,8 @@ public sealed class CatalogLoader
|
|||||||
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal);
|
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal);
|
||||||
var subjects = new Dictionary<string, SubjectDef>(StringComparer.Ordinal);
|
var subjects = new Dictionary<string, SubjectDef>(StringComparer.Ordinal);
|
||||||
var staffing = new Dictionary<string, StaffingDef>(StringComparer.Ordinal);
|
var staffing = new Dictionary<string, StaffingDef>(StringComparer.Ordinal);
|
||||||
|
var dayFrames = new Dictionary<string, DayFrameDef>(StringComparer.Ordinal);
|
||||||
|
var holidays = new Dictionary<string, HolidayDef>(StringComparer.Ordinal);
|
||||||
|
|
||||||
foreach (var (key, json) in resolved)
|
foreach (var (key, json) in resolved)
|
||||||
{
|
{
|
||||||
@@ -359,6 +361,12 @@ public sealed class CatalogLoader
|
|||||||
case DefKind.Staffing:
|
case DefKind.Staffing:
|
||||||
staffing[key.Name] = Jsonc.Deserialize<StaffingDef>(json);
|
staffing[key.Name] = Jsonc.Deserialize<StaffingDef>(json);
|
||||||
break;
|
break;
|
||||||
|
case DefKind.DayFrame:
|
||||||
|
dayFrames[key.Name] = Jsonc.Deserialize<DayFrameDef>(json);
|
||||||
|
break;
|
||||||
|
case DefKind.Holiday:
|
||||||
|
holidays[key.Name] = Jsonc.Deserialize<HolidayDef>(json);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,6 +387,8 @@ public sealed class CatalogLoader
|
|||||||
nameSets,
|
nameSets,
|
||||||
subjects,
|
subjects,
|
||||||
staffing,
|
staffing,
|
||||||
|
dayFrames,
|
||||||
|
holidays,
|
||||||
ru,
|
ru,
|
||||||
en);
|
en);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public sealed class DefCatalog
|
|||||||
IReadOnlyDictionary<string, NameSetDef> nameSets,
|
IReadOnlyDictionary<string, NameSetDef> nameSets,
|
||||||
IReadOnlyDictionary<string, SubjectDef> subjects,
|
IReadOnlyDictionary<string, SubjectDef> subjects,
|
||||||
IReadOnlyDictionary<string, StaffingDef> staffing,
|
IReadOnlyDictionary<string, StaffingDef> staffing,
|
||||||
|
IReadOnlyDictionary<string, DayFrameDef> dayFrames,
|
||||||
|
IReadOnlyDictionary<string, HolidayDef> holidays,
|
||||||
IReadOnlyDictionary<string, string> ru,
|
IReadOnlyDictionary<string, string> ru,
|
||||||
IReadOnlyDictionary<string, string> en)
|
IReadOnlyDictionary<string, string> en)
|
||||||
{
|
{
|
||||||
@@ -42,6 +44,8 @@ public sealed class DefCatalog
|
|||||||
NameSets = nameSets;
|
NameSets = nameSets;
|
||||||
Subjects = subjects;
|
Subjects = subjects;
|
||||||
Staffing = staffing;
|
Staffing = staffing;
|
||||||
|
DayFrames = dayFrames;
|
||||||
|
Holidays = holidays;
|
||||||
_ru = ru;
|
_ru = ru;
|
||||||
_en = en;
|
_en = en;
|
||||||
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
|
AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f);
|
||||||
@@ -86,9 +90,16 @@ public sealed class DefCatalog
|
|||||||
|
|
||||||
public IReadOnlyDictionary<string, StaffingDef> Staffing { get; }
|
public IReadOnlyDictionary<string, StaffingDef> Staffing { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, DayFrameDef> DayFrames { get; }
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, HolidayDef> Holidays { get; }
|
||||||
|
|
||||||
/// <summary>The one concrete staffing ruleset, or null when a pack has not defined it.</summary>
|
/// <summary>The one concrete staffing ruleset, or null when a pack has not defined it.</summary>
|
||||||
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
|
public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract);
|
||||||
|
|
||||||
|
/// <summary>The one concrete day frame, or null when a pack has not defined it.</summary>
|
||||||
|
public DayFrameDef? DayFrame => DayFrames.Values.FirstOrDefault(def => !def.Abstract);
|
||||||
|
|
||||||
private readonly IReadOnlyDictionary<string, string> _ru;
|
private readonly IReadOnlyDictionary<string, string> _ru;
|
||||||
private readonly IReadOnlyDictionary<string, string> _en;
|
private readonly IReadOnlyDictionary<string, string> _en;
|
||||||
|
|
||||||
@@ -111,6 +122,8 @@ public sealed class DefCatalog
|
|||||||
DefKind.NameSet => NameSets.GetValueOrDefault(defName),
|
DefKind.NameSet => NameSets.GetValueOrDefault(defName),
|
||||||
DefKind.Subject => Subjects.GetValueOrDefault(defName),
|
DefKind.Subject => Subjects.GetValueOrDefault(defName),
|
||||||
DefKind.Staffing => Staffing.GetValueOrDefault(defName),
|
DefKind.Staffing => Staffing.GetValueOrDefault(defName),
|
||||||
|
DefKind.DayFrame => DayFrames.GetValueOrDefault(defName),
|
||||||
|
DefKind.Holiday => Holidays.GetValueOrDefault(defName),
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,6 +199,8 @@ public sealed class DefCatalog
|
|||||||
NameSetDef => DefKind.NameSet,
|
NameSetDef => DefKind.NameSet,
|
||||||
SubjectDef => DefKind.Subject,
|
SubjectDef => DefKind.Subject,
|
||||||
StaffingDef => DefKind.Staffing,
|
StaffingDef => DefKind.Staffing,
|
||||||
|
DayFrameDef => DefKind.DayFrame,
|
||||||
|
HolidayDef => DefKind.Holiday,
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(def)),
|
_ => throw new ArgumentOutOfRangeException(nameof(def)),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ public enum DefKind
|
|||||||
NameSet,
|
NameSet,
|
||||||
Subject,
|
Subject,
|
||||||
Staffing,
|
Staffing,
|
||||||
|
DayFrame,
|
||||||
|
Holiday,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
|
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
|
||||||
|
|||||||
@@ -107,6 +107,12 @@ internal static class PackPaths
|
|||||||
case "staffing":
|
case "staffing":
|
||||||
kind = DefKind.Staffing;
|
kind = DefKind.Staffing;
|
||||||
return true;
|
return true;
|
||||||
|
case "dayframe":
|
||||||
|
kind = DefKind.DayFrame;
|
||||||
|
return true;
|
||||||
|
case "holidays":
|
||||||
|
kind = DefKind.Holiday;
|
||||||
|
return true;
|
||||||
default:
|
default:
|
||||||
kind = default;
|
kind = default;
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -39,11 +39,26 @@ internal static class PeopleDefValidator
|
|||||||
ValidateStaffing(staffing);
|
ValidateStaffing(staffing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (var frame in catalog.DayFrames.Values)
|
||||||
|
{
|
||||||
|
ValidateDayFrame(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var holiday in catalog.Holidays.Values)
|
||||||
|
{
|
||||||
|
ValidateHoliday(holiday);
|
||||||
|
}
|
||||||
|
|
||||||
if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1)
|
if (catalog.Staffing.Values.Count(def => !def.Abstract) > 1)
|
||||||
{
|
{
|
||||||
throw new ContentLoadException("A catalog may only have one concrete StaffingDef.");
|
throw new ContentLoadException("A catalog may only have one concrete StaffingDef.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (catalog.DayFrames.Values.Count(def => !def.Abstract) > 1)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException("A catalog may only have one concrete DayFrameDef.");
|
||||||
|
}
|
||||||
|
|
||||||
RequireBuildInputs(catalog);
|
RequireBuildInputs(catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +283,16 @@ internal static class PeopleDefValidator
|
|||||||
throw new ContentLoadException($"SubjectDef '{subject.DefName}' skill '{share.Skill}' share cannot be negative.");
|
throw new ContentLoadException($"SubjectDef '{subject.DefName}' skill '{share.Skill}' share cannot be negative.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(subject.Room))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!catalog.Rooms.TryGetValue(subject.Room, out var room) || room.Abstract)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"SubjectDef '{subject.DefName}' references unknown RoomDef '{subject.Room}'.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ValidateStaffing(StaffingDef staffing)
|
private static void ValidateStaffing(StaffingDef staffing)
|
||||||
@@ -308,6 +333,69 @@ internal static class PeopleDefValidator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void ValidateDayFrame(DayFrameDef frame)
|
||||||
|
{
|
||||||
|
if (frame.Abstract)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SchoolDay.TryParseTime(frame.FirstLesson, out _))
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' firstLesson '{frame.FirstLesson}' is not a time.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.LessonCount is < 1 or > 12)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' lessonCount must be 1–12.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.LessonMinutes is < 1 or > 180)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' lessonMinutes must be 1–180.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.BreakMinutes is < 0 or > 60)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' breakMinutes must be 0–60.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.LongBreakMinutes is < 0 or > 120)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakMinutes must be 0–120.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.LongBreakAfter is < 0 || frame.LongBreakAfter >= frame.LessonCount)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakAfter must be 0 or a lesson before the last.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateHoliday(HolidayDef holiday)
|
||||||
|
{
|
||||||
|
if (holiday.Abstract)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ValidateMonthDay(holiday.Start, holiday.DefName, "start");
|
||||||
|
ValidateMonthDay(holiday.End, holiday.DefName, "end");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ValidateMonthDay(MonthDay stamp, string defName, string field)
|
||||||
|
{
|
||||||
|
if (stamp.Month is < 1 or > 12)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"HolidayDef '{defName}' {field} month must be 1–12.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var days = DateTime.DaysInMonth(2000, stamp.Month);
|
||||||
|
if (stamp.Day < 1 || stamp.Day > days)
|
||||||
|
{
|
||||||
|
throw new ContentLoadException($"HolidayDef '{defName}' {field} day is not valid for that month.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static void ValidateNameSet(NameSetDef names)
|
private static void ValidateNameSet(NameSetDef names)
|
||||||
{
|
{
|
||||||
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
|
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ public sealed class SubjectDef : Def
|
|||||||
public int HoursPerWeek { get; init; }
|
public int HoursPerWeek { get; init; }
|
||||||
|
|
||||||
public IReadOnlyList<SubjectSkillShare> Skills { get; init; } = [];
|
public IReadOnlyList<SubjectSkillShare> Skills { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// RoomDef the lesson needs. Null means the class's homeroom. PE uses a gym, informatics a lab.
|
||||||
|
/// </summary>
|
||||||
|
public string? Room { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class TraitSkillModifier
|
public sealed class TraitSkillModifier
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace HSchool.Content;
|
||||||
|
|
||||||
|
public enum DaySlotKind
|
||||||
|
{
|
||||||
|
Outside,
|
||||||
|
Lesson,
|
||||||
|
Break,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the bells put this instant. <see cref="Index"/> is the 1-based lesson number, or the
|
||||||
|
/// lesson the current break follows. Zero when the school is closed.
|
||||||
|
/// </summary>
|
||||||
|
public readonly record struct DaySlot(DaySlotKind Kind, int Index)
|
||||||
|
{
|
||||||
|
public static DaySlot Outside { get; } = new(DaySlotKind.Outside, 0);
|
||||||
|
|
||||||
|
public static DaySlot Lesson(int number) => new(DaySlotKind.Lesson, number);
|
||||||
|
|
||||||
|
public static DaySlot BreakAfter(int lesson) => new(DaySlotKind.Break, lesson);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns catalog bells, holidays and the configured week length into "which slot is it now".
|
||||||
|
/// Week length is a school rule, not a def — pass it in from <c>SimulationOptions</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class SchoolDay
|
||||||
|
{
|
||||||
|
public static DaySlot At(DefCatalog catalog, DateTime time, int weekDays)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
|
if (weekDays is < 5 or > 7)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(weekDays), weekDays, "School week must be 5–7 days.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var utc = DateTime.SpecifyKind(time, DateTimeKind.Utc);
|
||||||
|
if (!IsWeekday(utc, weekDays) || IsHoliday(catalog, utc))
|
||||||
|
{
|
||||||
|
return DaySlot.Outside;
|
||||||
|
}
|
||||||
|
|
||||||
|
var frame = catalog.DayFrame;
|
||||||
|
if (frame is null || !TryParseTime(frame.FirstLesson, out var first))
|
||||||
|
{
|
||||||
|
return DaySlot.Outside;
|
||||||
|
}
|
||||||
|
|
||||||
|
var clock = utc.TimeOfDay;
|
||||||
|
var cursor = first.ToTimeSpan();
|
||||||
|
var lesson = TimeSpan.FromMinutes(frame.LessonMinutes);
|
||||||
|
for (var i = 1; i <= frame.LessonCount; i++)
|
||||||
|
{
|
||||||
|
var lessonEnd = cursor + lesson;
|
||||||
|
if (clock >= cursor && clock < lessonEnd)
|
||||||
|
{
|
||||||
|
return DaySlot.Lesson(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = lessonEnd;
|
||||||
|
if (i == frame.LessonCount)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var gap = TimeSpan.FromMinutes(i == frame.LongBreakAfter ? frame.LongBreakMinutes : frame.BreakMinutes);
|
||||||
|
var breakEnd = cursor + gap;
|
||||||
|
if (clock >= cursor && clock < breakEnd)
|
||||||
|
{
|
||||||
|
return DaySlot.BreakAfter(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = breakEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DaySlot.Outside;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool TryParseTime(string text, out TimeOnly value) =>
|
||||||
|
TimeOnly.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.None, out value);
|
||||||
|
|
||||||
|
public static bool Contains(HolidayDef holiday, DateTime date)
|
||||||
|
{
|
||||||
|
var day = DateTime.SpecifyKind(date, DateTimeKind.Utc).Date;
|
||||||
|
var stamp = day.Month * 100 + day.Day;
|
||||||
|
var start = holiday.Start.Month * 100 + holiday.Start.Day;
|
||||||
|
var end = holiday.End.Month * 100 + holiday.End.Day;
|
||||||
|
if (start <= end)
|
||||||
|
{
|
||||||
|
return stamp >= start && stamp <= end;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stamp >= start || stamp <= end;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsWeekday(DateTime time, int weekDays)
|
||||||
|
{
|
||||||
|
// Monday = 0 … Sunday = 6. A 5-day week is Mon–Fri; 6 adds Saturday; 7 is every day.
|
||||||
|
var mondayBased = ((int)time.DayOfWeek + 6) % 7;
|
||||||
|
return mondayBased < weekDays;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsHoliday(DefCatalog catalog, DateTime time)
|
||||||
|
{
|
||||||
|
foreach (var holiday in catalog.Holidays.Values)
|
||||||
|
{
|
||||||
|
if (!holiday.Abstract && Contains(holiday, time))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,8 @@ internal sealed record CatalogResponse(
|
|||||||
IReadOnlyList<DefInfoResponse> Things,
|
IReadOnlyList<DefInfoResponse> Things,
|
||||||
IReadOnlyList<DefInfoResponse> NameSets,
|
IReadOnlyList<DefInfoResponse> NameSets,
|
||||||
IReadOnlyList<SubjectInfoResponse> Subjects,
|
IReadOnlyList<SubjectInfoResponse> Subjects,
|
||||||
|
DayFrameResponse? DayFrame,
|
||||||
|
IReadOnlyList<HolidayInfoResponse> Holidays,
|
||||||
MapLayout DefaultMap)
|
MapLayout DefaultMap)
|
||||||
{
|
{
|
||||||
public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) =>
|
public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) =>
|
||||||
@@ -99,6 +101,8 @@ internal sealed record CatalogResponse(
|
|||||||
PlaceableThings(catalog, locale),
|
PlaceableThings(catalog, locale),
|
||||||
Placeable(catalog.NameSets.Values, catalog, locale),
|
Placeable(catalog.NameSets.Values, catalog, locale),
|
||||||
PlaceableSubjects(catalog, locale),
|
PlaceableSubjects(catalog, locale),
|
||||||
|
MapDayFrame(catalog, locale),
|
||||||
|
PlaceableHolidays(catalog, locale),
|
||||||
map);
|
map);
|
||||||
|
|
||||||
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
|
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
|
||||||
@@ -140,7 +144,35 @@ internal sealed record CatalogResponse(
|
|||||||
def.Grades.Min,
|
def.Grades.Min,
|
||||||
def.Grades.Max,
|
def.Grades.Max,
|
||||||
def.HoursPerWeek,
|
def.HoursPerWeek,
|
||||||
def.Skills.Select(share => new SubjectSkillInfo(share.Skill, share.Share)).ToArray()))
|
def.Skills.Select(share => new SubjectSkillInfo(share.Skill, share.Share)).ToArray(),
|
||||||
|
def.Room))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
private static DayFrameResponse? MapDayFrame(DefCatalog catalog, string locale)
|
||||||
|
{
|
||||||
|
var frame = catalog.DayFrame;
|
||||||
|
return frame is null
|
||||||
|
? null
|
||||||
|
: new DayFrameResponse(
|
||||||
|
frame.DefName,
|
||||||
|
catalog.Label(locale, frame),
|
||||||
|
frame.FirstLesson,
|
||||||
|
frame.LessonCount,
|
||||||
|
frame.LessonMinutes,
|
||||||
|
frame.BreakMinutes,
|
||||||
|
frame.LongBreakAfter,
|
||||||
|
frame.LongBreakMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<HolidayInfoResponse> PlaceableHolidays(DefCatalog catalog, string locale) =>
|
||||||
|
catalog.Holidays.Values
|
||||||
|
.Where(def => !def.Abstract)
|
||||||
|
.OrderBy(def => def.DefName, StringComparer.Ordinal)
|
||||||
|
.Select(def => new HolidayInfoResponse(
|
||||||
|
def.DefName,
|
||||||
|
catalog.Label(locale, def),
|
||||||
|
def.Start,
|
||||||
|
def.End))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,4 +197,17 @@ internal sealed record SubjectInfoResponse(
|
|||||||
int GradeMin,
|
int GradeMin,
|
||||||
int GradeMax,
|
int GradeMax,
|
||||||
int HoursPerWeek,
|
int HoursPerWeek,
|
||||||
IReadOnlyList<SubjectSkillInfo> Skills);
|
IReadOnlyList<SubjectSkillInfo> Skills,
|
||||||
|
string? Room);
|
||||||
|
|
||||||
|
internal sealed record DayFrameResponse(
|
||||||
|
string DefName,
|
||||||
|
string Label,
|
||||||
|
string FirstLesson,
|
||||||
|
int LessonCount,
|
||||||
|
int LessonMinutes,
|
||||||
|
int BreakMinutes,
|
||||||
|
int LongBreakAfter,
|
||||||
|
int LongBreakMinutes);
|
||||||
|
|
||||||
|
internal sealed record HolidayInfoResponse(string DefName, string Label, MonthDay Start, MonthDay End);
|
||||||
|
|||||||
@@ -180,7 +180,9 @@ internal sealed record StaffingResponse(
|
|||||||
float Remaining,
|
float Remaining,
|
||||||
IReadOnlyList<UncoveredSubjectResponse> Uncovered,
|
IReadOnlyList<UncoveredSubjectResponse> Uncovered,
|
||||||
IReadOnlyList<ApplicantResponse> Applicants,
|
IReadOnlyList<ApplicantResponse> Applicants,
|
||||||
IReadOnlyList<StaffMemberResponse> Staff);
|
IReadOnlyList<StaffMemberResponse> Staff,
|
||||||
|
IReadOnlyList<DefLabelResponse> Positions,
|
||||||
|
IReadOnlyList<UncoveredSubjectResponse> Subjects);
|
||||||
|
|
||||||
internal sealed record UncoveredSubjectResponse(
|
internal sealed record UncoveredSubjectResponse(
|
||||||
string DefName,
|
string DefName,
|
||||||
@@ -196,7 +198,8 @@ internal sealed record ApplicantResponse(
|
|||||||
int Age,
|
int Age,
|
||||||
bool IsParent,
|
bool IsParent,
|
||||||
float HourlyWageAsk,
|
float HourlyWageAsk,
|
||||||
float MonthlyBase);
|
float MonthlyBase,
|
||||||
|
IReadOnlyList<LabeledStatResponse> Skills);
|
||||||
|
|
||||||
internal sealed record StaffMemberResponse(
|
internal sealed record StaffMemberResponse(
|
||||||
string Id,
|
string Id,
|
||||||
@@ -245,7 +248,8 @@ internal static class StaffingMapper
|
|||||||
applicant.Person.AgeOn(asOf),
|
applicant.Person.AgeOn(asOf),
|
||||||
applicant.Person.IsParent,
|
applicant.Person.IsParent,
|
||||||
applicant.HourlyWageAsk,
|
applicant.HourlyWageAsk,
|
||||||
rules is null ? 0f : Staffing.MonthlyBase(rules, applicant.HourlyWageAsk)))
|
rules is null ? 0f : Staffing.MonthlyBase(rules, applicant.HourlyWageAsk),
|
||||||
|
StrongSkills(applicant.Person, catalog, locale)))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
var staff = roster.People
|
var staff = roster.People
|
||||||
@@ -255,7 +259,42 @@ internal static class StaffingMapper
|
|||||||
.Select(person => Member(person, catalog, rules, locale, asOf))
|
.Select(person => Member(person, catalog, rules, locale, asOf))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff);
|
var positions = catalog is null
|
||||||
|
? Array.Empty<DefLabelResponse>()
|
||||||
|
: catalog.Positions.Values
|
||||||
|
.Where(def => !def.Abstract)
|
||||||
|
.Select(def => new DefLabelResponse(def.DefName, catalog.Label(locale, def)))
|
||||||
|
.OrderBy(row => row.Label, StringComparer.Ordinal)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var subjects = catalog is null
|
||||||
|
? Array.Empty<UncoveredSubjectResponse>()
|
||||||
|
: catalog.Subjects.Values
|
||||||
|
.Where(def => !def.Abstract)
|
||||||
|
.Select(def => new UncoveredSubjectResponse(
|
||||||
|
def.DefName,
|
||||||
|
catalog.Label(locale, def),
|
||||||
|
def.Grades.Min,
|
||||||
|
def.Grades.Max,
|
||||||
|
def.HoursPerWeek))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff, positions, subjects);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<LabeledStatResponse> StrongSkills(Person person, DefCatalog? catalog, string locale)
|
||||||
|
{
|
||||||
|
return person.Skills
|
||||||
|
.OrderByDescending(pair => pair.Value)
|
||||||
|
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
|
||||||
|
.Take(3)
|
||||||
|
.Select(pair => new LabeledStatResponse(
|
||||||
|
pair.Key,
|
||||||
|
catalog is not null && catalog.Skills.TryGetValue(pair.Key, out var def)
|
||||||
|
? catalog.Label(locale, def)
|
||||||
|
: pair.Key,
|
||||||
|
pair.Value.ToString()))
|
||||||
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static StaffMemberResponse Member(
|
private static StaffMemberResponse Member(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ internal static class SchoolEndpoints
|
|||||||
state.MaxSchools,
|
state.MaxSchools,
|
||||||
options.DefaultStartDate,
|
options.DefaultStartDate,
|
||||||
options.GameMinutesPerRealSecond,
|
options.GameMinutesPerRealSecond,
|
||||||
|
options.SchoolWeekDays,
|
||||||
[.. state.Schools.Select(SchoolResponse.From)]);
|
[.. state.Schools.Select(SchoolResponse.From)]);
|
||||||
})
|
})
|
||||||
.WithName("GetSchools");
|
.WithName("GetSchools");
|
||||||
@@ -474,6 +475,7 @@ internal sealed record SchoolsResponse(
|
|||||||
int MaxSchools,
|
int MaxSchools,
|
||||||
DateTime DefaultStartDate,
|
DateTime DefaultStartDate,
|
||||||
double GameMinutesPerRealSecond,
|
double GameMinutesPerRealSecond,
|
||||||
|
int SchoolWeekDays,
|
||||||
IReadOnlyList<SchoolResponse> Schools);
|
IReadOnlyList<SchoolResponse> Schools);
|
||||||
|
|
||||||
internal sealed record RandomNameResponse(string Name);
|
internal sealed record RandomNameResponse(string Name);
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ internal static class PersonCardReader
|
|||||||
}
|
}
|
||||||
|
|
||||||
var person = roster.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal));
|
var person = roster.People.FirstOrDefault(candidate => candidate.Id.Equals(personId, StringComparison.Ordinal));
|
||||||
|
person ??= school.Applicants?.Applicants
|
||||||
|
.FirstOrDefault(applicant => applicant.Person.Id.Equals(personId, StringComparison.Ordinal))
|
||||||
|
?.Person;
|
||||||
if (person is null)
|
if (person is null)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ builder.Services
|
|||||||
.Validate(options => !string.IsNullOrWhiteSpace(options.ModsDirectory), "Simulation:ModsDirectory must be set.")
|
.Validate(options => !string.IsNullOrWhiteSpace(options.ModsDirectory), "Simulation:ModsDirectory must be set.")
|
||||||
.Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.")
|
.Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.")
|
||||||
.Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.")
|
.Validate(options => options.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.")
|
||||||
|
.Validate(options => options.SchoolWeekDays is >= 5 and <= 7, "Simulation:SchoolWeekDays must be between 5 and 7.")
|
||||||
.ValidateOnStart();
|
.ValidateOnStart();
|
||||||
|
|
||||||
builder.Services.AddSingleton<GameCommandQueue>();
|
builder.Services.AddSingleton<GameCommandQueue>();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"SavesDirectory": "saves",
|
"SavesDirectory": "saves",
|
||||||
"ModsDirectory": "mods",
|
"ModsDirectory": "mods",
|
||||||
"SaveIntervalSeconds": 30,
|
"SaveIntervalSeconds": 30,
|
||||||
"MonthlyPayrollCap": 10000
|
"MonthlyPayrollCap": 10000,
|
||||||
|
"SchoolWeekDays": 5
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"defName": "StandardDay",
|
||||||
|
"firstLesson": "08:30",
|
||||||
|
"lessonCount": 7,
|
||||||
|
"lessonMinutes": 45,
|
||||||
|
"breakMinutes": 10,
|
||||||
|
"longBreakAfter": 3,
|
||||||
|
"longBreakMinutes": 20,
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"defName": "AutumnBreak",
|
||||||
|
"start": { "month": 10, "day": 29 },
|
||||||
|
"end": { "month": 11, "day": 4 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "WinterBreak",
|
||||||
|
"start": { "month": 12, "day": 31 },
|
||||||
|
"end": { "month": 1, "day": 13 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "SpringBreak",
|
||||||
|
"start": { "month": 3, "day": 24 },
|
||||||
|
"end": { "month": 3, "day": 31 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"defName": "SummerBreak",
|
||||||
|
"start": { "month": 6, "day": 1 },
|
||||||
|
"end": { "month": 8, "day": 31 },
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"defName": "Informatics",
|
"defName": "Informatics",
|
||||||
"grades": { "min": 5, "max": 11 },
|
"grades": { "min": 5, "max": 11 },
|
||||||
"hoursPerWeek": 1,
|
"hoursPerWeek": 1,
|
||||||
|
"room": "ComputerLab",
|
||||||
"skills": [{ "skill": "Informatics", "share": 1 }],
|
"skills": [{ "skill": "Informatics", "share": 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
"defName": "PhysicalEducation",
|
"defName": "PhysicalEducation",
|
||||||
"grades": { "min": 1, "max": 11 },
|
"grades": { "min": 1, "max": 11 },
|
||||||
"hoursPerWeek": 3,
|
"hoursPerWeek": 3,
|
||||||
|
"room": "GymHall",
|
||||||
"skills": [{ "skill": "PhysicalEducation", "share": 1 }],
|
"skills": [{ "skill": "PhysicalEducation", "share": 1 }],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -94,4 +94,9 @@
|
|||||||
"Athletic": "Athletic",
|
"Athletic": "Athletic",
|
||||||
"Heavy": "Heavy",
|
"Heavy": "Heavy",
|
||||||
"Obese": "Obese",
|
"Obese": "Obese",
|
||||||
|
"StandardDay": "School day",
|
||||||
|
"AutumnBreak": "Autumn break",
|
||||||
|
"WinterBreak": "Winter break",
|
||||||
|
"SpringBreak": "Spring break",
|
||||||
|
"SummerBreak": "Summer break",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,4 +94,9 @@
|
|||||||
"Athletic": "Атлетическое",
|
"Athletic": "Атлетическое",
|
||||||
"Heavy": "Плотное",
|
"Heavy": "Плотное",
|
||||||
"Obese": "Полное",
|
"Obese": "Полное",
|
||||||
|
"StandardDay": "Учебный день",
|
||||||
|
"AutumnBreak": "Осенние каникулы",
|
||||||
|
"WinterBreak": "Зимние каникулы",
|
||||||
|
"SpringBreak": "Весенние каникулы",
|
||||||
|
"SummerBreak": "Летние каникулы",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ public sealed class SimulationOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public float MonthlyPayrollCap { get; set; } = 10_000f;
|
public float MonthlyPayrollCap { get; set; } = 10_000f;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Working days from Monday. Five is Mon–Fri; six adds Saturday; seven is every day.
|
||||||
|
/// </summary>
|
||||||
|
public int SchoolWeekDays { get; set; } = 5;
|
||||||
|
|
||||||
/// <summary>Length of one fixed step.</summary>
|
/// <summary>Length of one fixed step.</summary>
|
||||||
public double FixedDeltaTime => 1d / TickRate;
|
public double FixedDeltaTime => 1d / TickRate;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
// creation form would offer the wrong hour.
|
// creation form would offer the wrong hour.
|
||||||
Assert.Equal(DateTimeKind.Utc, state.DefaultStartDate.Kind);
|
Assert.Equal(DateTimeKind.Utc, state.DefaultStartDate.Kind);
|
||||||
Assert.Equal(5d, state.GameMinutesPerRealSecond);
|
Assert.Equal(5d, state.GameMinutesPerRealSecond);
|
||||||
|
Assert.Equal(5, state.SchoolWeekDays);
|
||||||
Assert.Empty(state.Schools);
|
Assert.Empty(state.Schools);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +168,16 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
Assert.Equal(16, classroom.DefaultSeats);
|
Assert.Equal(16, classroom.DefaultSeats);
|
||||||
Assert.Empty(classroom.Slots);
|
Assert.Empty(classroom.Slots);
|
||||||
Assert.Empty(classroom.Positions);
|
Assert.Empty(classroom.Positions);
|
||||||
|
Assert.NotNull(ru.DayFrame);
|
||||||
|
Assert.Equal("08:30", ru.DayFrame.FirstLesson);
|
||||||
|
Assert.Equal(7, ru.DayFrame.LessonCount);
|
||||||
|
Assert.Equal(3, ru.DayFrame.LongBreakAfter);
|
||||||
|
Assert.Equal("Учебный день", ru.DayFrame.Label);
|
||||||
|
Assert.NotNull(en.DayFrame);
|
||||||
|
Assert.Equal("School day", en.DayFrame.Label);
|
||||||
|
Assert.Equal("GymHall", Assert.Single(ru.Subjects, subject => subject.DefName == "PhysicalEducation").Room);
|
||||||
|
Assert.Null(Assert.Single(ru.Subjects, subject => subject.DefName == "Mathematics").Room);
|
||||||
|
Assert.Contains(ru.Holidays, holiday => holiday.DefName == "SpringBreak");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -347,6 +358,7 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
int MaxSchools,
|
int MaxSchools,
|
||||||
DateTime DefaultStartDate,
|
DateTime DefaultStartDate,
|
||||||
double GameMinutesPerRealSecond,
|
double GameMinutesPerRealSecond,
|
||||||
|
int SchoolWeekDays,
|
||||||
IReadOnlyList<SchoolResponse> Schools);
|
IReadOnlyList<SchoolResponse> Schools);
|
||||||
|
|
||||||
private sealed record RandomNameResponse(string Name);
|
private sealed record RandomNameResponse(string Name);
|
||||||
@@ -367,6 +379,8 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
IReadOnlyList<DefInfoResponse> Things,
|
IReadOnlyList<DefInfoResponse> Things,
|
||||||
IReadOnlyList<DefInfoResponse> NameSets,
|
IReadOnlyList<DefInfoResponse> NameSets,
|
||||||
IReadOnlyList<SubjectInfoResponse> Subjects,
|
IReadOnlyList<SubjectInfoResponse> Subjects,
|
||||||
|
DayFrameResponse? DayFrame,
|
||||||
|
IReadOnlyList<HolidayInfoResponse> Holidays,
|
||||||
MapLayoutResponse DefaultMap);
|
MapLayoutResponse DefaultMap);
|
||||||
|
|
||||||
private sealed record DefInfoResponse(string DefName, string Label);
|
private sealed record DefInfoResponse(string DefName, string Label);
|
||||||
@@ -382,7 +396,19 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
|
|
||||||
private sealed record RoomSlotResponse(string Key, string Thing);
|
private sealed record RoomSlotResponse(string Key, string Thing);
|
||||||
|
|
||||||
private sealed record SubjectInfoResponse(string DefName, string Label);
|
private sealed record SubjectInfoResponse(string DefName, string Label, string? Room);
|
||||||
|
|
||||||
|
private sealed record DayFrameResponse(
|
||||||
|
string DefName,
|
||||||
|
string Label,
|
||||||
|
string FirstLesson,
|
||||||
|
int LessonCount,
|
||||||
|
int LessonMinutes,
|
||||||
|
int BreakMinutes,
|
||||||
|
int LongBreakAfter,
|
||||||
|
int LongBreakMinutes);
|
||||||
|
|
||||||
|
private sealed record HolidayInfoResponse(string DefName, string Label);
|
||||||
|
|
||||||
private sealed record MapLayoutResponse(TerritoryResponse? Territory);
|
private sealed record MapLayoutResponse(TerritoryResponse? Territory);
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,46 @@ public class StaffingApiTests(AppHostFixture fixture)
|
|||||||
Assert.Empty(staffing.Staff);
|
Assert.Empty(staffing.Staff);
|
||||||
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics");
|
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics");
|
||||||
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "PrimarySchool");
|
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "PrimarySchool");
|
||||||
|
Assert.NotEmpty(staffing.Positions);
|
||||||
|
Assert.Contains(staffing.Positions, position => position.DefName == "Teacher");
|
||||||
|
Assert.Contains(staffing.Subjects, subject => subject.DefName == "Mathematics");
|
||||||
|
Assert.All(staffing.Applicants, applicant => Assert.NotEmpty(applicant.Skills));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Card_OpensAGeneratedApplicant()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(client, "Штат карточка пула", Start);
|
||||||
|
var staffing = await GetStaffingAsync(client, school.Id);
|
||||||
|
var generated = staffing.Applicants.First(applicant => !applicant.IsParent);
|
||||||
|
|
||||||
|
var card = await client.GetFromJsonAsync<PersonCardResponse>(
|
||||||
|
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(generated.Id)}?lang=ru",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(card);
|
||||||
|
Assert.Equal(generated.Id, card.Id);
|
||||||
|
Assert.NotEmpty(card.Skills);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Hire_ParentKeepsBothRolesOnTheCard()
|
||||||
|
{
|
||||||
|
using var client = fixture.App.CreateHttpClient("server");
|
||||||
|
await SchoolApiTests.ResetAsync(client);
|
||||||
|
var school = await SchoolApiTests.CreateAsync(client, "Штат родитель", Start);
|
||||||
|
var before = await GetStaffingAsync(client, school.Id);
|
||||||
|
var parent = before.Applicants.First(applicant => applicant.IsParent);
|
||||||
|
|
||||||
|
await HireAsync(client, school.Id, parent.Id, "Teacher");
|
||||||
|
|
||||||
|
var card = await client.GetFromJsonAsync<PersonCardResponse>(
|
||||||
|
$"/api/schools/{school.Id}/people/{Uri.EscapeDataString(parent.Id)}?lang=ru",
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(card);
|
||||||
|
Assert.Contains("parent", card.Roles);
|
||||||
|
Assert.Contains("staff", card.Roles);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -217,11 +257,23 @@ public class StaffingApiTests(AppHostFixture fixture)
|
|||||||
float Remaining,
|
float Remaining,
|
||||||
IReadOnlyList<UncoveredSubjectResponse> Uncovered,
|
IReadOnlyList<UncoveredSubjectResponse> Uncovered,
|
||||||
IReadOnlyList<ApplicantResponse> Applicants,
|
IReadOnlyList<ApplicantResponse> Applicants,
|
||||||
IReadOnlyList<StaffMemberResponse> Staff);
|
IReadOnlyList<StaffMemberResponse> Staff,
|
||||||
|
IReadOnlyList<DefLabelResponse> Positions,
|
||||||
|
IReadOnlyList<UncoveredSubjectResponse> Subjects);
|
||||||
|
|
||||||
private sealed record UncoveredSubjectResponse(string DefName, string Label, int GradeMin, int GradeMax, int HoursPerWeek);
|
private sealed record UncoveredSubjectResponse(string DefName, string Label, int GradeMin, int GradeMax, int HoursPerWeek);
|
||||||
|
|
||||||
private sealed record ApplicantResponse(string Id, string FullName, bool Female, int Age, bool IsParent, float HourlyWageAsk, float MonthlyBase);
|
private sealed record ApplicantResponse(
|
||||||
|
string Id,
|
||||||
|
string FullName,
|
||||||
|
bool Female,
|
||||||
|
int Age,
|
||||||
|
bool IsParent,
|
||||||
|
float HourlyWageAsk,
|
||||||
|
float MonthlyBase,
|
||||||
|
IReadOnlyList<LabeledStatResponse> Skills);
|
||||||
|
|
||||||
|
private sealed record LabeledStatResponse(string Id, string Label, string Value);
|
||||||
|
|
||||||
private sealed record StaffMemberResponse(
|
private sealed record StaffMemberResponse(
|
||||||
string Id,
|
string Id,
|
||||||
@@ -237,6 +289,8 @@ public class StaffingApiTests(AppHostFixture fixture)
|
|||||||
|
|
||||||
private sealed record DefLabelResponse(string DefName, string Label);
|
private sealed record DefLabelResponse(string DefName, string Label);
|
||||||
|
|
||||||
|
private sealed record PersonCardResponse(string Id, IReadOnlyList<string> Roles, IReadOnlyList<LabeledStatResponse> Skills);
|
||||||
|
|
||||||
private sealed record PeopleListResponse(
|
private sealed record PeopleListResponse(
|
||||||
int Total,
|
int Total,
|
||||||
int Page,
|
int Page,
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
namespace HSchool.Content.Tests;
|
||||||
|
|
||||||
|
public class CalendarTests
|
||||||
|
{
|
||||||
|
private readonly CatalogLoader _loader = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void VanillaCore_LoadsDayFrameAndHolidays()
|
||||||
|
{
|
||||||
|
var catalog = LoadVanilla();
|
||||||
|
var frame = catalog.DayFrame;
|
||||||
|
|
||||||
|
Assert.NotNull(frame);
|
||||||
|
Assert.Equal("08:30", frame.FirstLesson);
|
||||||
|
Assert.Equal(7, frame.LessonCount);
|
||||||
|
Assert.Equal(45, frame.LessonMinutes);
|
||||||
|
Assert.Equal(10, frame.BreakMinutes);
|
||||||
|
Assert.Equal(3, frame.LongBreakAfter);
|
||||||
|
Assert.Equal(20, frame.LongBreakMinutes);
|
||||||
|
Assert.Equal("Учебный день", catalog.Label("ru", frame));
|
||||||
|
Assert.Equal("School day", catalog.Label("en", frame));
|
||||||
|
Assert.Equal("GymHall", catalog.Subjects["PhysicalEducation"].Room);
|
||||||
|
Assert.Equal("ComputerLab", catalog.Subjects["Informatics"].Room);
|
||||||
|
Assert.Null(catalog.Subjects["Mathematics"].Room);
|
||||||
|
Assert.True(catalog.Holidays.ContainsKey("SpringBreak"));
|
||||||
|
Assert.Equal(24, catalog.Holidays["SpringBreak"].Start.Day);
|
||||||
|
Assert.Equal(31, catalog.Holidays["SpringBreak"].End.Day);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Saturday_IsOutsideOnAFiveDayWeek()
|
||||||
|
{
|
||||||
|
var slot = SchoolDay.At(LoadVanilla(), new DateTime(2012, 3, 31, 10, 20, 0, DateTimeKind.Utc), weekDays: 5);
|
||||||
|
|
||||||
|
Assert.Equal(DaySlot.Outside, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Holiday_IsOutsideEvenOnASevenDayWeek()
|
||||||
|
{
|
||||||
|
var slot = SchoolDay.At(LoadVanilla(), new DateTime(2012, 3, 31, 10, 20, 0, DateTimeKind.Utc), weekDays: 7);
|
||||||
|
|
||||||
|
Assert.Equal(DaySlot.Outside, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeekdaySchoolMorning_IsLessonThree()
|
||||||
|
{
|
||||||
|
var slot = SchoolDay.At(LoadVanilla(), new DateTime(2012, 4, 3, 10, 20, 0, DateTimeKind.Utc), weekDays: 5);
|
||||||
|
|
||||||
|
Assert.Equal(DaySlot.Lesson(3), slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WeekdayNight_IsOutside()
|
||||||
|
{
|
||||||
|
var slot = SchoolDay.At(LoadVanilla(), new DateTime(2012, 4, 3, 23, 0, 0, DateTimeKind.Utc), weekDays: 5);
|
||||||
|
|
||||||
|
Assert.Equal(DaySlot.Outside, slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SixDayWeek_MakesANonHolidaySaturdayASchoolDay()
|
||||||
|
{
|
||||||
|
var slot = SchoolDay.At(LoadVanilla(), new DateTime(2012, 4, 7, 10, 20, 0, DateTimeKind.Utc), weekDays: 6);
|
||||||
|
|
||||||
|
Assert.Equal(DaySlot.Lesson(3), slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void WinterBreak_WrapsTheNewYear()
|
||||||
|
{
|
||||||
|
var catalog = LoadVanilla();
|
||||||
|
var inside = SchoolDay.At(catalog, new DateTime(2012, 1, 5, 10, 20, 0, DateTimeKind.Utc), weekDays: 5);
|
||||||
|
var after = SchoolDay.At(catalog, new DateTime(2012, 1, 16, 10, 20, 0, DateTimeKind.Utc), weekDays: 5);
|
||||||
|
|
||||||
|
Assert.Equal(DaySlot.Outside, inside);
|
||||||
|
Assert.Equal(DaySlot.Lesson(3), after);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BreakAfterLessonTwo_IsTenFifteen()
|
||||||
|
{
|
||||||
|
var slot = SchoolDay.At(LoadVanilla(), new DateTime(2012, 4, 3, 10, 15, 0, DateTimeKind.Utc), weekDays: 5);
|
||||||
|
|
||||||
|
Assert.Equal(DaySlot.BreakAfter(2), slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Subject_UnknownRoom_FailsTheCatalog()
|
||||||
|
{
|
||||||
|
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
|
||||||
|
[CatalogLoader.CorePackId],
|
||||||
|
[
|
||||||
|
PackDocuments.Def(CatalogLoader.CorePackId, "skills", "math", """{ "defName": "Mathematics" }"""),
|
||||||
|
PackDocuments.Def(
|
||||||
|
CatalogLoader.CorePackId,
|
||||||
|
"subjects",
|
||||||
|
"pe",
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"defName": "PhysicalEducation",
|
||||||
|
"grades": { "min": 1, "max": 11 },
|
||||||
|
"hoursPerWeek": 3,
|
||||||
|
"room": "GhostHall",
|
||||||
|
"skills": [{ "skill": "Mathematics", "share": 1 }]
|
||||||
|
}
|
||||||
|
"""),
|
||||||
|
]));
|
||||||
|
|
||||||
|
Assert.Contains("GhostHall", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TwoConcreteDayFrames_FailTheCatalog()
|
||||||
|
{
|
||||||
|
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
|
||||||
|
[CatalogLoader.CorePackId],
|
||||||
|
[
|
||||||
|
PackDocuments.Def(
|
||||||
|
CatalogLoader.CorePackId,
|
||||||
|
"dayframe",
|
||||||
|
"a",
|
||||||
|
"""{ "defName": "A", "firstLesson": "08:30", "lessonCount": 1, "lessonMinutes": 45 }"""),
|
||||||
|
PackDocuments.Def(
|
||||||
|
CatalogLoader.CorePackId,
|
||||||
|
"dayframe",
|
||||||
|
"b",
|
||||||
|
"""{ "defName": "B", "firstLesson": "08:30", "lessonCount": 1, "lessonMinutes": 45 }"""),
|
||||||
|
]));
|
||||||
|
|
||||||
|
Assert.Contains("DayFrameDef", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DefCatalog LoadVanilla()
|
||||||
|
{
|
||||||
|
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
|
||||||
|
return _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,11 @@ public class PeopleDefTests
|
|||||||
Assert.Equal(0.25f, catalog.StaffingRules.ExtraSubjectSurcharge);
|
Assert.Equal(0.25f, catalog.StaffingRules.ExtraSubjectSurcharge);
|
||||||
Assert.Equal("Начальные классы", catalog.Label("ru", catalog.Subjects["PrimarySchool"]));
|
Assert.Equal("Начальные классы", catalog.Label("ru", catalog.Subjects["PrimarySchool"]));
|
||||||
Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"]));
|
Assert.Equal("Primary", catalog.Label("en", catalog.Subjects["PrimarySchool"]));
|
||||||
|
Assert.NotNull(catalog.DayFrame);
|
||||||
|
Assert.Equal(3, catalog.DayFrame.LongBreakAfter);
|
||||||
|
Assert.Equal("GymHall", catalog.Subjects["PhysicalEducation"].Room);
|
||||||
|
Assert.Equal("ComputerLab", catalog.Subjects["Informatics"].Room);
|
||||||
|
Assert.Null(catalog.Subjects["Mathematics"].Room);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ public class VanillaCoreTests
|
|||||||
Assert.Equal(12, catalog.StaffingRules.PoolSize);
|
Assert.Equal(12, catalog.StaffingRules.PoolSize);
|
||||||
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
|
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
|
||||||
Assert.Equal(0.25f, catalog.StaffingRules.ExtraSubjectSurcharge);
|
Assert.Equal(0.25f, catalog.StaffingRules.ExtraSubjectSurcharge);
|
||||||
|
Assert.NotNull(catalog.DayFrame);
|
||||||
|
Assert.Equal("08:30", catalog.DayFrame.FirstLesson);
|
||||||
|
Assert.Equal(7, catalog.DayFrame.LessonCount);
|
||||||
|
Assert.Equal("GymHall", catalog.Subjects["PhysicalEducation"].Room);
|
||||||
Assert.Equal(2, map.Buildings.Count);
|
Assert.Equal(2, map.Buildings.Count);
|
||||||
var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList();
|
var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList();
|
||||||
Assert.Equal(11, homerooms.Count);
|
Assert.Equal(11, homerooms.Count);
|
||||||
@@ -84,6 +88,8 @@ public class VanillaCoreTests
|
|||||||
keys.AddRange(Names(catalog.NameSets.Values));
|
keys.AddRange(Names(catalog.NameSets.Values));
|
||||||
keys.AddRange(Names(catalog.Subjects.Values));
|
keys.AddRange(Names(catalog.Subjects.Values));
|
||||||
keys.AddRange(Names(catalog.Staffing.Values));
|
keys.AddRange(Names(catalog.Staffing.Values));
|
||||||
|
keys.AddRange(Names(catalog.DayFrames.Values));
|
||||||
|
keys.AddRange(Names(catalog.Holidays.Values));
|
||||||
|
|
||||||
// Derived in code, so no def carries them.
|
// Derived in code, so no def carries them.
|
||||||
keys.Add(BodyBuilds.Attribute);
|
keys.Add(BodyBuilds.Attribute);
|
||||||
|
|||||||
Reference in New Issue
Block a user