quill/val Public Public: anyone can read this; nobody can change it.

forked from benjivers/val

workbenchthis machine
Files a9ddab6ef893cac593648df9ff6b4d2d3d14b7bc
520 lines · 23.3 KB · ~6.8k tok Raw History
1
/**
2
 * The client, played.
3
 *
4
 * The bundle the browser is served is loaded here against a stub document
5
 * and a canvas that only writes down what it was told to draw, and pointed
6
 * at the running server over a real socket. It is not a browser, but it is
7
 * the whole path a person takes: connect, take a snapshot, paint the HUD,
8
 * draw a frame, press a key, send an input back.
9
 */
10
11
import { expect, test } from "bun:test";
12
import { mkdtempSync } from "node:fs";
13
import { tmpdir } from "node:os";
14
import { join } from "node:path";
15
16
process.env.FORGE_APP_DATA ??= mkdtempSync(join(tmpdir(), "app-"));
17
process.env.PORT ??= "0";
18
const { server } = await import("./server.ts");
19
const base = `http://${server.hostname}:${server.port}`;
20
21
// ------------------------------------------------------------ the stub DOM
22
23
const drawn: string[] = [];
24
// Where the canvas was moved to: on the top-down view your own body is an
25
// arrow drawn about the origin, so the last translate of a frame is where
26
// this client thinks you are.
27
const moves: { x: number; y: number }[] = [];
28
const listeners = new Map<string, ((event: unknown) => void)[]>();
29
const frames: ((now: number) => void)[] = [];
30
31
const noop = () => {};
32
const context = () =>
33
  new Proxy(
34
    {
35
      measureText: (text: string) => ({ width: text.length * 6 }),
36
      createLinearGradient: () => ({ addColorStop: noop }),
37
    } as Record<string, unknown>,
38
    {
39
      get(target, prop: string) {
40
        if (prop in target) return target[prop];
41
        if (prop === "fillStyle" || prop === "strokeStyle" || prop === "font" || prop === "textAlign") return "";
42
        if (prop === "globalAlpha" || prop === "lineWidth") return 1;
43
        return (...args: unknown[]) => {
44
          if (prop === "translate") moves.push({ x: Number(args[0]), y: Number(args[1]) });
45
          return drawn.push(`${prop}(${args.length})`);
46
        };
47
      },
48
      set: () => true,
49
    },
50
  );
51
52
function element(id: string, attrs: Record<string, string> = {}) {
53
  const node: Record<string, unknown> = {
54
    id,
55
    dataset: { ...attrs },
56
    style: {},
57
    textContent: "",
58
    title: "",
59
    disabled: false,
60
    type: "",
61
    children: [] as unknown[],
62
    classList: {
63
      on: new Set<string>(),
64
      add(name: string) {
65
        (this.on as Set<string>).add(name);
66
      },
67
      remove(name: string) {
68
        (this.on as Set<string>).delete(name);
69
      },
70
      toggle(name: string, force?: boolean) {
71
        const on = force ?? !(this.on as Set<string>).has(name);
72
        if (on) (this.on as Set<string>).add(name);
73
        else (this.on as Set<string>).delete(name);
74
        return on;
75
      },
76
      contains(name: string) {
77
        return (this.on as Set<string>).has(name);
78
      },
79
    },
80
    events: new Map<string, ((event: unknown) => void)[]>(),
81
    addEventListener(kind: string, fn: (event: unknown) => void) {
82
      const map = node.events as Map<string, ((event: unknown) => void)[]>;
83
      map.set(kind, [...(map.get(kind) ?? []), fn]);
84
    },
85
    append(...kids: unknown[]) {
86
      (node.children as unknown[]).push(...kids);
87
    },
88
    replaceChildren(...kids: unknown[]) {
89
      node.children = kids;
90
    },
91
    getBoundingClientRect: () => ({ left: 0, top: 0, width: 900, height: 570 }),
92
    getContext: () => context(),
93
    requestPointerLock: noop,
94
    setPointerCapture: noop,
95
    width: 900,
96
    height: 570,
97
  };
98
  return node;
99
}
100
101
const click = (node: Record<string, unknown>) => {
102
  for (const fn of (node.events as Map<string, ((e: unknown) => void)[]>).get("click") ?? []) fn({});
103
};
104
const press = (code: string, key = "") => {
105
  for (const fn of listeners.get("keydown") ?? []) fn({ code, key, target: {}, preventDefault: noop });
106
};
107
const release = (code: string) => {
108
  for (const fn of listeners.get("keyup") ?? []) fn({ code });
109
};
110
111
const nodes = new Map<string, Record<string, unknown>>();
112
for (const id of [
113
  "view", "conn", "clock", "phase", "hp", "gun", "creds", "ultmeter", "kit", "buy", "buytoggle",
114
  "viewtoggle", "helptoggle", "helpclose", "help", "prompt", "flash", "dead", "aimhint", "stage",
115
  "log", "atkboard", "defboard", "helpagents", "roster", "feed", "perf",
116
])
117
  nodes.set(id, element(id));
118
119
const buys = [
120
  element("b1", { buy: "vandal", cost: "2900" }),
121
  element("b2", { buy: "c", cost: "0" }),
122
  element("b3", { buy: "q", cost: "0" }),
123
];
124
const agentButtons = [element("a1", { agent: "veil" })];
125
const scoreRed = element("red");
126
const scoreBlue = element("blue");
127
128
Object.assign(globalThis, {
129
  document: {
130
    getElementById: (id: string) => nodes.get(id) ?? null,
131
    querySelector: (sel: string) => (sel.includes("red") ? scoreRed : scoreBlue),
132
    querySelectorAll: (sel: string) => (sel.includes("data-buy") ? buys : agentButtons),
133
    createElement: (tag: string) => element(tag),
134
    addEventListener: noop,
135
    pointerLockElement: null,
136
    exitPointerLock: noop,
137
  },
138
  HTMLInputElement: class {},
139
  matchMedia: () => ({ matches: false }),
140
  devicePixelRatio: 1,
141
  localStorage: {
142
    store: new Map<string, string>(),
143
    getItem(key: string) {
144
      return this.store.get(key) ?? null;
145
    },
146
    setItem(key: string, value: string) {
147
      this.store.set(key, value);
148
    },
149
  },
150
  requestAnimationFrame: (fn: (now: number) => void) => frames.push(fn),
151
  addEventListener: (kind: string, fn: (event: unknown) => void) => {
152
    listeners.set(kind, [...(listeners.get(kind) ?? []), fn]);
153
  },
154
  location: new URL(base),
155
});
156
157
// Relative fetches, the way a page makes them.
158
const realFetch = globalThis.fetch;
159
globalThis.fetch = ((input: string, init?: RequestInit) =>
160
  realFetch(typeof input === "string" && input.startsWith("/") ? base + input : input, init)) as typeof fetch;
161
162
// A real browser attaches the session cookie to the websocket handshake on
163
// its own; this stub is not a browser, so it carries the cookie the page's
164
// own fetch was handed, the way one actually would.
165
let sessionCookie = "";
166
let openedSocket: WebSocket | null = null;
167
const RealWebSocket = globalThis.WebSocket;
168
globalThis.WebSocket = function (url: string) {
169
  openedSocket = new RealWebSocket(url, { headers: { cookie: sessionCookie } } as unknown as string[]);
170
  return openedSocket;
171
} as unknown as typeof WebSocket;
172
173
const runFrame = (at = performance.now()) => {
174
  const pending = frames.splice(0, frames.length);
175
  for (const fn of pending) fn(at);
176
};
177
const until = async (what: () => boolean, ms = 4000) => {
178
  const stop = Date.now() + ms;
179
  while (Date.now() < stop) {
180
    runFrame();
181
    if (what()) return true;
182
    await Bun.sleep(20);
183
  }
184
  return false;
185
};
186
187
// ------------------------------------------------------------------ the run
188
189
test("the bundled client connects, paints the HUD and draws a frame", async () => {
190
  // Every test in this run reaches the server from the one loopback address,
191
  // and a playing seat is rationed per address on purpose. A socket another
192
  // file closed a moment ago may not have given its seat back yet, and this
193
  // client would then be handed a spectator view and wait four seconds for a
194
  // health readout that never comes — the coin-flip failure the suite had.
195
  // Start from a clean slate, the way the server's own seat tests do.
196
  const { releaseAddress } = await import("./arena.ts");
197
  for (const address of ["127.0.0.1", "::1", "::ffff:127.0.0.1"]) releaseAddress(address);
198
  const first = await fetch(base + "/");
199
  sessionCookie = (first.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
200
  const page = await first.text();
201
  const src = /src="(\/static\/val\.[0-9a-f]+\.js)"/.exec(page)![1]!;
202
  const script = await (await fetch(base + src)).text();
203
  const path = join(process.env.FORGE_APP_DATA!, "client.mjs");
204
  await Bun.write(path, script);
205
206
  await import(path);
207
  expect(await until(() => nodes.get("conn")!.textContent === "live")).toBe(true);
208
  // The one socket this module opens is already under way; put the global
209
  // back so another test file's raw `new WebSocket(...)` is not silently
210
  // handed a cookie this test happened to pick up.
211
  globalThis.WebSocket = RealWebSocket;
212
  expect(await until(() => String(nodes.get("hp")!.textContent).includes("hp"))).toBe(true);
213
214
  // The HUD says what the match says.
215
  expect(String(nodes.get("phase")!.textContent)).toContain("round");
216
  expect(String(nodes.get("creds")!.textContent)).toContain("cr");
217
  // The kit is four buttons, each named after the ability it casts.
218
  expect((nodes.get("kit")!.children as unknown[]).length).toBe(4);
219
  // One prompt, in words, about the round that is running.
220
  expect(String(nodes.get("prompt")!.textContent).length).toBeGreaterThan(8);
221
  // And the buy panel prices itself off the agent's real kit.
222
  expect(String(buys[1]!.title)).toContain("charge");
223
224
  // The team is on screen whether anybody is in sight or not: five slots.
225
  const slots = nodes.get("roster")!.children as { className: string }[];
226
  expect(slots.length).toBe(5);
227
  expect(slots.filter((slot) => slot.className.includes("mine")).length).toBe(1);
228
229
  // Run the match on a few seconds: bots trade, and the feed fills with
230
  // names — and with nothing that says where anybody is.
231
  const arena = await import("./arena.ts");
232
  for (let i = 0; i < 40 && (nodes.get("feed")!.children as unknown[]).length === 0; i++) {
233
    arena.advance(5);
234
    await Bun.sleep(5);
235
  }
236
  const feedRows = nodes.get("feed")!.children as { children: { textContent: string }[] }[];
237
  expect(feedRows.length).toBeGreaterThan(0);
238
  // killer ▸ victim, and the gun or the ability it was: four spans, no numbers.
239
  const row = feedRows[0]!.children.map((span) => span.textContent).join("");
240
  expect(row).toContain(" ▸ ");
241
  expect(row).not.toMatch(/\d/);
242
243
  // A frame of the first-person view draws the world into its own buffer and
244
  // blits it, with the minimap's tiles blitted too. It is a picture, but it
245
  // is not one fill a screen column any more, nor one a map tile: the map has
246
  // 570 tiles and the canvas 900 columns, and the frame used to pay for both.
247
  // The minimap's tiles are the blit; the walls are a run a fill, and how
248
  // many runs a view has depends on where the seat happens to be standing —
249
  // a long corridor is many depths — so the bound is the columns, which no
250
  // view can reach, rather than the tile count, which a corridor can.
251
  const tally = () => ({
252
    fills: drawn.filter((op) => op.startsWith("fillRect")).length,
253
    blits: drawn.filter((op) => op.startsWith("drawImage")).length,
254
  });
255
  runFrame(); // whatever was cached at this size is cached before we count
256
  drawn.length = 0;
257
  runFrame();
258
  const fp = tally();
259
  expect(fp.blits).toBeGreaterThanOrEqual(2); // the world, and the map's tiles
260
  expect(fp.fills).toBeGreaterThan(40);
261
  expect(fp.fills).toBeLessThan(900);
262
263
  // Tab swaps that frame for the whole map. Its tiles are drawn once into a
264
  // canvas of their own and blitted from then on: the first such frame pays
265
  // for all 570 of them, and no frame after it pays for any.
266
  for (const fn of listeners.get("keydown") ?? []) fn({ code: "Tab", key: "", target: {}, preventDefault: noop });
267
  drawn.length = 0;
268
  runFrame();
269
  const built = tally();
270
  drawn.length = 0;
271
  runFrame();
272
  const top = tally();
273
  expect(built.fills).toBeGreaterThan(400);
274
  expect(top.blits).toBe(1);
275
  expect(top.fills).toBeLessThan(100);
276
  for (const fn of listeners.get("keyup") ?? []) fn({ code: "Tab" });
277
  drawn.length = 0;
278
  runFrame();
279
  expect(tally().blits).toBeGreaterThanOrEqual(2);
280
  // Holding W reaches the server as a direction, and it is the way we face.
281
  const seat = (await import("./arena.ts")).match().players.find((p) => !p.bot)!;
282
  press("KeyW");
283
  await Bun.sleep(160);
284
  expect(Math.hypot(seat.input.mx, seat.input.my)).toBeGreaterThan(0.9);
285
  expect(Math.atan2(seat.input.my, seat.input.mx)).toBeCloseTo(seat.input.angle, 4);
286
287
  // V falls back to the top-down view, and it draws too.
288
  press("KeyV");
289
  drawn.length = 0;
290
  runFrame();
291
  expect(drawn.some((op) => op.startsWith("fillRect"))).toBe(true);
292
  expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "top" });
293
  press("KeyV");
294
  expect(nodes.get("stage")!.dataset).toMatchObject({ mode: "fp" });
295
296
  // Where you are looking is this browser's between snapshots. Turning with
297
  // the arrow key, the angle the server is told only ever climbs: the client
298
  // used to take `snap.me.angle` back on every snapshot, so the view was
299
  // dragged to a stale angle thirty times a second and what it sent stepped
300
  // backwards in between.
301
  const shortWay = (from: number, to: number) => ((to - from + Math.PI * 3) % (Math.PI * 2)) - Math.PI;
302
  press("ArrowRight");
303
  const angles: number[] = [];
304
  for (let i = 0; i < 60; i++) {
305
    runFrame(performance.now());
306
    await Bun.sleep(10);
307
    angles.push(seat.input.angle);
308
  }
309
  release("ArrowRight");
310
  const steps = angles.slice(1).map((angle, i) => shortWay(angles[i]!, angle));
311
  expect(Math.min(...steps)).toBeGreaterThan(-1e-9);
312
  expect(steps.reduce((sum, step) => sum + step, 0)).toBeGreaterThan(0.2);
313
314
  // H toggles the card either way, and the button closes it for good.
315
  const help = nodes.get("help")!.style as Record<string, string>;
316
  const before = help.display;
317
  press("KeyH");
318
  expect(help.display).not.toBe(before);
319
  if (help.display !== "grid") press("KeyH");
320
  expect(help.display).toBe("grid");
321
  click(nodes.get("helpclose")!);
322
  expect((nodes.get("help")!.style as Record<string, string>).display).toBe("none");
323
  expect((globalThis as unknown as { localStorage: Storage }).localStorage.getItem("val.help.seen")).toBe("1");
324
325
  // B works the buy panel either way — it opens itself in the buy phase.
326
  const panel = nodes.get("buy")!.classList as { contains: (n: string) => boolean };
327
  const wasOpen = panel.contains("on");
328
  press("KeyB");
329
  expect(panel.contains("on")).toBe(!wasOpen);
330
331
  // And the socket really took a seat in the running match.
332
  expect(seat.bot).toBe(false);
333
334
  // Your own body is drawn from a guess about your own movement, not from
335
  // the last thing the server said. With the arena stopped — no snapshot can
336
  // arrive at all — the drawn body still carries the keys you are holding
337
  // forward, as far as the cap and no further, and stops there.
338
  press("KeyV"); // the top-down view, where your own body is an arrow
339
  // The measurement needs a live round and a seat that can walk. The arena
340
  // has been run through whole rounds above with this seat standing still,
341
  // so by now it may be dead, mid-plant, slowed, or stood inside a barrier a
342
  // bot put up — and a guess about a body that cannot move is rightly nothing.
343
  // Put it on its feet, clear what abilities left, and let a snapshot carry
344
  // that to the client before the clock is stopped; try again if a round
345
  // ends or a bot lands a kill in that moment.
346
  for (let tries = 0; tries < 10; tries++) {
347
    for (let i = 0; i < 60 && arena.match().phase !== "live"; i++) arena.advance(1);
348
    Object.assign(seat, { alive: true, hp: 100, planting: 0, defusing: 0, haste: 0, slow: 0 });
349
    arena.match().zones.length = 0;
350
    arena.advance(1 / 30);
351
    await Bun.sleep(80);
352
    runFrame();
353
    arena.stopArena();
354
    if (arena.match().phase === "live" && seat.alive && arena.match().zones.length === 0) break;
355
    arena.startArena();
356
  }
357
  expect(arena.match().phase).toBe("live");
358
  expect(seat.alive).toBe(true);
359
  // Walk whichever way is open from where the server last put us.
360
  for (const code of ["KeyW", "KeyA", "KeyS", "KeyD"]) release(code);
361
  const { blockedCircle } = await import("./map.ts");
362
  const clear = ([
363
    ["KeyW", 0, -1],
364
    ["KeyS", 0, 1],
365
    ["KeyA", -1, 0],
366
    ["KeyD", 1, 0],
367
  ] as const).find(([, dx, dy]) =>
368
    [20, 40, 60, 80].every((step) => !blockedCircle(seat.x + dx * step, seat.y + dy * step, 16)),
369
  )!;
370
  expect(clear).toBeDefined();
371
  press(clear[0]);
372
  const arrow = () => moves[moves.length - 1]!;
373
  const startedAt = { x: seat.x, y: seat.y };
374
  const scale = 900 / (30 * 80); // canvas pixels to a world unit
375
  const t0 = performance.now();
376
  for (let i = 1; i <= 10; i++) {
377
    moves.length = 0;
378
    runFrame(t0 + i * 40);
379
  }
380
  const drawnAt = arrow();
381
  // The drawn body is ahead of the server's last word by the input held
382
  // since it was sent — a quarter of a second of running at most, and then
383
  // it waits, because past that a guess would be a claim. Smoothing toward
384
  // a snapshot that cannot arrive would have gone nowhere at all.
385
  const lead = (drawnAt.x / scale - seat.x) * clear[1] + (drawnAt.y / scale - seat.y) * clear[2];
386
  const sideways = (drawnAt.x / scale - seat.x) * clear[2] - (drawnAt.y / scale - seat.y) * clear[1];
387
  expect(lead).toBeGreaterThan(30);
388
  // A quarter second of THIS seat's movement, not of a walking one: haste
389
  // multiplies the speed by 1.35, and a seat that happened to be hasted led
390
  // by 84 where a bound written around the base 250 expected 72. The cap is
391
  // in time (predict.js LEAD_MAX), so the bound has to be read off the same
392
  // speed table the client used, or it measures the match's mood instead of
393
  // the rule.
394
  // The lead is the cap plus the slack the guess is allowed over the answer
395
  // (predict.js LEAD_SLACK: the lag your own keys take to reach the server).
396
  const { moveSpeed } = await import("./glide.ts");
397
  const { LEAD_MAX, LEAD_SLACK } = await import("../static/predict.js");
398
  expect(lead).toBeLessThan((LEAD_MAX + LEAD_SLACK) * moveSpeed(seat) + 10);
399
  expect(Math.abs(sideways)).toBeLessThan(6);
400
  // Nothing on the server moved: this is a drawing, and the match is still
401
  // the only thing that says where anybody is.
402
  expect(seat.x).toBe(startedAt.x);
403
  expect(seat.y).toBe(startedAt.y);
404
  arena.startArena();
405
406
  // Back to the first-person view for the last of it, and a frame of it to
407
  // measure: the one before is the one that rebuilds anything cached.
408
  press("KeyV");
409
  runFrame();
410
  drawn.length = 0;
411
  runFrame();
412
  const onePixel = tally().fills;
413
  expect(onePixel).toBeGreaterThan(40);
414
415
  // The frame is not the screen's pixel count. On a retina display the same
416
  // window is twice the canvas, and the world is still drawn at a capped
417
  // internal width and blitted up: the work does not double with the device
418
  // pixel ratio, which is what made this choppy on a retina Mac.
419
  (globalThis as { devicePixelRatio: number }).devicePixelRatio = 2;
420
  for (const fn of listeners.get("resize") ?? []) fn({});
421
  expect(nodes.get("view")!.width).toBe(1800);
422
  runFrame(); // the first frame at a new size redraws the minimap's tiles
423
  drawn.length = 0;
424
  runFrame();
425
  expect(drawn.some((op) => op.startsWith("drawImage"))).toBe(true);
426
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBeLessThan(onePixel * 1.3);
427
  (globalThis as { devicePixelRatio: number }).devicePixelRatio = 1;
428
  for (const fn of listeners.get("resize") ?? []) fn({});
429
430
  // ` prints the numbers, so "it feels choppy" and "it delivers 30Hz" can be
431
  // told apart: the frame rate, the mean frame, the snapshot rate, the
432
  // interpolation delay, and the width the world is really drawn at.
433
  const perf = nodes.get("perf")!;
434
  expect((perf.style as Record<string, string>).display).not.toBe("block");
435
  press("Backquote");
436
  expect((perf.style as Record<string, string>).display).toBe("block");
437
  const readout = String(perf.textContent);
438
  expect(readout).toContain("fps");
439
  expect(readout).toContain("ms/frame");
440
  expect(readout).toContain("Hz in");
441
  expect(readout).toContain("ms behind");
442
  expect(readout).toContain("900×570"); // the internal size, of a 900×570 canvas
443
  // And the count of corrections taken outright, per minute: a stutter is a
444
  // jump, and this is how many there have been.
445
  expect(readout).toMatch(/\d+ jumps \([\d.]+\/min\)/);
446
  press("Backquote");
447
  expect((perf.style as Record<string, string>).display).toBe("none");
448
449
  // This test's loopback address is shared with every other test in the
450
  // run; close the socket so the address is free for the next real
451
  // connection rather than left holding a seat for the rest of the suite.
452
  openedSocket?.close();
453
  await Bun.sleep(50);
454
}, 15000);
455
456
test("a snapshot with no seat in it draws the watching view instead of throwing", async () => {
457
  // The shape no other test covers: score, round, clock and feed, and no `me`
458
  // and no players at all. The client used to reach straight into `snap.me`
459
  // on the way in, so a second tab's first frame threw and the page stopped
460
  // drawing. This is the server's own spectator snapshot, handed to the
461
  // bundled client the way the socket hands it over.
462
  const { spectatorSnapshot } = await import("./arena.ts");
463
  const deliver = (why: "address" | "cookie") =>
464
    openedSocket!.onmessage!({ data: spectatorSnapshot(why) } as MessageEvent);
465
466
  drawn.length = 0;
467
  deliver("address");
468
  runFrame();
469
470
  // It says what the match is doing, and why this connection is watching.
471
  expect(String(nodes.get("phase")!.textContent)).toContain("round");
472
  expect(String(nodes.get("hp")!.textContent)).toBe("watching");
473
  expect(String(nodes.get("prompt")!.textContent)).toContain("address");
474
  // Nothing on screen that would need a position to fill in.
475
  expect((nodes.get("kit")!.children as unknown[]).length).toBe(0);
476
  expect((nodes.get("roster")!.children as unknown[]).length).toBe(0);
477
  expect((nodes.get("atkboard")!.children as unknown[]).length).toBe(0);
478
  // And the canvas is words on a panel, not a map: one rectangle, no tiles.
479
  expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBe(1);
480
  expect(drawn.filter((op) => op.startsWith("fillText")).length).toBeGreaterThan(3);
481
482
  // The other reason reads differently, and the frame after it still draws.
483
  deliver("cookie");
484
  runFrame();
485
  expect(String(nodes.get("prompt")!.textContent)).toContain("cookie");
486
});
487
488
test("your own camera is never drawn as a stranger arriving", async () => {
489
  // At spawn the buy phase turns prediction off, and the frame used to fall
490
  // through to the treatments meant for other people: drawn in the past, and
491
  // slid in from where it was last seen. On a respawn that slides the CAMERA
492
  // across the room — the shake somebody reported the first time they played
493
  // a round from the start.
494
  const { spectatorSnapshot } = await import("./arena.ts");
495
  expect(typeof spectatorSnapshot).toBe("function");
496
  const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text();
497
  // The rule is small enough to state: own id short-circuits before both.
498
  const look = bundle.slice(bundle.indexOf("const look = (p) =>"));
499
  const body = look.slice(0, look.indexOf("};") + 2);
500
  const ownReturn = body.indexOf("if (p.id === snap.me.id) return p;");
501
  const slideCall = body.indexOf("slide(presence");
502
  expect(ownReturn).toBeGreaterThan(-1);
503
  expect(slideCall).toBeGreaterThan(ownReturn);
504
});
505
506
test("the buy phase is predicted, and its pen is the server's own rule", async () => {
507
  const bundle = await Bun.file(new URL("../static/val.js", import.meta.url)).text();
508
  // Prediction is no longer switched off for the whole buy phase: it is on
509
  // whenever the browser knows the pen, which it learns from the map it is
510
  // already given. Drawing your own body straight from a 30Hz snapshot while
511
  // rendering at 60 is a stutter, and that is what the phase used to do.
512
  expect(bundle).toContain('snap.phase !== "buy" || pens !== null');
513
  // And the pen is the server's number, not a guess: 120 from a spawn tile.
514
  expect(bundle).toContain("best > 120");
515
  // A refused step holds where it was rather than snapping to the server:
516
  // the pen is handed to the one step function as the place it is refused.
517
  expect(bundle).toContain("stepLocal(local, snap.me, held, speed");
518
  expect(bundle).toContain("world, refused)");
519
  expect(bundle).toContain("outsidePen(x, y) || insideBarrier(x, y)");
520
});
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts