diff --git a/README.md b/README.md index a422d9e..bc7b0dd 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ Simulation tunables live under the `Simulation` section of | `SaveIntervalSeconds` | 30 | rare clock snapshot; not every tick | | `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 | +| `SchoolWeekDays` | 5 | working days from Monday (5 is Mon–Fri; 6 adds Saturday) | ## What is deliberately missing diff --git a/docs/phases/13-management-tab.md b/docs/phases/13-management-tab.md index 79477f8..c451d5d 100644 --- a/docs/phases/13-management-tab.md +++ b/docs/phases/13-management-tab.md @@ -11,16 +11,16 @@ ## Задачи -- [ ] Под часами появляются два верхних раздела: «Обзор» и «Управление». В «Обзоре» — всё, что +- [x] Под часами появляются два верхних раздела: «Обзор» и «Управление». В «Обзоре» — всё, что есть сейчас; часы и дата видны в обоих -- [ ] Строка денег: выделено, расписано, свободно -- [ ] Список непокрытых предметов — что школа не может преподавать -- [ ] Список соискателей: имя, запрашиваемая ставка, сильные навыки; выбор показывает карточку +- [x] Строка денег: выделено, расписано, свободно +- [x] Список непокрытых предметов — что школа не может преподавать +- [x] Список соискателей: имя, запрашиваемая ставка, сильные навыки; выбор показывает карточку человека справа — ту же, что в «Людях» -- [ ] Кнопка найма; отказ по пределу показывается текстом, а не молчанием -- [ ] Список штата: кто нанят, какие предметы ведёт, сколько стоит -- [ ] Назначение и снятие предмета с карточки нанятого -- [ ] Строки через `t(...)`, обе локали +- [x] Кнопка найма; отказ по пределу показывается текстом, а не молчанием +- [x] Список штата: кто нанят, какие предметы ведёт, сколько стоит +- [x] Назначение и снятие предмета с карточки нанятого +- [x] Строки через `t(...)`, обе локали ## Критерий готовности diff --git a/docs/phases/14-school-calendar.md b/docs/phases/14-school-calendar.md index b80e962..cb34c58 100644 --- a/docs/phases/14-school-calendar.md +++ b/docs/phases/14-school-calendar.md @@ -11,15 +11,15 @@ ## Задачи -- [ ] Каркас дня в `core` дефом: начало первого урока, число уроков, длина урока, длины перемен +- [x] Каркас дня в `core` дефом: начало первого урока, число уроков, длина урока, длины перемен и какая из них большая -- [ ] Длина учебной недели — в `SimulationOptions`, пять дней по умолчанию -- [ ] Каникулы дефами: осенние, зимние, весенние, лето — диапазонами дат внутри учебного года -- [ ] Запрос «какой сейчас слот»: номер урока или перемена, или «вне учебного дня» — по игровому +- [x] Длина учебной недели — в `SimulationOptions`, пять дней по умолчанию +- [x] Каникулы дефами: осенние, зимние, весенние, лето — диапазонами дат внутри учебного года +- [x] Запрос «какой сейчас слот»: номер урока или перемена, или «вне учебного дня» — по игровому времени, выходным и каникулам -- [ ] Требование к типу помещения у `SubjectDef`: физкультура в спортзале, информатика в +- [x] Требование к типу помещения у `SubjectDef`: физкультура в спортзале, информатика в компьютерном классе; без требования — кабинет класса -- [ ] `GET /api/catalog` отдаёт каркас дня — он понадобится сетке расписания +- [x] `GET /api/catalog` отдаёт каркас дня — он понадобится сетке расписания ## Критерий готовности diff --git a/docs/phases/README.md b/docs/phases/README.md index 154321c..9cfbf6d 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -52,7 +52,7 @@ | [10. Предметы и мебель](10-subjects.md) | ✅ | `SubjectDef`, одна учительская должность, кабинет как число мест | | [11. Пустая школа и пул](11-applicants.md) | ✅ | Школа без сотрудников, соискатели с запросом по зарплате | | [12. Наём и бюджет](12-hiring-budget.md) | ✅ | Наём, назначение предметов, предел фонда оплаты | -| [13. Раздел «Управление»](13-management-tab.md) | ⬜ | Деньги, соискатели, штат и назначения на экране | +| [13. Раздел «Управление»](13-management-tab.md) | ✅ | Деньги, соискатели, штат и назначения на экране | ## Срез 4. Расписание @@ -64,7 +64,7 @@ | Фаза | Статус | Зачем | | --- | --- | --- | -| [14. Каркас дня и каникулы](14-school-calendar.md) | ⬜ | Звонки, длина недели, каникулы | +| [14. Каркас дня и каникулы](14-school-calendar.md) | ✅ | Звонки, длина недели, каникулы | | [15. Планировщик](15-timetable-planner.md) | ⬜ | Раскладка часов по слотам, четыре запрета | | [16. Расписание в школе](16-timetable-in-school.md) | ⬜ | Сейв, снимок, «кто где сейчас» | | [17. Расписание на экране](17-timetable-screen.md) | ⬜ | Скобки в дереве, сетка класса, расписание учителя | diff --git a/docs/protocol.md b/docs/protocol.md index c963e35..205c092 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -30,13 +30,15 @@ the wire format is unambiguous, and the client formats it back in UTC. ### `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 { "maxSchools": 6, "defaultStartDate": "2012-03-31T06:00:00Z", "gameMinutesPerRealSecond": 5, + "schoolWeekDays": 5, "schools": [ { "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. `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 -create editor does not. +`hoursPerWeek`, `skills` with shares, and optional `room`). `room` is the RoomDef the lesson +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 Russian. @@ -245,7 +254,8 @@ when hiring or assigning, not at month end; money itself does not move. "age": 34, "isParent": false, "hourlyWageAsk": 50, - "monthlyBase": 4000 + "monthlyBase": 4000, + "skills": [{ "id": "Mathematics", "label": "Математика", "value": "72" }] } ], "staff": [ @@ -261,13 +271,30 @@ when hiring or assigning, not at month end; money itself does not move. "monthlyPay": 5000, "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 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` diff --git a/src/HSchool.Client/src/i18n/strings.test.ts b/src/HSchool.Client/src/i18n/strings.test.ts index 27246ce..09196d9 100644 --- a/src/HSchool.Client/src/i18n/strings.test.ts +++ b/src/HSchool.Client/src/i18n/strings.test.ts @@ -22,6 +22,8 @@ describe('t', () => { expect(t('schoolCount', { current: 2, max: 6 })).toBe('Schools: 2 of 6.'); 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('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.'); }); }); diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index 2f9780e..42049e1 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -125,6 +125,46 @@ const ru = { peopleChildren: 'Дети', peopleSiblings: 'Братья и сёстры', 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; type Messages = { [K in keyof typeof ru]: string }; @@ -254,6 +294,46 @@ const en: Messages = { peopleChildren: 'Children', peopleSiblings: 'Siblings', 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 = { ru, en }; diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index a4abb87..8e62916 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -1,6 +1,6 @@ /** - * HTTP side of the server: the main menu and the in-school people list/card. The realtime clock - * arrives over the WebSocket instead — see `connection.ts`. + * HTTP side of the server: the main menu, the in-school people list/card, and staffing. The + * realtime clock arrives over the WebSocket instead — see `connection.ts`. */ export interface School { @@ -16,6 +16,7 @@ export interface SchoolsResponse { readonly maxSchools: number; readonly defaultStartDate: string; readonly gameMinutesPerRealSecond: number; + readonly schoolWeekDays: number; readonly schools: readonly School[]; } @@ -25,6 +26,10 @@ export class ApiError extends Error { readonly status: number, readonly code: string, message: string, + readonly allocated?: number, + readonly payroll?: number, + readonly remaining?: number, + readonly attempted?: number, ) { super(message); } @@ -104,6 +109,25 @@ export interface SubjectInfo { readonly gradeMax: number; readonly hoursPerWeek: number; 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 { @@ -131,6 +155,8 @@ export interface CatalogResponse { readonly defaultMap: MapLayout; readonly nameSets: readonly DefInfo[]; readonly subjects: readonly SubjectInfo[]; + readonly dayFrame: DayFrameInfo | null; + readonly holidays: readonly HolidayInfo[]; } export async function fetchMods(): Promise { @@ -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 { + const params = new URLSearchParams({ lang }); + return request(`/api/schools/${schoolId}/staffing?${params.toString()}`); +} + +export async function hireStaff( + schoolId: number, + personId: string, + position: string, + lang: string, +): Promise { + const params = new URLSearchParams({ lang }); + return request(`/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 { + const params = new URLSearchParams({ lang }); + return request( + `/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 { + const params = new URLSearchParams({ lang }); + return request( + `/api/schools/${schoolId}/staff/${encodeURIComponent(personId)}/subjects/${encodeURIComponent(subject)}?${params.toString()}`, + { method: 'DELETE' }, + ); +} + async function request( url: string, init?: RequestInit, @@ -286,11 +404,23 @@ async function request( async function toApiError(response: Response): Promise { try { // 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( response.status, problem.code ?? 'unknown', problem.detail ?? problem.title ?? response.statusText, + problem.allocated, + problem.payroll, + problem.remaining, + problem.attempted, ); } catch { return new ApiError(response.status, 'unknown', response.statusText); diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index a485165..ddfd48d 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -254,12 +254,42 @@ body { 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 */ /* - * Two panels, both full height. Left is a list — the map tree or the people table, whichever tab - * is up. Right inspects whatever was picked last, a room or a person. Nothing lives below the - * fold: the people list used to sit in a bottom row that had to be scrolled to. + * Two panels, both full height. Overview: map/people on the left, room or person on the right. + * Management: money and lists on the left, the same person card on the right. Mode tabs sit + * under the clock so the calendar stays visible while hiring. */ .manager { display: grid; @@ -671,6 +701,69 @@ body { 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. */ @media (max-width: 900px) { #app { diff --git a/src/HSchool.Client/src/ui/gameScreen.ts b/src/HSchool.Client/src/ui/gameScreen.ts index 538d8c1..adb0eb9 100644 --- a/src/HSchool.Client/src/ui/gameScreen.ts +++ b/src/HSchool.Client/src/ui/gameScreen.ts @@ -3,6 +3,7 @@ import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../forma import { t } from '../i18n/strings.ts'; import type { School } from '../net/api.ts'; import { clear, el } from './dom.ts'; +import { ManagementPanel } from './managementPanel.ts'; import { PeoplePanel } from './peoplePanel.ts'; interface GameScreenOptions { @@ -49,6 +50,13 @@ export class GameScreen { private readonly positionsList = el('ul', { class: 'panel__list' }); 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(); private nodes: readonly MapSnapshotNode[] = []; @@ -85,18 +93,24 @@ export class GameScreen { ), 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( - 'div', - { class: 'manager' }, - el( - 'section', - { class: 'panel' }, - el('div', { class: 'panel__tabs' }, this.mapTab, this.peopleTab), - this.mapBody, - this.peopleBody, - ), - el('section', { class: 'panel' }, this.inspectTitle, this.locationBody, this.people.cardElement), + 'section', + { class: 'panel' }, + el('div', { class: 'panel__tabs' }, this.mapTab, this.peopleTab), + this.mapBody, + this.peopleBody, ), + 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); @@ -111,8 +125,11 @@ export class GameScreen { this.mapTab.addEventListener('click', () => this.showTab('map')); 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.inspect('location'); + this.showMode('overview'); this.localize(); } @@ -123,6 +140,10 @@ export class GameScreen { localize(): void { 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.peopleTab.textContent = t('peopleTitle'); this.paintInspectTitle(); @@ -136,6 +157,7 @@ export class GameScreen { this.positionsEmpty.textContent = t('positionsEmpty'); this.people.localize(); + this.management.localize(); this.paintSelection(); if (this.lastGameTime !== null) { @@ -156,6 +178,7 @@ export class GameScreen { this.people.show(school.id); this.showTab('map'); this.inspect('location'); + this.showMode('overview'); } applyMap(schoolId: number, nodes: readonly MapSnapshotNode[]): void { @@ -211,6 +234,20 @@ export class GameScreen { 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 { this.mapTab.classList.toggle('panel__tab--active', tab === 'map'); this.peopleTab.classList.toggle('panel__tab--active', tab === 'people'); diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts new file mode 100644 index 0000000..c8e8ecc --- /dev/null +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -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 { + 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 { + this.selection = selection; + this.clearError(); + this.paint(); + await this.openCard(selection); + } + + private async openCard(selection: Selection): Promise { + 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 { + 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 { + 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 { + 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 { + 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'); + } +} diff --git a/src/HSchool.Client/src/ui/peoplePanel.ts b/src/HSchool.Client/src/ui/peoplePanel.ts index 68491d2..e32146b 100644 --- a/src/HSchool.Client/src/ui/peoplePanel.ts +++ b/src/HSchool.Client/src/ui/peoplePanel.ts @@ -3,20 +3,13 @@ import { fetchPerson, type PeoplePage, type PersonCard, - type PersonListItem, - type PersonRel, type PersonRole, type PersonSort, } from '../net/api.ts'; import { getLocale } from '../i18n/locale.ts'; import { t, type MessageKey } from '../i18n/strings.ts'; import { clear, el } from './dom.ts'; - -const ROLE_KEYS: Record = { - student: 'peopleRoleStudent', - staff: 'peopleRoleStaff', - parent: 'peopleRoleParent', -}; +import { placement, renderPersonCard, roleLabels } from './personCard.ts'; const COLUMNS: readonly { sort: PersonSort; label: MessageKey }[] = [ { sort: 'surname', label: 'peopleColName' }, @@ -173,6 +166,12 @@ export class PeoplePanel { void this.reload(); } + refresh(): void { + if (this.schoolId !== null) { + void this.reload(); + } + } + private onFilterChange(): void { this.page = 1; void this.reload(); @@ -357,23 +356,7 @@ export class PeoplePanel { return; } - this.card.append( - 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); - } + renderPersonCard(this.card, card, (id) => void this.openCard(id)); } } @@ -411,139 +394,3 @@ function parseOptionalInt(value: string): number | undefined { const parsed = Number(value); 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, - ), - ); -} diff --git a/src/HSchool.Client/src/ui/personCard.ts b/src/HSchool.Client/src/ui/personCard.ts new file mode 100644 index 0000000..b3dc4af --- /dev/null +++ b/src/HSchool.Client/src/ui/personCard.ts @@ -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 = { + 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): 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, + ), + ); +} diff --git a/src/HSchool.Content/CalendarDefs.cs b/src/HSchool.Content/CalendarDefs.cs new file mode 100644 index 0000000..bdd290d --- /dev/null +++ b/src/HSchool.Content/CalendarDefs.cs @@ -0,0 +1,37 @@ +namespace HSchool.Content; + +/// Bells and breaks of one school day. A catalog may have only one concrete frame. +public sealed class DayFrameDef : Def +{ + /// Local game-clock time of the first lesson, HH:mm. + public string FirstLesson { get; init; } = "08:30"; + + public int LessonCount { get; init; } + + public int LessonMinutes { get; init; } + + public int BreakMinutes { get; init; } + + /// The long break follows this 1-based lesson. Zero means every break is short. + public int LongBreakAfter { get; init; } + + public int LongBreakMinutes { get; init; } +} + +public sealed class MonthDay +{ + public int Month { get; init; } + + public int Day { get; init; } +} + +/// +/// A holiday range in month-day, repeating every academic year. When start is after end +/// (winter), the range wraps across 1 January. +/// +public sealed class HolidayDef : Def +{ + public MonthDay Start { get; init; } = new(); + + public MonthDay End { get; init; } = new(); +} diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index 10bceff..eb2edb2 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -309,6 +309,8 @@ public sealed class CatalogLoader var nameSets = new Dictionary(StringComparer.Ordinal); var subjects = new Dictionary(StringComparer.Ordinal); var staffing = new Dictionary(StringComparer.Ordinal); + var dayFrames = new Dictionary(StringComparer.Ordinal); + var holidays = new Dictionary(StringComparer.Ordinal); foreach (var (key, json) in resolved) { @@ -359,6 +361,12 @@ public sealed class CatalogLoader case DefKind.Staffing: staffing[key.Name] = Jsonc.Deserialize(json); break; + case DefKind.DayFrame: + dayFrames[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.Holiday: + holidays[key.Name] = Jsonc.Deserialize(json); + break; } } @@ -379,6 +387,8 @@ public sealed class CatalogLoader nameSets, subjects, staffing, + dayFrames, + holidays, ru, en); } diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs index 13e029f..4f82ed2 100644 --- a/src/HSchool.Content/DefCatalog.cs +++ b/src/HSchool.Content/DefCatalog.cs @@ -23,6 +23,8 @@ public sealed class DefCatalog IReadOnlyDictionary nameSets, IReadOnlyDictionary subjects, IReadOnlyDictionary staffing, + IReadOnlyDictionary dayFrames, + IReadOnlyDictionary holidays, IReadOnlyDictionary ru, IReadOnlyDictionary en) { @@ -42,6 +44,8 @@ public sealed class DefCatalog NameSets = nameSets; Subjects = subjects; Staffing = staffing; + DayFrames = dayFrames; + Holidays = holidays; _ru = ru; _en = en; AnyNeedDecays = needs.Values.Any(need => !need.Abstract && need.DecayPerHour > 0f); @@ -86,9 +90,16 @@ public sealed class DefCatalog public IReadOnlyDictionary Staffing { get; } + public IReadOnlyDictionary DayFrames { get; } + + public IReadOnlyDictionary Holidays { get; } + /// The one concrete staffing ruleset, or null when a pack has not defined it. public StaffingDef? StaffingRules => Staffing.Values.FirstOrDefault(def => !def.Abstract); + /// The one concrete day frame, or null when a pack has not defined it. + public DayFrameDef? DayFrame => DayFrames.Values.FirstOrDefault(def => !def.Abstract); + private readonly IReadOnlyDictionary _ru; private readonly IReadOnlyDictionary _en; @@ -111,6 +122,8 @@ public sealed class DefCatalog DefKind.NameSet => NameSets.GetValueOrDefault(defName), DefKind.Subject => Subjects.GetValueOrDefault(defName), DefKind.Staffing => Staffing.GetValueOrDefault(defName), + DefKind.DayFrame => DayFrames.GetValueOrDefault(defName), + DefKind.Holiday => Holidays.GetValueOrDefault(defName), _ => null, }; @@ -186,6 +199,8 @@ public sealed class DefCatalog NameSetDef => DefKind.NameSet, SubjectDef => DefKind.Subject, StaffingDef => DefKind.Staffing, + DayFrameDef => DefKind.DayFrame, + HolidayDef => DefKind.Holiday, _ => throw new ArgumentOutOfRangeException(nameof(def)), }; diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index 7cea87a..1dcbbfc 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -17,6 +17,8 @@ public enum DefKind NameSet, Subject, Staffing, + DayFrame, + Holiday, } /// Shared JSONC fields. Kind comes from the folder under defs/, not from the file. diff --git a/src/HSchool.Content/PackPaths.cs b/src/HSchool.Content/PackPaths.cs index a8bde16..f2691ec 100644 --- a/src/HSchool.Content/PackPaths.cs +++ b/src/HSchool.Content/PackPaths.cs @@ -107,6 +107,12 @@ internal static class PackPaths case "staffing": kind = DefKind.Staffing; return true; + case "dayframe": + kind = DefKind.DayFrame; + return true; + case "holidays": + kind = DefKind.Holiday; + return true; default: kind = default; return false; diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 27348f5..7cd630d 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -39,11 +39,26 @@ internal static class PeopleDefValidator 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) { 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); } @@ -268,6 +283,16 @@ internal static class PeopleDefValidator 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) @@ -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) { if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule)) diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 15d254f..b210a33 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -136,6 +136,11 @@ public sealed class SubjectDef : Def public int HoursPerWeek { get; init; } public IReadOnlyList Skills { get; init; } = []; + + /// + /// RoomDef the lesson needs. Null means the class's homeroom. PE uses a gym, informatics a lab. + /// + public string? Room { get; init; } } public sealed class TraitSkillModifier diff --git a/src/HSchool.Content/SchoolDay.cs b/src/HSchool.Content/SchoolDay.cs new file mode 100644 index 0000000..0f3f5f0 --- /dev/null +++ b/src/HSchool.Content/SchoolDay.cs @@ -0,0 +1,117 @@ +using System.Globalization; + +namespace HSchool.Content; + +public enum DaySlotKind +{ + Outside, + Lesson, + Break, +} + +/// +/// Where the bells put this instant. is the 1-based lesson number, or the +/// lesson the current break follows. Zero when the school is closed. +/// +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); +} + +/// +/// 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 SimulationOptions. +/// +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; + } +} diff --git a/src/HSchool.Server/Api/ModEndpoints.cs b/src/HSchool.Server/Api/ModEndpoints.cs index 5231de2..6262c47 100644 --- a/src/HSchool.Server/Api/ModEndpoints.cs +++ b/src/HSchool.Server/Api/ModEndpoints.cs @@ -88,6 +88,8 @@ internal sealed record CatalogResponse( IReadOnlyList Things, IReadOnlyList NameSets, IReadOnlyList Subjects, + DayFrameResponse? DayFrame, + IReadOnlyList Holidays, MapLayout DefaultMap) { public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) => @@ -99,6 +101,8 @@ internal sealed record CatalogResponse( PlaceableThings(catalog, locale), Placeable(catalog.NameSets.Values, catalog, locale), PlaceableSubjects(catalog, locale), + MapDayFrame(catalog, locale), + PlaceableHolidays(catalog, locale), map); private static IReadOnlyList Placeable(IEnumerable defs, DefCatalog catalog, string locale) @@ -140,7 +144,35 @@ internal sealed record CatalogResponse( def.Grades.Min, def.Grades.Max, 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 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(); } @@ -165,4 +197,17 @@ internal sealed record SubjectInfoResponse( int GradeMin, int GradeMax, int HoursPerWeek, - IReadOnlyList Skills); + IReadOnlyList 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); diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 6124a7c..0deacd8 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -180,7 +180,9 @@ internal sealed record StaffingResponse( float Remaining, IReadOnlyList Uncovered, IReadOnlyList Applicants, - IReadOnlyList Staff); + IReadOnlyList Staff, + IReadOnlyList Positions, + IReadOnlyList Subjects); internal sealed record UncoveredSubjectResponse( string DefName, @@ -196,7 +198,8 @@ internal sealed record ApplicantResponse( int Age, bool IsParent, float HourlyWageAsk, - float MonthlyBase); + float MonthlyBase, + IReadOnlyList Skills); internal sealed record StaffMemberResponse( string Id, @@ -245,7 +248,8 @@ internal static class StaffingMapper applicant.Person.AgeOn(asOf), applicant.Person.IsParent, 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(); var staff = roster.People @@ -255,7 +259,42 @@ internal static class StaffingMapper .Select(person => Member(person, catalog, rules, locale, asOf)) .ToArray(); - return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff); + var positions = catalog is null + ? Array.Empty() + : 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() + : 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 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( diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index db66c6d..0879cb6 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -27,6 +27,7 @@ internal static class SchoolEndpoints state.MaxSchools, options.DefaultStartDate, options.GameMinutesPerRealSecond, + options.SchoolWeekDays, [.. state.Schools.Select(SchoolResponse.From)]); }) .WithName("GetSchools"); @@ -474,6 +475,7 @@ internal sealed record SchoolsResponse( int MaxSchools, DateTime DefaultStartDate, double GameMinutesPerRealSecond, + int SchoolWeekDays, IReadOnlyList Schools); internal sealed record RandomNameResponse(string Name); diff --git a/src/HSchool.Server/Game/PersonCardReader.cs b/src/HSchool.Server/Game/PersonCardReader.cs index e919683..7fb3d79 100644 --- a/src/HSchool.Server/Game/PersonCardReader.cs +++ b/src/HSchool.Server/Game/PersonCardReader.cs @@ -23,6 +23,9 @@ internal static class PersonCardReader } 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) { return null; diff --git a/src/HSchool.Server/Program.cs b/src/HSchool.Server/Program.cs index a2c9955..cc0908c 100644 --- a/src/HSchool.Server/Program.cs +++ b/src/HSchool.Server/Program.cs @@ -22,6 +22,7 @@ builder.Services .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.MonthlyPayrollCap > 0, "Simulation:MonthlyPayrollCap must be positive.") + .Validate(options => options.SchoolWeekDays is >= 5 and <= 7, "Simulation:SchoolWeekDays must be between 5 and 7.") .ValidateOnStart(); builder.Services.AddSingleton(); diff --git a/src/HSchool.Server/appsettings.json b/src/HSchool.Server/appsettings.json index f362471..8e3c2f8 100644 --- a/src/HSchool.Server/appsettings.json +++ b/src/HSchool.Server/appsettings.json @@ -14,6 +14,7 @@ "SavesDirectory": "saves", "ModsDirectory": "mods", "SaveIntervalSeconds": 30, - "MonthlyPayrollCap": 10000 + "MonthlyPayrollCap": 10000, + "SchoolWeekDays": 5 } } diff --git a/src/HSchool.Server/mods/core/defs/dayframe/standard.jsonc b/src/HSchool.Server/mods/core/defs/dayframe/standard.jsonc new file mode 100644 index 0000000..f395514 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/dayframe/standard.jsonc @@ -0,0 +1,9 @@ +{ + "defName": "StandardDay", + "firstLesson": "08:30", + "lessonCount": 7, + "lessonMinutes": 45, + "breakMinutes": 10, + "longBreakAfter": 3, + "longBreakMinutes": 20, +} diff --git a/src/HSchool.Server/mods/core/defs/holidays/breaks.jsonc b/src/HSchool.Server/mods/core/defs/holidays/breaks.jsonc new file mode 100644 index 0000000..e03598b --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/holidays/breaks.jsonc @@ -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 }, + }, +] diff --git a/src/HSchool.Server/mods/core/defs/subjects/academic.jsonc b/src/HSchool.Server/mods/core/defs/subjects/academic.jsonc index 0b2ac73..92153d5 100644 --- a/src/HSchool.Server/mods/core/defs/subjects/academic.jsonc +++ b/src/HSchool.Server/mods/core/defs/subjects/academic.jsonc @@ -56,6 +56,7 @@ "defName": "Informatics", "grades": { "min": 5, "max": 11 }, "hoursPerWeek": 1, + "room": "ComputerLab", "skills": [{ "skill": "Informatics", "share": 1 }], }, { @@ -74,6 +75,7 @@ "defName": "PhysicalEducation", "grades": { "min": 1, "max": 11 }, "hoursPerWeek": 3, + "room": "GymHall", "skills": [{ "skill": "PhysicalEducation", "share": 1 }], }, ] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index c4319a8..24651b2 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -94,4 +94,9 @@ "Athletic": "Athletic", "Heavy": "Heavy", "Obese": "Obese", + "StandardDay": "School day", + "AutumnBreak": "Autumn break", + "WinterBreak": "Winter break", + "SpringBreak": "Spring break", + "SummerBreak": "Summer break", } diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index b2d107f..4779ace 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -94,4 +94,9 @@ "Athletic": "Атлетическое", "Heavy": "Плотное", "Obese": "Полное", + "StandardDay": "Учебный день", + "AutumnBreak": "Осенние каникулы", + "WinterBreak": "Зимние каникулы", + "SpringBreak": "Весенние каникулы", + "SummerBreak": "Летние каникулы", } diff --git a/src/HSchool.Simulation/SimulationOptions.cs b/src/HSchool.Simulation/SimulationOptions.cs index f661d57..275a2e3 100644 --- a/src/HSchool.Simulation/SimulationOptions.cs +++ b/src/HSchool.Simulation/SimulationOptions.cs @@ -55,6 +55,11 @@ public sealed class SimulationOptions /// public float MonthlyPayrollCap { get; set; } = 10_000f; + /// + /// Working days from Monday. Five is Mon–Fri; six adds Saturday; seven is every day. + /// + public int SchoolWeekDays { get; set; } = 5; + /// Length of one fixed step. public double FixedDeltaTime => 1d / TickRate; diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index 84412c8..0b694bd 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -26,6 +26,7 @@ public class SchoolApiTests(AppHostFixture fixture) // creation form would offer the wrong hour. Assert.Equal(DateTimeKind.Utc, state.DefaultStartDate.Kind); Assert.Equal(5d, state.GameMinutesPerRealSecond); + Assert.Equal(5, state.SchoolWeekDays); Assert.Empty(state.Schools); } @@ -167,6 +168,16 @@ public class SchoolApiTests(AppHostFixture fixture) Assert.Equal(16, classroom.DefaultSeats); Assert.Empty(classroom.Slots); 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] @@ -347,6 +358,7 @@ public class SchoolApiTests(AppHostFixture fixture) int MaxSchools, DateTime DefaultStartDate, double GameMinutesPerRealSecond, + int SchoolWeekDays, IReadOnlyList Schools); private sealed record RandomNameResponse(string Name); @@ -367,6 +379,8 @@ public class SchoolApiTests(AppHostFixture fixture) IReadOnlyList Things, IReadOnlyList NameSets, IReadOnlyList Subjects, + DayFrameResponse? DayFrame, + IReadOnlyList Holidays, MapLayoutResponse DefaultMap); 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 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); diff --git a/tests/HSchool.AppHost.Tests/StaffingApiTests.cs b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs index 50861c9..c47d1ca 100644 --- a/tests/HSchool.AppHost.Tests/StaffingApiTests.cs +++ b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs @@ -33,6 +33,46 @@ public class StaffingApiTests(AppHostFixture fixture) Assert.Empty(staffing.Staff); Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics"); 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( + $"/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( + $"/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] @@ -217,11 +257,23 @@ public class StaffingApiTests(AppHostFixture fixture) float Remaining, IReadOnlyList Uncovered, IReadOnlyList Applicants, - IReadOnlyList Staff); + IReadOnlyList Staff, + IReadOnlyList Positions, + IReadOnlyList Subjects); 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 Skills); + + private sealed record LabeledStatResponse(string Id, string Label, string Value); private sealed record StaffMemberResponse( string Id, @@ -237,6 +289,8 @@ public class StaffingApiTests(AppHostFixture fixture) private sealed record DefLabelResponse(string DefName, string Label); + private sealed record PersonCardResponse(string Id, IReadOnlyList Roles, IReadOnlyList Skills); + private sealed record PeopleListResponse( int Total, int Page, diff --git a/tests/HSchool.Content.Tests/CalendarTests.cs b/tests/HSchool.Content.Tests/CalendarTests.cs new file mode 100644 index 0000000..6e376e4 --- /dev/null +++ b/tests/HSchool.Content.Tests/CalendarTests.cs @@ -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(() => _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(() => _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)); + } +} diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs index a990f11..166b23a 100644 --- a/tests/HSchool.Content.Tests/PeopleDefTests.cs +++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs @@ -31,6 +31,11 @@ public class PeopleDefTests Assert.Equal(0.25f, catalog.StaffingRules.ExtraSubjectSurcharge); Assert.Equal("Начальные классы", catalog.Label("ru", 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] diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 929385f..17e6d0c 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -45,6 +45,10 @@ public class VanillaCoreTests Assert.Equal(12, catalog.StaffingRules.PoolSize); Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours); 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); var homerooms = map.Rooms.Where(room => room.Def == "Classroom").ToList(); Assert.Equal(11, homerooms.Count); @@ -84,6 +88,8 @@ public class VanillaCoreTests keys.AddRange(Names(catalog.NameSets.Values)); keys.AddRange(Names(catalog.Subjects.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. keys.Add(BodyBuilds.Attribute);