Public: anyone can read this; the crew can change it.
forked from trevato/cs
The board can be asked for one map: `/?board=<map>` renders that map's standings and says so #424
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/415-the-board-can-be-asked-for-one-map-board → main 1 commit fork
11 files
changed +136
−11
README.md +3 −1
| @@ -335,7 +335,9 @@ down and hand to somebody who has never played the round it happened in. | ||
| 335 | 335 | six generated maps with the most players (at least two each) — a board of one |
| 336 | 336 | is a personal best, not a comparison. `?board=` goes through the same |
| 337 | 337 | `src/mapname.ts` grammar as a stored result; anything else is every map and a |
| 338 | sentence saying so. The board on one map is one range of the `standing_by_rank` | |
| 338 | sentence saying so. A round played from a page on one map's board keeps it: | |
| 339 | the page posts to `/api/match?board=<map>` and the leaderboard it gets back is | |
| 340 | that map's, so the URL stays the state after the table refreshes. The board on one map is one range of the `standing_by_rank` | |
| 339 | 341 | index (`SEARCH standing USING INDEX standing_by_rank (map=?)`, no sort step), |
| 340 | 342 | about 0.04 ms at 20,000 matches on 200 maps. |
| 341 | 343 |
src/client/main.test.ts +11 −1
| @@ -81,7 +81,7 @@ function painted(stage: FakeStage): FakeStage { | ||
| 81 | 81 | /** A booted page, its atlas painted, and the match already started on it. */ |
| 82 | 82 | function playing( |
| 83 | 83 | options: { |
| 84 | webgl?: boolean; coarse?: boolean; fullscreen?: boolean; keyboardLock?: boolean; planEndpoint?: string; | |
| 84 | webgl?: boolean; coarse?: boolean; fullscreen?: boolean; keyboardLock?: boolean; planEndpoint?: string; board?: string; | |
| 85 | 85 | } = {}, |
| 86 | 86 | ): FakeStage { |
| 87 | 87 | const stage = painted(start(fakeStage(options))); |
| @@ -1089,6 +1089,16 @@ test("a phone gets the touch pads, a mouse does not", () => { | ||
| 1089 | 1089 | expect(playing().by("touch").hidden).toBe(true); |
| 1090 | 1090 | }); |
| 1091 | 1091 | |
| 1092 | test("a page reading one map's board posts its rounds for that board, so the table it gets back keeps the filter", () => { | |
| 1093 | const stage = playing({ board: "gen_ab12" }); | |
| 1094 | stage.step(FRAME); | |
| 1095 | expect(stage.posts[0]?.url).toBe("/api/match?board=gen_ab12"); | |
| 1096 | // What the server marked is checked again here: junk is the plain endpoint. | |
| 1097 | const junk = playing({ board: "<script>" }); | |
| 1098 | junk.step(FRAME); | |
| 1099 | expect(junk.posts[0]?.url).toBe("/api/match"); | |
| 1100 | }); | |
| 1101 | ||
| 1092 | 1102 | test("one POST per round scored, in the shape the server accepts", async () => { |
| 1093 | 1103 | const stage = playing(); |
| 1094 | 1104 | stage.step(FRAME); |
src/client/main.ts +5 −2
| @@ -98,7 +98,7 @@ import { parseSens, parseSound, readSettings, SENS, SETTING_KEYS, settingStrings | ||
| 98 | 98 | import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts"; |
| 99 | 99 | import { meshFor } from "./models.ts"; |
| 100 | 100 | import { parsePacked } from "./packed.ts"; |
| 101 | import { createReporter, type Reply } from "./report.ts"; | |
| 101 | import { createReporter, endpointFor, type Reply } from "./report.ts"; | |
| 102 | 102 | import { boardHtml, headline, resultOf, scoreLine, summarize, yourLine } from "./summary.ts"; |
| 103 | 103 | import { drawPreview, previewText } from "./preview.ts"; |
| 104 | 104 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| @@ -381,7 +381,9 @@ export function boot(host: Host): Frame { | ||
| 381 | 381 | // the lock is kept, and the trigger presses what is under it. |
| 382 | 382 | const buyCursor = by<Pointer>("buycursor"); |
| 383 | 383 | const touchPanel = by("touch"); |
| 384 | const stats = by("stats"); | |
| 384 | // The leaderboard, and which map's board it is: `data-board` is there only | |
| 385 | // when `?board=` named a map, and a result posted from here asks for that one. | |
| 386 | const stats = by<Link>("stats"); | |
| 385 | 387 | // The one line about whether what you played is on that board. It is a live |
| 386 | 388 | // region in the page, so what is written here is announced and not only drawn. |
| 387 | 389 | const savedLine = by("saved"); |
| @@ -1076,6 +1078,7 @@ export function boot(host: Host): Frame { | ||
| 1076 | 1078 | */ |
| 1077 | 1079 | const reporter = createReporter({ |
| 1078 | 1080 | fetch: (url, init) => host.fetch(url, init), |
| 1081 | endpoint: endpointFor(stats.getAttribute("data-board")), | |
| 1079 | 1082 | now: () => win.performance.now(), |
| 1080 | 1083 | say: (line) => { |
| 1081 | 1084 | savedLine.textContent = line; |
src/client/report.test.ts +22 −1
| @@ -21,7 +21,7 @@ import { SHORT } from "./match.ts"; | ||
| 21 | 21 | import { |
| 22 | 22 | FIRST_WAIT, LONGEST_WAIT, LOST, MAX_TRIES, NOTHING_POSTED, REFUSED, RETRYING, SAVED, SENDING, waitAfter, |
| 23 | 23 | } from "./pending.ts"; |
| 24 | import { createReporter, MATCH_ENDPOINT, type Reporter } from "./report.ts"; | |
| 24 | import { createReporter, endpointFor, MATCH_ENDPOINT, type Reporter } from "./report.ts"; | |
| 25 | 25 | import { parseResult, type Result } from "../result.ts"; |
| 26 | 26 | import { createWorld, type World } from "./world.ts"; |
| 27 | 27 | |
| @@ -257,3 +257,24 @@ test("a round scored after one landed is its own attempt, on a fresh count", asy | ||
| 257 | 257 | expect(run.body(2).rounds).toBe(1); |
| 258 | 258 | expect(run.reporter.standing()).toBe(SAVED); |
| 259 | 259 | }); |
| 260 | ||
| 261 | test("a page showing one map's board posts for that board, and anything else posts plain", async () => { | |
| 262 | expect(endpointFor("gen_ab12")).toBe("/api/match?board=gen_ab12"); | |
| 263 | expect(endpointFor("de_forge")).toBe("/api/match?board=de_forge"); | |
| 264 | for (const junk of [null, undefined, "", "generated", "gen_0ya", "de_moon", "<script>", 7]) | |
| 265 | expect(endpointFor(junk)).toBe(MATCH_ENDPOINT); | |
| 266 | ||
| 267 | const posts: string[] = []; | |
| 268 | const reporter = createReporter({ | |
| 269 | fetch: (url) => { | |
| 270 | posts.push(url); | |
| 271 | return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve("<h2>Leaderboard</h2>") }); | |
| 272 | }, | |
| 273 | endpoint: endpointFor("gen_ab12"), | |
| 274 | now: () => 0, | |
| 275 | say: () => {}, | |
| 276 | board: () => {}, | |
| 277 | }); | |
| 278 | reporter.post(world(), ID, "Vesper", false); | |
| 279 | expect(posts).toEqual(["/api/match?board=gen_ab12"]); | |
| 280 | }); |
src/client/report.ts +16 −1
| @@ -19,6 +19,7 @@ | ||
| 19 | 19 | * settles whenever it settles. |
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | import { playedOn } from "../mapname.ts"; | |
| 22 | 23 | import { nextStep, NOTHING_POSTED, SENDING, type Answer } from "./pending.ts"; |
| 23 | 24 | import { resultOf } from "./summary.ts"; |
| 24 | 25 | import type { World } from "./world.ts"; |
| @@ -26,6 +27,18 @@ import type { World } from "./world.ts"; | ||
| 26 | 27 | /** Where a result goes. The same path `src/server.ts` gates results at. */ |
| 27 | 28 | export const MATCH_ENDPOINT = "/api/match"; |
| 28 | 29 | |
| 30 | /** | |
| 31 | * Where a result goes from a page that is showing one map's board: the same | |
| 32 | * path with `?board=` on it, so the leaderboard a 200 carries back is the one | |
| 33 | * the page was showing rather than every map's (#415). The map is read off the | |
| 34 | * page and held to `playedOn` here as the server holds it there — anything | |
| 35 | * else posts to the plain path, and the page gets the whole board back. | |
| 36 | */ | |
| 37 | export const endpointFor = (board: unknown): string => { | |
| 38 | const map = playedOn(board); | |
| 39 | return map === null ? MATCH_ENDPOINT : `${MATCH_ENDPOINT}?board=${map}`; | |
| 40 | }; | |
| 41 | ||
| 29 | 42 | /** |
| 30 | 43 | * What this module reads off a response: whether it landed, the status it was |
| 31 | 44 | * refused with — a 429 is worth another attempt, a 422 is not — and the |
| @@ -47,6 +60,8 @@ export type Posts = ( | ||
| 47 | 60 | */ |
| 48 | 61 | export type Wire = { |
| 49 | 62 | fetch: Posts; |
| 63 | /** Where to post; `MATCH_ENDPOINT` when unsaid. See `endpointFor`. */ | |
| 64 | endpoint?: string; | |
| 50 | 65 | now(): number; |
| 51 | 66 | say(line: string): void; |
| 52 | 67 | board(html: string): void; |
| @@ -104,7 +119,7 @@ export function createReporter(wire: Wire): Reporter { | ||
| 104 | 119 | void (async (): Promise<void> => { |
| 105 | 120 | let answer: Answer = { ok: false, status: null }; |
| 106 | 121 | try { |
| 107 | const response = await wire.fetch(MATCH_ENDPOINT, { | |
| 122 | const response = await wire.fetch(wire.endpoint ?? MATCH_ENDPOINT, { | |
| 108 | 123 | method: "POST", |
| 109 | 124 | headers: { "content-type": "application/json" }, |
| 110 | 125 | body, |
src/client/stage.test-helper.ts +3
| @@ -220,6 +220,8 @@ export function fakeStage( | ||
| 220 | 220 | * plans its rounds in the browser and posts nothing. |
| 221 | 221 | */ |
| 222 | 222 | planEndpoint?: string; |
| 223 | /** The `data-board` the server puts on `#stats` when `?board=` named a map. */ | |
| 224 | board?: string; | |
| 223 | 225 | } = {}, |
| 224 | 226 | ): FakeStage { |
| 225 | 227 | const page = fakePage({ |
| @@ -239,6 +241,7 @@ export function fakeStage( | ||
| 239 | 241 | if (options.unlinked !== true) page.by("weights").setAttribute("href", WEIGHTS_URL); |
| 240 | 242 | // And the meta the shell renders for an endpoint, when this page has one. |
| 241 | 243 | if (options.planEndpoint !== undefined) page.by(PLAN_ID).setAttribute("content", options.planEndpoint); |
| 244 | if (options.board !== undefined) page.by("stats").setAttribute("data-board", options.board); | |
| 242 | 245 | // The same switch on both sides of the seam: `webgl: false` is a browser with |
| 243 | 246 | // no context to give, whether the frame asks this page's `host` for a renderer |
| 244 | 247 | // or — as the committed bundle does — asks the canvas itself. |
src/gate.ts +7 −2
| @@ -82,8 +82,13 @@ export type Gate = { | ||
| 82 | 82 | refusal: string; |
| 83 | 83 | /** The numbers, validated by shape and capped at what the game can produce. */ |
| 84 | 84 | accept(value: unknown): Checked | null; |
| 85 | /** What a post that landed answers with: this game's board, rendered. */ | |
| 86 | board(db: Database): string; | |
| 85 | /** | |
| 86 | * What a post that landed answers with: this game's board, rendered. `query` | |
| 87 | * is the post's own query string, unvalidated — a board that can be read one | |
| 88 | * way or another (`?board=` on the shooter's) settles it here, so the table | |
| 89 | * a page gets back is the one it was showing. | |
| 90 | */ | |
| 91 | board(db: Database, query: URLSearchParams): string; | |
| 87 | 92 | }; |
| 88 | 93 | |
| 89 | 94 | /** |
src/pages/game.test.ts +10
| @@ -580,3 +580,13 @@ test("the fold costs `/` nothing over the wire: the style block ships without it | ||
| 580 | 580 | // 9,562 bytes gzipped before #255; the page is held under that. |
| 581 | 581 | expect(Bun.gzipSync(html).byteLength).toBeLessThan(9562); |
| 582 | 582 | }); |
| 583 | ||
| 584 | test("the leaderboard section carries the board it shows, and only a map", () => { | |
| 585 | // `src/client/main.ts` reads this to post a round for the same board. | |
| 586 | const on = gamePage(db(), { ...options, asked: { ...NOTHING_ASKED, board: "gen_ab12" } }); | |
| 587 | expect(on).toContain(`<section id="stats" data-board="gen_ab12">`); | |
| 588 | expect(on).toContain("<h3>All time on gen_ab12</h3>"); | |
| 589 | const every = gamePage(db(), { ...options, asked: { ...NOTHING_ASKED, board: null, badBoard: true } }); | |
| 590 | expect(every).toContain(`<section id="stats">`); | |
| 591 | expect(every).not.toContain("data-board"); | |
| 592 | }); |
src/pages/game.ts +1 −1
| @@ -792,7 +792,7 @@ export function gamePage( | ||
| 792 | 792 | }), |
| 793 | 793 | sectionsHtml: `${thisMatch(asked)} |
| 794 | 794 | |
| 795 | <section id="stats">${statsHtml(db, now, asked.board ?? null, asked.badBoard ?? false)}</section> | |
| 795 | <section id="stats"${asked.board ? ` data-board="${escape(asked.board)}"` : ""}>${statsHtml(db, now, asked.board ?? null, asked.badBoard ?? false)}</section> | |
| 796 | 796 | ${SAVED_LINE} |
| 797 | 797 | |
| 798 | 798 | <section> |
src/server.test.ts +54
| @@ -2033,3 +2033,57 @@ test("a `?board=` that is not a map is the whole board and a sentence, never an | ||
| 2033 | 2033 | // And no `?board=` at all is the whole board with no complaint. |
| 2034 | 2034 | expect(await (await fetch(`${base}/`)).text()).not.toContain(escape(BAD_BOARD)); |
| 2035 | 2035 | }); |
| 2036 | ||
| 2037 | test("a page reading one map's board says which, so the result it posts keeps the filter", async () => { | |
| 2038 | // The first paint marks the section with the map, and only a map: junk and | |
| 2039 | // no `?board=` leave it bare, which is the plain endpoint in the client. | |
| 2040 | expect(await (await fetch(`${base}/?board=gen_ab12`)).text()).toContain(`<section id="stats" data-board="gen_ab12">`); | |
| 2041 | for (const path of ["/", "/?board=%3Cscript%3E", "/?board=de_moon"]) | |
| 2042 | expect(await (await fetch(base + path)).text()).toContain(`<section id="stats">`); | |
| 2043 | ||
| 2044 | // The leaderboard a landed round answers with is the one the page asked for. | |
| 2045 | const cookie = "cs_session=" + "e".repeat(32); | |
| 2046 | const quiet = { kills: 0, headshots: 0, damage: 0 }; | |
| 2047 | const posted = (query: string, name: string): Promise<Response> => | |
| 2048 | fetch(`${base}/api/match${query}`, { | |
| 2049 | method: "POST", | |
| 2050 | headers: { "content-type": "application/json", cookie }, | |
| 2051 | body: JSON.stringify(result({ name, map: "gen_cd34", ...quiet })), | |
| 2052 | }); | |
| 2053 | const onCd = await posted("?board=gen_cd34", "oncd2"); | |
| 2054 | expect(onCd.status).toBe(200); | |
| 2055 | const cdBoard = allTime(await onCd.text()); | |
| 2056 | expect(cdBoard).toContain("<h3>All time on gen_cd34</h3>"); | |
| 2057 | expect(cdBoard).toContain("oncd2 (guest)"); | |
| 2058 | expect(cdBoard).not.toContain("onab"); | |
| 2059 | ||
| 2060 | const onAb = allTime(await (await posted("?board=gen_ab12", "oncd3")).text()); | |
| 2061 | expect(onAb).toContain("<h3>All time on gen_ab12</h3>"); | |
| 2062 | expect(onAb).toContain("onab (guest)"); | |
| 2063 | expect(onAb).not.toContain("oncd"); | |
| 2064 | ||
| 2065 | // A junk board on the post is the whole board, never a refusal of the round. | |
| 2066 | const junk = await posted("?board=%3Cscript%3Ealert(1)%3C%2Fscript%3E", "oncd4"); | |
| 2067 | expect(junk.status).toBe(200); | |
| 2068 | const whole = await junk.text(); | |
| 2069 | expect(allTime(whole)).toContain("<h3>All time</h3>"); | |
| 2070 | expect(whole).not.toContain("alert(1)"); | |
| 2071 | }); | |
| 2072 | ||
| 2073 | test("`?map=` plays a map and leaves the board alone; `?board=` reads one and leaves the map alone", async () => { | |
| 2074 | const played = await (await fetch(`${base}/?map=gen_ab12`)).text(); | |
| 2075 | expect(played).toContain(`<b id="willplay">gen_ab12</b>`); | |
| 2076 | expect(played).toContain(`<section id="stats">`); | |
| 2077 | expect(allTime(played)).toContain("<h3>All time</h3>"); | |
| 2078 | expect(allTime(played)).toContain("Every result on every map"); | |
| 2079 | ||
| 2080 | const read = await (await fetch(`${base}/?board=gen_ab12`)).text(); | |
| 2081 | expect(read).toContain(`<b id="willplay">de_forge</b>`); | |
| 2082 | expect(allTime(read)).toContain("<h3>All time on gen_ab12</h3>"); | |
| 2083 | ||
| 2084 | // Both at once: each parameter does its own one thing. | |
| 2085 | const both = await (await fetch(`${base}/?map=gen_cd34&board=gen_ab12`)).text(); | |
| 2086 | expect(both).toContain(`<b id="willplay">gen_cd34</b>`); | |
| 2087 | expect(allTime(both)).toContain("<h3>All time on gen_ab12</h3>"); | |
| 2088 | expect(allTime(both)).not.toContain("oncd"); | |
| 2089 | }); |
src/server.ts +4 −2
| @@ -638,7 +638,9 @@ const GATES: readonly Gate[] = [ | ||
| 638 | 638 | const result = parseResult(value); |
| 639 | 639 | return result === null ? null : { name: result.name, write: (db, player) => saveMatch(db, player, result) }; |
| 640 | 640 | }, |
| 641 | board: (db) => statsHtml(db), | |
| 641 | // The page posts to `/api/match?board=<map>` when it is showing one map's | |
| 642 | // board, and gets that board back: a round that lands keeps the filter. | |
| 643 | board: (db, query) => statsHtml(db, Date.now(), playedOn(query.get("board"))), | |
| 642 | 644 | }, |
| 643 | 645 | { |
| 644 | 646 | path: "/api/run", |
| @@ -912,7 +914,7 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re | ||
| 912 | 914 | const session = held ?? newSession(); |
| 913 | 915 | const stored = checked.write(db, playerOf(visitor, session, checked.name)); |
| 914 | 916 | if (!stored) return text("not yours", 409); |
| 915 | return html(gate.board(db), SECURITY, 200, { "set-cookie": sessionCookie(session) }); | |
| 917 | return html(gate.board(db, url.searchParams), SECURITY, 200, { "set-cookie": sessionCookie(session) }); | |
| 916 | 918 | } |
| 917 | 919 | |
| 918 | 920 | /** |