rook/val Public Public: anyone can read this; nobody can change it.
forked from benjivers/val
workbenchthis machine
Files defe0100948405308550387735734785bfaa6c55
| 1 | /** |
| 2 | * The whole feel, measured. |
| 3 | * |
| 4 | * A wire between the real match and the real client modules: snapshots leave |
| 5 | * on the server's ticks, arrive a little late and a little unevenly, and the |
| 6 | * browser side draws sixty frames a second from them exactly the way val.js |
| 7 | * does. What comes out is a set of numbers a person's "it stutters" can be |
| 8 | * held against — how even the per-frame steps are, how often a correction |
| 9 | * jumps, how large the largest jump is — and every one of them is a rule |
| 10 | * this file enforces from here on. |
| 11 | */ |
| 12 | |
| 13 | import { expect, test } from "bun:test"; |
| 14 | import { BODY_R, moveSpeed, RUN } from "./engine/glide.ts"; |
| 15 | import { solidTile, TILE } from "./map.ts"; |
| 16 | import { newMatch, setInput, startRound, tick } from "./match.ts"; |
| 17 | import { runBots } from "./bots.ts"; |
| 18 | import { arrive, bodyAt, delay, feed, makeTimeline, renderTime } from "../static/engine/interp.js"; |
| 19 | import { makeLocal, stepLocal } from "../static/engine/predict.js"; |
| 20 | import { makeYaw, settle, told } from "../static/engine/yaw.js"; |
| 21 | |
| 22 | const round2 = (n: number) => Math.round(n * 10) / 10; |
| 23 | const world = { solid: solidTile, tile: TILE, r: BODY_R }; |
| 24 | |
| 25 | /** A seeded wobble, so the run is the same every time. */ |
| 26 | function rng(seed: number) { |
| 27 | let s = seed | 0; |
| 28 | return () => { |
| 29 | s = (s + 0x6d2b79f5) | 0; |
| 30 | let t = Math.imul(s ^ (s >>> 15), s | 1); |
| 31 | t ^= t + Math.imul(t ^ (t >>> 7), t | 61); |
| 32 | return ((t ^ (t >>> 14)) >>> 0) / 4294967296; |
| 33 | }; |
| 34 | } |
| 35 | |
| 36 | /** |
| 37 | * The wire as it was measured: 30 a second, p50 gap 33ms, p99 41–48ms, no |
| 38 | * gap over 100ms. Each packet takes a base latency plus a wobble, and now |
| 39 | * and then one is held a dozen milliseconds longer. |
| 40 | */ |
| 41 | function jitter(random: () => number): number { |
| 42 | const u = random(); |
| 43 | const v = random(); |
| 44 | const gauss = Math.sqrt(-2 * Math.log(1 - u)) * Math.cos(2 * Math.PI * v); |
| 45 | return Math.abs(gauss) * 0.004 + (random() < 0.03 ? 0.012 : 0); |
| 46 | } |
| 47 | |
| 48 | const spreadOf = (steps: number[]) => { |
| 49 | const mean = steps.reduce((s, x) => s + x, 0) / steps.length; |
| 50 | const sd = Math.sqrt(steps.reduce((s, x) => s + (x - mean) ** 2, 0) / steps.length); |
| 51 | return { mean, spread: sd / mean }; |
| 52 | }; |
| 53 | |
| 54 | type Body = { id: string; x: number; y: number; angle: number }; |
| 55 | type Packet = { at: number; stamp: number; me: Body & { alive: boolean; phase: string }; bodies: Body[] }; |
| 56 | |
| 57 | /** |
| 58 | * A person walking east across open ground for two seconds, sending intent |
| 59 | * every 50ms, hearing back thirty times a second over a jittery wire, and |
| 60 | * drawn at sixty frames — with the input reaching the server one way late. |
| 61 | */ |
| 62 | function walkOwnBody(base = 0.02) { |
| 63 | const random = rng(7); |
| 64 | const state = newMatch(11); |
| 65 | const me = state.players[0]!; |
| 66 | me.bot = false; |
| 67 | for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) { |
| 68 | runBots(state, 1 / 30); |
| 69 | tick(state, 1 / 30); |
| 70 | } |
| 71 | // Somewhere open: the middle of the map, facing east. |
| 72 | me.x = 1000; |
| 73 | me.y = 640; |
| 74 | me.hp = 100; |
| 75 | me.alive = true; |
| 76 | me.haste = 0; |
| 77 | me.slow = 0; |
| 78 | me.weapon = "classic"; |
| 79 | const packets: Packet[] = []; |
| 80 | const inputs: { at: number; mx: number; my: number }[] = []; |
| 81 | const held = { mx: 0, my: 0 }; |
| 82 | const walkFrom = 0.3; |
| 83 | const walkUntil = 1.6; |
| 84 | // The server's clock: one tick every 33ms; snapshots leave right after. |
| 85 | for (let k = 0; k * (1 / 30) < 2.2; k++) { |
| 86 | const t = k / 30; |
| 87 | while (inputs.length && inputs[0]!.at <= t) { |
| 88 | const input = inputs.shift()!; |
| 89 | setInput(me, { mx: input.mx, my: input.my, angle: 0 }); |
| 90 | } |
| 91 | // The browser's send timer is on its own clock: every 50ms, arriving one |
| 92 | // way of latency later. |
| 93 | for (let s = Math.ceil((t - 1 / 30) / 0.05); s * 0.05 < t; s++) { |
| 94 | if (s * 0.05 < t - 1 / 30) continue; |
| 95 | const sentAt = s * 0.05; |
| 96 | const moving = sentAt >= walkFrom && sentAt < walkUntil; |
| 97 | inputs.push({ at: sentAt + base, mx: moving ? 1 : 0, my: 0 }); |
| 98 | } |
| 99 | inputs.sort((a, b) => a.at - b.at); |
| 100 | tick(state, 1 / 30); |
| 101 | packets.push({ |
| 102 | at: t + base + jitter(random), |
| 103 | stamp: 0, |
| 104 | me: { id: me.id, x: round2(me.x), y: round2(me.y), angle: round2(me.angle), alive: me.alive, phase: state.phase }, |
| 105 | bodies: [], |
| 106 | }); |
| 107 | } |
| 108 | packets.sort((a, b) => a.at - b.at); |
| 109 | |
| 110 | const timeline = makeTimeline(); |
| 111 | const local = makeLocal(); |
| 112 | let snap: Packet | null = null; |
| 113 | let next = 0; |
| 114 | let previous = 0; |
| 115 | const steps: number[] = []; |
| 116 | let back = 0; |
| 117 | let anchors = 0; |
| 118 | for (let f = 0; f / 60 < 2.2; f++) { |
| 119 | const now = f / 60; |
| 120 | while (next < packets.length && packets[next]!.at <= now) { |
| 121 | snap = packets[next]!; |
| 122 | snap.stamp = arrive(timeline, snap.at); |
| 123 | next += 1; |
| 124 | } |
| 125 | if (!snap) continue; |
| 126 | held.mx = now >= walkFrom && now < walkUntil ? 1 : 0; |
| 127 | const before = local.anchored; |
| 128 | stepLocal(local, snap.me, held, moveSpeed({}), now - snap.stamp, 1 / 60, world, null); |
| 129 | // The first word about where you are is taken outright: that one is not a jump. |
| 130 | if (before > 0) anchors += local.anchored - before; |
| 131 | if (f > 0 && previous !== 0 && now > walkFrom + 0.2 && now < walkUntil - 0.05) { |
| 132 | const step = local.x - previous; |
| 133 | steps.push(step); |
| 134 | back = Math.min(back, step); |
| 135 | } |
| 136 | previous = local.x; |
| 137 | } |
| 138 | return { steps, back, anchors, local, server: me }; |
| 139 | } |
| 140 | |
| 141 | test("your own body, walking, takes even steps and never a backward one", () => { |
| 142 | const { steps, back, anchors } = walkOwnBody(); |
| 143 | const { mean, spread } = spreadOf(steps); |
| 144 | console.log( |
| 145 | `own body: ${steps.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` + |
| 146 | `largest backward step ${back.toFixed(2)}, anchors ${anchors}`, |
| 147 | ); |
| 148 | // Running east at 250 a second is 4.17 a frame. |
| 149 | expect(mean * 60).toBeGreaterThan(RUN * 0.9); |
| 150 | expect(mean * 60).toBeLessThan(RUN * 1.1); |
| 151 | // Even: the per-frame step does not beat against the wire's jitter. |
| 152 | expect(spread).toBeLessThan(0.05); |
| 153 | // And a step backwards is a stutter by definition. |
| 154 | expect(back).toBeGreaterThan(-0.01); |
| 155 | expect(anchors).toBe(0); |
| 156 | }); |
| 157 | |
| 158 | test("starting and stopping never pulls you back, and the speed never dips a third", () => { |
| 159 | // The first tenth of a second after the key goes down the server has not |
| 160 | // heard yet, and the guess used to be yanked back to the server's still |
| 161 | // body on each of the first two snapshots. Letting go, the server runs on |
| 162 | // for the same lag and the guess must not be dragged back to meet it. |
| 163 | const random = rng(3); |
| 164 | const timeline = makeTimeline(); |
| 165 | const local = makeLocal(); |
| 166 | const me = { x: 1000, y: 640, angle: 0, alive: true }; |
| 167 | const held = { mx: 0, my: 0 }; |
| 168 | const hop = 0.03; // one way, plus the send timer's own phase |
| 169 | let serverX = 1000; |
| 170 | const packets: { at: number; x: number }[] = []; |
| 171 | for (let k = 0; k / 30 < 2; k++) { |
| 172 | const t = k / 30; |
| 173 | // The server hears the key a hop after the next 50ms send. |
| 174 | const heardDown = Math.ceil(0.3 / 0.05) * 0.05 + hop; |
| 175 | const heardUp = Math.ceil(1.2 / 0.05) * 0.05 + hop; |
| 176 | if (t > heardDown && t <= heardUp) serverX += RUN / 30; |
| 177 | packets.push({ at: t + hop + jitter(random), x: round2(serverX) }); |
| 178 | } |
| 179 | let next = 0; |
| 180 | let previous = 0; |
| 181 | let back = 0; |
| 182 | let slowest = Infinity; |
| 183 | for (let f = 0; f / 60 < 2; f++) { |
| 184 | const now = f / 60; |
| 185 | while (next < packets.length && packets[next]!.at <= now) { |
| 186 | me.x = packets[next]!.x; |
| 187 | arrive(timeline, packets[next]!.at); |
| 188 | next += 1; |
| 189 | } |
| 190 | if (next === 0) continue; |
| 191 | held.mx = now >= 0.3 && now < 1.2 ? 1 : 0; |
| 192 | stepLocal(local, me, held, RUN, now - timeline.stamp, 1 / 60, world, null); |
| 193 | const step = local.x - previous; |
| 194 | if (now > 0.3 + 1 / 60) back = Math.min(back, step); |
| 195 | if (now > 0.3 + 1 / 60 && now < 1.2) slowest = Math.min(slowest, step); |
| 196 | previous = local.x; |
| 197 | } |
| 198 | console.log( |
| 199 | `walk start and stop: largest backward step ${back.toFixed(2)}, slowest frame while walking ${slowest.toFixed(2)} of ${(RUN / 60).toFixed(2)}, ` + |
| 200 | `settled ${(local.x - serverX).toFixed(1)} from the server`, |
| 201 | ); |
| 202 | expect(back).toBeGreaterThan(-0.01); |
| 203 | expect(slowest).toBeGreaterThan((RUN / 60) * 0.67); |
| 204 | // Where you stopped is where the server stops: the guess settles onto it. |
| 205 | expect(Math.abs(local.x - serverX)).toBeLessThan(1); |
| 206 | }); |
| 207 | |
| 208 | /** |
| 209 | * Everybody else: a bot walking, heard about thirty times a second over the |
| 210 | * same wire, drawn a little in the past between the samples on hand. |
| 211 | */ |
| 212 | function watchBots() { |
| 213 | const random = rng(5); |
| 214 | const state = newMatch(23); |
| 215 | const step = () => { |
| 216 | runBots(state, 1 / 30); |
| 217 | tick(state, 1 / 30); |
| 218 | }; |
| 219 | for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step(); |
| 220 | const packets: { at: number; bodies: Body[] }[] = []; |
| 221 | for (let k = 0; k < 90; k++) { |
| 222 | step(); |
| 223 | packets.push({ |
| 224 | at: k / 30 + 0.02 + jitter(random), |
| 225 | bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })), |
| 226 | }); |
| 227 | } |
| 228 | const timeline = makeTimeline(); |
| 229 | const paths = new Map<string, { x: number; y: number }[]>(); |
| 230 | const delays: number[] = []; |
| 231 | let ahead = 0; |
| 232 | let frames = 0; |
| 233 | let next = 0; |
| 234 | for (let f = 0; f / 60 < 2.8; f++) { |
| 235 | const now = f / 60; |
| 236 | while (next < packets.length && packets[next]!.at <= now) { |
| 237 | const stamp = arrive(timeline, packets[next]!.at); |
| 238 | for (const p of packets[next]!.bodies) feed(timeline, p.id, p, stamp); |
| 239 | next += 1; |
| 240 | } |
| 241 | if (next < 4) continue; |
| 242 | const drawAt = renderTime(timeline, now); |
| 243 | delays.push(now - drawAt); |
| 244 | frames += 1; |
| 245 | if (drawAt > timeline.stamp) ahead += 1; |
| 246 | for (const p of packets[next - 1]!.bodies) { |
| 247 | const at = bodyAt(timeline, p, drawAt); |
| 248 | paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); |
| 249 | } |
| 250 | } |
| 251 | const walked = [...paths.values()] |
| 252 | .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) |
| 253 | .map((steps) => steps.filter((s) => s > 0.001)) |
| 254 | .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; |
| 255 | return { walked, restarts: timeline.restarts, delays, ahead, frames, timeline }; |
| 256 | } |
| 257 | |
| 258 | test("everybody else walks at an even speed over a jittery wire, and nobody restarts", () => { |
| 259 | const { walked, restarts, delays, ahead, frames } = watchBots(); |
| 260 | const { mean, spread } = spreadOf(walked); |
| 261 | const dmin = Math.min(...delays); |
| 262 | const dmax = Math.max(...delays); |
| 263 | console.log( |
| 264 | `others: ${walked.length} steps, mean ${mean.toFixed(2)}/frame, spread ${(spread * 100).toFixed(1)}%, ` + |
| 265 | `restarts ${restarts}, delay ${(dmin * 1000).toFixed(1)}–${(dmax * 1000).toFixed(1)}ms, ` + |
| 266 | `drawn past the newest sample ${ahead}/${frames} frames`, |
| 267 | ); |
| 268 | expect(walked.length).toBeGreaterThan(40); |
| 269 | expect(mean * 60).toBeGreaterThan(150); |
| 270 | expect(spread).toBeLessThan(0.08); |
| 271 | // A body that was in sight all along never jumps to start again. |
| 272 | expect(restarts).toBe(0); |
| 273 | // The moment drawn is never later than the newest word: no guessing ahead. |
| 274 | expect(ahead).toBe(0); |
| 275 | }); |
| 276 | |
| 277 | test("the render clock never steps backwards or stalls, whatever the wire does", () => { |
| 278 | const random = rng(9); |
| 279 | const timeline = makeTimeline(); |
| 280 | let arrival = 0.02; |
| 281 | let drawn = 0; |
| 282 | let worstShort = Infinity; |
| 283 | let worstLong = 0; |
| 284 | for (let f = 0; f / 60 < 5; f++) { |
| 285 | const now = f / 60; |
| 286 | while (arrival <= now) { |
| 287 | arrive(timeline, arrival); |
| 288 | arrival += 1 / 30 + jitter(random) - 0.002; |
| 289 | } |
| 290 | const at = renderTime(timeline, now); |
| 291 | if (f > 30 && drawn > 0) { |
| 292 | worstShort = Math.min(worstShort, at - drawn); |
| 293 | worstLong = Math.max(worstLong, at - drawn); |
| 294 | } |
| 295 | drawn = at; |
| 296 | } |
| 297 | console.log(`render clock: frame advance ${(worstShort * 1000).toFixed(2)}–${(worstLong * 1000).toFixed(2)}ms of 16.67`); |
| 298 | // Within a few percent of real time, every frame: 16.67ms give or take. |
| 299 | expect(worstShort * 60).toBeGreaterThan(0.97); |
| 300 | expect(worstLong * 60).toBeLessThan(1.03); |
| 301 | }); |
| 302 | |
| 303 | test("a burst of snapshots does not walk the even clock into the future", () => { |
| 304 | // A tab back from the background is handed everything it missed at once. |
| 305 | // The clock those samples are filed on may lead the arrivals by half a |
| 306 | // beat and no more, or the sent angles age out and every echo of your own |
| 307 | // view comes back as a correction. |
| 308 | const timeline = makeTimeline(); |
| 309 | let at = 10; |
| 310 | for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30)); |
| 311 | const before = timeline.stamp; |
| 312 | for (let i = 0; i < 150; i++) { |
| 313 | const stamp = arrive(timeline, (at += 0.0002)); |
| 314 | expect(stamp - at).toBeLessThanOrEqual(timeline.interval / 2 + 1e-9); |
| 315 | expect(stamp).toBeGreaterThanOrEqual(before); |
| 316 | } |
| 317 | // And once the wire is itself again, the beat is back on it. |
| 318 | for (let i = 0; i < 40; i++) arrive(timeline, (at += 1 / 30)); |
| 319 | expect(Math.abs(timeline.stamp - at)).toBeLessThan(0.01); |
| 320 | }); |
| 321 | |
| 322 | test("a round start points you at the site once, and nothing stomps it a tick later", () => { |
| 323 | const state = newMatch(31); |
| 324 | const me = state.players[0]!; |
| 325 | me.bot = false; |
| 326 | const yaw = makeYaw(); |
| 327 | let corrections = 0; |
| 328 | let worstRound = 0; |
| 329 | let at = 0; |
| 330 | const hear = () => { |
| 331 | const before = yaw.angle; |
| 332 | settle(yaw, { id: me.id, angle: round2(me.angle), alive: me.alive }, at); |
| 333 | if (yaw.angle !== before) corrections += 1; |
| 334 | }; |
| 335 | hear(); |
| 336 | // A dozen round starts, each followed by a second of ticks with the client |
| 337 | // echoing back exactly what it was pointed at, the way the input timer |
| 338 | // does — and each echo reaching the server a tick after it was sent. |
| 339 | const inFlight: number[] = []; |
| 340 | for (let round = 0; round < 12; round++) { |
| 341 | const were = corrections; |
| 342 | startRound(state); |
| 343 | hear(); // the snapshot that follows the tick which started the round |
| 344 | for (let k = 0; k < 30; k++) { |
| 345 | at += 1 / 30; |
| 346 | if (inFlight.length) setInput(me, { angle: inFlight.shift()! }); |
| 347 | if (k % 2 === 0) { |
| 348 | told(yaw, yaw.angle, at); |
| 349 | inFlight.push(yaw.angle); |
| 350 | } |
| 351 | tick(state, 1 / 30); |
| 352 | hear(); |
| 353 | } |
| 354 | // Facing the site the server put us at, still, a second later. |
| 355 | const centre = me.side === "atk" ? { x: 1200, y: 400 } : { x: 1200, y: 1200 }; |
| 356 | const want = Math.atan2(centre.y - me.y, centre.x - me.x); |
| 357 | expect(Math.abs(((yaw.angle - want + Math.PI * 3) % (Math.PI * 2)) - Math.PI)).toBeLessThan(0.11); |
| 358 | worstRound = Math.max(worstRound, corrections - were); |
| 359 | } |
| 360 | console.log(`round starts: ${corrections} camera corrections over 12 rounds, at most ${worstRound} in one`); |
| 361 | // At most one a round — the spawn — and never a second one to angle zero. |
| 362 | expect(worstRound).toBeLessThanOrEqual(1); |
| 363 | }); |
| 364 | |
| 365 | /** |
| 366 | * The match's own motion, sampled at the rate the server broadcasts it and |
| 367 | * drawn at the rate a browser draws: what a bot walking across the map |
| 368 | * actually looks like. The samples carry exactly what a snapshot carries — |
| 369 | * a position rounded to a tenth, thirty times a second. |
| 370 | * |
| 371 | * The measure is the spread of the per-frame steps. A body drawn at the |
| 372 | * server's speed takes even steps; the exponential chase this replaced took |
| 373 | * a long step just after each snapshot and shorter ones after it, which is |
| 374 | * choppiness with a number on it — twice this spread, measured the same way. |
| 375 | */ |
| 376 | test("the simulation's own motion is drawn at an even speed", async () => { |
| 377 | const { runBots } = await import("./bots.ts"); |
| 378 | const { newMatch, tick } = await import("./match.ts"); |
| 379 | const round2 = (n: number) => Math.round(n * 10) / 10; |
| 380 | |
| 381 | // A match of its own: nothing here touches the arena everybody shares. |
| 382 | const state = newMatch(); |
| 383 | const step = () => { |
| 384 | runBots(state, 1 / 30); |
| 385 | tick(state, 1 / 30); |
| 386 | }; |
| 387 | for (let i = 0; i < 30 * 40 && state.phase !== "live"; i++) step(); |
| 388 | const stream: { t: number; bodies: { id: string; x: number; y: number; angle: number }[] }[] = []; |
| 389 | for (let i = 0; i < 60; i++) { |
| 390 | step(); |
| 391 | stream.push({ |
| 392 | t: i / 30, |
| 393 | bodies: state.players.map((p) => ({ id: p.id, x: round2(p.x), y: round2(p.y), angle: round2(p.angle) })), |
| 394 | }); |
| 395 | } |
| 396 | |
| 397 | const timeline = makeTimeline(); |
| 398 | const paths = new Map<string, { x: number; y: number }[]>(); |
| 399 | let next = 0; |
| 400 | for (let f = 0; f / 60 < 1.8; f++) { |
| 401 | const now = f / 60; |
| 402 | while (next < stream.length && stream[next]!.t <= now) { |
| 403 | arrive(timeline, stream[next]!.t); |
| 404 | for (const p of stream[next]!.bodies) feed(timeline, p.id, p, stream[next]!.t); |
| 405 | next += 1; |
| 406 | } |
| 407 | for (const p of stream[Math.max(0, next - 1)]!.bodies) { |
| 408 | const at = bodyAt(timeline, p, now - delay(timeline)); |
| 409 | paths.set(p.id, [...(paths.get(p.id) ?? []), { x: at.x, y: at.y }]); |
| 410 | } |
| 411 | } |
| 412 | |
| 413 | // Whoever walked furthest in that second and a half. |
| 414 | const walked = [...paths.values()] |
| 415 | .filter((path) => path.length > 80) |
| 416 | .map((path) => path.slice(1).map((at, i) => Math.hypot(at.x - path[i]!.x, at.y - path[i]!.y))) |
| 417 | .map((steps) => steps.filter((step) => step > 0.001)) |
| 418 | .sort((a, b) => b.reduce((s, x) => s + x, 0) - a.reduce((s, x) => s + x, 0))[0]!; |
| 419 | expect(walked.length).toBeGreaterThan(30); |
| 420 | const mean = walked.reduce((s, x) => s + x, 0) / walked.length; |
| 421 | const spread = Math.sqrt(walked.reduce((s, x) => s + (x - mean) ** 2, 0) / walked.length) / mean; |
| 422 | // A body moving at the server's own speed: about four units every sixtieth |
| 423 | // of a second, which is the run speed. |
| 424 | expect(mean * 60).toBeGreaterThan(150); |
| 425 | expect(spread).toBeLessThan(0.15); |
| 426 | }); |