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

forked from trevato/cs

The map you played has a name you can keep: the seed on the menu, in the URL, and a link that plays it again #213

Merged opened this

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

Merged

vesper/cs-engine:vesper/148-the-map-you-played-has-a-name-you-can-ke → main 2 commits fork

  • Merged. vesper/148-the-map-you-played-has-a-name-you-can-ke was merged into main as c5dce87.

Review

Sign in to review this change.
16 files changed +748 −108
README.md +41 −6
@@ -105,12 +105,12 @@ report.
105105 - **Paste it.** The menu has a **Match id** field. Put an id in it and Play
106106 plays that match again — the same layout, the same bots, the same seed. Leave
107107 it empty and you get a new match, exactly as before.
108 - **Link to it.** `/?match=<id>&map=<de_forge|generated>` preselects both. The
109 page's own **Link to this match**, under the stage, is that URL for whatever
110 you are playing: a plain `<a href>` a keyboard reaches and a thumb can hit,
111 not a clipboard trick. The map is in the link because the id carves a map only
112 when the match asked for a generated one; on `de_forge` the id is still the
113 whole of everything else.
108 - **Link to it.** `/?match=<id>&map=<de_forge|generated|gen_…>` preselects both.
109 The page's own **Link to this match**, under the stage, is that URL for
110 whatever you are playing: a plain `<a href>` a keyboard reaches and a thumb
111 can hit, not a clipboard trick. The map is in the link because the id carves a
112 map only when the match asked for a generated one; on `de_forge` the id is
113 still the whole of everything else.
114114 - **Read it.** The id of the match you are playing is in the *This match*
115115 section under the stage with the map's name beside it (one click selects the
116116 whole id), and in the pause panel, which is all there is to read when the
@@ -132,6 +132,41 @@ is rendered, the page escapes what it renders anyway, and
132132 reaches `generateLevel`. Anything else is a fresh match and a sentence saying
133133 so, never an error page and never a refusal to play.
134134
135 ## A map you can keep
136
137 A generated map is carved from a number, and that number has a name: `gen_14u5j6g`,
138 base36 of the seed. The name is the map — the same name is the same forty by forty
139 cells on every machine, for ever — so a layout you liked is a thing you can write
140 down and hand to somebody who has never played the round it happened in.
141
142 - **Choose it.** The menu's Map control is three things in two fields: the select
143 (`de_forge`, or `generated — a new one every match`) and a **Map name** field.
144 A name in the field is that exact map and wins over the select; empty, the
145 select decides.
146 - **Read it before you play.** The line above Play names the map Play is about to
147 give you — `de_forge`, or the name of the map that is about to be carved. On
148 `generated` the page draws the match id the moment it needs to name that map,
149 so the name on the screen is the map you get and not a guess.
150 - **Keep it.** Beside that name is **Copy this map's link**, which puts the whole
151 URL on the clipboard. It is an `<a href="/?map=gen_…">` first: a keyboard
152 reaches it, it is 44px to press, and a plain click — or a browser with no
153 clipboard, or one that refuses the write — simply opens the page on that map.
154 The name of the map you are playing, under the stage, is a link to it too.
155 - **Open it.** `/?map=gen_14u5j6g` preselects that map. `?map=` is the shooter's
156 parameter and no other page reads it.
157
158 A name is an input and it is checked twice against one definition:
159 `src/mapname.ts` says what a name is — `gen_` and base36, and the test is the
160 round trip, so `gen_0ya` and `gen_zzzzzzzz` are typos rather than maps —
161 `src/server.ts` runs the `?map=` query through it before the page is rendered,
162 the page escapes what it renders anyway, and `src/client/main.ts` runs the field
163 through the same door again before anything reaches `generateLevel`. Anything
164 else is `de_forge` and a sentence saying so, never an error page. The seed itself
165 is pinned: `src/client/generate.test.ts` folds `gen_14u5j6g`'s cells into a
166 digest through the engine's `digest` door and holds it against a committed
167 constant, so a change to the generator that would move somebody's map fails the
168 test rather than surprising them.
169
135170 ## Who a score belongs to
136171
137172 The numbers in a result are the browser's word; the name on it is not. Whoever
scripts/build.test.ts +1 −1
@@ -38,7 +38,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri
3838
3939 test("the entry table is what the app ships today, each with the size it may not pass", () => {
4040 expect(ENTRIES).toEqual([
41 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 96_000, gzip: 43_500 } },
41 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 100_000, gzip: 45_000 } },
4242 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } },
4343 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 10_800, gzip: 5_100 } },
4444 ]);
src/client/generate.test.ts +55 −2
@@ -14,12 +14,12 @@
1414 import { expect, test } from "bun:test";
1515 import { botThink } from "./bots.ts";
1616 import { MAT, type Cell } from "./cells.ts";
17 import { flaws, generateGrid, generateLevel, nameOf, page, SIZE } from "./generate.ts";
17 import { flaws, generateGrid, generateLevel, nameOf, page, seedOf, SIZE } from "./generate.ts";
1818 import { colOf, findPath, openCells, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts";
1919 import { SHORT } from "./match.ts";
2020 import { digest, withoutEntropy } from "./replay.ts";
2121 import { applyInput, createWorld, type Player } from "./world.ts";
22 import { region, type Vec3 } from "../engine/index.ts";
22 import { compare, fold, region, type Vec3 } from "../engine/index.ts";
2323
2424 /** How long a walk is, in Source units: the sum of the steps A* handed back. */
2525 const walkLength = (path: Vec3[], from: Vec3): number => {
@@ -33,6 +33,9 @@ const walkLength = (path: Vec3[], from: Vec3): number => {
3333
3434 const letters = (level: Level): Cell[] => level.grid.cells as Cell[];
3535
36 /** A level's own cells as a page of letters: `page` over the grid it carries. */
37 const rowsOf = (level: Level): string[] => page({ ...level.grid, cells: letters(level) });
38
3639 test("a seed is a map, and the same seed is the same map for ever", () => {
3740 expect(page(generateGrid(7))).toEqual(page(generateGrid(7)));
3841 expect(page(generateGrid(7))).not.toEqual(page(generateGrid(8)));
@@ -45,6 +48,56 @@ test("a seed is a map, and the same seed is the same map for ever", () => {
4548 expect(a.sites).toEqual(b.sites);
4649 });
4750
51 /**
52 * The map `gen_14u5j6g` is, folded cell by cell into a hash through the
53 * engine's `digest` door — the same instrument the rounds are pinned with,
54 * given rows of letters instead of ticks of a world.
55 *
56 * This is the claim the name makes: a person writes `gen_14u5j6g` down, hands
57 * it to somebody on another machine, and that machine carves the same forty by
58 * forty cells. Nothing here is timed, seeded by a clock or read off a file, so
59 * a digest that moves is the generator's shape changing — which is allowed, and
60 * is a line in the pull that says which maps changed, with these constants
61 * pasted in from the failure.
62 */
63 const NAMED = "gen_14u5j6g";
64 const NAMED_MAP = "d548dcae07259c836d93ad7d8df3a195281c4da8ee1ef53f9000f5d3e115def9";
65 const NAMED_BANDS: readonly string[] = [
66 "c674e6e4c9f2", "45bb896cd286", "e16819fdf569", "6b799f4e0392",
67 ];
68
69 test("a map you can name is the same map on every machine, cell for cell", () => {
70 const seed = seedOf(NAMED);
71 if (seed === null) throw new Error(`${NAMED} is not a map name, and it is the one this test is about`);
72 const level = generateLevel(seed);
73 // The name is the map's own name, not a label put beside it.
74 expect(level.name).toBe(NAMED);
75 expect(nameOf(seed)).toBe(NAMED);
76
77 const rows = rowsOf(level);
78 let at = 0;
79 const run = fold({
80 ticks: rows.length,
81 step: (row) => void (at = row),
82 line: () => rows[at] as string,
83 every: 10,
84 });
85 const why = compare(
86 run,
87 { label: `map ${NAMED}`, constant: "NAMED_MAP", digest: NAMED_MAP, checkpoints: NAMED_BANDS },
88 {
89 file: "src/client/generate.test.ts",
90 list: "NAMED_BANDS",
91 hashes: "every cell of the level's grid, one row of letters a line",
92 every: 10,
93 unit: "band",
94 },
95 );
96 if (why !== null) throw new Error(why);
97 // Asking twice is asking once: the whole point of a seed.
98 expect(rowsOf(generateLevel(seed))).toEqual(rows);
99 });
100
48101 test("a map is written on a page of letters, one string a row", () => {
49102 const grid = generateGrid(3);
50103 const rows = page(grid);
src/client/generate.ts +11 −3
@@ -28,9 +28,20 @@
2828 */
2929
3030 import { region, rng, type Grid, type Vec3 } from "../engine/index.ts";
31 import { nameOf, seedOf } from "../mapname.ts";
3132 import { CELL, levelOf, type Cell } from "./cells.ts";
3233 import { colOf, findPath, rowOf, type Level } from "./level.ts";
3334
35 /**
36 * What a map is called and what a name means, both from `src/mapname.ts` and
37 * re-exported here so the two live beside each other for a reader: `nameOf` is
38 * the name of a seed's map and `seedOf` is the seed of a name, which is how a
39 * map you played is a map you can ask for again. The grammar is there rather
40 * than here because the server validates `?map=` with it and may not import a
41 * generator to do it.
42 */
43 export { nameOf, seedOf };
44
3445 /** Cells on a side. 40 is 5,120 Source units: a little tighter than de_forge. */
3546 export const SIZE = 40;
3647
@@ -59,9 +70,6 @@ export function seedFrom(text: string): number {
5970 return hash >>> 0;
6071 }
6172
62 /** The name a seed's map answers to: `gen_1z3f`, short enough for a menu. */
63 export const nameOf = (seed: number): string => `gen_${(seed >>> 0).toString(36)}`;
64
6573 /** The page of letters a grid is, one string a row: what a test or a terminal reads. */
6674 export const page = (grid: Grid<Cell>): string[] =>
6775 Array.from({ length: grid.height }, (_, r) => grid.cells.slice(r * grid.width, (r + 1) * grid.width).join(""));
src/client/main.test.ts +122 −3
@@ -24,9 +24,9 @@
2424 */
2525
2626 import { expect, test } from "bun:test";
27 import { nameOf, seedFrom } from "./generate.ts";
27 import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts";
2828 import type { Level } from "./level.ts";
29 import { boot, gameMeshes, NOT_AN_ID, type Frame } from "./main.ts";
29 import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, 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";
@@ -37,7 +37,7 @@ import { digest, snapshot, withoutEntropy } from "./replay.ts";
3737 import { WEAPONS } from "./weapons.ts";
3838 import { matchOf } from "../matchid.ts";
3939 import { parseResult } from "../result.ts";
40 import { fakeStage, type FakeStage } from "./stage.test-helper.ts";
40 import { fakeStage, ORIGIN, type FakeStage } from "./stage.test-helper.ts";
4141 import { createWorld, type World } from "./world.ts";
4242
4343 /** Every frame booted here, so a stage can be asked what world it is running. */
@@ -791,6 +791,125 @@ test("the map field chooses the map, and a generated one costs one mesh", () =>
791791 expect(stage.replaced.map((r) => r.name)).toEqual(["level", "level"]);
792792 });
793793
794 // --- a map you can keep ---------------------------------------------------
795
796 /** One map's name, base36 of a seed, the way the page hands them out. */
797 const MAP = "gen_14u5j6g";
798
799 test("the map Play will give you is named before Play, and named again as a link", () => {
800 const stage = painted(start(fakeStage()));
801 // Nothing touched: de_forge, named and linked, and no id drawn to name it.
802 expect(stage.by("willplay").textContent).toBe("de_forge");
803 expect(stage.by("copymap").attributes.href).toBe("/?map=de_forge");
804 expect(stage.ids.length).toBe(0);
805
806 // A new map every match: the id that map is carved from is drawn now, so the
807 // name is on the screen *before* anything is played — and the link beside it
808 // is that map by name, not another new one.
809 stage.by("map").value = "generated";
810 stage.by("map").fire("change");
811 expect(stage.ids.length).toBe(1);
812 const name = nameOf(seedFrom(stage.ids[0] as string));
813 expect(stage.by("willplay").textContent).toBe(name);
814 expect(stage.by("copymap").attributes.href).toBe(`/?map=${name}`);
815
816 // And it is the map that is played: the promise the menu made is kept, out of
817 // the id it had already drawn rather than a second one.
818 stage.by("play").fire("click");
819 expect(worldOf(stage)?.level.name).toBe(name);
820 expect(stage.ids.length).toBe(1);
821 expect(stage.by("mapname").textContent).toBe(name);
822 expect(stage.by("mapname").attributes.href).toBe(`/?map=${name}`);
823
824 // Back at the menu, the line is about the next match again: another map, its
825 // own name, its own link, and the one just played still named under the stage.
826 stage.by("quit").fire("click");
827 expect(stage.ids.length).toBe(2);
828 const next = nameOf(seedFrom(stage.ids[1] as string));
829 expect(next).not.toBe(name);
830 expect(stage.by("willplay").textContent).toBe(next);
831 expect(stage.by("mapname").textContent).toBe(name);
832 });
833
834 test("a map by name is that map, and a name that is not one is de_forge and says so", () => {
835 const stage = painted(start(fakeStage()));
836 stage.by("mapseed").value = MAP;
837 stage.by("mapseed").fire("input");
838 expect(stage.by("willplay").textContent).toBe(MAP);
839 expect(stage.by("copymap").attributes.href).toBe(`/?map=${MAP}`);
840 // A name needs no id: the map is the seed the name means and nothing else.
841 expect(stage.ids.length).toBe(0);
842
843 stage.by("play").fire("click");
844 const level = worldOf(stage)?.level;
845 expect(level?.name).toBe(MAP);
846 // The same cells `generateLevel` carves from that seed — the field is a seed,
847 // not a label put on whatever the match would have made anyway.
848 expect(level?.grid.cells).toEqual(generateLevel(seedOf(MAP) as number).grid.cells);
849 // The match is still the match: a fresh id seeded the round it was played in.
850 expect(stage.ids.length).toBe(1);
851 expect(worldOf(stage)?.level.name).toBe(MAP);
852
853 // The name wins over the select, because a person who typed one meant it.
854 stage.by("quit").fire("click");
855 stage.by("map").value = "generated";
856 stage.by("map").fire("change");
857 expect(stage.by("willplay").textContent).toBe(MAP);
858 stage.by("play").fire("click");
859 expect(worldOf(stage)?.level.name).toBe(MAP);
860
861 // And junk is de_forge with a line saying so: `gen_0ya` is `gen_ya` written
862 // badly, which is a typo and not a map. Never an error, never a refusal.
863 stage.by("quit").fire("click");
864 stage.by("mapseed").value = "gen_0ya";
865 stage.by("mapseed").fire("input");
866 expect(stage.by("onmap").textContent).toBe(NOT_A_MAP);
867 expect(stage.by("willplay").textContent).toBe("de_forge");
868 stage.by("play").fire("click");
869 expect(worldOf(stage)?.level.name).toBe("de_forge");
870 expect(stage.by("onmap").textContent).toBe(NOT_A_MAP);
871 expect(stage.by("menu").hidden).toBe(true);
872 });
873
874 test("copy this map is a link first and a clipboard second", async () => {
875 const LABEL = "Copy this map's link";
876 const stage = fakeStage();
877 // The label the page renders, so the control can be put back to it.
878 stage.by("copymap").textContent = LABEL;
879 painted(start(stage));
880 stage.by("mapseed").value = MAP;
881 stage.by("mapseed").fire("input");
882
883 // A click with a clipboard behind it copies the whole URL — origin and all,
884 // because a link is pasted into a message and not into this page — and the
885 // navigation is the one thing it takes back.
886 let prevented = 0;
887 const click = (): void => stage.by("copymap").fire("click", { preventDefault: () => void (prevented += 1) });
888 click();
889 expect(prevented).toBe(1);
890 expect(stage.copied).toEqual([`${ORIGIN}/?map=${MAP}`]);
891 await settled();
892 expect(stage.by("copymap").textContent).toBe(COPIED);
893
894 // A browser that refuses the write says so rather than pretending.
895 stage.refuseCopy = true;
896 click();
897 await settled();
898 expect(stage.by("copymap").textContent).toBe(NOT_COPIED);
899
900 // Choosing another map puts the label back: it is a control, not a log.
901 stage.refuseCopy = false;
902 stage.by("map").fire("change");
903 expect(stage.by("copymap").textContent).toBe(LABEL);
904
905 // And a browser with no clipboard at all leaves the click alone: nothing is
906 // prevented, so the anchor navigates, which is this page on that map.
907 stage.host.copy = undefined;
908 click();
909 expect(prevented).toBe(2);
910 expect(stage.copied.length).toBe(2);
911 });
912
794913 /**
795914 * A match played from one id: the page hands that id out instead of a fresh
796915 * one, Play is pressed, and `frames` frames are stepped. The id is the only
src/client/main.ts +143 −7
@@ -12,6 +12,14 @@
1212 * map and the round are both drawn from. What is stored is a key for this
1313 * playing of it, so a rematch is a row of its own.
1414 *
15 * A map is its name, the same way: `gen_14u5j6g` is one layout for ever, and
16 * the Map name field takes one — typed, or put there by `/?map=gen_14u5j6g` —
17 * through `src/mapname.ts`, which is the door the server validated that query
18 * with. The menu names the map Play is about to give you *before* Play, which
19 * is why the id for a match that wants a new map is drawn when the menu asks
20 * rather than when the button is pressed, and the link beside that name is the
21 * map by name: press it and you get that layout, not another new one.
22 *
1523 * A round that did not land is not lost quietly. The result of every round is
1624 * posted as it is scored, and a post that threw or came back anything but 200
1725 * is kept as the one pending round and tried again — on the next round's post
@@ -47,6 +55,7 @@ import {
4755 type Button, type Camera, type KeyboardLock, type Listens, type Mesh,
4856 } from "../engine/index.ts";
4957 import { generateLevel, seedFrom } from "./generate.ts";
58 import { mapFrom, mapLink, nameOf, seedOf, type MapChoice } from "../mapname.ts";
5059 import { matchFrom, playKey } from "../matchid.ts";
5160 import { DE_FORGE } from "./map.ts";
5261 import type { Level } from "./level.ts";
@@ -69,6 +78,13 @@ const TICK = 1 / 64;
6978 */
7079 export const NOT_AN_ID = "That is not a match id — playing a fresh match instead.";
7180
81 /** And what it says when the Map name field holds something that is not one. */
82 export const NOT_A_MAP = "That is not a map name — playing de_forge instead.";
83
84 /** What the copy control says once the URL is on the clipboard, and if it is not. */
85 export const COPIED = "Copied";
86 export const NOT_COPIED = "Copy failed — the link still works";
87
7288 // --- the host: what a page has to be for this frame to run on it -----------
7389
7490 /** One element on the page, as far as this file is concerned. */
@@ -177,6 +193,17 @@ export type Host = {
177193 /** Null when this browser has no WebGL2: the menu says so and nothing starts. */
178194 createRenderer(canvas: Stage): BuildRenderer | null;
179195 createAudio(): Audio;
196 /**
197 * Where this page is, so a link to a map is a whole URL somebody can paste
198 * into a message. `location.origin`; no path, no query, nothing of the id.
199 */
200 origin: string;
201 /**
202 * `navigator.clipboard.writeText`, when this browser has one and the page is
203 * allowed it. Absent is a browser where the copy control is only a link, and
204 * a link is what it already was: the click is left to the browser.
205 */
206 copy?(text: string): Promise<void>;
180207 };
181208
182209 /**
@@ -225,10 +252,20 @@ export function boot(host: Host): Frame {
225252 // the field stops being junk rather than staying there as a lie.
226253 const aboutId = onId.textContent;
227254 const playingId = by("playing");
228 const mapName = by("mapname");
255 const mapName = by<Toggle>("mapname");
229256 const share = by<Toggle>("share");
230257 const pauseId = by("pauseid");
231258 const onMap = by("onmap");
259 // What that line said before anything was typed, kept for the same reason
260 // `aboutId` is: a warning about a map name has to be takeable back off.
261 const aboutMap = onMap.textContent;
262 // The Map control's other half: one exact map, by name. It wins over the
263 // select, because a person who typed a name meant that map.
264 const seedField = by<Field>("mapseed");
265 // The name of the map Play is about to give you, and the link that keeps it.
266 const willPlay = by("willplay");
267 const copyMap = by<Toggle>("copymap");
268 const copyLabel = copyMap.textContent;
232269 const prep = by("prep");
233270 const play = by<Panel & { disabled: boolean }>("play");
234271 const resumeButton = by("resume");
@@ -326,6 +363,16 @@ export function boot(host: Host): Frame {
326363 // string until somebody plays a match twice: see `start` below.
327364 let matchId = "";
328365 let playId = "";
366 /**
367 * The id the next match will use if nobody pastes one, drawn the moment
368 * something needs to *name* the map that id would carve — the menu asking
369 * what Play is about to give you, or Play itself — and never before. A page
370 * nobody has touched hands out no ids, and a map the menu named is the map
371 * that is played: the name before Play is not a guess.
372 */
373 let drawn: string | null = null;
374 /** The URL the copy control is holding, which is the one it puts on the clipboard. */
375 let keeping = mapLink("de_forge");
329376 // The map the renderer is holding the geometry of. It starts as the one the
330377 // meshes were built from, and a match on another map swaps that one mesh.
331378 let shown: Level = DE_FORGE;
@@ -528,6 +575,75 @@ export function boot(host: Host): Frame {
528575 send(win.performance.now());
529576 }
530577
578 // --- which map, and what it is called -------------------------------------
579
580 /**
581 * What the Map control is asking for, through the same door `src/server.ts`
582 * validated `?map=` with. Two fields, one value: a name in the Map name field
583 * is one exact map and wins over the select, because a person who typed a name
584 * meant that map; an empty field leaves the select's two words; and a name
585 * that is not one is de_forge, like an unknown anything here.
586 */
587 const chosenMap = (): MapChoice => {
588 const typed = seedField.value.trim();
589 if (typed !== "") return mapFrom(typed);
590 return mapField.value === "generated" ? "generated" : "de_forge";
591 };
592
593 /** Whether the Map name field holds something that is not a map name. */
594 const badName = (): boolean => {
595 const typed = seedField.value.trim();
596 return typed !== "" && seedOf(typed) === null;
597 };
598
599 /** The match the next Play will be, which is what "generated" draws its map from. */
600 const nextMatch = (): string => matchFrom(idField.value.trim()) ?? (drawn ??= host.randomUUID());
601
602 /** The level a choice names. `generated` is this match's own id and nothing else. */
603 const levelOfChoice = (choice: MapChoice, seed: number): Level =>
604 choice === "de_forge" ? DE_FORGE : generateLevel(choice === "generated" ? seed : seedOf(choice) ?? seed);
605
606 /**
607 * The map Play is about to give you, named and linked, before Play — which is
608 * the whole of #148: a seeded map whose seed you cannot read is a slot
609 * machine. For `generated` the name is the name of the map this match's id
610 * carves, so the id is drawn now rather than at Play and the link is that
611 * map by name — a link that plays *this* layout, not another new one.
612 *
613 * Called whenever either field changes and whenever the menu comes back, and
614 * never while a match runs: the section under the stage says what is being
615 * played, and this line is about what pressing Play would do.
616 */
617 function nameNextMap(): void {
618 const choice = chosenMap();
619 const name = choice === "generated" ? nameOf(seedFrom(nextMatch())) : choice;
620 keeping = mapLink(name);
621 willPlay.textContent = name;
622 copyMap.setAttribute("href", keeping);
623 copyMap.textContent = copyLabel;
624 onMap.textContent = badName() ? NOT_A_MAP : aboutMap;
625 }
626
627 for (const field of [mapField, seedField, idField])
628 for (const event of ["change", "input"]) field.addEventListener(event, nameNextMap);
629
630 /**
631 * The copy control, which is an anchor first and a clipboard second: a browser
632 * with no clipboard — or one that refuses the write — follows the link, which
633 * opens the page on that map and is the thing the URL says it is. A keyboard
634 * reaches it and a thumb can hit it because it is a link and not a script.
635 */
636 copyMap.addEventListener<{ preventDefault(): void }>("click", (event) => {
637 if (host.copy === undefined) return;
638 event.preventDefault();
639 void host.copy(host.origin + keeping).then(
640 () => void (copyMap.textContent = COPIED),
641 () => void (copyMap.textContent = NOT_COPIED),
642 );
643 });
644
645 nameNextMap();
646
531647 function start(): void {
532648 // The button is disabled until the atlas is painted; a click that arrives
533649 // anyway — a keyboard, a script, a browser being generous — is not a match.
@@ -553,7 +669,11 @@ export function boot(host: Host): Frame {
553669 const typed = idField.value.trim();
554670 const wanted = matchFrom(typed);
555671 onId.textContent = typed !== "" && wanted === null ? NOT_AN_ID : aboutId;
556 const fresh = host.randomUUID();
672 // The id the menu already named a map for, if it drew one, so the map that
673 // was promised is the map that is played. A fresh one otherwise, and a
674 // fresh one for the nonce on the row key when an id was pasted.
675 const fresh = drawn ?? host.randomUUID();
676 drawn = null;
557677 matchId = wanted ?? fresh;
558678 playId = wanted === null ? fresh : playKey(wanted, fresh);
559679 /**
@@ -566,14 +686,17 @@ export function boot(host: Host): Frame {
566686 * different things.
567687 */
568688 const seed = seedFrom(matchId);
569 // A map a person chose: de_forge, or one carved out of this match's id. An
570 // unknown value in the field is de_forge, like an unknown anything here.
571 const level = mapField.value === "generated" ? generateLevel(seed) : DE_FORGE;
689 // A map a person chose: de_forge, one carved out of this match's id, or one
690 // they named — which is the same carving from the seed that name means, so
691 // it is that layout on every machine for ever. An unknown value in either
692 // field is de_forge, like an unknown anything here.
693 const choice = chosenMap();
694 const level = levelOfChoice(choice, seed);
572695 if (level !== shown) {
573696 renderer.replace("level", meshFor("level", level));
574697 shown = level;
575698 }
576 onMap.textContent = `Playing ${level.name}.`;
699 onMap.textContent = badName() ? NOT_A_MAP : `Playing ${level.name}.`;
577700 // What is being played, where a person can copy it: the id and the map in
578701 // the section under the stage, the link that plays them again beside them,
579702 // and the same sentence in the pause panel for a full screen. `?map=` is
@@ -581,8 +704,11 @@ export function boot(host: Host): Frame {
581704 // match asked for one.
582705 playingId.textContent = matchId;
583706 mapName.textContent = level.name;
707 // The played map's own name is a link to that map and nothing else of the
708 // match: a layout you liked, handed to somebody who never played the round.
709 mapName.setAttribute("href", mapLink(mapFrom(level.name)));
584710 pauseId.textContent = `Match ${matchId} on ${level.name}.`;
585 share.setAttribute("href", `/?match=${matchId}&map=${level === DE_FORGE ? "de_forge" : "generated"}`);
711 share.setAttribute("href", `/?match=${matchId}&map=${choice}`);
586712 world = createWorld(level, rules, name, team, 5, seed);
587713 posted = -1;
588714 loop.reset();
@@ -611,6 +737,10 @@ export function boot(host: Host): Frame {
611737 show(pause, false);
612738 show(menu, true);
613739 show(touchPanel, false);
740 // The menu is back, so the line before Play is about the next match again —
741 // which on `generated` is a map nobody has drawn yet. The one just played
742 // is still named under the stage, and its name is still a link.
743 nameNextMap();
614744 });
615745 const saved = host.storage.getItem("cs.name");
616746 if (saved !== null && nameField.value === "") nameField.value = saved;
@@ -685,6 +815,7 @@ export function boot(host: Host): Frame {
685815 running = false;
686816 doc.exitPointerLock();
687817 show(menu, true);
818 nameNextMap();
688819 play.focus();
689820 }
690821
@@ -751,6 +882,11 @@ const browser = (): Host => ({
751882 storage: localStorage,
752883 fetch: (url, init) => fetch(url, init),
753884 randomUUID: () => crypto.randomUUID(),
885 origin: location.origin,
886 // The clipboard, if this browser has one. Read off `navigator` rather than
887 // asked for: a browser without it has no such member, and the copy control
888 // stays the plain link it is rendered as.
889 copy: navigator.clipboard === undefined ? undefined : (text) => navigator.clipboard.writeText(text),
754890 createRenderer: (canvas) => {
755891 const surface = canvas as unknown as HTMLCanvasElement;
756892 if (surface.getContext("webgl2") === null) return null;
src/client/stage.test-helper.ts +25 −3
@@ -94,6 +94,9 @@ export function glRecorder(): GlRecorder {
9494 /** One of this page's canvases: it always has a 2D recorder, and its GL one is this game's. */
9595 export type StageCanvas = FakeCanvas & { paint: FakePaint; gl: GlRecorder | null };
9696
97 /** Where this stub page is, for the whole URL a copied link has to be. */
98 export const ORIGIN = "https://cs.example";
99
97100 /** One POST the frame made, as it was made. */
98101 export type Post = { url: string; method: string; headers: Record<string, string>; body: string };
99102
@@ -139,6 +142,10 @@ export type FakeStage = {
139142 store: Map<string, string>;
140143 /** The match ids handed out, newest last. */
141144 ids: string[];
145 /** Every text the page put on the clipboard, in order. */
146 copied: string[];
147 /** Set before a click to have the clipboard refuse the write, as a browser may. */
148 refuseCopy: boolean;
142149 /**
143150 * Install this page as the globals the shipped entry reads for itself —
144151 * `document`, `window`, `localStorage` and `fetch` — and hand back the undo
@@ -155,7 +162,7 @@ export type FakeStage = {
155162 * contract between the page and this file, so `src/server.test.ts` checks the
156163 * served page against this same list rather than a second copy of it.
157164 */
158 export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "matchid", "onid", "onmap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"];
165 export const PAGE_IDS = ["stage", "scene", "hud", "menu", "pause", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "willplay", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "playing", "mapname", "share", "fps", "full"];
159166
160167 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
161168 const HIDDEN = ["pause", "buy", "touch", "full"];
@@ -168,8 +175,13 @@ export const PAGE_ACTIONS = ["duck", "jump", "use", "reload", "buy", "fire"];
168175 * `install()` puts in front of the shipped entry. Nothing else is touched:
169176 * `crypto.randomUUID` is real, and no `AudioContext` is installed, which is a
170177 * browser that makes no sound rather than one that throws.
178 *
179 * `location` is here because a link to a map is a whole URL and the entry asks
180 * where it is; `navigator` is not, because the runtime has one already and it
181 * has neither a keyboard lock nor a clipboard — which is a browser without
182 * either, and both of those are a path the game has to work on.
171183 */
172 const GLOBALS = ["document", "window", "localStorage", "fetch"];
184 const GLOBALS = ["document", "window", "localStorage", "fetch", "location"];
173185
174186 /**
175187 * The whole page the shooter's entry expects, plus the host around it. Pass
@@ -205,6 +217,7 @@ export function fakeStage(
205217 const sounds: string[] = [];
206218 const store = new Map<string, string>();
207219 const ids: string[] = [];
220 const copied: string[] = [];
208221 let uuid = 0;
209222
210223 const audio: Audio = {
@@ -256,6 +269,13 @@ export function fakeStage(
256269 return renderer;
257270 },
258271 createAudio: () => audio,
272 origin: ORIGIN,
273 // A clipboard that writes down what it was handed, and refuses once a
274 // test says so: both halves of the copy control are behaviour.
275 copy: (text) => {
276 copied.push(text);
277 return stub.refuseCopy ? Promise.reject(new Error("denied")) : Promise.resolve();
278 },
259279 },
260280 document: page.document,
261281 window: page.window,
@@ -264,7 +284,8 @@ export function fakeStage(
264284 hud,
265285 buttons: page.buttons,
266286 frames: page.frames,
267 posts, draws, replaced, atlases, sounds, store, ids,
287 posts, draws, replaced, atlases, sounds, store, ids, copied,
288 refuseCopy: false,
268289 by: page.by,
269290 step: page.step,
270291 lock(on) {
@@ -291,6 +312,7 @@ export function fakeStage(
291312 globals.document = page.document;
292313 globals.window = page.window;
293314 globals.localStorage = stub.host.storage;
315 globals.location = { origin: ORIGIN };
294316 globals.fetch = (url: string, init: Parameters<Host["fetch"]>[1]) => stub.host.fetch(url, init);
295317 return () => {
296318 for (const { key, there, value } of was)
src/games.ts +16 −1
@@ -315,6 +315,21 @@ export type Game = {
315315 * thinnest headroom on this page, and the next thing the shooter gains will want
316316 * it raised. Neither other bundle moved a byte: nothing outside `src/client/`
317317 * changed. Measured 2026-09-21.
318 * #148 gave a generated map a name you can keep — `src/mapname.ts`, the one door
319 * that says what a name is and what seed it means, plus the field the page
320 * validates with it, the line that names the map before Play and the link that
321 * copies it — and the app grew 1,358 raw / 531 gzipped bytes for it: 95,230 →
322 * 96,588 raw and 43,006 → 43,537 gzipped. That is 588 bytes past the raw half of
323 * the 96,000 / 43,500 #185 set and 37 past the gzipped half, which is the raise
324 * #135 said the next feature would want: the ceiling is 100,000 / 45,000 here,
325 * today's size plus about three per cent of each, on the same reasoning #164 and
326 * #185 raised the last two. The name module ships in the bundle for the reason
327 * `src/matchid.ts` does — the page validates what a person typed with the same
328 * grammar the server accepts a query with, and a second copy of that grammar is
329 * how two of them drift apart — and most of the rest is sentences a minifier
330 * cannot shorten. Neither other bundle moved a byte: `?map=` is the shooter's
331 * parameter and the other two games neither read it nor import any of this.
332 * Measured 2026-09-21.
318333 */
319334 export const GAMES: readonly Game[] = [
320335 {
@@ -322,7 +337,7 @@ export const GAMES: readonly Game[] = [
322337 href: "/",
323338 label: "cs",
324339 entry: "src/client/main.ts",
325 ceiling: { raw: 96_000, gzip: 43_500 },
340 ceiling: { raw: 100_000, gzip: 45_000 },
326341 blurb:
327342 "a defusal shooter in the browser, with bots that learned to aim, bomb rounds, an economy, maps carved from a seed and the leaderboard this server keeps.",
328343 },
src/mapname.test.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * The one door a map name goes through, held to the round trip it promises: one
3 * canonical name per map, one map per name, and everything else refused. It is
4 * the same shape of test `matchid.test.ts` is, for the same reason — this is
5 * what the server validates a query with and what the page validates a field
6 * with, and a hole here is a hole in both.
7 */
8
9 import { expect, test } from "bun:test";
10 import { mapFrom, mapLink, nameOf, seedOf } from "./mapname.ts";
11
12 test("a name is a seed and a seed is a name, both ways round", () => {
13 expect(nameOf(0)).toBe("gen_0");
14 expect(nameOf(1234)).toBe("gen_ya");
15 expect(nameOf(0xffffffff)).toBe("gen_1z141z3");
16 for (const seed of [0, 1, 7, 1234, 0x0fffffff, 0xffffffff, 2166136261]) {
17 expect(seedOf(nameOf(seed))).toBe(seed);
18 // And the seed a name means is the seed that names it: no second name for
19 // one map, which is what makes the name worth writing down.
20 expect(nameOf(seedOf(nameOf(seed)) as number)).toBe(nameOf(seed));
21 }
22 // A seed is thirty-two bits however it arrives, so the name is the same name.
23 expect(nameOf(-1)).toBe(nameOf(0xffffffff));
24 });
25
26 test("what is not a name is not a name: a typo, a shout, a padded number, a script", () => {
27 for (const junk of [
28 "", "gen_", "gen", "de_forge", "generated", "gen_14u5j6g!", "gen 14", "GEN_ya", "gen_YA",
29 // `parseInt` would take every one of these; the round trip does not.
30 "gen_0ya", "gen_ya ", " gen_ya", "gen_-1", "gen_1.5", "gen_zzzzzzzz", "gen_1z141z4",
31 "gen_<script>", "../../etc/passwd", "gen_%00", "gen_ya#frag",
32 null, undefined, 7, {}, ["gen_ya"],
33 ]) expect(seedOf(junk)).toBeNull();
34 // `gen_1z141z3` is the last name there is; one past it is nobody's map.
35 expect(seedOf("gen_1z141z3")).toBe(0xffffffff);
36 });
37
38 test("the Map control's value is one of three things, and anything else is de_forge", () => {
39 expect(mapFrom("generated")).toBe("generated");
40 expect(mapFrom("de_forge")).toBe("de_forge");
41 expect(mapFrom("gen_ya")).toBe("gen_ya");
42 for (const junk of [null, undefined, "", "de_moon", "GENERATED", "gen_0ya", "<script>", 7])
43 expect(mapFrom(junk)).toBe("de_forge");
44 // What comes back out is this module's own string, never the caller's bytes.
45 expect(mapLink(mapFrom("gen_ya"))).toBe("/?map=gen_ya");
46 expect(mapLink(mapFrom("generated"))).toBe("/?map=generated");
47 });
src/mapname.ts added +69
@@ -0,0 +1,69 @@
1 /**
2 * What a map is called, in one place: the name a generated map answers to, the
3 * seed that name means, and the three things the Map control can be on.
4 *
5 * A generated map was unreachable as long as its seed was. `generateLevel`
6 * carves a layout out of a number and `nameOf` has always given that number a
7 * short name — `gen_14u5j6g`, base36 — but a name you cannot type back in is a
8 * receipt for a map that no longer exists. `seedOf` is the way back, and it is
9 * the boundary: a name is something a person types into a field and something a
10 * link carries in `?map=`, so the server validates it here and the page
11 * validates it here again, and neither trusts the other.
12 *
13 * The grammar is exactly `gen_` and base36, and the test it is held to is not
14 * the regular expression but the round trip: a name is a name when `nameOf` of
15 * the seed it parses to is that name again. So `gen_0014` is not one (it is
16 * `gen_14` written badly), `gen_ZZZ` is not one (upper case is not what a seed
17 * prints as), and `gen_zzzzzzzz` is not one (no thirty-two-bit seed reaches it).
18 * One canonical name per map, and one map per name, which is what makes the
19 * name worth keeping.
20 *
21 * It imports nothing and ships in the bundle, for the same reason
22 * `src/matchid.ts` does: a second copy of this grammar in the page is how the
23 * two drift apart.
24 */
25
26 /**
27 * The name a seed's map answers to: `gen_1z3f`, short enough for a menu. The
28 * return type is the name's own shape, so `MapChoice` below needs no cast.
29 */
30 export const nameOf = (seed: number): `gen_${string}` => `gen_${(seed >>> 0).toString(36)}`;
31
32 /**
33 * `gen_` and base36. Seven digits is what `0xffffffff` prints as (`1z141z3`),
34 * so nothing longer can be a thirty-two-bit seed and nothing longer is parsed.
35 */
36 const SHAPE = /^gen_[0-9a-z]{1,7}$/;
37
38 /**
39 * The seed a map name means, or null when it is not one. The round trip is the
40 * check: a name that parses to a seed which prints as some other name is not a
41 * name, it is a typo, and a typo is not a map.
42 */
43 export function seedOf(value: unknown): number | null {
44 if (typeof value !== "string" || !SHAPE.test(value)) return null;
45 const seed = Number.parseInt(value.slice(4), 36) >>> 0;
46 return nameOf(seed) === value ? seed : null;
47 }
48
49 /**
50 * What the Map control is on: de_forge, a new map every match, or one map by
51 * name. It is one string, because `?map=` is one parameter — the link and the
52 * control say the same thing in the same word.
53 */
54 export type MapChoice = "de_forge" | "generated" | `gen_${string}`;
55
56 /**
57 * The map a link or a field is asking for. `generated` is itself, a name is
58 * its canonical self, and everything else — a typo, a missing parameter, a
59 * script tag, a name nobody could have meant — is `de_forge`, which is what an
60 * unknown value has always been here.
61 */
62 export function mapFrom(value: unknown): MapChoice {
63 if (value === "generated") return "generated";
64 const seed = seedOf(value);
65 return seed === null ? "de_forge" : nameOf(seed);
66 }
67
68 /** The link that plays this map: the one URL a person copies and hands over. */
69 export const mapLink = (map: MapChoice): string => `/?map=${map}`;
src/pages/game.test.ts +25 −4
@@ -19,7 +19,7 @@ import { openDatabase, type Player, saveMatch } from "../db.ts";
1919 import type { Result } from "../result.ts";
2020 import { CARD, type Card } from "../client/weights.ts";
2121 import { GAMES } from "../games.ts";
22 import { gamePage, policyHtml, shareHref, BAD_ID, type PageOptions } from "./game.ts";
22 import { gamePage, policyHtml, shareHref, BAD_ID, BAD_MAP, type PageOptions } from "./game.ts";
2323 import { escape } from "./shell.ts";
2424
2525 const db = (): ReturnType<typeof openDatabase> => openDatabase(mkdtempSync(join(tmpdir(), "game-")));
@@ -77,13 +77,34 @@ test("the leaderboard and the menu are in the first paint, not fetched", () => {
7777
7878 test("a link that asked for a match is what the page opens with, and junk is a sentence", () => {
7979 const id = "9f8a7b6c5d4e3f21";
80 const html = gamePage(db(), { ...options, asked: { match: id, badMatch: false, map: "generated" } });
80 const asked = { match: id, badMatch: false, map: "generated", badMap: false } as const;
81 const html = gamePage(db(), { ...options, asked });
8182 expect(html).toContain(`<code id="playing">${id}</code>`);
82 expect(html).toContain(`href="${escape(shareHref({ match: id, badMatch: false, map: "generated" }))}"`);
83 expect(gamePage(db(), { ...options, asked: { match: null, badMatch: true, map: "de_forge" } }))
83 expect(html).toContain(`href="${escape(shareHref(asked))}"`);
84 expect(gamePage(db(), { ...options, asked: { match: null, badMatch: true, map: "de_forge", badMap: false } }))
8485 .toContain(escape(BAD_ID));
8586 });
8687
88 test("a link that asked for one map by name opens on it, before Play and as a link", () => {
89 // The name is the whole of what that link carried: the field holds it, the
90 // line above Play names it, and the control beside the name is that map's
91 // own URL — a match id is not needed for any of it.
92 const name = "gen_14u5j6g";
93 const html = gamePage(db(), { ...options, asked: { match: null, badMatch: false, map: name, badMap: false } });
94 expect(html).toContain(`id="mapseed" maxlength="12" placeholder="gen_14u5j6g"`);
95 expect(html).toContain(`value="${name}"`);
96 expect(html).toContain(`<b id="willplay">${name}</b>`);
97 expect(html).toContain(`id="copymap" href="/?map=${name}"`);
98 expect(html).toContain(`<code id="playing">none yet</code>`);
99 expect(html).not.toContain(escape(BAD_MAP));
100 // And a `?map=` that was not a map is de_forge with a sentence, the way a
101 // `?match=` that was not an id is a fresh match with one.
102 const bad = gamePage(db(), { ...options, asked: { match: null, badMatch: false, map: "de_forge", badMap: true } });
103 expect(bad).toContain(escape(BAD_MAP));
104 expect(bad).toContain(`<b id="willplay">de_forge</b>`);
105 expect(bad).not.toMatch(/id="mapseed"[^>]*value=/);
106 });
107
87108 test("the boast about the bots is the card, formatted the way the page formats numbers", () => {
88109 const said = boast(gamePage(db(), options));
89110 expect(said).toContain(`trained with PPO against ${CARD.envSteps.toLocaleString("en")} ticks of this same simulation`);
src/pages/game.ts +52 −12
@@ -13,6 +13,7 @@ import type { Database } from "bun:sqlite";
1313 import { displayName, headshotShare, ratio, recentMatches, standings } from "../db.ts";
1414 import { NOTHING_POSTED } from "../client/pending.ts";
1515 import { CARD, type Card } from "../client/weights.ts";
16 import { mapLink, type MapChoice } from "../mapname.ts";
1617 import type { Who } from "../who.ts";
1718 import { navFor, othersHtml } from "./games.ts";
1819 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
@@ -48,17 +49,24 @@ ${table.length === 0
4849 * What the link a person followed asked for, validated in `src/server.ts` and
4950 * escaped here. `match` is a match id or null — never the raw parameter — and
5051 * `badMatch` says a parameter was there and was not one, which is a sentence
51 * in the menu and a fresh match, not an error page.
52 * in the menu and a fresh match, not an error page. `map` and `badMap` are the
53 * same two things for `?map=`, through `src/mapname.ts`.
5254 */
5355 export type Asked = {
5456 match: string | null;
5557 badMatch: boolean;
56 /** Which of the Map control's own values the link picked. */
57 map: "de_forge" | "generated";
58 /** Which map the link picked: the Map control's two words, or a map by name. */
59 map: MapChoice;
60 /** A `?map=` was there and was not a map, which is de_forge and a sentence. */
61 badMap: boolean;
5862 };
5963
6064 /** A link with nothing on it: no id asked for, no map asked for, a fresh match. */
61 export const NOTHING_ASKED: Asked = { match: null, badMatch: false, map: "de_forge" };
65 export const NOTHING_ASKED: Asked = { match: null, badMatch: false, map: "de_forge", badMap: false };
66
67 /** The name in `?map=`, or null when the link asked for one of the two words. */
68 export const namedMap = (map: MapChoice): string | null =>
69 map === "de_forge" || map === "generated" ? null : map;
6270
6371 /** What the shell needs that this page cannot know: the environment it runs in. */
6472 export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script" | "ssh"> & {
@@ -87,11 +95,23 @@ a.share { display:inline-flex; align-items:center; min-height:44px; padding:0 .7
8795 background:#1b2230; border:1px solid var(--line); color:var(--ct); text-decoration:none; }
8896 a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:underline; }
8997 #thismatch p { margin:.35rem 0; }
98 /* The name of the map Play is about to give you, and the link that keeps it.
99 It wraps rather than scrolls: at 360px the name is one line and the link is
100 the next, and both are still 44px to press. */
101 #willmap { display:flex; flex-wrap:wrap; gap:.5rem; align-items:center; justify-content:center;
102 margin:.35rem 0; }
103 #willplay { user-select:all; }
90104 `;
91105
92106 /** What the menu's hint under the id field says when the link carried junk. */
93107 export const BAD_ID = "That link's match id is not one — this is a fresh match instead.";
94108
109 /** And the same for `?map=`: a name that is not one is de_forge, never an error. */
110 export const BAD_MAP = "That link's map name is not one — this is de_forge instead.";
111
112 /** What the line before Play says when the map is one a match has yet to draw. */
113 export const UNDRAWN = "a new one every match";
114
95115 /** The link that plays this match again: the id it names and the map it was on. */
96116 export const shareHref = (asked: Asked): string =>
97117 asked.match === null ? "/" : `/?match=${asked.match}&map=${asked.map}`;
@@ -113,12 +133,19 @@ const SIGN_IN = "/__forge/signin?to=%2F";
113133 * hides that line and enables the button on the frame the last layer lands.
114134 * Rendered that way rather than switched on by the script so the first paint is
115135 * already true and nothing moves under the pointer afterwards.
136 *
137 * The Map control is three things in two fields: the select's two words, and a
138 * name in `#mapseed`, which is one exact map and wins over the select. The line
139 * before Play names the map Play is about to give you and carries the link that
140 * hands it to anyone — rendered from `?map=` here, and rewritten by
141 * `src/client/main.ts` whenever either field changes, because the name of "a new
142 * one every match" is the name of an id the browser has yet to draw.
116143 */
117144 const menu = (visitor: Who, asked: Asked): string => `
118145 <h2>Defuse or detonate</h2>
119 <p>Five against five on <b>de_forge</b>, or on a map generated for the match. Bomb rounds,
120 CS movement and economy, bots that push the sites, and a radar in the corner so a map
121 nobody has ever walked is still a place you can find your way around.</p>
146 <p>Five against five on <b>de_forge</b>, on a map generated for the match, or on any generated
147 map you can name. Bomb rounds, CS movement and economy, bots that push the sites, and a radar
148 in the corner so a map nobody has ever walked is still a place you can find your way around.</p>
122149 <p class="hint">${visitor.role === "anon"
123150 ? `You are playing as a guest, so the leaderboard lists your score as <b>a guest's</b>.
124151 <a href="${SIGN_IN}" rel="noreferrer">Sign in to the forge</a> to play under your own name.`
@@ -137,15 +164,24 @@ const menu = (visitor: Who, asked: Asked): string => `
137164 <div class="row">
138165 <label>Match id<input id="matchid" maxlength="64" placeholder="paste one to play it again"
139166 autocomplete="off" spellcheck="false"${asked.match === null ? "" : ` value="${escape(asked.match)}"`}></label>
167 <label>Map name<input id="mapseed" maxlength="12" placeholder="gen_14u5j6g"
168 autocomplete="off" spellcheck="false"${
169 namedMap(asked.map) === null ? "" : ` value="${escape(asked.map)}"`
170 }></label>
140171 </div>
141172 <p class="hint" id="onid">${asked.badMatch
142173 ? escape(BAD_ID)
143174 : `An id is the whole match: the same map, the same spawns, the same bots and the same seed.
144175 Paste one here — or open its link — to play it again; empty is a new match, as ever.`}</p>
176 <p class="hint" id="onmap">${asked.badMap
177 ? escape(BAD_MAP)
178 : `A generated map is carved from a seed and answers to a name: two bombsites, two spawns and
179 cover, and the same map for both sides — it is the layout turned half a circle, so the walk
180 to your site is the walk to theirs. A name in this field is that exact map, on any machine;
181 empty, the Map control above decides.`}</p>
182 <p id="willmap">Map <b id="willplay">${escape(asked.map === "generated" ? UNDRAWN : asked.map)}</b>
183 <a class="share" id="copymap" href="${escape(mapLink(asked.map))}">Copy this map's link</a></p>
145184 <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div>
146 <p class="hint" id="onmap">A generated map is carved from the match id: two bombsites, two spawns
147 and cover, and the same map for both sides — it is the layout turned half a circle, so the walk
148 to your site is the walk to theirs.</p>
149185 <p class="hint" id="prep">Preparing textures…</p>
150186 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
151187 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang ·
@@ -219,11 +255,15 @@ const PAUSE = `
219255 const thisMatch = (asked: Asked): string => ` <section id="thismatch">
220256 <h2>This match</h2>
221257 <p>Match <code id="playing">${asked.match === null ? "none yet" : escape(asked.match)}</code>
222 on <b id="mapname">${asked.match === null ? "—" : "the map its id carves"}</b>
258 on <a class="share" id="mapname" href="${escape(mapLink(asked.map))}">${
259 namedMap(asked.map) === null ? (asked.match === null ? "—" : "the map its id carves") : escape(asked.map)
260 }</a>
223261 · <a class="share" id="share" href="${escape(shareHref(asked))}">Link to this match</a></p>
224262 <p class="empty">Press Play and the id of the match you are playing appears here. Paste it into
225263 <b>Match id</b> above, or open the link, and you get that match again — the same layout, the same
226 spawns, the same bots and the same seed. Every playing of it is its own row on the leaderboard.</p>
264 spawns, the same bots and the same seed. Every playing of it is its own row on the leaderboard.
265 The map's own name is a link too: it opens this layout and nothing else of the match, which is how
266 a map you liked is handed to somebody who has never played it.</p>
227267 </section>`;
228268
229269 /**
src/server.test.ts +66 −1
@@ -8,7 +8,7 @@ import { measureExample } from "./cost.ts";
88 import { GAMES } from "./games.ts";
99 import { matchOf, playKey } from "./matchid.ts";
1010 import { NOTHING_POSTED } from "./client/pending.ts";
11 import { BAD_ID } from "./pages/game.ts";
11 import { BAD_ID, BAD_MAP, UNDRAWN } from "./pages/game.ts";
1212 import { escape } from "./pages/shell.ts";
1313 import { sign } from "./who.ts";
1414 import { PAGE_ACTIONS, PAGE_IDS } from "./client/stage.test-helper.ts";
@@ -137,6 +137,71 @@ test("a `?match=` that is not an id is a fresh match and a sentence, never an er
137137 }
138138 });
139139
140 test("`/?map=gen_…` preselects one map by name, and nothing else of a match", async () => {
141 const name = "gen_14u5j6g";
142 const body = await (await fetch(`${base}/?map=${name}`)).text();
143 // The field the client validates again is served holding it, the line before
144 // Play names the map that is about to be played, and the link beside that
145 // name is the map and nothing of a match: the whole of handing a map over.
146 expect(body).toContain(`id="mapseed" maxlength="12"`);
147 expect(body).toContain(`value="${name}"`);
148 expect(body).toContain(`<b id="willplay">${name}</b>`);
149 expect(body).toContain(`id="copymap" href="/?map=${name}"`);
150 expect(body).toContain(`id="mapname" href="/?map=${name}">${name}</a>`);
151 // A name is neither of the select's two words, so neither is picked.
152 expect(body).not.toContain("selected");
153 expect(body).not.toContain(escape(BAD_MAP));
154 // And the name a link carries is the map a match is played on: no id asked
155 // for, so the match is a fresh one, exactly as a page with no query is.
156 expect(body).toContain(`<code id="playing">none yet</code>`);
157 });
158
159 test("a `?map=` that is not a map is de_forge and a sentence, never an error page", async () => {
160 // `gen_0ya` is `gen_ya` written badly and `gen_zzzzzzzz` is nobody's seed:
161 // both are refused by the round trip in `src/mapname.ts`, not by a regexp
162 // that let them through, and neither is rendered anywhere on the page.
163 for (const junk of [
164 "<script>alert(1)</script>", "gen_0ya", "gen_zzzzzzzz", "gen_1z141z4", "de_moon",
165 "GEN_YA", "../../etc/passwd", "%00", "",
166 ]) {
167 const response = await fetch(`${base}/?map=${encodeURIComponent(junk)}`);
168 expect(response.status).toBe(200);
169 const body = await response.text();
170 expect(body).toContain(escape(BAD_MAP));
171 expect(body).toContain(`<b id="willplay">de_forge</b>`);
172 expect(body).toContain(`id="copymap" href="/?map=de_forge"`);
173 // Nothing of what was asked for came back, escaped or otherwise.
174 if (junk !== "") {
175 expect(body).not.toContain(junk);
176 expect(body).not.toContain(escape(junk));
177 }
178 expect(body).not.toContain("alert(1)");
179 }
180 });
181
182 test("`?map=generated` is the one map the server cannot name, and says so", async () => {
183 // The name of "a new one every match" is the name of an id the browser has
184 // yet to draw, so the first paint says that in words and `src/client/main.ts`
185 // fills in the name it drew — before Play, which is the point of #148.
186 const body = await (await fetch(`${base}/?map=generated`)).text();
187 expect(body).toContain(`<option value="generated" selected>`);
188 expect(body).toContain(`<b id="willplay">${UNDRAWN}</b>`);
189 expect(body).toContain(`id="copymap" href="/?map=generated"`);
190 expect(body).not.toContain(escape(BAD_MAP));
191 expect(body).not.toMatch(/id="mapseed"[^>]*value=/);
192 });
193
194 test("`?map=` belongs to the shooter and to no other game's page", async () => {
195 // It is this game's parameter, read in this game's renderer. The other two
196 // pages have no Map control and carry nothing of it, whatever a link says.
197 for (const path of ["/range", "/example"]) {
198 const body = await (await fetch(`${base}${path}?map=gen_14u5j6g`)).text();
199 expect(body).not.toContain("gen_14u5j6g");
200 expect(body).not.toContain("willplay");
201 expect(body).not.toContain("copymap");
202 }
203 });
204
140205 test("a page nobody linked to is the page it always was: no id, a fresh match, de_forge", async () => {
141206 const body = await (await fetch(base + "/")).text();
142207 expect(body).toContain(`<code id="playing">none yet</code>`);
src/server.ts +17 −7
@@ -39,6 +39,7 @@ import { GAMES } from "./games.ts";
3939 import { createLimiter } from "./limit.ts";
4040 import { examplePage } from "./pages/example.ts";
4141 import { type Asked, gamePage, STYLE as GAME_STYLE, statsHtml } from "./pages/game.ts";
42 import { mapFrom } from "./mapname.ts";
4243 import { matchFrom } from "./matchid.ts";
4344 import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts";
4445 import { rangePage } from "./pages/range.ts";
@@ -262,23 +263,32 @@ type Render = { extraStyle?: string; draw: (request: Request, script: string) =>
262263
263264 /**
264265 * What a link to the shooter asked for. `/?match=<id>` plays that match again
265 * and `?map=` picks which of the Map control's two values it is played on —
266 * together they are what the page's own "Link to this match" hands out.
266 * and `?map=` says which map it is played on — one of the Map control's two
267 * words, or the name of one generated map (`gen_14u5j6g`), which is the whole
268 * of how a layout somebody liked is handed to somebody else.
267269 *
268270 * Both are inputs and both are settled here: the id is whatever `src/matchid.ts`
269 * accepts and null otherwise, the map is one of two known words, and nothing a
270 * caller wrote reaches the page as itself. A parameter that is not an id is not
271 * an error — the page says so in a sentence and plays a fresh match — and the
272 * page escapes what it renders anyway, because two checks are the point.
271 * accepts and null otherwise, the map is whatever `src/mapname.ts` accepts and
272 * de_forge otherwise, and nothing a caller wrote reaches the page as itself — a
273 * name that comes back out is the canonical one this server printed, not the
274 * bytes that arrived. A parameter that is not an id or not a map is not an
275 * error either: the page says so in a sentence and plays the default, and it
276 * escapes what it renders anyway, because two checks are the point.
277 *
278 * Both belong to this game and to no other: `?map=` is read here, in the
279 * shooter's own renderer, and the range and the example never see it.
273280 */
274281 const asked = (request: Request): Asked => {
275282 const query = new URL(request.url).searchParams;
276283 const wanted = query.get("match");
277284 const match = matchFrom(wanted);
285 const wantedMap = query.get("map");
286 const map = mapFrom(wantedMap);
278287 return {
279288 match,
280289 badMatch: wanted !== null && match === null,
281 map: query.get("map") === "generated" ? "generated" : "de_forge",
290 map,
291 badMap: wantedMap !== null && wantedMap !== map,
282292 };
283293 };
284294
static/app.6a9982560a.js deleted −58

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

View file
static/app.9e515111bc.js added +58

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

View file

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs