diff --git a/.dockerignore b/.dockerignore index 67e5ce7..cca66ef 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,3 +6,4 @@ server/target data world.json public/cities +.env diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b4aa1d3 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Optional administrator API; at least 32 random characters. Empty disables it. +CITYGEN_ADMIN_TOKEN= +CITYGEN_HOST_IP=127.0.0.1 +CITYGEN_PORT=3000 +CITYGEN_AGENTS=1000 +CITYGEN_MAX_ROOMS=5 +CITYGEN_MEMORY=4g +CITYGEN_CPUS=4 +CITYGEN_SIM_THREADS=4 +CITYGEN_SAVE_QUOTA_BYTES=2147483648 diff --git a/.gitignore b/.gitignore index 7e2966f..8f08165 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ world.json server/target/ data/ public/cities/ +.env diff --git a/Dockerfile b/Dockerfile index e814ddb..9b1e393 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,9 +17,13 @@ COPY --from=client /app/node_modules node_modules COPY --from=client /app/dist dist COPY --from=client /app/src src COPY --from=client /app/tools tools +COPY --from=client /app/samples samples COPY --from=client /app/package.json package.json COPY --from=server /app/server/target/release/citygen-server /usr/local/bin/citygen-server +RUN mkdir -p /app/public/cities /app/data && chown -R node:node /app ENV CITYGEN_BIND=0.0.0.0:3000 +USER node +HEALTHCHECK --interval=15s --timeout=3s --start-period=30s --retries=3 CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" VOLUME ["/app/public/cities", "/app/data"] EXPOSE 3000 CMD ["citygen-server"] diff --git a/README.md b/README.md index 1f3eec0..7327c0e 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,13 @@ комнаты до 30 наблюдателей, жители по расписанию, общий календарь и голосования, сохранения и очередь генерации. Браузерный генератор ниже остаётся режимом отладки. +## Эксплуатация и игровой слой + +Для запуска через Docker Compose см. [deploy/README.md](deploy/README.md). +В комнате доступны казна, запасы еды, потребности населения, события и три +ограниченных действия игроков. Подробности модели и настроек — +[server/README.md](server/README.md). Интерфейс оператора: `/?admin`. + ## Сетевая игра Нужны Node.js 22+ и Rust stable (на Windows — установленный MSVC linker). diff --git a/client/admin.ts b/client/admin.ts new file mode 100644 index 0000000..4e83d14 --- /dev/null +++ b/client/admin.ts @@ -0,0 +1,38 @@ +/** Operator UI. The token lives only in this page's memory, never in a URL or storage. */ +export function showAdmin(root: HTMLElement) { + root.replaceChildren(); + const panel = document.createElement('section'); panel.className = 'card picker-panel'; panel.style.maxWidth = '850px'; + panel.innerHTML = `

Администрирование

Комнаты`; + root.appendChild(panel); + const status = panel.querySelector('[role=status]')!; + const results = panel.querySelector('[data-results]')!; + const request = async (path: string, method = 'GET') => { + const token = panel.querySelector('input')!.value; + const response = await fetch(path, { method, headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(25_000) }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`); + return data; + }; + const refresh = async () => { + results.replaceChildren(); status.textContent = 'Загрузка…'; + try { + const data = await request('/api/admin'); + status.textContent = `Снимки: ${(data.saveBytes / 1048576).toFixed(1)} / ${(data.saveQuotaBytes / 1048576).toFixed(1)} МиБ`; + for (const room of data.rooms) { + const row = document.createElement('p'); + row.textContent = `${room.id} · ${room.cityId} · ${room.players} игроков · тик ${room.tick} · сохранён ${room.savedTick} · ошибок записи ${room.saveErrors} `; + const button = document.createElement('button'); button.className = 'btn'; button.textContent = 'Сохранить'; button.disabled = !room.active; + button.onclick = async () => { + button.disabled = true; + try { await request(`/api/admin/rooms/${encodeURIComponent(room.id)}/checkpoint`, 'POST'); await refresh(); } + catch (e) { status.textContent = (e as Error).message; button.disabled = false; } + }; + row.appendChild(button); results.appendChild(row); + } + for (const snapshot of data.snapshots) if (snapshot.error) { + const row = document.createElement('p'); row.textContent = `${snapshot.id}: ${snapshot.error}`; results.appendChild(row); + } + } catch (e) { status.textContent = (e as Error).message; } + }; + panel.querySelector('[data-refresh]')!.onclick = () => { void refresh(); }; +} diff --git a/client/connection.ts b/client/connection.ts index 71aa7f3..9f95c11 100644 --- a/client/connection.ts +++ b/client/connection.ts @@ -1,5 +1,5 @@ import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; -import { ClientMessageSchema, ServerMessageSchema, type ClientMessage, type Welcome, type Clock, type VoteState } from './generated/city_pb'; +import { ClientMessageSchema, ServerMessageSchema, type ClientMessage, type Welcome, type Clock, type VoteState, type Gameplay } from './generated/city_pb'; import type { Bounds } from '../src/city/format'; import { EntityState } from './entities'; @@ -15,6 +15,7 @@ export class RoomConnection { constructor(room: string, nickname: string, private events: { welcome: (welcome: Welcome) => void; status: (text: string) => void; clock: (clock: Clock) => void; vote: (vote: VoteState) => void; + gameplay: (state: Gameplay) => void; }) { const url = new URL(`/api/rooms/${encodeURIComponent(room)}/ws`, location.href); url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; @@ -42,6 +43,7 @@ export class RoomConnection { } else events.status(b.value.code === 'VOTE_LIMIT' ? 'Дождитесь завершения голосования или 30-секундного интервала между предложениями' : `${b.value.code}: ${b.value.message}`); break; case 'clock': events.clock(b.value); break; + case 'gameplay': events.gameplay(b.value); break; case 'voteState': events.vote(b.value); break; } } catch (error) { events.status((error as Error).message); this.destroy(); } @@ -64,5 +66,6 @@ export class RoomConnection { } propose(speed: number) { this.send({ case: 'propose', value: { speed } }); } vote(yes: boolean) { this.send({ case: 'vote', value: { yes } }); } + intervene(action: string) { this.send({ case: 'intervene', value: { action } }); } destroy() { this.closed = true; clearTimeout(this.retry); clearInterval(this.ping); this.socket.close(); this.entities.clear(); } } diff --git a/client/generated/city_pb.ts b/client/generated/city_pb.ts index 44acb96..9caf958 100644 --- a/client/generated/city_pb.ts +++ b/client/generated/city_pb.ts @@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file city.proto. */ export const file_city: GenFile = /*@__PURE__*/ - fileDesc("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"); + fileDesc("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"); /** * @generated from message city.Rect @@ -144,6 +144,121 @@ export type Vote = Message<"city.Vote"> & { export const VoteSchema: GenMessage = /*@__PURE__*/ messageDesc(file_city, 5); +/** + * Equal rights for every connected player; only these three bounded actions exist. + * + * @generated from message city.Intervene + */ +export type Intervene = Message<"city.Intervene"> & { + /** + * @generated from field: string action = 1; + */ + action: string; +}; + +/** + * Describes the message city.Intervene. + * Use `create(InterveneSchema)` to create a new message. + */ +export const InterveneSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 6); + +/** + * @generated from message city.Needs + */ +export type Needs = Message<"city.Needs"> & { + /** + * @generated from field: uint32 hunger = 1; + */ + hunger: number; + + /** + * @generated from field: uint32 fatigue = 2; + */ + fatigue: number; + + /** + * @generated from field: uint32 morale = 3; + */ + morale: number; +}; + +/** + * Describes the message city.Needs. + * Use `create(NeedsSchema)` to create a new message. + */ +export const NeedsSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 7); + +/** + * @generated from message city.GameEvent + */ +export type GameEvent = Message<"city.GameEvent"> & { + /** + * @generated from field: uint64 minute = 1; + */ + minute: bigint; + + /** + * @generated from field: string kind = 2; + */ + kind: string; +}; + +/** + * Describes the message city.GameEvent. + * Use `create(GameEventSchema)` to create a new message. + */ +export const GameEventSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 8); + +/** + * @generated from message city.Gameplay + */ +export type Gameplay = Message<"city.Gameplay"> & { + /** + * @generated from field: sint64 treasury = 1; + */ + treasury: bigint; + + /** + * @generated from field: uint64 food = 2; + */ + food: bigint; + + /** + * @generated from field: city.Needs needs = 3; + */ + needs?: Needs | undefined; + + /** + * @generated from field: repeated city.GameEvent events = 4; + */ + events: GameEvent[]; + + /** + * @generated from field: repeated string pending = 5; + */ + pending: string[]; + + /** + * @generated from field: uint64 minute = 6; + */ + minute: bigint; + + /** + * @generated from field: uint64 next_intervention_minute = 7; + */ + nextInterventionMinute: bigint; +}; + +/** + * Describes the message city.Gameplay. + * Use `create(GameplaySchema)` to create a new message. + */ +export const GameplaySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 9); + /** * @generated from message city.CreateCity */ @@ -169,7 +284,7 @@ export type CreateCity = Message<"city.CreateCity"> & { * Use `create(CreateCitySchema)` to create a new message. */ export const CreateCitySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 6); + messageDesc(file_city, 10); /** * @generated from message city.CityJob @@ -211,7 +326,7 @@ export type CityJob = Message<"city.CityJob"> & { * Use `create(CityJobSchema)` to create a new message. */ export const CityJobSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 7); + messageDesc(file_city, 11); /** * @generated from message city.ClientMessage @@ -250,6 +365,12 @@ export type ClientMessage = Message<"city.ClientMessage"> & { */ value: Vote; case: "vote"; + } | { + /** + * @generated from field: city.Intervene intervene = 6; + */ + value: Intervene; + case: "intervene"; } | { case: undefined; value?: undefined }; }; @@ -258,7 +379,7 @@ export type ClientMessage = Message<"city.ClientMessage"> & { * Use `create(ClientMessageSchema)` to create a new message. */ export const ClientMessageSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 8); + messageDesc(file_city, 12); /** * @generated from message city.Agent @@ -290,7 +411,7 @@ export type Agent = Message<"city.Agent"> & { * Use `create(AgentSchema)` to create a new message. */ export const AgentSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 9); + messageDesc(file_city, 13); /** * @generated from message city.Welcome @@ -327,7 +448,7 @@ export type Welcome = Message<"city.Welcome"> & { * Use `create(WelcomeSchema)` to create a new message. */ export const WelcomeSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 10); + messageDesc(file_city, 14); /** * @generated from message city.Snapshot @@ -349,7 +470,7 @@ export type Snapshot = Message<"city.Snapshot"> & { * Use `create(SnapshotSchema)` to create a new message. */ export const SnapshotSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 11); + messageDesc(file_city, 15); /** * @generated from message city.Delta @@ -381,7 +502,7 @@ export type Delta = Message<"city.Delta"> & { * Use `create(DeltaSchema)` to create a new message. */ export const DeltaSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 12); + messageDesc(file_city, 16); /** * @generated from message city.Error @@ -403,7 +524,7 @@ export type Error = Message<"city.Error"> & { * Use `create(ErrorSchema)` to create a new message. */ export const ErrorSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 13); + messageDesc(file_city, 17); /** * @generated from message city.VoteState @@ -440,7 +561,7 @@ export type VoteState = Message<"city.VoteState"> & { * Use `create(VoteStateSchema)` to create a new message. */ export const VoteStateSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 14); + messageDesc(file_city, 18); /** * @generated from message city.Clock @@ -467,7 +588,7 @@ export type Clock = Message<"city.Clock"> & { * Use `create(ClockSchema)` to create a new message. */ export const ClockSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 15); + messageDesc(file_city, 19); /** * @generated from message city.ServerMessage @@ -518,6 +639,12 @@ export type ServerMessage = Message<"city.ServerMessage"> & { */ value: Clock; case: "clock"; + } | { + /** + * @generated from field: city.Gameplay gameplay = 8; + */ + value: Gameplay; + case: "gameplay"; } | { case: undefined; value?: undefined }; }; @@ -526,7 +653,7 @@ export type ServerMessage = Message<"city.ServerMessage"> & { * Use `create(ServerMessageSchema)` to create a new message. */ export const ServerMessageSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 16); + messageDesc(file_city, 20); /** * @generated from message city.SavedAgent @@ -553,7 +680,7 @@ export type SavedAgent = Message<"city.SavedAgent"> & { * Use `create(SavedAgentSchema)` to create a new message. */ export const SavedAgentSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 17); + messageDesc(file_city, 21); /** * @generated from message city.Route @@ -580,7 +707,7 @@ export type Route = Message<"city.Route"> & { * Use `create(RouteSchema)` to create a new message. */ export const RouteSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 18); + messageDesc(file_city, 22); /** * @generated from message city.RoomSave @@ -640,6 +767,11 @@ export type RoomSave = Message<"city.RoomSave"> & { * @generated from field: string city_revision = 11; */ cityRevision: string; + + /** + * @generated from field: city.Gameplay gameplay = 12; + */ + gameplay?: Gameplay | undefined; }; /** @@ -647,5 +779,5 @@ export type RoomSave = Message<"city.RoomSave"> & { * Use `create(RoomSaveSchema)` to create a new message. */ export const RoomSaveSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 19); + messageDesc(file_city, 23); diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..01a52f7 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,61 @@ +name: citygen +services: + app: + build: . + init: true + restart: unless-stopped + ports: + - "${CITYGEN_HOST_IP:-127.0.0.1}:${CITYGEN_PORT:-3000}:3000" + environment: + CITYGEN_BIND: 0.0.0.0:3000 + CITYGEN_ADMIN_TOKEN: ${CITYGEN_ADMIN_TOKEN:-} + CITYGEN_AGENTS: ${CITYGEN_AGENTS:-1000} + CITYGEN_MAX_ROOMS: ${CITYGEN_MAX_ROOMS:-5} + CITYGEN_SAVE_INTERVAL: ${CITYGEN_SAVE_INTERVAL:-60} + CITYGEN_SAVE_QUOTA_BYTES: ${CITYGEN_SAVE_QUOTA_BYTES:-2147483648} + CITYGEN_SIM_THREADS: ${CITYGEN_SIM_THREADS:-4} + CITYGEN_MAX_GENERATION_KM2: ${CITYGEN_MAX_GENERATION_KM2:-25} + CITYGEN_MAX_BUILDINGS: ${CITYGEN_MAX_BUILDINGS:-10000} + CITYGEN_GENERATION_WORKERS: ${CITYGEN_GENERATION_WORKERS:-2} + volumes: + - cities:/app/public/cities + - saves:/app/data + stop_grace_period: 120s + mem_limit: ${CITYGEN_MEMORY:-4g} + cpus: ${CITYGEN_CPUS:-4} + pids_limit: 256 + security_opt: + - no-new-privileges:true + cap_drop: [ALL] + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + prometheus: + image: prom/prometheus:v3.15.1 + profiles: [monitoring] + restart: unless-stopped + depends_on: + app: + condition: service_healthy + command: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.retention.time=7d + - --storage.tsdb.retention.size=1GB + ports: + - "127.0.0.1:${CITYGEN_METRICS_PORT:-9090}:9090" + volumes: + - ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./deploy/alerts.yml:/etc/prometheus/alerts.yml:ro + - metrics:/prometheus + mem_limit: 512m + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" +volumes: + cities: + saves: + metrics: diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..ada94c5 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,75 @@ +# Запуск через Compose + +Из корня репозитория, с работающим Docker Engine: + +```sh +cp .env.example .env +docker compose config --quiet +docker compose build +# Необязательно: подготовить город из локальной OSM-выгрузки. +docker compose run --rm --no-deps app node --import tsx tools/demo.ts +docker compose up -d --wait +``` + +Открыть `http://localhost:3000/?play`. Можно создать город через интерфейс. +Города и сохранения находятся в отдельных постоянных томах `cities` и `saves`. +Пересборка и `docker compose down` сохраняют тома. `down -v` удаляет их. +Существующий каталог города нельзя перезаписывать, пока на него ссылаются комнаты. + +Приложение работает от пользователя `node`, с лимитами памяти, CPU, +процессов и ротацией логов. Значения `.env.example` рассчитаны на старт с +1000 жителями; миллион жителей и пять комнат требуют замера и настройки. +Порт по умолчанию доступен локально. Для внешнего доступа измените +`CITYGEN_HOST_IP` или используйте HTTPS reverse proxy с WebSocket. +Встроенный лимит создания видит IP TCP-соединения, поэтому за прокси он +общий для всех посетителей; недоверенные X-Forwarded-For не используются. + +Остановка посылает SIGTERM; сервер завершает комнаты и ждёт запись снимков +(до 120 секунд). Перед обновлением: + +```sh +docker compose stop +# Сделать резервную копию ОБОИХ томов средствами Docker-хоста. +docker compose build +docker compose up -d --wait +``` + +Проверка: `docker compose ps`, `docker compose logs --tail=100 app`, +`curl --fail http://localhost:3000/health`. Миграция комнат v1→v2 выполняется +при открытии комнаты; неизвестная версия остаётся на диске с ошибкой. +Квота `CITYGEN_SAVE_QUOTA_BYTES` включает все снимки и временный новый файл +до ротации: оставьте запас минимум на один полный снимок. При переполнении +запись отклоняется, прежние снимки сохраняются. Квота относится к снимкам; +объём городов ограничивайте средствами Docker-хоста. + +## Администрирование + +Задать `CITYGEN_ADMIN_TOKEN` случайной строкой минимум из 32 символов в `.env` +и пересоздать контейнер. Пустое значение отключает API. `.env` игнорируется Git +и не попадает в build context. `/?admin` показывает комнаты, использование +диска и ошибки несовместимых снимков, позволяет сохранить активную комнату. +Ответ приходит после записи. Токен остаётся только в памяти страницы. + +API: `GET /api/admin`, `POST /api/admin/rooms/{id}/checkpoint`, +заголовок `Authorization: Bearer `. Привилегии оператора не выдаются +игрокам и создателям комнат. Удаление миров/городов через API не предусмотрено. + +## Мониторинг + +```sh +docker compose --profile monitoring up -d --wait +``` + +Prometheus на `http://localhost:9090` опрашивает `/metrics` каждые 5 секунд, +хранит до 7 дней / 1 ГБ. `alerts.yml` содержит правила недоступности, +тиков дольше 50 мс и ошибок сохранения. Предупреждения видны в Prometheus; +внешние уведомления требуют отдельного Alertmanager. Зафиксирована +[версия 3.15.1](https://github.com/prometheus/prometheus/releases/tag/v3.15.1). + +Метрики: комнаты/игроки, сущности, последний/максимальный тик, +байты рассылки, ошибки и последний успешный тик сохранения. Ошибки снимков, +медленные тики, запуск/остановка пишутся JSON-строками в stderr. + +Без Engine можно проверить `docker compose --profile monitoring config --quiet`. +Полный smoke-test контейнера после запуска: `/health`, вход двух клиентов, +команда «Обед», остановка/запуск и проверка восстановленного состояния. diff --git a/deploy/alerts.yml b/deploy/alerts.yml new file mode 100644 index 0000000..7b2846c --- /dev/null +++ b/deploy/alerts.yml @@ -0,0 +1,23 @@ +groups: + - name: citygen + rules: + - alert: CitygenDown + expr: up{job="citygen"} == 0 + for: 1m + labels: + severity: critical + annotations: + summary: Citygen cannot be scraped + - alert: CitygenSlowTick + expr: citygen_room_tick_seconds > 0.05 + for: 30s + labels: + severity: warning + annotations: + summary: Room tick exceeds 50 ms + - alert: CitygenSaveFailure + expr: increase(citygen_room_save_errors_total[5m]) > 0 + labels: + severity: critical + annotations: + summary: Room snapshot failed; check disk quota and logs diff --git a/deploy/prometheus.yml b/deploy/prometheus.yml new file mode 100644 index 0000000..4d043f9 --- /dev/null +++ b/deploy/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 5s +rule_files: + - /etc/prometheus/alerts.yml +scrape_configs: + - job_name: citygen + static_configs: + - targets: [app:3000] diff --git a/docs/PLAN.md b/docs/PLAN.md index a89cd8c..28be18c 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -4,7 +4,7 @@ ## 1. Цель и рамки -Игра: симулятор бога в духе RimWorld, но с меньшим влиянием игрока. Город живёт сам (жители, машины, потребности, события), игроки в основном наблюдают и ограниченно вмешиваются (вмешательство в первую версию не входит). +Игра: симулятор бога в духе RimWorld, но с меньшим влиянием игрока. Город живёт сам (жители, машины, потребности, события), игроки в основном наблюдают и ограниченно вмешиваются. По запросу автора от 2026-10-30 этап 5 включает базовые команды вмешательства. Исходные требования: @@ -14,7 +14,7 @@ - Клиент остаётся в браузере и только отображает. Вся симуляция и правила на сервере. - Сгенерированный город хранится готовым, генерация не выполняется в рантайме комнаты. -Не входит в первую версию: влияние игроков на мир, аккаунты и монетизация, предсказание на клиенте (в наблюдательной игре оно не нужно). +Не входит в первую версию: аккаунты и монетизация, предсказание на клиенте (в наблюдательной игре оно не нужно). Влияние ограничено командами этапа 5. ## 2. Ключевые решения @@ -172,20 +172,22 @@ - [x] Оптимизация протокола и рендера. Бинарные дельты, квантизация, объединение пропущенных обновлений под бюджетом трафика, ограничение видимых сущностей и кэш тайлов; динамика пока на Canvas. -**Этап 5. Игровой слой (дальше)** -- [ ] Потребности, события, экономика. -- [ ] Ограниченное влияние игроков (команды, права, очереди, лимиты). -- [ ] Сохранения, восстановление комнат, админка. - Сохранения и восстановление уже работают; админка, миграции схем и квоты диска остаются следующей частью этапа. +**Этап 5. Базовый игровой слой** +- [x] Потребности, события, экономика. + Усреднённые голод, усталость и настроение населения; сон, питание, производство еды и доход казны. События урожая, болезни и дождя воспроизводятся из seed и игрового времени. Индивидуальный ИИ жителей пока остаётся расписанием дом–работа. +- [x] Ограниченное влияние игроков (команды, права, очереди, лимиты). + Покупка запасов, общий обед и отдых; одинаковые права подключённых игроков, цена из общей казны, ограниченная очередь, общий часовой игровой интервал и интервал сессии. Пауза/скорость остаются голосованием. +- [x] Сохранения, восстановление комнат, админка. + Схема v2 сохраняет игровой слой и очередь, v1 мигрируется при открытии. Добавлены общая квота снимков, защита прежних файлов при переполнении и админский интерфейс/API с отдельным токеном оператора. **Этап 6. Эксплуатация** -- [ ] Деплой, мониторинг (метрики тика, число сущностей, трафик), логи. - Метрики, логи и Dockerfile подготовлены. Docker Engine в текущей среде не запущен; внешний сервер для деплоя не задан. +- [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`. Этапы 5–6 не объявляются завершёнными готовностью сетевого прототипа. +Состояние и ограничения реализации описаны в `server/README.md`, запуск Compose — в `deploy/README.md`. Этап 5 реализует базовую городскую модель, этап 6 — подготовку Compose в согласованном объёме. ## 10. Риски @@ -269,7 +271,10 @@ - Реальная нагрузка, 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. -Для эксплуатации также остаются миграции снимков, квоты диска, админка, -проверка контейнера и развёртывание в выбранном окружении. +- Этапы 5–6: 17 TS-тестов, 11 Rust unit-тестов и 2 интеграционных теста проходят. Проверены миграция v1→v2, сохранение очереди команды, детерминизм экономики/событий, влияние потребностей на производство, расход еды, квота без потери последнего снимка, отказ без админского токена и подтверждение записи через API. +- Chrome: отображение казны/еды/потребностей, команда «Обед» со списанием ресурсов и снижением голода, пауза, этаж/масштаб, админское сохранение. Загрузка области — 1,12 с локально. + +Дальнейшее развитие за пределами базового слоя: индивидуальные потребности и +выбор целей, детальные переходы между этажами, использование мебели и +уличных слотов жителями, транспорт. Подготовленный Compose ещё нужно собрать +и запустить на Docker-хосте; внешнее развёртывание в текущий запрос не входит. diff --git a/proto/city.proto b/proto/city.proto index 9bec345..fde3b47 100644 --- a/proto/city.proto +++ b/proto/city.proto @@ -7,10 +7,19 @@ message Subscribe { Rect rect = 1; uint32 lod = 2; } message Ping { uint32 nonce = 1; } message Propose { uint32 speed = 1; } message Vote { bool yes = 1; } +// Equal rights for every connected player; only these three bounded actions exist. +message Intervene { string action = 1; } +message Needs { uint32 hunger = 1; uint32 fatigue = 2; uint32 morale = 3; } +message GameEvent { uint64 minute = 1; string kind = 2; } +message Gameplay { + sint64 treasury = 1; uint64 food = 2; Needs needs = 3; + repeated GameEvent events = 4; repeated string pending = 5; + uint64 minute = 6; uint64 next_intervention_minute = 7; +} message CreateCity { repeated double bbox = 1; string name = 2; uint32 seed = 3; } message CityJob { string id = 1; string city_id = 2; string status = 3; string room_id = 4; string error = 5; string stage = 6; } message ClientMessage { - oneof body { Join join = 1; Subscribe subscribe = 2; Ping ping = 3; Propose propose = 4; Vote vote = 5; } + oneof body { Join join = 1; Subscribe subscribe = 2; Ping ping = 3; Propose propose = 4; Vote vote = 5; Intervene intervene = 6; } } message Agent { uint32 id = 1; sint32 x = 2; sint32 y = 3; uint32 kind = 4; } message Welcome { string room = 1; uint64 tick = 2; string city_manifest_url = 3; string session = 4; uint32 protocol_version = 5; } @@ -20,7 +29,7 @@ message Error { string code = 1; string message = 2; } message VoteState { bool active = 1; uint32 speed = 2; uint32 yes = 3; uint32 players = 4; uint32 remaining_seconds = 5; } message Clock { uint64 tick = 1; double game_seconds = 2; uint32 speed = 3; } message ServerMessage { - oneof body { Welcome welcome = 1; Snapshot snapshot = 2; Delta delta = 3; Error error = 4; Ping pong = 5; VoteState vote_state = 6; Clock clock = 7; } + oneof body { Welcome welcome = 1; Snapshot snapshot = 2; Delta delta = 3; Error error = 4; Ping pong = 5; VoteState vote_state = 6; Clock clock = 7; Gameplay gameplay = 8; } } message SavedAgent { uint32 id = 1; uint32 route = 2; double distance = 3; } message Route { repeated sint32 xy = 1; string home_id = 2; string workplace_id = 3; } @@ -29,4 +38,5 @@ message RoomSave { uint64 tick = 5; uint64 seed = 6; double game_seconds = 7; uint32 speed = 8; repeated SavedAgent agents = 9; repeated Route routes = 10; string city_revision = 11; + Gameplay gameplay = 12; } diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs index ada3bee..f4136d8 100644 --- a/scripts/browser-smoke.mjs +++ b/scripts/browser-smoke.mjs @@ -24,6 +24,10 @@ try { await page.waitForFunction(()=>document.querySelector('.viewer-top [role=status]')?.textContent.includes('в загруженной области'),null,{timeout:60000}); const visibleMs=Math.round(performance.now()-started); await page.waitForFunction(()=>document.querySelector('.room-clock')?.textContent.includes('День'),null,{timeout:10000}); + await page.waitForFunction(()=>document.querySelector('.room-gameplay')?.textContent.includes('Казна'),null,{timeout:10000}); + await page.locator('[data-action=meal]').click(); + await page.waitForFunction(()=>document.querySelector('[data-action=meal]')?.disabled,null,{timeout:5000}); + await page.waitForFunction(()=>document.querySelector('.room-gameplay')?.textContent.includes('Общий обед'),null,{timeout:15000}); await page.getByRole('button',{name:'Пауза',exact:true}).click(); await page.waitForFunction(()=>document.querySelector('.room-clock')?.textContent.includes('×0'),null,{timeout:10000}); await page.getByRole('button',{name:'1',exact:true}).click(); @@ -31,4 +35,14 @@ try { await mkdir('.tools',{recursive:true}); await page.screenshot({path:'.tools/browser-room.png'}); assert.deepEqual(errors,[]); console.log(JSON.stringify({ui:'passed',visibleMs,status:await page.locator('.viewer-top').innerText(),screenshot:resolve('.tools/browser-room.png')})); + if (process.env.CITYGEN_TEST_ADMIN_TOKEN) { + await page.goto(`${base}/?admin`); + await page.getByLabel('Токен оператора').fill(process.env.CITYGEN_TEST_ADMIN_TOKEN); + await page.getByRole('button',{name:'Обновить',exact:true}).click(); + await page.waitForFunction(()=>document.querySelector('[role=status]')?.textContent.includes('МиБ'),null,{timeout:10000}); + const saved = page.waitForResponse(r=>r.url().endsWith('/checkpoint') && r.request().method()==='POST'); + await page.getByRole('button',{name:'Сохранить',exact:true}).first().click(); + assert.equal((await saved).status(),200); + console.log(JSON.stringify({admin:'passed'})); + } } finally {await browser.close();} diff --git a/server/README.md b/server/README.md index ee48a94..ed945a1 100644 --- a/server/README.md +++ b/server/README.md @@ -42,6 +42,8 @@ - `CITYGEN_MAX_VISIBLE`: 1000, максимум 5000; `CITYGEN_SIM_THREADS`: до четырёх CPU по умолчанию, общий пул ECS на процесс. - `CITYGEN_EMPTY_TIMEOUT`: 300 с; `CITYGEN_SAVE_INTERVAL`: 60 с. - `CITYGEN_KEEP_SNAPSHOTS`: 3. +- `CITYGEN_SAVE_QUOTA_BYTES`: 2147483648 (2 ГиБ, включая временный снимок). +- `CITYGEN_ADMIN_TOKEN`: пусто; для включения API требуется минимум 32 символа. - `CITYGEN_MAX_GENERATION_KM2`: 25; `CITYGEN_MAX_BUILDINGS`: 10000. - `CITYGEN_GENERATION_WORKERS`: 2; `CITYGEN_GENERATION_QUEUE`: 8. - `CITYGEN_GENERATION_TIMEOUT`: 600 с. @@ -68,6 +70,30 @@ Боты создают комнату, подключаются, подписываются на 30 областей, адаптируют LOD и выводят трафик, выборку длительности тиков и ошибки соединений. -Dockerfile подготавливает приложение для одного сервера. Каталоги городов -и `data/` необходимо подключать постоянными томами. Docker-сборка и внешний -деплой требуют отдельной проверки в целевом окружении. +Compose, постоянные тома, лимиты, healthcheck и профиль Prometheus описаны +в [deploy/README.md](../deploy/README.md). + +## Игровой слой + +Голод, усталость и настроение моделируются как усреднённые показатели +населения комнаты. Они меняются по игровому календарю независимо от камеры. +Сон ночью восстанавливает силы; питание трижды в день тратит запасы еды. +Голод и усталость снижают производство и доход казны в рабочие часы. +Почасовые события (урожай, болезнь, дождь) детерминированы seed и временем. +История ограничена 16 событиями. Это базовая экономика общего города; +индивидуальные кошельки, машины и детальное использование мебели не включены. + +Все игроки имеют одинаковые права на три команды: покупка запасов +(2 единицы казны на жителя), общий обед (1), отдых (3). Цена списывается +при приёме, действие исполняется в следующую игровую минуту. Сервер проверяет +ресурсы, паузу, очередь (до 8), общий интервал 60 игровых минут и интервал +сессии 30 реальных секунд. Общий интервал переживает переподключение. +Пауза и скорость по-прежнему требуют большинства голосов. + +Снимки v2 сохраняют экономику, потребности, историю, очередь и общий интервал. +Для v1 игровой слой инициализируется по числу жителей и сохранённому времени; +позиции, seed и расписания сохраняются. Неизвестные версии не перезаписываются. +Квота проверяется под общей блокировкой писателей комнат; при нехватке места +прежние снимки остаются. Один снимок ограничен 128 МиБ. +`/?admin` и API с Bearer-токеном показывают каталог, ошибки, квоту +и позволяют сохранить активную комнату. diff --git a/server/src/gameplay.rs b/server/src/gameplay.rs new file mode 100644 index 0000000..890f1d8 --- /dev/null +++ b/server/src/gameplay.rs @@ -0,0 +1,205 @@ +//! Coarse city-wide needs/economy. Updates cost O(game minutes), independent of +//! population and camera. Residents keep their deterministic commuter schedule. +use crate::protocol::{GameEvent, Gameplay, Needs}; +use bevy_ecs::prelude::Resource; + +#[derive(Resource, Clone)] +pub struct Game(pub Gameplay); +impl Game { + pub fn new(population: usize, seconds: f64) -> Self { + Self(Gameplay { + treasury: population as i64 * 100, + food: population as u64 * 2, + needs: Some(Needs { + hunger: 20, + fatigue: 20, + morale: 70, + }), + minute: (seconds / 60.0) as u64, + ..Default::default() + }) + } + pub fn validate(state: &Gameplay, seconds: f64, population: usize) -> Result<(), String> { + let needs = state.needs.as_ref().ok_or("Missing saved needs")?; + if state.minute != (seconds / 60.0) as u64 + || state.treasury < 0 + || state.food > (population as u64).saturating_mul(100).max(100) + || [needs.hunger, needs.fatigue, needs.morale] + .iter() + .any(|n| *n > 100) + || state.events.len() > 16 + || state.pending.len() > 8 + || state.events.iter().any(|e| { + e.minute > state.minute + || !["harvest", "illness", "rain", "supplies", "meal", "rest"] + .contains(&e.kind.as_str()) + }) + || state + .pending + .iter() + .any(|a| Self::cost(a, population).is_none()) + { + return Err("Invalid saved gameplay".into()); + } + Ok(()) + } + pub fn cost(action: &str, population: usize) -> Option { + let unit = population.max(1) as i64; + match action { + "supplies" => Some(unit * 2), + "meal" => Some(unit), + "rest" => Some(unit * 3), + _ => None, + } + } + pub fn enqueue(&mut self, action: &str, population: usize, paused: bool) -> Result<(), String> { + let cost = Self::cost(action, population).ok_or("Unknown intervention")?; + if paused { + return Err("Commands are unavailable while paused".into()); + } + if self.0.pending.len() >= 8 || self.0.minute < self.0.next_intervention_minute { + return Err("City intervention cooldown: one command per game hour".into()); + } + if self.0.treasury < cost { + return Err("Insufficient treasury".into()); + } + if action == "meal" && self.0.food < population as u64 { + return Err("Insufficient food; purchase supplies first".into()); + } + self.0.treasury -= cost; + self.0.pending.push(action.into()); + self.0.next_intervention_minute = self.0.minute + 60; + Ok(()) + } + fn event(&mut self, kind: &str) { + self.0.events.push(GameEvent { + minute: self.0.minute, + kind: kind.into(), + }); + if self.0.events.len() > 16 { + self.0.events.remove(0); + } + } + pub fn advance(&mut self, seconds: f64, population: usize, seed: u64) { + let until = (seconds / 60.0) as u64; + let population = population as u64; + while self.0.minute < until { + self.0.minute += 1; + for action in std::mem::take(&mut self.0.pending) { + match action.as_str() { + "supplies" => { + self.0.food = (self.0.food + population * 2).min(population * 100) + } + "meal" => { + let fed = self.0.food >= population; + if fed { + self.0.food -= population; + } + let needs = self.0.needs.as_mut().unwrap(); + if fed { + needs.hunger = needs.hunger.saturating_sub(40); + needs.morale = (needs.morale + 5).min(100); + } + } + "rest" => { + let n = self.0.needs.as_mut().unwrap(); + n.fatigue = n.fatigue.saturating_sub(30); + n.morale = (n.morale + 10).min(100); + } + _ => unreachable!(), + } + self.event(&action); + } + if self.0.minute % 10 == 0 { + let hour = (self.0.minute / 60) % 24; + let n = self.0.needs.as_mut().unwrap(); + n.hunger = (n.hunger + 1).min(100); + n.fatigue = if hour < 7 || hour >= 22 { + n.fatigue.saturating_sub(3) + } else { + (n.fatigue + 1).min(100) + }; + if n.hunger > 70 || n.fatigue > 80 { + n.morale = n.morale.saturating_sub(1); + } + } + if self.0.minute % 60 == 0 { + let hour = (self.0.minute / 60) % 24; + if (8..17).contains(&hour) { + let n = self.0.needs.as_ref().unwrap(); + // Hunger and fatigue reduce labour output and tax receipts. + let productivity = (200 - n.hunger - n.fatigue) as u64; + let output = population * productivity / 200; + self.0.treasury = self.0.treasury.saturating_add(output as i64); + self.0.food = (self.0.food + output / 3).min(population * 100); + } + if [7, 13, 19].contains(&hour) { + let served = self.0.food.min(population); + self.0.food -= served; + let reduction = (served * 25 / population.max(1)) as u32; + let n = self.0.needs.as_mut().unwrap(); + n.hunger = n.hunger.saturating_sub(reduction); + } + let random = (seed ^ (self.0.minute / 60).wrapping_mul(0x9e3779b97f4a7c15)) + .wrapping_mul(0xbf58476d1ce4e5b9); + match random % 24 { + 0 => { + self.0.food = (self.0.food + population).min(population * 100); + self.event("harvest"); + } + 1 => { + let n = self.0.needs.as_mut().unwrap(); + n.fatigue = (n.fatigue + 10).min(100); + self.event("illness"); + } + 2 => { + let n = self.0.needs.as_mut().unwrap(); + n.morale = n.morale.saturating_sub(5); + self.event("rain"); + } + _ => {} + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn needs_reduce_output_and_food_is_consumed() { + let mut healthy = Game::new(1000, 7.0 * 3600.0); + let mut hungry = healthy.clone(); + hungry.0.needs.as_mut().unwrap().hunger = 100; + hungry.0.needs.as_mut().unwrap().fatigue = 100; + healthy.advance(8.0 * 3600.0, 1000, 7); + hungry.advance(8.0 * 3600.0, 1000, 7); + assert!(healthy.0.treasury > hungry.0.treasury); + let mut meal = Game::new(1000, 12.0 * 3600.0); + meal.advance(13.0 * 3600.0, 1000, 7); + assert!(meal.0.food < 2000, "scheduled meals consume reserves"); + meal.0.food = 0; + assert!(meal.enqueue("meal", 1000, false).is_err()); + meal.0.treasury = 0; + assert!(meal.enqueue("supplies", 1000, false).is_err()); + } + #[test] + fn commands_budget_cooldown_and_deterministic_recovery() { + let mut game = Game::new(1000, 0.0); + assert!(game.enqueue("delete", 1000, false).is_err()); + assert!(game.enqueue("meal", 1000, true).is_err()); + game.enqueue("meal", 1000, false).unwrap(); + assert_eq!(game.0.treasury, 99000); + assert!(game.enqueue("meal", 1000, false).is_err()); + let mut restored = Game(game.0.clone()); + for minute in 1..=2880 { + game.advance(minute as f64 * 60.0, 1000, 7); + restored.advance(minute as f64 * 60.0, 1000, 7); + } + assert_eq!(game.0, restored.0); + assert!(game.0.pending.is_empty()); + assert!(!game.0.events.is_empty()); + Game::validate(&game.0, 2880.0 * 60.0, 1000).unwrap(); + } +} diff --git a/server/src/gateway.rs b/server/src/gateway.rs index eb1eb68..94d67ae 100644 --- a/server/src/gateway.rs +++ b/server/src/gateway.rs @@ -9,7 +9,7 @@ use axum::{ ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, ConnectInfo, Path, State, }, - http::StatusCode, + http::{HeaderMap, StatusCode}, response::IntoResponse, routing::get, Json, Router, @@ -48,6 +48,8 @@ pub struct Config { pub max_visible: usize, pub osm_fixture: Option, pub project_root: PathBuf, + pub save_quota_bytes: u64, + pub admin_token: Option, } impl Default for Config { fn default() -> Self { @@ -68,6 +70,8 @@ impl Default for Config { max_visible: 1000, osm_fixture: None, project_root: std::env::current_dir().unwrap_or_else(|_| ".".into()), + save_quota_bytes: 2 * 1024 * 1024 * 1024, + admin_token: None, } } } @@ -93,6 +97,8 @@ struct RoomMetrics { entities: AtomicU64, bytes: AtomicU64, max_micros: AtomicU64, + save_errors: AtomicU64, + saved_tick: AtomicU64, } pub(crate) enum Command { Join { @@ -103,6 +109,7 @@ pub(crate) enum Command { Leave(String), Input(String, p::ClientMessage), Stop, + Checkpoint(tokio::sync::oneshot::Sender>), } struct Player { nickname: String, @@ -111,6 +118,7 @@ struct Player { lod: u32, known: HashMap, last_proposal: Option, + last_intervention: Option, traffic: Traffic, metrics: Arc, } @@ -193,6 +201,8 @@ impl App { || config.generation_queue == 0 || config.max_visible == 0 || config.max_visible > 5000 + || config.save_quota_bytes == 0 + || config.admin_token.as_ref().is_some_and(|t| t.len() < 32) || !config.max_generation_km2.is_finite() || config.max_generation_km2 <= 0.0 { @@ -360,6 +370,11 @@ pub fn router(app: App) -> Router { .route("/api/rooms/{id}/ws", get(upgrade)) .route("/health", get(|| async { "ok" })) .route("/metrics", get(metrics)) + .route("/api/admin", get(admin_status)) + .route( + "/api/admin/rooms/{id}/checkpoint", + axum::routing::post(admin_checkpoint), + ) .nest("/cities", city_files) .fallback_service(tower_http::services::ServeDir::new("dist")) .with_state(app) @@ -373,6 +388,83 @@ async fn cache_city(mut response: axum::response::Response) -> axum::response::R } response } + +fn authorize(app: &App, headers: &HeaderMap) -> Result<(), ApiError> { + let expected = app + .config + .admin_token + .as_ref() + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Administration is disabled"))?; + let received = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + .unwrap_or(""); + use sha2::{Digest, Sha256}; + let a = Sha256::digest(expected.as_bytes()); + let b = Sha256::digest(received.as_bytes()); + if a.iter() + .zip(b.iter()) + .fold(0u8, |diff, (a, b)| diff | (a ^ b)) + != 0 + { + return Err(api_error( + StatusCode::UNAUTHORIZED, + "Administrator token required", + )); + } + Ok(()) +} +async fn admin_status( + State(app): State, + headers: HeaderMap, +) -> Result, ApiError> { + authorize(&app, &headers)?; + let root = app.config.saves.clone(); + let disk = tokio::task::spawn_blocking(move || { + let mut snapshots = vec![]; + for entry in std::fs::read_dir(&root).map_err(|e| e.to_string())?.flatten() { + let id = entry.file_name().to_string_lossy().to_string(); + if !entry.file_type().map_err(|e| e.to_string())?.is_dir() || !safe_id(&id) { continue; } + let result = storage::load(&root, &id); + snapshots.push(serde_json::json!({"id":id,"error":result.as_ref().err(),"version":result.as_ref().ok().map(|s|s.version),"tick":result.as_ref().ok().map(|s|s.tick)})); + } + Ok::<_, String>((storage::disk_bytes(&root)?, snapshots)) + }).await.map_err(|e|api_error(StatusCode::INTERNAL_SERVER_ERROR,e.to_string()))?.map_err(|e|api_error(StatusCode::INTERNAL_SERVER_ERROR,e))?; + let rooms = app.rooms.lock().unwrap().iter().map(|(id,r)| serde_json::json!({"id":id,"cityId":r.city_id,"active":r.active.load(Ordering::Relaxed),"players":r.players.load(Ordering::Relaxed),"tick":r.metrics.tick.load(Ordering::Relaxed),"savedTick":r.metrics.saved_tick.load(Ordering::Relaxed),"saveErrors":r.metrics.save_errors.load(Ordering::Relaxed)})).collect::>(); + Ok(Json( + serde_json::json!({"rooms":rooms,"snapshots":disk.1,"saveBytes":disk.0,"saveQuotaBytes":app.config.save_quota_bytes}), + )) +} +async fn admin_checkpoint( + State(app): State, + Path(id): Path, + headers: HeaderMap, +) -> Result, ApiError> { + authorize(&app, &headers)?; + let (tx, rx) = tokio::sync::oneshot::channel(); + { + let rooms = app.rooms.lock().unwrap(); + let room = rooms + .get(&id) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Room not found"))?; + if !room.active.load(Ordering::Relaxed) { + return Err(api_error( + StatusCode::CONFLICT, + "Room is already saved and inactive", + )); + } + room.commands + .try_send(Command::Checkpoint(tx)) + .map_err(|_| api_error(StatusCode::TOO_MANY_REQUESTS, "Room command queue is busy"))?; + } + tokio::time::timeout(Duration::from_secs(20), rx) + .await + .map_err(|_| api_error(StatusCode::GATEWAY_TIMEOUT, "Checkpoint timed out"))? + .map_err(|_| api_error(StatusCode::SERVICE_UNAVAILABLE, "Room stopped"))? + .map_err(|e| api_error(StatusCode::INSUFFICIENT_STORAGE, e))?; + Ok(Json(serde_json::json!({"saved":true}))) +} async fn create_city( State(app): State, ConnectInfo(peer): ConnectInfo, @@ -533,6 +625,7 @@ async fn metrics(State(app): State) -> String { app.active() ); for (id, room) in app.rooms.lock().unwrap().iter() { + text.push_str(&format!("citygen_room_save_errors_total{{room=\"{id}\"}} {}\ncitygen_room_saved_tick{{room=\"{id}\"}} {}\n", room.metrics.save_errors.load(Ordering::Relaxed), room.metrics.saved_tick.load(Ordering::Relaxed))); text.push_str(&format!( "citygen_room_tick_max_seconds{{room=\"{id}\"}} {}\n", room.metrics.max_micros.load(Ordering::Relaxed) as f64 / 1_000_000.0 @@ -648,13 +741,31 @@ fn room_loop( } } let _lifecycle = Lifecycle(active.clone(), player_count.clone()); - let (save_tx, save_rx) = mpsc::sync_channel::(1); + let (save_tx, save_rx) = mpsc::sync_channel::<( + crate::simulation::Checkpoint, + Option>>, + )>(1); let save_root = config.saves.clone(); let keep = config.keep_snapshots; + let quota = config.save_quota_bytes; + let save_metrics = metrics.clone(); let save_thread = std::thread::spawn(move || { - while let Ok(snapshot) = save_rx.recv() { - if let Err(e) = storage::save(&save_root, &snapshot.materialize(), keep) { - eprintln!("snapshot failed: {e}"); + while let Ok((snapshot, response)) = save_rx.recv() { + let snapshot = snapshot.materialize(); + let result = storage::save_with_quota(&save_root, &snapshot, keep, quota); + if let Err(e) = &result { + save_metrics.save_errors.fetch_add(1, Ordering::Relaxed); + eprintln!( + "{}", + serde_json::json!({"event":"snapshot_failed","room":snapshot.id,"error":e}) + ); + } else { + save_metrics + .saved_tick + .store(snapshot.tick, Ordering::Relaxed); + } + if let Some(response) = response { + let _ = response.send(result); } } }); @@ -669,6 +780,15 @@ fn room_loop( if let Ok(command) = rx.recv_timeout(wait) { match command { Command::Stop => stopping = true, + Command::Checkpoint(response) => { + if let Err(e) = save_tx.try_send((sim.checkpoint(), Some(response))) { + let (mpsc::TrySendError::Full((_, response)) + | mpsc::TrySendError::Disconnected((_, response))) = e; + if let Some(response) = response { + let _ = response.send(Err("Snapshot writer is busy".into())); + } + } + } Command::Leave(id) => { players.remove(&id); if let Some(b) = &mut ballot { @@ -703,6 +823,7 @@ fn room_loop( lod: 0, known: HashMap::new(), last_proposal: None, + last_intervention: None, traffic: Traffic::new(), metrics: metrics.clone(), }; @@ -798,6 +919,32 @@ fn room_loop( }); } } + Some(client_message::Body::Intervene(command)) => { + if player + .last_intervention + .is_some_and(|at| at.elapsed() < Duration::from_secs(30)) + { + reply( + player, + error("COMMAND_LIMIT", "Wait 30 seconds between commands"), + ); + } else { + let paused = sim.clock().speed == 0; + let count = sim.world.entities().len() as usize; + let result = sim + .world + .resource_mut::() + .enqueue(&command.action, count, paused); + match result { + Ok(()) => { + player.last_intervention = Some(Instant::now()); + } + Err(e) => { + reply(player, error("COMMAND_LIMIT", e)); + } + } + } + } Some(client_message::Body::Vote(v)) => { if let Some(b) = &mut ballot { if Instant::now() < b.deadline @@ -886,6 +1033,14 @@ fn room_loop( } } if clock.tick % 10 == 0 { + if !reply( + player, + message(server_message::Body::Gameplay( + sim.world.resource::().0.clone(), + )), + ) { + return false; + } let b = ballot.as_ref(); if !reply( player, @@ -923,10 +1078,8 @@ fn room_loop( } if begin.elapsed() > Duration::from_millis(50) { eprintln!( - "room={} tick={} elapsed_ms={}", - sim.id, - clock.tick, - begin.elapsed().as_millis() + "{}", + serde_json::json!({"event":"slow_tick","room":sim.id,"tick":clock.tick,"elapsed_ms":begin.elapsed().as_millis()}) ); } next_tick += Duration::from_millis(100); @@ -934,7 +1087,7 @@ fn room_loop( next_tick = Instant::now() + Duration::from_millis(100); } if saved_at.elapsed() >= config.save_interval { - if let Err(error) = save_tx.try_send(sim.checkpoint()) { + if let Err(error) = save_tx.try_send((sim.checkpoint(), None)) { eprintln!("snapshot writer is busy or unavailable: {error}"); } saved_at = Instant::now(); @@ -954,7 +1107,7 @@ fn room_loop( .store(sim.world.entities().len() as u64, Ordering::Relaxed); } } - if save_tx.send(sim.checkpoint()).is_err() { + if save_tx.send((sim.checkpoint(), None)).is_err() { eprintln!("final snapshot writer is unavailable"); } drop(save_tx); diff --git a/server/src/lib.rs b/server/src/lib.rs index 5839c1f..b34a974 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -2,6 +2,7 @@ pub mod protocol { include!(concat!(env!("OUT_DIR"), "/city.rs")); } pub mod city; +pub mod gameplay; pub mod gateway; pub mod generation; pub mod navigation; diff --git a/server/src/main.rs b/server/src/main.rs index 057bec0..42f1885 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -28,6 +28,12 @@ async fn main() -> Result<(), Box> { if let Ok(value) = std::env::var("CITYGEN_KEEP_SNAPSHOTS") { config.keep_snapshots = value.parse()?; } + if let Ok(value) = std::env::var("CITYGEN_SAVE_QUOTA_BYTES") { + config.save_quota_bytes = value.parse()?; + } + config.admin_token = std::env::var("CITYGEN_ADMIN_TOKEN") + .ok() + .filter(|s| !s.is_empty()); if let Ok(value) = std::env::var("CITYGEN_MAX_GENERATION_KM2") { config.max_generation_km2 = value.parse()?; } @@ -49,17 +55,33 @@ async fn main() -> Result<(), Box> { let app = App::new(config).map_err(std::io::Error::other)?; let address = std::env::var("CITYGEN_BIND").unwrap_or("127.0.0.1:3000".into()); let listener = tokio::net::TcpListener::bind(&address).await?; - println!("Citygen: http://{address}"); + eprintln!( + "{}", + serde_json::json!({"event":"server_started","address":address}) + ); let shutdown_app = app.clone(); axum::serve( listener, gateway::router(app.clone()).into_make_service_with_connect_info::(), ) .with_graceful_shutdown(async move { - let _ = tokio::signal::ctrl_c().await; + shutdown_signal().await; let _ = tokio::task::spawn_blocking(move || shutdown_app.stop()).await; }) .await?; app.stop(); Ok(()) } + +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("SIGTERM handler"); + tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} } + } + #[cfg(not(unix))] + let _ = tokio::signal::ctrl_c().await; + eprintln!("{}", serde_json::json!({"event":"server_stopping"})); +} diff --git a/server/src/simulation.rs b/server/src/simulation.rs index 5438c41..9df8e8d 100644 --- a/server/src/simulation.rs +++ b/server/src/simulation.rs @@ -225,6 +225,7 @@ impl Simulation { ..Default::default() }); world.insert_resource(PositionChanges::default()); + world.insert_resource(crate::gameplay::Game::new(count as usize, 7.9 * 3600.0)); world.spawn_batch((0..count).map(|i| { ( Resident { @@ -268,6 +269,12 @@ impl Simulation { clock.tick += 1; clock.seconds += clock.speed as f64 * 6.0; } + let seconds = self.clock().seconds; + self.world.resource_mut::().advance( + seconds, + self.population.len(), + self.seed, + ); self.schedule.run(&mut self.world); } pub fn clock(&self) -> Clock { @@ -387,7 +394,7 @@ impl Simulation { }) .collect(); let metadata = RoomSave { - version: 1, + version: 2, id: self.id.clone(), city_id: self.city_id.clone(), city_revision: self.city_revision.clone(), @@ -398,6 +405,7 @@ impl Simulation { speed: clock.speed, agents: vec![], routes, + gameplay: Some(self.world.resource::().0.clone()), }; Checkpoint { metadata, @@ -405,10 +413,13 @@ impl Simulation { } } pub fn restore(saved: RoomSave) -> Result { - if saved.version != 1 || saved.city_version != 1 { + if ![1, 2].contains(&saved.version) || saved.city_version != 1 { return Err("Incompatible room snapshot".into()); } - if !saved.game_seconds.is_finite() || ![0, 1, 2, 4].contains(&saved.speed) { + if !saved.game_seconds.is_finite() + || saved.game_seconds < 0.0 + || ![0, 1, 2, 4].contains(&saved.speed) + { return Err("Invalid room clock".into()); } let mut ids = std::collections::HashSet::new(); @@ -427,6 +438,14 @@ impl Simulation { paths.push(route.xy.chunks_exact(2).map(|p| [p[0], p[1]]).collect()); } let mut s = Self::new(saved.id, saved.city_id, saved.seed, 0, paths)?; + let gameplay = if saved.version == 1 { + crate::gameplay::Game::new(saved.agents.len(), saved.game_seconds) + } else { + let state = saved.gameplay.ok_or("Missing saved gameplay")?; + crate::gameplay::Game::validate(&state, saved.game_seconds, saved.agents.len())?; + crate::gameplay::Game(state) + }; + s.world.insert_resource(gameplay); s.goals( saved .routes @@ -545,4 +564,31 @@ mod tests { a.step(); assert_eq!(a.clock().seconds, seconds); } + #[test] + fn legacy_snapshot_migrates_and_pending_commands_survive_restart() { + let mut sim = + Simulation::new("r".into(), "c".into(), 7, 10, vec![vec![[0, 0], [100, 0]]]).unwrap(); + let mut legacy = sim.save(); + legacy.version = 1; + legacy.gameplay = None; + let mut migrated = Simulation::restore(legacy).unwrap(); + assert_eq!(migrated.save().version, 2); + migrated + .world + .resource_mut::() + .enqueue("supplies", 10, false) + .unwrap(); + let saved = migrated.save(); + let mut restored = Simulation::restore(saved.clone()).unwrap(); + assert_eq!(saved, restored.save()); + for _ in 0..100 { + migrated.step(); + restored.step(); + } + assert_eq!(migrated.save(), restored.save()); + assert!(restored.save().gameplay.unwrap().pending.is_empty()); + let mut invalid = restored.save(); + invalid.version = 99; + assert!(Simulation::restore(invalid).is_err()); + } } diff --git a/server/src/storage.rs b/server/src/storage.rs index e1e3f6a..560014a 100644 --- a/server/src/storage.rs +++ b/server/src/storage.rs @@ -7,11 +7,46 @@ use std::{ }; pub fn save(root: &Path, snapshot: &RoomSave, keep: usize) -> Result<(), String> { + save_with_quota(root, snapshot, keep, u64::MAX) +} +// Serialize quota checks and writes across room writer threads. Never evict the +// last snapshot of another room to make space for the current one. +static WRITES: std::sync::Mutex<()> = std::sync::Mutex::new(()); +pub fn disk_bytes(root: &Path) -> Result { + let mut bytes = 0u64; + for entry in fs::read_dir(root).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let kind = entry.file_type().map_err(|e| e.to_string())?; + if kind.is_symlink() { + continue; + } + bytes = bytes.saturating_add(if kind.is_dir() { + disk_bytes(&entry.path())? + } else { + entry.metadata().map_err(|e| e.to_string())?.len() + }); + } + Ok(bytes) +} +pub fn save_with_quota( + root: &Path, + snapshot: &RoomSave, + keep: usize, + quota: u64, +) -> Result<(), String> { + let _guard = WRITES.lock().map_err(|_| "Snapshot lock poisoned")?; if !safe_id(&snapshot.id) { return Err("Invalid room ID".into()); } let folder = root.join(&snapshot.id); fs::create_dir_all(&folder).map_err(|e| e.to_string())?; + let bytes = snapshot.encode_to_vec(); + if bytes.len() > 128 * 1024 * 1024 { + return Err("Snapshot exceeds 128 MiB".into()); + } + if disk_bytes(root)?.saturating_add(bytes.len() as u64) > quota { + return Err("Snapshot disk quota exceeded; previous snapshots preserved".into()); + } let name = format!("{:020}.pb", snapshot.tick); let file = folder.join(&name); // Replace through a complete temporary file, including control changes that @@ -19,9 +54,7 @@ pub fn save(root: &Path, snapshot: &RoomSave, keep: usize) -> Result<(), String> { let temporary = folder.join(format!("{name}.tmp")); let mut output = fs::File::create(&temporary).map_err(|e| e.to_string())?; - output - .write_all(&snapshot.encode_to_vec()) - .map_err(|e| e.to_string())?; + output.write_all(&bytes).map_err(|e| e.to_string())?; output.sync_all().map_err(|e| e.to_string())?; drop(output); fs::rename(temporary, file).map_err(|e| e.to_string())?; @@ -51,12 +84,15 @@ pub fn load(root: &Path, id: &str) -> Result { files.reverse(); // Try an older intact file if the latest is damaged. for file in files { + if fs::metadata(&file).map_or(true, |m| m.len() > 128 * 1024 * 1024) { + continue; + } if let Ok(bytes) = fs::read(file) { if let Ok(snapshot) = RoomSave::decode(&bytes[..]) { if snapshot.id != id { continue; } - if snapshot.version != 1 || snapshot.city_version != 1 { + if ![1, 2].contains(&snapshot.version) || snapshot.city_version != 1 { return Err("Incompatible room snapshot".into()); } return Ok(snapshot); @@ -91,4 +127,20 @@ mod tests { assert_eq!(load(dir.path(), "r").unwrap().tick, 3); assert!(load(dir.path(), "../secret").is_err()); } + #[test] + fn quota_preserves_committed_snapshot() { + let dir = tempfile::tempdir().unwrap(); + let mut snapshot = RoomSave { + version: 2, + city_version: 1, + id: "r".into(), + ..Default::default() + }; + save(dir.path(), &snapshot, 3).unwrap(); + let size = disk_bytes(dir.path()).unwrap(); + snapshot.tick = 1; + assert!(save_with_quota(dir.path(), &snapshot, 3, size).is_err()); + assert_eq!(load(dir.path(), "r").unwrap().tick, 0); + assert_eq!(disk_bytes(dir.path()).unwrap(), size); + } } diff --git a/server/tests/websocket.rs b/server/tests/websocket.rs index eb9069b..8c0690e 100644 --- a/server/tests/websocket.rs +++ b/server/tests/websocket.rs @@ -31,6 +31,19 @@ async fn read( } } } +async fn http_request( + address: std::net::SocketAddr, + path: &str, + method: &str, + token: &str, +) -> String { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut socket = tokio::net::TcpStream::connect(address).await.unwrap(); + socket.write_all(format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer {token}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap(); + let mut response = String::new(); + socket.read_to_string(&mut response).await.unwrap(); + response +} #[tokio::test] async fn thirty_observers_snapshot_delta_vote_and_restart() { let dir = tempfile::tempdir().unwrap(); @@ -38,6 +51,7 @@ async fn thirty_observers_snapshot_delta_vote_and_restart() { let app = App::new(Config { cities: dir.path().join("cities"), saves: dir.path().join("rooms"), + admin_token: Some("test-only-administrator-token-32-chars".into()), ..Default::default() }) .unwrap(); @@ -63,6 +77,26 @@ async fn thirty_observers_snapshot_delta_vote_and_restart() { let value: serde_json::Value = serde_json::from_str(response.split("\r\n\r\n").nth(1).unwrap()).unwrap(); let id = value["id"].as_str().unwrap().to_string(); + assert!(http_request(address, "/api/admin", "GET", "wrong") + .await + .starts_with("HTTP/1.1 401")); + assert!(http_request( + address, + "/api/admin", + "GET", + "test-only-administrator-token-32-chars" + ) + .await + .starts_with("HTTP/1.1 200")); + let checkpoint = http_request( + address, + &format!("/api/admin/rooms/{id}/checkpoint"), + "POST", + "test-only-administrator-token-32-chars", + ) + .await; + assert!(checkpoint.starts_with("HTTP/1.1 200"), "{checkpoint}"); + assert!(citygen_server::storage::load(&dir.path().join("rooms"), &id).is_ok()); let mut sockets = vec![]; for i in 0..30 { let (mut socket, _) = @@ -117,6 +151,37 @@ async fn thirty_observers_snapshot_delta_vote_and_restart() { } } // 31st observer must be rejected without affecting existing players. + let command = p::ClientMessage { + body: Some(client_message::Body::Intervene(p::Intervene { + action: "meal".into(), + })), + } + .encode_to_vec(); + s.send(tokio_tungstenite::tungstenite::Message::Binary( + command.clone().into(), + )) + .await + .unwrap(); + s.send(tokio_tungstenite::tungstenite::Message::Binary( + command.into(), + )) + .await + .unwrap(); + loop { + if let Some(server_message::Body::Error(error)) = read(s).await.body { + assert_eq!(error.code, "COMMAND_LIMIT"); + break; + } + } + loop { + if let Some(server_message::Body::Gameplay(state)) = read(s).await.body { + assert!( + state.pending.iter().any(|p| p == "meal") + || state.events.iter().any(|e| e.kind == "meal") + ); + break; + } + } let (mut extra, _) = tokio_tungstenite::connect_async(format!("ws://{address}/api/rooms/{id}/ws")) .await @@ -232,6 +297,14 @@ async fn thirty_observers_snapshot_delta_vote_and_restart() { )) .await .unwrap(); + assert!(http_request( + address, + "/api/admin", + "GET", + "test-only-administrator-token-32-chars" + ) + .await + .starts_with("HTTP/1.1 404")); assert!( matches!(read(&mut socket).await.body,Some(server_message::Body::Welcome(w)) if w.tick>=saved.tick) ); diff --git a/src/main.ts b/src/main.ts index 3a4d4cc..473d6f9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,6 +7,7 @@ import type { World } from './types'; import { TileLoader } from '../client/tiles'; import type { Bounds } from './city/format'; import { RoomConnection } from '../client/connection'; +import { showAdmin } from '../client/admin'; import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; import { CityJobSchema, CreateCitySchema } from '../client/generated/city_pb'; @@ -325,6 +326,14 @@ async function showTiledCity(url: string, connection?: RoomConnection) { } const vote = document.createElement('span'); vote.className = 'room-vote'; vote.setAttribute('role','status'); controls.appendChild(vote); tiledViewer.root.querySelector('.viewer-top')!.appendChild(controls); + const gameplay = document.createElement('div'); gameplay.className = 'row'; + const state = document.createElement('span'); state.className = 'room-gameplay'; state.setAttribute('role', 'status'); gameplay.appendChild(state); + for (const [action, label] of [['supplies', 'Запасы · 2¤/жит.'], ['meal', 'Обед · 1¤/жит.'], ['rest', 'Отдых · 3¤/жит.']]) { + const button = document.createElement('button'); button.className = 'btn'; button.dataset.action = action; button.textContent = label; + button.title = 'Общая казна. Одна команда на игровой час; исполнение в следующую игровую минуту.'; + button.onclick = () => connection.intervene(action); gameplay.appendChild(button); + } + tiledViewer.root.querySelector('.viewer-top')!.appendChild(gameplay); } (window as any).viewer = viewer; hideProgress(); @@ -361,6 +370,13 @@ async function showLobby() { status: text => { status.textContent = text; viewer?.setLoadStatus(text); }, clock: c => { const clock = app.querySelector('.room-clock'); if (clock) clock.textContent = `День ${Math.floor(c.gameSeconds / 86400) + 1} · ${String(Math.floor(c.gameSeconds/3600)%24).padStart(2,'0')}:${String(Math.floor(c.gameSeconds/60)%60).padStart(2,'0')} · ×${c.speed}`; }, vote: v => { const vote = app.querySelector('.room-vote'); if (vote) vote.textContent = v.active ? `Голосование за ×${v.speed}: ${v.yes}/${v.players}, ${v.remainingSeconds} с` : `${v.players} игроков`; }, + gameplay: g => { + const state = app.querySelector('.room-gameplay'); + const names: Record = { harvest: 'Урожай', illness: 'Болезнь', rain: 'Дождь', supplies: 'Запасы пополнены', meal: 'Общий обед', rest: 'Отдых' }; + const event = g.events.at(-1); + if (state) state.textContent = `Казна ${g.treasury}¤ · Еда ${g.food} · Голод ${g.needs?.hunger ?? 0}% · Усталость ${g.needs?.fatigue ?? 0}% · Настроение ${g.needs?.morale ?? 0}%${g.pending.length ? ' · Команда в очереди' : ''}${event ? ` · ${names[event.kind] ?? event.kind}` : ''}`; + for (const button of app.querySelectorAll('[data-action]')) button.disabled = g.pending.length > 0 || g.minute < g.nextInterventionMinute; + }, }); roomConnection = connection; }; @@ -394,6 +410,7 @@ async function showLobby() { (async () => { const params = new URLSearchParams(location.search); + if (params.has('admin')) return showAdmin(app); if (params.has('play')) return showLobby(); if (params.has('city')) return showTiledCity(params.get('city')!); if (params.has('last') && (await hasWorld())) {