Enhance school creation and staffing management with native language support
- Updated protocol documentation to include `nativeLanguages` in `nameSets` and added `nativeLanguage` to school creation options. - Enhanced the UI for school creation to allow selection of native languages, improving user experience. - Revised API interfaces to accommodate new native language features, ensuring proper data handling. - Improved localization strings to support new native language functionalities in both English and Russian. - Updated tests to validate the new native language features and ensure robust functionality in staffing scenarios.
This commit is contained in:
@@ -139,9 +139,9 @@
|
|||||||
| Место урока | Кабинет класса, если у предмета нет требования к типу помещения |
|
| Место урока | Кабинет класса, если у предмета нет требования к типу помещения |
|
||||||
| Нет учителя | Урок не ставится; предмет числится непокрытым |
|
| Нет учителя | Урок не ставится; предмет числится непокрытым |
|
||||||
| Карта | В дереве рядом с комнатой — что и у кого там сейчас |
|
| Карта | В дереве рядом с комнатой — что и у кого там сейчас |
|
||||||
| Панель локации | «Сейчас» и «Персонажи» заполняются из расписания |
|
| Панель локации | «Сейчас» и «Персонажи» заполняются из расписания — с срезом 5 из присутствия, см. [`ai.md`](ai.md) |
|
||||||
| Расписание класса | В разделе «Управление», сетка «день × урок» |
|
| Расписание класса | В разделе «Управление», сетка «день × урок» |
|
||||||
| Расписание учителя | В его карточке, там же где навыки и семья |
|
| Расписание учителя | В его карточке, там же где навыки и семья |
|
||||||
| Общая сетка школы | Не делаем: её читает только тот, кто раскладывает вручную |
|
| Общая сетка школы | Не делаем: её читает только тот, кто раскладывает вручную |
|
||||||
| Где живёт код | Отдельная библиотека рядом с `HSchool.People` |
|
| Где живёт код | Отдельная библиотека рядом с `HSchool.People` |
|
||||||
| Люди и место | Находятся по расписанию; ходьбы нет |
|
| Люди и место | Находятся по расписанию; ходьбы нет — отменено срезом 5, присутствие стало состоянием ([`ai.md`](ai.md)) |
|
||||||
|
|||||||
@@ -175,3 +175,230 @@
|
|||||||
- Срез 1 успел сдвинуться: коммит `5cd5a6d` тронул `gameScreen.ts` и `strings.ts` (подпись
|
- Срез 1 успел сдвинуться: коммит `5cd5a6d` тронул `gameScreen.ts` и `strings.ts` (подпись
|
||||||
идущего человека). По правилу журнала он больше не «проверен на `b135a9c`» — правка маленькая и
|
идущего человека). По правилу журнала он больше не «проверен на `b135a9c`» — правка маленькая и
|
||||||
относится к срезу 5, но следующий проход это увидит.
|
относится к срезу 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 прямо обещает, что «опоздание и отсутствие
|
||||||
|
видны».
|
||||||
|
|||||||
+16
-8
@@ -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
|
Things carry `pupilSlots` — how many pupils that thing hosts. The snapshot's per-node pupil-slot
|
||||||
total is computed on the server.
|
total is computed on the server.
|
||||||
|
|
||||||
`nameSets` is the list of placeable name packs (`defName` + label). The create dialog picks one;
|
`nameSets` is the list of placeable name packs (`defName` + label + `nativeLanguages`). Each
|
||||||
it is independent of the UI language.
|
`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`,
|
`subjects` is the list of placeable subjects (`defName`, label, `gradeMin`/`gradeMax`,
|
||||||
`hoursPerWeek`, `skills` with shares, and optional `room`). `room` is the RoomDef the lesson
|
`hoursPerWeek`, `skills` with shares, and optional `room`). `room` is the RoomDef the lesson
|
||||||
@@ -104,7 +106,8 @@ Body:
|
|||||||
"startDate": "2012-03-31T06:00:00Z",
|
"startDate": "2012-03-31T06:00:00Z",
|
||||||
"modIds": [],
|
"modIds": [],
|
||||||
"map": null,
|
"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.
|
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:
|
`nameSetId` is a `NameSetDef`; omit it to use the first placeable set in the catalog (vanilla:
|
||||||
`Slavic`). Unknown ids return `400` `unknown-name-set`.
|
`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 |
|
| 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` `unknown-mod` | An extra pack id is missing under `mods/`. |
|
||||||
| `400` `invalid-catalog` | The selected packs could not be loaded. |
|
| `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-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. |
|
| `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.
|
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`
|
### `POST /api/schools/{id}/staff/hire`
|
||||||
|
|
||||||
Body: `{ "personId": "a0.p0", "position": "Teacher" }`. Goes through the school's mailbox.
|
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;
|
On success returns the same payload as `GET .../staffing`, so it takes the same `?lang=ru|en`.
|
||||||
other positions fill the first free `RoomDef.positions` slot of that kind.
|
`Teacher` needs no room opening; other positions fill the first free `RoomDef.positions` slot
|
||||||
|
of that kind.
|
||||||
|
|
||||||
| Status | `code` | When |
|
| 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`
|
### `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 |
|
| 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}`
|
### `DELETE /api/schools/{id}/staff/{personId}/subjects/{subject}`
|
||||||
|
|
||||||
Removes one assignment. Payroll drops when the subject was not the only one. Unknown
|
Removes one assignment. Payroll drops when the subject was not the only one. Same success
|
||||||
assignment is `404` `unknown-assignment`.
|
payload and `?lang=ru|en` as the other two. Unknown assignment is `404` `unknown-assignment`.
|
||||||
|
|
||||||
### `GET /api/schools/{id}/timetable`
|
### `GET /api/schools/{id}/timetable`
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,14 @@ const ru = {
|
|||||||
errorUnknownMod: 'Выбранный мод не найден.',
|
errorUnknownMod: 'Выбранный мод не найден.',
|
||||||
errorInvalidCatalog: 'Не удалось загрузить выбранные моды.',
|
errorInvalidCatalog: 'Не удалось загрузить выбранные моды.',
|
||||||
errorUnknownNameSet: 'Выбранный набор имён не найден.',
|
errorUnknownNameSet: 'Выбранный набор имён не найден.',
|
||||||
|
errorUnknownNativeLanguage: 'Выбранный родной язык не входит в этот набор имён.',
|
||||||
catalogLoadFailed: 'Не удалось загрузить каталог модов.',
|
catalogLoadFailed: 'Не удалось загрузить каталог модов.',
|
||||||
|
|
||||||
modsTitle: 'Моды',
|
modsTitle: 'Моды',
|
||||||
nameSetTitle: 'Набор имён',
|
nameSetTitle: 'Набор имён',
|
||||||
|
nativeLanguageTitle: 'Родной язык',
|
||||||
|
nativeLanguageRandom: 'Случайный',
|
||||||
|
randomNativeTitle: 'Выбрать язык наугад',
|
||||||
coreModLocked: '{id} (всегда включён)',
|
coreModLocked: '{id} (всегда включён)',
|
||||||
mapEditorTitle: 'Карта',
|
mapEditorTitle: 'Карта',
|
||||||
editMap: 'Редактировать карту',
|
editMap: 'Редактировать карту',
|
||||||
@@ -135,6 +139,16 @@ const ru = {
|
|||||||
staffUncoveredEmpty: 'Все предметы существующих параллелей кем-то ведутся.',
|
staffUncoveredEmpty: 'Все предметы существующих параллелей кем-то ведутся.',
|
||||||
staffApplicants: 'Соискатели',
|
staffApplicants: 'Соискатели',
|
||||||
staffApplicantsEmpty: 'Пул пуст.',
|
staffApplicantsEmpty: 'Пул пуст.',
|
||||||
|
staffApplicantsOpen: 'Открыть список',
|
||||||
|
staffApplicantsCount: 'В пуле: {n}',
|
||||||
|
staffApplicantsSearch: 'Имя',
|
||||||
|
staffApplicantsSearchHint: 'Поиск по имени',
|
||||||
|
staffApplicantsKind: 'Кто',
|
||||||
|
staffApplicantsKindAll: 'Все',
|
||||||
|
staffApplicantsKindParent: 'Родители',
|
||||||
|
staffApplicantsKindOther: 'Со стороны',
|
||||||
|
staffApplicantsNoMatches: 'Никто не подходит под фильтр.',
|
||||||
|
staffApplicantsPickHint: 'Выберите соискателя в списке.',
|
||||||
staffHired: 'Штат',
|
staffHired: 'Штат',
|
||||||
staffHiredEmpty: 'Никого не наняли.',
|
staffHiredEmpty: 'Никого не наняли.',
|
||||||
staffColName: 'ФИО',
|
staffColName: 'ФИО',
|
||||||
@@ -148,7 +162,7 @@ const ru = {
|
|||||||
staffAsk: '{hourly}/ч · {monthly}/мес',
|
staffAsk: '{hourly}/ч · {monthly}/мес',
|
||||||
staffSubjectRange: '{label} ({min}–{max})',
|
staffSubjectRange: '{label} ({min}–{max})',
|
||||||
staffParent: 'родитель',
|
staffParent: 'родитель',
|
||||||
staffPickHint: 'Выберите соискателя или работника в списке.',
|
staffPickHint: 'Выберите сотрудника в списке или откройте соискателей.',
|
||||||
staffHireTitle: 'Наём',
|
staffHireTitle: 'Наём',
|
||||||
staffHire: 'Нанять',
|
staffHire: 'Нанять',
|
||||||
staffPosition: 'Должность',
|
staffPosition: 'Должность',
|
||||||
@@ -240,10 +254,14 @@ const en: Messages = {
|
|||||||
errorUnknownMod: 'A selected mod is missing.',
|
errorUnknownMod: 'A selected mod is missing.',
|
||||||
errorInvalidCatalog: 'The selected packs could not be loaded.',
|
errorInvalidCatalog: 'The selected packs could not be loaded.',
|
||||||
errorUnknownNameSet: 'The selected name set is not in the catalog.',
|
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.',
|
catalogLoadFailed: 'Could not load the mod catalog.',
|
||||||
|
|
||||||
modsTitle: 'Mods',
|
modsTitle: 'Mods',
|
||||||
nameSetTitle: 'Name set',
|
nameSetTitle: 'Name set',
|
||||||
|
nativeLanguageTitle: 'Native language',
|
||||||
|
nativeLanguageRandom: 'Random',
|
||||||
|
randomNativeTitle: 'Pick a language at random',
|
||||||
coreModLocked: '{id} (always on)',
|
coreModLocked: '{id} (always on)',
|
||||||
mapEditorTitle: 'Map',
|
mapEditorTitle: 'Map',
|
||||||
editMap: 'Edit map',
|
editMap: 'Edit map',
|
||||||
@@ -333,6 +351,16 @@ const en: Messages = {
|
|||||||
staffUncoveredEmpty: 'Every subject in the existing year-groups has a teacher.',
|
staffUncoveredEmpty: 'Every subject in the existing year-groups has a teacher.',
|
||||||
staffApplicants: 'Applicants',
|
staffApplicants: 'Applicants',
|
||||||
staffApplicantsEmpty: 'The pool is empty.',
|
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',
|
staffHired: 'Staff',
|
||||||
staffHiredEmpty: 'Nobody hired yet.',
|
staffHiredEmpty: 'Nobody hired yet.',
|
||||||
staffColName: 'Name',
|
staffColName: 'Name',
|
||||||
@@ -346,7 +374,7 @@ const en: Messages = {
|
|||||||
staffAsk: '{hourly}/h · {monthly}/mo',
|
staffAsk: '{hourly}/h · {monthly}/mo',
|
||||||
staffSubjectRange: '{label} ({min}–{max})',
|
staffSubjectRange: '{label} ({min}–{max})',
|
||||||
staffParent: 'parent',
|
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',
|
staffHireTitle: 'Hire',
|
||||||
staffHire: 'Hire',
|
staffHire: 'Hire',
|
||||||
staffPosition: 'Position',
|
staffPosition: 'Position',
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface CreateSchoolOptions {
|
|||||||
readonly modIds?: readonly string[];
|
readonly modIds?: readonly string[];
|
||||||
readonly map?: MapLayout;
|
readonly map?: MapLayout;
|
||||||
readonly nameSetId?: string;
|
readonly nameSetId?: string;
|
||||||
|
readonly nativeLanguage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSchool(
|
export async function createSchool(
|
||||||
@@ -66,6 +67,7 @@ export async function createSchool(
|
|||||||
modIds: extras.modIds ?? [],
|
modIds: extras.modIds ?? [],
|
||||||
map: extras.map ?? null,
|
map: extras.map ?? null,
|
||||||
nameSetId: extras.nameSetId ?? null,
|
nameSetId: extras.nameSetId ?? null,
|
||||||
|
nativeLanguage: extras.nativeLanguage ?? null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -81,6 +83,12 @@ export interface DefInfo {
|
|||||||
readonly pupilSlots?: number;
|
readonly pupilSlots?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NameSetInfo {
|
||||||
|
readonly defName: string;
|
||||||
|
readonly label: string;
|
||||||
|
readonly nativeLanguages: readonly DefInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RoomSlotInfo {
|
export interface RoomSlotInfo {
|
||||||
readonly key: string;
|
readonly key: string;
|
||||||
readonly thing: string;
|
readonly thing: string;
|
||||||
@@ -153,7 +161,7 @@ export interface CatalogResponse {
|
|||||||
readonly rooms: readonly RoomInfo[];
|
readonly rooms: readonly RoomInfo[];
|
||||||
readonly things: readonly DefInfo[];
|
readonly things: readonly DefInfo[];
|
||||||
readonly defaultMap: MapLayout;
|
readonly defaultMap: MapLayout;
|
||||||
readonly nameSets: readonly DefInfo[];
|
readonly nameSets: readonly NameSetInfo[];
|
||||||
readonly subjects: readonly SubjectInfo[];
|
readonly subjects: readonly SubjectInfo[];
|
||||||
readonly dayFrame: DayFrameInfo | null;
|
readonly dayFrame: DayFrameInfo | null;
|
||||||
readonly holidays: readonly HolidayInfo[];
|
readonly holidays: readonly HolidayInfo[];
|
||||||
@@ -324,6 +332,7 @@ export interface StaffingApplicant {
|
|||||||
readonly hourlyWageAsk: number;
|
readonly hourlyWageAsk: number;
|
||||||
readonly monthlyBase: number;
|
readonly monthlyBase: number;
|
||||||
readonly skills: readonly LabeledStat[];
|
readonly skills: readonly LabeledStat[];
|
||||||
|
readonly traits: readonly DefLabel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StaffMember {
|
export interface StaffMember {
|
||||||
|
|||||||
@@ -792,8 +792,14 @@ body {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.staffing__summary {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.staffing__list-section .people__table-wrap {
|
.staffing__list-section .people__table-wrap {
|
||||||
max-height: 220px;
|
max-height: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timetable {
|
.timetable {
|
||||||
@@ -1015,6 +1021,50 @@ body {
|
|||||||
margin-top: 12px;
|
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 {
|
.dialog::backdrop {
|
||||||
background: rgba(6, 9, 14, 0.7);
|
background: rgba(6, 9, 14, 0.7);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> | 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<void> {
|
||||||
|
const modal = new Modal<void>(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<void> {
|
||||||
|
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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
let catalog: CatalogResponse | null = null;
|
let catalog: CatalogResponse | null = null;
|
||||||
let currentMap: MapLayout | null = null;
|
let currentMap: MapLayout | null = null;
|
||||||
let nameSetId: string | null = null;
|
let nameSetId: string | null = null;
|
||||||
|
let nativeLanguageId: string | null = null;
|
||||||
|
|
||||||
const modsField = el('div', { class: 'field' });
|
const modsField = el('div', { class: 'field' });
|
||||||
const modsLabel = el('span', { class: 'field__label' });
|
const modsLabel = el('span', { class: 'field__label' });
|
||||||
@@ -44,6 +45,23 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
const nameSetField = el('label', { class: 'field' }, nameSetLabel, nameSetSelect);
|
const nameSetField = el('label', { class: 'field' }, nameSetLabel, nameSetSelect);
|
||||||
nameSetSelect.addEventListener('change', () => {
|
nameSetSelect.addEventListener('change', () => {
|
||||||
nameSetId = nameSetSelect.value === '' ? null : nameSetSelect.value;
|
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' });
|
const mapLabel = el('span', { class: 'field__label' });
|
||||||
@@ -87,6 +105,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)),
|
el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)),
|
||||||
modsField,
|
modsField,
|
||||||
nameSetField,
|
nameSetField,
|
||||||
|
nativeField,
|
||||||
mapField,
|
mapField,
|
||||||
error,
|
error,
|
||||||
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
|
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
|
||||||
@@ -99,6 +118,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
const waiting = value || catalog === null;
|
const waiting = value || catalog === null;
|
||||||
submitButton.toggleAttribute('disabled', waiting);
|
submitButton.toggleAttribute('disabled', waiting);
|
||||||
randomButton.toggleAttribute('disabled', value);
|
randomButton.toggleAttribute('disabled', value);
|
||||||
|
nativeRandomButton.toggleAttribute('disabled', value || languagesOf(catalog, nameSetId).length <= 1);
|
||||||
editMapButton.toggleAttribute('disabled', waiting);
|
editMapButton.toggleAttribute('disabled', waiting);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -123,7 +143,11 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
currentMap = structuredClone(next.defaultMap);
|
currentMap = structuredClone(next.defaultMap);
|
||||||
const stillThere = next.nameSets.some((set) => set.defName === nameSetId);
|
const stillThere = next.nameSets.some((set) => set.defName === nameSetId);
|
||||||
nameSetId = stillThere ? nameSetId : (next.nameSets[0]?.defName ?? null);
|
nameSetId = stillThere ? nameSetId : (next.nameSets[0]?.defName ?? null);
|
||||||
|
if (!languagesOf(next, nameSetId).some((language) => language.defName === nativeLanguageId)) {
|
||||||
|
nativeLanguageId = null;
|
||||||
|
}
|
||||||
paintNameSets(next);
|
paintNameSets(next);
|
||||||
|
paintNativeLanguages(next);
|
||||||
paintMapHint();
|
paintMapHint();
|
||||||
setBusy(busy);
|
setBusy(busy);
|
||||||
};
|
};
|
||||||
@@ -140,6 +164,24 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
nameSetSelect.disabled = next.nameSets.length <= 1;
|
nameSetSelect.disabled = next.nameSets.length <= 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const paintNativeLanguages = (next: CatalogResponse): void => {
|
||||||
|
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<void> => {
|
const reloadCatalog = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
applyCatalog(await fetchCatalog(getLocale(), [...extraModIds]));
|
applyCatalog(await fetchCatalog(getLocale(), [...extraModIds]));
|
||||||
@@ -193,6 +235,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
title.textContent = t('newSchool');
|
title.textContent = t('newSchool');
|
||||||
modsLabel.textContent = t('modsTitle');
|
modsLabel.textContent = t('modsTitle');
|
||||||
nameSetLabel.textContent = t('nameSetTitle');
|
nameSetLabel.textContent = t('nameSetTitle');
|
||||||
|
nativeLabel.textContent = t('nativeLanguageTitle');
|
||||||
|
nativeRandomButton.textContent = t('randomName');
|
||||||
|
nativeRandomButton.title = t('randomNativeTitle');
|
||||||
nameLabel.textContent = t('schoolName');
|
nameLabel.textContent = t('schoolName');
|
||||||
startLabel.textContent = t('gameStart');
|
startLabel.textContent = t('gameStart');
|
||||||
mapLabel.textContent = t('mapEditorTitle');
|
mapLabel.textContent = t('mapEditorTitle');
|
||||||
@@ -205,6 +250,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
paintMapHint();
|
paintMapHint();
|
||||||
if (catalog !== null) {
|
if (catalog !== null) {
|
||||||
paintNameSets(catalog);
|
paintNameSets(catalog);
|
||||||
|
paintNativeLanguages(catalog);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,6 +268,20 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
nativeRandomButton.addEventListener('click', () => {
|
||||||
|
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', () => {
|
randomButton.addEventListener('click', () => {
|
||||||
if (busy) {
|
if (busy) {
|
||||||
return;
|
return;
|
||||||
@@ -256,6 +316,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
modIds: [...extraModIds],
|
modIds: [...extraModIds],
|
||||||
map: currentMap,
|
map: currentMap,
|
||||||
nameSetId: nameSetId ?? undefined,
|
nameSetId: nameSetId ?? undefined,
|
||||||
|
nativeLanguage: nativeLanguageId ?? undefined,
|
||||||
})
|
})
|
||||||
.then((school) => modal.close(school))
|
.then((school) => modal.close(school))
|
||||||
.catch((reason: unknown) => {
|
.catch((reason: unknown) => {
|
||||||
@@ -270,6 +331,14 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
|
|||||||
return modal.open(nameInput);
|
return modal.open(nameInput);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function languagesOf(catalog: CatalogResponse | null, nameSetId: string | null): readonly { readonly defName: string; readonly label: string }[] {
|
||||||
|
if (catalog === null || nameSetId === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return catalog.nameSets.find((set) => set.defName === nameSetId)?.nativeLanguages ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
function mapsEqual(left: MapLayout, right: MapLayout): boolean {
|
function mapsEqual(left: MapLayout, right: MapLayout): boolean {
|
||||||
return JSON.stringify(left) === JSON.stringify(right);
|
return JSON.stringify(left) === JSON.stringify(right);
|
||||||
}
|
}
|
||||||
@@ -294,6 +363,8 @@ function describe(reason: unknown): string {
|
|||||||
return t('errorInvalidCatalog');
|
return t('errorInvalidCatalog');
|
||||||
case 'unknown-name-set':
|
case 'unknown-name-set':
|
||||||
return t('errorUnknownNameSet');
|
return t('errorUnknownNameSet');
|
||||||
|
case 'unknown-native-language':
|
||||||
|
return t('errorUnknownNativeLanguage');
|
||||||
default:
|
default:
|
||||||
return reason.message;
|
return reason.message;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
ApiError,
|
|
||||||
assignSubject,
|
assignSubject,
|
||||||
fetchPerson,
|
fetchPerson,
|
||||||
fetchStaffing,
|
fetchStaffing,
|
||||||
fetchTimetable,
|
fetchTimetable,
|
||||||
hireStaff,
|
|
||||||
unassignSubject,
|
unassignSubject,
|
||||||
type PersonCard,
|
type PersonCard,
|
||||||
type Staffing,
|
type Staffing,
|
||||||
type StaffingApplicant,
|
|
||||||
type StaffMember,
|
type StaffMember,
|
||||||
type Timetable,
|
type Timetable,
|
||||||
} from '../net/api.ts';
|
} from '../net/api.ts';
|
||||||
import { getLocale, intlTag } from '../i18n/locale.ts';
|
import { getLocale } from '../i18n/locale.ts';
|
||||||
import { t } from '../i18n/strings.ts';
|
import { t } from '../i18n/strings.ts';
|
||||||
|
import { ApplicantsDialog } from './applicantsDialog.ts';
|
||||||
import { clear, el } from './dom.ts';
|
import { clear, el } from './dom.ts';
|
||||||
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
|
import { formatPersonPlace, renderPersonCard } from './personCard.ts';
|
||||||
|
import { actionError, fillSelect, formatHours, formatMoney } from './staffingUi.ts';
|
||||||
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
import { personTimetableQuery, TimetableGrid } from './timetableGrid.ts';
|
||||||
|
|
||||||
const TEACHER = 'Teacher';
|
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
|
* Left: money, coverage, a door into the applicant window, staff. Right: the person card plus
|
||||||
* and subject actions. HTTP only — the clock stays on the bar above.
|
* subject actions. HTTP only — the clock stays on the bar above.
|
||||||
*/
|
*/
|
||||||
export class ManagementPanel {
|
export class ManagementPanel {
|
||||||
readonly listElement: HTMLElement;
|
readonly listElement: HTMLElement;
|
||||||
@@ -38,8 +33,8 @@ export class ManagementPanel {
|
|||||||
private readonly uncovered = el('div', { class: 'people__tags' });
|
private readonly uncovered = el('div', { class: 'people__tags' });
|
||||||
private readonly uncoveredEmpty = el('p', { class: 'panel__empty' });
|
private readonly uncoveredEmpty = el('p', { class: 'panel__empty' });
|
||||||
private readonly applicantsTitle = el('h3', { class: 'panel__section-title' });
|
private readonly applicantsTitle = el('h3', { class: 'panel__section-title' });
|
||||||
private readonly applicantsTable = el('table', { class: 'people__table' });
|
private readonly applicantsSummary = el('p', { class: 'staffing__summary' });
|
||||||
private readonly applicantsEmpty = el('p', { class: 'panel__empty' });
|
private readonly applicantsButton = el('button', { class: 'button', type: 'button' });
|
||||||
private readonly staffTitle = el('h3', { class: 'panel__section-title' });
|
private readonly staffTitle = el('h3', { class: 'panel__section-title' });
|
||||||
private readonly staffTable = el('table', { class: 'people__table' });
|
private readonly staffTable = el('table', { class: 'people__table' });
|
||||||
private readonly staffEmpty = el('p', { class: 'panel__empty' });
|
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 personGrid = new TimetableGrid({ editable: false, showClass: true });
|
||||||
private readonly personTimetableTitle = el('h4', { class: 'people__section-title' });
|
private readonly personTimetableTitle = el('h4', { class: 'people__section-title' });
|
||||||
private readonly card = el('aside', { class: 'panel__body people__card' });
|
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 readonly subjectSelect = el('select', { class: 'input people__input' });
|
||||||
|
|
||||||
private schoolId: number | null = null;
|
private schoolId: number | null = null;
|
||||||
private staffing: Staffing | null = null;
|
private staffing: Staffing | null = null;
|
||||||
private timetable: Timetable | null = null;
|
private timetable: Timetable | null = null;
|
||||||
private classId: string | null = null;
|
private classId: string | null = null;
|
||||||
private selection: Selection | null = null;
|
private selectedId: string | null = null;
|
||||||
private loadToken = 0;
|
private loadToken = 0;
|
||||||
private cardToken = 0;
|
private cardToken = 0;
|
||||||
private busy = false;
|
private busy = false;
|
||||||
private locate: ((id: string) => string) | null = null;
|
private locate: ((id: string) => string) | null = null;
|
||||||
private painted: PersonCard | null = null;
|
private painted: PersonCard | null = null;
|
||||||
|
private poolDialog: ApplicantsDialog | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.error.hidden = true;
|
this.error.hidden = true;
|
||||||
|
this.applicantsButton.addEventListener('click', () => void this.openApplicants());
|
||||||
this.listElement = el(
|
this.listElement = el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'panel__body staffing' },
|
{ 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' }, this.uncoveredTitle, this.uncoveredEmpty, this.uncovered),
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'panel__section staffing__list-section' },
|
{ class: 'panel__section' },
|
||||||
this.applicantsTitle,
|
this.applicantsTitle,
|
||||||
el('div', { class: 'people__table-wrap' }, this.applicantsTable, this.applicantsEmpty),
|
this.applicantsSummary,
|
||||||
|
this.applicantsButton,
|
||||||
),
|
),
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
@@ -109,18 +106,19 @@ export class ManagementPanel {
|
|||||||
localize(): void {
|
localize(): void {
|
||||||
this.uncoveredTitle.textContent = t('staffUncovered');
|
this.uncoveredTitle.textContent = t('staffUncovered');
|
||||||
this.applicantsTitle.textContent = t('staffApplicants');
|
this.applicantsTitle.textContent = t('staffApplicants');
|
||||||
|
this.applicantsButton.textContent = t('staffApplicantsOpen');
|
||||||
this.staffTitle.textContent = t('staffHired');
|
this.staffTitle.textContent = t('staffHired');
|
||||||
this.timetableTitle.textContent = t('timetableTitle');
|
this.timetableTitle.textContent = t('timetableTitle');
|
||||||
this.classLabel.textContent = t('timetableClass');
|
this.classLabel.textContent = t('timetableClass');
|
||||||
this.uncoveredEmpty.textContent = t('staffUncoveredEmpty');
|
this.uncoveredEmpty.textContent = t('staffUncoveredEmpty');
|
||||||
this.applicantsEmpty.textContent = t('staffApplicantsEmpty');
|
|
||||||
this.staffEmpty.textContent = t('staffHiredEmpty');
|
this.staffEmpty.textContent = t('staffHiredEmpty');
|
||||||
this.timetableGrid.localize();
|
this.timetableGrid.localize();
|
||||||
this.personGrid.localize();
|
this.personGrid.localize();
|
||||||
this.personTimetableTitle.textContent = t('timetableTitle');
|
this.personTimetableTitle.textContent = t('timetableTitle');
|
||||||
this.paint();
|
this.paint();
|
||||||
if (this.selection !== null) {
|
this.poolDialog?.localize();
|
||||||
void this.openCard(this.selection);
|
if (this.selectedId !== null) {
|
||||||
|
void this.openCard(this.selectedId);
|
||||||
} else {
|
} else {
|
||||||
this.paintCard(null);
|
this.paintCard(null);
|
||||||
}
|
}
|
||||||
@@ -130,7 +128,7 @@ export class ManagementPanel {
|
|||||||
const switched = this.schoolId !== schoolId;
|
const switched = this.schoolId !== schoolId;
|
||||||
this.schoolId = schoolId;
|
this.schoolId = schoolId;
|
||||||
if (switched) {
|
if (switched) {
|
||||||
this.selection = null;
|
this.selectedId = null;
|
||||||
this.staffing = null;
|
this.staffing = null;
|
||||||
this.timetable = null;
|
this.timetable = null;
|
||||||
this.classId = null;
|
this.classId = null;
|
||||||
@@ -167,9 +165,10 @@ export class ManagementPanel {
|
|||||||
this.syncClass(timetable);
|
this.syncClass(timetable);
|
||||||
this.syncSelection();
|
this.syncSelection();
|
||||||
this.paint();
|
this.paint();
|
||||||
|
this.poolDialog?.setStaffing(staffing);
|
||||||
this.timetableGrid.setTable(timetable, this.classId);
|
this.timetableGrid.setTable(timetable, this.classId);
|
||||||
if (this.selection !== null) {
|
if (this.selectedId !== null) {
|
||||||
void this.openCard(this.selection);
|
void this.openCard(this.selectedId);
|
||||||
} else {
|
} else {
|
||||||
this.paintCard(null);
|
this.paintCard(null);
|
||||||
}
|
}
|
||||||
@@ -207,21 +206,16 @@ export class ManagementPanel {
|
|||||||
|
|
||||||
private syncSelection(): void {
|
private syncSelection(): void {
|
||||||
const staffing = this.staffing;
|
const staffing = this.staffing;
|
||||||
const selected = this.selection;
|
const selected = this.selectedId;
|
||||||
if (staffing === null || selected === null) {
|
if (staffing === null || selected === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selected.kind === 'applicant' && staffing.applicants.some((row) => row.id === selected.id)) {
|
if (staffing.staff.some((row) => row.id === selected)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (staffing.staff.some((row) => row.id === selected.id)) {
|
this.selectedId = null;
|
||||||
this.selection = { kind: 'staff', id: selected.id };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.selection = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private paint(): void {
|
private paint(): void {
|
||||||
@@ -274,55 +268,10 @@ export class ManagementPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private paintApplicants(staffing: Staffing | null): void {
|
private paintApplicants(staffing: Staffing | null): void {
|
||||||
const rows = staffing?.applicants ?? [];
|
const count = staffing?.applicants.length ?? 0;
|
||||||
this.applicantsEmpty.hidden = rows.length > 0;
|
this.applicantsSummary.textContent =
|
||||||
this.applicantsTable.hidden = rows.length === 0;
|
count === 0 ? t('staffApplicantsEmpty') : t('staffApplicantsCount', { n: count });
|
||||||
clear(this.applicantsTable);
|
this.applicantsButton.disabled = count === 0;
|
||||||
if (rows.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = el('tbody');
|
|
||||||
this.applicantsTable.append(
|
|
||||||
el(
|
|
||||||
'thead',
|
|
||||||
{},
|
|
||||||
el(
|
|
||||||
'tr',
|
|
||||||
{},
|
|
||||||
el('th', { text: t('staffColName') }),
|
|
||||||
el('th', { text: t('staffColAsk') }),
|
|
||||||
el('th', { text: t('staffColSkills') }),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
body,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const applicant of rows) {
|
|
||||||
const selected = this.selection?.kind === 'applicant' && this.selection.id === applicant.id;
|
|
||||||
const row = el('tr', {
|
|
||||||
class: selected ? 'people__row people__row--active' : 'people__row',
|
|
||||||
dataset: { personId: applicant.id },
|
|
||||||
onClick: () => void this.select({ kind: 'applicant', id: applicant.id }),
|
|
||||||
});
|
|
||||||
const ask = el('td', {
|
|
||||||
text: t('staffAsk', {
|
|
||||||
hourly: formatMoney(applicant.hourlyWageAsk),
|
|
||||||
monthly: formatMoney(applicant.monthlyBase),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const name = el('td', { text: applicant.fullName });
|
|
||||||
if (applicant.isParent) {
|
|
||||||
name.append(el('span', { class: 'staffing__badge', text: t('staffParent') }));
|
|
||||||
}
|
|
||||||
|
|
||||||
row.append(
|
|
||||||
name,
|
|
||||||
ask,
|
|
||||||
el('td', { text: applicant.skills.map((skill) => skill.label).join(', ') || '—' }),
|
|
||||||
);
|
|
||||||
body.append(row);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private paintStaff(staffing: Staffing | null): void {
|
private paintStaff(staffing: Staffing | null): void {
|
||||||
@@ -343,9 +292,12 @@ export class ManagementPanel {
|
|||||||
'tr',
|
'tr',
|
||||||
{},
|
{},
|
||||||
el('th', { text: t('staffColName') }),
|
el('th', { text: t('staffColName') }),
|
||||||
|
el('th', { text: t('peopleColAge') }),
|
||||||
|
el('th', { text: t('peopleColSex') }),
|
||||||
el('th', { text: t('staffColPosition') }),
|
el('th', { text: t('staffColPosition') }),
|
||||||
el('th', { text: t('staffColSubjects') }),
|
el('th', { text: t('staffColSubjects') }),
|
||||||
el('th', { text: t('staffColHours') }),
|
el('th', { text: t('staffColHours') }),
|
||||||
|
el('th', { text: t('staffColAsk') }),
|
||||||
el('th', { text: t('staffColPay') }),
|
el('th', { text: t('staffColPay') }),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -353,11 +305,11 @@ export class ManagementPanel {
|
|||||||
);
|
);
|
||||||
|
|
||||||
for (const member of rows) {
|
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', {
|
const row = el('tr', {
|
||||||
class: selected ? 'people__row people__row--active' : 'people__row',
|
class: selected ? 'people__row people__row--active' : 'people__row',
|
||||||
dataset: { personId: member.id },
|
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 });
|
const name = el('td', { text: member.fullName });
|
||||||
if (member.isParent) {
|
if (member.isParent) {
|
||||||
@@ -366,23 +318,62 @@ export class ManagementPanel {
|
|||||||
|
|
||||||
row.append(
|
row.append(
|
||||||
name,
|
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.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: t('staffHours', { n: formatHours(member.weeklyHours) }) }),
|
||||||
|
el('td', { text: formatMoney(member.hourlyWageAsk) }),
|
||||||
el('td', { text: formatMoney(member.monthlyPay) }),
|
el('td', { text: formatMoney(member.monthlyPay) }),
|
||||||
);
|
);
|
||||||
body.append(row);
|
body.append(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async select(selection: Selection): Promise<void> {
|
private async select(personId: string): Promise<void> {
|
||||||
this.selection = selection;
|
this.selectedId = personId;
|
||||||
this.clearError();
|
this.clearError();
|
||||||
this.paint();
|
this.paint();
|
||||||
await this.openCard(selection);
|
await this.openCard(personId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openCard(selection: Selection): Promise<void> {
|
private async openApplicants(selectedId?: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
const schoolId = this.schoolId;
|
const schoolId = this.schoolId;
|
||||||
if (schoolId === null) {
|
if (schoolId === null) {
|
||||||
return;
|
return;
|
||||||
@@ -390,7 +381,7 @@ export class ManagementPanel {
|
|||||||
|
|
||||||
const token = ++this.cardToken;
|
const token = ++this.cardToken;
|
||||||
try {
|
try {
|
||||||
const card = await fetchPerson(schoolId, selection.id, getLocale());
|
const card = await fetchPerson(schoolId, personId, getLocale());
|
||||||
if (token !== this.cardToken) {
|
if (token !== this.cardToken) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -462,16 +453,16 @@ export class ManagementPanel {
|
|||||||
private async openRelative(personId: string): Promise<void> {
|
private async openRelative(personId: string): Promise<void> {
|
||||||
const staffing = this.staffing;
|
const staffing = this.staffing;
|
||||||
if (staffing !== null && staffing.staff.some((row) => row.id === personId)) {
|
if (staffing !== null && staffing.staff.some((row) => row.id === personId)) {
|
||||||
await this.select({ kind: 'staff', id: personId });
|
await this.select(personId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (staffing !== null && staffing.applicants.some((row) => row.id === personId)) {
|
if (staffing !== null && staffing.applicants.some((row) => row.id === personId)) {
|
||||||
await this.select({ kind: 'applicant', id: personId });
|
await this.openApplicants(personId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.select({ kind: 'staff', id: personId });
|
await this.select(personId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private appendActions(personId: string): void {
|
private appendActions(personId: string): void {
|
||||||
@@ -480,45 +471,12 @@ export class ManagementPanel {
|
|||||||
return;
|
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);
|
const member = staffing.staff.find((row) => row.id === personId);
|
||||||
if (member !== undefined && member.position === TEACHER) {
|
if (member !== undefined && member.position === TEACHER) {
|
||||||
this.appendSubjects(member);
|
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 {
|
private appendSubjects(member: StaffMember): void {
|
||||||
const staffing = this.staffing;
|
const staffing = this.staffing;
|
||||||
if (staffing === null) {
|
if (staffing === null) {
|
||||||
@@ -574,30 +532,6 @@ export class ManagementPanel {
|
|||||||
this.card.append(actions);
|
this.card.append(actions);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async hire(personId: string): Promise<void> {
|
|
||||||
const schoolId = this.schoolId;
|
|
||||||
if (schoolId === null || this.busy) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.busy = true;
|
|
||||||
this.clearError();
|
|
||||||
try {
|
|
||||||
const staffing = await hireStaff(schoolId, personId, this.positionSelect.value, getLocale());
|
|
||||||
this.staffing = staffing;
|
|
||||||
this.selection = { kind: 'staff', id: personId };
|
|
||||||
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<void> {
|
private async assign(personId: string): Promise<void> {
|
||||||
const schoolId = this.schoolId;
|
const schoolId = this.schoolId;
|
||||||
const subject = this.subjectSelect.value;
|
const subject = this.subjectSelect.value;
|
||||||
@@ -609,15 +543,16 @@ export class ManagementPanel {
|
|||||||
this.clearError();
|
this.clearError();
|
||||||
try {
|
try {
|
||||||
this.staffing = await assignSubject(schoolId, personId, subject, getLocale());
|
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();
|
await this.refreshTimetable();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showError(actionError(error));
|
this.showError(actionError(error));
|
||||||
} finally {
|
} finally {
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
this.paint();
|
this.paint();
|
||||||
if (this.selection !== null) {
|
if (this.selectedId !== null) {
|
||||||
await this.openCard(this.selection);
|
await this.openCard(this.selectedId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -632,15 +567,16 @@ export class ManagementPanel {
|
|||||||
this.clearError();
|
this.clearError();
|
||||||
try {
|
try {
|
||||||
this.staffing = await unassignSubject(schoolId, personId, subject, getLocale());
|
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();
|
await this.refreshTimetable();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showError(actionError(error));
|
this.showError(actionError(error));
|
||||||
} finally {
|
} finally {
|
||||||
this.busy = false;
|
this.busy = false;
|
||||||
this.paint();
|
this.paint();
|
||||||
if (this.selection !== null) {
|
if (this.selectedId !== null) {
|
||||||
await this.openCard(this.selection);
|
await this.openCard(this.selectedId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -670,63 +606,3 @@ export class ManagementPanel {
|
|||||||
this.error.textContent = '';
|
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ internal static class PeopleDefValidator
|
|||||||
|
|
||||||
foreach (var names in catalog.NameSets.Values)
|
foreach (var names in catalog.NameSets.Values)
|
||||||
{
|
{
|
||||||
ValidateNameSet(names);
|
ValidateNameSet(names, catalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var subject in catalog.Subjects.Values)
|
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.");
|
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)
|
foreach (var limit in skill.BodyLimits)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(limit.Attribute))
|
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))
|
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
|
||||||
{
|
{
|
||||||
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown patronymicRule '{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))
|
if (!NameGrammar.IsKnownGiven(names.DefaultGivenDeclension))
|
||||||
{
|
{
|
||||||
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown defaultGivenDeclension.");
|
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown defaultGivenDeclension.");
|
||||||
|
|||||||
@@ -120,6 +120,18 @@ public sealed class SkillDef : Def
|
|||||||
public IReadOnlyList<AgeMeanPoint> AgeMeans { get; init; } = [];
|
public IReadOnlyList<AgeMeanPoint> AgeMeans { get; init; } = [];
|
||||||
|
|
||||||
public IReadOnlyList<BodySkillLimit> BodyLimits { get; init; } = [];
|
public IReadOnlyList<BodySkillLimit> BodyLimits { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>Everyone has this: body, speech, the floor under a profession.</summary>
|
||||||
|
public bool Always { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Adult extras: cook, nurse, secretary. Not rolled for pupils.</summary>
|
||||||
|
public bool Work { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public float AdultChance { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class SubjectSkillShare
|
public sealed class SubjectSkillShare
|
||||||
@@ -338,6 +350,34 @@ public sealed class NameSetDef : Def
|
|||||||
|
|
||||||
public string DefaultSurnameDeclension { get; init; } = NameGrammar.Ov;
|
public string DefaultSurnameDeclension { get; init; } = NameGrammar.Ov;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Languages this set can speak natively. Slavic names cover Russian, Belarusian and
|
||||||
|
/// Ukrainian; the school picks one at create. Singular <see cref="NativeLanguage"/> is still
|
||||||
|
/// accepted in JSONC for a one-language pack.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> NativeLanguages { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>One-language form. Folded into <see cref="Spoken"/> when the list is empty.</summary>
|
||||||
|
public string? NativeLanguage { get; init; }
|
||||||
|
|
||||||
|
public IReadOnlyList<string> Spoken =>
|
||||||
|
NativeLanguages.Count > 0
|
||||||
|
? NativeLanguages
|
||||||
|
: string.IsNullOrWhiteSpace(NativeLanguage) ? [] : [NativeLanguage];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Chance each other language in <see cref="Spoken"/> is present at a low level — a Russian
|
||||||
|
/// speaker who understands Belarusian. 0 leaves relatives off the card.
|
||||||
|
/// </summary>
|
||||||
|
public float RelatedLanguageChance { get; init; }
|
||||||
|
|
||||||
|
public float RelatedLanguageMean { get; init; } = 22f;
|
||||||
|
|
||||||
|
public float RelatedLanguageStdDev { get; init; } = 8f;
|
||||||
|
|
||||||
|
/// <summary>Cap so a related roll cannot look like a native speaker.</summary>
|
||||||
|
public int RelatedLanguageMax { get; init; } = 40;
|
||||||
|
|
||||||
public IReadOnlyList<GivenNameEntry> MaleGiven { get; init; } = [];
|
public IReadOnlyList<GivenNameEntry> MaleGiven { get; init; } = [];
|
||||||
|
|
||||||
public IReadOnlyList<GivenNameEntry> FemaleGiven { get; init; } = [];
|
public IReadOnlyList<GivenNameEntry> FemaleGiven { get; init; } = [];
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
|
|||||||
Roster roster,
|
Roster roster,
|
||||||
int schoolSeed,
|
int schoolSeed,
|
||||||
string nameSetId,
|
string nameSetId,
|
||||||
DateTime asOf)
|
DateTime asOf,
|
||||||
|
string? nativeLanguage = null)
|
||||||
{
|
{
|
||||||
var week = ApplicantWeeks.Id(asOf);
|
var week = ApplicantWeeks.Id(asOf);
|
||||||
return Fill(catalog, roster, schoolSeed, nameSetId, asOf, new ApplicantPool(week, 0, []));
|
return Fill(catalog, roster, schoolSeed, nameSetId, asOf, new ApplicantPool(week, 0, []), nativeLanguage: nativeLanguage);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -31,7 +32,8 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
|
|||||||
Roster roster,
|
Roster roster,
|
||||||
int schoolSeed,
|
int schoolSeed,
|
||||||
string nameSetId,
|
string nameSetId,
|
||||||
DateTime asOf)
|
DateTime asOf,
|
||||||
|
string? nativeLanguage = null)
|
||||||
{
|
{
|
||||||
var target = ApplicantWeeks.Id(asOf);
|
var target = ApplicantWeeks.Id(asOf);
|
||||||
if (target <= Week)
|
if (target <= Week)
|
||||||
@@ -42,7 +44,7 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
|
|||||||
var pool = this;
|
var pool = this;
|
||||||
for (var week = Week + 1; week <= target; week++)
|
for (var week = Week + 1; week <= target; week++)
|
||||||
{
|
{
|
||||||
pool = Step(pool, catalog, roster, schoolSeed, nameSetId, asOf, week);
|
pool = Step(pool, catalog, roster, schoolSeed, nameSetId, asOf, week, nativeLanguage);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pool;
|
return pool;
|
||||||
@@ -93,7 +95,8 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
|
|||||||
int schoolSeed,
|
int schoolSeed,
|
||||||
string nameSetId,
|
string nameSetId,
|
||||||
DateTime asOf,
|
DateTime asOf,
|
||||||
int week)
|
int week,
|
||||||
|
string? nativeLanguage)
|
||||||
{
|
{
|
||||||
var rules = RequireRules(catalog);
|
var rules = RequireRules(catalog);
|
||||||
var rng = new Random(Seed.Mix(schoolSeed, week, Seed.ApplicantSalt));
|
var rng = new Random(Seed.Mix(schoolSeed, week, Seed.ApplicantSalt));
|
||||||
@@ -113,7 +116,8 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
|
|||||||
nameSetId,
|
nameSetId,
|
||||||
asOf,
|
asOf,
|
||||||
new ApplicantPool(week, current.NextIndex, staying),
|
new ApplicantPool(week, current.NextIndex, staying),
|
||||||
rng);
|
rng,
|
||||||
|
nativeLanguage);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ApplicantPool Fill(
|
private static ApplicantPool Fill(
|
||||||
@@ -123,7 +127,8 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
|
|||||||
string nameSetId,
|
string nameSetId,
|
||||||
DateTime asOf,
|
DateTime asOf,
|
||||||
ApplicantPool current,
|
ApplicantPool current,
|
||||||
Random? rng = null)
|
Random? rng = null,
|
||||||
|
string? nativeLanguage = null)
|
||||||
{
|
{
|
||||||
var rules = RequireRules(catalog);
|
var rules = RequireRules(catalog);
|
||||||
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
|
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
|
||||||
@@ -131,6 +136,8 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
|
|||||||
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
|
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: false);
|
||||||
|
|
||||||
rng ??= new Random(Seed.Mix(schoolSeed, current.Week, Seed.ApplicantSalt));
|
rng ??= new Random(Seed.Mix(schoolSeed, current.Week, Seed.ApplicantSalt));
|
||||||
var applicants = new List<Applicant>(rules.PoolSize);
|
var applicants = new List<Applicant>(rules.PoolSize);
|
||||||
var taken = new HashSet<string>(StringComparer.Ordinal);
|
var taken = new HashSet<string>(StringComparer.Ordinal);
|
||||||
@@ -163,7 +170,7 @@ public sealed record ApplicantPool(int Week, int NextIndex, IReadOnlyList<Applic
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var (_, person) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextIndex, asOf);
|
var (_, person) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextIndex, asOf, nativeLanguage: native);
|
||||||
nextIndex++;
|
nextIndex++;
|
||||||
taken.Add(person.Id);
|
taken.Add(person.Id);
|
||||||
next = new Applicant(person, HourlyAsk(catalog, person));
|
next = new Applicant(person, HourlyAsk(catalog, person));
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ internal static class FamilyFactory
|
|||||||
int schoolSeed,
|
int schoolSeed,
|
||||||
FamilyPlan plan,
|
FamilyPlan plan,
|
||||||
DateTime yearStart,
|
DateTime yearStart,
|
||||||
DateTime asOf)
|
DateTime asOf,
|
||||||
|
string? nativeLanguage = null)
|
||||||
{
|
{
|
||||||
var rng = new Random(Seed.Mix(schoolSeed, plan.FamilyIndex, Seed.AppearanceSalt));
|
var rng = new Random(Seed.Mix(schoolSeed, plan.FamilyIndex, Seed.AppearanceSalt));
|
||||||
var familyId = $"f{plan.FamilyIndex}";
|
var familyId = $"f{plan.FamilyIndex}";
|
||||||
@@ -89,7 +90,8 @@ internal static class FamilyFactory
|
|||||||
surname,
|
surname,
|
||||||
fatherGiven,
|
fatherGiven,
|
||||||
NameGrammar.Patronymic(fatherPatronymicSource.Form, female: false, names.PatronymicRule),
|
NameGrammar.Patronymic(fatherPatronymicSource.Form, female: false, names.PatronymicRule),
|
||||||
isParent: childDrafts.Length > 0));
|
isParent: childDrafts.Length > 0,
|
||||||
|
nativeLanguage));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasMother)
|
if (hasMother)
|
||||||
@@ -108,7 +110,8 @@ internal static class FamilyFactory
|
|||||||
surname,
|
surname,
|
||||||
motherGiven,
|
motherGiven,
|
||||||
NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule),
|
NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule),
|
||||||
isParent: childDrafts.Length > 0));
|
isParent: childDrafts.Length > 0,
|
||||||
|
nativeLanguage));
|
||||||
}
|
}
|
||||||
|
|
||||||
var childIds = new List<string>(childDrafts.Length);
|
var childIds = new List<string>(childDrafts.Length);
|
||||||
@@ -127,7 +130,8 @@ internal static class FamilyFactory
|
|||||||
draft,
|
draft,
|
||||||
asOf,
|
asOf,
|
||||||
surname,
|
surname,
|
||||||
fatherGiven.Form));
|
fatherGiven.Form,
|
||||||
|
nativeLanguage));
|
||||||
}
|
}
|
||||||
|
|
||||||
var family = new Family(familyId, parentIds, childIds, childIds.Count, fatherGiven.Form, surname.Male);
|
var family = new Family(familyId, parentIds, childIds, childIds.Count, fatherGiven.Form, surname.Male);
|
||||||
@@ -146,7 +150,8 @@ internal static class FamilyFactory
|
|||||||
PupilSeat seat,
|
PupilSeat seat,
|
||||||
DateTime yearStart,
|
DateTime yearStart,
|
||||||
DateTime asOf,
|
DateTime asOf,
|
||||||
int childIndex)
|
int childIndex,
|
||||||
|
string? nativeLanguage = null)
|
||||||
{
|
{
|
||||||
var fatherGiven = FatherGivenOf(family, members);
|
var fatherGiven = FatherGivenOf(family, members);
|
||||||
if (fatherGiven is null)
|
if (fatherGiven is null)
|
||||||
@@ -169,7 +174,7 @@ internal static class FamilyFactory
|
|||||||
var (surnameNom, surnameCases) = SurnameFor(family, names, members, female);
|
var (surnameNom, surnameCases) = SurnameFor(family, names, members, female);
|
||||||
var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age);
|
var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age);
|
||||||
var traits = PersonSampler.Traits(catalog, rng, [PersonRoles.Student], 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 needs = PersonSampler.Needs(catalog);
|
||||||
var name = new PersonName(
|
var name = new PersonName(
|
||||||
given.Form,
|
given.Form,
|
||||||
@@ -209,7 +214,8 @@ internal static class FamilyFactory
|
|||||||
int schoolSeed,
|
int schoolSeed,
|
||||||
int familyIndex,
|
int familyIndex,
|
||||||
DateTime asOf,
|
DateTime asOf,
|
||||||
string idPrefix = "a")
|
string idPrefix = "a",
|
||||||
|
string? nativeLanguage = null)
|
||||||
{
|
{
|
||||||
var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.AppearanceSalt));
|
var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.AppearanceSalt));
|
||||||
var familyId = $"{idPrefix}{familyIndex}";
|
var familyId = $"{idPrefix}{familyIndex}";
|
||||||
@@ -232,7 +238,8 @@ internal static class FamilyFactory
|
|||||||
surname,
|
surname,
|
||||||
given,
|
given,
|
||||||
NameGrammar.Patronymic(patronymicSource.Form, female, names.PatronymicRule),
|
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);
|
return (new Family(familyId, [id], [], NextChild: 0, FatherGiven: string.Empty, Surname: surname.Male), person);
|
||||||
}
|
}
|
||||||
@@ -249,7 +256,8 @@ internal static class FamilyFactory
|
|||||||
SurnameEntry surname,
|
SurnameEntry surname,
|
||||||
GivenNameEntry given,
|
GivenNameEntry given,
|
||||||
string patronymic,
|
string patronymic,
|
||||||
bool isParent)
|
bool isParent,
|
||||||
|
string? nativeLanguage)
|
||||||
{
|
{
|
||||||
var age = SchoolYears.AgeYears(birth, asOf);
|
var age = SchoolYears.AgeYears(birth, asOf);
|
||||||
var roles = isParent ? new[] { PersonRoles.Parent } : new[] { PersonRoles.Staff };
|
var roles = isParent ? new[] { PersonRoles.Parent } : new[] { PersonRoles.Staff };
|
||||||
@@ -269,7 +277,9 @@ internal static class FamilyFactory
|
|||||||
roles,
|
roles,
|
||||||
isStudent: false,
|
isStudent: false,
|
||||||
isParent,
|
isParent,
|
||||||
classId: null);
|
classId: null,
|
||||||
|
pupilYear: null,
|
||||||
|
nativeLanguage);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Person RollChild(
|
private static Person RollChild(
|
||||||
@@ -281,7 +291,8 @@ internal static class FamilyFactory
|
|||||||
ChildDraft draft,
|
ChildDraft draft,
|
||||||
DateTime asOf,
|
DateTime asOf,
|
||||||
SurnameEntry surname,
|
SurnameEntry surname,
|
||||||
string fatherGiven)
|
string fatherGiven,
|
||||||
|
string? nativeLanguage)
|
||||||
{
|
{
|
||||||
var age = SchoolYears.AgeYears(draft.Birth, asOf);
|
var age = SchoolYears.AgeYears(draft.Birth, asOf);
|
||||||
var patronymic = NameGrammar.Patronymic(fatherGiven, draft.Female, names.PatronymicRule);
|
var patronymic = NameGrammar.Patronymic(fatherGiven, draft.Female, names.PatronymicRule);
|
||||||
@@ -301,7 +312,9 @@ internal static class FamilyFactory
|
|||||||
[PersonRoles.Student],
|
[PersonRoles.Student],
|
||||||
isStudent: true,
|
isStudent: true,
|
||||||
isParent: false,
|
isParent: false,
|
||||||
draft.Seat.ClassId);
|
draft.Seat.ClassId,
|
||||||
|
draft.Seat.Year,
|
||||||
|
nativeLanguage);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Person FinishPerson(
|
private static Person FinishPerson(
|
||||||
@@ -320,11 +333,22 @@ internal static class FamilyFactory
|
|||||||
IReadOnlyList<string> roles,
|
IReadOnlyList<string> roles,
|
||||||
bool isStudent,
|
bool isStudent,
|
||||||
bool isParent,
|
bool isParent,
|
||||||
string? classId)
|
string? classId,
|
||||||
|
int? pupilYear,
|
||||||
|
string? nativeLanguage)
|
||||||
{
|
{
|
||||||
var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age);
|
var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age);
|
||||||
var traits = PersonSampler.Traits(catalog, rng, roles, 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 needs = PersonSampler.Needs(catalog);
|
||||||
var name = new PersonName(
|
var name = new PersonName(
|
||||||
given.Form,
|
given.Form,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace HSchool.People;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public static class NativeLanguages
|
||||||
|
{
|
||||||
|
public static bool Allows(NameSetDef names, string skill) =>
|
||||||
|
names.Spoken.Contains(skill, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <paramref name="requested"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
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)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,28 +35,275 @@ internal static class PersonSampler
|
|||||||
|
|
||||||
public static Dictionary<string, int> Skills(
|
public static Dictionary<string, int> Skills(
|
||||||
DefCatalog catalog,
|
DefCatalog catalog,
|
||||||
|
NameSetDef? names,
|
||||||
Random rng,
|
Random rng,
|
||||||
int age,
|
int age,
|
||||||
IReadOnlyDictionary<string, string> choices,
|
IReadOnlyDictionary<string, string> choices,
|
||||||
IReadOnlyList<string> traits)
|
IReadOnlyList<string> traits,
|
||||||
|
int? pupilYear,
|
||||||
|
bool adult,
|
||||||
|
string? nativeLanguage = null)
|
||||||
{
|
{
|
||||||
var values = new Dictionary<string, int>(StringComparer.Ordinal);
|
var values = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||||
foreach (var skill in catalog.Skills.Values.OrderBy(candidate => candidate.DefName, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds subject skills a pupil just became old enough for. Existing values stay put — yearly
|
||||||
|
/// intake must not re-roll mathematics every September.
|
||||||
|
/// </summary>
|
||||||
|
public static Dictionary<string, int> EnsurePupilYear(
|
||||||
|
DefCatalog catalog,
|
||||||
|
NameSetDef? names,
|
||||||
|
Random rng,
|
||||||
|
int age,
|
||||||
|
IReadOnlyDictionary<string, string> choices,
|
||||||
|
IReadOnlyList<string> traits,
|
||||||
|
IReadOnlyDictionary<string, int> existing,
|
||||||
|
int pupilYear,
|
||||||
|
string? nativeLanguage = null)
|
||||||
|
{
|
||||||
|
var values = new Dictionary<string, int>(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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var mean = MeanForAge(skill, age);
|
RollInto(values, rng, age, choices, skill);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<string, int> values,
|
||||||
|
DefCatalog catalog,
|
||||||
|
string? native,
|
||||||
|
Random rng,
|
||||||
|
int age,
|
||||||
|
IReadOnlyDictionary<string, string> choices)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(native)
|
||||||
|
|| !catalog.Skills.TryGetValue(native, out var skill)
|
||||||
|
|| skill.Abstract)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RollInto(values, rng, age, choices, skill);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
private static void GrantRelated(
|
||||||
|
Dictionary<string, int> values,
|
||||||
|
DefCatalog catalog,
|
||||||
|
NameSetDef? names,
|
||||||
|
string? native,
|
||||||
|
Random rng,
|
||||||
|
IReadOnlyDictionary<string, string> 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<string, int> values,
|
||||||
|
DefCatalog catalog,
|
||||||
|
Random rng,
|
||||||
|
int age,
|
||||||
|
IReadOnlyDictionary<string, string> 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<string, int> values,
|
||||||
|
DefCatalog catalog,
|
||||||
|
Random rng,
|
||||||
|
int age,
|
||||||
|
IReadOnlyDictionary<string, string> 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<string, int> values,
|
||||||
|
Random rng,
|
||||||
|
int age,
|
||||||
|
IReadOnlyDictionary<string, string> choices,
|
||||||
|
List<SkillDef> 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<string, int> values,
|
||||||
|
Random rng,
|
||||||
|
int age,
|
||||||
|
IReadOnlyDictionary<string, string> 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<string, int> values,
|
||||||
|
DefCatalog catalog,
|
||||||
|
IReadOnlyDictionary<string, string> choices,
|
||||||
|
IReadOnlyList<string> traits,
|
||||||
|
IEnumerable<string> names)
|
||||||
|
{
|
||||||
|
var allowed = names as ISet<string> ?? new HashSet<string>(names, StringComparer.Ordinal);
|
||||||
var touched = new HashSet<string>(StringComparer.Ordinal);
|
var touched = new HashSet<string>(StringComparer.Ordinal);
|
||||||
foreach (var traitName in traits)
|
foreach (var traitName in traits)
|
||||||
{
|
{
|
||||||
@@ -67,7 +314,8 @@ internal static class PersonSampler
|
|||||||
|
|
||||||
foreach (var modifier in trait.SkillModifiers)
|
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))
|
|| !catalog.Skills.TryGetValue(modifier.Skill, out var skill))
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -86,8 +334,6 @@ internal static class PersonSampler
|
|||||||
var skill = catalog.Skills[skillName];
|
var skill = catalog.Skills[skillName];
|
||||||
values[skillName] = Clamp(ApplyBodyLimits(values[skillName], skill, choices), skill.Range);
|
values[skillName] = Clamp(ApplyBodyLimits(values[skillName], skill, choices), skill.Range);
|
||||||
}
|
}
|
||||||
|
|
||||||
return values;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<string> Traits(DefCatalog catalog, Random rng, IReadOnlyList<string> roles, int age)
|
public static List<string> Traits(DefCatalog catalog, Random rng, IReadOnlyList<string> roles, int age)
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public static class RosterGenerator
|
|||||||
MapLayout map,
|
MapLayout map,
|
||||||
int schoolSeed,
|
int schoolSeed,
|
||||||
string nameSetId,
|
string nameSetId,
|
||||||
DateTime? asOf = null)
|
DateTime? asOf = null,
|
||||||
|
string? nativeLanguage = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(catalog);
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
ArgumentNullException.ThrowIfNull(map);
|
ArgumentNullException.ThrowIfNull(map);
|
||||||
@@ -27,6 +28,7 @@ public static class RosterGenerator
|
|||||||
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
|
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 when = DateTime.SpecifyKind(asOf ?? DefaultAsOf, DateTimeKind.Utc);
|
||||||
var yearStart = SchoolYears.StartOn(when);
|
var yearStart = SchoolYears.StartOn(when);
|
||||||
var demand = SchoolDemand.From(catalog, map);
|
var demand = SchoolDemand.From(catalog, map);
|
||||||
@@ -36,7 +38,7 @@ public static class RosterGenerator
|
|||||||
var families = new List<Family>(plans.Count);
|
var families = new List<Family>(plans.Count);
|
||||||
foreach (var plan in plans)
|
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);
|
families.Add(family);
|
||||||
people.AddRange(members);
|
people.AddRange(members);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public static class Seed
|
|||||||
public const int HouseholdSalt = 5;
|
public const int HouseholdSalt = 5;
|
||||||
public const int ApplicantSalt = 6;
|
public const int ApplicantSalt = 6;
|
||||||
public const int CommuteSalt = 7;
|
public const int CommuteSalt = 7;
|
||||||
|
public const int SkillGrantSalt = 8;
|
||||||
|
public const int NativeLanguageSalt = 9;
|
||||||
|
|
||||||
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
/// <summary>A stream that belongs to the school rather than to one family.</summary>
|
||||||
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
public static int ForSchool(int schoolSeed, int salt) => Mix(schoolSeed, familyIndex: -1, salt);
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ public static class YearlyIntake
|
|||||||
Roster roster,
|
Roster roster,
|
||||||
int schoolSeed,
|
int schoolSeed,
|
||||||
string nameSetId,
|
string nameSetId,
|
||||||
DateTime asOf)
|
DateTime asOf,
|
||||||
|
string? nativeLanguage = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(catalog);
|
ArgumentNullException.ThrowIfNull(catalog);
|
||||||
ArgumentNullException.ThrowIfNull(roster);
|
ArgumentNullException.ThrowIfNull(roster);
|
||||||
@@ -50,6 +51,8 @@ public static class YearlyIntake
|
|||||||
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
|
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var native = NativeLanguages.Pick(names, schoolSeed, nativeLanguage, rollIfOmitted: false);
|
||||||
|
|
||||||
if (roster.Classes.Count == 0)
|
if (roster.Classes.Count == 0)
|
||||||
{
|
{
|
||||||
return roster;
|
return roster;
|
||||||
@@ -107,6 +110,10 @@ public static class YearlyIntake
|
|||||||
|
|
||||||
var classes = new SchoolClass[roster.Classes.Count];
|
var classes = new SchoolClass[roster.Classes.Count];
|
||||||
var newSeats = new List<PupilSeat>();
|
var newSeats = new List<PupilSeat>();
|
||||||
|
var incumbents = remainingPeople
|
||||||
|
.Where(pair => pair.Value.IsStudent)
|
||||||
|
.Select(pair => pair.Key)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
for (var i = 0; i < roster.Classes.Count; i++)
|
for (var i = 0; i < roster.Classes.Count; i++)
|
||||||
{
|
{
|
||||||
var schoolClass = roster.Classes[i];
|
var schoolClass = roster.Classes[i];
|
||||||
@@ -134,13 +141,56 @@ public static class YearlyIntake
|
|||||||
families,
|
families,
|
||||||
remainingPeople,
|
remainingPeople,
|
||||||
newSeats,
|
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 people = remainingPeople.Values.ToList();
|
||||||
var filled = AttachPupils(classes, people);
|
var filled = AttachPupils(classes, people);
|
||||||
return new Roster(people, families, filled);
|
return new Roster(people, families, filled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void GrantPromotedSkills(
|
||||||
|
DefCatalog catalog,
|
||||||
|
NameSetDef names,
|
||||||
|
int schoolSeed,
|
||||||
|
DateTime when,
|
||||||
|
Dictionary<string, Person> people,
|
||||||
|
SchoolClass[] classes,
|
||||||
|
HashSet<string> 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(
|
private static void FillFirstYear(
|
||||||
DefCatalog catalog,
|
DefCatalog catalog,
|
||||||
NameSetDef names,
|
NameSetDef names,
|
||||||
@@ -150,7 +200,8 @@ public static class YearlyIntake
|
|||||||
List<Family> families,
|
List<Family> families,
|
||||||
Dictionary<string, Person> people,
|
Dictionary<string, Person> people,
|
||||||
List<PupilSeat> seats,
|
List<PupilSeat> seats,
|
||||||
int nextFamilyIndex)
|
int nextFamilyIndex,
|
||||||
|
string? nativeLanguage)
|
||||||
{
|
{
|
||||||
var cursor = 0;
|
var cursor = 0;
|
||||||
foreach (var family in families.ToArray())
|
foreach (var family in families.ToArray())
|
||||||
@@ -185,7 +236,7 @@ public static class YearlyIntake
|
|||||||
|
|
||||||
var childIndex = family.NextChildIndex;
|
var childIndex = family.NextChildIndex;
|
||||||
var rng = new Random(Seed.Mix(schoolSeed, index, Seed.IntakeSalt + yearStart.Year * 10 + childIndex));
|
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;
|
people[child.Id] = child;
|
||||||
var familyAt = families.FindIndex(candidate => candidate.Id.Equals(family.Id, StringComparison.Ordinal));
|
var familyAt = families.FindIndex(candidate => candidate.Id.Equals(family.Id, StringComparison.Ordinal));
|
||||||
families[familyAt] = family with
|
families[familyAt] = family with
|
||||||
@@ -209,7 +260,7 @@ public static class YearlyIntake
|
|||||||
var leftover = seats.Skip(cursor).ToArray();
|
var leftover = seats.Skip(cursor).ToArray();
|
||||||
foreach (var plan in FamilyPlanner.Singletons(leftover, Math.Max(nextFamilyIndex, 0)))
|
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);
|
families.Add(created);
|
||||||
foreach (var member in members)
|
foreach (var member in members)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ internal sealed record CatalogResponse(
|
|||||||
IReadOnlyList<DefInfoResponse> Floors,
|
IReadOnlyList<DefInfoResponse> Floors,
|
||||||
IReadOnlyList<RoomInfoResponse> Rooms,
|
IReadOnlyList<RoomInfoResponse> Rooms,
|
||||||
IReadOnlyList<DefInfoResponse> Things,
|
IReadOnlyList<DefInfoResponse> Things,
|
||||||
IReadOnlyList<DefInfoResponse> NameSets,
|
IReadOnlyList<NameSetInfoResponse> NameSets,
|
||||||
IReadOnlyList<SubjectInfoResponse> Subjects,
|
IReadOnlyList<SubjectInfoResponse> Subjects,
|
||||||
DayFrameResponse? DayFrame,
|
DayFrameResponse? DayFrame,
|
||||||
IReadOnlyList<HolidayInfoResponse> Holidays,
|
IReadOnlyList<HolidayInfoResponse> Holidays,
|
||||||
@@ -99,7 +99,7 @@ internal sealed record CatalogResponse(
|
|||||||
Placeable(catalog.Floors.Values, catalog, locale),
|
Placeable(catalog.Floors.Values, catalog, locale),
|
||||||
PlaceableRooms(catalog, locale),
|
PlaceableRooms(catalog, locale),
|
||||||
PlaceableThings(catalog, locale),
|
PlaceableThings(catalog, locale),
|
||||||
Placeable(catalog.NameSets.Values, catalog, locale),
|
PlaceableNameSets(catalog, locale),
|
||||||
PlaceableSubjects(catalog, locale),
|
PlaceableSubjects(catalog, locale),
|
||||||
MapDayFrame(catalog, locale),
|
MapDayFrame(catalog, locale),
|
||||||
PlaceableHolidays(catalog, locale),
|
PlaceableHolidays(catalog, locale),
|
||||||
@@ -113,6 +113,22 @@ internal sealed record CatalogResponse(
|
|||||||
.Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def)))
|
.Select(def => new DefInfoResponse(def.DefName, catalog.Label(locale, def)))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
|
private static IReadOnlyList<NameSetInfoResponse> 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<DefInfoResponse> PlaceableThings(DefCatalog catalog, string locale) =>
|
private static IReadOnlyList<DefInfoResponse> PlaceableThings(DefCatalog catalog, string locale) =>
|
||||||
catalog.Things.Values
|
catalog.Things.Values
|
||||||
.Where(def => !def.Abstract)
|
.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 DefInfoResponse(string DefName, string Label, int PupilSlots = 0);
|
||||||
|
|
||||||
|
internal sealed record NameSetInfoResponse(
|
||||||
|
string DefName,
|
||||||
|
string Label,
|
||||||
|
IReadOnlyList<DefInfoResponse> NativeLanguages);
|
||||||
|
|
||||||
internal sealed record RoomInfoResponse(
|
internal sealed record RoomInfoResponse(
|
||||||
string DefName,
|
string DefName,
|
||||||
string Label,
|
string Label,
|
||||||
|
|||||||
@@ -206,7 +206,8 @@ internal sealed record ApplicantResponse(
|
|||||||
bool IsParent,
|
bool IsParent,
|
||||||
float HourlyWageAsk,
|
float HourlyWageAsk,
|
||||||
float MonthlyBase,
|
float MonthlyBase,
|
||||||
IReadOnlyList<LabeledStatResponse> Skills);
|
IReadOnlyList<LabeledStatResponse> Skills,
|
||||||
|
IReadOnlyList<DefLabelResponse> Traits);
|
||||||
|
|
||||||
internal sealed record StaffMemberResponse(
|
internal sealed record StaffMemberResponse(
|
||||||
string Id,
|
string Id,
|
||||||
@@ -257,7 +258,8 @@ internal static class StaffingMapper
|
|||||||
applicant.Person.IsParent,
|
applicant.Person.IsParent,
|
||||||
applicant.HourlyWageAsk,
|
applicant.HourlyWageAsk,
|
||||||
rules is null ? 0f : Staffing.MonthlyBase(rules, applicant.HourlyWageAsk),
|
rules is null ? 0f : Staffing.MonthlyBase(rules, applicant.HourlyWageAsk),
|
||||||
StrongSkills(applicant.Person, catalog, locale)))
|
SkillsOf(applicant.Person, catalog, locale),
|
||||||
|
TraitsOf(applicant.Person, catalog, locale)))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
var staff = roster.People
|
var staff = roster.People
|
||||||
@@ -290,12 +292,11 @@ internal static class StaffingMapper
|
|||||||
return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff, positions, subjects);
|
return new StaffingResponse(allocated, payroll, remaining, uncovered, applicants, staff, positions, subjects);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<LabeledStatResponse> StrongSkills(Person person, DefCatalog? catalog, string locale)
|
private static IReadOnlyList<LabeledStatResponse> SkillsOf(Person person, DefCatalog? catalog, string locale)
|
||||||
{
|
{
|
||||||
return person.Skills
|
return person.Skills
|
||||||
.OrderByDescending(pair => pair.Value)
|
.OrderByDescending(pair => pair.Value)
|
||||||
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
|
.ThenBy(pair => pair.Key, StringComparer.Ordinal)
|
||||||
.Take(3)
|
|
||||||
.Select(pair => new LabeledStatResponse(
|
.Select(pair => new LabeledStatResponse(
|
||||||
pair.Key,
|
pair.Key,
|
||||||
catalog is not null && catalog.Skills.TryGetValue(pair.Key, out var def)
|
catalog is not null && catalog.Skills.TryGetValue(pair.Key, out var def)
|
||||||
@@ -305,6 +306,17 @@ internal static class StaffingMapper
|
|||||||
.ToArray();
|
.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<DefLabelResponse> 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(
|
private static StaffMemberResponse Member(
|
||||||
Person person,
|
Person person,
|
||||||
Roster roster,
|
Roster roster,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ internal static class SchoolEndpoints
|
|||||||
request.ModIds,
|
request.ModIds,
|
||||||
request.Map,
|
request.Map,
|
||||||
request.NameSetId,
|
request.NameSetId,
|
||||||
|
request.NativeLanguage,
|
||||||
NewCompletion<SchoolCreationOutcome>());
|
NewCompletion<SchoolCreationOutcome>());
|
||||||
commands.Enqueue(command);
|
commands.Enqueue(command);
|
||||||
|
|
||||||
@@ -77,6 +78,8 @@ internal static class SchoolEndpoints
|
|||||||
Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."),
|
Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."),
|
||||||
SchoolCreationError.UnknownNameSet =>
|
SchoolCreationError.UnknownNameSet =>
|
||||||
Problem(StatusCodes.Status400BadRequest, "unknown-name-set", "The selected name set is not in the catalog."),
|
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."),
|
_ => Results.Problem("Unknown error."),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -486,7 +489,8 @@ internal sealed record CreateSchoolRequest(
|
|||||||
DateTime StartDate,
|
DateTime StartDate,
|
||||||
IReadOnlyList<string>? ModIds,
|
IReadOnlyList<string>? ModIds,
|
||||||
MapLayout? Map,
|
MapLayout? Map,
|
||||||
string? NameSetId);
|
string? NameSetId,
|
||||||
|
string? NativeLanguage);
|
||||||
|
|
||||||
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
|
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ internal abstract record GameCommand
|
|||||||
IReadOnlyList<string>? ExtraModIds,
|
IReadOnlyList<string>? ExtraModIds,
|
||||||
MapLayout? Map,
|
MapLayout? Map,
|
||||||
string? NameSetId,
|
string? NameSetId,
|
||||||
|
string? NativeLanguage,
|
||||||
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
|
||||||
|
|
||||||
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
|
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
|
||||||
|
|||||||
@@ -328,10 +328,23 @@ internal sealed class GameLoopService(
|
|||||||
return;
|
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++;
|
var id = _nextId++;
|
||||||
store.WriteNextId(_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);
|
Track(worker);
|
||||||
worker.Start();
|
worker.Start();
|
||||||
|
|
||||||
@@ -499,6 +512,7 @@ internal sealed class GameLoopService(
|
|||||||
save.ModIds,
|
save.ModIds,
|
||||||
save.Map,
|
save.Map,
|
||||||
save.NameSetId,
|
save.NameSetId,
|
||||||
|
save.NativeLanguage,
|
||||||
save.Presence);
|
save.Presence);
|
||||||
worker.Start();
|
worker.Start();
|
||||||
|
|
||||||
@@ -547,6 +561,7 @@ internal sealed class GameLoopService(
|
|||||||
IReadOnlyList<string>? modIds,
|
IReadOnlyList<string>? modIds,
|
||||||
MapLayout? map,
|
MapLayout? map,
|
||||||
string? nameSetId,
|
string? nameSetId,
|
||||||
|
string? nativeLanguage,
|
||||||
IReadOnlyList<PresenceSnapshot>? presence = null) =>
|
IReadOnlyList<PresenceSnapshot>? presence = null) =>
|
||||||
new(
|
new(
|
||||||
id,
|
id,
|
||||||
@@ -558,6 +573,7 @@ internal sealed class GameLoopService(
|
|||||||
modIds,
|
modIds,
|
||||||
map,
|
map,
|
||||||
nameSetId,
|
nameSetId,
|
||||||
|
nativeLanguage,
|
||||||
presence,
|
presence,
|
||||||
_options,
|
_options,
|
||||||
clients,
|
clients,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ internal sealed class SchoolSave
|
|||||||
|
|
||||||
public string? NameSetId { get; init; }
|
public string? NameSetId { get; init; }
|
||||||
|
|
||||||
|
public string? NativeLanguage { get; init; }
|
||||||
|
|
||||||
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
|
public IReadOnlyList<PresenceSnapshot>? Presence { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +164,7 @@ internal sealed class SchoolStore
|
|||||||
ModIds = save.ModIds,
|
ModIds = save.ModIds,
|
||||||
Map = save.Map,
|
Map = save.Map,
|
||||||
NameSetId = save.NameSetId,
|
NameSetId = save.NameSetId,
|
||||||
|
NativeLanguage = save.NativeLanguage,
|
||||||
Presence = save.Presence,
|
Presence = save.Presence,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ internal sealed class SchoolWorker
|
|||||||
private readonly IReadOnlyList<string>? _modIds;
|
private readonly IReadOnlyList<string>? _modIds;
|
||||||
private readonly MapLayout? _savedMap;
|
private readonly MapLayout? _savedMap;
|
||||||
private readonly string? _nameSetId;
|
private readonly string? _nameSetId;
|
||||||
|
private string? _nativeLanguage;
|
||||||
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
private readonly IReadOnlyList<PresenceSnapshot>? _savedPresence;
|
||||||
private readonly Action<int> _onFailed;
|
private readonly Action<int> _onFailed;
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ internal sealed class SchoolWorker
|
|||||||
IReadOnlyList<string>? modIds,
|
IReadOnlyList<string>? modIds,
|
||||||
MapLayout? savedMap,
|
MapLayout? savedMap,
|
||||||
string? nameSetId,
|
string? nameSetId,
|
||||||
|
string? nativeLanguage,
|
||||||
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
IReadOnlyList<PresenceSnapshot>? savedPresence,
|
||||||
SimulationOptions options,
|
SimulationOptions options,
|
||||||
ClientRegistry clients,
|
ClientRegistry clients,
|
||||||
@@ -83,6 +85,7 @@ internal sealed class SchoolWorker
|
|||||||
_modIds = modIds;
|
_modIds = modIds;
|
||||||
_savedMap = savedMap;
|
_savedMap = savedMap;
|
||||||
_nameSetId = nameSetId;
|
_nameSetId = nameSetId;
|
||||||
|
_nativeLanguage = nativeLanguage;
|
||||||
_savedPresence = savedPresence;
|
_savedPresence = savedPresence;
|
||||||
_options = options;
|
_options = options;
|
||||||
_clients = clients;
|
_clients = clients;
|
||||||
@@ -849,12 +852,15 @@ internal sealed class SchoolWorker
|
|||||||
ApplicantPool applicants;
|
ApplicantPool applicants;
|
||||||
int seed;
|
int seed;
|
||||||
var generated = false;
|
var generated = false;
|
||||||
|
string? native;
|
||||||
|
|
||||||
if (_isNew)
|
if (_isNew)
|
||||||
{
|
{
|
||||||
seed = school.Id;
|
seed = school.Id;
|
||||||
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time);
|
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true);
|
||||||
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time);
|
_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;
|
generated = true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -863,13 +869,17 @@ internal sealed class SchoolWorker
|
|||||||
if (loaded is null)
|
if (loaded is null)
|
||||||
{
|
{
|
||||||
seed = school.Id;
|
seed = school.Id;
|
||||||
roster = RosterGenerator.Generate(catalog, map, seed, nameSetId, school.Clock.Time);
|
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: true);
|
||||||
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time);
|
_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;
|
generated = true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
seed = loaded.Seed;
|
seed = loaded.Seed;
|
||||||
|
native = ResolveNative(catalog, nameSetId, seed, _nativeLanguage, generating: false);
|
||||||
|
_nativeLanguage = native;
|
||||||
roster = loaded.ToRoster();
|
roster = loaded.ToRoster();
|
||||||
if (loaded.Applicants is { Applicants.Count: > 0 })
|
if (loaded.Applicants is { Applicants.Count: > 0 })
|
||||||
{
|
{
|
||||||
@@ -877,7 +887,7 @@ internal sealed class SchoolWorker
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time);
|
applicants = ApplicantPool.Create(catalog, roster, seed, nameSetId, school.Clock.Time, native);
|
||||||
generated = true;
|
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 {_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);
|
InstallTimetable(school);
|
||||||
school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick);
|
school.ConfigurePresence(_options.SchoolWeekDays, _options.MaxDecisionsPerTick);
|
||||||
school.RestorePresence(_savedPresence);
|
school.RestorePresence(_savedPresence);
|
||||||
@@ -916,6 +926,21 @@ internal sealed class SchoolWorker
|
|||||||
return available.Contains(requested, StringComparer.Ordinal) ? requested : null;
|
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()
|
private void Persist()
|
||||||
{
|
{
|
||||||
var school = _school;
|
var school = _school;
|
||||||
@@ -938,6 +963,7 @@ internal sealed class SchoolWorker
|
|||||||
ModIds = school.Catalog?.PackIds,
|
ModIds = school.Catalog?.PackIds,
|
||||||
Map = school.Map,
|
Map = school.Map,
|
||||||
NameSetId = _nameSetId,
|
NameSetId = _nameSetId,
|
||||||
|
NativeLanguage = _nativeLanguage,
|
||||||
Presence = school.CapturePresence(),
|
Presence = school.CapturePresence(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
"patronymicRule": "slavic",
|
"patronymicRule": "slavic",
|
||||||
"defaultGivenDeclension": "hard",
|
"defaultGivenDeclension": "hard",
|
||||||
"defaultSurnameDeclension": "ov",
|
"defaultSurnameDeclension": "ov",
|
||||||
|
"nativeLanguages": ["RussianLanguage", "BelarusianLanguage", "UkrainianLanguage"],
|
||||||
|
"relatedLanguageChance": 0.6,
|
||||||
|
"relatedLanguageMean": 22,
|
||||||
|
"relatedLanguageStdDev": 8,
|
||||||
|
"relatedLanguageMax": 40,
|
||||||
"maleGiven": [
|
"maleGiven": [
|
||||||
{ "form": "Александр" },
|
{ "form": "Александр" },
|
||||||
{ "form": "Алексей" },
|
{ "form": "Алексей" },
|
||||||
|
|||||||
@@ -61,9 +61,4 @@
|
|||||||
"range": { "min": 0, "max": 100 },
|
"range": { "min": 0, "max": 100 },
|
||||||
"distribution": { "mean": 48, "stdDev": 16 },
|
"distribution": { "mean": 48, "stdDev": 16 },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"defName": "ForeignLanguage",
|
|
||||||
"range": { "min": 0, "max": 100 },
|
|
||||||
"distribution": { "mean": 50, "stdDev": 15 },
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
[
|
[
|
||||||
{
|
{
|
||||||
"defName": "Agility",
|
"defName": "Agility",
|
||||||
|
"always": true,
|
||||||
"range": { "min": 0, "max": 100 },
|
"range": { "min": 0, "max": 100 },
|
||||||
"distribution": { "mean": 50, "stdDev": 14 },
|
"distribution": { "mean": 50, "stdDev": 14 },
|
||||||
"bodyLimits": [
|
"bodyLimits": [
|
||||||
@@ -11,6 +12,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "Strength",
|
"defName": "Strength",
|
||||||
|
"always": true,
|
||||||
"range": { "min": 0, "max": 100 },
|
"range": { "min": 0, "max": 100 },
|
||||||
"distribution": { "mean": 50, "stdDev": 14 },
|
"distribution": { "mean": 50, "stdDev": 14 },
|
||||||
"bodyLimits": [
|
"bodyLimits": [
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "Endurance",
|
"defName": "Endurance",
|
||||||
|
"always": true,
|
||||||
"range": { "min": 0, "max": 100 },
|
"range": { "min": 0, "max": 100 },
|
||||||
"distribution": { "mean": 50, "stdDev": 13 },
|
"distribution": { "mean": 50, "stdDev": 13 },
|
||||||
"bodyLimits": [
|
"bodyLimits": [
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -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 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"defName": "Staffing",
|
"defName": "Staffing",
|
||||||
"poolSize": 12,
|
"poolSize": 32,
|
||||||
"stayChance": 0.65,
|
"stayChance": 0.65,
|
||||||
"parentChance": 0.35,
|
"parentChance": 0.35,
|
||||||
"hourlyWageBase": 30,
|
"hourlyWageBase": 30,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"defName": "ForeignLanguage",
|
"defName": "ForeignLanguage",
|
||||||
"grades": { "min": 5, "max": 11 },
|
"grades": { "min": 5, "max": 11 },
|
||||||
"hoursPerWeek": 3,
|
"hoursPerWeek": 3,
|
||||||
"skills": [{ "skill": "ForeignLanguage", "share": 1 }],
|
"skills": [{ "skill": "English", "share": 1 }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"defName": "History",
|
"defName": "History",
|
||||||
|
|||||||
@@ -62,6 +62,16 @@
|
|||||||
"Geography": "Geography",
|
"Geography": "Geography",
|
||||||
"Informatics": "Informatics",
|
"Informatics": "Informatics",
|
||||||
"ForeignLanguage": "Foreign language",
|
"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",
|
"Agility": "Agility",
|
||||||
"Strength": "Strength",
|
"Strength": "Strength",
|
||||||
"Endurance": "Endurance",
|
"Endurance": "Endurance",
|
||||||
|
|||||||
@@ -62,6 +62,16 @@
|
|||||||
"Geography": "География",
|
"Geography": "География",
|
||||||
"Informatics": "Информатика",
|
"Informatics": "Информатика",
|
||||||
"ForeignLanguage": "Иностранный язык",
|
"ForeignLanguage": "Иностранный язык",
|
||||||
|
"English": "Английский",
|
||||||
|
"German": "Немецкий",
|
||||||
|
"BelarusianLanguage": "Белорусский",
|
||||||
|
"UkrainianLanguage": "Украинский",
|
||||||
|
"Communication": "Общение",
|
||||||
|
"Pedagogy": "Педагогика",
|
||||||
|
"Medicine": "Медицина",
|
||||||
|
"Cooking": "Готовка",
|
||||||
|
"Administration": "Делопроизводство",
|
||||||
|
"Librarianship": "Библиотечное дело",
|
||||||
"Agility": "Ловкость",
|
"Agility": "Ловкость",
|
||||||
"Strength": "Сила",
|
"Strength": "Сила",
|
||||||
"Endurance": "Выносливость",
|
"Endurance": "Выносливость",
|
||||||
|
|||||||
@@ -65,13 +65,16 @@ internal static class LessonLearningSystem
|
|||||||
var hunger = needs.Values.GetValueOrDefault("Hunger", 1f);
|
var hunger = needs.Values.GetValueOrDefault("Hunger", 1f);
|
||||||
foreach (var share in subject.Skills)
|
foreach (var share in subject.Skills)
|
||||||
{
|
{
|
||||||
if (!catalog.Skills.TryGetValue(share.Skill, out var skill)
|
if (!catalog.Skills.TryGetValue(share.Skill, out var skill) || skill.Abstract)
|
||||||
|| skill.Abstract
|
|
||||||
|| !skills.Values.TryGetValue(share.Skill, out var current))
|
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!skills.Values.TryGetValue(share.Skill, out var current))
|
||||||
|
{
|
||||||
|
current = skill.Range.Min;
|
||||||
|
}
|
||||||
|
|
||||||
skills.Values[share.Skill] = LessonLearning.Gain(
|
skills.Values[share.Skill] = LessonLearning.Gain(
|
||||||
current,
|
current,
|
||||||
skill,
|
skill,
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ public sealed class School : IDisposable
|
|||||||
/// <summary>Name pack used to generate this school's people. Needed again on 1 September.</summary>
|
/// <summary>Name pack used to generate this school's people. Needed again on 1 September.</summary>
|
||||||
public string? NameSetId { get; private set; }
|
public string? NameSetId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Skill everyone generated for this school speaks natively.</summary>
|
||||||
|
public string? NativeLanguage { get; private set; }
|
||||||
|
|
||||||
/// <summary>Last built table. Null until the worker installs people.</summary>
|
/// <summary>Last built table. Null until the worker installs people.</summary>
|
||||||
public Timetable? Timetable { get; private set; }
|
public Timetable? Timetable { get; private set; }
|
||||||
|
|
||||||
@@ -115,7 +118,7 @@ public sealed class School : IDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
|
/// Installs a roster that already matches the map. Spawns entities; does not write to disk.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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);
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
ArgumentNullException.ThrowIfNull(roster);
|
ArgumentNullException.ThrowIfNull(roster);
|
||||||
@@ -123,6 +126,7 @@ public sealed class School : IDisposable
|
|||||||
Roster = roster;
|
Roster = roster;
|
||||||
PeopleSeed = seed;
|
PeopleSeed = seed;
|
||||||
NameSetId = nameSetId;
|
NameSetId = nameSetId;
|
||||||
|
NativeLanguage = nativeLanguage;
|
||||||
Applicants = applicants;
|
Applicants = applicants;
|
||||||
RosterSpawner.Spawn(World, roster);
|
RosterSpawner.Spawn(World, roster);
|
||||||
PlanDay = null;
|
PlanDay = null;
|
||||||
@@ -282,7 +286,7 @@ public sealed class School : IDisposable
|
|||||||
var changed = false;
|
var changed = false;
|
||||||
foreach (var date in YearlyIntake.DatesBetween(before, after))
|
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;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +308,7 @@ public sealed class School : IDisposable
|
|||||||
return false;
|
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)
|
if (next.Week == Applicants.Week)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public enum SchoolCreationError
|
|||||||
UnknownMod,
|
UnknownMod,
|
||||||
InvalidCatalog,
|
InvalidCatalog,
|
||||||
UnknownNameSet,
|
UnknownNameSet,
|
||||||
|
UnknownNativeLanguage,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Outcome of <see cref="SchoolRegistry.Create"/>: either the school or the reason there is none.</summary>
|
/// <summary>Outcome of <see cref="SchoolRegistry.Create"/>: either the school or the reason there is none.</summary>
|
||||||
|
|||||||
@@ -160,6 +160,11 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
|
Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
|
||||||
Assert.Equal("Славянский", Assert.Single(ru.NameSets, set => set.DefName == "Slavic").Label);
|
Assert.Equal("Славянский", Assert.Single(ru.NameSets, set => set.DefName == "Slavic").Label);
|
||||||
Assert.Equal("Slavic", Assert.Single(en.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("Начальные классы", Assert.Single(ru.Subjects, subject => subject.DefName == "PrimarySchool").Label);
|
||||||
Assert.Equal("Primary", Assert.Single(en.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");
|
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));
|
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]
|
[Fact]
|
||||||
public async Task CreateSchool_WithACustomConnectedMap_Succeeds()
|
public async Task CreateSchool_WithACustomConnectedMap_Succeeds()
|
||||||
{
|
{
|
||||||
@@ -417,7 +443,7 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
IReadOnlyList<DefInfoResponse> Floors,
|
IReadOnlyList<DefInfoResponse> Floors,
|
||||||
IReadOnlyList<RoomInfoResponse> Rooms,
|
IReadOnlyList<RoomInfoResponse> Rooms,
|
||||||
IReadOnlyList<DefInfoResponse> Things,
|
IReadOnlyList<DefInfoResponse> Things,
|
||||||
IReadOnlyList<DefInfoResponse> NameSets,
|
IReadOnlyList<NameSetInfoResponse> NameSets,
|
||||||
IReadOnlyList<SubjectInfoResponse> Subjects,
|
IReadOnlyList<SubjectInfoResponse> Subjects,
|
||||||
DayFrameResponse? DayFrame,
|
DayFrameResponse? DayFrame,
|
||||||
IReadOnlyList<HolidayInfoResponse> Holidays,
|
IReadOnlyList<HolidayInfoResponse> Holidays,
|
||||||
@@ -425,6 +451,11 @@ public class SchoolApiTests(AppHostFixture fixture)
|
|||||||
|
|
||||||
private sealed record DefInfoResponse(string DefName, string Label);
|
private sealed record DefInfoResponse(string DefName, string Label);
|
||||||
|
|
||||||
|
private sealed record NameSetInfoResponse(
|
||||||
|
string DefName,
|
||||||
|
string Label,
|
||||||
|
IReadOnlyList<DefInfoResponse> NativeLanguages);
|
||||||
|
|
||||||
private sealed record RoomInfoResponse(
|
private sealed record RoomInfoResponse(
|
||||||
string DefName,
|
string DefName,
|
||||||
string Label,
|
string Label,
|
||||||
|
|||||||
@@ -29,14 +29,23 @@ public class StaffingApiTests(AppHostFixture fixture)
|
|||||||
Assert.Equal(100_000f, staffing.Allocated);
|
Assert.Equal(100_000f, staffing.Allocated);
|
||||||
Assert.Equal(0f, staffing.Payroll);
|
Assert.Equal(0f, staffing.Payroll);
|
||||||
Assert.Equal(100_000f, staffing.Remaining);
|
Assert.Equal(100_000f, staffing.Remaining);
|
||||||
Assert.Equal(12, staffing.Applicants.Count);
|
Assert.Equal(32, staffing.Applicants.Count);
|
||||||
Assert.Empty(staffing.Staff);
|
Assert.Empty(staffing.Staff);
|
||||||
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics");
|
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "Mathematics");
|
||||||
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "PrimarySchool");
|
Assert.Contains(staffing.Uncovered, subject => subject.DefName == "PrimarySchool");
|
||||||
Assert.NotEmpty(staffing.Positions);
|
Assert.NotEmpty(staffing.Positions);
|
||||||
Assert.Contains(staffing.Positions, position => position.DefName == "Teacher");
|
Assert.Contains(staffing.Positions, position => position.DefName == "Teacher");
|
||||||
Assert.Contains(staffing.Subjects, subject => subject.DefName == "Mathematics");
|
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]
|
[Fact]
|
||||||
@@ -135,6 +144,12 @@ public class StaffingApiTests(AppHostFixture fixture)
|
|||||||
$"/api/schools/{school.Id}/staff/hire",
|
$"/api/schools/{school.Id}/staff/hire",
|
||||||
new { personId = applicant.Id, position = "Teacher" },
|
new { personId = applicant.Id, position = "Teacher" },
|
||||||
TestContext.Current.CancellationToken);
|
TestContext.Current.CancellationToken);
|
||||||
|
if (response.StatusCode == HttpStatusCode.Conflict)
|
||||||
|
{
|
||||||
|
Assert.Equal("payroll-exceeded", await ProblemCodeAsync(response));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
staffing = await response.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
|
staffing = await response.Content.ReadFromJsonAsync<StaffingResponse>(TestContext.Current.CancellationToken);
|
||||||
Assert.NotNull(staffing);
|
Assert.NotNull(staffing);
|
||||||
@@ -143,7 +158,7 @@ public class StaffingApiTests(AppHostFixture fixture)
|
|||||||
Assert.Equal(100_000f, staffing.Allocated);
|
Assert.Equal(100_000f, staffing.Allocated);
|
||||||
Assert.True(staffing.Payroll > 0f);
|
Assert.True(staffing.Payroll > 0f);
|
||||||
Assert.True(staffing.Payroll <= staffing.Allocated);
|
Assert.True(staffing.Payroll <= staffing.Allocated);
|
||||||
Assert.Equal(12, staffing.Staff.Count);
|
Assert.NotEmpty(staffing.Staff);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -224,9 +239,23 @@ public class StaffingApiTests(AppHostFixture fixture)
|
|||||||
var staffing = await GetStaffingAsync(client, school.Id);
|
var staffing = await GetStaffingAsync(client, school.Id);
|
||||||
while (staffing.Applicants.Count > 0)
|
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<StaffingResponse>(TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(staffing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Assert.NotEmpty(staffing.Staff);
|
||||||
|
|
||||||
var subjects = staffing.Subjects.Select(subject => subject.DefName).ToArray();
|
var subjects = staffing.Subjects.Select(subject => subject.DefName).ToArray();
|
||||||
Assert.NotEmpty(subjects);
|
Assert.NotEmpty(subjects);
|
||||||
|
|
||||||
@@ -327,7 +356,8 @@ public class StaffingApiTests(AppHostFixture fixture)
|
|||||||
bool IsParent,
|
bool IsParent,
|
||||||
float HourlyWageAsk,
|
float HourlyWageAsk,
|
||||||
float MonthlyBase,
|
float MonthlyBase,
|
||||||
IReadOnlyList<LabeledStatResponse> Skills);
|
IReadOnlyList<LabeledStatResponse> Skills,
|
||||||
|
IReadOnlyList<DefLabelResponse> Traits);
|
||||||
|
|
||||||
private sealed record LabeledStatResponse(string Id, string Label, string Value);
|
private sealed record LabeledStatResponse(string Id, string Label, string Value);
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,73 @@ public class TimetableApiTests(AppHostFixture fixture)
|
|||||||
&& lesson.Locked);
|
&& lesson.Locked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
[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<string?> 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]
|
[Fact]
|
||||||
public async Task Saturday_HasNoOccupancyOnTheMap()
|
public async Task Saturday_HasNoOccupancyOnTheMap()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,7 +19,17 @@ public class PeopleDefTests
|
|||||||
Assert.All(catalog.Needs.Values, need => Assert.True(need.DecayPerHour > 0));
|
Assert.All(catalog.Needs.Values, need => Assert.True(need.DecayPerHour > 0));
|
||||||
Assert.True(catalog.Needs["Sleep"].RestoredOffCampus);
|
Assert.True(catalog.Needs["Sleep"].RestoredOffCampus);
|
||||||
Assert.Equal(0.1f, catalog.Needs["Hunger"].DecayPerHour);
|
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.ContainsKey("Slavic"));
|
||||||
Assert.True(catalog.NameSets["Slavic"].MaleGiven.Count >= 20);
|
Assert.True(catalog.NameSets["Slavic"].MaleGiven.Count >= 20);
|
||||||
Assert.Equal("Славянский", catalog.Label("ru", catalog.NameSets["Slavic"]));
|
Assert.Equal("Славянский", catalog.Label("ru", catalog.NameSets["Slavic"]));
|
||||||
@@ -27,7 +37,7 @@ public class PeopleDefTests
|
|||||||
Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"]));
|
Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"]));
|
||||||
Assert.True(catalog.Subjects.ContainsKey("PrimarySchool"));
|
Assert.True(catalog.Subjects.ContainsKey("PrimarySchool"));
|
||||||
Assert.NotNull(catalog.StaffingRules);
|
Assert.NotNull(catalog.StaffingRules);
|
||||||
Assert.Equal(12, catalog.StaffingRules.PoolSize);
|
Assert.Equal(32, catalog.StaffingRules.PoolSize);
|
||||||
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
|
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
|
||||||
Assert.Equal(4, catalog.StaffingRules.WeeksPerMonth);
|
Assert.Equal(4, catalog.StaffingRules.WeeksPerMonth);
|
||||||
Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours);
|
Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours);
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ public class VanillaCoreTests
|
|||||||
Assert.Equal(4, catalog.Subjects["PrimarySchool"].Grades.Max);
|
Assert.Equal(4, catalog.Subjects["PrimarySchool"].Grades.Max);
|
||||||
Assert.True(catalog.Subjects.ContainsKey("PhysicalEducation"));
|
Assert.True(catalog.Subjects.ContainsKey("PhysicalEducation"));
|
||||||
Assert.NotNull(catalog.StaffingRules);
|
Assert.NotNull(catalog.StaffingRules);
|
||||||
Assert.Equal(12, catalog.StaffingRules.PoolSize);
|
Assert.Equal(32, catalog.StaffingRules.PoolSize);
|
||||||
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
|
Assert.Equal(20, catalog.StaffingRules.BaseWeeklyHours);
|
||||||
Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours);
|
Assert.Equal(36, catalog.StaffingRules.MaxWeeklyHours);
|
||||||
Assert.NotNull(catalog.DayFrame);
|
Assert.NotNull(catalog.DayFrame);
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ internal static class Fixtures
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static Roster Generate(MapLayout map, int seed = SchoolSeed) =>
|
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()
|
public static string RepoRoot()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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<Person> 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -224,9 +224,10 @@ public class StaffingTests
|
|||||||
Assert.Equal(catalog.Subjects.Values.Count(subject => !subject.Abstract), uncovered.Count);
|
Assert.Equal(catalog.Subjects.Values.Count(subject => !subject.Abstract), uncovered.Count);
|
||||||
|
|
||||||
var applicant = pool.Applicants[0];
|
var applicant = pool.Applicants[0];
|
||||||
var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, 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", Cap);
|
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.DoesNotContain(Staffing.Uncovered(catalog, assigned.Roster), subject => subject.DefName == "Mathematics");
|
||||||
Assert.Contains(Staffing.Uncovered(catalog, assigned.Roster), subject => subject.DefName == "PrimarySchool");
|
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 (catalog, map, roster, pool) = Fresh();
|
||||||
var applicant = pool.Applicants[0];
|
var applicant = pool.Applicants[0];
|
||||||
var hired = Staffing.Hire(catalog, map, roster, pool, applicant.Person.Id, Staffing.TeacherPosition, 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", Cap);
|
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 json = RosterJson.Serialize(RosterDocument.From(Fixtures.SchoolSeed, assigned.Roster, assigned.Pool));
|
||||||
var loaded = RosterJson.Parse(json);
|
var loaded = RosterJson.Parse(json);
|
||||||
|
|||||||
@@ -118,5 +118,8 @@ public class YearlyIntakeTests
|
|||||||
|
|
||||||
private static string Snapshot(Roster roster) =>
|
private static string Snapshot(Roster roster) =>
|
||||||
string.Join('\n', roster.People.Select(person =>
|
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}"));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user