diff --git a/docs/design/schedule.md b/docs/design/schedule.md index c8543ba..f74f03b 100644 --- a/docs/design/schedule.md +++ b/docs/design/schedule.md @@ -139,9 +139,9 @@ | Место урока | Кабинет класса, если у предмета нет требования к типу помещения | | Нет учителя | Урок не ставится; предмет числится непокрытым | | Карта | В дереве рядом с комнатой — что и у кого там сейчас | -| Панель локации | «Сейчас» и «Персонажи» заполняются из расписания | +| Панель локации | «Сейчас» и «Персонажи» заполняются из расписания — с срезом 5 из присутствия, см. [`ai.md`](ai.md) | | Расписание класса | В разделе «Управление», сетка «день × урок» | | Расписание учителя | В его карточке, там же где навыки и семья | | Общая сетка школы | Не делаем: её читает только тот, кто раскладывает вручную | | Где живёт код | Отдельная библиотека рядом с `HSchool.People` | -| Люди и место | Находятся по расписанию; ходьбы нет | +| Люди и место | Находятся по расписанию; ходьбы нет — отменено срезом 5, присутствие стало состоянием ([`ai.md`](ai.md)) | diff --git a/docs/phases/reviewed.md b/docs/phases/reviewed.md index b7d2731..6959573 100644 --- a/docs/phases/reviewed.md +++ b/docs/phases/reviewed.md @@ -175,3 +175,230 @@ - Срез 1 успел сдвинуться: коммит `5cd5a6d` тронул `gameScreen.ts` и `strings.ts` (подпись идущего человека). По правилу журнала он больше не «проверен на `b135a9c`» — правка маленькая и относится к срезу 5, но следующий проход это увидит. + +## Срез 3. Штат + +- **Фазы:** 10–13 +- **Проверен на:** `21d79cb`, 2026-08-19 +- **Пути:** `src/HSchool.People/Staffing.cs`, `src/HSchool.People/ApplicantPool.cs`, + `src/HSchool.Content/SubjectDef` и `defs/staffing`, `src/HSchool.Server/Api/SchoolEndpoints.cs` + (staffing/hire/subjects), `src/HSchool.Server/mods/core/defs/{subjects,positions,rooms,staffing,traits}`, + `src/HSchool.Client/src/ui/managementPanel.ts`, `src/HSchool.Client/src/net/api.ts`, + `tests/HSchool.People.Tests/{StaffingTests,ApplicantPoolTests}.cs`, + `tests/HSchool.AppHost.Tests/StaffingApiTests.cs` +- **Итог:** дописано 3 теста (один из них — прямо требуемый фазой 12 и отсутствовавший), + дополнен `docs/protocol.md`; расхождений кода с дизайном не найдено + +Что подтверждено: + +- **Фаза 10.** `PETeacher` в `core` нет, `Classroom`, `ComputerLab` и `GymHall` не открывают + должностей — должности остались у библиотеки, приёмной, столовой, медкабинета и кабинета + директора, ровно как обещано. Кабинет описан числом мест (`seatThing`/`defaultSeats`, пустой + список слотов — проверено в `Catalog_LabelsDefsInTheRequestedLanguage`), редактор карты + показывает у него одно поле «мест». Неизвестный навык в предмете роняет каталог + (`Subject_UnknownSkill_FailsTheCatalog`), полнота локалей — `EveryVanillaLabel_ExistsInBothLocales`, + стартовая дата 31 марта 2012 зашита в тестах API. +- **Фаза 11.** Все шесть тестов из списка фазы на месте и проходят: детерминизм пула по сиду и + неделе, частичное обновление с сохранением навыков и запроса, устойчивый размер за пятьдесят + недель, «сильный просит больше», пустой штат при полной школе, соискатель вне ростера до найма. +- **Фаза 12.** Фонд оплаты, деление часов между учителями предмета, пол одной ставки и покрытие + предметов покрыты в `StaffingTests`. `GET /staffing` читает опубликованный снимок + (`loop.FindPeople`), наём и назначение идут в воркер через `GameCommand` с + `TaskCompletionSource` — инвариант 3 цел. Перезапуск с тем же штатом и назначениями — + `AssignAndUnassign_HonourTheCap_AndReloadKeepsAssignments`. +- **Фаза 13.** Строка денег, непокрытые предметы, список соискателей со ставкой и сильными + навыками, кнопка найма с текстом ошибки, список штата с предметами и стоимостью, назначение и + снятие с карточки — всё есть и всё через `t(...)`. +- **Дизайн.** `design/staffing.md` сверен построчно. Особо проверено: `MaxWeeklyHours` не + запрещает назначение, а делает предмет непокрытым (в коде нет такой ошибки, и замер это + подтверждает); цена часа считается от навыков **и от черт**. + +Дописано: + +- `StaffingApiTests.AssigningPastTheCap_IsRejectedWithTheNumbers` — фаза 12 требует теста API на + «отклонение по пределу», а его не было: единственное место, где он мог сработать, было написано + через `if (over.StatusCode == Conflict) … else …` и проходило при любом исходе. Пределом + занялся замер: при выделенных 100 000 полностью загруженная школа стоит всего 68–72 тысячи, + то есть «нанять всех» предела не достигает. Но пик фонда приходится на середину раздачи + предметов — пока часы предмета несут один-два учителя, а не двенадцать, — и там отказ + случается: по сидам 1–30 отказов от 4 до 24 на прогон, ни одного сида без отказа. Тест + раздаёт предметы штату, ловит первый `409`, сверяет `payroll-exceeded`, `allocated`, + `attempted > allocated`, `remaining` и то, что отклонённое назначение действительно не легло. +- `ApplicantPoolTests.ConfidentTraits_AskForMoreAndQuietOnesForLess` — дизайн фиксирует «цена + часа: от навыков и от черт (самоуверенный просит больше)», код это делает через + `TraitDef.WageAsk` (в `core` Leader +10, HotTempered +6, Quiet −8), а тестом закреплена была + только половина про навыки. +- Проверка в `AssignAndUnassign_HonourTheCap_AndReloadKeepsAssignments`, что после перезапуска + возвращается **тот же пул** с теми же ставками — критерий готовности фазы 11, который никто не + проверял. Тест кусается: пул на диске уже без нанятого, а сгенерированный заново вернул бы его + обратно. + +Исправлено: + +- `docs/protocol.md` не упоминал `?lang=ru|en` у `POST staff/hire`, `POST …/subjects` и + `DELETE …/subjects/{subject}`, хотя все три его принимают и клиент его шлёт. Поведение не + трогалось — дописана документация. + +Замечено рядом (за границами среза): + +- Предел фонда при ванильных данных почти не ограничивает игрока: нанять всех двенадцать и + раздать им все предметы стоит около 70 000 из 100 000. Отказ достижим только по дороге, на + пике неравномерной нагрузки. Это игровой баланс, а не ошибка (см. коммит `207e7ec`, которым + предел поднимали намеренно), но если предел задумывался как настоящее ограничение — числа + сейчас говорят обратное. +- В `AssignAndUnassign_HonourTheCap_AndReloadKeepsAssignments` осталась ветка `if/else` вокруг + ожидаемого отказа. Теперь, когда отказ проверяется отдельным тестом, эта ветка просто не может + упасть — её стоит однажды упростить, но трогать работающий тест ради этого ревью не стало. +## Срез 4. Расписание + +- **Фазы:** 14–17 +- **Проверен на:** `21d79cb`, 2026-08-19 +- **Пути:** `src/HSchool.Schedule`, `src/HSchool.Content` (каркас дня, каникулы, `SubjectDef.room`), + `src/HSchool.Simulation/SchoolTimetables.cs`, `src/HSchool.Server/Api/TimetableEndpoints.cs`, + `src/HSchool.Server/Game/SchoolWorker.cs` (пин, перестроение), + `src/HSchool.Client/src/ui/timetableGrid.ts`, `src/HSchool.Client/src/ui/gameScreen.ts`, + `tests/HSchool.Schedule.Tests`, `tests/HSchool.Content.Tests/CalendarTests.cs`, + `tests/HSchool.AppHost.Tests/TimetableApiTests.cs` +- **Итог:** дописан один тест на пять непокрытых кодов отказа, поправлены две устаревшие строки + дизайна; расхождений кода с принятыми решениями нет + +Что подтверждено: + +- **Фаза 14.** Все пункты критерия закрыты в `CalendarTests`: суббота, каникулы и ночь дают «вне + учебного дня», 10:20 буднего дня — третий урок, шестидневка делает субботу рабочей, зимние + каникулы переносятся через 1 января. Требование помещения у предмета есть (физкультура — + спортзал), неизвестная комната роняет каталог. +- **Фаза 15.** Все шесть тестов из списка фазы на месте. Четыре запрета проверяются одним + утверждением в `TeacherWithTwoSubjects_IsNeverInTwoRoomsAtOnce` (уникальность по учителю, классу + и кабинету в слоте) плюс `ClassOfSixteen_DoesNotEnterALabOfTwelve`. Отсутствие сокетов, ECS и + часов реального времени — самопроверкой сборки, как в `HSchool.People`. +- **Фаза 16.** Расписание пересобирается только когда изменился состав: `TimetableDirty` ставится + в `InstallPeople`/`Replace` и в годовом наборе, а воркер перестраивает таблицу под + `if (result.PeopleChanged)` — по тику этого не происходит. `GET /timetable` читает + опубликованный снимок, пин и снятие идут через `GameCommand` с `TaskCompletionSource`. + Перезапуск с закреплениями — `Reload_RestoresLockedLessons`. +- **Фаза 17.** Скобки в дереве (`Математика · 5Б`) собираются из присутствия, сетка «день × урок» + с переносом, сменой кабинета и отметкой «закреплено» есть, отказ планировщика показывается + текстом (`timetableErrorPin`, `timetableErrorNoTeacher`, `timetableErrorLesson`), личное + расписание — в карточке. Всё через `t(...)`. Клиентские тесты и сборка на `21d79cb`: 33/33 и + зелёная сборка. + +Дописано: + +- `TimetableApiTests.PinAndUnpinRefusals_CarryTheDocumentedCodes` — из шести кодов отказа пина в + `docs/protocol.md` тестом был закрыт только `pin-rejected` (хвост + `PinThenHireAnother_KeepsTheLockedSlot`, попытка провести урок в спортзале). Остальные пять — + `no-teacher`, `unknown-class`, `unknown-subject`, `unknown-room`, `unknown-lesson` на повторном + снятии, плюс `invalid-query` на неполном запросе — не проверялись, хотя клиент по ним + переключает текст ошибки. + +Исправлено: + +- `design/schedule.md` держал в «Зафиксировано» две строки, которые срез 5 сознательно отменил: + «Панель локации заполняется из расписания» и «Люди находятся по расписанию; ходьбы нет». + Отмена записана в `design/ai.md` («присутствие — состояние человека, а не функция от + расписания»), а старый документ об этом не знал. Помечены как отменённые со ссылкой; код не + трогался — он давно живёт по новому решению. + +Замечено рядом (за границами среза): + +- Задача фазы 16 «снимок карты отдаёт по узлу, что там сейчас идёт и у кого» в протоколе v7 + выглядит иначе: снимок карты стал статическим, а занятость уехала в кадр `Presence`. Это + сделано фазой 19 намеренно и описано в `docs/protocol.md`; строка фазы 16 просто историческая. +- `GameSocketTests.OpeningASchoolDuringAMathLesson_...`, который в срезе 1 падал дважды под + нагрузкой, за этот проход не воспроизвёлся ни разу: шесть полных прогонов подряд на чистом + worktree и два одиночных под искусственной нагрузкой из шести жгущих процессов — зелено. + Оставляю как «наблюдалось, не воспроизводится»; если вернётся, смотреть на выдачу кадров + присутствия под нехваткой процессора, а не на логику. + +Про окружение: + +- Во время проверки параллельная сессия правила `core` и клиент (размер пула соискателей 12 → 32, + новые навыки, диалог соискателей). Два прогона на рабочем дереве успели показать «падения» + `Hire_IdlePool_FitsUnderTheCap` и `GetStaffing_EmptySchool_HasPoolAndNoPayroll` — это артефакт + прогона старых бинарников против новых данных, а не находка. Поэтому весь срез проверялся в + отдельном worktree на `21d79cb`: AppHost 63/63 (три прогона подряд), клиент 33/33 и сборка. + +## Срез 5. Жизнь школы + +- **Фазы:** 18–21 +- **Проверен на:** `21d79cb`, 2026-08-19. Фаза 21 закрыта за час до проверки, `core` правился + параллельно — проверка велась в отдельном worktree на коммите, не на рабочем дереве +- **Пути:** `src/HSchool.Ai`, `src/HSchool.Simulation/{PresenceSystem,ActivitySystem,LessonLearningSystem,School}.cs`, + `src/HSchool.Protocol` (кадр `Presence`, пропуск в кадре часов), + `src/HSchool.Server/Game/PresenceFrame.cs`, `src/HSchool.Client/src/ui/gameScreen.ts`, + `src/HSchool.Server/mods/core/defs/{actions,behavior,needs}`, `tests/HSchool.Ai.Tests`, + `tests/HSchool.Simulation.Tests/{PresenceTests,DecisionTests,ActivityTests,NeedDecayTests}.cs` +- **Итог:** все 32 теста из списков четырёх фаз на месте и проходят; тестов не дописано, зато + измерен прожитый учебный день — и он показал две вещи, которых списки не ловят + +Что подтверждено: + +- **Фаза 18.** Все 13 требуемых тестов есть: маршрут 201 → санузел через лестницу, совпадение + суммы `travelMinutes` с реально пройденным временем, пустая школа в воскресенье и в каникулы, + класс без уроков не приходит, ученик в кабинете к звонку, спортзал впритык к перемене, + детерминизм по сиду, сохранение посреди перемены, четыре сценария пропуска пустого времени, + школа без учителей, лето с первым сентября, промотанная неделя против прожитой. +- **Фаза 19.** Все пять тестов: байтовая раскладка присутствия с обеих сторон, круговой прогон, + отсутствие людей и урока в снимке карты, разрешение на пропуск в кадре часов, отброшенный + пропуск в учебное время. +- **Фаза 20.** Все шесть: декей голода за час, обед на заявленные минуты и величину, нехватка + стульев, ночь вне школы, действие с неизвестной нуждой роняет каталог, детерминизм нужд. +- **Фаза 21.** Все восемь, включая сквозной `ZeroToilet_LeavesClass_ReachesRestroom_AndReturns`, + `DecisionCap_DefersTheOverflow` и `SameSeedAndActions_MatchAfterAWeek`, плюс табличный разбор + решений в `DecisionPlannerTests` без мира. +- **Инварианты.** `HSchool.Ai` не знает Arch, ASP.NET и часов реального времени, `HSchool.Simulation` + не знает сокетов — обе проверки в самих тестах. Протокол v7 сходится в трёх местах. + +Измерено (временный тест, удалён после): + +Полностью укомплектованная ванильная школа (12 учителей, 225 уроков, 176 учеников), прожитая +тиками с вторника по пятницу: + +| | день 1 | день 2 | день 3 | день 4 | +| --- | --- | --- | --- | --- | +| побывали в столовой | 0 | 144 | 133 | 141 | +| завершённых обедов | 0 | 75 | 132 | 111 | +| минимальный голод | 0.32 | 0.00 | 0.00 | 0.00 | + +Картина дня в остальном осмысленная: в 8:25 двор, вестибюль и лестницы полны, к 8:45 все 144 +пришедших в кабинетах и спортзале, на перемене коридор — самый людный узел школы (пик 112), +после уроков остаются только те, у кого урок ещё идёт. + +- **Столовая пустует весь первый день.** Голод стартует с 1.0 и падает на 0.1 в игровой час + только на территории школы, так что порог 0.35 достигается лишь к концу первого дня. Критерий + готовности фазы 21 обещает «перемены с коридором и столовой» — со второго дня так и есть, но + прогон одного дня, как в критерии написано, столовой не покажет. Это не поломка, это то, чего + стоит ожидать при проверке руками. +- **Со второго дня голод упирается в ноль и оттуда не выходит.** К концу четырёх учебных дней + 27 учеников из 176 держат голод ≤0.05, 98 из 176 — ниже порога срочности, средний по школе + ровно 0.35. Причина арифметическая: вне школы голод не восстанавливается (в отличие от сна), + дома никто не ест, а единственный источник еды — восемь стульев в столовой на 176 человек при + обеде в 15 минут. Обедов в день выходит 75–132, то есть до половины школы ежедневно остаётся + без еды, и дефицит переносится на завтра. Поскольку рост навыка на уроке зависит от состояния + нужд, больше половины школы учится со штрафом постоянно. Это баланс, а не логика: числа лежат + в `core` (`decayPerHour`, `needGain`, число стульев на карте) и правятся данными. Молча их + менять ревью не стало — см. «Открыто». + +Открыто: + +- **Чем закрывать голод.** Вариантов три, и это решение автора, а не ревью: восстанавливать голод + вне школы, как сон (`restoredOffCampus` на `Hunger`); поставить в ванильную столовую больше + стульев; или снизить `decayPerHour` голода. Первое честнее всего описывает «поел дома», третье + дешевле всего, второе меняет карту. Числа выше дают точку отсчёта для любого из них. + +Замечено рядом (за границами среза): + +- **`GameSocketTests.OpeningASchoolDuringAMathLesson_...` подтверждённо нестабилен.** За сессию + он упал четырежды — и все четыре раза в полном `dotnet test` по решению, когда параллельно шла + сборка; в одиночку он проходит всегда, в том числе шесть прогонов подряд и два под нагрузкой из + шести жгущих процессов. Симптом всегда один: последнее ожидание после снятия паузы — «нанятый + учитель появился среди людей в кадре присутствия» — не выполняется за 40 кадров (~20 секунд). + Механизм не пойман: инструментированный прогон, печатающий замороженное время и уроки этого + учителя, три раза подряд прошёл зелёным. Две правдоподобные версии, обе про то, что тест + зависит от игрового времени, набежавшего за время его собственных HTTP-вызовов (5 игровых минут + на реальную секунду): либо замороженный момент уезжает за последний урок учителя и он по плану + дня уже не приходит, либо за 20 секунд ожидания школьный день успевает кончиться. В обоих + случаях чинить надо тест, а не присутствие: занятость узла берётся из расписания, а явка — из + состояния, и совпадать они не обязаны — фаза 21 прямо обещает, что «опоздание и отсутствие + видны». diff --git a/docs/protocol.md b/docs/protocol.md index a76641e..06ad9ae 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -77,8 +77,10 @@ single seats field on a homeroom and the old slot rows on everything else. Things carry `pupilSlots` — how many pupils that thing hosts. The snapshot's per-node pupil-slot total is computed on the server. -`nameSets` is the list of placeable name packs (`defName` + label). The create dialog picks one; -it is independent of the UI language. +`nameSets` is the list of placeable name packs (`defName` + label + `nativeLanguages`). Each +`nativeLanguages` entry is a skill (`defName` + label). The create dialog picks a name set and a +native language; the language list comes from that set (vanilla Slavic: Russian, Belarusian, +Ukrainian). It is independent of the UI language. `subjects` is the list of placeable subjects (`defName`, label, `gradeMin`/`gradeMax`, `hoursPerWeek`, `skills` with shares, and optional `room`). `room` is the RoomDef the lesson @@ -104,7 +106,8 @@ Body: "startDate": "2012-03-31T06:00:00Z", "modIds": [], "map": null, - "nameSetId": "Slavic" + "nameSetId": "Slavic", + "nativeLanguage": null } ``` @@ -112,6 +115,8 @@ Body: pack set's default layout. A supplied map is validated as a connected yard-and-rooms graph. `nameSetId` is a `NameSetDef`; omit it to use the first placeable set in the catalog (vanilla: `Slavic`). Unknown ids return `400` `unknown-name-set`. +`nativeLanguage` is a skill from that set's `nativeLanguages`. Omit it (or send `null`) to pick +one from the school seed. An id that is not in the set returns `400` `unknown-native-language`. | Status | Meaning | | --- | --- | @@ -122,6 +127,7 @@ pack set's default layout. A supplied map is validated as a connected yard-and-r | `400` `unknown-mod` | An extra pack id is missing under `mods/`. | | `400` `invalid-catalog` | The selected packs could not be loaded. | | `400` `unknown-name-set` | `nameSetId` is not a placeable `NameSetDef` in those packs. | +| `400` `unknown-native-language` | `nativeLanguage` is not in that name set's `nativeLanguages`. | | `409` `school-limit-reached` | `maxSchools` schools already exist. | Failures are RFC 7807 problem details with an extra `code` field — that is what the UI switches on. @@ -334,8 +340,9 @@ ids that are in neither place stay `404` `unknown-person`. ### `POST /api/schools/{id}/staff/hire` Body: `{ "personId": "a0.p0", "position": "Teacher" }`. Goes through the school's mailbox. -On success returns the same payload as `GET .../staffing`. `Teacher` needs no room opening; -other positions fill the first free `RoomDef.positions` slot of that kind. +On success returns the same payload as `GET .../staffing`, so it takes the same `?lang=ru|en`. +`Teacher` needs no room opening; other positions fill the first free `RoomDef.positions` slot +of that kind. | Status | `code` | When | | --- | --- | --- | @@ -351,7 +358,8 @@ other positions fill the first free `RoomDef.positions` slot of that kind. ### `POST /api/schools/{id}/staff/{personId}/subjects` -Body: `{ "subject": "Mathematics" }`. Teachers only. Same success payload as GET staffing. +Body: `{ "subject": "Mathematics" }`. Teachers only. Same success payload as GET staffing, +and the same `?lang=ru|en`. | Status | `code` | When | | --- | --- | --- | @@ -363,8 +371,8 @@ Body: `{ "subject": "Mathematics" }`. Teachers only. Same success payload as GET ### `DELETE /api/schools/{id}/staff/{personId}/subjects/{subject}` -Removes one assignment. Payroll drops when the subject was not the only one. Unknown -assignment is `404` `unknown-assignment`. +Removes one assignment. Payroll drops when the subject was not the only one. Same success +payload and `?lang=ru|en` as the other two. Unknown assignment is `404` `unknown-assignment`. ### `GET /api/schools/{id}/timetable` diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index d9b4408..0e893c0 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -42,10 +42,14 @@ const ru = { errorUnknownMod: 'Выбранный мод не найден.', errorInvalidCatalog: 'Не удалось загрузить выбранные моды.', errorUnknownNameSet: 'Выбранный набор имён не найден.', + errorUnknownNativeLanguage: 'Выбранный родной язык не входит в этот набор имён.', catalogLoadFailed: 'Не удалось загрузить каталог модов.', modsTitle: 'Моды', nameSetTitle: 'Набор имён', + nativeLanguageTitle: 'Родной язык', + nativeLanguageRandom: 'Случайный', + randomNativeTitle: 'Выбрать язык наугад', coreModLocked: '{id} (всегда включён)', mapEditorTitle: 'Карта', editMap: 'Редактировать карту', @@ -135,6 +139,16 @@ const ru = { staffUncoveredEmpty: 'Все предметы существующих параллелей кем-то ведутся.', staffApplicants: 'Соискатели', staffApplicantsEmpty: 'Пул пуст.', + staffApplicantsOpen: 'Открыть список', + staffApplicantsCount: 'В пуле: {n}', + staffApplicantsSearch: 'Имя', + staffApplicantsSearchHint: 'Поиск по имени', + staffApplicantsKind: 'Кто', + staffApplicantsKindAll: 'Все', + staffApplicantsKindParent: 'Родители', + staffApplicantsKindOther: 'Со стороны', + staffApplicantsNoMatches: 'Никто не подходит под фильтр.', + staffApplicantsPickHint: 'Выберите соискателя в списке.', staffHired: 'Штат', staffHiredEmpty: 'Никого не наняли.', staffColName: 'ФИО', @@ -148,7 +162,7 @@ const ru = { staffAsk: '{hourly}/ч · {monthly}/мес', staffSubjectRange: '{label} ({min}–{max})', staffParent: 'родитель', - staffPickHint: 'Выберите соискателя или работника в списке.', + staffPickHint: 'Выберите сотрудника в списке или откройте соискателей.', staffHireTitle: 'Наём', staffHire: 'Нанять', staffPosition: 'Должность', @@ -240,10 +254,14 @@ const en: Messages = { errorUnknownMod: 'A selected mod is missing.', errorInvalidCatalog: 'The selected packs could not be loaded.', errorUnknownNameSet: 'The selected name set is not in the catalog.', + errorUnknownNativeLanguage: 'The selected native language is not in that name set.', catalogLoadFailed: 'Could not load the mod catalog.', modsTitle: 'Mods', nameSetTitle: 'Name set', + nativeLanguageTitle: 'Native language', + nativeLanguageRandom: 'Random', + randomNativeTitle: 'Pick a language at random', coreModLocked: '{id} (always on)', mapEditorTitle: 'Map', editMap: 'Edit map', @@ -333,6 +351,16 @@ const en: Messages = { staffUncoveredEmpty: 'Every subject in the existing year-groups has a teacher.', staffApplicants: 'Applicants', staffApplicantsEmpty: 'The pool is empty.', + staffApplicantsOpen: 'Open list', + staffApplicantsCount: '{n} in the pool', + staffApplicantsSearch: 'Name', + staffApplicantsSearchHint: 'Search by name', + staffApplicantsKind: 'Who', + staffApplicantsKindAll: 'All', + staffApplicantsKindParent: 'Parents', + staffApplicantsKindOther: 'Outside', + staffApplicantsNoMatches: 'Nobody matches the filter.', + staffApplicantsPickHint: 'Select an applicant in the list.', staffHired: 'Staff', staffHiredEmpty: 'Nobody hired yet.', staffColName: 'Name', @@ -346,7 +374,7 @@ const en: Messages = { staffAsk: '{hourly}/h · {monthly}/mo', staffSubjectRange: '{label} ({min}–{max})', staffParent: 'parent', - staffPickHint: 'Select an applicant or a staff member in the list.', + staffPickHint: 'Select a staff member in the list, or open the applicants.', staffHireTitle: 'Hire', staffHire: 'Hire', staffPosition: 'Position', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index 836c5ad..8bbb17a 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -50,6 +50,7 @@ export interface CreateSchoolOptions { readonly modIds?: readonly string[]; readonly map?: MapLayout; readonly nameSetId?: string; + readonly nativeLanguage?: string; } export async function createSchool( @@ -66,6 +67,7 @@ export async function createSchool( modIds: extras.modIds ?? [], map: extras.map ?? null, nameSetId: extras.nameSetId ?? null, + nativeLanguage: extras.nativeLanguage ?? null, }), }); } @@ -81,6 +83,12 @@ export interface DefInfo { readonly pupilSlots?: number; } +export interface NameSetInfo { + readonly defName: string; + readonly label: string; + readonly nativeLanguages: readonly DefInfo[]; +} + export interface RoomSlotInfo { readonly key: string; readonly thing: string; @@ -153,7 +161,7 @@ export interface CatalogResponse { readonly rooms: readonly RoomInfo[]; readonly things: readonly DefInfo[]; readonly defaultMap: MapLayout; - readonly nameSets: readonly DefInfo[]; + readonly nameSets: readonly NameSetInfo[]; readonly subjects: readonly SubjectInfo[]; readonly dayFrame: DayFrameInfo | null; readonly holidays: readonly HolidayInfo[]; @@ -324,6 +332,7 @@ export interface StaffingApplicant { readonly hourlyWageAsk: number; readonly monthlyBase: number; readonly skills: readonly LabeledStat[]; + readonly traits: readonly DefLabel[]; } export interface StaffMember { diff --git a/src/HSchool.Client/src/style.css b/src/HSchool.Client/src/style.css index 25ccff6..36f33db 100644 --- a/src/HSchool.Client/src/style.css +++ b/src/HSchool.Client/src/style.css @@ -792,8 +792,14 @@ body { font-size: 13px; } +.staffing__summary { + margin: 0 0 8px; + color: var(--text-muted); + font-size: 13px; +} + .staffing__list-section .people__table-wrap { - max-height: 220px; + max-height: 320px; } .timetable { @@ -1015,6 +1021,50 @@ body { margin-top: 12px; } +.dialog--applicants .screen__header, +.dialog--applicants .staffing__error, +.dialog--applicants .people__toolbar { + flex: 0 0 auto; +} + +.dialog--applicants[open] { + width: min(1280px, calc(100vw - 32px)); + height: min(840px, calc(100dvh - 48px)); + max-height: calc(100dvh - 48px); + overflow: hidden; +} + +.dialog--applicants .applicants__split { + display: grid; + grid-template-columns: minmax(0, 1.7fr) minmax(280px, 0.9fr); + gap: 16px; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.dialog--applicants .applicants__table { + min-height: 0; +} + +.dialog--applicants .applicants__card { + min-height: 0; + overflow: auto; + padding: 0; +} + +.dialog--applicants .applicants__count { + margin: 0; + color: var(--text-muted); + font-size: 13px; +} + +.people__table td.applicants__cell--wrap { + white-space: normal; + max-width: 28em; + line-height: 1.35; +} + .dialog::backdrop { background: rgba(6, 9, 14, 0.7); } diff --git a/src/HSchool.Client/src/ui/applicantsDialog.ts b/src/HSchool.Client/src/ui/applicantsDialog.ts new file mode 100644 index 0000000..a934bd0 --- /dev/null +++ b/src/HSchool.Client/src/ui/applicantsDialog.ts @@ -0,0 +1,412 @@ +import { + fetchPerson, + hireStaff, + type PersonCard, + type Staffing, + type StaffingApplicant, +} from '../net/api.ts'; +import { getLocale } from '../i18n/locale.ts'; +import { t, type MessageKey } from '../i18n/strings.ts'; +import { clear, el } from './dom.ts'; +import { Modal } from './modal.ts'; +import { formatPersonPlace, renderPersonCard } from './personCard.ts'; +import { + actionError, + fillSelect, + formatMoney, + formatSkill, + formatTraits, + parseOptionalInt, +} from './staffingUi.ts'; + +const TEACHER = 'Teacher'; + +type ApplicantSort = 'name' | 'age' | 'ask'; + +const SORT_COLUMNS: readonly { sort: ApplicantSort; label: MessageKey }[] = [ + { sort: 'name', label: 'staffColName' }, + { sort: 'age', label: 'peopleColAge' }, + { sort: 'ask', label: 'staffColAsk' }, +]; + +export interface ApplicantsDialogOptions { + readonly schoolId: number; + readonly staffing: Staffing; + readonly locate: (id: string) => string; + readonly selectedId?: string; + readonly onChange: (staffing: Staffing, hiredId: string) => void; +} + +/** + * Full-size applicant browser. The management pane only has room for a summary; comparing + * skills and asks needs a table that can actually scroll, plus the person card beside it. + */ +export class ApplicantsDialog { + private readonly error = el('p', { class: 'staffing__error' }); + private readonly title = el('h2', { class: 'screen__title' }); + private readonly count = el('p', { class: 'applicants__count' }); + private readonly searchLabel = el('span', { class: 'people__label' }); + private readonly search = el('input', { class: 'input people__input', type: 'search' }); + private readonly sexLabel = el('span', { class: 'people__label' }); + private readonly sexSelect = el('select', { class: 'input people__input' }); + private readonly kindLabel = el('span', { class: 'people__label' }); + private readonly kindSelect = el('select', { class: 'input people__input' }); + private readonly ageFromLabel = el('span', { class: 'people__label' }); + private readonly ageMinInput = el('input', { class: 'input input--count', type: 'number' }); + private readonly ageToLabel = el('span', { class: 'people__label' }); + private readonly ageMaxInput = el('input', { class: 'input input--count', type: 'number' }); + private readonly table = el('table', { class: 'people__table' }); + private readonly empty = el('p', { class: 'panel__empty' }); + private readonly card = el('aside', { class: 'panel__body people__card applicants__card' }); + private readonly positionSelect = el('select', { class: 'input people__input' }); + private readonly closeButton = el('button', { class: 'button button--primary', type: 'button' }); + + private modal: Modal | null = null; + private staffing: Staffing; + private selectedId: string | null; + private sort: ApplicantSort = 'name'; + private dir: 'asc' | 'desc' = 'asc'; + private cardToken = 0; + private busy = false; + private painted: PersonCard | null = null; + + constructor(private readonly options: ApplicantsDialogOptions) { + this.staffing = options.staffing; + this.selectedId = options.selectedId ?? null; + this.error.hidden = true; + this.ageMinInput.min = '0'; + this.ageMaxInput.min = '0'; + this.search.addEventListener('input', () => this.paintList()); + this.sexSelect.addEventListener('change', () => this.paintList()); + this.kindSelect.addEventListener('change', () => this.paintList()); + this.ageMinInput.addEventListener('change', () => this.paintList()); + this.ageMaxInput.addEventListener('change', () => this.paintList()); + this.closeButton.addEventListener('click', () => this.modal?.close(undefined)); + } + + open(): Promise { + const modal = new Modal(undefined); + this.modal = modal; + modal.element.classList.add('dialog--screen', 'dialog--applicants'); + modal.element.append( + el('header', { class: 'screen__header' }, this.title, el('div', { class: 'screen__actions' }, this.count)), + this.error, + el( + 'div', + { class: 'people__toolbar' }, + el('label', { class: 'people__field' }, this.searchLabel, this.search), + el('label', { class: 'people__field' }, this.sexLabel, this.sexSelect), + el('label', { class: 'people__field' }, this.kindLabel, this.kindSelect), + el('label', { class: 'people__field' }, this.ageFromLabel, this.ageMinInput), + el('label', { class: 'people__field' }, this.ageToLabel, this.ageMaxInput), + ), + el( + 'div', + { class: 'applicants__split' }, + el('div', { class: 'people__table-wrap applicants__table' }, this.table, this.empty), + this.card, + ), + el('div', { class: 'dialog__actions' }, this.closeButton), + ); + this.localize(); + return modal.open(this.search).finally(() => { + this.modal = null; + }); + } + + localize(): void { + this.title.textContent = t('staffApplicants'); + this.searchLabel.textContent = t('staffApplicantsSearch'); + this.search.placeholder = t('staffApplicantsSearchHint'); + this.sexLabel.textContent = t('peopleSex'); + this.kindLabel.textContent = t('staffApplicantsKind'); + this.ageFromLabel.textContent = t('peopleAgeFrom'); + this.ageToLabel.textContent = t('peopleAgeTo'); + this.closeButton.textContent = t('done'); + this.empty.textContent = t('staffApplicantsEmpty'); + fillFixedSelect(this.sexSelect, [ + { value: '', label: t('peopleSexAll') }, + { value: 'female', label: t('peopleFemale') }, + { value: 'male', label: t('peopleMale') }, + ]); + fillFixedSelect(this.kindSelect, [ + { value: '', label: t('staffApplicantsKindAll') }, + { value: 'parent', label: t('staffApplicantsKindParent') }, + { value: 'other', label: t('staffApplicantsKindOther') }, + ]); + this.paintList(); + if (this.selectedId !== null) { + void this.openCard(this.selectedId); + } else { + this.paintCard(null); + } + } + + setStaffing(staffing: Staffing): void { + this.staffing = staffing; + if (this.selectedId !== null && !staffing.applicants.some((row) => row.id === this.selectedId)) { + this.selectedId = null; + this.paintCard(null); + } + + this.paintList(); + if (this.selectedId !== null) { + this.appendHireIfNeeded(); + } + } + + select(id: string): void { + this.selectedId = id; + this.clearError(); + this.paintList(); + void this.openCard(id); + } + + private filtered(): StaffingApplicant[] { + const query = this.search.value.trim().toLocaleLowerCase(getLocale()); + const sex = this.sexSelect.value; + const kind = this.kindSelect.value; + const ageMin = parseOptionalInt(this.ageMinInput.value); + const ageMax = parseOptionalInt(this.ageMaxInput.value); + const rows = this.staffing.applicants.filter((applicant) => { + if (query.length > 0 && !applicant.fullName.toLocaleLowerCase(getLocale()).includes(query)) { + return false; + } + + if (sex === 'female' && !applicant.female) { + return false; + } + + if (sex === 'male' && applicant.female) { + return false; + } + + if (kind === 'parent' && !applicant.isParent) { + return false; + } + + if (kind === 'other' && applicant.isParent) { + return false; + } + + if (ageMin !== undefined && applicant.age < ageMin) { + return false; + } + + if (ageMax !== undefined && applicant.age > ageMax) { + return false; + } + + return true; + }); + + const factor = this.dir === 'asc' ? 1 : -1; + rows.sort((left, right) => { + const compared = + this.sort === 'age' + ? left.age - right.age + : this.sort === 'ask' + ? left.hourlyWageAsk - right.hourlyWageAsk + : left.fullName.localeCompare(right.fullName, getLocale()); + return compared === 0 ? left.id.localeCompare(right.id, getLocale()) : compared * factor; + }); + return rows; + } + + private paintList(): void { + const total = this.staffing.applicants.length; + this.count.textContent = t('staffApplicantsCount', { n: total }); + const rows = this.filtered(); + const hasRows = rows.length > 0; + this.empty.hidden = hasRows; + this.table.hidden = !hasRows; + this.empty.textContent = total === 0 ? t('staffApplicantsEmpty') : t('staffApplicantsNoMatches'); + clear(this.table); + if (!hasRows) { + return; + } + + const head = el('tr'); + for (const column of SORT_COLUMNS) { + const button = el('button', { + class: 'people__sort', + type: 'button', + text: t(column.label), + onClick: () => this.onSort(column.sort), + }); + button.setAttribute( + 'aria-sort', + this.sort === column.sort ? (this.dir === 'asc' ? 'ascending' : 'descending') : 'none', + ); + head.append(el('th', {}, button)); + } + + head.append( + el('th', { text: t('staffColPay') }), + el('th', { text: t('peopleColSex') }), + el('th', { text: t('staffColSkills') }), + el('th', { text: t('peopleTraits') }), + ); + + const body = el('tbody'); + for (const applicant of rows) { + const selected = applicant.id === this.selectedId; + const row = el('tr', { + class: selected ? 'people__row people__row--active' : 'people__row', + dataset: { personId: applicant.id }, + onClick: () => this.select(applicant.id), + }); + const name = el('td', { text: applicant.fullName }); + if (applicant.isParent) { + name.append(el('span', { class: 'staffing__badge', text: t('staffParent') })); + } + + row.append( + name, + el('td', { text: String(applicant.age) }), + el('td', { text: formatMoney(applicant.hourlyWageAsk) }), + el('td', { text: formatMoney(applicant.monthlyBase) }), + el('td', { text: applicant.female ? t('peopleFemale') : t('peopleMale') }), + el('td', { + class: 'applicants__cell--wrap', + text: applicant.skills.map(formatSkill).join(' · ') || '—', + }), + el('td', { class: 'applicants__cell--wrap', text: formatTraits(applicant.traits) }), + ); + body.append(row); + } + + this.table.append(el('thead', {}, head), body); + } + + private onSort(sort: ApplicantSort): void { + if (this.sort === sort) { + this.dir = this.dir === 'asc' ? 'desc' : 'asc'; + } else { + this.sort = sort; + this.dir = 'asc'; + } + + this.paintList(); + } + + private async openCard(personId: string): Promise { + const token = ++this.cardToken; + try { + const card = await fetchPerson(this.options.schoolId, personId, 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); + this.painted = card; + if (card === null) { + this.card.append(el('p', { class: 'panel__empty', text: t('staffApplicantsPickHint') })); + return; + } + + renderPersonCard(this.card, card, (id) => this.openRelative(id), this.placeOf(card.id)); + this.appendHireIfNeeded(); + } + + private appendHireIfNeeded(): void { + const applicant = this.staffing.applicants.find((row) => row.id === this.painted?.id); + if (applicant === undefined) { + return; + } + + fillSelect( + this.positionSelect, + this.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 openRelative(personId: string): void { + if (this.staffing.applicants.some((row) => row.id === personId)) { + this.select(personId); + } + } + + private placeOf(id: string): string { + return this.options.locate(id) || formatPersonPlace('away'); + } + + private async hire(personId: string): Promise { + if (this.busy) { + return; + } + + this.busy = true; + this.clearError(); + try { + const staffing = await hireStaff(this.options.schoolId, personId, this.positionSelect.value, getLocale()); + this.staffing = staffing; + this.selectedId = null; + this.options.onChange(staffing, personId); + this.paintList(); + this.paintCard(null); + } catch (error) { + this.showError(actionError(error)); + } finally { + this.busy = false; + if (this.painted !== null && this.selectedId === this.painted.id) { + this.paintCard(this.painted); + } + } + } + + private showError(message: string): void { + this.error.hidden = false; + this.error.textContent = message; + } + + private clearError(): void { + this.error.hidden = true; + this.error.textContent = ''; + } +} + +function fillFixedSelect(select: HTMLSelectElement, items: readonly { value: string; label: 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 ([...select.options].some((option) => option.value === current)) { + select.value = current; + } +} diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.ts b/src/HSchool.Client/src/ui/createSchoolDialog.ts index 53a38b7..6ff892b 100644 --- a/src/HSchool.Client/src/ui/createSchoolDialog.ts +++ b/src/HSchool.Client/src/ui/createSchoolDialog.ts @@ -33,6 +33,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { nameSetId = nameSetSelect.value === '' ? null : nameSetSelect.value; + nativeLanguageId = null; + if (catalog !== null) { + paintNativeLanguages(catalog); + } + }); + + const nativeLabel = el('span', { class: 'field__label' }); + const nativeSelect = el('select', { class: 'input' }); + const nativeRandomButton = el('button', { class: 'button', type: 'button' }); + const nativeField = el( + 'div', + { class: 'field' }, + nativeLabel, + el('div', { class: 'field__row' }, nativeSelect, nativeRandomButton), + ); + nativeSelect.addEventListener('change', () => { + nativeLanguageId = nativeSelect.value === '' ? null : nativeSelect.value; }); const mapLabel = el('span', { class: 'field__label' }); @@ -87,6 +105,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise set.defName === nameSetId); nameSetId = stillThere ? nameSetId : (next.nameSets[0]?.defName ?? null); + if (!languagesOf(next, nameSetId).some((language) => language.defName === nativeLanguageId)) { + nativeLanguageId = null; + } paintNameSets(next); + paintNativeLanguages(next); paintMapHint(); setBusy(busy); }; @@ -140,6 +164,24 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { + const languages = languagesOf(next, nameSetId); + nativeSelect.replaceChildren(); + const random = el('option', { text: t('nativeLanguageRandom') }); + random.value = ''; + nativeSelect.append(random); + for (const language of languages) { + const option = el('option', { text: language.label }); + option.value = language.defName; + nativeSelect.append(option); + } + + nativeSelect.value = nativeLanguageId ?? ''; + nativeSelect.disabled = languages.length <= 1; + nativeRandomButton.toggleAttribute('disabled', busy || languages.length <= 1); + nativeField.hidden = languages.length === 0; + }; + const reloadCatalog = async (): Promise => { try { applyCatalog(await fetchCatalog(getLocale(), [...extraModIds])); @@ -193,6 +235,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { + if (busy || catalog === null) { + return; + } + + const languages = languagesOf(catalog, nameSetId); + if (languages.length === 0) { + return; + } + + nativeLanguageId = languages[Math.floor(Math.random() * languages.length)]?.defName ?? null; + nativeSelect.value = nativeLanguageId ?? ''; + }); + randomButton.addEventListener('click', () => { if (busy) { return; @@ -256,6 +316,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise modal.close(school)) .catch((reason: unknown) => { @@ -270,6 +331,14 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise set.defName === nameSetId)?.nativeLanguages ?? []; +} + function mapsEqual(left: MapLayout, right: MapLayout): boolean { return JSON.stringify(left) === JSON.stringify(right); } @@ -294,6 +363,8 @@ function describe(reason: unknown): string { return t('errorInvalidCatalog'); case 'unknown-name-set': return t('errorUnknownNameSet'); + case 'unknown-native-language': + return t('errorUnknownNativeLanguage'); default: return reason.message; } diff --git a/src/HSchool.Client/src/ui/managementPanel.ts b/src/HSchool.Client/src/ui/managementPanel.ts index 77defe6..fa609fe 100644 --- a/src/HSchool.Client/src/ui/managementPanel.ts +++ b/src/HSchool.Client/src/ui/managementPanel.ts @@ -1,32 +1,27 @@ import { - ApiError, assignSubject, fetchPerson, fetchStaffing, fetchTimetable, - hireStaff, unassignSubject, type PersonCard, type Staffing, - type StaffingApplicant, type StaffMember, type Timetable, } from '../net/api.ts'; -import { getLocale, intlTag } from '../i18n/locale.ts'; +import { getLocale } from '../i18n/locale.ts'; import { t } from '../i18n/strings.ts'; +import { ApplicantsDialog } from './applicantsDialog.ts'; import { clear, el } from './dom.ts'; import { formatPersonPlace, renderPersonCard } from './personCard.ts'; +import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts'; import { personTimetableQuery, TimetableGrid } from './timetableGrid.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. + * Left: money, coverage, a door into the applicant window, staff. Right: the person card plus + * subject actions. HTTP only — the clock stays on the bar above. */ export class ManagementPanel { readonly listElement: HTMLElement; @@ -38,8 +33,8 @@ export class ManagementPanel { 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 applicantsSummary = el('p', { class: 'staffing__summary' }); + private readonly applicantsButton = el('button', { class: 'button', type: 'button' }); 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' }); @@ -56,22 +51,23 @@ export class ManagementPanel { private readonly personGrid = new TimetableGrid({ editable: false, showClass: true }); private readonly personTimetableTitle = el('h4', { class: 'people__section-title' }); 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 timetable: Timetable | null = null; private classId: string | null = null; - private selection: Selection | null = null; + private selectedId: string | null = null; private loadToken = 0; private cardToken = 0; private busy = false; private locate: ((id: string) => string) | null = null; private painted: PersonCard | null = null; + private poolDialog: ApplicantsDialog | null = null; constructor() { this.error.hidden = true; + this.applicantsButton.addEventListener('click', () => void this.openApplicants()); this.listElement = el( 'div', { class: 'panel__body staffing' }, @@ -80,9 +76,10 @@ export class ManagementPanel { el('div', { class: 'panel__section' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered), el( 'div', - { class: 'panel__section staffing__list-section' }, + { class: 'panel__section' }, this.applicantsTitle, - el('div', { class: 'people__table-wrap' }, this.applicantsTable, this.applicantsEmpty), + this.applicantsSummary, + this.applicantsButton, ), el( 'div', @@ -109,18 +106,19 @@ export class ManagementPanel { localize(): void { this.uncoveredTitle.textContent = t('staffUncovered'); this.applicantsTitle.textContent = t('staffApplicants'); + this.applicantsButton.textContent = t('staffApplicantsOpen'); this.staffTitle.textContent = t('staffHired'); this.timetableTitle.textContent = t('timetableTitle'); this.classLabel.textContent = t('timetableClass'); this.uncoveredEmpty.textContent = t('staffUncoveredEmpty'); - this.applicantsEmpty.textContent = t('staffApplicantsEmpty'); this.staffEmpty.textContent = t('staffHiredEmpty'); this.timetableGrid.localize(); this.personGrid.localize(); this.personTimetableTitle.textContent = t('timetableTitle'); this.paint(); - if (this.selection !== null) { - void this.openCard(this.selection); + this.poolDialog?.localize(); + if (this.selectedId !== null) { + void this.openCard(this.selectedId); } else { this.paintCard(null); } @@ -130,7 +128,7 @@ export class ManagementPanel { const switched = this.schoolId !== schoolId; this.schoolId = schoolId; if (switched) { - this.selection = null; + this.selectedId = null; this.staffing = null; this.timetable = null; this.classId = null; @@ -167,9 +165,10 @@ export class ManagementPanel { this.syncClass(timetable); this.syncSelection(); this.paint(); + this.poolDialog?.setStaffing(staffing); this.timetableGrid.setTable(timetable, this.classId); - if (this.selection !== null) { - void this.openCard(this.selection); + if (this.selectedId !== null) { + void this.openCard(this.selectedId); } else { this.paintCard(null); } @@ -207,21 +206,16 @@ export class ManagementPanel { private syncSelection(): void { const staffing = this.staffing; - const selected = this.selection; + const selected = this.selectedId; if (staffing === null || selected === null) { return; } - if (selected.kind === 'applicant' && staffing.applicants.some((row) => row.id === selected.id)) { + if (staffing.staff.some((row) => row.id === selected)) { return; } - if (staffing.staff.some((row) => row.id === selected.id)) { - this.selection = { kind: 'staff', id: selected.id }; - return; - } - - this.selection = null; + this.selectedId = null; } private paint(): void { @@ -274,55 +268,10 @@ export class ManagementPanel { } 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); - } + const count = staffing?.applicants.length ?? 0; + this.applicantsSummary.textContent = + count === 0 ? t('staffApplicantsEmpty') : t('staffApplicantsCount', { n: count }); + this.applicantsButton.disabled = count === 0; } private paintStaff(staffing: Staffing | null): void { @@ -343,9 +292,12 @@ export class ManagementPanel { 'tr', {}, el('th', { text: t('staffColName') }), + el('th', { text: t('peopleColAge') }), + el('th', { text: t('peopleColSex') }), el('th', { text: t('staffColPosition') }), el('th', { text: t('staffColSubjects') }), el('th', { text: t('staffColHours') }), + el('th', { text: t('staffColAsk') }), el('th', { text: t('staffColPay') }), ), ), @@ -353,11 +305,11 @@ export class ManagementPanel { ); for (const member of rows) { - const selected = this.selection?.kind === 'staff' && this.selection.id === member.id; + const selected = this.selectedId === 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 }), + onClick: () => void this.select(member.id), }); const name = el('td', { text: member.fullName }); if (member.isParent) { @@ -366,23 +318,62 @@ export class ManagementPanel { row.append( name, + el('td', { text: String(member.age) }), + el('td', { text: member.female ? t('peopleFemale') : t('peopleMale') }), el('td', { text: member.positionLabel }), - el('td', { text: member.subjects.map((subject) => subject.label).join(', ') || '—' }), + el('td', { class: 'applicants__cell--wrap', text: member.subjects.map((subject) => subject.label).join(', ') || '—' }), el('td', { text: t('staffHours', { n: formatHours(member.weeklyHours) }) }), + el('td', { text: formatMoney(member.hourlyWageAsk) }), el('td', { text: formatMoney(member.monthlyPay) }), ); body.append(row); } } - private async select(selection: Selection): Promise { - this.selection = selection; + private async select(personId: string): Promise { + this.selectedId = personId; this.clearError(); this.paint(); - await this.openCard(selection); + await this.openCard(personId); } - private async openCard(selection: Selection): Promise { + private async openApplicants(selectedId?: string): Promise { + const schoolId = this.schoolId; + const staffing = this.staffing; + if (schoolId === null || staffing === null || staffing.applicants.length === 0) { + return; + } + + if (this.poolDialog !== null) { + if (selectedId !== undefined) { + this.poolDialog.select(selectedId); + } + + return; + } + + const dialog = new ApplicantsDialog({ + schoolId, + staffing, + locate: (id) => this.placeOf(id), + selectedId, + onChange: (next, hiredId) => { + this.staffing = next; + this.selectedId = hiredId; + this.paint(); + void this.refreshTimetable(); + void this.openCard(hiredId); + }, + }); + this.poolDialog = dialog; + try { + await dialog.open(); + } finally { + this.poolDialog = null; + } + } + + private async openCard(personId: string): Promise { const schoolId = this.schoolId; if (schoolId === null) { return; @@ -390,7 +381,7 @@ export class ManagementPanel { const token = ++this.cardToken; try { - const card = await fetchPerson(schoolId, selection.id, getLocale()); + const card = await fetchPerson(schoolId, personId, getLocale()); if (token !== this.cardToken) { return; } @@ -462,16 +453,16 @@ export class ManagementPanel { 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 }); + await this.select(personId); return; } if (staffing !== null && staffing.applicants.some((row) => row.id === personId)) { - await this.select({ kind: 'applicant', id: personId }); + await this.openApplicants(personId); return; } - await this.select({ kind: 'staff', id: personId }); + await this.select(personId); } private appendActions(personId: string): void { @@ -480,45 +471,12 @@ export class ManagementPanel { 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) { @@ -574,30 +532,6 @@ export class ManagementPanel { 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 }; - await this.refreshTimetable(); - } 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; @@ -609,15 +543,16 @@ export class ManagementPanel { this.clearError(); try { this.staffing = await assignSubject(schoolId, personId, subject, getLocale()); - this.selection = { kind: 'staff', id: personId }; + this.selectedId = personId; + this.poolDialog?.setStaffing(this.staffing); await this.refreshTimetable(); } catch (error) { this.showError(actionError(error)); } finally { this.busy = false; this.paint(); - if (this.selection !== null) { - await this.openCard(this.selection); + if (this.selectedId !== null) { + await this.openCard(this.selectedId); } } } @@ -632,15 +567,16 @@ export class ManagementPanel { this.clearError(); try { this.staffing = await unassignSubject(schoolId, personId, subject, getLocale()); - this.selection = { kind: 'staff', id: personId }; + this.selectedId = personId; + this.poolDialog?.setStaffing(this.staffing); await this.refreshTimetable(); } catch (error) { this.showError(actionError(error)); } finally { this.busy = false; this.paint(); - if (this.selection !== null) { - await this.openCard(this.selection); + if (this.selectedId !== null) { + await this.openCard(this.selectedId); } } } @@ -670,63 +606,3 @@ export class ManagementPanel { 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 formatHours(value: number): string { - return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 1 }).format(value); -} - -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/staffingUi.ts b/src/HSchool.Client/src/ui/staffingUi.ts new file mode 100644 index 0000000..ae8e550 --- /dev/null +++ b/src/HSchool.Client/src/ui/staffingUi.ts @@ -0,0 +1,81 @@ +import { ApiError, type DefLabel } from '../net/api.ts'; +import { intlTag } from '../i18n/locale.ts'; +import { t } from '../i18n/strings.ts'; +import { el } from './dom.ts'; + +export 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; + } +} + +export function formatHours(value: number): string { + return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 1 }).format(value); +} + +export function formatMoney(value: number): string { + return new Intl.NumberFormat(intlTag(), { maximumFractionDigits: 2 }).format(value); +} + +export function formatSkill(skill: { readonly label: string; readonly value: string }): string { + return `${skill.label} ${skill.value}`; +} + +export function formatTraits(traits: readonly DefLabel[]): string { + return traits.map((trait) => trait.label).join(', ') || '—'; +} + +export 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'); + } +} + +export function parseOptionalInt(value: string): number | undefined { + if (value === '') { + return undefined; + } + + const parsed = Number(value); + return Number.isInteger(parsed) ? parsed : undefined; +} diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs index 1cda408..5999ff7 100644 --- a/src/HSchool.Content/PeopleDefValidator.cs +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -26,7 +26,7 @@ internal static class PeopleDefValidator foreach (var names in catalog.NameSets.Values) { - ValidateNameSet(names); + ValidateNameSet(names, catalog); } foreach (var subject in catalog.Subjects.Values) @@ -171,6 +171,11 @@ internal static class PeopleDefValidator throw new ContentLoadException($"SkillDef '{skill.DefName}' has a negative stdDev."); } + if (skill.AdultChance is < 0 or > 1) + { + throw new ContentLoadException($"SkillDef '{skill.DefName}' adultChance must be 0–1."); + } + foreach (var limit in skill.BodyLimits) { if (string.IsNullOrWhiteSpace(limit.Attribute)) @@ -500,13 +505,41 @@ internal static class PeopleDefValidator } } - private static void ValidateNameSet(NameSetDef names) + private static void ValidateNameSet(NameSetDef names, DefCatalog catalog) { if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule)) { throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown patronymicRule '{names.PatronymicRule}'."); } + foreach (var native in names.Spoken) + { + if (!catalog.Skills.TryGetValue(native, out var language) || language.Abstract) + { + throw new ContentLoadException($"NameSetDef '{names.DefName}' native language '{native}' is not a SkillDef."); + } + } + + if (names.RelatedLanguageChance is < 0 or > 1) + { + throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageChance must be 0–1."); + } + + if (names.RelatedLanguageStdDev < 0) + { + throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageStdDev must not be negative."); + } + + if (names.RelatedLanguageMean < 0) + { + throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageMean must not be negative."); + } + + if (names.RelatedLanguageMax < 0) + { + throw new ContentLoadException($"NameSetDef '{names.DefName}' relatedLanguageMax must not be negative."); + } + if (!NameGrammar.IsKnownGiven(names.DefaultGivenDeclension)) { throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown defaultGivenDeclension."); diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs index 968ad73..4847705 100644 --- a/src/HSchool.Content/PeopleDefs.cs +++ b/src/HSchool.Content/PeopleDefs.cs @@ -120,6 +120,18 @@ public sealed class SkillDef : Def public IReadOnlyList AgeMeans { get; init; } = []; public IReadOnlyList BodyLimits { get; init; } = []; + + /// Everyone has this: body, speech, the floor under a profession. + public bool Always { get; init; } + + /// Adult extras: cook, nurse, secretary. Not rolled for pupils. + public bool Work { get; init; } + + /// + /// Probability that an adult also has this, on top of the native language. 0 means never + /// by chance — pupils still get it when a subject of their year points here. + /// + public float AdultChance { get; init; } } public sealed class SubjectSkillShare @@ -338,6 +350,34 @@ public sealed class NameSetDef : Def public string DefaultSurnameDeclension { get; init; } = NameGrammar.Ov; + /// + /// Languages this set can speak natively. Slavic names cover Russian, Belarusian and + /// Ukrainian; the school picks one at create. Singular is still + /// accepted in JSONC for a one-language pack. + /// + public IReadOnlyList NativeLanguages { get; init; } = []; + + /// One-language form. Folded into when the list is empty. + public string? NativeLanguage { get; init; } + + public IReadOnlyList Spoken => + NativeLanguages.Count > 0 + ? NativeLanguages + : string.IsNullOrWhiteSpace(NativeLanguage) ? [] : [NativeLanguage]; + + /// + /// Chance each other language in is present at a low level — a Russian + /// speaker who understands Belarusian. 0 leaves relatives off the card. + /// + public float RelatedLanguageChance { get; init; } + + public float RelatedLanguageMean { get; init; } = 22f; + + public float RelatedLanguageStdDev { get; init; } = 8f; + + /// Cap so a related roll cannot look like a native speaker. + public int RelatedLanguageMax { get; init; } = 40; + public IReadOnlyList MaleGiven { get; init; } = []; public IReadOnlyList FemaleGiven { get; init; } = []; diff --git a/src/HSchool.People/ApplicantPool.cs b/src/HSchool.People/ApplicantPool.cs index 57caa57..eeaab4f 100644 --- a/src/HSchool.People/ApplicantPool.cs +++ b/src/HSchool.People/ApplicantPool.cs @@ -16,10 +16,11 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList @@ -31,7 +32,8 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList(rules.PoolSize); var taken = new HashSet(StringComparer.Ordinal); @@ -163,7 +170,7 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList 0)); + isParent: childDrafts.Length > 0, + nativeLanguage)); } if (hasMother) @@ -108,7 +110,8 @@ internal static class FamilyFactory surname, motherGiven, NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule), - isParent: childDrafts.Length > 0)); + isParent: childDrafts.Length > 0, + nativeLanguage)); } var childIds = new List(childDrafts.Length); @@ -127,7 +130,8 @@ internal static class FamilyFactory draft, asOf, surname, - fatherGiven.Form)); + fatherGiven.Form, + nativeLanguage)); } var family = new Family(familyId, parentIds, childIds, childIds.Count, fatherGiven.Form, surname.Male); @@ -146,7 +150,8 @@ internal static class FamilyFactory PupilSeat seat, DateTime yearStart, DateTime asOf, - int childIndex) + int childIndex, + string? nativeLanguage = null) { var fatherGiven = FatherGivenOf(family, members); if (fatherGiven is null) @@ -169,7 +174,7 @@ internal static class FamilyFactory var (surnameNom, surnameCases) = SurnameFor(family, names, members, female); var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age); var traits = PersonSampler.Traits(catalog, rng, [PersonRoles.Student], age); - var skills = PersonSampler.Skills(catalog, rng, age, choices, traits); + var skills = PersonSampler.Skills(catalog, names, rng, age, choices, traits, seat.Year, adult: false, nativeLanguage); var needs = PersonSampler.Needs(catalog); var name = new PersonName( given.Form, @@ -209,7 +214,8 @@ internal static class FamilyFactory int schoolSeed, int familyIndex, DateTime asOf, - string idPrefix = "a") + string idPrefix = "a", + string? nativeLanguage = null) { var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.AppearanceSalt)); var familyId = $"{idPrefix}{familyIndex}"; @@ -232,7 +238,8 @@ internal static class FamilyFactory surname, given, NameGrammar.Patronymic(patronymicSource.Form, female, names.PatronymicRule), - isParent: false); + isParent: false, + nativeLanguage); return (new Family(familyId, [id], [], NextChild: 0, FatherGiven: string.Empty, Surname: surname.Male), person); } @@ -249,7 +256,8 @@ internal static class FamilyFactory SurnameEntry surname, GivenNameEntry given, string patronymic, - bool isParent) + bool isParent, + string? nativeLanguage) { var age = SchoolYears.AgeYears(birth, asOf); var roles = isParent ? new[] { PersonRoles.Parent } : new[] { PersonRoles.Staff }; @@ -269,7 +277,9 @@ internal static class FamilyFactory roles, isStudent: false, isParent, - classId: null); + classId: null, + pupilYear: null, + nativeLanguage); } private static Person RollChild( @@ -281,7 +291,8 @@ internal static class FamilyFactory ChildDraft draft, DateTime asOf, SurnameEntry surname, - string fatherGiven) + string fatherGiven, + string? nativeLanguage) { var age = SchoolYears.AgeYears(draft.Birth, asOf); var patronymic = NameGrammar.Patronymic(fatherGiven, draft.Female, names.PatronymicRule); @@ -301,7 +312,9 @@ internal static class FamilyFactory [PersonRoles.Student], isStudent: true, isParent: false, - draft.Seat.ClassId); + draft.Seat.ClassId, + draft.Seat.Year, + nativeLanguage); } private static Person FinishPerson( @@ -320,11 +333,22 @@ internal static class FamilyFactory IReadOnlyList roles, bool isStudent, bool isParent, - string? classId) + string? classId, + int? pupilYear, + string? nativeLanguage) { var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age); var traits = PersonSampler.Traits(catalog, rng, roles, age); - var skills = PersonSampler.Skills(catalog, rng, age, choices, traits); + var skills = PersonSampler.Skills( + catalog, + names, + rng, + age, + choices, + traits, + isStudent ? pupilYear : null, + adult: !isStudent, + nativeLanguage); var needs = PersonSampler.Needs(catalog); var name = new PersonName( given.Form, diff --git a/src/HSchool.People/NativeLanguages.cs b/src/HSchool.People/NativeLanguages.cs new file mode 100644 index 0000000..147f137 --- /dev/null +++ b/src/HSchool.People/NativeLanguages.cs @@ -0,0 +1,38 @@ +namespace HSchool.People; + +/// +/// A name set can speak several native languages; the school picks one. Empty request rolls from +/// the school seed so the same create always lands on the same tongue. +/// +public static class NativeLanguages +{ + public static bool Allows(NameSetDef names, string skill) => + names.Spoken.Contains(skill, StringComparer.Ordinal); + + /// + /// must already be in the set, or empty. Invalid ids return + /// null so the create path can 400. When omitted, a new school rolls; a reload without a + /// saved pick takes the first listed language so old saves do not reshuffle intake. + /// + public static string? Pick(NameSetDef names, int schoolSeed, string? requested, bool rollIfOmitted) + { + var spoken = names.Spoken; + if (spoken.Count == 0) + { + return null; + } + + if (!string.IsNullOrWhiteSpace(requested)) + { + return Allows(names, requested) ? requested : null; + } + + if (!rollIfOmitted || spoken.Count == 1) + { + return spoken[0]; + } + + var rng = new Random(Seed.ForSchool(schoolSeed, Seed.NativeLanguageSalt)); + return spoken[rng.Next(spoken.Count)]; + } +} diff --git a/src/HSchool.People/PersonSampler.cs b/src/HSchool.People/PersonSampler.cs index 4725861..d41cc09 100644 --- a/src/HSchool.People/PersonSampler.cs +++ b/src/HSchool.People/PersonSampler.cs @@ -35,28 +35,275 @@ internal static class PersonSampler public static Dictionary Skills( DefCatalog catalog, + NameSetDef? names, Random rng, int age, IReadOnlyDictionary choices, - IReadOnlyList traits) + IReadOnlyList traits, + int? pupilYear, + bool adult, + string? nativeLanguage = null) { var values = new Dictionary(StringComparer.Ordinal); foreach (var skill in catalog.Skills.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal)) { - if (skill.Abstract) + if (!skill.Abstract && skill.Always) + { + RollInto(values, rng, age, choices, skill); + } + } + + GrantNative(values, catalog, NativeOf(names, nativeLanguage), rng, age, choices); + if (pupilYear is { } year) + { + GrantYearSubjects(values, catalog, rng, age, choices, year); + } + + if (adult) + { + GrantAdultExtras(values, catalog, rng, age, choices); + } + + ApplyTraitsAndBody(values, catalog, choices, traits, values.Keys.ToArray()); + // After traits so Diligent cannot push a related tongue past the "knows a bit" cap. + GrantRelated(values, catalog, names, NativeOf(names, nativeLanguage), rng, choices); + return values; + } + + /// + /// Adds subject skills a pupil just became old enough for. Existing values stay put — yearly + /// intake must not re-roll mathematics every September. + /// + public static Dictionary EnsurePupilYear( + DefCatalog catalog, + NameSetDef? names, + Random rng, + int age, + IReadOnlyDictionary choices, + IReadOnlyList traits, + IReadOnlyDictionary existing, + int pupilYear, + string? nativeLanguage = null) + { + var values = new Dictionary(existing, StringComparer.Ordinal); + foreach (var skill in catalog.Skills.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal)) + { + if (skill.Abstract || !skill.Always || values.ContainsKey(skill.DefName)) { continue; } - var mean = MeanForAge(skill, age); - var stdDev = skill.Distribution?.StdDev ?? 10f; - var rolled = (int)Math.Round(mean + (stdDev * NextGaussian(rng))); - rolled = Clamp(rolled, skill.Range); - rolled = ApplyBodyLimits(rolled, skill, choices); - rolled = Clamp(rolled, skill.Range); - values[skill.DefName] = rolled; + RollInto(values, rng, age, choices, skill); } + GrantNative(values, catalog, NativeOf(names, nativeLanguage), rng, age, choices); + GrantYearSubjects(values, catalog, rng, age, choices, pupilYear); + + var added = values.Keys.Where(name => !existing.ContainsKey(name)).ToArray(); + ApplyTraitsAndBody(values, catalog, choices, traits, added); + return values; + } + + private static string? NativeOf(NameSetDef? names, string? nativeLanguage) + { + if (!string.IsNullOrWhiteSpace(nativeLanguage)) + { + return nativeLanguage; + } + + var spoken = names?.Spoken; + return spoken is { Count: > 0 } ? spoken[0] : null; + } + + private static void GrantNative( + Dictionary values, + DefCatalog catalog, + string? native, + Random rng, + int age, + IReadOnlyDictionary choices) + { + if (string.IsNullOrWhiteSpace(native) + || !catalog.Skills.TryGetValue(native, out var skill) + || skill.Abstract) + { + return; + } + + RollInto(values, rng, age, choices, skill); + } + + /// + /// Other tongues of the same name set, at a low level. Skips keys already granted as native + /// or by a subject — a Belarusian pupil who studies Russian keeps the school value. + /// + private static void GrantRelated( + Dictionary values, + DefCatalog catalog, + NameSetDef? names, + string? native, + Random rng, + IReadOnlyDictionary choices) + { + if (names is null || names.RelatedLanguageChance <= 0) + { + return; + } + + foreach (var name in names.Spoken.OrderBy(candidate => candidate, StringComparer.Ordinal)) + { + if (name.Equals(native, StringComparison.Ordinal) + || values.ContainsKey(name) + || !catalog.Skills.TryGetValue(name, out var skill) + || skill.Abstract) + { + continue; + } + + if (rng.NextDouble() >= names.RelatedLanguageChance) + { + continue; + } + + var rolled = (int)Math.Round(names.RelatedLanguageMean + (names.RelatedLanguageStdDev * NextGaussian(rng))); + rolled = Clamp(rolled, skill.Range); + rolled = Math.Min(rolled, names.RelatedLanguageMax); + rolled = ApplyBodyLimits(rolled, skill, choices); + values[skill.DefName] = Clamp(rolled, skill.Range); + } + } + + private static void GrantYearSubjects( + Dictionary values, + DefCatalog catalog, + Random rng, + int age, + IReadOnlyDictionary choices, + int pupilYear) + { + foreach (var subject in catalog.Subjects.Values.OrderBy(def => def.DefName, StringComparer.Ordinal)) + { + if (subject.Abstract || pupilYear < subject.Grades.Min || pupilYear > subject.Grades.Max) + { + continue; + } + + foreach (var share in subject.Skills.OrderBy(row => row.Skill, StringComparer.Ordinal)) + { + if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract) + { + continue; + } + + RollInto(values, rng, age, choices, skill); + } + } + } + + private static void GrantAdultExtras( + Dictionary values, + DefCatalog catalog, + Random rng, + int age, + IReadOnlyDictionary choices) + { + var academic = catalog.Skills.Values + .Where(skill => + !skill.Abstract + && !skill.Always + && !skill.Work + && skill.AdultChance <= 0 + && SubjectUses(catalog, skill.DefName)) + .OrderBy(skill => skill.DefName, StringComparer.Ordinal) + .ToList(); + PickInto(values, rng, age, choices, academic, rng.Next(3)); + + var work = catalog.Skills.Values + .Where(skill => !skill.Abstract && skill.Work) + .OrderBy(skill => skill.DefName, StringComparer.Ordinal) + .ToList(); + PickInto(values, rng, age, choices, work, rng.Next(3)); + + foreach (var skill in catalog.Skills.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal)) + { + if (skill.Abstract || skill.AdultChance <= 0 || values.ContainsKey(skill.DefName)) + { + continue; + } + + if (rng.NextDouble() < skill.AdultChance) + { + RollInto(values, rng, age, choices, skill); + } + } + } + + private static bool SubjectUses(DefCatalog catalog, string skillName) + { + foreach (var subject in catalog.Subjects.Values) + { + if (subject.Abstract) + { + continue; + } + + foreach (var share in subject.Skills) + { + if (share.Skill.Equals(skillName, StringComparison.Ordinal)) + { + return true; + } + } + } + + return false; + } + + private static void PickInto( + Dictionary values, + Random rng, + int age, + IReadOnlyDictionary choices, + List pool, + int count) + { + var remaining = pool.Where(skill => !values.ContainsKey(skill.DefName)).ToList(); + for (var n = 0; n < count && remaining.Count > 0; n++) + { + var index = rng.Next(remaining.Count); + RollInto(values, rng, age, choices, remaining[index]); + remaining.RemoveAt(index); + } + } + + private static void RollInto( + Dictionary values, + Random rng, + int age, + IReadOnlyDictionary choices, + SkillDef skill) + { + if (values.ContainsKey(skill.DefName)) + { + return; + } + + var mean = MeanForAge(skill, age); + var stdDev = skill.Distribution?.StdDev ?? 10f; + var rolled = (int)Math.Round(mean + (stdDev * NextGaussian(rng))); + rolled = Clamp(rolled, skill.Range); + rolled = ApplyBodyLimits(rolled, skill, choices); + values[skill.DefName] = Clamp(rolled, skill.Range); + } + + private static void ApplyTraitsAndBody( + Dictionary values, + DefCatalog catalog, + IReadOnlyDictionary choices, + IReadOnlyList traits, + IEnumerable names) + { + var allowed = names as ISet ?? new HashSet(names, StringComparer.Ordinal); var touched = new HashSet(StringComparer.Ordinal); foreach (var traitName in traits) { @@ -67,7 +314,8 @@ internal static class PersonSampler foreach (var modifier in trait.SkillModifiers) { - if (!values.TryGetValue(modifier.Skill, out var current) + if (!allowed.Contains(modifier.Skill) + || !values.TryGetValue(modifier.Skill, out var current) || !catalog.Skills.TryGetValue(modifier.Skill, out var skill)) { continue; @@ -86,8 +334,6 @@ internal static class PersonSampler var skill = catalog.Skills[skillName]; values[skillName] = Clamp(ApplyBodyLimits(values[skillName], skill, choices), skill.Range); } - - return values; } public static List Traits(DefCatalog catalog, Random rng, IReadOnlyList roles, int age) diff --git a/src/HSchool.People/RosterGenerator.cs b/src/HSchool.People/RosterGenerator.cs index 2a0e1f5..f1fb8a2 100644 --- a/src/HSchool.People/RosterGenerator.cs +++ b/src/HSchool.People/RosterGenerator.cs @@ -16,7 +16,8 @@ public static class RosterGenerator MapLayout map, int schoolSeed, string nameSetId, - DateTime? asOf = null) + DateTime? asOf = null, + string? nativeLanguage = null) { ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(map); @@ -27,6 +28,7 @@ public static class RosterGenerator throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId)); } + var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: true); var when = DateTime.SpecifyKind(asOf ?? DefaultAsOf, DateTimeKind.Utc); var yearStart = SchoolYears.StartOn(when); var demand = SchoolDemand.From(catalog, map); @@ -36,7 +38,7 @@ public static class RosterGenerator var families = new List(plans.Count); foreach (var plan in plans) { - var (family, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, when); + var (family, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, when, native); families.Add(family); people.AddRange(members); } diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs index fe88def..4bd5d84 100644 --- a/src/HSchool.People/Seed.cs +++ b/src/HSchool.People/Seed.cs @@ -13,6 +13,8 @@ public static class Seed public const int HouseholdSalt = 5; public const int ApplicantSalt = 6; public const int CommuteSalt = 7; + public const int SkillGrantSalt = 8; + public const int NativeLanguageSalt = 9; /// A stream that belongs to the school rather than to one family. public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt); diff --git a/src/HSchool.People/YearlyIntake.cs b/src/HSchool.People/YearlyIntake.cs index 878b4e4..d87363e 100644 --- a/src/HSchool.People/YearlyIntake.cs +++ b/src/HSchool.People/YearlyIntake.cs @@ -39,7 +39,8 @@ public static class YearlyIntake Roster roster, int schoolSeed, string nameSetId, - DateTime asOf) + DateTime asOf, + string? nativeLanguage = null) { ArgumentNullException.ThrowIfNull(catalog); ArgumentNullException.ThrowIfNull(roster); @@ -50,6 +51,8 @@ public static class YearlyIntake throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId)); } + var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: false); + if (roster.Classes.Count == 0) { return roster; @@ -107,6 +110,10 @@ public static class YearlyIntake var classes = new SchoolClass[roster.Classes.Count]; var newSeats = new List(); + var incumbents = remainingPeople + .Where(pair => pair.Value.IsStudent) + .Select(pair => pair.Key) + .ToHashSet(StringComparer.Ordinal); for (var i = 0; i < roster.Classes.Count; i++) { var schoolClass = roster.Classes[i]; @@ -134,13 +141,56 @@ public static class YearlyIntake families, remainingPeople, newSeats, - nextFamilyIndex: roster.Families.Select(family => IndexOf(family.Id)).DefaultIfEmpty(-1).Max() + 1); + nextFamilyIndex: roster.Families.Select(family => IndexOf(family.Id)).DefaultIfEmpty(-1).Max() + 1, + native); + + GrantPromotedSkills(catalog, names, schoolSeed, when, remainingPeople, classes, incumbents, native); var people = remainingPeople.Values.ToList(); var filled = AttachPupils(classes, people); return new Roster(people, families, filled); } + private static void GrantPromotedSkills( + DefCatalog catalog, + NameSetDef names, + int schoolSeed, + DateTime when, + Dictionary people, + SchoolClass[] classes, + HashSet incumbents, + string? nativeLanguage) + { + var yearByClass = classes.ToDictionary( + schoolClass => schoolClass.Id, + schoolClass => schoolClass.Year, + StringComparer.Ordinal); + foreach (var id in incumbents) + { + if (!people.TryGetValue(id, out var person) + || person.ClassId is not { } classId + || !yearByClass.TryGetValue(classId, out var year)) + { + continue; + } + + var rng = new Random(Seed.Mix(schoolSeed, person.Id, when.Year, Seed.SkillGrantSalt)); + people[id] = person with + { + Skills = PersonSampler.EnsurePupilYear( + catalog, + names, + rng, + person.AgeOn(when), + person.Choices, + person.Traits, + person.Skills, + year, + nativeLanguage), + }; + } + } + private static void FillFirstYear( DefCatalog catalog, NameSetDef names, @@ -150,7 +200,8 @@ public static class YearlyIntake List families, Dictionary people, List seats, - int nextFamilyIndex) + int nextFamilyIndex, + string? nativeLanguage) { var cursor = 0; foreach (var family in families.ToArray()) @@ -185,7 +236,7 @@ public static class YearlyIntake var childIndex = family.NextChildIndex; var rng = new Random(Seed.Mix(schoolSeed, index, Seed.IntakeSalt + yearStart.Year * 10 + childIndex)); - var child = FamilyFactory.AddChild(catalog, names, rng, family, members, seats[cursor], yearStart, asOf, childIndex); + var child = FamilyFactory.AddChild(catalog, names, rng, family, members, seats[cursor], yearStart, asOf, childIndex, nativeLanguage); people[child.Id] = child; var familyAt = families.FindIndex(candidate => candidate.Id.Equals(family.Id, StringComparison.Ordinal)); families[familyAt] = family with @@ -209,7 +260,7 @@ public static class YearlyIntake var leftover = seats.Skip(cursor).ToArray(); foreach (var plan in FamilyPlanner.Singletons(leftover, Math.Max(nextFamilyIndex, 0))) { - var (created, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, asOf); + var (created, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, asOf, nativeLanguage); families.Add(created); foreach (var member in members) { diff --git a/src/HSchool.Server/Api/ModEndpoints.cs b/src/HSchool.Server/Api/ModEndpoints.cs index 6262c47..3c35325 100644 --- a/src/HSchool.Server/Api/ModEndpoints.cs +++ b/src/HSchool.Server/Api/ModEndpoints.cs @@ -86,7 +86,7 @@ internal sealed record CatalogResponse( IReadOnlyList Floors, IReadOnlyList Rooms, IReadOnlyList Things, - IReadOnlyList NameSets, + IReadOnlyList NameSets, IReadOnlyList Subjects, DayFrameResponse? DayFrame, IReadOnlyList Holidays, @@ -99,7 +99,7 @@ internal sealed record CatalogResponse( Placeable(catalog.Floors.Values, catalog, locale), PlaceableRooms(catalog, locale), PlaceableThings(catalog, locale), - Placeable(catalog.NameSets.Values, catalog, locale), + PlaceableNameSets(catalog, locale), PlaceableSubjects(catalog, locale), MapDayFrame(catalog, locale), PlaceableHolidays(catalog, locale), @@ -113,6 +113,22 @@ internal sealed record CatalogResponse( .Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def))) .ToArray(); + private static IReadOnlyList PlaceableNameSets(DefCatalog catalog, string locale) => + catalog.NameSets.Values + .Where(def => !def.Abstract) + .OrderBy(def => def.DefName, StringComparer.Ordinal) + .Select(def => new NameSetInfoResponse( + def.DefName, + catalog.Label(locale, def), + def.Spoken + .Select(skill => new DefInfoResponse( + skill, + catalog.Skills.TryGetValue(skill, out var language) + ? catalog.Label(locale, language) + : skill)) + .ToArray())) + .ToArray(); + private static IReadOnlyList PlaceableThings(DefCatalog catalog, string locale) => catalog.Things.Values .Where(def => !def.Abstract) @@ -178,6 +194,11 @@ internal sealed record CatalogResponse( internal sealed record DefInfoResponse(string DefName, string Label, int PupilSlots = 0); +internal sealed record NameSetInfoResponse( + string DefName, + string Label, + IReadOnlyList NativeLanguages); + internal sealed record RoomInfoResponse( string DefName, string Label, diff --git a/src/HSchool.Server/Api/PeopleModels.cs b/src/HSchool.Server/Api/PeopleModels.cs index 345a9ed..b44525b 100644 --- a/src/HSchool.Server/Api/PeopleModels.cs +++ b/src/HSchool.Server/Api/PeopleModels.cs @@ -206,7 +206,8 @@ internal sealed record ApplicantResponse( bool IsParent, float HourlyWageAsk, float MonthlyBase, - IReadOnlyList Skills); + IReadOnlyList Skills, + IReadOnlyList Traits); internal sealed record StaffMemberResponse( string Id, @@ -257,7 +258,8 @@ internal static class StaffingMapper applicant.Person.IsParent, applicant.HourlyWageAsk, rules is null ? 0f : Staffing.MonthlyBase(rules, applicant.HourlyWageAsk), - StrongSkills(applicant.Person, catalog, locale))) + SkillsOf(applicant.Person, catalog, locale), + TraitsOf(applicant.Person, catalog, locale))) .ToArray(); var staff = roster.People @@ -290,12 +292,11 @@ internal static class StaffingMapper return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff, positions, subjects); } - private static IReadOnlyList StrongSkills(Person person, DefCatalog? catalog, string locale) + private static IReadOnlyList SkillsOf(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) @@ -305,6 +306,17 @@ internal static class StaffingMapper .ToArray(); } + private static IReadOnlyList TraitsOf(Person person, DefCatalog? catalog, string locale) + { + return person.Traits + .Select(name => new DefLabelResponse( + name, + catalog is not null && catalog.Traits.TryGetValue(name, out var def) + ? catalog.Label(locale, def) + : name)) + .ToArray(); + } + private static StaffMemberResponse Member( Person person, Roster roster, diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 2beae24..3083437 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -54,6 +54,7 @@ internal static class SchoolEndpoints request.ModIds, request.Map, request.NameSetId, + request.NativeLanguage, NewCompletion()); commands.Enqueue(command); @@ -77,6 +78,8 @@ internal static class SchoolEndpoints Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."), SchoolCreationError.UnknownNameSet => Problem(StatusCodes.Status400BadRequest, "unknown-name-set", "The selected name set is not in the catalog."), + SchoolCreationError.UnknownNativeLanguage => + Problem(StatusCodes.Status400BadRequest, "unknown-native-language", "The selected native language is not in that name set."), _ => Results.Problem("Unknown error."), }; }) @@ -486,7 +489,8 @@ internal sealed record CreateSchoolRequest( DateTime StartDate, IReadOnlyList? ModIds, MapLayout? Map, - string? NameSetId); + string? NameSetId, + string? NativeLanguage); internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex) { diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 9362f58..9917e5e 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -17,6 +17,7 @@ internal abstract record GameCommand IReadOnlyList? ExtraModIds, MapLayout? Map, string? NameSetId, + string? NativeLanguage, TaskCompletionSource Result) : GameCommand; internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource Result) : GameCommand; diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index 0b4cb10..9ae24ab 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -328,10 +328,23 @@ internal sealed class GameLoopService( return; } + if (!catalog.NameSets.TryGetValue(nameSetId, out var names)) + { + command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNameSet)); + return; + } + + if (!string.IsNullOrWhiteSpace(command.NativeLanguage) && !NativeLanguages.Allows(names, command.NativeLanguage)) + { + command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNativeLanguage)); + return; + } + var id = _nextId++; store.WriteNextId(_nextId); + var nativeLanguage = NativeLanguages.Pick(names, id, command.NativeLanguage, rollIfOmitted: true); - var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId); + var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId, nativeLanguage); Track(worker); worker.Start(); @@ -499,6 +512,7 @@ internal sealed class GameLoopService( save.ModIds, save.Map, save.NameSetId, + save.NativeLanguage, save.Presence); worker.Start(); @@ -547,6 +561,7 @@ internal sealed class GameLoopService( IReadOnlyList? modIds, MapLayout? map, string? nameSetId, + string? nativeLanguage, IReadOnlyList? presence = null) => new( id, @@ -558,6 +573,7 @@ internal sealed class GameLoopService( modIds, map, nameSetId, + nativeLanguage, presence, _options, clients, diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 61c5618..7d8c54b 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -28,6 +28,8 @@ internal sealed class SchoolSave public string? NameSetId { get; init; } + public string? NativeLanguage { get; init; } + public IReadOnlyList? Presence { get; init; } } @@ -162,6 +164,7 @@ internal sealed class SchoolStore ModIds = save.ModIds, Map = save.Map, NameSetId = save.NameSetId, + NativeLanguage = save.NativeLanguage, Presence = save.Presence, }); } diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index 15f0219..972b94e 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -33,6 +33,7 @@ internal sealed class SchoolWorker private readonly IReadOnlyList? _modIds; private readonly MapLayout? _savedMap; private readonly string? _nameSetId; + private string? _nativeLanguage; private readonly IReadOnlyList? _savedPresence; private readonly Action _onFailed; @@ -65,6 +66,7 @@ internal sealed class SchoolWorker IReadOnlyList? modIds, MapLayout? savedMap, string? nameSetId, + string? nativeLanguage, IReadOnlyList? savedPresence, SimulationOptions options, ClientRegistry clients, @@ -83,6 +85,7 @@ internal sealed class SchoolWorker _modIds = modIds; _savedMap = savedMap; _nameSetId = nameSetId; + _nativeLanguage = nativeLanguage; _savedPresence = savedPresence; _options = options; _clients = clients; @@ -849,12 +852,15 @@ internal sealed class SchoolWorker ApplicantPool applicants; int seed; var generated = false; + string? native; if (_isNew) { seed = school.Id; - roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time); - applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time); + native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true); + _nativeLanguage = native; + roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native); + applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native); generated = true; } else @@ -863,13 +869,17 @@ internal sealed class SchoolWorker if (loaded is null) { seed = school.Id; - roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time); - applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time); + native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true); + _nativeLanguage = native; + roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time, native); + applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native); generated = true; } else { seed = loaded.Seed; + native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: false); + _nativeLanguage = native; roster = loaded.ToRoster(); if (loaded.Applicants is { Applicants.Count: > 0 }) { @@ -877,7 +887,7 @@ internal sealed class SchoolWorker } else { - applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time); + applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native); generated = true; } } @@ -889,7 +899,7 @@ internal sealed class SchoolWorker $"School {_id} roster does not match its map; the people file was left untouched."); } - school.InstallPeople(roster, seed, nameSetId, applicants); + school.InstallPeople(roster, seed, nameSetId, applicants, _nativeLanguage); InstallTimetable(school); school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick); school.RestorePresence(_savedPresence); @@ -916,6 +926,21 @@ internal sealed class SchoolWorker return available.Contains(requested, StringComparer.Ordinal) ? requested : null; } + private static string? ResolveNative( + DefCatalog catalog, + string nameSetId, + int schoolSeed, + string? requested, + bool generating) + { + if (!catalog.NameSets.TryGetValue(nameSetId, out var names)) + { + return null; + } + + return NativeLanguages.Pick(names, schoolSeed, requested, rollIfOmitted: generating && string.IsNullOrWhiteSpace(requested)); + } + private void Persist() { var school = _school; @@ -938,6 +963,7 @@ internal sealed class SchoolWorker ModIds = school.Catalog?.PackIds, Map = school.Map, NameSetId = _nameSetId, + NativeLanguage = _nativeLanguage, Presence = school.CapturePresence(), }); } diff --git a/src/HSchool.Server/mods/core/defs/namesets/slavic.jsonc b/src/HSchool.Server/mods/core/defs/namesets/slavic.jsonc index 938a4f6..6364a9b 100644 --- a/src/HSchool.Server/mods/core/defs/namesets/slavic.jsonc +++ b/src/HSchool.Server/mods/core/defs/namesets/slavic.jsonc @@ -3,6 +3,11 @@ "patronymicRule": "slavic", "defaultGivenDeclension": "hard", "defaultSurnameDeclension": "ov", + "nativeLanguages": ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"], + "relatedLanguageChance": 0.6, + "relatedLanguageMean": 22, + "relatedLanguageStdDev": 8, + "relatedLanguageMax": 40, "maleGiven": [ { "form": "Александр" }, { "form": "Алексей" }, diff --git a/src/HSchool.Server/mods/core/defs/skills/academic.jsonc b/src/HSchool.Server/mods/core/defs/skills/academic.jsonc index f3b0583..70f87c5 100644 --- a/src/HSchool.Server/mods/core/defs/skills/academic.jsonc +++ b/src/HSchool.Server/mods/core/defs/skills/academic.jsonc @@ -61,9 +61,4 @@ "range": { "min": 0, "max": 100 }, "distribution": { "mean": 48, "stdDev": 16 }, }, - { - "defName": "ForeignLanguage", - "range": { "min": 0, "max": 100 }, - "distribution": { "mean": 50, "stdDev": 15 }, - }, ] diff --git a/src/HSchool.Server/mods/core/defs/skills/languages.jsonc b/src/HSchool.Server/mods/core/defs/skills/languages.jsonc new file mode 100644 index 0000000..ec93238 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/skills/languages.jsonc @@ -0,0 +1,40 @@ +[ + { + "defName": "English", + "range": { "min": 0, "max": 100 }, + "distribution": { "mean": 48, "stdDev": 15 }, + "adultChance": 0.4, + "ageMeans": [ + { "age": 11, "mean": 28 }, + { "age": 17, "mean": 50 }, + ], + }, + { + "defName": "German", + "range": { "min": 0, "max": 100 }, + "distribution": { "mean": 42, "stdDev": 14 }, + "adultChance": 0.08, + "ageMeans": [ + { "age": 14, "mean": 24 }, + { "age": 17, "mean": 42 }, + ], + }, + { + "defName": "BelarusianLanguage", + "range": { "min": 0, "max": 100 }, + "distribution": { "mean": 52, "stdDev": 13 }, + "ageMeans": [ + { "age": 7, "mean": 32 }, + { "age": 17, "mean": 56 }, + ], + }, + { + "defName": "UkrainianLanguage", + "range": { "min": 0, "max": 100 }, + "distribution": { "mean": 52, "stdDev": 13 }, + "ageMeans": [ + { "age": 7, "mean": 32 }, + { "age": 17, "mean": 56 }, + ], + }, +] diff --git a/src/HSchool.Server/mods/core/defs/skills/physical.jsonc b/src/HSchool.Server/mods/core/defs/skills/physical.jsonc index 9b8e51a..fca16d1 100644 --- a/src/HSchool.Server/mods/core/defs/skills/physical.jsonc +++ b/src/HSchool.Server/mods/core/defs/skills/physical.jsonc @@ -1,6 +1,7 @@ [ { "defName": "Agility", + "always": true, "range": { "min": 0, "max": 100 }, "distribution": { "mean": 50, "stdDev": 14 }, "bodyLimits": [ @@ -11,6 +12,7 @@ }, { "defName": "Strength", + "always": true, "range": { "min": 0, "max": 100 }, "distribution": { "mean": 50, "stdDev": 14 }, "bodyLimits": [ @@ -20,6 +22,7 @@ }, { "defName": "Endurance", + "always": true, "range": { "min": 0, "max": 100 }, "distribution": { "mean": 50, "stdDev": 13 }, "bodyLimits": [ diff --git a/src/HSchool.Server/mods/core/defs/skills/social.jsonc b/src/HSchool.Server/mods/core/defs/skills/social.jsonc new file mode 100644 index 0000000..af7b5ec --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/skills/social.jsonc @@ -0,0 +1,13 @@ +[ + { + "defName": "Communication", + "always": true, + "range": { "min": 0, "max": 100 }, + "distribution": { "mean": 52, "stdDev": 12 }, + "ageMeans": [ + { "age": 7, "mean": 32 }, + { "age": 17, "mean": 52 }, + { "age": 40, "mean": 58 }, + ], + }, +] diff --git a/src/HSchool.Server/mods/core/defs/skills/work.jsonc b/src/HSchool.Server/mods/core/defs/skills/work.jsonc new file mode 100644 index 0000000..ff1287a --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/skills/work.jsonc @@ -0,0 +1,52 @@ +[ + { + "defName": "Pedagogy", + "work": true, + "range": { "min": 0, "max": 100 }, + "distribution": { "mean": 48, "stdDev": 14 }, + "ageMeans": [ + { "age": 22, "mean": 40 }, + { "age": 45, "mean": 58 }, + ], + }, + { + "defName": "Medicine", + "work": true, + "range": { "min": 0, "max": 100 }, + "distribution": { "mean": 46, "stdDev": 15 }, + "ageMeans": [ + { "age": 22, "mean": 38 }, + { "age": 45, "mean": 56 }, + ], + }, + { + "defName": "Cooking", + "work": true, + "range": { "min": 0, "max": 100 }, + "distribution": { "mean": 50, "stdDev": 14 }, + "ageMeans": [ + { "age": 20, "mean": 42 }, + { "age": 45, "mean": 58 }, + ], + }, + { + "defName": "Administration", + "work": true, + "range": { "min": 0, "max": 100 }, + "distribution": { "mean": 48, "stdDev": 13 }, + "ageMeans": [ + { "age": 22, "mean": 40 }, + { "age": 45, "mean": 57 }, + ], + }, + { + "defName": "Librarianship", + "work": true, + "range": { "min": 0, "max": 100 }, + "distribution": { "mean": 46, "stdDev": 13 }, + "ageMeans": [ + { "age": 22, "mean": 38 }, + { "age": 45, "mean": 55 }, + ], + }, +] diff --git a/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc b/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc index e7cb673..740caa6 100644 --- a/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc +++ b/src/HSchool.Server/mods/core/defs/staffing/rules.jsonc @@ -1,6 +1,6 @@ { "defName": "Staffing", - "poolSize": 12, + "poolSize": 32, "stayChance": 0.65, "parentChance": 0.35, "hourlyWageBase": 30, diff --git a/src/HSchool.Server/mods/core/defs/subjects/academic.jsonc b/src/HSchool.Server/mods/core/defs/subjects/academic.jsonc index 92153d5..7262c0a 100644 --- a/src/HSchool.Server/mods/core/defs/subjects/academic.jsonc +++ b/src/HSchool.Server/mods/core/defs/subjects/academic.jsonc @@ -32,7 +32,7 @@ "defName": "ForeignLanguage", "grades": { "min": 5, "max": 11 }, "hoursPerWeek": 3, - "skills": [{ "skill": "ForeignLanguage", "share": 1 }], + "skills": [{ "skill": "English", "share": 1 }], }, { "defName": "History", diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index 0223575..5f8aa83 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -62,6 +62,16 @@ "Geography": "Geography", "Informatics": "Informatics", "ForeignLanguage": "Foreign language", + "English": "English", + "German": "German", + "BelarusianLanguage": "Belarusian", + "UkrainianLanguage": "Ukrainian", + "Communication": "Communication", + "Pedagogy": "Pedagogy", + "Medicine": "Medicine", + "Cooking": "Cooking", + "Administration": "Administration", + "Librarianship": "Librarianship", "Agility": "Agility", "Strength": "Strength", "Endurance": "Endurance", diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index 0e7c3a2..5079728 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -62,6 +62,16 @@ "Geography": "География", "Informatics": "Информатика", "ForeignLanguage": "Иностранный язык", + "English": "Английский", + "German": "Немецкий", + "BelarusianLanguage": "Белорусский", + "UkrainianLanguage": "Украинский", + "Communication": "Общение", + "Pedagogy": "Педагогика", + "Medicine": "Медицина", + "Cooking": "Готовка", + "Administration": "Делопроизводство", + "Librarianship": "Библиотечное дело", "Agility": "Ловкость", "Strength": "Сила", "Endurance": "Выносливость", diff --git a/src/HSchool.Simulation/LessonLearningSystem.cs b/src/HSchool.Simulation/LessonLearningSystem.cs index 38f9e75..f1c1e88 100644 --- a/src/HSchool.Simulation/LessonLearningSystem.cs +++ b/src/HSchool.Simulation/LessonLearningSystem.cs @@ -65,13 +65,16 @@ internal static class LessonLearningSystem var hunger = needs.Values.GetValueOrDefault("Hunger", 1f); foreach (var share in subject.Skills) { - if (!catalog.Skills.TryGetValue(share.Skill, out var skill) - || skill.Abstract - || !skills.Values.TryGetValue(share.Skill, out var current)) + if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract) { continue; } + if (!skills.Values.TryGetValue(share.Skill, out var current)) + { + current = skill.Range.Min; + } + skills.Values[share.Skill] = LessonLearning.Gain( current, skill, diff --git a/src/HSchool.Simulation/School.cs b/src/HSchool.Simulation/School.cs index 62180c3..dff5e09 100644 --- a/src/HSchool.Simulation/School.cs +++ b/src/HSchool.Simulation/School.cs @@ -78,6 +78,9 @@ public sealed class School : IDisposable /// Name pack used to generate this school's people. Needed again on 1 September. public string? NameSetId { get; private set; } + /// Skill everyone generated for this school speaks natively. + public string? NativeLanguage { get; private set; } + /// Last built table. Null until the worker installs people. public Timetable? Timetable { get; private set; } @@ -115,7 +118,7 @@ public sealed class School : IDisposable /// /// Installs a roster that already matches the map. Spawns entities; does not write to disk. /// - public void InstallPeople(Roster roster, int seed, string? nameSetId = null, ApplicantPool? applicants = null) + public void InstallPeople(Roster roster, int seed, string? nameSetId = null, ApplicantPool? applicants = null, string? nativeLanguage = null) { ObjectDisposedException.ThrowIf(_disposed, this); ArgumentNullException.ThrowIfNull(roster); @@ -123,6 +126,7 @@ public sealed class School : IDisposable Roster = roster; PeopleSeed = seed; NameSetId = nameSetId; + NativeLanguage = nativeLanguage; Applicants = applicants; RosterSpawner.Spawn(World, roster); PlanDay = null; @@ -282,7 +286,7 @@ public sealed class School : IDisposable var changed = false; foreach (var date in YearlyIntake.DatesBetween(before, after)) { - Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, NameSetId, date); + Roster = YearlyIntake.Apply(Catalog, Roster, PeopleSeed, NameSetId, date, NativeLanguage); changed = true; } @@ -304,7 +308,7 @@ public sealed class School : IDisposable return false; } - var next = Applicants.Advance(Catalog, Roster, PeopleSeed, NameSetId, Clock.Time); + var next = Applicants.Advance(Catalog, Roster, PeopleSeed, NameSetId, Clock.Time, NativeLanguage); if (next.Week == Applicants.Week) { return false; diff --git a/src/HSchool.Simulation/SchoolRegistry.cs b/src/HSchool.Simulation/SchoolRegistry.cs index 96b9b5b..a5ad734 100644 --- a/src/HSchool.Simulation/SchoolRegistry.cs +++ b/src/HSchool.Simulation/SchoolRegistry.cs @@ -11,6 +11,7 @@ public enum SchoolCreationError UnknownMod, InvalidCatalog, UnknownNameSet, + UnknownNativeLanguage, } /// Outcome of : either the school or the reason there is none. diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index edce469..9c4ae01 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -160,6 +160,11 @@ public class SchoolApiTests(AppHostFixture fixture) Assert.Equal("yard", ru.DefaultMap.Territory?.Id); Assert.Equal("Славянский", Assert.Single(ru.NameSets, set => set.DefName == "Slavic").Label); Assert.Equal("Slavic", Assert.Single(en.NameSets, set => set.DefName == "Slavic").Label); + var slavic = Assert.Single(ru.NameSets, set => set.DefName == "Slavic"); + Assert.Equal( + ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"], + slavic.NativeLanguages.Select(language => language.DefName).ToArray()); + Assert.Equal("Белорусский", Assert.Single(slavic.NativeLanguages, language => language.DefName == "BelarusianLanguage").Label); Assert.Equal("Начальные классы", Assert.Single(ru.Subjects, subject => subject.DefName == "PrimarySchool").Label); Assert.Equal("Primary", Assert.Single(en.Subjects, subject => subject.DefName == "PrimarySchool").Label); var classroom = Assert.Single(ru.Rooms, room => room.DefName == "Classroom"); @@ -287,6 +292,27 @@ public class SchoolApiTests(AppHostFixture fixture) Assert.Equal("unknown-name-set", await ProblemCodeAsync(response)); } + [Fact] + public async Task CreateSchool_WithAnUnknownNativeLanguage_IsRejected() + { + using var client = fixture.App.CreateHttpClient("server"); + await ResetAsync(client); + + using var response = await client.PostAsJsonAsync( + "/api/schools", + new + { + name = "Чужой язык", + startDate = ExpectedDefaultStart, + nameSetId = "Slavic", + nativeLanguage = "Klingon", + }, + TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("unknown-native-language", await ProblemCodeAsync(response)); + } + [Fact] public async Task CreateSchool_WithACustomConnectedMap_Succeeds() { @@ -417,7 +443,7 @@ public class SchoolApiTests(AppHostFixture fixture) IReadOnlyList Floors, IReadOnlyList Rooms, IReadOnlyList Things, - IReadOnlyList NameSets, + IReadOnlyList NameSets, IReadOnlyList Subjects, DayFrameResponse? DayFrame, IReadOnlyList Holidays, @@ -425,6 +451,11 @@ public class SchoolApiTests(AppHostFixture fixture) private sealed record DefInfoResponse(string DefName, string Label); + private sealed record NameSetInfoResponse( + string DefName, + string Label, + IReadOnlyList NativeLanguages); + private sealed record RoomInfoResponse( string DefName, string Label, diff --git a/tests/HSchool.AppHost.Tests/StaffingApiTests.cs b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs index b11beb2..f33bd07 100644 --- a/tests/HSchool.AppHost.Tests/StaffingApiTests.cs +++ b/tests/HSchool.AppHost.Tests/StaffingApiTests.cs @@ -29,14 +29,23 @@ public class StaffingApiTests(AppHostFixture fixture) Assert.Equal(100_000f, staffing.Allocated); Assert.Equal(0f, staffing.Payroll); Assert.Equal(100_000f, staffing.Remaining); - Assert.Equal(12, staffing.Applicants.Count); + Assert.Equal(32, staffing.Applicants.Count); 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)); + Assert.All( + staffing.Applicants, + applicant => + { + Assert.Contains(applicant.Skills, skill => skill.Id == "Communication"); + Assert.Contains( + applicant.Skills, + skill => skill.Id is "RussianLanguage" or "BelarusianLanguage" or "UkrainianLanguage"); + Assert.All(applicant.Skills, skill => Assert.False(string.IsNullOrWhiteSpace(skill.Value))); + }); } [Fact] @@ -135,6 +144,12 @@ public class StaffingApiTests(AppHostFixture fixture) $"/api/schools/{school.Id}/staff/hire", new { personId = applicant.Id, position = "Teacher" }, TestContext.Current.CancellationToken); + if (response.StatusCode == HttpStatusCode.Conflict) + { + Assert.Equal("payroll-exceeded", await ProblemCodeAsync(response)); + break; + } + response.EnsureSuccessStatusCode(); staffing = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); Assert.NotNull(staffing); @@ -143,7 +158,7 @@ public class StaffingApiTests(AppHostFixture fixture) Assert.Equal(100_000f, staffing.Allocated); Assert.True(staffing.Payroll > 0f); Assert.True(staffing.Payroll <= staffing.Allocated); - Assert.Equal(12, staffing.Staff.Count); + Assert.NotEmpty(staffing.Staff); } [Fact] @@ -224,9 +239,23 @@ public class StaffingApiTests(AppHostFixture fixture) var staffing = await GetStaffingAsync(client, school.Id); while (staffing.Applicants.Count > 0) { - staffing = await HireAsync(client, school.Id, staffing.Applicants[0].Id, "Teacher"); + var applicant = staffing.Applicants[0]; + using var response = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/staff/hire", + new { personId = applicant.Id, position = "Teacher" }, + TestContext.Current.CancellationToken); + if (response.StatusCode == HttpStatusCode.Conflict) + { + break; + } + + response.EnsureSuccessStatusCode(); + staffing = await response.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken); + Assert.NotNull(staffing); } + Assert.NotEmpty(staffing.Staff); + var subjects = staffing.Subjects.Select(subject => subject.DefName).ToArray(); Assert.NotEmpty(subjects); @@ -327,7 +356,8 @@ public class StaffingApiTests(AppHostFixture fixture) bool IsParent, float HourlyWageAsk, float MonthlyBase, - IReadOnlyList Skills); + IReadOnlyList Skills, + IReadOnlyList Traits); private sealed record LabeledStatResponse(string Id, string Label, string Value); diff --git a/tests/HSchool.AppHost.Tests/TimetableApiTests.cs b/tests/HSchool.AppHost.Tests/TimetableApiTests.cs index 43cec49..ff5a6b5 100644 --- a/tests/HSchool.AppHost.Tests/TimetableApiTests.cs +++ b/tests/HSchool.AppHost.Tests/TimetableApiTests.cs @@ -118,6 +118,73 @@ public class TimetableApiTests(AppHostFixture fixture) && lesson.Locked); } + /// + /// The refusal codes the client switches on. `pin-rejected` is covered above; these five are + /// the rest of the documented contract, and none of them was exercised. + /// + [Fact] + public async Task PinAndUnpinRefusals_CarryTheDocumentedCodes() + { + using var client = fixture.App.CreateHttpClient("server"); + await SchoolApiTests.ResetAsync(client); + var school = await SchoolApiTests.CreateAsync(client, "Расписание отказы", TuesdayMorning); + + var empty = await GetTimetableAsync(client, school.Id); + var klass = empty.Classes[0]; + var room = empty.Rooms.First(candidate => candidate.Id.StartsWith("classroom-", StringComparison.Ordinal)); + + // Nobody teaches anything yet, so a well-formed pin still has no teacher to place. + Assert.Equal("no-teacher", await PinCodeAsync(client, school.Id, klass.Id, "Mathematics", room.Id)); + Assert.Equal("unknown-class", await PinCodeAsync(client, school.Id, "cZZ", "Mathematics", room.Id)); + Assert.Equal("unknown-subject", await PinCodeAsync(client, school.Id, klass.Id, "Astrology", room.Id)); + Assert.Equal("unknown-room", await PinCodeAsync(client, school.Id, klass.Id, "Mathematics", "no-such-room")); + + await HireMathAsync(client, school.Id); + var table = await GetTimetableAsync(client, school.Id); + var lesson = table.Lessons.First(row => row.Subject == "Mathematics"); + + using var pin = await client.PostAsJsonAsync( + $"/api/schools/{school.Id}/timetable/pin", + new { lesson.ClassId, lesson.Subject, lesson.RoomId, lesson.Day, lesson.Period }, + TestContext.Current.CancellationToken); + pin.EnsureSuccessStatusCode(); + + var unpinQuery = $"classId={Uri.EscapeDataString(lesson.ClassId)}&subject={lesson.Subject}" + + $"&day={lesson.Day}&period={lesson.Period}"; + using var unpin = await client.DeleteAsync( + $"/api/schools/{school.Id}/timetable/pin?{unpinQuery}", + TestContext.Current.CancellationToken); + unpin.EnsureSuccessStatusCode(); + + // The lock is gone; asking again is a 404, not a silent success. + using var again = await client.DeleteAsync( + $"/api/schools/{school.Id}/timetable/pin?{unpinQuery}", + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.NotFound, again.StatusCode); + Assert.Equal("unknown-lesson", await ProblemCodeAsync(again)); + + using var incomplete = await client.DeleteAsync( + $"/api/schools/{school.Id}/timetable/pin?classId={Uri.EscapeDataString(lesson.ClassId)}", + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.BadRequest, incomplete.StatusCode); + Assert.Equal("invalid-query", await ProblemCodeAsync(incomplete)); + } + + private static async Task PinCodeAsync( + HttpClient client, + int schoolId, + string classId, + string subject, + string roomId) + { + using var response = await client.PostAsJsonAsync( + $"/api/schools/{schoolId}/timetable/pin", + new { classId, subject, roomId, day = 0, period = 1 }, + TestContext.Current.CancellationToken); + Assert.False(response.IsSuccessStatusCode, $"pinning {subject} into {roomId} was expected to fail."); + return await ProblemCodeAsync(response); + } + [Fact] public async Task Saturday_HasNoOccupancyOnTheMap() { diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs index 54a16b4..74e60a7 100644 --- a/tests/HSchool.Content.Tests/PeopleDefTests.cs +++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs @@ -19,7 +19,17 @@ public class PeopleDefTests Assert.All(catalog.Needs.Values, need => Assert.True(need.DecayPerHour > 0)); Assert.True(catalog.Needs["Sleep"].RestoredOffCampus); Assert.Equal(0.1f, catalog.Needs["Hunger"].DecayPerHour); - Assert.Contains(catalog.Skills["Agility"].BodyLimits, limit => limit.Attribute == "Build" && limit.Value == "Obese"); + Assert.True(catalog.Skills["Communication"].Always); + Assert.True(catalog.Skills["Agility"].Always); + Assert.True(catalog.Skills["Pedagogy"].Work); + Assert.Equal(0.4f, catalog.Skills["English"].AdultChance); + Assert.Equal( + ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"], + catalog.NameSets["Slavic"].Spoken); + Assert.Equal(0.6f, catalog.NameSets["Slavic"].RelatedLanguageChance); + Assert.Equal(40, catalog.NameSets["Slavic"].RelatedLanguageMax); + Assert.Contains(catalog.Subjects["ForeignLanguage"].Skills, share => share.Skill == "English"); + Assert.False(catalog.Skills.ContainsKey("ForeignLanguage")); Assert.True(catalog.NameSets.ContainsKey("Slavic")); Assert.True(catalog.NameSets["Slavic"].MaleGiven.Count >= 20); Assert.Equal("Славянский", catalog.Label("ru", catalog.NameSets["Slavic"])); @@ -27,7 +37,7 @@ public class PeopleDefTests Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"])); Assert.True(catalog.Subjects.ContainsKey("PrimarySchool")); Assert.NotNull(catalog.StaffingRules); - Assert.Equal(12, catalog.StaffingRules.PoolSize); + Assert.Equal(32, catalog.StaffingRules.PoolSize); Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours); Assert.Equal(4, catalog.StaffingRules.WeeksPerMonth); Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours); diff --git a/tests/HSchool.Content.Tests/VanillaCoreTests.cs b/tests/HSchool.Content.Tests/VanillaCoreTests.cs index 1a74987..959fa1e 100644 --- a/tests/HSchool.Content.Tests/VanillaCoreTests.cs +++ b/tests/HSchool.Content.Tests/VanillaCoreTests.cs @@ -42,7 +42,7 @@ public class VanillaCoreTests Assert.Equal(4, catalog.Subjects["PrimarySchool"].Grades.Max); Assert.True(catalog.Subjects.ContainsKey("PhysicalEducation")); Assert.NotNull(catalog.StaffingRules); - Assert.Equal(12, catalog.StaffingRules.PoolSize); + Assert.Equal(32, catalog.StaffingRules.PoolSize); Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours); Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours); Assert.NotNull(catalog.DayFrame); diff --git a/tests/HSchool.People.Tests/Fixtures.cs b/tests/HSchool.People.Tests/Fixtures.cs index 573a02a..e29ec8a 100644 --- a/tests/HSchool.People.Tests/Fixtures.cs +++ b/tests/HSchool.People.Tests/Fixtures.cs @@ -96,7 +96,7 @@ internal static class Fixtures } public static Roster Generate(MapLayout map, int seed = SchoolSeed) => - RosterGenerator.Generate(Catalog(), map, seed, "Slavic", AsOf); + RosterGenerator.Generate(Catalog(), map, seed, "Slavic", AsOf, "RussianLanguage"); public static string RepoRoot() { diff --git a/tests/HSchool.People.Tests/SkillGrantTests.cs b/tests/HSchool.People.Tests/SkillGrantTests.cs new file mode 100644 index 0000000..89ea77e --- /dev/null +++ b/tests/HSchool.People.Tests/SkillGrantTests.cs @@ -0,0 +1,185 @@ +namespace HSchool.People.Tests; + +public class SkillGrantTests +{ + [Fact] + public void FirstYear_HasCoreAndPrimary_ButNotChemistryOrEnglish() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(11)); + var pupils = PupilsIn(roster, 1).ToArray(); + Assert.NotEmpty(pupils); + Assert.All( + pupils, + person => + { + Assert.True(person.Skills.ContainsKey("Communication")); + Assert.True(person.Skills.ContainsKey("RussianLanguage")); + Assert.True(person.Skills.ContainsKey("Agility")); + Assert.True(person.Skills.ContainsKey("Mathematics")); + Assert.False(person.Skills.ContainsKey("Chemistry")); + Assert.False(person.Skills.ContainsKey("Physics")); + Assert.False(person.Skills.ContainsKey("English")); + Assert.False(person.Skills.ContainsKey("German")); + Assert.False(person.Skills.ContainsKey("Pedagogy")); + }); + } + + [Fact] + public void YearFive_HasEnglish_YearEight_HasChemistry() + { + var roster = Fixtures.Generate(Fixtures.Classrooms(11)); + Assert.All( + PupilsIn(roster, 5), + person => + { + Assert.True(person.Skills.ContainsKey("English")); + Assert.False(person.Skills.ContainsKey("Chemistry")); + Assert.False(person.Skills.ContainsKey("German")); + }); + Assert.All(PupilsIn(roster, 8), person => Assert.True(person.Skills.ContainsKey("Chemistry"))); + } + + [Fact] + public void Adults_HaveNativeAndCommunication_ButNotEverySkill() + { + var catalog = Fixtures.Catalog(); + var roster = Fixtures.Generate(Fixtures.Classrooms(11)); + var adults = roster.People.Where(person => !person.IsStudent).ToArray(); + Assert.NotEmpty(adults); + + var concrete = catalog.Skills.Values.Count(skill => !skill.Abstract); + Assert.All( + adults, + person => + { + Assert.True(person.Skills.ContainsKey("Communication")); + Assert.True(person.Skills.ContainsKey("RussianLanguage")); + Assert.True(person.Skills.Count < concrete); + Assert.InRange(person.Skills.Keys.Count(name => catalog.Skills[name].Work), 0, 2); + }); + Assert.Contains( + adults, + person => person.Skills.Keys.Any(name => catalog.Skills[name].Work)); + } + + [Fact] + public void BelarusianNative_IsGrantedToEveryone_PupilsStillStudyRussian() + { + var catalog = Fixtures.Catalog(); + var roster = RosterGenerator.Generate( + catalog, + Fixtures.Classrooms(11), + Fixtures.SchoolSeed, + "Slavic", + Fixtures.AsOf, + "BelarusianLanguage"); + + var max = catalog.NameSets["Slavic"].RelatedLanguageMax; + Assert.All( + roster.People, + person => + { + Assert.True(person.Skills.ContainsKey("BelarusianLanguage")); + if (person.Skills.TryGetValue("UkrainianLanguage", out var ukrainian)) + { + Assert.InRange(ukrainian, 0, max); + } + }); + Assert.All( + roster.People.Where(person => person.IsStudent), + person => Assert.True(person.Skills.ContainsKey("RussianLanguage"))); + Assert.Contains( + roster.People.Where(person => person.IsStudent), + person => person.Skills["RussianLanguage"] > max); + } + + [Fact] + public void RelatedLanguages_AreCommonAndStayLow() + { + var catalog = Fixtures.Catalog(); + var max = catalog.NameSets["Slavic"].RelatedLanguageMax; + var roster = Fixtures.Generate(Fixtures.Classrooms(11)); + Assert.Contains( + roster.People, + person => + person.Skills.ContainsKey("BelarusianLanguage") + || person.Skills.ContainsKey("UkrainianLanguage")); + Assert.Contains(roster.People, person => !person.Skills.ContainsKey("BelarusianLanguage")); + Assert.Contains(roster.People, person => !person.Skills.ContainsKey("UkrainianLanguage")); + Assert.True( + roster.People.Count(person => + person.Skills.ContainsKey("BelarusianLanguage") + || person.Skills.ContainsKey("UkrainianLanguage")) + > roster.People.Count / 2); + + Assert.All( + roster.People, + person => + { + Assert.True(person.Skills.ContainsKey("RussianLanguage")); + if (person.Skills.TryGetValue("BelarusianLanguage", out var belarusian)) + { + Assert.InRange(belarusian, 0, max); + } + + if (person.Skills.TryGetValue("UkrainianLanguage", out var ukrainian)) + { + Assert.InRange(ukrainian, 0, max); + } + }); + } + + [Fact] + public void OmittedNativeLanguage_IsStableForTheSameSeed() + { + var map = Fixtures.Classrooms(2); + var a = RosterGenerator.Generate(Fixtures.Catalog(), map, 7, "Slavic", Fixtures.AsOf); + var b = RosterGenerator.Generate(Fixtures.Catalog(), map, 7, "Slavic", Fixtures.AsOf); + Assert.Equal( + a.People.Select(person => string.Join(',', person.Skills.Keys.Order(StringComparer.Ordinal))), + b.People.Select(person => string.Join(',', person.Skills.Keys.Order(StringComparer.Ordinal)))); + } + + [Fact] + public void Pupils_DoNotRollWorkSkills() + { + var catalog = Fixtures.Catalog(); + var roster = Fixtures.Generate(Fixtures.Classrooms(11)); + Assert.All( + roster.People.Where(person => person.IsStudent), + person => Assert.DoesNotContain(person.Skills.Keys, name => catalog.Skills[name].Work)); + } + + [Fact] + public void Intake_YearFourGainsEnglishWithoutRerollingMath() + { + var catalog = Fixtures.Catalog(); + var before = Fixtures.Generate(Fixtures.Classrooms(5)); + var year4 = before.Classes.Single(schoolClass => schoolClass.Year == 4); + var pupil = before.People.First(person => person.Id == year4.PupilIds[0]); + Assert.False(pupil.Skills.ContainsKey("English")); + var math = pupil.Skills["Mathematics"]; + + var after = YearlyIntake.Apply( + catalog, + before, + Fixtures.SchoolSeed, + "Slavic", + new DateTime(2012, 9, 1, 0, 0, 0, DateTimeKind.Utc)); + var grown = after.People.First(person => person.Id == pupil.Id); + Assert.True(grown.ClassId is { } classId && after.Classes.Any(schoolClass => + schoolClass.Id.Equals(classId, StringComparison.Ordinal) && schoolClass.Year == 5)); + Assert.True(grown.Skills.ContainsKey("English")); + Assert.Equal(math, grown.Skills["Mathematics"]); + } + + private static IEnumerable PupilsIn(Roster roster, int year) + { + var classIds = roster.Classes + .Where(schoolClass => schoolClass.Year == year) + .Select(schoolClass => schoolClass.Id) + .ToHashSet(StringComparer.Ordinal); + return roster.People.Where(person => + person.IsStudent && person.ClassId is { } classId && classIds.Contains(classId)); + } +} diff --git a/tests/HSchool.People.Tests/StaffingTests.cs b/tests/HSchool.People.Tests/StaffingTests.cs index f84581e..3893077 100644 --- a/tests/HSchool.People.Tests/StaffingTests.cs +++ b/tests/HSchool.People.Tests/StaffingTests.cs @@ -224,9 +224,10 @@ public class StaffingTests Assert.Equal(catalog.Subjects.Values.Count(subject => !subject.Abstract), uncovered.Count); var applicant = pool.Applicants[0]; - var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap); - var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap); + var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, 100_000f); + var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", 100_000f); + Assert.Equal(StaffingError.None, assigned.Error); Assert.DoesNotContain(Staffing.Uncovered(catalog, assigned.Roster), subject => subject.DefName == "Mathematics"); Assert.Contains(Staffing.Uncovered(catalog, assigned.Roster), subject => subject.DefName == "PrimarySchool"); } @@ -236,8 +237,9 @@ public class StaffingTests { var (catalog, map, roster, pool) = Fresh(); var applicant = pool.Applicants[0]; - var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, Cap); - var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", Cap); + var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, 100_000f); + var assigned = Staffing.AssignSubject(catalog, hired.Roster, hired.Pool, applicant.Person.Id, "Mathematics", 100_000f); + Assert.Equal(StaffingError.None, assigned.Error); var json = RosterJson.Serialize(RosterDocument.From(Fixtures.SchoolSeed, assigned.Roster, assigned.Pool)); var loaded = RosterJson.Parse(json); diff --git a/tests/HSchool.People.Tests/YearlyIntakeTests.cs b/tests/HSchool.People.Tests/YearlyIntakeTests.cs index 19e229b..09e96b9 100644 --- a/tests/HSchool.People.Tests/YearlyIntakeTests.cs +++ b/tests/HSchool.People.Tests/YearlyIntakeTests.cs @@ -118,5 +118,8 @@ public class YearlyIntakeTests private static string Snapshot(Roster roster) => string.Join('\n', roster.People.Select(person => - $"{person.Id}|{person.FamilyId}|{person.IsStudent}|{person.IsStaff}|{person.IsParent}|{person.ClassId}|{person.Name.Full}")); + $"{person.Id}|{person.FamilyId}|{person.IsStudent}|{person.IsStaff}|{person.IsParent}|{person.ClassId}|{person.Name.Full}|{Skills(person)}")); + + private static string Skills(Person person) => + string.Join(',', person.Skills.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value}")); }