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,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; }
|
||||
});
|
||||
Reference in new issue
Block a user