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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/417-a-feed-row-says-which-map-it-was-and-a-g → main 1 commit fork
3 files
changed +68
−28
src/pages/game.test.ts +42 −15
| @@ -26,7 +26,8 @@ import { join } from "node:path"; | ||
| 26 | 26 | import { IDLE_SECONDS, openDatabase, type Player, recentMatches, saveMatch, standings } from "../db.ts"; |
| 27 | 27 | import { dailyOn, NOT_A_LADDER, RANKED_BY, saidToday } from "../daily.ts"; |
| 28 | 28 | 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"; | |
| 30 | 31 | import type { Result } from "../result.ts"; |
| 31 | 32 | import { CARD, type Card } from "../client/weights.ts"; |
| 32 | 33 | 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 | ||
| 134 | 135 | expect(html).toContain(`<select id="psound"><option value="on">On</option><option value="off">Off</option></select>`); |
| 135 | 136 | }); |
| 136 | 137 | |
| 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", () => { | |
| 138 | 139 | const home = db(); |
| 139 | 140 | 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, | |
| 142 | 143 | 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">`)); | |
| 146 | 147 | |
| 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("<script>alert(1)</script></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>`, `<script>alert(1)</script>`], | |
| 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 | } | |
| 155 | 182 | }); |
| 156 | 183 | |
| 157 | 184 | 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"; | ||
| 31 | 31 | import { SENS, SOUND_LABELS, SOUNDS } from "../client/settings.ts"; |
| 32 | 32 | import { FLASH, HE, SMOKE } from "../client/weapons.ts"; |
| 33 | 33 | 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"; | |
| 35 | 35 | import { LONGEST_PASTE } from "../matchlink.ts"; |
| 36 | 36 | import { roomLink } from "../roomlink.ts"; |
| 37 | 37 | import type { Who } from "../who.ts"; |
| @@ -55,6 +55,21 @@ const STATE_SAID: Record<Liveness, string> = { | ||
| 55 | 55 | abandoned: " · abandoned", |
| 56 | 56 | }; |
| 57 | 57 | |
| 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 | ||
| 58 | 73 | /** The link that plays the match of the day: its id, on the map that id carves. */ |
| 59 | 74 | export const dailyLink = (daily: Daily): string => `/?match=${daily.match}&map=generated`; |
| 60 | 75 | |
| @@ -160,12 +175,7 @@ export function statsHtml(db: Database, now = Date.now(), board: PlayedMap | nul | ||
| 160 | 175 | .join(""); |
| 161 | 176 | const feed = recent |
| 162 | 177 | .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)} ` + | |
| 169 | 179 | `<span>${m.kills}k ${m.deaths}d · ${m.damage} damage${STATE_SAID[liveness(m, now)]}</span> ` + |
| 170 | 180 | `<time>${escape(m.updated_at)}</time></li>`, |
| 171 | 181 | ) |
| @@ -335,11 +345,14 @@ a.share[hidden] { display:none; } | ||
| 335 | 345 | the one the browser measured off the level: the line changes, the box does not. */ |
| 336 | 346 | #planalt { min-height:2.5em; } |
| 337 | 347 | #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; } | |
| 343 | 356 | ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-decoration:underline; } |
| 344 | 357 | /* The maps the all-time board can be read on: share-sized links that wrap on a |
| 345 | 358 | 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 () | ||
| 279 | 279 | // played de_forge, which is what the row says. |
| 280 | 280 | expect((await post(result({ name: "oldclient" }), cookie)).status).toBe(200); |
| 281 | 281 | 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>/); | |
| 283 | 283 | // And a map that is not one is the 422 every other bad field gets. The map |
| 284 | 284 | // is the browser's claim like every number beside it — checked for shape, |
| 285 | 285 | // never believed — so nothing that is not a map reaches the table. |