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

forked from benjivers/val

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

Keyboard shortcuts