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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/252-the-shooter-s-entry-sheds-its-last-conce → main 1 commit fork
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: | ||
| 280 | 280 | characters — the shape `src/result.ts` has always accepted for a result's |
| 281 | 281 | `matchId`), `src/server.ts` runs the `?match=` query through it before the page |
| 282 | 282 | 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 | |
| 284 | 284 | reaches `generateLevel`. Anything else is a fresh match and a sentence saying |
| 285 | 285 | so, never an error page and never a refusal to play. |
| 286 | 286 | |
| @@ -336,7 +336,7 @@ A name is an input and it is checked twice against one definition: | ||
| 336 | 336 | `src/mapname.ts` says what a name is — `gen_` and base36, and the test is the |
| 337 | 337 | round trip, so `gen_0ya` and `gen_zzzzzzzz` are typos rather than maps — |
| 338 | 338 | `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 | |
| 340 | 340 | through the same door again before anything reaches `generateLevel`. Anything |
| 341 | 341 | else is `de_forge` and a sentence saying so, never an error page. The seed itself |
| 342 | 342 | is pinned: `src/client/generate.test.ts` folds `gen_14u5j6g`'s cells into a |
| @@ -1078,6 +1078,13 @@ never checked itself. | ||
| 1078 | 1078 | doubling wait, the cap of four, and the sentence a player reads. It takes a |
| 1079 | 1079 | `fetch`-shaped call, a clock and the two things it has to say out loud, so |
| 1080 | 1080 | `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), | |
| 1081 | 1088 | `bomb` (the objective: `giveBomb`, `dropBomb`, `updateBomb` and the |
| 1082 | 1089 | `useProgress` a readout draws its bar from — carrying it, planting it on a |
| 1083 | 1090 | 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 @@ | ||
| 31 | 31 | import { expect, test } from "bun:test"; |
| 32 | 32 | import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts"; |
| 33 | 33 | 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"; | |
| 35 | 36 | import type { Socket } from "./session.ts"; |
| 36 | 37 | import { NO_PICTURE } from "../engine/index.ts"; |
| 37 | 38 | 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 | ||
| 1250 | 1251 | // A name needs no id: the map is the seed the name means and nothing else. |
| 1251 | 1252 | expect(stage.ids.length).toBe(0); |
| 1252 | 1253 | |
| 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. | |
| 1253 | 1256 | 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); | |
| 1259 | 1258 | // The match is still the match: a fresh id seeded the round it was played in. |
| 1260 | 1259 | expect(stage.ids.length).toBe(1); |
| 1261 | expect(worldOf(stage)?.level.name).toBe(MAP); | |
| 1262 | 1260 | |
| 1263 | 1261 | // The name wins over the select, because a person who typed one meant it. |
| 1264 | 1262 | stage.by("quit").fire("click"); |
| @@ -1345,10 +1343,11 @@ test("copy this map is a link first and a clipboard second", async () => { | ||
| 1345 | 1343 | |
| 1346 | 1344 | /** |
| 1347 | 1345 | * 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. | |
| 1350 | 1349 | */ |
| 1351 | const SETTLE = Math.ceil(200 / FRAME) + 1; | |
| 1350 | const SETTLE = Math.ceil(PLAN_WAIT / FRAME) + 1; | |
| 1352 | 1351 | |
| 1353 | 1352 | /** The stub canvas's size, which is what a browser reports before any CSS. */ |
| 1354 | 1353 | const PLAN_WIDTH = 800; |
| @@ -1679,19 +1678,16 @@ test("a bare id leaves the Map control exactly where the player left it", () => | ||
| 1679 | 1678 | test("a link that is not ours is not followed and not a match: the sentence, and de_forge", () => { |
| 1680 | 1679 | const stage = painted(start(fakeStage())); |
| 1681 | 1680 | 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); | |
| 1695 | 1691 | |
| 1696 | 1692 | // Play is a fresh match on de_forge, which is what a bad id has always been. |
| 1697 | 1693 | stage.by("play").fire("click"); |
src/client/main.ts +46 −134
| @@ -37,7 +37,9 @@ | ||
| 37 | 37 | * map by name: press it and you get that layout, not another new one. Beside |
| 38 | 38 | * that name is a plan of the map itself, drawn through `preview.ts` once per |
| 39 | 39 | * 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. | |
| 41 | 43 | * |
| 42 | 44 | * A round that did not land is not lost quietly, and none of that is in here |
| 43 | 45 | * either. `report.ts` turns the world into the result the server takes, posts |
| @@ -77,11 +79,13 @@ import { | ||
| 77 | 79 | NO_PICTURE, NO_PICTURE_SAYS, supportLine, |
| 78 | 80 | type Button, type Camera, type Facts, type KeyboardLock, type Listens, type Mesh, type Running, |
| 79 | 81 | } 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"; | |
| 82 | 87 | 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"; | |
| 85 | 89 | import { describe, localPlan, parsePlan, type RoundPlan } from "../plan/plan.ts"; |
| 86 | 90 | import type { Situation } from "../plan/situation.ts"; |
| 87 | 91 | import { DE_FORGE, levelNamed } from "./map.ts"; |
| @@ -113,26 +117,6 @@ import { roomCodeFrom, unpack } from "../engine/index.ts"; | ||
| 113 | 117 | |
| 114 | 118 | const TICK = 1 / 64; |
| 115 | 119 | |
| 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 | ||
| 136 | 120 | /** |
| 137 | 121 | * What the menu says once the textures are ready and the bots' policy is not: |
| 138 | 122 | * one more request stands between a player and a match, and on a slow |
| @@ -672,11 +656,12 @@ export function boot(host: Host): Frame { | ||
| 672 | 656 | * When the plan of the chosen map is due to be drawn, on the frame clock, or |
| 673 | 657 | * null when what is on the canvas is already the map Play would start. |
| 674 | 658 | * 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. | |
| 680 | 665 | */ |
| 681 | 666 | let planAt: number | null = null; |
| 682 | 667 | // 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 { | ||
| 984 | 969 | |
| 985 | 970 | // --- which map, and what it is called ------------------------------------- |
| 986 | 971 | |
| 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()); | |
| 1005 | 976 | |
| 1006 | 977 | /** |
| 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. | |
| 1046 | 981 | */ |
| 1047 | 982 | 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; | |
| 1053 | 991 | } |
| 1054 | 992 | |
| 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 | ||
| 1062 | 993 | /** |
| 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`. | |
| 1072 | 997 | */ |
| 1073 | 998 | 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; | |
| 1078 | 1002 | copyMap.setAttribute("href", keeping); |
| 1079 | 1003 | 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; | |
| 1084 | 1006 | } |
| 1085 | 1007 | |
| 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. */ | |
| 1096 | 1009 | function drawMapPlan(): void { |
| 1097 | const choice = chosenMap(); | |
| 1098 | const level = levelOfChoice(choice, choice === "generated" ? seedFrom(nextMatch()) : 0); | |
| 1010 | const level = planLevel(asked(), draw); | |
| 1099 | 1011 | // Device pixels, like the HUD's canvas: the CSS box is a fixed square and |
| 1100 | 1012 | // this is what the browser is actually giving us of it. |
| 1101 | 1013 | const [w, h] = backing(planCanvas.clientWidth, planCanvas.clientHeight, win.devicePixelRatio); |
| @@ -1213,13 +1125,13 @@ export function boot(host: Host): Frame { | ||
| 1213 | 1125 | // they named — which is the same carving from the seed that name means, so |
| 1214 | 1126 | // it is that layout on every machine for ever. An unknown value in either |
| 1215 | 1127 | // 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); | |
| 1218 | 1130 | if (level !== shown) { |
| 1219 | 1131 | renderer.replace("level", meshFor("level", level)); |
| 1220 | 1132 | shown = level; |
| 1221 | 1133 | } |
| 1222 | onMap.textContent = badName() ? NOT_A_MAP : `Playing ${level.name}.`; | |
| 1134 | onMap.textContent = badName(asked()) ? NOT_A_MAP : `Playing ${level.name}.`; | |
| 1223 | 1135 | // What is being played, where a person can copy it: the id and the map in |
| 1224 | 1136 | // the section under the stage, the link that plays them again beside them, |
| 1225 | 1137 | // and the same sentence in the pause panel for a full screen. `?map=` is |
| @@ -1229,7 +1141,7 @@ export function boot(host: Host): Frame { | ||
| 1229 | 1141 | mapName.textContent = level.name; |
| 1230 | 1142 | // The played map's own name is a link to that map and nothing else of the |
| 1231 | 1143 | // 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)); | |
| 1233 | 1145 | pauseId.textContent = `Match ${matchId} on ${level.name}.`; |
| 1234 | 1146 | share.setAttribute("href", `/?match=${matchId}&map=${choice}`); |
| 1235 | 1147 | share.textContent = shareLabel; |
| @@ -1372,7 +1284,7 @@ export function boot(host: Host): Frame { | ||
| 1372 | 1284 | * server's. |
| 1373 | 1285 | */ |
| 1374 | 1286 | makeRoom.addEventListener("click", () => { |
| 1375 | const chosen = chosenMap(); | |
| 1287 | const chosen = chosenMap(asked()); | |
| 1376 | 1288 | void host.fetch("/api/room", { |
| 1377 | 1289 | method: "POST", |
| 1378 | 1290 | headers: { "content-type": "application/json" }, |
| @@ -1601,7 +1513,7 @@ export function boot(host: Host): Frame { | ||
| 1601 | 1513 | // The plan of the map the menu is offering, once the choice has stood still |
| 1602 | 1514 | // — and never while a match is running, where the frame belongs to the round |
| 1603 | 1515 | // 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)) { | |
| 1605 | 1517 | planAt = null; |
| 1606 | 1518 | drawMapPlan(); |
| 1607 | 1519 | } |