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(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((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();}