Implement tiled multiplayer city simulation and scaling stages

This commit is contained in:
mrleo1nid committed 2026-10-30 22:06:56 +03:00
1 parent 3d4b29fedf
commit 3b1fcd996a
53 files changed
+7354 -13

No files matched your search

+275
View File
@@ -0,0 +1,275 @@
# План: переход к клиент-серверной игре
Статус: выполняется. Дата: 2026-10-30. Проверенный прогресс отмечен в разделе 9.
## 1. Цель и рамки
Игра: симулятор бога в духе RimWorld, но с меньшим влиянием игрока. Город живёт сам (жители, машины, потребности, события), игроки в основном наблюдают и ограниченно вмешиваются (вмешательство в первую версию не входит).
Исходные требования:
- До 30 игроков в одной комнате.
- Комната — отдельный инстанс мира, у каждой комнаты свой город (из OSM).
- Сущностей очень много, до миллиона на комнату.
- Клиент остаётся в браузере и только отображает. Вся симуляция и правила на сервере.
- Сгенерированный город хранится готовым, генерация не выполняется в рантайме комнаты.
Не входит в первую версию: влияние игроков на мир, аккаунты и монетизация, предсказание на клиенте (в наблюдательной игре оно не нужно).
## 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. Игровой слой (дальше)**
- [ ] Потребности, события, экономика.
- [ ] Ограниченное влияние игроков (команды, права, очереди, лимиты).
- [ ] Сохранения, восстановление комнат, админка.
Сохранения и восстановление уже работают; админка, миграции схем и квоты диска остаются следующей частью этапа.
**Этап 6. Эксплуатация**
- [ ] Деплой, мониторинг (метрики тика, число сущностей, трафик), логи.
Метрики, логи и Dockerfile подготовлены. Docker Engine в текущей среде не запущен; внешний сервер для деплоя не задан.
- [x] Создание комнат по запросу (выбор города), лимиты ресурсов.
Проверка минимальной сцены: `npm run demo`, `npm run build`, `npm run server:dev`, затем `http://localhost:3000/?play`.
Имя репозитория остаётся `citygen`; генератор и существующий рендер сохраняют свои пути в `src/`, новые сетевые модули находятся в `client/`.
Состояние и ограничения реализации описаны в `server/README.md`. Этапы 5–6 не объявляются завершёнными готовностью сетевого прототипа.
## 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.
Для эксплуатации также остаются миграции снимков, квоты диска, админка,
проверка контейнера и развёртывание в выбранном окружении.