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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/cs-engine:torque/319-the-page-s-end-of-a-room-is-pinned-befor → main 1 commit fork

  • Merged. torque/319-the-page-s-end-of-a-room-is-pinned-befor was merged into main as 9940180.

Review

Sign in to review this change.
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 @@
11 /**
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.
45 *
56 * A member of a room does not simulate. The round is stepped on the server
67 * (`src/rooms/room.ts`) and this fills a local world from the snapshots that
78 * arrive, so everything already built on a `World` — the scene, the HUD, the
89 * 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
1011 * 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.
1516 *
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.
1824 */
1925
2026 import type { Hello, Pressed } from "../rooms/wire.ts";
src/rooms/room.ts +6 −4
@@ -43,10 +43,12 @@ export const TICK = 1 / 64;
4343 * How many snapshots a member is sent a second. Sixteen is the number #302's
4444 * arithmetic lands on and the number this ships at: a snapshot of a ten-body
4545 * 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.
5052 */
5153 export const SNAPSHOT_HZ = 16;
5254

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs