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/
|
||||
*.log
|
||||
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 строится подробная карта
|
||||
с видом строго сверху: дороги реальной ширины с тротуарами и разметкой, парковки
|
||||
с машинами, деревья, и **у каждого здания — этажи, комнаты, двери, окна, лестницы
|
||||
и мебель**, нарисованные простыми геометрическими фигурами, как на плане квартиры.
|
||||
|
||||
Всё работает в браузере, без сервера: данные берутся из Overpass API, генерация
|
||||
В отладочном режиме всё работает в браузере: данные берутся из Overpass API, генерация
|
||||
идёт в Web Worker'ах, готовый мир сохраняется в IndexedDB.
|
||||
|
||||
Машины появляются только на парковках и в гаражах; вдоль улиц они не генерируются.
|
||||
@@ -45,7 +81,7 @@ npm run cli -- samples/balzers-2013.json 47.058 9.492 47.074 9.518 world.json
|
||||
- справа — этаж для всей карты (⌂ — крыши), `PgUp`/`PgDn` или `[` `]`;
|
||||
- клик по зданию — карточка: вид, этажность (из OSM или оценка), квартиры, спальные места,
|
||||
свой переключатель этажей только для этого здания;
|
||||
- «Экспорт мира» — весь мир одним файлом `.json.gz` (будущий формат для игры).
|
||||
- «Экспорт мира» в отладочном режиме — весь мир одним файлом `.json.gz`; `export-city` преобразует его в серверный тайловый формат.
|
||||
|
||||
## Как устроена генерация
|
||||
|
||||
@@ -74,7 +110,7 @@ Overpass JSON ─► city.ts ──────────────► зо
|
||||
|
||||
## Модель данных мира (`src/types.ts`)
|
||||
|
||||
Заложено под будущую симуляцию:
|
||||
Статические данные уже используются сервером; для дальнейшего игрового слоя предусмотрены:
|
||||
|
||||
| Что | Зачем потом |
|
||||
|---|---|
|
||||
|
||||
@@ -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",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.16.0",
|
||||
"@noble/hashes": "^2.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bufbuild/buf": "^1.75.0",
|
||||
"@bufbuild/protoc-gen-es": "^2.16.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf": {
|
||||
"version": "1.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf/-/buf-1.75.0.tgz",
|
||||
"integrity": "sha512-uMuBCULLDax9/3d5Lo9mea38vuPDv4mrRVE4XfSq3qPcD5UUxlg0RJ9hm84lYCzI3A3lIvLeChxrw9c1RUiD2w==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"buf": "bin/buf",
|
||||
"protoc-gen-buf-breaking": "bin/protoc-gen-buf-breaking",
|
||||
"protoc-gen-buf-lint": "bin/protoc-gen-buf-lint"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@bufbuild/buf-darwin-arm64": "1.75.0",
|
||||
"@bufbuild/buf-darwin-x64": "1.75.0",
|
||||
"@bufbuild/buf-linux-aarch64": "1.75.0",
|
||||
"@bufbuild/buf-linux-armv7": "1.75.0",
|
||||
"@bufbuild/buf-linux-x64": "1.75.0",
|
||||
"@bufbuild/buf-win32-arm64": "1.75.0",
|
||||
"@bufbuild/buf-win32-x64": "1.75.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-darwin-arm64": {
|
||||
"version": "1.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-arm64/-/buf-darwin-arm64-1.75.0.tgz",
|
||||
"integrity": "sha512-QiQYSmtVUcKy9DK/d+zJ3+D4iUQmmiDQ3i8/yBr8cf7Nr28lqKMAIKznJE/uGRsjr90fHaUQO+Xsedec0ArN5g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-darwin-x64": {
|
||||
"version": "1.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-darwin-x64/-/buf-darwin-x64-1.75.0.tgz",
|
||||
"integrity": "sha512-U2W+MRE9jJ7Am7x02ZWt87U5lfPrjKMdB1aZDslsuyOHo+fEUvZolupmhoW30v6syNAWBOfkoi0k5TLc2FNGqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-linux-aarch64": {
|
||||
"version": "1.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-aarch64/-/buf-linux-aarch64-1.75.0.tgz",
|
||||
"integrity": "sha512-eX+XyokzJDqsNIFo8b1O3DBvbnksOozPJX4QVsNn61JqSUtWspyTNeYqwg8lRDancML+bDqZEDFPABjAsVCQGQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-linux-armv7": {
|
||||
"version": "1.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-armv7/-/buf-linux-armv7-1.75.0.tgz",
|
||||
"integrity": "sha512-2RVhmr3vJ145DazYTk9dWAzeQPtcJ0SiSzY02v1Su/JIfgzhv9lgOm+y3nGuLjyZFF8UfTtmuHfHso5zDBgsCQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-linux-x64": {
|
||||
"version": "1.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-linux-x64/-/buf-linux-x64-1.75.0.tgz",
|
||||
"integrity": "sha512-YGGJAx0lpMCE7aMWjNOJcfoEaG/e+iYHnTC4SZ0P5eS4IffMryA9Z4/bb1m1wNXAEJ8ATFMEZOAk/5x9VE47ig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-win32-arm64": {
|
||||
"version": "1.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-arm64/-/buf-win32-arm64-1.75.0.tgz",
|
||||
"integrity": "sha512-iRge6Ntta41gt0i7P8/EZFDWe3uLNnCEN86r1tKTWctumWxPET3UJcSzzYPyL4gxvzu1/1VTu+AB3+MYAqHy6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/buf-win32-x64": {
|
||||
"version": "1.75.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/buf-win32-x64/-/buf-win32-x64-1.75.0.tgz",
|
||||
"integrity": "sha512-/BgLt3VWndocuG4lbMdlBc1i41I+Bap4zm8R1Cnmov6DtoAQp4dy/leRrvT4aDArIUQ1o7W/Y92xTRITedU4ww==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "2.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.16.0.tgz",
|
||||
"integrity": "sha512-FWa0sPlqYGJgpTs6OxBRcL/AW4JT0OIO+W1ajerluoTVh8CV0B7XM1qsNCRgnkSCkRCgOICKHRQlY17jrsm0+Q==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@bufbuild/protoc-gen-es": {
|
||||
"version": "2.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.16.0.tgz",
|
||||
"integrity": "sha512-LbPrEaGd7bOxKbJHllTR5439+PZjqd6fM7xyDZkDVtZ+Efzr7TMdMqShG9Qkf/nYVyiNS/SK5r9JhRaBTFkfZw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "2.16.0",
|
||||
"@bufbuild/protoplugin": "2.16.0"
|
||||
},
|
||||
"bin": {
|
||||
"protoc-gen-es": "bin/protoc-gen-es"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@bufbuild/protobuf": "2.16.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@bufbuild/protobuf": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protoplugin": {
|
||||
"version": "2.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.16.0.tgz",
|
||||
"integrity": "sha512-3FdYJys34VhFnBQsjiV9wurBkX/DBACQAkAk+nCKdO1p8ROVNi7gmaRAcba+4qq8hQq8rYVukuutwvGCvd4kJA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "2.16.0",
|
||||
"@typescript/vfs": "^1.6.2",
|
||||
"typescript": "5.4.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protoplugin/node_modules/typescript": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -455,6 +662,49 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||
"integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/vfs": {
|
||||
"version": "1.6.5",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.5.tgz",
|
||||
"integrity": "sha512-gobLHQLmvQGJInFk31NvgMd1uYzRXsd8qgnOssyLD055R2QPnw1ubRbBQ4+T+5qC7IQVuYXdvNDE2G7tMDVHgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
@@ -512,6 +762,13 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.15",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
|
||||
|
||||
+15
-1
@@ -9,11 +9,25 @@
|
||||
"dev": "node build.mjs --serve",
|
||||
"build": "node build.mjs",
|
||||
"typecheck": "tsc -p .",
|
||||
"cli": "tsx scripts/cli.ts"
|
||||
"cli": "tsx scripts/cli.ts",
|
||||
"export-city": "tsx tools/export/index.ts",
|
||||
"demo": "tsx tools/demo.ts",
|
||||
"proto:generate": "buf generate",
|
||||
"server:dev": "node tools/rust.mjs run --manifest-path server/Cargo.toml --bin citygen-server",
|
||||
"server:release": "node tools/rust.mjs run --release --manifest-path server/Cargo.toml --bin citygen-server",
|
||||
"server:test": "node tools/rust.mjs test --manifest-path server/Cargo.toml",
|
||||
"server:load": "node tools/rust.mjs run --release --manifest-path server/Cargo.toml --bin load",
|
||||
"server:bots": "tsx tools/load-bots.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@bufbuild/buf": "^1.75.0",
|
||||
"@bufbuild/protoc-gen-es": "^2.16.0",
|
||||
"esbuild": "^0.28.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.16.0",
|
||||
"@noble/hashes": "^2.4.0"
|
||||
}
|
||||
}
|
||||
@@ -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 { classify, clampLevels, defaultLevels, parseLevels } from './classify';
|
||||
import { carShapes, CAR_COLORS, treeProp } from './props';
|
||||
import { populateStreets } from './streets';
|
||||
|
||||
export interface InteriorJob {
|
||||
building: Building;
|
||||
@@ -379,6 +380,7 @@ export function buildCity(elements: OsmElement[], bbox: BBox, name: string, seed
|
||||
models: {},
|
||||
attribution: '© OpenStreetMap contributors (ODbL)',
|
||||
};
|
||||
populateStreets(world, elements);
|
||||
return { world, jobs };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Финальная упаковка мира: общие модели предметов и округление координат до сантиметра.
|
||||
|
||||
import type { Building, Prop, Shape, World } from '../types';
|
||||
import { attachInteractions } from './objects';
|
||||
|
||||
const r2 = (v: number) => Math.round(v * 100) / 100;
|
||||
const r3 = (v: number) => Math.round(v * 1000) / 1000;
|
||||
@@ -44,6 +45,7 @@ function roundProp(p: Prop) {
|
||||
p.rot = r3(p.rot);
|
||||
p.w = r2(p.w);
|
||||
p.d = r2(p.d);
|
||||
attachInteractions(p);
|
||||
if (!p.tags?.length) delete p.tags;
|
||||
if (!p.roomId) delete p.roomId;
|
||||
if (!p.color) delete p.color;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { World } from '../types';
|
||||
import { buildCity, type InteriorJob } from './city';
|
||||
import { generateInterior, type InteriorResult } from './interior';
|
||||
import { finalizeWorld } from './finalize';
|
||||
import { addStreetEntrances } from './streets';
|
||||
|
||||
export type Progress = (stage: string, done: number, total: number) => void;
|
||||
|
||||
@@ -29,6 +30,7 @@ export function generateWorldSync(elements: OsmElement[], bbox: BBox, name: stri
|
||||
console.warn('interior failed', job.building.id, e);
|
||||
}
|
||||
});
|
||||
addStreetEntrances(world);
|
||||
finalizeWorld(world);
|
||||
return world;
|
||||
}
|
||||
|
||||
@@ -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 { buildCity, type InteriorJob } from './city';
|
||||
import { finalizeWorld } from './finalize';
|
||||
import { addStreetEntrances } from './streets';
|
||||
|
||||
export type OnProgress = (text: string, frac: number) => void;
|
||||
|
||||
@@ -14,6 +15,8 @@ export async function generateWorld(elements: OsmElement[], bbox: BBox, name: st
|
||||
finalizeWorld(world, false);
|
||||
onProgress(`Планировки: 0 / ${jobs.length}`, 0.1);
|
||||
await runInteriors(world, jobs, (done) => onProgress(`Планировки: ${done} / ${jobs.length} зданий`, 0.1 + 0.88 * (done / Math.max(1, jobs.length))), signal);
|
||||
addStreetEntrances(world);
|
||||
finalizeWorld(world, false);
|
||||
onProgress('Готово', 1);
|
||||
return world;
|
||||
}
|
||||
|
||||
@@ -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 { downloadWorld, hasWorld, loadWorld, readJsonFile, saveWorld } from './store';
|
||||
import type { World } from './types';
|
||||
import { TileLoader } from '../client/tiles';
|
||||
import type { Bounds } from './city/format';
|
||||
import { RoomConnection } from '../client/connection';
|
||||
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
||||
import { CityJobSchema, CreateCitySchema } from '../client/generated/city_pb';
|
||||
|
||||
const MAX_KM2 = 25;
|
||||
const WARN_KM2 = 10;
|
||||
@@ -11,6 +16,17 @@ const WARN_KM2 = 10;
|
||||
const app = document.getElementById('app')!;
|
||||
let viewer: Viewer | null = null;
|
||||
let picker: AreaPicker | null = null;
|
||||
let tileLoader: TileLoader | null = null;
|
||||
let cityLoad: AbortController | null = null;
|
||||
let roomConnection: RoomConnection | null = null;
|
||||
function stopRoom() { roomConnection?.destroy(); roomConnection = null; }
|
||||
|
||||
function stopTileLoading() {
|
||||
cityLoad?.abort();
|
||||
cityLoad = null;
|
||||
tileLoader?.destroy();
|
||||
tileLoader = null;
|
||||
}
|
||||
|
||||
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]!));
|
||||
|
||||
@@ -33,6 +49,8 @@ const hideProgress = () => (overlay.hidden = true);
|
||||
// ------------------------------------------------------------------ экраны
|
||||
|
||||
function showViewer(world: World) {
|
||||
stopRoom();
|
||||
stopTileLoading();
|
||||
picker = null;
|
||||
app.innerHTML = '';
|
||||
viewer?.destroy();
|
||||
@@ -44,6 +62,8 @@ function showViewer(world: World) {
|
||||
}
|
||||
|
||||
async function showPicker(initial?: BBox) {
|
||||
stopRoom();
|
||||
stopTileLoading();
|
||||
viewer?.destroy();
|
||||
viewer = null;
|
||||
app.innerHTML = '';
|
||||
@@ -54,6 +74,7 @@ async function showPicker(initial?: BBox) {
|
||||
panel.className = 'picker-panel card';
|
||||
panel.innerHTML = `
|
||||
<div class="brand">Город сверху</div>
|
||||
<a class="btn" href="/?play">Сетевая игра — комнаты</a>
|
||||
<p class="lead">Выделите на карте небольшой город или район — по данным OpenStreetMap будет построена подробная карта с планировками и мебелью во всех зданиях.</p>
|
||||
<form class="search"><input type="search" placeholder="Найти место: город, район, улица…" autocomplete="off"><button class="btn">Найти</button></form>
|
||||
<div class="results"></div>
|
||||
@@ -144,16 +165,36 @@ async function showPicker(initial?: BBox) {
|
||||
});
|
||||
|
||||
goBtn.addEventListener('click', async () => {
|
||||
if (!bbox) return;
|
||||
if (!bbox || goBtn.disabled) return;
|
||||
goBtn.disabled = true;
|
||||
showErr(null);
|
||||
abort = new AbortController();
|
||||
(overlay.querySelector('[data-a=cancel]') as HTMLButtonElement).textContent = new URLSearchParams(location.search).has('create-city') ? 'Закрыть ожидание' : 'Отмена';
|
||||
try {
|
||||
if (new URLSearchParams(location.search).has('create-city')) {
|
||||
const response = await fetch('/api/cities/jobs',{method:'POST',headers:{'Content-Type':'application/x-protobuf'},body:toBinary(CreateCitySchema,create(CreateCitySchema,{bbox,name:nameInput.value.trim() || 'Новый город',seed:1})),signal:abort.signal});
|
||||
if (!response.ok) throw new Error((await response.json()).error || `HTTP ${response.status}`);
|
||||
const job = fromBinary(CityJobSchema,new Uint8Array(await response.arrayBuffer()));
|
||||
for (;;) {
|
||||
const response = await fetch(`/api/cities/jobs/${encodeURIComponent(job.id)}`,{signal:abort.signal});
|
||||
if (!response.ok) throw new Error((await response.json()).error || `HTTP ${response.status}`);
|
||||
const state = fromBinary(CityJobSchema,new Uint8Array(await response.arrayBuffer()));
|
||||
if (state.status === 'failed') throw new Error(state.error || 'Не удалось создать город');
|
||||
if (state.status === 'ready') { hideProgress(); history.replaceState(null,'',`/?play&room=${encodeURIComponent(state.roomId)}`); await showLobby(); return; }
|
||||
progress(state.status === 'queued' ? 'Город в очереди…' : `${state.stage || 'Подготовка города'}…`,state.status === 'queued' ? 0.05 : 0.5);
|
||||
await new Promise(resolve => setTimeout(resolve,1000));
|
||||
abort.signal.throwIfAborted();
|
||||
}
|
||||
}
|
||||
progress('Загрузка данных OpenStreetMap…', 0.02);
|
||||
const els = await fetchOverpass(bbox, (s) => progress(s, 0.03), abort.signal);
|
||||
await runGeneration(els, bbox, nameInput.value.trim() || 'Новая карта');
|
||||
} catch (ex) {
|
||||
hideProgress();
|
||||
if ((ex as Error).name !== 'AbortError') showErr((ex as Error).message);
|
||||
else if (new URLSearchParams(location.search).has('create-city')) showErr('Ожидание закрыто. Принятая сервером задача продолжится; готовый город появится в каталоге комнат.');
|
||||
} finally {
|
||||
goBtn.disabled = !bbox || bboxSizeKm(bbox).km2 > MAX_KM2;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -224,8 +265,137 @@ async function runGeneration(els: OsmElement[], bbox: BBox, name: string) {
|
||||
|
||||
// ------------------------------------------------------------------ старт
|
||||
|
||||
async function showTiledCity(url: string, connection?: RoomConnection) {
|
||||
stopTileLoading();
|
||||
const controller = cityLoad = new AbortController();
|
||||
progress('Загрузка манифеста города…', 0.1);
|
||||
abort = controller;
|
||||
try {
|
||||
const loader = await TileLoader.open(url, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
tileLoader = loader;
|
||||
viewer?.destroy();
|
||||
app.innerHTML = '';
|
||||
let lastKey = '';
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const tiledViewer = new Viewer(app, loader.emptyWorld(), {
|
||||
onNew: () => { clearTimeout(timer); if (connection) {history.replaceState(null,'','/?play');void showLobby();} else void showPicker(loader.manifest.city.bbox); },
|
||||
newLabel:connection ? 'Комнаты' : undefined,
|
||||
maxViewSpan: loader.manifest.tileSize * (connection ? 4 : 6),
|
||||
maxLevels: loader.manifest.counts.maxLevels,
|
||||
entities: connection ? () => connection.entities.positions() : undefined,
|
||||
onViewport: (bounds: Bounds) => {
|
||||
// Snap to tile boundaries: a paint after loading must not trigger a new request.
|
||||
const size = loader.manifest.tileSize;
|
||||
const x0 = Math.floor(bounds.minX / size) - 1, y0 = Math.floor(bounds.minY / size) - 1;
|
||||
const x1 = Math.floor(bounds.maxX / size) + 1, y1 = Math.floor(bounds.maxY / size) + 1;
|
||||
const key = `${x0},${y0},${x1},${y1}`;
|
||||
if (key === lastKey) return;
|
||||
lastKey = key;
|
||||
connection?.subscribe({ minX: x0 * size, minY: y0 * size, maxX: x1 * size, maxY: y1 * size });
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(async () => {
|
||||
if (tileLoader !== loader) return;
|
||||
tiledViewer.setLoadStatus('Загрузка тайлов…');
|
||||
try {
|
||||
const world = await loader.load({ minX: x0 * size, minY: y0 * size, maxX: x1 * size, maxY: y1 * size });
|
||||
if (tileLoader !== loader) return;
|
||||
tiledViewer.replaceWorld(world);
|
||||
tiledViewer.setLoadStatus(`${loader.manifest.counts.buildings} зданий в городе · ${world.buildings.length} в загруженной области`);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError' && tileLoader === loader) {
|
||||
tiledViewer.setLoadStatus((error as Error).message);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
});
|
||||
viewer = tiledViewer;
|
||||
if (connection) {
|
||||
const controls = document.createElement('div'); controls.className = 'row';
|
||||
const label = document.createElement('span'); label.className = 'room-clock'; label.setAttribute('role','status');
|
||||
controls.appendChild(label);
|
||||
for (const speed of [0, 1, 2, 4]) {
|
||||
const button = document.createElement('button'); button.className = 'btn'; button.textContent = speed === 0 ? 'Пауза' : `×${speed}`;
|
||||
button.onclick = () => connection.propose(speed); controls.appendChild(button);
|
||||
}
|
||||
for (const yes of [true,false]) {
|
||||
const button = document.createElement('button'); button.className = 'btn'; button.textContent = yes ? 'За' : 'Против';
|
||||
button.onclick = () => connection.vote(yes); controls.appendChild(button);
|
||||
}
|
||||
const vote = document.createElement('span'); vote.className = 'room-vote'; vote.setAttribute('role','status'); controls.appendChild(vote);
|
||||
tiledViewer.root.querySelector('.viewer-top')!.appendChild(controls);
|
||||
}
|
||||
(window as any).viewer = viewer;
|
||||
hideProgress();
|
||||
} catch (error) {
|
||||
hideProgress();
|
||||
if ((error as Error).name === 'AbortError') return showPicker();
|
||||
await showPicker();
|
||||
const err = app.querySelector<HTMLElement>('.err')!;
|
||||
err.hidden = false;
|
||||
err.textContent = `Не удалось загрузить город: ${(error as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function showLobby() {
|
||||
stopRoom(); stopTileLoading(); viewer?.destroy(); viewer = null; app.innerHTML = '';
|
||||
const panel = document.createElement('div'); panel.className = 'card picker-panel'; panel.style.maxWidth = '700px';
|
||||
panel.innerHTML = `<div class="brand">Комнаты города</div>
|
||||
<label class="field">Ник <input name="nickname" maxlength="24" value="Наблюдатель"></label>
|
||||
<div class="row"><select aria-label="Город"></select><button class="btn primary" data-create>Создать комнату</button><button class="btn" data-refresh>Обновить</button></div>
|
||||
<div data-rooms></div><p role="status" data-status></p><a class="btn" href="/?create-city">Создать город на сервере</a><a class="btn" href="/">Генератор для отладки</a>`;
|
||||
app.appendChild(panel);
|
||||
const status = panel.querySelector<HTMLElement>('[data-status]')!;
|
||||
const api = async (url: string, init?: RequestInit) => {
|
||||
const response = await fetch(url,init); const body = await response.json();
|
||||
if (!response.ok) throw new Error(body.error || `HTTP ${response.status}`); return body;
|
||||
};
|
||||
const join = (room: string) => {
|
||||
const nickname = panel.querySelector<HTMLInputElement>('[name=nickname]')!.value.trim();
|
||||
if (!nickname) { status.textContent = 'Введите ник'; return; }
|
||||
status.textContent = 'Подключение…';
|
||||
stopRoom();
|
||||
const connection = new RoomConnection(room,nickname,{
|
||||
welcome: w => { void showTiledCity(w.cityManifestUrl,connection); },
|
||||
status: text => { status.textContent = text; viewer?.setLoadStatus(text); },
|
||||
clock: c => { const clock = app.querySelector('.room-clock'); if (clock) clock.textContent = `День ${Math.floor(c.gameSeconds / 86400) + 1} · ${String(Math.floor(c.gameSeconds/3600)%24).padStart(2,'0')}:${String(Math.floor(c.gameSeconds/60)%60).padStart(2,'0')} · ×${c.speed}`; },
|
||||
vote: v => { const vote = app.querySelector('.room-vote'); if (vote) vote.textContent = v.active ? `Голосование за ×${v.speed}: ${v.yes}/${v.players}, ${v.remainingSeconds} с` : `${v.players} игроков`; },
|
||||
});
|
||||
roomConnection = connection;
|
||||
};
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const [cities, rooms] = await Promise.all([api('/api/cities'),api('/api/rooms')]);
|
||||
const select = panel.querySelector('select')!; select.replaceChildren();
|
||||
for (const city of cities) { const option = document.createElement('option'); option.value = city.id; option.textContent = city.name; select.appendChild(option); }
|
||||
const list = panel.querySelector('[data-rooms]')!; list.replaceChildren();
|
||||
for (const room of rooms) {
|
||||
const button = document.createElement('button'); button.className = 'btn'; button.textContent = `${room.cityId} · ${room.players}/30 · ${room.active ? 'открыта' : 'сохранена'}`;
|
||||
button.dataset.room=room.id;
|
||||
button.onclick = () => join(room.id); list.appendChild(button);
|
||||
}
|
||||
status.textContent = cities.length ? (rooms.length ? '' : 'Создайте первую комнату') : 'Каталог пуст. Экспортируйте город: npm run export-city -- world.json public/cities/demo';
|
||||
} catch (error) { status.textContent = `Сервер недоступен: ${(error as Error).message}`; }
|
||||
};
|
||||
panel.querySelector<HTMLButtonElement>('[data-refresh]')!.onclick = () => { void refresh(); };
|
||||
panel.querySelector<HTMLButtonElement>('[data-create]')!.onclick = async () => {
|
||||
const button = panel.querySelector<HTMLButtonElement>('[data-create]')!; button.disabled = true;
|
||||
try {
|
||||
const room = await api('/api/rooms',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cityId:panel.querySelector('select')!.value,seed:1})});
|
||||
join(room.id);
|
||||
} catch (error) { status.textContent = (error as Error).message; }
|
||||
finally { button.disabled = false; }
|
||||
};
|
||||
await refresh();
|
||||
const requestedRoom = new URLSearchParams(location.search).get('room');
|
||||
if (requestedRoom) join(requestedRoom);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.has('play')) return showLobby();
|
||||
if (params.has('city')) return showTiledCity(params.get('city')!);
|
||||
if (params.has('last') && (await hasWorld())) {
|
||||
const w = await loadWorld();
|
||||
if (w) return showViewer(w);
|
||||
|
||||
@@ -65,6 +65,9 @@ export function buildQuery(b: BBox): string {
|
||||
node["tourism"]${bb};
|
||||
node["healthcare"]${bb};
|
||||
node["highway"="street_lamp"]${bb};
|
||||
node["highway"="bus_stop"]${bb};
|
||||
node["highway"="crossing"]${bb};
|
||||
node["public_transport"="platform"]${bb};
|
||||
);
|
||||
out geom;`;
|
||||
}
|
||||
|
||||
@@ -194,4 +194,14 @@ export interface Prop {
|
||||
color?: string;
|
||||
/** Для симуляции: можно сидеть/спать/хранить и т.п. */
|
||||
tags?: string[];
|
||||
/** World-space interaction points shared by indoor and outdoor objects. */
|
||||
interactions?: InteractionSlot[];
|
||||
target?: { buildingId: string; doorId: string; level: number };
|
||||
}
|
||||
|
||||
export interface InteractionSlot {
|
||||
id: string;
|
||||
action: string;
|
||||
pos: Vec;
|
||||
capacity: number;
|
||||
}
|
||||
+55
-6
@@ -4,6 +4,7 @@ import type { Building, Vec, World } from '../types';
|
||||
import { Renderer, type Camera, type ViewOpts } from './renderer';
|
||||
import { KIND_NAME, ROOM_NAME } from './style';
|
||||
import { centroid } from '../util/geom';
|
||||
import type { Bounds } from '../city/format';
|
||||
|
||||
const el = <K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string) => {
|
||||
const e = document.createElement(tag);
|
||||
@@ -21,6 +22,9 @@ export class Viewer {
|
||||
cam: Camera;
|
||||
opts: ViewOpts = { floor: null, overrides: new Map(), selected: null, furniture: true, labels: true };
|
||||
private raf = 0;
|
||||
private disposed = false;
|
||||
private resizeObserver = new ResizeObserver(() => this.resize());
|
||||
private loadStatus = el('div', 'hint');
|
||||
private floorBar = el('div', 'floor-bar card');
|
||||
private info = el('div', 'info card');
|
||||
private scale = el('div', 'scale');
|
||||
@@ -28,28 +32,52 @@ export class Viewer {
|
||||
private pointers = new Map<number, { x: number; y: number }>();
|
||||
private gesture: { x: number; y: number; cam: Camera; d?: number; moved: boolean } | null = null;
|
||||
|
||||
constructor(parent: HTMLElement, public world: World, private actions: { onNew: () => void; onExport: () => void }) {
|
||||
constructor(parent: HTMLElement, public world: World, private actions: {
|
||||
onNew: () => void; onExport?: () => void; onViewport?: (bounds: Bounds) => void;
|
||||
maxViewSpan?: number; maxLevels?: number;
|
||||
newLabel?: string;
|
||||
entities?: () => { id: number; x: number; y: number }[];
|
||||
}) {
|
||||
this.renderer = new Renderer(world);
|
||||
const B = world.bounds;
|
||||
this.cam = { x: (B.minX + B.maxX) / 2, y: (B.minY + B.maxY) / 2, z: 1 };
|
||||
this.maxLevel = Math.min(40, Math.max(1, ...world.buildings.map((b) => b.levels)));
|
||||
this.maxLevel = Math.min(40, actions.maxLevels ?? world.buildings.reduce((n, b) => Math.max(n, b.levels), 1));
|
||||
this.root.append(this.canvas, this.buildTop(), this.floorBar, this.info, this.scale, el('div', 'attribution', '© <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors'));
|
||||
parent.appendChild(this.root);
|
||||
this.info.hidden = true;
|
||||
this.buildFloorBar();
|
||||
new ResizeObserver(() => this.resize()).observe(this.root);
|
||||
this.loadStatus.setAttribute('role', 'status');
|
||||
this.root.querySelector('.viewer-top')!.appendChild(this.loadStatus);
|
||||
this.resizeObserver.observe(this.root);
|
||||
this.bindInput();
|
||||
requestAnimationFrame(() => {
|
||||
if (this.disposed) return;
|
||||
this.resize();
|
||||
this.fitBounds();
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.disposed = true;
|
||||
cancelAnimationFrame(this.raf);
|
||||
this.resizeObserver.disconnect();
|
||||
this.root.remove();
|
||||
window.removeEventListener('keydown', this.onKey);
|
||||
}
|
||||
|
||||
setLoadStatus(message: string) { this.loadStatus.textContent = message; }
|
||||
|
||||
replaceWorld(world: World) {
|
||||
if (this.disposed) return;
|
||||
this.world = world;
|
||||
this.renderer = new Renderer(world);
|
||||
const stats = this.root.querySelector('.stats');
|
||||
if (stats) stats.textContent = `${world.buildings.length.toLocaleString('ru')} зданий · ${world.props.length.toLocaleString('ru')} уличных объектов в загруженной области`;
|
||||
if (!world.buildings.some(b => b.id === this.opts.selected)) this.opts.selected = null;
|
||||
this.updateInfo();
|
||||
this.request();
|
||||
}
|
||||
|
||||
private buildTop(): HTMLElement {
|
||||
const top = el('div', 'viewer-top card');
|
||||
const w = this.world;
|
||||
@@ -64,13 +92,16 @@ export class Viewer {
|
||||
<div class="title">${esc(w.name || 'Без названия')}</div>
|
||||
<div class="stats">${w.buildings.length.toLocaleString('ru')} зданий · ${floors.toLocaleString('ru')} этажей · ${rooms.toLocaleString('ru')} комнат · ${furn.toLocaleString('ru')} предметов · ${homes.toLocaleString('ru')} квартир/домов · ~${beds.toLocaleString('ru')} жителей</div>
|
||||
<div class="row">
|
||||
<button class="btn" data-a="new">Новая карта</button>
|
||||
<button class="btn" data-a="new">${esc(this.actions.newLabel || 'Новая карта')}</button>
|
||||
<button class="btn" data-a="export" title="Скачать мир целиком (JSON) — формат для будущей игры">Экспорт мира</button>
|
||||
<label class="chip"><input type="checkbox" data-t="furniture" checked> Мебель</label>
|
||||
<label class="chip"><input type="checkbox" data-t="labels" checked> Подписи</label>
|
||||
</div>`;
|
||||
top.querySelector('[data-a=new]')!.addEventListener('click', () => this.actions.onNew());
|
||||
top.querySelector('[data-a=export]')!.addEventListener('click', () => this.actions.onExport());
|
||||
const exportButton = top.querySelector<HTMLButtonElement>('[data-a=export]')!;
|
||||
exportButton.disabled = !this.actions.onExport;
|
||||
if (!this.actions.onExport) exportButton.title = 'Полный город экспортируется офлайн командой npm run export-city';
|
||||
exportButton.addEventListener('click', () => this.actions.onExport?.());
|
||||
top.querySelectorAll<HTMLInputElement>('input[data-t]').forEach((inp) =>
|
||||
inp.addEventListener('change', () => {
|
||||
(this.opts as any)[inp.dataset.t!] = inp.checked;
|
||||
@@ -126,6 +157,7 @@ export class Viewer {
|
||||
this.cam.x = (B.minX + B.maxX) / 2;
|
||||
this.cam.y = (B.minY + B.maxY) / 2;
|
||||
this.cam.z = Math.min(r.width / (B.maxX - B.minX), r.height / (B.maxY - B.minY)) * 0.92;
|
||||
if (this.actions.maxViewSpan) this.cam.z = Math.max(this.cam.z, Math.max(r.width, r.height) / this.actions.maxViewSpan);
|
||||
this.request();
|
||||
}
|
||||
|
||||
@@ -136,12 +168,29 @@ export class Viewer {
|
||||
}
|
||||
|
||||
request() {
|
||||
if (this.raf) return;
|
||||
if (this.raf || this.disposed) return;
|
||||
this.raf = requestAnimationFrame(() => {
|
||||
this.raf = 0;
|
||||
const r = this.root.getBoundingClientRect();
|
||||
this.renderer.draw(this.ctx, r.width, r.height, this.cam, this.opts, window.devicePixelRatio || 1);
|
||||
if (this.actions.entities) {
|
||||
const ctx = this.ctx;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
ctx.save(); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = '#ffd36b'; ctx.strokeStyle = '#382c21';
|
||||
for (const agent of this.actions.entities()) {
|
||||
const x = (agent.x - this.cam.x) * this.cam.z + r.width / 2;
|
||||
const y = (agent.y - this.cam.y) * this.cam.z + r.height / 2;
|
||||
if (x < -5 || x > r.width + 5 || y < -5 || y > r.height + 5) continue;
|
||||
ctx.beginPath(); ctx.arc(x,y,Math.max(2,Math.min(5,this.cam.z*0.3)),0,Math.PI*2); ctx.fill(); ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
this.updateScale();
|
||||
if (r.width && r.height && this.cam.z > 0) this.actions.onViewport?.({
|
||||
minX: this.cam.x - r.width / this.cam.z / 2, maxX: this.cam.x + r.width / this.cam.z / 2,
|
||||
minY: this.cam.y - r.height / this.cam.z / 2, maxY: this.cam.y + r.height / this.cam.z / 2,
|
||||
});
|
||||
if (this.actions.entities) this.request();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "client"]
|
||||
}
|
||||
Reference in new issue
Block a user