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.
ci / server (push) Failing after 3m37s
ci / client (push) Successful in 13s

This commit is contained in:
Leonid Pershin
2026-08-19 00:47:09 +03:00
parent 94842ab192
commit 2ebc783585
38 changed files with 1837 additions and 208 deletions
+1
View File
@@ -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 MonFri; 6 adds Saturday) |
## What is deliberately missing ## What is deliberately missing
+8 -8
View File
@@ -11,16 +11,16 @@
## Задачи ## Задачи
- [ ] Под часами появляются два верхних раздела: «Обзор» и «Управление». В «Обзоре» — всё, что - [x] Под часами появляются два верхних раздела: «Обзор» и «Управление». В «Обзоре» — всё, что
есть сейчас; часы и дата видны в обоих есть сейчас; часы и дата видны в обоих
- [ ] Строка денег: выделено, расписано, свободно - [x] Строка денег: выделено, расписано, свободно
- [ ] Список непокрытых предметов — что школа не может преподавать - [x] Список непокрытых предметов — что школа не может преподавать
- [ ] Список соискателей: имя, запрашиваемая ставка, сильные навыки; выбор показывает карточку - [x] Список соискателей: имя, запрашиваемая ставка, сильные навыки; выбор показывает карточку
человека справа — ту же, что в «Людях» человека справа — ту же, что в «Людях»
- [ ] Кнопка найма; отказ по пределу показывается текстом, а не молчанием - [x] Кнопка найма; отказ по пределу показывается текстом, а не молчанием
- [ ] Список штата: кто нанят, какие предметы ведёт, сколько стоит - [x] Список штата: кто нанят, какие предметы ведёт, сколько стоит
- [ ] Назначение и снятие предмета с карточки нанятого - [x] Назначение и снятие предмета с карточки нанятого
- [ ] Строки через `t(...)`, обе локали - [x] Строки через `t(...)`, обе локали
## Критерий готовности ## Критерий готовности
+6 -6
View File
@@ -11,15 +11,15 @@
## Задачи ## Задачи
- [ ] Каркас дня в `core` дефом: начало первого урока, число уроков, длина урока, длины перемен - [x] Каркас дня в `core` дефом: начало первого урока, число уроков, длина урока, длины перемен
и какая из них большая и какая из них большая
- [ ] Длина учебной недели — в `SimulationOptions`, пять дней по умолчанию - [x] Длина учебной недели — в `SimulationOptions`, пять дней по умолчанию
- [ ] Каникулы дефами: осенние, зимние, весенние, лето — диапазонами дат внутри учебного года - [x] Каникулы дефами: осенние, зимние, весенние, лето — диапазонами дат внутри учебного года
- [ ] Запрос «какой сейчас слот»: номер урока или перемена, или «вне учебного дня» — по игровому - [x] Запрос «какой сейчас слот»: номер урока или перемена, или «вне учебного дня» — по игровому
времени, выходным и каникулам времени, выходным и каникулам
- [ ] Требование к типу помещения у `SubjectDef`: физкультура в спортзале, информатика в - [x] Требование к типу помещения у `SubjectDef`: физкультура в спортзале, информатика в
компьютерном классе; без требования — кабинет класса компьютерном классе; без требования — кабинет класса
- [ ] `GET /api/catalog` отдаёт каркас дня — он понадобится сетке расписания - [x] `GET /api/catalog` отдаёт каркас дня — он понадобится сетке расписания
## Критерий готовности ## Критерий готовности
+2 -2
View File
@@ -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
View File
@@ -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 57 working days counted
from Monday (five is MonFri; 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.');
}); });
}); });
+80
View File
@@ -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 };
+133 -3
View File
@@ -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);
+96 -3
View File
@@ -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 {
+47 -10
View File
@@ -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');
}
}
+8 -161
View File
@@ -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,
),
);
}
+169
View File
@@ -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,
),
);
}
+37
View File
@@ -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();
}
+10
View File
@@ -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);
} }
+15
View File
@@ -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)),
}; };
+2
View File
@@ -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>
+6
View File
@@ -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;
+88
View File
@@ -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 112.");
}
if (frame.LessonMinutes is < 1 or > 180)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' lessonMinutes must be 1180.");
}
if (frame.BreakMinutes is < 0 or > 60)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' breakMinutes must be 060.");
}
if (frame.LongBreakMinutes is < 0 or > 120)
{
throw new ContentLoadException($"DayFrameDef '{frame.DefName}' longBreakMinutes must be 0120.");
}
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 112.");
}
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))
+5
View File
@@ -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
+117
View File
@@ -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 57 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 MonFri; 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;
}
}
+47 -2
View File
@@ -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);
+43 -4
View File
@@ -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;
+1
View File
@@ -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>();
+2 -1
View File
@@ -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 MonFri; 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;
+27 -1
View File
@@ -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);