| 1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
|
| 8 |
|
| 9 |
|
| 10 |
|
| 11 |
|
| 12 |
|
| 13 |
import { expect, test } from "bun:test"; |
| 14 |
import { mkdtempSync } from "node:fs"; |
| 15 |
import { tmpdir } from "node:os"; |
| 16 |
import { join } from "node:path"; |
| 17 |
import { BODY_R, RUN } from "./engine/glide.ts"; |
| 18 |
import { solidTile, TILE } from "./map.ts"; |
| 19 |
import { newMatch, setInput, speedOf, startRound, tick } from "./match.ts"; |
| 20 |
import { runBots } from "./bots.ts"; |
| 21 |
import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/engine/interp.js"; |
| 22 |
import { makeLocal, stepLocal } from "../static/engine/predict.js"; |
| 23 |
import { makeYaw, settle, told } from "../static/engine/yaw.js"; |
| 24 |
|
| 25 |
|
| 26 |
process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-")); |
| 27 |
const { broadcastEvery, makeValArena } = await import("./arena.ts"); |
| 28 |
|
| 29 |
const round2 = (n: number) => Math.round(n * 10) / 10; |
| 30 |
const world = { solid: solidTile, tile: TILE, r: BODY_R }; |
| 31 |
|
| 32 |
|
| 33 |
function rng(seed: number) { |
| 34 |
let s = seed | 0; |
| 35 |
return () => { |
| 36 |
s = (s + 0x6d2b79f5) | 0; |
| 37 |
let t = Math.imul(s ^ (s >>> 15), s | 1); |
| 38 |
t ^= t + Math.imul(t ^ (t >>> 7), t | 61); |
| 39 |
return ((t ^ (t >>> 14)) >>> 0) / 4294967296; |
| 40 |
}; |
| 41 |
} |
| 42 |
|
| 43 |
|
| 44 |
|
| 45 |
|
| 46 |
|
| 47 |
|
| 48 |
function jitter(random: () => number): number { |
| 49 |
const u = random(); |
| 50 |
const v = random(); |
| 51 |
const gauss = Math.sqrt(-2 * Math.log(1 - u)) * Math.cos(2 * Math.PI * v); |
| 52 |
return Math.abs(gauss) * 0.004 + (random() < 0.03 ? 0.012 : 0); |
| 53 |
} |
| 54 |
|
| 55 |
const spreadOf = (steps: number[]) => { |
| 56 |
const mean = steps.reduce((s, x) => s + x, 0) / steps.length; |
| 57 |
const sd = Math.sqrt(steps.reduce((s, x) => s + (x - mean) ** 2, 0) / steps.length); |
| 58 |
return { mean, spread: sd / mean }; |
| 59 |
}; |
| 60 |
|
| 61 |
type Body = { id: string; x: number; y: number; angle: number }; |
| 62 |
type Packet = { at: number; stamp: number; me: Body & { alive: boolean; phase: string; speed: number }; bodies: Body[] }; |
| 63 |
|
| 64 |
|
| 65 |
|
| 66 |
|
| 67 |
|
| 68 |
|
| 69 |
|
| 70 |
|
| 71 |
|
| 72 |
|
| 73 |
|
| 74 |
function walkOwnBody(base = 0.02, on: Partial<{ haste: number; slow: number; weapon: string }> = {}) { |
| 75 |
const random = rng(7); |
| 76 |
const state = newMatch(11); |
| 77 |
const me = state.players[0]!; |
| 78 |
me.bot = false; |
| 79 |
for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) { |
| 80 |
runBots(state, 1 / 30); |
| 81 |
tick(state, 1 / 30); |
| 82 |
} |
| 83 |
|
| 84 |
me.x = 1000; |
| 85 |
me.y = 640; |
| 86 |
me.hp = 100; |
| 87 |
me.alive = true; |
| 88 |
me.haste = 0; |
| 89 |
me.slow = 0; |
| 90 |
me.weapon = "classic"; |
| 91 |
Object.assign(me, on); |
| 92 |
const packets: Packet[] = []; |
| 93 |
const inputs: { at: number; mx: number; my: number }[] = []; |
| 94 |
const held = { mx: 0, my: 0 }; |
| 95 |
const walkFrom = 0.3; |
| 96 |
const walkUntil = 1.6; |
| 97 |
|
| 98 |
for (let k = 0; k * (1 / 30) < 2.2; k++) { |
| 99 |
const t = k / 30; |
| 100 |
while (inputs.length && inputs[0]!.at <= t) { |
| 101 |
const input = inputs.shift()!; |
| 102 |
setInput(me, { mx: input.mx, my: input.my, angle: 0 }); |
| 103 |
} |
| 104 |
|
| 105 |
|
| 106 |
for (let s = Math.ceil((t - 1 / 30) / 0.05); s * 0.05 < t; s++) { |
| 107 |
if (s * 0.05 < t - 1 / 30) continue; |
| 108 |
const sentAt = s * 0.05; |
| 109 |
const moving = sentAt >= walkFrom && sentAt < walkUntil; |
| 110 |
inputs.push({ at: sentAt + base, mx: moving ? 1 : 0, my: 0 }); |
| 111 |
} |
| 112 |
inputs.sort((a, b) => a.at - b.at); |
| 113 |
tick(state, 1 / 30); |
| 114 |
packets.push({ |
| 115 |
at: t + base + jitter(random), |
| 116 |
stamp: 0, |
| 117 |
me: { |
| 118 |
id: me.id, |
| 119 |
x: round2(me.x), |
| 120 |
y: round2(me.y), |
| 121 |
angle: round2(me.angle), |
| 122 |
alive: me.alive, |
| 123 |
phase: state.phase, |
| 124 |
speed: round2(speedOf(state, me)), |
| 125 |
}, |
| 126 |
bodies: [], |
| 127 |
}); |
| 128 |
} |
| 129 |
packets.sort((a, b) => a.at - b.at); |
| 130 |
|
| 131 |
const timeline = makeTimeline(); |
| 132 |
const local = makeLocal(); |
| 133 |
let snap: Packet | null = null; |
| 134 |
let next = 0; |
| 135 |
let previous = 0; |
| 136 |
const steps: number[] = []; |
| 137 |
let back = 0; |
| 138 |
let anchors = 0; |
| 139 |
for (let f = 0; f / 60 < 2.2; f++) { |
| 140 |
const now = f / 60; |
| 141 |
while (next < packets.length && packets[next]!.at <= now) { |
| 142 |
snap = packets[next]!; |
| 143 |
snap.stamp = arrive(timeline, snap.at); |
| 144 |
next += 1; |
| 145 |
} |
| 146 |
if (!snap) continue; |
| 147 |
held.mx = now >= walkFrom && now < walkUntil ? 1 : 0; |
| 148 |
const before = local.anchored; |
| 149 |
|
| 150 |
|
| 151 |
stepLocal(local, snap.me, held, snap.me.speed, now - snap.stamp, 1 / 60, world, null); |
| 152 |
|
| 153 |
if (before > 0) anchors += local.anchored - before; |
| 154 |
if (f > 0 && previous !== 0 && now > walkFrom + 0.2 && now < walkUntil - 0.05) { |
| 155 |
const step = local.x - previous; |
| 156 |
steps.push(step); |
| 157 |
back = Math.min(back, step); |
| 158 |
} |
| 159 |
previous = local.x; |
| 160 |
} |
| 161 |
return { steps, back, anchors, local, server: me }; |
| 162 |
} |
| 163 |
|
| 164 |
test("your own body, walking, takes even steps and never a backward one", () => { |
| 165 |
const { steps, back, anchors } = walkOwnBody(); |
| 166 |
const { mean, spread } = spreadOf(steps); |
| 167 |
console.log( |
| 168 |
`own body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` + |
| 169 |
`largest backward step ${back.toFixed(2)}, anchors ${anchors}`, |
| 170 |
); |
| 171 |
|
| 172 |
expect(mean * 60).toBeGreaterThan(RUN * 0.9); |
| 173 |
expect(mean * 60).toBeLessThan(RUN * 1.1); |
| 174 |
|
| 175 |
expect(spread).toBeLessThan(0.05); |
| 176 |
|
| 177 |
expect(back).toBeGreaterThan(-0.01); |
| 178 |
expect(anchors).toBe(0); |
| 179 |
}); |
| 180 |
|
| 181 |
test("a hasted walk is drawn at the speed the wire carried, rounding and all", () => { |
| 182 |
|
| 183 |
|
| 184 |
|
| 185 |
|
| 186 |
|
| 187 |
const { steps, back, anchors, local, server } = walkOwnBody(0.02, { haste: 10 }); |
| 188 |
const { mean, spread } = spreadOf(steps); |
| 189 |
console.log( |
| 190 |
`hasted body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` + |
| 191 |
`largest backward step ${back.toFixed(2)}, anchors ${anchors}, drawn ${(local.x - server.x).toFixed(1)} past the server`, |
| 192 |
); |
| 193 |
expect(mean * 60).toBeGreaterThan(RUN * 1.35 * 0.9); |
| 194 |
expect(mean * 60).toBeLessThan(RUN * 1.35 * 1.1); |
| 195 |
expect(spread).toBeLessThan(0.05); |
| 196 |
expect(back).toBeGreaterThan(-0.01); |
| 197 |
expect(anchors).toBe(0); |
| 198 |
}); |
| 199 |
|
| 200 |
test("starting and stopping never pulls you back, and the speed never dips a third", () => { |
| 201 |
|
| 202 |
|
| 203 |
|
| 204 |
|
| 205 |
const random = rng(3); |
| 206 |
const timeline = makeTimeline(); |
| 207 |
const local = makeLocal(); |
| 208 |
const me = { x: 1000, y: 640, angle: 0, alive: true }; |
| 209 |
const held = { mx: 0, my: 0 }; |
| 210 |
const hop = 0.03; |
| 211 |
let serverX = 1000; |
| 212 |
const packets: { at: number; x: number }[] = []; |
| 213 |
for (let k = 0; k / 30 < 2; k++) { |
| 214 |
const t = k / 30; |
| 215 |
|
| 216 |
const heardDown = Math.ceil(0.3 / 0.05) * 0.05 + hop; |
| 217 |
const heardUp = Math.ceil(1.2 / 0.05) * 0.05 + hop; |
| 218 |
if (t > heardDown && t <= heardUp) serverX += RUN / 30; |
| 219 |
packets.push({ at: t + hop + jitter(random), x: round2(serverX) }); |
| 220 |
} |
| 221 |
let next = 0; |
| 222 |
let previous = 0; |
| 223 |
let back = 0; |
| 224 |
let slowest = Infinity; |
| 225 |
for (let f = 0; f / 60 < 2; f++) { |
| 226 |
const now = f / 60; |
| 227 |
while (next < packets.length && packets[next]!.at <= now) { |
| 228 |
me.x = packets[next]!.x; |
| 229 |
arrive(timeline, packets[next]!.at); |
| 230 |
next += 1; |
| 231 |
} |
| 232 |
if (next === 0) continue; |
| 233 |
held.mx = now >= 0.3 && now < 1.2 ? 1 : 0; |
| 234 |
stepLocal(local, me, held, RUN, now - timeline.stamp, 1 / 60, world, null); |
| 235 |
const step = local.x - previous; |
| 236 |
if (now > 0.3 + 1 / 60) back = Math.min(back, step); |
| 237 |
if (now > 0.3 + 1 / 60 && now < 1.2) slowest = Math.min(slowest, step); |
| 238 |
previous = local.x; |
| 239 |
} |
| 240 |
console.log( |
| 241 |
`walk start and stop: largest backward step ${back.toFixed(2)}, slowest frame while walking ${slowest.toFixed(2)} of ${(RUN / 60).toFixed(2)}, ` + |
| 242 |
`settled ${(local.x - serverX).toFixed(1)} from the server`, |
| 243 |
); |
| 244 |
expect(back).toBeGreaterThan(-0.01); |
| 245 |
expect(slowest).toBeGreaterThan((RUN / 60) * 0.67); |
| 246 |
|
| 247 |
expect(Math.abs(local.x - serverX)).toBeLessThan(1); |
| 248 |
}); |
| 249 |
|
| 250 |
|
| 251 |
|
| 252 |
|
| 253 |
|
| 254 |
function watchBots() { |
| 255 |
const random = rng(5); |
| 256 |
const state = newMatch(23); |
| 257 |
const step = () => { |
| 258 |
runBots(state, 1 / 30); |
| 259 |
tick(state, 1 / 30); |
| 260 |
}; |
| 261 |
for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step(); |
| 262 |
const packets: { at: number; bodies: Body[] }[] = []; |
| 263 |
for (let k = 0; k < 90; k++) { |
| 264 |
step(); |
| 265 |
packets.push({ |
| 266 |
at: k / 30 + 0.02 + jitter(random), |
| 267 |
bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })), |
| 268 |
}); |
| 269 |
} |
| 270 |
const timeline = makeTimeline(); |
| 271 |
const paths = new Map<string, { x: number; y: number }[]>(); |
| 272 |
const delays: number[] = []; |
| 273 |
let ahead = 0; |
| 274 |
let frames = 0; |
| 275 |
let next = 0; |
| 276 |
for (let f = 0; f / 60 < 2.8; f++) { |
| 277 |
const now = f / 60; |
| 278 |
while (next < packets.length && packets[next]!.at <= now) { |
| 279 |
const stamp = arrive(timeline, packets[next]!.at); |
| 280 |
for (const p of packets[next]!.bodies) feed(timeline, p.id, p, stamp); |
| 281 |
next += 1; |
| 282 |
} |
| 283 |
if (next < 4) continue; |
| 284 |
const drawAt = renderTime(timeline, now); |
| 285 |
delays.push(now - drawAt); |
| 286 |
frames += 1; |
| 287 |
if (drawAt > timeline.stamp) ahead += 1; |
| 288 |
for (const p of packets[next - 1]!.bodies) { |
| 289 |
const at = bodyAt(timeline, p, drawAt); |
| 290 |
paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); |
| 291 |
} |
| 292 |
} |
| 293 |
const walked = [...paths.values()] |
| 294 |
.map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) |
| 295 |
.map((steps) => steps.filter((s) => s > 0.001)) |
| 296 |
.sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; |
| 297 |
return { walked, restarts: timeline.restarts, delays, ahead, frames, timeline }; |
| 298 |
} |
| 299 |
|
| 300 |
test("everybody else walks at an even speed over a jittery wire, and nobody restarts", () => { |
| 301 |
const { walked, restarts, delays, ahead, frames } = watchBots(); |
| 302 |
const { mean, spread } = spreadOf(walked); |
| 303 |
const dmin = Math.min(...delays); |
| 304 |
const dmax = Math.max(...delays); |
| 305 |
console.log( |
| 306 |
`others: ${walked.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` + |
| 307 |
`restarts ${restarts}, delay ${(dmin * 1000).toFixed(1)}–${(dmax * 1000).toFixed(1)}ms, ` + |
| 308 |
`drawn past the newest sample ${ahead}/${frames} frames`, |
| 309 |
); |
| 310 |
expect(walked.length).toBeGreaterThan(40); |
| 311 |
expect(mean * 60).toBeGreaterThan(150); |
| 312 |
expect(spread).toBeLessThan(0.08); |
| 313 |
|
| 314 |
expect(restarts).toBe(0); |
| 315 |
|
| 316 |
expect(ahead).toBe(0); |
| 317 |
}); |
| 318 |
|
| 319 |
test("the render clock never steps backwards or stalls, whatever the wire does", () => { |
| 320 |
const random = rng(9); |
| 321 |
const timeline = makeTimeline(); |
| 322 |
let arrival = 0.02; |
| 323 |
let drawn = 0; |
| 324 |
let worstShort = Infinity; |
| 325 |
let worstLong = 0; |
| 326 |
for (let f = 0; f / 60 < 5; f++) { |
| 327 |
const now = f / 60; |
| 328 |
while (arrival <= now) { |
| 329 |
arrive(timeline, arrival); |
| 330 |
arrival += 1 / 30 + jitter(random) - 0.002; |
| 331 |
} |
| 332 |
const at = renderTime(timeline, now); |
| 333 |
if (f > 30 && drawn > 0) { |
| 334 |
worstShort = Math.min(worstShort, at - drawn); |
| 335 |
worstLong = Math.max(worstLong, at - drawn); |
| 336 |
} |
| 337 |
drawn = at; |
| 338 |
} |
| 339 |
console.log(`render clock: frame advance ${(worstShort * 1000).toFixed(2)}–${(worstLong * 1000).toFixed(2)}ms of 16.67`); |
| 340 |
|
| 341 |
expect(worstShort * 60).toBeGreaterThan(0.97); |
| 342 |
expect(worstLong * 60).toBeLessThan(1.03); |
| 343 |
}); |
| 344 |
|
| 345 |
test("a burst of snapshots does not walk the even clock into the future", () => { |
| 346 |
|
| 347 |
|
| 348 |
|
| 349 |
|
| 350 |
const timeline = makeTimeline(); |
| 351 |
let at = 10; |
| 352 |
for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30)); |
| 353 |
const before = timeline.stamp; |
| 354 |
for (let i = 0; i < 150; i++) { |
| 355 |
const stamp = arrive(timeline, (at += 0.0002)); |
| 356 |
expect(stamp - at).toBeLessThanOrEqual(timeline.interval / 2 + 1e-9); |
| 357 |
expect(stamp).toBeGreaterThanOrEqual(before); |
| 358 |
} |
| 359 |
|
| 360 |
for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30)); |
| 361 |
expect(Math.abs(timeline.stamp - at)).toBeLessThan(0.01); |
| 362 |
}); |
| 363 |
|
| 364 |
test("a round start points you at the site once, and nothing stomps it a tick later", () => { |
| 365 |
const state = newMatch(31); |
| 366 |
const me = state.players[0]!; |
| 367 |
me.bot = false; |
| 368 |
const yaw = makeYaw(); |
| 369 |
let corrections = 0; |
| 370 |
let worstRound = 0; |
| 371 |
let at = 0; |
| 372 |
const hear = () => { |
| 373 |
const before = yaw.angle; |
| 374 |
settle(yaw, { id: me.id, angle: round2(me.angle), alive: me.alive }, at); |
| 375 |
if (yaw.angle !== before) corrections += 1; |
| 376 |
}; |
| 377 |
hear(); |
| 378 |
|
| 379 |
|
| 380 |
|
| 381 |
const inFlight: number[] = []; |
| 382 |
for (let round = 0; round < 12; round++) { |
| 383 |
const were = corrections; |
| 384 |
startRound(state); |
| 385 |
hear(); |
| 386 |
for (let k = 0; k < 30; k++) { |
| 387 |
at += 1 / 30; |
| 388 |
if (inFlight.length) setInput(me, { angle: inFlight.shift()! }); |
| 389 |
if (k % 2 === 0) { |
| 390 |
told(yaw, yaw.angle, at); |
| 391 |
inFlight.push(yaw.angle); |
| 392 |
} |
| 393 |
tick(state, 1 / 30); |
| 394 |
hear(); |
| 395 |
} |
| 396 |
|
| 397 |
const centre = me.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 }; |
| 398 |
const want = Math.atan2(centre.y - me.y, centre.x - me.x); |
| 399 |
expect(Math.abs(((yaw.angle - want + Math.PI * 3) % (Math.PI * 2)) - Math.PI)).toBeLessThan(0.11); |
| 400 |
worstRound = Math.max(worstRound, corrections - were); |
| 401 |
} |
| 402 |
console.log(`round starts: ${corrections} camera corrections over 12 rounds, at most ${worstRound} in one`); |
| 403 |
|
| 404 |
expect(worstRound).toBeLessThanOrEqual(1); |
| 405 |
}); |
| 406 |
|
| 407 |
|
| 408 |
|
| 409 |
|
| 410 |
|
| 411 |
|
| 412 |
|
| 413 |
|
| 414 |
|
| 415 |
|
| 416 |
|
| 417 |
|
| 418 |
test("the simulation's own motion is drawn at an even speed", async () => { |
| 419 |
const { runBots } = await import("./bots.ts"); |
| 420 |
const { newMatch, tick } = await import("./match.ts"); |
| 421 |
const round2 = (n: number) => Math.round(n * 10) / 10; |
| 422 |
|
| 423 |
|
| 424 |
const state = newMatch(); |
| 425 |
const step = () => { |
| 426 |
runBots(state, 1 / 30); |
| 427 |
tick(state, 1 / 30); |
| 428 |
}; |
| 429 |
for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step(); |
| 430 |
const stream: { t: number; bodies: { id: string; x: number; y: number; angle: number }[] }[] = []; |
| 431 |
for (let i = 0; i < 60; i++) { |
| 432 |
step(); |
| 433 |
stream.push({ |
| 434 |
t: i / 30, |
| 435 |
bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })), |
| 436 |
}); |
| 437 |
} |
| 438 |
|
| 439 |
const timeline = makeTimeline(); |
| 440 |
const paths = new Map<string, { x: number; y: number }[]>(); |
| 441 |
let next = 0; |
| 442 |
for (let f = 0; f / 60 < 1.8; f++) { |
| 443 |
const now = f / 60; |
| 444 |
while (next < stream.length && stream[next]!.t <= now) { |
| 445 |
arrive(timeline, stream[next]!.t); |
| 446 |
for (const p of stream[next]!.bodies) feed(timeline, p.id, p, stream[next]!.t); |
| 447 |
next += 1; |
| 448 |
} |
| 449 |
for (const p of stream[Math.max(0, next - 1)]!.bodies) { |
| 450 |
const at = bodyAt(timeline, p, now - delay(timeline)); |
| 451 |
paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); |
| 452 |
} |
| 453 |
} |
| 454 |
|
| 455 |
|
| 456 |
const walked = [...paths.values()] |
| 457 |
.filter((path) => path.length > 80) |
| 458 |
.map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) |
| 459 |
.map((steps) => steps.filter((step) => step > 0.001)) |
| 460 |
.sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; |
| 461 |
expect(walked.length).toBeGreaterThan(30); |
| 462 |
const mean = walked.reduce((s, x) => s + x, 0) / walked.length; |
| 463 |
const spread = Math.sqrt(walked.reduce((s, x) => s + (x - mean) ** 2, 0) / walked.length) / mean; |
| 464 |
|
| 465 |
|
| 466 |
expect(mean * 60).toBeGreaterThan(150); |
| 467 |
expect(spread).toBeLessThan(0.15); |
| 468 |
}); |
| 469 |
|
| 470 |
|
| 471 |
|
| 472 |
|
| 473 |
|
| 474 |
|
| 475 |
|
| 476 |
|
| 477 |
|
| 478 |
|
| 479 |
|
| 480 |
|
| 481 |
|
| 482 |
|
| 483 |
|
| 484 |
|
| 485 |
function watchRoom(seats: number, cadence: (filled: number) => number) { |
| 486 |
const random = rng(5); |
| 487 |
const arena = makeValArena(newMatch(23), () => {}, cadence); |
| 488 |
const packets: { at: number; bodies: Body[] }[] = []; |
| 489 |
let t = 0; |
| 490 |
let viewer = ""; |
| 491 |
for (let i = 0; i < seats; i++) { |
| 492 |
const seat = arena.takeSeat(`10.9.0.${i + 1}`, `feel-${i}`, `Seat ${i}`)!; |
| 493 |
if (i === 0) viewer = seat.id; |
| 494 |
arena.attach(`feel-${i}`, seat.id, `10.9.0.${i + 1}`, `t${i}`, (data) => { |
| 495 |
if (i !== 0) return; |
| 496 |
const snap = JSON.parse(data) as { players: Body[] }; |
| 497 |
packets.push({ at: t + 0.02 + jitter(random), bodies: snap.players.filter((p) => p.id !== viewer) }); |
| 498 |
}); |
| 499 |
} |
| 500 |
const tickOnce = () => { |
| 501 |
t += 1 / 30; |
| 502 |
arena.advance(1 / 30); |
| 503 |
}; |
| 504 |
|
| 505 |
const state = arena.match(); |
| 506 |
while (!(state.phase === "buy" && state.clock <= 3)) tickOnce(); |
| 507 |
packets.length = 0; |
| 508 |
t = 0; |
| 509 |
let warm = 0; |
| 510 |
while (state.phase !== "live") { |
| 511 |
tickOnce(); |
| 512 |
warm = t; |
| 513 |
} |
| 514 |
for (let k = 0; k < 90; k++) tickOnce(); |
| 515 |
packets.sort((a, b) => a.at - b.at); |
| 516 |
|
| 517 |
const timeline = makeTimeline(); |
| 518 |
const paths = new Map<string, { x: number; y: number }[]>(); |
| 519 |
const delays: number[] = []; |
| 520 |
let ahead = 0; |
| 521 |
let cold = 0; |
| 522 |
let frames = 0; |
| 523 |
let next = 0; |
| 524 |
let restartsWarm = 0; |
| 525 |
for (let f = 0; f / 60 < warm + 2.8; f++) { |
| 526 |
const now = f / 60; |
| 527 |
while (next < packets.length && packets[next]!.at <= now) { |
| 528 |
const stamp = arrive(timeline, packets[next]!.at); |
| 529 |
for (const p of packets[next]!.bodies) feed(timeline, p.id, p, stamp); |
| 530 |
next += 1; |
| 531 |
} |
| 532 |
if (next < 4) continue; |
| 533 |
const drawAt = renderTime(timeline, now); |
| 534 |
if (now < warm) { |
| 535 |
if (drawAt > timeline.stamp) cold += 1; |
| 536 |
restartsWarm = timeline.restarts; |
| 537 |
continue; |
| 538 |
} |
| 539 |
delays.push(now - drawAt); |
| 540 |
frames += 1; |
| 541 |
if (drawAt > timeline.stamp) ahead += 1; |
| 542 |
for (const p of packets[next - 1]!.bodies) { |
| 543 |
const at = bodyAt(timeline, p, drawAt); |
| 544 |
paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); |
| 545 |
} |
| 546 |
} |
| 547 |
const walked = [...paths.values()] |
| 548 |
.map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) |
| 549 |
.map((steps) => steps.filter((s) => s > 0.001)) |
| 550 |
.sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; |
| 551 |
const { mean, spread } = spreadOf(walked); |
| 552 |
return { |
| 553 |
packets: packets.filter((p) => p.at >= warm).length, |
| 554 |
steps: walked.length, |
| 555 |
mean, |
| 556 |
spread, |
| 557 |
restarts: timeline.restarts - restartsWarm, |
| 558 |
delay: { min: Math.min(...delays), max: Math.max(...delays), now: delay(timeline) }, |
| 559 |
ahead, |
| 560 |
cold, |
| 561 |
frames, |
| 562 |
}; |
| 563 |
} |
| 564 |
|
| 565 |
|
| 566 |
const halvedAtThree = (filled: number): number => (filled <= 2 ? 1 : 2); |
| 567 |
|
| 568 |
test("in a room of four or eight, everybody else is drawn 45ms behind, not 87, with no new jitter", () => { |
| 569 |
for (const seats of [4, 8]) { |
| 570 |
const before = watchRoom(seats, halvedAtThree); |
| 571 |
const after = watchRoom(seats, broadcastEvery); |
| 572 |
for (const [label, r] of [ |
| 573 |
["before", before], |
| 574 |
[" after", after], |
| 575 |
] as const) { |
| 576 |
console.log( |
| 577 |
`room of ${seats}, ${label}: ${r.packets} snapshots in 3s, ${r.steps} steps, mean ${r.mean.toFixed(2)}/frame, ` + |
| 578 |
`spread ${(r.spread * 100).toFixed(1)}%, restarts ${r.restarts}, ` + |
| 579 |
`delay ${(r.delay.min * 1000).toFixed(1)}–${(r.delay.max * 1000).toFixed(1)}ms (${(r.delay.now * 1000).toFixed(1)} in force), ` + |
| 580 |
`drawn past the newest sample ${r.ahead}/${r.frames} frames, ${r.cold} while the delay settled`, |
| 581 |
); |
| 582 |
} |
| 583 |
|
| 584 |
expect(after.packets).toBeGreaterThanOrEqual(85); |
| 585 |
expect(before.packets).toBeLessThanOrEqual(46); |
| 586 |
|
| 587 |
|
| 588 |
expect(after.delay.max).toBeLessThan(0.05); |
| 589 |
expect(before.delay.max).toBeGreaterThan(0.08); |
| 590 |
|
| 591 |
|
| 592 |
|
| 593 |
|
| 594 |
|
| 595 |
|
| 596 |
expect(after.steps).toBeGreaterThan(40); |
| 597 |
expect(after.mean * 60).toBeGreaterThan(150); |
| 598 |
if (seats === 4) expect(after.spread).toBeLessThan(0.08); |
| 599 |
expect(after.spread).toBeLessThanOrEqual(before.spread + 0.01); |
| 600 |
expect(after.restarts).toBeLessThanOrEqual(before.restarts); |
| 601 |
|
| 602 |
|
| 603 |
|
| 604 |
|
| 605 |
|
| 606 |
expect(after.ahead).toBeLessThanOrEqual(Math.ceil(after.frames * 0.03)); |
| 607 |
expect(before.cold).toBeGreaterThan(10); |
| 608 |
expect(after.cold).toBeLessThanOrEqual(2); |
| 609 |
} |
| 610 |
}); |