Implement tiled multiplayer city simulation and scaling stages

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

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
// Optional UI check: install Playwright, or set CITYGEN_BROWSER_MODULES to its
// node_modules folder. Run against `npm run server:dev` with a prepared city.
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { mkdir } from 'node:fs/promises';
import assert from 'node:assert/strict';
const require = createRequire(process.env.CITYGEN_BROWSER_MODULES ? resolve(process.env.CITYGEN_BROWSER_MODULES,'package.json') : import.meta.url);
const { chromium } = require('playwright');
const browser = await chromium.launch({headless:true, executablePath:process.env.CITYGEN_BROWSER_EXECUTABLE});
try {
const page = await browser.newPage({viewport:{width:1440,height:1000}});
const errors=[]; page.on('pageerror',error=>errors.push(error.message));
const base=process.env.CITYGEN_TEST_URL || 'http://127.0.0.1:3000';
await page.goto(`${base}/?play`);
await page.locator('select option').first().waitFor({state:'attached'});
const city=process.env.CITYGEN_TEST_CITY;
if (city) await page.getByRole('combobox',{name:'Город'}).selectOption(city);
await page.locator('[name=nickname]').fill(`Smoke-${Date.now()}`);
const room=process.env.CITYGEN_TEST_ROOM;
const started=performance.now();
if(room) await page.locator(`[data-room="${room}"]`).click();
else await page.getByRole('button',{name:'Создать комнату',exact:true}).click();
await page.locator('.world-canvas').waitFor({timeout:60000});
await page.waitForFunction(()=>document.querySelector('.viewer-top [role=status]')?.textContent.includes('в загруженной области'),null,{timeout:60000});
const visibleMs=Math.round(performance.now()-started);
await page.waitForFunction(()=>document.querySelector('.room-clock')?.textContent.includes('День'),null,{timeout:10000});
await page.getByRole('button',{name:'Пауза',exact:true}).click();
await page.waitForFunction(()=>document.querySelector('.room-clock')?.textContent.includes('×0'),null,{timeout:10000});
await page.getByRole('button',{name:'1',exact:true}).click();
await page.locator('.world-canvas').hover();await page.mouse.wheel(0,-550);
await mkdir('.tools',{recursive:true}); await page.screenshot({path:'.tools/browser-room.png'});
assert.deepEqual(errors,[]);
console.log(JSON.stringify({ui:'passed',visibleMs,status:await page.locator('.viewer-top').innerText(),screenshot:resolve('.tools/browser-room.png')}));
} finally {await browser.close();}
+21
View File
@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
import { AgentSchema, DeltaSchema, ServerMessageSchema, SnapshotSchema } from '../client/generated/city_pb';
import { EntityState } from '../client/entities';
test('binary protocol and interpolation preserve AOI spawn, change and despawn', () => {
const state = new EntityState();
const agent = create(AgentSchema,{id:1,x:0,y:0});
const envelope = create(ServerMessageSchema,{body:{case:'snapshot',value:{tick:10n,entities:[agent]}}});
const decoded = fromBinary(ServerMessageSchema,toBinary(ServerMessageSchema,envelope));
assert.equal(decoded.body.case,'snapshot');
state.snapshot(create(SnapshotSchema,{tick:10n,entities:[agent]}),1000);
state.delta(create(DeltaSchema,{tick:12n,changed:[create(AgentSchema,{id:1,x:200,y:100})]}),1200);
assert.deepEqual(state.positions(1300),[{id:1,x:1,y:0.5}]);
state.delta(create(DeltaSchema,{tick:11n,despawned:[1]}),1300);
assert.equal(state.size,1);
state.delta(create(DeltaSchema,{tick:14n,despawned:[1],spawned:[create(AgentSchema,{id:2,x:400,y:0})]}),1400);
assert.deepEqual(state.positions(1500),[{id:2,x:4,y:0}]);
state.clear(); assert.equal(state.size,0);
});
+35
View File
@@ -0,0 +1,35 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { generateWorldSync } from '../src/gen/generate';
import { attachInteractions, streetObject } from '../src/gen/objects';
import { tileWorld, assembleWorld } from '../src/city/format';
import type { OsmElement } from '../src/osm/overpass';
test('indoor and outdoor objects share rotated interaction slots', () => {
const bench = streetObject('bench','bench',[10,20],Math.PI/2);
assert.deepEqual(bench.interactions?.find(s=>s.action==='sit'),{id:'bench:sit',action:'sit',capacity:3,pos:[9.25,20]});
const bed = {...bench,id:'bed',kind:'bed_double',tags:['sleep:2'],w:1.6,d:2,rot:0};
attachInteractions(bed);
assert.equal(bed.interactions?.[0].capacity,2);
assert.equal(bed.interactions?.[0].action,'sleep');
});
test('generation produces stable street furniture and actual entrance references', () => {
const elements: OsmElement[] = [
{type:'way',id:1,tags:{highway:'residential'},geometry:[{lat:47,lon:9},{lat:47,lon:9.005}]},
{type:'way',id:2,tags:{building:'house','building:levels':'1'},geometry:[{lat:47.0002,lon:9.001},{lat:47.0002,lon:9.0012},{lat:47.0004,lon:9.0012},{lat:47.0004,lon:9.001},{lat:47.0002,lon:9.001}]},
{type:'node',id:3,tags:{highway:'bus_stop'},lat:47.0001,lon:9.003},
{type:'node',id:4,tags:{highway:'crossing'},lat:47,lon:9.004},
];
const a = generateWorldSync(elements,[46.999,8.999,47.002,9.006],'Test',1);
const b = generateWorldSync(elements,[46.999,8.999,47.002,9.006],'Test',1);
assert.deepEqual(a.props,b.props);
assert.ok(a.props.some(p=>p.kind==='bench'));
assert.ok(a.props.some(p=>p.kind==='lamp'));
assert.ok(a.props.some(p=>p.kind==='bus_stop'));
assert.ok(a.props.some(p=>p.kind==='crossing'));
const entrance = a.props.find(p=>p.kind==='entrance'); assert.ok(entrance);
assert.ok(a.buildings.some(building=>building.id===entrance.target?.buildingId && building.floors.some(f=>f.doors.some(d=>d.id===entrance.target?.doorId))));
const tiled = tileWorld(a);
assert.equal(assembleWorld(tiled.manifest,tiled.tiles.values()).props.length,a.props.length);
});
+128
View File
@@ -0,0 +1,128 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { gzipSync, gunzipSync } from 'node:zlib';
import { createHash } from 'node:crypto';
import { assembleWorld, parseManifest, parseTile, tileWorld } from '../src/city/format';
import { TileLoader, fetchJson } from '../client/tiles';
import { exportCity } from '../tools/export/index';
import type { World } from '../src/types';
function fixture(): World {
return { version: 1, id: 'test', name: 'Test', createdAt: '2026-10-30T00:00:00Z', seed: 1,
origin: { lat: 0, lon: 0 }, bbox: [0, 0, 1, 1], bounds: { minX: -128, minY: -128, maxX: 512, maxY: 512 },
attribution: 'OSM', areas: [{ id: 'area', kind: 'park', rings: [[[-20, -20], [20, -20], [20, 20], [-20, 20]]] }],
roads: [{ id: 'road', kind: 'residential', pts: [[-10, 10], [300, 10]], width: 6, sidewalk: 2, lanes: 2, oneway: false, layer: 0, bridge: false, tunnel: false }],
buildings: [{ id: 'building', kind: 'house', footprint: [[250, 20], [270, 20], [270, 40], [250, 40]], holes: [], area: 400, levels: 1, levelsSource: 'osm', frame: { origin: [250, 20], angle: 0 }, beds: 1, seed: 1, units: [],
floors: [{ level: 0, rooms: [], walls: [{ a: [250, 20], b: [270, 20], thickness: 0.2, exterior: true }], doors: [], windows: [], stairs: [], props: [{ id: 'bed', kind: 'bed', pos: [260, 30], rot: 0, w: 2, d: 1, blocksMove: true, blocksSight: false, model: 'bed-model' }] }] }],
props: [{ id: 'tree', kind: 'tree', pos: [-1, -1], w: 4, d: 4, rot: 0, blocksMove: true, blocksSight: false }],
models: { 'bed-model': [{ t: 'rect', x: 0, y: 0, w: 2, h: 1 }] } };
}
test('tile roundtrip preserves full interiors and model references without duplicate objects', () => {
const world = fixture();
const { manifest, tiles } = tileWorld(world);
assert.ok(tiles.has('-1_-1'));
assert.equal(tiles.get('0_0')!.buildings[0].id, 'building');
assert.equal(tiles.get('1_0')!.buildings[0].id, 'building');
const restored = assembleWorld(parseManifest(JSON.parse(JSON.stringify(manifest))), [...tiles].map(([id, tile]) => parseTile(JSON.parse(JSON.stringify(tile)), world.id, id)));
assert.deepEqual(JSON.parse(JSON.stringify(restored)), world);
assert.deepEqual(tileWorld(world), tileWorld(world));
});
test('rejects incompatible versions, paths, duplicate IDs and invalid grids', () => {
const { manifest, tiles } = tileWorld(fixture());
assert.throws(() => parseManifest({ ...manifest, formatVersion: 99 }), /version/);
assert.throws(() => parseManifest({ ...manifest, tiles: [{ ...manifest.tiles[0], url: '../secret' }] }), /reference/);
assert.throws(() => parseManifest({ ...manifest, tiles: [manifest.tiles[0], manifest.tiles[0]] }), /reference/);
assert.throws(() => tileWorld(fixture(), 0));
assert.throws(() => tileWorld(fixture(), NaN));
const tile = [...tiles.values()][0];
assert.throws(() => parseTile(tile, 'wrong-city', tile.id));
assert.throws(() => tileWorld({ ...fixture(), models: {} }), /Missing model/);
});
test('area layering is stable regardless of tile arrival order',()=>{
const world=fixture();world.areas.push({id:'grass',kind:'grass',rings:[[[300,0],[310,0],[310,10],[300,10]]]});
const {manifest,tiles}=tileWorld(world);
assert.deepEqual(assembleWorld(manifest,[...tiles.values()].reverse()).areas.map(a=>a.id),world.areas.map(a=>a.id));
});
test('verifies SHA-256 in browsers without secure-context WebCrypto',async()=>{
const originalFetch=globalThis.fetch;const descriptor=Object.getOwnPropertyDescriptor(globalThis,'crypto');
const json=JSON.stringify({name:'Бальцерс'});
try {
Object.defineProperty(globalThis,'crypto',{value:undefined,configurable:true});
globalThis.fetch=async()=>new Response(json);
assert.deepEqual(await fetchJson('http://lan/tile',undefined,createHash('sha256').update(json).digest('hex')),{name:'Бальцерс'});
await assert.rejects(fetchJson('http://lan/tile',undefined,'0'.repeat(64)),/checksum/);
} finally {globalThis.fetch=originalFetch;if(descriptor)Object.defineProperty(globalThis,'crypto',descriptor);}
});
test('export produces readable gzip files and a complete manifest', async () => {
const dir = await mkdtemp(join(tmpdir(), 'citygen-export-'));
try {
const manifest = await exportCity(fixture(), dir);
await assert.rejects(exportCity(fixture(),dir),/immutable/);
assert.deepEqual(JSON.parse(await readFile(join(dir, 'manifest.json'), 'utf8')), manifest);
const navigation=JSON.parse(gunzipSync(await readFile(join(dir,manifest.navigation!.url))).toString());
assert.equal(navigation.roads[0].id,'road');
for (const ref of manifest.tiles) {
const value = JSON.parse(gunzipSync(await readFile(join(dir, ref.url))).toString());
assert.equal(parseTile(value, manifest.city.id, ref.id).id, ref.id);
}
} finally { await rm(dir, { recursive: true, force: true }); }
});
test('HTTP loader selects AOI, handles gzip headers or files, caches and evicts', async () => {
const { manifest, tiles } = tileWorld(fixture());
const original = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = async input => {
const url = String(input);
calls.push(url);
const id = url.split('/').pop()!.replace('.json.gz', '');
return new Response(gzipSync(JSON.stringify(tiles.get(id))));
};
try {
const loader = new TileLoader(manifest, 'http://test/city/manifest.json', 1);
const aoi = { minX: 30, minY: 30, maxX: 40, maxY: 40 };
const world = await loader.load(aoi);
assert.equal(calls.length, 1);
assert.equal(world.buildings[0].id, 'building');
await loader.load(aoi);
assert.equal(calls.length, 1);
await loader.load({ minX: -10, minY: -10, maxX: -5, maxY: -5 });
await loader.load(aoi);
assert.equal(calls.length, 3);
await assert.rejects(loader.load(manifest.city.bounds), /Приблизьте/);
loader.destroy();
globalThis.fetch = async () => new Response(JSON.stringify({ ok: true }));
assert.deepEqual(await fetchJson('http://test/plain'), { ok: true });
await assert.rejects(fetchJson('http://test/plain',undefined,'0'.repeat(64)),/checksum/);
globalThis.fetch = async () => new Response('missing', { status: 404 });
await assert.rejects(fetchJson('http://test/missing'), /404/);
} finally { globalThis.fetch = original; }
});
test('superseded requests never replace a newer viewport', async () => {
const { manifest, tiles } = tileWorld(fixture());
const original = globalThis.fetch;
let finish!: () => void;
let first = true;
globalThis.fetch = async input => {
const id = String(input).split('/').pop()!.replace('.json.gz', '');
if (first) { first = false; await new Promise<void>(resolve => { finish = resolve; }); }
return new Response(JSON.stringify(tiles.get(id)));
};
try {
const loader = new TileLoader(manifest, 'http://test/city/manifest.json');
const old = loader.load({ minX: 30, minY: 30, maxX: 40, maxY: 40 });
await loader.load({ minX: -10, minY: -10, maxX: -5, maxY: -5 });
finish();
await assert.rejects(old, { name: 'AbortError' });
loader.destroy();
} finally { globalThis.fetch = original; }
});