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

forked from benjivers/val

workbenchthis machine

Draw the watching view instead of throwing on a seatless snapshot

A connection with no seat — no session cookie, or a second client from an
address that already holds one — is sent a snapshot with no `me` and no
players, which is the point of it. The client reached into `snap.me` on the
way in, so a spectator's first frame threw and the page stopped drawing:
whoever opened a second tab got a dead screen rather than the watching view
we built for them.

`static/watch.js` is that view: a snapshot with no seat is recognised there,
turned into the lines it can honestly show — the score, the round, the clock
and why this connection has no seat — and drawn on a flat panel. No map and
no bodies, because it is never told where anybody is. `val.js` takes that path on
the way in, skips the input it has nothing to drive, and blanks the panels a
seat owns rather than leaving the first paint to go stale.

The snapshot now carries the kill feed (names and a gun, never a place, the
same as a seat is sent) so the watching view has something to show, and the
reason it has no seat, because the two have different answers: open the tab
that is playing, or let the page keep its cookie.

Co-Authored-By: Claude Opus 5 <[email protected]>
Wren committed 1ccff50 Browse files
8 files changed +338 −29
README.md +4 −1
@@ -80,7 +80,10 @@ Guns: Classic, Ghost, Sheriff, Spectre, Phantom, Vandal, Operator.
8080 again — cannot buy a second one on the other team. A second machine on a
8181 second network is still a second person as far as the server can tell,
8282 the same as any anonymous game without accounts; a socket that does not
83 get a seat is sent a spectator view with no position in it, never a side.
83 get a seat is sent a spectator view with no position in it, never a side:
84 the page draws the score, the round, the clock and the kill feed, says
85 which of the two reasons it is watching for, and draws no map and no
86 bodies, because it is never told where anybody is.
8487 - No overtime — first to 13 ends it at 12–12 too.
8588 - Sight is a full circle rather than a view cone — you are drawn what is in
8689 front of you, but the server reveals anything you have line of sight to,
src/arena.ts +24 −7
@@ -174,12 +174,20 @@ export const seatCount = (): number => seats.size;
174174
175175 // A connection that never carried a session the page minted gets no side and
176176 // no seat: only the public state everyone already sees on the page itself.
177 const spectators = new Map<string, (data: string) => void>();
177 const spectators = new Map<string, { send: (data: string) => void; why: Why }>();
178178
179 export function spectatorSnapshot(): string {
179 /**
180 * Why this connection is watching. The client says so in words, and the two
181 * have different answers — open the tab that is playing, or let the page keep
182 * its cookie — so which one it is travels with the snapshot.
183 */
184 export type Why = "address" | "cookie" | "unsaid";
185
186 export function spectatorSnapshot(why: Why = "unsaid"): string {
180187 return JSON.stringify({
181188 t: "s",
182189 spectator: true,
190 why,
183191 phase: state.phase,
184192 clock: round2(Math.max(0, state.clock)),
185193 round: state.round,
@@ -187,12 +195,21 @@ export function spectatorSnapshot(): string {
187195 attackers: state.attackers,
188196 result: state.result,
189197 events: state.events.slice(0, 5).map((e) => e.text),
198 // Names and a gun, the same as a seat is sent, and for the same reason it
199 // is safe there: the feed says what happened, never where.
200 feed: feed.map((f) => ({
201 k: f.killer,
202 v: f.victim,
203 w: f.weapon ? WEAPONS[f.weapon].name : null,
204 h: f.headshot,
205 t: round2(Math.max(0, state.now - f.at)),
206 })),
190207 });
191208 }
192209
193 export function watch(token: string, send: (data: string) => void): void {
194 spectators.set(token, send);
195 send(spectatorSnapshot());
210 export function watch(token: string, send: (data: string) => void, why: Why = "unsaid"): void {
211 spectators.set(token, { send, why });
212 send(spectatorSnapshot(why));
196213 }
197214
198215 export function unwatch(token: string): void {
@@ -400,9 +417,9 @@ function step(): void {
400417 // handler gives the seat back.
401418 }
402419 }
403 for (const send of spectators.values()) {
420 for (const spectator of spectators.values()) {
404421 try {
405 send(spectatorSnapshot());
422 spectator.send(spectatorSnapshot(spectator.why));
406423 } catch {
407424 // Same story: the close handler drops it from the set.
408425 }
src/client.test.ts +33 −1
@@ -347,10 +347,42 @@ test("the bundled client connects, paints the HUD and draws a frame", async () =
347347 expect(seat.x).toBe(startedAt.x);
348348 expect(seat.y).toBe(startedAt.y);
349349 arena.startArena();
350 }, 15000);
351
352 test("a snapshot with no seat in it draws the watching view instead of throwing", async () => {
353 // The shape no other test covers: score, round, clock and feed, and no `me`
354 // and no players at all. The client used to reach straight into `snap.me`
355 // on the way in, so a second tab's first frame threw and the page stopped
356 // drawing. This is the server's own spectator snapshot, handed to the
357 // bundled client the way the socket hands it over.
358 const { spectatorSnapshot } = await import("./arena.ts");
359 const deliver = (why: "address" | "cookie") =>
360 openedSocket!.onmessage!({ data: spectatorSnapshot(why) } as MessageEvent);
361
362 drawn.length = 0;
363 deliver("address");
364 runFrame();
365
366 // It says what the match is doing, and why this connection is watching.
367 expect(String(nodes.get("phase")!.textContent)).toContain("round");
368 expect(String(nodes.get("hp")!.textContent)).toBe("watching");
369 expect(String(nodes.get("prompt")!.textContent)).toContain("address");
370 // Nothing on screen that would need a position to fill in.
371 expect((nodes.get("kit")!.children as unknown[]).length).toBe(0);
372 expect((nodes.get("roster")!.children as unknown[]).length).toBe(0);
373 expect((nodes.get("atkboard")!.children as unknown[]).length).toBe(0);
374 // And the canvas is words on a panel, not a map: one rectangle, no tiles.
375 expect(drawn.filter((op) => op.startsWith("fillRect")).length).toBe(1);
376 expect(drawn.filter((op) => op.startsWith("fillText")).length).toBeGreaterThan(3);
377
378 // The other reason reads differently, and the frame after it still draws.
379 deliver("cookie");
380 runFrame();
381 expect(String(nodes.get("prompt")!.textContent)).toContain("cookie");
350382
351383 // This test's loopback address is shared with every other test in the
352384 // run; close the socket so the address is free for the next real
353385 // connection rather than left holding a seat for the rest of the suite.
354386 openedSocket?.close();
355387 await Bun.sleep(50);
356 }, 15000);
388 });
src/server.test.ts +14 −1
@@ -281,11 +281,24 @@ test("a real second connection from one address gets a spectator view, positions
281281 wsTwo.onmessage = (event) => resolve(JSON.parse(String(event.data)));
282282 wsTwo.onerror = reject;
283283 });
284 expect(snapTwo).toMatchObject({ t: "s", spectator: true });
284 // It says why it has no seat, because the two reasons have different
285 // answers, and it carries the kill feed — names and a gun, never a place.
286 expect(snapTwo).toMatchObject({ t: "s", spectator: true, why: "address" });
287 expect(Array.isArray((snapTwo as { feed?: unknown[] }).feed)).toBe(true);
285288 // No coordinate anywhere, not under "me", not under a different key either.
286289 expect(JSON.stringify(snapTwo)).not.toMatch(/"x"|"y"/);
290
291 // And a connection carrying no session at all is told the other reason.
292 const wsThree = new WebSocket(wsUrl);
293 const snapThree = await new Promise<unknown>((resolve, reject) => {
294 wsThree.onmessage = (event) => resolve(JSON.parse(String(event.data)));
295 wsThree.onerror = reject;
296 });
297 expect(snapThree).toMatchObject({ spectator: true, why: "cookie" });
298
287299 wsOne.close();
288300 wsTwo.close();
301 wsThree.close();
289302 await Bun.sleep(50);
290303 });
291304
src/server.ts +11 −5
@@ -23,6 +23,7 @@ import {
2323 unwatch,
2424 watch,
2525 type Command,
26 type Why,
2627 } from "./arena.ts";
2728 import { recentResults } from "./db.ts";
2829 import { gridRows, TILE } from "./map.ts";
@@ -158,7 +159,7 @@ const cleanName = (raw: unknown): string => {
158159 const setCookie = (name: string, value: string, httpOnly: boolean): string =>
159160 `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=604800${httpOnly ? "; HttpOnly" : ""}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`;
160161
161 type SocketData = { session: string; playerId: string; address: string; token: string; spectator: boolean };
162 type SocketData = { session: string; playerId: string; address: string; token: string; spectator: boolean; why: Why };
162163
163164 // ---------------------------------------------------------------- routing
164165
@@ -201,13 +202,18 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
201202 if (!sameOrigin(request)) return new Response("forbidden", { status: 403, headers: SECURITY });
202203 const token = randomUUID();
203204 if (fresh || addressCapped(address, session)) {
204 const upgraded = self.upgrade(request, { data: { session, playerId: "", address, token, spectator: true } });
205 // Which of the two it is: the watching page says so in words, and the
206 // answer differs — open the tab that plays, or keep the cookie.
207 const why: Why = fresh ? "cookie" : "address";
208 const upgraded = self.upgrade(request, { data: { session, playerId: "", address, token, spectator: true, why } });
205209 return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY });
206210 }
207211 const name = cleanName(jar[NAME]) || `Guest-${session.slice(0, 4)}`;
208212 const player = takeSeat(address, session, name);
209213 if (!player) return new Response("no seats", { status: 503, headers: SECURITY });
210 const upgraded = self.upgrade(request, { data: { session, playerId: player.id, address, token, spectator: false } });
214 const upgraded = self.upgrade(request, {
215 data: { session, playerId: player.id, address, token, spectator: false, why: "unsaid" },
216 });
211217 return upgraded ? undefined : new Response("expected a websocket", { status: 426, headers: SECURITY });
212218 }
213219
@@ -291,8 +297,8 @@ export const server = Bun.serve<SocketData, Record<string, never>>({
291297 websocket: {
292298 maxPayloadLength: 4 * 1024,
293299 open(socket) {
294 const { session, playerId, address, token, spectator } = socket.data;
295 if (spectator) return void watch(token, (data) => socket.send(data));
300 const { session, playerId, address, token, spectator, why } = socket.data;
301 if (spectator) return void watch(token, (data) => socket.send(data), why);
296302 attach(session, playerId, address, token, (data) => socket.send(data));
297303 socket.send(snapshot(playerId));
298304 },
src/watch.test.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * The watching view: what a snapshot with no seat in it turns into on screen.
3 */
4
5 import { describe, expect, test } from "bun:test";
6 import { drawWatching, watchLines, watching, wrap } from "../static/watch.js";
7
8 const spectator = (over: Record<string, unknown> = {}) => ({
9 t: "s",
10 spectator: true,
11 why: "address",
12 phase: "live",
13 clock: 42.4,
14 round: 7,
15 score: { red: 3, blue: 3 },
16 attackers: "red",
17 result: null,
18 events: ["red won the round"],
19 feed: [],
20 ...over,
21 });
22
23 const seated = () => ({ t: "s", phase: "live", me: { id: "p1", alive: true }, players: [] });
24
25 describe("telling a seat from a watcher", () => {
26 test("a spectator snapshot is watching, a seat's is not", () => {
27 expect(watching(spectator())).toBe(true);
28 expect(watching(seated())).toBe(false);
29 });
30
31 test("a snapshot with no `me` is watching even if nobody flagged it", () => {
32 expect(watching({ t: "s", phase: "live" })).toBe(true);
33 expect(watching(null)).toBe(false);
34 expect(watching(undefined)).toBe(false);
35 });
36 });
37
38 describe("what the watching view says", () => {
39 test("the score, the round and the clock, from the snapshot it was given", () => {
40 const lines = watchLines(spectator());
41 expect(lines.score).toBe("red 3 — 3 blue");
42 expect(lines.clock).toBe("43s");
43 expect(lines.phase).toBe("round 7 · live · red attacking");
44 });
45
46 test("the reason is named: the seat is held at this address", () => {
47 expect(watchLines(spectator()).why).toContain("address");
48 expect(watchLines(spectator({ why: "cookie" })).why).toContain("cookie");
49 // A snapshot that does not say still gets a sentence, not `undefined`.
50 expect(watchLines(spectator({ why: undefined })).why).toContain("watching");
51 });
52
53 test("it never claims to know where anybody is", () => {
54 const lines = watchLines(spectator());
55 expect(lines.note).toContain("No map");
56 expect(JSON.stringify(lines)).not.toMatch(/"x"|"y"/);
57 });
58
59 test("a half-built snapshot is drawn, not thrown on", () => {
60 const lines = watchLines({ spectator: true });
61 expect(lines.score).toBe("red 0 — 0 blue");
62 expect(lines.clock).toBe("0s");
63 expect(lines.phase).toContain("round 1");
64 });
65 });
66
67 test("long sentences are broken to fit the canvas", () => {
68 const ctx = { measureText: (text: string) => ({ width: text.length * 10 }) };
69 const lines = wrap(ctx, "one two three four five six", 100);
70 expect(lines.length).toBeGreaterThan(1);
71 for (const line of lines) expect(line.length).toBeLessThanOrEqual(10);
72 expect(lines.join(" ")).toBe("one two three four five six");
73 });
74
75 test("drawing it fills a flat panel and writes the score, and draws no world", () => {
76 const ops: string[] = [];
77 const ctx = new Proxy(
78 { measureText: (text: string) => ({ width: text.length * 6 }) } as Record<string, unknown>,
79 {
80 get: (target, prop: string) =>
81 prop in target
82 ? target[prop]
83 : (...args: unknown[]) => ops.push(`${prop}(${args.map(String).join(",")})`),
84 set: () => true,
85 },
86 );
87 drawWatching(ctx as never, spectator(), 900, 570);
88 // One background rectangle and nothing else with an edge: no tiles, no bodies.
89 expect(ops.filter((op) => op.startsWith("fillRect")).length).toBe(1);
90 expect(ops.some((op) => op.startsWith("fillText(red 3 — 3 blue"))).toBe(true);
91 expect(ops.some((op) => op.startsWith("fillText(watching"))).toBe(true);
92 });
static/val.js +69 −14
@@ -21,6 +21,7 @@ import { roster, rosterKey } from "./roster.js";
2121 import { AGENT_COLOUR, drawScene, makeScene } from "./scene.js";
2222 import { moveVector } from "./steer.js";
2323 import { drawTopdown } from "./topdown.js";
24 import { drawWatching, watching, watchLines } from "./watch.js";
2425 import { aim as aimAt, makeYaw, settle, told, turn } from "./yaw.js";
2526
2627 const $ = (id) => document.getElementById(id);
@@ -87,6 +88,9 @@ function connect() {
8788 if (data.t !== "s") return;
8889 snap = data;
8990 snapAt = performance.now();
91 // No seat, no `me`, no players. Everything below reads a body out of the
92 // snapshot, so a watching connection takes the other path entirely.
93 if (watching(data)) return void paintWatch(data);
9094 // Timed samples for every body in it, ours included: the drawing walks
9195 // between them rather than chasing the newest one.
9296 arrive(timeline, snapAt / 1000);
@@ -162,6 +166,7 @@ addEventListener("blur", () => {
162166 // look, Escape gives it back. The angle sent is the same angle as ever.
163167 view.addEventListener("click", () => {
164168 beeper.wake();
169 if (watching(snap)) return; // nothing to aim: capturing the mouse would be a lie
165170 if (mode !== "fp" || locked) return;
166171 if (!view.requestPointerLock) return setMode("top"); // no lock, no first person
167172 // Asked for too soon after the last release, a browser rejects this; that
@@ -275,7 +280,7 @@ function readIntent() {
275280 // Twenty times a second the server hears it. Intent only — a direction, an
276281 // angle, a held key — never a position: the guess below is for drawing.
277282 setInterval(() => {
278 if (!snap || !map) return;
283 if (!snap || !map || watching(snap)) return; // nothing to drive, nothing to send
279284 const now = readIntent();
280285 send({ t: "i", mx: now.mx, my: now.my, a: now.angle, f: now.fire, u: now.use, r: now.reload });
281286 // What we told it, so its echo is recognisable as ours when it comes back.
@@ -309,7 +314,8 @@ const look = (p) => (predicting() && p.id === snap.me.id ? drawnMe : bodyAt(time
309314 * match, not of the map — and a guess that does not know it would spend the
310315 * phase being pulled back through the door.
311316 */
312 const predicting = () => snap !== null && world !== null && snap.phase !== "buy" && snap.me.alive;
317 const predicting = () =>
318 snap !== null && !watching(snap) && world !== null && snap.phase !== "buy" && snap.me.alive;
313319
314320 /** The server's answer, caught up to now, then this frame of your own keys. */
315321 function predict(dt, now) {
@@ -347,6 +353,9 @@ function frame(now) {
347353 const dt = Math.min(0.1, (now - last) / 1000);
348354 last = now;
349355 drawAt = renderTime(timeline, now / 1000);
356 // A watching connection draws the watching view and stops there: it has no
357 // map to stand on and no body to put on it, and must invent neither.
358 if (watching(snap)) return void drawWatch();
350359 if (!map) return;
351360 gunTick(gun, dt);
352361 ctx.clearRect(0, 0, view.width, view.height);
@@ -486,18 +495,7 @@ function paintHud(s) {
486495 paintBuy(s);
487496 paintRoster(s);
488497 paintFeed(s);
489
490 const log = $("log");
491 const lines = s.events.slice(0, 3).join("\n");
492 if (log.dataset.text !== lines) {
493 log.dataset.text = lines;
494 log.replaceChildren();
495 for (const line of s.events.slice(0, 3)) {
496 const div = document.createElement("div");
497 div.textContent = line;
498 log.append(div);
499 }
500 }
498 paintLog(s);
501499
502500 for (const side of ["atk", "def"]) {
503501 const body = $(side + "board");
@@ -525,6 +523,63 @@ function paintHud(s) {
525523 }
526524 }
527525
526 /** The last few things that happened, in words. Rebuilt only when they change. */
527 function paintLog(s) {
528 const log = $("log");
529 const lines = (s.events ?? []).slice(0, 3);
530 if (log.dataset.text === lines.join("\n")) return;
531 log.dataset.text = lines.join("\n");
532 log.replaceChildren();
533 for (const line of lines) {
534 const div = document.createElement("div");
535 div.textContent = line;
536 log.append(div);
537 }
538 }
539
540 /**
541 * The HUD for a connection with no seat. Everything a seat's HUD says about
542 * you — health, gun, credits, kit, your five — has no answer here, so it says
543 * so rather than leaving the page's first paint standing there going stale.
544 */
545 function paintWatch(s) {
546 const lines = watchLines(s);
547 $("clock").textContent = Math.ceil(Math.max(0, Number(s.clock) || 0));
548 document.querySelector("#scoreline .red").textContent = "red " + (s.score?.red ?? 0);
549 document.querySelector("#scoreline .blue").textContent = (s.score?.blue ?? 0) + " blue";
550 $("phase").textContent = lines.phase;
551 $("hp").textContent = "watching";
552 $("gun").textContent = "no seat";
553 $("creds").textContent = "—";
554 $("ultmeter").textContent = "—";
555 $("buy").classList.toggle("on", false);
556 if ($("prompt").textContent !== lines.why) $("prompt").textContent = lines.why;
557 paintFeed(s);
558 paintLog(s);
559 blank($("kit"));
560 blank($("roster"));
561 blank($("atkboard"));
562 blank($("defboard"));
563 if (KITS.shape !== "watching") KITS.shape = "watching";
564 }
565
566 /** Empty a panel whose answer this connection is not sent, once. */
567 function blank(panel) {
568 if (panel.dataset.key === "watching") return;
569 panel.dataset.key = "watching";
570 panel.dataset.text = "watching";
571 panel.replaceChildren();
572 }
573
574 /** The canvas, watching: no map, no bodies, and the overlays a seat owns off. */
575 function drawWatch() {
576 ctx.clearRect(0, 0, view.width, view.height);
577 drawWatching(ctx, snap, view.width, view.height);
578 $("flash").style.opacity = 0;
579 $("dead").style.display = "none";
580 $("aimhint").style.display = "none";
581 }
582
528583 /** Is this player standing where the spike arms? The grid says so. */
529584 function onSite(me) {
530585 if (!map) return false;
static/watch.js added +91
@@ -0,0 +1,91 @@
1 /**
2 * Watching.
3 *
4 * A connection the server gave no seat — one that carried no session the page
5 * minted, or a second client from an address that already holds a seat — is
6 * sent a snapshot with no `me` and no players: the score, the round, the
7 * clock and the kill feed, and nothing at all that says where anybody is.
8 * That is the whole point of it, so this view draws no map and no bodies. It
9 * says what the match is doing and why this connection is only watching.
10 */
11
12 /** Is this a snapshot for a connection that has no seat? */
13 export const watching = (snap) => Boolean(snap) && (snap.spectator === true || !snap.me);
14
15 // Why the server handed this connection no seat. It says which, because the
16 // two have different answers: open the tab that is playing, or let the page
17 // keep its cookie.
18 const WHY = {
19 address: "Somebody at this address is already playing — that tab holds the seat, and this one watches.",
20 cookie: "This browser did not send the session cookie the page sets, so there is no seat to give it.",
21 };
22 const WHY_UNSAID = "This connection has no seat, so it is watching the match.";
23
24 const PHASE = { buy: "buy phase", live: "live", end: "round over" };
25
26 /** What the watching view says, in order. Plain strings, nothing drawn. */
27 export function watchLines(snap) {
28 const score = snap.score ?? { red: 0, blue: 0 };
29 return {
30 title: "watching",
31 why: WHY[snap.why] ?? WHY_UNSAID,
32 phase: `round ${snap.round ?? 1} · ${PHASE[snap.phase] ?? "match over"} · ${snap.attackers ?? "—"} attacking${snap.result ? " · " + snap.result : ""}`,
33 score: `red ${score.red ?? 0} — ${score.blue ?? 0} blue`,
34 clock: `${Math.ceil(Math.max(0, Number(snap.clock) || 0))}s`,
35 note: "No map and no bodies: a watching connection is never told where anybody is.",
36 };
37 }
38
39 /** Break a line at the last space that still fits, measured by the canvas. */
40 export function wrap(ctx, text, maxWidth) {
41 const lines = [];
42 let line = "";
43 for (const word of String(text).split(" ")) {
44 const tried = line ? `${line} ${word}` : word;
45 if (line && ctx.measureText(tried).width > maxWidth) {
46 lines.push(line);
47 line = word;
48 } else line = tried;
49 }
50 if (line) lines.push(line);
51 return lines;
52 }
53
54 /**
55 * The view itself: the score, the round, the clock, and the sentence that
56 * says why this connection is watching. Centred on a flat panel, because
57 * there is no world here to stand it on.
58 */
59 export function drawWatching(ctx, snap, width, height) {
60 const lines = watchLines(snap);
61 const mid = width / 2;
62 const unit = Math.min(width / 900, height / 570);
63 ctx.fillStyle = "#0b0d11";
64 ctx.fillRect(0, 0, width, height);
65 ctx.textAlign = "center";
66
67 ctx.fillStyle = "#8b93a5";
68 ctx.font = `${Math.round(22 * unit)}px system-ui, sans-serif`;
69 ctx.fillText(lines.title, mid, height * 0.3);
70
71 ctx.fillStyle = "#e7e9ee";
72 ctx.font = `${Math.round(54 * unit)}px system-ui, sans-serif`;
73 ctx.fillText(lines.score, mid, height * 0.42);
74
75 ctx.font = `${Math.round(30 * unit)}px system-ui, sans-serif`;
76 ctx.fillText(lines.clock, mid, height * 0.52);
77
78 ctx.fillStyle = "#b9c0cf";
79 ctx.font = `${Math.round(19 * unit)}px system-ui, sans-serif`;
80 ctx.fillText(lines.phase, mid, height * 0.6);
81
82 ctx.fillStyle = "#8b93a5";
83 ctx.font = `${Math.round(17 * unit)}px system-ui, sans-serif`;
84 let y = height * 0.7;
85 for (const line of [...wrap(ctx, lines.why, width * 0.8), ...wrap(ctx, lines.note, width * 0.8)]) {
86 ctx.fillText(line, mid, y);
87 y += 24 * unit;
88 }
89 ctx.textAlign = "left";
90 return lines;
91 }
↑↓ navigate ↵ open esc close

Keyboard shortcuts