From 3b1fcd996aed62298318d8c7a4d80beb92e81daf Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:06:56 +0300 Subject: [PATCH] Implement tiled multiplayer city simulation and scaling stages --- .dockerignore | 8 + .gitignore | 4 + Dockerfile | 25 + README.md | 44 +- buf.gen.yaml | 5 + buf.yaml | 3 + client/README.md | 7 + client/connection.ts | 68 ++ client/entities.ts | 33 + client/generated/city_pb.ts | 651 ++++++++++++ client/tiles.ts | 55 ++ docs/PLAN.md | 275 ++++++ package-lock.json | 257 +++++ package.json | 16 +- proto/README.md | 12 + proto/city.proto | 32 + scripts/browser-smoke.mjs | 34 + scripts/entities.test.ts | 21 + scripts/streets.test.ts | 35 + scripts/tiles.test.ts | 128 +++ server/Cargo.lock | 1859 +++++++++++++++++++++++++++++++++++ server/Cargo.toml | 26 + server/README.md | 73 ++ server/build.rs | 5 + server/src/bin/load.rs | 55 ++ server/src/city.rs | 333 +++++++ server/src/gateway.rs | 986 +++++++++++++++++++ server/src/generation.rs | 231 +++++ server/src/lib.rs | 9 + server/src/main.rs | 65 ++ server/src/navigation.rs | 285 ++++++ server/src/simulation.rs | 548 +++++++++++ server/src/storage.rs | 94 ++ server/tests/generation.rs | 121 +++ server/tests/websocket.rs | 255 +++++ src/city/format.ts | 136 +++ src/gen/city.ts | 2 + src/gen/finalize.ts | 2 + src/gen/generate.ts | 2 + src/gen/objects.ts | 44 + src/gen/pipeline.ts | 3 + src/gen/streets.ts | 106 ++ src/main.ts | 172 +++- src/osm/overpass.ts | 3 + src/types.ts | 10 + src/view/viewer.ts | 61 +- tools/README.md | 14 + tools/demo.ts | 15 + tools/export/index.ts | 43 + tools/generate-worker.ts | 27 + tools/load-bots.ts | 53 + tools/rust.mjs | 14 + tsconfig.json | 2 +- 53 files changed, 7354 insertions(+), 13 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 buf.gen.yaml create mode 100644 buf.yaml create mode 100644 client/README.md create mode 100644 client/connection.ts create mode 100644 client/entities.ts create mode 100644 client/generated/city_pb.ts create mode 100644 client/tiles.ts create mode 100644 docs/PLAN.md create mode 100644 proto/README.md create mode 100644 proto/city.proto create mode 100644 scripts/browser-smoke.mjs create mode 100644 scripts/entities.test.ts create mode 100644 scripts/streets.test.ts create mode 100644 scripts/tiles.test.ts create mode 100644 server/Cargo.lock create mode 100644 server/Cargo.toml create mode 100644 server/README.md create mode 100644 server/build.rs create mode 100644 server/src/bin/load.rs create mode 100644 server/src/city.rs create mode 100644 server/src/gateway.rs create mode 100644 server/src/generation.rs create mode 100644 server/src/lib.rs create mode 100644 server/src/main.rs create mode 100644 server/src/navigation.rs create mode 100644 server/src/simulation.rs create mode 100644 server/src/storage.rs create mode 100644 server/tests/generation.rs create mode 100644 server/tests/websocket.rs create mode 100644 src/city/format.ts create mode 100644 src/gen/objects.ts create mode 100644 src/gen/streets.ts create mode 100644 tools/README.md create mode 100644 tools/demo.ts create mode 100644 tools/export/index.ts create mode 100644 tools/generate-worker.ts create mode 100644 tools/load-bots.ts create mode 100644 tools/rust.mjs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..67e5ce7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.tools +node_modules +dist +server/target +data +world.json +public/cities diff --git a/.gitignore b/.gitignore index 641e4e9..7e2966f 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ node_modules/ dist/ *.log world.json +.tools/ +server/target/ +data/ +public/cities/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e814ddb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM node:22-bookworm-slim AS client +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run proto:generate && npm run typecheck && npm test && npm run build + +FROM rust:1-bookworm AS server +WORKDIR /app +COPY server server +COPY proto proto +RUN cargo build --release --locked --manifest-path server/Cargo.toml --bin citygen-server + +FROM node:22-bookworm-slim +WORKDIR /app +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/package.json package.json +COPY --from=server /app/server/target/release/citygen-server /usr/local/bin/citygen-server +ENV CITYGEN_BIND=0.0.0.0:3000 +VOLUME ["/app/public/cities", "/app/data"] +EXPOSE 3000 +CMD ["citygen-server"] diff --git a/README.md b/README.md index e6e3f5c..1f3eec0 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,47 @@ -# Город сверху — генератор карты по OpenStreetMap (MVP) +# Город сверху — генератор OSM и клиент-серверная симуляция + +Добавлен серверный режим по [плану](docs/PLAN.md): подготовленные города, +комнаты до 30 наблюдателей, жители по расписанию, общий календарь и голосования, +сохранения и очередь генерации. Браузерный генератор ниже остаётся режимом отладки. + +## Сетевая игра + +Нужны Node.js 22+ и Rust stable (на Windows — установленный MSVC linker). +Запускать команды из корня репозитория: + +```bash +npm ci +npm run demo # готовый город из локального образца OSM, без запросов к Overpass +npm run build +npm run server:dev # http://localhost:3000/?play +``` + +В списке выберите Бальцерс, задайте ник и создайте комнату. Другие игроки входят +в ту же комнату из списка. Кнопка «Создать город на сервере» открывает выбор +области и запускает отдельную задачу генерации с прогрессом. + +Можно экспортировать уже готовый мир и открыть только статику без симуляции: + +```bash +npm run export-city -- world.json public/cities/my-city +# http://localhost:3000/?city=/cities/my-city/manifest.json +``` + +Экспорты не перезаписываются: новая версия города получает новый каталог. +Настройки, API, ограничения LOD и описание сохранений находятся в +[server/README.md](server/README.md). + +Проверки: `npm run typecheck`, `npm test`, `npm run server:test`, +`npm run server:load`. Изменение протокола: `npm run proto:generate`. + +## Браузерный генератор для отладки Выделяете на карте небольшой город или район → по данным OSM строится подробная карта с видом строго сверху: дороги реальной ширины с тротуарами и разметкой, парковки с машинами, деревья, и **у каждого здания — этажи, комнаты, двери, окна, лестницы и мебель**, нарисованные простыми геометрическими фигурами, как на плане квартиры. -Всё работает в браузере, без сервера: данные берутся из Overpass API, генерация +В отладочном режиме всё работает в браузере: данные берутся из Overpass API, генерация идёт в Web Worker'ах, готовый мир сохраняется в IndexedDB. Машины появляются только на парковках и в гаражах; вдоль улиц они не генерируются. @@ -45,7 +81,7 @@ npm run cli -- samples/balzers-2013.json 47.058 9.492 47.074 9.518 world.json - справа — этаж для всей карты (⌂ — крыши), `PgUp`/`PgDn` или `[` `]`; - клик по зданию — карточка: вид, этажность (из OSM или оценка), квартиры, спальные места, свой переключатель этажей только для этого здания; -- «Экспорт мира» — весь мир одним файлом `.json.gz` (будущий формат для игры). +- «Экспорт мира» в отладочном режиме — весь мир одним файлом `.json.gz`; `export-city` преобразует его в серверный тайловый формат. ## Как устроена генерация @@ -74,7 +110,7 @@ Overpass JSON ─► city.ts ──────────────► зо ## Модель данных мира (`src/types.ts`) -Заложено под будущую симуляцию: +Статические данные уже используются сервером; для дальнейшего игрового слоя предусмотрены: | Что | Зачем потом | |---|---| diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..e192f83 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,5 @@ +version: v2 +plugins: + - local: protoc-gen-es + out: client/generated + opt: target=ts diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..ba5ddf5 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +modules: + - path: proto diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..0d51b04 --- /dev/null +++ b/client/README.md @@ -0,0 +1,7 @@ +# Browser client + +`tiles.ts` loads prepared city tiles over HTTP. The existing renderer and offline +debug generator remain in `src/` to preserve the working prototype. + +Open `/?city=/cities/demo/manifest.json`. Camera movement loads only intersecting +tiles plus a margin, with six concurrent requests and a bounded tile cache. diff --git a/client/connection.ts b/client/connection.ts new file mode 100644 index 0000000..71aa7f3 --- /dev/null +++ b/client/connection.ts @@ -0,0 +1,68 @@ +import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; +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'; + +export class RoomConnection { + readonly entities = new EntityState(); + private socket: WebSocket; + private ping: ReturnType; + private closed = false; + private pendingBounds: Bounds | null = null; + private welcomed = false; + private lod = 0; + private retry:ReturnType | undefined; + constructor(room: string, nickname: string, private events: { + welcome: (welcome: Welcome) => void; status: (text: string) => void; + clock: (clock: Clock) => void; vote: (vote: VoteState) => void; + }) { + const url = new URL(`/api/rooms/${encodeURIComponent(room)}/ws`, location.href); + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; + this.socket = new WebSocket(url); this.socket.binaryType = 'arraybuffer'; + this.socket.onopen = () => this.send({ case: 'join', value: { room, nickname, clientVersion: 1 } }); + this.socket.onmessage = ev => { + try { + const message = fromBinary(ServerMessageSchema, new Uint8Array(ev.data)); + const b = message.body; + switch (b.case) { + case 'welcome': + if (b.value.protocolVersion !== 1) throw new Error('Несовместимая версия сервера'); + this.welcomed = true; events.welcome(b.value); + if (this.pendingBounds) this.subscribe(this.pendingBounds); + break; + case 'snapshot': this.entities.snapshot(b.value); break; + case 'delta': this.entities.delta(b.value); break; + case 'error': + if (b.value.code === 'ENTITY_LIMIT' && this.pendingBounds && this.lod < 10) { + this.lod++; this.subscribe(this.pendingBounds); events.status(`Плотная область: отображается 1 из ${2 ** this.lod} жителей`); + } else if (b.value.code==='TRAFFIC_LIMIT' && this.pendingBounds) { + const bounds=this.pendingBounds;clearTimeout(this.retry); + this.retry=setTimeout(()=>{if (this.pendingBounds===bounds) this.subscribe(bounds);},1000); + events.status('Подождите, обновляем область…'); + } else events.status(b.value.code === 'VOTE_LIMIT' ? 'Дождитесь завершения голосования или 30-секундного интервала между предложениями' : `${b.value.code}: ${b.value.message}`); + break; + case 'clock': events.clock(b.value); break; + case 'voteState': events.vote(b.value); break; + } + } catch (error) { events.status((error as Error).message); this.destroy(); } + }; + this.socket.onclose = () => { clearInterval(this.ping); this.entities.clear(); if (!this.closed) events.status('Соединение закрыто. Вернитесь в список комнат, чтобы войти снова.'); }; + this.socket.onerror = () => events.status('Не удалось подключиться к серверу'); + this.ping = setInterval(() => this.send({ case: 'ping', value: { nonce: 1 } }), 20_000); + } + private send(body: any) { + if (this.socket.readyState === WebSocket.OPEN) this.socket.send(toBinary(ClientMessageSchema, create(ClientMessageSchema, { body }))); + } + subscribe(bounds: Bounds) { + clearTimeout(this.retry); + this.pendingBounds = bounds; + if (!this.welcomed) return; + this.send({ case: 'subscribe', value: { rect: { + minX: Math.round(bounds.minX * 100), minY: Math.round(bounds.minY * 100), + maxX: Math.round(bounds.maxX * 100), maxY: Math.round(bounds.maxY * 100), + }, lod: this.lod } }); + } + propose(speed: number) { this.send({ case: 'propose', value: { speed } }); } + vote(yes: boolean) { this.send({ case: 'vote', value: { yes } }); } + destroy() { this.closed = true; clearTimeout(this.retry); clearInterval(this.ping); this.socket.close(); this.entities.clear(); } +} diff --git a/client/entities.ts b/client/entities.ts new file mode 100644 index 0000000..11d3d7f --- /dev/null +++ b/client/entities.ts @@ -0,0 +1,33 @@ +import type { Agent, Delta, Snapshot } from './generated/city_pb'; + +interface Sample { previous: Agent; current: Agent; fromTick: number; toTick: number } +/** Keep only subscribed entities. Time is aligned to the latest server tick. */ +export class EntityState { + private entities = new Map(); + private tick = 0; + private received = 0; + snapshot(snapshot: Snapshot, now = performance.now()) { + this.entities.clear(); this.tick = Number(snapshot.tick); this.received = now; + for (const a of snapshot.entities) this.entities.set(a.id, { previous: a, current: a, fromTick: this.tick, toTick: this.tick }); + } + delta(delta: Delta, now = performance.now()) { + const tick = Number(delta.tick); + if (tick < this.tick) return; + this.tick = tick; this.received = now; + for (const id of delta.despawned) this.entities.delete(id); + for (const a of delta.spawned) this.entities.set(a.id, { previous: a, current: a, fromTick: tick, toTick: tick }); + for (const a of delta.changed) { + const old = this.entities.get(a.id); + if (old) this.entities.set(a.id, { previous: old.current, current: a, fromTick: old.toTick, toTick: tick }); + } + } + positions(now = performance.now()): { id: number; x: number; y: number }[] { + const renderTick = this.tick + (now - this.received - 200) / 100; + return [...this.entities].map(([id, s]) => { + const t = s.toTick === s.fromTick ? 1 : Math.max(0, Math.min(1, (renderTick - s.fromTick) / (s.toTick - s.fromTick))); + return { id, x: (s.previous.x + (s.current.x - s.previous.x) * t) / 100, y: (s.previous.y + (s.current.y - s.previous.y) * t) / 100 }; + }); + } + clear() { this.entities.clear(); } + get size() { return this.entities.size; } +} diff --git a/client/generated/city_pb.ts b/client/generated/city_pb.ts new file mode 100644 index 0000000..44acb96 --- /dev/null +++ b/client/generated/city_pb.ts @@ -0,0 +1,651 @@ +// @generated by protoc-gen-es v2.16.0 with parameter "target=ts" +// @generated from file city.proto (package city, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file city.proto. + */ +export const file_city: GenFile = /*@__PURE__*/ + fileDesc("CgpjaXR5LnByb3RvEgRjaXR5IkIKBFJlY3QSDQoFbWluX3gYASABKBESDQoFbWluX3kYAiABKBESDQoFbWF4X3gYAyABKBESDQoFbWF4X3kYBCABKBEiPgoESm9pbhIMCgRyb29tGAEgASgJEhYKDmNsaWVudF92ZXJzaW9uGAIgASgNEhAKCG5pY2tuYW1lGAMgASgJIjIKCVN1YnNjcmliZRIYCgRyZWN0GAEgASgLMgouY2l0eS5SZWN0EgsKA2xvZBgCIAEoDSIVCgRQaW5nEg0KBW5vbmNlGAEgASgNIhgKB1Byb3Bvc2USDQoFc3BlZWQYASABKA0iEwoEVm90ZRILCgN5ZXMYASABKAgiNgoKQ3JlYXRlQ2l0eRIMCgRiYm94GAEgAygBEgwKBG5hbWUYAiABKAkSDAoEc2VlZBgDIAEoDSJlCgdDaXR5Sm9iEgoKAmlkGAEgASgJEg8KB2NpdHlfaWQYAiABKAkSDgoGc3RhdHVzGAMgASgJEg8KB3Jvb21faWQYBCABKAkSDQoFZXJyb3IYBSABKAkSDQoFc3RhZ2UYBiABKAkiswEKDUNsaWVudE1lc3NhZ2USGgoEam9pbhgBIAEoCzIKLmNpdHkuSm9pbkgAEiQKCXN1YnNjcmliZRgCIAEoCzIPLmNpdHkuU3Vic2NyaWJlSAASGgoEcGluZxgDIAEoCzIKLmNpdHkuUGluZ0gAEiAKB3Byb3Bvc2UYBCABKAsyDS5jaXR5LlByb3Bvc2VIABIaCgR2b3RlGAUgASgLMgouY2l0eS5Wb3RlSABCBgoEYm9keSI3CgVBZ2VudBIKCgJpZBgBIAEoDRIJCgF4GAIgASgREgkKAXkYAyABKBESDAoEa2luZBgEIAEoDSJrCgdXZWxjb21lEgwKBHJvb20YASABKAkSDAoEdGljaxgCIAEoBBIZChFjaXR5X21hbmlmZXN0X3VybBgDIAEoCRIPCgdzZXNzaW9uGAQgASgJEhgKEHByb3RvY29sX3ZlcnNpb24YBSABKA0iNwoIU25hcHNob3QSDAoEdGljaxgBIAEoBBIdCghlbnRpdGllcxgCIAMoCzILLmNpdHkuQWdlbnQiZAoFRGVsdGESDAoEdGljaxgBIAEoBBIcCgdzcGF3bmVkGAIgAygLMgsuY2l0eS5BZ2VudBIcCgdjaGFuZ2VkGAMgAygLMgsuY2l0eS5BZ2VudBIRCglkZXNwYXduZWQYBCADKA0iJgoFRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJImMKCVZvdGVTdGF0ZRIOCgZhY3RpdmUYASABKAgSDQoFc3BlZWQYAiABKA0SCwoDeWVzGAMgASgNEg8KB3BsYXllcnMYBCABKA0SGQoRcmVtYWluaW5nX3NlY29uZHMYBSABKA0iOgoFQ2xvY2sSDAoEdGljaxgBIAEoBBIUCgxnYW1lX3NlY29uZHMYAiABKAESDQoFc3BlZWQYAyABKA0i+gEKDVNlcnZlck1lc3NhZ2USIAoHd2VsY29tZRgBIAEoCzINLmNpdHkuV2VsY29tZUgAEiIKCHNuYXBzaG90GAIgASgLMg4uY2l0eS5TbmFwc2hvdEgAEhwKBWRlbHRhGAMgASgLMgsuY2l0eS5EZWx0YUgAEhwKBWVycm9yGAQgASgLMgsuY2l0eS5FcnJvckgAEhoKBHBvbmcYBSABKAsyCi5jaXR5LlBpbmdIABIlCgp2b3RlX3N0YXRlGAYgASgLMg8uY2l0eS5Wb3RlU3RhdGVIABIcCgVjbG9jaxgHIAEoCzILLmNpdHkuQ2xvY2tIAEIGCgRib2R5IjkKClNhdmVkQWdlbnQSCgoCaWQYASABKA0SDQoFcm91dGUYAiABKA0SEAoIZGlzdGFuY2UYAyABKAEiOgoFUm91dGUSCgoCeHkYASADKBESDwoHaG9tZV9pZBgCIAEoCRIUCgx3b3JrcGxhY2VfaWQYAyABKAki5QEKCFJvb21TYXZlEg8KB3ZlcnNpb24YASABKA0SCgoCaWQYAiABKAkSDwoHY2l0eV9pZBgDIAEoCRIUCgxjaXR5X3ZlcnNpb24YBCABKA0SDAoEdGljaxgFIAEoBBIMCgRzZWVkGAYgASgEEhQKDGdhbWVfc2Vjb25kcxgHIAEoARINCgVzcGVlZBgIIAEoDRIgCgZhZ2VudHMYCSADKAsyEC5jaXR5LlNhdmVkQWdlbnQSGwoGcm91dGVzGAogAygLMgsuY2l0eS5Sb3V0ZRIVCg1jaXR5X3JldmlzaW9uGAsgASgJYgZwcm90bzM"); + +/** + * @generated from message city.Rect + */ +export type Rect = Message<"city.Rect"> & { + /** + * @generated from field: sint32 min_x = 1; + */ + minX: number; + + /** + * @generated from field: sint32 min_y = 2; + */ + minY: number; + + /** + * @generated from field: sint32 max_x = 3; + */ + maxX: number; + + /** + * @generated from field: sint32 max_y = 4; + */ + maxY: number; +}; + +/** + * Describes the message city.Rect. + * Use `create(RectSchema)` to create a new message. + */ +export const RectSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 0); + +/** + * @generated from message city.Join + */ +export type Join = Message<"city.Join"> & { + /** + * @generated from field: string room = 1; + */ + room: string; + + /** + * @generated from field: uint32 client_version = 2; + */ + clientVersion: number; + + /** + * @generated from field: string nickname = 3; + */ + nickname: string; +}; + +/** + * Describes the message city.Join. + * Use `create(JoinSchema)` to create a new message. + */ +export const JoinSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 1); + +/** + * @generated from message city.Subscribe + */ +export type Subscribe = Message<"city.Subscribe"> & { + /** + * @generated from field: city.Rect rect = 1; + */ + rect?: Rect | undefined; + + /** + * @generated from field: uint32 lod = 2; + */ + lod: number; +}; + +/** + * Describes the message city.Subscribe. + * Use `create(SubscribeSchema)` to create a new message. + */ +export const SubscribeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 2); + +/** + * @generated from message city.Ping + */ +export type Ping = Message<"city.Ping"> & { + /** + * @generated from field: uint32 nonce = 1; + */ + nonce: number; +}; + +/** + * Describes the message city.Ping. + * Use `create(PingSchema)` to create a new message. + */ +export const PingSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 3); + +/** + * @generated from message city.Propose + */ +export type Propose = Message<"city.Propose"> & { + /** + * @generated from field: uint32 speed = 1; + */ + speed: number; +}; + +/** + * Describes the message city.Propose. + * Use `create(ProposeSchema)` to create a new message. + */ +export const ProposeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 4); + +/** + * @generated from message city.Vote + */ +export type Vote = Message<"city.Vote"> & { + /** + * @generated from field: bool yes = 1; + */ + yes: boolean; +}; + +/** + * Describes the message city.Vote. + * Use `create(VoteSchema)` to create a new message. + */ +export const VoteSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 5); + +/** + * @generated from message city.CreateCity + */ +export type CreateCity = Message<"city.CreateCity"> & { + /** + * @generated from field: repeated double bbox = 1; + */ + bbox: number[]; + + /** + * @generated from field: string name = 2; + */ + name: string; + + /** + * @generated from field: uint32 seed = 3; + */ + seed: number; +}; + +/** + * Describes the message city.CreateCity. + * Use `create(CreateCitySchema)` to create a new message. + */ +export const CreateCitySchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 6); + +/** + * @generated from message city.CityJob + */ +export type CityJob = Message<"city.CityJob"> & { + /** + * @generated from field: string id = 1; + */ + id: string; + + /** + * @generated from field: string city_id = 2; + */ + cityId: string; + + /** + * @generated from field: string status = 3; + */ + status: string; + + /** + * @generated from field: string room_id = 4; + */ + roomId: string; + + /** + * @generated from field: string error = 5; + */ + error: string; + + /** + * @generated from field: string stage = 6; + */ + stage: string; +}; + +/** + * Describes the message city.CityJob. + * Use `create(CityJobSchema)` to create a new message. + */ +export const CityJobSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 7); + +/** + * @generated from message city.ClientMessage + */ +export type ClientMessage = Message<"city.ClientMessage"> & { + /** + * @generated from oneof city.ClientMessage.body + */ + body: { + /** + * @generated from field: city.Join join = 1; + */ + value: Join; + case: "join"; + } | { + /** + * @generated from field: city.Subscribe subscribe = 2; + */ + value: Subscribe; + case: "subscribe"; + } | { + /** + * @generated from field: city.Ping ping = 3; + */ + value: Ping; + case: "ping"; + } | { + /** + * @generated from field: city.Propose propose = 4; + */ + value: Propose; + case: "propose"; + } | { + /** + * @generated from field: city.Vote vote = 5; + */ + value: Vote; + case: "vote"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message city.ClientMessage. + * Use `create(ClientMessageSchema)` to create a new message. + */ +export const ClientMessageSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 8); + +/** + * @generated from message city.Agent + */ +export type Agent = Message<"city.Agent"> & { + /** + * @generated from field: uint32 id = 1; + */ + id: number; + + /** + * @generated from field: sint32 x = 2; + */ + x: number; + + /** + * @generated from field: sint32 y = 3; + */ + y: number; + + /** + * @generated from field: uint32 kind = 4; + */ + kind: number; +}; + +/** + * Describes the message city.Agent. + * Use `create(AgentSchema)` to create a new message. + */ +export const AgentSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 9); + +/** + * @generated from message city.Welcome + */ +export type Welcome = Message<"city.Welcome"> & { + /** + * @generated from field: string room = 1; + */ + room: string; + + /** + * @generated from field: uint64 tick = 2; + */ + tick: bigint; + + /** + * @generated from field: string city_manifest_url = 3; + */ + cityManifestUrl: string; + + /** + * @generated from field: string session = 4; + */ + session: string; + + /** + * @generated from field: uint32 protocol_version = 5; + */ + protocolVersion: number; +}; + +/** + * Describes the message city.Welcome. + * Use `create(WelcomeSchema)` to create a new message. + */ +export const WelcomeSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 10); + +/** + * @generated from message city.Snapshot + */ +export type Snapshot = Message<"city.Snapshot"> & { + /** + * @generated from field: uint64 tick = 1; + */ + tick: bigint; + + /** + * @generated from field: repeated city.Agent entities = 2; + */ + entities: Agent[]; +}; + +/** + * Describes the message city.Snapshot. + * Use `create(SnapshotSchema)` to create a new message. + */ +export const SnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 11); + +/** + * @generated from message city.Delta + */ +export type Delta = Message<"city.Delta"> & { + /** + * @generated from field: uint64 tick = 1; + */ + tick: bigint; + + /** + * @generated from field: repeated city.Agent spawned = 2; + */ + spawned: Agent[]; + + /** + * @generated from field: repeated city.Agent changed = 3; + */ + changed: Agent[]; + + /** + * @generated from field: repeated uint32 despawned = 4; + */ + despawned: number[]; +}; + +/** + * Describes the message city.Delta. + * Use `create(DeltaSchema)` to create a new message. + */ +export const DeltaSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 12); + +/** + * @generated from message city.Error + */ +export type Error = Message<"city.Error"> & { + /** + * @generated from field: string code = 1; + */ + code: string; + + /** + * @generated from field: string message = 2; + */ + message: string; +}; + +/** + * Describes the message city.Error. + * Use `create(ErrorSchema)` to create a new message. + */ +export const ErrorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 13); + +/** + * @generated from message city.VoteState + */ +export type VoteState = Message<"city.VoteState"> & { + /** + * @generated from field: bool active = 1; + */ + active: boolean; + + /** + * @generated from field: uint32 speed = 2; + */ + speed: number; + + /** + * @generated from field: uint32 yes = 3; + */ + yes: number; + + /** + * @generated from field: uint32 players = 4; + */ + players: number; + + /** + * @generated from field: uint32 remaining_seconds = 5; + */ + remainingSeconds: number; +}; + +/** + * Describes the message city.VoteState. + * Use `create(VoteStateSchema)` to create a new message. + */ +export const VoteStateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 14); + +/** + * @generated from message city.Clock + */ +export type Clock = Message<"city.Clock"> & { + /** + * @generated from field: uint64 tick = 1; + */ + tick: bigint; + + /** + * @generated from field: double game_seconds = 2; + */ + gameSeconds: number; + + /** + * @generated from field: uint32 speed = 3; + */ + speed: number; +}; + +/** + * Describes the message city.Clock. + * Use `create(ClockSchema)` to create a new message. + */ +export const ClockSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 15); + +/** + * @generated from message city.ServerMessage + */ +export type ServerMessage = Message<"city.ServerMessage"> & { + /** + * @generated from oneof city.ServerMessage.body + */ + body: { + /** + * @generated from field: city.Welcome welcome = 1; + */ + value: Welcome; + case: "welcome"; + } | { + /** + * @generated from field: city.Snapshot snapshot = 2; + */ + value: Snapshot; + case: "snapshot"; + } | { + /** + * @generated from field: city.Delta delta = 3; + */ + value: Delta; + case: "delta"; + } | { + /** + * @generated from field: city.Error error = 4; + */ + value: Error; + case: "error"; + } | { + /** + * @generated from field: city.Ping pong = 5; + */ + value: Ping; + case: "pong"; + } | { + /** + * @generated from field: city.VoteState vote_state = 6; + */ + value: VoteState; + case: "voteState"; + } | { + /** + * @generated from field: city.Clock clock = 7; + */ + value: Clock; + case: "clock"; + } | { case: undefined; value?: undefined }; +}; + +/** + * Describes the message city.ServerMessage. + * Use `create(ServerMessageSchema)` to create a new message. + */ +export const ServerMessageSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 16); + +/** + * @generated from message city.SavedAgent + */ +export type SavedAgent = Message<"city.SavedAgent"> & { + /** + * @generated from field: uint32 id = 1; + */ + id: number; + + /** + * @generated from field: uint32 route = 2; + */ + route: number; + + /** + * @generated from field: double distance = 3; + */ + distance: number; +}; + +/** + * Describes the message city.SavedAgent. + * Use `create(SavedAgentSchema)` to create a new message. + */ +export const SavedAgentSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 17); + +/** + * @generated from message city.Route + */ +export type Route = Message<"city.Route"> & { + /** + * @generated from field: repeated sint32 xy = 1; + */ + xy: number[]; + + /** + * @generated from field: string home_id = 2; + */ + homeId: string; + + /** + * @generated from field: string workplace_id = 3; + */ + workplaceId: string; +}; + +/** + * Describes the message city.Route. + * Use `create(RouteSchema)` to create a new message. + */ +export const RouteSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 18); + +/** + * @generated from message city.RoomSave + */ +export type RoomSave = Message<"city.RoomSave"> & { + /** + * @generated from field: uint32 version = 1; + */ + version: number; + + /** + * @generated from field: string id = 2; + */ + id: string; + + /** + * @generated from field: string city_id = 3; + */ + cityId: string; + + /** + * @generated from field: uint32 city_version = 4; + */ + cityVersion: number; + + /** + * @generated from field: uint64 tick = 5; + */ + tick: bigint; + + /** + * @generated from field: uint64 seed = 6; + */ + seed: bigint; + + /** + * @generated from field: double game_seconds = 7; + */ + gameSeconds: number; + + /** + * @generated from field: uint32 speed = 8; + */ + speed: number; + + /** + * @generated from field: repeated city.SavedAgent agents = 9; + */ + agents: SavedAgent[]; + + /** + * @generated from field: repeated city.Route routes = 10; + */ + routes: Route[]; + + /** + * @generated from field: string city_revision = 11; + */ + cityRevision: string; +}; + +/** + * Describes the message city.RoomSave. + * Use `create(RoomSaveSchema)` to create a new message. + */ +export const RoomSaveSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_city, 19); + diff --git a/client/tiles.ts b/client/tiles.ts new file mode 100644 index 0000000..a8df2f7 --- /dev/null +++ b/client/tiles.ts @@ -0,0 +1,55 @@ +import { assembleWorld, intersects, parseManifest, parseTile, type Bounds, type CityManifest, type CityTile } from '../src/city/format'; + +/** Explicit gzip files work even on static hosts without Content-Encoding headers. */ +export async function fetchJson(url: string, signal?: AbortSignal, sha256?: string): Promise { + const response = await fetch(url, { signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${url}`); + const bytes = new Uint8Array(await response.arrayBuffer()); + let blob = new Blob([bytes]); + if (bytes[0] === 0x1f && bytes[1] === 0x8b) { + blob = await new Response(blob.stream().pipeThrough(new DecompressionStream('gzip'))).blob(); + } + const content = await blob.arrayBuffer(); + if (sha256) { + const hash = globalThis.crypto?.subtle ? new Uint8Array(await crypto.subtle.digest('SHA-256', content)) : + (await import('@noble/hashes/sha2.js')).sha256(new Uint8Array(content)); + const digest = [...hash].map(n=>n.toString(16).padStart(2,'0')).join(''); + if (digest !== sha256) throw new Error(`City tile checksum mismatch: ${url}`); + } + return JSON.parse(new TextDecoder().decode(content)); +} + +export class TileLoader { + private cache = new Map(); + private pending: AbortController | null = null; + private revision = 0; + constructor(readonly manifest: CityManifest, readonly manifestUrl: string, private maxTiles = 128) {} + static async open(url: string, signal?: AbortSignal) { + const absolute = new URL(url, location.href).href; + return new TileLoader(parseManifest(await fetchJson(absolute, signal)), absolute); + } + async load(bounds: Bounds) { + const revision = ++this.revision; + this.pending?.abort(); + const controller = this.pending = new AbortController(); + const wanted = this.manifest.tiles.filter(t => intersects(t.bounds, bounds)); + if (wanted.length > this.maxTiles) throw new Error(`Слишком большой обзор: ${wanted.length} тайлов. Приблизьте карту (лимит ${this.maxTiles}).`); + const loaded = new Map(); + let cursor = 0; + // Bound HTTP concurrency and preserve old viewport until every new tile is available. + try { await Promise.all(Array.from({ length: Math.min(6, wanted.length) }, async () => { + while (cursor < wanted.length) { + const ref = wanted[cursor++]; + let tile = this.cache.get(ref.id); + if (!tile) tile = parseTile(await fetchJson(new URL(ref.url, this.manifestUrl).href, controller.signal, ref.sha256), this.manifest.city.id, ref.id); + loaded.set(ref.id, tile); + } + })); } catch (error) { controller.abort(); throw error; } + if (revision !== this.revision) throw new DOMException('Superseded viewport', 'AbortError'); + for (const [id, tile] of loaded) { this.cache.delete(id); this.cache.set(id, tile); } + while (this.cache.size > this.maxTiles) this.cache.delete(this.cache.keys().next().value!); + return assembleWorld(this.manifest, wanted.map(t => loaded.get(t.id)!)); + } + emptyWorld() { return assembleWorld(this.manifest, []); } + destroy() { ++this.revision; this.pending?.abort(); this.cache.clear(); } +} diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..a89cd8c --- /dev/null +++ b/docs/PLAN.md @@ -0,0 +1,275 @@ +# План: переход к клиент-серверной игре + +Статус: выполняется. Дата: 2026-10-30. Проверенный прогресс отмечен в разделе 9. + +## 1. Цель и рамки + +Игра: симулятор бога в духе RimWorld, но с меньшим влиянием игрока. Город живёт сам (жители, машины, потребности, события), игроки в основном наблюдают и ограниченно вмешиваются (вмешательство в первую версию не входит). + +Исходные требования: + +- До 30 игроков в одной комнате. +- Комната — отдельный инстанс мира, у каждой комнаты свой город (из OSM). +- Сущностей очень много, до миллиона на комнату. +- Клиент остаётся в браузере и только отображает. Вся симуляция и правила на сервере. +- Сгенерированный город хранится готовым, генерация не выполняется в рантайме комнаты. + +Не входит в первую версию: влияние игроков на мир, аккаунты и монетизация, предсказание на клиенте (в наблюдательной игре оно не нужно). + +## 2. Ключевые решения + +| Вопрос | Решение | Почему | +|---|---|---| +| Язык сервера | Rust | Нет GC-пауз, плотные данные, параллельность без data race | +| ECS | `bevy_ecs` как отдельная библиотека (без движка Bevy) | Архетипы, автопараллелизм систем, change detection, `Schedule`. Запасной вариант: `hecs` | +| Асинхронность и сеть | `tokio` + `axum` + `tokio-tungstenite` | Стандарт для WebSocket в Rust | +| Транспорт | WebSocket (бинарный) | В браузере работает везде, TCP достаточен: игроки наблюдают, потеря пакета не критична, мир обновляется ~5–10 раз в секунду. WebTransport подключим, только если упрёмся в задержки | +| Формат сообщений | Protobuf (`prost` на сервере, `ts-proto` или `protobuf-es` на клиенте) | Общая схема, кодогенерация для обеих сторон, компактно, версионируется | +| Статика города | Тайлы-файлы, отдаются по HTTP | Кэшируются браузером и CDN, не грузят сервер симуляции | +| Генерация города | Остаётся на TypeScript, запускается офлайн (CLI) | Уже написана и отлажена (около 5 тыс. строк), портировать нет смысла | +| Сохранения | Снимки мира на диск, позже БД | Комнат немного, симуляция детерминируемая | + +## 3. Архитектура + +``` +Браузер (TS) Сервер (Rust) +┌───────────────┐ HTTP ┌───────────────────────┐ +│ Рендерер │◄─────────►│ Статика: тайлы города │ +│ (src/view) │ тайлы └───────────────────────┘ +│ │ WS ┌───────────────────────┐ +│ Состояние │◄─────────►│ Gateway (axum) │ +│ клиента: │ дельты │ - подключение к комн.│ +│ интерполяция │ └──────────┬────────────┘ +└───────────────┘ │ каналы + ┌───────────▼────────────┐ + │ Room (1 поток/задача) │ + │ bevy_ecs World │ + │ Schedule систем │ + │ Interest manager │ + └────────────────────────┘ +``` + +Принципы: + +1. **Одна комната — один `World`.** Комнаты независимы, масштабируются добавлением потоков и процессов. Внутри комнаты системы выполняются параллельно средствами `bevy_ecs`. +2. **Сервер авторитарен.** Клиент отправляет только запросы (подписка на область, позже команды влияния), состояние никогда не принимает от клиента. +3. **Статика отдельно от динамики.** Здания, дороги, интерьеры и мебель из OSM лежат в тайлах и не рассылаются через WebSocket. По сети идут только изменяемые сущности. +4. **Интерес по области (AOI).** Каждый игрок подписан на прямоугольник карты (вьюпорт плюс запас). Сервер шлёт ему дельты только для сущностей в этой области. Миллион сущностей к клиенту не уходит. +5. **LOD симуляции.** Вдали от всех наблюдателей жители симулируются грубо (потребности и расписания по шагам в десятки секунд, без пути по каждому тайлу), рядом с наблюдателем подробно. Это главный способ удержать миллион сущностей. + +## 4. Формат данных города + +Сейчас `src/types.ts` описывает город одним объектом, всё генерируется в браузере. Для игры нужно разрезать: + +- **Манифест города**: границы, размер тайла, версия формата, список тайлов, метаданные. +- **Тайлы**: сетка (например 256 x 256 м). В тайле: дороги, здания, этажи, интерьеры, мебель, индекс проходимости (navmesh или сетка). +- **Формат файла**: бинарный (protobuf или FlatBuffers) со сжатием, чтобы читать и сервер, и клиент. На первом этапе допустим JSON + gzip, потом заменить. +- **Навигация**: для сервера отдельно строится граф дорог и проходимая сетка, чтобы жители ходили, не загружая геометрию. + +**Интерьеры нужны серверу (решено).** Жители перемещаются по интерьерам и улицам и взаимодействуют с ними, поэтому сервер загружает полную проходимую геометрию: стены, двери, этажи, лестницы, мебель с типом, размером и точками взаимодействия (спать, сидеть, есть, работать). Загрузка по тайлам и лениво: интерьеры тайла нужны, только когда в нём есть наблюдатель или активные жители. + +Для навигации строятся два уровня: грубый граф (здания, улицы, входы) для дальних путей и детальная сетка внутри зданий и вокруг для ближних. Переходы между уровнями — двери и лестницы. + +**Улицы сейчас «пустоваты» (замечание автора).** До появления жителей на улицах нужно сгенерировать то, с чем они взаимодействуют снаружи: уличную мебель (скамейки, фонари, урны), остановки, парковки, дворы, переходы, входы в здания, места для торговли. Это отдельная задача генератора (этап 1б), см. раздел 9. + +## 5. Модель симуляции (первая версия) + +Компоненты (примерный набор): + +- `Position`, `Velocity`, `TileId` (для быстрого AOI). +- `Agent` (житель), `Needs` (сон, еда, работа, отдых), `Schedule`, `Home`, `Workplace`. +- `Vehicle`, `Route`. +- `Building`, `Room`, `Furniture` (служебные ссылки на статику). +- `Dirty` (было ли изменение с прошлой рассылки). + +Системы (порядок и зависимости задаёт `Schedule`): + +1. Время и календарь. +2. Потребности и выбор цели. +3. Построение пути, движение. +4. События (пожар, болезнь, происшествие), позже вмешательство игроков. +5. Сбор изменений и рассылка по AOI. + +Тик симуляции: 10 Гц фиксированный, скорость игры меняется множителем (как в RimWorld). Рассылка клиентам: 5–10 Гц, дельтами, клиент интерполирует. + +## 6. Сеть и протокол + +Сообщения клиент → сервер: + +- `Join { room, client_version }` +- `Subscribe { rect, lod }` — какую область смотрю. +- `Ping`. +- (позже) `Intervene { ... }`. + +Сообщения сервер → клиент: + +- `Welcome { room_info, tick, city_manifest_url }` +- `Snapshot { tick, entities[] }` — полное состояние области при подписке. +- `Delta { tick, spawned[], changed[], despawned[] }` — изменения области. +- `Event { ... }` — редкие события (уведомления). + +Правила: + +- Позиции квантуются (например, в сантиметрах целым числом), идентификаторы компактные, дельты по полям. +- Версия протокола в рукопожатии, несовместимый клиент получает понятную ошибку. +- Ограничение размера и частоты запросов на клиента, ограничение размера области подписки. + +## 7. Производительность: бюджеты + +Ориентиры для проверки (замерить и уточнить): + +- Тик комнаты с 1 млн сущностей: не более 50 мс на целевом сервере (при LOD, когда подробно симулируется лишь малая часть). +- Рассылка: не более 100 КБ/с на клиента в среднем. +- Память: порядка 100–200 байт на сущность в ECS, итого до 200 МБ на комнату без учёта статики. +- Подключение клиента: менее 2 с до первой картинки (за счёт тайлов, а не снимка всего мира). + +Для проверки сразу закладываем нагрузочный стенд: генератор синтетической популяции (1 млн агентов на карте) и клиент-бот на 30 подключений. + +## 8. Клиент (браузер) + +- `src/view` остаётся, но получает данные не из воркера генерации, а из загрузчика тайлов и WebSocket-клиента. +- Слой состояния: хранит сущности вокруг камеры, применяет дельты, интерполирует позиции между тиками. +- Рендер динамики: на первом этапе Canvas, дальше (при 10–50 тыс. видимых сущностей) WebGL с инстансингом. Геометрию тайлов можно кэшировать в офскрин-текстуры. +- Режим «воспроизведение» (без сервера, текущий прототип) оставить как отладочный. + +## 9. Этапы работ + +**Этап 0. Подготовка (1 неделя)** +- [x] Зафиксировать решения этого документа, выбрать имя и структуру репозитория (монорепо: `server/`, `client/`, `tools/`, `proto/`). +- [x] Определить минимальную сцену для проверки: один небольшой город, 1000 агентов, 1 клиент. + +**Этап 1. Тайловый формат (1–2 недели)** +- [x] Экспорт готового города из текущего генератора в тайлы + манифест (`tools/export`). +- [x] Клиент загружает город по тайлам, а не генерирует в браузере. +- [x] Тесты формата, версия формата в манифесте. + +**Этап 1б. Обустройство улиц в генераторе (2 недели)** +- [x] Уличные объекты и точки взаимодействия снаружи зданий: скамейки, фонари, остановки, парковки, дворы, переходы, входы. +- [x] Единый реестр типов объектов с интерактивными слотами (как у мебели в `src/gen/catalog.ts`), общий для интерьеров и улиц. +- [x] Цель: к моменту появления жителей снаружи есть куда идти и что делать. + +**Этап 2. Скелет сервера (1–2 недели)** +- [x] Rust-проект: `axum`, WebSocket, протокол (`proto/`), одна комната, `bevy_ecs`. +- [x] Загрузка манифеста и тайлов на сервере (навигационная сетка). +- [x] Подключение клиента, подписка на область, снимок. + +**Этап 2б. Сервис генерации** +- [x] Очередь с лимитами площади, зданий, одновременных задач и таймаутом. +- [x] Изолированный TS-воркер, прогресс, кэш по нормализованному bbox, seed и версии. +- [x] `CreateCity` / `CityJob` в общей Protobuf-схеме и интерфейс создания города. +- [x] Сквозной тест запроса, отдельного Node-процесса, экспорта, открытия комнаты и сохранения без внешней сети. + +**Этап 3. Простая симуляция (2–3 недели)** +- [x] Агенты ходят по дорогам между домом и работой по расписанию. +- [x] Дельты по AOI, интерполяция на клиенте. +- [x] Детерминизм: seed комнаты, воспроизводимые тики. +- [x] Бинарные атомарные снимки, ротация, ленивое открытие сохранённых комнат и тест эквивалентности после восстановления. + +**Этап 4. Масштаб и LOD (2–3 недели)** +- [x] Нагрузочный стенд (1 млн агентов, 30 клиентов). +- [x] Профилирование, LOD симуляции, параллелизм систем. + Реализованы грубый шаг дальних маршрутов, общий пространственный индекс, стабильный LOD репликации и параллельная обработка движения через пул ECS. Построение индекса зависит от движения и идёт следующим шагом. +- [x] Оптимизация протокола и рендера. + Бинарные дельты, квантизация, объединение пропущенных обновлений под бюджетом трафика, ограничение видимых сущностей и кэш тайлов; динамика пока на Canvas. + +**Этап 5. Игровой слой (дальше)** +- [ ] Потребности, события, экономика. +- [ ] Ограниченное влияние игроков (команды, права, очереди, лимиты). +- [ ] Сохранения, восстановление комнат, админка. + Сохранения и восстановление уже работают; админка, миграции схем и квоты диска остаются следующей частью этапа. + +**Этап 6. Эксплуатация** +- [ ] Деплой, мониторинг (метрики тика, число сущностей, трафик), логи. + Метрики, логи и Dockerfile подготовлены. Docker Engine в текущей среде не запущен; внешний сервер для деплоя не задан. +- [x] Создание комнат по запросу (выбор города), лимиты ресурсов. + +Проверка минимальной сцены: `npm run demo`, `npm run build`, `npm run server:dev`, затем `http://localhost:3000/?play`. +Имя репозитория остаётся `citygen`; генератор и существующий рендер сохраняют свои пути в `src/`, новые сетевые модули находятся в `client/`. +Состояние и ограничения реализации описаны в `server/README.md`. Этапы 5–6 не объявляются завершёнными готовностью сетевого прототипа. + +## 10. Риски + +| Риск | Что делаем | +|---|---| +| 1 млн сущностей не успевают в тик | LOD, чанки, разреженные системы; замер на этапе 4, а не в конце | +| Объём рассылки вырастет | AOI, квантование, дельты, приоритет близким к камере | +| Размер тайлов и время загрузки | Компактный бинарный формат, сжатие, ленивая загрузка тайлов по камере | +| Крутая кривая Rust для команды | Держать код простым, избегать лишних абстракций, начать с одной комнаты | +| Изменение формата данных ломает сохранения | Версия формата, миграции, тесты совместимости | +| Дублирование типов между Rust и TS | Единая схема в `proto/`, кодогенерация | + +## 11. Принятые решения, допущения и открытые вопросы + +Принятые решения: + +1. **Интерьеры нужны на сервере** (раздел 4): жители ходят по интерьерам и улицам и взаимодействуют с ними. +2. **Без аккаунтов.** Вход по нику; персистентности игрока между комнатами пока нет. Ник уникален в пределах комнаты, идентификатор сессии выдаёт сервер. +3. **Создание комнат игроком** с лимитом из конфига сервера (`max_rooms`, по умолчанию 5). При достижении лимита создание отклоняется с понятной ошибкой. Пустые комнаты закрываются по таймауту (параметр конфига), чтобы не занимать слот. +4. **Время общее для комнаты.** Пауза и смена скорости — через голосование: предложение принимается, если «за» большинство подключённых игроков (более половины). Один игрок в комнате решает сам. Нужны: таймаут голосования, один активный вопрос за раз, защита от спама предложениями. +5. **Только десктопные браузеры.** Мобильные не поддерживаем, рендер и управление рассчитываем на мышь и клавиатуру. + +6. **`max_rooms` — лимит на весь сервер** одновременно открытых комнат (подтверждено). +7. **Игрок создаёт свой город**: выбирает область на карте и запускает генерацию (раздел 12). Каталог готовых городов тоже остаётся. +8. **Владельца комнаты нет**: создатель не может закрыть комнату, выгнать игрока или поставить пароль. Комната закрывается только по таймауту пустоты. +9. **Комнаты переживают перезапуск сервера**: состояние сохраняется на диск (раздел 13). + +Допущения (если неверны, скажи — поправлю): + +1. Серверная симуляция детерминируема от seed. Отдельный вопрос, нужна ли строгая детерминированность (для повторов и отладки). +2. Деплой на один сервер, комнаты как задачи внутри одного процесса. Горизонтальное масштабирование — позже. +3. Бюджет железа: современный многоядерный сервер, 8+ ядер. + +Открытые вопросы: + +1. Лимит размера области для генерации (площадь, число зданий) и лимит одновременных генераций. Предварительно: берём из конфига, значения подобрать после замеров на этапе 1. +2. Что делать с комнатой без владельца, если она не закрывается: пустая комната закрывается через N минут, но сохраняется на диск и может быть открыта заново? Предварительно: да, закрытая комната хранится как снимок, пока есть место. +3. Нужна ли защита от злоупотреблений при отсутствии аккаунтов (лимит создания по IP, антиспам)? Предварительно: да, простой лимит по IP. + +## 12. Создание города игроком + +Генерация города — тяжёлая работа (запросы Overpass, построение зданий и интерьеров), поэтому выполняется отдельным сервисом, а не в потоке комнаты. + +Процесс: + +1. Игрок на карте выбирает прямоугольник (в клиенте уже есть выбор области: `src/view/picker.ts`, ограничения размера в `src/main.ts`). +2. Клиент отправляет серверу `CreateCity { bbox, name }`. Сервер проверяет лимит площади, лимит очереди и лимит комнат (`max_rooms`). +3. Задача попадает в очередь. Воркер (текущий TS-генератор как отдельный Node-процесс, использующий `scripts/cli.ts` и `src/gen`) запрашивает Overpass, генерирует город, экспортирует тайлы и манифест (этап 1) в хранилище. +4. Клиент видит прогресс (в очереди, загрузка OSM, генерация, экспорт). По готовности город попадает в каталог игрока и создаётся комната. +5. Ошибки Overpass (таймауты, лимиты) обрабатываются повторами и понятными сообщениями; уже есть наработки в `src/osm/overpass.ts`. + +Требования: + +- Города кэшируются по нормализованному bbox и версии генератора, чтобы одну область не генерировать дважды. +- Ограничения: максимальная площадь и число зданий, максимум одновременных задач (конфиг), таймаут задачи. +- Версия формата и генератора пишется в манифест; при смене версии кэш инвалидируется. +- Генерация должна быть детерминированной от (bbox, seed, версия), чтобы город можно было воспроизвести. + +## 13. Сохранение и восстановление комнат + +- **Снимки** `World` раз в N минут и при остановке: сущности, компоненты, время игры, seed, состояние голосований не сохраняется (сбрасывается). +- **Формат**: бинарный, версионируемый (протокол из `proto/`), атомарная запись во временный файл с переименованием, хранение последних K снимков. +- **Восстановление**: при старте сервера список комнат читается с диска, комнаты открываются лениво при первом подключении игрока, чтобы не держать в памяти все. +- **Миграции**: при смене схемы компонентов снимок либо мигрируется, либо комната помечается несовместимой с понятным сообщением. +- **Целостность**: комната ссылается на город по идентификатору и версии манифеста; удаление или смена города при существующих комнатах запрещена. + +Дополнение к этапам: + +- Этап 2: пустая комната сохраняется и восстанавливается (скелет сохранения). +- Этап 3: сохранение/загрузка простой симуляции, тест «остановил — запустил — состояние совпадает». +- Этап 5: миграции, ротация снимков, лимиты места. +- Новый этап **2б. Сервис генерации** (2 недели): очередь задач, воркер на базе `scripts/cli.ts`, кэш по bbox, прогресс и лимиты, протокол `CreateCity` и ответы о статусе. + +## 14. Проверки реализации + +- TypeScript: `npm run typecheck`, `npm test`, `npm run build`. +- Rust: `npm run server:test`; отдельно проверяются навигация через двери, точность пространственного индекса, детерминизм, атомарные снимки и ротация. +- Интеграция: 30 настоящих WebSocket-подключений, отказ 31-му, голосование 15/30 против 16/30, повторное подключение после восстановления. +- Очередь: Protobuf-запрос → отдельный TS-процесс с локальной OSM-выгрузкой → экспорт с контрольными суммами → открытие комнаты → сохранение. Внешний Overpass в тесте не используется. +- Chrome: каталог, вход, загрузка тайлов, этажи и пауза через голосование. Повторяемая проверка: `scripts/browser-smoke.mjs` при установленном Playwright. +- Реальная нагрузка, 2026-10-30: Бальцерс, 1 000 000 жителей, 30 WebSocket-клиентов, 20 с, фоновые сохранения каждые 2 с. Выборка длительности тиков через метрики: p95 36,5 мс; максимум всех тиков по счётчику комнаты 44,7 мс. Средний трафик 4,8 КБ/с на клиента, максимальный 44,7 КБ/с. Ошибок, разрывов и срабатываний ограничения трафика не было. Это короткий локальный тест с небольшими AOI, а не гарантия для любого города, плотности, диска и целевого сервера. Пиковая память процесса со статикой и буферами — около 272 МиБ; память одной ECS отдельно не измерялась. +- Браузер: повторный вход в сохранённую миллионную комнату до загруженной области — 1,31 с в локальном Chrome. + +Детальные пути между этажами, использование мебели жителями, потребности, +события, экономика и вмешательство игроков остаются игровым слоем этапа 5. +Для эксплуатации также остаются миграции снимков, квоты диска, админка, +проверка контейнера и развёртывание в выбранном окружении. diff --git a/package-lock.json b/package-lock.json index b94ca3b..a5f8316 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,12 +7,219 @@ "": { "name": "citygen", "version": "0.1.0", + "dependencies": { + "@bufbuild/protobuf": "^2.16.0", + "@noble/hashes": "^2.4.0" + }, "devDependencies": { + "@bufbuild/buf": "^1.75.0", + "@bufbuild/protoc-gen-es": "^2.16.0", "esbuild": "^0.28.0", "tsx": "^4.19.0", "typescript": "^5.6.0" } }, + "node_modules/@bufbuild/buf": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.75.0.tgz", + "integrity": "sha512-uMuBCULLDax9/3d5Lo9mea38vuPDv4mrRVE4XfSq3qPcD5UUxlg0RJ9hm84lYCzI3A3lIvLeChxrw9c1RUiD2w==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "buf": "bin/buf", + "protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking", + "protoc-gen-buf-lint": "bin/protoc-gen-buf-lint" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@bufbuild/buf-darwin-arm64": "1.75.0", + "@bufbuild/buf-darwin-x64": "1.75.0", + "@bufbuild/buf-linux-aarch64": "1.75.0", + "@bufbuild/buf-linux-armv7": "1.75.0", + "@bufbuild/buf-linux-x64": "1.75.0", + "@bufbuild/buf-win32-arm64": "1.75.0", + "@bufbuild/buf-win32-x64": "1.75.0" + } + }, + "node_modules/@bufbuild/buf-darwin-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.75.0.tgz", + "integrity": "sha512-QiQYSmtVUcKy9DK/d+zJ3+D4iUQmmiDQ3i8/yBr8cf7Nr28lqKMAIKznJE/uGRsjr90fHaUQO+Xsedec0ArN5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-darwin-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.75.0.tgz", + "integrity": "sha512-U2W+MRE9jJ7Am7x02ZWt87U5lfPrjKMdB1aZDslsuyOHo+fEUvZolupmhoW30v6syNAWBOfkoi0k5TLc2FNGqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-aarch64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.75.0.tgz", + "integrity": "sha512-eX+XyokzJDqsNIFo8b1O3DBvbnksOozPJX4QVsNn61JqSUtWspyTNeYqwg8lRDancML+bDqZEDFPABjAsVCQGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-armv7": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.75.0.tgz", + "integrity": "sha512-2RVhmr3vJ145DazYTk9dWAzeQPtcJ0SiSzY02v1Su/JIfgzhv9lgOm+y3nGuLjyZFF8UfTtmuHfHso5zDBgsCQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-linux-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.75.0.tgz", + "integrity": "sha512-YGGJAx0lpMCE7aMWjNOJcfoEaG/e+iYHnTC4SZ0P5eS4IffMryA9Z4/bb1m1wNXAEJ8ATFMEZOAk/5x9VE47ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-arm64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.75.0.tgz", + "integrity": "sha512-iRge6Ntta41gt0i7P8/EZFDWe3uLNnCEN86r1tKTWctumWxPET3UJcSzzYPyL4gxvzu1/1VTu+AB3+MYAqHy6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/buf-win32-x64": { + "version": "1.75.0", + "resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.75.0.tgz", + "integrity": "sha512-/BgLt3VWndocuG4lbMdlBc1i41I+Bap4zm8R1Cnmov6DtoAQp4dy/leRrvT4aDArIUQ1o7W/Y92xTRITedU4ww==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.16.0.tgz", + "integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.16.0.tgz", + "integrity": "sha512-LbPrEaGd7bOxKbJHllTR5439+PZjqd6fM7xyDZkDVtZ+Efzr7TMdMqShG9Qkf/nYVyiNS/SK5r9JhRaBTFkfZw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.16.0", + "@bufbuild/protoplugin": "2.16.0" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.16.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.16.0.tgz", + "integrity": "sha512-3FdYJys34VhFnBQsjiV9wurBkX/DBACQAkAk+nCKdO1p8ROVNi7gmaRAcba+4qq8hQq8rYVukuutwvGCvd4kJA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.16.0", + "@typescript/vfs": "^1.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@bufbuild/protoplugin/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -455,6 +662,49 @@ "node": ">=18" } }, + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@typescript/vfs": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.5.tgz", + "integrity": "sha512-gobLHQLmvQGJInFk31NvgMd1uYzRXsd8qgnOssyLD055R2QPnw1ubRbBQ4+T+5qC7IQVuYXdvNDE2G7tMDVHgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -512,6 +762,13 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/tsx": { "version": "4.23.15", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", diff --git a/package.json b/package.json index 164a3a7..fe7457d 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,25 @@ "dev": "node build.mjs --serve", "build": "node build.mjs", "typecheck": "tsc -p .", - "cli": "tsx scripts/cli.ts" + "cli": "tsx scripts/cli.ts", + "export-city": "tsx tools/export/index.ts", + "demo": "tsx tools/demo.ts", + "proto:generate": "buf generate", + "server:dev": "node tools/rust.mjs run --manifest-path server/Cargo.toml --bin citygen-server", + "server:release": "node tools/rust.mjs run --release --manifest-path server/Cargo.toml --bin citygen-server", + "server:test": "node tools/rust.mjs test --manifest-path server/Cargo.toml", + "server:load": "node tools/rust.mjs run --release --manifest-path server/Cargo.toml --bin load", + "server:bots": "tsx tools/load-bots.ts" }, "devDependencies": { + "@bufbuild/buf": "^1.75.0", + "@bufbuild/protoc-gen-es": "^2.16.0", "esbuild": "^0.28.0", "tsx": "^4.19.0", "typescript": "^5.6.0" + }, + "dependencies": { + "@bufbuild/protobuf": "^2.16.0", + "@noble/hashes": "^2.4.0" } } diff --git a/proto/README.md b/proto/README.md new file mode 100644 index 0000000..609fc2d --- /dev/null +++ b/proto/README.md @@ -0,0 +1,12 @@ +# Shared protocol + +`city.proto` defines WebSocket messages, city generation jobs and room snapshots. +Run `npm run proto:generate` to regenerate the committed TypeScript bindings. +Rust bindings are generated by Cargo using a vendored protoc executable. + +Handshake protocol version is 1. Positions are signed centimetres and resident +IDs are uint32. Snapshots replace the AOI; deltas use absolute changed values +so intermediate ticks can be coalesced. New fields must be additive; never +reuse field numbers. An incompatible save version is rejected explicitly. + +Static city format v1 uses JSON + gzip, independently of the network protocol. diff --git a/proto/city.proto b/proto/city.proto new file mode 100644 index 0000000..9bec345 --- /dev/null +++ b/proto/city.proto @@ -0,0 +1,32 @@ +syntax = "proto3"; +package city; + +message Rect { sint32 min_x = 1; sint32 min_y = 2; sint32 max_x = 3; sint32 max_y = 4; } +message Join { string room = 1; uint32 client_version = 2; string nickname = 3; } +message Subscribe { Rect rect = 1; uint32 lod = 2; } +message Ping { uint32 nonce = 1; } +message Propose { uint32 speed = 1; } +message Vote { bool yes = 1; } +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; } +} +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; } +message Snapshot { uint64 tick = 1; repeated Agent entities = 2; } +message Delta { uint64 tick = 1; repeated Agent spawned = 2; repeated Agent changed = 3; repeated uint32 despawned = 4; } +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; } +} +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; } +message RoomSave { + uint32 version = 1; string id = 2; string city_id = 3; uint32 city_version = 4; + 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; +} diff --git a/scripts/browser-smoke.mjs b/scripts/browser-smoke.mjs new file mode 100644 index 0000000..ada3bee --- /dev/null +++ b/scripts/browser-smoke.mjs @@ -0,0 +1,34 @@ +// Optional UI check: install Playwright, or set CITYGEN_BROWSER_MODULES to its +// node_modules folder. Run against `npm run server:dev` with a prepared city. +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { mkdir } from 'node:fs/promises'; +import assert from 'node:assert/strict'; +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({viewport:{width:1440,height:1000}}); + const errors=[]; page.on('pageerror',error=>errors.push(error.message)); + const base=process.env.CITYGEN_TEST_URL || 'http://127.0.0.1:3000'; + await page.goto(`${base}/?play`); + await page.locator('select option').first().waitFor({state:'attached'}); + const city=process.env.CITYGEN_TEST_CITY; + if (city) await page.getByRole('combobox',{name:'Город'}).selectOption(city); + await page.locator('[name=nickname]').fill(`Smoke-${Date.now()}`); + const room=process.env.CITYGEN_TEST_ROOM; + const started=performance.now(); + if(room) await page.locator(`[data-room="${room}"]`).click(); + else await page.getByRole('button',{name:'Создать комнату',exact:true}).click(); + await page.locator('.world-canvas').waitFor({timeout:60000}); + 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.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(); + await page.locator('.world-canvas').hover();await page.mouse.wheel(0,-550); + 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')})); +} finally {await browser.close();} diff --git a/scripts/entities.test.ts b/scripts/entities.test.ts new file mode 100644 index 0000000..a30c4da --- /dev/null +++ b/scripts/entities.test.ts @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; +import { AgentSchema, DeltaSchema, ServerMessageSchema, SnapshotSchema } from '../client/generated/city_pb'; +import { EntityState } from '../client/entities'; + +test('binary protocol and interpolation preserve AOI spawn, change and despawn', () => { + const state = new EntityState(); + const agent = create(AgentSchema,{id:1,x:0,y:0}); + const envelope = create(ServerMessageSchema,{body:{case:'snapshot',value:{tick:10n,entities:[agent]}}}); + const decoded = fromBinary(ServerMessageSchema,toBinary(ServerMessageSchema,envelope)); + assert.equal(decoded.body.case,'snapshot'); + state.snapshot(create(SnapshotSchema,{tick:10n,entities:[agent]}),1000); + state.delta(create(DeltaSchema,{tick:12n,changed:[create(AgentSchema,{id:1,x:200,y:100})]}),1200); + assert.deepEqual(state.positions(1300),[{id:1,x:1,y:0.5}]); + state.delta(create(DeltaSchema,{tick:11n,despawned:[1]}),1300); + assert.equal(state.size,1); + state.delta(create(DeltaSchema,{tick:14n,despawned:[1],spawned:[create(AgentSchema,{id:2,x:400,y:0})]}),1400); + assert.deepEqual(state.positions(1500),[{id:2,x:4,y:0}]); + state.clear(); assert.equal(state.size,0); +}); diff --git a/scripts/streets.test.ts b/scripts/streets.test.ts new file mode 100644 index 0000000..e9993c6 --- /dev/null +++ b/scripts/streets.test.ts @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { generateWorldSync } from '../src/gen/generate'; +import { attachInteractions, streetObject } from '../src/gen/objects'; +import { tileWorld, assembleWorld } from '../src/city/format'; +import type { OsmElement } from '../src/osm/overpass'; + +test('indoor and outdoor objects share rotated interaction slots', () => { + const bench = streetObject('bench','bench',[10,20],Math.PI/2); + assert.deepEqual(bench.interactions?.find(s=>s.action==='sit'),{id:'bench:sit',action:'sit',capacity:3,pos:[9.25,20]}); + const bed = {...bench,id:'bed',kind:'bed_double',tags:['sleep:2'],w:1.6,d:2,rot:0}; + attachInteractions(bed); + assert.equal(bed.interactions?.[0].capacity,2); + assert.equal(bed.interactions?.[0].action,'sleep'); +}); + +test('generation produces stable street furniture and actual entrance references', () => { + const elements: OsmElement[] = [ + {type:'way',id:1,tags:{highway:'residential'},geometry:[{lat:47,lon:9},{lat:47,lon:9.005}]}, + {type:'way',id:2,tags:{building:'house','building:levels':'1'},geometry:[{lat:47.0002,lon:9.001},{lat:47.0002,lon:9.0012},{lat:47.0004,lon:9.0012},{lat:47.0004,lon:9.001},{lat:47.0002,lon:9.001}]}, + {type:'node',id:3,tags:{highway:'bus_stop'},lat:47.0001,lon:9.003}, + {type:'node',id:4,tags:{highway:'crossing'},lat:47,lon:9.004}, + ]; + const a = generateWorldSync(elements,[46.999,8.999,47.002,9.006],'Test',1); + const b = generateWorldSync(elements,[46.999,8.999,47.002,9.006],'Test',1); + assert.deepEqual(a.props,b.props); + assert.ok(a.props.some(p=>p.kind==='bench')); + assert.ok(a.props.some(p=>p.kind==='lamp')); + assert.ok(a.props.some(p=>p.kind==='bus_stop')); + assert.ok(a.props.some(p=>p.kind==='crossing')); + const entrance = a.props.find(p=>p.kind==='entrance'); assert.ok(entrance); + assert.ok(a.buildings.some(building=>building.id===entrance.target?.buildingId && building.floors.some(f=>f.doors.some(d=>d.id===entrance.target?.doorId)))); + const tiled = tileWorld(a); + assert.equal(assembleWorld(tiled.manifest,tiled.tiles.values()).props.length,a.props.length); +}); diff --git a/scripts/tiles.test.ts b/scripts/tiles.test.ts new file mode 100644 index 0000000..6201445 --- /dev/null +++ b/scripts/tiles.test.ts @@ -0,0 +1,128 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { gzipSync, gunzipSync } from 'node:zlib'; +import { createHash } from 'node:crypto'; +import { assembleWorld, parseManifest, parseTile, tileWorld } from '../src/city/format'; +import { TileLoader, fetchJson } from '../client/tiles'; +import { exportCity } from '../tools/export/index'; +import type { World } from '../src/types'; + +function fixture(): World { + return { version: 1, id: 'test', name: 'Test', createdAt: '2026-10-30T00:00:00Z', seed: 1, + origin: { lat: 0, lon: 0 }, bbox: [0, 0, 1, 1], bounds: { minX: -128, minY: -128, maxX: 512, maxY: 512 }, + attribution: 'OSM', areas: [{ id: 'area', kind: 'park', rings: [[[-20, -20], [20, -20], [20, 20], [-20, 20]]] }], + roads: [{ id: 'road', kind: 'residential', pts: [[-10, 10], [300, 10]], width: 6, sidewalk: 2, lanes: 2, oneway: false, layer: 0, bridge: false, tunnel: false }], + buildings: [{ id: 'building', kind: 'house', footprint: [[250, 20], [270, 20], [270, 40], [250, 40]], holes: [], area: 400, levels: 1, levelsSource: 'osm', frame: { origin: [250, 20], angle: 0 }, beds: 1, seed: 1, units: [], + floors: [{ level: 0, rooms: [], walls: [{ a: [250, 20], b: [270, 20], thickness: 0.2, exterior: true }], doors: [], windows: [], stairs: [], props: [{ id: 'bed', kind: 'bed', pos: [260, 30], rot: 0, w: 2, d: 1, blocksMove: true, blocksSight: false, model: 'bed-model' }] }] }], + props: [{ id: 'tree', kind: 'tree', pos: [-1, -1], w: 4, d: 4, rot: 0, blocksMove: true, blocksSight: false }], + models: { 'bed-model': [{ t: 'rect', x: 0, y: 0, w: 2, h: 1 }] } }; +} + +test('tile roundtrip preserves full interiors and model references without duplicate objects', () => { + const world = fixture(); + const { manifest, tiles } = tileWorld(world); + assert.ok(tiles.has('-1_-1')); + assert.equal(tiles.get('0_0')!.buildings[0].id, 'building'); + assert.equal(tiles.get('1_0')!.buildings[0].id, 'building'); + const restored = assembleWorld(parseManifest(JSON.parse(JSON.stringify(manifest))), [...tiles].map(([id, tile]) => parseTile(JSON.parse(JSON.stringify(tile)), world.id, id))); + assert.deepEqual(JSON.parse(JSON.stringify(restored)), world); + assert.deepEqual(tileWorld(world), tileWorld(world)); +}); + +test('rejects incompatible versions, paths, duplicate IDs and invalid grids', () => { + const { manifest, tiles } = tileWorld(fixture()); + assert.throws(() => parseManifest({ ...manifest, formatVersion: 99 }), /version/); + assert.throws(() => parseManifest({ ...manifest, tiles: [{ ...manifest.tiles[0], url: '../secret' }] }), /reference/); + assert.throws(() => parseManifest({ ...manifest, tiles: [manifest.tiles[0], manifest.tiles[0]] }), /reference/); + assert.throws(() => tileWorld(fixture(), 0)); + assert.throws(() => tileWorld(fixture(), NaN)); + const tile = [...tiles.values()][0]; + assert.throws(() => parseTile(tile, 'wrong-city', tile.id)); + assert.throws(() => tileWorld({ ...fixture(), models: {} }), /Missing model/); +}); + +test('area layering is stable regardless of tile arrival order',()=>{ + const world=fixture();world.areas.push({id:'grass',kind:'grass',rings:[[[300,0],[310,0],[310,10],[300,10]]]}); + const {manifest,tiles}=tileWorld(world); + assert.deepEqual(assembleWorld(manifest,[...tiles.values()].reverse()).areas.map(a=>a.id),world.areas.map(a=>a.id)); +}); + +test('verifies SHA-256 in browsers without secure-context WebCrypto',async()=>{ + const originalFetch=globalThis.fetch;const descriptor=Object.getOwnPropertyDescriptor(globalThis,'crypto'); + const json=JSON.stringify({name:'Бальцерс'}); + try { + Object.defineProperty(globalThis,'crypto',{value:undefined,configurable:true}); + globalThis.fetch=async()=>new Response(json); + assert.deepEqual(await fetchJson('http://lan/tile',undefined,createHash('sha256').update(json).digest('hex')),{name:'Бальцерс'}); + await assert.rejects(fetchJson('http://lan/tile',undefined,'0'.repeat(64)),/checksum/); + } finally {globalThis.fetch=originalFetch;if(descriptor)Object.defineProperty(globalThis,'crypto',descriptor);} +}); + +test('export produces readable gzip files and a complete manifest', async () => { + const dir = await mkdtemp(join(tmpdir(), 'citygen-export-')); + try { + const manifest = await exportCity(fixture(), dir); + await assert.rejects(exportCity(fixture(),dir),/immutable/); + assert.deepEqual(JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8')), manifest); + const navigation=JSON.parse(gunzipSync(await readFile(join(dir,manifest.navigation!.url))).toString()); + assert.equal(navigation.roads[0].id,'road'); + for (const ref of manifest.tiles) { + const value = JSON.parse(gunzipSync(await readFile(join(dir, ref.url))).toString()); + assert.equal(parseTile(value, manifest.city.id, ref.id).id, ref.id); + } + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test('HTTP loader selects AOI, handles gzip headers or files, caches and evicts', async () => { + const { manifest, tiles } = tileWorld(fixture()); + const original = globalThis.fetch; + const calls: string[] = []; + globalThis.fetch = async input => { + const url = String(input); + calls.push(url); + const id = url.split('/').pop()!.replace('.json.gz', ''); + return new Response(gzipSync(JSON.stringify(tiles.get(id)))); + }; + try { + const loader = new TileLoader(manifest, 'http://test/city/manifest.json', 1); + const aoi = { minX: 30, minY: 30, maxX: 40, maxY: 40 }; + const world = await loader.load(aoi); + assert.equal(calls.length, 1); + assert.equal(world.buildings[0].id, 'building'); + await loader.load(aoi); + assert.equal(calls.length, 1); + await loader.load({ minX: -10, minY: -10, maxX: -5, maxY: -5 }); + await loader.load(aoi); + assert.equal(calls.length, 3); + await assert.rejects(loader.load(manifest.city.bounds), /Приблизьте/); + loader.destroy(); + globalThis.fetch = async () => new Response(JSON.stringify({ ok: true })); + assert.deepEqual(await fetchJson('http://test/plain'), { ok: true }); + await assert.rejects(fetchJson('http://test/plain',undefined,'0'.repeat(64)),/checksum/); + globalThis.fetch = async () => new Response('missing', { status: 404 }); + await assert.rejects(fetchJson('http://test/missing'), /404/); + } finally { globalThis.fetch = original; } +}); + +test('superseded requests never replace a newer viewport', async () => { + const { manifest, tiles } = tileWorld(fixture()); + const original = globalThis.fetch; + let finish!: () => void; + let first = true; + globalThis.fetch = async input => { + const id = String(input).split('/').pop()!.replace('.json.gz', ''); + if (first) { first = false; await new Promise(resolve => { finish = resolve; }); } + return new Response(JSON.stringify(tiles.get(id))); + }; + try { + const loader = new TileLoader(manifest, 'http://test/city/manifest.json'); + const old = loader.load({ minX: 30, minY: 30, maxX: 40, maxY: 40 }); + await loader.load({ minX: -10, minY: -10, maxX: -5, maxY: -5 }); + finish(); + await assert.rejects(old, { name: 'AbortError' }); + loader.destroy(); + } finally { globalThis.fetch = original; } +}); diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..aac180e --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,1859 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "assert_type_match" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bevy_ecs" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c2bf6521aae57a0ec3487c4bfb59e36c4a378e834b626a4bea6a885af2fdfe7" +dependencies = [ + "arrayvec", + "bevy_ecs_macros", + "bevy_platform", + "bevy_ptr", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags", + "bumpalo", + "concurrent-queue", + "derive_more", + "disqualified", + "fixedbitset", + "indexmap", + "log", + "nonmax", + "serde", + "smallvec", + "thiserror", + "variadics_please", +] + +[[package]] +name = "bevy_ecs_macros" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38748d6f3339175c582d751f410fb60a93baf2286c3deb7efebb0878dce7f413" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_macro_utils" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052eeebcb8e7e072beea5031b227d9a290f8a7fbbb947573ab6ec81df0fb94be" +dependencies = [ + "parking_lot", + "proc-macro2", + "quote", + "syn 2.0.119", + "toml_edit", +] + +[[package]] +name = "bevy_platform" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7573dc824a1b08b4c93fdbe421c53e1e8188e9ca1dd74a414455fe571facb47" +dependencies = [ + "cfg-if", + "critical-section", + "foldhash", + "hashbrown 0.15.5", + "portable-atomic", + "portable-atomic-util", + "serde", + "spin", +] + +[[package]] +name = "bevy_ptr" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7370d0e46b60e071917711d0860721f5347bc958bf325975ae6913a5dfcf01" + +[[package]] +name = "bevy_reflect" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daeb91a63a1a4df00aa58da8cc4ddbd4b9f16ab8bb647c5553eb156ce36fa8c2" +dependencies = [ + "assert_type_match", + "bevy_platform", + "bevy_ptr", + "bevy_reflect_derive", + "bevy_utils", + "derive_more", + "disqualified", + "downcast-rs", + "erased-serde", + "foldhash", + "glam", + "serde", + "smallvec", + "smol_str", + "thiserror", + "uuid", + "variadics_please", + "wgpu-types", +] + +[[package]] +name = "bevy_reflect_derive" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ddadc55fe16b45faaa54ab2f9cb00548013c74812e8b018aa172387103cce6" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn 2.0.119", + "uuid", +] + +[[package]] +name = "bevy_tasks" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b674242641cab680688fc3b850243b351c1af49d4f3417a576debd6cca8dcf5" +dependencies = [ + "async-channel", + "async-executor", + "async-task", + "atomic-waker", + "bevy_platform", + "cfg-if", + "concurrent-queue", + "crossbeam-queue", + "derive_more", + "futures-lite", + "heapless", +] + +[[package]] +name = "bevy_utils" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f7a8905a125d2017e8561beefb7f2f5e67e93ff6324f072ad87c5fd6ec3b99" +dependencies = [ + "bevy_platform", + "thread_local", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "citygen-server" +version = "0.1.0" +dependencies = [ + "axum", + "bevy_ecs", + "bevy_tasks", + "flate2", + "futures-util", + "prost", + "prost-build", + "protoc-bin-vendored", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tokio-tungstenite 0.26.2", + "tower-http", + "uuid", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", + "portable-atomic", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "derive_more" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a9b99b9cbbe49445b21764dc0625032a89b145a2642e67603e1c936f5458d05" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "disqualified" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e90a5dd66beeca277ae954f8055b02a712792b6b6b43f2bc2f92b0b7d8291614" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "erased-serde" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" +dependencies = [ + "serde", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "equivalent", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "portable-atomic", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8760a25b6ff9c620324822737e468478fa092234190d2e449760344354896ed9" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73fa2624782ca04cd44f51554566717377acd240e4c0016d757dd74fccc9324f" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2417e9817fa237dab803ad4dda7357a111656e242959cc6b8f9a1a583367d42" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d189c34636356a46a7ed3188233dc8a88c431278cc54d4a19b096a2d270e985" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171e39f1e846e5f322ced1ac3b8d4cd3a3833ca24b6e5d58b3632574fe6204fa" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "873cdcc097593432086661aa432b8078f1cd87bfb02847c332e98ae2c119e966" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb72df001783b8297847fe8f5f874ee400fd742c843d60583e8c23d96977c7f" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04652167eca899dda05f32f5481adeaf25c623a98ce2fc146a001cc59a2add7" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263a3f48f01e7309e857138bd47f785585b4a005e8e56c6d2824ce91195999c3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.26.2", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "toml_datetime", + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "variadics_please" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu-types" +version = "24.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +dependencies = [ + "bitflags", + "js-sys", + "log", + "serde", + "web-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..62d2e9a --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "citygen-server" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { version = "0.8", features = ["ws"] } +tokio = { version = "1", features = ["full"] } +bevy_ecs = {version="0.16.1",features=["multi_threaded"]} +bevy_tasks = {version="0.16.1",features=["multi_threaded"]} +prost = "0.13" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +flate2 = "1" +sha2 = "0.10" +tower-http = { version = "0.6", features = ["fs", "set-header"] } +futures-util = "0.3" +uuid = { version = "1", features = ["v4"] } + +[build-dependencies] +prost-build = "0.13" +protoc-bin-vendored = "3" + +[dev-dependencies] +tempfile = "3" +tokio-tungstenite = "0.26" diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..ee48a94 --- /dev/null +++ b/server/README.md @@ -0,0 +1,73 @@ +# Сервер симуляции + +Из корня репозитория: `npm run server:dev`, затем открыть +`http://localhost:3000/?play`. Нужны Node.js 22+ и Rust stable с системным +компилятором/linker. Обёртка также находит локальную установку в `.tools/`. + +`axum` обслуживает клиент, каталог, очередь генерации и бинарный WebSocket. +Каждая активная комната владеет отдельным `bevy_ecs::World` в своём потоке. +Симуляция работает на 10 Гц, репликация — на 5 Гц. Один реальный шаг +соответствует шести игровым секундам при скорости ×1. + +Жители идут по дорожному графу между ближайшими к дому и работе узлами +утром и возвращаются вечером. Если в OSM нет подходящих домов/рабочих мест, +используются демонстрационные маршруты по связным дорогам. Интерьерная +геометрия, двери, лестницы и слоты загружаются по области наблюдения; +детальный поиск пути на этаже учитывает стены, проёмы и мебель. Перемещение +жителей между этажами и использование мебели относятся к игровому слою. + +Далёкие маршруты обновляются раз в десять реальных секунд. Положение при +подписке вычисляется точно из календаря. AOI ограничен 2×2 км, снимок — +1000 жителями по умолчанию; клиент увеличивает LOD при переполнении. Плотные области +показывают стабильную выборку жителей. Репликация ограничена 90 КБ/с на +клиента с запасом 180 КБ на начальный снимок; пропущенные дельты объединяются. + +Ник уникален в комнате, сессия выдаётся сервером. Пауза и скорости 1/2/4 +принимаются большинством более половины подключённых игроков. Голосование +длится 20 с; интервал предложений игрока — 30 с. Владелец и привилегии +создателя отсутствуют. Создание ограничено одним запросом с IP в минуту. + +Снимки Protobuf кодируются и записываются отдельным ограниченным по очереди +потоком, атомарно заменяются и ротируются; голоса и подключения +не сохраняются. При старте восстанавливается каталог комнат, `World` +загружается при первом подключении. Изменившийся манифест блокирует +восстановление с понятной ошибкой. Новые экспорты содержат SHA-256 тайлов +и навигационного индекса; существующий каталог города не перезаписывается. + +Переменные окружения: + +- `CITYGEN_BIND`: `127.0.0.1:3000`. +- `CITYGEN_CITIES`: `public/cities`; `CITYGEN_SAVES`: `data/rooms`. +- `CITYGEN_MAX_ROOMS`: 5; `CITYGEN_AGENTS`: 1000, максимум 1000000. +- `CITYGEN_MAX_VISIBLE`: 1000, максимум 5000; `CITYGEN_SIM_THREADS`: до четырёх CPU по умолчанию, общий пул ECS на процесс. +- `CITYGEN_EMPTY_TIMEOUT`: 300 с; `CITYGEN_SAVE_INTERVAL`: 60 с. +- `CITYGEN_KEEP_SNAPSHOTS`: 3. +- `CITYGEN_MAX_GENERATION_KM2`: 25; `CITYGEN_MAX_BUILDINGS`: 10000. +- `CITYGEN_GENERATION_WORKERS`: 2; `CITYGEN_GENERATION_QUEUE`: 8. +- `CITYGEN_GENERATION_TIMEOUT`: 600 с. +- `CITYGEN_OSM_FIXTURE`: путь к локальной выгрузке Overpass для офлайн-проверки очереди; по умолчанию используется Overpass API. + +Очередь запускает TS-генератор отдельным Node-процессом. `CreateCity` и +`CityJob` из общей схемы передаются по HTTP как `application/x-protobuf`. +Каталог и создание комнаты используют JSON. Задания очереди не переживают +перезапуск; готовые города и снимки комнат сохраняются на диске. + +`/health` проверяет доступность; `/metrics` отдаёт число игроков/комнат, +тик, время последнего и максимального шага, число сущностей и байты очереди рассылки каждой +комнаты в формате Prometheus. Медленные тики и ошибки снимков идут в stderr. +При Ctrl+C сервер останавливает комнаты с сохранением. + +Проверки: `npm run server:test`, `npm run server:load`. Второй стенд использует +миллион жителей, 4096 синтетических маршрутов и 30 небольших областей +наблюдения, включая кодирование полных снимков как верхнюю оценку трафика. +Он не моделирует RTT, диск, браузеры или плотную толпу в одной точке. Отдельный +интеграционный тест подключает 30 настоящих WebSocket-клиентов к 1000 жителей. + +Настоящая нагрузка на выбранный город: запустить `npm run server:release` с +`CITYGEN_AGENTS=1000000`, затем `npm run server:bots -- http://localhost:3000 balzers-demo 20`. +Боты создают комнату, подключаются, подписываются на 30 областей, адаптируют +LOD и выводят трафик, выборку длительности тиков и ошибки соединений. + +Dockerfile подготавливает приложение для одного сервера. Каталоги городов +и `data/` необходимо подключать постоянными томами. Docker-сборка и внешний +деплой требуют отдельной проверки в целевом окружении. diff --git a/server/build.rs b/server/build.rs new file mode 100644 index 0000000..3998c44 --- /dev/null +++ b/server/build.rs @@ -0,0 +1,5 @@ +fn main() { + std::env::set_var("PROTOC", protoc_bin_vendored::protoc_bin_path().unwrap()); + prost_build::compile_protos(&["../proto/city.proto"], &["../proto"]).unwrap(); + println!("cargo:rerun-if-changed=../proto/city.proto"); +} diff --git a/server/src/bin/load.rs b/server/src/bin/load.rs new file mode 100644 index 0000000..304124a --- /dev/null +++ b/server/src/bin/load.rs @@ -0,0 +1,55 @@ +use citygen_server::{protocol::Rect, simulation::Simulation}; +use std::time::Instant; +fn main() { + let count = std::env::args() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(1_000_000); + let mut routes = vec![]; + for y in 0..64 { + for x in 0..64 { + routes.push(vec![ + [x * 100000, y * 100000], + [x * 100000 + 20000, y * 100000], + [x * 100000 + 20000, y * 100000 + 20000], + ]); + } + } + let mut sim = Simulation::new("load".into(), "synthetic".into(), 1, count, routes).unwrap(); + let views: Vec<_> = (0..30) + .map(|i| Rect { + min_x: i * 100000, + min_y: 0, + max_x: i * 100000 + 30000, + max_y: 30000, + }) + .collect(); + sim.observe(&views); + let mut times = vec![]; + let mut bytes = 0usize; + use prost::Message; + for _ in 0..200 { + let start = Instant::now(); + sim.step(); + if sim.clock().tick % 2 == 0 { + for view in &views { + let entities = sim + .visible_lod(view, 5000, 0) + .unwrap() + .into_values() + .collect(); + bytes += citygen_server::protocol::Snapshot { + tick: sim.clock().tick, + entities, + } + .encoded_len(); + } + } + times.push(start.elapsed().as_secs_f64() * 1000.0); + } + times.sort_by(f64::total_cmp); + println!("agents={count} observers=30 ticks=200 p50_ms={:.3} p95_ms={:.3} max_ms={:.3} upper_bound_bytes_per_client_second={}",times[100],times[190],times[199],bytes/30/20); + if times[190] > 50.0 { + eprintln!("Tick target exceeded on this machine"); + } +} diff --git a/server/src/city.rs b/server/src/city.rs new file mode 100644 index 0000000..76bcc7e --- /dev/null +++ b/server/src/city.rs @@ -0,0 +1,333 @@ +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, VecDeque}, + fs, + io::Read, + path::Path, +}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Bounds { + pub min_x: f64, + pub min_y: f64, + pub max_x: f64, + pub max_y: f64, +} +#[derive(Clone, Deserialize)] +pub struct Metadata { + pub id: String, + pub name: String, + pub bounds: Bounds, +} +#[derive(Clone, Deserialize)] +pub struct TileRef { + pub id: String, + pub url: String, + pub bounds: Bounds, + pub sha256: Option, +} +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Manifest { + pub format_version: u32, + pub city: Metadata, + pub tiles: Vec, + pub navigation: Option, +} +#[derive(Clone, Deserialize)] +pub struct NavigationRef { + pub url: String, + pub sha256: String, +} +#[derive(Clone, Default)] +pub struct Graph { + pub points: Vec<[i32; 2]>, + pub edges: Vec>, +} +#[derive(Clone)] +pub struct City { + pub id: String, + pub manifest: Manifest, + pub graph: Graph, + pub revision: String, + homes: BTreeMap, + workplaces: BTreeMap, +} +pub struct Commute { + pub home: String, + pub workplace: String, + pub points: Vec<[i32; 2]>, +} + +pub fn safe_id(id: &str) -> bool { + !id.is_empty() + && id.len() <= 80 + && id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} +fn point(v: &serde_json::Value) -> Result<[i32; 2], String> { + let x = v.get(0).and_then(|v| v.as_f64()).ok_or("Invalid point")? * 100.0; + let y = v.get(1).and_then(|v| v.as_f64()).ok_or("Invalid point")? * 100.0; + if !x.is_finite() || !y.is_finite() || x.abs() > i32::MAX as f64 || y.abs() > i32::MAX as f64 { + return Err("Point out of range".into()); + } + Ok([x.round() as i32, y.round() as i32]) +} +impl City { + pub fn load(root: &Path, id: &str) -> Result { + if !safe_id(id) { + return Err("Invalid city ID".into()); + } + let folder = root.join(id); + let manifest_bytes = fs::read(folder.join("manifest.json")).map_err(|e| e.to_string())?; + let revision = format!("{:x}", Sha256::digest(&manifest_bytes)); + let manifest: Manifest = + serde_json::from_slice(&manifest_bytes).map_err(|e| e.to_string())?; + if manifest.format_version != 1 { + return Err("Unsupported city format".into()); + } + let mut graph = Graph::default(); + let mut nodes = BTreeMap::<[i32; 2], usize>::new(); + let mut roads = std::collections::HashSet::new(); + let mut homes = BTreeMap::new(); + let mut workplaces = BTreeMap::new(); + let inputs = if let Some(nav) = &manifest.navigation { + if nav.url != "navigation.json.gz" { + return Err("Invalid navigation URL".into()); + } + vec![TileRef { + id: "navigation".into(), + url: nav.url.clone(), + bounds: manifest.city.bounds.clone(), + sha256: Some(nav.sha256.clone()), + }] + } else { + manifest.tiles.clone() + }; + for tile in &inputs { + let expected = if tile.id == "navigation" { + "navigation.json.gz".to_string() + } else { + format!("tiles/{}.json.gz", tile.id) + }; + if tile.url != expected + || (tile.id != "navigation" + && tile + .id + .bytes() + .any(|c| !(c.is_ascii_digit() || c == b'-' || c == b'_'))) + { + return Err("Invalid tile URL".into()); + } + let bytes = fs::read(folder.join(&tile.url)).map_err(|e| e.to_string())?; + let mut text = String::new(); + flate2::read::GzDecoder::new(&bytes[..]) + .read_to_string(&mut text) + .map_err(|e| e.to_string())?; + if let Some(expected) = &tile.sha256 { + if &format!("{:x}", Sha256::digest(text.as_bytes())) != expected { + return Err("City tile checksum mismatch".into()); + } + } + let value: serde_json::Value = + serde_json::from_str(&text).map_err(|e| e.to_string())?; + if value["formatVersion"] != 1 + || value["cityId"] != manifest.city.id + || value["id"] != tile.id + { + return Err("Tile does not belong to manifest".into()); + } + if let Some(buildings) = value["buildings"].as_array() { + for building in buildings { + let Some(id) = building["id"].as_str() else { + continue; + }; + let mut entrance = None; + if let Some(floors) = building["floors"].as_array() { + for floor in floors { + if floor["level"] == 0 { + if let Some(doors) = floor["doors"].as_array() { + for door in doors { + if door["rooms"].as_array().is_some_and(|rooms| { + rooms.iter().any(|r| r == "outside") + }) { + let a = point(&door["a"])?; + let b = point(&door["b"])?; + entrance = Some([ + ((a[0] as i64 + b[0] as i64) / 2) as i32, + ((a[1] as i64 + b[1] as i64) / 2) as i32, + ]); + break; + } + } + } + } + } + } + if let Some(p) = entrance { + match building["kind"].as_str().unwrap_or("") { + "house" | "apartments" | "mixed" => { + homes.insert(id.to_string(), p); + } + "office" | "retail" | "industrial" | "school" | "hospital" + | "restaurant" | "warehouse" => { + workplaces.insert(id.to_string(), p); + } + _ => {} + } + } + } + } + for road in value["roads"].as_array().ok_or("Missing roads")? { + let id = road["id"].as_str().ok_or("Missing road ID")?; + if !roads.insert(id.to_string()) { + continue; + } + if [ + "river", "stream", "canal", "ditch", "rail", "tram", "motorway", "trunk", + ] + .contains(&road["kind"].as_str().unwrap_or("")) + { + continue; + } + let mut previous: Option = None; + for v in road["pts"].as_array().ok_or("Missing road points")? { + let p = point(v)?; + let next_id = graph.points.len(); + let index = *nodes.entry(p).or_insert_with(|| { + graph.points.push(p); + graph.edges.push(vec![]); + next_id + }); + if let Some(prev) = previous { + if prev != index { + graph.edges[prev].push(index); + graph.edges[index].push(prev); + } + } + previous = Some(index); + } + } + } + if graph.points.len() < 2 { + return Err("City has no walkable road graph".into()); + } + Ok(Self { + id: id.into(), + manifest, + graph, + revision, + homes, + workplaces, + }) + } + pub fn commutes(&self, count: usize, seed: u64) -> Vec { + let homes: Vec<_> = self.homes.iter().collect(); + let works: Vec<_> = self.workplaces.iter().collect(); + let nearest = |p: &[i32; 2]| { + self.graph + .points + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| { + let distance = |q: &&[i32; 2]| { + (q[0] as f64 - p[0] as f64).powi(2) + (q[1] as f64 - p[1] as f64).powi(2) + }; + distance(a).total_cmp(&distance(b)) + }) + .map(|(i, _)| i) + .unwrap_or(0) + }; + let mut result = vec![]; + if !homes.is_empty() && !works.is_empty() { + for i in 0..count { + let (home, a) = + homes[(i.wrapping_mul(7919).wrapping_add(seed as usize)) % homes.len()]; + let (work, b) = + works[(i.wrapping_mul(31).wrapping_add(seed as usize)) % works.len()]; + if let Some(points) = self.graph.between(nearest(a), nearest(b)) { + if points.len() > 1 { + result.push(Commute { + home: home.clone(), + workplace: work.clone(), + points, + }); + } + } + } + } + if result.is_empty() { + return self + .graph + .routes(count, seed) + .into_iter() + .map(|points| Commute { + home: String::new(), + workplace: String::new(), + points, + }) + .collect(); + } + result + } +} +impl Graph { + fn between(&self, start: usize, end: usize) -> Option> { + let mut parent = vec![usize::MAX; self.points.len()]; + parent[start] = start; + let mut queue = VecDeque::from([start]); + while let Some(at) = queue.pop_front() { + if at == end { + let mut p = vec![self.points[at]]; + let mut at = at; + while at != start { + at = parent[at]; + p.push(self.points[at]); + } + p.reverse(); + return Some(p); + } + for &next in &self.edges[at] { + if parent[next] == usize::MAX { + parent[next] = at; + queue.push_back(next); + } + } + } + None + } + /// Stable breadth-first paths along actual road segments, shared by many residents. + pub fn routes(&self, count: usize, seed: u64) -> Vec> { + let mut routes = Vec::new(); + for i in 0..count { + let start = (seed as usize).wrapping_add(i.wrapping_mul(7919)) % self.points.len(); + let mut parent = vec![usize::MAX; self.points.len()]; + parent[start] = start; + let mut queue = VecDeque::from([start]); + let mut last = start; + while let Some(node) = queue.pop_front() { + last = node; + for &next in &self.edges[node] { + if parent[next] == usize::MAX { + parent[next] = node; + queue.push_back(next); + } + } + } + if last == start { + continue; + } + let mut route = vec![self.points[last]]; + while last != start { + last = parent[last]; + route.push(self.points[last]); + } + route.reverse(); + routes.push(route); + } + routes + } +} diff --git a/server/src/gateway.rs b/server/src/gateway.rs new file mode 100644 index 0000000..eb1eb68 --- /dev/null +++ b/server/src/gateway.rs @@ -0,0 +1,986 @@ +use crate::{ + city::{safe_id, City}, + protocol::{self as p, client_message, server_message}, + simulation::Simulation, + storage, +}; +use axum::{ + extract::{ + ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, + ConnectInfo, Path, State, + }, + http::StatusCode, + response::IntoResponse, + routing::get, + Json, Router, +}; +use futures_util::{SinkExt, StreamExt}; +use prost::Message; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + net::SocketAddr, + path::PathBuf, + sync::{ + atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, + mpsc, Arc, Mutex, + }, + time::{Duration, Instant}, +}; +use tokio::sync::mpsc as async_mpsc; + +pub const PROTOCOL_VERSION: u32 = 1; +#[derive(Clone)] +pub struct Config { + pub cities: PathBuf, + pub saves: PathBuf, + pub max_rooms: usize, + pub max_players: usize, + pub empty_timeout: Duration, + pub save_interval: Duration, + pub keep_snapshots: usize, + pub agents: u32, + pub max_generation_km2: f64, + pub max_buildings: usize, + pub generation_timeout: u64, + pub generation_workers: usize, + pub generation_queue: usize, + pub max_visible: usize, + pub osm_fixture: Option, + pub project_root: PathBuf, +} +impl Default for Config { + fn default() -> Self { + Self { + cities: "public/cities".into(), + saves: "data/rooms".into(), + max_rooms: 5, + max_players: 30, + empty_timeout: Duration::from_secs(300), + save_interval: Duration::from_secs(60), + keep_snapshots: 3, + agents: 1000, + max_generation_km2: 25.0, + max_buildings: 10000, + generation_timeout: 600, + generation_workers: 2, + generation_queue: 8, + max_visible: 1000, + osm_fixture: None, + project_root: std::env::current_dir().unwrap_or_else(|_| ".".into()), + } + } +} +#[derive(Clone)] +pub struct App { + pub config: Config, + rooms: Arc>>, + limits: Arc>>, + pub jobs: crate::generation::Jobs, +} +struct RoomHandle { + city_id: String, + commands: mpsc::SyncSender, + active: Arc, + players: Arc, + thread: Option>, + metrics: Arc, +} +#[derive(Default)] +struct RoomMetrics { + tick: AtomicU64, + micros: AtomicU64, + entities: AtomicU64, + bytes: AtomicU64, + max_micros: AtomicU64, +} +pub(crate) enum Command { + Join { + session: String, + nickname: String, + out: async_mpsc::Sender, + }, + Leave(String), + Input(String, p::ClientMessage), + Stop, +} +struct Player { + nickname: String, + out: async_mpsc::Sender, + rect: Option, + lod: u32, + known: HashMap, + last_proposal: Option, + traffic: Traffic, + metrics: Arc, +} +struct Traffic { + credit: f64, + at: Instant, +} +impl Traffic { + fn new() -> Self { + Self { + credit: 180000.0, + at: Instant::now(), + } + } + fn allow(&mut self, bytes: usize) -> bool { + let now = Instant::now(); + self.credit = + (self.credit + now.duration_since(self.at).as_secs_f64() * 90000.0).min(180000.0); + self.at = now; + if bytes as f64 > self.credit { + return false; + } + self.credit -= bytes as f64; + true + } +} +struct Ballot { + speed: u32, + yes: HashSet, + voted: HashSet, + deadline: Instant, +} +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RoomInfo { + id: String, + city_id: String, + active: bool, + players: usize, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateRoom { + city_id: String, + seed: Option, +} +fn message(body: server_message::Body) -> p::ServerMessage { + p::ServerMessage { body: Some(body) } +} +fn error(code: &str, text: impl Into) -> p::ServerMessage { + message(server_message::Body::Error(p::Error { + code: code.into(), + message: text.into(), + })) +} +fn reply(player: &Player, value: p::ServerMessage) -> bool { + let bytes = value.encoded_len(); + if player.out.try_send(value).is_ok() { + player + .metrics + .bytes + .fetch_add(bytes as u64, Ordering::Relaxed); + true + } else { + false + } +} +type ApiError = (StatusCode, Json); +fn api_error(code: StatusCode, text: impl Into) -> ApiError { + (code, Json(serde_json::json!({"error":text.into()}))) +} + +impl App { + pub fn new(config: Config) -> Result { + if config.max_rooms == 0 + || config.max_players == 0 + || config.max_players > 30 + || config.agents > 1_000_000 + || config.generation_workers == 0 + || config.generation_queue == 0 + || config.max_visible == 0 + || config.max_visible > 5000 + || !config.max_generation_km2.is_finite() + || config.max_generation_km2 <= 0.0 + { + return Err("Invalid room/player/population/generation limits".into()); + } + std::fs::create_dir_all(&config.saves).map_err(|e| e.to_string())?; + std::fs::create_dir_all(&config.cities).map_err(|e| e.to_string())?; + let mut rooms = HashMap::new(); + for entry in std::fs::read_dir(&config.saves) + .map_err(|e| e.to_string())? + .flatten() + { + let id = entry.file_name().to_string_lossy().to_string(); + if !entry.path().is_dir() || !safe_id(&id) { + continue; + } + match storage::load(&config.saves, &id) { + Ok(saved) => { + let (tx, _) = mpsc::sync_channel(1); + rooms.insert( + id, + RoomHandle { + city_id: saved.city_id, + commands: tx, + active: Arc::new(AtomicBool::new(false)), + players: Arc::new(AtomicUsize::new(0)), + thread: None, + metrics: Arc::new(RoomMetrics::default()), + }, + ); + } + Err(e) => eprintln!("room {id} unavailable: {e}"), + } + } + let jobs = crate::generation::Jobs::new(config.generation_workers); + Ok(Self { + config, + rooms: Arc::new(Mutex::new(rooms)), + limits: Arc::new(Mutex::new(HashMap::new())), + jobs, + }) + } + pub(crate) fn active(&self) -> usize { + self.rooms + .lock() + .unwrap() + .values() + .filter(|r| r.active.load(Ordering::Relaxed)) + .count() + } + pub(crate) fn start( + &self, + id: String, + city_id: String, + seed: u64, + restore: bool, + ) -> Result, String> { + let mut rooms = self.rooms.lock().unwrap(); + if let Some(room) = rooms.get(&id) { + if room.active.load(Ordering::Relaxed) { + return Ok(room.commands.clone()); + } + } + if rooms + .values() + .filter(|r| r.active.load(Ordering::Relaxed)) + .count() + >= self.config.max_rooms + { + return Err("Server room limit reached".into()); + } + let city = City::load(&self.config.cities, &city_id)?; + let navigation = + crate::navigation::Cache::new(self.config.cities.join(&city_id), city.manifest.clone()); + let mut simulation = if restore { + let saved = storage::load(&self.config.saves, &id)?; + if saved.city_id != city_id + || saved.city_version != city.manifest.format_version + || saved.city_revision != city.revision + { + return Err("Room city is incompatible or has changed since the snapshot".into()); + } + Simulation::restore(saved)? + } else { + let commutes = city.commutes(64, seed); + let goals = commutes + .iter() + .map(|r| (r.home.clone(), r.workplace.clone())) + .collect(); + let mut simulation = Simulation::new( + id.clone(), + city_id.clone(), + seed, + self.config.agents, + commutes.into_iter().map(|r| r.points).collect(), + )?; + simulation.goals(goals); + simulation + }; + simulation.city_revision = city.revision; + let (tx, rx) = mpsc::sync_channel(1024); + let active = Arc::new(AtomicBool::new(true)); + let players = Arc::new(AtomicUsize::new(0)); + let thread_active = active.clone(); + let thread_players = players.clone(); + let metrics = Arc::new(RoomMetrics::default()); + let thread_metrics = metrics.clone(); + let config = self.config.clone(); + let thread = std::thread::spawn(move || { + room_loop( + simulation, + rx, + config, + thread_active, + thread_players, + navigation, + thread_metrics, + ) + }); + if let Some(old) = rooms.get_mut(&id) { + if let Some(thread) = old.thread.take() { + let _ = thread.join(); + } + } + rooms.insert( + id, + RoomHandle { + city_id, + commands: tx.clone(), + active, + players, + thread: Some(thread), + metrics, + }, + ); + Ok(tx) + } + pub fn stop(&self) { + let mut rooms = self.rooms.lock().unwrap(); + for room in rooms.values() { + if room.active.load(Ordering::Relaxed) { + let _ = room.commands.send(Command::Stop); + } + } + for room in rooms.values_mut() { + if let Some(thread) = room.thread.take() { + if thread.join().is_err() { + eprintln!("room thread panicked"); + } + } + } + } +} + +pub fn router(app: App) -> Router { + let cities = app.config.cities.clone(); + let city_files = Router::new() + .fallback_service(tower_http::services::ServeDir::new(cities)) + .layer(axum::middleware::map_response(cache_city)); + Router::new() + .route("/api/rooms", get(list_rooms).post(create_room)) + .route("/api/cities", get(list_cities)) + .route("/api/cities/jobs", axum::routing::post(create_city)) + .route("/api/cities/jobs/{id}", get(city_job)) + .route("/api/rooms/{id}/ws", get(upgrade)) + .route("/health", get(|| async { "ok" })) + .route("/metrics", get(metrics)) + .nest("/cities", city_files) + .fallback_service(tower_http::services::ServeDir::new("dist")) + .with_state(app) +} +async fn cache_city(mut response: axum::response::Response) -> axum::response::Response { + if response.status().is_success() { + response.headers_mut().insert( + axum::http::header::CACHE_CONTROL, + axum::http::HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + } + response +} +async fn create_city( + State(app): State, + ConnectInfo(peer): ConnectInfo, + body: axum::body::Bytes, +) -> Result { + let input = p::CreateCity::decode(&body[..]).map_err(|_| { + api_error( + StatusCode::BAD_REQUEST, + "Expected a Protobuf CreateCity message", + ) + })?; + let request = crate::generation::Request { + bbox: input + .bbox + .try_into() + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "Expected four bbox coordinates"))?, + name: input.name, + seed: input.seed, + }; + crate::generation::normalize(&request, app.config.max_generation_km2) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, e))?; + { + 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 creation per IP per minute", + )); + } + 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())) +} +async fn city_job( + State(app): State, + Path(id): Path, +) -> Result { + if !safe_id(&id) { + return Err(api_error(StatusCode::BAD_REQUEST, "Invalid job ID")); + } + let job = app + .jobs + .items + .lock() + .unwrap() + .get(&id) + .cloned() + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Job not found"))?; + let mut stage = String::new(); + if let Ok(bytes) = tokio::fs::read( + app.config + .saves + .join(".jobs") + .join(format!("{id}.progress.json")), + ) + .await + { + if let Ok(progress) = serde_json::from_slice::(&bytes) { + stage = progress["stage"].as_str().unwrap_or("").to_string(); + } + } + Ok(job_response(job, stage)) +} +fn job_response(job: crate::generation::Job, stage: String) -> impl IntoResponse { + let message = p::CityJob { + id: job.id, + city_id: job.city_id, + status: job.status, + room_id: job.room_id.unwrap_or_default(), + error: job.error.unwrap_or_default(), + stage, + }; + ( + [(axum::http::header::CONTENT_TYPE, "application/x-protobuf")], + message.encode_to_vec(), + ) +} +async fn list_rooms(State(app): State) -> Json> { + Json( + app.rooms + .lock() + .unwrap() + .iter() + .map(|(id, r)| RoomInfo { + id: id.clone(), + city_id: r.city_id.clone(), + active: r.active.load(Ordering::Relaxed), + players: r.players.load(Ordering::Relaxed), + }) + .collect(), + ) +} +async fn list_cities(State(app): State) -> Json> { + let mut cities = vec![]; + if let Ok(entries) = std::fs::read_dir(&app.config.cities) { + for entry in entries.flatten() { + let id = entry.file_name().to_string_lossy().to_string(); + if !safe_id(&id) { + continue; + } + if let Ok(data) = std::fs::read(entry.path().join("manifest.json")) { + if let Ok(m) = serde_json::from_slice::(&data) { + if m.format_version == 1 { + cities.push(serde_json::json!({"id":id, "name":m.city.name, "manifestUrl":format!("/cities/{id}/manifest.json")})); + } + } + } + } + } + Json(cities) +} +async fn create_room( + State(app): State, + ConnectInfo(peer): ConnectInfo, + Json(request): Json, +) -> Result, ApiError> { + if !safe_id(&request.city_id) { + return Err(api_error(StatusCode::BAD_REQUEST, "Invalid city ID")); + } + { + 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", + )); + } + limits.insert(peer.ip(), now); + } + let id = uuid::Uuid::new_v4().simple().to_string(); + let room_id = id.clone(); + tokio::task::spawn_blocking(move || { + app.start(room_id, request.city_id, request.seed.unwrap_or(1), false) + }) + .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}))) +} +async fn metrics(State(app): State) -> String { + let players: usize = app + .rooms + .lock() + .unwrap() + .values() + .map(|r| r.players.load(Ordering::Relaxed)) + .sum(); + let mut text = format!( + "citygen_active_rooms {}\ncitygen_players {players}\n", + app.active() + ); + for (id, room) in app.rooms.lock().unwrap().iter() { + 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 + )); + text.push_str(&format!("citygen_room_tick{{room=\"{id}\"}} {}\ncitygen_room_tick_seconds{{room=\"{id}\"}} {}\ncitygen_room_entities{{room=\"{id}\"}} {}\ncitygen_room_queued_bytes_total{{room=\"{id}\"}} {}\n",room.metrics.tick.load(Ordering::Relaxed),room.metrics.micros.load(Ordering::Relaxed) as f64/1_000_000.0,room.metrics.entities.load(Ordering::Relaxed),room.metrics.bytes.load(Ordering::Relaxed))); + } + text +} +async fn upgrade( + State(app): State, + Path(id): Path, + ws: WebSocketUpgrade, +) -> Result { + let city_id = app + .rooms + .lock() + .unwrap() + .get(&id) + .map(|r| r.city_id.clone()) + .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Room not found"))?; + let commands = tokio::task::spawn_blocking(move || app.start(id, city_id, 1, true)) + .await + .map_err(|e| api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? + .map_err(|e| api_error(StatusCode::CONFLICT, e))?; + Ok(ws + .max_message_size(4096) + .max_frame_size(4096) + .on_upgrade(move |socket| connection(socket, commands))) +} +async fn connection(socket: WebSocket, commands: mpsc::SyncSender) { + let (mut sink, mut stream) = socket.split(); + let first = tokio::time::timeout(Duration::from_secs(10), stream.next()).await; + let join = match first { + Ok(Some(Ok(WsMessage::Binary(bytes)))) => p::ClientMessage::decode(&bytes[..]) + .ok() + .and_then(|m| m.body), + _ => None, + }; + let Some(client_message::Body::Join(join)) = join else { + return; + }; + let session = uuid::Uuid::new_v4().simple().to_string(); + let nickname = join.nickname.trim().to_string(); + if join.client_version != PROTOCOL_VERSION + || nickname.is_empty() + || nickname.chars().count() > 24 + || nickname.chars().any(char::is_control) + { + let _ = sink + .send(WsMessage::Binary( + error( + "HANDSHAKE", + "Protocol version 1 and a nickname of 1–24 characters are required", + ) + .encode_to_vec() + .into(), + )) + .await; + return; + } + let (out, mut incoming) = async_mpsc::channel(8); + if commands + .try_send(Command::Join { + session: session.clone(), + nickname, + out, + }) + .is_err() + { + return; + } + let mut window = Instant::now(); + let mut requests = 0; + loop { + tokio::select! { + response = incoming.recv() => { + let Some(response) = response else { break; }; + let terminal = matches!(response.body, Some(server_message::Body::Error(ref e)) if ["NICKNAME","ROOM_FULL"].contains(&e.code.as_str())); + if tokio::time::timeout(Duration::from_secs(5), sink.send(WsMessage::Binary(response.encode_to_vec().into()))).await.map_or(true, |r| r.is_err()) || terminal { break; } + } + frame = tokio::time::timeout(Duration::from_secs(60), stream.next()) => { + let bytes = match frame { Ok(Some(Ok(WsMessage::Binary(bytes)))) => bytes, Ok(Some(Ok(WsMessage::Ping(_)))) | Ok(Some(Ok(WsMessage::Pong(_)))) => continue, _ => break }; + if window.elapsed() >= Duration::from_secs(1) { window = Instant::now(); requests = 0; } + requests += 1; if requests > 20 { break; } + let Ok(input) = p::ClientMessage::decode(&bytes[..]) else { break; }; + if commands.try_send(Command::Input(session.clone(), input)).is_err() { break; } + } + } + } + // A bounded actor queue must not turn disconnect into a blocking Tokio call. + let _ = tokio::task::spawn_blocking(move || commands.send(Command::Leave(session))).await; +} + +fn valid_rect(r: &p::Rect) -> bool { + let w = r.max_x as i64 - r.min_x as i64; + let h = r.max_y as i64 - r.min_y as i64; + w > 0 && h > 0 && w <= 200_000 && h <= 200_000 && w * h <= 40_000_000_000 +} +fn room_loop( + mut sim: Simulation, + rx: mpsc::Receiver, + config: Config, + active: Arc, + player_count: Arc, + mut navigation: crate::navigation::Cache, + metrics: Arc, +) { + struct Lifecycle(Arc, Arc); + impl Drop for Lifecycle { + fn drop(&mut self) { + self.0.store(false, Ordering::Relaxed); + self.1.store(0, Ordering::Relaxed); + } + } + let _lifecycle = Lifecycle(active.clone(), player_count.clone()); + let (save_tx, save_rx) = mpsc::sync_channel::(1); + let save_root = config.saves.clone(); + let keep = config.keep_snapshots; + 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}"); + } + } + }); + let mut players = HashMap::::new(); + let mut ballot: Option = None; + let mut empty_since = Instant::now(); + let mut saved_at = Instant::now(); + let mut next_tick = Instant::now(); + let mut stopping = false; + while !stopping { + let wait = next_tick.saturating_duration_since(Instant::now()); + if let Ok(command) = rx.recv_timeout(wait) { + match command { + Command::Stop => stopping = true, + Command::Leave(id) => { + players.remove(&id); + if let Some(b) = &mut ballot { + b.yes.remove(&id); + b.voted.remove(&id); + } + if players.is_empty() { + empty_since = Instant::now(); + ballot = None; + } + } + Command::Join { + session, + nickname, + out, + } => { + if players.len() >= config.max_players { + let _ = out.try_send(error("ROOM_FULL", "Room has 30 players")); + continue; + } + if players + .values() + .any(|p| p.nickname.to_lowercase() == nickname.to_lowercase()) + { + let _ = out.try_send(error("NICKNAME", "Nickname is already in use")); + continue; + } + let p = Player { + nickname, + out, + rect: None, + lod: 0, + known: HashMap::new(), + last_proposal: None, + traffic: Traffic::new(), + metrics: metrics.clone(), + }; + reply( + &p, + message(server_message::Body::Welcome(p::Welcome { + room: sim.id.clone(), + tick: sim.clock().tick, + city_manifest_url: format!("/cities/{}/manifest.json", sim.city_id), + session: session.clone(), + protocol_version: 1, + })), + ); + players.insert(session, p); + } + Command::Input(id, input) => { + if let Some(player) = players.get_mut(&id) { + match input.body { + Some(client_message::Body::Subscribe(s)) => { + if let Some(rect) = s.rect.filter(valid_rect) { + if let Err(e) = navigation.observe(&rect) { + reply(player, error("NAVIGATION", e)); + continue; + } + match sim.visible_fresh_lod( + &rect, + config.max_visible, + s.lod.min(10), + ) { + Ok(visible) => { + let clock = sim.clock(); + let snapshot = message(server_message::Body::Snapshot( + p::Snapshot { + tick: clock.tick, + entities: visible.values().cloned().collect(), + }, + )); + if !player.traffic.allow(snapshot.encoded_len()) { + reply( + player, + error( + "TRAFFIC_LIMIT", + "Move the camera more slowly", + ), + ); + continue; + } + if !reply(player, snapshot) { + continue; + } + player.known = visible; + player.rect = Some(rect); + player.lod = s.lod.min(10); + } + Err(e) => { + reply(player, error("ENTITY_LIMIT", e)); + } + } + } else { + reply( + player, + error( + "AOI_LIMIT", + "Subscription must be at most 2000 by 2000 metres", + ), + ); + } + } + Some(client_message::Body::Ping(ping)) => { + reply(player, message(server_message::Body::Pong(ping))); + } + Some(client_message::Body::Propose(p)) => { + if ![0, 1, 2, 4].contains(&p.speed) + || ballot.is_some() + || player + .last_proposal + .is_some_and(|at| at.elapsed() < Duration::from_secs(30)) + { + reply( + player, + error( + "VOTE_LIMIT", + "Wait for the current vote or proposal cooldown", + ), + ); + } else { + player.last_proposal = Some(Instant::now()); + ballot = Some(Ballot { + speed: p.speed, + yes: HashSet::from([id.clone()]), + voted: HashSet::from([id]), + deadline: Instant::now() + Duration::from_secs(20), + }); + } + } + Some(client_message::Body::Vote(v)) => { + if let Some(b) = &mut ballot { + if Instant::now() < b.deadline + && b.voted.insert(id.clone()) + && v.yes + { + b.yes.insert(id); + } + } + } + _ => { + reply(player, error("PROTOCOL", "Unexpected message")); + } + } + } + } + } + } + if players.is_empty() && player_count.load(Ordering::Relaxed) > 0 { + empty_since = Instant::now(); + } + player_count.store(players.len(), Ordering::Relaxed); + if let Some(b) = &ballot { + if Instant::now() >= b.deadline { + ballot = None; + } else if b.yes.len() > players.len() / 2 { + sim.world.resource_mut::().speed = b.speed; + ballot = None; + } + } + if Instant::now() >= next_tick { + let begin = Instant::now(); + sim.observe( + &players + .values() + .filter_map(|p| p.rect.clone()) + .collect::>(), + ); + sim.step(); + let clock = sim.clock(); + // Fixed 10 Hz simulation, 5 Hz replication. + if clock.tick % 2 == 0 { + players.retain(|_, player| { + if player.out.is_closed() { + return false; + } + if let Some(rect) = &player.rect { + match sim.visible_lod(rect, config.max_visible, player.lod) { + Ok(visible) => { + let mut spawned = vec![]; + let mut changed = vec![]; + for (id, entity) in &visible { + match player.known.get(id) { + None => spawned.push(entity.clone()), + Some(old) if old != entity => changed.push(entity.clone()), + _ => {} + } + } + let despawned = player + .known + .keys() + .filter(|id| !visible.contains_key(id)) + .copied() + .collect(); + let delta = message(server_message::Body::Delta(p::Delta { + tick: clock.tick, + spawned, + changed, + despawned, + })); + // Deltas contain absolute changed values, so skipped + // frames can be coalesced without losing state. + if !player.traffic.allow(delta.encoded_len()) { + return true; + } + if !reply(player, delta) { + return false; + } + player.known = visible; + } + Err(e) => { + reply(player, error("ENTITY_LIMIT", e)); + player.rect = None; + player.known.clear(); + } + } + } + if clock.tick % 10 == 0 { + let b = ballot.as_ref(); + if !reply( + player, + message(server_message::Body::VoteState(p::VoteState { + active: b.is_some(), + speed: b.map_or(clock.speed, |b| b.speed), + yes: b.map_or(0, |b| b.yes.len() as u32), + players: player_count.load(Ordering::Relaxed) as u32, + remaining_seconds: b.map_or(0, |b| { + b.deadline + .saturating_duration_since(Instant::now()) + .as_secs() as u32 + }), + })), + ) { + return false; + } + if !reply( + player, + message(server_message::Body::Clock(p::Clock { + tick: clock.tick, + game_seconds: clock.seconds, + speed: clock.speed, + })), + ) { + return false; + } + } + true + }); + if let Some(b) = &mut ballot { + b.yes.retain(|id| players.contains_key(id)); + b.voted.retain(|id| players.contains_key(id)); + } + } + if begin.elapsed() > Duration::from_millis(50) { + eprintln!( + "room={} tick={} elapsed_ms={}", + sim.id, + clock.tick, + begin.elapsed().as_millis() + ); + } + next_tick += Duration::from_millis(100); + if next_tick < Instant::now() { + 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()) { + eprintln!("snapshot writer is busy or unavailable: {error}"); + } + saved_at = Instant::now(); + } + if players.is_empty() && empty_since.elapsed() >= config.empty_timeout { + stopping = true; + } + metrics.tick.store(clock.tick, Ordering::Relaxed); + metrics + .max_micros + .fetch_max(begin.elapsed().as_micros() as u64, Ordering::Relaxed); + metrics + .micros + .store(begin.elapsed().as_micros() as u64, Ordering::Relaxed); + metrics + .entities + .store(sim.world.entities().len() as u64, Ordering::Relaxed); + } + } + if save_tx.send(sim.checkpoint()).is_err() { + eprintln!("final snapshot writer is unavailable"); + } + drop(save_tx); + if save_thread.join().is_err() { + eprintln!("snapshot writer panicked"); + } + active.store(false, Ordering::Relaxed); + player_count.store(0, Ordering::Relaxed); +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn bounds_are_checked_without_integer_overflow() { + assert!(!valid_rect(&p::Rect { + min_x: i32::MIN, + min_y: 0, + max_x: i32::MAX, + max_y: 1 + })); + assert!(valid_rect(&p::Rect { + min_x: -100, + min_y: -100, + max_x: 100, + max_y: 100 + })); + } +} diff --git a/server/src/generation.rs b/server/src/generation.rs new file mode 100644 index 0000000..d629aea --- /dev/null +++ b/server/src/generation.rs @@ -0,0 +1,231 @@ +use crate::gateway::App; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, + time::Duration, +}; +use tokio::sync::Semaphore; + +#[derive(Clone, Deserialize)] +pub struct Request { + pub bbox: [f64; 4], + pub name: String, + #[serde(default = "default_seed")] + pub seed: u32, +} +fn default_seed() -> u32 { + 1 +} +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Job { + pub id: String, + pub city_id: String, + pub status: String, + pub room_id: Option, + pub error: Option, +} +#[derive(Clone)] +pub struct Jobs { + pub items: Arc>>, + workers: Arc, +} +impl Default for Jobs { + fn default() -> Self { + Self { + items: Arc::new(Mutex::new(HashMap::new())), + workers: Arc::new(Semaphore::new(2)), + } + } +} +pub fn normalize(request: &Request, max_km2: f64) -> Result<[f64; 4], String> { + let b = request.bbox.map(|n| (n * 100000.0).round() / 100000.0); + if b.iter().any(|n| !n.is_finite()) + || b[0] < -85.0 + || b[2] > 85.0 + || b[1] < -180.0 + || b[3] > 180.0 + || b[0] >= b[2] + || b[1] >= b[3] + { + return Err("Invalid bounding box".into()); + } + let area = + (b[2] - b[0]) * 111.32 * (b[3] - b[1]) * 111.32 * ((b[0] + b[2]) / 2.0).to_radians().cos(); + if area > max_km2 { + return Err(format!("Maximum generation area is {max_km2} km²")); + } + if request.name.trim().is_empty() || request.name.chars().count() > 80 { + return Err("City name must contain 1–80 characters".into()); + } + Ok(b) +} +pub fn cache_id(bbox: [f64; 4], seed: u32) -> String { + let text = format!("{:?}:{seed}:0.2.0:1", bbox); + let mut hash = 0xcbf29ce484222325u64; + for byte in text.bytes() { + hash ^= byte as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + format!("city-{hash:016x}") +} +impl Jobs { + pub fn new(workers: usize) -> Self { + Self { + items: Arc::new(Mutex::new(HashMap::new())), + workers: Arc::new(Semaphore::new(workers)), + } + } + pub fn submit(&self, app: App, mut request: Request) -> Result { + request.bbox = normalize(&request, app.config.max_generation_km2)?; + let city_id = cache_id(request.bbox, request.seed); + let mut items = self.items.lock().unwrap(); + if let Some(job) = items + .values() + .find(|j| j.city_id == city_id && j.status != "failed") + { + return Ok(job.clone()); + } + let pending = items + .values() + .filter(|j| j.status == "queued" || j.status == "running") + .count(); + if pending >= app.config.generation_queue || app.active() + pending >= app.config.max_rooms + { + return Err("Generation queue or room limit reached".into()); + } + if items.len() >= 1000 { + items.retain(|_, j| j.status == "running" || j.status == "queued"); + } + let job = Job { + id: uuid::Uuid::new_v4().simple().to_string(), + city_id, + status: "queued".into(), + room_id: None, + error: None, + }; + items.insert(job.id.clone(), job.clone()); + drop(items); + let jobs = self.clone(); + let task = job.clone(); + tokio::spawn(async move { + let result = jobs.run(app, request, &task).await; + let mut items = jobs.items.lock().unwrap(); + let current = items.get_mut(&task.id).unwrap(); + match result { + Ok(room) => { + current.status = "ready".into(); + current.room_id = Some(room); + } + Err(e) => { + current.status = "failed".into(); + current.error = Some(e); + } + } + }); + Ok(job) + } + async fn run(&self, app: App, request: Request, job: &Job) -> Result { + let _permit = self.workers.acquire().await.map_err(|e| e.to_string())?; + self.items.lock().unwrap().get_mut(&job.id).unwrap().status = "running".into(); + let output = + std::path::absolute(app.config.cities.join(&job.city_id)).map_err(|e| e.to_string())?; + if !output.join("manifest.json").exists() { + let jobs_dir = app.config.saves.join(".jobs"); + tokio::fs::create_dir_all(&jobs_dir) + .await + .map_err(|e| e.to_string())?; + let input = jobs_dir.join(format!("{}.json", job.id)); + let progress = jobs_dir.join(format!("{}.progress.json", job.id)); + let fixture = app + .config + .osm_fixture + .as_ref() + .map(std::path::absolute) + .transpose() + .map_err(|e| e.to_string())?; + let content = serde_json::json!({"bbox":request.bbox,"name":request.name,"seed":request.seed,"cityId":job.city_id,"output":output,"maxBuildings":app.config.max_buildings,"progress":std::path::absolute(&progress).map_err(|e|e.to_string())?,"input":fixture}); + tokio::fs::write(&input, serde_json::to_vec(&content).unwrap()) + .await + .map_err(|e| e.to_string())?; + let mut child = tokio::process::Command::new("node") + .current_dir(&app.config.project_root) + .args(["--import", "tsx", "tools/generate-worker.ts"]) + .arg(&input) + .kill_on_drop(true) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| e.to_string())?; + let mut stderr = child.stderr.take().unwrap(); + let reader = tokio::spawn(async move { + use tokio::io::AsyncReadExt; + let mut bytes = vec![]; + let mut buffer = [0u8; 4096]; + loop { + match stderr.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(n) => { + let remaining = 32768usize.saturating_sub(bytes.len()); + bytes.extend_from_slice(&buffer[..n.min(remaining)]); + } + } + } + String::from_utf8_lossy(&bytes).to_string() + }); + let status = tokio::time::timeout( + Duration::from_secs(app.config.generation_timeout), + child.wait(), + ) + .await + .map_err(|_| "City generation timed out".to_string())? + .map_err(|e| e.to_string())?; + let error = reader.await.unwrap_or_default(); + if !status.success() { + return Err(format!( + "City generation failed: {}", + error.chars().take(2000).collect::() + )); + } + } + let room = uuid::Uuid::new_v4().simple().to_string(); + let id = room.clone(); + let city = job.city_id.clone(); + tokio::task::spawn_blocking(move || app.start(id, city, request.seed as u64, false)) + .await + .map_err(|e| e.to_string())??; + Ok(room) + } +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn validates_area_and_stable_versioned_cache() { + let r = Request { + bbox: [47.0, 9.0, 47.001, 9.001], + name: "Test".into(), + seed: 1, + }; + let b = normalize(&r, 25.0).unwrap(); + assert_eq!(cache_id(b, 1), cache_id(b, 1)); + assert_ne!(cache_id(b, 1), cache_id(b, 2)); + assert!(normalize( + &Request { + bbox: [47.0, 9.0, 48.0, 10.0], + ..r.clone() + }, + 25.0 + ) + .is_err()); + assert!(normalize( + &Request { + bbox: [f64::NAN, 9.0, 47.001, 9.001], + ..r + }, + 25.0 + ) + .is_err()); + } +} diff --git a/server/src/lib.rs b/server/src/lib.rs new file mode 100644 index 0000000..5839c1f --- /dev/null +++ b/server/src/lib.rs @@ -0,0 +1,9 @@ +pub mod protocol { + include!(concat!(env!("OUT_DIR"), "/city.rs")); +} +pub mod city; +pub mod gateway; +pub mod generation; +pub mod navigation; +pub mod simulation; +pub mod storage; diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..057bec0 --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,65 @@ +use citygen_server::gateway::{self, App, Config}; +use std::time::Duration; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let mut config = Config::default(); + if let Ok(value) = std::env::var("CITYGEN_CITIES") { + config.cities = value.into(); + } + if let Ok(value) = std::env::var("CITYGEN_SAVES") { + config.saves = value.into(); + } + if let Ok(value) = std::env::var("CITYGEN_MAX_ROOMS") { + config.max_rooms = value.parse()?; + } + if let Ok(value) = std::env::var("CITYGEN_AGENTS") { + config.agents = value.parse()?; + } + if let Ok(value) = std::env::var("CITYGEN_MAX_VISIBLE") { + config.max_visible = value.parse()?; + } + if let Ok(value) = std::env::var("CITYGEN_EMPTY_TIMEOUT") { + config.empty_timeout = Duration::from_secs(value.parse()?); + } + if let Ok(value) = std::env::var("CITYGEN_SAVE_INTERVAL") { + config.save_interval = Duration::from_secs(value.parse()?); + } + if let Ok(value) = std::env::var("CITYGEN_KEEP_SNAPSHOTS") { + config.keep_snapshots = value.parse()?; + } + if let Ok(value) = std::env::var("CITYGEN_MAX_GENERATION_KM2") { + config.max_generation_km2 = value.parse()?; + } + if let Ok(value) = std::env::var("CITYGEN_MAX_BUILDINGS") { + config.max_buildings = value.parse()?; + } + if let Ok(value) = std::env::var("CITYGEN_GENERATION_TIMEOUT") { + config.generation_timeout = value.parse()?; + } + if let Ok(value) = std::env::var("CITYGEN_GENERATION_WORKERS") { + config.generation_workers = value.parse()?; + } + if let Ok(value) = std::env::var("CITYGEN_GENERATION_QUEUE") { + config.generation_queue = value.parse()?; + } + if let Ok(value) = std::env::var("CITYGEN_OSM_FIXTURE") { + config.osm_fixture = Some(value.into()); + } + 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}"); + 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; + let _ = tokio::task::spawn_blocking(move || shutdown_app.stop()).await; + }) + .await?; + app.stop(); + Ok(()) +} diff --git a/server/src/navigation.rs b/server/src/navigation.rs new file mode 100644 index 0000000..beacdb7 --- /dev/null +++ b/server/src/navigation.rs @@ -0,0 +1,285 @@ +//! Detailed floor navigation is loaded only for observed city tiles. The street +//! simulation uses the coarse road graph; grids are constructed on demand. +use crate::{city::Manifest, protocol::Rect}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{ + collections::{HashMap, VecDeque}, + io::Read, + path::PathBuf, +}; +type Point = [f64; 2]; +#[derive(Clone, Deserialize)] +pub struct Wall { + pub a: Point, + pub b: Point, + pub thickness: f64, +} +#[derive(Clone, Deserialize)] +pub struct Door { + pub id: String, + pub a: Point, + pub b: Point, + pub rooms: [String; 2], +} +#[derive(Clone, Deserialize)] +pub struct Stairs { + pub id: String, + pub from: Point, + pub to: Point, + pub up: bool, + pub down: bool, + #[serde(rename = "roomId")] + pub room_id: String, +} +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Prop { + pub pos: Point, + pub rot: f64, + pub w: f64, + pub d: f64, + pub blocks_move: bool, + #[serde(default)] + pub interactions: Vec, +} +#[derive(Clone, Deserialize)] +pub struct Floor { + pub level: i32, + pub walls: Vec, + pub doors: Vec, + pub stairs: Vec, + pub props: Vec, +} +#[derive(Clone, Deserialize)] +pub struct Building { + pub id: String, + pub footprint: Vec, + pub holes: Vec>, + pub floors: Vec, +} +#[derive(Deserialize)] +struct Tile { + #[serde(rename = "formatVersion")] + version: u32, + #[serde(rename = "cityId")] + city_id: String, + id: String, + buildings: Vec, +} +pub struct Cache { + root: PathBuf, + manifest: Manifest, + tiles: HashMap, + order: VecDeque, +} +impl Cache { + pub fn new(root: PathBuf, manifest: Manifest) -> Self { + Self { + root, + manifest, + tiles: HashMap::new(), + order: VecDeque::new(), + } + } + pub fn observe(&mut self, rect: &Rect) -> Result<(), String> { + let mut wanted = vec![]; + for tile in &self.manifest.tiles { + let b = &tile.bounds; + if b.min_x * 100.0 <= rect.max_x as f64 + && b.max_x * 100.0 >= rect.min_x as f64 + && b.min_y * 100.0 <= rect.max_y as f64 + && b.max_y * 100.0 >= rect.min_y as f64 + { + wanted.push((tile.id.clone(), tile.url.clone(), tile.sha256.clone())); + } + } + if wanted.len() > 128 { + return Err("Too many navigation tiles".into()); + } + for (id, url, checksum) in wanted { + if !self.tiles.contains_key(&id) { + let expected = format!("tiles/{id}.json.gz"); + if url != expected + || id + .bytes() + .any(|b| !b.is_ascii_digit() && b != b'-' && b != b'_') + { + return Err("Invalid navigation tile URL".into()); + } + let file = std::fs::File::open(self.root.join(url)).map_err(|e| e.to_string())?; + let mut bytes = vec![]; + flate2::read::GzDecoder::new(file) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if let Some(expected) = checksum { + if format!("{:x}", Sha256::digest(&bytes)) != expected { + return Err("Navigation tile checksum mismatch".into()); + } + } + let tile: Tile = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + if tile.version != 1 || tile.city_id != self.manifest.city.id || tile.id != id { + return Err("Invalid navigation tile identity".into()); + } + self.tiles.insert(id.clone(), tile); + } + self.order.retain(|key| key != &id); + self.order.push_back(id); + } + while self.order.len() > 128 { + if let Some(id) = self.order.pop_front() { + self.tiles.remove(&id); + } + } + Ok(()) + } + pub fn floor(&self, building: &str, level: i32) -> Option<(&Building, &Floor)> { + for tile in self.tiles.values() { + for b in &tile.buildings { + if b.id == building { + return b.floors.iter().find(|f| f.level == level).map(|f| (b, f)); + } + } + } + None + } +} +fn inside(p: Point, ring: &[Point]) -> bool { + if ring.is_empty() { + return false; + } + let mut hit = false; + let mut j = ring.len() - 1; + for i in 0..ring.len() { + let a = ring[i]; + let b = ring[j]; + if (a[1] > p[1]) != (b[1] > p[1]) + && p[0] < (b[0] - a[0]) * (p[1] - a[1]) / (b[1] - a[1]) + a[0] + { + hit = !hit; + } + j = i; + } + hit +} +fn segment_distance(p: Point, a: Point, b: Point) -> f64 { + let dx = b[0] - a[0]; + let dy = b[1] - a[1]; + let t = (((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / (dx * dx + dy * dy).max(1e-12)) + .clamp(0.0, 1.0); + (p[0] - a[0] - t * dx).hypot(p[1] - a[1] - t * dy) +} +pub fn walkable(building: &Building, floor: &Floor, p: Point) -> bool { + if !inside(p, &building.footprint) || building.holes.iter().any(|r| inside(p, r)) { + return false; + } + for prop in &floor.props { + if prop.blocks_move { + let dx = p[0] - prop.pos[0]; + let dy = p[1] - prop.pos[1]; + let x = dx * prop.rot.cos() + dy * prop.rot.sin(); + let y = -dx * prop.rot.sin() + dy * prop.rot.cos(); + if x.abs() < prop.w / 2.0 + 0.2 && y.abs() < prop.d / 2.0 + 0.2 { + return false; + } + } + } + for wall in &floor.walls { + if segment_distance(p, wall.a, wall.b) < wall.thickness / 2.0 + 0.2 { + if !floor.doors.iter().any(|d| { + segment_distance(p, d.a, d.b) < 0.35 + && segment_distance( + [(d.a[0] + d.b[0]) / 2.0, (d.a[1] + d.b[1]) / 2.0], + wall.a, + wall.b, + ) < 0.3 + }) { + return false; + } + } + } + true +} +/// Bounded half-metre grid search. Four neighbours avoid cutting wall corners. +pub fn path( + building: &Building, + floor: &Floor, + start: Point, + end: Point, + max_cells: usize, +) -> Option> { + let cell = |p: Point| [(p[0] * 2.0).floor() as i32, (p[1] * 2.0).floor() as i32]; + let point = |c: [i32; 2]| [c[0] as f64 / 2.0 + 0.25, c[1] as f64 / 2.0 + 0.25]; + let from = cell(start); + let goal = cell(end); + if !walkable(building, floor, point(from)) || !walkable(building, floor, point(goal)) { + return None; + } + let mut parents = HashMap::from([(from, from)]); + let mut queue = VecDeque::from([from]); + while let Some(at) = queue.pop_front() { + if at == goal { + let mut result = vec![point(at)]; + let mut at = at; + while at != from { + at = parents[&at]; + result.push(point(at)); + } + result.reverse(); + return Some(result); + } + for [dx, dy] in [[-1, 0], [1, 0], [0, -1], [0, 1]] { + let next = [at[0] + dx, at[1] + dy]; + if !parents.contains_key(&next) && walkable(building, floor, point(next)) { + parents.insert(next, at); + queue.push_back(next); + if parents.len() > max_cells { + return None; + } + } + } + } + None +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn walls_doors_and_furniture_block_grid_paths() { + let b = Building { + id: "b".into(), + footprint: vec![[0., 0.], [10., 0.], [10., 10.], [0., 10.]], + holes: vec![], + floors: vec![], + }; + let mut f = Floor { + level: 0, + walls: vec![Wall { + a: [5., 0.], + b: [5., 10.], + thickness: 0.2, + }], + doors: vec![], + stairs: vec![], + props: vec![], + }; + assert!(path(&b, &f, [1., 1.], [9., 1.], 10000).is_none()); + f.doors.push(Door { + id: "d".into(), + a: [5., 4.], + b: [5., 6.], + rooms: ["a".into(), "b".into()], + }); + let route = path(&b, &f, [1., 1.], [9., 1.], 10000).unwrap(); + assert!(route.iter().any(|p| p[1] >= 4.)); + f.props.push(Prop { + pos: [5., 5.], + rot: 0., + w: 2., + d: 3., + blocks_move: true, + interactions: vec![], + }); + assert!(path(&b, &f, [1., 1.], [9., 1.], 10000).is_none()); + } +} diff --git a/server/src/simulation.rs b/server/src/simulation.rs new file mode 100644 index 0000000..5438c41 --- /dev/null +++ b/server/src/simulation.rs @@ -0,0 +1,548 @@ +use crate::protocol::{Agent, RoomSave, Route, SavedAgent}; +use bevy_ecs::prelude::*; +use bevy_tasks::{ComputeTaskPool, TaskPoolBuilder}; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; + +#[derive(Component)] +pub struct Resident { + pub id: u32, + pub route: u32, +} +#[derive(Component, Default)] +pub struct Position { + pub x: i32, + pub y: i32, +} +#[derive(Resource, Clone)] +pub struct Clock { + pub tick: u64, + pub seconds: f64, + pub speed: u32, +} +#[derive(Resource)] +pub struct Routes(pub Vec>); +#[derive(Resource)] +pub struct Lod { + detailed: Vec, + force: bool, +} +#[derive(Resource)] +pub struct Goals(pub Vec<(String, String)>); +const CELL: i32 = 5000; +#[derive(Resource, Default)] +struct SpatialIndex { + cells: HashMap<(i32, i32), Vec>, + invalid: bool, +} +#[derive(Resource, Default)] +struct PositionChanges(bool); +fn sampled(id: u32, lod: u32) -> bool { + let mixed = id.wrapping_mul(0x9e3779b1); + ((mixed ^ (mixed >> 16)) & ((1u32 << lod.min(10)) - 1)) == 0 +} +pub struct Path { + pub points: Vec<[i32; 2]>, + pub lengths: Vec, + pub total: f64, + bounds: [i32; 4], +} +impl Path { + pub fn new(points: Vec<[i32; 2]>) -> Self { + let mut lengths = vec![0.0]; + for pair in points.windows(2) { + let length = ((pair[1][0] as f64 - pair[0][0] as f64).powi(2) + + (pair[1][1] as f64 - pair[0][1] as f64).powi(2)) + .sqrt(); + lengths.push(lengths.last().copied().unwrap() + length); + } + let total = *lengths.last().unwrap(); + let bounds = [ + points.iter().map(|p| p[0]).min().unwrap_or(0), + points.iter().map(|p| p[1]).min().unwrap_or(0), + points.iter().map(|p| p[0]).max().unwrap_or(0), + points.iter().map(|p| p[1]).max().unwrap_or(0), + ]; + Self { + points, + lengths, + total, + bounds, + } + } + pub fn position(&self, distance: f64) -> [i32; 2] { + if self.points.len() == 1 || distance <= 0.0 { + return self.points[0]; + } + if distance >= self.total { + return *self.points.last().unwrap(); + } + let index = self + .lengths + .partition_point(|n| *n <= distance) + .min(self.points.len() - 1); + let a = self.points[index - 1]; + let b = self.points[index]; + let t = (distance - self.lengths[index - 1]) + / (self.lengths[index] - self.lengths[index - 1]).max(0.001); + [ + (a[0] as f64 + (b[0] as f64 - a[0] as f64) * t).round() as i32, + (a[1] as f64 + (b[1] as f64 - a[1] as f64) * t).round() as i32, + ] + } +} +fn position(path: &Path, id: u32, seconds: f64) -> [i32; 2] { + let seconds = (seconds + (id % 600) as f64) % 86400.0; + let distance = if seconds < 8.0 * 3600.0 { + 0.0 + } else if seconds < 17.0 * 3600.0 { + ((seconds - 8.0 * 3600.0) * 140.0).min(path.total) + } else { + (path.total - (seconds - 17.0 * 3600.0) * 140.0).max(0.0) + }; + path.position(distance) +} +fn movement( + clock: Res, + routes: Res, + lod: Res, + mut changes: ResMut, + mut agents: Query<(&Resident, &mut Position)>, +) { + changes.0 = false; + let changed = AtomicBool::new(false); + agents.par_iter_mut().for_each(|(resident, mut p)| { + // Unobserved routes use a ten-second coarse cadence. Exact positions can + // always be reconstructed analytically on subscription or restoration. + if !lod.force && !lod.detailed[resident.route as usize] && clock.tick % 100 != 0 { + return; + } + let path = &routes.0[resident.route as usize]; + let [x, y] = position(path, resident.id, clock.seconds); + if p.x != x || p.y != y { + p.x = x; + p.y = y; + if !changed.load(Ordering::Relaxed) { + changed.store(true, Ordering::Relaxed); + } + } + }); + changes.0 = changed.load(Ordering::Relaxed); +} +fn index_positions( + lod: Res, + changes: Res, + agents: Query<(&Resident, &Position)>, + mut index: ResMut, +) { + if !index.invalid && !changes.0 { + return; + } + for cell in index.cells.values_mut() { + cell.clear(); + } + for (r, p) in &agents { + if lod.detailed[r.route as usize] { + index + .cells + .entry((p.x.div_euclid(CELL), p.y.div_euclid(CELL))) + .or_default() + .push(Agent { + id: r.id, + x: p.x, + y: p.y, + kind: 0, + }); + } + } + index.invalid = false; +} +pub struct Simulation { + pub world: World, + schedule: Schedule, + pub seed: u64, + pub id: String, + pub city_id: String, + pub city_revision: String, + route_index: Vec>, + population: Arc>, +} +pub struct Checkpoint { + metadata: RoomSave, + population: Arc>, +} +impl Checkpoint { + pub fn materialize(mut self) -> RoomSave { + self.metadata.agents = (*self.population).clone(); + self.metadata + } +} +impl Simulation { + pub fn new( + id: String, + city_id: String, + seed: u64, + count: u32, + paths: Vec>, + ) -> Result { + if paths.is_empty() || paths.iter().any(|p| p.len() < 2) { + return Err("No connected walking routes".into()); + } + ComputeTaskPool::get_or_init(|| { + let available = std::thread::available_parallelism().map_or(1, |n| n.get()); + let threads = std::env::var("CITYGEN_SIM_THREADS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(available.min(4)) + .clamp(1, available); + TaskPoolBuilder::new().num_threads(threads).build() + }); + let mut world = World::new(); + let route_count = paths.len() as u32; + world.insert_resource(Routes( + paths.into_iter().map(|p| Arc::new(Path::new(p))).collect(), + )); + world.insert_resource(Clock { + tick: 0, + seconds: 7.9 * 3600.0, + speed: 1, + }); + world.insert_resource(Lod { + detailed: vec![false; route_count as usize], + force: true, + }); + world.insert_resource(Goals(vec![ + (String::new(), String::new()); + route_count as usize + ])); + world.insert_resource(SpatialIndex { + invalid: true, + ..Default::default() + }); + world.insert_resource(PositionChanges::default()); + world.spawn_batch((0..count).map(|i| { + ( + Resident { + id: i + 1, + route: i % route_count, + }, + Position::default(), + ) + })); + let mut schedule = Schedule::default(); + schedule.add_systems((movement, index_positions).chain()); + let mut route_index = vec![vec![]; route_count as usize]; + for r in world.query::<&Resident>().iter(&world) { + route_index[r.route as usize].push(r.id); + } + let mut s = Self { + world, + schedule, + seed, + id, + city_id, + city_revision: String::new(), + route_index, + population: Arc::new( + (0..count) + .map(|i| SavedAgent { + id: i + 1, + route: i % route_count, + distance: 0.0, + }) + .collect(), + ), + }; + s.schedule.run(&mut s.world); + s.world.resource_mut::().force = false; + Ok(s) + } + pub fn step(&mut self) { + { + let mut clock = self.world.resource_mut::(); + clock.tick += 1; + clock.seconds += clock.speed as f64 * 6.0; + } + self.schedule.run(&mut self.world); + } + pub fn clock(&self) -> Clock { + self.world.resource::().clone() + } + pub fn goals(&mut self, goals: Vec<(String, String)>) { + self.world.insert_resource(Goals(goals)); + } + pub fn observe(&mut self, rects: &[crate::protocol::Rect]) { + let routes = self.world.resource::(); + let detailed: Vec = routes + .0 + .iter() + .map(|path| { + rects.iter().any(|r| { + path.bounds[0] <= r.max_x + && path.bounds[2] >= r.min_x + && path.bounds[1] <= r.max_y + && path.bounds[3] >= r.min_y + }) + }) + .collect(); + if self.world.resource::().detailed != detailed { + self.world.resource_mut::().detailed = detailed; + self.world.resource_mut::().invalid = true; + } + } + pub fn visible( + &mut self, + rect: &crate::protocol::Rect, + limit: usize, + ) -> Result, String> { + self.visible_fresh_lod(rect, limit, 0) + } + pub fn visible_lod( + &mut self, + rect: &crate::protocol::Rect, + limit: usize, + lod: u32, + ) -> Result, String> { + let index = self.world.resource::(); + let mut result = HashMap::new(); + for y in rect.min_y.div_euclid(CELL)..=rect.max_y.div_euclid(CELL) { + for x in rect.min_x.div_euclid(CELL)..=rect.max_x.div_euclid(CELL) { + if let Some(agents) = index.cells.get(&(x, y)) { + for a in agents { + if sampled(a.id, lod) + && a.x >= rect.min_x + && a.x <= rect.max_x + && a.y >= rect.min_y + && a.y <= rect.max_y + { + if result.len() == limit { + return Err("Too many visible residents; increase LOD".into()); + } + result.insert(a.id, a.clone()); + } + } + } + } + } + Ok(result) + } + /// Subscription snapshots also cover newly observed routes whose coarse + /// ECS positions have not yet been refreshed by the next fixed tick. + pub fn visible_fresh_lod( + &mut self, + rect: &crate::protocol::Rect, + limit: usize, + lod: u32, + ) -> Result, String> { + let mut result = HashMap::new(); + let clock = self.clock(); + let routes = self.world.resource::(); + for (index, path) in routes.0.iter().enumerate() { + if path.bounds[0] > rect.max_x + || path.bounds[2] < rect.min_x + || path.bounds[1] > rect.max_y + || path.bounds[3] < rect.min_y + { + continue; + } + for &id in &self.route_index[index] { + if !sampled(id, lod) { + continue; + } + let [x, y] = position(path, id, clock.seconds); + if x >= rect.min_x && x <= rect.max_x && y >= rect.min_y && y <= rect.max_y { + if result.len() == limit { + return Err("Too many visible residents; reduce subscription area".into()); + } + result.insert(id, Agent { id, x, y, kind: 0 }); + } + } + } + Ok(result) + } + pub fn save(&mut self) -> RoomSave { + self.checkpoint().materialize() + } + /// The commuter population is immutable in this simulation version. Share + /// its roster with the writer; copying/encoding a million records stays + /// outside the fixed room tick. Future spawning systems must update it. + pub fn checkpoint(&self) -> Checkpoint { + let clock = self.clock(); + let goals = &self.world.resource::().0; + let routes = self + .world + .resource::() + .0 + .iter() + .enumerate() + .map(|(i, p)| Route { + xy: p.points.iter().flatten().copied().collect(), + home_id: goals[i].0.clone(), + workplace_id: goals[i].1.clone(), + }) + .collect(); + let metadata = RoomSave { + version: 1, + id: self.id.clone(), + city_id: self.city_id.clone(), + city_revision: self.city_revision.clone(), + city_version: 1, + tick: clock.tick, + seed: self.seed, + game_seconds: clock.seconds, + speed: clock.speed, + agents: vec![], + routes, + }; + Checkpoint { + metadata, + population: self.population.clone(), + } + } + pub fn restore(saved: RoomSave) -> Result { + if saved.version != 1 || saved.city_version != 1 { + return Err("Incompatible room snapshot".into()); + } + if !saved.game_seconds.is_finite() || ![0, 1, 2, 4].contains(&saved.speed) { + return Err("Invalid room clock".into()); + } + let mut ids = std::collections::HashSet::new(); + if saved + .agents + .iter() + .any(|a| a.id == 0 || a.route as usize >= saved.routes.len() || !ids.insert(a.id)) + { + return Err("Invalid saved resident".into()); + } + let mut paths = vec![]; + for route in &saved.routes { + if route.xy.len() < 4 || route.xy.len() % 2 != 0 { + return Err("Invalid saved route".into()); + } + 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)?; + s.goals( + saved + .routes + .iter() + .map(|r| (r.home_id.clone(), r.workplace_id.clone())) + .collect(), + ); + s.city_revision = saved.city_revision; + let mut population = saved.agents.clone(); + population.sort_by_key(|a| a.id); + s.population = Arc::new(population); + s.world.spawn_batch(saved.agents.into_iter().map(|a| { + ( + Resident { + id: a.id, + route: a.route, + }, + Position::default(), + ) + })); + s.world.insert_resource(Clock { + tick: saved.tick, + seconds: saved.game_seconds, + speed: saved.speed, + }); + s.route_index = vec![vec![]; s.world.resource::().0.len()]; + for r in s.world.query::<&Resident>().iter(&s.world) { + s.route_index[r.route as usize].push(r.id); + } + s.world.resource_mut::().force = true; + s.schedule.run(&mut s.world); + s.world.resource_mut::().force = false; + Ok(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn spatial_interest_matches_exact_positions_after_camera_moves() { + let mut sim = Simulation::new( + "r".into(), + "c".into(), + 1, + 10000, + vec![ + vec![[-20000, -20000], [0, 0], [20000, 0]], + vec![[100000, 100000], [110000, 100000]], + ], + ) + .unwrap(); + let first = crate::protocol::Rect { + min_x: -21000, + min_y: -21000, + max_x: 21000, + max_y: 21000, + }; + let second = crate::protocol::Rect { + min_x: 99000, + min_y: 99000, + max_x: 111000, + max_y: 101000, + }; + for rect in [first, second] { + sim.observe(&[rect.clone()]); + for _ in 0..15 { + sim.step(); + for lod in 0..4 { + assert_eq!( + sim.visible_lod(&rect, 10000, lod).unwrap(), + sim.visible_fresh_lod(&rect, 10000, lod).unwrap() + ); + } + } + } + } + #[test] + fn save_restore_is_deterministic() { + let mut a = Simulation::new( + "room".into(), + "city".into(), + 1, + 1000, + vec![vec![[0, 0], [10000, 0], [10000, 20000]]], + ) + .unwrap(); + for _ in 0..100 { + a.step(); + } + let saved = a.save(); + let mut b = Simulation::restore(saved.clone()).unwrap(); + assert_eq!(saved, b.save()); + for _ in 0..100 { + a.step(); + b.step(); + } + assert_eq!(a.save(), b.save()); + let rect = crate::protocol::Rect { + min_x: -1, + min_y: -1, + max_x: 30000, + max_y: 30000, + }; + assert_eq!( + a.visible(&rect, 1000).unwrap(), + b.visible(&rect, 1000).unwrap() + ); + } + #[test] + fn paused_world_does_not_move() { + let mut a = + Simulation::new("r".into(), "c".into(), 3, 10, vec![vec![[0, 0], [100, 0]]]).unwrap(); + a.world.resource_mut::().speed = 0; + let seconds = a.clock().seconds; + a.step(); + assert_eq!(a.clock().seconds, seconds); + } +} diff --git a/server/src/storage.rs b/server/src/storage.rs new file mode 100644 index 0000000..e1e3f6a --- /dev/null +++ b/server/src/storage.rs @@ -0,0 +1,94 @@ +use crate::{city::safe_id, protocol::RoomSave}; +use prost::Message; +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, +}; + +pub fn save(root: &Path, snapshot: &RoomSave, keep: usize) -> Result<(), String> { + 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 name = format!("{:020}.pb", snapshot.tick); + let file = folder.join(&name); + // Replace through a complete temporary file, including control changes that + // happened between two ticks. Never truncate the last committed snapshot. + { + 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.sync_all().map_err(|e| e.to_string())?; + drop(output); + fs::rename(temporary, file).map_err(|e| e.to_string())?; + } + let mut files = snapshots(&folder)?; + files.sort(); + let excess = files.len().saturating_sub(keep.max(1)); + for path in files.into_iter().take(excess) { + fs::remove_file(path).map_err(|e| e.to_string())?; + } + Ok(()) +} +fn snapshots(folder: &Path) -> Result, String> { + Ok(fs::read_dir(folder) + .map_err(|e| e.to_string())? + .filter_map(Result::ok) + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "pb")) + .collect()) +} +pub fn load(root: &Path, id: &str) -> Result { + if !safe_id(id) { + return Err("Invalid room ID".into()); + } + let mut files = snapshots(&root.join(id))?; + files.sort(); + files.reverse(); + // Try an older intact file if the latest is damaged. + for file in files { + 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 { + return Err("Incompatible room snapshot".into()); + } + return Ok(snapshot); + } + } + } + Err("No compatible room snapshot".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn atomic_save_rotation_and_corrupt_fallback() { + let dir = tempfile::tempdir().unwrap(); + let mut s = RoomSave { + version: 1, + city_version: 1, + id: "r".into(), + ..Default::default() + }; + for tick in 0..5 { + s.tick = tick; + save(dir.path(), &s, 3).unwrap(); + } + assert_eq!(snapshots(&dir.path().join("r")).unwrap().len(), 3); + assert_eq!(load(dir.path(), "r").unwrap().tick, 4); + s.speed = 4; + save(dir.path(), &s, 3).unwrap(); + assert_eq!(load(dir.path(), "r").unwrap().speed, 4); + fs::write(dir.path().join("r/00000000000000000004.pb"), [255, 255]).unwrap(); + assert_eq!(load(dir.path(), "r").unwrap().tick, 3); + assert!(load(dir.path(), "../secret").is_err()); + } +} diff --git a/server/tests/generation.rs b/server/tests/generation.rs new file mode 100644 index 0000000..4e69a2b --- /dev/null +++ b/server/tests/generation.rs @@ -0,0 +1,121 @@ +use citygen_server::{ + gateway::{router, App, Config}, + generation::{cache_id, Request}, + protocol::{CityJob, CreateCity}, +}; +use prost::Message; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +async fn request( + address: std::net::SocketAddr, + method: &str, + path: &str, + body: &[u8], +) -> (u16, Vec) { + let mut stream = tokio::net::TcpStream::connect(address).await.unwrap(); + stream.write_all(format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/x-protobuf\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",body.len()).as_bytes()).await.unwrap(); + stream.write_all(body).await.unwrap(); + let mut bytes = vec![]; + stream.read_to_end(&mut bytes).await.unwrap(); + let split = bytes.windows(4).position(|b| b == b"\r\n\r\n").unwrap(); + let status = std::str::from_utf8(&bytes[..split]) + .unwrap() + .split_whitespace() + .nth(1) + .unwrap() + .parse() + .unwrap(); + (status, bytes[split + 4..].to_vec()) +} +#[tokio::test] +async fn isolated_typescript_worker_exports_city_and_opens_room_without_network() { + let dir = tempfile::tempdir().unwrap(); + let fixture = dir.path().join("osm.json"); + std::fs::write(&fixture,r#"{"elements":[{"type":"way","id":1,"tags":{"highway":"residential"},"geometry":[{"lat":47.001,"lon":9.0},{"lat":47.001,"lon":9.004}]},{"type":"way","id":2,"tags":{"building":"house","building:levels":"1"},"geometry":[{"lat":47.0011,"lon":9.001},{"lat":47.0011,"lon":9.0012},{"lat":47.0013,"lon":9.0012},{"lat":47.0013,"lon":9.001},{"lat":47.0011,"lon":9.001}]},{"type":"way","id":3,"tags":{"building":"industrial","building:levels":"1"},"geometry":[{"lat":47.0006,"lon":9.002},{"lat":47.0006,"lon":9.0023},{"lat":47.0009,"lon":9.0023},{"lat":47.0009,"lon":9.002},{"lat":47.0006,"lon":9.002}]}]}"#).unwrap(); + let app = App::new(Config { + cities: dir.path().join("cities"), + saves: dir.path().join("rooms"), + osm_fixture: Some(fixture), + project_root: std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_owned(), + ..Default::default() + }) + .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let serve_app = app.clone(); + let server = tokio::spawn(async move { + axum::serve( + listener, + router(serve_app).into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + let bbox = [47., 9., 47.002, 9.004]; + let message = CreateCity { + bbox: bbox.to_vec(), + name: "Test town".into(), + seed: 1, + }; + let (status, body) = request( + address, + "POST", + "/api/cities/jobs", + &message.encode_to_vec(), + ) + .await; + assert_eq!(status, 200, "{}", String::from_utf8_lossy(&body)); + let job = CityJob::decode(&body[..]).unwrap(); + assert_eq!(job.city_id, cache_id(bbox, 1)); + let ready = tokio::time::timeout(Duration::from_secs(30), async { + loop { + let (status, body) = + request(address, "GET", &format!("/api/cities/jobs/{}", job.id), &[]).await; + assert_eq!(status, 200); + let state = CityJob::decode(&body[..]).unwrap(); + assert_ne!(state.status, "failed", "{}", state.error); + if state.status == "ready" { + break state; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await + .unwrap(); + assert!(!ready.room_id.is_empty()); + let city = citygen_server::city::City::load(&app.config.cities, &ready.city_id).unwrap(); + assert!(!city.graph.points.is_empty()); + assert!(city.manifest.navigation.is_some()); + let duplicate = app + .jobs + .submit( + app.clone(), + Request { + bbox: [47.0000001, 9., 47.002, 9.004], + name: "Same area".into(), + seed: 1, + }, + ) + .unwrap(); + assert_eq!(duplicate.id, job.id); + let (status, _) = request( + address, + "POST", + "/api/cities/jobs", + &message.encode_to_vec(), + ) + .await; + assert_eq!(status, 429); + app.stop(); + server.abort(); + assert_eq!( + citygen_server::storage::load(&app.config.saves, &ready.room_id) + .unwrap() + .city_id, + ready.city_id + ); +} diff --git a/server/tests/websocket.rs b/server/tests/websocket.rs new file mode 100644 index 0000000..eb9069b --- /dev/null +++ b/server/tests/websocket.rs @@ -0,0 +1,255 @@ +use citygen_server::{ + gateway::{router, App, Config}, + protocol::{self as p, client_message, server_message}, +}; +use futures_util::{SinkExt, StreamExt}; +use prost::Message; +use std::{io::Write, time::Duration}; + +fn fixture(dir: &std::path::Path) { + let folder = dir.join("cities/demo/tiles"); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(dir.join("cities/demo/manifest.json"),r#"{"formatVersion":1,"city":{"id":"demo","name":"Demo","bounds":{"minX":0,"minY":0,"maxX":100,"maxY":100}},"tiles":[{"id":"0_0","url":"tiles/0_0.json.gz","bounds":{"minX":0,"minY":0,"maxX":256,"maxY":256}}]}"#).unwrap(); + let file = std::fs::File::create(folder.join("0_0.json.gz")).unwrap(); + let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); + encoder.write_all(br#"{"formatVersion":1,"cityId":"demo","id":"0_0","buildings":[],"roads":[{"id":"road","kind":"residential","pts":[[0,0],[100,0],[100,100]]}]}"#).unwrap(); + encoder.finish().unwrap(); +} +async fn read( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> p::ServerMessage { + loop { + let frame = tokio::time::timeout(Duration::from_secs(5), socket.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + if let tokio_tungstenite::tungstenite::Message::Binary(bytes) = frame { + return p::ServerMessage::decode(&bytes[..]).unwrap(); + } + } +} +#[tokio::test] +async fn thirty_observers_snapshot_delta_vote_and_restart() { + let dir = tempfile::tempdir().unwrap(); + fixture(dir.path()); + let app = App::new(Config { + cities: dir.path().join("cities"), + saves: dir.path().join("rooms"), + ..Default::default() + }) + .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let serve_app = app.clone(); + let server = tokio::spawn(async move { + axum::serve( + listener, + router(serve_app).into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + // Minimal HTTP request avoids adding a second HTTP client dependency. + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut http = tokio::net::TcpStream::connect(address).await.unwrap(); + let body = r#"{"cityId":"demo","seed":1}"#; + http.write_all(format!("POST /api/rooms HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).as_bytes()).await.unwrap(); + let mut response = String::new(); + http.read_to_string(&mut response).await.unwrap(); + assert!(response.starts_with("HTTP/1.1 200"), "{response}"); + 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(); + let mut sockets = vec![]; + for i in 0..30 { + let (mut socket, _) = + tokio_tungstenite::connect_async(format!("ws://{address}/api/rooms/{id}/ws")) + .await + .unwrap(); + let join = p::ClientMessage { + body: Some(client_message::Body::Join(p::Join { + room: id.clone(), + client_version: 1, + nickname: format!("Player{i}"), + })), + }; + socket + .send(tokio_tungstenite::tungstenite::Message::Binary( + join.encode_to_vec().into(), + )) + .await + .unwrap(); + assert!(matches!( + read(&mut socket).await.body, + Some(server_message::Body::Welcome(_)) + )); + sockets.push(socket); + } + let s = &mut sockets[0]; + let subscribe = p::ClientMessage { + body: Some(client_message::Body::Subscribe(p::Subscribe { + rect: Some(p::Rect { + min_x: -100, + min_y: -100, + max_x: 20000, + max_y: 20000, + }), + lod: 0, + })), + }; + s.send(tokio_tungstenite::tungstenite::Message::Binary( + subscribe.encode_to_vec().into(), + )) + .await + .unwrap(); + loop { + if let Some(server_message::Body::Snapshot(snapshot)) = read(s).await.body { + assert_eq!(snapshot.entities.len(), 1000); + break; + } + } + loop { + if matches!(read(s).await.body, Some(server_message::Body::Delta(_))) { + break; + } + } + // 31st observer must be rejected without affecting existing players. + let (mut extra, _) = + tokio_tungstenite::connect_async(format!("ws://{address}/api/rooms/{id}/ws")) + .await + .unwrap(); + extra + .send(tokio_tungstenite::tungstenite::Message::Binary( + p::ClientMessage { + body: Some(client_message::Body::Join(p::Join { + room: id.clone(), + client_version: 1, + nickname: "extra".into(), + })), + } + .encode_to_vec() + .into(), + )) + .await + .unwrap(); + assert!( + matches!(read(&mut extra).await.body,Some(server_message::Body::Error(e)) if e.code=="ROOM_FULL") + ); + let proposal = p::ClientMessage { + body: Some(client_message::Body::Propose(p::Propose { speed: 0 })), + }; + sockets[0] + .send(tokio_tungstenite::tungstenite::Message::Binary( + proposal.encode_to_vec().into(), + )) + .await + .unwrap(); + let yes = p::ClientMessage { + body: Some(client_message::Body::Vote(p::Vote { yes: true })), + } + .encode_to_vec(); + for socket in sockets.iter_mut().take(15).skip(1) { + socket + .send(tokio_tungstenite::tungstenite::Message::Binary( + yes.clone().into(), + )) + .await + .unwrap(); + } + loop { + if let Some(server_message::Body::Clock(clock)) = read(&mut sockets[0]).await.body { + assert_eq!(clock.speed, 1, "15 of 30 is not a majority"); + break; + } + } + sockets[15] + .send(tokio_tungstenite::tungstenite::Message::Binary(yes.into())) + .await + .unwrap(); + loop { + if let Some(server_message::Body::Clock(clock)) = read(&mut sockets[0]).await.body { + if clock.speed == 0 { + break; + } + } + } + for socket in &mut sockets { + let _ = socket.close(None).await; + } + app.stop(); + server.abort(); + let saved = citygen_server::storage::load(&dir.path().join("rooms"), &id).unwrap(); + assert_eq!(saved.agents.len(), 1000); + assert_eq!(saved.speed, 0); + assert!(!saved.city_revision.is_empty()); + let mut expected = citygen_server::simulation::Simulation::restore(saved.clone()).unwrap(); + let expected_entities = expected + .visible( + &p::Rect { + min_x: -100, + min_y: -100, + max_x: 20000, + max_y: 20000, + }, + 1000, + ) + .unwrap(); + let restarted = App::new(Config { + cities: dir.path().join("cities"), + saves: dir.path().join("rooms"), + ..Default::default() + }) + .unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let serve_app = restarted.clone(); + let server = tokio::spawn(async move { + axum::serve( + listener, + router(serve_app).into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + let (mut socket, _) = + tokio_tungstenite::connect_async(format!("ws://{address}/api/rooms/{id}/ws")) + .await + .unwrap(); + socket + .send(tokio_tungstenite::tungstenite::Message::Binary( + p::ClientMessage { + body: Some(client_message::Body::Join(p::Join { + room: id.clone(), + client_version: 1, + nickname: "Restored".into(), + })), + } + .encode_to_vec() + .into(), + )) + .await + .unwrap(); + assert!( + matches!(read(&mut socket).await.body,Some(server_message::Body::Welcome(w)) if w.tick>=saved.tick) + ); + socket + .send(tokio_tungstenite::tungstenite::Message::Binary( + subscribe.encode_to_vec().into(), + )) + .await + .unwrap(); + loop { + if let Some(server_message::Body::Snapshot(snapshot)) = read(&mut socket).await.body { + let entities: std::collections::HashMap<_, _> = + snapshot.entities.into_iter().map(|a| (a.id, a)).collect(); + assert_eq!(entities, expected_entities); + break; + } + } + let _ = socket.close(None).await; + restarted.stop(); + server.abort(); +} diff --git a/src/city/format.ts b/src/city/format.ts new file mode 100644 index 0000000..8f758cb --- /dev/null +++ b/src/city/format.ts @@ -0,0 +1,136 @@ +import type { Area, Building, Prop, Road, Vec, World } from '../types'; + +export const CITY_FORMAT_VERSION = 1; +export const GENERATOR_VERSION = '0.2.0'; +export type Bounds = World['bounds']; +export type CityMetadata = Omit; +export interface TileRef { id: string; bounds: Bounds; url: string; sha256?: string } +export interface CityManifest { + formatVersion: 1; + generatorVersion: string; + tileSize: number; + city: CityMetadata; + tiles: TileRef[]; + navigation?: { url: 'navigation.json.gz'; sha256: string }; + areaOrder?: string[]; + counts: { buildings: number; roads: number; areas: number; props: number; maxLevels: number }; +} +export interface CityTile { + formatVersion: 1; + cityId: string; + id: string; + areas: Area[]; + roads: Road[]; + buildings: Building[]; + props: Prop[]; + models: World['models']; +} +export function intersects(a: Bounds, b: Bounds): boolean { + return a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY; +} +export function validBounds(value: unknown): value is Bounds { + const b = value as Bounds; + return !!b && [b.minX, b.minY, b.maxX, b.maxY].every(Number.isFinite) && b.minX < b.maxX && b.minY < b.maxY; +} +export function pointsBounds(points: Vec[], margin = 0): Bounds { + if (!points.length || points.some(p => p.length !== 2 || !p.every(Number.isFinite))) throw new Error('Invalid geometry'); + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const [x, y] of points) { minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } + return { minX: minX - margin, minY: minY - margin, maxX: maxX + margin, maxY: maxY + margin }; +} +export function parseManifest(value: unknown): CityManifest { + const m = value as CityManifest; + if (!m || m.formatVersion !== CITY_FORMAT_VERSION) throw new Error('Unsupported city format version'); + if (!m.city || m.city.version !== 1 || typeof m.city.id !== 'string' || !validBounds(m.city.bounds) || + !Number.isFinite(m.tileSize) || m.tileSize < 1 || !Array.isArray(m.tiles) || + typeof m.generatorVersion !== 'string' || !m.counts || !Number.isInteger(m.counts.maxLevels) || m.counts.maxLevels < 1) + throw new Error('Invalid city manifest'); + const ids = new Set(); + if (m.navigation && (m.navigation.url !== 'navigation.json.gz' || !/^[a-f0-9]{64}$/.test(m.navigation.sha256))) throw new Error('Invalid navigation reference'); + if (m.areaOrder && (!Array.isArray(m.areaOrder) || m.areaOrder.some(id=>typeof id!=='string'))) throw new Error('Invalid area order'); + for (const t of m.tiles) { + if (!t || typeof t.id !== 'string' || ids.has(t.id) || !validBounds(t.bounds) || + typeof t.url !== 'string' || !/^tiles\/-?\d+_-?\d+\.json(?:\.gz)?$/.test(t.url) || (t.sha256 !== undefined && !/^[a-f0-9]{64}$/.test(t.sha256))) throw new Error('Invalid tile reference'); + ids.add(t.id); + } + return m; +} +export function parseTile(value: unknown, cityId: string, id: string): CityTile { + const t = value as CityTile; + if (!t || t.formatVersion !== CITY_FORMAT_VERSION || t.cityId !== cityId || t.id !== id || + !['areas', 'roads', 'buildings', 'props'].every(k => Array.isArray((t as any)[k])) || !t.models || typeof t.models !== 'object') + throw new Error(`Invalid city tile: ${id}`); + for (const group of [t.areas, t.roads, t.buildings, t.props]) { + const ids = new Set(); + for (const item of group) { + if (!item || typeof item.id !== 'string' || ids.has(item.id)) throw new Error(`Invalid entity ID in tile: ${id}`); + ids.add(item.id); + } + } + return t; +} + +/** Objects crossing a boundary are repeated whole, then deduplicated by ID on load. + * Keeping buildings whole preserves doors, stairs, unit references and interiors. */ +export function tileWorld(world: World, tileSize = 256): { manifest: CityManifest; tiles: Map } { + if (world.version !== 1 || !validBounds(world.bounds) || !Number.isFinite(tileSize) || tileSize < 1) throw new Error('Invalid world or tile size'); + const tiles = new Map(); + const included = { areas: new Set(), roads: new Set(), buildings: new Set(), props: new Set() }; + function add(key: 'areas' | 'roads' | 'buildings' | 'props', item: Area | Road | Building | Prop, b: Bounds) { + // OSM ways and land-use rings often extend kilometres outside the selected + // bbox. Keep the whole object, but only index tiles inside the selected city. + if (!intersects(b, world.bounds)) return; + b = { minX: Math.max(b.minX,world.bounds.minX), minY: Math.max(b.minY,world.bounds.minY), maxX: Math.min(b.maxX,world.bounds.maxX), maxY: Math.min(b.maxY,world.bounds.maxY) }; + if (included[key].has(item.id)) throw new Error(`Duplicate ${key} ID: ${item.id}`); + included[key].add(item.id); + const cells = (Math.floor(b.maxX / tileSize) - Math.floor(b.minX / tileSize) + 1) * (Math.floor(b.maxY / tileSize) - Math.floor(b.minY / tileSize) + 1); + if (cells > 100_000) throw new Error('Geometry spans too many tiles'); + for (let y = Math.floor(b.minY / tileSize); y <= Math.floor(b.maxY / tileSize); y++) { + for (let x = Math.floor(b.minX / tileSize); x <= Math.floor(b.maxX / tileSize); x++) { + const id = `${x}_${y}`; + let t = tiles.get(id); + if (!t) { t = { formatVersion: 1, cityId: world.id, id, areas: [], roads: [], buildings: [], props: [], models: {} }; tiles.set(id, t); } + (t[key] as (Area | Road | Building | Prop)[]).push(item); + } + } + } + for (const a of world.areas) add('areas', a, pointsBounds(a.rings.flat())); + for (const r of world.roads) add('roads', r, pointsBounds(r.pts, r.width / 2 + r.sidewalk)); + for (const b of world.buildings) add('buildings', b, pointsBounds(b.footprint)); + for (const p of world.props) add('props', p, pointsBounds([p.pos], Math.hypot(p.w, p.d) / 2)); + for (const t of tiles.values()) { + const props = [...t.props, ...t.buildings.flatMap(b => b.floors.flatMap(f => f.props))]; + for (const p of props) if (p.model) { + if (!Object.hasOwn(world.models, p.model)) throw new Error(`Missing model: ${p.model}`); + Object.defineProperty(t.models, p.model, { value: world.models[p.model], enumerable: true, configurable: true }); + } + } + const { areas, roads, buildings, props, models, ...city } = world; + const manifest: CityManifest = { formatVersion: 1, generatorVersion: GENERATOR_VERSION, tileSize, city, + areaOrder:areas.map(a=>a.id), + counts: { areas: included.areas.size, roads: included.roads.size, buildings: included.buildings.size, props: included.props.size, maxLevels: buildings.reduce((n, b) => Math.max(n, b.levels), 1) }, + tiles: [...tiles.keys()].sort().map(id => { + const [x, y] = id.split('_').map(Number); + return { id, url: `tiles/${id}.json.gz`, bounds: { minX: x * tileSize, minY: y * tileSize, maxX: (x + 1) * tileSize, maxY: (y + 1) * tileSize } }; + }) }; + return { manifest, tiles }; +} + +export function assembleWorld(manifest: CityManifest, tiles: Iterable): World { + const world: World = { ...manifest.city, areas: [], roads: [], buildings: [], props: [], models: Object.create(null) }; + const seen = { areas: new Set(), roads: new Set(), buildings: new Set(), props: new Set() }; + for (const tile of tiles) { + for (const key of ['areas', 'roads', 'buildings', 'props'] as const) { + for (const item of tile[key]) if (!seen[key].has(item.id)) { + seen[key].add(item.id); + (world[key] as (Area | Road | Building | Prop)[]).push(item); + } + } + Object.assign(world.models, tile.models); + } + if (manifest.areaOrder) { + const order=new Map(manifest.areaOrder.map((id,i)=>[id,i])); + world.areas.sort((a,b)=>(order.get(a.id) ?? Infinity)-(order.get(b.id) ?? Infinity)); + } + return world; +} diff --git a/src/gen/city.ts b/src/gen/city.ts index 753c47e..70830e0 100644 --- a/src/gen/city.ts +++ b/src/gen/city.ts @@ -12,6 +12,7 @@ import { Rng, hashString } from '../util/rng'; import { SpatialGrid } from '../util/spatial'; import { classify, clampLevels, defaultLevels, parseLevels } from './classify'; import { carShapes, CAR_COLORS, treeProp } from './props'; +import { populateStreets } from './streets'; export interface InteriorJob { building: Building; @@ -379,6 +380,7 @@ export function buildCity(elements: OsmElement[], bbox: BBox, name: string, seed models: {}, attribution: '© OpenStreetMap contributors (ODbL)', }; + populateStreets(world, elements); return { world, jobs }; } diff --git a/src/gen/finalize.ts b/src/gen/finalize.ts index 50af618..0ee85af 100644 --- a/src/gen/finalize.ts +++ b/src/gen/finalize.ts @@ -1,6 +1,7 @@ // Финальная упаковка мира: общие модели предметов и округление координат до сантиметра. import type { Building, Prop, Shape, World } from '../types'; +import { attachInteractions } from './objects'; const r2 = (v: number) => Math.round(v * 100) / 100; const r3 = (v: number) => Math.round(v * 1000) / 1000; @@ -44,6 +45,7 @@ function roundProp(p: Prop) { p.rot = r3(p.rot); p.w = r2(p.w); p.d = r2(p.d); + attachInteractions(p); if (!p.tags?.length) delete p.tags; if (!p.roomId) delete p.roomId; if (!p.color) delete p.color; diff --git a/src/gen/generate.ts b/src/gen/generate.ts index acb3337..f80c111 100644 --- a/src/gen/generate.ts +++ b/src/gen/generate.ts @@ -3,6 +3,7 @@ import type { World } from '../types'; import { buildCity, type InteriorJob } from './city'; import { generateInterior, type InteriorResult } from './interior'; import { finalizeWorld } from './finalize'; +import { addStreetEntrances } from './streets'; export type Progress = (stage: string, done: number, total: number) => void; @@ -29,6 +30,7 @@ export function generateWorldSync(elements: OsmElement[], bbox: BBox, name: stri console.warn('interior failed', job.building.id, e); } }); + addStreetEntrances(world); finalizeWorld(world); return world; } diff --git a/src/gen/objects.ts b/src/gen/objects.ts new file mode 100644 index 0000000..1e7db9d --- /dev/null +++ b/src/gen/objects.ts @@ -0,0 +1,44 @@ +import type { InteractionSlot, Prop, Shape, Vec } from '../types'; + +interface ObjectType { + w: number; d: number; blocksMove: boolean; shapes: Shape[]; tags: string[]; +} +const rect = (w: number, h: number, s: Shape['s']): Shape => ({ t: 'rect', x: 0, y: 0, w, h, s }); +/** Shared registry of outdoor types; indoor types use the same tagged slot rules. */ +export const OBJECT_TYPES: Record = { + bench: { w: 1.8, d: 0.6, blocksMove: true, shapes: [rect(1.8, 0.6, 'wood')], tags: ['sit:3', 'rest'] }, + lamp: { w: 0.4, d: 0.4, blocksMove: true, shapes: [{ t: 'circle', x: 0, y: 0, r: 0.2, s: 'metal' }], tags: [] }, + bin: { w: 0.5, d: 0.5, blocksMove: true, shapes: [rect(0.5, 0.5, 'metal')], tags: ['dispose'] }, + bus_stop: { w: 3, d: 1.2, blocksMove: true, shapes: [rect(3, 1.2, 'glass'), rect(2.6, 0.3, 'wood')], tags: ['wait:6'] }, + parking_spot: { w: 2.5, d: 5, blocksMove: false, shapes: [rect(2.5, 5, 'line')], tags: ['park'] }, + courtyard: { w: 3, d: 3, blocksMove: false, shapes: [rect(3, 3, 'green')], tags: ['rest:4'] }, + crossing: { w: 2.5, d: 6, blocksMove: false, shapes: [rect(2.5, 6, 'line')], tags: ['cross'] }, + entrance: { w: 1, d: 0.3, blocksMove: false, shapes: [rect(1, 0.3, 'wood')], tags: ['enter'] }, + market_stall: { w: 2, d: 1.2, blocksMove: true, shapes: [rect(2, 1.2, 'fabric'), rect(1.8, 0.6, 'wood')], tags: ['trade:2', 'work'] }, +}; + +export function streetObject(kind: string, id: string, pos: Vec, rot = 0): Prop { + const def = OBJECT_TYPES[kind]; + if (!def) throw new Error(`Unknown street object: ${kind}`); + const p: Prop = { id, kind, pos, rot, w: def.w, d: def.d, shapes: structuredClone(def.shapes), tags: [...def.tags], blocksMove: def.blocksMove, blocksSight: false }; + attachInteractions(p); + return p; +} + +/** Tags already describe variable-size furniture, e.g. sleep:2 or sit:6. + * All points are world coordinates, just outside blocking geometry. */ +export function attachInteractions(p: Prop) { + const tags = p.tags ?? OBJECT_TYPES[p.kind]?.tags ?? []; + const actions = new Set(); + const slots: InteractionSlot[] = []; + for (const tag of tags) { + const [action, count] = tag.split(':'); + if (!['sit', 'sleep', 'rest', 'wait', 'park', 'enter', 'cross', 'trade', 'work', 'cook', 'food', 'storage', 'dispose'].includes(action) || actions.has(action)) continue; + actions.add(action); + const capacity = Math.max(1, Math.min(32, Number(count) || 1)); + const y = p.blocksMove ? p.d / 2 + 0.45 : 0; + const pos: Vec = [p.pos[0] - Math.sin(p.rot) * y, p.pos[1] + Math.cos(p.rot) * y]; + slots.push({ id: `${p.id}:${action}`, action, pos: pos.map(n => Math.round(n * 100) / 100) as Vec, capacity }); + } + p.interactions = slots; +} diff --git a/src/gen/pipeline.ts b/src/gen/pipeline.ts index 3b2986a..95183de 100644 --- a/src/gen/pipeline.ts +++ b/src/gen/pipeline.ts @@ -4,6 +4,7 @@ import type { OsmElement, BBox } from '../osm/overpass'; import type { World } from '../types'; import { buildCity, type InteriorJob } from './city'; import { finalizeWorld } from './finalize'; +import { addStreetEntrances } from './streets'; export type OnProgress = (text: string, frac: number) => void; @@ -14,6 +15,8 @@ export async function generateWorld(elements: OsmElement[], bbox: BBox, name: st finalizeWorld(world, false); onProgress(`Планировки: 0 / ${jobs.length}`, 0.1); await runInteriors(world, jobs, (done) => onProgress(`Планировки: ${done} / ${jobs.length} зданий`, 0.1 + 0.88 * (done / Math.max(1, jobs.length))), signal); + addStreetEntrances(world); + finalizeWorld(world, false); onProgress('Готово', 1); return world; } diff --git a/src/gen/streets.ts b/src/gen/streets.ts new file mode 100644 index 0000000..3ac045d --- /dev/null +++ b/src/gen/streets.ts @@ -0,0 +1,106 @@ +import type { OsmElement } from '../osm/overpass'; +import type { Prop, Vec, World } from '../types'; +import { bboxOf, centroid, closestOnSegment, distToSegment, pointInRings } from '../util/geom'; +import { SpatialGrid } from '../util/spatial'; +import { Projector } from './project'; +import { streetObject } from './objects'; + +export function populateStreets(world: World, elements: OsmElement[]) { + const buildings = new SpatialGrid(50); + for (const b of world.buildings) buildings.insert(b, bboxOf(b.footprint)); + const water = new SpatialGrid(100); + for (const a of world.areas) if (['water', 'wetland'].includes(a.kind)) water.insert(a, bboxOf(a.rings[0])); + const occupied = new SpatialGrid(10); + const box = (p: Prop): [number, number, number, number] => { + const x = (Math.abs(Math.cos(p.rot)) * p.w + Math.abs(Math.sin(p.rot)) * p.d) / 2 + 0.3; + const y = (Math.abs(Math.sin(p.rot)) * p.w + Math.abs(Math.cos(p.rot)) * p.d) / 2 + 0.3; + return [p.pos[0] - x, p.pos[1] - y, p.pos[0] + x, p.pos[1] + y]; + }; + for (const p of world.props) occupied.insert(p, box(p)); + const roadSegments = new SpatialGrid<{ a: Vec; b: Vec; width: number }>(40); + for (const road of world.roads) if (!road.tunnel && !road.bridge) for (let i = 1; i < road.pts.length; i++) { + const a = road.pts[i - 1], b = road.pts[i]; + roadSegments.insert({ a, b, width: road.width }, [Math.min(a[0], b[0]) - road.width, Math.min(a[1], b[1]) - road.width, Math.max(a[0], b[0]) + road.width, Math.max(a[1], b[1]) + road.width]); + } + const ids = new Set(world.props.map(p => p.id)); + let budget = 20000; + const add = (p: Prop, onRoad = false) => { + if (budget <= 0 || ids.has(p.id)) return; + const bb = box(p), B = world.bounds; + if (bb[0] < B.minX || bb[1] < B.minY || bb[2] > B.maxX || bb[3] > B.maxY) return; + const samples: Vec[] = [p.pos, [bb[0], bb[1]], [bb[2], bb[1]], [bb[2], bb[3]], [bb[0], bb[3]]]; + if (buildings.query(bb).some(b => samples.some(q => pointInRings(q, [b.footprint, ...b.holes]))) || + water.query(bb).some(a => samples.some(q => pointInRings(q, a.rings)))) return; + if (!onRoad && roadSegments.query(bb).some(s => samples.some(q => distToSegment(q, s.a, s.b) < s.width / 2 + 0.15))) return; + if (occupied.query(bb).some(other => { + const b = box(other); + return bb[0] < b[2] && bb[2] > b[0] && bb[1] < b[3] && bb[3] > b[1]; + })) return; + world.props.push(p); occupied.insert(p, bb); ids.add(p.id); budget--; + }; + + const projector = new Projector(world.origin.lat, world.origin.lon); + for (const element of elements) { + if (element.type !== 'node' || element.lat == null || element.lon == null) continue; + const t = element.tags ?? {}, pos = projector.toXY(element.lat, element.lon); + const kind = t.highway === 'bus_stop' || t.public_transport === 'platform' ? 'bus_stop' : + t.highway === 'crossing' ? 'crossing' : t.amenity === 'waste_basket' ? 'bin' : t.amenity === 'marketplace' ? 'market_stall' : null; + if (kind) { + const p = streetObject(kind, `osm-${kind}-${element.id}`, pos); + if (kind === 'crossing') { + const near = roadSegments.query([pos[0] - 10, pos[1] - 10, pos[0] + 10, pos[1] + 10]) + .sort((a, b) => distToSegment(pos, a.a, a.b) - distToSegment(pos, b.a, b.b))[0]; + if (near) { + const q = closestOnSegment(pos, near.a, near.b); + p.pos = q; p.rot = Math.atan2(near.b[1] - near.a[1], near.b[0] - near.a[0]); p.d = near.width; + p.shapes = [{ t: 'rect', x: 0, y: 0, w: p.w, h: p.d, s: 'line' }]; + } + } + add(p, kind === 'crossing'); + } + } + // Regular furniture along walkable streets; stable IDs and per-road seeds. + for (const road of world.roads) { + if (road.bridge || road.tunnel || road.layer !== 0 || !['residential', 'secondary', 'tertiary', 'pedestrian', 'living_street', 'footway'].includes(road.kind)) continue; + let distance = 15; + let serial = 0; + for (let i = 1; i < road.pts.length; i++) { + const a = road.pts[i - 1], b = road.pts[i], length = Math.hypot(b[0] - a[0], b[1] - a[1]); + if (!length) continue; + const angle = Math.atan2(b[1] - a[1], b[0] - a[0]); + for (; distance < length && budget > 0; distance += 35) { + const side = serial % 2 ? 1 : -1; + const offset = road.width / 2 + road.sidewalk + 1; + const p: Vec = [a[0] + (b[0] - a[0]) * distance / length - Math.sin(angle) * offset * side, + a[1] + (b[1] - a[1]) * distance / length + Math.cos(angle) * offset * side]; + const kind = serial % 4 === 0 ? 'bench' : serial % 4 === 2 ? 'bin' : 'lamp'; + add(streetObject(kind, `street-${road.id}-${serial++}`, p, angle + (side < 0 ? Math.PI : 0))); + } + distance -= length; + } + } + for (const area of world.areas) { + if (!['park', 'garden', 'playground', 'pedestrian'].includes(area.kind)) continue; + const pos = centroid(area.rings[0]); + if (!pointInRings(pos, area.rings)) continue; + add(streetObject('courtyard', `courtyard-${area.id}`, pos)); + add(streetObject(area.kind === 'pedestrian' ? 'market_stall' : 'bench', `amenity-${area.id}`, [pos[0] + 4, pos[1]])); + } +} + +/** Entrances are derived from the actual finished floor plans, never guessed. */ +export function addStreetEntrances(world: World) { + const ids = new Set(world.props.map(p => p.id)); + for (const building of world.buildings) for (const floor of building.floors) { + if (floor.level !== 0) continue; + for (const door of floor.doors) { + if (!door.rooms.includes('outside')) continue; + const id = `entrance-${building.id}-${door.id}`; + if (ids.has(id)) continue; + const pos: Vec = [(door.a[0] + door.b[0]) / 2, (door.a[1] + door.b[1]) / 2]; + const prop = streetObject('entrance', id, pos, Math.atan2(door.b[1] - door.a[1], door.b[0] - door.a[0])); + prop.target = { buildingId: building.id, doorId: door.id, level: floor.level }; + world.props.push(prop); ids.add(id); + } + } +} diff --git a/src/main.ts b/src/main.ts index 9461313..3a4d4cc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,11 @@ import { fetchOverpass, geocode, type BBox, type OsmElement } from './osm/overpa import { generateWorld } from './gen/pipeline'; import { downloadWorld, hasWorld, loadWorld, readJsonFile, saveWorld } from './store'; import type { World } from './types'; +import { TileLoader } from '../client/tiles'; +import type { Bounds } from './city/format'; +import { RoomConnection } from '../client/connection'; +import { create, fromBinary, toBinary } from '@bufbuild/protobuf'; +import { CityJobSchema, CreateCitySchema } from '../client/generated/city_pb'; const MAX_KM2 = 25; const WARN_KM2 = 10; @@ -11,6 +16,17 @@ const WARN_KM2 = 10; const app = document.getElementById('app')!; let viewer: Viewer | null = null; let picker: AreaPicker | null = null; +let tileLoader: TileLoader | null = null; +let cityLoad: AbortController | null = null; +let roomConnection: RoomConnection | null = null; +function stopRoom() { roomConnection?.destroy(); roomConnection = null; } + +function stopTileLoading() { + cityLoad?.abort(); + cityLoad = null; + tileLoader?.destroy(); + tileLoader = null; +} const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!)); @@ -33,6 +49,8 @@ const hideProgress = () => (overlay.hidden = true); // ------------------------------------------------------------------ экраны function showViewer(world: World) { + stopRoom(); + stopTileLoading(); picker = null; app.innerHTML = ''; viewer?.destroy(); @@ -44,6 +62,8 @@ function showViewer(world: World) { } async function showPicker(initial?: BBox) { + stopRoom(); + stopTileLoading(); viewer?.destroy(); viewer = null; app.innerHTML = ''; @@ -54,6 +74,7 @@ async function showPicker(initial?: BBox) { panel.className = 'picker-panel card'; panel.innerHTML = `
Город сверху
+ Сетевая игра — комнаты

Выделите на карте небольшой город или район — по данным OpenStreetMap будет построена подробная карта с планировками и мебелью во всех зданиях.

@@ -144,16 +165,36 @@ async function showPicker(initial?: BBox) { }); goBtn.addEventListener('click', async () => { - if (!bbox) return; + if (!bbox || 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 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(); + } + } progress('Загрузка данных OpenStreetMap…', 0.02); const els = await fetchOverpass(bbox, (s) => progress(s, 0.03), abort.signal); await runGeneration(els, bbox, nameInput.value.trim() || 'Новая карта'); } catch (ex) { hideProgress(); 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; } }); @@ -224,8 +265,137 @@ async function runGeneration(els: OsmElement[], bbox: BBox, name: string) { // ------------------------------------------------------------------ старт +async function showTiledCity(url: string, connection?: RoomConnection) { + stopTileLoading(); + const controller = cityLoad = new AbortController(); + progress('Загрузка манифеста города…', 0.1); + abort = controller; + try { + const loader = await TileLoader.open(url, controller.signal); + if (controller.signal.aborted) return; + tileLoader = loader; + viewer?.destroy(); + app.innerHTML = ''; + let lastKey = ''; + let timer: ReturnType; + const tiledViewer = new Viewer(app, loader.emptyWorld(), { + onNew: () => { clearTimeout(timer); if (connection) {history.replaceState(null,'','/?play');void showLobby();} else void showPicker(loader.manifest.city.bbox); }, + newLabel:connection ? 'Комнаты' : undefined, + maxViewSpan: loader.manifest.tileSize * (connection ? 4 : 6), + maxLevels: loader.manifest.counts.maxLevels, + entities: connection ? () => connection.entities.positions() : undefined, + onViewport: (bounds: Bounds) => { + // Snap to tile boundaries: a paint after loading must not trigger a new request. + const size = loader.manifest.tileSize; + const x0 = Math.floor(bounds.minX / size) - 1, y0 = Math.floor(bounds.minY / size) - 1; + const x1 = Math.floor(bounds.maxX / size) + 1, y1 = Math.floor(bounds.maxY / size) + 1; + const key = `${x0},${y0},${x1},${y1}`; + if (key === lastKey) return; + lastKey = key; + connection?.subscribe({ minX: x0 * size, minY: y0 * size, maxX: x1 * size, maxY: y1 * size }); + clearTimeout(timer); + timer = setTimeout(async () => { + if (tileLoader !== loader) return; + tiledViewer.setLoadStatus('Загрузка тайлов…'); + try { + const world = await loader.load({ minX: x0 * size, minY: y0 * size, maxX: x1 * size, maxY: y1 * size }); + if (tileLoader !== loader) return; + tiledViewer.replaceWorld(world); + tiledViewer.setLoadStatus(`${loader.manifest.counts.buildings} зданий в городе · ${world.buildings.length} в загруженной области`); + } catch (error) { + if ((error as Error).name !== 'AbortError' && tileLoader === loader) { + tiledViewer.setLoadStatus((error as Error).message); + } + } + }, 100); + }, + }); + viewer = tiledViewer; + if (connection) { + const controls = document.createElement('div'); controls.className = 'row'; + const label = document.createElement('span'); label.className = 'room-clock'; label.setAttribute('role','status'); + controls.appendChild(label); + for (const speed of [0, 1, 2, 4]) { + const button = document.createElement('button'); button.className = 'btn'; button.textContent = speed === 0 ? 'Пауза' : `×${speed}`; + button.onclick = () => connection.propose(speed); controls.appendChild(button); + } + for (const yes of [true,false]) { + const button = document.createElement('button'); button.className = 'btn'; button.textContent = yes ? 'За' : 'Против'; + button.onclick = () => connection.vote(yes); controls.appendChild(button); + } + const vote = document.createElement('span'); vote.className = 'room-vote'; vote.setAttribute('role','status'); controls.appendChild(vote); + tiledViewer.root.querySelector('.viewer-top')!.appendChild(controls); + } + (window as any).viewer = viewer; + hideProgress(); + } catch (error) { + hideProgress(); + if ((error as Error).name === 'AbortError') return showPicker(); + await showPicker(); + const err = app.querySelector('.err')!; + err.hidden = false; + err.textContent = `Не удалось загрузить город: ${(error as Error).message}`; + } +} + +async function showLobby() { + stopRoom(); stopTileLoading(); viewer?.destroy(); viewer = null; app.innerHTML = ''; + const panel = document.createElement('div'); panel.className = 'card picker-panel'; panel.style.maxWidth = '700px'; + panel.innerHTML = `
Комнаты города
+ +
+

Создать город на сервереГенератор для отладки`; + app.appendChild(panel); + const status = panel.querySelector('[data-status]')!; + const api = async (url: string, init?: RequestInit) => { + const response = await fetch(url,init); const body = await response.json(); + if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`); return body; + }; + const join = (room: string) => { + const nickname = panel.querySelector('[name=nickname]')!.value.trim(); + if (!nickname) { status.textContent = 'Введите ник'; return; } + status.textContent = 'Подключение…'; + stopRoom(); + const connection = new RoomConnection(room,nickname,{ + welcome: w => { void showTiledCity(w.cityManifestUrl,connection); }, + 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} игроков`; }, + }); + roomConnection = connection; + }; + const refresh = async () => { + try { + const [cities, rooms] = await Promise.all([api('/api/cities'),api('/api/rooms')]); + const select = panel.querySelector('select')!; select.replaceChildren(); + for (const city of cities) { const option = document.createElement('option'); option.value = city.id; option.textContent = city.name; select.appendChild(option); } + const list = panel.querySelector('[data-rooms]')!; list.replaceChildren(); + for (const room of rooms) { + const button = document.createElement('button'); button.className = 'btn'; button.textContent = `${room.cityId} · ${room.players}/30 · ${room.active ? 'открыта' : 'сохранена'}`; + button.dataset.room=room.id; + button.onclick = () => join(room.id); list.appendChild(button); + } + status.textContent = cities.length ? (rooms.length ? '' : 'Создайте первую комнату') : 'Каталог пуст. Экспортируйте город: npm run export-city -- world.json public/cities/demo'; + } catch (error) { status.textContent = `Сервер недоступен: ${(error as Error).message}`; } + }; + panel.querySelector('[data-refresh]')!.onclick = () => { void refresh(); }; + panel.querySelector('[data-create]')!.onclick = async () => { + const button = panel.querySelector('[data-create]')!; button.disabled = true; + try { + const room = await api('/api/rooms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cityId:panel.querySelector('select')!.value,seed:1})}); + join(room.id); + } catch (error) { status.textContent = (error as Error).message; } + finally { button.disabled = false; } + }; + await refresh(); + const requestedRoom = new URLSearchParams(location.search).get('room'); + if (requestedRoom) join(requestedRoom); +} + (async () => { const params = new URLSearchParams(location.search); + if (params.has('play')) return showLobby(); + if (params.has('city')) return showTiledCity(params.get('city')!); if (params.has('last') && (await hasWorld())) { const w = await loadWorld(); if (w) return showViewer(w); diff --git a/src/osm/overpass.ts b/src/osm/overpass.ts index 2ba5f7e..4cff98a 100644 --- a/src/osm/overpass.ts +++ b/src/osm/overpass.ts @@ -65,6 +65,9 @@ export function buildQuery(b: BBox): string { node["tourism"]${bb}; node["healthcare"]${bb}; node["highway"="street_lamp"]${bb}; + node["highway"="bus_stop"]${bb}; + node["highway"="crossing"]${bb}; + node["public_transport"="platform"]${bb}; ); out geom;`; } diff --git a/src/types.ts b/src/types.ts index f95d21b..651b39a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -194,4 +194,14 @@ export interface Prop { color?: string; /** Для симуляции: можно сидеть/спать/хранить и т.п. */ tags?: string[]; + /** World-space interaction points shared by indoor and outdoor objects. */ + interactions?: InteractionSlot[]; + target?: { buildingId: string; doorId: string; level: number }; +} + +export interface InteractionSlot { + id: string; + action: string; + pos: Vec; + capacity: number; } diff --git a/src/view/viewer.ts b/src/view/viewer.ts index 6807bc6..45e28ae 100644 --- a/src/view/viewer.ts +++ b/src/view/viewer.ts @@ -4,6 +4,7 @@ import type { Building, Vec, World } from '../types'; import { Renderer, type Camera, type ViewOpts } from './renderer'; import { KIND_NAME, ROOM_NAME } from './style'; import { centroid } from '../util/geom'; +import type { Bounds } from '../city/format'; const el = (tag: K, cls?: string, html?: string) => { const e = document.createElement(tag); @@ -21,6 +22,9 @@ export class Viewer { cam: Camera; opts: ViewOpts = { floor: null, overrides: new Map(), selected: null, furniture: true, labels: true }; private raf = 0; + private disposed = false; + private resizeObserver = new ResizeObserver(() => this.resize()); + private loadStatus = el('div', 'hint'); private floorBar = el('div', 'floor-bar card'); private info = el('div', 'info card'); private scale = el('div', 'scale'); @@ -28,28 +32,52 @@ export class Viewer { private pointers = new Map(); private gesture: { x: number; y: number; cam: Camera; d?: number; moved: boolean } | null = null; - constructor(parent: HTMLElement, public world: World, private actions: { onNew: () => void; onExport: () => void }) { + constructor(parent: HTMLElement, public world: World, private actions: { + onNew: () => void; onExport?: () => void; onViewport?: (bounds: Bounds) => void; + maxViewSpan?: number; maxLevels?: number; + newLabel?: string; + entities?: () => { id: number; x: number; y: number }[]; + }) { this.renderer = new Renderer(world); const B = world.bounds; this.cam = { x: (B.minX + B.maxX) / 2, y: (B.minY + B.maxY) / 2, z: 1 }; - this.maxLevel = Math.min(40, Math.max(1, ...world.buildings.map((b) => b.levels))); + this.maxLevel = Math.min(40, actions.maxLevels ?? world.buildings.reduce((n, b) => Math.max(n, b.levels), 1)); this.root.append(this.canvas, this.buildTop(), this.floorBar, this.info, this.scale, el('div', 'attribution', '© OpenStreetMap contributors')); parent.appendChild(this.root); this.info.hidden = true; this.buildFloorBar(); - new ResizeObserver(() => this.resize()).observe(this.root); + this.loadStatus.setAttribute('role', 'status'); + this.root.querySelector('.viewer-top')!.appendChild(this.loadStatus); + this.resizeObserver.observe(this.root); this.bindInput(); requestAnimationFrame(() => { + if (this.disposed) return; this.resize(); this.fitBounds(); }); } destroy() { + this.disposed = true; + cancelAnimationFrame(this.raf); + this.resizeObserver.disconnect(); this.root.remove(); window.removeEventListener('keydown', this.onKey); } + setLoadStatus(message: string) { this.loadStatus.textContent = message; } + + replaceWorld(world: World) { + if (this.disposed) return; + this.world = world; + this.renderer = new Renderer(world); + const stats = this.root.querySelector('.stats'); + if (stats) stats.textContent = `${world.buildings.length.toLocaleString('ru')} зданий · ${world.props.length.toLocaleString('ru')} уличных объектов в загруженной области`; + if (!world.buildings.some(b => b.id === this.opts.selected)) this.opts.selected = null; + this.updateInfo(); + this.request(); + } + private buildTop(): HTMLElement { const top = el('div', 'viewer-top card'); const w = this.world; @@ -64,13 +92,16 @@ export class Viewer {
${esc(w.name || 'Без названия')}
${w.buildings.length.toLocaleString('ru')} зданий · ${floors.toLocaleString('ru')} этажей · ${rooms.toLocaleString('ru')} комнат · ${furn.toLocaleString('ru')} предметов · ${homes.toLocaleString('ru')} квартир/домов · ~${beds.toLocaleString('ru')} жителей
- +
`; top.querySelector('[data-a=new]')!.addEventListener('click', () => this.actions.onNew()); - top.querySelector('[data-a=export]')!.addEventListener('click', () => this.actions.onExport()); + const exportButton = top.querySelector('[data-a=export]')!; + exportButton.disabled = !this.actions.onExport; + if (!this.actions.onExport) exportButton.title = 'Полный город экспортируется офлайн командой npm run export-city'; + exportButton.addEventListener('click', () => this.actions.onExport?.()); top.querySelectorAll('input[data-t]').forEach((inp) => inp.addEventListener('change', () => { (this.opts as any)[inp.dataset.t!] = inp.checked; @@ -126,6 +157,7 @@ export class Viewer { this.cam.x = (B.minX + B.maxX) / 2; this.cam.y = (B.minY + B.maxY) / 2; this.cam.z = Math.min(r.width / (B.maxX - B.minX), r.height / (B.maxY - B.minY)) * 0.92; + if (this.actions.maxViewSpan) this.cam.z = Math.max(this.cam.z, Math.max(r.width, r.height) / this.actions.maxViewSpan); this.request(); } @@ -136,12 +168,29 @@ export class Viewer { } request() { - if (this.raf) return; + if (this.raf || this.disposed) return; this.raf = requestAnimationFrame(() => { this.raf = 0; const r = this.root.getBoundingClientRect(); this.renderer.draw(this.ctx, r.width, r.height, this.cam, this.opts, window.devicePixelRatio || 1); + if (this.actions.entities) { + const ctx = this.ctx; + const dpr = window.devicePixelRatio || 1; + ctx.save(); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#382c21'; + for (const agent of this.actions.entities()) { + const x = (agent.x - this.cam.x) * this.cam.z + r.width / 2; + const y = (agent.y - this.cam.y) * this.cam.z + r.height / 2; + if (x < -5 || x > r.width + 5 || y < -5 || y > r.height + 5) continue; + ctx.beginPath(); ctx.arc(x,y,Math.max(2,Math.min(5,this.cam.z*0.3)),0,Math.PI*2); ctx.fill(); ctx.stroke(); + } + ctx.restore(); + } this.updateScale(); + if (r.width && r.height && this.cam.z > 0) this.actions.onViewport?.({ + minX: this.cam.x - r.width / this.cam.z / 2, maxX: this.cam.x + r.width / this.cam.z / 2, + minY: this.cam.y - r.height / this.cam.z / 2, maxY: this.cam.y + r.height / this.cam.z / 2, + }); + if (this.actions.entities) this.request(); }); } diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..d795347 --- /dev/null +++ b/tools/README.md @@ -0,0 +1,14 @@ +# Offline tools + +City generation stays in TypeScript. `npm run cli` generates a world from OSM; +`npm run export-city -- world.json public/cities/demo` publishes its tiled form. + +Exports are immutable: choose a new directory for a new city revision. Each +export contains a versioned manifest, gzip tiles with SHA-256 hashes and a +compact navigation index. Buildings are repeated whole at tile boundaries; +clients deduplicate IDs. Geometry extending outside the chosen OSM bbox does +not create tiles outside the city. + +`generate-worker.ts` implements the isolated server job: fetch OSM, generate, +export into an unpublished staging folder, then rename it into the catalogue. +`rust.mjs` provides npm entry points for Cargo without changing the system PATH. diff --git a/tools/demo.ts b/tools/demo.ts new file mode 100644 index 0000000..d611c08 --- /dev/null +++ b/tools/demo.ts @@ -0,0 +1,15 @@ +import { readFile,access } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { generateWorldSync } from '../src/gen/generate'; +import { exportCity } from './export/index'; +const output=resolve(process.argv[2] || 'public/cities/balzers-demo'); +let ready=false; +try {await access(resolve(output,'manifest.json'));ready=true;} catch(error) {if ((error as NodeJS.ErrnoException).code!=='ENOENT') throw error;} +if (ready) console.log(`Prepared city already exists: ${output}`); +else { + const data=JSON.parse(await readFile('samples/balzers-2013.json','utf8')); + const world=generateWorldSync(data.elements,[47.058,9.492,47.074,9.518],'Бальцерс',1); + world.id='balzers-0-2-0'; + const manifest=await exportCity(world,output); + console.log(`${manifest.city.name}: ${manifest.counts.buildings} buildings, ${manifest.tiles.length} tiles → ${output}`); +} diff --git a/tools/export/index.ts b/tools/export/index.ts new file mode 100644 index 0000000..e75dca2 --- /dev/null +++ b/tools/export/index.ts @@ -0,0 +1,43 @@ +import { access, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { gzipSync, gunzipSync } from 'node:zlib'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createHash } from 'node:crypto'; +import { tileWorld } from '../../src/city/format'; +import type { World } from '../../src/types'; + +export async function exportCity(world: World, output: string, tileSize = 256) { + let exists = false; + try { await access(resolve(output, 'manifest.json')); exists = true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + if (exists) throw new Error('City export is immutable: use a new directory to preserve existing rooms and HTTP caches'); + const { manifest, tiles } = tileWorld(world, tileSize); + await mkdir(resolve(output, 'tiles'), { recursive: true }); + for (const ref of manifest.tiles) { + const file = resolve(output, ref.url); + const json = JSON.stringify(tiles.get(ref.id)); + ref.sha256 = createHash('sha256').update(json).digest('hex'); + await writeFile(file + '.tmp', gzipSync(json, { level: 6 })); + await rename(file + '.tmp', file); + } + const navigation = JSON.stringify({formatVersion:1,cityId:world.id,id:'navigation', + roads:world.roads.map(({id,kind,pts})=>({id,kind,pts})), + buildings:world.buildings.map(({id,kind,floors})=>({id,kind,floors:floors.filter(f=>f.level===0).map(f=>({level:0,doors:f.doors.filter(d=>d.rooms.includes('outside'))}))})), + }); + manifest.navigation={url:'navigation.json.gz',sha256:createHash('sha256').update(navigation).digest('hex')}; + const navFile=resolve(output,manifest.navigation.url); + await writeFile(navFile+'.tmp',gzipSync(navigation,{level:6})); + await rename(navFile+'.tmp',navFile); + const file = resolve(output, 'manifest.json'); + await writeFile(file + '.tmp', JSON.stringify(manifest, null, 2)); + await rename(file + '.tmp', file); + return manifest; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const [input, output, size] = process.argv.slice(2); + if (!input || !output) throw new Error('Usage: npm run export-city -- world.json[.gz] output-directory [tile-size-metres]'); + const bytes = await readFile(input); + const world = JSON.parse((bytes[0] === 0x1f && bytes[1] === 0x8b ? gunzipSync(bytes) : bytes).toString()) as World; + const manifest = await exportCity(world, output, size === undefined ? 256 : Number(size)); + console.log(`${manifest.city.name}: ${manifest.tiles.length} tiles → ${resolve(output, 'manifest.json')}`); +} diff --git a/tools/generate-worker.ts b/tools/generate-worker.ts new file mode 100644 index 0000000..ab32d5c --- /dev/null +++ b/tools/generate-worker.ts @@ -0,0 +1,27 @@ +import { readFile, mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { fetchOverpass, type BBox } from '../src/osm/overpass'; +import { generateWorldSync } from '../src/gen/generate'; +import { exportCity } from './export/index'; +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})); +await progress('Загрузка OSM'); +const elements = job.input ? JSON.parse(await readFile(job.input,'utf8')).elements : await fetchOverpass(job.bbox); +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); +if (world.buildings.length > job.maxBuildings) throw new Error('Превышен лимит зданий'); +world.id = job.cityId; +await progress('Экспорт'); +const temporary = `${job.output}.pending`; +try { + // A timed-out worker may leave its unpublished staging directory behind. + await rm(temporary,{recursive:true,force:true}); + await mkdir(temporary,{recursive:true}); + await exportCity(world,temporary); + await rename(temporary,job.output); +} catch (error) { + await rm(temporary,{recursive:true,force:true}); throw error; +} +await progress('Готово'); diff --git a/tools/load-bots.ts b/tools/load-bots.ts new file mode 100644 index 0000000..94e2c29 --- /dev/null +++ b/tools/load-bots.ts @@ -0,0 +1,53 @@ +import { create,fromBinary,toBinary } from '@bufbuild/protobuf'; +import { ClientMessageSchema,ServerMessageSchema } from '../client/generated/city_pb'; +const [base='http://127.0.0.1:3000',cityId='balzers-demo',duration='20']=process.argv.slice(2); +const seconds=Number(duration); +if (!Number.isFinite(seconds) || seconds<1 || seconds>600) throw new Error('Duration must be 1–600 seconds'); +const response=await fetch(`${base}/api/rooms`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cityId,seed:1})}); +const room=await response.json();if (!response.ok) throw new Error(room.error); +const manifest=await (await fetch(`${base}/cities/${encodeURIComponent(cityId)}/manifest.json`)).json(); +const bounds=manifest.city.bounds; +const sockets:WebSocket[]=[];const bytes=Array(30).fill(0);let snapshots=0,deltas=0,throttles=0;const errors:string[]=[]; +let started=0; +let expectedClose=false; +try { + await Promise.all(Array.from({length:30},(_,i)=>new Promise((resolve,reject)=>{ + const url=new URL(`/api/rooms/${encodeURIComponent(room.id)}/ws`,base);url.protocol=url.protocol==='https:'?'wss:':'ws:'; + const ws=new WebSocket(url);ws.binaryType='arraybuffer';sockets.push(ws); + let lod=0; + const send=(body:any)=>ws.send(toBinary(ClientMessageSchema,create(ClientMessageSchema,{body}))); + const x=bounds.minX+(bounds.maxX-bounds.minX)*(i%6+0.5)/6; + const y=bounds.minY+(bounds.maxY-bounds.minY)*(Math.floor(i/6)+0.5)/5; + const subscribe=()=>send({case:'subscribe',value:{lod,rect:{minX:Math.round((x-50)*100),maxX:Math.round((x+50)*100),minY:Math.round((y-50)*100),maxY:Math.round((y+50)*100)}}}); + const timeout=setTimeout(()=>reject(new Error(`Client ${i} did not receive a snapshot`)),30000); + ws.onopen=()=>send({case:'join',value:{room:room.id,clientVersion:1,nickname:`Load-${i}`}}); + ws.onerror=()=>{errors.push(`Client ${i} connection error`);clearTimeout(timeout);reject(new Error(errors.at(-1)));}; + ws.onclose=()=>{clearTimeout(timeout);if(!expectedClose){errors.push(`Client ${i} disconnected`);reject(new Error(errors.at(-1)));}}; + ws.onmessage=ev=>{ + bytes[i]+=(ev.data as ArrayBuffer).byteLength; + const b=fromBinary(ServerMessageSchema,new Uint8Array(ev.data as ArrayBuffer)).body; + if (b.case==='welcome') subscribe(); + else if (b.case==='snapshot') {snapshots++;clearTimeout(timeout);resolve();} + else if (b.case==='delta') deltas++; + else if (b.case==='error') { + if (b.value.code==='ENTITY_LIMIT' && lod<10) {lod++;subscribe();} + else if (b.value.code==='TRAFFIC_LIMIT') {throttles++;setTimeout(()=>{if(ws.readyState===WebSocket.OPEN)subscribe();},1000);} + else {errors.push(`${i}:${b.value.code}:${b.value.message}`);clearTimeout(timeout);reject(new Error(errors.at(-1)));} + } + }; + }))); + started=performance.now(); + const samples:number[]=[];let sampling=true; + const sampler=(async()=>{while(sampling){ + const text=await (await fetch(`${base}/metrics`)).text(); + const line=text.split('\n').find(line=>line.startsWith(`citygen_room_tick_seconds{room="${room.id}"}`)); + if(line)samples.push(Number(line.split(' ').at(-1))*1000); + await new Promise(resolve=>setTimeout(resolve,200)); + }})(); + await new Promise(resolve=>setTimeout(resolve,seconds*1000)); + sampling=false;await sampler;samples.sort((a,b)=>a-b); + const metrics=await (await fetch(`${base}/metrics`)).text(); + const elapsed=(performance.now()-started)/1000; + console.log(JSON.stringify({room:room.id,clients:30,seconds:elapsed,snapshots,deltas,throttles,sampledTickP95Ms:samples[Math.floor(samples.length*.95)],sampledTickMaxMs:samples.at(-1),meanBytesPerClientSecond:Math.round(bytes.reduce((a,b)=>a+b,0)/30/elapsed),maxBytesPerClientSecond:Math.round(Math.max(...bytes)/elapsed),errors,metrics},null,2)); + if (errors.length) process.exitCode=1; +} finally {expectedClose=true;for(const socket of sockets)socket.close();} diff --git a/tools/rust.mjs b/tools/rust.mjs new file mode 100644 index 0000000..055f2ea --- /dev/null +++ b/tools/rust.mjs @@ -0,0 +1,14 @@ +import { existsSync } from 'node:fs'; +import { resolve, delimiter } from 'node:path'; +import { spawn } from 'node:child_process'; +const local = resolve('.tools/cargo'); +const executable = resolve(local,'bin',process.platform === 'win32' ? 'cargo.exe' : 'cargo'); +const env = {...process.env}; +if (existsSync(executable)) { + env.CARGO_HOME = local; env.RUSTUP_HOME = resolve('.tools/rustup'); + const pathKey=Object.keys(env).find(key=>key.toLowerCase()==='path') || 'PATH'; + env[pathKey] = `${resolve(local,'bin')}${delimiter}${env[pathKey] || ''}`; +} +const child = spawn(existsSync(executable) ? executable : 'cargo',process.argv.slice(2),{stdio:'inherit',env}); +child.on('error',error=>{console.error(`Rust toolchain is required: ${error.message}`);process.exitCode=1;}); +child.on('exit',code=>{process.exitCode=code ?? 1;}); diff --git a/tsconfig.json b/tsconfig.json index 817e85c..c26c485 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,5 +9,5 @@ "skipLibCheck": true, "types": [] }, - "include": ["src"] + "include": ["src", "client"] }