Files
h-school/docs/design/people.md
T

402 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Люди: генерация, характеристики, просмотр
Договорённость на следующий срез, не текущий код.
Типы и карта: [`defs.md`](defs.md). Экран игрока: [`near-term.md`](near-term.md).
Потоки: [`runtime.md`](runtime.md). Проекты: [`projects.md`](projects.md).
Школа перестаёт быть пустой коробкой. Появляются ученики, работники и родители: у каждого имя,
тело, навыки, черты и нужды. **Ничего из этого пока ни на что не влияет** — люди не ходят, не
работают и не учатся. Срез отвечает на вопрос «кто в этой школе», а не «что они делают».
Тот же принцип, что и с картой: **код — системы, содержание — данные**. Новый навык, черта, цвет
волос или набор имён появляются файлом в `mods/<id>/defs/`, без пересборки.
## Откуда берётся состав
Карта уже знает вместимость. `ThingDef.PupilSlots × count` даёт ученические места в комнате,
`RoomDef.positions` — должности, которые комната открывает. Отдельного «числа учеников» в форме
создания **нет**: состав школы — следствие того, что построено.
| Что построено | Что появляется |
| --- | --- |
| Кабинет с 16 партами | Класс на 16 учеников |
| Кабинет директора | Должность директора и один работник |
| Спортзал | Должность физрука |
| Библиотека | Должность библиотекаря |
**Один кабинет — один класс.** Кабинет закреплён за классом, как в обычной школе: класс живёт в
своём кабинете, и вместимость кабинета есть размер класса. Хотите одиннадцать параллелей —
поставьте одиннадцать кабинетов. Это делает редактор карты осмысленным: игрок строит не декорации,
а штатное расписание.
**Ванильная карта дорабатывается до одиннадцати кабинетов**, по одному на параллель, чтобы школа
из коробки была полной. Сейчас их четыре.
### Класс — сущность ростера, а не поле карты
Параллель и литера — именно поля, а не разбор подписи комнаты. Подпись разбирать нельзя дважды:
кириллическая «А» и латинская «A» на глаз неразличимы (в нынешней ванильной карте кабинеты
подписаны латиницей — `1A`, `2B`), а в другой локали литеры вообще другие.
Но и в **карте** этим полям не место. Параллель растёт каждый год: 5Б становится 6Б, не переезжая
из кабинета. Раскладка же после создания школы неизменна — иначе файл карты пришлось бы
переписывать каждое игровое 1 сентября.
Поэтому класс — отдельная сущность ростера: параллель, литера, закреплённый кабинет. Кабинет
закреплён за классом на всё время его жизни; одиннадцатый выпускается — кабинет достаётся новому
первому. Раскладку по параллелям при создании делает генератор, игрок её не задаёт.
Из этого следует, что подписи кабинетов в ванильной карте — **номера помещений** («101», «204»),
как в настоящей школе, а не имена классов. Имя класса живёт на классе и меняется вместе с ним.
## Из чего состоит человек
Шесть слоёв. Первые два есть у всех, остальные — по роли.
| Слой | Что это | Пример |
| --- | --- | --- |
| **Личность** | ФИО со склонениями, пол, дата рождения, роль | Иванова Мария Петровна, ж, 2001-03-14 |
| **Тело** | Рост, вес, цвет волос, цвет глаз, телосложение | 164 см, 52 кг, русые, серые, худощавое |
| **Навыки** | Числовые шкалы | Математика 62, Ловкость 31 |
| **Черты** | Дискретные, с весами и несовместимостями | Усидчивый, Задира |
| **Нужды** | Числовые шкалы, убывают со временем | Сон 0.7, Голод 0.4 |
| **Связи** | Семья: родители, дети, братья и сёстры | дочь Иванова П.С. и Ивановой О.М. |
Роли три: **ученик**, **работник**, **родитель**. Роль — не def: код различает их по существу,
а не по данным. Должность работника — `PositionDef`, он уже есть.
Один человек может быть в двух ролях сразу: учительница математики, чей сын учится в седьмом
классе, — это **один** человек с ролью работника и связью «родитель» к ученику. Отдельной сущности
«родитель Иванова» для неё не заводится.
## Родители
Родители — **полноценные сущности** в мире школы, но **без места на карте**. Они существуют,
потому что у ученика есть семья, а не потому что ходят по коридорам.
Это задел, а не упрощение: позже родителя можно будет вызвать в школу — тогда у него появится
местоположение и цель, но не появится новый тип сущности. Поэтому родитель с самого начала лежит
в `World` наравне с учеником, а не отдельным списком «карточек при ученике».
## Генерация семьями
Люди генерируются **не по одному**. Единица генерации — семья:
```
Семья Ивановых
├── Иванов Пётр Сергеевич родитель, 41 год
├── Иванова Ольга Михайловна родитель, 39 лет
├── Иванова Мария Петровна ученица, 7Б
└── Иванов Кирилл Петрович ученик, 3А
```
Что это даёт бесплатно и чего иначе не получить:
- **Фамилии повторяются.** Школа без братьев и сестёр читается как список случайных строк.
- **Отчества сходятся.** Отчество ребёнка — от имени отца, а не от отдельного словаря.
- **Родовые формы согласованы.** Иванов / Иванова в одной семье, а не вразнобой.
- **Работник может быть родителем ученика** — фактом семьи, без специального правила.
Генератор идёт сверху вниз: сначала решает, сколько семей нужно, чтобы заполнить места и
должности; потом строит взрослых; потом детей нужных возрастов; и только затем раскладывает детей
по классам, а взрослых — по должностям. Обратный порядок («сгенерировать 400 учеников, потом
приписать им родителей») не даёт ни братьев, ни учителей-родителей.
**Места раздаются вперемешку по всей школе, а не подряд.** Карта отдаёт ученические места
сгруппированными по кабинетам, и семья, берущая подряд идущие места, получала бы всех детей в один
класс — то есть ровесниками, с одним и тем же окном рождения. Порядок мест перемешивается от
школьного сида до раздачи.
Цена решения названа прямо: набор мест перестаёт быть «дописываемым с конца». Более крупная карта
даёт другую раскладку по классам с самого начала, а не только в хвосте. Что остаётся неизменным —
**как разыгрывается сама семья**: её сид зависит только от номера, поэтому фамилия, состав и имена
семьи №5 одинаковы в школе на четыре кабинета и в школе на пять. Меняется то, в каком классе сидят
её дети, и вместе с классом — год рождения.
### Неполные семьи
Примерно **8%** семей с детьми живут с одним родителем. Кто именно остался — отец или мать —
решается броском, без перевеса в чью-либо сторону.
**Причина не моделируется.** Ростер записывает, кто живёт в доме, а не почему. Развод, вдовство и
прочее — это события, а событий в этом срезе нет; появится лента — появится и повод завести признак.
Ребёнок **в любом случае носит отцовскую фамилию и отчество**. Значит, отсутствующему отцу всё
равно разыгрывается имя — оно записывается на семью вместе с фамилией. Читать их с присутствующего
родителя нельзя: у матери-одиночки нет мужской формы фамилии, а сыну нужна именно она.
Решение «кто остался» берётся из **отдельного потока**, а не из общего с внешностью. В общем потоке
два соседних броска оказались связаны, и все неполные семьи до единой вышли материнскими.
**Номер ребёнка в идентификаторе — счётчик, а не длина списка.** Выпускник уходит из ростера;
если следующий ребёнок семьи получит номер по числу оставшихся детей, он унаследует идентификатор
ушедшего, и все ссылки на него — семейные связи, открытая карточка — молча начнут показывать
другого человека.
## Дефы
Пять новых видов. Ссылки — строками `defName`, как везде.
1. **SkillDef** — навык. Диапазон, распределение при генерации, зависимость от возраста,
ограничения со стороны тела (см. ниже). Не каждый человек получает каждый навык:
`always` — у всех, предметы — по параллели ученика, `work` — только взрослым,
`adultChance` — шанс у взрослого поверх родного языка.
2. **TraitDef** — черта. Вес (частота), список несовместимых черт, ограничения по роли и возрасту,
модификаторы навыков.
3. **BodyAttributeDef** — свойство тела. Либо число с распределением (рост, вес), либо выбор из
списка с весами (цвет волос, цвет глаз). Распределение зависит от пола и возраста.
4. **NeedDef** — нужда. Начальное значение и скорость убывания.
5. **NameSetDef** — набор имён (см. отдельный раздел).
Телосложение — не отдельный def, а **производное**: считается из роста и веса кодом. Данные не
должны дублировать то, что выводится.
### Что кладём в `core`
| Вид | Ванильное содержание |
| --- | --- |
| Навыки | Школьные предметы, языки набора имён, общение, физические (ловкость, сила, выносливость) и рабочие (педагогика, медицина, кухня…) |
| Черты | Около десяти для начала: усидчивый, рассеянный, задира, тихоня, лидер, лентяй, любопытный, вспыльчивый, добрый, аккуратный |
| Тело | Рост, вес, цвет волос, цвет глаз |
| Нужды | Сон, голод, туалет, общение |
Слои одинаковы у всех ролей: у родителя те же *виды* данных, что у ученика. Набор ключей
навыков — нет. Нет ключа — нет навыка, а не ноль: карточка его не показывает, урок, если
предмет всё же начался, стартует с `Range.Min`. Иначе первоклассник носил бы химию «для
полноты», а код всё равно спрашивал бы, есть ли она.
### Как тело ограничивает навыки
«Толстый не может быть ловким» — правило **генерации**, а не игровой эффект: характеристики пока
ни на что не влияют, но между собой согласованы.
Ограничение живёт в `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 },
],
}
```
Генератор сначала бросает тело, потом те навыки, которые человеку положены, потом применяет
ограничения как зажим. Порядок важен и фиксируется: **тело первично**, навык подстраивается.
Иначе один и тот же сид даст разных людей в зависимости от порядка перебора.
Выдача навыков тоже фиксирована: `always` → родной язык школы → предметы параллели (ученик) →
лишние академические, рабочие и `adultChance` (взрослый) → черты и зажим тела → родственные
языки набора. Годовой набор добавляет только новые ключи предметов; уже выданные значения не
перебрасываются.
Ограничения — не только вниз: «атлет не может быть совсем неловким» так же осмысленно, поэтому
`min` есть наравне с `max`.
## Нужды
Четыре нужды в `core`: **сон, голод, туалет, общение**. `NeedDef` описывает шкалу и скорость
убывания. Машинерия работает с этого среза: значение лежит на человеке, тикает вместе со школой,
показывается в карточке.
**Но в `core` скорость убывания равна нулю.** Есть тела, которые тратят, и нет ничего, что
восполняет: некому поесть, негде поспать. Ненулевая скорость в этом срезе означала бы школу, где
через игровые сутки все нужды на нуле, — и это выглядело бы поломкой, а не игрой.
Скорость — данные, не код. Когда появится еда и сон, цифра меняется в JSONC, а не в C#.
## Имена
`NameSetDef` — набор имён целиком: мужские имена, женские имена, фамилии, правила отчеств и
склонений. В `core` один набор — славянский. Другие наборы приходят модами.
**Один набор на школу.** Выбирается в диалоге создания рядом с модами и сохраняется в файле школы.
Смеси с весами («80% славянских, 20% прочих») — не в этом срезе: они требуют UI для весов.
Набор несёт список языков, на которых здесь говорят (`nativeLanguages`). Школа выбирает **один**
при создании — это родной язык всех людей. Остальные языки списка часто появляются на низком
уровне (`relatedLanguageChance` и соседние поля на том же def): русскоязычный может понимать
белорусский. Уже выданный ключ — родной или предмет школы — не перезаписывается, поэтому
белорусский ученик сохраняет школьный русский, а не обрезанный «чуть-чуть». Шанс `0` оставляет
родственников выключенными (одноязычный пакет).
Набор имён **не связан с языком интерфейса**. Английский UI не превращает Иванову в Ivanova:
язык интерфейса — про подписи кнопок, набор имён — про то, кто учится в школе.
### Склонения
Русскому тексту нужны падежи: «вызвать **Иванову Марию**», «дневник **Ивановой Марии**». Шесть
падежей на каждую часть ФИО.
Два способа, и нужны оба:
- **Правило** — по умолчанию. Код знает небольшой набор моделей склонения (`-ов/-ова`, `-ий/-ая`,
`-а/-я`, несклоняемые). В данных — только имя и id модели.
- **Явная таблица** — когда правило не работает. Любая запись может задать все шесть форм руками.
Это тот же принцип, что и везде: код знает *как*, данные говорят *что*. Словарь из шести форм на
каждое из тысячи имён никто не выдержит, а одними правилами русские фамилии не покрываются.
Падежи нужны заранее, до появления ленты событий: набор имён с одной формой потом придётся
переписывать целиком, а он самый объёмный файл в `core`.
## Отдельная библиотека
Новый проект **`HSchool.People`**.
```
Protocol ← Server → Simulation → People → Content
```
- Зависит от `HSchool.Content` (нужны defs и каталог).
- **Не** зависит от Arch, ASP.NET и сокетов — как `Content` сейчас.
- Отдаёт простые записи. Превращает их в сущности `World` уже `HSchool.Simulation`.
Так генератор тестируется без мира и без хоста: «этот сид + эта карта + этот набор имён + этот
родной язык → ровно эти люди». Это единственный способ поймать регрессию в генераторе — глазами
такое не проверяется.
### Детерминизм
Генерация детерминирована от **сида**. Сид — своё поле школы, не её идентификатор: при создании
его бросает сервер, либо его передают в `POST /api/schools`. Он лежит в файле людей и виден в
списке школ и на экране школы, чтобы им можно было поделиться. Один сид + одна карта + один набор
имён + один родной язык = одна и та же школа, всегда. Если родной язык при создании не указан,
его выбирает тот же сид — повтор без поля даёт тот же язык.
Существующие школы не перебрасываются: они продолжают жить с числом, уже записанным в файле людей.
Сид у каждой семьи свой, выведенный из школьного: добавление тринадцатой семьи не должно менять
первые двенадцать. Иначе тест на генератор ломается от любой правки порядка.
## Годовой набор
Состав живёт: 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`, без места на карте; задел на вызов в школу |
| Совмещение ролей | Один человек может быть работником и родителем ученика |
| Единица генерации | Семья, а не человек |
| Неполные семьи | Около 8% семей с детьми; остаётся случайный родитель; причина не моделируется |
| Фамилия и отчество | Всегда отцовские; имя отца и фамилия записаны на семье, а не на человеке |
| Раздача мест | Вперемешку по школе от сида: иначе братья и сёстры — всегда одноклассники |
| Стабильность сида | Гарантируется по семье (фамилия, состав, имена), не по месту в классе |
| Идентификатор ребёнка | Счётчик семьи; номер выпустившегося не переиспользуется |
| Набор в первый класс | Новые семьи приходят по одному ребёнку — иначе это тройняшки-ровесники |
| Порядок ограничений | Черта не может пробить потолок тела: зажим повторяется после модификаторов |
| Слои человека | Личность, тело, навыки, черты, нужды, связи |
| Новые defs | `SkillDef`, `TraitDef`, `BodyAttributeDef`, `NeedDef`, `NameSetDef` |
| Телосложение | Производное от роста и веса, не def |
| Тело и навыки | `SkillDef.bodyLimits` зажимают навык; тело бросается первым |
| Влияние характеристик | Никакого; только просмотр |
| Навыки в `core` | Предметы, языки, общение, физические, рабочие |
| Выдача навыков | Не полный каталог: always, родной, предметы года, взрослые лишние, родственные языки |
| Черты в `core` | Около десяти |
| Нужды в `core` | Сон, голод, туалет, общение |
| Нужды | Машинерия есть, скорость убывания в `core` — ноль |
| Слои по ролям | Одинаковые виды данных; ключи навыков зависят от роли и возраста |
| Набор имён | Один на школу, выбор в create, в `core` славянский; прочие — моды |
| Родной язык | Один из `nativeLanguages` набора, выбор в create; остальные языки списка — часто низкий навык |
| Падежи | Правило по умолчанию (код), явная таблица как исключение (данные) |
| Имена и язык UI | Независимы |
| Библиотека | `HSchool.People`: зависит от Content, не знает Arch и ASP.NET |
| Детерминизм | Свой сид школы (не id), в сейве и в API; свой сид на семью; родной язык — часть входа |
| Просмотр | Панель в оболочке менеджера, вкладкой рядом с картой; не отдельный экран |
| Список | Фильтры, сортировки, пейджинг; в строке — только лёгкие поля |
| Транспорт списка | HTTP по опубликованному снимку ростера |
| Транспорт карточки | Запрос в воркер, потому что нужды живые |
| Сохранение | `saves/{id}.people.json`, пишется при изменении состава |