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

forked from trevato/cs

A feed row says which map it was, and a generated map's name plays it #423

Merged opened this

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

Merged

vesper/cs-engine:vesper/417-a-feed-row-says-which-map-it-was-and-a-g → main 1 commit fork

  • Merged. vesper/417-a-feed-row-says-which-map-it-was-and-a-g was merged into main as 15beb2d.

Review

Sign in to review this change.
3 files changed +68 −28
src/pages/game.test.ts +42 −15
@@ -26,7 +26,8 @@ import { join } from "node:path";
2626 import { IDLE_SECONDS, openDatabase, type Player, recentMatches, saveMatch, standings } from "../db.ts";
2727 import { dailyOn, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts";
2828 import { playKey } from "../matchid.ts";
29 import { LONGEST_PASTE } from "../matchlink.ts";
29 import { LONGEST_PASTE, pastedLink } from "../matchlink.ts";
30 import { type PlayedMap, seedOf } from "../mapname.ts";
3031 import type { Result } from "../result.ts";
3132 import { CARD, type Card } from "../client/weights.ts";
3233 import { COMPETITIVE, SHORT } from "../client/match.ts";
@@ -134,24 +135,50 @@ test("#401: the pause panel carries the settings, with the menu's range, once ea
134135 expect(html).toContain(`<select id="psound"><option value="on">On</option><option value="off">Off</option></select>`);
135136 });
136137
137 test("a recent match names the map it was played on, and the name is a link to it", () => {
138 test("#417: a recent match names its map, and only a generated map is a link that plays it", () => {
138139 const home = db();
139140 const player: Player = { userId: "u-1", session: "s1", name: "kestrel" };
140 const result: Result = {
141 matchId: crypto.randomUUID(), name: "ignored", map: "gen_ir1", rounds: 5, scoreMine: 5, scoreTheirs: 2,
141 const result = (map: PlayedMap): Result => ({
142 matchId: crypto.randomUUID(), name: "ignored", map, rounds: 5, scoreMine: 5, scoreTheirs: 2,
142143 kills: 9, deaths: 4, headshots: 3, damage: 1200, seconds: 420, finished: true,
143 };
144 expect(saveMatch(home, player, result)).toBe(true);
145 expect(gamePage(home, options)).toContain(`<a class="map" href="/?map=gen_ir1">gen_ir1</a>`);
144 });
145 expect(saveMatch(home, player, result("gen_ir1"))).toBe(true);
146 const feedOf = (html: string) => html.slice(html.indexOf(`<ul class="recent">`));
146147
147 // A row whose map is not a map — which `parseResult` will not write, but a
148 // hand on the database could — is escaped where it is printed, and its link
149 // is this app's own string rather than the row's bytes.
150 home.query(`UPDATE match SET map = ?1`).run(`<script>alert(1)</script>`);
151 const html = gamePage(home, options);
152 expect(html).not.toContain("<script>alert(1)");
153 expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;</a>");
154 expect(html).toContain(`<a class="map" href="/?map=de_forge">`);
148 // A generated map is a link, and the link parses back to the same seed.
149 const generated = feedOf(gamePage(home, options));
150 expect(generated).toContain(`<a class="map" href="/?map=gen_ir1">gen_ir1</a>`);
151 const href = generated.match(/<a class="map" href="([^"]+)"/)![1]!;
152 expect(seedOf(pastedLink(href, "https://cs.example")!.map)).toBe(seedOf("gen_ir1"));
153
154 // de_forge is named, and is not a link: Play is de_forge already.
155 home.query(`UPDATE match SET map = 'de_forge'`).run();
156 const forge = feedOf(gamePage(home, options));
157 expect(forge).toContain(`<span class="map">de_forge</span>`);
158 expect(forge).not.toContain(`<a class="map"`);
159
160 // A stored name that does not parse — which `parseResult` will not write, but
161 // a hand on the database could — is escaped text, never a link. So is a name
162 // that has the shape and not the round trip.
163 for (const [stored, said] of [
164 [`<script>alert(1)</script>`, `&lt;script&gt;alert(1)&lt;/script&gt;`],
165 [`gen_0ir1`, `gen_0ir1`],
166 [`generated`, `generated`],
167 ] as const) {
168 home.query(`UPDATE match SET map = ?1`).run(stored);
169 const html = feedOf(gamePage(home, options));
170 expect(html).not.toContain("<script>alert(1)");
171 expect(html).toContain(`<span class="map">${said}</span>`);
172 expect(html).not.toContain(`<a class="map"`);
173 }
174 });
175
176 test("#417: a feed row's map truncates in a capped box, so a long name cannot widen a row", () => {
177 const css = gamePage(db(), options);
178 const rule = css.match(/ul\.recent \.map \{([^}]+)\}/)![1]!;
179 for (const decl of ["max-width:9rem", "overflow:hidden", "text-overflow:ellipsis", "white-space:nowrap"]) {
180 expect(rule).toContain(decl);
181 }
155182 });
156183
157184 test("a match that stopped posting reads as abandoned in the feed, and still counts what it scored", () => {
src/pages/game.ts +25 −12
@@ -31,7 +31,7 @@ import { NOTHING_POSTED } from "../client/pending.ts";
3131 import { SENS, SOUND_LABELS, SOUNDS } from "../client/settings.ts";
3232 import { FLASH, HE, SMOKE } from "../client/weapons.ts";
3333 import { CARD, type Card } from "../client/weights.ts";
34 import { mapFrom, mapLink, type MapChoice, type PlayedMap } from "../mapname.ts";
34 import { mapLink, type MapChoice, playedOn, type PlayedMap } from "../mapname.ts";
3535 import { LONGEST_PASTE } from "../matchlink.ts";
3636 import { roomLink } from "../roomlink.ts";
3737 import type { Who } from "../who.ts";
@@ -55,6 +55,21 @@ const STATE_SAID: Record<Liveness, string> = {
5555 abandoned: " · abandoned",
5656 };
5757
58 /**
59 * The map a feed row was played on (#417). A generated map is a link that plays
60 * it again, built by `mapLink` like every other map link on the page; de_forge
61 * is plain text, because Play is already de_forge; and a stored name that
62 * `playedOn` does not accept — which `parseResult` will not write, but a hand
63 * on the database could — is plain escaped text, never a link. The href is
64 * never the stored bytes: it is the canonical name the seed prints as.
65 */
66 export function feedMap(stored: unknown): string {
67 const map = playedOn(stored);
68 const said = escape(typeof stored === "string" ? stored : "");
69 if (map === null || map === "de_forge") return `<span class="map">${said}</span>`;
70 return `<a class="map" href="${escape(mapLink(map))}">${escape(map)}</a>`;
71 }
72
5873 /** The link that plays the match of the day: its id, on the map that id carves. */
5974 export const dailyLink = (daily: Daily): string => `/?match=${daily.match}&map=generated`;
6075
@@ -160,12 +175,7 @@ export function statsHtml(db: Database, now = Date.now(), board: PlayedMap | nul
160175 .join("");
161176 const feed = recent
162177 .map(
163 // The map is a link to that map, and the href is never the stored bytes:
164 // `mapFrom` hands back this app's own string for a name it recognises and
165 // de_forge for anything else, so the row's text is escaped and its link
166 // is built rather than copied.
167 (m) => `<li><b>${escape(displayName(m))}</b> ${m.score_mine}–${m.score_theirs} ` +
168 `<a class="map" href="${mapLink(mapFrom(m.map))}">${escape(m.map)}</a> ` +
178 (m) => `<li><b>${escape(displayName(m))}</b> ${m.score_mine}–${m.score_theirs} ${feedMap(m.map)} ` +
169179 `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${STATE_SAID[liveness(m, now)]}</span> ` +
170180 `<time>${escape(m.updated_at)}</time></li>`,
171181 )
@@ -335,11 +345,14 @@ a.share[hidden] { display:none; }
335345 the one the browser measured off the level: the line changes, the box does not. */
336346 #planalt { min-height:2.5em; }
337347 #willplay { user-select:all; }
338 /* The map a recent match was played on, which is also the link that plays it.
339 Padded rather than bare so a thumb has something to land on in a list of
340 rows, and dim like the figures beside it until it is hovered or focused. */
341 ul.recent a.map { color:var(--dim); text-decoration:none; padding:.35rem .4rem; margin:-.35rem 0;
342 border-radius:.3rem; background:#161b24; }
348 /* The map a recent match was played on; a generated one is also the link that
349 plays it. One box for both, capped and truncated, so a long name cannot
350 widen a row on a phone and a link and plain text take the same room. Padded
351 rather than bare so a thumb has something to land on in a list of rows, and
352 dim like the figures beside it until it is hovered or focused. */
353 ul.recent .map { color:var(--dim); padding:.35rem .4rem; margin:-.35rem 0; max-width:9rem; min-width:0;
354 overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
355 ul.recent a.map { text-decoration:none; border-radius:.3rem; background:#161b24; }
343356 ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-decoration:underline; }
344357 /* The maps the all-time board can be read on: share-sized links that wrap on a
345358 phone, and the one on screen told apart by more than its colour. */
src/server.test.ts +1 −1
@@ -279,7 +279,7 @@ test("a result says which map it was played on, and the feed names it", async ()
279279 // played de_forge, which is what the row says.
280280 expect((await post(result({ name: "oldclient" }), cookie)).status).toBe(200);
281281 const feed = await (await fetch(base + "/")).text();
282 expect(feed).toMatch(/oldclient \(guest\)<\/b>[^<]*<a class="map" href="\/\?map=de_forge">de_forge<\/a>/);
282 expect(feed).toMatch(/oldclient \(guest\)<\/b>[^<]*<span class="map">de_forge<\/span>/);
283283 // And a map that is not one is the 422 every other bad field gets. The map
284284 // is the browser's claim like every number beside it — checked for shape,
285285 // never believed — so nothing that is not a map reaches the table.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs