Implement tiled multiplayer city simulation and scaling stages

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

No files matched your search

+8
View File
@@ -0,0 +1,8 @@
.git
.tools
node_modules
dist
server/target
data
world.json
public/cities
+4
View File
@@ -2,3 +2,7 @@ node_modules/
dist/
*.log
world.json
.tools/
server/target/
data/
public/cities/
+25
View File
@@ -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"]
+40 -4
View File
@@ -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`)
Заложено под будущую симуляцию:
Статические данные уже используются сервером; для дальнейшего игрового слоя предусмотрены:
| Что | Зачем потом |
|---|---|
+5
View File
@@ -0,0 +1,5 @@
version: v2
plugins:
- local: protoc-gen-es
out: client/generated
opt: target=ts
+3
View File
@@ -0,0 +1,3 @@
version: v2
modules:
- path: proto
+7
View File
@@ -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.
+68
View File
@@ -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<typeof setInterval>;
private closed = false;
private pendingBounds: Bounds | null = null;
private welcomed = false;
private lod = 0;
private retry:ReturnType<typeof setTimeout> | 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(); }
}
+33
View File
@@ -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<number, Sample>();
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; }
}
+651
View File
@@ -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<Rect> = /*@__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<Join> = /*@__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<Subscribe> = /*@__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<Ping> = /*@__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<Propose> = /*@__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<Vote> = /*@__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<CreateCity> = /*@__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<CityJob> = /*@__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<ClientMessage> = /*@__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<Agent> = /*@__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<Welcome> = /*@__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<Snapshot> = /*@__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<Delta> = /*@__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<Error> = /*@__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<VoteState> = /*@__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<Clock> = /*@__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<ServerMessage> = /*@__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<SavedAgent> = /*@__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<Route> = /*@__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<RoomSave> = /*@__PURE__*/
messageDesc(file_city, 19);
+55
View File
@@ -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<unknown> {
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<string, CityTile>();
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<string, CityTile>();
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(); }
}
+275
View File
@@ -0,0 +1,275 @@
# План: переход к клиент-серверной игре
Статус: выполняется. Дата: 2026-10-30. Проверенный прогресс отмечен в разделе 9.
## 1. Цель и рамки
Игра: симулятор бога в духе RimWorld, но с меньшим влиянием игрока. Город живёт сам (жители, машины, потребности, события), игроки в основном наблюдают и ограниченно вмешиваются (вмешательство в первую версию не входит).
Исходные требования:
- До 30 игроков в одной комнате.
- Комната — отдельный инстанс мира, у каждой комнаты свой город (из OSM).
- Сущностей очень много, до миллиона на комнату.
- Клиент остаётся в браузере и только отображает. Вся симуляция и правила на сервере.
- Сгенерированный город хранится готовым, генерация не выполняется в рантайме комнаты.
Не входит в первую версию: влияние игроков на мир, аккаунты и монетизация, предсказание на клиенте (в наблюдательной игре оно не нужно).
## 2. Ключевые решения
| Вопрос | Решение | Почему |
|---|---|---|
| Язык сервера | Rust | Нет GC-пауз, плотные данные, параллельность без data race |
| ECS | `bevy_ecs` как отдельная библиотека (без движка Bevy) | Архетипы, автопараллелизм систем, change detection, `Schedule`. Запасной вариант: `hecs` |
| Асинхронность и сеть | `tokio` + `axum` + `tokio-tungstenite` | Стандарт для WebSocket в Rust |
| Транспорт | WebSocket (бинарный) | В браузере работает везде, TCP достаточен: игроки наблюдают, потеря пакета не критична, мир обновляется ~5–10 раз в секунду. WebTransport подключим, только если упрёмся в задержки |
| Формат сообщений | Protobuf (`prost` на сервере, `ts-proto` или `protobuf-es` на клиенте) | Общая схема, кодогенерация для обеих сторон, компактно, версионируется |
| Статика города | Тайлы-файлы, отдаются по HTTP | Кэшируются браузером и CDN, не грузят сервер симуляции |
| Генерация города | Остаётся на TypeScript, запускается офлайн (CLI) | Уже написана и отлажена (около 5 тыс. строк), портировать нет смысла |
| Сохранения | Снимки мира на диск, позже БД | Комнат немного, симуляция детерминируемая |
## 3. Архитектура
```
Браузер (TS) Сервер (Rust)
┌───────────────┐ HTTP ┌───────────────────────┐
│ Рендерер │◄─────────►│ Статика: тайлы города │
│ (src/view) │ тайлы └───────────────────────┘
│ │ WS ┌───────────────────────┐
│ Состояние │◄─────────►│ Gateway (axum) │
│ клиента: │ дельты │ - подключение к комн.│
│ интерполяция │ └──────────┬────────────┘
└───────────────┘ │ каналы
┌───────────▼────────────┐
│ Room (1 поток/задача) │
│ bevy_ecs World │
│ Schedule систем │
│ Interest manager │
└────────────────────────┘
```
Принципы:
1. **Одна комната — один `World`.** Комнаты независимы, масштабируются добавлением потоков и процессов. Внутри комнаты системы выполняются параллельно средствами `bevy_ecs`.
2. **Сервер авторитарен.** Клиент отправляет только запросы (подписка на область, позже команды влияния), состояние никогда не принимает от клиента.
3. **Статика отдельно от динамики.** Здания, дороги, интерьеры и мебель из OSM лежат в тайлах и не рассылаются через WebSocket. По сети идут только изменяемые сущности.
4. **Интерес по области (AOI).** Каждый игрок подписан на прямоугольник карты (вьюпорт плюс запас). Сервер шлёт ему дельты только для сущностей в этой области. Миллион сущностей к клиенту не уходит.
5. **LOD симуляции.** Вдали от всех наблюдателей жители симулируются грубо (потребности и расписания по шагам в десятки секунд, без пути по каждому тайлу), рядом с наблюдателем подробно. Это главный способ удержать миллион сущностей.
## 4. Формат данных города
Сейчас `src/types.ts` описывает город одним объектом, всё генерируется в браузере. Для игры нужно разрезать:
- **Манифест города**: границы, размер тайла, версия формата, список тайлов, метаданные.
- **Тайлы**: сетка (например 256 x 256 м). В тайле: дороги, здания, этажи, интерьеры, мебель, индекс проходимости (navmesh или сетка).
- **Формат файла**: бинарный (protobuf или FlatBuffers) со сжатием, чтобы читать и сервер, и клиент. На первом этапе допустим JSON + gzip, потом заменить.
- **Навигация**: для сервера отдельно строится граф дорог и проходимая сетка, чтобы жители ходили, не загружая геометрию.
**Интерьеры нужны серверу (решено).** Жители перемещаются по интерьерам и улицам и взаимодействуют с ними, поэтому сервер загружает полную проходимую геометрию: стены, двери, этажи, лестницы, мебель с типом, размером и точками взаимодействия (спать, сидеть, есть, работать). Загрузка по тайлам и лениво: интерьеры тайла нужны, только когда в нём есть наблюдатель или активные жители.
Для навигации строятся два уровня: грубый граф (здания, улицы, входы) для дальних путей и детальная сетка внутри зданий и вокруг для ближних. Переходы между уровнями — двери и лестницы.
**Улицы сейчас «пустоваты» (замечание автора).** До появления жителей на улицах нужно сгенерировать то, с чем они взаимодействуют снаружи: уличную мебель (скамейки, фонари, урны), остановки, парковки, дворы, переходы, входы в здания, места для торговли. Это отдельная задача генератора (этап 1б), см. раздел 9.
## 5. Модель симуляции (первая версия)
Компоненты (примерный набор):
- `Position`, `Velocity`, `TileId` (для быстрого AOI).
- `Agent` (житель), `Needs` (сон, еда, работа, отдых), `Schedule`, `Home`, `Workplace`.
- `Vehicle`, `Route`.
- `Building`, `Room`, `Furniture` (служебные ссылки на статику).
- `Dirty` (было ли изменение с прошлой рассылки).
Системы (порядок и зависимости задаёт `Schedule`):
1. Время и календарь.
2. Потребности и выбор цели.
3. Построение пути, движение.
4. События (пожар, болезнь, происшествие), позже вмешательство игроков.
5. Сбор изменений и рассылка по AOI.
Тик симуляции: 10 Гц фиксированный, скорость игры меняется множителем (как в RimWorld). Рассылка клиентам: 5–10 Гц, дельтами, клиент интерполирует.
## 6. Сеть и протокол
Сообщения клиент → сервер:
- `Join { room, client_version }`
- `Subscribe { rect, lod }` — какую область смотрю.
- `Ping`.
- (позже) `Intervene { ... }`.
Сообщения сервер → клиент:
- `Welcome { room_info, tick, city_manifest_url }`
- `Snapshot { tick, entities[] }` — полное состояние области при подписке.
- `Delta { tick, spawned[], changed[], despawned[] }` — изменения области.
- `Event { ... }` — редкие события (уведомления).
Правила:
- Позиции квантуются (например, в сантиметрах целым числом), идентификаторы компактные, дельты по полям.
- Версия протокола в рукопожатии, несовместимый клиент получает понятную ошибку.
- Ограничение размера и частоты запросов на клиента, ограничение размера области подписки.
## 7. Производительность: бюджеты
Ориентиры для проверки (замерить и уточнить):
- Тик комнаты с 1 млн сущностей: не более 50 мс на целевом сервере (при LOD, когда подробно симулируется лишь малая часть).
- Рассылка: не более 100 КБ/с на клиента в среднем.
- Память: порядка 100–200 байт на сущность в ECS, итого до 200 МБ на комнату без учёта статики.
- Подключение клиента: менее 2 с до первой картинки (за счёт тайлов, а не снимка всего мира).
Для проверки сразу закладываем нагрузочный стенд: генератор синтетической популяции (1 млн агентов на карте) и клиент-бот на 30 подключений.
## 8. Клиент (браузер)
- `src/view` остаётся, но получает данные не из воркера генерации, а из загрузчика тайлов и WebSocket-клиента.
- Слой состояния: хранит сущности вокруг камеры, применяет дельты, интерполирует позиции между тиками.
- Рендер динамики: на первом этапе Canvas, дальше (при 10–50 тыс. видимых сущностей) WebGL с инстансингом. Геометрию тайлов можно кэшировать в офскрин-текстуры.
- Режим «воспроизведение» (без сервера, текущий прототип) оставить как отладочный.
## 9. Этапы работ
**Этап 0. Подготовка (1 неделя)**
- [x] Зафиксировать решения этого документа, выбрать имя и структуру репозитория (монорепо: `server/`, `client/`, `tools/`, `proto/`).
- [x] Определить минимальную сцену для проверки: один небольшой город, 1000 агентов, 1 клиент.
**Этап 1. Тайловый формат (1–2 недели)**
- [x] Экспорт готового города из текущего генератора в тайлы + манифест (`tools/export`).
- [x] Клиент загружает город по тайлам, а не генерирует в браузере.
- [x] Тесты формата, версия формата в манифесте.
**Этап 1б. Обустройство улиц в генераторе (2 недели)**
- [x] Уличные объекты и точки взаимодействия снаружи зданий: скамейки, фонари, остановки, парковки, дворы, переходы, входы.
- [x] Единый реестр типов объектов с интерактивными слотами (как у мебели в `src/gen/catalog.ts`), общий для интерьеров и улиц.
- [x] Цель: к моменту появления жителей снаружи есть куда идти и что делать.
**Этап 2. Скелет сервера (1–2 недели)**
- [x] Rust-проект: `axum`, WebSocket, протокол (`proto/`), одна комната, `bevy_ecs`.
- [x] Загрузка манифеста и тайлов на сервере (навигационная сетка).
- [x] Подключение клиента, подписка на область, снимок.
**Этап 2б. Сервис генерации**
- [x] Очередь с лимитами площади, зданий, одновременных задач и таймаутом.
- [x] Изолированный TS-воркер, прогресс, кэш по нормализованному bbox, seed и версии.
- [x] `CreateCity` / `CityJob` в общей Protobuf-схеме и интерфейс создания города.
- [x] Сквозной тест запроса, отдельного Node-процесса, экспорта, открытия комнаты и сохранения без внешней сети.
**Этап 3. Простая симуляция (2–3 недели)**
- [x] Агенты ходят по дорогам между домом и работой по расписанию.
- [x] Дельты по AOI, интерполяция на клиенте.
- [x] Детерминизм: seed комнаты, воспроизводимые тики.
- [x] Бинарные атомарные снимки, ротация, ленивое открытие сохранённых комнат и тест эквивалентности после восстановления.
**Этап 4. Масштаб и LOD (2–3 недели)**
- [x] Нагрузочный стенд (1 млн агентов, 30 клиентов).
- [x] Профилирование, LOD симуляции, параллелизм систем.
Реализованы грубый шаг дальних маршрутов, общий пространственный индекс, стабильный LOD репликации и параллельная обработка движения через пул ECS. Построение индекса зависит от движения и идёт следующим шагом.
- [x] Оптимизация протокола и рендера.
Бинарные дельты, квантизация, объединение пропущенных обновлений под бюджетом трафика, ограничение видимых сущностей и кэш тайлов; динамика пока на Canvas.
**Этап 5. Игровой слой (дальше)**
- [ ] Потребности, события, экономика.
- [ ] Ограниченное влияние игроков (команды, права, очереди, лимиты).
- [ ] Сохранения, восстановление комнат, админка.
Сохранения и восстановление уже работают; админка, миграции схем и квоты диска остаются следующей частью этапа.
**Этап 6. Эксплуатация**
- [ ] Деплой, мониторинг (метрики тика, число сущностей, трафик), логи.
Метрики, логи и Dockerfile подготовлены. Docker Engine в текущей среде не запущен; внешний сервер для деплоя не задан.
- [x] Создание комнат по запросу (выбор города), лимиты ресурсов.
Проверка минимальной сцены: `npm run demo`, `npm run build`, `npm run server:dev`, затем `http://localhost:3000/?play`.
Имя репозитория остаётся `citygen`; генератор и существующий рендер сохраняют свои пути в `src/`, новые сетевые модули находятся в `client/`.
Состояние и ограничения реализации описаны в `server/README.md`. Этапы 5–6 не объявляются завершёнными готовностью сетевого прототипа.
## 10. Риски
| Риск | Что делаем |
|---|---|
| 1 млн сущностей не успевают в тик | LOD, чанки, разреженные системы; замер на этапе 4, а не в конце |
| Объём рассылки вырастет | AOI, квантование, дельты, приоритет близким к камере |
| Размер тайлов и время загрузки | Компактный бинарный формат, сжатие, ленивая загрузка тайлов по камере |
| Крутая кривая Rust для команды | Держать код простым, избегать лишних абстракций, начать с одной комнаты |
| Изменение формата данных ломает сохранения | Версия формата, миграции, тесты совместимости |
| Дублирование типов между Rust и TS | Единая схема в `proto/`, кодогенерация |
## 11. Принятые решения, допущения и открытые вопросы
Принятые решения:
1. **Интерьеры нужны на сервере** (раздел 4): жители ходят по интерьерам и улицам и взаимодействуют с ними.
2. **Без аккаунтов.** Вход по нику; персистентности игрока между комнатами пока нет. Ник уникален в пределах комнаты, идентификатор сессии выдаёт сервер.
3. **Создание комнат игроком** с лимитом из конфига сервера (`max_rooms`, по умолчанию 5). При достижении лимита создание отклоняется с понятной ошибкой. Пустые комнаты закрываются по таймауту (параметр конфига), чтобы не занимать слот.
4. **Время общее для комнаты.** Пауза и смена скорости — через голосование: предложение принимается, если «за» большинство подключённых игроков (более половины). Один игрок в комнате решает сам. Нужны: таймаут голосования, один активный вопрос за раз, защита от спама предложениями.
5. **Только десктопные браузеры.** Мобильные не поддерживаем, рендер и управление рассчитываем на мышь и клавиатуру.
6. **`max_rooms` — лимит на весь сервер** одновременно открытых комнат (подтверждено).
7. **Игрок создаёт свой город**: выбирает область на карте и запускает генерацию (раздел 12). Каталог готовых городов тоже остаётся.
8. **Владельца комнаты нет**: создатель не может закрыть комнату, выгнать игрока или поставить пароль. Комната закрывается только по таймауту пустоты.
9. **Комнаты переживают перезапуск сервера**: состояние сохраняется на диск (раздел 13).
Допущения (если неверны, скажи — поправлю):
1. Серверная симуляция детерминируема от seed. Отдельный вопрос, нужна ли строгая детерминированность (для повторов и отладки).
2. Деплой на один сервер, комнаты как задачи внутри одного процесса. Горизонтальное масштабирование — позже.
3. Бюджет железа: современный многоядерный сервер, 8+ ядер.
Открытые вопросы:
1. Лимит размера области для генерации (площадь, число зданий) и лимит одновременных генераций. Предварительно: берём из конфига, значения подобрать после замеров на этапе 1.
2. Что делать с комнатой без владельца, если она не закрывается: пустая комната закрывается через N минут, но сохраняется на диск и может быть открыта заново? Предварительно: да, закрытая комната хранится как снимок, пока есть место.
3. Нужна ли защита от злоупотреблений при отсутствии аккаунтов (лимит создания по IP, антиспам)? Предварительно: да, простой лимит по IP.
## 12. Создание города игроком
Генерация города — тяжёлая работа (запросы Overpass, построение зданий и интерьеров), поэтому выполняется отдельным сервисом, а не в потоке комнаты.
Процесс:
1. Игрок на карте выбирает прямоугольник (в клиенте уже есть выбор области: `src/view/picker.ts`, ограничения размера в `src/main.ts`).
2. Клиент отправляет серверу `CreateCity { bbox, name }`. Сервер проверяет лимит площади, лимит очереди и лимит комнат (`max_rooms`).
3. Задача попадает в очередь. Воркер (текущий TS-генератор как отдельный Node-процесс, использующий `scripts/cli.ts` и `src/gen`) запрашивает Overpass, генерирует город, экспортирует тайлы и манифест (этап 1) в хранилище.
4. Клиент видит прогресс (в очереди, загрузка OSM, генерация, экспорт). По готовности город попадает в каталог игрока и создаётся комната.
5. Ошибки Overpass (таймауты, лимиты) обрабатываются повторами и понятными сообщениями; уже есть наработки в `src/osm/overpass.ts`.
Требования:
- Города кэшируются по нормализованному bbox и версии генератора, чтобы одну область не генерировать дважды.
- Ограничения: максимальная площадь и число зданий, максимум одновременных задач (конфиг), таймаут задачи.
- Версия формата и генератора пишется в манифест; при смене версии кэш инвалидируется.
- Генерация должна быть детерминированной от (bbox, seed, версия), чтобы город можно было воспроизвести.
## 13. Сохранение и восстановление комнат
- **Снимки** `World` раз в N минут и при остановке: сущности, компоненты, время игры, seed, состояние голосований не сохраняется (сбрасывается).
- **Формат**: бинарный, версионируемый (протокол из `proto/`), атомарная запись во временный файл с переименованием, хранение последних K снимков.
- **Восстановление**: при старте сервера список комнат читается с диска, комнаты открываются лениво при первом подключении игрока, чтобы не держать в памяти все.
- **Миграции**: при смене схемы компонентов снимок либо мигрируется, либо комната помечается несовместимой с понятным сообщением.
- **Целостность**: комната ссылается на город по идентификатору и версии манифеста; удаление или смена города при существующих комнатах запрещена.
Дополнение к этапам:
- Этап 2: пустая комната сохраняется и восстанавливается (скелет сохранения).
- Этап 3: сохранение/загрузка простой симуляции, тест «остановил — запустил — состояние совпадает».
- Этап 5: миграции, ротация снимков, лимиты места.
- Новый этап **2б. Сервис генерации** (2 недели): очередь задач, воркер на базе `scripts/cli.ts`, кэш по bbox, прогресс и лимиты, протокол `CreateCity` и ответы о статусе.
## 14. Проверки реализации
- TypeScript: `npm run typecheck`, `npm test`, `npm run build`.
- Rust: `npm run server:test`; отдельно проверяются навигация через двери, точность пространственного индекса, детерминизм, атомарные снимки и ротация.
- Интеграция: 30 настоящих WebSocket-подключений, отказ 31-му, голосование 15/30 против 16/30, повторное подключение после восстановления.
- Очередь: Protobuf-запрос → отдельный TS-процесс с локальной OSM-выгрузкой → экспорт с контрольными суммами → открытие комнаты → сохранение. Внешний Overpass в тесте не используется.
- Chrome: каталог, вход, загрузка тайлов, этажи и пауза через голосование. Повторяемая проверка: `scripts/browser-smoke.mjs` при установленном Playwright.
- Реальная нагрузка, 2026-10-30: Бальцерс, 1 000 000 жителей, 30 WebSocket-клиентов, 20 с, фоновые сохранения каждые 2 с. Выборка длительности тиков через метрики: p95 36,5 мс; максимум всех тиков по счётчику комнаты 44,7 мс. Средний трафик 4,8 КБ/с на клиента, максимальный 44,7 КБ/с. Ошибок, разрывов и срабатываний ограничения трафика не было. Это короткий локальный тест с небольшими AOI, а не гарантия для любого города, плотности, диска и целевого сервера. Пиковая память процесса со статикой и буферами — около 272 МиБ; память одной ECS отдельно не измерялась.
- Браузер: повторный вход в сохранённую миллионную комнату до загруженной области — 1,31 с в локальном Chrome.
Детальные пути между этажами, использование мебели жителями, потребности,
события, экономика и вмешательство игроков остаются игровым слоем этапа 5.
Для эксплуатации также остаются миграции снимков, квоты диска, админка,
проверка контейнера и развёртывание в выбранном окружении.
+257
View File
@@ -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",
+15 -1
View File
@@ -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"
}
}
+12
View File
@@ -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.
+32
View File
@@ -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;
}
+34
View File
@@ -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();}
+21
View File
@@ -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);
});
+35
View File
@@ -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);
});
+128
View File
@@ -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<void>(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; }
});
+1859
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -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"
+73
View File
@@ -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-сборка и внешний
деплой требуют отдельной проверки в целевом окружении.
+5
View File
@@ -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");
}
+55
View File
@@ -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");
}
}
+333
View File
@@ -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<String>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
pub format_version: u32,
pub city: Metadata,
pub tiles: Vec<TileRef>,
pub navigation: Option<NavigationRef>,
}
#[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<Vec<usize>>,
}
#[derive(Clone)]
pub struct City {
pub id: String,
pub manifest: Manifest,
pub graph: Graph,
pub revision: String,
homes: BTreeMap<String, [i32; 2]>,
workplaces: BTreeMap<String, [i32; 2]>,
}
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<Self, String> {
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<usize> = 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<Commute> {
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<Vec<[i32; 2]>> {
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<Vec<[i32; 2]>> {
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
}
}
+986
View File
@@ -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<PathBuf>,
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<Mutex<HashMap<String, RoomHandle>>>,
limits: Arc<Mutex<HashMap<std::net::IpAddr, Instant>>>,
pub jobs: crate::generation::Jobs,
}
struct RoomHandle {
city_id: String,
commands: mpsc::SyncSender<Command>,
active: Arc<AtomicBool>,
players: Arc<AtomicUsize>,
thread: Option<std::thread::JoinHandle<()>>,
metrics: Arc<RoomMetrics>,
}
#[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<p::ServerMessage>,
},
Leave(String),
Input(String, p::ClientMessage),
Stop,
}
struct Player {
nickname: String,
out: async_mpsc::Sender<p::ServerMessage>,
rect: Option<p::Rect>,
lod: u32,
known: HashMap<u32, p::Agent>,
last_proposal: Option<Instant>,
traffic: Traffic,
metrics: Arc<RoomMetrics>,
}
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<String>,
voted: HashSet<String>,
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<u64>,
}
fn message(body: server_message::Body) -> p::ServerMessage {
p::ServerMessage { body: Some(body) }
}
fn error(code: &str, text: impl Into<String>) -> 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<serde_json::Value>);
fn api_error(code: StatusCode, text: impl Into<String>) -> ApiError {
(code, Json(serde_json::json!({"error":text.into()})))
}
impl App {
pub fn new(config: Config) -> Result<Self, String> {
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<mpsc::SyncSender<Command>, 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<App>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
body: axum::body::Bytes,
) -> Result<impl IntoResponse, ApiError> {
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<App>,
Path(id): Path<String>,
) -> Result<impl IntoResponse, ApiError> {
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::<serde_json::Value>(&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<App>) -> Json<Vec<RoomInfo>> {
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<App>) -> Json<Vec<serde_json::Value>> {
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::<crate::city::Manifest>(&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<App>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Json(request): Json<CreateRoom>,
) -> Result<Json<serde_json::Value>, 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<App>) -> 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<App>,
Path(id): Path<String>,
ws: WebSocketUpgrade,
) -> Result<impl IntoResponse, ApiError> {
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<Command>) {
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<Command>,
config: Config,
active: Arc<AtomicBool>,
player_count: Arc<AtomicUsize>,
mut navigation: crate::navigation::Cache,
metrics: Arc<RoomMetrics>,
) {
struct Lifecycle(Arc<AtomicBool>, Arc<AtomicUsize>);
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::<crate::simulation::Checkpoint>(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::<String, Player>::new();
let mut ballot: Option<Ballot> = 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::<crate::simulation::Clock>().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::<Vec<_>>(),
);
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
}));
}
}
+231
View File
@@ -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<String>,
pub error: Option<String>,
}
#[derive(Clone)]
pub struct Jobs {
pub items: Arc<Mutex<HashMap<String, Job>>>,
workers: Arc<Semaphore>,
}
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<Job, String> {
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<String, String> {
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::<String>()
));
}
}
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());
}
}
+9
View File
@@ -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;
+65
View File
@@ -0,0 +1,65 @@
use citygen_server::gateway::{self, App, Config};
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
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::<std::net::SocketAddr>(),
)
.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(())
}
+285
View File
@@ -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<serde_json::Value>,
}
#[derive(Clone, Deserialize)]
pub struct Floor {
pub level: i32,
pub walls: Vec<Wall>,
pub doors: Vec<Door>,
pub stairs: Vec<Stairs>,
pub props: Vec<Prop>,
}
#[derive(Clone, Deserialize)]
pub struct Building {
pub id: String,
pub footprint: Vec<Point>,
pub holes: Vec<Vec<Point>>,
pub floors: Vec<Floor>,
}
#[derive(Deserialize)]
struct Tile {
#[serde(rename = "formatVersion")]
version: u32,
#[serde(rename = "cityId")]
city_id: String,
id: String,
buildings: Vec<Building>,
}
pub struct Cache {
root: PathBuf,
manifest: Manifest,
tiles: HashMap<String, Tile>,
order: VecDeque<String>,
}
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<Vec<Point>> {
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());
}
}
+548
View File
@@ -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<Arc<Path>>);
#[derive(Resource)]
pub struct Lod {
detailed: Vec<bool>,
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<Agent>>,
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<f64>,
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<Clock>,
routes: Res<Routes>,
lod: Res<Lod>,
mut changes: ResMut<PositionChanges>,
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<Lod>,
changes: Res<PositionChanges>,
agents: Query<(&Resident, &Position)>,
mut index: ResMut<SpatialIndex>,
) {
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<Vec<u32>>,
population: Arc<Vec<SavedAgent>>,
}
pub struct Checkpoint {
metadata: RoomSave,
population: Arc<Vec<SavedAgent>>,
}
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<Vec<[i32; 2]>>,
) -> Result<Self, String> {
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::<usize>().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::<Lod>().force = false;
Ok(s)
}
pub fn step(&mut self) {
{
let mut clock = self.world.resource_mut::<Clock>();
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::<Clock>().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::<Routes>();
let detailed: Vec<bool> = 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::<Lod>().detailed != detailed {
self.world.resource_mut::<Lod>().detailed = detailed;
self.world.resource_mut::<SpatialIndex>().invalid = true;
}
}
pub fn visible(
&mut self,
rect: &crate::protocol::Rect,
limit: usize,
) -> Result<HashMap<u32, Agent>, String> {
self.visible_fresh_lod(rect, limit, 0)
}
pub fn visible_lod(
&mut self,
rect: &crate::protocol::Rect,
limit: usize,
lod: u32,
) -> Result<HashMap<u32, Agent>, String> {
let index = self.world.resource::<SpatialIndex>();
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<HashMap<u32, Agent>, String> {
let mut result = HashMap::new();
let clock = self.clock();
let routes = self.world.resource::<Routes>();
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::<Goals>().0;
let routes = self
.world
.resource::<Routes>()
.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<Self, String> {
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::<Routes>().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::<Lod>().force = true;
s.schedule.run(&mut s.world);
s.world.resource_mut::<Lod>().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::<Clock>().speed = 0;
let seconds = a.clock().seconds;
a.step();
assert_eq!(a.clock().seconds, seconds);
}
}
+94
View File
@@ -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<Vec<PathBuf>, 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<RoomSave, String> {
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());
}
}
+121
View File
@@ -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<u8>) {
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::<std::net::SocketAddr>(),
)
.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
);
}
+255
View File
@@ -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<tokio::net::TcpStream>,
>,
) -> 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::<std::net::SocketAddr>(),
)
.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::<std::net::SocketAddr>(),
)
.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();
}
+136
View File
@@ -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<World, 'areas' | 'roads' | 'buildings' | 'props' | 'models'>;
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<string>();
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<string>();
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<string, CityTile> } {
if (world.version !== 1 || !validBounds(world.bounds) || !Number.isFinite(tileSize) || tileSize < 1) throw new Error('Invalid world or tile size');
const tiles = new Map<string, CityTile>();
const included = { areas: new Set<string>(), roads: new Set<string>(), buildings: new Set<string>(), props: new Set<string>() };
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<CityTile>): World {
const world: World = { ...manifest.city, areas: [], roads: [], buildings: [], props: [], models: Object.create(null) };
const seen = { areas: new Set<string>(), roads: new Set<string>(), buildings: new Set<string>(), props: new Set<string>() };
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;
}
+2
View File
@@ -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 };
}
+2
View File
@@ -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;
+2
View File
@@ -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;
}
+44
View File
@@ -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<string, ObjectType> = {
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<string>();
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;
}
+3
View File
@@ -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;
}
+106
View File
@@ -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<World['buildings'][number]>(50);
for (const b of world.buildings) buildings.insert(b, bboxOf(b.footprint));
const water = new SpatialGrid<World['areas'][number]>(100);
for (const a of world.areas) if (['water', 'wetland'].includes(a.kind)) water.insert(a, bboxOf(a.rings[0]));
const occupied = new SpatialGrid<Prop>(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);
}
}
}
+171 -1
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 = `
<div class="brand">Город сверху</div>
<a class="btn" href="/?play">Сетевая игра — комнаты</a>
<p class="lead">Выделите на карте небольшой город или район — по данным OpenStreetMap будет построена подробная карта с планировками и мебелью во всех зданиях.</p>
<form class="search"><input type="search" placeholder="Найти место: город, район, улица…" autocomplete="off"><button class="btn">Найти</button></form>
<div class="results"></div>
@@ -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<typeof setTimeout>;
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<HTMLElement>('.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 = `<div class="brand">Комнаты города</div>
<label class="field">Ник <input name="nickname" maxlength="24" value="Наблюдатель"></label>
<div class="row"><select aria-label="Город"></select><button class="btn primary" data-create>Создать комнату</button><button class="btn" data-refresh>Обновить</button></div>
<div data-rooms></div><p role="status" data-status></p><a class="btn" href="/?create-city">Создать город на сервере</a><a class="btn" href="/">Генератор для отладки</a>`;
app.appendChild(panel);
const status = panel.querySelector<HTMLElement>('[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<HTMLInputElement>('[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<HTMLButtonElement>('[data-refresh]')!.onclick = () => { void refresh(); };
panel.querySelector<HTMLButtonElement>('[data-create]')!.onclick = async () => {
const button = panel.querySelector<HTMLButtonElement>('[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);
+3
View File
@@ -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;`;
}
+10
View File
@@ -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;
}
+55 -6
View File
@@ -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 = <K extends keyof HTMLElementTagNameMap>(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<number, { x: number; y: number }>();
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', '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> 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 {
<div class="title">${esc(w.name || 'Без названия')}</div>
<div class="stats">${w.buildings.length.toLocaleString('ru')} зданий · ${floors.toLocaleString('ru')} этажей · ${rooms.toLocaleString('ru')} комнат · ${furn.toLocaleString('ru')} предметов · ${homes.toLocaleString('ru')} квартир/домов · ~${beds.toLocaleString('ru')} жителей</div>
<div class="row">
<button class="btn" data-a="new">Новая карта</button>
<button class="btn" data-a="new">${esc(this.actions.newLabel || 'Новая карта')}</button>
<button class="btn" data-a="export" title="Скачать мир целиком (JSON) — формат для будущей игры">Экспорт мира</button>
<label class="chip"><input type="checkbox" data-t="furniture" checked> Мебель</label>
<label class="chip"><input type="checkbox" data-t="labels" checked> Подписи</label>
</div>`;
top.querySelector('[data-a=new]')!.addEventListener('click', () => this.actions.onNew());
top.querySelector('[data-a=export]')!.addEventListener('click', () => this.actions.onExport());
const exportButton = top.querySelector<HTMLButtonElement>('[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<HTMLInputElement>('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();
});
}
+14
View File
@@ -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.
+15
View File
@@ -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}`);
}
+43
View File
@@ -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')}`);
}
+27
View File
@@ -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('Готово');
+53
View File
@@ -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<number>(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<void>((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();}
+14
View File
@@ -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;});
+1 -1
View File
@@ -9,5 +9,5 @@
"skipLibCheck": true,
"types": []
},
"include": ["src"]
"include": ["src", "client"]
}