Public: anyone can read this; the crew can change it.
forked from trevato/cs
The page's end of a room is pinned before anything moves it: src/client/session.ts gets the test file its docstring already claims #345
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/319-the-page-s-end-of-a-room-is-pinned-befor → main 1 commit fork
3 files
changed +626
−13
src/client/session.test.ts added +605
| @@ -0,0 +1,605 @@ | ||
| 1 | /** | |
| 2 | * A whole room played through a socket of plain objects: the file | |
| 3 | * `src/client/session.ts` has claimed in its own docstring since it was | |
| 4 | * written. There is no browser here, no timer and no port — a socket is four | |
| 5 | * callbacks and a `send` that keeps what it was handed, and the clock is a | |
| 6 | * number the test moves. | |
| 7 | * | |
| 8 | * Everything this module does is a boundary. A `hello`, a roster, a log line | |
| 9 | * and a binary frame all arrive from the network and are believed, so every | |
| 10 | * claim below comes in a pair: what an honest frame does to the world, and | |
| 11 | * that a frame which is not one changes nothing and throws nothing. The | |
| 12 | * arithmetic of the clock is written out where it is used — `behind` is | |
| 13 | * `now - landed` in milliseconds, and `BEHIND_MS` is 500 of them, so 500 is | |
| 14 | * still playing and 501 is behind. | |
| 15 | * | |
| 16 | * The frames are real: they are written by `src/rooms/wire.ts` out of a second | |
| 17 | * `World` that stands in for the room's own, because a snapshot a test made up | |
| 18 | * by hand would prove the test and not the wire. Nothing here imports | |
| 19 | * `src/rooms/stage.test-helper.ts` — those fixtures are the rooms tests' — and | |
| 20 | * the world on this side is the game's own `createWorld`, the same one every | |
| 21 | * other test under `src/client/` uses. | |
| 22 | */ | |
| 23 | ||
| 24 | import { expect, test } from "bun:test"; | |
| 25 | import { pushEffect } from "../engine/index.ts"; | |
| 26 | import { | |
| 27 | INPUT_BYTES, SNAPSHOT_FRAME, SNAPSHOT_MAX, SWITCH_PRIMARY, | |
| 28 | readInput, writeSnapshot, type Hello, type Seated, | |
| 29 | } from "../rooms/wire.ts"; | |
| 30 | import { DE_FORGE } from "./map.ts"; | |
| 31 | import { SHORT } from "./match.ts"; | |
| 32 | import { | |
| 33 | BEHIND, BEHIND_MS, CONNECTING, DROPPED, PLAYING, createSession, type Session, type Socket, | |
| 34 | } from "./session.ts"; | |
| 35 | import { createWorld, NO_INPUT, type Player, type World } from "./world.ts"; | |
| 36 | ||
| 37 | const CODE = "ABCDEF"; | |
| 38 | const ROOM_URL = "/ws?room=ABCDEF&name=you"; | |
| 39 | /** Which body the room says is ours. Not seat 0, so `me` has to move. */ | |
| 40 | const SEAT = 3; | |
| 41 | /** The room's seed, which both ends build the same ten bodies from. */ | |
| 42 | const SEED = 9; | |
| 43 | /** Where the test's clock starts, in milliseconds since the page loaded. */ | |
| 44 | const START = 1000; | |
| 45 | ||
| 46 | /** The same ten bodies on the same map: one world each end of the wire. */ | |
| 47 | const tenBodies = (name: string): World => createWorld(DE_FORGE, SHORT, name, "ct", 5, SEED); | |
| 48 | ||
| 49 | const body = (world: World, seat: number): Player => world.players[seat] as Player; | |
| 50 | ||
| 51 | const person = (seat: number, name: string, team: "t" | "ct" = "ct"): Seated => | |
| 52 | ({ seat, name, bot: false, team }); | |
| 53 | const bot = (seat: number, name: string, team: "t" | "ct" = "ct"): Seated => | |
| 54 | ({ seat, name, bot: true, team }); | |
| 55 | ||
| 56 | /** Two people and a bot, with seats 1, 2, 4 and 6…9 left unmentioned. */ | |
| 57 | const ROSTER: Seated[] = [person(0, "ada", "t"), bot(5, "Rook", "t"), person(SEAT, "you")]; | |
| 58 | ||
| 59 | const helloText = (over: Partial<Hello> = {}): string => | |
| 60 | JSON.stringify({ | |
| 61 | k: "hello", code: CODE, seat: SEAT, map: "de_forge", seed: SEED, | |
| 62 | rules: "short", tick: 64, snapshotHz: 16, roster: ROSTER, ...over, | |
| 63 | }); | |
| 64 | ||
| 65 | const rosterText = (roster: Seated[]): string => JSON.stringify({ k: "roster", roster }); | |
| 66 | ||
| 67 | /** The room's round as bytes, written the way `src/rooms/room.ts` sends it. */ | |
| 68 | const snapshotOf = (world: World, tick = 1, since = 0): Uint8Array => | |
| 69 | writeSnapshot(world, tick, since, new Uint8Array(SNAPSHOT_MAX)); | |
| 70 | ||
| 71 | /** | |
| 72 | * Put the room's bodies somewhere they could not have spawned, in numbers an | |
| 73 | * `f32` keeps exactly — so a position off the wire is compared with `toEqual` | |
| 74 | * and not with a tolerance that would hide a wrong field. | |
| 75 | */ | |
| 76 | function move(world: World): void { | |
| 77 | for (const [i, p] of world.players.entries()) { | |
| 78 | p.pos = [100 + i * 64, 200 - i * 32, 64]; | |
| 79 | p.vel = [i * 8, -i * 4, 0]; | |
| 80 | p.yaw = 90; | |
| 81 | p.health = 80 - i; | |
| 82 | } | |
| 83 | world.time = 12.5; | |
| 84 | world.match.phase = "live"; | |
| 85 | world.match.round = 3; | |
| 86 | } | |
| 87 | ||
| 88 | /** One socket of plain objects, and the handle the test drives it by. */ | |
| 89 | type Wire = { | |
| 90 | socket: Socket; | |
| 91 | /** | |
| 92 | * Every frame the session sent, each one copied. The session writes every | |
| 93 | * input into one 16-byte buffer it keeps, so what `send` is handed is a view | |
| 94 | * of that buffer: keeping the view would make all three frames read back as | |
| 95 | * the last one. | |
| 96 | */ | |
| 97 | sent: Uint8Array[]; | |
| 98 | /** How many times the session asked the socket to close. */ | |
| 99 | closes: number; | |
| 100 | open(): void; | |
| 101 | /** The socket errored: a connection that is no longer live, but not closed. */ | |
| 102 | error(): void; | |
| 103 | /** The room hung up. */ | |
| 104 | drop(): void; | |
| 105 | /** | |
| 106 | * One message arrives. A socket that has been closed delivers nothing, which | |
| 107 | * is what a browser's does — the message event only fires while it is open — | |
| 108 | * so this is also how `close()` is asked whether it really closed the socket. | |
| 109 | */ | |
| 110 | say(data: unknown): void; | |
| 111 | }; | |
| 112 | ||
| 113 | function wire(): Wire { | |
| 114 | let shut = false; | |
| 115 | const socket: Socket = { | |
| 116 | binaryType: "", | |
| 117 | send: (data) => void handle.sent.push(new Uint8Array(data)), | |
| 118 | close: () => { | |
| 119 | shut = true; | |
| 120 | handle.closes += 1; | |
| 121 | }, | |
| 122 | onopen: null, onmessage: null, onclose: null, onerror: null, | |
| 123 | }; | |
| 124 | const handle: Wire = { | |
| 125 | socket, | |
| 126 | sent: [], | |
| 127 | closes: 0, | |
| 128 | open: () => socket.onopen?.(), | |
| 129 | error: () => socket.onerror?.(), | |
| 130 | drop: () => { | |
| 131 | shut = true; | |
| 132 | socket.onclose?.(); | |
| 133 | }, | |
| 134 | say: (data) => { | |
| 135 | if (shut) return; | |
| 136 | socket.onmessage?.({ data }); | |
| 137 | }, | |
| 138 | }; | |
| 139 | return handle; | |
| 140 | } | |
| 141 | ||
| 142 | type Stage = Wire & { | |
| 143 | session: Session; | |
| 144 | /** The URLs `connect` was asked for, in order. */ | |
| 145 | urls: string[]; | |
| 146 | /** Every world `build` handed back, newest last. */ | |
| 147 | built: World[]; | |
| 148 | /** What this page's `performance.now()` says, which a test moves. */ | |
| 149 | clock: number; | |
| 150 | /** The room, opened and seated: the socket up and a `hello` delivered. */ | |
| 151 | join(over?: Partial<Hello>): void; | |
| 152 | }; | |
| 153 | ||
| 154 | /** | |
| 155 | * A session on that socket, with `build` handing back a world of ten bodies — | |
| 156 | * or whichever world the test prepared, which is how the effects ring is | |
| 157 | * proved forgotten. | |
| 158 | */ | |
| 159 | function stage(build?: (hello: Hello) => World): Stage { | |
| 160 | const w = wire(); | |
| 161 | const urls: string[] = []; | |
| 162 | const built: World[] = []; | |
| 163 | // `connect` is called inside `createSession`, so everything it touches is in | |
| 164 | // hand before the session is made. | |
| 165 | let now = START; | |
| 166 | const session = createSession({ | |
| 167 | connect: (url) => { | |
| 168 | urls.push(url); | |
| 169 | return w.socket; | |
| 170 | }, | |
| 171 | url: ROOM_URL, | |
| 172 | code: CODE, | |
| 173 | build: (hello) => { | |
| 174 | const world = build?.(hello) ?? tenBodies("you"); | |
| 175 | built.push(world); | |
| 176 | return world; | |
| 177 | }, | |
| 178 | now: () => now, | |
| 179 | }); | |
| 180 | const s: Stage = { | |
| 181 | ...w, | |
| 182 | urls, | |
| 183 | built, | |
| 184 | session, | |
| 185 | // A count and not a copy of one: the spread above took the number as it was. | |
| 186 | get closes() { return w.closes; }, | |
| 187 | get clock() { return now; }, | |
| 188 | set clock(ms) { now = ms; }, | |
| 189 | join(over) { | |
| 190 | s.open(); | |
| 191 | s.say(helloText(over)); | |
| 192 | }, | |
| 193 | }; | |
| 194 | return s; | |
| 195 | } | |
| 196 | ||
| 197 | /** The world a seated session is filling. Seated, so it is never null. */ | |
| 198 | const worldOf = (s: Stage): World => s.session.world() as World; | |
| 199 | ||
| 200 | // --- the socket ----------------------------------------------------------- | |
| 201 | ||
| 202 | test("the session connects to the url it was given and asks for bytes it can read", () => { | |
| 203 | const s = stage(); | |
| 204 | expect(s.urls).toEqual([ROOM_URL]); | |
| 205 | // Without this a browser hands `onmessage` a Blob and every snapshot is | |
| 206 | // dropped as "not bytes" — the ArrayBuffer branch below is the live one. | |
| 207 | expect(s.socket.binaryType).toBe("arraybuffer"); | |
| 208 | expect(s.session.world()).toBeNull(); | |
| 209 | expect(s.session.seat()).toBe(-1); | |
| 210 | expect(s.session.standing(START)).toEqual({ | |
| 211 | code: CODE, live: false, behind: -1, line: CONNECTING, people: 0, | |
| 212 | }); | |
| 213 | }); | |
| 214 | ||
| 215 | // --- hello ---------------------------------------------------------------- | |
| 216 | ||
| 217 | test("a hello seats you on the body the room says is yours", () => { | |
| 218 | const s = stage(); | |
| 219 | // A roster that does not mention our seat at all, so the body being a person | |
| 220 | // is the hello's own line and not something the roster did afterwards. | |
| 221 | s.join({ roster: [person(0, "ada", "t"), bot(5, "Rook", "t")] }); | |
| 222 | expect(s.session.seat()).toBe(SEAT); | |
| 223 | const world = worldOf(s); | |
| 224 | expect(world).toBe(s.built[0] as World); | |
| 225 | expect(world.me).toBe(body(world, SEAT)); | |
| 226 | // Every body but seat 0 is a bot to `createWorld`; ours is a person, and | |
| 227 | // that is what the camera and the HUD follow. | |
| 228 | expect(world.me.bot).toBe(false); | |
| 229 | expect(body(world, 1).bot).toBe(true); | |
| 230 | expect(s.session.standing(START).people).toBe(1); | |
| 231 | }); | |
| 232 | ||
| 233 | test("the world a hello is built on is handed the whole message", () => { | |
| 234 | const seen: Hello[] = []; | |
| 235 | const s = stage((hello) => { | |
| 236 | seen.push(hello); | |
| 237 | return tenBodies("you"); | |
| 238 | }); | |
| 239 | s.join({ seed: 4242, rules: "long", map: "de_forge" }); | |
| 240 | expect(seen).toHaveLength(1); | |
| 241 | expect(seen[0]?.seed).toBe(4242); | |
| 242 | expect(seen[0]?.rules).toBe("long"); | |
| 243 | expect(seen[0]?.seat).toBe(SEAT); | |
| 244 | expect(seen[0]?.roster).toEqual(ROSTER); | |
| 245 | }); | |
| 246 | ||
| 247 | test("the previous match's tracers are not on the new screen: a hello forgets", () => { | |
| 248 | const stale = tenBodies("you"); | |
| 249 | pushEffect(stale.effects, { kind: "tracer", at: 1, from: [0, 0, 0], to: [10, 0, 0] }, 1); | |
| 250 | pushEffect(stale.effects, { kind: "blood", at: 2, pos: [5, 5, 5] }, 2); | |
| 251 | stale.grenades.push({ | |
| 252 | pos: [0, 0, 0], vel: [0, 0, 0], onGround: false, ducking: false, duckAmount: 0, | |
| 253 | weapon: "he", owner: 1, team: "t", fuse: 1, | |
| 254 | }); | |
| 255 | stale.clouds.push({ pos: [0, 0, 0], born: 0, owner: 2 }); | |
| 256 | const s = stage(() => stale); | |
| 257 | expect(stale.effects.count).toBe(2); | |
| 258 | s.join(); | |
| 259 | expect(worldOf(s).effects.count).toBe(0); | |
| 260 | expect(worldOf(s).grenades).toEqual([]); | |
| 261 | expect(worldOf(s).clouds).toEqual([]); | |
| 262 | }); | |
| 263 | ||
| 264 | // --- the roster ----------------------------------------------------------- | |
| 265 | ||
| 266 | test("a roster names the bodies it names and leaves the rest alone", () => { | |
| 267 | const s = stage(); | |
| 268 | const before = tenBodies("you"); | |
| 269 | s.join(); | |
| 270 | const world = worldOf(s); | |
| 271 | expect(body(world, 0).name).toBe("ada"); | |
| 272 | expect(body(world, 0).bot).toBe(false); | |
| 273 | expect(body(world, 5).name).toBe("Rook"); | |
| 274 | expect(body(world, 5).bot).toBe(true); | |
| 275 | expect(body(world, SEAT).name).toBe("you"); | |
| 276 | // Seat 1 is in nobody's roster line: it keeps the name `createWorld` gave it. | |
| 277 | expect(body(world, 1).name).toBe(body(before, 1).name); | |
| 278 | expect(s.session.standing(START).people).toBe(2); | |
| 279 | }); | |
| 280 | ||
| 281 | test("a later roster is the one that counts, and our body stays ours", () => { | |
| 282 | const s = stage(); | |
| 283 | s.join(); | |
| 284 | s.say(rosterText([person(0, "ada", "t"), person(1, "bo", "t"), person(SEAT, "you"), bot(5, "Rook", "t")])); | |
| 285 | const world = worldOf(s); | |
| 286 | expect(body(world, 1).name).toBe("bo"); | |
| 287 | expect(body(world, 1).bot).toBe(false); | |
| 288 | expect(s.session.standing(START).people).toBe(3); | |
| 289 | expect(world.me).toBe(body(world, SEAT)); | |
| 290 | }); | |
| 291 | ||
| 292 | test("a roster line for a seat this room has no body for changes nothing", () => { | |
| 293 | const s = stage(); | |
| 294 | s.join(); | |
| 295 | const world = worldOf(s); | |
| 296 | const names = world.players.map((p) => p.name); | |
| 297 | s.say(rosterText([person(0, "ada", "t"), person(99, "nobody"), person(SEAT, "you")])); | |
| 298 | expect(world.players.map((p) => p.name)).toEqual(names); | |
| 299 | // And the seat nobody has is not a person in the count either. | |
| 300 | expect(s.session.standing(START).people).toBe(2); | |
| 301 | }); | |
| 302 | ||
| 303 | test("a roster before a hello is dropped rather than thrown", () => { | |
| 304 | const s = stage(); | |
| 305 | s.open(); | |
| 306 | expect(() => s.say(rosterText(ROSTER))).not.toThrow(); | |
| 307 | expect(s.session.world()).toBeNull(); | |
| 308 | expect(s.session.standing(START).people).toBe(0); | |
| 309 | }); | |
| 310 | ||
| 311 | // --- a snapshot ----------------------------------------------------------- | |
| 312 | ||
| 313 | test("a snapshot moves the bodies and the round with them", () => { | |
| 314 | const s = stage(); | |
| 315 | s.join(); | |
| 316 | const room = tenBodies("room"); | |
| 317 | move(room); | |
| 318 | s.clock = 5000; | |
| 319 | s.say(snapshotOf(room, 7)); | |
| 320 | const world = worldOf(s); | |
| 321 | expect(body(world, 1).pos).toEqual([164, 168, 64]); | |
| 322 | expect(body(world, 1).vel).toEqual([8, -4, 0]); | |
| 323 | expect(body(world, 1).yaw).toBe(90); | |
| 324 | expect(body(world, 1).health).toBe(79); | |
| 325 | expect(world.time).toBe(12.5); | |
| 326 | expect(world.match.phase).toBe("live"); | |
| 327 | expect(world.match.round).toBe(3); | |
| 328 | expect(s.session.standing(5000)).toEqual({ | |
| 329 | code: CODE, live: true, behind: 0, line: PLAYING, people: 2, | |
| 330 | }); | |
| 331 | // `behind` is the milliseconds since it landed, and nothing else. | |
| 332 | expect(s.session.standing(5120).behind).toBe(120); | |
| 333 | }); | |
| 334 | ||
| 335 | test("your own body's position is the server's, and your aim is your own", () => { | |
| 336 | const s = stage(); | |
| 337 | s.join(); | |
| 338 | const world = worldOf(s); | |
| 339 | // The mouse moved since the snapshot was written: the frame owns the angles | |
| 340 | // and sends them upstream, so nothing off the wire may put them back. | |
| 341 | world.me.yaw = 33; | |
| 342 | world.me.pitch = -12; | |
| 343 | const room = tenBodies("room"); | |
| 344 | move(room); | |
| 345 | s.say(snapshotOf(room, 2)); | |
| 346 | expect(world.me).toBe(body(world, SEAT)); | |
| 347 | expect(world.me.pos).toEqual([292, 104, 64]); | |
| 348 | expect(world.me.vel).toEqual([24, -12, 0]); | |
| 349 | expect(world.me.yaw).toBe(33); | |
| 350 | expect(world.me.pitch).toBe(-12); | |
| 351 | // Everything else about our body is the server's word, health included. | |
| 352 | expect(world.me.health).toBe(77); | |
| 353 | }); | |
| 354 | ||
| 355 | test("an ArrayBuffer is the same frame as the bytes, because a browser sends one", () => { | |
| 356 | const s = stage(); | |
| 357 | s.join(); | |
| 358 | const room = tenBodies("room"); | |
| 359 | move(room); | |
| 360 | s.say(snapshotOf(room, 3).slice().buffer); | |
| 361 | expect(body(worldOf(s), 1).pos).toEqual([164, 168, 64]); | |
| 362 | expect(s.session.standing(START).line).toBe(PLAYING); | |
| 363 | }); | |
| 364 | ||
| 365 | // --- everything a frame can be that is not one ---------------------------- | |
| 366 | ||
| 367 | /** What a world looks like from outside, for the refusals to compare against. */ | |
| 368 | const shape = (world: World): string => | |
| 369 | JSON.stringify({ | |
| 370 | time: world.time, | |
| 371 | phase: world.match.phase, | |
| 372 | round: world.match.round, | |
| 373 | players: world.players.map((p) => ({ pos: p.pos, vel: p.vel, health: p.health, name: p.name })), | |
| 374 | effects: world.effects.count, | |
| 375 | feed: world.feed.length, | |
| 376 | messages: world.messages.length, | |
| 377 | }); | |
| 378 | ||
| 379 | test("a frame whose first byte is not a snapshot changes nothing", () => { | |
| 380 | const s = stage(); | |
| 381 | s.join(); | |
| 382 | const world = worldOf(s); | |
| 383 | const was = shape(world); | |
| 384 | const room = tenBodies("room"); | |
| 385 | move(room); | |
| 386 | const bytes = snapshotOf(room, 4); | |
| 387 | bytes[0] = SNAPSHOT_FRAME + 1; | |
| 388 | expect(() => s.say(bytes)).not.toThrow(); | |
| 389 | expect(shape(world)).toBe(was); | |
| 390 | // It never landed, so the page is still joining rather than playing. | |
| 391 | expect(s.session.standing(START)).toMatchObject({ behind: -1, line: CONNECTING }); | |
| 392 | }); | |
| 393 | ||
| 394 | test("a truncated snapshot changes nothing and does not stamp the clock", () => { | |
| 395 | const s = stage(); | |
| 396 | s.join(); | |
| 397 | const room = tenBodies("room"); | |
| 398 | move(room); | |
| 399 | s.clock = 2000; | |
| 400 | s.say(snapshotOf(room, 5)); | |
| 401 | const world = worldOf(s); | |
| 402 | const was = shape(world); | |
| 403 | // Cut inside the header, which `applySnapshot` refuses before it writes: the | |
| 404 | // clock is stamped for a frame that was read, so `behind` keeps counting from | |
| 405 | // the last whole one. | |
| 406 | s.clock = 3000; | |
| 407 | expect(() => s.say(snapshotOf(room, 6).subarray(0, 20))).not.toThrow(); | |
| 408 | expect(shape(world)).toBe(was); | |
| 409 | expect(s.session.standing(3000).behind).toBe(1000); | |
| 410 | }); | |
| 411 | ||
| 412 | test("a snapshot that arrives before a hello is dropped, and the next one is not", () => { | |
| 413 | const s = stage(); | |
| 414 | s.open(); | |
| 415 | const room = tenBodies("room"); | |
| 416 | move(room); | |
| 417 | expect(() => s.say(snapshotOf(room, 1))).not.toThrow(); | |
| 418 | expect(s.session.world()).toBeNull(); | |
| 419 | expect(s.session.standing(START).line).toBe(CONNECTING); | |
| 420 | s.say(helloText()); | |
| 421 | s.say(snapshotOf(room, 2)); | |
| 422 | expect(body(worldOf(s), 1).pos).toEqual([164, 168, 64]); | |
| 423 | }); | |
| 424 | ||
| 425 | test("data that is neither a string nor bytes is dropped", () => { | |
| 426 | const s = stage(); | |
| 427 | s.join(); | |
| 428 | const world = worldOf(s); | |
| 429 | const was = shape(world); | |
| 430 | for (const data of [42, null, undefined, true, {}, [1, 2, 3], new Date(0)]) | |
| 431 | expect(() => s.say(data)).not.toThrow(); | |
| 432 | expect(shape(world)).toBe(was); | |
| 433 | expect(s.session.standing(START).behind).toBe(-1); | |
| 434 | }); | |
| 435 | ||
| 436 | test("a line that is not a message this page knows is dropped", () => { | |
| 437 | const s = stage(); | |
| 438 | s.join(); | |
| 439 | const world = worldOf(s); | |
| 440 | const was = shape(world); | |
| 441 | for (const text of [ | |
| 442 | "", | |
| 443 | "{not json", | |
| 444 | "[1,2,3]", | |
| 445 | "5", | |
| 446 | "null", | |
| 447 | `"hello"`, | |
| 448 | JSON.stringify({ k: "bye" }), | |
| 449 | JSON.stringify({ k: "hello" }), | |
| 450 | // A hello with the seed left off is not a hello, so nothing is rebuilt. | |
| 451 | helloText({ seed: undefined as unknown as number }), | |
| 452 | // A roster whose line has no team is not a roster. | |
| 453 | JSON.stringify({ k: "roster", roster: [{ seat: 0, name: "ada", bot: false }] }), | |
| 454 | // A log whose feed is not a list is not a log. | |
| 455 | JSON.stringify({ k: "log", feed: "k", say: [] }), | |
| 456 | ]) | |
| 457 | expect(() => s.say(text)).not.toThrow(); | |
| 458 | expect(shape(world)).toBe(was); | |
| 459 | // And the session is still the one the first hello seated. | |
| 460 | expect(s.session.seat()).toBe(SEAT); | |
| 461 | expect(s.session.world()).toBe(world); | |
| 462 | expect(s.built).toHaveLength(1); | |
| 463 | }); | |
| 464 | ||
| 465 | // --- the words of the round ----------------------------------------------- | |
| 466 | ||
| 467 | test("the feed and the messages are capped at eight however many arrive at once", () => { | |
| 468 | const s = stage(); | |
| 469 | s.join(); | |
| 470 | const world = worldOf(s); | |
| 471 | expect(world.feed).toEqual([]); | |
| 472 | s.say(JSON.stringify({ | |
| 473 | k: "log", | |
| 474 | feed: Array.from({ length: 12 }, (_, i) => ({ | |
| 475 | at: i, killer: `k${i}`, victim: `v${i}`, weapon: "ak47", headshot: false, killerTeam: "t", | |
| 476 | })), | |
| 477 | say: Array.from({ length: 12 }, (_, i) => ({ at: i, text: `m${i}` })), | |
| 478 | })); | |
| 479 | expect(world.feed).toHaveLength(8); | |
| 480 | expect(world.messages).toHaveLength(8); | |
| 481 | // The oldest go: what is left is the newest eight, in the order they came. | |
| 482 | expect(world.feed.map((e) => e.killer)).toEqual(["k4", "k5", "k6", "k7", "k8", "k9", "k10", "k11"]); | |
| 483 | expect(world.messages.map((m) => m.text)).toEqual(["m4", "m5", "m6", "m7", "m8", "m9", "m10", "m11"]); | |
| 484 | expect(world.feed[7]).toEqual({ | |
| 485 | at: 11, killer: "k11", victim: "v11", weapon: "ak47", headshot: false, killerTeam: "t", | |
| 486 | }); | |
| 487 | }); | |
| 488 | ||
| 489 | test("a log before a hello is dropped rather than thrown", () => { | |
| 490 | const s = stage(); | |
| 491 | s.open(); | |
| 492 | expect(() => s.say(JSON.stringify({ k: "log", feed: [], say: [{ at: 0, text: "hi" }] }))).not.toThrow(); | |
| 493 | expect(s.session.world()).toBeNull(); | |
| 494 | }); | |
| 495 | ||
| 496 | // --- the four sentences --------------------------------------------------- | |
| 497 | ||
| 498 | test("joining, playing, behind and dropped, on a clock the test moves", () => { | |
| 499 | const s = stage(); | |
| 500 | // Before the socket is open there is nothing to be behind. | |
| 501 | expect(s.session.standing(START)).toMatchObject({ live: false, behind: -1, line: CONNECTING }); | |
| 502 | s.open(); | |
| 503 | expect(s.session.standing(START)).toMatchObject({ live: true, behind: -1, line: CONNECTING }); | |
| 504 | s.say(helloText()); | |
| 505 | // A hello is not a snapshot: the world exists and the round has not arrived. | |
| 506 | expect(s.session.standing(START).line).toBe(CONNECTING); | |
| 507 | const room = tenBodies("room"); | |
| 508 | move(room); | |
| 509 | s.clock = 10_000; | |
| 510 | s.say(snapshotOf(room, 8)); | |
| 511 | expect(s.session.standing(10_000).line).toBe(PLAYING); | |
| 512 | // 500 ms is the edge and is still playing; 501 is the sentence. | |
| 513 | expect(s.session.standing(10_000 + BEHIND_MS)).toMatchObject({ behind: 500, line: PLAYING }); | |
| 514 | expect(s.session.standing(10_000 + BEHIND_MS + 1)).toMatchObject({ behind: 501, line: BEHIND }); | |
| 515 | // A snapshot that lands puts the page back in the room. | |
| 516 | s.clock = 12_000; | |
| 517 | s.say(snapshotOf(room, 9)); | |
| 518 | expect(s.session.standing(12_000).line).toBe(PLAYING); | |
| 519 | // The room hung up: the last sentence wins over every other. | |
| 520 | s.drop(); | |
| 521 | expect(s.session.standing(12_000)).toMatchObject({ live: false, line: DROPPED }); | |
| 522 | expect(s.session.standing(99_999).line).toBe(DROPPED); | |
| 523 | }); | |
| 524 | ||
| 525 | test("an error is a connection that is no longer live", () => { | |
| 526 | const s = stage(); | |
| 527 | s.join(); | |
| 528 | const room = tenBodies("room"); | |
| 529 | move(room); | |
| 530 | s.say(snapshotOf(room, 1)); | |
| 531 | expect(s.session.standing(START).line).toBe(PLAYING); | |
| 532 | s.error(); | |
| 533 | expect(s.session.standing(START)).toMatchObject({ live: false, line: CONNECTING }); | |
| 534 | // And nothing goes upstream on a socket that is not live. | |
| 535 | s.session.press({ input: NO_INPUT, yaw: 0, pitch: 0, switchCode: 0, buy: null }); | |
| 536 | expect(s.sent).toEqual([]); | |
| 537 | }); | |
| 538 | ||
| 539 | test("after close nothing the socket says can change the world again", () => { | |
| 540 | const s = stage(); | |
| 541 | s.join(); | |
| 542 | const room = tenBodies("room"); | |
| 543 | move(room); | |
| 544 | s.say(snapshotOf(room, 1)); | |
| 545 | const world = worldOf(s); | |
| 546 | const was = shape(world); | |
| 547 | s.session.close(); | |
| 548 | expect(s.closes).toBe(1); | |
| 549 | expect(s.session.standing(START)).toMatchObject({ live: false, line: DROPPED }); | |
| 550 | // The same frames that moved the world a moment ago: a closed socket | |
| 551 | // delivers none of them, which is what `close()` closing it buys. | |
| 552 | for (const p of room.players) p.pos = [7, 7, 7]; | |
| 553 | s.say(snapshotOf(room, 2)); | |
| 554 | s.say(rosterText([person(1, "late", "t")])); | |
| 555 | s.say(JSON.stringify({ k: "log", feed: [], say: [{ at: 1, text: "late" }] })); | |
| 556 | s.say(helloText({ seat: 1 })); | |
| 557 | expect(shape(world)).toBe(was); | |
| 558 | expect(s.session.seat()).toBe(SEAT); | |
| 559 | expect(s.session.world()).toBe(world); | |
| 560 | expect(s.built).toHaveLength(1); | |
| 561 | }); | |
| 562 | ||
| 563 | // --- what goes up --------------------------------------------------------- | |
| 564 | ||
| 565 | const pressed = (over: Partial<Parameters<Session["press"]>[0]> = {}): Parameters<Session["press"]>[0] => | |
| 566 | ({ input: NO_INPUT, yaw: 0, pitch: 0, switchCode: 0, buy: null, ...over }); | |
| 567 | ||
| 568 | test("keys pressed before the socket is open go nowhere", () => { | |
| 569 | const s = stage(); | |
| 570 | s.session.press(pressed({ input: { ...NO_INPUT, fire: true } })); | |
| 571 | expect(s.sent).toEqual([]); | |
| 572 | s.open(); | |
| 573 | s.session.press(pressed({ input: { ...NO_INPUT, fire: true } })); | |
| 574 | expect(s.sent).toHaveLength(1); | |
| 575 | // The frame that was dropped was not counted either: this is the first tick. | |
| 576 | expect(readInput(s.sent[0] as Uint8Array)?.tick).toBe(1); | |
| 577 | }); | |
| 578 | ||
| 579 | test("one tick of keys is fourteen bytes, and the tick counts up once a call", () => { | |
| 580 | const s = stage(); | |
| 581 | s.join(); | |
| 582 | s.session.press(pressed({ input: { ...NO_INPUT, forward: 1 }, yaw: 90, switchCode: SWITCH_PRIMARY })); | |
| 583 | s.session.press(pressed({ buy: "ak47" })); | |
| 584 | s.session.press(pressed({ input: { ...NO_INPUT, duck: true }, pitch: 45 })); | |
| 585 | expect(s.sent.map((frame) => frame.byteLength)).toEqual([INPUT_BYTES, INPUT_BYTES, INPUT_BYTES]); | |
| 586 | const read = s.sent.map((frame) => readInput(frame)); | |
| 587 | expect(read.map((r) => r?.tick)).toEqual([1, 2, 3]); | |
| 588 | // The buy rides on the frame it was asked on and on no other. | |
| 589 | expect(read.map((r) => r?.buy)).toEqual([null, "ak47", null]); | |
| 590 | expect(read[0]?.input.forward).toBe(1); | |
| 591 | expect(read[0]?.yaw).toBe(90); | |
| 592 | expect(read[0]?.switchCode).toBe(SWITCH_PRIMARY); | |
| 593 | expect(read[2]?.input.duck).toBe(true); | |
| 594 | expect(read[2]?.input.forward).toBe(0); | |
| 595 | expect(read[2]?.pitch).toBe(45); | |
| 596 | }); | |
| 597 | ||
| 598 | test("keys pressed after close go nowhere", () => { | |
| 599 | const s = stage(); | |
| 600 | s.join(); | |
| 601 | s.session.press(pressed()); | |
| 602 | s.session.close(); | |
| 603 | s.session.press(pressed({ input: { ...NO_INPUT, fire: true } })); | |
| 604 | expect(s.sent).toHaveLength(1); | |
| 605 | }); |
src/client/session.ts +15 −9
| @@ -1,20 +1,26 @@ | ||
| 1 | 1 | /** |
| 2 | * The page's end of a room: one socket, one world it writes into, and the two | |
| 3 | * sentences it can say about the connection. | |
| 2 | * The page's end of a room: one socket, one world it writes into, and the four | |
| 3 | * sentences it can say about the connection — joining, in the room, behind, and | |
| 4 | * dropped. | |
| 4 | 5 | * |
| 5 | 6 | * A member of a room does not simulate. The round is stepped on the server |
| 6 | 7 | * (`src/rooms/room.ts`) and this fills a local world from the snapshots that |
| 7 | 8 | * arrive, so everything already built on a `World` — the scene, the HUD, the |
| 8 | 9 | * minimap, the sounds, the spectator camera — draws a room without knowing it |
| 9 | * is one. Two things are the browser's own even so: the view angles, which the | |
| 10 | * is one. One thing is the browser's own even so: the view angles, which the | |
| 10 | 11 | * frame writes on `world.me` and sends upstream, so aim answers the hand rather |
| 11 | * than the network; and nothing else. Your body's position is the server's | |
| 12 | * word, arriving 16 times a second — which is honest and is also the lag a | |
| 13 | * player feels in a room today. Predicting your own movement is #305's | |
| 14 | * remaining half and is deliberately not faked here. | |
| 12 | * than the network. Your body's position is the server's word, arriving 16 | |
| 13 | * times a second — which is honest and is also the lag a player feels in a room | |
| 14 | * today. Predicting your own movement is #305's remaining half and is | |
| 15 | * deliberately not faked here. | |
| 15 | 16 | * |
| 16 | * Nothing in here is a browser either: `connect` is handed in, so `session.test.ts` | |
| 17 | * plays a whole room through a socket of plain objects. | |
| 17 | * Nothing between two snapshots is smoothed either: a snapshot is written | |
| 18 | * straight onto the bodies, so every one of them moves in 62 ms steps and stands | |
| 19 | * still in between. Interpolating them is its own issue and is not pretended to | |
| 20 | * here or anywhere else. | |
| 21 | * | |
| 22 | * Nothing in here is a browser: `connect` is handed in, so `session.test.ts` | |
| 23 | * plays a whole room through a socket of plain objects, on a clock it moves. | |
| 18 | 24 | */ |
| 19 | 25 | |
| 20 | 26 | import type { Hello, Pressed } from "../rooms/wire.ts"; |
src/rooms/room.ts +6 −4
| @@ -43,10 +43,12 @@ export const TICK = 1 / 64; | ||
| 43 | 43 | * How many snapshots a member is sent a second. Sixteen is the number #302's |
| 44 | 44 | * arithmetic lands on and the number this ships at: a snapshot of a ten-body |
| 45 | 45 | * round is 544 bytes, so sixteen of them is 8.7 kB a second down to each member |
| 46 | * — half a megabyte a minute, which a phone on a mobile connection can hold — | |
| 47 | * and the frame between two of them is 62 ms of interpolation, which is what | |
| 48 | * `src/client/session.ts` hides. Thirty-two would double the bytes to buy a | |
| 49 | * smoothness the interpolation already has. | |
| 46 | * — half a megabyte a minute, which a phone on a mobile connection can hold. | |
| 47 | * The 62 ms between two of them is not interpolated by anything today: | |
| 48 | * `src/client/session.ts` writes a snapshot straight onto the bodies, so what a | |
| 49 | * member sees is 16 steps a second. Thirty-two would double the bytes to buy | |
| 50 | * half of a smoothness that smoothing those steps buys whole, which is why the | |
| 51 | * answer is interpolation and not a faster wire. | |
| 50 | 52 | */ |
| 51 | 53 | export const SNAPSHOT_HZ = 16; |
| 52 | 54 |
new above
offline