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

36 KiB
Raw Blame History

Люди: генерация, характеристики, просмотр

Договорённость на следующий срез, не текущий код. Типы и карта: defs.md. Экран игрока: near-term.md. Потоки: runtime.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 и ссылается на свойство тела:

{
  "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.

Имена

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.

Заведомо не сейчас

  • Поведение: расписание, уроки, перемещение по карте, исполнение действий.
  • Влияние характеристик на что-либо. Черты и навыки в этом срезе — текст в карточке.
  • Вызов родителя в школу. Задел есть (родитель — сущность), реализации нет.
  • Наём и увольнение вручную. Должности закрываются генератором.
  • Смеси наборов имён с весами.
  • Новые роли из модов: ученик, работник, родитель — код, не данные.
  • Портреты и любой арт.
  • Причина неполной семьи: развод, вдовство и прочее — это события, а ленты событий ещё нет.

Зафиксировано этим разговором

Тема Решение
Размер школы Из карты: 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
Слои по ролям Одинаковые виды данных; ключи навыков зависят от роли и возраста
Набор имён Один на школу, выбор в create, в core славянский; прочие — моды
Родной язык Один из nativeLanguages набора, выбор в create; остальные языки списка — часто низкий навык
Падежи Правило по умолчанию (код), явная таблица как исключение (данные)
Имена и язык UI Независимы
Библиотека HSchool.People: зависит от Content, не знает Arch и ASP.NET
Детерминизм Свой сид школы (не id), в сейве и в API; свой сид на семью; родной язык — часть входа
Просмотр Панель в оболочке менеджера, вкладкой рядом с картой; не отдельный экран
Список Фильтры, сортировки, пейджинг; в строке — только лёгкие поля
Транспорт списка HTTP по опубликованному снимку ростера
Транспорт карточки Запрос в воркер, потому что нужды живые
Сохранение saves/{id}.people.json, пишется при изменении состава