diff --git a/AGENTS.md b/AGENTS.md index d8b881f..3c95c60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/docs/design/people.md b/docs/design/people.md new file mode 100644 index 0000000..a292684 --- /dev/null +++ b/docs/design/people.md @@ -0,0 +1,335 @@ +# Люди: генерация, характеристики, просмотр + +Договорённость на следующий срез, не текущий код. +Типы и карта: [`defs.md`](defs.md). Экран игрока: [`near-term.md`](near-term.md). +Потоки: [`runtime.md`](runtime.md). Проекты: [`projects.md`](projects.md). + +Школа перестаёт быть пустой коробкой. Появляются ученики, работники и родители: у каждого имя, +тело, навыки, черты и нужды. **Ничего из этого пока ни на что не влияет** — люди не ходят, не +работают и не учатся. Срез отвечает на вопрос «кто в этой школе», а не «что они делают». + +Тот же принцип, что и с картой: **код — системы, содержание — данные**. Новый навык, черта, цвет +волос или набор имён появляются файлом в `mods//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`, пишется при изменении состава | diff --git a/docs/design/projects.md b/docs/design/projects.md index 6b35acf..812e001 100644 --- a/docs/design/projects.md +++ b/docs/design/projects.md @@ -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 с картой, рестарт, сейв на диске. diff --git a/docs/phases/05-people-defs.md b/docs/phases/05-people-defs.md new file mode 100644 index 0000000..84e1153 --- /dev/null +++ b/docs/phases/05-people-defs.md @@ -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, сервер сверх выдачи каталога и клиент сверх выбора набора. diff --git a/docs/phases/06-people-generator.md b/docs/phases/06-people-generator.md new file mode 100644 index 0000000..6e33114 --- /dev/null +++ b/docs/phases/06-people-generator.md @@ -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`. Не трогать сервер, клиент и формат сейва. diff --git a/docs/phases/07-people-in-school.md b/docs/phases/07-people-in-school.md new file mode 100644 index 0000000..8dccceb --- /dev/null +++ b/docs/phases/07-people-in-school.md @@ -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 списка людей. Никакого поведения: люди существуют и стоят. diff --git a/docs/phases/08-people-browser.md b/docs/phases/08-people-browser.md new file mode 100644 index 0000000..eb3e19a --- /dev/null +++ b/docs/phases/08-people-browser.md @@ -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` проходят + +## Стоп + +Не редактировать людей. Не нанимать и не увольнять руками. Не показывать характеристики в списке — +только в карточке. diff --git a/docs/phases/09-yearly-intake.md b/docs/phases/09-yearly-intake.md new file mode 100644 index 0000000..1444758 --- /dev/null +++ b/docs/phases/09-yearly-intake.md @@ -0,0 +1,34 @@ +# Фаза 9. Годовой набор + +## Зависимости + +- [Фаза 7](07-people-in-school.md) + +## Зачем + +Состав перестаёт быть слепком момента создания. Первого сентября классы переходят в следующую +параллель, старший выпускается, приходит новый первый. + +## Задачи + +- [ ] Переход по игровому календарю на 1 сентября: у каждого класса параллель +1 +- [ ] Выпуск **старшей имеющейся** параллели — школа из четырёх кабинетов не ждёт одиннадцатого + класса, чтобы кого-то выпустить +- [ ] Освободившийся кабинет достаётся новому первому классу; вместимость школы не меняется — + её меняет только стройка +- [ ] Набор первого класса тем же генератором и теми же семьями: у нового ученика может + оказаться брат или сестра в старшей параллели +- [ ] Выпускники уходят из ростера; их родители уходят тоже, если не осталось других детей в + школе и они в ней не работают +- [ ] Ростер переписывается один раз за переход, снимок публикуется заново — не по тику + +## Критерий готовности + +- Перемотать год на ×4: параллели сдвинулись, прежний выпускной класс исчез, появился новый первый +- Вместимость школы до и после перехода совпадает +- Перезапуск сервера после перехода поднимает новый состав +- Тест на школу без одиннадцатой параллели: выпускается старшая имеющаяся + +## Стоп + +Не второгодники. Не переводы между школами. Не увольнения и наём работников. diff --git a/docs/phases/README.md b/docs/phases/README.md index 535e639..8a72e9d 100644 --- a/docs/phases/README.md +++ b/docs/phases/README.md @@ -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) | ⬜ | Первое сентября: переход, выпуск, набор | diff --git a/docs/protocol.md b/docs/protocol.md index 00b8781..64c477c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -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. diff --git a/h-school.sln b/h-school.sln index 54c64fe..e077706 100644 --- a/h-school.sln +++ b/h-school.sln @@ -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} diff --git a/src/HSchool.Client/src/i18n/strings.ts b/src/HSchool.Client/src/i18n/strings.ts index b0408ac..d9c6aee 100644 --- a/src/HSchool.Client/src/i18n/strings.ts +++ b/src/HSchool.Client/src/i18n/strings.ts @@ -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', diff --git a/src/HSchool.Client/src/net/api.ts b/src/HSchool.Client/src/net/api.ts index cb482de..f14d6c7 100644 --- a/src/HSchool.Client/src/net/api.ts +++ b/src/HSchool.Client/src/net/api.ts @@ -44,6 +44,7 @@ export async function fetchRandomName(lang: string): Promise { 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 { diff --git a/src/HSchool.Client/src/ui/createSchoolDialog.ts b/src/HSchool.Client/src/ui/createSchoolDialog.ts index d44017c..53a38b7 100644 --- a/src/HSchool.Client/src/ui/createSchoolDialog.ts +++ b/src/HSchool.Client/src/ui/createSchoolDialog.ts @@ -32,12 +32,20 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise(); 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 { 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 => { try { applyCatalog(await fetchCatalog(getLocale(), [...extraModIds])); @@ -168,6 +192,7 @@ export function createSchoolDialog(options: CreateSchoolOptions): Promise { 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 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; } diff --git a/src/HSchool.Content/CatalogLoader.cs b/src/HSchool.Content/CatalogLoader.cs index 721a23c..61e248e 100644 --- a/src/HSchool.Content/CatalogLoader.cs +++ b/src/HSchool.Content/CatalogLoader.cs @@ -302,6 +302,11 @@ public sealed class CatalogLoader var buildings = new Dictionary(StringComparer.Ordinal); var floors = new Dictionary(StringComparer.Ordinal); var territories = new Dictionary(StringComparer.Ordinal); + var skills = new Dictionary(StringComparer.Ordinal); + var traits = new Dictionary(StringComparer.Ordinal); + var bodyAttributes = new Dictionary(StringComparer.Ordinal); + var needs = new Dictionary(StringComparer.Ordinal); + var nameSets = new Dictionary(StringComparer.Ordinal); foreach (var (key, json) in resolved) { @@ -331,6 +336,21 @@ public sealed class CatalogLoader case DefKind.Territory: territories[key.Name] = Jsonc.Deserialize(json); break; + case DefKind.Skill: + skills[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.Trait: + traits[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.BodyAttribute: + bodyAttributes[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.Need: + needs[key.Name] = Jsonc.Deserialize(json); + break; + case DefKind.NameSet: + nameSets[key.Name] = Jsonc.Deserialize(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); diff --git a/src/HSchool.Content/DefCatalog.cs b/src/HSchool.Content/DefCatalog.cs index 0173cec..5af2626 100644 --- a/src/HSchool.Content/DefCatalog.cs +++ b/src/HSchool.Content/DefCatalog.cs @@ -16,6 +16,11 @@ public sealed class DefCatalog IReadOnlyDictionary buildings, IReadOnlyDictionary floors, IReadOnlyDictionary territories, + IReadOnlyDictionary skills, + IReadOnlyDictionary traits, + IReadOnlyDictionary bodyAttributes, + IReadOnlyDictionary needs, + IReadOnlyDictionary nameSets, IReadOnlyDictionary ru, IReadOnlyDictionary 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 Territories { get; } + public IReadOnlyDictionary Skills { get; } + + public IReadOnlyDictionary Traits { get; } + + public IReadOnlyDictionary BodyAttributes { get; } + + public IReadOnlyDictionary Needs { get; } + + public IReadOnlyDictionary NameSets { get; } + private readonly IReadOnlyDictionary _ru; private readonly IReadOnlyDictionary _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)), }; + + /// Incompatibilities written on either trait — a one-way list is mutual. + public IReadOnlySet TraitIncompatibilities(string defName) + { + var set = new HashSet(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; + } } diff --git a/src/HSchool.Content/Defs.cs b/src/HSchool.Content/Defs.cs index 24864cc..62eaf5c 100644 --- a/src/HSchool.Content/Defs.cs +++ b/src/HSchool.Content/Defs.cs @@ -10,6 +10,11 @@ public enum DefKind Building, Floor, Territory, + Skill, + Trait, + BodyAttribute, + Need, + NameSet, } /// Shared JSONC fields. Kind comes from the folder under defs/, not from the file. diff --git a/src/HSchool.Content/Jsonc.cs b/src/HSchool.Content/Jsonc.cs index 69f2c73..eb23cb5 100644 --- a/src/HSchool.Content/Jsonc.cs +++ b/src/HSchool.Content/Jsonc.cs @@ -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() diff --git a/src/HSchool.Content/NameGrammar.cs b/src/HSchool.Content/NameGrammar.cs new file mode 100644 index 0000000..652b99c --- /dev/null +++ b/src/HSchool.Content/NameGrammar.cs @@ -0,0 +1,302 @@ +namespace HSchool.Content; + +public enum GrammaticalCase +{ + Nominative, + Genitive, + Dative, + Accusative, + Instrumental, + Prepositional, +} + +/// +/// How a written name becomes six cases. Models live here; a on the +/// entry wins when the rule is wrong. People generation calls this; Content tests pin it. +/// +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 GivenModels = [Hard, Soft, A, Ya, Iya, Indeclinable]; + public static readonly IReadOnlyList SurnameModels = [Ov, In, Sky, Indeclinable]; + public static readonly IReadOnlyList 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 + "ович"; + } +} diff --git a/src/HSchool.Content/PackPaths.cs b/src/HSchool.Content/PackPaths.cs index 79bf6f8..f2447fa 100644 --- a/src/HSchool.Content/PackPaths.cs +++ b/src/HSchool.Content/PackPaths.cs @@ -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; diff --git a/src/HSchool.Content/PeopleDefValidator.cs b/src/HSchool.Content/PeopleDefValidator.cs new file mode 100644 index 0000000..c1b3792 --- /dev/null +++ b/src/HSchool.Content/PeopleDefValidator.cs @@ -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(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 min–max."); + } + + 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."); + } + } +} diff --git a/src/HSchool.Content/PeopleDefs.cs b/src/HSchool.Content/PeopleDefs.cs new file mode 100644 index 0000000..4a3af34 --- /dev/null +++ b/src/HSchool.Content/PeopleDefs.cs @@ -0,0 +1,251 @@ +namespace HSchool.Content; + +/// Roles the generator and traits talk about. Code, not a def — new roles are a rebuild. +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); +} + +/// +/// Derived body type, not a . Skill limits may name it the same +/// way they name hair colour; the generator computes it from height and weight. +/// +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 Values = + [Skinny, Average, Athletic, Heavy, Obese]; + + public static bool IsKnown(string value) => + Values.Any(candidate => candidate.Equals(value, StringComparison.Ordinal)); + + /// + /// WHO-ish bands with a fit slice in the healthy range so Athletic is derived, not rolled. + /// Height in centimetres, weight in kilograms. + /// + 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 AgeMeans { get; init; } = []; + + public IReadOnlyList 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 Incompatible { get; init; } = []; + + /// Empty means every role. Values are ids. + public IReadOnlyList Roles { get; init; } = []; + + public IntRange? Age { get; init; } + + public IReadOnlyList SkillModifiers { get; init; } = []; +} + +public enum BodyAttributeKind +{ + Number, + Choice, +} + +public sealed class SexAgeDistribution +{ + /// male, female, or omit for both. + 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 Distributions { get; init; } = []; + + public IReadOnlyList 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 MaleGiven { get; init; } = []; + + public IReadOnlyList FemaleGiven { get; init; } = []; + + public IReadOnlyList Surnames { get; init; } = []; +} diff --git a/src/HSchool.People/FamilyFactory.cs b/src/HSchool.People/FamilyFactory.cs new file mode 100644 index 0000000..6f439ac --- /dev/null +++ b/src/HSchool.People/FamilyFactory.cs @@ -0,0 +1,298 @@ +namespace HSchool.People; + +internal static class FamilyFactory +{ + public static (Family Family, List 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(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(); + 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(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 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 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 pool, + Random rng, + HashSet? 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); +} diff --git a/src/HSchool.People/FamilyPlanner.cs b/src/HSchool.People/FamilyPlanner.cs new file mode 100644 index 0000000..bffbbce --- /dev/null +++ b/src/HSchool.People/FamilyPlanner.cs @@ -0,0 +1,47 @@ +namespace HSchool.People; + +internal readonly record struct FamilyPlan(int FamilyIndex, IReadOnlyList Seats); + +/// +/// Walks seats in order and groups them into families of 1–3. A short remainder becomes +/// singleton families rather than shrinking an earlier family's intended size, so a longer +/// seat list only appends families. +/// +internal static class FamilyPlanner +{ + public static IReadOnlyList Plan(int schoolSeed, IReadOnlyList seats) + { + var plans = new List(); + 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; + } +} diff --git a/src/HSchool.People/GlobalUsings.cs b/src/HSchool.People/GlobalUsings.cs new file mode 100644 index 0000000..358dd5a --- /dev/null +++ b/src/HSchool.People/GlobalUsings.cs @@ -0,0 +1 @@ +global using HSchool.Content; diff --git a/src/HSchool.People/HSchool.People.csproj b/src/HSchool.People/HSchool.People.csproj new file mode 100644 index 0000000..96fac25 --- /dev/null +++ b/src/HSchool.People/HSchool.People.csproj @@ -0,0 +1,15 @@ + + + + HSchool.People + + + + + + + + + + + diff --git a/src/HSchool.People/PersonSampler.cs b/src/HSchool.People/PersonSampler.cs new file mode 100644 index 0000000..81a97ce --- /dev/null +++ b/src/HSchool.People/PersonSampler.cs @@ -0,0 +1,354 @@ +namespace HSchool.People; + +internal static class PersonSampler +{ + public static (Dictionary Numbers, Dictionary Choices) Body( + DefCatalog catalog, + Random rng, + bool female, + int age) + { + var numbers = new Dictionary(StringComparer.Ordinal); + var choices = new Dictionary(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 Skills( + DefCatalog catalog, + Random rng, + int age, + IReadOnlyDictionary choices, + IReadOnlyList traits) + { + var values = new Dictionary(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 Traits(DefCatalog catalog, Random rng, IReadOnlyList roles, int age) + { + var picked = new List(); + 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 Needs(DefCatalog catalog) + { + var values = new Dictionary(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 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 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 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 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); + } +} diff --git a/src/HSchool.People/Roster.cs b/src/HSchool.People/Roster.cs new file mode 100644 index 0000000..aa8c064 --- /dev/null +++ b/src/HSchool.People/Roster.cs @@ -0,0 +1,65 @@ +namespace HSchool.People; + +/// Plain roster data. Simulation turns these into entities; this project does not. +public sealed record Roster( + IReadOnlyList People, + IReadOnlyList Families, + IReadOnlyList 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 Numbers { get; init; } + + public required IReadOnlyDictionary Choices { get; init; } + + public required IReadOnlyDictionary Skills { get; init; } + + public required IReadOnlyList Traits { get; init; } + + public required IReadOnlyDictionary 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 ParentIds, + IReadOnlyList ChildIds); + +public sealed record SchoolClass( + string Id, + int Year, + string Letter, + string RoomId, + int Capacity, + IReadOnlyList PupilIds); diff --git a/src/HSchool.People/RosterGenerator.cs b/src/HSchool.People/RosterGenerator.cs new file mode 100644 index 0000000..0b12afe --- /dev/null +++ b/src/HSchool.People/RosterGenerator.cs @@ -0,0 +1,127 @@ +namespace HSchool.People; + +/// +/// Builds a roster from a catalog, a map and a school seed. Deterministic: same inputs, same people. +/// +public static class RosterGenerator +{ + /// + /// Matches SimulationOptions.DefaultStartDate so tests without a clock still sit in + /// the default school year. Callers with a live clock must pass . + /// + 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(); + var families = new List(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 AssignStaff(List people, IReadOnlyList openings) + { + if (openings.Count == 0) + { + return people; + } + + var jobs = new Dictionary(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 FillClasses( + IReadOnlyList classes, + IReadOnlyList people) + { + var pupils = new Dictionary>(StringComparer.Ordinal); + foreach (var schoolClass in classes) + { + pupils[schoolClass.Id] = new List(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; + } +} diff --git a/src/HSchool.People/SchoolDemand.cs b/src/HSchool.People/SchoolDemand.cs new file mode 100644 index 0000000..f6c97fe --- /dev/null +++ b/src/HSchool.People/SchoolDemand.cs @@ -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); + +/// +/// How many pupils and staff a map asks for. Classrooms are rooms with pupil slots; each one +/// becomes a roster class. Positions come from , not the wire labels. +/// +public sealed class SchoolDemand +{ + internal SchoolDemand( + IReadOnlyList classes, + IReadOnlyList seats, + IReadOnlyList staff) + { + Classes = classes; + Seats = seats; + Staff = staff; + } + + public IReadOnlyList Classes { get; } + + public IReadOnlyList Seats { get; } + + public IReadOnlyList Staff { get; } + + public static SchoolDemand From(DefCatalog catalog, MapLayout map) + { + var classrooms = new List<(RoomNode Room, int Slots)>(); + var staff = new List(); + + 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(); + 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); + } +} diff --git a/src/HSchool.People/SchoolYears.cs b/src/HSchool.People/SchoolYears.cs new file mode 100644 index 0000000..6d7ae16 --- /dev/null +++ b/src/HSchool.People/SchoolYears.cs @@ -0,0 +1,43 @@ +namespace HSchool.People; + +/// +/// Game-calendar helpers. Takes the caller's date so this library never reads the wall clock. +/// +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); + } + + /// + /// Grade N pupils turned N+6 on or before 1 September of the current school year. + /// + 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); + } +} diff --git a/src/HSchool.People/Seed.cs b/src/HSchool.People/Seed.cs new file mode 100644 index 0000000..627dfcf --- /dev/null +++ b/src/HSchool.People/Seed.cs @@ -0,0 +1,27 @@ +namespace HSchool.People; + +/// +/// 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. +/// +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); + } +} diff --git a/src/HSchool.Server/Api/ModEndpoints.cs b/src/HSchool.Server/Api/ModEndpoints.cs index 7ecffa5..72bfd71 100644 --- a/src/HSchool.Server/Api/ModEndpoints.cs +++ b/src/HSchool.Server/Api/ModEndpoints.cs @@ -86,6 +86,7 @@ internal sealed record CatalogResponse( IReadOnlyList Floors, IReadOnlyList Rooms, IReadOnlyList Things, + IReadOnlyList 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 Placeable(IEnumerable defs, DefCatalog catalog, string locale) diff --git a/src/HSchool.Server/Api/SchoolEndpoints.cs b/src/HSchool.Server/Api/SchoolEndpoints.cs index 4d4be5b..6c04bbd 100644 --- a/src/HSchool.Server/Api/SchoolEndpoints.cs +++ b/src/HSchool.Server/Api/SchoolEndpoints.cs @@ -50,6 +50,7 @@ internal static class SchoolEndpoints DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc), request.ModIds, request.Map, + request.NameSetId, NewCompletion()); 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 } /// Body of POST /api/schools. The start date is a game calendar date, not a real one. -internal sealed record CreateSchoolRequest(string? Name, DateTime StartDate, IReadOnlyList? ModIds, MapLayout? Map); +internal sealed record CreateSchoolRequest( + string? Name, + DateTime StartDate, + IReadOnlyList? ModIds, + MapLayout? Map, + string? NameSetId); internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex) { diff --git a/src/HSchool.Server/Game/GameCommand.cs b/src/HSchool.Server/Game/GameCommand.cs index 4481608..0fa323e 100644 --- a/src/HSchool.Server/Game/GameCommand.cs +++ b/src/HSchool.Server/Game/GameCommand.cs @@ -14,6 +14,7 @@ internal abstract record GameCommand DateTime StartDate, IReadOnlyList? ExtraModIds, MapLayout? Map, + string? NameSetId, TaskCompletionSource Result) : GameCommand; internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource Result) : GameCommand; diff --git a/src/HSchool.Server/Game/GameLoopService.cs b/src/HSchool.Server/Game/GameLoopService.cs index c5130a1..5afd8ea 100644 --- a/src/HSchool.Server/Game/GameLoopService.cs +++ b/src/HSchool.Server/Game/GameLoopService.cs @@ -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? 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}")); + /// + /// 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. + /// + 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; diff --git a/src/HSchool.Server/Game/SchoolStore.cs b/src/HSchool.Server/Game/SchoolStore.cs index 40844d7..8cb7967 100644 --- a/src/HSchool.Server/Game/SchoolStore.cs +++ b/src/HSchool.Server/Game/SchoolStore.cs @@ -23,6 +23,8 @@ internal sealed class SchoolSave public IReadOnlyList? ModIds { get; init; } public MapLayout? Map { get; init; } + + public string? NameSetId { get; init; } } /// Allocates school ids that survive a process restart. @@ -152,6 +154,7 @@ internal sealed class SchoolStore SpeedIndex = save.SpeedIndex, ModIds = save.ModIds, Map = save.Map, + NameSetId = save.NameSetId, }); } catch (Exception ex) diff --git a/src/HSchool.Server/Game/SchoolWorker.cs b/src/HSchool.Server/Game/SchoolWorker.cs index b6851b5..556a79a 100644 --- a/src/HSchool.Server/Game/SchoolWorker.cs +++ b/src/HSchool.Server/Game/SchoolWorker.cs @@ -29,6 +29,7 @@ internal sealed class SchoolWorker private readonly bool _isNew; private readonly IReadOnlyList? _modIds; private readonly MapLayout? _savedMap; + private readonly string? _nameSetId; private readonly Action _onFailed; private readonly int _id; @@ -53,6 +54,7 @@ internal sealed class SchoolWorker bool isNew, IReadOnlyList? 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) diff --git a/src/HSchool.Server/mods/core/defs/bodies/eyes.jsonc b/src/HSchool.Server/mods/core/defs/bodies/eyes.jsonc new file mode 100644 index 0000000..51ca751 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/bodies/eyes.jsonc @@ -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 }, + ], +} diff --git a/src/HSchool.Server/mods/core/defs/bodies/hair.jsonc b/src/HSchool.Server/mods/core/defs/bodies/hair.jsonc new file mode 100644 index 0000000..2c9b4e6 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/bodies/hair.jsonc @@ -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 }, + ], +} diff --git a/src/HSchool.Server/mods/core/defs/bodies/height.jsonc b/src/HSchool.Server/mods/core/defs/bodies/height.jsonc new file mode 100644 index 0000000..cf4623e --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/bodies/height.jsonc @@ -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 } }, + ], +} diff --git a/src/HSchool.Server/mods/core/defs/bodies/weight.jsonc b/src/HSchool.Server/mods/core/defs/bodies/weight.jsonc new file mode 100644 index 0000000..6b02f24 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/bodies/weight.jsonc @@ -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 } }, + ], +} diff --git a/src/HSchool.Server/mods/core/defs/namesets/slavic.jsonc b/src/HSchool.Server/mods/core/defs/namesets/slavic.jsonc new file mode 100644 index 0000000..938a4f6 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/namesets/slavic.jsonc @@ -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" }, + ], +} diff --git a/src/HSchool.Server/mods/core/defs/needs/needs.jsonc b/src/HSchool.Server/mods/core/defs/needs/needs.jsonc new file mode 100644 index 0000000..f2edddd --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/needs/needs.jsonc @@ -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 }, +] diff --git a/src/HSchool.Server/mods/core/defs/skills/academic.jsonc b/src/HSchool.Server/mods/core/defs/skills/academic.jsonc new file mode 100644 index 0000000..f3b0583 --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/skills/academic.jsonc @@ -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 }, + }, +] diff --git a/src/HSchool.Server/mods/core/defs/skills/physical.jsonc b/src/HSchool.Server/mods/core/defs/skills/physical.jsonc new file mode 100644 index 0000000..9b8e51a --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/skills/physical.jsonc @@ -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 }, + ], + }, +] diff --git a/src/HSchool.Server/mods/core/defs/traits/traits.jsonc b/src/HSchool.Server/mods/core/defs/traits/traits.jsonc new file mode 100644 index 0000000..f1d3efb --- /dev/null +++ b/src/HSchool.Server/mods/core/defs/traits/traits.jsonc @@ -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 }, + ], + }, +] diff --git a/src/HSchool.Server/mods/core/localizations/en.jsonc b/src/HSchool.Server/mods/core/localizations/en.jsonc index f0e861e..9640683 100644 --- a/src/HSchool.Server/mods/core/localizations/en.jsonc +++ b/src/HSchool.Server/mods/core/localizations/en.jsonc @@ -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", } diff --git a/src/HSchool.Server/mods/core/localizations/ru.jsonc b/src/HSchool.Server/mods/core/localizations/ru.jsonc index da57ead..5d1e89f 100644 --- a/src/HSchool.Server/mods/core/localizations/ru.jsonc +++ b/src/HSchool.Server/mods/core/localizations/ru.jsonc @@ -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": "Полное", } diff --git a/src/HSchool.Simulation/SchoolRegistry.cs b/src/HSchool.Simulation/SchoolRegistry.cs index 00fed0a..96b9b5b 100644 --- a/src/HSchool.Simulation/SchoolRegistry.cs +++ b/src/HSchool.Simulation/SchoolRegistry.cs @@ -10,6 +10,7 @@ public enum SchoolCreationError InvalidMap, UnknownMod, InvalidCatalog, + UnknownNameSet, } /// Outcome of : either the school or the reason there is none. diff --git a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs index 6ce5e04..d45875c 100644 --- a/tests/HSchool.AppHost.Tests/SchoolApiTests.cs +++ b/tests/HSchool.AppHost.Tests/SchoolApiTests.cs @@ -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 Floors, IReadOnlyList Rooms, IReadOnlyList Things, + IReadOnlyList NameSets, MapLayoutResponse DefaultMap); private sealed record DefInfoResponse(string DefName, string Label); diff --git a/tests/HSchool.Content.Tests/PeopleDefTests.cs b/tests/HSchool.Content.Tests/PeopleDefTests.cs new file mode 100644 index 0000000..ae162aa --- /dev/null +++ b/tests/HSchool.Content.Tests/PeopleDefTests.cs @@ -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(() => _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(() => _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(() => _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)); + } +} diff --git a/tests/HSchool.People.Tests/Fixtures.cs b/tests/HSchool.People.Tests/Fixtures.cs new file mode 100644 index 0000000..5a9ad71 --- /dev/null +++ b/tests/HSchool.People.Tests/Fixtures.cs @@ -0,0 +1,78 @@ +namespace HSchool.People.Tests; + +internal static class PackDocuments +{ + public static IReadOnlyList FromDirectory(string packId, string packRoot) + { + var documents = new List(); + 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; + } +} diff --git a/tests/HSchool.People.Tests/GlobalUsings.cs b/tests/HSchool.People.Tests/GlobalUsings.cs new file mode 100644 index 0000000..b7b3161 --- /dev/null +++ b/tests/HSchool.People.Tests/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using HSchool.Content; +global using HSchool.People; diff --git a/tests/HSchool.People.Tests/HSchool.People.Tests.csproj b/tests/HSchool.People.Tests/HSchool.People.Tests.csproj new file mode 100644 index 0000000..4e0f024 --- /dev/null +++ b/tests/HSchool.People.Tests/HSchool.People.Tests.csproj @@ -0,0 +1,31 @@ + + + + HSchool.People.Tests + true + Exe + + + + + + + + + + + + + + + + + + + + vanilla\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + + diff --git a/tests/HSchool.People.Tests/RosterGeneratorTests.cs b/tests/HSchool.People.Tests/RosterGeneratorTests.cs new file mode 100644 index 0000000..5f7ae70 --- /dev/null +++ b/tests/HSchool.People.Tests/RosterGeneratorTests.cs @@ -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 { [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 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(); + } +}