Public: anyone can read this; the crew can change it.
forked from trevato/cs
The reference gets an address: the weapons and damage tables become a page you can link to #289
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/272-the-reference-gets-an-address-the-weapon → main 1 commit fork
7 files
changed +355
−69
README.md +34 −12
| @@ -336,25 +336,36 @@ reads as live with no flag to clear and no special case anywhere. The standings | ||
| 336 | 336 | are untouched either way: an abandoned match counts the rounds, kills and damage |
| 337 | 337 | it scored, exactly as it did before. |
| 338 | 338 | |
| 339 | ## The numbers, on the page | |
| 339 | ## The numbers, on a page of their own | |
| 340 | 340 | |
| 341 | Under the game on `/` is every weapon in the registry with what it costs, what | |
| 342 | it fires, how fast it lets you walk and how much of a vest it goes through — | |
| 343 | and beside it the thing a player actually argues about: what one shot does to a | |
| 344 | person. Head, chest and leg, against a bare body and against kevlar and a | |
| 345 | helmet, with the shots it takes to put that body down, and a last column saying | |
| 346 | how far one shot to the head still kills from. The Deagle's row says *all 8,192 | |
| 347 | units*; the M4A4 through a helmet says *never*; the AK says *2,840 units*. | |
| 341 | `/weapons` is every weapon in the registry with what it costs, what it fires, | |
| 342 | how fast it lets you walk and how much of a vest it goes through — and beside | |
| 343 | it the thing a player actually argues about: what one shot does to a person. | |
| 344 | Head, chest and leg, against a bare body and against kevlar and a helmet, with | |
| 345 | the shots it takes to put that body down, and a last column saying how far one | |
| 346 | shot to the head still kills from. The Deagle's row says *all 8,192 units*; the | |
| 347 | M4A4 through a helmet says *never*; the AK says *2,840 units*. | |
| 348 | ||
| 349 | It has an address because it is the one part of this app you send to somebody: | |
| 350 | paste `/weapons` and they get the numbers and nothing else — no leaderboard, no | |
| 351 | menu, no essays. It prints, too: the nav, the pill, the footer and the terminal | |
| 352 | line are dropped on paper and the tables stop being scroll boxes, so what comes | |
| 353 | out of a printer is the figures. The shooter's page keeps three sentences of | |
| 354 | the same sheet and a link to it, rather than the whole table behind a | |
| 355 | `<details>`: a closed `<details>` still ships every byte of it to every | |
| 356 | visitor, and moving it took 1,586 gzipped bytes (9,231 raw) off `/`. | |
| 348 | 357 | |
| 349 | 358 | Not one of those figures is typed. `src/sheet.ts` reads the table in |
| 350 | 359 | `src/client/weapons.ts` for the stats and fires the shots through |
| 351 | 360 | `src/client/combat.ts` for the damage — the same `applyHit` a bullet in a round |
| 352 | 361 | goes through, into a body with the health `src/client/world.ts` spawns and the |
| 353 | 362 | armour `src/client/economy.ts` sells — so change a number in `weapons.ts` and |
| 354 | the page moves with it, and a test fails if it does not. `src/pages/weapons.ts` | |
| 355 | renders that sheet into the first paint (no request, no script, built once at | |
| 356 | start-up), and the terminal's **weapons** tab renders the same sheet as | |
| 357 | columns. The assumptions are printed under the tables, because a figure without | |
| 363 | the page moves with it, and a test fails if it does not. That holds for the | |
| 364 | summary on `/` as much as for the tables: how many guns there are, what the | |
| 365 | dearest costs and which of them kill with one shot to a helmeted head are all | |
| 366 | read off the same sheet. `src/pages/weapons.ts` renders it into the first paint | |
| 367 | (no request, no script, the whole document built and gzipped once at start-up), | |
| 368 | and the terminal's **weapons** tab renders the same sheet as columns. The assumptions are printed under the tables, because a figure without | |
| 358 | 369 | the body it was measured against is a rumour. |
| 359 | 370 | |
| 360 | 371 | ## Playing |
| @@ -1200,6 +1211,17 @@ never checked itself. | ||
| 1200 | 1211 | SVG data URI of the crosshair — so **`/favicon.ico` is a request no browser |
| 1201 | 1212 | makes here**: no file to serve, no second round trip, and the CSP |
| 1202 | 1213 | already allows `img-src 'self' data:`. |
| 1214 | - `src/pages/weapons.ts` — **the read route no game owns**: `/weapons`, the | |
| 1215 | reference, through that same shell. It is not in `src/games.ts` because it is | |
| 1216 | not a game — no bundle, no assets, no ceiling, no state and nothing to run — | |
| 1217 | but it answers on exactly a game's terms: a document with its own style block | |
| 1218 | hashed into its own `style-src`, the security floor, gzip through `send`, and | |
| 1219 | `HEAD` for free. Its body cannot change between requests, because the weapons | |
| 1220 | cannot, so `src/server.ts` renders **and weighs** it once at start-up and a | |
| 1221 | hit is a map lookup and a write of bytes that already exist — the same | |
| 1222 | bargain the 404 makes. The shooter's page gets `weaponsSummary()` instead of | |
| 1223 | the tables: three sentences read off the same sheet, and the link. That is | |
| 1224 | one module rendering the numbers in two shapes, never two copies of them. | |
| 1203 | 1225 | - `src/cost.ts` and `src/bytes.ts` — **the numbers `/example` states about |
| 1204 | 1226 | itself are measured, not typed.** That page's whole claim is what a third game |
| 1205 | 1227 | costs, so `src/cost.ts` weighs the bundles this boot built with |
src/pages/game.test.ts +19 −11
| @@ -23,7 +23,7 @@ import type { Result } from "../result.ts"; | ||
| 23 | 23 | import { CARD, type Card } from "../client/weights.ts"; |
| 24 | 24 | import { GAMES } from "../games.ts"; |
| 25 | 25 | import { gamePage, policyHtml, shareHref, statsHtml, STYLE, BAD_ID, BAD_MAP, type PageOptions } from "./game.ts"; |
| 26 | import { WEAPONS_SECTION } from "./weapons.ts"; | |
| 26 | import { WEAPONS_HREF, WEAPONS_SECTION, WEAPONS_SUMMARY } from "./weapons.ts"; | |
| 27 | 27 | import { escape } from "./shell.ts"; |
| 28 | 28 | |
| 29 | 29 | const db = (): ReturnType<typeof openDatabase> => openDatabase(mkdtempSync(join(tmpdir(), "game-"))); |
| @@ -296,16 +296,24 @@ test("the menu offers today's match beside the other controls, with its map name | ||
| 296 | 296 | expect(html).toContain(escape(dailyOn(now).map)); |
| 297 | 297 | }); |
| 298 | 298 | |
| 299 | test("the weapons table is in the first paint, with its own CSS inside this page's block", () => { | |
| 299 | test("the weapons tables are a link now, and the summary that carries it is the sheet's", () => { | |
| 300 | 300 | const html = gamePage(db(), options); |
| 301 | // The section itself, whole and built once: `weapons.test.ts` holds the | |
| 302 | // figures in it honest, and this holds the page to carrying them. | |
| 303 | expect(html).toContain(WEAPONS_SECTION); | |
| 304 | expect(html).toContain(`<h2>Every weapon, and what it does to a person</h2>`); | |
| 305 | // Its rules are in the one style block the policy hashes, not a second one. | |
| 301 | // The summary, whole and built once: `weapons.test.ts` holds every figure in | |
| 302 | // it honest, and this holds the page to carrying it and to linking out. | |
| 303 | expect(html).toContain(WEAPONS_SUMMARY); | |
| 304 | expect(html).toContain(`<a class="share" href="${WEAPONS_HREF}">`); | |
| 305 | // The menu points at the same address, because a table nobody can find | |
| 306 | // settles nothing — and it is an address now, not an anchor down the page. | |
| 307 | expect(html).toContain(`<a href="${WEAPONS_HREF}">the weapons page</a>`); | |
| 308 | expect(html).not.toContain(`href="#weapons"`); | |
| 309 | // And the tables themselves are not here: neither the rows, nor the | |
| 310 | // assumptions under them, nor the CSS that laid them out. A page that kept | |
| 311 | // them behind a `<details>` would still be shipping every byte of them. | |
| 312 | expect(html).not.toContain(WEAPONS_SECTION); | |
| 313 | expect(html).not.toContain(`<div class="scroll"`); | |
| 314 | expect(html).not.toContain("one head shot kills to"); | |
| 315 | expect(html).not.toContain("#weapons .scroll { overflow-x:auto;"); | |
| 316 | expect(STYLE).not.toContain("#weapons"); | |
| 317 | // One style block still, and it is this page's own. | |
| 306 | 318 | expect(html.match(/<style>/g)?.length).toBe(1); |
| 307 | expect(html).toContain("#weapons .scroll { overflow-x:auto;"); | |
| 308 | expect(STYLE).toContain("#weapons table { min-width:40rem;"); | |
| 309 | // And the menu points at it, because a table nobody can find settles nothing. | |
| 310 | expect(html).toContain(`<a href="#weapons">under the game on this page</a>`); | |
| 311 | 319 | }); |
src/pages/game.ts +9 −8
| @@ -18,7 +18,7 @@ import { mapFrom, mapLink, type MapChoice } from "../mapname.ts"; | ||
| 18 | 18 | import { LONGEST_PASTE } from "../matchlink.ts"; |
| 19 | 19 | import type { Who } from "../who.ts"; |
| 20 | 20 | import { navFor, noscriptFor, othersHtml } from "./games.ts"; |
| 21 | import { STYLE as WEAPONS_STYLE, WEAPONS_SECTION } from "./weapons.ts"; | |
| 21 | import { WEAPONS_HREF, WEAPONS_SUMMARY } from "./weapons.ts"; | |
| 22 | 22 | import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| 23 | 23 | |
| 24 | 24 | export { escape }; |
| @@ -160,9 +160,10 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script | ||
| 160 | 160 | * block with this appended and puts that hash in this page's `style-src`, so |
| 161 | 161 | * a rule added here has to be a rule the header knows about. It is a constant |
| 162 | 162 | * for the same reason — a style block built per request could not be hashed |
| 163 | * before the request. The weapons table's own rules are appended from | |
| 164 | * `./weapons.ts` at the end of it: that section belongs to this page, so its | |
| 165 | * CSS is inside this page's block and inside this page's hash. | |
| 163 | * before the request. The weapons tables are not here at all any more: they are | |
| 164 | * `/weapons`, with their own block and their own hash, and what is left of them | |
| 165 | * on this page is a summary made of the shell's own classes and this page's | |
| 166 | * `a.share` — so a reference that moved took its rules with it. | |
| 166 | 167 | */ |
| 167 | 168 | export const STYLE = ` |
| 168 | 169 | #thismatch code { user-select:all; word-break:break-all; } |
| @@ -213,7 +214,7 @@ ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-dec | ||
| 213 | 214 | #buyshelf { display:flex; flex-direction:column; align-items:center; gap:.75rem; width:min(40rem,100%); } |
| 214 | 215 | #buyshelf:focus { outline:none; } |
| 215 | 216 | #buyshelf:focus-visible { outline:2px solid var(--ct); outline-offset:.35rem; border-radius:.45rem; } |
| 216 | ${WEAPONS_STYLE}`; | |
| 217 | `; | |
| 217 | 218 | |
| 218 | 219 | /** What the menu's hint under the id field says when the link carried junk. */ |
| 219 | 220 | export const BAD_ID = "That link's match id is not one — this is a fresh match instead."; |
| @@ -369,8 +370,8 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => ` | ||
| 369 | 370 | <p class="hint">Dead, you watch a teammate until the round ends: fire for the next player, |
| 370 | 371 | aim for the one before.</p> |
| 371 | 372 | <p class="hint">Every weapon's numbers — what it costs, what it fires and what one shot of it |
| 372 | does to a person — are <a href="#weapons">under the game on this page</a>, rendered from the | |
| 373 | table the fight is tuned by.</p> | |
| 373 | does to a person — are on <a href="${WEAPONS_HREF}">the weapons page</a>, rendered from the | |
| 374 | table the fight is tuned by. It is a page of its own so it is a link you can send.</p> | |
| 374 | 375 | `; |
| 375 | 376 | |
| 376 | 377 | /** |
| @@ -573,6 +574,6 @@ export function gamePage( | ||
| 573 | 574 | this game: ${othersHtml("/")}</p> |
| 574 | 575 | </section> |
| 575 | 576 | |
| 576 | ${WEAPONS_SECTION}`, | |
| 577 | ${WEAPONS_SUMMARY}`, | |
| 577 | 578 | }); |
| 578 | 579 | } |
src/pages/weapons.test.ts +76 −2
| @@ -14,9 +14,12 @@ | ||
| 14 | 14 | import { expect, test } from "bun:test"; |
| 15 | 15 | import { shotCost } from "../client/combat.ts"; |
| 16 | 16 | import { WEAPONS, type Weapon } from "../client/weapons.ts"; |
| 17 | import { ALL, damageRows, GROUPS, thrownSaid } from "../sheet.ts"; | |
| 17 | import { GAMES } from "../games.ts"; | |
| 18 | import { ALL, damageRows, GROUPS, gunsOf, thrownSaid } from "../sheet.ts"; | |
| 18 | 19 | import { escape } from "./shell.ts"; |
| 19 | import { STYLE, weaponsSection, WEAPONS_SECTION } from "./weapons.ts"; | |
| 20 | import { | |
| 21 | STYLE, weaponsPage, weaponsSection, weaponsSummary, WEAPONS_HREF, WEAPONS_SECTION, WEAPONS_SUMMARY, | |
| 22 | } from "./weapons.ts"; | |
| 20 | 23 | |
| 21 | 24 | /** A weapons table nobody tuned: one gun, and not one of today's numbers on it. */ |
| 22 | 25 | const TOY: Weapon = { |
| @@ -110,4 +113,75 @@ test("the tables scroll in their own box, reachable by a keyboard", () => { | ||
| 110 | 113 | test("the section is built once, not per request", () => { |
| 111 | 114 | expect(WEAPONS_SECTION).toBe(weaponsSection()); |
| 112 | 115 | expect(weaponsSection(ALL)).toBe(WEAPONS_SECTION); |
| 116 | expect(WEAPONS_SUMMARY).toBe(weaponsSummary()); | |
| 117 | }); | |
| 118 | ||
| 119 | const options = { env: "local", pull: undefined, commit: "abc1234", ssh: undefined }; | |
| 120 | ||
| 121 | test("the reference has an address of its own: a whole document, both tables in it", () => { | |
| 122 | const html = weaponsPage(options); | |
| 123 | expect(html.startsWith(`<!doctype html>\n<html lang="en">\n`)).toBe(true); | |
| 124 | expect(html).toContain("<title>weapons — every number cs is tuned by</title>"); | |
| 125 | expect(html).toContain("<h1>weapons</h1>"); | |
| 126 | // The section, whole, and out of the same module that used to hand it to the | |
| 127 | // shooter — the tables are rendered once, wherever they are shown. | |
| 128 | expect(html).toContain(WEAPONS_SECTION); | |
| 129 | // Nothing on it runs, so nothing is downloaded for it and nothing apologises | |
| 130 | // for a dead control it does not have. | |
| 131 | expect(html).not.toContain("<script"); | |
| 132 | expect(html).not.toContain("<noscript"); | |
| 133 | expect(html).not.toContain(`<div id="stage">`); | |
| 134 | // One style block, this page's own, which is what `src/server.ts` hashes. | |
| 135 | expect(html.match(/<style>/g)?.length).toBe(1); | |
| 136 | expect(html).toContain("#weapons table { min-width:40rem;"); | |
| 137 | // Every game is a way back out of it, and none of them is marked current: | |
| 138 | // this is not a game, so nothing up there is the page you are on. | |
| 139 | for (const game of GAMES) expect(html).toContain(`<a href="${escape(game.href)}">${escape(game.label)}</a>`); | |
| 140 | expect(html).not.toContain(`aria-current="page"`); | |
| 141 | }); | |
| 142 | ||
| 143 | test("a weapon added to the registry is on the page, with nobody editing the page", () => { | |
| 144 | const html = weaponsPage({ ...options, weapons: [...ALL, { ...TOY, name: "Newcomer", price: 999 }] }); | |
| 145 | expect(html).toContain("Newcomer"); | |
| 146 | expect(html).toContain(`<td class="n">$999</td>`); | |
| 147 | // And it is a row in both tables, not only the stat one: the damage columns | |
| 148 | // are computed for whatever is handed in. | |
| 149 | const head = shotCost({ ...TOY, name: "Newcomer", price: 999 }, "head", 0, 100, true); | |
| 150 | expect(html).toContain(`<td class="n">${head.health} <span>${head.shots} shots</span></td>`); | |
| 151 | }); | |
| 152 | ||
| 153 | test("the page is printable: the tables stay whole and the furniture goes", () => { | |
| 154 | expect(STYLE).toContain("@media print {"); | |
| 155 | // A scroll box clipped at the paper's edge prints a table with a column | |
| 156 | // missing, so on paper it is not a box. | |
| 157 | expect(STYLE).toContain("#weapons .scroll { overflow:visible; border:0; margin:0; }"); | |
| 158 | expect(STYLE).toContain("#weapons table { min-width:0; font-size:9pt; }"); | |
| 159 | // What is not the figures is not printed. | |
| 160 | for (const gone of ["header nav", "footer", "p.terminal"]) expect(STYLE).toContain(gone); | |
| 161 | }); | |
| 162 | ||
| 163 | test("the shooter's summary is three figures and a link, and every figure is the sheet's", () => { | |
| 164 | const html = weaponsSummary(); | |
| 165 | const guns = gunsOf(ALL); | |
| 166 | expect(html).toContain(`${guns.length} guns and ${ALL.length - guns.length} thrown`); | |
| 167 | expect(html).toContain(`$${Math.max(...guns.map((weapon) => weapon.price)).toLocaleString("en")} apiece`); | |
| 168 | // Which weapons one-tap a helmet is read off the damage rows, not listed by | |
| 169 | // hand: today that is the Deagle and the AWP. | |
| 170 | const oneTap = damageRows().filter((r) => r.kit === "armor" && r.cost.head.shots === 1).map((r) => r.weapon.name); | |
| 171 | expect(oneTap.length).toBeGreaterThan(0); | |
| 172 | for (const name of oneTap) expect(html).toContain(escape(name)); | |
| 173 | expect(html).toContain(`<a class="share" href="${WEAPONS_HREF}">`); | |
| 174 | // It is a summary and not the table: no row of either one is in it. | |
| 175 | expect(html).not.toContain("<table>"); | |
| 176 | }); | |
| 177 | ||
| 178 | test("handed a table nobody tuned, the summary moves with it too", () => { | |
| 179 | const html = weaponsSummary([TOY, WEAPONS.he]); | |
| 180 | expect(html).toContain("1 gun and 1 thrown"); | |
| 181 | expect(html).toContain("$12,345"); | |
| 182 | // The toy takes 7 damage a shot with a tenth of it through armour, so nothing | |
| 183 | // in this table one-taps a helmet — and the sentence says so rather than | |
| 184 | // naming a weapon that is not there. | |
| 185 | expect(html).toContain("Not one of them kills with a single shot to a helmeted head"); | |
| 186 | for (const gone of ["AK-47", "Desert Eagle", "$4,750"]) expect(html).not.toContain(gone); | |
| 113 | 187 | }); |
src/pages/weapons.ts +126 −15
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | * The weapons table, rendered: the section on `/` that prints what the game is | |
| 3 | * tuned by, and what each weapon does to a person. | |
| 2 | * The reference: the page at `/weapons` that prints what the game is tuned by, | |
| 3 | * and what each weapon does to a person. | |
| 4 | 4 | * |
| 5 | 5 | * It renders `src/sheet.ts` and holds no figure of its own. Every stat cell is |
| 6 | 6 | * a field of `WEAPONS`, every damage cell is `src/client/combat.ts` firing a |
| @@ -10,10 +10,20 @@ | ||
| 10 | 10 | * the only reason this file exists: a hand-typed "kills in one shot" column |
| 11 | 11 | * would be a claim about the simulation rather than a reading of it. |
| 12 | 12 | * |
| 13 | * It costs a request nothing: the table is built once at module load into | |
| 14 | * `WEAPONS_SECTION` — the sheet is the same for every visitor, because the | |
| 15 | * weapons are — and `src/pages/game.ts` interpolates that string. Nothing here | |
| 16 | * runs per request. | |
| 13 | * It has an address of its own because it is the one part of this app a person | |
| 14 | * sends to somebody else: "here is what the AK does through armour" is a link | |
| 15 | * now, not a page of the shooter you have to scroll past a leaderboard to | |
| 16 | * reach. The shooter keeps a way in — `weaponsSummary` below, three sentences | |
| 17 | * made of the same sheet and a link — rather than the whole table behind a | |
| 18 | * `<details>`, because a closed `<details>` still ships every byte of it to | |
| 19 | * every visitor and the point of moving it was that the shooter's page stopped | |
| 20 | * being a manual with a Play button in it. | |
| 21 | * | |
| 22 | * It costs a request nothing: the section and the summary are built once at | |
| 23 | * module load into `WEAPONS_SECTION` and `WEAPONS_SUMMARY` — the sheet is the | |
| 24 | * same for every visitor, because the weapons are — `src/server.ts` renders the | |
| 25 | * whole document once at start-up and `src/pages/game.ts` interpolates the | |
| 26 | * summary. Nothing here runs per request. | |
| 17 | 27 | * |
| 18 | 28 | * Every cell is escaped on the way out like anywhere else markup is made here, |
| 19 | 29 | * even though nothing in it came from a visitor: the escaping is the boundary, |
| @@ -21,19 +31,29 @@ | ||
| 21 | 31 | */ |
| 22 | 32 | |
| 23 | 33 | import { |
| 24 | ALL, ASSUMED, damageRows, GROUPS, round, seconds, thrownSaid, | |
| 34 | ALL, ASSUMED, damageRows, GROUPS, gunsOf, round, seconds, thrownSaid, | |
| 25 | 35 | type DamageRow, type Kit, |
| 26 | 36 | } from "../sheet.ts"; |
| 27 | 37 | import type { ShotCost } from "../client/combat.ts"; |
| 28 | 38 | import { isThrown, type Weapon } from "../client/weapons.ts"; |
| 29 | import { escape } from "./shell.ts"; | |
| 39 | import { navFor } from "./games.ts"; | |
| 40 | import { escape, page, type PageOptions as ShellOptions } from "./shell.ts"; | |
| 41 | ||
| 42 | /** Where the reference answers. The shooter links it; `src/server.ts` routes it. */ | |
| 43 | export const WEAPONS_HREF = "/weapons"; | |
| 30 | 44 | |
| 31 | 45 | /** |
| 32 | * This section's own CSS, appended to the shooter's block (and through it to | |
| 33 | * the hash in the page's `style-src`). Its whole job is the phone: the table is | |
| 34 | * wider than 360px and always will be, so it scrolls *inside its box* — never | |
| 35 | * the page — and the box is focusable, because a region a mouse can scroll and | |
| 36 | * a keyboard cannot is a region half the visitors cannot read. | |
| 46 | * This page's own CSS, and the hash in its `style-src`. Its first job is the | |
| 47 | * phone: the table is wider than 360px and always will be, so it scrolls | |
| 48 | * *inside its box* — never the page — and the box is focusable, because a | |
| 49 | * region a mouse can scroll and a keyboard cannot is a region half the visitors | |
| 50 | * cannot read. | |
| 51 | * | |
| 52 | * Its second job is paper. A reference is a thing people print, and what is | |
| 53 | * worth printing is the figures: the nav, the pill, the commit, the footer and | |
| 54 | * the terminal line go, the scroll box stops being a box (a clipped table | |
| 55 | * prints clipped), and the colours become ink on white. Nothing about the | |
| 56 | * screen moves for it — `@media print` is read by nobody until they press it. | |
| 37 | 57 | */ |
| 38 | 58 | export const STYLE = ` |
| 39 | 59 | #weapons .scroll { overflow-x:auto; border:1px solid var(--line); border-radius:.45rem; margin:.5rem 0; } |
| @@ -48,6 +68,15 @@ export const STYLE = ` | ||
| 48 | 68 | #weapons ul.said li { padding:.35rem 0; border-bottom:1px solid var(--line); } |
| 49 | 69 | #weapons ul.said b { color:var(--text); } |
| 50 | 70 | #weapons ul.assumed { color:var(--dim); font-size:.8rem; margin:.5rem 0; padding-left:1.1rem; } |
| 71 | @media print { | |
| 72 | header nav, header .env, header small.commit, footer, p.terminal { display:none; } | |
| 73 | body { background:#fff; color:#000; } | |
| 74 | #weapons .scroll { overflow:visible; border:0; margin:0; } | |
| 75 | #weapons table { min-width:0; font-size:9pt; } | |
| 76 | #weapons th, #weapons td, #weapons ul.said li { border-bottom:1px solid #999; } | |
| 77 | #weapons th, #weapons td span, #weapons ul.assumed { color:#444; } | |
| 78 | #weapons b.kills, #weapons ul.said b { color:#000; } | |
| 79 | } | |
| 51 | 80 | `; |
| 52 | 81 | |
| 53 | 82 | /** A number as this page writes one: grouped by thousands, and escaped. */ |
| @@ -167,7 +196,89 @@ ${assumptions()} | ||
| 167 | 196 | |
| 168 | 197 | /** |
| 169 | 198 | * The section every visitor gets, built once here. The weapons do not change |
| 170 | * between requests, so neither does this: `src/pages/game.ts` interpolates the | |
| 171 | * string and a request pays for a table lookup's worth of nothing. | |
| 199 | * between requests, so neither does this: `src/server.ts` renders the document | |
| 200 | * around it once at start-up and a request pays for a map lookup's worth of | |
| 201 | * nothing. | |
| 172 | 202 | */ |
| 173 | 203 | export const WEAPONS_SECTION = weaponsSection(); |
| 204 | ||
| 205 | /** A count of things, with the thing named: `8 guns`, and `1 gun`. */ | |
| 206 | const count = (n: number, thing: string): string => `${num(n)} ${thing}${n === 1 ? "" : "s"}`; | |
| 207 | ||
| 208 | /** A list of names, in the order the registry holds them: `a, b and c`. */ | |
| 209 | const listed = (names: readonly string[]): string => | |
| 210 | names.length < 2 | |
| 211 | ? names.map(escape).join("") | |
| 212 | : `${names.slice(0, -1).map(escape).join(", ")} and ${escape(names[names.length - 1] as string)}`; | |
| 213 | ||
| 214 | /** | |
| 215 | * What the shooter's page says instead of the tables: the shape of the | |
| 216 | * reference in four figures, and the link. Every one of those figures is read | |
| 217 | * from the same sheet the tables are — how many guns, how many thrown, what the | |
| 218 | * dearest costs, and which of them kill with one shot to a helmeted head — so | |
| 219 | * the teaser cannot promise something the tables do not say. Nothing here is | |
| 220 | * typed either, which is the rule #224 set and the reason this is a function | |
| 221 | * over the weapons rather than a paragraph. | |
| 222 | */ | |
| 223 | export function weaponsSummary(weapons: readonly Weapon[] = ALL): string { | |
| 224 | const guns = gunsOf(weapons); | |
| 225 | const dearest = Math.max(...guns.map((weapon) => weapon.price)); | |
| 226 | const oneTap = damageRows(weapons) | |
| 227 | .filter((row) => row.kit === "armor" && row.cost.head.shots === 1) | |
| 228 | .map((row) => row.weapon.name); | |
| 229 | return ` <section id="weapons"> | |
| 230 | <h2>Every weapon, and what it does to a person</h2> | |
| 231 | <p>${count(guns.length, "gun")} and ${num(weapons.length - guns.length)} thrown, up to | |
| 232 | ${money(dearest)} apiece: what each one costs, what it fires, how fast it lets you walk, and | |
| 233 | what one shot of it takes off a head, a chest and a pair of legs — bare, and through kevlar | |
| 234 | and a helmet. ${ | |
| 235 | oneTap.length === 0 | |
| 236 | ? `Not one of them kills with a single shot to a helmeted head at the muzzle.` | |
| 237 | : `${num(oneTap.length)} of them ${oneTap.length === 1 ? "kills" : "kill"} with one shot to a | |
| 238 | helmeted head at the muzzle: <b>${listed(oneTap)}</b>.` | |
| 239 | }</p> | |
| 240 | <p><a class="share" href="${WEAPONS_HREF}">The weapons and damage tables</a></p> | |
| 241 | <p class="hint">Every figure on that page is read from the table the fight is tuned by and from | |
| 242 | the same arithmetic a bullet in a round goes through — none of it is typed.</p> | |
| 243 | </section>`; | |
| 244 | } | |
| 245 | ||
| 246 | /** The summary every visitor to the shooter gets, built once for the same reason. */ | |
| 247 | export const WEAPONS_SUMMARY = weaponsSummary(); | |
| 248 | ||
| 249 | /** | |
| 250 | * What the shell needs that this page cannot know: the environment it runs in. | |
| 251 | * `weapons` is here for the same reason `weaponsSection` takes one — a test | |
| 252 | * hands it a table nobody tuned and reads back a document with none of today's | |
| 253 | * numbers on it. | |
| 254 | */ | |
| 255 | export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "ssh"> & { | |
| 256 | weapons?: readonly Weapon[]; | |
| 257 | }; | |
| 258 | ||
| 259 | /** | |
| 260 | * The reference, whole: the same section the shooter used to carry, in the same | |
| 261 | * document every game on this server is served inside. It hands the shell no | |
| 262 | * script, because nothing on it runs — the tables are the first paint and there | |
| 263 | * is nothing to boot — and therefore no `<noscript>` line either: a page with | |
| 264 | * no dead control has nothing to apologise for. | |
| 265 | * | |
| 266 | * The nav marks nothing current, like the 404's: this is not a game, it keeps | |
| 267 | * no state and ships no bundle, so it is not in `../games.ts` — every entry up | |
| 268 | * there is a way back to something you can play. | |
| 269 | */ | |
| 270 | export function weaponsPage({ weapons, ...options }: PageOptions): string { | |
| 271 | return page({ | |
| 272 | ...options, | |
| 273 | title: "weapons — every number cs is tuned by", | |
| 274 | description: | |
| 275 | "Every weapon in cs with what it costs, what it fires and how fast it lets you walk, and what one shot of it does to a head, a chest and a pair of legs — bare and through armour.", | |
| 276 | heading: "weapons", | |
| 277 | nav: navFor(WEAPONS_HREF), | |
| 278 | terminalLead: "The same two tables in a terminal, on the weapons tab:", | |
| 279 | footer: "no database, no cookie: this page is a reading of the simulation", | |
| 280 | extraStyle: STYLE, | |
| 281 | stageHtml: "", | |
| 282 | sectionsHtml: weapons === undefined ? WEAPONS_SECTION : weaponsSection(weapons), | |
| 283 | }); | |
| 284 | } |
src/server.test.ts +66 −19
| @@ -12,6 +12,8 @@ import { matchOf, playKey } from "./matchid.ts"; | ||
| 12 | 12 | import { LONGEST_PASTE } from "./matchlink.ts"; |
| 13 | 13 | import { NOTHING_POSTED } from "./client/pending.ts"; |
| 14 | 14 | import { BAD_ID, BAD_MAP, UNDRAWN } from "./pages/game.ts"; |
| 15 | import { WEAPONS_HREF, WEAPONS_SECTION } from "./pages/weapons.ts"; | |
| 16 | import { WEAPONS } from "./client/weapons.ts"; | |
| 15 | 17 | import { endpoints, escape } from "./pages/shell.ts"; |
| 16 | 18 | import { localPlan, parsePlan } from "./plan/plan.ts"; |
| 17 | 19 | import { parseSituation, type Situation } from "./plan/situation.ts"; |
| @@ -522,6 +524,49 @@ test("a bug is a document too, and the error stays in the log", async () => { | ||
| 522 | 524 | expect(logged).toEqual([boom]); |
| 523 | 525 | }); |
| 524 | 526 | |
| 527 | test("the reference answers at its own address, with both tables on it", async () => { | |
| 528 | const response = await fetch(base + "/weapons"); | |
| 529 | expect(response.status).toBe(200); | |
| 530 | expect(response.headers.get("content-type")).toBe("text/html; charset=utf-8"); | |
| 531 | const body = await response.text(); | |
| 532 | expect(body).toContain("<h1>weapons</h1>"); | |
| 533 | // Every weapon in the registry is a row, and the damage table is under it — | |
| 534 | // the same section the shooter used to carry, out of the same module. | |
| 535 | for (const weapon of Object.values(WEAPONS)) expect(body).toContain(`>${escape(weapon.name)}</td>`); | |
| 536 | expect(body).toContain("one head shot kills to"); | |
| 537 | expect(body).toContain(WEAPONS_SECTION); | |
| 538 | // Nothing runs on it and nothing is stored for it: no script, no cookie. | |
| 539 | expect(body).not.toContain("<script"); | |
| 540 | expect(response.headers.get("set-cookie")).toBeNull(); | |
| 541 | }); | |
| 542 | ||
| 543 | test("the reference stands on the same security floor as the games, and HEAD answers like GET", async () => { | |
| 544 | const [game, reference] = await Promise.all([fetch(base + "/"), fetch(base + "/weapons")]); | |
| 545 | expect(floorOf(reference)).toEqual(floorOf(game)); | |
| 546 | expect(reference.headers.get("content-security-policy")).toContain("default-src 'self'"); | |
| 547 | // Its own style block, so its own hash: never the shooter's. | |
| 548 | expect(reference.headers.get("content-security-policy")) | |
| 549 | .not.toBe(game.headers.get("content-security-policy")); | |
| 550 | ||
| 551 | const head = await server.fetch(new Request(base + "/weapons", { method: "HEAD" })); | |
| 552 | expect(head.status).toBe(200); | |
| 553 | expect(await head.text()).toBe(""); | |
| 554 | expect(head.headers.get("content-security-policy")).toBe(reference.headers.get("content-security-policy")); | |
| 555 | // A path under it is a 404 like any other: one address answers, not a prefix. | |
| 556 | expect((await fetch(base + "/weapons/ak47")).status).toBe(404); | |
| 557 | }); | |
| 558 | ||
| 559 | test("the shooter links the reference instead of carrying it", async () => { | |
| 560 | const body = await (await fetch(base + "/")).text(); | |
| 561 | expect(body).toContain(`href="/weapons"`); | |
| 562 | expect(body).toContain("Every weapon, and what it does to a person"); | |
| 563 | // The tables are not in it any more — not the rows, not the scroll boxes, not | |
| 564 | // the assumptions under them. | |
| 565 | expect(body).not.toContain("one head shot kills to"); | |
| 566 | expect(body).not.toContain(`<div class="scroll"`); | |
| 567 | expect(body).not.toContain("Shots to kill are fired rather than divided"); | |
| 568 | }); | |
| 569 | ||
| 525 | 570 | test("the range stands on the same security floor as the game", async () => { |
| 526 | 571 | const [game, range] = await Promise.all([fetch(base + "/"), fetch(base + "/range")]); |
| 527 | 572 | expect(floorOf(range)).toEqual(floorOf(game)); |
| @@ -582,8 +627,10 @@ test("the policy names each page's own style block, hashed from the bytes the pa | ||
| 582 | 627 | // adds a rule and does not tell src/server.ts about it fails here, rather |
| 583 | 628 | // than losing its styling in a browser where nobody is watching. |
| 584 | 629 | const seen = new Set<string>(); |
| 585 | for (const game of GAMES) { | |
| 586 | const response = await fetch(base + game.href); | |
| 630 | // Every game, and the reference, which is not one: it is a read route on the | |
| 631 | // same terms, so its block is hashed into its own policy like theirs. | |
| 632 | for (const href of [...GAMES.map((game) => game.href), WEAPONS_HREF]) { | |
| 633 | const response = await fetch(base + href); | |
| 587 | 634 | const block = styleBlock(await response.text()); |
| 588 | 635 | expect(block.length).toBeGreaterThan(500); |
| 589 | 636 | expect(response.headers.get("content-security-policy")).toBe(policyFor(block)); |
| @@ -963,7 +1010,7 @@ test("a browser that asks for gzip is sent gzip, and it is exactly the bytes one | ||
| 963 | 1010 | const weights = /href="(\/static\/weights\.[0-9a-f]+\.json)"/.exec(page)?.[1] as string; |
| 964 | 1011 | // A page rendered for this request, an asset weighed at start-up, and the |
| 965 | 1012 | // 404, which is neither: the same rule under all three. |
| 966 | for (const path of ["/", "/range", "/example", script, weights, "/nope"]) { | |
| 1013 | for (const path of ["/", "/range", "/example", "/weapons", script, weights, "/nope"]) { | |
| 967 | 1014 | const plain = await raw(path); |
| 968 | 1015 | const zipped = await raw(path, ASKS); |
| 969 | 1016 | expect(zipped.status).toBe(plain.status); |
| @@ -1053,21 +1100,21 @@ test("every file in static/ is gzipped once, at start-up, and held beside its by | ||
| 1053 | 1100 | |
| 1054 | 1101 | /** |
| 1055 | 1102 | * What a first visit transfers to a browser that accepts gzip, per page, and |
| 1056 | * what each may not pass. Measured 2026-09-21 on this tree, compressed and on | |
| 1057 | * an empty database: `/` 9,683 bytes against 32,047 uncompressed, `/range` | |
| 1058 | * 6,631 against 17,655, `/example` 4,490 against 11,637 — before the bundle | |
| 1059 | * each of them then downloads. `/` is measured again by the test itself | |
| 1060 | * against the database these tests filled, which is the page at its heaviest: | |
| 1061 | * 10,032 gzipped against 34,300. | |
| 1103 | * what each may not pass. Measured 2026-09-22 on this tree, compressed and on | |
| 1104 | * an empty database: `/` 8,442 bytes against 23,933 uncompressed, `/weapons` | |
| 1105 | * 4,649 against 16,968, `/range` 6,631 against 17,655, `/example` 4,489 | |
| 1106 | * against 11,638 — before the bundle each of the games then downloads. | |
| 1107 | * `/weapons` downloads none: it has no script. | |
| 1062 | 1108 | * |
| 1063 | * `/` grew 1,879 gzipped (9,947 raw) for #199, and the whole of that growth is | |
| 1064 | * the weapons table: every weapon in the registry with its numbers, and what | |
| 1065 | * one shot of each does to a person, rendered server-side into the first paint | |
| 1066 | * rather than fetched. It is a table of figures, which is the shape gzip is | |
| 1067 | * worst at and a reader is best at, and it is what the page is *for* — the | |
| 1068 | * three issues before it argued about numbers nobody could see. The budget | |
| 1069 | * below was raised from 9,000 to 12,000 with it, which keeps the headroom it | |
| 1070 | * had: about a fifth over today, so a page that doubles still fails here. | |
| 1109 | * `/` fell 1,586 gzipped (9,231 raw) for #272, from 10,028 and 33,164, and the | |
| 1110 | * whole of that is the weapons tables leaving it for `/weapons`: #199 put them | |
| 1111 | * in the first paint, which was right, and by #266 the shooter's page was a | |
| 1112 | * manual with a Play button in it and the one thing a person most wanted to | |
| 1113 | * send somebody had no address. What is left on `/` in their place is three | |
| 1114 | * sentences of the same sheet and a link, so the budget below comes down from | |
| 1115 | * 12,000 to 10,500 — and the reference gets one of its own, 5,600, on the same | |
| 1116 | * terms. The two together are more bytes than the one page was, because the | |
| 1117 | * shell is paid for twice; neither visit pays for both. | |
| 1071 | 1118 | * |
| 1072 | 1119 | * The budgets are those numbers with room for a paragraph and a control or two, |
| 1073 | 1120 | * the same way `src/games.ts` writes a bundle's ceiling: tight enough that a |
| @@ -1077,9 +1124,9 @@ test("every file in static/ is gzipped once, at start-up, and held beside its by | ||
| 1077 | 1124 | * |
| 1078 | 1125 | * `/` is measured on this file's database, which by now holds every match the |
| 1079 | 1126 | * tests above posted, so it is the page at its heaviest rather than at its |
| 1080 | * emptiest. | |
| 1127 | * emptiest: 8,830 gzipped against 26,188 when this was written. | |
| 1081 | 1128 | */ |
| 1082 | const BUDGET: Record<string, number> = { "/": 12000, "/range": 7500, "/example": 5500 }; | |
| 1129 | const BUDGET: Record<string, number> = { "/": 10500, "/weapons": 5600, "/range": 7500, "/example": 5500 }; | |
| 1083 | 1130 | |
| 1084 | 1131 | test("a first visit stays inside its byte budget, compressed", async () => { |
| 1085 | 1132 | for (const [path, budget] of Object.entries(BUDGET)) { |
src/server.ts +25 −2
| @@ -10,7 +10,9 @@ | ||
| 10 | 10 | * routes below are that registry against `RENDER`, the one map from a game's |
| 11 | 11 | * name to the page that draws it and the CSS that page appends to the shell's |
| 12 | 12 | * style block. The POST route is not in it: `/api/match` belongs to the |
| 13 | * shooter, not to games in general. | |
| 13 | * shooter, not to games in general. Neither is `/weapons`, which is a page with | |
| 14 | * no game behind it — the reference the shooter used to carry in its own body — | |
| 15 | * and which is added to the same table below so it answers on the same terms. | |
| 14 | 16 | * |
| 15 | 17 | * The floor every response stands on is here too, and the one part of it that |
| 16 | 18 | * is not a constant is the style hash: each page's block is hashed once, at |
| @@ -64,6 +66,7 @@ import { createPlanner, type Planner } from "./plan/typesafe.ts"; | ||
| 64 | 66 | import { matchFrom } from "./matchid.ts"; |
| 65 | 67 | import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts"; |
| 66 | 68 | import { boardsHtml, rangePage } from "./pages/range.ts"; |
| 69 | import { STYLE as WEAPONS_STYLE, weaponsPage, WEAPONS_HREF } from "./pages/weapons.ts"; | |
| 67 | 70 | import { policyFor } from "./policy.ts"; |
| 68 | 71 | import { styleFor, type Linked } from "./pages/shell.ts"; |
| 69 | 72 | import { cleanName, parseResult } from "./result.ts"; |
| @@ -551,7 +554,7 @@ const gateAt = (path: string): Gate | undefined => GATES.find((gate) => gate.pat | ||
| 551 | 554 | // against its renderer holding its own bundle. A game in the registry with no |
| 552 | 555 | // page here is a mistake that fails now, at the first boot, rather than as a |
| 553 | 556 | // 404 nobody expected. |
| 554 | const READ = new Map<string, (request: Request) => Response>( | |
| 557 | const READ = new Map<string, (request: Request) => Response | Ready>( | |
| 555 | 558 | GAMES.map((game) => { |
| 556 | 559 | const render = RENDER[game.name]; |
| 557 | 560 | if (render === undefined) throw new Error(`the game ${JSON.stringify(game.name)} has no page to render it`); |
| @@ -572,6 +575,26 @@ const READ = new Map<string, (request: Request) => Response>( | ||
| 572 | 575 | }), |
| 573 | 576 | ); |
| 574 | 577 | |
| 578 | /** | |
| 579 | * And the one read route no game owns: the weapons reference. It is not in | |
| 580 | * `./games.ts` because it is not a game — no bundle, no assets, no ceiling, no | |
| 581 | * state and nothing to run — but it answers exactly like one: a document | |
| 582 | * through the same shell, its own style hash in its own `style-src`, the floor, | |
| 583 | * gzip through `send`, and HEAD for free. | |
| 584 | * | |
| 585 | * Its body never changes, because the weapons do not, so it is rendered and | |
| 586 | * weighed once here and a request for it is a map lookup and a write of bytes | |
| 587 | * that already exist — the same bargain the 404 below makes, and the reason it | |
| 588 | * is a `Ready` rather than a function that renders. | |
| 589 | */ | |
| 590 | const WEAPONS_SECURITY = documentFloor(WEAPONS_STYLE); | |
| 591 | const REFERENCE: Ready = { | |
| 592 | body: weighText(weaponsPage({ env, pull, commit, ssh }), HTML_TYPE), | |
| 593 | status: 200, | |
| 594 | headers: { "content-type": HTML_TYPE, ...WEAPONS_SECURITY }, | |
| 595 | }; | |
| 596 | READ.set(WEAPONS_HREF, () => REFERENCE); | |
| 597 | ||
| 575 | 598 | // The two pages no game owns, rendered through the same shell every game is |
| 576 | 599 | // served inside: `src/pages/miss.ts` writes both. A wrong path is a dead end |
| 577 | 600 | // unless it says where the games are, so the 404 names every one of them and |
new above
offline