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

Merged opened this

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

Merged

torque/cs-engine:torque/272-the-reference-gets-an-address-the-weapon → main 1 commit fork

  • Merged. torque/272-the-reference-gets-an-address-the-weapon was merged into main as b7d6153.

Review

Sign in to review this change.
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
336336 are untouched either way: an abandoned match counts the rounds, kills and damage
337337 it scored, exactly as it did before.
338338
339 ## The numbers, on the page
339 ## The numbers, on a page of their own
340340
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 `/`.
348357
349358 Not one of those figures is typed. `src/sheet.ts` reads the table in
350359 `src/client/weapons.ts` for the stats and fires the shots through
351360 `src/client/combat.ts` for the damage — the same `applyHit` a bullet in a round
352361 goes through, into a body with the health `src/client/world.ts` spawns and the
353362 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
358369 the body it was measured against is a rumour.
359370
360371 ## Playing
@@ -1200,6 +1211,17 @@ never checked itself.
12001211 SVG data URI of the crosshair — so **`/favicon.ico` is a request no browser
12011212 makes here**: no file to serve, no second round trip, and the CSP
12021213 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.
12031225 - `src/cost.ts` and `src/bytes.ts` — **the numbers `/example` states about
12041226 itself are measured, not typed.** That page's whole claim is what a third game
12051227 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";
2323 import { CARD, type Card } from "../client/weights.ts";
2424 import { GAMES } from "../games.ts";
2525 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";
2727 import { escape } from "./shell.ts";
2828
2929 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
296296 expect(html).toContain(escape(dailyOn(now).map));
297297 });
298298
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", () => {
300300 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.
306318 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>`);
311319 });
src/pages/game.ts +9 −8
@@ -18,7 +18,7 @@ import { mapFrom, mapLink, type MapChoice } from "../mapname.ts";
1818 import { LONGEST_PASTE } from "../matchlink.ts";
1919 import type { Who } from "../who.ts";
2020 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";
2222 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
2323
2424 export { escape };
@@ -160,9 +160,10 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script
160160 * block with this appended and puts that hash in this page's `style-src`, so
161161 * a rule added here has to be a rule the header knows about. It is a constant
162162 * 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.
166167 */
167168 export const STYLE = `
168169 #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
213214 #buyshelf { display:flex; flex-direction:column; align-items:center; gap:.75rem; width:min(40rem,100%); }
214215 #buyshelf:focus { outline:none; }
215216 #buyshelf:focus-visible { outline:2px solid var(--ct); outline-offset:.35rem; border-radius:.45rem; }
216 ${WEAPONS_STYLE}`;
217 `;
217218
218219 /** What the menu's hint under the id field says when the link carried junk. */
219220 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 => `
369370 <p class="hint">Dead, you watch a teammate until the round ends: fire for the next player,
370371 aim for the one before.</p>
371372 <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>
374375 `;
375376
376377 /**
@@ -573,6 +574,6 @@ export function gamePage(
573574 this game: ${othersHtml("/")}</p>
574575 </section>
575576
576 ${WEAPONS_SECTION}`,
577 ${WEAPONS_SUMMARY}`,
577578 });
578579 }
src/pages/weapons.test.ts +76 −2
@@ -14,9 +14,12 @@
1414 import { expect, test } from "bun:test";
1515 import { shotCost } from "../client/combat.ts";
1616 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";
1819 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";
2023
2124 /** A weapons table nobody tuned: one gun, and not one of today's numbers on it. */
2225 const TOY: Weapon = {
@@ -110,4 +113,75 @@ test("the tables scroll in their own box, reachable by a keyboard", () => {
110113 test("the section is built once, not per request", () => {
111114 expect(WEAPONS_SECTION).toBe(weaponsSection());
112115 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);
113187 });
src/pages/weapons.ts +126 −15
@@ -1,6 +1,6 @@
11 /**
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.
44 *
55 * It renders `src/sheet.ts` and holds no figure of its own. Every stat cell is
66 * a field of `WEAPONS`, every damage cell is `src/client/combat.ts` firing a
@@ -10,10 +10,20 @@
1010 * the only reason this file exists: a hand-typed "kills in one shot" column
1111 * would be a claim about the simulation rather than a reading of it.
1212 *
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.
1727 *
1828 * Every cell is escaped on the way out like anywhere else markup is made here,
1929 * even though nothing in it came from a visitor: the escaping is the boundary,
@@ -21,19 +31,29 @@
2131 */
2232
2333 import {
24 ALL, ASSUMED, damageRows, GROUPS, round, seconds, thrownSaid,
34 ALL, ASSUMED, damageRows, GROUPS, gunsOf, round, seconds, thrownSaid,
2535 type DamageRow, type Kit,
2636 } from "../sheet.ts";
2737 import type { ShotCost } from "../client/combat.ts";
2838 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";
3044
3145 /**
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.
3757 */
3858 export const STYLE = `
3959 #weapons .scroll { overflow-x:auto; border:1px solid var(--line); border-radius:.45rem; margin:.5rem 0; }
@@ -48,6 +68,15 @@ export const STYLE = `
4868 #weapons ul.said li { padding:.35rem 0; border-bottom:1px solid var(--line); }
4969 #weapons ul.said b { color:var(--text); }
5070 #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 }
5180 `;
5281
5382 /** A number as this page writes one: grouped by thousands, and escaped. */
@@ -167,7 +196,89 @@ ${assumptions()}
167196
168197 /**
169198 * 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.
172202 */
173203 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";
1212 import { LONGEST_PASTE } from "./matchlink.ts";
1313 import { NOTHING_POSTED } from "./client/pending.ts";
1414 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";
1517 import { endpoints, escape } from "./pages/shell.ts";
1618 import { localPlan, parsePlan } from "./plan/plan.ts";
1719 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 () => {
522524 expect(logged).toEqual([boom]);
523525 });
524526
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
525570 test("the range stands on the same security floor as the game", async () => {
526571 const [game, range] = await Promise.all([fetch(base + "/"), fetch(base + "/range")]);
527572 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
582627 // adds a rule and does not tell src/server.ts about it fails here, rather
583628 // than losing its styling in a browser where nobody is watching.
584629 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);
587634 const block = styleBlock(await response.text());
588635 expect(block.length).toBeGreaterThan(500);
589636 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
9631010 const weights = /href="(\/static\/weights\.[0-9a-f]+\.json)"/.exec(page)?.[1] as string;
9641011 // A page rendered for this request, an asset weighed at start-up, and the
9651012 // 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"]) {
9671014 const plain = await raw(path);
9681015 const zipped = await raw(path, ASKS);
9691016 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
10531100
10541101 /**
10551102 * 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.
10621108 *
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.
10711118 *
10721119 * The budgets are those numbers with room for a paragraph and a control or two,
10731120 * 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
10771124 *
10781125 * `/` is measured on this file's database, which by now holds every match the
10791126 * 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.
10811128 */
1082 const BUDGET: Record<string, number> = { "/": 12000, "/range": 7500, "/example": 5500 };
1129 const BUDGET: Record<string, number> = { "/": 10500, "/weapons": 5600, "/range": 7500, "/example": 5500 };
10831130
10841131 test("a first visit stays inside its byte budget, compressed", async () => {
10851132 for (const [path, budget] of Object.entries(BUDGET)) {
src/server.ts +25 −2
@@ -10,7 +10,9 @@
1010 * routes below are that registry against `RENDER`, the one map from a game's
1111 * name to the page that draws it and the CSS that page appends to the shell's
1212 * 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.
1416 *
1517 * The floor every response stands on is here too, and the one part of it that
1618 * 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";
6466 import { matchFrom } from "./matchid.ts";
6567 import { errorPage, notFoundPage, STYLE as MISS_STYLE } from "./pages/miss.ts";
6668 import { boardsHtml, rangePage } from "./pages/range.ts";
69 import { STYLE as WEAPONS_STYLE, weaponsPage, WEAPONS_HREF } from "./pages/weapons.ts";
6770 import { policyFor } from "./policy.ts";
6871 import { styleFor, type Linked } from "./pages/shell.ts";
6972 import { cleanName, parseResult } from "./result.ts";
@@ -551,7 +554,7 @@ const gateAt = (path: string): Gate | undefined => GATES.find((gate) => gate.pat
551554 // against its renderer holding its own bundle. A game in the registry with no
552555 // page here is a mistake that fails now, at the first boot, rather than as a
553556 // 404 nobody expected.
554 const READ = new Map<string, (request: Request) => Response>(
557 const READ = new Map<string, (request: Request) => Response | Ready>(
555558 GAMES.map((game) => {
556559 const render = RENDER[game.name];
557560 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>(
572575 }),
573576 );
574577
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
575598 // The two pages no game owns, rendered through the same shell every game is
576599 // served inside: `src/pages/miss.ts` writes both. A wrong path is a dead end
577600 // unless it says where the games are, so the 404 names every one of them and

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs