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

333 lines
29 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.
# Жизнь школы
Договорённость на срез после расписания, не текущий код.
Расписание: [`schedule.md`](schedule.md). Штат: [`staffing.md`](staffing.md).
Люди: [`people.md`](people.md). Типы и карта: [`defs.md`](defs.md). Экран: [`near-term.md`](near-term.md).
Люди начинают **ходить и решать**. До сих пор человек находился там, где его застало расписание;
теперь он приходит утром, идёт коридором в кабинет, может уйти с урока в туалет и вернуться —
и всё это видно на карте.
Это самый дорогой срез базы, поэтому он разбит **на два этапа**. Первый ставит присутствие и
ходьбу и уже сам по себе оживляет школу. Второй добавляет нужды и выбор. Между ними можно
остановиться, и школа останется целостной.
## Что уже готово
Три вещи не надо изобретать — они лежат с прошлых срезов:
- **Граф ходьбы.** `MapLayout.Links` — рёбра между двором и комнатами, валидатор требует
связности. Коридоры, лестницы, вестибюль и двор уже узлы этого графа, а не декорация.
- **Комнаты для нужд.** В `core` есть столовая, два санузла, учительская, библиотека и двор.
Карту под этот срез дорабатывать не нужно.
- **Нужды.** Сон, голод, туалет и общение существуют с нулевым `decayPerHour` — потому что
восполнить их было нечем.
## Присутствие — состояние, а не функция от времени
Ключевая смена модели. Сейчас «кто в кабинете» вычисляется из расписания и часов
(`TimetableClock`); занятость нигде не хранится, и это было правильно, пока никто не ходил.
Теперь у человека есть **место**: узел графа, в котором он находится. Расписание перестаёт быть
источником истины о присутствии и становится тем, чем является, — **обязанностью**: где человеку
следует быть. Совпадение обязанности и места — уже не аксиома, а результат работы ИИ, и именно
в зазоре между ними живёт весь геймплей: опоздания, прогулы, пустой кабинет у слабого завуча.
## Ходьба
**Человек всегда находится в узле.** Переход — не полёт между комнатами, а последовательное
занятие узлов пути: полторы минуты в коридоре, потом полминуты на вход в кабинет. Рёбер как
состояний нет.
Из этого сразу следует то, ради чего всё затевалось: на перемене коридор действительно полон
людей, и это видно в дереве.
Цена перехода — **свойство дефа локации**: `travelMinutes` на `RoomDef` и `TerritoryDef`, столько
игровых минут занимает пересечение узла. В `core`: кабинет 0.5, коридор 1.5, лестница 1.5,
вестибюль 1, двор 3. Мод, где двор — это парк на гектар, ставит своё число и получает школу, по
которой опаздывают.
Маршрут — кратчайший по сумме `travelMinutes`, посчитанный **один раз при загрузке школы**
матрицей следующих шагов. Узлов десятки, не тысячи; искать путь на каждом шаге незачем.
Расстояния сразу дают карте смысл, которого у неё не было: спортзал через двор — это шесть минут
от кабинета 101 при перемене в десять. Кто ставит физкультуру пятым уроком, тот получает класс,
который переодевается в коридоре.
## Приход и уход
**Нет расписания — не приходит.** Ученик, у чьего класса сегодня нет уроков, не появляется в
школе вовсе; учитель без уроков сегодня — тоже. Каникулы и воскресенье школа проводит пустой,
и это не отдельное правило, а следствие.
- **Ученик** приходит к своему первому уроку и уходит после последнего.
- **Учитель** — так же, по своим урокам.
- **Прочий штат** (директор, секретарь, повар, медсестра, библиотекарь) работает по дню школы:
приходит к первому уроку дня и уходит после последнего.
- **Родители** не приходят. Их вызов в школу — отдельный сюжет, не в этом срезе.
Запас времени на дорогу человек берёт себе сам: путь от двора до кабинета плюс несколько минут
из своего потока случайности. Аккуратный придёт раньше, ленивый — в притык, и если путь длиннее
запаса, он опоздает. Это первое место, где черта характера делает что-то видимое.
Вне школы человек **не исчезает**: у него состояние «вне школы», он есть в списке людей, и его
карточка это показывает. Дом мы не моделируем.
## Пропуск пустого времени
Ночью, в выходной, в праздник и на каникулах школа пуста и не делает ничего. Игроку при этом
остаётся смотреть на часы и мотать ×4 — летние каникулы в таком темпе это несколько минут
пустого экрана, а каждая ночь — полминуты. Поэтому: кнопка **«пропустить»**, переносящая время
к шести утра ближайшего рабочего дня.
Два условия, и оба проверяет сервер:
1. в школе никого нет;
2. сейчас **вне рабочего окна дня** — либо нерабочий день целиком, либо рабочий, но до шести
утра или после последнего звонка.
Первое выглядит как вежливость к игроку, но дело не в ней: **оно и делает прыжок законным.**
Вне школы нужды не тратятся, а сон восстанавливается за кадром — значит для пустой школы
промотанное время и прожитое дают ровно одно состояние. Останься в здании хоть один человек,
прыжок пришлось бы либо честно просимулировать, либо соврать.
Что прыжок обязан отработать по-настоящему — это события календаря, через которые он
перескакивает: первое сентября (переход, выпуск, набор) и недельное обновление пула соискателей.
Оба уже написаны как «что случилось между двумя датами», а не «что случилось на этом тике», так
что прыжок их не ломает. Но тест обязателен: летние каникулы перескакивают ровно через первое
сентября, и это самый вероятный способ потерять целый набор.
Разрешение на кнопку приходит **вместе с часами**. Клиент не вычисляет его сам — это была бы
игровая логика на клиенте, а её там нет.
Шесть утра — то же время, с которого начинается новая школа: до первого звонка есть чем заняться.
В три часа ночи вторника прыжок попадает в утро того же вторника, в десять вечера — в утро
среды, в субботу днём — в утро понедельника. Правило одно: ближайшее шесть утра рабочего дня,
которое ещё впереди.
Границы рабочего окна берутся из **каркаса дня**, а не из фактического расписания. Разница
важна: у школы без единого нанятого учителя уроков нет вообще, и по фактическому расписанию
кнопка позволила бы перепрыгивать целые учебные дни. По звонкам — не позволит.
## Действия — дефами
`ActionDef` существует, но пуст. Он получает поля:
```jsonc
{
"defName": "UseToilet",
"room": "Restroom", // где это делают
"minutes": 4, // сколько длится
"need": "Toilet", // что восполняет
"needGain": 1, // насколько, за всё действие
"roles": ["student", "staff"],
"weight": 0, // 0 — ради удовольствия не выбирают
}
```
Действие может требовать **вещь** (`thing`), и тогда занимает её на время: за столом в столовой
не сидят вдвоём. Это связывает мебель на карте с поведением, а не только с вместимостью класса.
Так мод добавляет поведение, не написав ни строчки кода, — ровно как обещано в
[`defs.md`](defs.md).
**Обязанности из каталога действий не выбирают.** Урок приходит из расписания, работа по
должности — из `RoomDef.works`, который уже проставлен в `core` (`TeachLesson` у кабинета,
`ServeLunch` у столовой, `MedicalDuty` у медкабинета). `ActionDef` описывает то, что человек
делает **сам**: нужды и досуг.
Числа поведения — отдельный деф правил, как `StaffingDef` у штата: пороги нужд, скорость
обучения на уроке, разброс запаса на дорогу, порог переключения решения и веса целей.
Это игровой баланс, а не настройка движка, и место ему в контенте.
## Как выбирается действие
Гибрид: обязанность — не приказ, а **сильная цель среди прочих**.
В момент решения человек собирает цели и сравнивает их вес:
| Цель | Вес |
| --- | --- |
| Обязанность (урок, работа по должности) | постоянный, высокий |
| Нужда ниже порога | тем больше, чем ближе нужда к нулю |
| Досуг | низкий; берётся, когда обязанности нет |
Побеждает наибольший. Отсюда бесплатно получается то, ради чего гибрид и нужен: ученик с
критическим «туалетом» уходит с урока, потому что нужда **обогнала** обязанность, а не потому
что где-то записано «можно выйти».
Дальше — планирование. Цель выбирает действие, которое её закрывает, а планировщик достраивает
недостающее: «дойти до узла с такой комнатой» → «сделать». Предусловия у нас всего два — быть в
подходящей комнате и иметь свободную вещь, — поэтому цепочка выходит длиной в два-три шага.
Честно про GOAP: **это его вырожденный случай**. Полноценный поиск по пространству состояний в
школе не окупается — он стоит заметно дороже и не порождает ни одной новой истории сверх «дойти
и сделать». Если однажды появятся действия, которые открывают другие действия (взять ключ →
открыть кабинет → принести журнал), планировщик углубится, а форма целей и предусловий останется
той же. Место для роста заложено, цена — нет.
Одно правило против дёрганья: **начатое доводят до конца.** Решение переключается, только если
новая цель весит заметно больше текущей. Иначе человек с двумя почти равными нуждами будет
метаться между столовой и туалетом, ничего не успевая.
Решение принимается **не каждый тик**, а по событиям: звонок, конец действия, приход в
назначенный узел, пересечение нуждой порога. Двадцать раз в секунду думать не о чем.
## Нужды
Декей включается. Голод — столовая, туалет — санузел, общение — перемена рядом с
одноклассниками. Сон **за кадром**: человек вне школы отдыхает, и утром приходит выспавшимся.
Ночной школы у нас нет, а изображать её ради одной полоски — лишняя работа. **Голод восстанавливается
там же**: дома завтракают и ужинают, иначе дефицит копится из дня в день и вся школа через неделю
сидит на нуле — так и было, пока это не измерили.
**Обед — расписание, а не порыв.** Действие с `lunch: true` предлагается только в свою смену
(`DayFrameDef.lunchBreaks`), зато в неё идут и те, кто ещё не проголодался до порога. Иначе
получается одно из двух: либо класс уходит с урока в столовую, когда приспичило, либо — если
ждать порога — вся параллель проскакивает свою смену и голодает до вечера. Смены разводят
параллели: в `core` младшие едят после третьего урока, старшие после четвёртого, и каждая такая
перемена становится длинной.
Прогресс-бары нужд в карточке уже нарисованы. В этом срезе они впервые начнут шевелиться.
## Последствия — минимум
Осознанно мало:
- **Навык растёт на уроке** — от предмета, с поправкой на черты и на то, в каком человек
состоянии. Голодный учится хуже. Если ключа ещё нет (химия в восьмом), рост начинается с
`Range.Min`, а не с нуля «как будто навык был всегда».
- **Опоздание и прогул видны.** В кабинете 5Б на математике не двадцать три человека, а
двадцать один, и карточка каждого говорит, где он.
Оценок, успеваемости, конфликтов и настроения тут нет. Это следующий срез, и он будет
опираться на числа, которые появятся здесь.
## Что видит игрок
Игрок пока **наблюдает**. Приказы — «вызвать к директору», «отправить домой» — отдельный
разговор; сначала школа должна жить без вмешательства.
На экране:
- **дерево** — сколько человек в каждом узле, живым числом;
- **панель локации** — кто здесь и чем занят;
- **карточка человека** — где он сейчас и что делает, рядом с уже существующим расписанием.
## Поток на клиент
Присутствие меняется постоянно, поэтому у него **своё сообщение** — примерно два раза в секунду,
только по открытой школе. Полсекунды задержки на счётчик в дереве незаметны, а двадцать кадров в
секунду ради этого не нужны.
Побочный выигрыш: **снимок карты становится статическим**. Сейчас он несёт и людей, и текущий
урок, и поэтому пересылается на каждой смене слота. Всё живое уезжает в присутствие, структура
уходит один раз при открытии — и одна из самых неприятных зависимостей в протоколе исчезает.
Имена в поток не кладём: клиент один раз забирает по HTTP короткий справочник «id → имя» и держит
его открытым; неизвестный id — повод перечитать справочник, а не строка в каждом кадре.
Присутствие приходит **по всей школе**, а выбранный узел клиент фильтрует сам — как дерево
локаций сейчас. Если школа на тысячу человек однажды сделает это дорогим, серверный
`OpenLocation` — тот самый шов, который для этого оставлен.
## Отдельная библиотека
`HSchool.Ai` встаёт рядом с `People` и `Schedule`: зависит на `Content`, `People` и `Schedule`,
не знает ни про Arch, ни про ASP.NET, ни про часы реального времени.
Граница проходит там, где мало трафика. В библиотеку уезжает всё, что считается функцией от
входов:
- **маршруты** — граф из `MapLayout.Links` и `travelMinutes` в матрицу следующих шагов и
стоимость пути;
- **план дня** — во сколько человеку выходить, чтобы успеть, и когда он уходит;
- **решение** — цели, веса, выбор действия и цепочка «дойти → сделать»;
- **продвижение плана** — прошло столько-то минут, значит человек здесь и делает вот это.
В `HSchool.Simulation` остаётся то, что без мира не имеет смысла: компоненты, порядок обхода,
тик и адаптер, который собирает значения из компонентов, зовёт библиотеку и записывает ответ
обратно.
Адаптер — настоящая цена разделения: на каждое решение надо сложить нужды и роль человека в
структуру и разобрать ответ. Цена приемлема ровно потому, что **решения редки, а движение
часто**: горячий путь — уменьшить остаток минут и перейти в следующий узел — границу не
пересекает вовсе, а думает человек несколько раз за урок. Провести черту где-нибудь ещё
означало бы гонять копирование двадцать раз в секунду на каждого.
Взамен — то же, что уже дали `People` и `Schedule`: самая сложная логика базы проверяется
таблицей входов и выходов, без ECS, без сокета и без хоста. Для среза, где половина вопросов
будет вида «почему он пошёл туда», это и есть главный аргумент.
## Детерминизм
Тот же сид и те же действия игрока дают ту же школу. Это стоит дороже, чем кажется, и держится
на трёх вещах:
1. **Стабильный порядок обхода.** Порядок сущностей в Arch меняется при создании и удалении —
опираться на него нельзя. Обход идёт по собственному упорядоченному списку людей.
2. **Свой поток случайности у каждого.** Как в генерации: `Seed.Mix` от сида школы, номера
человека, соли и номера дня. Никакого общего `Random` на мир.
3. **Решения по событиям, а не по бюджету времени.** Ограничение «столько решений за тик» —
очередь, а не отсечка: отложенное решение принимается следующим тиком, а не пропадает.
## Сохранение
Место человека, его путь и текущее действие едут в файл часов — там же, где уже лежат карта и
моды, с той же редкой периодичностью и обязательным сбросом при остановке. Кого в файле нет
(новичок после набора), тот расставляется по обязанности.
Отдельного формата под ИИ не заводим: тридцать секунд неточности после аварийной остановки
игрок не заметит, а лишний файл на школу — заметим мы.
## Масштаб
Считаем на нынешние сотни людей, а не сразу на тысячу. Дорогих мест два, и оба уже закрыты
формой решения: маршруты считаются один раз при загрузке, решения принимаются по событиям.
Движение — это уменьшение счётчика у тех, кто идёт.
Потолок решений за тик — настройка движка, ему место в `SimulationOptions`.
## Что зафиксировано
| Тема | Решение |
| --- | --- |
| Этапы | Два: присутствие и ходьба, затем нужды и решения |
| Присутствие | Состояние человека, а не функция от расписания |
| Ходьба | Всегда в узле; переход — занятие узлов пути |
| Цена перехода | `travelMinutes` в дефе локации |
| Маршруты | Матрица следующих шагов, один раз при загрузке |
| Приход | Только если сегодня есть уроки; запас на дорогу свой у каждого |
| Прочий штат | По дню школы, а не по своим урокам |
| Родители | Не приходят |
| Вне школы | Состояние, не исчезновение; дом не моделируем |
| Пропуск пустого времени | Кнопка к шести утра ближайшего рабочего дня: ночь, выходной, каникулы |
| Условие кнопки | Пустая школа **и** время вне рабочего окна дня; решает сервер, шлёт с часами |
| Границы окна | По каркасу дня, а не по фактическому расписанию — иначе прыгали бы через дни |
| Сон | Восстанавливается за кадром |
| Действия | Дефами: комната, вещь, длительность, нужда, роли, вес |
| Обязанности | Из расписания и `RoomDef.works`, не из каталога действий |
| Выбор | Обязанность — сильная цель, а не приказ; побеждает наибольший вес |
| Планирование | Вырожденный GOAP: «дойти → сделать», два предусловия |
| Переключение | Начатое доводится до конца, если новая цель не сильно тяжелее |
| Частота решений | По событиям: звонок, конец действия, приход, порог нужды |
| Нужды | Декей включается; сон и голод восстанавливаются вне школы |
| Обед | Смены по параллелям в каркасе дня; в свою смену едят и не проголодавшиеся |
| Последствия | Навык на уроке, видимые опоздание и прогул. Оценок нет |
| Игрок | Наблюдает; приказов нет |
| Поток присутствия | Своё сообщение, ~2 Гц, по всей школе, фильтр на клиенте |
| Снимок карты | Становится статическим: всё живое уходит в присутствие |
| Имена | Справочник по HTTP, не в каждом кадре |
| Библиотека | `HSchool.Ai` рядом с People и Schedule; в Simulation — только адаптер |
| Детерминизм | Держим: свой порядок обхода, поток на человека, очередь решений |
| Сейв | В файле часов, вместе с картой и модами |
## Заведомо не сейчас
- Оценки, успеваемость, настроение, конфликты и дружба.
- Приказы игрока и вызов родителей в школу.
- Ночная школа, сон как событие, продлёнка.
- Пиксельные координаты внутри комнаты и рисованные человечки.
- Полноценный GOAP с поиском по пространству состояний.
- Серверный `OpenLocation` — пока фильтрует клиент.