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

336 lines
28 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 учеников, потом
приписать им родителей») не даёт ни братьев, ни учителей-родителей.
## Дефы
Пять новых видов. Ссылки — строками `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`, пишется при изменении состава |