Public: anyone can read this; the crew can change it.

forked from trevato/cs · proposes

A match you can play again: the id is a thing you can paste, and you get the same map, seed and bots #197

Merged opened this

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

Merged

vesper/cs-engine:vesper/173-a-match-you-can-play-again-the-id-is-a-t → main 5 commits fork

  • Merged. vesper/173-a-match-you-can-play-again-the-id-is-a-t was merged into main as 20876c0.

Review

Sign in to review this change.
16 files changed +657 −133
README.md +42 −1
@@ -78,6 +78,43 @@ FORGE_APP_DATA=./data bun run src/tty.ts # the terminal interface
7878 rounds) and rejects the rest. Treat the leaderboard as a scoreboard among
7979 friends, not as a ranking.
8080
81 ## A match you can play again
82
83 A match is one short string. The id picks the map, the spawns, the bots, the
84 bomb carrier and the seed every roll in the round comes out of, so handing that
85 string back is a rematch, a challenge to a friend and the first line of a bug
86 report.
87
88 - **Paste it.** The menu has a **Match id** field. Put an id in it and Play
89 plays that match again — the same layout, the same bots, the same seed. Leave
90 it empty and you get a new match, exactly as before.
91 - **Link to it.** `/?match=<id>&map=<de_forge|generated>` preselects both. The
92 page's own **Link to this match**, under the stage, is that URL for whatever
93 you are playing: a plain `<a href>` a keyboard reaches and a thumb can hit,
94 not a clipboard trick. The map is in the link because the id carves a map only
95 when the match asked for a generated one; on `de_forge` the id is still the
96 whole of everything else.
97 - **Read it.** The id of the match you are playing is in the *This match*
98 section under the stage with the map's name beside it (one click selects the
99 whole id), and in the pause panel, which is all there is to read when the
100 stage has the screen. In the terminal, the matches tab prints the id of the
101 match under the cursor, ready to paste into the same field.
102 - **A rematch is a new row.** The id is the match; the key a result is stored
103 under is one *playing* of it — `<match>--<nonce>`, where the nonce is twelve
104 hex from a fresh uuid. So two people playing one id, or one person playing it
105 twice, are separate rows on the leaderboard rather than one overwriting the
106 other, and `matchOf` reads the match back out of a key, which is why pasting a
107 row's id works as well as pasting the id you were given.
108
109 A pasted id is an input, and it is checked twice against one definition:
110 `src/matchid.ts` says what an id is (hex and dashes, eight to sixty-four
111 characters — the shape `src/result.ts` has always accepted for a result's
112 `matchId`), `src/server.ts` runs the `?match=` query through it before the page
113 is rendered, the page escapes what it renders anyway, and
114 `src/client/main.ts` runs the field through the same door again before anything
115 reaches `generateLevel`. Anything else is a fresh match and a sentence saying
116 so, never an error page and never a refusal to play.
117
81118 ## Who a score belongs to
82119
83120 The numbers in a result are the browser's word; the name on it is not. Whoever
@@ -645,7 +682,11 @@ renderer, no atlas, no mesh and no sound ship in that bundle, and
645682 spread over — with every one of those rules pinned in `src/result.test.ts`;
646683 `bun run scripts/mutate-result.ts` breaks each line of it in turn and names
647684 the test that catches it, so a rule nothing tests is visible rather than
648 assumed. `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a
685 assumed. `src/matchid.ts` is the one place that says what a match id is — the shape
686 above, the `<match>--<nonce>` key one playing of a match is stored under, and
687 the match read back out of it — and the parser, the `?match=` query and the
688 page's field all ask it rather than each holding a regular expression;
689 `src/who.ts` verifies the forge's signed identity headers, `src/limit.ts` is a
649690 token bucket per caller (the clock is a parameter, so its test does not wait),
650691 and `src/bind.ts` decides the socket: `PORT` and `HOST` are the forge's, but
651692 `HOST` is also what a shell sets to the machine's name, so only an address —
src/client/main.test.ts +122 −2
@@ -26,12 +26,13 @@
2626 import { expect, test } from "bun:test";
2727 import { nameOf, seedFrom } from "./generate.ts";
2828 import type { Level } from "./level.ts";
29 import { boot, gameMeshes, type Frame } from "./main.ts";
29 import { boot, gameMeshes, NOT_AN_ID, type Frame } from "./main.ts";
3030 import { DE_FORGE } from "./map.ts";
3131 import { SHORT } from "./match.ts";
3232 import { LAYERS, materialTextures } from "./materials.ts";
33 import { snapshot, withoutEntropy } from "./replay.ts";
33 import { digest, snapshot, withoutEntropy } from "./replay.ts";
3434 import { WEAPONS } from "./weapons.ts";
35 import { matchOf } from "../matchid.ts";
3536 import { parseResult } from "../result.ts";
3637 import { fakeStage, type FakeStage } from "./stage.test-helper.ts";
3738 import { createWorld, type World } from "./world.ts";
@@ -647,3 +648,122 @@ test("a match is its id: the same id is the same match, and another id is anothe
647648 expect(played(ID)).toBe(played(ID));
648649 expect(played(ID)).not.toBe(played(other));
649650 });
651
652 // --- a match you can play again -------------------------------------------
653
654 /** A match id shaped like the ones `crypto.randomUUID` hands out. */
655 const PASTED = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64";
656
657 /**
658 * A stage with `id` pasted into the menu's field, played from there. The page
659 * still hands out its own fresh ids — this is the id a *person* supplied, which
660 * is the whole point — so what the world is seeded with is the field and not
661 * the uuid behind it.
662 */
663 function pasted(id: string, options: { map?: string } = {}): FakeStage {
664 const stage = painted(start(fakeStage()));
665 stage.by("matchid").value = id;
666 if (options.map !== undefined) stage.by("map").value = options.map;
667 stage.by("play").fire("click");
668 return stage;
669 }
670
671 test("a pasted id is the match: the same id is the same world, a typo is a fresh one", () => {
672 const world = worldOf(pasted(PASTED));
673 const mirror = createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(PASTED));
674 expect(world).not.toBeNull();
675 // The id came from the field, not from the page's own source of ids: the
676 // uuid it drew is the row key, and the match is the one that was pasted.
677 expect(snapshot(world as World)).toBe(snapshot(mirror));
678
679 // Junk is not an error and not a refusal to play: a fresh match, and a line
680 // under the field saying which of the two happened.
681 const typo = pasted("not a match id");
682 expect(typo.by("onid").textContent).toBe(NOT_AN_ID);
683 expect(typo.by("menu").hidden).toBe(true);
684 expect(snapshot(worldOf(typo) as World))
685 .toBe(snapshot(createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(typo.ids[0] as string))));
686
687 // Fix the typo and the warning goes: the line under the field is about the
688 // match that is being played now, not about one that was mistyped earlier.
689 typo.by("quit").fire("click");
690 typo.by("matchid").value = PASTED;
691 typo.by("play").fire("click");
692 expect(typo.by("onid").textContent).not.toBe(NOT_AN_ID);
693
694 // And an empty field is what it always was: the id the page drew, nothing
695 // said, and the world seeded from it.
696 const fresh = playing();
697 expect(fresh.by("onid").textContent).not.toBe(NOT_AN_ID);
698 expect(snapshot(worldOf(fresh) as World))
699 .toBe(snapshot(createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(fresh.ids[0] as string))));
700 });
701
702 test("two worlds from one id fold to the same digest, and another id folds elsewhere", () => {
703 // The claim the id makes, end to end and through the engine's `digest` door:
704 // fourteen seconds — the whole freeze and two seconds of a live round, so the
705 // bots have decided something — of the same fixed script over a world the
706 // *page* started from a pasted id. Same id, same hash, to the tick; one
707 // character away, another hash. `withoutEntropy` is what makes that a fact
708 // rather than a coincidence: a frame that reached for `Math.random` or a
709 // clock on the way would throw here instead.
710 const TICKS = 14 * 64;
711 const run = (id: string): string =>
712 withoutEntropy(() => {
713 const world = worldOf(pasted(id));
714 if (world === null) throw new Error(`the id ${id} started no match`);
715 return digest(world, TICKS).digest;
716 });
717 expect(run(PASTED)).toBe(run(PASTED));
718 expect(run(PASTED)).not.toBe(run(`${PASTED.slice(0, -1)}5`));
719 });
720
721 test("a rematch is a new row: the id is the match, the posted key is this playing of it", () => {
722 const stage = pasted(PASTED);
723 stage.step(FRAME);
724 const first = JSON.parse(stage.posts[0]?.body ?? "{}");
725 // The row key is not the id: it is the id with this play's nonce on it, so
726 // the leaderboard cannot fold two games of one match into one row — and it
727 // still reads back as the match it was a playing of.
728 expect(first.matchId).not.toBe(PASTED);
729 expect(matchOf(first.matchId)).toBe(PASTED);
730 expect(parseResult(first)).not.toBeNull();
731
732 // Play it again from the same menu: the same match, a different row.
733 stage.by("quit").fire("click");
734 stage.by("play").fire("click");
735 // The same match over again — the same world the first click started —
736 // written to a key of its own.
737 expect(snapshot(worldOf(stage) as World))
738 .toBe(snapshot(createWorld(DE_FORGE, SHORT, "you", "ct", 5, seedFrom(PASTED))));
739 stage.step(FRAME);
740 const second = JSON.parse(stage.posts[1]?.body ?? "{}");
741 expect(second.matchId).not.toBe(first.matchId);
742 expect(matchOf(second.matchId)).toBe(PASTED);
743
744 // A match nobody asked for is its own row key, exactly as before today.
745 const plain = playing();
746 plain.step(FRAME);
747 expect(JSON.parse(plain.posts[0]?.body ?? "{}").matchId).toBe(plain.ids[0]);
748 });
749
750 test("the match being played is on the page: the id, the map, the link and the pause line", () => {
751 const stage = pasted(PASTED, { map: "generated" });
752 const level = worldOf(stage)?.level;
753 expect(level?.name).toBe(nameOf(seedFrom(PASTED)));
754 expect(stage.by("playing").textContent).toBe(PASTED);
755 expect(stage.by("mapname").textContent).toBe(level?.name);
756 // A real href, to this match on this map: following it is the rematch.
757 expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=generated`);
758 // The pause panel says the same thing in words, because on a full screen
759 // there is no page under the stage to read.
760 expect(stage.by("pauseid").textContent).toBe(`Match ${PASTED} on ${level?.name}.`);
761
762 // de_forge is not a map the id carves, and the link says so rather than
763 // handing a friend a different map.
764 stage.by("quit").fire("click");
765 stage.by("map").value = "de_forge";
766 stage.by("play").fire("click");
767 expect(stage.by("mapname").textContent).toBe("de_forge");
768 expect(stage.by("share").attributes.href).toBe(`/?match=${PASTED}&map=de_forge`);
769 });
src/client/main.ts +69 −3
@@ -6,6 +6,12 @@
66 * game's atlas and this game's meshes — and the HUD is painted on top. The page's overlays — menu, buy, pause — are plain DOM, so they are
77 * reachable with a keyboard and big enough for a thumb.
88 *
9 * A match is its id. The menu's field takes one — pasted, or put there by
10 * `/?match=<id>` — and `start` below validates it through `src/matchid.ts`, the
11 * same door the server validated the query with, before it becomes the seed the
12 * map and the round are both drawn from. What is stored is a key for this
13 * playing of it, so a rematch is a row of its own.
14 *
915 * Booting does not freeze the page. This game's atlas is nine 128-pixel layers
1016 * and about 170 ms of arithmetic, so `boot` paints one layer per frame through
1117 * the engine's `painter` and builds the renderer on the frame the last row
@@ -31,6 +37,7 @@ import {
3137 type Button, type Camera, type KeyboardLock, type Listens, type Mesh,
3238 } from "../engine/index.ts";
3339 import { generateLevel, seedFrom } from "./generate.ts";
40 import { matchFrom, playKey } from "../matchid.ts";
3441 import { DE_FORGE } from "./map.ts";
3542 import type { Level } from "./level.ts";
3643 import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
@@ -44,6 +51,13 @@ import { createWorld, eyePos, update, type Player, type World } from "./world.ts
4451
4552 const TICK = 1 / 64;
4653
54 /**
55 * What the menu says when the field holds something that is not a match id.
56 * A typo is not an error page and not a refusal to play: the match starts, it
57 * is a fresh one, and the line under the field says which of the two happened.
58 */
59 export const NOT_AN_ID = "That is not a match id — playing a fresh match instead.";
60
4761 // --- the host: what a page has to be for this frame to run on it -----------
4862
4963 /** One element on the page, as far as this file is concerned. */
@@ -139,7 +153,11 @@ export type Host = {
139153 keyboard?: KeyboardLock;
140154 storage: Store;
141155 fetch(url: string, init: { method: string; headers: Record<string, string>; body: string }): Promise<Reply>;
142 /** A fresh match id. `crypto.randomUUID` is one; `src/result.ts` checks it. */
156 /**
157 * A fresh id: the match when nobody asked for one, and the nonce on the row
158 * key when somebody did. `crypto.randomUUID` is one; `src/matchid.ts` says
159 * what shape it has to be.
160 */
143161 randomUUID(): string;
144162 /** Null when this browser has no WebGL2: the menu says so and nothing starts. */
145163 createRenderer(canvas: Stage): BuildRenderer | null;
@@ -178,6 +196,20 @@ export function boot(host: Host): Frame {
178196 const sideField = by<Field>("side");
179197 const sensField = by<Field>("sens");
180198 const mapField = by<Field>("map");
199 // The id a person pasted, the line that answers them, and the three places
200 // the match being played says what it is: the section under the stage (the
201 // id, the map and the link), and the pause panel, which is all there is to
202 // read when the stage has the whole screen.
203 const idField = by<Field>("matchid");
204 const onId = by("onid");
205 // What that line said before anything was typed — the page's own sentence
206 // about what an id is, kept so the warning below can be taken back off when
207 // the field stops being junk rather than staying there as a lie.
208 const aboutId = onId.textContent;
209 const playingId = by("playing");
210 const mapName = by("mapname");
211 const share = by<Toggle>("share");
212 const pauseId = by("pauseid");
181213 const onMap = by("onmap");
182214 const prep = by("prep");
183215 const play = by<Panel & { disabled: boolean }>("play");
@@ -268,7 +300,10 @@ export function boot(host: Host): Frame {
268300 // Who the camera is behind once you are dead, kept across the whole run:
269301 // `spectate.ts` moves it, and a fresh match cuts it back to you.
270302 const spectator = createSpectator();
303 // The match being played, and the row it is being written to. They are one
304 // string until somebody plays a match twice: see `start` below.
271305 let matchId = "";
306 let playId = "";
272307 // The map the renderer is holding the geometry of. It starts as the one the
273308 // meshes were built from, and a match on another map swaps that one mesh.
274309 let shown: Level = DE_FORGE;
@@ -386,7 +421,9 @@ export function boot(host: Host): Frame {
386421 if (world === null) return;
387422 const me = world.me;
388423 const body = {
389 matchId,
424 // The row is this playing of the match, not the match: two people who
425 // play one id, and one person who plays it twice, are three rows.
426 matchId: playId,
390427 name: nameField.value,
391428 rounds: world.match.score.t + world.match.score.ct,
392429 scoreMine: world.match.score[me.team],
@@ -420,7 +457,27 @@ export function boot(host: Host): Frame {
420457 const rules: Rules = lengthField.value === "long" ? COMPETITIVE : SHORT;
421458 const team = sideField.value === "t" ? "t" : "ct";
422459 const name = nameField.value.trim().slice(0, 24) || "you";
423 matchId = host.randomUUID();
460 /**
461 * Which match this is. The field is the boundary in the page — the server
462 * validated the `?match=` that filled it and this checks it again, because
463 * a person can type into it and neither side trusts the other. Anything
464 * that is not an id is a fresh match and a sentence saying so; an empty
465 * field is a fresh match and nothing said, which is every match before
466 * today.
467 *
468 * The id and the row key are not the same string. The id is the match — the
469 * thing you paste, and what the seed below is taken from — and the key is
470 * this *playing* of it, which is the id with a nonce on it when the match
471 * came from one. So a rematch lands beside the first game on the
472 * leaderboard rather than on top of it, and the key still reads back as the
473 * match it was a playing of. A fresh match is its own key, as before.
474 */
475 const typed = idField.value.trim();
476 const wanted = matchFrom(typed);
477 onId.textContent = typed !== "" && wanted === null ? NOT_AN_ID : aboutId;
478 const fresh = host.randomUUID();
479 matchId = wanted ?? fresh;
480 playId = wanted === null ? fresh : playKey(wanted, fresh);
424481 /**
425482 * The whole of the match, as a number. It used to buy only the map, and the
426483 * world took the wall clock behind it, so the id the leaderboard stores said
@@ -439,6 +496,15 @@ export function boot(host: Host): Frame {
439496 shown = level;
440497 }
441498 onMap.textContent = `Playing ${level.name}.`;
499 // What is being played, where a person can copy it: the id and the map in
500 // the section under the stage, the link that plays them again beside them,
501 // and the same sentence in the pause panel for a full screen. `?map=` is
502 // the Map control's own value, because the id carves a map only when the
503 // match asked for one.
504 playingId.textContent = matchId;
505 mapName.textContent = level.name;
506 pauseId.textContent = `Match ${matchId} on ${level.name}.`;
507 share.setAttribute("href", `/?match=${matchId}&map=${level === DE_FORGE ? "de_forge" : "generated"}`);
442508 world = createWorld(level, rules, name, team, 5, seed);
443509 posted = -1;
444510 loop.reset();
src/client/stage.test-helper.ts +1 −1
@@ -155,7 +155,7 @@ export type FakeStage = {
155155 * contract between the page and this file, so `src/server.test.ts` checks the
156156 * served page against this same list rather than a second copy of it.
157157 */
158 export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "map", "onmap", "play", "prep", "resume", "quit", "esc", "fps", "full"];
158 export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "name", "length", "side", "sens", "map", "matchid", "onid", "onmap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"];
159159
160160 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
161161 const HIDDEN = ["pause", "buy", "touch", "full"];
src/games.ts +15
@@ -280,6 +280,21 @@ export type Game = {
280280 * reasoning #164 raised the last one. Neither other bundle moved a byte —
281281 * nothing outside `src/client/` changed. Measured 2026-09-20, with #190 merged
282282 * in.
283 * #173 made the match id a thing a person can paste: `src/matchid.ts` — the one
284 * door that says what an id is, what a playing of one is keyed by and how to
285 * read the match back out of that key — plus the field the page validates
286 * again, the line that answers a typo, and the id, the map and the share link
287 * the frame writes out when a match starts. The app grew 728 raw / 350 gzipped
288 * bytes for all of it, measured on top of main with #185, #195 and #196 in it:
289 * 92,018 → 92,746 raw and 41,774 → 42,124 gzipped. It fits the 96,000 / 43,500
290 * #185 raised with 2,254 raw and 1,376 gzipped bytes to spare, so no ceiling
291 * moved here — this branch asked for 95,000 before #185 landed and took #185's
292 * larger number instead of arguing with it. The id module ships in the bundle
293 * on purpose: the page has to validate what a person typed with the same shape
294 * the server accepts, and a second copy of that regular expression is how two
295 * of them drift apart. Neither other bundle moved a byte: nothing outside
296 * `src/client/`, `src/pages/game.ts` and the new module changed, and neither of
297 * the other two games imports any of them. Measured 2026-09-20.
283298 */
284299 export const GAMES: readonly Game[] = [
285300 {
src/matchid.test.ts added +64
@@ -0,0 +1,64 @@
1 /**
2 * The id, from both ends: what is accepted, what a row is keyed by, and that
3 * a key reads back as the match it was a playing of.
4 */
5
6 import { expect, test } from "bun:test";
7 import { isMatchId, matchFrom, matchOf, playKey } from "./matchid.ts";
8 import { parseResult } from "./result.ts";
9
10 const ID = "3d9b7c1e-0000-4000-8000-5a2f6b4c8d11";
11
12 test("a match id is hex and dashes, eight to sixty-four characters, and nothing else", () => {
13 expect(isMatchId(ID)).toBe(true);
14 expect(isMatchId("deadbeef")).toBe(true);
15 expect(isMatchId("-".repeat(64))).toBe(true);
16
17 expect(isMatchId("deadbee")).toBe(false); // seven
18 expect(isMatchId("a".repeat(65))).toBe(false);
19 expect(isMatchId("gggggggg")).toBe(false); // not hex
20 expect(isMatchId("dead beef")).toBe(false);
21 expect(isMatchId("<script>x</script>")).toBe(false);
22 expect(isMatchId("../../etc/passwd")).toBe(false);
23 expect(isMatchId(null)).toBe(false);
24 expect(isMatchId(12345678)).toBe(false);
25 });
26
27 test("the server's parser accepts exactly what this door does", () => {
28 // src/result.ts asks here rather than carrying a second copy of the shape:
29 // an id the page would play is an id the leaderboard would store.
30 const result = (matchId: unknown): unknown => ({
31 matchId, name: "x", rounds: 1, scoreMine: 1, scoreTheirs: 0,
32 kills: 0, deaths: 0, headshots: 0, damage: 0, seconds: 60, finished: false,
33 });
34 for (const id of [ID, "deadbeef", playKey(ID, "00000000-0000-4000-8000-00000000abcd")])
35 expect(parseResult(result(id))).not.toBeNull();
36 for (const id of ["deadbee", "gggggggg", "", null]) expect(parseResult(result(id))).toBeNull();
37 });
38
39 test("a play key is unique per play, still an id, and reads back as its match", () => {
40 const first = playKey(ID, "00000000-0000-4000-8000-00000000000a");
41 const second = playKey(ID, "00000000-0000-4000-8000-00000000000b");
42 expect(first).not.toBe(second);
43 expect(isMatchId(first)).toBe(true);
44 expect(matchOf(first)).toBe(ID);
45 expect(matchFrom(first)).toBe(ID);
46 // A match with no nonce on it is its own match.
47 expect(matchOf(ID)).toBe(ID);
48 });
49
50 test("a key that would not fit keeps the fresh id alone", () => {
51 const long = "a".repeat(60);
52 const fresh = "00000000-0000-4000-8000-00000000000a";
53 expect(playKey(long, fresh)).toBe(fresh);
54 expect(isMatchId(playKey(long, fresh))).toBe(true);
55 });
56
57 test("what a person pastes is a match, or it is nothing", () => {
58 expect(matchFrom(ID)).toBe(ID);
59 expect(matchFrom(` ${ID}`)).toBeNull(); // the page trims; this door does not guess
60 expect(matchFrom("not-an-id!")).toBeNull();
61 expect(matchFrom(undefined)).toBeNull();
62 // A nonce with nothing in front of it is not a match to play.
63 expect(matchFrom("--abcdefabcdef")).toBeNull();
64 });
src/matchid.ts added +77
@@ -0,0 +1,77 @@
1 /**
2 * What a match id is, in one place: the shape a browser may hand back, the
3 * match a person typed, and the key one *playing* of a match is stored under.
4 *
5 * The id stopped being bookkeeping when a match became reproducible from it.
6 * The same id is the same map, the same spawns, the same bots and the same
7 * seed, so it is a rematch, a challenge and the first line of a bug report —
8 * which means a person pastes it, and a pasted string is an input. `isMatchId`
9 * is the whole of what is accepted, and `src/result.ts` accepts exactly this
10 * shape for the id on a result, because it is this one: the server's parser,
11 * the server's query, the page's field and the terminal all ask here.
12 *
13 * A match and a playing of it are not the same thing. Two people can play one
14 * id, and one person can play it twice, and each of those is its own row on
15 * the leaderboard — so the row is keyed by `<match>--<nonce>`: the match, a
16 * double dash, and twelve hex from a fresh uuid. It is still an id of the same
17 * shape, it is still unique per play, and the match is readable back out of it
18 * with `matchOf`, so the id on a row is a thing you can paste and play.
19 *
20 * It imports nothing and ships in the bundle: `src/client/main.ts` validates
21 * the field with the same door the server validates the query with.
22 */
23
24 /**
25 * The shape, and the only one. Hex and dashes, eight to sixty-four characters:
26 * a uuid (`3d9b7c1e-0000-4000-8000-5a2f6b4c8d11`), a play key made of one, or
27 * any short hex id a browser might hand out. Nothing else is a match id, and
28 * nothing that is not one ever reaches `generateLevel` or the database.
29 */
30 const SHAPE = /^[0-9a-fA-F-]{8,64}$/;
31
32 /** The longest an id may be, which is `SHAPE`'s upper bound written down. */
33 const MAX = 64;
34
35 /** What separates a match from the playing of it. Two dashes; a uuid has none. */
36 const PLAY = "--";
37
38 /** Twelve hex of a fresh uuid — its last field, which is the random one. */
39 const NONCE = 12;
40
41 /** Is this a match id at all? The shape above, and nothing else. */
42 export const isMatchId = (value: unknown): value is string =>
43 typeof value === "string" && SHAPE.test(value);
44
45 /**
46 * The match an id names: everything before the play nonce, so the key a row is
47 * stored under reads back as the match it was a playing of. An id with no
48 * nonce in it is its own match, which is the usual case.
49 */
50 export const matchOf = (id: string): string => id.split(PLAY)[0] ?? id;
51
52 /**
53 * The match a person typed or a link carried, or null if it is not one. This
54 * is the boundary: a query parameter goes through it on the server and the
55 * menu's field goes through it again in the page, and neither trusts the
56 * other. A row's key is accepted as the match it names, so pasting either an
57 * id or the key of one playing of it plays the same match.
58 */
59 export function matchFrom(value: unknown): string | null {
60 if (!isMatchId(value)) return null;
61 const match = matchOf(value);
62 return isMatchId(match) ? match : null;
63 }
64
65 /**
66 * The key this playing of `match` is stored under: the match, a double dash
67 * and the tail of a fresh uuid. Unique per play, so a rematch is a new row
68 * rather than an overwrite, and still the match it came from to read.
69 *
70 * An id long enough that there is no room for a nonce — sixty-odd characters,
71 * which nothing this app hands out is — keeps the fresh id alone instead: a
72 * row key that is unique but says nothing is better than one that is neither.
73 */
74 export function playKey(match: string, fresh: string): string {
75 const key = `${match}${PLAY}${fresh.slice(-NONCE)}`;
76 return key.length <= MAX && isMatchId(key) ? key : fresh;
77 }
src/pages/game.ts +80 −6
@@ -43,12 +43,50 @@ ${table.length === 0
4343 <ul class="recent">${feed || `<li class="empty">nothing yet</li>`}</ul>`;
4444 }
4545
46 /**
47 * What the link a person followed asked for, validated in `src/server.ts` and
48 * escaped here. `match` is a match id or null — never the raw parameter — and
49 * `badMatch` says a parameter was there and was not one, which is a sentence
50 * in the menu and a fresh match, not an error page.
51 */
52 export type Asked = {
53 match: string | null;
54 badMatch: boolean;
55 /** Which of the Map control's own values the link picked. */
56 map: "de_forge" | "generated";
57 };
58
59 /** A link with nothing on it: no id asked for, no map asked for, a fresh match. */
60 export const NOTHING_ASKED: Asked = { match: null, badMatch: false, map: "de_forge" };
61
4662 /** What the shell needs that this page cannot know: the environment it runs in. */
4763 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh"> & {
4864 /** The forge identity the request carried, verified in `src/who.ts`. */
4965 who: Who;
66 /** What `/?match=…&map=…` asked for. Absent is `NOTHING_ASKED`. */
67 asked?: Asked;
5068 };
5169
70 /**
71 * This page's own CSS: the share link, which is an anchor and has to be as big
72 * as the buttons around it, and the id itself, which one click selects whole
73 * because the only thing anyone does with it is copy it.
74 */
75 const STYLE = `
76 #thismatch code { user-select:all; word-break:break-all; }
77 a.share { display:inline-flex; align-items:center; min-height:44px; padding:0 .7rem; border-radius:.45rem;
78 background:#1b2230; border:1px solid var(--line); color:var(--ct); text-decoration:none; }
79 a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:underline; }
80 #thismatch p { margin:.35rem 0; }
81 `;
82
83 /** What the menu's hint under the id field says when the link carried junk. */
84 export const BAD_ID = "That link's match id is not one — this is a fresh match instead.";
85
86 /** The link that plays this match again: the id it names and the map it was on. */
87 export const shareHref = (asked: Asked): string =>
88 asked.match === null ? "/" : `/?match=${asked.match}&map=${asked.map}`;
89
5290 /**
5391 * Where a guest goes to become someone. The forge serves its sign-in on this
5492 * origin, so the link carries `rel="noreferrer"`: `Referrer-Policy` is decided
@@ -67,7 +105,7 @@ const SIGN_IN = "/__forge/signin?to=%2F";
67105 * Rendered that way rather than switched on by the script so the first paint is
68106 * already true and nothing moves under the pointer afterwards.
69107 */
70 const menu = (visitor: Who): string => `
108 const menu = (visitor: Who, asked: Asked): string => `
71109 <h2>Defuse or detonate</h2>
72110 <p>Five against five on <b>de_forge</b>, or on a map generated for the match. Bomb rounds,
73111 CS movement and economy, bots that push the sites, and a radar in the corner so a map
@@ -80,11 +118,21 @@ const menu = (visitor: Who): string => `
80118 <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"${
81119 visitor.role === "anon" ? "" : ` value="${escape(visitor.name)}" readonly`
82120 }></label>
83 <label>Match<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label>
121 <label>Length<select id="length"><option value="short">Short — first to 5</option><option value="long">Competitive — first to 13</option></select></label>
84122 <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label>
85 <label>Map<select id="map"><option value="de_forge">de_forge</option><option value="generated">generated — a new one every match</option></select></label>
123 <label>Map<select id="map"><option value="de_forge">de_forge</option><option value="generated"${
124 asked.map === "generated" ? " selected" : ""
125 }>generated — a new one every match</option></select></label>
86126 <label>Sensitivity<input id="sens" type="number" min="0.2" max="12" step="0.1" value="2.2"></label>
87127 </div>
128 <div class="row">
129 <label>Match id<input id="matchid" maxlength="64" placeholder="paste one to play it again"
130 autocomplete="off" spellcheck="false"${asked.match === null ? "" : ` value="${escape(asked.match)}"`}></label>
131 </div>
132 <p class="hint" id="onid">${asked.badMatch
133 ? escape(BAD_ID)
134 : `An id is the whole match: the same map, the same spawns, the same bots and the same seed.
135 Paste one here — or open its link — to play it again; empty is a new match, as ever.`}</p>
88136 <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div>
89137 <p class="hint" id="onmap">A generated map is carved from the match id: two bombsites, two spawns
90138 and cover, and the same map for both sides — it is the layout turned half a circle, so the walk
@@ -113,11 +161,35 @@ const PAUSE = `
113161 <h2>Paused</h2>
114162 <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div>
115163 <p class="hint" id="esc"><kbd>Esc</kbd> or <kbd>P</kbd> pauses, and Resume goes back.</p>
164 <p class="hint" id="pauseid"></p>
116165 `;
117166
118 export function gamePage(db: Database, { who: visitor, ...options }: PageOptions): string {
167 /**
168 * The match you are playing, under the stage rather than over it: the id, the
169 * map it is on and a link that plays it again. It sits in a section instead of
170 * in an overlay because both overlays cover the stage and only one of them is
171 * ever up — this is on the screen while the menu is, while the pause is, and
172 * while the round is running, and a mouse can select the id out of it at any
173 * of those moments. The pause panel carries the same line in words, for the
174 * full screen, where there is no page to look at.
175 *
176 * It is server-rendered with whatever the link asked for and `src/client/main.ts`
177 * fills it in on the frame a match starts; nothing here moves when it does.
178 */
179 const thisMatch = (asked: Asked): string => ` <section id="thismatch">
180 <h2>This match</h2>
181 <p>Match <code id="playing">${asked.match === null ? "none yet" : escape(asked.match)}</code>
182 on <b id="mapname">${asked.match === null ? "—" : "the map its id carves"}</b>
183 · <a class="share" id="share" href="${escape(shareHref(asked))}">Link to this match</a></p>
184 <p class="empty">Press Play and the id of the match you are playing appears here. Paste it into
185 <b>Match id</b> above, or open the link, and you get that match again — the same layout, the same
186 spawns, the same bots and the same seed. Every playing of it is its own row on the leaderboard.</p>
187 </section>`;
188
189 export function gamePage(db: Database, { who: visitor, asked = NOTHING_ASKED, ...options }: PageOptions): string {
119190 return page({
120191 ...options,
192 extraStyle: STYLE,
121193 title: "cs — a browser defusal shooter",
122194 description:
123195 "A Counter-Strike-shaped defusal shooter that runs in the browser: WebGL2, bots, bomb rounds and an economy.",
@@ -141,12 +213,14 @@ export function gamePage(db: Database, { who: visitor, ...options }: PageOptions
141213 ],
142214 ],
143215 overlays: [
144 { id: "menu", html: menu(visitor) },
216 { id: "menu", html: menu(visitor, asked) },
145217 { id: "pause", html: PAUSE, hidden: true },
146218 { id: "buy", html: "", hidden: true },
147219 ],
148220 }),
149 sectionsHtml: ` <section id="stats">${statsHtml(db)}</section>
221 sectionsHtml: `${thisMatch(asked)}
222
223 <section id="stats">${statsHtml(db)}</section>
150224
151225 <section>
152226 <h2>What this is</h2>
src/result.ts +7 −2
@@ -5,6 +5,8 @@
55 * refuse anything else outright.
66 */
77
8 import { isMatchId } from "./matchid.ts";
9
810 export type Result = {
911 matchId: string;
1012 name: string;
@@ -50,8 +52,11 @@ export function cleanName(value: unknown): string {
5052 export function parseResult(value: unknown): Result | null {
5153 if (typeof value !== "object" || value === null) return null;
5254 const body = value as Record<string, unknown>;
53 const matchId = typeof body.matchId === "string" ? body.matchId : "";
54 if (!/^[0-9a-fA-F-]{8,64}$/.test(matchId)) return null;
55 // The id is the one shape `src/matchid.ts` accepts, asked for rather than
56 // repeated: the menu's field, the server's query and this parser all agree
57 // about what an id is because there is one of them.
58 const matchId = body.matchId;
59 if (!isMatchId(matchId)) return null;
5560 const rounds = integer(body.rounds, MAX_ROUNDS);
5661 const scoreMine = integer(body.scoreMine, MAX_ROUNDS);
5762 const scoreTheirs = integer(body.scoreTheirs, MAX_ROUNDS);
src/server.test.ts +63
@@ -4,6 +4,8 @@ import { tmpdir } from "node:os";
44 import { join } from "node:path";
55 import { bundle, STATIC_DIR } from "../scripts/build.ts";
66 import { GAMES } from "./games.ts";
7 import { matchOf, playKey } from "./matchid.ts";
8 import { BAD_ID } from "./pages/game.ts";
79 import { escape } from "./pages/shell.ts";
810 import { sign } from "./who.ts";
911 import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts";
@@ -65,6 +67,67 @@ test("the page carries every id and action the client reaches for", async () =>
6567 expect(body).toContain(`id="prep">Preparing textures…</p>`);
6668 });
6769
70 test("`/?match=` preselects a match to play again, and says so in the first paint", async () => {
71 const id = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64";
72 const body = await (await fetch(`${base}/?match=${id}&map=generated`)).text();
73 // The field the client validates again is served holding it, the section
74 // under the stage names it, and the link beside it plays it again.
75 expect(body).toContain(`id="matchid" maxlength="64"`);
76 expect(body).toContain(`value="${id}"`);
77 expect(body).toContain(`<code id="playing">${id}</code>`);
78 expect(body).toContain(`href="/?match=${id}&amp;map=generated"`);
79 // The map the link asked for is the one the control is on.
80 expect(body).toContain(`<option value="generated" selected>`);
81 expect(body).not.toContain(`<option value="de_forge" selected>`);
82 expect(body).not.toContain(escape(BAD_ID));
83 });
84
85 test("a `?match=` that is not an id is a fresh match and a sentence, never an error page", async () => {
86 // Every one of these is refused by the same door `src/result.ts` refuses a
87 // result's id with, and not one of them is rendered anywhere on the page.
88 for (const junk of ["<script>alert(1)</script>", "'; drop table match--", "../../etc/passwd", "zzz", "%00", ""]) {
89 const response = await fetch(`${base}/?match=${encodeURIComponent(junk)}`);
90 expect(response.status).toBe(200);
91 const body = await response.text();
92 expect(body).toContain(escape(BAD_ID));
93 expect(body).toContain(`<code id="playing">none yet</code>`);
94 expect(body).toContain(`href="/"`);
95 // Nothing of what was asked for came back, escaped or otherwise.
96 if (junk !== "") {
97 expect(body).not.toContain(junk);
98 expect(body).not.toContain(escape(junk));
99 }
100 expect(body).not.toContain("alert(1)");
101 }
102 });
103
104 test("a page nobody linked to is the page it always was: no id, a fresh match, de_forge", async () => {
105 const body = await (await fetch(base + "/")).text();
106 expect(body).toContain(`<code id="playing">none yet</code>`);
107 expect(body).not.toContain(escape(BAD_ID));
108 // de_forge is the first option and so the one a browser picks: the markup a
109 // person who followed no link gets is the markup they always got.
110 expect(body).not.toContain("selected");
111 // The field is there and it is empty: an id is something a person supplies.
112 expect(body).toContain(`id="matchid" maxlength="64"`);
113 expect(body).not.toMatch(/id="matchid"[^>]*value=/);
114 });
115
116 test("the row key one playing of a match is stored under is a row of its own", async () => {
117 // What the page posts for a rematch: the match, a double dash and this
118 // play's nonce. Two of them are two rows, and both are the same match.
119 const id = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64";
120 const first = playKey(id, "00000000-0000-4000-8000-0000000000a1");
121 const second = playKey(id, "00000000-0000-4000-8000-0000000000a2");
122 const cookie = "cs_session=" + "a".repeat(32);
123 for (const key of [first, second])
124 expect((await post(result({ matchId: key, name: "rematcher" }), cookie)).status).toBe(200);
125 expect(matchOf(first)).toBe(id);
126 const feed = await (await fetch(base + "/")).text();
127 // Two matches on the board under that name, not one overwritten.
128 expect(feed.match(/rematcher/g)?.length).toBeGreaterThanOrEqual(2);
129 });
130
68131 test("serves the range on its own route, with its own page and its own bundle", async () => {
69132 const response = await fetch(base + "/range");
70133 expect(response.status).toBe(200);
src/server.ts +25 −2
@@ -26,7 +26,8 @@ import { openDatabase, type Player, saveMatch } from "./db.ts";
2626 import { GAMES } from "./games.ts";
2727 import { createLimiter } from "./limit.ts";
2828 import { examplePage } from "./pages/example.ts";
29 import { gamePage, statsHtml } from "./pages/game.ts";
29 import { type Asked, gamePage, statsHtml } from "./pages/game.ts";
30 import { matchFrom } from "./matchid.ts";
3031 import { errorPage, notFoundPage } from "./pages/miss.ts";
3132 import { rangePage } from "./pages/range.ts";
3233 import { cleanName, parseResult } from "./result.ts";
@@ -191,13 +192,35 @@ const scripts = findScripts();
191192 */
192193 type Render = (request: Request, script: string) => Response;
193194
195 /**
196 * What a link to the shooter asked for. `/?match=<id>` plays that match again
197 * and `?map=` picks which of the Map control's two values it is played on —
198 * together they are what the page's own "Link to this match" hands out.
199 *
200 * Both are inputs and both are settled here: the id is whatever `src/matchid.ts`
201 * accepts and null otherwise, the map is one of two known words, and nothing a
202 * caller wrote reaches the page as itself. A parameter that is not an id is not
203 * an error — the page says so in a sentence and plays a fresh match — and the
204 * page escapes what it renders anyway, because two checks are the point.
205 */
206 const asked = (request: Request): Asked => {
207 const query = new URL(request.url).searchParams;
208 const wanted = query.get("match");
209 const match = matchFrom(wanted);
210 return {
211 match,
212 badMatch: wanted !== null && match === null,
213 map: query.get("map") === "generated" ? "generated" : "de_forge",
214 };
215 };
216
194217 const RENDER: Record<string, Render> = {
195218 // The shooter: the forge's identity, this environment's database, and a
196219 // session cookie for a browser that arrives without one.
197220 app: (request, script) => {
198221 const session = readSession(request);
199222 return html(
200 gamePage(db, { env, pull, commit, script, ssh, who: who(request, identityKey) }),
223 gamePage(db, { env, pull, commit, script, ssh, who: who(request, identityKey), asked: asked(request) }),
201224 200,
202225 session === null ? { "set-cookie": sessionCookie(newSession()) } : {},
203226 );
src/terminal/screen.test.ts +24
@@ -106,6 +106,30 @@ test("the match list keeps its columns and marks the ones still being played", (
106106 expect(lines[6]).toBe(" bo 3-2 7/4 950 2026-09-16 10:00:00 (live)");
107107 });
108108
109 test("the matches tab prints the id of the match under the cursor, ready to paste", () => {
110 // A match somebody played is a match anybody can play again, and the page is
111 // not the only place that is true: the id under the cursor is printed as the
112 // string the menu's Match id field takes.
113 const rows = data({
114 matches: [
115 match("7f1c2a4e-1111-4000-8000-9b3d5e7a2c64--0000000000a1", "ace"),
116 match("3d9b7c1e-0000-4000-8000-5a2f6b4c8d11", "bo"),
117 ],
118 });
119 const lines = text(view({ tab: "matches" }), rows);
120 // The key the row is stored under is one playing of a match; what is offered
121 // is the match inside it.
122 const offer = (ls: string[]): string | undefined => ls.find((l) => l.startsWith("play this match again"));
123 expect(offer(lines)).toBe("play this match again: paste 7f1c2a4e-1111-4000-8000-9b3d5e7a2c64 into Match id");
124 // It follows the cursor, and a match with no nonce on it is its own id.
125 expect(offer(text(view({ tab: "matches", cursor: 1 }), rows)))
126 .toBe("play this match again: paste 3d9b7c1e-0000-4000-8000-5a2f6b4c8d11 into Match id");
127 // Nothing to stand on, nothing offered.
128 expect(text(view({ tab: "matches" }), data({ matches: [] })).some((l) => l.includes("paste"))).toBe(false);
129 // And it is the matches tab's line alone.
130 expect(text(view()).some((l) => l.includes("paste"))).toBe(false);
131 });
132
109133 test("a guest is named as one on both lists, so no row is read as a signed-in player's", () => {
110134 const guest = data({
111135 standings: [standing("kestrel"), standing("kestrel", { user_id: "" })],
src/terminal/screen.ts +10
@@ -16,6 +16,7 @@
1616 import type { Card } from "../client/weights.ts";
1717 import type { MatchRow, Standing } from "../db.ts";
1818 import { displayName, headshotShare, ratio } from "../db.ts";
19 import { matchOf } from "../matchid.ts";
1920
2021 export type Tab = "standings" | "matches" | "policy";
2122
@@ -123,6 +124,15 @@ function matchLines(matches: MatchRow[], cursor: number): string[] {
123124 `${m.finished === 1 ? "" : " (live)"}`;
124125 lines.push(i === cursor ? `${esc}7m▸ ${line}${esc}0m` : ` ${line}`);
125126 });
127 // The match the cursor is on, as the id that plays it again. A row is keyed
128 // by one *playing* of a match, so what is printed is the match inside that
129 // key — the same string the page's Match id field takes, which is the point
130 // of printing it: a match somebody played is a match you can play here too,
131 // by pasting this into the menu. Never the raw column: `printable` guards
132 // every line here, and an id that came from a browser is no exception.
133 const selected = matches[cursor];
134 if (selected !== undefined)
135 lines.push("", `${esc}2mplay this match again: paste ${printable(matchOf(selected.id))} into Match id${esc}0m`);
126136 return lines;
127137 }
128138
static/app.46e58255bb.js deleted −58

Diff not shown — a line 73,131 characters wide (limit 2,000). −58

View file
static/app.6bbbaf11e0.js deleted −58

Diff not shown — a line 72,599 characters wide (limit 2,000). −58

View file
static/app.bb7df12ff4.js added +58

Diff not shown — a line 73,851 characters wide (limit 2,000). +58

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs