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

Merged opened this

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

Merged

torque/cs-engine:torque/415-the-board-can-be-asked-for-one-map-board → main 1 commit fork

  • Merged. torque/415-the-board-can-be-asked-for-one-map-board was merged into main as 6058840.

Review

Sign in to review this change.
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.
335335 six generated maps with the most players (at least two each) — a board of one
336336 is a personal best, not a comparison. `?board=` goes through the same
337337 `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`
339341 index (`SEARCH standing USING INDEX standing_by_rank (map=?)`, no sort step),
340342 about 0.04 ms at 20,000 matches on 200 maps.
341343
src/client/main.test.ts +11 −1
@@ -81,7 +81,7 @@ function painted(stage: FakeStage): FakeStage {
8181 /** A booted page, its atlas painted, and the match already started on it. */
8282 function playing(
8383 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;
8585 } = {},
8686 ): FakeStage {
8787 const stage = painted(start(fakeStage(options)));
@@ -1089,6 +1089,16 @@ test("a phone gets the touch pads, a mouse does not", () => {
10891089 expect(playing().by("touch").hidden).toBe(true);
10901090 });
10911091
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
10921102 test("one POST per round scored, in the shape the server accepts", async () => {
10931103 const stage = playing();
10941104 stage.step(FRAME);
src/client/main.ts +5 −2
@@ -98,7 +98,7 @@ import { parseSens, parseSound, readSettings, SENS, SETTING_KEYS, settingStrings
9898 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
9999 import { meshFor } from "./models.ts";
100100 import { parsePacked } from "./packed.ts";
101 import { createReporter, type Reply } from "./report.ts";
101 import { createReporter, endpointFor, type Reply } from "./report.ts";
102102 import { boardHtml, headline, resultOf, scoreLine, summarize, yourLine } from "./summary.ts";
103103 import { drawPreview, previewText } from "./preview.ts";
104104 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
@@ -381,7 +381,9 @@ export function boot(host: Host): Frame {
381381 // the lock is kept, and the trigger presses what is under it.
382382 const buyCursor = by<Pointer>("buycursor");
383383 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");
385387 // The one line about whether what you played is on that board. It is a live
386388 // region in the page, so what is written here is announced and not only drawn.
387389 const savedLine = by("saved");
@@ -1076,6 +1078,7 @@ export function boot(host: Host): Frame {
10761078 */
10771079 const reporter = createReporter({
10781080 fetch: (url, init) => host.fetch(url, init),
1081 endpoint: endpointFor(stats.getAttribute("data-board")),
10791082 now: () => win.performance.now(),
10801083 say: (line) => {
10811084 savedLine.textContent = line;
src/client/report.test.ts +22 −1
@@ -21,7 +21,7 @@ import { SHORT } from "./match.ts";
2121 import {
2222 FIRST_WAIT, LONGEST_WAIT, LOST, MAX_TRIES, NOTHING_POSTED, REFUSED, RETRYING, SAVED, SENDING, waitAfter,
2323 } from "./pending.ts";
24 import { createReporter, MATCH_ENDPOINT, type Reporter } from "./report.ts";
24 import { createReporter, endpointFor, MATCH_ENDPOINT, type Reporter } from "./report.ts";
2525 import { parseResult, type Result } from "../result.ts";
2626 import { createWorld, type World } from "./world.ts";
2727
@@ -257,3 +257,24 @@ test("a round scored after one landed is its own attempt, on a fresh count", asy
257257 expect(run.body(2).rounds).toBe(1);
258258 expect(run.reporter.standing()).toBe(SAVED);
259259 });
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 @@
1919 * settles whenever it settles.
2020 */
2121
22 import { playedOn } from "../mapname.ts";
2223 import { nextStep, NOTHING_POSTED, SENDING, type Answer } from "./pending.ts";
2324 import { resultOf } from "./summary.ts";
2425 import type { World } from "./world.ts";
@@ -26,6 +27,18 @@ import type { World } from "./world.ts";
2627 /** Where a result goes. The same path `src/server.ts` gates results at. */
2728 export const MATCH_ENDPOINT = "/api/match";
2829
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
2942 /**
3043 * What this module reads off a response: whether it landed, the status it was
3144 * refused with — a 429 is worth another attempt, a 422 is not — and the
@@ -47,6 +60,8 @@ export type Posts = (
4760 */
4861 export type Wire = {
4962 fetch: Posts;
63 /** Where to post; `MATCH_ENDPOINT` when unsaid. See `endpointFor`. */
64 endpoint?: string;
5065 now(): number;
5166 say(line: string): void;
5267 board(html: string): void;
@@ -104,7 +119,7 @@ export function createReporter(wire: Wire): Reporter {
104119 void (async (): Promise<void> => {
105120 let answer: Answer = { ok: false, status: null };
106121 try {
107 const response = await wire.fetch(MATCH_ENDPOINT, {
122 const response = await wire.fetch(wire.endpoint ?? MATCH_ENDPOINT, {
108123 method: "POST",
109124 headers: { "content-type": "application/json" },
110125 body,
src/client/stage.test-helper.ts +3
@@ -220,6 +220,8 @@ export function fakeStage(
220220 * plans its rounds in the browser and posts nothing.
221221 */
222222 planEndpoint?: string;
223 /** The `data-board` the server puts on `#stats` when `?board=` named a map. */
224 board?: string;
223225 } = {},
224226 ): FakeStage {
225227 const page = fakePage({
@@ -239,6 +241,7 @@ export function fakeStage(
239241 if (options.unlinked !== true) page.by("weights").setAttribute("href", WEIGHTS_URL);
240242 // And the meta the shell renders for an endpoint, when this page has one.
241243 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);
242245 // The same switch on both sides of the seam: `webgl: false` is a browser with
243246 // no context to give, whether the frame asks this page's `host` for a renderer
244247 // or — as the committed bundle does — asks the canvas itself.
src/gate.ts +7 −2
@@ -82,8 +82,13 @@ export type Gate = {
8282 refusal: string;
8383 /** The numbers, validated by shape and capped at what the game can produce. */
8484 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;
8792 };
8893
8994 /**
src/pages/game.test.ts +10
@@ -580,3 +580,13 @@ test("the fold costs `/` nothing over the wire: the style block ships without it
580580 // 9,562 bytes gzipped before #255; the page is held under that.
581581 expect(Bun.gzipSync(html).byteLength).toBeLessThan(9562);
582582 });
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(
792792 }),
793793 sectionsHtml: `${thisMatch(asked)}
794794
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>
796796 ${SAVED_LINE}
797797
798798 <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
20332033 // And no `?board=` at all is the whole board with no complaint.
20342034 expect(await (await fetch(`${base}/`)).text()).not.toContain(escape(BAD_BOARD));
20352035 });
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[] = [
638638 const result = parseResult(value);
639639 return result === null ? null : { name: result.name, write: (db, player) => saveMatch(db, player, result) };
640640 },
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"))),
642644 },
643645 {
644646 path: "/api/run",
@@ -912,7 +914,7 @@ async function route(request: Request, listener: Bun.Server<Joined>): Promise<Re
912914 const session = held ?? newSession();
913915 const stored = checked.write(db, playerOf(visitor, session, checked.name));
914916 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) });
916918 }
917919
918920 /**

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs