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

forked from trevato/cs

The shooter's entry sheds its last concept: the menu's map, plan and link become their own module #409

Merged opened this

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

Merged

torque/cs-engine:torque/252-the-shooter-s-entry-sheds-its-last-conce → main 1 commit fork

  • Merged. torque/252-the-shooter-s-entry-sheds-its-last-conce was merged into main as 1e6514b.

Review

Sign in to review this change.
5 files changed +349 −159
README.md +9 −2
@@ -280,7 +280,7 @@ A pasted id is an input, and it is checked twice against one definition:
280280 characters — the shape `src/result.ts` has always accepted for a result's
281281 `matchId`), `src/server.ts` runs the `?match=` query through it before the page
282282 is rendered, the page escapes what it renders anyway, and
283 `src/client/main.ts` runs the field through the same door again before anything
283 `src/client/chooser.ts` runs the field through the same door again before anything
284284 reaches `generateLevel`. Anything else is a fresh match and a sentence saying
285285 so, never an error page and never a refusal to play.
286286
@@ -336,7 +336,7 @@ A name is an input and it is checked twice against one definition:
336336 `src/mapname.ts` says what a name is — `gen_` and base36, and the test is the
337337 round trip, so `gen_0ya` and `gen_zzzzzzzz` are typos rather than maps —
338338 `src/server.ts` runs the `?map=` query through it before the page is rendered,
339 the page escapes what it renders anyway, and `src/client/main.ts` runs the field
339 the page escapes what it renders anyway, and `src/client/chooser.ts` runs the field
340340 through the same door again before anything reaches `generateLevel`. Anything
341341 else is `de_forge` and a sentence saying so, never an error page. The seed itself
342342 is pinned: `src/client/generate.test.ts` folds `gen_14u5j6g`'s cells into a
@@ -1078,6 +1078,13 @@ never checked itself.
10781078 doubling wait, the cap of four, and the sentence a player reads. It takes a
10791079 `fetch`-shaped call, a clock and the two things it has to say out loud, so
10801080 `main` lends it the network and the elements and holds no bytes itself),
1081 `chooser` (which map the menu is asking for: the Map select, the Map name
1082 field and the Match id field read as three strings into the choice, its name,
1083 the link that keeps it and when its plan is due — the 200 ms `PLAN_WAIT` that
1084 stops a keystroke carving a level — with a pasted link taken apart first and
1085 junk read as de_forge with a sentence. It takes the values, a clock reading
1086 and a source of ids and hands back plain data, so `main` writes the fields and
1087 draws the plan through `preview` and decides nothing about which map it is),
10811088 `bomb` (the objective: `giveBomb`, `dropBomb`, `updateBomb` and the
10821089 `useProgress` a readout draws its bar from — carrying it, planting it on a
10831090 site, cutting the wire with a kit or without one; it hands back what the
src/client/chooser.test.ts added +125
@@ -0,0 +1,125 @@
1 import { expect, test } from "bun:test";
2 import {
3 badName, chosenMap, levelOf, nameMap, nextMatch, NOT_A_MAP, PLAN_WAIT, planDue, planLevel, playedLink, readId,
4 showMap, type Controls,
5 } from "./chooser.ts";
6 import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts";
7 import { DE_FORGE } from "./map.ts";
8
9 const ORIGIN = "https://cs.example";
10 const MAP = "gen_14u5j6g";
11 const ID = "7f1c2a4e-1111-4000-8000-9b3d5e7a2c64";
12 const FRESH = "3d9b7c1e-0000-4000-8000-5a2f6b4c8d11";
13
14 /** The menu as a page first paints it: de_forge, no name, no id. */
15 const menu = (over: Partial<Controls> = {}): Controls => ({ map: "de_forge", name: "", id: "", ...over });
16
17 /** A source of ids that counts what it handed out, so laziness can be asserted. */
18 function ids(): { draw: () => string; count: () => number } {
19 let n = 0;
20 return { draw: () => (n++, FRESH), count: () => n };
21 }
22
23 test("the wait is 200 ms, and a plan is due once it has passed and never during a match", () => {
24 expect(PLAN_WAIT).toBe(200);
25 expect(nameMap(menu(), 1000, ids().draw).planAt).toBe(1200);
26 expect(planDue(null, 5000, false)).toBe(false);
27 expect(planDue(1200, 1199, false)).toBe(false);
28 expect(planDue(1200, 1200, false)).toBe(true);
29 expect(planDue(1200, 9999, true)).toBe(false);
30 });
31
32 test("a name wins over the select, an empty one leaves the select, and junk is de_forge", () => {
33 expect(chosenMap(menu())).toBe("de_forge");
34 expect(chosenMap(menu({ map: "generated" }))).toBe("generated");
35 // A value nobody rendered is de_forge, like an unknown anything here.
36 expect(chosenMap(menu({ map: "de_moon" }))).toBe("de_forge");
37 expect(chosenMap(menu({ map: "generated", name: ` ${MAP} ` }))).toBe(MAP);
38 expect(badName(menu({ name: MAP }))).toBe(false);
39 // `gen_0ya` is `gen_ya` written badly: a typo, not a map.
40 expect(chosenMap(menu({ map: "generated", name: "gen_0ya" }))).toBe("de_forge");
41 expect(badName(menu({ name: "gen_0ya" }))).toBe(true);
42 expect(badName(menu({ name: " " }))).toBe(false);
43 });
44
45 test("the map is named before Play, and only `generated` draws an id to name it", () => {
46 const source = ids();
47 const plain = nameMap(menu(), 0, source.draw);
48 expect([plain.name, plain.link, plain.bad]).toEqual(["de_forge", "/?map=de_forge", false]);
49 const typed = nameMap(menu({ name: MAP }), 0, source.draw);
50 expect([typed.name, typed.link]).toEqual([MAP, `/?map=${MAP}`]);
51 expect(source.count()).toBe(0);
52
53 // A new map every match: the name is the map the drawn id carves, and the
54 // link is that map by name, not `generated`.
55 const fresh = nameMap(menu({ map: "generated" }), 0, source.draw);
56 expect(fresh.choice).toBe("generated");
57 expect(fresh.name).toBe(nameOf(seedFrom(FRESH)));
58 expect(fresh.link).toBe(`/?map=${fresh.name}`);
59 expect(source.count()).toBe(1);
60
61 // An id in the field is the match, so it names the map and nothing is drawn.
62 const pasted = nameMap(menu({ map: "generated", id: ` ${ID} ` }), 0, source.draw);
63 expect(pasted.name).toBe(nameOf(seedFrom(ID)));
64 expect(source.count()).toBe(1);
65 expect(nextMatch(menu({ id: "nope" }), source.draw)).toBe(FRESH);
66
67 // Junk is de_forge, with the sentence the line under the control prints.
68 const junk = nameMap(menu({ name: "gen_0ya" }), 0, source.draw);
69 expect([junk.name, junk.bad]).toEqual(["de_forge", true]);
70 expect(NOT_A_MAP).toContain("de_forge");
71 });
72
73 test("the level is the one the name means: the same cells, whichever way it was asked", () => {
74 expect(levelOf("de_forge", 7)).toBe(DE_FORGE);
75 expect(levelOf("generated", 7).grid.cells).toEqual(generateLevel(7).grid.cells);
76 expect(levelOf(MAP, 7).grid.cells).toEqual(generateLevel(seedOf(MAP) as number).grid.cells);
77 expect(levelOf(MAP, 7).name).toBe(MAP);
78
79 // The plan is of that level too, and draws an id only for `generated`.
80 const source = ids();
81 expect(planLevel(menu(), source.draw)).toBe(DE_FORGE);
82 expect(planLevel(menu({ name: MAP }), source.draw).name).toBe(MAP);
83 expect(planLevel(menu({ name: "gen_0ya" }), source.draw)).toBe(DE_FORGE);
84 expect(source.count()).toBe(0);
85 expect(planLevel(menu({ map: "generated" }), source.draw).name).toBe(nameOf(seedFrom(FRESH)));
86 expect(source.count()).toBe(1);
87 });
88
89 test("the played map's name links to that map and nothing else of the match", () => {
90 expect(playedLink(DE_FORGE)).toBe("/?map=de_forge");
91 expect(playedLink(generateLevel(seedOf(MAP) as number))).toBe(`/?map=${MAP}`);
92 });
93
94 test("the Map control is written the way the server renders the same ?map=", () => {
95 expect(showMap("de_forge")).toEqual({ map: "de_forge", name: "" });
96 expect(showMap("generated")).toEqual({ map: "generated", name: "" });
97 expect(showMap(MAP)).toEqual({ map: "de_forge", name: MAP });
98 });
99
100 test("an id is read as it is, and a typo says so without rewriting anything", () => {
101 expect(readId(menu({ id: ` ${ID} ` }), ORIGIN)).toEqual({ controls: null, match: ID, bad: false });
102 expect(readId(menu(), ORIGIN)).toEqual({ controls: null, match: null, bad: false });
103 expect(readId(menu({ id: "nope" }), ORIGIN)).toEqual({ controls: null, match: null, bad: true });
104 });
105
106 test("a link of ours becomes the controls it names; one that is not ours is left alone", () => {
107 // Both halves: the bare id in the id field and the map in the Map control.
108 const both = readId(menu({ map: "generated", id: `${ORIGIN}/?match=${ID}&map=${MAP}` }), ORIGIN);
109 expect(both).toEqual({ controls: { map: "de_forge", name: MAP, id: ID }, match: ID, bad: false });
110 const carved = readId(menu({ name: MAP, id: `${ORIGIN}/?match=${ID}&map=generated` }), ORIGIN);
111 expect(carved.controls).toEqual({ map: "generated", name: "", id: ID });
112 expect(chosenMap(carved.controls as Controls)).toBe("generated");
113
114 // Our link with a match nobody could play keeps its map and says NOT_AN_ID.
115 expect(readId(menu({ id: `${ORIGIN}/?match=nope&map=${MAP}` }), ORIGIN))
116 .toEqual({ controls: { map: "de_forge", name: MAP, id: "" }, match: null, bad: true });
117
118 // Another origin, a script, a URL with neither parameter: not believed, not
119 // rewritten, and not a match.
120 for (const text of [
121 `https://evil.example/?match=${ID}&map=${MAP}`,
122 `javascript:alert(1)?match=${ID}&map=${MAP}`,
123 `${ORIGIN}/?utm=x`,
124 ]) expect(readId(menu({ name: "", id: text }), ORIGIN)).toEqual({ controls: null, match: null, bad: true });
125 });
src/client/chooser.ts added +150
@@ -0,0 +1,150 @@
1 /**
2 * Which map the menu is asking for, and what it is called. Three controls say
3 * it — the Map select, the Map name field and the Match id field — and this
4 * module is the one place their values are read into a map: the choice, the
5 * name the line above Play prints, the link that keeps that map, and when the
6 * plan of it is due to be drawn.
7 *
8 * Two fields, one value: a name in the Map name field is one exact map and
9 * wins over the select, because a person who typed a name meant that map; an
10 * empty field leaves the select's two words; and a name that is not one is
11 * de_forge with a sentence, like an unknown anything here. The id field is the
12 * one that can rewrite the other two: a whole link pasted into it is taken
13 * apart into the controls it names (`src/matchlink.ts`, which reads it and never
14 * follows it) before anything else is read.
15 *
16 * It is not a browser and it is not the frame. What comes in is the controls'
17 * values as strings, a clock reading and a source of ids; what goes out is
18 * plain data. `src/mapname.ts` stays the only grammar of a map's name and
19 * `preview.ts` the only drawing — this names the level, `main.ts` holds the
20 * canvas and draws it.
21 */
22
23 import { mapFrom, mapLink, nameOf, seedFrom, seedOf, type MapChoice } from "../mapname.ts";
24 import { matchFrom } from "../matchid.ts";
25 import { pastedLink } from "../matchlink.ts";
26 import { generateLevel } from "./generate.ts";
27 import type { Level } from "./level.ts";
28 import { levelNamed } from "./map.ts";
29
30 /**
31 * How still the Map controls have to be before the map they name is carved and
32 * drawn, in milliseconds. It is a debounce rather than a blur handler because
33 * the choice is three controls and a person moves between them — a blur is not
34 * the moment a choice was made. 200 ms is longer than the gap between two
35 * keystrokes of a name being typed and shorter than a person noticing, so
36 * `gen_14u5j6g` costs one map and the picture is there when they look up.
37 */
38 export const PLAN_WAIT = 200;
39
40 /**
41 * What the menu says when the field holds something that is not a match id.
42 * A typo is not an error page and not a refusal to play: the match starts, it
43 * is a fresh one, and the line under the field says which of the two happened.
44 */
45 export const NOT_AN_ID = "That is not a match id — playing a fresh match instead.";
46
47 /** And what it says when the Map name field holds something that is not one. */
48 export const NOT_A_MAP = "That is not a map name — playing de_forge instead.";
49
50 /** The three controls, as the page holds them: untrimmed, unvalidated text. */
51 export type Controls = {
52 /** The Map select: `de_forge` or `generated`, or whatever a page sent. */
53 readonly map: string;
54 /** The Map name field: a `gen_…` name, nothing, or a typo. */
55 readonly name: string;
56 /** The Match id field: an id, a pasted link, nothing, or a typo. */
57 readonly id: string;
58 };
59
60 /** What the Map control is asking for, through the same door `src/server.ts` validated `?map=` with. */
61 export function chosenMap(controls: Controls): MapChoice {
62 const typed = controls.name.trim();
63 if (typed !== "") return mapFrom(typed);
64 return controls.map === "generated" ? "generated" : "de_forge";
65 }
66
67 /** Whether the Map name field holds something that is not a map name. */
68 export function badName(controls: Controls): boolean {
69 const typed = controls.name.trim();
70 return typed !== "" && seedOf(typed) === null;
71 }
72
73 /**
74 * The Map control, set to one map: the select and the name field written the
75 * way the server renders them for the same `?map=`, so a link opened and a link
76 * pasted leave the menu looking the same.
77 */
78 export const showMap = (choice: MapChoice): Pick<Controls, "map" | "name"> => ({
79 map: choice === "generated" ? "generated" : "de_forge",
80 name: choice === "generated" || choice === "de_forge" ? "" : choice,
81 });
82
83 /**
84 * The Match id field, read. `controls` is what the page should now show, or
85 * null when nothing needs rewriting — a link of ours becomes the bare id in the
86 * id field and its map in the Map control, so what is on the screen afterwards
87 * is what will be played; anything else is left exactly as typed, for the id's
88 * own door to judge. `match` is the match to play, or null for a fresh one, and
89 * `bad` is whether the line under the field should say `NOT_AN_ID` — which a
90 * link naming a match that is not an id also earns, since the field cannot say
91 * so once the link is out of it.
92 */
93 export type IdRead = { controls: Controls | null; match: string | null; bad: boolean };
94
95 export function readId(controls: Controls, origin: string): IdRead {
96 const link = pastedLink(controls.id.trim(), origin);
97 const after = link === null ? null : { ...showMap(link.map), id: link.match ?? "" };
98 const typed = (after ?? controls).id.trim();
99 const match = matchFrom(typed);
100 return { controls: after, match, bad: (link?.badMatch ?? false) || (typed !== "" && match === null) };
101 }
102
103 /**
104 * The match the next Play will be: the id in the field, or the fresh one `draw`
105 * hands out — asked only when it is needed, so a page nobody has touched hands
106 * out no ids. It is what `generated` carves its map from.
107 */
108 export const nextMatch = (controls: Controls, draw: () => string): string =>
109 matchFrom(controls.id.trim()) ?? draw();
110
111 /**
112 * The level a choice names. `generated` is this match's own seed and nothing
113 * else; a name is the server's own builder (`levelNamed`), because `mapFrom`
114 * only ever hands out a name that has a seed.
115 */
116 export const levelOf = (choice: MapChoice, seed: number): Level =>
117 choice === "generated" ? generateLevel(seed) : levelNamed(choice);
118
119 /**
120 * The map Play is about to give you, before Play: its name, the link that is
121 * that map by name, whether the line under the Map control says `NOT_A_MAP`,
122 * and when its plan is due — `PLAN_WAIT` after `now`, because naming a map is a
123 * string and drawing one is a level, and this is asked on every keystroke. For
124 * `generated` the name is the map this match's id carves, so the id is drawn
125 * now rather than at Play and the link plays *this* layout, not another.
126 */
127 export type Named = { choice: MapChoice; name: MapChoice; link: string; bad: boolean; planAt: number };
128
129 export function nameMap(controls: Controls, now: number, draw: () => string): Named {
130 const choice = chosenMap(controls);
131 const name = choice === "generated" ? nameOf(seedFrom(nextMatch(controls, draw))) : choice;
132 return { choice, name, link: mapLink(name), bad: badName(controls), planAt: now + PLAN_WAIT };
133 }
134
135 /**
136 * The level the plan beside that name is of, chosen exactly the way Play
137 * chooses it — same controls, same seed, same `levelOf` — so the picture is the
138 * map, not a map like it. Only `generated` asks `draw` for an id.
139 */
140 export function planLevel(controls: Controls, draw: () => string): Level {
141 const choice = chosenMap(controls);
142 return levelOf(choice, choice === "generated" ? seedFrom(nextMatch(controls, draw)) : 0);
143 }
144
145 /** Whether a plan scheduled for `at` is to be drawn now: never while a match runs. */
146 export const planDue = (at: number | null, now: number, running: boolean): boolean =>
147 at !== null && !running && now >= at;
148
149 /** The played map's own name as a link: that layout and nothing else of the match. */
150 export const playedLink = (level: Level): string => mapLink(mapFrom(level.name));
src/client/main.test.ts +19 −23
@@ -31,7 +31,8 @@
3131 import { expect, test } from "bun:test";
3232 import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts";
3333 import type { Level } from "./level.ts";
34 import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, ROOM_SAVED, ROOM_SHARE, type Frame } from "./main.ts";
34 import { boot, COPIED, gameMeshes, NOT_COPIED, ROOM_SAVED, ROOM_SHARE, type Frame } from "./main.ts";
35 import { NOT_AN_ID, NOT_A_MAP, PLAN_WAIT } from "./chooser.ts";
3536 import type { Socket } from "./session.ts";
3637 import { NO_PICTURE } from "../engine/index.ts";
3738 import { HINT } from "./buy.ts";
@@ -1250,15 +1251,12 @@ test("a map by name is that map, and a name that is not one is de_forge and says
12501251 // A name needs no id: the map is the seed the name means and nothing else.
12511252 expect(stage.ids.length).toBe(0);
12521253
1254 // Which cells that name carves, and the precedence between the fields, are
1255 // `chooser.test.ts`'s; this is the wiring — the name reaches the world.
12531256 stage.by("play").fire("click");
1254 const level = worldOf(stage)?.level;
1255 expect(level?.name).toBe(MAP);
1256 // The same cells `generateLevel` carves from that seed — the field is a seed,
1257 // not a label put on whatever the match would have made anyway.
1258 expect(level?.grid.cells).toEqual(generateLevel(seedOf(MAP) as number).grid.cells);
1257 expect(worldOf(stage)?.level.name).toBe(MAP);
12591258 // The match is still the match: a fresh id seeded the round it was played in.
12601259 expect(stage.ids.length).toBe(1);
1261 expect(worldOf(stage)?.level.name).toBe(MAP);
12621260
12631261 // The name wins over the select, because a person who typed one meant it.
12641262 stage.by("quit").fire("click");
@@ -1345,10 +1343,11 @@ test("copy this map is a link first and a clipboard second", async () => {
13451343
13461344 /**
13471345 * How long a choice has to stand still before the plan of it is drawn, in
1348 * frames of this test's clock. It is `PLAN_WAIT` in `main.ts` — 200 ms — and it
1349 * is written as a count of frames because that is the only clock a test has.
1346 * frames of this test's clock. It is `PLAN_WAIT` in `chooser.ts` — 200 ms, which
1347 * `chooser.test.ts` holds it to — written as a count of frames because that is
1348 * the only clock a test has.
13501349 */
1351 const SETTLE = Math.ceil(200 / FRAME) + 1;
1350 const SETTLE = Math.ceil(PLAN_WAIT / FRAME) + 1;
13521351
13531352 /** The stub canvas's size, which is what a browser reports before any CSS. */
13541353 const PLAN_WIDTH = 800;
@@ -1679,19 +1678,16 @@ test("a bare id leaves the Map control exactly where the player left it", () =>
16791678 test("a link that is not ours is not followed and not a match: the sentence, and de_forge", () => {
16801679 const stage = painted(start(fakeStage()));
16811680 const untouched = stage.by("map").value;
1682 for (const text of [
1683 `https://evil.example/?match=${PASTED}&map=${MAP}`,
1684 `javascript:alert(1)?match=${PASTED}&map=${MAP}`,
1685 `${ORIGIN}/?utm=x`,
1686 ]) {
1687 type(stage, text);
1688 // The existing sentence, not an error — and the Map control is untouched,
1689 // because nothing of that URL was believed.
1690 expect(stage.by("onid").textContent).toBe(NOT_AN_ID);
1691 expect(stage.by("mapseed").value).toBe("");
1692 expect(stage.by("map").value).toBe(untouched);
1693 expect(stage.by("matchid").value).toBe(text);
1694 }
1681 // Every kind of link that is not believed is `chooser.test.ts`'s; one is
1682 // enough to see the page leave its controls alone.
1683 const text = `https://evil.example/?match=${PASTED}&map=${MAP}`;
1684 type(stage, text);
1685 // The existing sentence, not an error — and the Map control is untouched,
1686 // because nothing of that URL was believed.
1687 expect(stage.by("onid").textContent).toBe(NOT_AN_ID);
1688 expect(stage.by("mapseed").value).toBe("");
1689 expect(stage.by("map").value).toBe(untouched);
1690 expect(stage.by("matchid").value).toBe(text);
16951691
16961692 // Play is a fresh match on de_forge, which is what a bad id has always been.
16971693 stage.by("play").fire("click");
src/client/main.ts +46 −134
@@ -37,7 +37,9 @@
3737 * map by name: press it and you get that layout, not another new one. Beside
3838 * that name is a plan of the map itself, drawn through `preview.ts` once per
3939 * choice — never per frame and never during a match — so what a player picks is
40 * a place and not a number.
40 * a place and not a number. Which map the three controls are asking for, what
41 * it is called and when its plan is due is `chooser.ts`; what is here is the
42 * fields it reads, the elements it names and the canvas the plan is drawn on.
4143 *
4244 * A round that did not land is not lost quietly, and none of that is in here
4345 * either. `report.ts` turns the world into the result the server takes, posts
@@ -77,11 +79,13 @@ import {
7779 NO_PICTURE, NO_PICTURE_SAYS, supportLine,
7880 type Button, type Camera, type Facts, type KeyboardLock, type Listens, type Mesh, type Running,
7981 } from "../engine/index.ts";
80 import { generateLevel, seedFrom } from "./generate.ts";
81 import { mapFrom, mapLink, nameOf, seedOf, type MapChoice } from "../mapname.ts";
82 import { seedFrom } from "./generate.ts";
83 import { mapFrom, mapLink } from "../mapname.ts";
84 import {
85 badName, chosenMap, levelOf, nameMap, NOT_A_MAP, NOT_AN_ID, planDue, planLevel, playedLink, readId, type Controls,
86 } from "./chooser.ts";
8287 import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts";
83 import { matchFrom, playKey } from "../matchid.ts";
84 import { pastedLink } from "../matchlink.ts";
88 import { playKey } from "../matchid.ts";
8589 import { describe, localPlan, parsePlan, type RoundPlan } from "../plan/plan.ts";
8690 import type { Situation } from "../plan/situation.ts";
8791 import { DE_FORGE, levelNamed } from "./map.ts";
@@ -113,26 +117,6 @@ import { roomCodeFrom, unpack } from "../engine/index.ts";
113117
114118 const TICK = 1 / 64;
115119
116 /**
117 * How still the Map controls have to be before the map they name is carved and
118 * drawn, in milliseconds. It is a debounce rather than a blur handler because
119 * the choice is three controls and a person moves between them — a blur is not
120 * the moment a choice was made. 200 ms is longer than the gap between two
121 * keystrokes of a name being typed and shorter than a person noticing, so
122 * `gen_14u5j6g` costs one map and the picture is there when they look up.
123 */
124 const PLAN_WAIT = 200;
125
126 /**
127 * What the menu says when the field holds something that is not a match id.
128 * A typo is not an error page and not a refusal to play: the match starts, it
129 * is a fresh one, and the line under the field says which of the two happened.
130 */
131 export const NOT_AN_ID = "That is not a match id — playing a fresh match instead.";
132
133 /** And what it says when the Map name field holds something that is not one. */
134 export const NOT_A_MAP = "That is not a map name — playing de_forge instead.";
135
136120 /**
137121 * What the menu says once the textures are ready and the bots' policy is not:
138122 * one more request stands between a player and a match, and on a slow
@@ -672,11 +656,12 @@ export function boot(host: Host): Frame {
672656 * When the plan of the chosen map is due to be drawn, on the frame clock, or
673657 * null when what is on the canvas is already the map Play would start.
674658 * Carving a level is about six milliseconds and the Map name field fires on
675 * every keystroke, so a choice has to stand still for `PLAN_WAIT` before it
676 * costs anything: `gen_14u5j6g` typed a character at a time is one map drawn,
677 * not ten. The drawing itself is not the price — it is three hundredths of a
678 * millisecond, because the rectangles are merged once per level. Scheduled here and spent in the frame loop, which is the only clock
679 * this file has and the only place a slow frame can be seen.
659 * every keystroke, so a choice has to stand still for `PLAN_WAIT` (in
660 * `chooser.ts`) before it costs anything: `gen_14u5j6g` typed a character at
661 * a time is one map drawn, not ten. The drawing itself is not the price — it
662 * is three hundredths of a millisecond, because the rectangles are merged
663 * once per level. Scheduled by `nameMap` and spent in the frame loop, which
664 * is the only clock this file has and the only place a slow frame can be seen.
680665 */
681666 let planAt: number | null = null;
682667 // The map the renderer is holding the geometry of. It starts as the one the
@@ -984,118 +969,45 @@ export function boot(host: Host): Frame {
984969
985970 // --- which map, and what it is called -------------------------------------
986971
987 /**
988 * What the Map control is asking for, through the same door `src/server.ts`
989 * validated `?map=` with. Two fields, one value: a name in the Map name field
990 * is one exact map and wins over the select, because a person who typed a name
991 * meant that map; an empty field leaves the select's two words; and a name
992 * that is not one is de_forge, like an unknown anything here.
993 */
994 const chosenMap = (): MapChoice => {
995 const typed = seedField.value.trim();
996 if (typed !== "") return mapFrom(typed);
997 return mapField.value === "generated" ? "generated" : "de_forge";
998 };
999
1000 /** Whether the Map name field holds something that is not a map name. */
1001 const badName = (): boolean => {
1002 const typed = seedField.value.trim();
1003 return typed !== "" && seedOf(typed) === null;
1004 };
972 // The three controls `chooser.ts` reads a map out of, as the plain values it
973 // takes, and the one place they are written back.
974 const asked = (): Controls => ({ map: mapField.value, name: seedField.value, id: idField.value });
975 const draw = (): string => (drawn ??= host.randomUUID());
1005976
1006977 /**
1007 * The Map control, set to one map: the select and the name field written the
1008 * way the server renders them for the same `?map=`, so a link opened and a
1009 * link pasted leave the menu looking the same. It is the only thing that
1010 * writes those two fields, and it writes them together — `chosenMap` above
1011 * stays the one place they are read.
1012 */
1013 function showMap(choice: MapChoice): void {
1014 mapField.value = choice === "generated" ? "generated" : "de_forge";
1015 seedField.value = choice === "generated" || choice === "de_forge" ? "" : choice;
1016 }
1017
1018 /**
1019 * A whole link in the Match id field, taken apart into the controls it names.
1020 * People copy a link, not the id in the middle of it, and the link is the
1021 * only thing that carries both halves of a match — the id, which is the
1022 * spawns and the bots and the seed, and the map it was played on.
1023 *
1024 * `src/matchlink.ts` reads it and never follows it: another origin, a
1025 * `javascript:` string or a URL with neither parameter is not a link, and the
1026 * field is left exactly as it was for the id's own door to judge. What is
1027 * ours becomes the bare id in this field and the map in the Map control, so
1028 * what is on the screen afterwards is what will be played, and the line above
1029 * Play names that map because `nameNextMap` runs straight after.
1030 *
1031 * Answers whether the link named a match that is not an id, which is the one
1032 * thing the field cannot say for itself once the link is out of it.
1033 */
1034 function absorbLink(): boolean {
1035 const link = pastedLink(idField.value.trim(), host.origin);
1036 if (link === null) return false;
1037 idField.value = link.match ?? "";
1038 showMap(link.map);
1039 return link.badMatch;
1040 }
1041
1042 /**
1043 * The Match id field, read: a link becomes the controls it names, and then
1044 * what is left is an id, nothing, or a typo — and the line under the field
1045 * says which. The match to play comes back, so `start` below asks once.
978 * The Match id field, read: a pasted link becomes the controls it names, and
979 * the line under the field says whether what is left is an id. The match to
980 * play comes back, so `start` below asks once.
1046981 */
1047982 function readIdField(): string | null {
1048 const bad = absorbLink();
1049 const typed = idField.value.trim();
1050 const wanted = matchFrom(typed);
1051 onId.textContent = bad || (typed !== "" && wanted === null) ? NOT_AN_ID : aboutId;
1052 return wanted;
983 const read = readId(asked(), host.origin);
984 if (read.controls !== null) {
985 idField.value = read.controls.id;
986 mapField.value = read.controls.map;
987 seedField.value = read.controls.name;
988 }
989 onId.textContent = read.bad ? NOT_AN_ID : aboutId;
990 return read.match;
1053991 }
1054992
1055 /** The match the next Play will be, which is what "generated" draws its map from. */
1056 const nextMatch = (): string => matchFrom(idField.value.trim()) ?? (drawn ??= host.randomUUID());
1057
1058 /** The level a choice names. `generated` is this match's own id and nothing else. */
1059 const levelOfChoice = (choice: MapChoice, seed: number): Level =>
1060 choice === "de_forge" ? DE_FORGE : generateLevel(choice === "generated" ? seed : seedOf(choice) ?? seed);
1061
1062993 /**
1063 * The map Play is about to give you, named and linked, before Play — which is
1064 * the whole of #148: a seeded map whose seed you cannot read is a slot
1065 * machine. For `generated` the name is the name of the map this match's id
1066 * carves, so the id is drawn now rather than at Play and the link is that
1067 * map by name — a link that plays *this* layout, not another new one.
1068 *
1069 * Called whenever either field changes and whenever the menu comes back, and
1070 * never while a match runs: the section under the stage says what is being
1071 * played, and this line is about what pressing Play would do.
994 * The map Play is about to give you, named and linked, before Play. Called
995 * whenever a control changes and whenever the menu comes back, and never
996 * while a match runs; the plan of it waits for `planAt`.
1072997 */
1073998 function nameNextMap(): void {
1074 const choice = chosenMap();
1075 const name = choice === "generated" ? nameOf(seedFrom(nextMatch())) : choice;
1076 keeping = mapLink(name);
1077 willPlay.textContent = name;
999 const next = nameMap(asked(), win.performance.now(), draw);
1000 keeping = next.link;
1001 willPlay.textContent = next.name;
10781002 copyMap.setAttribute("href", keeping);
10791003 copyMap.textContent = copyLabel;
1080 onMap.textContent = badName() ? NOT_A_MAP : aboutMap;
1081 // The picture of it is not drawn here: naming a map is a string and drawing
1082 // one is a level, and this runs on every keystroke. See `PLAN_WAIT`.
1083 planAt = win.performance.now() + PLAN_WAIT;
1004 onMap.textContent = next.bad ? NOT_A_MAP : aboutMap;
1005 planAt = next.planAt;
10841006 }
10851007
1086 /**
1087 * The plan of the map Play would start, drawn on the small canvas beside its
1088 * name, and the sentence beside that for anyone who is not looking at it. The
1089 * level is chosen exactly the way `start` below chooses it — same fields, same
1090 * seed, same `levelOfChoice` — so the picture is the map, not a map like it.
1091 *
1092 * `generated` is the only choice that needs the match's id, so it is the only
1093 * one that draws one. Picking de_forge or typing a name hands out no id here,
1094 * exactly as naming that map on the line above hands out none.
1095 */
1008 /** The plan of the map Play would start, and the sentence beside it. */
10961009 function drawMapPlan(): void {
1097 const choice = chosenMap();
1098 const level = levelOfChoice(choice, choice === "generated" ? seedFrom(nextMatch()) : 0);
1010 const level = planLevel(asked(), draw);
10991011 // Device pixels, like the HUD's canvas: the CSS box is a fixed square and
11001012 // this is what the browser is actually giving us of it.
11011013 const [w, h] = backing(planCanvas.clientWidth, planCanvas.clientHeight, win.devicePixelRatio);
@@ -1213,13 +1125,13 @@ export function boot(host: Host): Frame {
12131125 // they named — which is the same carving from the seed that name means, so
12141126 // it is that layout on every machine for ever. An unknown value in either
12151127 // field is de_forge, like an unknown anything here.
1216 const choice = chosenMap();
1217 const level = levelOfChoice(choice, seed);
1128 const choice = chosenMap(asked());
1129 const level = levelOf(choice, seed);
12181130 if (level !== shown) {
12191131 renderer.replace("level", meshFor("level", level));
12201132 shown = level;
12211133 }
1222 onMap.textContent = badName() ? NOT_A_MAP : `Playing ${level.name}.`;
1134 onMap.textContent = badName(asked()) ? NOT_A_MAP : `Playing ${level.name}.`;
12231135 // What is being played, where a person can copy it: the id and the map in
12241136 // the section under the stage, the link that plays them again beside them,
12251137 // and the same sentence in the pause panel for a full screen. `?map=` is
@@ -1229,7 +1141,7 @@ export function boot(host: Host): Frame {
12291141 mapName.textContent = level.name;
12301142 // The played map's own name is a link to that map and nothing else of the
12311143 // match: a layout you liked, handed to somebody who never played the round.
1232 mapName.setAttribute("href", mapLink(mapFrom(level.name)));
1144 mapName.setAttribute("href", playedLink(level));
12331145 pauseId.textContent = `Match ${matchId} on ${level.name}.`;
12341146 share.setAttribute("href", `/?match=${matchId}&map=${choice}`);
12351147 share.textContent = shareLabel;
@@ -1372,7 +1284,7 @@ export function boot(host: Host): Frame {
13721284 * server's.
13731285 */
13741286 makeRoom.addEventListener("click", () => {
1375 const chosen = chosenMap();
1287 const chosen = chosenMap(asked());
13761288 void host.fetch("/api/room", {
13771289 method: "POST",
13781290 headers: { "content-type": "application/json" },
@@ -1601,7 +1513,7 @@ export function boot(host: Host): Frame {
16011513 // The plan of the map the menu is offering, once the choice has stood still
16021514 // — and never while a match is running, where the frame belongs to the round
16031515 // and the menu is not on the screen to look at anyway.
1604 if (planAt !== null && !running && now >= planAt) {
1516 if (planDue(planAt, now, running)) {
16051517 planAt = null;
16061518 drawMapPlan();
16071519 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs