Implement tiled multiplayer city simulation and scaling stages
This commit is contained in:
1 parent
3d4b29fedf
commit
3b1fcd996a
53 files changed
+7354
-13
No files matched your search
@@ -0,0 +1,8 @@
|
|||||||
|
.git
|
||||||
|
.tools
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
server/target
|
||||||
|
data
|
||||||
|
world.json
|
||||||
|
public/cities
|
||||||
@@ -2,3 +2,7 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
*.log
|
*.log
|
||||||
world.json
|
world.json
|
||||||
|
.tools/
|
||||||
|
server/target/
|
||||||
|
data/
|
||||||
|
public/cities/
|
||||||
+25
@@ -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"]
|
||||||
@@ -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 строится подробная карта
|
Выделяете на карте небольшой город или район → по данным OSM строится подробная карта
|
||||||
с видом строго сверху: дороги реальной ширины с тротуарами и разметкой, парковки
|
с видом строго сверху: дороги реальной ширины с тротуарами и разметкой, парковки
|
||||||
с машинами, деревья, и **у каждого здания — этажи, комнаты, двери, окна, лестницы
|
с машинами, деревья, и **у каждого здания — этажи, комнаты, двери, окна, лестницы
|
||||||
и мебель**, нарисованные простыми геометрическими фигурами, как на плане квартиры.
|
и мебель**, нарисованные простыми геометрическими фигурами, как на плане квартиры.
|
||||||
|
|
||||||
Всё работает в браузере, без сервера: данные берутся из Overpass API, генерация
|
В отладочном режиме всё работает в браузере: данные берутся из Overpass API, генерация
|
||||||
идёт в Web Worker'ах, готовый мир сохраняется в IndexedDB.
|
идёт в 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` или `[` `]`;
|
- справа — этаж для всей карты (⌂ — крыши), `PgUp`/`PgDn` или `[` `]`;
|
||||||
- клик по зданию — карточка: вид, этажность (из OSM или оценка), квартиры, спальные места,
|
- клик по зданию — карточка: вид, этажность (из OSM или оценка), квартиры, спальные места,
|
||||||
свой переключатель этажей только для этого здания;
|
свой переключатель этажей только для этого здания;
|
||||||
- «Экспорт мира» — весь мир одним файлом `.json.gz` (будущий формат для игры).
|
- «Экспорт мира» в отладочном режиме — весь мир одним файлом `.json.gz`; `export-city` преобразует его в серверный тайловый формат.
|
||||||
|
|
||||||
## Как устроена генерация
|
## Как устроена генерация
|
||||||
|
|
||||||
@@ -74,7 +110,7 @@ Overpass JSON ─► city.ts ──────────────► зо
|
|||||||
|
|
||||||
## Модель данных мира (`src/types.ts`)
|
## Модель данных мира (`src/types.ts`)
|
||||||
|
|
||||||
Заложено под будущую симуляцию:
|
Статические данные уже используются сервером; для дальнейшего игрового слоя предусмотрены:
|
||||||
|
|
||||||
| Что | Зачем потом |
|
| Что | Зачем потом |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
version: v2
|
||||||
|
plugins:
|
||||||
|
- local: protoc-gen-es
|
||||||
|
out: client/generated
|
||||||
|
opt: target=ts
|
||||||
@@ -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.
|
||||||
@@ -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(); }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -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
@@ -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.
|
||||||
|
Для эксплуатации также остаются миграции снимков, квоты диска, админка,
|
||||||
|
проверка контейнера и развёртывание в выбранном окружении.
|
||||||
Generated
+257
@@ -7,12 +7,219 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "citygen",
|
"name": "citygen",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.16.0",
|
||||||
|
"@noble/hashes": "^2.4.0"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@bufbuild/buf": "^1.75.0",
|
||||||
|
"@bufbuild/protoc-gen-es": "^2.16.0",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||||
@@ -455,6 +662,49 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.2",
|
"version": "0.28.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
"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": "^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": {
|
"node_modules/tsx": {
|
||||||
"version": "4.23.15",
|
"version": "4.23.15",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
|
||||||
|
|||||||
+15
-1
@@ -9,11 +9,25 @@
|
|||||||
"dev": "node build.mjs --serve",
|
"dev": "node build.mjs --serve",
|
||||||
"build": "node build.mjs",
|
"build": "node build.mjs",
|
||||||
"typecheck": "tsc -p .",
|
"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": {
|
"devDependencies": {
|
||||||
|
"@bufbuild/buf": "^1.75.0",
|
||||||
|
"@bufbuild/protoc-gen-es": "^2.16.0",
|
||||||
"esbuild": "^0.28.0",
|
"esbuild": "^0.28.0",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"typescript": "^5.6.0"
|
"typescript": "^5.6.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bufbuild/protobuf": "^2.16.0",
|
||||||
|
"@noble/hashes": "^2.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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; }
|
||||||
|
});
|
||||||
Generated
+1859
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
@@ -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-сборка и внешний
|
||||||
|
деплой требуют отдельной проверки в целевом окружении.
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { Rng, hashString } from '../util/rng';
|
|||||||
import { SpatialGrid } from '../util/spatial';
|
import { SpatialGrid } from '../util/spatial';
|
||||||
import { classify, clampLevels, defaultLevels, parseLevels } from './classify';
|
import { classify, clampLevels, defaultLevels, parseLevels } from './classify';
|
||||||
import { carShapes, CAR_COLORS, treeProp } from './props';
|
import { carShapes, CAR_COLORS, treeProp } from './props';
|
||||||
|
import { populateStreets } from './streets';
|
||||||
|
|
||||||
export interface InteriorJob {
|
export interface InteriorJob {
|
||||||
building: Building;
|
building: Building;
|
||||||
@@ -379,6 +380,7 @@ export function buildCity(elements: OsmElement[], bbox: BBox, name: string, seed
|
|||||||
models: {},
|
models: {},
|
||||||
attribution: '© OpenStreetMap contributors (ODbL)',
|
attribution: '© OpenStreetMap contributors (ODbL)',
|
||||||
};
|
};
|
||||||
|
populateStreets(world, elements);
|
||||||
return { world, jobs };
|
return { world, jobs };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Финальная упаковка мира: общие модели предметов и округление координат до сантиметра.
|
// Финальная упаковка мира: общие модели предметов и округление координат до сантиметра.
|
||||||
|
|
||||||
import type { Building, Prop, Shape, World } from '../types';
|
import type { Building, Prop, Shape, World } from '../types';
|
||||||
|
import { attachInteractions } from './objects';
|
||||||
|
|
||||||
const r2 = (v: number) => Math.round(v * 100) / 100;
|
const r2 = (v: number) => Math.round(v * 100) / 100;
|
||||||
const r3 = (v: number) => Math.round(v * 1000) / 1000;
|
const r3 = (v: number) => Math.round(v * 1000) / 1000;
|
||||||
@@ -44,6 +45,7 @@ function roundProp(p: Prop) {
|
|||||||
p.rot = r3(p.rot);
|
p.rot = r3(p.rot);
|
||||||
p.w = r2(p.w);
|
p.w = r2(p.w);
|
||||||
p.d = r2(p.d);
|
p.d = r2(p.d);
|
||||||
|
attachInteractions(p);
|
||||||
if (!p.tags?.length) delete p.tags;
|
if (!p.tags?.length) delete p.tags;
|
||||||
if (!p.roomId) delete p.roomId;
|
if (!p.roomId) delete p.roomId;
|
||||||
if (!p.color) delete p.color;
|
if (!p.color) delete p.color;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { World } from '../types';
|
|||||||
import { buildCity, type InteriorJob } from './city';
|
import { buildCity, type InteriorJob } from './city';
|
||||||
import { generateInterior, type InteriorResult } from './interior';
|
import { generateInterior, type InteriorResult } from './interior';
|
||||||
import { finalizeWorld } from './finalize';
|
import { finalizeWorld } from './finalize';
|
||||||
|
import { addStreetEntrances } from './streets';
|
||||||
|
|
||||||
export type Progress = (stage: string, done: number, total: number) => void;
|
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);
|
console.warn('interior failed', job.building.id, e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
addStreetEntrances(world);
|
||||||
finalizeWorld(world);
|
finalizeWorld(world);
|
||||||
return world;
|
return world;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import type { OsmElement, BBox } from '../osm/overpass';
|
|||||||
import type { World } from '../types';
|
import type { World } from '../types';
|
||||||
import { buildCity, type InteriorJob } from './city';
|
import { buildCity, type InteriorJob } from './city';
|
||||||
import { finalizeWorld } from './finalize';
|
import { finalizeWorld } from './finalize';
|
||||||
|
import { addStreetEntrances } from './streets';
|
||||||
|
|
||||||
export type OnProgress = (text: string, frac: number) => void;
|
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);
|
finalizeWorld(world, false);
|
||||||
onProgress(`Планировки: 0 / ${jobs.length}`, 0.1);
|
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);
|
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);
|
onProgress('Готово', 1);
|
||||||
return world;
|
return world;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -4,6 +4,11 @@ import { fetchOverpass, geocode, type BBox, type OsmElement } from './osm/overpa
|
|||||||
import { generateWorld } from './gen/pipeline';
|
import { generateWorld } from './gen/pipeline';
|
||||||
import { downloadWorld, hasWorld, loadWorld, readJsonFile, saveWorld } from './store';
|
import { downloadWorld, hasWorld, loadWorld, readJsonFile, saveWorld } from './store';
|
||||||
import type { World } from './types';
|
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 MAX_KM2 = 25;
|
||||||
const WARN_KM2 = 10;
|
const WARN_KM2 = 10;
|
||||||
@@ -11,6 +16,17 @@ const WARN_KM2 = 10;
|
|||||||
const app = document.getElementById('app')!;
|
const app = document.getElementById('app')!;
|
||||||
let viewer: Viewer | null = null;
|
let viewer: Viewer | null = null;
|
||||||
let picker: AreaPicker | null = null;
|
let picker: AreaPicker | null = null;
|
||||||
|
let tileLoader: TileLoader | null = null;
|
||||||
|
let cityLoad: AbortController | null = null;
|
||||||
|
let roomConnection: RoomConnection | null = null;
|
||||||
|
function stopRoom() { roomConnection?.destroy(); roomConnection = null; }
|
||||||
|
|
||||||
|
function stopTileLoading() {
|
||||||
|
cityLoad?.abort();
|
||||||
|
cityLoad = null;
|
||||||
|
tileLoader?.destroy();
|
||||||
|
tileLoader = null;
|
||||||
|
}
|
||||||
|
|
||||||
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
||||||
|
|
||||||
@@ -33,6 +49,8 @@ const hideProgress = () => (overlay.hidden = true);
|
|||||||
// ------------------------------------------------------------------ экраны
|
// ------------------------------------------------------------------ экраны
|
||||||
|
|
||||||
function showViewer(world: World) {
|
function showViewer(world: World) {
|
||||||
|
stopRoom();
|
||||||
|
stopTileLoading();
|
||||||
picker = null;
|
picker = null;
|
||||||
app.innerHTML = '';
|
app.innerHTML = '';
|
||||||
viewer?.destroy();
|
viewer?.destroy();
|
||||||
@@ -44,6 +62,8 @@ function showViewer(world: World) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function showPicker(initial?: BBox) {
|
async function showPicker(initial?: BBox) {
|
||||||
|
stopRoom();
|
||||||
|
stopTileLoading();
|
||||||
viewer?.destroy();
|
viewer?.destroy();
|
||||||
viewer = null;
|
viewer = null;
|
||||||
app.innerHTML = '';
|
app.innerHTML = '';
|
||||||
@@ -54,6 +74,7 @@ async function showPicker(initial?: BBox) {
|
|||||||
panel.className = 'picker-panel card';
|
panel.className = 'picker-panel card';
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
<div class="brand">Город сверху</div>
|
<div class="brand">Город сверху</div>
|
||||||
|
<a class="btn" href="/?play">Сетевая игра — комнаты</a>
|
||||||
<p class="lead">Выделите на карте небольшой город или район — по данным OpenStreetMap будет построена подробная карта с планировками и мебелью во всех зданиях.</p>
|
<p class="lead">Выделите на карте небольшой город или район — по данным OpenStreetMap будет построена подробная карта с планировками и мебелью во всех зданиях.</p>
|
||||||
<form class="search"><input type="search" placeholder="Найти место: город, район, улица…" autocomplete="off"><button class="btn">Найти</button></form>
|
<form class="search"><input type="search" placeholder="Найти место: город, район, улица…" autocomplete="off"><button class="btn">Найти</button></form>
|
||||||
<div class="results"></div>
|
<div class="results"></div>
|
||||||
@@ -144,16 +165,36 @@ async function showPicker(initial?: BBox) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
goBtn.addEventListener('click', async () => {
|
goBtn.addEventListener('click', async () => {
|
||||||
if (!bbox) return;
|
if (!bbox || goBtn.disabled) return;
|
||||||
|
goBtn.disabled = true;
|
||||||
showErr(null);
|
showErr(null);
|
||||||
abort = new AbortController();
|
abort = new AbortController();
|
||||||
|
(overlay.querySelector('[data-a=cancel]') as HTMLButtonElement).textContent = new URLSearchParams(location.search).has('create-city') ? 'Закрыть ожидание' : 'Отмена';
|
||||||
try {
|
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);
|
progress('Загрузка данных OpenStreetMap…', 0.02);
|
||||||
const els = await fetchOverpass(bbox, (s) => progress(s, 0.03), abort.signal);
|
const els = await fetchOverpass(bbox, (s) => progress(s, 0.03), abort.signal);
|
||||||
await runGeneration(els, bbox, nameInput.value.trim() || 'Новая карта');
|
await runGeneration(els, bbox, nameInput.value.trim() || 'Новая карта');
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
hideProgress();
|
hideProgress();
|
||||||
if ((ex as Error).name !== 'AbortError') showErr((ex as Error).message);
|
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 () => {
|
(async () => {
|
||||||
const params = new URLSearchParams(location.search);
|
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())) {
|
if (params.has('last') && (await hasWorld())) {
|
||||||
const w = await loadWorld();
|
const w = await loadWorld();
|
||||||
if (w) return showViewer(w);
|
if (w) return showViewer(w);
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export function buildQuery(b: BBox): string {
|
|||||||
node["tourism"]${bb};
|
node["tourism"]${bb};
|
||||||
node["healthcare"]${bb};
|
node["healthcare"]${bb};
|
||||||
node["highway"="street_lamp"]${bb};
|
node["highway"="street_lamp"]${bb};
|
||||||
|
node["highway"="bus_stop"]${bb};
|
||||||
|
node["highway"="crossing"]${bb};
|
||||||
|
node["public_transport"="platform"]${bb};
|
||||||
);
|
);
|
||||||
out geom;`;
|
out geom;`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,4 +194,14 @@ export interface Prop {
|
|||||||
color?: string;
|
color?: string;
|
||||||
/** Для симуляции: можно сидеть/спать/хранить и т.п. */
|
/** Для симуляции: можно сидеть/спать/хранить и т.п. */
|
||||||
tags?: 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
@@ -4,6 +4,7 @@ import type { Building, Vec, World } from '../types';
|
|||||||
import { Renderer, type Camera, type ViewOpts } from './renderer';
|
import { Renderer, type Camera, type ViewOpts } from './renderer';
|
||||||
import { KIND_NAME, ROOM_NAME } from './style';
|
import { KIND_NAME, ROOM_NAME } from './style';
|
||||||
import { centroid } from '../util/geom';
|
import { centroid } from '../util/geom';
|
||||||
|
import type { Bounds } from '../city/format';
|
||||||
|
|
||||||
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string) => {
|
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string) => {
|
||||||
const e = document.createElement(tag);
|
const e = document.createElement(tag);
|
||||||
@@ -21,6 +22,9 @@ export class Viewer {
|
|||||||
cam: Camera;
|
cam: Camera;
|
||||||
opts: ViewOpts = { floor: null, overrides: new Map(), selected: null, furniture: true, labels: true };
|
opts: ViewOpts = { floor: null, overrides: new Map(), selected: null, furniture: true, labels: true };
|
||||||
private raf = 0;
|
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 floorBar = el('div', 'floor-bar card');
|
||||||
private info = el('div', 'info card');
|
private info = el('div', 'info card');
|
||||||
private scale = el('div', 'scale');
|
private scale = el('div', 'scale');
|
||||||
@@ -28,28 +32,52 @@ export class Viewer {
|
|||||||
private pointers = new Map<number, { x: number; y: number }>();
|
private pointers = new Map<number, { x: number; y: number }>();
|
||||||
private gesture: { x: number; y: number; cam: Camera; d?: number; moved: boolean } | null = null;
|
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);
|
this.renderer = new Renderer(world);
|
||||||
const B = world.bounds;
|
const B = world.bounds;
|
||||||
this.cam = { x: (B.minX + B.maxX) / 2, y: (B.minY + B.maxY) / 2, z: 1 };
|
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'));
|
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);
|
parent.appendChild(this.root);
|
||||||
this.info.hidden = true;
|
this.info.hidden = true;
|
||||||
this.buildFloorBar();
|
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();
|
this.bindInput();
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
|
if (this.disposed) return;
|
||||||
this.resize();
|
this.resize();
|
||||||
this.fitBounds();
|
this.fitBounds();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
this.disposed = true;
|
||||||
|
cancelAnimationFrame(this.raf);
|
||||||
|
this.resizeObserver.disconnect();
|
||||||
this.root.remove();
|
this.root.remove();
|
||||||
window.removeEventListener('keydown', this.onKey);
|
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 {
|
private buildTop(): HTMLElement {
|
||||||
const top = el('div', 'viewer-top card');
|
const top = el('div', 'viewer-top card');
|
||||||
const w = this.world;
|
const w = this.world;
|
||||||
@@ -64,13 +92,16 @@ export class Viewer {
|
|||||||
<div class="title">${esc(w.name || 'Без названия')}</div>
|
<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="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">
|
<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>
|
<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="furniture" checked> Мебель</label>
|
||||||
<label class="chip"><input type="checkbox" data-t="labels" checked> Подписи</label>
|
<label class="chip"><input type="checkbox" data-t="labels" checked> Подписи</label>
|
||||||
</div>`;
|
</div>`;
|
||||||
top.querySelector('[data-a=new]')!.addEventListener('click', () => this.actions.onNew());
|
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) =>
|
top.querySelectorAll<HTMLInputElement>('input[data-t]').forEach((inp) =>
|
||||||
inp.addEventListener('change', () => {
|
inp.addEventListener('change', () => {
|
||||||
(this.opts as any)[inp.dataset.t!] = inp.checked;
|
(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.x = (B.minX + B.maxX) / 2;
|
||||||
this.cam.y = (B.minY + B.maxY) / 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;
|
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();
|
this.request();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,12 +168,29 @@ export class Viewer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
request() {
|
request() {
|
||||||
if (this.raf) return;
|
if (this.raf || this.disposed) return;
|
||||||
this.raf = requestAnimationFrame(() => {
|
this.raf = requestAnimationFrame(() => {
|
||||||
this.raf = 0;
|
this.raf = 0;
|
||||||
const r = this.root.getBoundingClientRect();
|
const r = this.root.getBoundingClientRect();
|
||||||
this.renderer.draw(this.ctx, r.width, r.height, this.cam, this.opts, window.devicePixelRatio || 1);
|
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();
|
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();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
@@ -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')}`);
|
||||||
|
}
|
||||||
@@ -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('Готово');
|
||||||
@@ -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();}
|
||||||
@@ -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
@@ -9,5 +9,5 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src", "client"]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user