diff --git a/README.md b/README.md index 7327c0e..f60aaa2 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,11 @@ комнаты до 30 наблюдателей, жители по расписанию, общий календарь и голосования, сохранения и очередь генерации. Браузерный генератор ниже остаётся режимом отладки. -## Эксплуатация и игровой слой +## Эксплуатация Для запуска через Docker Compose см. [deploy/README.md](deploy/README.md). -В комнате доступны казна, запасы еды, потребности населения, события и три -ограниченных действия игроков. Подробности модели и настроек — -[server/README.md](server/README.md). Интерфейс оператора: `/?admin`. +Настройки сервера описаны в [server/README.md](server/README.md). +Интерфейс оператора: `/?admin`. ## Сетевая игра diff --git a/client/connection.ts b/client/connection.ts index 9f95c11..71aa7f3 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, type Gameplay } from './generated/city_pb'; +import { ClientMessageSchema, ServerMessageSchema, type ClientMessage, type Welcome, type Clock, type VoteState } from './generated/city_pb'; import type { Bounds } from '../src/city/format'; import { EntityState } from './entities'; @@ -15,7 +15,6 @@ 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:'; @@ -43,7 +42,6 @@ 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(); } @@ -66,6 +64,5 @@ 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 9caf958..1b19dbb 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,121 +144,6 @@ 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 */ @@ -284,7 +169,7 @@ export type CreateCity = Message<"city.CreateCity"> & { * Use `create(CreateCitySchema)` to create a new message. */ export const CreateCitySchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 10); + messageDesc(file_city, 6); /** * @generated from message city.CityJob @@ -326,7 +211,7 @@ export type CityJob = Message<"city.CityJob"> & { * Use `create(CityJobSchema)` to create a new message. */ export const CityJobSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 11); + messageDesc(file_city, 7); /** * @generated from message city.ClientMessage @@ -365,12 +250,6 @@ 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 }; }; @@ -379,7 +258,7 @@ export type ClientMessage = Message<"city.ClientMessage"> & { * Use `create(ClientMessageSchema)` to create a new message. */ export const ClientMessageSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 12); + messageDesc(file_city, 8); /** * @generated from message city.Agent @@ -411,7 +290,7 @@ export type Agent = Message<"city.Agent"> & { * Use `create(AgentSchema)` to create a new message. */ export const AgentSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 13); + messageDesc(file_city, 9); /** * @generated from message city.Welcome @@ -448,7 +327,7 @@ export type Welcome = Message<"city.Welcome"> & { * Use `create(WelcomeSchema)` to create a new message. */ export const WelcomeSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 14); + messageDesc(file_city, 10); /** * @generated from message city.Snapshot @@ -470,7 +349,7 @@ export type Snapshot = Message<"city.Snapshot"> & { * Use `create(SnapshotSchema)` to create a new message. */ export const SnapshotSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 15); + messageDesc(file_city, 11); /** * @generated from message city.Delta @@ -502,7 +381,7 @@ export type Delta = Message<"city.Delta"> & { * Use `create(DeltaSchema)` to create a new message. */ export const DeltaSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 16); + messageDesc(file_city, 12); /** * @generated from message city.Error @@ -524,7 +403,7 @@ export type Error = Message<"city.Error"> & { * Use `create(ErrorSchema)` to create a new message. */ export const ErrorSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 17); + messageDesc(file_city, 13); /** * @generated from message city.VoteState @@ -561,7 +440,7 @@ export type VoteState = Message<"city.VoteState"> & { * Use `create(VoteStateSchema)` to create a new message. */ export const VoteStateSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 18); + messageDesc(file_city, 14); /** * @generated from message city.Clock @@ -588,7 +467,7 @@ export type Clock = Message<"city.Clock"> & { * Use `create(ClockSchema)` to create a new message. */ export const ClockSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 19); + messageDesc(file_city, 15); /** * @generated from message city.ServerMessage @@ -639,12 +518,6 @@ 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 }; }; @@ -653,7 +526,7 @@ export type ServerMessage = Message<"city.ServerMessage"> & { * Use `create(ServerMessageSchema)` to create a new message. */ export const ServerMessageSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 20); + messageDesc(file_city, 16); /** * @generated from message city.SavedAgent @@ -680,7 +553,7 @@ export type SavedAgent = Message<"city.SavedAgent"> & { * Use `create(SavedAgentSchema)` to create a new message. */ export const SavedAgentSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 21); + messageDesc(file_city, 17); /** * @generated from message city.Route @@ -707,7 +580,7 @@ export type Route = Message<"city.Route"> & { * Use `create(RouteSchema)` to create a new message. */ export const RouteSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 22); + messageDesc(file_city, 18); /** * @generated from message city.RoomSave @@ -767,11 +640,6 @@ 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; }; /** @@ -779,5 +647,5 @@ export type RoomSave = Message<"city.RoomSave"> & { * Use `create(RoomSaveSchema)` to create a new message. */ export const RoomSaveSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_city, 23); + messageDesc(file_city, 19); diff --git a/deploy/README.md b/deploy/README.md index ada94c5..267b657 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -35,7 +35,7 @@ docker compose up -d --wait ``` Проверка: `docker compose ps`, `docker compose logs --tail=100 app`, -`curl --fail http://localhost:3000/health`. Миграция комнат v1→v2 выполняется +`curl --fail http://localhost:3000/health`. Миграция комнат v1/v2→v3 выполняется при открытии комнаты; неизвестная версия остаётся на диске с ошибкой. Квота `CITYGEN_SAVE_QUOTA_BYTES` включает все снимки и временный новый файл до ротации: оставьте запас минимум на один полный снимок. При переполнении @@ -72,4 +72,4 @@ Prometheus на `http://localhost:9090` опрашивает `/metrics` кажд Без Engine можно проверить `docker compose --profile monitoring config --quiet`. Полный smoke-test контейнера после запуска: `/health`, вход двух клиентов, -команда «Обед», остановка/запуск и проверка восстановленного состояния. +остановка/запуск и проверка восстановленного состояния. diff --git a/docs/PLAN.md b/docs/PLAN.md index 28be18c..eba1633 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -4,7 +4,7 @@ ## 1. Цель и рамки -Игра: симулятор бога в духе RimWorld, но с меньшим влиянием игрока. Город живёт сам (жители, машины, потребности, события), игроки в основном наблюдают и ограниченно вмешиваются. По запросу автора от 2026-10-30 этап 5 включает базовые команды вмешательства. +Игра: симулятор бога в духе RimWorld, но с меньшим влиянием игрока. Город живёт сам (жители, машины, потребности, события), игроки в основном наблюдают и ограниченно вмешиваются. Конкретные механики вмешательства требуют отдельного согласования. Исходные требования: @@ -172,13 +172,13 @@ - [x] Оптимизация протокола и рендера. Бинарные дельты, квантизация, объединение пропущенных обновлений под бюджетом трафика, ограничение видимых сущностей и кэш тайлов; динамика пока на Canvas. -**Этап 5. Базовый игровой слой** -- [x] Потребности, события, экономика. - Усреднённые голод, усталость и настроение населения; сон, питание, производство еды и доход казны. События урожая, болезни и дождя воспроизводятся из seed и игрового времени. Индивидуальный ИИ жителей пока остаётся расписанием дом–работа. -- [x] Ограниченное влияние игроков (команды, права, очереди, лимиты). - Покупка запасов, общий обед и отдых; одинаковые права подключённых игроков, цена из общей казны, ограниченная очередь, общий часовой игровой интервал и интервал сессии. Пауза/скорость остаются голосованием. +**Этап 5. Игровой слой** +- [ ] Потребности, события, экономика. + Общегородская казна, запасы и усреднённые потребности удалены по просьбе автора. Индивидуальный ИИ жителей пока остаётся расписанием дом–работа. +- [ ] Ограниченное влияние игроков (команды, права, очереди, лимиты). + Массовые команды запасов, обеда и отдыха удалены. Пауза/скорость остаются голосованием. - [x] Сохранения, восстановление комнат, админка. - Схема v2 сохраняет игровой слой и очередь, v1 мигрируется при открытии. Добавлены общая квота снимков, защита прежних файлов при переполнении и админский интерфейс/API с отдельным токеном оператора. + Схема v3 загружает снимки v1/v2, отбрасывая удалённую экономику. Общая квота снимков, защита прежних файлов при переполнении и админский интерфейс/API с отдельным токеном оператора сохраняются. **Этап 6. Эксплуатация** - [x] Подготовка Compose, мониторинг (метрики тика, число сущностей, трафик), логи. @@ -187,7 +187,7 @@ Проверка минимальной сцены: `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 в согласованном объёме. +Состояние и ограничения реализации описаны в `server/README.md`, запуск Compose — в `deploy/README.md`. Игровые механики этапа 5 остаются незавершёнными; этап 6 — подготовка Compose в согласованном объёме. ## 10. Риски @@ -271,8 +271,8 @@ - Реальная нагрузка, 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 с локально. +- После удаления городской экономики: 18 TS-тестов, 9 Rust unit-тестов и 2 интеграционных теста. Проверяются миграция v1/v2→v3 с отбрасыванием удалённых полей, детерминизм жителей, квота, авторизация админки и запись снимков. +- Chrome: загрузка города, общий календарь, пауза, этаж/масштаб, админское сохранение; возобновление ожидания генерации без повторного POST. Дальнейшее развитие за пределами базового слоя: индивидуальные потребности и выбор целей, детальные переходы между этажами, использование мебели и diff --git a/proto/city.proto b/proto/city.proto index fde3b47..d88a63a 100644 --- a/proto/city.proto +++ b/proto/city.proto @@ -7,19 +7,11 @@ 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; Intervene intervene = 6; } + reserved 6; + oneof body { Join join = 1; Subscribe subscribe = 2; Ping ping = 3; Propose propose = 4; Vote vote = 5; } } 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; } @@ -29,7 +21,8 @@ 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; Gameplay gameplay = 8; } + reserved 8; + oneof body { Welcome welcome = 1; Snapshot snapshot = 2; Delta delta = 3; Error error = 4; Ping pong = 5; VoteState vote_state = 6; Clock clock = 7; } } 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; } @@ -38,5 +31,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; + reserved 12; // Retired city-wide economy in snapshot v2. } diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs index f4136d8..c517879 100644 --- a/scripts/browser-smoke.mjs +++ b/scripts/browser-smoke.mjs @@ -24,10 +24,6 @@ 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(); diff --git a/scripts/generation-browser-smoke.mjs b/scripts/generation-browser-smoke.mjs new file mode 100644 index 0000000..063952f --- /dev/null +++ b/scripts/generation-browser-smoke.mjs @@ -0,0 +1,37 @@ +// Run with `node --import tsx scripts/generation-browser-smoke.mjs`. +// Uses the same optional Playwright environment variables as browser-smoke.mjs. +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import assert from 'node:assert/strict'; +import { create, toBinary } from '@bufbuild/protobuf'; +import { CityJobSchema } from '../client/generated/city_pb.ts'; +const require = createRequire(process.env.CITYGEN_BROWSER_MODULES ? resolve(process.env.CITYGEN_BROWSER_MODULES, 'package.json') : import.meta.url); +const { chromium } = require('playwright'); +const browser = await chromium.launch({ headless: true, executablePath: process.env.CITYGEN_BROWSER_EXECUTABLE }); +try { + const page = await browser.newPage(); + const errors = []; page.on('pageerror', error => errors.push(error.message)); + let posts = 0, polls = 0, failed = false; + await page.route('**/api/cities/jobs**', async route => { + if (route.request().method() === 'POST') posts++; + else polls++; + const job = create(CityJobSchema, { id: 'resume-test', cityId: 'test', status: failed ? 'failed' : 'running', stage: 'Повтор Overpass', error: failed ? 'Overpass временно недоступен' : '' }); + await route.fulfill({ contentType: 'application/x-protobuf', body: Buffer.from(toBinary(CityJobSchema, job)) }); + }); + const url = `${process.env.CITYGEN_TEST_URL || 'http://127.0.0.1:3000'}/?create-city&job=resume-test`; + await page.goto(url); + await page.getByText('Повтор Overpass…', { exact: true }).waitFor(); + await page.reload(); + await page.getByText('Повтор Overpass…', { exact: true }).waitFor(); + await page.getByRole('button', { name: 'Закрыть ожидание', exact: true }).click(); + await page.locator('.overlay').waitFor({ state: 'hidden' }); + await page.getByRole('button', { name: 'Сгенерировать', exact: true }).click(); + await page.getByText('Повтор Overpass…', { exact: true }).waitFor(); + failed = true; + await page.locator('.err').filter({ hasText: 'Overpass временно недоступен' }).waitFor(); + assert.equal(new URL(page.url()).searchParams.has('job'), false); + assert.equal(posts, 0, 'reload and resume must not create another job'); + assert.ok(polls >= 3); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ generationResume: 'passed', posts, polls })); +} finally { await browser.close(); } diff --git a/scripts/overpass.test.ts b/scripts/overpass.test.ts index a7bbedb..a80fe9d 100644 --- a/scripts/overpass.test.ts +++ b/scripts/overpass.test.ts @@ -27,7 +27,7 @@ test('times out a hanging request and tries the next server', async (t) => { return ++calls === 1 ? pending(init.signal!) : Promise.resolve(ok()); }); const result = fetchOverpass(bbox); - t.mock.timers.tick(45_000); + t.mock.timers.tick(90_000); assert.deepEqual(await result, elements); assert.equal(calls, 2); }); @@ -41,7 +41,7 @@ test('timeout also covers downloading the response body', async (t) => { }); const result = fetchOverpass(bbox); await Promise.resolve(); - t.mock.timers.tick(45_000); + t.mock.timers.tick(90_000); assert.deepEqual(await result, elements); assert.equal(calls, 2); }); @@ -76,11 +76,18 @@ test('reports failure when every server times out', async (t) => { t.mock.timers.enable({ apis: ['setTimeout'] }); const fetch = t.mock.method(globalThis, 'fetch', (_url: string, init: RequestInit) => pending(init.signal!)); const result = fetchOverpass(bbox); - const check = assert.rejects(result, /Таймаут.*45 секунд/); + const check = assert.rejects(result, /Таймаут.*90 секунд/); for (let i = 0; i < OVERPASS_ENDPOINTS.length; i++) { - t.mock.timers.tick(45_000); + t.mock.timers.tick(90_000); await Promise.resolve(); } await check; assert.equal(fetch.mock.callCount(), OVERPASS_ENDPOINTS.length); }); +test('server identifies itself to Overpass rather than using the generic Node user agent', async (t) => { + t.mock.method(globalThis, 'fetch', async (_url: string, init: RequestInit) => { + assert.match(new Headers(init.headers).get('User-Agent')!, /citygen/); + return ok(); + }); + assert.deepEqual(await fetchOverpass(bbox), elements); +}); diff --git a/server/README.md b/server/README.md index ed945a1..bf74070 100644 --- a/server/README.md +++ b/server/README.md @@ -25,7 +25,10 @@ Ник уникален в комнате, сессия выдаётся сервером. Пауза и скорости 1/2/4 принимаются большинством более половины подключённых игроков. Голосование длится 20 с; интервал предложений игрока — 30 с. Владелец и привилегии -создателя отсутствуют. Создание ограничено одним запросом с IP в минуту. +создателя отсутствуют. Создание ограничено одним новым городом и одной новой +комнатой с IP в минуту (независимые лимиты). Повторный запрос той же области +возвращает уже принятую задачу без расхода лимита. Отказ 429 содержит +`Retry-After` и `retryAfterSeconds` с оставшимся временем ожидания. Снимки Protobuf кодируются и записываются отдельным ограниченным по очереди потоком, атомарно заменяются и ротируются; голоса и подключения @@ -53,6 +56,10 @@ `CityJob` из общей схемы передаются по HTTP как `application/x-protobuf`. Каталог и создание комнаты используют JSON. Задания очереди не переживают перезапуск; готовые города и снимки комнат сохраняются на диске. +Ссылка `/?create-city&job=ID` возобновляет ожидание после обновления страницы. +Серверные запросы Overpass используют User-Agent приложения и таймаут 90 с +(браузерный отладочный генератор — 45 с). Прогресс включает выбранный сервер +и повторные попытки; технический stderr воркера остаётся в логах сервера. `/health` проверяет доступность; `/metrics` отдаёт число игроков/комнат, тик, время последнего и максимального шага, число сущностей и байты очереди рассылки каждой @@ -73,27 +80,10 @@ LOD и выводят трафик, выборку длительности ти Compose, постоянные тома, лимиты, healthcheck и профиль Prometheus описаны в [deploy/README.md](../deploy/README.md). -## Игровой слой +## Совместимость сохранений -Голод, усталость и настроение моделируются как усреднённые показатели -населения комнаты. Они меняются по игровому календарю независимо от камеры. -Сон ночью восстанавливает силы; питание трижды в день тратит запасы еды. -Голод и усталость снижают производство и доход казны в рабочие часы. -Почасовые события (урожай, болезнь, дождь) детерминированы seed и временем. -История ограничена 16 событиями. Это базовая экономика общего города; -индивидуальные кошельки, машины и детальное использование мебели не включены. - -Все игроки имеют одинаковые права на три команды: покупка запасов -(2 единицы казны на жителя), общий обед (1), отдых (3). Цена списывается -при приёме, действие исполняется в следующую игровую минуту. Сервер проверяет -ресурсы, паузу, очередь (до 8), общий интервал 60 игровых минут и интервал -сессии 30 реальных секунд. Общий интервал переживает переподключение. -Пауза и скорость по-прежнему требуют большинства голосов. - -Снимки v2 сохраняют экономику, потребности, историю, очередь и общий интервал. -Для v1 игровой слой инициализируется по числу жителей и сохранённому времени; -позиции, seed и расписания сохраняются. Неизвестные версии не перезаписываются. -Квота проверяется под общей блокировкой писателей комнат; при нехватке места -прежние снимки остаются. Один снимок ограничен 128 МиБ. -`/?admin` и API с Bearer-токеном показывают каталог, ошибки, квоту -и позволяют сохранить активную комнату. +Снимки v3 загружаются вместе с прежними v1/v2. Удалённые поля городской +экономики отбрасываются; жители, маршруты, время и seed сохраняются. +Общегородская казна, запасы, усреднённые потребности и массовые команды удалены. +Пауза и скорость по-прежнему принимаются голосованием. +Квота снимков и администрирование продолжают работать. diff --git a/server/src/gameplay.rs b/server/src/gameplay.rs deleted file mode 100644 index 890f1d8..0000000 --- a/server/src/gameplay.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! 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 94d67ae..49c57b6 100644 --- a/server/src/gateway.rs +++ b/server/src/gateway.rs @@ -79,7 +79,7 @@ impl Default for Config { pub struct App { pub config: Config, rooms: Arc>>, - limits: Arc>>, + limits: Arc>>, pub jobs: crate::generation::Jobs, } struct RoomHandle { @@ -118,7 +118,6 @@ struct Player { lod: u32, known: HashMap, last_proposal: Option, - last_intervention: Option, traffic: Traffic, metrics: Arc, } @@ -190,6 +189,14 @@ type ApiError = (StatusCode, Json); fn api_error(code: StatusCode, text: impl Into) -> ApiError { (code, Json(serde_json::json!({"error":text.into()}))) } +fn creation_throttled(at: Instant, now: Instant) -> axum::response::Response { + let seconds = 60u64 + .saturating_sub(now.duration_since(at).as_secs()) + .max(1); + (StatusCode::TOO_MANY_REQUESTS, + [(axum::http::header::RETRY_AFTER, seconds.to_string())], + Json(serde_json::json!({"error":format!("Слишком частые запросы создания. Повторите через {seconds} с."),"retryAfterSeconds":seconds}))).into_response() +} impl App { pub fn new(config: Config) -> Result { @@ -469,7 +476,7 @@ async fn create_city( State(app): State, ConnectInfo(peer): ConnectInfo, body: axum::body::Bytes, -) -> Result { +) -> Result { let input = p::CreateCity::decode(&body[..]).map_err(|_| { api_error( StatusCode::BAD_REQUEST, @@ -484,25 +491,37 @@ async fn create_city( name: input.name, seed: input.seed, }; - crate::generation::normalize(&request, app.config.max_generation_km2) + let bbox = crate::generation::normalize(&request, app.config.max_generation_km2) .map_err(|e| api_error(StatusCode::BAD_REQUEST, e))?; - { + let city_id = crate::generation::cache_id(bbox, request.seed); + let job = { let mut limits = app.limits.lock().unwrap(); + // Resuming an accepted job does not create work or spend another token. + if let Some(job) = app + .jobs + .items + .lock() + .unwrap() + .values() + .find(|j| j.city_id == city_id && j.status != "failed") + .cloned() + { + return Ok(job_response(job, String::new()).into_response()); + } let now = Instant::now(); limits.retain(|_, at| now.duration_since(*at) < Duration::from_secs(60)); - if limits.contains_key(&peer.ip()) { - return Err(api_error( - StatusCode::TOO_MANY_REQUESTS, - "One creation per IP per minute", - )); + let key = (peer.ip(), "city"); + if let Some(at) = limits.get(&key) { + return Ok(creation_throttled(*at, now)); } - limits.insert(peer.ip(), now); - } - let job = app - .jobs - .submit(app.clone(), request) - .map_err(|e| api_error(StatusCode::CONFLICT, e))?; - Ok(job_response(job, String::new())) + let job = app + .jobs + .submit(app.clone(), request) + .map_err(|e| api_error(StatusCode::CONFLICT, e))?; + limits.insert(key, now); + job + }; + Ok(job_response(job, String::new()).into_response()) } async fn city_job( State(app): State, @@ -586,7 +605,7 @@ async fn create_room( State(app): State, ConnectInfo(peer): ConnectInfo, Json(request): Json, -) -> Result, ApiError> { +) -> Result { if !safe_id(&request.city_id) { return Err(api_error(StatusCode::BAD_REQUEST, "Invalid city ID")); } @@ -594,13 +613,10 @@ async fn create_room( let mut limits = app.limits.lock().unwrap(); let now = Instant::now(); limits.retain(|_, at| now.duration_since(*at) < Duration::from_secs(60)); - if limits.contains_key(&peer.ip()) { - return Err(api_error( - StatusCode::TOO_MANY_REQUESTS, - "One room creation per IP per minute", - )); + if let Some(at) = limits.get(&(peer.ip(), "room")) { + return Ok(creation_throttled(*at, now)); } - limits.insert(peer.ip(), now); + limits.insert((peer.ip(), "room"), now); } let id = uuid::Uuid::new_v4().simple().to_string(); let room_id = id.clone(); @@ -610,7 +626,7 @@ async fn create_room( .await .map_err(|e| api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .map_err(|e| api_error(StatusCode::CONFLICT, e))?; - Ok(Json(serde_json::json!({"id":id}))) + Ok(Json(serde_json::json!({"id":id})).into_response()) } async fn metrics(State(app): State) -> String { let players: usize = app @@ -823,7 +839,6 @@ fn room_loop( lod: 0, known: HashMap::new(), last_proposal: None, - last_intervention: None, traffic: Traffic::new(), metrics: metrics.clone(), }; @@ -919,32 +934,6 @@ 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 @@ -1033,14 +1022,6 @@ 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, diff --git a/server/src/generation.rs b/server/src/generation.rs index d629aea..038d9bb 100644 --- a/server/src/generation.rs +++ b/server/src/generation.rs @@ -119,6 +119,10 @@ impl Jobs { current.room_id = Some(room); } Err(e) => { + eprintln!( + "{}", + serde_json::json!({"event":"generation_failed","job":task.id,"error":e}) + ); current.status = "failed".into(); current.error = Some(e); } @@ -183,9 +187,23 @@ impl Jobs { .map_err(|e| e.to_string())?; let error = reader.await.unwrap_or_default(); if !status.success() { + let readable = error + .lines() + .find_map(|line| { + let value: serde_json::Value = + serde_json::from_str(line.strip_prefix("CITYGEN_ERROR:")?).ok()?; + value["message"].as_str().map(str::to_owned) + }) + .unwrap_or_else(|| { + "Ошибка воркера генерации. Подробности в логах сервера.".into() + }); + eprintln!( + "{}", + serde_json::json!({"event":"generation_worker_failed","job":job.id,"stderr":error}) + ); return Err(format!( - "City generation failed: {}", - error.chars().take(2000).collect::() + "Не удалось создать город: {}", + readable.chars().take(2000).collect::() )); } } diff --git a/server/src/lib.rs b/server/src/lib.rs index b34a974..5839c1f 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -2,7 +2,6 @@ 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/simulation.rs b/server/src/simulation.rs index 9df8e8d..0ddde97 100644 --- a/server/src/simulation.rs +++ b/server/src/simulation.rs @@ -225,7 +225,6 @@ 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 { @@ -269,12 +268,6 @@ 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 { @@ -394,7 +387,7 @@ impl Simulation { }) .collect(); let metadata = RoomSave { - version: 2, + version: 3, id: self.id.clone(), city_id: self.city_id.clone(), city_revision: self.city_revision.clone(), @@ -405,7 +398,6 @@ impl Simulation { speed: clock.speed, agents: vec![], routes, - gameplay: Some(self.world.resource::().0.clone()), }; Checkpoint { metadata, @@ -413,7 +405,7 @@ impl Simulation { } } pub fn restore(saved: RoomSave) -> Result { - if ![1, 2].contains(&saved.version) || saved.city_version != 1 { + if ![1, 2, 3].contains(&saved.version) || saved.city_version != 1 { return Err("Incompatible room snapshot".into()); } if !saved.game_seconds.is_finite() @@ -438,14 +430,6 @@ 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 @@ -565,29 +549,25 @@ mod tests { assert_eq!(a.clock().seconds, seconds); } #[test] - fn legacy_snapshot_migrates_and_pending_commands_survive_restart() { + fn legacy_snapshots_restore_without_retired_economy() { + use prost::Message; 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(); + for version in [1, 2] { + let mut legacy = sim.save(); + legacy.version = version; + let mut bytes = legacy.encode_to_vec(); + // Former field 12 with treasury=100; unknown fields are discarded. + bytes.extend_from_slice(&[0x62, 0x02, 0x08, 0x64]); + let mut restored = Simulation::restore(RoomSave::decode(&bytes[..]).unwrap()).unwrap(); + let migrated = restored.save(); + assert_eq!(migrated.version, 3); + assert_eq!(migrated.agents, legacy.agents); + assert_eq!(migrated.game_seconds, legacy.game_seconds); + assert_eq!(migrated.routes, legacy.routes); + assert_eq!(migrated.seed, legacy.seed); } - assert_eq!(migrated.save(), restored.save()); - assert!(restored.save().gameplay.unwrap().pending.is_empty()); - let mut invalid = restored.save(); + let mut invalid = sim.save(); invalid.version = 99; assert!(Simulation::restore(invalid).is_err()); } diff --git a/server/src/storage.rs b/server/src/storage.rs index 560014a..7798bcd 100644 --- a/server/src/storage.rs +++ b/server/src/storage.rs @@ -92,7 +92,7 @@ pub fn load(root: &Path, id: &str) -> Result { if snapshot.id != id { continue; } - if ![1, 2].contains(&snapshot.version) || snapshot.city_version != 1 { + if ![1, 2, 3].contains(&snapshot.version) || snapshot.city_version != 1 { return Err("Incompatible room snapshot".into()); } return Ok(snapshot); diff --git a/server/tests/generation.rs b/server/tests/generation.rs index 4e69a2b..a032872 100644 --- a/server/tests/generation.rs +++ b/server/tests/generation.rs @@ -102,14 +102,26 @@ async fn isolated_typescript_worker_exports_city_and_opens_room_without_network( ) .unwrap(); assert_eq!(duplicate.id, job.id); - let (status, _) = request( + let (status, body) = request( address, "POST", "/api/cities/jobs", &message.encode_to_vec(), ) .await; + assert_eq!(status, 200, "repeated POST resumes the accepted job"); + assert_eq!(CityJob::decode(&body[..]).unwrap().id, job.id); + let different = CreateCity { seed: 2, ..message }; + let (status, body) = request( + address, + "POST", + "/api/cities/jobs", + &different.encode_to_vec(), + ) + .await; assert_eq!(status, 429); + let error: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!(error["retryAfterSeconds"].as_u64().unwrap() <= 60); app.stop(); server.abort(); assert_eq!( diff --git a/server/tests/websocket.rs b/server/tests/websocket.rs index 8c0690e..18830d5 100644 --- a/server/tests/websocket.rs +++ b/server/tests/websocket.rs @@ -151,37 +151,6 @@ 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 diff --git a/src/main.ts b/src/main.ts index 473d6f9..596c8b0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -47,6 +47,28 @@ function progress(text: string, frac: number) { } const hideProgress = () => (overlay.hidden = true); +async function followCityJob(id: string, signal: AbortSignal) { + history.replaceState(null, '', `/?create-city&job=${encodeURIComponent(id)}`); + progress('Проверка задачи генерации…', 0.05); + for (;;) { + const response = await fetch(`/api/cities/jobs/${encodeURIComponent(id)}`, { signal }); + if (!response.ok) { + if (response.status === 404) history.replaceState(null, '', '/?create-city'); + throw new Error((await response.json()).error || `HTTP ${response.status}`); + } + const state = fromBinary(CityJobSchema, new Uint8Array(await response.arrayBuffer())); + if (state.status === 'failed') { + history.replaceState(null, '', '/?create-city'); + throw new Error(state.error || 'Не удалось создать город'); + } + if (state.status === 'ready') { + hideProgress(); history.replaceState(null, '', `/?play&room=${encodeURIComponent(state.roomId)}`); await showLobby(); return; + } + progress(state.status === 'queued' ? 'Город в очереди…' : `${state.stage || 'Подготовка города'}…`, state.status === 'queued' ? 0.05 : 0.5); + await new Promise(resolve => setTimeout(resolve, 1000)); signal.throwIfAborted(); + } +} + // ------------------------------------------------------------------ экраны function showViewer(world: World) { @@ -166,27 +188,22 @@ async function showPicker(initial?: BBox) { }); goBtn.addEventListener('click', async () => { - if (!bbox || goBtn.disabled) return; + if ((!bbox && !new URLSearchParams(location.search).has('job')) || goBtn.disabled) return; goBtn.disabled = true; showErr(null); abort = new AbortController(); (overlay.querySelector('[data-a=cancel]') as HTMLButtonElement).textContent = new URLSearchParams(location.search).has('create-city') ? 'Закрыть ожидание' : 'Отмена'; try { if (new URLSearchParams(location.search).has('create-city')) { + const existing = new URLSearchParams(location.search).get('job'); + if (existing) { await followCityJob(existing, abort.signal); return; } + if (!bbox) return; const response = await fetch('/api/cities/jobs',{method:'POST',headers:{'Content-Type':'application/x-protobuf'},body:toBinary(CreateCitySchema,create(CreateCitySchema,{bbox,name:nameInput.value.trim() || 'Новый город',seed:1})),signal:abort.signal}); if (!response.ok) throw new Error((await response.json()).error || `HTTP ${response.status}`); const job = fromBinary(CityJobSchema,new Uint8Array(await response.arrayBuffer())); - for (;;) { - const response = await fetch(`/api/cities/jobs/${encodeURIComponent(job.id)}`,{signal:abort.signal}); - if (!response.ok) throw new Error((await response.json()).error || `HTTP ${response.status}`); - const state = fromBinary(CityJobSchema,new Uint8Array(await response.arrayBuffer())); - if (state.status === 'failed') throw new Error(state.error || 'Не удалось создать город'); - if (state.status === 'ready') { hideProgress(); history.replaceState(null,'',`/?play&room=${encodeURIComponent(state.roomId)}`); await showLobby(); return; } - progress(state.status === 'queued' ? 'Город в очереди…' : `${state.stage || 'Подготовка города'}…`,state.status === 'queued' ? 0.05 : 0.5); - await new Promise(resolve => setTimeout(resolve,1000)); - abort.signal.throwIfAborted(); - } + await followCityJob(job.id, abort.signal); return; } + if (!bbox) return; progress('Загрузка данных OpenStreetMap…', 0.02); const els = await fetchOverpass(bbox, (s) => progress(s, 0.03), abort.signal); await runGeneration(els, bbox, nameInput.value.trim() || 'Новая карта'); @@ -195,7 +212,7 @@ async function showPicker(initial?: BBox) { if ((ex as Error).name !== 'AbortError') showErr((ex as Error).message); else if (new URLSearchParams(location.search).has('create-city')) showErr('Ожидание закрыто. Принятая сервером задача продолжится; готовый город появится в каталоге комнат.'); } finally { - goBtn.disabled = !bbox || bboxSizeKm(bbox).km2 > MAX_KM2; + goBtn.disabled = !new URLSearchParams(location.search).has('job') && (!bbox || bboxSizeKm(bbox).km2 > MAX_KM2); } }); @@ -237,6 +254,15 @@ async function showPicker(initial?: BBox) { showErr((ex as Error).message); } }); + const pendingJob = new URLSearchParams(location.search).get('job'); + if (pendingJob && new URLSearchParams(location.search).has('create-city')) { + abort = new AbortController(); goBtn.disabled = true; + (overlay.querySelector('[data-a=cancel]') as HTMLButtonElement).textContent = 'Закрыть ожидание'; + try { await followCityJob(pendingJob, abort.signal); } + catch (ex) { + hideProgress(); showErr((ex as Error).name === 'AbortError' ? 'Ожидание закрыто. Задача продолжает выполняться; нажмите «Сгенерировать», чтобы продолжить ожидание.' : (ex as Error).message); + } finally { goBtn.disabled = !new URLSearchParams(location.search).has('job') && (!bbox || bboxSizeKm(bbox).km2 > MAX_KM2); } + } } function bboxOfElements(els: OsmElement[]): BBox { @@ -326,14 +352,6 @@ 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(); @@ -370,13 +388,6 @@ 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; }; diff --git a/src/osm/overpass.ts b/src/osm/overpass.ts index 4cff98a..9d465b2 100644 --- a/src/osm/overpass.ts +++ b/src/osm/overpass.ts @@ -21,6 +21,7 @@ export const OVERPASS_ENDPOINTS = [ ]; const REQUEST_TIMEOUT_MS = 45_000; +const SERVER_REQUEST_TIMEOUT_MS = 90_000; function waitForRetry(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { @@ -73,7 +74,9 @@ out geom;`; } export async function fetchOverpass(b: BBox, onStatus?: (s: string) => void, signal?: AbortSignal): Promise { - const query = buildQuery(b); + const server = typeof window === 'undefined'; + const timeout = server ? SERVER_REQUEST_TIMEOUT_MS : REQUEST_TIMEOUT_MS; + const query = server ? buildQuery(b).replace('[timeout:30]', '[timeout:60]') : buildQuery(b); let lastErr: unknown = null; for (const url of OVERPASS_ENDPOINTS) { for (let attempt = 0; attempt < 2; attempt++) { @@ -85,19 +88,24 @@ export async function fetchOverpass(b: BBox, onStatus?: (s: string) => void, sig const timer = setTimeout(() => { timedOut = true; controller.abort(); - }, REQUEST_TIMEOUT_MS); + }, timeout); try { - onStatus?.(`Запрос к ${new URL(url).host}${attempt ? ' (повтор)' : ''} — до 45 секунд…`); + onStatus?.(`Запрос к ${new URL(url).host}${attempt ? ' (повтор)' : ''} — до ${timeout / 1000} секунд…`); const res = await fetch(url, { method: 'POST', body: 'data=' + encodeURIComponent(query), - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(server ? { 'User-Agent': 'citygen/0.2.0 (self-hosted OSM city generator)' } : {}) }, signal: controller.signal, }); if (res.status === 429 || res.status === 504) { - lastErr = new Error(`Сервер перегружен (${res.status})`); + lastErr = new Error(`Overpass ${new URL(url).host} временно отклонил запрос (${res.status})`); clearTimeout(timer); - if (attempt === 0) await waitForRetry(3000, signal); + if (attempt === 0) { + const retry = Number(res.headers?.get('Retry-After')); + const delay = Number.isFinite(retry) && retry > 0 ? Math.min(30_000, retry * 1000) : 3000; + onStatus?.(`Overpass вернул ${res.status}. Повтор через ${Math.ceil(delay / 1000)} с…`); + await waitForRetry(delay, signal); + } continue; } if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -108,7 +116,7 @@ export async function fetchOverpass(b: BBox, onStatus?: (s: string) => void, sig return json.elements as OsmElement[]; } catch (e) { if (signal?.aborted) throw new DOMException('Отменено', 'AbortError'); - lastErr = timedOut ? new Error(`Таймаут ${new URL(url).host}: 45 секунд`) : e; + lastErr = timedOut ? new Error(`Таймаут ${new URL(url).host}: ${timeout / 1000} секунд`) : e; break; } finally { clearTimeout(timer); diff --git a/tools/generate-worker.ts b/tools/generate-worker.ts index ab32d5c..aad459e 100644 --- a/tools/generate-worker.ts +++ b/tools/generate-worker.ts @@ -1,4 +1,5 @@ import { readFile, mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { writeFileSync } from 'node:fs'; import { fetchOverpass, type BBox } from '../src/osm/overpass'; import { generateWorldSync } from '../src/gen/generate'; import { exportCity } from './export/index'; @@ -6,8 +7,9 @@ const job = JSON.parse(await readFile(process.argv[2], 'utf8')) as { bbox: BBox; name: string; seed: number; cityId: string; output: string; maxBuildings: number; progress: string; input?:string; }; const progress = async (stage: string) => writeFile(job.progress,JSON.stringify({stage})); +async function run() { await progress('Загрузка OSM'); -const elements = job.input ? JSON.parse(await readFile(job.input,'utf8')).elements : await fetchOverpass(job.bbox); +const elements = job.input ? JSON.parse(await readFile(job.input,'utf8')).elements : await fetchOverpass(job.bbox, stage => writeFileSync(job.progress, JSON.stringify({stage}))); if (elements.filter(e => e.tags?.building && e.tags.building !== 'no').length > job.maxBuildings) throw new Error('Превышен лимит зданий'); await progress('Генерация'); const world = generateWorldSync(elements,job.bbox,job.name,job.seed); @@ -25,3 +27,8 @@ try { await rm(temporary,{recursive:true,force:true}); throw error; } await progress('Готово'); +} +await run().catch(error => { + console.error(`CITYGEN_ERROR:${JSON.stringify({message: error instanceof Error ? error.message : String(error)})}`); + process.exitCode = 1; +});