Enhance school creation functionality by introducing support for name sets in the API and UI. Update the catalog to include skills, traits, body attributes, needs, and name sets, improving character generation capabilities. Revise localization strings for better user guidance and update tests to validate the new name set functionality and ensure robustness in school creation processes.
ci / server (push) Failing after 3m45s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 18:57:01 +03:00
parent 38afbcad36
commit e6182e0e45
56 changed files with 3706 additions and 9 deletions
+4
View File
@@ -10,6 +10,8 @@ way; this file is *how to work in them*.
| --- | --- |
| schools, the game clock, game rules | `src/HSchool.Simulation` |
| defs, JSONC catalog, map validation | `src/HSchool.Content` |
| skills, traits, body, needs, name sets | `src/HSchool.Content` |
| people generation, families, roster records | `src/HSchool.People` |
| the menu API (list, create, delete, mods, catalog) | `src/HSchool.Server/Api` **and** `docs/protocol.md` |
| what the socket carries | `src/HSchool.Protocol` **and** `src/HSchool.Client/src/net/protocol.ts` **and** `docs/protocol.md` |
| connection handling, workers, saves | `src/HSchool.Server` |
@@ -102,6 +104,8 @@ say so explicitly in the change description.
## Testing policy
- Simulation changes need a `GameClock` or `SchoolRegistry` test. They are fast and need no host.
- People generation belongs in `tests/HSchool.People.Tests`. Same seed, map and name set must
produce the same roster; the suite does not boot a host.
- Catalog, inheritance, patches and map validation belong in `tests/HSchool.Content.Tests`.
Feed the loader documents, not disk paths.
- Protocol changes need a round-trip test **and** a byte-layout assertion on both sides.
+335
View File
@@ -0,0 +1,335 @@
# Люди: генерация, характеристики, просмотр
Договорённость на следующий срез, не текущий код.
Типы и карта: [`defs.md`](defs.md). Экран игрока: [`near-term.md`](near-term.md).
Потоки: [`runtime.md`](runtime.md). Проекты: [`projects.md`](projects.md).
Школа перестаёт быть пустой коробкой. Появляются ученики, работники и родители: у каждого имя,
тело, навыки, черты и нужды. **Ничего из этого пока ни на что не влияет** — люди не ходят, не
работают и не учатся. Срез отвечает на вопрос «кто в этой школе», а не «что они делают».
Тот же принцип, что и с картой: **код — системы, содержание — данные**. Новый навык, черта, цвет
волос или набор имён появляются файлом в `mods/<id>/defs/`, без пересборки.
## Откуда берётся состав
Карта уже знает вместимость. `ThingDef.PupilSlots × count` даёт ученические места в комнате,
`RoomDef.positions` — должности, которые комната открывает. Отдельного «числа учеников» в форме
создания **нет**: состав школы — следствие того, что построено.
| Что построено | Что появляется |
| --- | --- |
| Кабинет с 16 партами | Класс на 16 учеников |
| Кабинет директора | Должность директора и один работник |
| Спортзал | Должность физрука |
| Библиотека | Должность библиотекаря |
**Один кабинет — один класс.** Кабинет закреплён за классом, как в обычной школе: класс живёт в
своём кабинете, и вместимость кабинета есть размер класса. Хотите одиннадцать параллелей —
поставьте одиннадцать кабинетов. Это делает редактор карты осмысленным: игрок строит не декорации,
а штатное расписание.
**Ванильная карта дорабатывается до одиннадцати кабинетов**, по одному на параллель, чтобы школа
из коробки была полной. Сейчас их четыре.
### Класс — сущность ростера, а не поле карты
Параллель и литера — именно поля, а не разбор подписи комнаты. Подпись разбирать нельзя дважды:
кириллическая «А» и латинская «A» на глаз неразличимы (в нынешней ванильной карте кабинеты
подписаны латиницей — `1A`, `2B`), а в другой локали литеры вообще другие.
Но и в **карте** этим полям не место. Параллель растёт каждый год: 5Б становится 6Б, не переезжая
из кабинета. Раскладка же после создания школы неизменна — иначе файл карты пришлось бы
переписывать каждое игровое 1 сентября.
Поэтому класс — отдельная сущность ростера: параллель, литера, закреплённый кабинет. Кабинет
закреплён за классом на всё время его жизни; одиннадцатый выпускается — кабинет достаётся новому
первому. Раскладку по параллелям при создании делает генератор, игрок её не задаёт.
Из этого следует, что подписи кабинетов в ванильной карте — **номера помещений** («101», «204»),
как в настоящей школе, а не имена классов. Имя класса живёт на классе и меняется вместе с ним.
## Из чего состоит человек
Шесть слоёв. Первые два есть у всех, остальные — по роли.
| Слой | Что это | Пример |
| --- | --- | --- |
| **Личность** | ФИО со склонениями, пол, дата рождения, роль | Иванова Мария Петровна, ж, 2001-03-14 |
| **Тело** | Рост, вес, цвет волос, цвет глаз, телосложение | 164 см, 52 кг, русые, серые, худощавое |
| **Навыки** | Числовые шкалы | Математика 62, Ловкость 31 |
| **Черты** | Дискретные, с весами и несовместимостями | Усидчивый, Задира |
| **Нужды** | Числовые шкалы, убывают со временем | Сон 0.7, Голод 0.4 |
| **Связи** | Семья: родители, дети, братья и сёстры | дочь Иванова П.С. и Ивановой О.М. |
Роли три: **ученик**, **работник**, **родитель**. Роль — не def: код различает их по существу,
а не по данным. Должность работника — `PositionDef`, он уже есть.
Один человек может быть в двух ролях сразу: учительница математики, чей сын учится в седьмом
классе, — это **один** человек с ролью работника и связью «родитель» к ученику. Отдельной сущности
«родитель Иванова» для неё не заводится.
## Родители
Родители — **полноценные сущности** в мире школы, но **без места на карте**. Они существуют,
потому что у ученика есть семья, а не потому что ходят по коридорам.
Это задел, а не упрощение: позже родителя можно будет вызвать в школу — тогда у него появится
местоположение и цель, но не появится новый тип сущности. Поэтому родитель с самого начала лежит
в `World` наравне с учеником, а не отдельным списком «карточек при ученике».
## Генерация семьями
Люди генерируются **не по одному**. Единица генерации — семья:
```
Семья Ивановых
├── Иванов Пётр Сергеевич родитель, 41 год
├── Иванова Ольга Михайловна родитель, 39 лет
├── Иванова Мария Петровна ученица, 7Б
└── Иванов Кирилл Петрович ученик, 3А
```
Что это даёт бесплатно и чего иначе не получить:
- **Фамилии повторяются.** Школа без братьев и сестёр читается как список случайных строк.
- **Отчества сходятся.** Отчество ребёнка — от имени отца, а не от отдельного словаря.
- **Родовые формы согласованы.** Иванов / Иванова в одной семье, а не вразнобой.
- **Работник может быть родителем ученика** — фактом семьи, без специального правила.
Генератор идёт сверху вниз: сначала решает, сколько семей нужно, чтобы заполнить места и
должности; потом строит взрослых; потом детей нужных возрастов; и только затем раскладывает детей
по классам, а взрослых — по должностям. Обратный порядок («сгенерировать 400 учеников, потом
приписать им родителей») не даёт ни братьев, ни учителей-родителей.
## Дефы
Пять новых видов. Ссылки — строками `defName`, как везде.
1. **SkillDef** — навык. Диапазон, распределение при генерации, зависимость от возраста,
ограничения со стороны тела (см. ниже).
2. **TraitDef** — черта. Вес (частота), список несовместимых черт, ограничения по роли и возрасту,
модификаторы навыков.
3. **BodyAttributeDef** — свойство тела. Либо число с распределением (рост, вес), либо выбор из
списка с весами (цвет волос, цвет глаз). Распределение зависит от пола и возраста.
4. **NeedDef** — нужда. Начальное значение и скорость убывания.
5. **NameSetDef** — набор имён (см. отдельный раздел).
Телосложение — не отдельный def, а **производное**: считается из роста и веса кодом. Данные не
должны дублировать то, что выводится.
### Что кладём в `core`
| Вид | Ванильное содержание |
| --- | --- |
| Навыки | Школьные предметы (математика, русский, литература, физика, история, физкультура…) и физические (ловкость, сила, выносливость) |
| Черты | Около десяти для начала: усидчивый, рассеянный, задира, тихоня, лидер, лентяй, любопытный, вспыльчивый, добрый, аккуратный |
| Тело | Рост, вес, цвет волос, цвет глаз |
| Нужды | Сон, голод, туалет, общение |
Слои одинаковы у всех ролей. У неработающего родителя те же навыки и черты, что у ученика: урезать
их дешевле по памяти и дороже в коде — иначе каждое место, читающее навык, начинается с вопроса
«а есть ли он у этого человека».
### Как тело ограничивает навыки
«Толстый не может быть ловким» — правило **генерации**, а не игровой эффект: характеристики пока
ни на что не влияют, но между собой согласованы.
Ограничение живёт в `SkillDef` и ссылается на свойство тела:
```jsonc
{
"defName": "Agility",
"range": { "min": 0, "max": 100 },
"bodyLimits": [
{ "attribute": "Build", "value": "Obese", "max": 25 },
{ "attribute": "Build", "value": "Heavy", "max": 45 },
{ "attribute": "Build", "value": "Athletic", "min": 40 },
],
}
```
Генератор сначала бросает тело, потом навык, потом применяет ограничения как зажим. Порядок важен
и фиксируется: **тело первично**, навык подстраивается. Иначе один и тот же сид даст разных людей
в зависимости от порядка перебора.
Ограничения — не только вниз: «атлет не может быть совсем неловким» так же осмысленно, поэтому
`min` есть наравне с `max`.
## Нужды
Четыре нужды в `core`: **сон, голод, туалет, общение**. `NeedDef` описывает шкалу и скорость
убывания. Машинерия работает с этого среза: значение лежит на человеке, тикает вместе со школой,
показывается в карточке.
**Но в `core` скорость убывания равна нулю.** Есть тела, которые тратят, и нет ничего, что
восполняет: некому поесть, негде поспать. Ненулевая скорость в этом срезе означала бы школу, где
через игровые сутки все нужды на нуле, — и это выглядело бы поломкой, а не игрой.
Скорость — данные, не код. Когда появится еда и сон, цифра меняется в JSONC, а не в C#.
## Имена
`NameSetDef` — набор имён целиком: мужские имена, женские имена, фамилии, правила отчеств и
склонений. В `core` один набор — славянский. Другие наборы приходят модами.
**Один набор на школу.** Выбирается в диалоге создания рядом с модами и сохраняется в файле школы.
Смеси с весами («80% славянских, 20% прочих») — не в этом срезе: они требуют UI для весов.
Набор имён **не связан с языком интерфейса**. Английский UI не превращает Иванову в Ivanova:
язык интерфейса — про подписи кнопок, набор имён — про то, кто учится в школе.
### Склонения
Русскому тексту нужны падежи: «вызвать **Иванову Марию**», «дневник **Ивановой Марии**». Шесть
падежей на каждую часть ФИО.
Два способа, и нужны оба:
- **Правило** — по умолчанию. Код знает небольшой набор моделей склонения (`-ов/-ова`, `-ий/-ая`,
`-а/-я`, несклоняемые). В данных — только имя и id модели.
- **Явная таблица** — когда правило не работает. Любая запись может задать все шесть форм руками.
Это тот же принцип, что и везде: код знает *как*, данные говорят *что*. Словарь из шести форм на
каждое из тысячи имён никто не выдержит, а одними правилами русские фамилии не покрываются.
Падежи нужны заранее, до появления ленты событий: набор имён с одной формой потом придётся
переписывать целиком, а он самый объёмный файл в `core`.
## Отдельная библиотека
Новый проект **`HSchool.People`**.
```
Protocol ← Server → Simulation → People → Content
```
- Зависит от `HSchool.Content` (нужны defs и каталог).
- **Не** зависит от Arch, ASP.NET и сокетов — как `Content` сейчас.
- Отдаёт простые записи. Превращает их в сущности `World` уже `HSchool.Simulation`.
Так генератор тестируется без мира и без хоста: «этот сид + эта карта + этот набор имён → ровно
эти люди». Это единственный способ поймать регрессию в генераторе — глазами такое не проверяется.
### Детерминизм
Генерация детерминирована от **сида**, сид лежит в файле школы. Один сид + одна карта + один набор
имён = одна и та же школа, всегда.
Сид у каждой семьи свой, выведенный из школьного: добавление тринадцатой семьи не должно менять
первые двенадцать. Иначе тест на генератор ломается от любой правки порядка.
## Годовой набор
Состав живёт: 1 сентября классы переходят в следующую параллель, одиннадцатый выпускается, его
кабинет освобождается под новый первый.
Вместимость при этом постоянна — она задана картой. Набор не увеличивает школу, а **замещает**
выпуск. Школа растёт только стройкой.
Выпускается **старшая имеющаяся** параллель: школа из четырёх кабинетов не должна ждать
одиннадцатого класса, чтобы кого-то выпустить.
Выпускники уходят из ростера. Их родители уходят следом, но только если в школе не осталось
других их детей и сами они в ней не работают, — иначе ростер копил бы людей, ни с чем не
связанных, до бесконечности.
Школа создаётся посреди учебного года (по умолчанию 3 апреля), поэтому при создании классы уже
сформированы и возраст ученика соответствует его параллели: в N-й класс ходят те, кому
исполнилось N+6 до 1 сентября.
## Просмотр
Не отдельный экран. Список людей — **панель в оболочке менеджера**, рядом с картой и событиями:
уходя в отдельный экран, игрок теряет из виду часы, локацию и события, а смысл этой оболочки
ровно в том, чтобы всё было на одном экране. Ориентир — прогрессивный QSP: текст, списки и ссылки,
без сцены.
Панель «Люди» занимает нижний ряд во всю ширину — фильтрам, колонкам и пейджеру нужна ширина,
которой нет в колонке шириной в треть экрана.
- **Фильтры**: роль (ученики / работники / родители), параллель и литера, должность, пол,
возрастной диапазон.
- **Сортировки**: фамилия, возраст, параллель, должность.
- **Пейджинг** обязателен: тысяча учеников — это три тысячи человек с семьями.
- **Строка списка** — только лёгкое: ФИО, роль, класс или должность, возраст, пол.
- **Карточка** одного человека — тело, навыки, черты, нужды, семья со ссылками на родных.
## Масштаб
Школа на 1000 учеников — это около **3000 человек** вместе с родителями, и до шести таких школ
живут одновременно. Три решения следуют отсюда напрямую.
**Ростер не ходит в снимке карты.** Снимок при открытии школы остаётся про помещения. Люди —
отдельный путь, иначе открытие школы означало бы мегабайт по сокету.
**Список — HTTP, а не сокет.** Запрос-ответ с фильтром, сортировкой и страницей — это REST по
природе, как и меню. Воркер публикует неизменяемый снимок ростера (он меняется редко: создание,
набор, наём), обработчик читает опубликованный снимок и не трогает воркер — инвариант 3 цел.
**Живые значения — из воркера.** Нужды тикают, поэтому карточка одного человека запрашивается у
воркера через мейлбокс и `TaskCompletionSource`, как create и delete. Один человек — дешёвый
запрос; три тысячи людей двадцать раз в секунду — нет.
**Ростер — отдельный файл.** `saves/{id}.people.json` пишется только когда состав изменился;
`saves/{id}.json` остаётся маленьким и частым. Сейчас файл школы переписывается каждые 30 секунд —
таскать через это мегабайт ростера, который меняется раз в игровой год, незачем.
## Советы, которые стоит принять сразу
- Не делать «родителя» отдельным видом сущности. Иначе учительница-мать станет двумя людьми.
- Не хранить телосложение в данных — оно выводится из роста и веса.
- Не начинать с падежных таблиц на каждое имя: правило по умолчанию, таблица как исключение.
- Не отправлять черты и навыки в списке — только в карточке. Список читают глазами, карточку
открывают по одному человеку.
- Сид на семью, а не один на школу: иначе любая правка генератора переставит всех.
- Ненулевую скорость нужд не включать, пока нечем их восполнять.
## Заведомо не сейчас
- Поведение: расписание, уроки, перемещение по карте, исполнение действий.
- Влияние характеристик на что-либо. Черты и навыки в этом срезе — текст в карточке.
- Вызов родителя в школу. Задел есть (родитель — сущность), реализации нет.
- Наём и увольнение вручную. Должности закрываются генератором.
- Смеси наборов имён с весами.
- Новые роли из модов: ученик, работник, родитель — код, не данные.
- Портреты и любой арт.
## Зафиксировано этим разговором
| Тема | Решение |
| --- | --- |
| Размер школы | Из карты: `PupilSlots` — ученики, `RoomDef.positions` — штат |
| Класс | Один кабинет = один класс; класс — сущность ростера, не поле карты |
| Параллель и литера | Поля класса, а не разбор подписи комнаты; раскладывает генератор |
| Подписи кабинетов | Номера помещений («204»), имя класса живёт на классе |
| Ванильная карта | Дорабатывается до 11 кабинетов — по одному на параллель |
| Когда генерируются | Полный состав при создании, затем набор каждое 1 сентября |
| Годовой набор | Замещает выпуск; вместимость меняет только стройка |
| Кого выпускать | Старшую имеющуюся параллель, а не обязательно одиннадцатую |
| Уход из ростера | Выпускники уходят; родители — если не осталось детей и они здесь не работают |
| Роли | Ученик, работник, родитель; роль — код, должность — `PositionDef` |
| Родители | Сущности в `World`, без места на карте; задел на вызов в школу |
| Совмещение ролей | Один человек может быть работником и родителем ученика |
| Единица генерации | Семья, а не человек |
| Слои человека | Личность, тело, навыки, черты, нужды, связи |
| Новые defs | `SkillDef`, `TraitDef`, `BodyAttributeDef`, `NeedDef`, `NameSetDef` |
| Телосложение | Производное от роста и веса, не def |
| Тело и навыки | `SkillDef.bodyLimits` зажимают навык; тело бросается первым |
| Влияние характеристик | Никакого; только просмотр |
| Навыки в `core` | Школьные предметы + физические |
| Черты в `core` | Около десяти |
| Нужды в `core` | Сон, голод, туалет, общение |
| Нужды | Машинерия есть, скорость убывания в `core` — ноль |
| Слои по ролям | Одинаковые у всех; родителей не урезаем |
| Набор имён | Один на школу, выбор в create, в `core` славянский; прочие — моды |
| Падежи | Правило по умолчанию (код), явная таблица как исключение (данные) |
| Имена и язык UI | Независимы |
| Библиотека | `HSchool.People`: зависит от Content, не знает Arch и ASP.NET |
| Детерминизм | Сид в сейве, свой сид на семью |
| Просмотр | Панель в оболочке менеджера, нижний ряд; не отдельный экран |
| Список | Фильтры, сортировки, пейджинг; в строке — только лёгкие поля |
| Транспорт списка | HTTP по опубликованному снимку ростера |
| Транспорт карточки | Запрос в воркер, потому что нужды живые |
| Сохранение | `saves/{id}.people.json`, пишется при изменении состава |
+6 -3
View File
@@ -1,6 +1,6 @@
# Нарезка проектов (целевая)
Сейчас в коде: `Protocol ← Server → Simulation → Content`. Каталог не в Simulation (рядом с Arch)
Сейчас в коде: `Protocol ← Server → Simulation → People → Content`. Каталог не в Simulation (рядом с Arch)
и не в Server (рядом с Kestrel) — парсер JSONC и проверка графа не знают, что такое tick.
## Зависимости
@@ -8,7 +8,8 @@
```
Protocol — ни на кого из игровых проектов (только байты)
Content — ни на Protocol, ни на Simulation, ни на ASP.NET
Simulation → Content (каталог и раскладка, без файловых путей хоста)
People → Content (defs, карта, склонения; без Arch и хоста)
Simulation → Content (каталог и раскладка; People — в фазе 7)
Server → Protocol, Simulation, Content
Client — своя сторона Protocol (TS) + HTTP
```
@@ -19,7 +20,8 @@ Client — своя сторона Protocol (TS) + HTTP
| Проект | Да | Нет |
| --- | --- | --- |
| **HSchool.Content** (новый) | Типы def, JSONC-загрузчик, наследование, патчи, слияние локалей, граф карты, валидация связности | Arch, часы, HTTP, потоки, пути `mods/` с диска хоста |
| **HSchool.Content** | Типы def, JSONC-загрузчик, наследование, патчи, слияние локалей, граф карты, валидация связности | Arch, часы, HTTP, потоки, пути `mods/` с диска хоста |
| **HSchool.People** | Генерация ростера: семьи, классы, тело/навыки/черты из каталога и сида | Arch, ASP.NET, сокеты, `DateTime.Now`, файлы сейва |
| **HSchool.Simulation** | `School`, `GameClock`, Arch `World`, тик, применение раскладки к миру | Kestrel, сокеты, `Directory.Enumerate`, сейв-файлы |
| **HSchool.Server** | Хост, супервизор, работник-поток, `mods/` и `saves/` из конфига, HTTP/WS, DTO запросов | Правила «комната должна быть связана», схема Chair |
| **HSchool.Protocol** | Кадры сокета, в том числе снимок карты и locale в Hello | Имена комнат, JSONC |
@@ -46,6 +48,7 @@ Arch и фиксированный тик — про живую школу. Па
## Тесты
- `tests/HSchool.Content.Tests` — фикстуры JSONC, коллизии имён, дырявый граф, двор обязателен.
- `tests/HSchool.People.Tests` — тот же сид даёт тот же ростер; места и должности заполнены.
- `HSchool.Simulation.Tests` — часы, тик, «школа с таким каталогом живёт».
- `HSchool.AppHost.Tests` — create с картой, рестарт, сейв на диске.
+39
View File
@@ -0,0 +1,39 @@
# Фаза 5. Дефы человека
## Зависимости
- [Фаза 3](03-defs-map.md)
## Зачем
Каталог учится описывать людей. Ни одного человека в этой фазе не появляется — только типы,
из которых их потом соберут.
## Задачи
- [x] `SkillDef`, `TraitDef`, `BodyAttributeDef`, `NeedDef`, `NameSetDef` в `HSchool.Content`:
новые `DefKind`, разбор, наследование и патчи на общих правилах
- [x] `BodyAttributeDef` двух видов: число с распределением (рост, вес) и выбор из списка с
весами (цвет волос, цвет глаз); распределение зависит от пола и возраста
- [x] `SkillDef.bodyLimits` — зажимы навыка по свойству тела (`min` / `max`); ссылка на
неизвестный атрибут или его значение — ошибка загрузки, а не тихий пропуск
- [x] `TraitDef`: вес, несовместимости, ограничения по роли и возрасту, модификаторы навыков;
односторонняя несовместимость считается взаимной
- [x] `NameSetDef`: мужские и женские имена, фамилии с родовыми формами, правило отчества,
модель склонения по умолчанию или явная таблица шести падежей на запись
- [x] Ванильное содержание `core`: навыки (предметы + физические), около десяти черт, четыре
нужды со скоростью убывания **ноль**, славянский набор имён
- [x] Локализация новых defs в `ru` и `en`
- [x] `GET /api/catalog` отдаёт список наборов имён — форме создания нужен выбор
- [x] Тесты Content: разбор каждого вида, наследование навыка, патч черты, неизвестная ссылка —
ошибка, склонение по правилу и перекрытие таблицей
## Критерий готовности
- Каталог `core` грузится, все новые defs проходят валидацию
- Мод, добавляющий свой набор имён и пару черт, виден в каталоге
- `dotnet test` проходит; школы по-прежнему пустые
## Стоп
Не генерировать людей. Не трогать ECS, сервер сверх выдачи каталога и клиент сверх выбора набора.
+42
View File
@@ -0,0 +1,42 @@
# Фаза 6. Библиотека генерации
## Зависимости
- [Фаза 5](05-people-defs.md)
## Зачем
Из каталога, карты и сида получаются люди — семьями и детерминированно. Библиотека тестируется
без мира, без сокета и без хоста, как `HSchool.Content` сейчас.
## Задачи
- [x] Новый проект `HSchool.People` и `tests/HSchool.People.Tests`; ссылка только на
`HSchool.Content`, ни Arch, ни ASP.NET
- [x] Записи `Person`, `Family`, `SchoolClass`, `Roster` — простые данные, не сущности
- [x] Ёмкость из карты: `PupilSlots` по кабинетам и `RoomDef.positions` дают, сколько нужно
учеников и каких работников
- [x] Генерация семьями: взрослые → дети нужных возрастов → раскладка детей по параллелям и
взрослых по должностям
- [x] Совмещение ролей: работник может оказаться родителем ученика той же школы
- [x] Порядок бросков зафиксирован: тело первым, навыки после, `bodyLimits` применяются зажимом
- [x] Склонения: модели в коде, явная таблица из def перекрывает модель
- [x] Сид школы приходит снаружи; сид каждой семьи выводится из него
## Тесты, без которых фаза не закрыта
- [x] Тот же сид, карта и набор имён — ровно тот же ростер
- [x] Тринадцатая семья не меняет первые двенадцать
- [x] Фамилии, родовые формы и отчества внутри семьи согласованы
- [x] Ученик с телосложением «полное» не получает высокую ловкость
- [x] Все ученические места заполнены, все должности закрыты
- [x] Школа из одного кабинета и школа из одиннадцати — обе собираются
## Критерий готовности
- `HSchool.People.Tests` зелёные
- В проекте нет упоминаний сокетов, ECS и `DateTime.Now`
## Стоп
Не класть людей в `World`. Не трогать сервер, клиент и формат сейва.
+35
View File
@@ -0,0 +1,35 @@
# Фаза 7. Люди в школе
## Зависимости
- [Фаза 6](06-people-generator.md)
- [Фаза 2](02-school-worker.md)
## Зачем
Школа создаётся с людьми, они живут в её `World` и переживают перезапуск сервера. Экран пока
не меняется — это фаза про данные.
## Задачи
- [ ] Ванильная карта дорабатывается до одиннадцати кабинетов; подписи кабинетов становятся
номерами помещений («204»), а не именами классов
- [ ] Компоненты Arch под слои человека; сущности собираются из записей генератора
- [ ] Класс как сущность: параллель, литера, закреплённый кабинет
- [ ] Нужды тикают вместе со школой — механика есть, скорость в `core` нулевая
- [ ] `saves/{id}.people.json`: сид, люди, семьи, классы. Пишется **только** при изменении
состава, не по таймеру; `saves/{id}.json` остаётся маленьким и частым
- [ ] Загрузка ростера при старте школы; ростер, не сходящийся с картой, оставляет школу
незапущенной и файл нетронутым — как сейчас с картой
- [ ] Воркер публикует неизменяемый снимок ростера, как публикует `SchoolState`
## Критерий готовности
- Создать школу — все ученические места и должности заняты
- Перезапустить сервер — те же люди с теми же именами и характеристиками
- Файл ростера не переписывается каждые тридцать секунд
- `dotnet test` проходит
## Стоп
Ни панели, ни фильтров, ни HTTP списка людей. Никакого поведения: люди существуют и стоят.
+39
View File
@@ -0,0 +1,39 @@
# Фаза 8. Просмотр людей
## Зависимости
- [Фаза 7](07-people-in-school.md)
- [Фаза 1](01-manager-shell.md)
## Зачем
Игрок видит, кто учится и работает в его школе, не теряя из виду часы, карту и события.
## Задачи
- [ ] `GET /api/schools/{id}/people`: фильтры (роль, параллель, должность, пол, диапазон
возраста), сортировка, страница. Читает **опубликованный снимок** ростера и не трогает
воркер — инвариант «только поток школы касается школы» остаётся целым
- [ ] `GET /api/schools/{id}/people/{personId}`: карточка через мейлбокс воркера с
`TaskCompletionSource`, потому что нужды живые
- [ ] Панель «Люди» нижним рядом оболочки менеджера, во всю ширину: фильтры, сортируемые
колонки, пейджер
- [ ] Строка списка — только лёгкие поля: ФИО, роль, класс или должность, возраст, пол
- [ ] Карточка: тело, навыки, черты, нужды, семья со ссылками на родных — по ссылке открывается
карточка родственника
- [ ] Строки и подписи через `t(...)`, как всё остальное
- [ ] `docs/protocol.md` пополняется в том же коммите, что и обработчики
- [ ] Тесты API: фильтр по роли и параллели, сортировка, границы страниц, неизвестная школа,
неизвестный человек
## Критерий готовности
- Открыть школу, отфильтровать учеников пятой параллели, отсортировать по фамилии
- Открыть карточку ученика, увидеть родителей, перейти по ссылке к матери
- Тысяча строк не роняет и не подвешивает панель
- `dotnet test` и клиентские `npm test` / `build` проходят
## Стоп
Не редактировать людей. Не нанимать и не увольнять руками. Не показывать характеристики в списке —
только в карточке.
+34
View File
@@ -0,0 +1,34 @@
# Фаза 9. Годовой набор
## Зависимости
- [Фаза 7](07-people-in-school.md)
## Зачем
Состав перестаёт быть слепком момента создания. Первого сентября классы переходят в следующую
параллель, старший выпускается, приходит новый первый.
## Задачи
- [ ] Переход по игровому календарю на 1 сентября: у каждого класса параллель +1
- [ ] Выпуск **старшей имеющейся** параллели — школа из четырёх кабинетов не ждёт одиннадцатого
класса, чтобы кого-то выпустить
- [ ] Освободившийся кабинет достаётся новому первому классу; вместимость школы не меняется —
её меняет только стройка
- [ ] Набор первого класса тем же генератором и теми же семьями: у нового ученика может
оказаться брат или сестра в старшей параллели
- [ ] Выпускники уходят из ростера; их родители уходят тоже, если не осталось других детей в
школе и они в ней не работают
- [ ] Ростер переписывается один раз за переход, снимок публикуется заново — не по тику
## Критерий готовности
- Перемотать год на ×4: параллели сдвинулись, прежний выпускной класс исчез, появился новый первый
- Вместимость школы до и после перехода совпадает
- Перезапуск сервера после перехода поднимает новый состав
- Тест на школу без одиннадцатой параллели: выпускается старшая имеющаяся
## Стоп
Не второгодники. Не переводы между школами. Не увольнения и наём работников.
+21 -1
View File
@@ -1,7 +1,9 @@
# Фазы первого среза
# Фазы
Индекс. Статус: ⬜ не начата, 🔄 в работе, ✅ готова.
## Срез 1. Оболочка и карта
Дизайн закрыт: [`../design/near-term.md`](../design/near-term.md),
[`../design/defs.md`](../design/defs.md),
[`../design/runtime.md`](../design/runtime.md),
@@ -16,3 +18,21 @@
| [2. Работник школы и диск](02-school-worker.md) | ✅ | Поток + World + сейв — основа |
| [3. Каталог def и карта](03-defs-map.md) | ✅ | JSONC, core, валидация раскладки |
| [4. Моды и редактор в create](04-create-editor.md) | ✅ | Выбор модов, карта в POST, снимок при открытии |
## Срез 2. Люди
Дизайн: [`../design/people.md`](../design/people.md).
Школа наполняется людьми. Ученики, работники и родители существуют, показываются и обновляются
раз в год — но **ничего не делают**: ни расписания, ни перемещений, ни влияния характеристик.
Фазы идут по порядку, кроме 8 и 9: обе зависят только от 7 и могут вестись параллельно
(клиент / симуляция).
| Фаза | Статус | Зачем |
| --- | --- | --- |
| [5. Дефы человека](05-people-defs.md) | ✅ | Навыки, черты, тело, нужды, наборы имён в каталоге |
| [6. Библиотека генерации](06-people-generator.md) | ✅ | `HSchool.People`: семьи из карты и сида |
| [7. Люди в школе](07-people-in-school.md) | ⬜ | Сущности в `World`, ростер на диске, 11 кабинетов в `core` |
| [8. Просмотр людей](08-people-browser.md) | ⬜ | Панель со списком, фильтрами и карточкой |
| [9. Годовой набор](09-yearly-intake.md) | ⬜ | Первое сентября: переход, выпуск, набор |
+8 -1
View File
@@ -69,6 +69,9 @@ Room slots include a `count` (how many of the default thing the def places). Thi
`pupilSlots` — how many pupils that thing hosts for lessons. The create editor copies both onto
the map instance; the snapshot's per-node pupil-slot total is computed on the server.
`nameSets` is the list of placeable name packs (`defName` + label). The create dialog picks one;
it is independent of the UI language.
`lang` is the same value Hello carries — not `Accept-Language`. Anything other than `en` is
Russian.
@@ -81,12 +84,15 @@ Body:
"name": "Гимназия №14",
"startDate": "2012-04-03T06:00:00Z",
"modIds": [],
"map": null
"map": null,
"nameSetId": "Slavic"
}
```
`modIds` are extras; the server always prepends `core`. Omit `map` (or send `null`) to use that
pack set's default layout. A supplied map is validated as a connected yard-and-rooms graph.
`nameSetId` is a `NameSetDef`; omit it to use the first placeable set in the catalog (vanilla:
`Slavic`). Unknown ids return `400` `unknown-name-set`.
| Status | Meaning |
| --- | --- |
@@ -96,6 +102,7 @@ pack set's default layout. A supplied map is validated as a connected yard-and-r
| `400` `invalid-map` | Missing yard, no rooms, unknown def, or a disconnected graph. |
| `400` `unknown-mod` | An extra pack id is missing under `mods/`. |
| `400` `invalid-catalog` | The selected packs could not be loaded. |
| `400` `unknown-name-set` | `nameSetId` is not a placeable `NameSetDef` in those packs. |
| `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.
+30
View File
@@ -27,6 +27,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Content", "src\HSch
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.Content.Tests", "tests\HSchool.Content.Tests\HSchool.Content.Tests.csproj", "{C59BD649-ED81-40EE-864D-9C7D5378B956}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.People", "src\HSchool.People\HSchool.People.csproj", "{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HSchool.People.Tests", "tests\HSchool.People.Tests\HSchool.People.Tests.csproj", "{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -157,6 +161,30 @@ Global
{C59BD649-ED81-40EE-864D-9C7D5378B956}.Release|x64.Build.0 = Release|Any CPU
{C59BD649-ED81-40EE-864D-9C7D5378B956}.Release|x86.ActiveCfg = Release|Any CPU
{C59BD649-ED81-40EE-864D-9C7D5378B956}.Release|x86.Build.0 = Release|Any CPU
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}.Debug|Any CPU.Build.0 = Debug|Any CPU
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}.Debug|x64.ActiveCfg = Debug|Any CPU
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}.Debug|x64.Build.0 = Debug|Any CPU
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}.Debug|x86.ActiveCfg = Debug|Any CPU
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}.Debug|x86.Build.0 = Debug|Any CPU
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}.Release|Any CPU.ActiveCfg = Release|Any CPU
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}.Release|Any CPU.Build.0 = Release|Any CPU
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}.Release|x64.ActiveCfg = Release|Any CPU
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}.Release|x64.Build.0 = Release|Any CPU
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}.Release|x86.ActiveCfg = Release|Any CPU
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874}.Release|x86.Build.0 = Release|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Debug|x64.ActiveCfg = Debug|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Debug|x64.Build.0 = Debug|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Debug|x86.ActiveCfg = Debug|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Debug|x86.Build.0 = Debug|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|Any CPU.Build.0 = Release|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x64.ActiveCfg = Release|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x64.Build.0 = Release|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x86.ActiveCfg = Release|Any CPU
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -172,6 +200,8 @@ Global
{25518ACB-AC00-4DE7-7F61-2756A5F47A38} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{1C464147-8717-46FD-8459-9F458A07836D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C59BD649-ED81-40EE-864D-9C7D5378B956} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{40CC68F9-E60F-4AA3-81FB-FF89A06FD874} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{E3A79C5F-FDD4-4DC8-9D3F-13962C6FC27E} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {DD14EF4D-167E-4AC7-953A-AF606CC34829}
+4
View File
@@ -41,9 +41,11 @@ const ru = {
errorInvalidMap: 'Карта должна быть связным графом: двор и хотя бы одна комната.',
errorUnknownMod: 'Выбранный мод не найден.',
errorInvalidCatalog: 'Не удалось загрузить выбранные моды.',
errorUnknownNameSet: 'Выбранный набор имён не найден.',
catalogLoadFailed: 'Не удалось загрузить каталог модов.',
modsTitle: 'Моды',
nameSetTitle: 'Набор имён',
coreModLocked: '{id} (всегда включён)',
mapEditorTitle: 'Карта',
editMap: 'Редактировать карту',
@@ -126,9 +128,11 @@ const en: Messages = {
errorInvalidMap: 'The map must be a connected graph: a yard and at least one room.',
errorUnknownMod: 'A selected mod is missing.',
errorInvalidCatalog: 'The selected packs could not be loaded.',
errorUnknownNameSet: 'The selected name set is not in the catalog.',
catalogLoadFailed: 'Could not load the mod catalog.',
modsTitle: 'Mods',
nameSetTitle: 'Name set',
coreModLocked: '{id} (always on)',
mapEditorTitle: 'Map',
editMap: 'Edit map',
+3
View File
@@ -44,6 +44,7 @@ export async function fetchRandomName(lang: string): Promise<string> {
export interface CreateSchoolOptions {
readonly modIds?: readonly string[];
readonly map?: MapLayout;
readonly nameSetId?: string;
}
export async function createSchool(
@@ -59,6 +60,7 @@ export async function createSchool(
startDate: startDate.toISOString(),
modIds: extras.modIds ?? [],
map: extras.map ?? null,
nameSetId: extras.nameSetId ?? null,
}),
});
}
@@ -109,6 +111,7 @@ export interface CatalogResponse {
readonly rooms: readonly RoomInfo[];
readonly things: readonly DefInfo[];
readonly defaultMap: MapLayout;
readonly nameSets: readonly DefInfo[];
}
export async function fetchMods(): Promise<readonly ModInfo[]> {
@@ -32,12 +32,20 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const extraModIds = new Set<string>();
let catalog: CatalogResponse | null = null;
let currentMap: MapLayout | null = null;
let nameSetId: string | null = null;
const modsField = el('div', { class: 'field' });
const modsLabel = el('span', { class: 'field__label' });
const modsList = el('div', { class: 'mod-list' });
modsField.append(modsLabel, modsList);
const nameSetLabel = el('span', { class: 'field__label' });
const nameSetSelect = el('select', { class: 'input' });
const nameSetField = el('label', { class: 'field' }, nameSetLabel, nameSetSelect);
nameSetSelect.addEventListener('change', () => {
nameSetId = nameSetSelect.value === '' ? null : nameSetSelect.value;
});
const mapLabel = el('span', { class: 'field__label' });
const editMapButton = el('button', { class: 'button', type: 'button' });
const mapHint = el('p', { class: 'hint' });
@@ -78,6 +86,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
el('label', { class: 'field' }, nameLabel, el('div', { class: 'field__row' }, nameInput, randomButton)),
el('div', { class: 'field' }, startLabel, el('div', { class: 'field__row' }, dateInput, timeInput)),
modsField,
nameSetField,
mapField,
error,
el('div', { class: 'dialog__actions' }, cancelButton, submitButton),
@@ -112,10 +121,25 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const applyCatalog = (next: CatalogResponse): void => {
catalog = next;
currentMap = structuredClone(next.defaultMap);
const stillThere = next.nameSets.some((set) => set.defName === nameSetId);
nameSetId = stillThere ? nameSetId : (next.nameSets[0]?.defName ?? null);
paintNameSets(next);
paintMapHint();
setBusy(busy);
};
const paintNameSets = (next: CatalogResponse): void => {
nameSetSelect.replaceChildren();
for (const set of next.nameSets) {
const option = el('option', { text: set.label });
option.value = set.defName;
nameSetSelect.append(option);
}
nameSetSelect.value = nameSetId ?? '';
nameSetSelect.disabled = next.nameSets.length <= 1;
};
const reloadCatalog = async (): Promise<void> => {
try {
applyCatalog(await fetchCatalog(getLocale(), [...extraModIds]));
@@ -168,6 +192,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
const localize = (): void => {
title.textContent = t('newSchool');
modsLabel.textContent = t('modsTitle');
nameSetLabel.textContent = t('nameSetTitle');
nameLabel.textContent = t('schoolName');
startLabel.textContent = t('gameStart');
mapLabel.textContent = t('mapEditorTitle');
@@ -178,6 +203,9 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
submitButton.textContent = t('create');
cancelButton.textContent = t('cancel');
paintMapHint();
if (catalog !== null) {
paintNameSets(catalog);
}
};
localize();
@@ -227,6 +255,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise<School
.create(nameInput.value.trim(), startDate, {
modIds: [...extraModIds],
map: currentMap,
nameSetId: nameSetId ?? undefined,
})
.then((school) => modal.close(school))
.catch((reason: unknown) => {
@@ -263,6 +292,8 @@ function describe(reason: unknown): string {
return t('errorUnknownMod');
case 'invalid-catalog':
return t('errorInvalidCatalog');
case 'unknown-name-set':
return t('errorUnknownNameSet');
default:
return reason.message;
}
+27
View File
@@ -302,6 +302,11 @@ public sealed class CatalogLoader
var buildings = new Dictionary<string, BuildingDef>(StringComparer.Ordinal);
var floors = new Dictionary<string, FloorDef>(StringComparer.Ordinal);
var territories = new Dictionary<string, TerritoryDef>(StringComparer.Ordinal);
var skills = new Dictionary<string, SkillDef>(StringComparer.Ordinal);
var traits = new Dictionary<string, TraitDef>(StringComparer.Ordinal);
var bodyAttributes = new Dictionary<string, BodyAttributeDef>(StringComparer.Ordinal);
var needs = new Dictionary<string, NeedDef>(StringComparer.Ordinal);
var nameSets = new Dictionary<string, NameSetDef>(StringComparer.Ordinal);
foreach (var (key, json) in resolved)
{
@@ -331,6 +336,21 @@ public sealed class CatalogLoader
case DefKind.Territory:
territories[key.Name] = Jsonc.Deserialize<TerritoryDef>(json);
break;
case DefKind.Skill:
skills[key.Name] = Jsonc.Deserialize<SkillDef>(json);
break;
case DefKind.Trait:
traits[key.Name] = Jsonc.Deserialize<TraitDef>(json);
break;
case DefKind.BodyAttribute:
bodyAttributes[key.Name] = Jsonc.Deserialize<BodyAttributeDef>(json);
break;
case DefKind.Need:
needs[key.Name] = Jsonc.Deserialize<NeedDef>(json);
break;
case DefKind.NameSet:
nameSets[key.Name] = Jsonc.Deserialize<NameSetDef>(json);
break;
}
}
@@ -344,6 +364,11 @@ public sealed class CatalogLoader
buildings,
floors,
territories,
skills,
traits,
bodyAttributes,
needs,
nameSets,
ru,
en);
}
@@ -397,6 +422,8 @@ public sealed class CatalogLoader
}
}
}
PeopleDefValidator.Validate(catalog);
}
private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source);
+54
View File
@@ -16,6 +16,11 @@ public sealed class DefCatalog
IReadOnlyDictionary<string, BuildingDef> buildings,
IReadOnlyDictionary<string, FloorDef> floors,
IReadOnlyDictionary<string, TerritoryDef> territories,
IReadOnlyDictionary<string, SkillDef> skills,
IReadOnlyDictionary<string, TraitDef> traits,
IReadOnlyDictionary<string, BodyAttributeDef> bodyAttributes,
IReadOnlyDictionary<string, NeedDef> needs,
IReadOnlyDictionary<string, NameSetDef> nameSets,
IReadOnlyDictionary<string, string> ru,
IReadOnlyDictionary<string, string> en)
{
@@ -28,6 +33,11 @@ public sealed class DefCatalog
Buildings = buildings;
Floors = floors;
Territories = territories;
Skills = skills;
Traits = traits;
BodyAttributes = bodyAttributes;
Needs = needs;
NameSets = nameSets;
_ru = ru;
_en = en;
}
@@ -50,6 +60,16 @@ public sealed class DefCatalog
public IReadOnlyDictionary<string, TerritoryDef> Territories { get; }
public IReadOnlyDictionary<string, SkillDef> Skills { get; }
public IReadOnlyDictionary<string, TraitDef> Traits { get; }
public IReadOnlyDictionary<string, BodyAttributeDef> BodyAttributes { get; }
public IReadOnlyDictionary<string, NeedDef> Needs { get; }
public IReadOnlyDictionary<string, NameSetDef> NameSets { get; }
private readonly IReadOnlyDictionary<string, string> _ru;
private readonly IReadOnlyDictionary<string, string> _en;
@@ -65,6 +85,11 @@ public sealed class DefCatalog
DefKind.Building => Buildings.GetValueOrDefault(defName),
DefKind.Floor => Floors.GetValueOrDefault(defName),
DefKind.Territory => Territories.GetValueOrDefault(defName),
DefKind.Skill => Skills.GetValueOrDefault(defName),
DefKind.Trait => Traits.GetValueOrDefault(defName),
DefKind.BodyAttribute => BodyAttributes.GetValueOrDefault(defName),
DefKind.Need => Needs.GetValueOrDefault(defName),
DefKind.NameSet => NameSets.GetValueOrDefault(defName),
_ => null,
};
@@ -115,6 +140,35 @@ public sealed class DefCatalog
BuildingDef => DefKind.Building,
FloorDef => DefKind.Floor,
TerritoryDef => DefKind.Territory,
SkillDef => DefKind.Skill,
TraitDef => DefKind.Trait,
BodyAttributeDef => DefKind.BodyAttribute,
NeedDef => DefKind.Need,
NameSetDef => DefKind.NameSet,
_ => throw new ArgumentOutOfRangeException(nameof(def)),
};
/// <summary>Incompatibilities written on either trait — a one-way list is mutual.</summary>
public IReadOnlySet<string> TraitIncompatibilities(string defName)
{
var set = new HashSet<string>(StringComparer.Ordinal);
if (Traits.TryGetValue(defName, out var trait))
{
foreach (var other in trait.Incompatible)
{
set.Add(other);
}
}
foreach (var candidate in Traits.Values)
{
if (candidate.Incompatible.Contains(defName, StringComparer.Ordinal))
{
set.Add(candidate.DefName);
}
}
set.Remove(defName);
return set;
}
}
+5
View File
@@ -10,6 +10,11 @@ public enum DefKind
Building,
Floor,
Territory,
Skill,
Trait,
BodyAttribute,
Need,
NameSet,
}
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
+2
View File
@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace HSchool.Content;
@@ -12,6 +13,7 @@ public static class Jsonc
PropertyNameCaseInsensitive = true,
AllowTrailingCommas = true,
WriteIndented = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
};
public static JsonDocumentOptions DocumentOptions { get; } = new()
+302
View File
@@ -0,0 +1,302 @@
namespace HSchool.Content;
public enum GrammaticalCase
{
Nominative,
Genitive,
Dative,
Accusative,
Instrumental,
Prepositional,
}
/// <summary>
/// How a written name becomes six cases. Models live here; a <see cref="CaseTable"/> on the
/// entry wins when the rule is wrong. People generation calls this; Content tests pin it.
/// </summary>
public static class NameGrammar
{
public const string SlavicPatronymic = "slavic";
public const string Hard = "hard";
public const string Soft = "soft";
public const string A = "a";
public const string Ya = "ya";
public const string Iya = "iya";
public const string Ov = "ov";
public const string In = "in";
public const string Sky = "sky";
public const string Indeclinable = "indeclinable";
public static readonly IReadOnlyList<string> GivenModels = [Hard, Soft, A, Ya, Iya, Indeclinable];
public static readonly IReadOnlyList<string> SurnameModels = [Ov, In, Sky, Indeclinable];
public static readonly IReadOnlyList<string> PatronymicRules = [SlavicPatronymic];
public static bool IsKnownGiven(string model) =>
GivenModels.Any(candidate => candidate.Equals(model, StringComparison.OrdinalIgnoreCase));
public static bool IsKnownSurname(string model) =>
SurnameModels.Any(candidate => candidate.Equals(model, StringComparison.OrdinalIgnoreCase));
public static bool IsKnownPatronymic(string rule) =>
PatronymicRules.Any(candidate => candidate.Equals(rule, StringComparison.OrdinalIgnoreCase));
public static string InflectGiven(GivenNameEntry entry, GrammaticalCase grammaticalCase, string defaultModel)
{
if (entry.Cases is { } table)
{
return table[grammaticalCase];
}
var model = string.IsNullOrWhiteSpace(entry.Declension) ? defaultModel : entry.Declension;
return InflectGivenForm(entry.Form, model, grammaticalCase);
}
public static string InflectSurname(
SurnameEntry entry,
bool female,
GrammaticalCase grammaticalCase,
string defaultModel)
{
var table = female ? entry.FemaleCases : entry.MaleCases;
if (table is not null)
{
return table[grammaticalCase];
}
var nominative = female ? entry.Female : entry.Male;
if (grammaticalCase == GrammaticalCase.Nominative)
{
return nominative;
}
var model = string.IsNullOrWhiteSpace(entry.Declension) ? defaultModel : entry.Declension;
return InflectSurnameForm(nominative, model, female, grammaticalCase);
}
public static string InflectPatronymic(string nominative, bool female, GrammaticalCase grammaticalCase)
{
if (string.IsNullOrWhiteSpace(nominative) || grammaticalCase == GrammaticalCase.Nominative)
{
return nominative;
}
return female
? InflectGivenForm(nominative, A, grammaticalCase)
: InflectGivenForm(nominative, Hard, grammaticalCase);
}
public static string Patronymic(string fatherNominative, bool female, string rule)
{
if (!rule.Equals(SlavicPatronymic, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentOutOfRangeException(nameof(rule), rule, "Unknown patronymic rule.");
}
return SlavicFromFather(fatherNominative, female);
}
public static string InflectGivenForm(string nominative, string model, GrammaticalCase grammaticalCase)
{
if (grammaticalCase == GrammaticalCase.Nominative)
{
return nominative;
}
return model.ToLowerInvariant() switch
{
Hard => HardGiven(nominative, grammaticalCase),
Soft => SoftGiven(nominative, grammaticalCase),
A => AGiven(nominative, grammaticalCase),
Ya => YaGiven(nominative, grammaticalCase),
Iya => IyaGiven(nominative, grammaticalCase),
Indeclinable => nominative,
_ => throw new ArgumentOutOfRangeException(nameof(model), model, "Unknown given-name declension."),
};
}
public static string InflectSurnameForm(string nominative, string model, bool female, GrammaticalCase grammaticalCase)
{
if (grammaticalCase == GrammaticalCase.Nominative)
{
return nominative;
}
return model.ToLowerInvariant() switch
{
Ov => OvSurname(nominative, female, grammaticalCase),
In => InSurname(nominative, female, grammaticalCase),
Sky => SkySurname(nominative, female, grammaticalCase),
Indeclinable => nominative,
_ => throw new ArgumentOutOfRangeException(nameof(model), model, "Unknown surname declension."),
};
}
private static string HardGiven(string nom, GrammaticalCase grammaticalCase)
{
// Иван → Ивана, Ивану, Ивана, Иваном, Иване. Пётр → Петра.
var stem = nom.EndsWith('ь') ? nom[..^1] : nom;
stem = stem.Replace('ё', 'е').Replace('Ё', 'Е');
if (stem.EndsWith('й'))
{
stem = stem[..^1];
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "я",
GrammaticalCase.Dative => stem + "ю",
GrammaticalCase.Accusative => stem + "я",
GrammaticalCase.Instrumental => stem + "ем",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "а",
GrammaticalCase.Dative => stem + "у",
GrammaticalCase.Accusative => stem + "а",
GrammaticalCase.Instrumental => stem + "ом",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
private static string SoftGiven(string nom, GrammaticalCase grammaticalCase)
{
// Игорь → Игоря; Любовь needs a table.
var stem = nom.EndsWith('ь') ? nom[..^1] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "я",
GrammaticalCase.Dative => stem + "ю",
GrammaticalCase.Accusative => stem + "я",
GrammaticalCase.Instrumental => stem + "ем",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
private static string AGiven(string nom, GrammaticalCase grammaticalCase)
{
var stem = nom.EndsWith('а') ? nom[..^1] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "ы",
GrammaticalCase.Dative => stem + "е",
GrammaticalCase.Accusative => stem + "у",
GrammaticalCase.Instrumental => stem + "ой",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
private static string YaGiven(string nom, GrammaticalCase grammaticalCase)
{
var stem = nom.EndsWith('я') ? nom[..^1] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "и",
GrammaticalCase.Dative => stem + "е",
GrammaticalCase.Accusative => stem + "ю",
GrammaticalCase.Instrumental => stem + "ей",
GrammaticalCase.Prepositional => stem + "е",
_ => nom,
};
}
private static string IyaGiven(string nom, GrammaticalCase grammaticalCase)
{
var stem = nom.EndsWith("ия", StringComparison.Ordinal) ? nom[..^2] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "ии",
GrammaticalCase.Dative => stem + "ии",
GrammaticalCase.Accusative => stem + "ию",
GrammaticalCase.Instrumental => stem + "ией",
GrammaticalCase.Prepositional => stem + "ии",
_ => nom,
};
}
private static string OvSurname(string nom, bool female, GrammaticalCase grammaticalCase)
{
if (female)
{
var stem = nom.EndsWith('а') ? nom[..^1] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "ой",
GrammaticalCase.Dative => stem + "ой",
GrammaticalCase.Accusative => stem + "у",
GrammaticalCase.Instrumental => stem + "ой",
GrammaticalCase.Prepositional => stem + "ой",
_ => nom,
};
}
return grammaticalCase switch
{
GrammaticalCase.Genitive => nom + "а",
GrammaticalCase.Dative => nom + "у",
GrammaticalCase.Accusative => nom + "а",
GrammaticalCase.Instrumental => nom + "ым",
GrammaticalCase.Prepositional => nom + "е",
_ => nom,
};
}
private static string InSurname(string nom, bool female, GrammaticalCase grammaticalCase) =>
OvSurname(nom, female, grammaticalCase);
private static string SkySurname(string nom, bool female, GrammaticalCase grammaticalCase)
{
if (female)
{
var stem = nom.EndsWith("ая", StringComparison.Ordinal) ? nom[..^2] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => stem + "ой",
GrammaticalCase.Dative => stem + "ой",
GrammaticalCase.Accusative => stem + "ую",
GrammaticalCase.Instrumental => stem + "ой",
GrammaticalCase.Prepositional => stem + "ой",
_ => nom,
};
}
var maleStem = nom.EndsWith("ий", StringComparison.Ordinal) ? nom[..^2] : nom;
return grammaticalCase switch
{
GrammaticalCase.Genitive => maleStem + "ого",
GrammaticalCase.Dative => maleStem + "ому",
GrammaticalCase.Accusative => maleStem + "ого",
GrammaticalCase.Instrumental => maleStem + "им",
GrammaticalCase.Prepositional => maleStem + "ом",
_ => nom,
};
}
private static string SlavicFromFather(string father, bool female)
{
var name = father.Replace('ё', 'е').Replace('Ё', 'Е');
if (name.Equals("Илья", StringComparison.Ordinal))
{
return female ? "Ильинична" : "Ильич";
}
if (name.EndsWith('й') || name.EndsWith('ь'))
{
var stem = name[..^1];
return female ? stem + "евна" : stem + "евич";
}
if (name.EndsWith('а') || name.EndsWith('я'))
{
var stem = name[..^1];
return female ? stem + "ична" : stem + "ич";
}
return female ? name + "овна" : name + "ович";
}
}
+15
View File
@@ -86,6 +86,21 @@ internal static class PackPaths
case "territories":
kind = DefKind.Territory;
return true;
case "skills":
kind = DefKind.Skill;
return true;
case "traits":
kind = DefKind.Trait;
return true;
case "bodies":
kind = DefKind.BodyAttribute;
return true;
case "needs":
kind = DefKind.Need;
return true;
case "namesets":
kind = DefKind.NameSet;
return true;
default:
kind = default;
return false;
+282
View File
@@ -0,0 +1,282 @@
namespace HSchool.Content;
internal static class PeopleDefValidator
{
public static void Validate(DefCatalog catalog)
{
foreach (var body in catalog.BodyAttributes.Values)
{
ValidateBody(body);
}
foreach (var skill in catalog.Skills.Values)
{
ValidateSkill(skill, catalog);
}
foreach (var trait in catalog.Traits.Values)
{
ValidateTrait(trait, catalog);
}
foreach (var need in catalog.Needs.Values)
{
ValidateNeed(need);
}
foreach (var names in catalog.NameSets.Values)
{
ValidateNameSet(names);
}
}
private static void ValidateBody(BodyAttributeDef def)
{
if (def.DefName.Equals(BodyBuilds.Attribute, StringComparison.Ordinal))
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' is reserved for the derived build.");
}
switch (def.Kind)
{
case BodyAttributeKind.Number:
if (def.Distributions.Count == 0)
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' of kind number needs at least one distribution.");
}
foreach (var row in def.Distributions)
{
RequireSex(row.Sex, def.DefName);
RequireAgeWindow(row.AgeMin, row.AgeMax, def.DefName);
if (row.Distribution.StdDev < 0)
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' has a negative stdDev.");
}
RequireRange(row.Range, $"BodyAttributeDef '{def.DefName}'");
}
break;
case BodyAttributeKind.Choice:
if (def.Options.Count == 0)
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' of kind choice needs at least one option.");
}
var values = new HashSet<string>(StringComparer.Ordinal);
foreach (var option in def.Options)
{
if (string.IsNullOrWhiteSpace(option.Value) || !values.Add(option.Value))
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' has a missing or duplicate option.");
}
if (option.Weight < 1)
{
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' option '{option.Value}' needs a positive weight.");
}
RequireSex(option.Sex, def.DefName);
RequireAgeWindow(option.AgeMin, option.AgeMax, def.DefName);
}
break;
default:
throw new ContentLoadException($"BodyAttributeDef '{def.DefName}' has unknown kind '{def.Kind}'.");
}
}
private static void ValidateSkill(SkillDef skill, DefCatalog catalog)
{
RequireRange(skill.Range, $"SkillDef '{skill.DefName}'");
if (skill.Distribution is { StdDev: < 0 })
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' has a negative stdDev.");
}
foreach (var limit in skill.BodyLimits)
{
if (string.IsNullOrWhiteSpace(limit.Attribute))
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits entry is missing attribute.");
}
if (limit.Min is { } min && limit.Max is { } max && min > max)
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits min is above max.");
}
if (limit.Attribute.Equals(BodyBuilds.Attribute, StringComparison.Ordinal))
{
if (string.IsNullOrWhiteSpace(limit.Value) || !BodyBuilds.IsKnown(limit.Value))
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits references unknown build '{limit.Value}'.");
}
continue;
}
if (!catalog.BodyAttributes.TryGetValue(limit.Attribute, out var body))
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits references unknown attribute '{limit.Attribute}'.");
}
if (body.Kind != BodyAttributeKind.Choice)
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits attribute '{limit.Attribute}' is not categorical.");
}
if (string.IsNullOrWhiteSpace(limit.Value)
|| !body.Options.Any(option => option.Value.Equals(limit.Value, StringComparison.Ordinal)))
{
throw new ContentLoadException($"SkillDef '{skill.DefName}' bodyLimits references unknown value '{limit.Value}' of '{limit.Attribute}'.");
}
}
}
private static void ValidateTrait(TraitDef trait, DefCatalog catalog)
{
if (trait.Weight < 1)
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' weight must be at least 1.");
}
RequireRange(trait.Age, $"TraitDef '{trait.DefName}'");
foreach (var role in trait.Roles)
{
if (!PersonRoles.IsKnown(role))
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' has unknown role '{role}'.");
}
}
foreach (var other in trait.Incompatible)
{
if (!catalog.Traits.ContainsKey(other))
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' is incompatible with unknown TraitDef '{other}'.");
}
}
foreach (var modifier in trait.SkillModifiers)
{
if (!catalog.Skills.ContainsKey(modifier.Skill))
{
throw new ContentLoadException($"TraitDef '{trait.DefName}' skill modifier references unknown SkillDef '{modifier.Skill}'.");
}
}
}
private static void ValidateNeed(NeedDef need)
{
if (need.Min > need.Max)
{
throw new ContentLoadException($"NeedDef '{need.DefName}' min is above max.");
}
if (need.Initial < need.Min || need.Initial > need.Max)
{
throw new ContentLoadException($"NeedDef '{need.DefName}' initial is outside minmax.");
}
if (need.DecayPerHour < 0)
{
throw new ContentLoadException($"NeedDef '{need.DefName}' decayPerHour cannot be negative.");
}
}
private static void ValidateNameSet(NameSetDef names)
{
if (!NameGrammar.IsKnownPatronymic(names.PatronymicRule))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown patronymicRule '{names.PatronymicRule}'.");
}
if (!NameGrammar.IsKnownGiven(names.DefaultGivenDeclension))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown defaultGivenDeclension.");
}
if (!NameGrammar.IsKnownSurname(names.DefaultSurnameDeclension))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has unknown defaultSurnameDeclension.");
}
if (names.MaleGiven.Count == 0 || names.FemaleGiven.Count == 0 || names.Surnames.Count == 0)
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' needs male names, female names and surnames.");
}
foreach (var given in names.MaleGiven.Concat(names.FemaleGiven))
{
if (string.IsNullOrWhiteSpace(given.Form))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has an empty given name.");
}
if (given.Cases is null)
{
var model = string.IsNullOrWhiteSpace(given.Declension)
? names.DefaultGivenDeclension
: given.Declension;
if (!NameGrammar.IsKnownGiven(model))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' given '{given.Form}' has unknown declension '{model}'.");
}
}
}
foreach (var surname in names.Surnames)
{
if (string.IsNullOrWhiteSpace(surname.Male) || string.IsNullOrWhiteSpace(surname.Female))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' has a surname missing a gendered form.");
}
if (surname.MaleCases is null && surname.FemaleCases is null)
{
var model = string.IsNullOrWhiteSpace(surname.Declension)
? names.DefaultSurnameDeclension
: surname.Declension;
if (!NameGrammar.IsKnownSurname(model))
{
throw new ContentLoadException($"NameSetDef '{names.DefName}' surname '{surname.Male}' has unknown declension '{model}'.");
}
}
}
}
private static void RequireSex(string? sex, string defName)
{
if (sex is null)
{
return;
}
if (!sex.Equals("male", StringComparison.OrdinalIgnoreCase)
&& !sex.Equals("female", StringComparison.OrdinalIgnoreCase))
{
throw new ContentLoadException($"Def '{defName}' has unknown sex '{sex}'.");
}
}
private static void RequireAgeWindow(int? min, int? max, string defName)
{
if (min is { } lo && max is { } hi && lo > hi)
{
throw new ContentLoadException($"Def '{defName}' has ageMin above ageMax.");
}
}
private static void RequireRange(IntRange? range, string where)
{
if (range is { } value && value.Min > value.Max)
{
throw new ContentLoadException($"{where} range min is above max.");
}
}
}
+251
View File
@@ -0,0 +1,251 @@
namespace HSchool.Content;
/// <summary>Roles the generator and traits talk about. Code, not a def — new roles are a rebuild.</summary>
public static class PersonRoles
{
public const string Student = "student";
public const string Staff = "staff";
public const string Parent = "parent";
public static bool IsKnown(string role) =>
role.Equals(Student, StringComparison.OrdinalIgnoreCase)
|| role.Equals(Staff, StringComparison.OrdinalIgnoreCase)
|| role.Equals(Parent, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// Derived body type, not a <see cref="BodyAttributeDef"/>. Skill limits may name it the same
/// way they name hair colour; the generator computes it from height and weight.
/// </summary>
public static class BodyBuilds
{
public const string Attribute = "Build";
public const string Skinny = "Skinny";
public const string Average = "Average";
public const string Athletic = "Athletic";
public const string Heavy = "Heavy";
public const string Obese = "Obese";
public static readonly IReadOnlyList<string> Values =
[Skinny, Average, Athletic, Heavy, Obese];
public static bool IsKnown(string value) =>
Values.Any(candidate => candidate.Equals(value, StringComparison.Ordinal));
/// <summary>
/// WHO-ish bands with a fit slice in the healthy range so <c>Athletic</c> is derived, not rolled.
/// Height in centimetres, weight in kilograms.
/// </summary>
public static string FromHeightAndWeight(int heightCm, int weightKg)
{
if (heightCm <= 0)
{
return Average;
}
var metres = heightCm / 100d;
var bmi = weightKg / (metres * metres);
if (bmi < 18.5)
{
return Skinny;
}
if (bmi < 22.0)
{
return Average;
}
if (bmi < 25.0)
{
return Athletic;
}
if (bmi < 30.0)
{
return Heavy;
}
return Obese;
}
}
public sealed class IntRange
{
public int Min { get; init; }
public int Max { get; init; } = 100;
}
public sealed class StatDistribution
{
public float Mean { get; init; }
public float StdDev { get; init; } = 1;
}
public sealed class AgeMeanPoint
{
public int Age { get; init; }
public float Mean { get; init; }
}
public sealed class BodySkillLimit
{
public required string Attribute { get; init; }
public string? Value { get; init; }
public int? Min { get; init; }
public int? Max { get; init; }
}
public sealed class SkillDef : Def
{
public IntRange Range { get; init; } = new();
public StatDistribution? Distribution { get; init; }
public IReadOnlyList<AgeMeanPoint> AgeMeans { get; init; } = [];
public IReadOnlyList<BodySkillLimit> BodyLimits { get; init; } = [];
}
public sealed class TraitSkillModifier
{
public required string Skill { get; init; }
public int Offset { get; init; }
}
public sealed class TraitDef : Def
{
public int Weight { get; init; } = 1;
public IReadOnlyList<string> Incompatible { get; init; } = [];
/// <summary>Empty means every role. Values are <see cref="PersonRoles"/> ids.</summary>
public IReadOnlyList<string> Roles { get; init; } = [];
public IntRange? Age { get; init; }
public IReadOnlyList<TraitSkillModifier> SkillModifiers { get; init; } = [];
}
public enum BodyAttributeKind
{
Number,
Choice,
}
public sealed class SexAgeDistribution
{
/// <summary><c>male</c>, <c>female</c>, or omit for both.</summary>
public string? Sex { get; init; }
public int? AgeMin { get; init; }
public int? AgeMax { get; init; }
public required StatDistribution Distribution { get; init; }
public IntRange? Range { get; init; }
}
public sealed class WeightedOption
{
public required string Value { get; init; }
public int Weight { get; init; } = 1;
public string? Sex { get; init; }
public int? AgeMin { get; init; }
public int? AgeMax { get; init; }
}
public sealed class BodyAttributeDef : Def
{
public BodyAttributeKind Kind { get; init; }
public IReadOnlyList<SexAgeDistribution> Distributions { get; init; } = [];
public IReadOnlyList<WeightedOption> Options { get; init; } = [];
}
public sealed class NeedDef : Def
{
public float Initial { get; init; } = 1;
public float DecayPerHour { get; init; }
public float Min { get; init; }
public float Max { get; init; } = 1;
}
public sealed class CaseTable
{
public required string Nom { get; init; }
public required string Gen { get; init; }
public required string Dat { get; init; }
public required string Acc { get; init; }
public required string Ins { get; init; }
public required string Pre { get; init; }
public string this[GrammaticalCase grammaticalCase] => grammaticalCase switch
{
GrammaticalCase.Nominative => Nom,
GrammaticalCase.Genitive => Gen,
GrammaticalCase.Dative => Dat,
GrammaticalCase.Accusative => Acc,
GrammaticalCase.Instrumental => Ins,
GrammaticalCase.Prepositional => Pre,
_ => Nom,
};
}
public sealed class GivenNameEntry
{
public required string Form { get; init; }
public string? Declension { get; init; }
public CaseTable? Cases { get; init; }
}
public sealed class SurnameEntry
{
public required string Male { get; init; }
public required string Female { get; init; }
public string? Declension { get; init; }
public CaseTable? MaleCases { get; init; }
public CaseTable? FemaleCases { get; init; }
}
public sealed class NameSetDef : Def
{
public string PatronymicRule { get; init; } = NameGrammar.SlavicPatronymic;
public string DefaultGivenDeclension { get; init; } = NameGrammar.Hard;
public string DefaultSurnameDeclension { get; init; } = NameGrammar.Ov;
public IReadOnlyList<GivenNameEntry> MaleGiven { get; init; } = [];
public IReadOnlyList<GivenNameEntry> FemaleGiven { get; init; } = [];
public IReadOnlyList<SurnameEntry> Surnames { get; init; } = [];
}
+298
View File
@@ -0,0 +1,298 @@
namespace HSchool.People;
internal static class FamilyFactory
{
public static (Family Family, List<Person> Members) Create(
DefCatalog catalog,
NameSetDef names,
int schoolSeed,
FamilyPlan plan,
DateTime yearStart,
DateTime asOf)
{
var rng = new Random(Seed.Mix(schoolSeed, plan.FamilyIndex, Seed.AppearanceSalt));
var familyId = $"f{plan.FamilyIndex}";
var surname = names.Surnames[rng.Next(names.Surnames.Count)];
var childDrafts = new ChildDraft[plan.Seats.Count];
var usedGiven = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < plan.Seats.Count; i++)
{
var seat = plan.Seats[i];
var female = rng.Next(2) == 0;
var given = PickGiven(female ? names.FemaleGiven : names.MaleGiven, rng, usedGiven);
var (first, last) = SchoolYears.BirthWindow(yearStart, seat.Year);
childDrafts[i] = new ChildDraft(seat, female, given, SchoolYears.RandomInRange(rng, first, last));
}
var fatherGiven = PickGiven(names.MaleGiven, rng);
var motherGiven = PickGiven(names.FemaleGiven, rng);
var fatherPatronymicSource = PickGiven(names.MaleGiven, rng);
var motherPatronymicSource = PickGiven(names.MaleGiven, rng);
DateTime motherBirth;
DateTime fatherBirth;
if (childDrafts.Length == 0)
{
motherBirth = asOf.AddYears(-(28 + rng.Next(28))).AddDays(-rng.Next(365));
fatherBirth = motherBirth.AddDays(rng.Next(-5 * 365, (5 * 365) + 1));
}
else
{
var oldestBirth = childDrafts[0].Birth;
foreach (var child in childDrafts)
{
if (child.Birth < oldestBirth)
{
oldestBirth = child.Birth;
}
}
var motherYearsOlder = 22 + rng.Next(17);
motherBirth = oldestBirth.AddYears(-motherYearsOlder).AddDays(-rng.Next(30));
fatherBirth = motherBirth.AddDays(rng.Next(-5 * 365, (5 * 365) + 1));
}
var members = new List<Person>();
var fatherId = $"{familyId}.p0";
var motherId = $"{familyId}.p1";
members.Add(
RollAdult(
catalog,
names,
rng,
fatherId,
familyId,
female: false,
fatherBirth,
asOf,
surname,
fatherGiven,
NameGrammar.Patronymic(fatherPatronymicSource.Form, female: false, names.PatronymicRule),
isParent: childDrafts.Length > 0));
members.Add(
RollAdult(
catalog,
names,
rng,
motherId,
familyId,
female: true,
motherBirth,
asOf,
surname,
motherGiven,
NameGrammar.Patronymic(motherPatronymicSource.Form, female: true, names.PatronymicRule),
isParent: childDrafts.Length > 0));
var childIds = new List<string>(childDrafts.Length);
for (var i = 0; i < childDrafts.Length; i++)
{
var draft = childDrafts[i];
var id = $"{familyId}.c{i}";
childIds.Add(id);
members.Add(
RollChild(
catalog,
names,
rng,
id,
familyId,
draft,
asOf,
surname,
fatherGiven.Form));
}
var family = new Family(familyId, [fatherId, motherId], childIds);
return (family, members);
}
public static (Family Family, List<Person> Members) CreateStaffOnly(
DefCatalog catalog,
NameSetDef names,
int schoolSeed,
int familyIndex,
DateTime asOf)
{
var empty = new FamilyPlan(familyIndex, []);
return Create(catalog, names, schoolSeed, empty, SchoolYears.StartOn(asOf), asOf);
}
private static Person RollAdult(
DefCatalog catalog,
NameSetDef names,
Random rng,
string id,
string familyId,
bool female,
DateTime birth,
DateTime asOf,
SurnameEntry surname,
GivenNameEntry given,
string patronymic,
bool isParent)
{
var age = SchoolYears.AgeYears(birth, asOf);
var roles = isParent ? new[] { PersonRoles.Parent } : new[] { PersonRoles.Staff };
return FinishPerson(
catalog,
names,
rng,
id,
familyId,
female,
birth,
asOf,
age,
surname,
given,
patronymic,
roles,
isStudent: false,
isParent,
classId: null);
}
private static Person RollChild(
DefCatalog catalog,
NameSetDef names,
Random rng,
string id,
string familyId,
ChildDraft draft,
DateTime asOf,
SurnameEntry surname,
string fatherGiven)
{
var age = SchoolYears.AgeYears(draft.Birth, asOf);
var patronymic = NameGrammar.Patronymic(fatherGiven, draft.Female, names.PatronymicRule);
return FinishPerson(
catalog,
names,
rng,
id,
familyId,
draft.Female,
draft.Birth,
asOf,
age,
surname,
draft.Given,
patronymic,
[PersonRoles.Student],
isStudent: true,
isParent: false,
draft.Seat.ClassId);
}
private static Person FinishPerson(
DefCatalog catalog,
NameSetDef names,
Random rng,
string id,
string familyId,
bool female,
DateTime birth,
DateTime asOf,
int age,
SurnameEntry surname,
GivenNameEntry given,
string patronymic,
IReadOnlyList<string> roles,
bool isStudent,
bool isParent,
string? classId)
{
var (numbers, choices) = PersonSampler.Body(catalog, rng, female, age);
var traits = PersonSampler.Traits(catalog, rng, roles, age);
var skills = PersonSampler.Skills(catalog, rng, age, choices, traits);
var needs = PersonSampler.Needs(catalog);
var name = new PersonName(
given.Form,
female ? surname.Female : surname.Male,
patronymic,
GivenTable(given, names.DefaultGivenDeclension),
SurnameTable(surname, female, names.DefaultSurnameDeclension),
PatronymicTable(patronymic, female));
return new Person
{
Id = id,
FamilyId = familyId,
Female = female,
BirthDate = DateTime.SpecifyKind(birth, DateTimeKind.Utc),
Name = name,
IsStudent = isStudent,
IsStaff = false,
IsParent = isParent,
ClassId = classId,
Numbers = numbers,
Choices = choices,
Skills = skills,
Traits = traits,
Needs = needs,
};
}
private static GivenNameEntry PickGiven(
IReadOnlyList<GivenNameEntry> pool,
Random rng,
HashSet<string>? used = null)
{
if (pool.Count == 0)
{
throw new InvalidOperationException("Name set has no given names for this sex.");
}
for (var attempt = 0; attempt < 16; attempt++)
{
var pick = pool[rng.Next(pool.Count)];
if (used is null || used.Add(pick.Form) || used.Count >= pool.Count)
{
return pick;
}
}
return pool[rng.Next(pool.Count)];
}
private static CaseTable GivenTable(GivenNameEntry entry, string defaultModel) =>
new()
{
Nom = NameGrammar.InflectGiven(entry, GrammaticalCase.Nominative, defaultModel),
Gen = NameGrammar.InflectGiven(entry, GrammaticalCase.Genitive, defaultModel),
Dat = NameGrammar.InflectGiven(entry, GrammaticalCase.Dative, defaultModel),
Acc = NameGrammar.InflectGiven(entry, GrammaticalCase.Accusative, defaultModel),
Ins = NameGrammar.InflectGiven(entry, GrammaticalCase.Instrumental, defaultModel),
Pre = NameGrammar.InflectGiven(entry, GrammaticalCase.Prepositional, defaultModel),
};
private static CaseTable SurnameTable(SurnameEntry entry, bool female, string defaultModel) =>
new()
{
Nom = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Nominative, defaultModel),
Gen = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Genitive, defaultModel),
Dat = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Dative, defaultModel),
Acc = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Accusative, defaultModel),
Ins = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Instrumental, defaultModel),
Pre = NameGrammar.InflectSurname(entry, female, GrammaticalCase.Prepositional, defaultModel),
};
private static CaseTable PatronymicTable(string nominative, bool female) =>
new()
{
Nom = nominative,
Gen = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Genitive),
Dat = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Dative),
Acc = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Accusative),
Ins = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Instrumental),
Pre = NameGrammar.InflectPatronymic(nominative, female, GrammaticalCase.Prepositional),
};
private readonly record struct ChildDraft(
PupilSeat Seat,
bool Female,
GivenNameEntry Given,
DateTime Birth);
}
+47
View File
@@ -0,0 +1,47 @@
namespace HSchool.People;
internal readonly record struct FamilyPlan(int FamilyIndex, IReadOnlyList<PupilSeat> Seats);
/// <summary>
/// Walks seats in order and groups them into families of 13. A short remainder becomes
/// singleton families rather than shrinking an earlier family's intended size, so a longer
/// seat list only appends families.
/// </summary>
internal static class FamilyPlanner
{
public static IReadOnlyList<FamilyPlan> Plan(int schoolSeed, IReadOnlyList<PupilSeat> seats)
{
var plans = new List<FamilyPlan>();
var offset = 0;
var index = 0;
while (offset < seats.Count)
{
var remaining = seats.Count - offset;
var want = ChildCount(schoolSeed, index);
var take = want <= remaining ? want : 1;
var slice = new PupilSeat[take];
for (var i = 0; i < take; i++)
{
slice[i] = seats[offset + i];
}
plans.Add(new FamilyPlan(index, slice));
offset += take;
index++;
}
return plans;
}
public static int ChildCount(int schoolSeed, int familyIndex)
{
var rng = new Random(Seed.Mix(schoolSeed, familyIndex, Seed.ChildCountSalt));
var roll = rng.Next(100);
if (roll < 50)
{
return 1;
}
return roll < 85 ? 2 : 3;
}
}
+1
View File
@@ -0,0 +1 @@
global using HSchool.Content;
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.People</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="HSchool.People.Tests" />
</ItemGroup>
</Project>
+354
View File
@@ -0,0 +1,354 @@
namespace HSchool.People;
internal static class PersonSampler
{
public static (Dictionary<string, int> Numbers, Dictionary<string, string> Choices) Body(
DefCatalog catalog,
Random rng,
bool female,
int age)
{
var numbers = new Dictionary<string, int>(StringComparer.Ordinal);
var choices = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var def in catalog.BodyAttributes.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal))
{
if (def.Abstract)
{
continue;
}
if (def.Kind == BodyAttributeKind.Number)
{
numbers[def.DefName] = SampleNumber(def, rng, female, age);
}
else
{
choices[def.DefName] = SampleChoice(def, rng, female, age);
}
}
var height = numbers.GetValueOrDefault("Height", 170);
var weight = numbers.GetValueOrDefault("Weight", 65);
choices[BodyBuilds.Attribute] = BodyBuilds.FromHeightAndWeight(height, weight);
return (numbers, choices);
}
public static Dictionary<string, int> Skills(
DefCatalog catalog,
Random rng,
int age,
IReadOnlyDictionary<string, string> choices,
IReadOnlyList<string> traits)
{
var values = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var skill in catalog.Skills.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal))
{
if (skill.Abstract)
{
continue;
}
var mean = MeanForAge(skill, age);
var stdDev = skill.Distribution?.StdDev ?? 10f;
var rolled = (int)Math.Round(mean + (stdDev * NextGaussian(rng)));
rolled = Clamp(rolled, skill.Range);
rolled = ApplyBodyLimits(rolled, skill, choices);
rolled = Clamp(rolled, skill.Range);
values[skill.DefName] = rolled;
}
foreach (var traitName in traits)
{
if (!catalog.Traits.TryGetValue(traitName, out var trait))
{
continue;
}
foreach (var modifier in trait.SkillModifiers)
{
if (!values.TryGetValue(modifier.Skill, out var current)
|| !catalog.Skills.TryGetValue(modifier.Skill, out var skill))
{
continue;
}
values[modifier.Skill] = Clamp(current + modifier.Offset, skill.Range);
}
}
return values;
}
public static List<string> Traits(DefCatalog catalog, Random rng, IReadOnlyList<string> roles, int age)
{
var picked = new List<string>();
var count = rng.Next(3);
if (count == 0 || catalog.Traits.Count == 0)
{
return picked;
}
var pool = catalog.Traits.Values
.Where(trait => !trait.Abstract && TraitFits(trait, roles, age))
.OrderBy(trait => trait.DefName, StringComparer.Ordinal)
.ToList();
for (var n = 0; n < count && pool.Count > 0; n++)
{
var chosen = WeightedTrait(pool, rng);
if (chosen is null)
{
break;
}
picked.Add(chosen.DefName);
var banned = catalog.TraitIncompatibilities(chosen.DefName);
pool.RemoveAll(trait =>
trait.DefName == chosen.DefName
|| banned.Contains(trait.DefName));
}
picked.Sort(StringComparer.Ordinal);
return picked;
}
public static Dictionary<string, float> Needs(DefCatalog catalog)
{
var values = new Dictionary<string, float>(StringComparer.Ordinal);
foreach (var need in catalog.Needs.Values.OrderBy(candidate => candidate.DefName, StringComparer.Ordinal))
{
if (!need.Abstract)
{
values[need.DefName] = need.Initial;
}
}
return values;
}
internal static int ApplyBodyLimits(int value, SkillDef skill, IReadOnlyDictionary<string, string> choices)
{
foreach (var limit in skill.BodyLimits)
{
if (!choices.TryGetValue(limit.Attribute, out var actual))
{
continue;
}
if (limit.Value is not null && !actual.Equals(limit.Value, StringComparison.Ordinal))
{
continue;
}
if (limit.Min is { } min)
{
value = Math.Max(value, min);
}
if (limit.Max is { } max)
{
value = Math.Min(value, max);
}
}
return value;
}
private static bool TraitFits(TraitDef trait, IReadOnlyList<string> roles, int age)
{
if (trait.Age is { } range && (age < range.Min || age > range.Max))
{
return false;
}
if (trait.Roles.Count == 0)
{
return true;
}
foreach (var role in trait.Roles)
{
foreach (var have in roles)
{
if (role.Equals(have, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
private static TraitDef? WeightedTrait(List<TraitDef> pool, Random rng)
{
var total = 0;
foreach (var trait in pool)
{
total += Math.Max(trait.Weight, 1);
}
if (total <= 0)
{
return null;
}
var pick = rng.Next(total);
foreach (var trait in pool)
{
pick -= Math.Max(trait.Weight, 1);
if (pick < 0)
{
return trait;
}
}
return pool[^1];
}
private static int SampleNumber(BodyAttributeDef def, Random rng, bool female, int age)
{
var row = MatchDistribution(def.Distributions, female, age);
var mean = row?.Distribution.Mean ?? 0;
var stdDev = row?.Distribution.StdDev ?? 1;
var value = (int)Math.Round(mean + (stdDev * NextGaussian(rng)));
if (row?.Range is { } range)
{
return Clamp(value, range);
}
return value;
}
private static string SampleChoice(BodyAttributeDef def, Random rng, bool female, int age)
{
var options = def.Options.Where(option => OptionFits(option, female, age)).ToList();
if (options.Count == 0)
{
options = [.. def.Options];
}
var total = 0;
foreach (var option in options)
{
total += Math.Max(option.Weight, 1);
}
var pick = rng.Next(Math.Max(total, 1));
foreach (var option in options)
{
pick -= Math.Max(option.Weight, 1);
if (pick < 0)
{
return option.Value;
}
}
return options[^1].Value;
}
private static bool OptionFits(WeightedOption option, bool female, int age)
{
if (option.AgeMin is { } min && age < min)
{
return false;
}
if (option.AgeMax is { } max && age > max)
{
return false;
}
if (option.Sex is null)
{
return true;
}
var want = female ? "female" : "male";
return option.Sex.Equals(want, StringComparison.OrdinalIgnoreCase);
}
private static SexAgeDistribution? MatchDistribution(
IReadOnlyList<SexAgeDistribution> rows,
bool female,
int age)
{
SexAgeDistribution? unisex = null;
foreach (var row in rows)
{
if (row.AgeMin is { } min && age < min)
{
continue;
}
if (row.AgeMax is { } max && age > max)
{
continue;
}
if (row.Sex is null)
{
unisex ??= row;
continue;
}
var want = female ? "female" : "male";
if (row.Sex.Equals(want, StringComparison.OrdinalIgnoreCase))
{
return row;
}
}
return unisex ?? (rows.Count == 0 ? null : rows[0]);
}
private static float MeanForAge(SkillDef skill, int age)
{
if (skill.AgeMeans.Count == 0)
{
return skill.Distribution?.Mean ?? (skill.Range.Min + skill.Range.Max) / 2f;
}
var points = skill.AgeMeans.OrderBy(point => point.Age).ToList();
if (age <= points[0].Age)
{
return points[0].Mean;
}
if (age >= points[^1].Age)
{
return points[^1].Mean;
}
for (var i = 1; i < points.Count; i++)
{
if (age > points[i].Age)
{
continue;
}
var a = points[i - 1];
var b = points[i];
var span = b.Age - a.Age;
var t = span == 0 ? 0f : (age - a.Age) / (float)span;
return a.Mean + (t * (b.Mean - a.Mean));
}
return points[^1].Mean;
}
private static int Clamp(int value, IntRange range) => Math.Clamp(value, range.Min, range.Max);
private static double NextGaussian(Random rng)
{
double u1;
do
{
u1 = rng.NextDouble();
}
while (u1 <= double.Epsilon);
var u2 = rng.NextDouble();
return Math.Sqrt(-2d * Math.Log(u1)) * Math.Cos(2d * Math.PI * u2);
}
}
+65
View File
@@ -0,0 +1,65 @@
namespace HSchool.People;
/// <summary>Plain roster data. Simulation turns these into entities; this project does not.</summary>
public sealed record Roster(
IReadOnlyList<Person> People,
IReadOnlyList<Family> Families,
IReadOnlyList<SchoolClass> Classes);
public sealed record Person
{
public required string Id { get; init; }
public required string FamilyId { get; init; }
public required bool Female { get; init; }
public required DateTime BirthDate { get; init; }
public required PersonName Name { get; init; }
public required bool IsStudent { get; init; }
public required bool IsStaff { get; init; }
public required bool IsParent { get; init; }
public string? ClassId { get; init; }
public string? Position { get; init; }
public string? WorkplaceRoomId { get; init; }
public required IReadOnlyDictionary<string, int> Numbers { get; init; }
public required IReadOnlyDictionary<string, string> Choices { get; init; }
public required IReadOnlyDictionary<string, int> Skills { get; init; }
public required IReadOnlyList<string> Traits { get; init; }
public required IReadOnlyDictionary<string, float> Needs { get; init; }
public int AgeOn(DateTime asOf) => SchoolYears.AgeYears(BirthDate, asOf);
}
public sealed record PersonName(
string Given,
string Surname,
string Patronymic,
CaseTable GivenCases,
CaseTable SurnameCases,
CaseTable PatronymicCases);
public sealed record Family(
string Id,
IReadOnlyList<string> ParentIds,
IReadOnlyList<string> ChildIds);
public sealed record SchoolClass(
string Id,
int Year,
string Letter,
string RoomId,
int Capacity,
IReadOnlyList<string> PupilIds);
+127
View File
@@ -0,0 +1,127 @@
namespace HSchool.People;
/// <summary>
/// Builds a roster from a catalog, a map and a school seed. Deterministic: same inputs, same people.
/// </summary>
public static class RosterGenerator
{
/// <summary>
/// Matches <c>SimulationOptions.DefaultStartDate</c> so tests without a clock still sit in
/// the default school year. Callers with a live clock must pass <paramref name="asOf"/>.
/// </summary>
public static readonly DateTime DefaultAsOf = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
public static Roster Generate(
DefCatalog catalog,
MapLayout map,
int schoolSeed,
string nameSetId,
DateTime? asOf = null)
{
ArgumentNullException.ThrowIfNull(catalog);
ArgumentNullException.ThrowIfNull(map);
ArgumentException.ThrowIfNullOrWhiteSpace(nameSetId);
if (!catalog.NameSets.TryGetValue(nameSetId, out var names))
{
throw new ArgumentException($"Unknown name set '{nameSetId}'.", nameof(nameSetId));
}
var when = DateTime.SpecifyKind(asOf ?? DefaultAsOf, DateTimeKind.Utc);
var yearStart = SchoolYears.StartOn(when);
var demand = SchoolDemand.From(catalog, map);
var plans = FamilyPlanner.Plan(schoolSeed, demand.Seats);
var people = new List<Person>();
var families = new List<Family>(plans.Count);
foreach (var plan in plans)
{
var (family, members) = FamilyFactory.Create(catalog, names, schoolSeed, plan, yearStart, when);
families.Add(family);
people.AddRange(members);
}
var nextFamily = plans.Count;
while (people.Count(person => !person.IsStudent) < demand.Staff.Count)
{
var (family, members) = FamilyFactory.CreateStaffOnly(catalog, names, schoolSeed, nextFamily, when);
families.Add(family);
people.AddRange(members);
nextFamily++;
}
var staffed = AssignStaff(people, demand.Staff);
var classes = FillClasses(demand.Classes, staffed);
return new Roster(staffed, families, classes);
}
private static IReadOnlyList<Person> AssignStaff(List<Person> people, IReadOnlyList<StaffOpening> openings)
{
if (openings.Count == 0)
{
return people;
}
var jobs = new Dictionary<string, StaffOpening>(StringComparer.Ordinal);
var index = 0;
foreach (var person in people)
{
if (person.IsStudent || index >= openings.Count)
{
continue;
}
jobs[person.Id] = openings[index];
index++;
}
if (jobs.Count == 0)
{
return people;
}
var result = new Person[people.Count];
for (var i = 0; i < people.Count; i++)
{
var person = people[i];
result[i] = jobs.TryGetValue(person.Id, out var job)
? person with
{
IsStaff = true,
Position = job.Position,
WorkplaceRoomId = job.RoomId,
}
: person;
}
return result;
}
private static IReadOnlyList<SchoolClass> FillClasses(
IReadOnlyList<SchoolClass> classes,
IReadOnlyList<Person> people)
{
var pupils = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var schoolClass in classes)
{
pupils[schoolClass.Id] = new List<string>(schoolClass.Capacity);
}
foreach (var person in people)
{
if (person.ClassId is { } classId && pupils.TryGetValue(classId, out var list))
{
list.Add(person.Id);
}
}
var filled = new SchoolClass[classes.Count];
for (var i = 0; i < classes.Count; i++)
{
var schoolClass = classes[i];
filled[i] = schoolClass with { PupilIds = pupils[schoolClass.Id] };
}
return filled;
}
}
+85
View File
@@ -0,0 +1,85 @@
namespace HSchool.People;
public readonly record struct StaffOpening(string RoomId, string Position);
public readonly record struct PupilSeat(string ClassId, string RoomId, int Year, string Letter);
/// <summary>
/// How many pupils and staff a map asks for. Classrooms are rooms with pupil slots; each one
/// becomes a roster class. Positions come from <see cref="RoomDef.Positions"/>, not the wire labels.
/// </summary>
public sealed class SchoolDemand
{
internal SchoolDemand(
IReadOnlyList<SchoolClass> classes,
IReadOnlyList<PupilSeat> seats,
IReadOnlyList<StaffOpening> staff)
{
Classes = classes;
Seats = seats;
Staff = staff;
}
public IReadOnlyList<SchoolClass> Classes { get; }
public IReadOnlyList<PupilSeat> Seats { get; }
public IReadOnlyList<StaffOpening> Staff { get; }
public static SchoolDemand From(DefCatalog catalog, MapLayout map)
{
var classrooms = new List<(RoomNode Room, int Slots)>();
var staff = new List<StaffOpening>();
foreach (var room in map.Rooms)
{
var slots = PupilSlotsOf(catalog, room);
if (slots > 0)
{
classrooms.Add((room, slots));
}
if (catalog.Rooms.TryGetValue(room.Def, out var def))
{
foreach (var position in def.Positions)
{
staff.Add(new StaffOpening(room.Id, position));
}
}
}
const string letters = "АБВГДЕЖЗИКЛМНОПРСТУФХЦЧШЩЭЮЯ";
var classes = new SchoolClass[classrooms.Count];
var seats = new List<PupilSeat>();
for (var i = 0; i < classrooms.Count; i++)
{
var (room, capacity) = classrooms[i];
var year = (i % 11) + 1;
var letterIndex = i / 11;
var letter = letterIndex < letters.Length ? letters[letterIndex].ToString() : "?";
var classId = $"class-{room.Id}";
classes[i] = new SchoolClass(classId, year, letter, room.Id, capacity, []);
for (var seat = 0; seat < capacity; seat++)
{
seats.Add(new PupilSeat(classId, room.Id, year, letter));
}
}
return new SchoolDemand(classes, seats, staff);
}
private static int PupilSlotsOf(DefCatalog catalog, RoomNode room)
{
var pupilSlots = 0L;
foreach (var fill in room.Slots)
{
var count = fill.Count < 1 ? 1 : Math.Min(fill.Count, byte.MaxValue);
if (catalog.Things.TryGetValue(fill.Thing, out var thing) && thing.PupilSlots > 0)
{
pupilSlots += (long)thing.PupilSlots * count;
}
}
return (int)Math.Clamp(pupilSlots, 0, ushort.MaxValue);
}
}
+43
View File
@@ -0,0 +1,43 @@
namespace HSchool.People;
/// <summary>
/// Game-calendar helpers. Takes the caller's date so this library never reads the wall clock.
/// </summary>
internal static class SchoolYears
{
public static DateTime StartOn(DateTime asOf)
{
var utc = DateTime.SpecifyKind(asOf, DateTimeKind.Utc);
var year = utc.Month >= 9 ? utc.Year : utc.Year - 1;
return new DateTime(year, 9, 1, 0, 0, 0, DateTimeKind.Utc);
}
/// <summary>
/// Grade N pupils turned N+6 on or before 1 September of the current school year.
/// </summary>
public static (DateTime First, DateTime Last) BirthWindow(DateTime yearStart, int grade)
{
var ageOnStart = grade + 6;
var last = yearStart.AddYears(-ageOnStart);
var first = last.AddYears(-1).AddDays(1);
return (first, last);
}
public static int AgeYears(DateTime birth, DateTime asOf)
{
var age = asOf.Year - birth.Year;
if (birth.Date.AddYears(age) > asOf.Date)
{
age--;
}
return Math.Max(age, 0);
}
public static DateTime RandomInRange(Random rng, DateTime first, DateTime last)
{
var days = (last.Date - first.Date).Days;
var offset = days <= 0 ? 0 : rng.Next(days + 1);
return DateTime.SpecifyKind(first.Date.AddDays(offset), DateTimeKind.Utc);
}
}
+27
View File
@@ -0,0 +1,27 @@
namespace HSchool.People;
/// <summary>
/// Per-family streams derived from the school seed. Family N never consumes family N-1's rolls,
/// so appending a thirteenth family leaves the first twelve unchanged.
/// </summary>
internal static class Seed
{
public const int ChildCountSalt = 1;
public const int AppearanceSalt = 2;
public static int Mix(int schoolSeed, int familyIndex, int salt)
{
var z = Mix64((uint)schoolSeed);
z = Mix64(z ^ (uint)(familyIndex + 1));
z = Mix64(z ^ (uint)(salt + 1));
return (int)z;
}
private static ulong Mix64(ulong z)
{
z += 0x9E3779B97F4A7C15UL;
z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9UL;
z = (z ^ (z >> 27)) * 0x94D049BB133111EBUL;
return z ^ (z >> 31);
}
}
+2
View File
@@ -86,6 +86,7 @@ internal sealed record CatalogResponse(
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<DefInfoResponse> NameSets,
MapLayout DefaultMap)
{
public static CatalogResponse From(DefCatalog catalog, MapLayout map, string locale) =>
@@ -95,6 +96,7 @@ internal sealed record CatalogResponse(
Placeable(catalog.Floors.Values, catalog, locale),
PlaceableRooms(catalog, locale),
PlaceableThings(catalog, locale),
Placeable(catalog.NameSets.Values, catalog, locale),
map);
private static IReadOnlyList<DefInfoResponse> Placeable<T>(IEnumerable<T> defs, DefCatalog catalog, string locale)
+9 -1
View File
@@ -50,6 +50,7 @@ internal static class SchoolEndpoints
DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
request.ModIds,
request.Map,
request.NameSetId,
NewCompletion<SchoolCreationOutcome>());
commands.Enqueue(command);
@@ -71,6 +72,8 @@ internal static class SchoolEndpoints
Problem(StatusCodes.Status400BadRequest, "unknown-mod", "A selected mod is missing."),
SchoolCreationError.InvalidCatalog =>
Problem(StatusCodes.Status400BadRequest, "invalid-catalog", "The selected packs could not be loaded."),
SchoolCreationError.UnknownNameSet =>
Problem(StatusCodes.Status400BadRequest, "unknown-name-set", "The selected name set is not in the catalog."),
_ => Results.Problem("Unknown error."),
};
})
@@ -107,7 +110,12 @@ internal static class SchoolEndpoints
}
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
internal sealed record CreateSchoolRequest(string? Name, DateTime StartDate, IReadOnlyList<string>? ModIds, MapLayout? Map);
internal sealed record CreateSchoolRequest(
string? Name,
DateTime StartDate,
IReadOnlyList<string>? ModIds,
MapLayout? Map,
string? NameSetId);
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
{
+1
View File
@@ -14,6 +14,7 @@ internal abstract record GameCommand
DateTime StartDate,
IReadOnlyList<string>? ExtraModIds,
MapLayout? Map,
string? NameSetId,
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
+48 -3
View File
@@ -216,10 +216,28 @@ internal sealed class GameLoopService(
return;
}
DefCatalog catalog;
try
{
catalog = mods.LoadCatalog(packIds);
}
catch (Exception ex) when (ex is ContentLoadException or SchoolContentUnavailableException)
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.InvalidCatalog));
return;
}
var nameSetId = ResolveNameSetId(catalog, command.NameSetId);
if (nameSetId is null)
{
command.Result.TrySetResult(new SchoolCreationOutcome(null, SchoolCreationError.UnknownNameSet));
return;
}
var id = _nextId++;
store.WriteNextId(_nextId);
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map);
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, packIds, command.Map, nameSetId);
Track(worker);
worker.Start();
@@ -385,7 +403,8 @@ internal sealed class GameLoopService(
save.SpeedIndex,
isNew: false,
save.ModIds,
save.Map);
save.Map,
save.NameSetId);
worker.Start();
try
@@ -431,7 +450,8 @@ internal sealed class GameLoopService(
int speedIndex,
bool isNew,
IReadOnlyList<string>? modIds,
MapLayout? map) =>
MapLayout? map,
string? nameSetId) =>
new(
id,
name,
@@ -441,6 +461,7 @@ internal sealed class GameLoopService(
isNew,
modIds,
map,
nameSetId,
_options,
clients,
metrics,
@@ -449,6 +470,30 @@ internal sealed class GameLoopService(
onFailed: schoolId => commands.Enqueue(new GameCommand.WorkerFailed(schoolId)),
loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}"));
/// <summary>
/// Empty request uses the first placeable set (core's Slavic). A named id must exist in the
/// catalog already loaded for this pack list — unknown extras were rejected above.
/// </summary>
internal static string? ResolveNameSetId(DefCatalog catalog, string? requested)
{
var available = catalog.NameSets.Values
.Where(def => !def.Abstract)
.Select(def => def.DefName)
.OrderBy(name => name, StringComparer.Ordinal)
.ToArray();
if (available.Length == 0)
{
return null;
}
if (string.IsNullOrWhiteSpace(requested))
{
return available[0];
}
return available.Contains(requested, StringComparer.Ordinal) ? requested : null;
}
private void Track(SchoolWorker worker)
{
_workers[worker.Id] = worker;
+3
View File
@@ -23,6 +23,8 @@ internal sealed class SchoolSave
public IReadOnlyList<string>? ModIds { get; init; }
public MapLayout? Map { get; init; }
public string? NameSetId { get; init; }
}
/// <summary>Allocates school ids that survive a process restart.</summary>
@@ -152,6 +154,7 @@ internal sealed class SchoolStore
SpeedIndex = save.SpeedIndex,
ModIds = save.ModIds,
Map = save.Map,
NameSetId = save.NameSetId,
});
}
catch (Exception ex)
+4
View File
@@ -29,6 +29,7 @@ internal sealed class SchoolWorker
private readonly bool _isNew;
private readonly IReadOnlyList<string>? _modIds;
private readonly MapLayout? _savedMap;
private readonly string? _nameSetId;
private readonly Action<int> _onFailed;
private readonly int _id;
@@ -53,6 +54,7 @@ internal sealed class SchoolWorker
bool isNew,
IReadOnlyList<string>? modIds,
MapLayout? savedMap,
string? nameSetId,
SimulationOptions options,
ClientRegistry clients,
GameMetrics metrics,
@@ -69,6 +71,7 @@ internal sealed class SchoolWorker
_isNew = isNew;
_modIds = modIds;
_savedMap = savedMap;
_nameSetId = nameSetId;
_options = options;
_clients = clients;
_metrics = metrics;
@@ -411,6 +414,7 @@ internal sealed class SchoolWorker
SpeedIndex = school.Clock.SpeedIndex,
ModIds = school.Catalog?.PackIds,
Map = school.Map,
NameSetId = _nameSetId,
});
}
catch (Exception ex)
@@ -0,0 +1,11 @@
{
"defName": "EyeColor",
"kind": "choice",
"options": [
{ "value": "BrownEyes", "weight": 40 },
{ "value": "BlueEyes", "weight": 20 },
{ "value": "GrayEyes", "weight": 20 },
{ "value": "GreenEyes", "weight": 12 },
{ "value": "HazelEyes", "weight": 8 },
],
}
@@ -0,0 +1,11 @@
{
"defName": "HairColor",
"kind": "choice",
"options": [
{ "value": "BlackHair", "weight": 25 },
{ "value": "BrownHair", "weight": 40 },
{ "value": "BlondHair", "weight": 20 },
{ "value": "RedHair", "weight": 8 },
{ "value": "GrayHair", "weight": 2, "ageMin": 40 },
],
}
@@ -0,0 +1,14 @@
{
"defName": "Height",
"kind": "number",
"distributions": [
{ "sex": "male", "ageMin": 6, "ageMax": 9, "distribution": { "mean": 128, "stdDev": 6 }, "range": { "min": 110, "max": 150 } },
{ "sex": "male", "ageMin": 10, "ageMax": 13, "distribution": { "mean": 150, "stdDev": 8 }, "range": { "min": 128, "max": 175 } },
{ "sex": "male", "ageMin": 14, "ageMax": 17, "distribution": { "mean": 172, "stdDev": 8 }, "range": { "min": 150, "max": 195 } },
{ "sex": "male", "ageMin": 18, "distribution": { "mean": 176, "stdDev": 7 }, "range": { "min": 155, "max": 205 } },
{ "sex": "female", "ageMin": 6, "ageMax": 9, "distribution": { "mean": 127, "stdDev": 6 }, "range": { "min": 108, "max": 148 } },
{ "sex": "female", "ageMin": 10, "ageMax": 13, "distribution": { "mean": 148, "stdDev": 7 }, "range": { "min": 128, "max": 170 } },
{ "sex": "female", "ageMin": 14, "ageMax": 17, "distribution": { "mean": 163, "stdDev": 6 }, "range": { "min": 145, "max": 185 } },
{ "sex": "female", "ageMin": 18, "distribution": { "mean": 164, "stdDev": 6 }, "range": { "min": 145, "max": 190 } },
],
}
@@ -0,0 +1,14 @@
{
"defName": "Weight",
"kind": "number",
"distributions": [
{ "sex": "male", "ageMin": 6, "ageMax": 9, "distribution": { "mean": 27, "stdDev": 5 }, "range": { "min": 16, "max": 45 } },
{ "sex": "male", "ageMin": 10, "ageMax": 13, "distribution": { "mean": 42, "stdDev": 8 }, "range": { "min": 25, "max": 70 } },
{ "sex": "male", "ageMin": 14, "ageMax": 17, "distribution": { "mean": 62, "stdDev": 10 }, "range": { "min": 40, "max": 95 } },
{ "sex": "male", "ageMin": 18, "distribution": { "mean": 78, "stdDev": 12 }, "range": { "min": 50, "max": 130 } },
{ "sex": "female", "ageMin": 6, "ageMax": 9, "distribution": { "mean": 26, "stdDev": 5 }, "range": { "min": 15, "max": 44 } },
{ "sex": "female", "ageMin": 10, "ageMax": 13, "distribution": { "mean": 42, "stdDev": 8 }, "range": { "min": 24, "max": 70 } },
{ "sex": "female", "ageMin": 14, "ageMax": 17, "distribution": { "mean": 54, "stdDev": 8 }, "range": { "min": 38, "max": 85 } },
{ "sex": "female", "ageMin": 18, "distribution": { "mean": 62, "stdDev": 10 }, "range": { "min": 42, "max": 110 } },
],
}
@@ -0,0 +1,131 @@
{
"defName": "Slavic",
"patronymicRule": "slavic",
"defaultGivenDeclension": "hard",
"defaultSurnameDeclension": "ov",
"maleGiven": [
{ "form": "Александр" },
{ "form": "Алексей" },
{ "form": "Андрей" },
{ "form": "Антон" },
{ "form": "Артём" },
{ "form": "Борис" },
{ "form": "Вадим" },
{ "form": "Валерий" },
{ "form": "Виктор" },
{ "form": "Виталий" },
{ "form": "Владимир" },
{ "form": "Владислав" },
{ "form": "Григорий" },
{ "form": "Денис" },
{ "form": "Дмитрий" },
{ "form": "Евгений" },
{ "form": "Егор" },
{ "form": "Иван" },
{ "form": "Игорь", "declension": "soft" },
{ "form": "Илья", "declension": "ya" },
{ "form": "Кирилл" },
{ "form": "Константин" },
{ "form": "Максим" },
{ "form": "Михаил" },
{ "form": "Никита", "declension": "a" },
{ "form": "Николай" },
{ "form": "Олег" },
{ "form": "Павел" },
{ "form": "Пётр" },
{ "form": "Роман" },
{ "form": "Сергей" },
{ "form": "Станислав" },
{ "form": "Степан" },
{ "form": "Фёдор" },
{ "form": "Юрий" },
],
"femaleGiven": [
{ "form": "Александра", "declension": "a" },
{ "form": "Алина", "declension": "a" },
{ "form": "Анастасия", "declension": "iya" },
{ "form": "Анна", "declension": "a" },
{ "form": "Валентина", "declension": "a" },
{ "form": "Валерия", "declension": "iya" },
{ "form": "Вера", "declension": "a" },
{ "form": "Виктория", "declension": "iya" },
{ "form": "Дарья", "declension": "ya" },
{ "form": "Екатерина", "declension": "a" },
{ "form": "Елена", "declension": "a" },
{ "form": "Елизавета", "declension": "a" },
{ "form": "Ирина", "declension": "a" },
{ "form": "Ксения", "declension": "iya" },
{
"form": "Любовь",
"cases": {
"nom": "Любовь",
"gen": "Любови",
"dat": "Любови",
"acc": "Любовь",
"ins": "Любовью",
"pre": "Любови",
},
},
{ "form": "Людмила", "declension": "a" },
{ "form": "Маргарита", "declension": "a" },
{ "form": "Мария", "declension": "iya" },
{ "form": "Надежда", "declension": "a" },
{ "form": "Наталья", "declension": "ya" },
{ "form": "Нина", "declension": "a" },
{ "form": "Оксана", "declension": "a" },
{ "form": "Ольга", "declension": "a" },
{ "form": "Полина", "declension": "a" },
{ "form": "Светлана", "declension": "a" },
{ "form": "София", "declension": "iya" },
{ "form": "Татьяна", "declension": "a" },
{ "form": "Юлия", "declension": "iya" },
{ "form": "Яна", "declension": "a" },
{ "form": "Вероника", "declension": "a" },
{ "form": "Диана", "declension": "a" },
{ "form": "Марина", "declension": "a" },
],
"surnames": [
{ "male": "Иванов", "female": "Иванова" },
{ "male": "Петров", "female": "Петрова" },
{ "male": "Смирнов", "female": "Смирнова" },
{ "male": "Кузнецов", "female": "Кузнецова" },
{ "male": "Попов", "female": "Попова" },
{ "male": "Васильев", "female": "Васильева" },
{ "male": "Соколов", "female": "Соколова" },
{ "male": "Михайлов", "female": "Михайлова" },
{ "male": "Новиков", "female": "Новикова" },
{ "male": "Фёдоров", "female": "Фёдорова" },
{ "male": "Морозов", "female": "Морозова" },
{ "male": "Волков", "female": "Волкова" },
{ "male": "Алексеев", "female": "Алексеева" },
{ "male": "Лебедев", "female": "Лебедева" },
{ "male": "Семёнов", "female": "Семёнова" },
{ "male": "Егоров", "female": "Егорова" },
{ "male": "Павлов", "female": "Павлова" },
{ "male": "Козлов", "female": "Козлова" },
{ "male": "Степанов", "female": "Степанова" },
{ "male": "Николаев", "female": "Николаева" },
{ "male": "Орлов", "female": "Орлова" },
{ "male": "Андреев", "female": "Андреева" },
{ "male": "Макаров", "female": "Макарова" },
{ "male": "Никитин", "female": "Никитина", "declension": "in" },
{ "male": "Захаров", "female": "Захарова" },
{ "male": "Зайцев", "female": "Зайцева" },
{ "male": "Соловьёв", "female": "Соловьёва" },
{ "male": "Борисов", "female": "Борисова" },
{ "male": "Яковлев", "female": "Яковлева" },
{ "male": "Григорьев", "female": "Григорьева" },
{ "male": "Романов", "female": "Романова" },
{ "male": "Воробьёв", "female": "Воробьёва" },
{ "male": "Сергеев", "female": "Сергеева" },
{ "male": "Кузьмин", "female": "Кузьмина", "declension": "in" },
{ "male": "Фролов", "female": "Фролова" },
{ "male": "Александров", "female": "Александрова" },
{ "male": "Дмитриев", "female": "Дмитриева" },
{ "male": "Королёв", "female": "Королёва" },
{ "male": "Громов", "female": "Громова" },
{ "male": "Ильин", "female": "Ильина", "declension": "in" },
{ "male": "Козловский", "female": "Козловская", "declension": "sky" },
{ "male": "Орловский", "female": "Орловская", "declension": "sky" },
],
}
@@ -0,0 +1,6 @@
[
{ "defName": "Sleep", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
{ "defName": "Hunger", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
{ "defName": "Toilet", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
{ "defName": "Social", "initial": 1, "decayPerHour": 0, "min": 0, "max": 1 },
]
@@ -0,0 +1,69 @@
[
{
"defName": "Mathematics",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 14 },
"ageMeans": [
{ "age": 7, "mean": 28 },
{ "age": 17, "mean": 55 },
],
},
{
"defName": "RussianLanguage",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 52, "stdDev": 13 },
"ageMeans": [
{ "age": 7, "mean": 32 },
{ "age": 17, "mean": 56 },
],
},
{
"defName": "Literature",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 14 },
},
{
"defName": "Physics",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 48, "stdDev": 15 },
"ageMeans": [
{ "age": 12, "mean": 30 },
{ "age": 17, "mean": 50 },
],
},
{
"defName": "History",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 13 },
},
{
"defName": "PhysicalEducation",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 55, "stdDev": 14 },
},
{
"defName": "Biology",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 13 },
},
{
"defName": "Chemistry",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 48, "stdDev": 15 },
},
{
"defName": "Geography",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 12 },
},
{
"defName": "Informatics",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 48, "stdDev": 16 },
},
{
"defName": "ForeignLanguage",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 15 },
},
]
@@ -0,0 +1,30 @@
[
{
"defName": "Agility",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 14 },
"bodyLimits": [
{ "attribute": "Build", "value": "Obese", "max": 25 },
{ "attribute": "Build", "value": "Heavy", "max": 45 },
{ "attribute": "Build", "value": "Athletic", "min": 40 },
],
},
{
"defName": "Strength",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 14 },
"bodyLimits": [
{ "attribute": "Build", "value": "Skinny", "max": 45 },
{ "attribute": "Build", "value": "Athletic", "min": 40 },
],
},
{
"defName": "Endurance",
"range": { "min": 0, "max": 100 },
"distribution": { "mean": 50, "stdDev": 13 },
"bodyLimits": [
{ "attribute": "Build", "value": "Obese", "max": 35 },
{ "attribute": "Build", "value": "Athletic", "min": 40 },
],
},
]
@@ -0,0 +1,78 @@
[
{
"defName": "Diligent",
"weight": 8,
"incompatible": ["Lazy", "AbsentMinded"],
"skillModifiers": [
{ "skill": "Mathematics", "offset": 8 },
{ "skill": "RussianLanguage", "offset": 6 },
],
},
{
"defName": "AbsentMinded",
"weight": 6,
"incompatible": ["Diligent"],
"skillModifiers": [
{ "skill": "Mathematics", "offset": -6 },
{ "skill": "Informatics", "offset": -4 },
],
},
{
"defName": "Bully",
"weight": 4,
"incompatible": ["Kind", "Quiet"],
"roles": ["student"],
"age": { "min": 7, "max": 18 },
"skillModifiers": [
{ "skill": "Strength", "offset": 8 },
{ "skill": "Literature", "offset": -4 },
],
},
{
"defName": "Quiet",
"weight": 7,
"incompatible": ["Leader", "Bully"],
},
{
"defName": "Leader",
"weight": 4,
"incompatible": ["Quiet"],
"skillModifiers": [
{ "skill": "History", "offset": 4 },
],
},
{
"defName": "Lazy",
"weight": 6,
"incompatible": ["Diligent"],
"skillModifiers": [
{ "skill": "PhysicalEducation", "offset": -8 },
{ "skill": "Mathematics", "offset": -6 },
],
},
{
"defName": "Curious",
"weight": 7,
"skillModifiers": [
{ "skill": "Physics", "offset": 6 },
{ "skill": "Informatics", "offset": 6 },
],
},
{
"defName": "HotTempered",
"weight": 5,
"incompatible": ["Quiet"],
},
{
"defName": "Kind",
"weight": 8,
"incompatible": ["Bully"],
},
{
"defName": "Neat",
"weight": 6,
"skillModifiers": [
{ "skill": "Chemistry", "offset": 4 },
],
},
]
@@ -44,4 +44,52 @@
"ComputerLab": "Computer lab",
"GymHall": "Gym",
"ChangingRoom": "Changing room",
"Mathematics": "Mathematics",
"RussianLanguage": "Russian",
"Literature": "Literature",
"Physics": "Physics",
"History": "History",
"PhysicalEducation": "PE",
"Biology": "Biology",
"Chemistry": "Chemistry",
"Geography": "Geography",
"Informatics": "Informatics",
"ForeignLanguage": "Foreign language",
"Agility": "Agility",
"Strength": "Strength",
"Endurance": "Endurance",
"Diligent": "Diligent",
"AbsentMinded": "Absent-minded",
"Bully": "Bully",
"Quiet": "Quiet",
"Leader": "Leader",
"Lazy": "Lazy",
"Curious": "Curious",
"HotTempered": "Hot-tempered",
"Kind": "Kind",
"Neat": "Neat",
"Height": "Height",
"Weight": "Weight",
"HairColor": "Hair colour",
"EyeColor": "Eye colour",
"BlackHair": "Black",
"BrownHair": "Brown",
"BlondHair": "Blond",
"RedHair": "Red",
"GrayHair": "Grey",
"BrownEyes": "Brown",
"BlueEyes": "Blue",
"GrayEyes": "Grey",
"GreenEyes": "Green",
"HazelEyes": "Hazel",
"Sleep": "Sleep",
"Hunger": "Hunger",
"Toilet": "Toilet",
"Social": "Social",
"Slavic": "Slavic",
"Skinny": "Skinny",
"Average": "Average",
"Athletic": "Athletic",
"Heavy": "Heavy",
"Obese": "Obese",
}
@@ -44,4 +44,52 @@
"ComputerLab": "Компьютерный класс",
"GymHall": "Спортивный зал",
"ChangingRoom": "Раздевалка",
"Mathematics": "Математика",
"RussianLanguage": "Русский язык",
"Literature": "Литература",
"Physics": "Физика",
"History": "История",
"PhysicalEducation": "Физкультура",
"Biology": "Биология",
"Chemistry": "Химия",
"Geography": "География",
"Informatics": "Информатика",
"ForeignLanguage": "Иностранный язык",
"Agility": "Ловкость",
"Strength": "Сила",
"Endurance": "Выносливость",
"Diligent": "Усидчивый",
"AbsentMinded": "Рассеянный",
"Bully": "Задира",
"Quiet": "Тихоня",
"Leader": "Лидер",
"Lazy": "Лентяй",
"Curious": "Любопытный",
"HotTempered": "Вспыльчивый",
"Kind": "Добрый",
"Neat": "Аккуратный",
"Height": "Рост",
"Weight": "Вес",
"HairColor": "Цвет волос",
"EyeColor": "Цвет глаз",
"BlackHair": "Чёрные",
"BrownHair": "Каштановые",
"BlondHair": "Русые",
"RedHair": "Рыжие",
"GrayHair": "Седые",
"BrownEyes": "Карие",
"BlueEyes": "Голубые",
"GrayEyes": "Серые",
"GreenEyes": "Зелёные",
"HazelEyes": "Ореховые",
"Sleep": "Сон",
"Hunger": "Голод",
"Toilet": "Туалет",
"Social": "Общение",
"Slavic": "Славянский",
"Skinny": "Худощавое",
"Average": "Обычное",
"Athletic": "Атлетическое",
"Heavy": "Плотное",
"Obese": "Полное",
}
+1
View File
@@ -10,6 +10,7 @@ public enum SchoolCreationError
InvalidMap,
UnknownMod,
InvalidCatalog,
UnknownNameSet,
}
/// <summary>Outcome of <see cref="SchoolRegistry.Create"/>: either the school or the reason there is none.</summary>
@@ -157,6 +157,8 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.Equal("Кабинет директора", Assert.Single(ru.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("Principal's office", Assert.Single(en.Rooms, room => room.DefName == "PrincipalsOffice").Label);
Assert.Equal("yard", ru.DefaultMap.Territory?.Id);
Assert.Equal("Славянский", Assert.Single(ru.NameSets, set => set.DefName == "Slavic").Label);
Assert.Equal("Slavic", Assert.Single(en.NameSets, set => set.DefName == "Slavic").Label);
}
[Fact]
@@ -206,6 +208,26 @@ public class SchoolApiTests(AppHostFixture fixture)
Assert.Equal("unknown-mod", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithAnUnknownNameSet_IsRejected()
{
using var client = fixture.App.CreateHttpClient("server");
await ResetAsync(client);
using var response = await client.PostAsJsonAsync(
"/api/schools",
new
{
name = "Чужие имена",
startDate = ExpectedDefaultStart,
nameSetId = "NoSuchNames",
},
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Equal("unknown-name-set", await ProblemCodeAsync(response));
}
[Fact]
public async Task CreateSchool_WithACustomConnectedMap_Succeeds()
{
@@ -335,6 +357,7 @@ public class SchoolApiTests(AppHostFixture fixture)
IReadOnlyList<DefInfoResponse> Floors,
IReadOnlyList<RoomInfoResponse> Rooms,
IReadOnlyList<DefInfoResponse> Things,
IReadOnlyList<DefInfoResponse> NameSets,
MapLayoutResponse DefaultMap);
private sealed record DefInfoResponse(string DefName, string Label);
@@ -0,0 +1,260 @@
namespace HSchool.Content.Tests;
public class PeopleDefTests
{
private readonly CatalogLoader _loader = new();
[Fact]
public void VanillaCore_LoadsPeopleDefs()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
var catalog = _loader.Load([CatalogLoader.CorePackId], PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
Assert.True(catalog.Skills.Count >= 10);
Assert.Equal(10, catalog.Traits.Count);
Assert.Equal(4, catalog.Needs.Count);
Assert.True(catalog.BodyAttributes.ContainsKey("Height"));
Assert.Equal(BodyAttributeKind.Number, catalog.BodyAttributes["Height"].Kind);
Assert.Equal(BodyAttributeKind.Choice, catalog.BodyAttributes["HairColor"].Kind);
Assert.All(catalog.Needs.Values, need => Assert.Equal(0, need.DecayPerHour));
Assert.Contains(catalog.Skills["Agility"].BodyLimits, limit => limit.Attribute == "Build" && limit.Value == "Obese");
Assert.True(catalog.NameSets.ContainsKey("Slavic"));
Assert.True(catalog.NameSets["Slavic"].MaleGiven.Count >= 20);
Assert.Equal("Славянский", catalog.Label("ru", catalog.NameSets["Slavic"]));
Assert.Equal("Slavic", catalog.Label("en", catalog.NameSets["Slavic"]));
Assert.Equal("Усидчивый", catalog.Label("ru", catalog.Traits["Diligent"]));
}
[Fact]
public void Skill_InheritsRangeFromParent()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(
CatalogLoader.CorePackId,
"skills",
"base",
"""{ "defName": "AcademicBase", "abstract": true, "range": { "min": 0, "max": 100 }, "distribution": { "mean": 40, "stdDev": 10 } }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"skills",
"math",
"""{ "defName": "Mathematics", "parent": "AcademicBase", "distribution": { "mean": 55, "stdDev": 12 } }"""),
]);
Assert.Equal(0, catalog.Skills["Mathematics"].Range.Min);
Assert.Equal(100, catalog.Skills["Mathematics"].Range.Max);
Assert.Equal(55, catalog.Skills["Mathematics"].Distribution?.Mean);
Assert.False(catalog.Skills["Mathematics"].Abstract);
Assert.True(catalog.Skills["AcademicBase"].Abstract);
}
[Fact]
public void TraitPatch_AddsAnIncompatibility()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId, "addon"],
[
PackDocuments.Def(CatalogLoader.CorePackId, "skills", "math", """{ "defName": "Mathematics" }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"traits",
"kind",
"""{ "defName": "Kind", "weight": 1, "incompatible": [] }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"traits",
"bully",
"""{ "defName": "Bully", "weight": 1 }"""),
PackDocuments.Patch(
"addon",
"kind",
"""{ "target": "Kind", "ops": [ { "op": "add", "path": "/incompatible/-", "value": "Bully" } ] }"""),
]);
Assert.Equal(["Bully"], catalog.Traits["Kind"].Incompatible);
Assert.Contains("Bully", catalog.TraitIncompatibilities("Kind"));
Assert.Contains("Kind", catalog.TraitIncompatibilities("Bully"));
}
[Fact]
public void UnknownBodyLimitAttribute_FailsTheCatalog()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(
CatalogLoader.CorePackId,
"skills",
"agility",
"""{ "defName": "Agility", "bodyLimits": [ { "attribute": "Missing", "value": "X", "max": 10 } ] }"""),
]));
Assert.Contains("Missing", ex.Message);
}
[Fact]
public void UnknownBuildValue_FailsTheCatalog()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(
CatalogLoader.CorePackId,
"skills",
"agility",
"""{ "defName": "Agility", "bodyLimits": [ { "attribute": "Build", "value": "Huge", "max": 10 } ] }"""),
]));
Assert.Contains("Huge", ex.Message);
}
[Fact]
public void UnknownTraitIncompatibility_FailsTheCatalog()
{
var ex = Assert.Throws<ContentLoadException>(() => _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(
CatalogLoader.CorePackId,
"traits",
"kind",
"""{ "defName": "Kind", "incompatible": ["Ghost"] }"""),
]));
Assert.Contains("Ghost", ex.Message);
}
[Fact]
public void OneWayIncompatibility_IsMutual()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId],
[
PackDocuments.Def(CatalogLoader.CorePackId, "traits", "a", """{ "defName": "Diligent", "weight": 1, "incompatible": ["Lazy"] }"""),
PackDocuments.Def(CatalogLoader.CorePackId, "traits", "b", """{ "defName": "Lazy", "weight": 1 }"""),
]);
Assert.Equal(["Lazy"], catalog.Traits["Diligent"].Incompatible);
Assert.Empty(catalog.Traits["Lazy"].Incompatible);
Assert.Contains("Lazy", catalog.TraitIncompatibilities("Diligent"));
Assert.Contains("Diligent", catalog.TraitIncompatibilities("Lazy"));
}
[Fact]
public void ExtraPack_AddsANameSetAndTrait()
{
var catalog = _loader.Load(
[CatalogLoader.CorePackId, "addon"],
[
PackDocuments.Def(CatalogLoader.CorePackId, "traits", "kind", """{ "defName": "Kind", "weight": 1 }"""),
PackDocuments.Def(
CatalogLoader.CorePackId,
"namesets",
"slavic",
"""
{
"defName": "Slavic",
"maleGiven": [{ "form": "Иван" }],
"femaleGiven": [{ "form": "Анна", "declension": "a" }],
"surnames": [{ "male": "Иванов", "female": "Иванова" }]
}
"""),
PackDocuments.Def("addon", "traits", "stoic", """{ "defName": "Stoic", "weight": 3 }"""),
PackDocuments.Def("addon", "traits", "cheerful", """{ "defName": "Cheerful", "weight": 2 }"""),
PackDocuments.Def(
"addon",
"namesets",
"nordic",
"""
{
"defName": "Nordic",
"maleGiven": [{ "form": "Lars", "declension": "indeclinable" }],
"femaleGiven": [{ "form": "Ingrid", "declension": "indeclinable" }],
"surnames": [{ "male": "Berg", "female": "Berg", "declension": "indeclinable" }]
}
"""),
PackDocuments.Locale("addon", "ru", """{ "Nordic": "Северный", "Stoic": "Стоик", "Cheerful": "Жизнерадостный" }"""),
]);
Assert.True(catalog.NameSets.ContainsKey("Nordic"));
Assert.True(catalog.Traits.ContainsKey("Stoic"));
Assert.True(catalog.Traits.ContainsKey("Cheerful"));
Assert.Equal("Северный", catalog.Label("ru", catalog.NameSets["Nordic"]));
Assert.Equal("Стоик", catalog.Label("ru", catalog.Traits["Stoic"]));
}
}
public class NameGrammarTests
{
[Fact]
public void HardGiven_FollowsTheConsonantModel()
{
var entry = new GivenNameEntry { Form = "Иван", Declension = NameGrammar.Hard };
Assert.Equal("Иван", NameGrammar.InflectGiven(entry, GrammaticalCase.Nominative, NameGrammar.Hard));
Assert.Equal("Ивана", NameGrammar.InflectGiven(entry, GrammaticalCase.Genitive, NameGrammar.Hard));
Assert.Equal("Ивану", NameGrammar.InflectGiven(entry, GrammaticalCase.Dative, NameGrammar.Hard));
Assert.Equal("Иваном", NameGrammar.InflectGiven(entry, GrammaticalCase.Instrumental, NameGrammar.Hard));
}
[Fact]
public void ExplicitTable_OverridesTheModel()
{
var entry = new GivenNameEntry
{
Form = "Любовь",
Declension = NameGrammar.Soft,
Cases = new CaseTable
{
Nom = "Любовь",
Gen = "Любови",
Dat = "Любови",
Acc = "Любовь",
Ins = "Любовью",
Pre = "Любови",
},
};
Assert.Equal("Любови", NameGrammar.InflectGiven(entry, GrammaticalCase.Genitive, NameGrammar.Soft));
Assert.Equal("Любовью", NameGrammar.InflectGiven(entry, GrammaticalCase.Instrumental, NameGrammar.Soft));
Assert.Equal("Любовя", NameGrammar.InflectGivenForm("Любовь", NameGrammar.Soft, GrammaticalCase.Genitive));
}
[Fact]
public void OvSurname_UsesGenderedEndings()
{
var entry = new SurnameEntry { Male = "Иванов", Female = "Иванова", Declension = NameGrammar.Ov };
Assert.Equal("Иванова", NameGrammar.InflectSurname(entry, female: false, GrammaticalCase.Genitive, NameGrammar.Ov));
Assert.Equal("Ивановой", NameGrammar.InflectSurname(entry, female: true, GrammaticalCase.Genitive, NameGrammar.Ov));
Assert.Equal("Иванову", NameGrammar.InflectSurname(entry, female: true, GrammaticalCase.Accusative, NameGrammar.Ov));
}
[Fact]
public void SlavicPatronymic_FromTheFathersName()
{
Assert.Equal("Иванович", NameGrammar.Patronymic("Иван", female: false, NameGrammar.SlavicPatronymic));
Assert.Equal("Ивановна", NameGrammar.Patronymic("Иван", female: true, NameGrammar.SlavicPatronymic));
Assert.Equal("Андреевич", NameGrammar.Patronymic("Андрей", female: false, NameGrammar.SlavicPatronymic));
Assert.Equal("Дмитриевич", NameGrammar.Patronymic("Дмитрий", female: false, NameGrammar.SlavicPatronymic));
Assert.Equal("Ильинична", NameGrammar.Patronymic("Илья", female: true, NameGrammar.SlavicPatronymic));
}
[Fact]
public void BodyBuild_ComesFromBmiBands()
{
Assert.Equal(BodyBuilds.Skinny, BodyBuilds.FromHeightAndWeight(170, 50));
Assert.Equal(BodyBuilds.Athletic, BodyBuilds.FromHeightAndWeight(170, 68));
Assert.Equal(BodyBuilds.Obese, BodyBuilds.FromHeightAndWeight(170, 90));
}
[Fact]
public void Patronymic_InflectsLikeAHardOrAStem()
{
Assert.Equal("Ивановича", NameGrammar.InflectPatronymic("Иванович", female: false, GrammaticalCase.Genitive));
Assert.Equal("Ивановны", NameGrammar.InflectPatronymic("Ивановна", female: true, GrammaticalCase.Genitive));
Assert.Equal("Ивановну", NameGrammar.InflectPatronymic("Ивановна", female: true, GrammaticalCase.Accusative));
}
}
+78
View File
@@ -0,0 +1,78 @@
namespace HSchool.People.Tests;
internal static class PackDocuments
{
public static IReadOnlyList<ContentDocument> FromDirectory(string packId, string packRoot)
{
var documents = new List<ContentDocument>();
foreach (var path in Directory.EnumerateFiles(packRoot, "*.*", SearchOption.AllDirectories))
{
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
continue;
}
var relative = Path.GetRelativePath(packRoot, path).Replace('\\', '/');
documents.Add(new ContentDocument(packId, relative, File.ReadAllText(path)));
}
return documents;
}
}
internal static class Fixtures
{
public static readonly DateTime AsOf = RosterGenerator.DefaultAsOf;
public const int SchoolSeed = 20260818;
public static DefCatalog Catalog()
{
var root = Path.Combine(AppContext.BaseDirectory, "vanilla");
return new CatalogLoader().Load(
[CatalogLoader.CorePackId],
PackDocuments.FromDirectory(CatalogLoader.CorePackId, root));
}
public static MapLayout Classrooms(int count, int desks = 16)
{
var rooms = new RoomNode[count];
for (var i = 0; i < count; i++)
{
rooms[i] = new RoomNode
{
Id = $"classroom-{i:00}",
Def = "Classroom",
Building = "main",
Floor = "floor-1",
Label = $"{101 + i}",
Slots =
[
new SlotFill { Key = "studentDesks", Thing = "StudentDesk", Count = desks },
],
};
}
return new MapLayout { Rooms = rooms };
}
public static Roster Generate(MapLayout map, int seed = SchoolSeed) =>
RosterGenerator.Generate(Catalog(), map, seed, "Slavic", AsOf);
public static string RepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "h-school.sln")))
{
dir = dir.Parent;
}
if (dir is null)
{
throw new InvalidOperationException("Could not find h-school.sln from the test output directory.");
}
return dir.FullName;
}
}
@@ -0,0 +1,2 @@
global using HSchool.Content;
global using HSchool.People;
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.People.Tests</RootNamespace>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\HSchool.People\HSchool.People.csproj" />
<ProjectReference Include="..\..\src\HSchool.Content\HSchool.Content.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<Content Include="..\..\src\HSchool.Server\mods\core\**\*">
<Link>vanilla\%(RecursiveDir)%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,153 @@
namespace HSchool.People.Tests;
public class RosterGeneratorTests
{
[Fact]
public void SameSeedMapAndNameSet_YieldTheSameRoster()
{
var map = Fixtures.Classrooms(4);
var a = Fixtures.Generate(map);
var b = Fixtures.Generate(map);
Assert.Equal(Snapshot(a), Snapshot(b));
}
[Fact]
public void ThirteenthFamily_DoesNotChangeTheFirstTwelve()
{
var twelve = FamilySnapshots(Fixtures.Generate(Fixtures.Classrooms(4)), take: 12);
var thirteen = FamilySnapshots(Fixtures.Generate(Fixtures.Classrooms(5)), take: 12);
Assert.Equal(12, twelve.Count);
Assert.Equal(twelve, thirteen);
}
[Fact]
public void FamilyNames_ShareSurnameAndPatronymicFromTheFather()
{
var roster = Fixtures.Generate(Fixtures.Classrooms(4));
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
foreach (var family in roster.Families)
{
if (family.ChildIds.Count == 0)
{
continue;
}
var father = people[family.ParentIds[0]];
var mother = people[family.ParentIds[1]];
Assert.False(father.Female);
Assert.True(mother.Female);
foreach (var childId in family.ChildIds)
{
var child = people[childId];
Assert.Equal(
NameGrammar.Patronymic(father.Name.Given, child.Female, NameGrammar.SlavicPatronymic),
child.Name.Patronymic);
Assert.Equal(child.Female ? mother.Name.Surname : father.Name.Surname, child.Name.Surname);
Assert.Equal(father.Name.SurnameCases.Nom, father.Name.Surname);
Assert.Equal(mother.Name.SurnameCases.Nom, mother.Name.Surname);
}
}
}
[Fact]
public void ObesePupil_CannotHaveHighAgility()
{
var agility = Fixtures.Catalog().Skills["Agility"];
var clamped = PersonSampler.ApplyBodyLimits(
80,
agility,
new Dictionary<string, string> { [BodyBuilds.Attribute] = BodyBuilds.Obese });
Assert.Equal(25, clamped);
var roster = Fixtures.Generate(Fixtures.Classrooms(11));
var obesePupils = roster.People.Where(person =>
person.IsStudent
&& person.Choices.TryGetValue(BodyBuilds.Attribute, out var build)
&& build == BodyBuilds.Obese);
Assert.All(
obesePupils,
person => Assert.True(
person.Skills["Agility"] <= 25,
$"{person.Name.Surname} {person.Name.Given} is obese with agility {person.Skills["Agility"]}."));
}
[Fact]
public void OneClassroomAndEleven_BothFillSeatsAndJobs()
{
AssertFilled(Fixtures.Generate(Fixtures.Classrooms(1)), classrooms: 1);
AssertFilled(Fixtures.Generate(Fixtures.Classrooms(11)), classrooms: 11);
}
[Fact]
public void SomeStaffAreAlsoParents()
{
var roster = Fixtures.Generate(Fixtures.Classrooms(4));
Assert.Contains(roster.People, person => person.IsStaff && person.IsParent);
}
[Fact]
public void Assembly_DoesNotReferenceArchAspNetOrSockets()
{
var names = typeof(RosterGenerator).Assembly.GetReferencedAssemblies().Select(assembly => assembly.Name!);
Assert.DoesNotContain(names, name => name.StartsWith("Arch", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(names, name => name.Contains("AspNet", StringComparison.OrdinalIgnoreCase));
Assert.DoesNotContain(names, name => name.Contains("Sockets", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Sources_DoNotUseWallClock()
{
var root = Path.Combine(Fixtures.RepoRoot(), "src", "HSchool.People");
foreach (var path in Directory.EnumerateFiles(root, "*.cs"))
{
var text = File.ReadAllText(path);
Assert.DoesNotContain("DateTime.Now", text, StringComparison.Ordinal);
Assert.DoesNotContain("DateTime.UtcNow", text, StringComparison.Ordinal);
}
}
private static void AssertFilled(Roster roster, int classrooms)
{
Assert.Equal(classrooms, roster.Classes.Count);
Assert.Equal(classrooms * 16, roster.People.Count(person => person.IsStudent));
Assert.All(roster.Classes, schoolClass =>
{
Assert.Equal(schoolClass.Capacity, schoolClass.PupilIds.Count);
Assert.InRange(schoolClass.Year, 1, 11);
});
var demand = SchoolDemand.From(Fixtures.Catalog(), Fixtures.Classrooms(classrooms));
Assert.Equal(demand.Staff.Count, roster.People.Count(person => person.IsStaff));
Assert.All(demand.Staff, opening =>
Assert.Contains(
roster.People,
person => person.IsStaff && person.Position == opening.Position && person.WorkplaceRoomId == opening.RoomId));
}
private static string Snapshot(Roster roster) =>
string.Join('\n', roster.People.Select(person =>
$"{person.Id}|{person.FamilyId}|{person.Female}|{person.BirthDate:O}|{person.Name.Given}|{person.Name.Surname}|{person.Name.Patronymic}|{person.IsStudent}|{person.IsStaff}|{person.IsParent}|{person.ClassId}|{person.Position}|{person.Choices[BodyBuilds.Attribute]}|{Skills(person)}|{string.Join(',', person.Traits)}"));
private static string Skills(Person person) =>
string.Join(',', person.Skills.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => $"{pair.Key}={pair.Value}"));
private static List<string> FamilySnapshots(Roster roster, int take)
{
var people = roster.People.ToDictionary(person => person.Id, StringComparer.Ordinal);
return roster.Families
.OrderBy(family => family.Id, StringComparer.Ordinal)
.Take(take)
.Select(family =>
{
var members = family.ParentIds.Concat(family.ChildIds).Select(id => people[id]);
return string.Join(';', members.Select(person =>
$"{person.Id}:{person.Name.Surname} {person.Name.Given} {person.Name.Patronymic}:{person.BirthDate:O}:{person.Female}:{Skills(person)}"));
})
.ToList();
}
}