281 lines
33 KiB
Markdown
281 lines
33 KiB
Markdown
# План: переход к клиент-серверной игре
|
|
|
|
Статус: выполняется. Дата: 2026-10-30. Проверенный прогресс отмечен в разделе 9.
|
|
|
|
## 1. Цель и рамки
|
|
|
|
Игра: симулятор бога в духе RimWorld, но с меньшим влиянием игрока. Город живёт сам (жители, машины, потребности, события), игроки в основном наблюдают и ограниченно вмешиваются. По запросу автора от 2026-10-30 этап 5 включает базовые команды вмешательства.
|
|
|
|
Исходные требования:
|
|
|
|
- До 30 игроков в одной комнате.
|
|
- Комната — отдельный инстанс мира, у каждой комнаты свой город (из OSM).
|
|
- Сущностей очень много, до миллиона на комнату.
|
|
- Клиент остаётся в браузере и только отображает. Вся симуляция и правила на сервере.
|
|
- Сгенерированный город хранится готовым, генерация не выполняется в рантайме комнаты.
|
|
|
|
Не входит в первую версию: аккаунты и монетизация, предсказание на клиенте (в наблюдательной игре оно не нужно). Влияние ограничено командами этапа 5.
|
|
|
|
## 2. Ключевые решения
|
|
|
|
| Вопрос | Решение | Почему |
|
|
|---|---|---|
|
|
| Язык сервера | Rust | Нет GC-пауз, плотные данные, параллельность без data race |
|
|
| ECS | `bevy_ecs` как отдельная библиотека (без движка Bevy) | Архетипы, автопараллелизм систем, change detection, `Schedule`. Запасной вариант: `hecs` |
|
|
| Асинхронность и сеть | `tokio` + `axum` + `tokio-tungstenite` | Стандарт для WebSocket в Rust |
|
|
| Транспорт | WebSocket (бинарный) | В браузере работает везде, TCP достаточен: игроки наблюдают, потеря пакета не критична, мир обновляется ~5–10 раз в секунду. WebTransport подключим, только если упрёмся в задержки |
|
|
| Формат сообщений | Protobuf (`prost` на сервере, `ts-proto` или `protobuf-es` на клиенте) | Общая схема, кодогенерация для обеих сторон, компактно, версионируется |
|
|
| Статика города | Тайлы-файлы, отдаются по HTTP | Кэшируются браузером и CDN, не грузят сервер симуляции |
|
|
| Генерация города | Остаётся на TypeScript, запускается офлайн (CLI) | Уже написана и отлажена (около 5 тыс. строк), портировать нет смысла |
|
|
| Сохранения | Снимки мира на диск, позже БД | Комнат немного, симуляция детерминируемая |
|
|
|
|
## 3. Архитектура
|
|
|
|
```
|
|
Браузер (TS) Сервер (Rust)
|
|
┌───────────────┐ HTTP ┌───────────────────────┐
|
|
│ Рендерер │◄─────────►│ Статика: тайлы города │
|
|
│ (src/view) │ тайлы └───────────────────────┘
|
|
│ │ WS ┌───────────────────────┐
|
|
│ Состояние │◄─────────►│ Gateway (axum) │
|
|
│ клиента: │ дельты │ - подключение к комн.│
|
|
│ интерполяция │ └──────────┬────────────┘
|
|
└───────────────┘ │ каналы
|
|
┌───────────▼────────────┐
|
|
│ Room (1 поток/задача) │
|
|
│ bevy_ecs World │
|
|
│ Schedule систем │
|
|
│ Interest manager │
|
|
└────────────────────────┘
|
|
```
|
|
|
|
Принципы:
|
|
|
|
1. **Одна комната — один `World`.** Комнаты независимы, масштабируются добавлением потоков и процессов. Внутри комнаты системы выполняются параллельно средствами `bevy_ecs`.
|
|
2. **Сервер авторитарен.** Клиент отправляет только запросы (подписка на область, позже команды влияния), состояние никогда не принимает от клиента.
|
|
3. **Статика отдельно от динамики.** Здания, дороги, интерьеры и мебель из OSM лежат в тайлах и не рассылаются через WebSocket. По сети идут только изменяемые сущности.
|
|
4. **Интерес по области (AOI).** Каждый игрок подписан на прямоугольник карты (вьюпорт плюс запас). Сервер шлёт ему дельты только для сущностей в этой области. Миллион сущностей к клиенту не уходит.
|
|
5. **LOD симуляции.** Вдали от всех наблюдателей жители симулируются грубо (потребности и расписания по шагам в десятки секунд, без пути по каждому тайлу), рядом с наблюдателем подробно. Это главный способ удержать миллион сущностей.
|
|
|
|
## 4. Формат данных города
|
|
|
|
Сейчас `src/types.ts` описывает город одним объектом, всё генерируется в браузере. Для игры нужно разрезать:
|
|
|
|
- **Манифест города**: границы, размер тайла, версия формата, список тайлов, метаданные.
|
|
- **Тайлы**: сетка (например 256 x 256 м). В тайле: дороги, здания, этажи, интерьеры, мебель, индекс проходимости (navmesh или сетка).
|
|
- **Формат файла**: бинарный (protobuf или FlatBuffers) со сжатием, чтобы читать и сервер, и клиент. На первом этапе допустим JSON + gzip, потом заменить.
|
|
- **Навигация**: для сервера отдельно строится граф дорог и проходимая сетка, чтобы жители ходили, не загружая геометрию.
|
|
|
|
**Интерьеры нужны серверу (решено).** Жители перемещаются по интерьерам и улицам и взаимодействуют с ними, поэтому сервер загружает полную проходимую геометрию: стены, двери, этажи, лестницы, мебель с типом, размером и точками взаимодействия (спать, сидеть, есть, работать). Загрузка по тайлам и лениво: интерьеры тайла нужны, только когда в нём есть наблюдатель или активные жители.
|
|
|
|
Для навигации строятся два уровня: грубый граф (здания, улицы, входы) для дальних путей и детальная сетка внутри зданий и вокруг для ближних. Переходы между уровнями — двери и лестницы.
|
|
|
|
**Улицы сейчас «пустоваты» (замечание автора).** До появления жителей на улицах нужно сгенерировать то, с чем они взаимодействуют снаружи: уличную мебель (скамейки, фонари, урны), остановки, парковки, дворы, переходы, входы в здания, места для торговли. Это отдельная задача генератора (этап 1б), см. раздел 9.
|
|
|
|
## 5. Модель симуляции (первая версия)
|
|
|
|
Компоненты (примерный набор):
|
|
|
|
- `Position`, `Velocity`, `TileId` (для быстрого AOI).
|
|
- `Agent` (житель), `Needs` (сон, еда, работа, отдых), `Schedule`, `Home`, `Workplace`.
|
|
- `Vehicle`, `Route`.
|
|
- `Building`, `Room`, `Furniture` (служебные ссылки на статику).
|
|
- `Dirty` (было ли изменение с прошлой рассылки).
|
|
|
|
Системы (порядок и зависимости задаёт `Schedule`):
|
|
|
|
1. Время и календарь.
|
|
2. Потребности и выбор цели.
|
|
3. Построение пути, движение.
|
|
4. События (пожар, болезнь, происшествие), позже вмешательство игроков.
|
|
5. Сбор изменений и рассылка по AOI.
|
|
|
|
Тик симуляции: 10 Гц фиксированный, скорость игры меняется множителем (как в RimWorld). Рассылка клиентам: 5–10 Гц, дельтами, клиент интерполирует.
|
|
|
|
## 6. Сеть и протокол
|
|
|
|
Сообщения клиент → сервер:
|
|
|
|
- `Join { room, client_version }`
|
|
- `Subscribe { rect, lod }` — какую область смотрю.
|
|
- `Ping`.
|
|
- (позже) `Intervene { ... }`.
|
|
|
|
Сообщения сервер → клиент:
|
|
|
|
- `Welcome { room_info, tick, city_manifest_url }`
|
|
- `Snapshot { tick, entities[] }` — полное состояние области при подписке.
|
|
- `Delta { tick, spawned[], changed[], despawned[] }` — изменения области.
|
|
- `Event { ... }` — редкие события (уведомления).
|
|
|
|
Правила:
|
|
|
|
- Позиции квантуются (например, в сантиметрах целым числом), идентификаторы компактные, дельты по полям.
|
|
- Версия протокола в рукопожатии, несовместимый клиент получает понятную ошибку.
|
|
- Ограничение размера и частоты запросов на клиента, ограничение размера области подписки.
|
|
|
|
## 7. Производительность: бюджеты
|
|
|
|
Ориентиры для проверки (замерить и уточнить):
|
|
|
|
- Тик комнаты с 1 млн сущностей: не более 50 мс на целевом сервере (при LOD, когда подробно симулируется лишь малая часть).
|
|
- Рассылка: не более 100 КБ/с на клиента в среднем.
|
|
- Память: порядка 100–200 байт на сущность в ECS, итого до 200 МБ на комнату без учёта статики.
|
|
- Подключение клиента: менее 2 с до первой картинки (за счёт тайлов, а не снимка всего мира).
|
|
|
|
Для проверки сразу закладываем нагрузочный стенд: генератор синтетической популяции (1 млн агентов на карте) и клиент-бот на 30 подключений.
|
|
|
|
## 8. Клиент (браузер)
|
|
|
|
- `src/view` остаётся, но получает данные не из воркера генерации, а из загрузчика тайлов и WebSocket-клиента.
|
|
- Слой состояния: хранит сущности вокруг камеры, применяет дельты, интерполирует позиции между тиками.
|
|
- Рендер динамики: на первом этапе Canvas, дальше (при 10–50 тыс. видимых сущностей) WebGL с инстансингом. Геометрию тайлов можно кэшировать в офскрин-текстуры.
|
|
- Режим «воспроизведение» (без сервера, текущий прототип) оставить как отладочный.
|
|
|
|
## 9. Этапы работ
|
|
|
|
**Этап 0. Подготовка (1 неделя)**
|
|
- [x] Зафиксировать решения этого документа, выбрать имя и структуру репозитория (монорепо: `server/`, `client/`, `tools/`, `proto/`).
|
|
- [x] Определить минимальную сцену для проверки: один небольшой город, 1000 агентов, 1 клиент.
|
|
|
|
**Этап 1. Тайловый формат (1–2 недели)**
|
|
- [x] Экспорт готового города из текущего генератора в тайлы + манифест (`tools/export`).
|
|
- [x] Клиент загружает город по тайлам, а не генерирует в браузере.
|
|
- [x] Тесты формата, версия формата в манифесте.
|
|
|
|
**Этап 1б. Обустройство улиц в генераторе (2 недели)**
|
|
- [x] Уличные объекты и точки взаимодействия снаружи зданий: скамейки, фонари, остановки, парковки, дворы, переходы, входы.
|
|
- [x] Единый реестр типов объектов с интерактивными слотами (как у мебели в `src/gen/catalog.ts`), общий для интерьеров и улиц.
|
|
- [x] Цель: к моменту появления жителей снаружи есть куда идти и что делать.
|
|
|
|
**Этап 2. Скелет сервера (1–2 недели)**
|
|
- [x] Rust-проект: `axum`, WebSocket, протокол (`proto/`), одна комната, `bevy_ecs`.
|
|
- [x] Загрузка манифеста и тайлов на сервере (навигационная сетка).
|
|
- [x] Подключение клиента, подписка на область, снимок.
|
|
|
|
**Этап 2б. Сервис генерации**
|
|
- [x] Очередь с лимитами площади, зданий, одновременных задач и таймаутом.
|
|
- [x] Изолированный TS-воркер, прогресс, кэш по нормализованному bbox, seed и версии.
|
|
- [x] `CreateCity` / `CityJob` в общей Protobuf-схеме и интерфейс создания города.
|
|
- [x] Сквозной тест запроса, отдельного Node-процесса, экспорта, открытия комнаты и сохранения без внешней сети.
|
|
|
|
**Этап 3. Простая симуляция (2–3 недели)**
|
|
- [x] Агенты ходят по дорогам между домом и работой по расписанию.
|
|
- [x] Дельты по AOI, интерполяция на клиенте.
|
|
- [x] Детерминизм: seed комнаты, воспроизводимые тики.
|
|
- [x] Бинарные атомарные снимки, ротация, ленивое открытие сохранённых комнат и тест эквивалентности после восстановления.
|
|
|
|
**Этап 4. Масштаб и LOD (2–3 недели)**
|
|
- [x] Нагрузочный стенд (1 млн агентов, 30 клиентов).
|
|
- [x] Профилирование, LOD симуляции, параллелизм систем.
|
|
Реализованы грубый шаг дальних маршрутов, общий пространственный индекс, стабильный LOD репликации и параллельная обработка движения через пул ECS. Построение индекса зависит от движения и идёт следующим шагом.
|
|
- [x] Оптимизация протокола и рендера.
|
|
Бинарные дельты, квантизация, объединение пропущенных обновлений под бюджетом трафика, ограничение видимых сущностей и кэш тайлов; динамика пока на Canvas.
|
|
|
|
**Этап 5. Базовый игровой слой**
|
|
- [x] Потребности, события, экономика.
|
|
Усреднённые голод, усталость и настроение населения; сон, питание, производство еды и доход казны. События урожая, болезни и дождя воспроизводятся из seed и игрового времени. Индивидуальный ИИ жителей пока остаётся расписанием дом–работа.
|
|
- [x] Ограниченное влияние игроков (команды, права, очереди, лимиты).
|
|
Покупка запасов, общий обед и отдых; одинаковые права подключённых игроков, цена из общей казны, ограниченная очередь, общий часовой игровой интервал и интервал сессии. Пауза/скорость остаются голосованием.
|
|
- [x] Сохранения, восстановление комнат, админка.
|
|
Схема v2 сохраняет игровой слой и очередь, v1 мигрируется при открытии. Добавлены общая квота снимков, защита прежних файлов при переполнении и админский интерфейс/API с отдельным токеном оператора.
|
|
|
|
**Этап 6. Эксплуатация**
|
|
- [x] Подготовка Compose, мониторинг (метрики тика, число сущностей, трафик), логи.
|
|
По уточнению автора этап 6 ограничен подготовкой Compose. Добавлены постоянные тома, healthcheck, лимиты, SIGTERM с сохранением, ротация логов и опциональный Prometheus с правилами предупреждений. Проверка `docker compose --profile monitoring config --quiet` проходит. Docker Engine выключен: сборка и запуск контейнера не проверены.
|
|
- [x] Создание комнат по запросу (выбор города), лимиты ресурсов.
|
|
|
|
Проверка минимальной сцены: `npm run demo`, `npm run build`, `npm run server:dev`, затем `http://localhost:3000/?play`.
|
|
Имя репозитория остаётся `citygen`; генератор и существующий рендер сохраняют свои пути в `src/`, новые сетевые модули находятся в `client/`.
|
|
Состояние и ограничения реализации описаны в `server/README.md`, запуск Compose — в `deploy/README.md`. Этап 5 реализует базовую городскую модель, этап 6 — подготовку Compose в согласованном объёме.
|
|
|
|
## 10. Риски
|
|
|
|
| Риск | Что делаем |
|
|
|---|---|
|
|
| 1 млн сущностей не успевают в тик | LOD, чанки, разреженные системы; замер на этапе 4, а не в конце |
|
|
| Объём рассылки вырастет | AOI, квантование, дельты, приоритет близким к камере |
|
|
| Размер тайлов и время загрузки | Компактный бинарный формат, сжатие, ленивая загрузка тайлов по камере |
|
|
| Крутая кривая Rust для команды | Держать код простым, избегать лишних абстракций, начать с одной комнаты |
|
|
| Изменение формата данных ломает сохранения | Версия формата, миграции, тесты совместимости |
|
|
| Дублирование типов между Rust и TS | Единая схема в `proto/`, кодогенерация |
|
|
|
|
## 11. Принятые решения, допущения и открытые вопросы
|
|
|
|
Принятые решения:
|
|
|
|
1. **Интерьеры нужны на сервере** (раздел 4): жители ходят по интерьерам и улицам и взаимодействуют с ними.
|
|
2. **Без аккаунтов.** Вход по нику; персистентности игрока между комнатами пока нет. Ник уникален в пределах комнаты, идентификатор сессии выдаёт сервер.
|
|
3. **Создание комнат игроком** с лимитом из конфига сервера (`max_rooms`, по умолчанию 5). При достижении лимита создание отклоняется с понятной ошибкой. Пустые комнаты закрываются по таймауту (параметр конфига), чтобы не занимать слот.
|
|
4. **Время общее для комнаты.** Пауза и смена скорости — через голосование: предложение принимается, если «за» большинство подключённых игроков (более половины). Один игрок в комнате решает сам. Нужны: таймаут голосования, один активный вопрос за раз, защита от спама предложениями.
|
|
5. **Только десктопные браузеры.** Мобильные не поддерживаем, рендер и управление рассчитываем на мышь и клавиатуру.
|
|
|
|
6. **`max_rooms` — лимит на весь сервер** одновременно открытых комнат (подтверждено).
|
|
7. **Игрок создаёт свой город**: выбирает область на карте и запускает генерацию (раздел 12). Каталог готовых городов тоже остаётся.
|
|
8. **Владельца комнаты нет**: создатель не может закрыть комнату, выгнать игрока или поставить пароль. Комната закрывается только по таймауту пустоты.
|
|
9. **Комнаты переживают перезапуск сервера**: состояние сохраняется на диск (раздел 13).
|
|
|
|
Допущения (если неверны, скажи — поправлю):
|
|
|
|
1. Серверная симуляция детерминируема от seed. Отдельный вопрос, нужна ли строгая детерминированность (для повторов и отладки).
|
|
2. Деплой на один сервер, комнаты как задачи внутри одного процесса. Горизонтальное масштабирование — позже.
|
|
3. Бюджет железа: современный многоядерный сервер, 8+ ядер.
|
|
|
|
Открытые вопросы:
|
|
|
|
1. Лимит размера области для генерации (площадь, число зданий) и лимит одновременных генераций. Предварительно: берём из конфига, значения подобрать после замеров на этапе 1.
|
|
2. Что делать с комнатой без владельца, если она не закрывается: пустая комната закрывается через N минут, но сохраняется на диск и может быть открыта заново? Предварительно: да, закрытая комната хранится как снимок, пока есть место.
|
|
3. Нужна ли защита от злоупотреблений при отсутствии аккаунтов (лимит создания по IP, антиспам)? Предварительно: да, простой лимит по IP.
|
|
|
|
## 12. Создание города игроком
|
|
|
|
Генерация города — тяжёлая работа (запросы Overpass, построение зданий и интерьеров), поэтому выполняется отдельным сервисом, а не в потоке комнаты.
|
|
|
|
Процесс:
|
|
|
|
1. Игрок на карте выбирает прямоугольник (в клиенте уже есть выбор области: `src/view/picker.ts`, ограничения размера в `src/main.ts`).
|
|
2. Клиент отправляет серверу `CreateCity { bbox, name }`. Сервер проверяет лимит площади, лимит очереди и лимит комнат (`max_rooms`).
|
|
3. Задача попадает в очередь. Воркер (текущий TS-генератор как отдельный Node-процесс, использующий `scripts/cli.ts` и `src/gen`) запрашивает Overpass, генерирует город, экспортирует тайлы и манифест (этап 1) в хранилище.
|
|
4. Клиент видит прогресс (в очереди, загрузка OSM, генерация, экспорт). По готовности город попадает в каталог игрока и создаётся комната.
|
|
5. Ошибки Overpass (таймауты, лимиты) обрабатываются повторами и понятными сообщениями; уже есть наработки в `src/osm/overpass.ts`.
|
|
|
|
Требования:
|
|
|
|
- Города кэшируются по нормализованному bbox и версии генератора, чтобы одну область не генерировать дважды.
|
|
- Ограничения: максимальная площадь и число зданий, максимум одновременных задач (конфиг), таймаут задачи.
|
|
- Версия формата и генератора пишется в манифест; при смене версии кэш инвалидируется.
|
|
- Генерация должна быть детерминированной от (bbox, seed, версия), чтобы город можно было воспроизвести.
|
|
|
|
## 13. Сохранение и восстановление комнат
|
|
|
|
- **Снимки** `World` раз в N минут и при остановке: сущности, компоненты, время игры, seed, состояние голосований не сохраняется (сбрасывается).
|
|
- **Формат**: бинарный, версионируемый (протокол из `proto/`), атомарная запись во временный файл с переименованием, хранение последних K снимков.
|
|
- **Восстановление**: при старте сервера список комнат читается с диска, комнаты открываются лениво при первом подключении игрока, чтобы не держать в памяти все.
|
|
- **Миграции**: при смене схемы компонентов снимок либо мигрируется, либо комната помечается несовместимой с понятным сообщением.
|
|
- **Целостность**: комната ссылается на город по идентификатору и версии манифеста; удаление или смена города при существующих комнатах запрещена.
|
|
|
|
Дополнение к этапам:
|
|
|
|
- Этап 2: пустая комната сохраняется и восстанавливается (скелет сохранения).
|
|
- Этап 3: сохранение/загрузка простой симуляции, тест «остановил — запустил — состояние совпадает».
|
|
- Этап 5: миграции, ротация снимков, лимиты места.
|
|
- Новый этап **2б. Сервис генерации** (2 недели): очередь задач, воркер на базе `scripts/cli.ts`, кэш по bbox, прогресс и лимиты, протокол `CreateCity` и ответы о статусе.
|
|
|
|
## 14. Проверки реализации
|
|
|
|
- TypeScript: `npm run typecheck`, `npm test`, `npm run build`.
|
|
- Rust: `npm run server:test`; отдельно проверяются навигация через двери, точность пространственного индекса, детерминизм, атомарные снимки и ротация.
|
|
- Интеграция: 30 настоящих WebSocket-подключений, отказ 31-му, голосование 15/30 против 16/30, повторное подключение после восстановления.
|
|
- Очередь: Protobuf-запрос → отдельный TS-процесс с локальной OSM-выгрузкой → экспорт с контрольными суммами → открытие комнаты → сохранение. Внешний Overpass в тесте не используется.
|
|
- Chrome: каталог, вход, загрузка тайлов, этажи и пауза через голосование. Повторяемая проверка: `scripts/browser-smoke.mjs` при установленном Playwright.
|
|
- Реальная нагрузка, 2026-10-30: Бальцерс, 1 000 000 жителей, 30 WebSocket-клиентов, 20 с, фоновые сохранения каждые 2 с. Выборка длительности тиков через метрики: p95 36,5 мс; максимум всех тиков по счётчику комнаты 44,7 мс. Средний трафик 4,8 КБ/с на клиента, максимальный 44,7 КБ/с. Ошибок, разрывов и срабатываний ограничения трафика не было. Это короткий локальный тест с небольшими AOI, а не гарантия для любого города, плотности, диска и целевого сервера. Пиковая память процесса со статикой и буферами — около 272 МиБ; память одной ECS отдельно не измерялась.
|
|
- Браузер: повторный вход в сохранённую миллионную комнату до загруженной области — 1,31 с в локальном Chrome.
|
|
|
|
- Этапы 5–6: 17 TS-тестов, 11 Rust unit-тестов и 2 интеграционных теста проходят. Проверены миграция v1→v2, сохранение очереди команды, детерминизм экономики/событий, влияние потребностей на производство, расход еды, квота без потери последнего снимка, отказ без админского токена и подтверждение записи через API.
|
|
- Chrome: отображение казны/еды/потребностей, команда «Обед» со списанием ресурсов и снижением голода, пауза, этаж/масштаб, админское сохранение. Загрузка области — 1,12 с локально.
|
|
|
|
Дальнейшее развитие за пределами базового слоя: индивидуальные потребности и
|
|
выбор целей, детальные переходы между этажами, использование мебели и
|
|
уличных слотов жителями, транспорт. Подготовленный Compose ещё нужно собрать
|
|
и запустить на Docker-хосте; внешнее развёртывание в текущий запрос не входит.
|