Enhance school creation and staffing management with native language support
ci / server (push) Failing after 3m44s
ci / client (push) Successful in 14s

- 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:
Leonid Pershin
2026-08-19 22:22:31 +03:00
parent 21d79cb49b
commit 5a00ad7746
48 changed files with 2064 additions and 312 deletions
+2 -2
View File
@@ -139,9 +139,9 @@
| Место урока | Кабинет класса, если у предмета нет требования к типу помещения | | Место урока | Кабинет класса, если у предмета нет требования к типу помещения |
| Нет учителя | Урок не ставится; предмет числится непокрытым | | Нет учителя | Урок не ставится; предмет числится непокрытым |
| Карта | В дереве рядом с комнатой — что и у кого там сейчас | | Карта | В дереве рядом с комнатой — что и у кого там сейчас |
| Панель локации | «Сейчас» и «Персонажи» заполняются из расписания | | Панель локации | «Сейчас» и «Персонажи» заполняются из расписания — с срезом 5 из присутствия, см. [`ai.md`](ai.md) |
| Расписание класса | В разделе «Управление», сетка «день × урок» | | Расписание класса | В разделе «Управление», сетка «день × урок» |
| Расписание учителя | В его карточке, там же где навыки и семья | | Расписание учителя | В его карточке, там же где навыки и семья |
| Общая сетка школы | Не делаем: её читает только тот, кто раскладывает вручную | | Общая сетка школы | Не делаем: её читает только тот, кто раскладывает вручную |
| Где живёт код | Отдельная библиотека рядом с `HSchool.People` | | Где живёт код | Отдельная библиотека рядом с `HSchool.People` |
| Люди и место | Находятся по расписанию; ходьбы нет | | Люди и место | Находятся по расписанию; ходьбы нет — отменено срезом 5, присутствие стало состоянием ([`ai.md`](ai.md)) |
+227
View File
@@ -175,3 +175,230 @@
- Срез 1 успел сдвинуться: коммит `5cd5a6d` тронул `gameScreen.ts` и `strings.ts` (подпись - Срез 1 успел сдвинуться: коммит `5cd5a6d` тронул `gameScreen.ts` и `strings.ts` (подпись
идущего человека). По правилу журнала он больше не «проверен на `b135a9c`» — правка маленькая и идущего человека). По правилу журнала он больше не «проверен на `b135a9c`» — правка маленькая и
относится к срезу 5, но следующий проход это увидит. относится к срезу 5, но следующий проход это увидит.
## Срез 3. Штат
- **Фазы:** 1013
- **Проверен на:** `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. Расписание
- **Фазы:** 1417
- **Проверен на:** `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. Жизнь школы
- **Фазы:** 1821
- **Проверен на:** `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
View File
@@ -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`
+30 -2
View File
@@ -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',
+10 -1
View File
@@ -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 {
+51 -1
View File
@@ -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;
} }
+89 -213
View File
@@ -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');
}
}
+81
View File
@@ -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;
}
+35 -2
View File
@@ -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 01.");
}
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 01.");
}
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.");
+40
View File
@@ -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; } = [];
+15 -8
View File
@@ -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));
+38 -14
View File
@@ -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,
+38
View File
@@ -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)];
}
}
+258 -12
View File
@@ -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)
+4 -2
View File
@@ -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);
} }
+2
View File
@@ -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);
+56 -5
View File
@@ -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)
{ {
+23 -2
View File
@@ -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,
+16 -4
View File
@@ -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,
+5 -1
View File
@@ -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)
{ {
+1
View File
@@ -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;
+17 -1
View File
@@ -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,
+3
View File
@@ -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,
}); });
} }
+32 -6
View File
@@ -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,
+7 -3
View File
@@ -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;
+1
View File
@@ -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>
+32 -1
View File
@@ -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()
{ {
+12 -2
View File
@@ -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);
+1 -1
View File
@@ -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));
}
}
+6 -4
View File
@@ -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}"));
} }