Remove off-queue.md and add new design documentation files including README.md, defs.md, near-term.md, projects.md, runtime.md, people.md, staffing.md, and schedule.md to outline the structure and agreements for the game's design phases.

This commit is contained in:
Leonid Pershin
2026-08-20 12:26:13 +03:00
parent 12cc36b4dd
commit a37a5eb82d
101 changed files with 1015 additions and 921 deletions
+406
View File
@@ -0,0 +1,406 @@
# Люди: генерация, характеристики, просмотр
Договорённость на следующий срез, не текущий код.
Типы и карта: [`defs.md`](../01-shell/defs.md). Экран игрока: [`near-term.md`](../01-shell/near-term.md).
Потоки: [`runtime.md`](../01-shell/runtime.md). Проекты: [`projects.md`](../01-shell/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#.
Отменено срезом 5: в `core` ненулевой `decayPerHour`, сон и голод восстанавливаются вне школы,
см. [`ai.md`](../05-ai/ai.md).
## Имена
`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 секунд —
таскать через это мегабайт ростера, который меняется раз в игровой год, незачем.
## Советы, которые стоит принять сразу
- Не делать «родителя» отдельным видом сущности. Иначе учительница-мать станет двумя людьми.
- Не хранить телосложение в данных — оно выводится из роста и веса.
- Не начинать с падежных таблиц на каждое имя: правило по умолчанию, таблица как исключение.
- Не отправлять черты и навыки в списке — только в карточке. Список читают глазами, карточку
открывают по одному человеку.
- Сид на семью, а не один на школу: иначе любая правка генератора переставит всех.
- Ненулевую скорость нужд не включать, пока нечем их восполнять — сделано срезом 5, см. [`ai.md`](../05-ai/ai.md).
## Заведомо не сейчас
Срез 2 закрыт. Строки, которые отменили следующие срезы, помечены.
- Поведение: расписание, уроки, перемещение по карте — отменено срезами 4–5, см. [`schedule.md`](../04-schedule/schedule.md), [`ai.md`](../05-ai/ai.md).
- Влияние характеристик на что-либо. Черты и навыки в этом срезе — текст в карточке — отменено срезом 5 (маршрут, обучение).
- Вызов родителя в школу. Задел есть (родитель — сущность), реализации нет.
- Наём и увольнение вручную. Должности закрываются генератором — отменено срезом 3, см. [`staffing.md`](../03-staffing/staffing.md). Школа открывается без штата.
- Смеси наборов имён с весами.
- Новые роли из модов: ученик, работник, родитель — код, не данные.
- Портреты и любой арт.
- Причина неполной семьи: развод, вдовство и прочее — это события, а ленты событий ещё нет.
## Зафиксировано этим разговором
| Тема | Решение |
| --- | --- |
| Размер школы | Из карты: `PupilSlots` — ученики, `RoomDef.positions` — штат |
| Класс | Один кабинет = один класс; класс — сущность ростера, не поле карты |
| Параллель и литера | Поля класса, а не разбор подписи комнаты; раскладывает генератор |
| Подписи кабинетов | Номера помещений («204»), имя класса живёт на классе |
| Ванильная карта | Дорабатывается до 11 кабинетов — по одному на параллель |
| Когда генерируются | Полный состав при создании, затем набор каждое 1 сентября |
| Годовой набор | Замещает выпуск; вместимость меняет только стройка |
| Кого выпускать | Старшую имеющуюся параллель, а не обязательно одиннадцатую |
| Уход из ростера | Выпускники уходят; родители — если не осталось детей и они здесь не работают |
| Роли | Ученик, работник, родитель; роль — код, должность — `PositionDef` |
| Родители | Сущности в `World`, без места на карте; задел на вызов в школу |
| Совмещение ролей | Один человек может быть работником и родителем ученика |
| Единица генерации | Семья, а не человек |
| Неполные семьи | Около 8% семей с детьми; остаётся случайный родитель; причина не моделируется |
| Фамилия и отчество | Всегда отцовские; имя отца и фамилия записаны на семье, а не на человеке |
| Раздача мест | Вперемешку по школе от сида: иначе братья и сёстры — всегда одноклассники |
| Стабильность сида | Гарантируется по семье (фамилия, состав, имена), не по месту в классе |
| Идентификатор ребёнка | Счётчик семьи; номер выпустившегося не переиспользуется |
| Набор в первый класс | Новые семьи приходят по одному ребёнку — иначе это тройняшки-ровесники |
| Порядок ограничений | Черта не может пробить потолок тела: зажим повторяется после модификаторов |
| Слои человека | Личность, тело, навыки, черты, нужды, связи |
| Новые defs | `SkillDef`, `TraitDef`, `BodyAttributeDef`, `NeedDef`, `NameSetDef` |
| Телосложение | Производное от роста и веса, не def |
| Тело и навыки | `SkillDef.bodyLimits` зажимают навык; тело бросается первым |
| Влияние характеристик | Никакого; только просмотр |
| Навыки в `core` | Предметы, языки, общение, физические, рабочие |
| Выдача навыков | Не полный каталог: always, родной, предметы года, взрослые лишние, родственные языки |
| Черты в `core` | Около десяти |
| Нужды в `core` | Сон, голод, туалет, общение |
| Нужды | Машинерия есть; убывание в `core` — срез 5, не ноль, см. [`ai.md`](../05-ai/ai.md) |
| Слои по ролям | Одинаковые виды данных; ключи навыков зависят от роли и возраста |
| Набор имён | Один на школу, выбор в create, в `core` славянский; прочие — моды |
| Родной язык | Один из `nativeLanguages` набора, выбор в create; остальные языки списка — часто низкий навык |
| Падежи | Правило по умолчанию (код), явная таблица как исключение (данные) |
| Имена и язык UI | Независимы |
| Библиотека | `HSchool.People`: зависит от Content, не знает Arch и ASP.NET |
| Детерминизм | Свой сид школы (не id), в сейве и в API; свой сид на семью; родной язык — часть входа |
| Просмотр | Панель в оболочке менеджера, вкладкой рядом с картой; не отдельный экран |
| Список | Фильтры, сортировки, пейджинг; в строке — только лёгкие поля |
| Транспорт списка | HTTP по опубликованному снимку ростера |
| Транспорт карточки | Запрос в воркер, потому что нужды живые |
| Сохранение | `saves/{id}.people.json`, пишется при изменении состава |