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

forked from trevato/cs · proposes

The numbers the game is tuned by are on the page: a weapons table rendered from the table itself #266

Merged opened this

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

Merged

torque/cs-engine:torque/199-the-numbers-the-game-is-tuned-by-are-on → main 3 commits fork

  • Merged. torque/199-the-numbers-the-game-is-tuned-by-are-on was merged into main as ad4108b.

Review

Sign in to review this change.
18 files changed +797 −39
README.md +21
@@ -320,6 +320,27 @@ reads as live with no flag to clear and no special case anywhere. The standings
320320 are untouched either way: an abandoned match counts the rounds, kills and damage
321321 it scored, exactly as it did before.
322322
323 ## The numbers, on the page
324
325 Under the game on `/` is every weapon in the registry with what it costs, what
326 it fires, how fast it lets you walk and how much of a vest it goes through —
327 and beside it the thing a player actually argues about: what one shot does to a
328 person. Head, chest and leg, against a bare body and against kevlar and a
329 helmet, with the shots it takes to put that body down, and a last column saying
330 how far one shot to the head still kills from. The Deagle's row says *all 8,192
331 units*; the M4A4 through a helmet says *never*; the AK says *2,840 units*.
332
333 Not one of those figures is typed. `src/sheet.ts` reads the table in
334 `src/client/weapons.ts` for the stats and fires the shots through
335 `src/client/combat.ts` for the damage — the same `applyHit` a bullet in a round
336 goes through, into a body with the health `src/client/world.ts` spawns and the
337 armour `src/client/economy.ts` sells — so change a number in `weapons.ts` and
338 the page moves with it, and a test fails if it does not. `src/pages/weapons.ts`
339 renders that sheet into the first paint (no request, no script, built once at
340 start-up), and the terminal's **weapons** tab renders the same sheet as
341 columns. The assumptions are printed under the tables, because a figure without
342 the body it was measured against is a rumour.
343
323344 ## Playing
324345
325346 <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch,
src/client/combat.test.ts +69 −2
@@ -1,8 +1,11 @@
11 import { expect, test } from "bun:test";
2 import { applyHit, hitboxes, traceBullet, type Actor } from "./combat.ts";
2 import {
3 applyHit, FULL_ARMOR, FULL_HEALTH, hitboxes, killRange, shotCost, traceBullet, wholeBody,
4 type Actor,
5 } from "./combat.ts";
36 import type { Box } from "./level.ts";
47 import type { Vec3 } from "../engine/index.ts";
5 import { BULLET_RANGE, WEAPONS } from "./weapons.ts";
8 import { BULLET_RANGE, KNIFE_RANGE, WEAPONS } from "./weapons.ts";
69
710 const actor = (over: Partial<Actor> = {}): Actor => ({
811 id: 1, team: "t", pos: [500, 0, 0], yaw: 180, duckAmount: 0,
@@ -71,3 +74,67 @@ test("damage credited never exceeds the health that was there", () => {
7174 expect(hit.damage).toBe(12);
7275 expect(hit.killed).toBe(true);
7376 });
77
78 /**
79 * `shotCost` and `killRange` are what a page of weapon numbers is allowed to
80 * ask, so every figure below is one a person can do on paper and check against
81 * the same arithmetic the round runs on. If one of these moves, a number
82 * printed somewhere moved with it, which is the point of both.
83 */
84
85 test("what one shot costs is the fight's own sum, to the point", () => {
86 // 36 × 4 (head) = 144, nothing in the way of it.
87 expect(shotCost(WEAPONS.ak47, "head", 0)).toEqual({ health: 144, armor: 0, shots: 1 });
88 // 36 through 77.5% penetration is 27.9 → 28 health, and the vest takes half
89 // of the 8.1 it stopped → 4. Four of those is 112, which is more than a body.
90 expect(shotCost(WEAPONS.ak47, "chest", 0, FULL_ARMOR, true)).toEqual({ health: 28, armor: 4, shots: 4 });
91 // A leg is never covered, so the vest changes nothing about it: 115 × 0.75.
92 expect(shotCost(WEAPONS.awp, "leg", 0, FULL_ARMOR, true).health).toBe(86);
93 // The first shot's cost is uncapped by what is left, which is how a table
94 // shows the headroom a gun has: 63 × 4 × 0.955^(8192/500) × 0.925 = 110.
95 expect(shotCost(WEAPONS.deagle, "head", BULLET_RANGE, FULL_ARMOR, true).health).toBe(110);
96 // And a grenade has no bullet at all: nothing off, and nobody falls.
97 expect(shotCost(WEAPONS.he, "chest", 0)).toEqual({ health: 0, armor: 0, shots: null });
98 });
99
100 test("the shots to kill are the shots the fight needs", () => {
101 // Counted by firing them, not by dividing: the same loop a round runs, into
102 // the same body, so the count on a page and the count in a fight are one
103 // number. Checked here against a body shot the long way round.
104 for (const weapon of [WEAPONS.glock, WEAPONS.ak47, WEAPONS.awp, WEAPONS.knife]) {
105 for (const group of ["head", "chest", "leg"] as const) {
106 const target = actor({ armor: FULL_ARMOR, helmet: true });
107 let fired = 0;
108 while (target.alive && fired < 64) {
109 applyHit(target, weapon, group, 0);
110 fired += 1;
111 }
112 expect(shotCost(weapon, group, 0, FULL_ARMOR, true).shots).toBe(fired);
113 }
114 }
115 // And the arithmetic of one of them, on paper: a Glock's 30 to a vested chest
116 // is 14 health and 8 armour a shot, so it takes eight of them.
117 expect(shotCost(WEAPONS.glock, "chest", 0, FULL_ARMOR, true)).toEqual({ health: 14, armor: 8, shots: 8 });
118 });
119
120 test("how far one shot to the head still kills is searched to the unit", () => {
121 // A Glock's 30 falls at 0.75 per 500 units: 30 × 4 × 0.75^(325/500) = 99.54,
122 // which rounds to 100 and is exactly a body. One unit further it is 99.48,
123 // rounds to 99, and the head gets up.
124 expect(killRange(WEAPONS.glock, "head")).toBe(325);
125 expect(shotCost(WEAPONS.glock, "head", 325).health).toBe(100);
126 expect(shotCost(WEAPONS.glock, "head", 326).health).toBe(99);
127 // The Deagle's promise, as a number: every unit a bullet can travel.
128 expect(killRange(WEAPONS.deagle, "head", FULL_ARMOR, true)).toBe(BULLET_RANGE);
129 // The M4A4's 33 × 4 through a helmet is 92, which is not a body at any range.
130 expect(killRange(WEAPONS.m4a4, "head", FULL_ARMOR, true)).toBe(null);
131 // A knife's answer is its own reach and never a bullet's.
132 expect(killRange(WEAPONS.knife, "head")).toBe(KNIFE_RANGE);
133 });
134
135 test("a whole body is the body the world spawns and the shop dresses", () => {
136 const body = wholeBody(FULL_ARMOR, true);
137 expect(body.health).toBe(FULL_HEALTH);
138 expect(body.armor).toBe(FULL_ARMOR);
139 expect(body.alive).toBe(true);
140 });
src/client/combat.ts +97 −1
@@ -2,12 +2,23 @@
22 * Hitboxes and what a bullet does when it reaches one. A body is four boxes
33 * stacked from the feet, scaled by how far the player is crouched, so a
44 * crouching head really is where a crouching head looks.
5 *
6 * What a body *is* is here too — a hundred hit points and, when it bought one,
7 * a hundred points of vest — because those two numbers are the denominator of
8 * every answer in this file: "does this kill" is a comparison against them.
9 * `src/client/world.ts` spawns on them and `src/client/economy.ts` sells them.
510 */
611
712 import type { Box } from "./level.ts";
813 import { dist, rayBox, traceWorld, type Vec3 } from "../engine/index.ts";
914 import { CS_HULL } from "./movement.ts";
10 import { computeDamage, type Hitgroup, type Weapon } from "./weapons.ts";
15 import { computeDamage, type Hitgroup, reachOf, type Weapon } from "./weapons.ts";
16
17 /** The health a body spawns with, and the health it is killed by losing. */
18 export const FULL_HEALTH = 100;
19
20 /** The armour a vest is worth, bought whole or not at all. */
21 export const FULL_ARMOR = 100;
1122
1223 export type Actor = {
1324 id: number;
@@ -113,3 +124,88 @@ export function applyHit(
113124 const { health, armor } = computeDamage(weapon, distance, group, target.armor, target.helmet);
114125 return { ...applyDamage(target, health, armor), headshot: group === "head" };
115126 }
127
128 /**
129 * A body with nothing yet wrong with it: full health, the vest it was asked
130 * for, standing still. It is what a question about a weapon is asked against —
131 * `shotCost` below fires into one — so anything that wants to know what a gun
132 * does to a person shoots the same body the game does rather than inventing
133 * one with numbers of its own.
134 */
135 export const wholeBody = (armor: number, helmet: boolean): Actor => ({
136 id: -1, team: "t", pos: [0, 0, 0], yaw: 0, duckAmount: 0,
137 health: FULL_HEALTH, armor, helmet, alive: true,
138 });
139
140 /**
141 * What one shot costs a whole body, and how many of them that body takes.
142 *
143 * `health` and `armor` are the first shot's, uncapped by what is left — a
144 * Deagle that takes 110 off a 100-point body says 110, because the question
145 * "how much headroom does it have at range" is the one being asked of it.
146 * `shots` is not arithmetic on that number: the shots are *fired*, through
147 * `applyHit` above, into `wholeBody`, so a vest wearing down between them is
148 * counted the way the fight counts it. It is null when the body never falls —
149 * a grenade in this table has no bullet, and a shot that rounds to nothing off
150 * a leg at the far end of its range would stand there all day.
151 */
152 export type ShotCost = { health: number; armor: number; shots: number | null };
153
154 /** The most shots worth firing before a body that will not fall is called immortal. */
155 const NEVER = 64;
156
157 export function shotCost(
158 weapon: Weapon,
159 group: Hitgroup,
160 distance: number,
161 armor = 0,
162 helmet = false,
163 ): ShotCost {
164 const target = wholeBody(armor, helmet);
165 const first = computeDamage(weapon, distance, group, target.armor, target.helmet);
166 let shots = 0;
167 while (target.alive && shots < NEVER) {
168 applyHit(target, weapon, group, distance);
169 shots += 1;
170 }
171 return { ...first, shots: target.alive ? null : shots };
172 }
173
174 /**
175 * The farthest distance one shot to `group` still kills a whole body from, in
176 * units, or null when it does not kill even with the muzzle against them.
177 *
178 * Damage falls with distance and never rises — `computeDamage` multiplies by
179 * `rangeModifier^(d/500)`, and that is monotone — so the answer is a binary
180 * search between the muzzle and the end of what the weapon reaches, about
181 * thirteen calls to `shotCost` and exact to the unit. Rounding makes the edge a
182 * step rather than a slope, which is why it is searched rather than solved: the
183 * distance handed back is one the simulation itself would kill from.
184 *
185 * `reachOf` is the ceiling, so a knife's answer is inside its 64 units and a
186 * gun's is inside the flight of a bullet: nothing here claims a kill at a
187 * distance the weapon cannot reach.
188 */
189 export function killRange(
190 weapon: Weapon,
191 group: Hitgroup,
192 armor = 0,
193 helmet = false,
194 ): number | null {
195 // One shot into a whole body, and whether it got up: `applyHit` answers that
196 // directly, so the search asks the fight the same question thirteen times
197 // rather than firing a magazine into a corpse to learn the first shot killed.
198 const kills = (distance: number): boolean =>
199 applyHit(wholeBody(armor, helmet), weapon, group, distance).killed;
200 const reach = reachOf(weapon);
201 if (!kills(0)) return null;
202 if (kills(reach)) return reach;
203 let near = 0;
204 let far = reach;
205 while (far - near > 1) {
206 const mid = Math.floor((near + far) / 2);
207 if (kills(mid)) near = mid;
208 else far = mid;
209 }
210 return near;
211 }
src/client/economy.ts +4 −3
@@ -11,6 +11,7 @@
1111 * reach back for either.
1212 */
1313
14 import { FULL_ARMOR } from "./combat.ts";
1415 import type { Phase, Team } from "./match.ts";
1516 import {
1617 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, isThrown,
@@ -71,11 +72,11 @@ export function buy(market: Market, player: Buyer, item: BuyItem): boolean {
7172 const price = priceOf(item, player);
7273 if (!Number.isFinite(price) || price > player.money) return false;
7374 if (item === "armor") {
74 if (player.armor >= 100) return false;
75 player.armor = 100;
75 if (player.armor >= FULL_ARMOR) return false;
76 player.armor = FULL_ARMOR;
7677 } else if (item === "helmet") {
7778 if (player.helmet) return false;
78 player.armor = 100;
79 player.armor = FULL_ARMOR;
7980 player.helmet = true;
8081 } else if (item === "kit") {
8182 if (player.team !== "ct" || player.defuseKit) return false;
src/client/weapons.ts +15
@@ -193,6 +193,21 @@ export const buyable = (team: "t" | "ct"): Weapon[] =>
193193 */
194194 export const BULLET_RANGE = 8192;
195195
196 /**
197 * How far a knife reaches: an arm's length, and the one weapon here whose
198 * range is its own rather than a bullet's.
199 */
200 export const KNIFE_RANGE = 64;
201
202 /**
203 * How far this weapon reaches, in units. `src/client/world.ts` traces exactly
204 * this far when it fires one, so it is the honest ceiling on any claim about
205 * what a weapon does at a distance — a knife that killed at 2,000 units would
206 * be a sentence about a stab nobody can take.
207 */
208 export const reachOf = (weapon: Weapon): number =>
209 weapon.kind === "knife" ? KNIFE_RANGE : BULLET_RANGE;
210
196211 export const ARMOR_PRICE = 650;
197212 export const HELMET_PRICE = 350;
198213 export const DEFUSE_KIT_PRICE = 400;
src/client/world.ts +5 −5
@@ -17,7 +17,7 @@
1717 */
1818
1919 import { beepDue, dropBomb, giveBomb, newBomb, updateBomb, type Bomb } from "./bomb.ts";
20 import { applyDamage, applyHit, traceBullet, type Actor } from "./combat.ts";
20 import { applyDamage, applyHit, FULL_HEALTH, traceBullet, type Actor } from "./combat.ts";
2121 import { emptyAmmo, type Ammo } from "./economy.ts";
2222 import {
2323 blastCentre, blastHits, cloudFrom, cloudGone, cloudRadius, flashHits, spent, stepGrenade,
@@ -34,7 +34,7 @@ import {
3434 import * as rules from "./match.ts";
3535 import { CS_HULL, CS_MOVE } from "./movement.ts";
3636 import {
37 BULLET_RANGE, WEAPONS, inaccuracy, recoilOffset, RECOIL_SHARE,
37 WEAPONS, inaccuracy, reachOf, recoilOffset, RECOIL_SHARE,
3838 type ThrownId, type WeaponId, type Weapon,
3939 } from "./weapons.ts";
4040 import { botBuy, botThink, type BotBrain } from "./bots.ts";
@@ -221,7 +221,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player
221221 id, team, name, bot,
222222 pos: [0, 0, 0], vel: [0, 0, 0], yaw: 0, pitch: 0,
223223 onGround: true, ducking: false, duckAmount: 0,
224 health: 100, armor: 0, helmet: false, alive: true,
224 health: FULL_HEALTH, armor: 0, helmet: false, alive: true,
225225 money: 800,
226226 weapon: secondary, primary: null, secondary,
227227 ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) },
@@ -364,7 +364,7 @@ export function startRound(world: World): void {
364364 list.splice(Math.floor(world.random() * list.length), 1)[0] ?? spawns[p.team][0] ?? p.pos;
365365 p.pos = [at[0], at[1], 2];
366366 p.vel = [0, 0, 0];
367 p.health = 100;
367 p.health = FULL_HEALTH;
368368 // A grenade survives the round its owner did, like their gun does; one they
369369 // died holding is left on the floor with them.
370370 if (!p.alive) p.pocket = { he: 0, flash: 0, smoke: 0 };
@@ -487,7 +487,7 @@ export function shoot(world: World, p: Player): boolean {
487487 p.punch = [p.punch[0] + (rx - p.punch[0]) * 0.5, p.punch[1] + (ry - p.punch[1]) * 0.5];
488488
489489 const from = eyePos(p);
490 const range = w.kind === "knife" ? 64 : BULLET_RANGE;
490 const range = reachOf(w);
491491 const dir = angleVectors(yaw, pitch).forward;
492492 const to: Vec3 = [from[0] + dir[0] * range, from[1] + dir[1] * range, from[2] + dir[2] * range];
493493 const hit = traceBullet(from, to, world.level.boxes, world.players, p.id);
src/pages/game.test.ts +16 −1
@@ -21,7 +21,8 @@ import { playKey } from "../matchid.ts";
2121 import type { Result } from "../result.ts";
2222 import { CARD, type Card } from "../client/weights.ts";
2323 import { GAMES } from "../games.ts";
24 import { gamePage, policyHtml, shareHref, statsHtml, BAD_ID, BAD_MAP, type PageOptions } from "./game.ts";
24 import { gamePage, policyHtml, shareHref, statsHtml, STYLE, BAD_ID, BAD_MAP, type PageOptions } from "./game.ts";
25 import { WEAPONS_SECTION } from "./weapons.ts";
2526 import { escape } from "./shell.ts";
2627
2728 const db = (): ReturnType<typeof openDatabase> => openDatabase(mkdtempSync(join(tmpdir(), "game-")));
@@ -274,3 +275,17 @@ test("the menu offers today's match beside the other controls, with its map name
274275 // The map is named before Play, because the sentence names it.
275276 expect(html).toContain(escape(dailyOn(now).map));
276277 });
278
279 test("the weapons table is in the first paint, with its own CSS inside this page's block", () => {
280 const html = gamePage(db(), options);
281 // The section itself, whole and built once: `weapons.test.ts` holds the
282 // figures in it honest, and this holds the page to carrying them.
283 expect(html).toContain(WEAPONS_SECTION);
284 expect(html).toContain(`<h2>Every weapon, and what it does to a person</h2>`);
285 // Its rules are in the one style block the policy hashes, not a second one.
286 expect(html.match(/<style>/g)?.length).toBe(1);
287 expect(html).toContain("#weapons .scroll { overflow-x:auto;");
288 expect(STYLE).toContain("#weapons table { min-width:40rem;");
289 // And the menu points at it, because a table nobody can find settles nothing.
290 expect(html).toContain(`<a href="#weapons">under the game on this page</a>`);
291 });
src/pages/game.ts +11 −3
@@ -17,6 +17,7 @@ import { CARD, type Card } from "../client/weights.ts";
1717 import { mapFrom, mapLink, type MapChoice } from "../mapname.ts";
1818 import type { Who } from "../who.ts";
1919 import { navFor, noscriptFor, othersHtml } from "./games.ts";
20 import { STYLE as WEAPONS_STYLE, WEAPONS_SECTION } from "./weapons.ts";
2021 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
2122
2223 export { escape };
@@ -158,7 +159,9 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script
158159 * block with this appended and puts that hash in this page's `style-src`, so
159160 * a rule added here has to be a rule the header knows about. It is a constant
160161 * for the same reason — a style block built per request could not be hashed
161 * before the request.
162 * before the request. The weapons table's own rules are appended from
163 * `./weapons.ts` at the end of it: that section belongs to this page, so its
164 * CSS is inside this page's block and inside this page's hash.
162165 */
163166 export const STYLE = `
164167 #thismatch code { user-select:all; word-break:break-all; }
@@ -202,7 +205,7 @@ a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:under
202205 ul.recent a.map { color:var(--dim); text-decoration:none; padding:.35rem .4rem; margin:-.35rem 0;
203206 border-radius:.3rem; background:#161b24; }
204207 ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-decoration:underline; }
205 `;
208 ${WEAPONS_STYLE}`;
206209
207210 /** What the menu's hint under the id field says when the link carried junk. */
208211 export const BAD_ID = "That link's match id is not one — this is a fresh match instead.";
@@ -354,6 +357,9 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
354357 or pause with <kbd>P</kbd>, which no browser has a meaning of its own for.</p>
355358 <p class="hint">Dead, you watch a teammate until the round ends: fire for the next player,
356359 aim for the one before.</p>
360 <p class="hint">Every weapon's numbers — what it costs, what it fires and what one shot of it
361 does to a person — are <a href="#weapons">under the game on this page</a>, rendered from the
362 table the fight is tuned by.</p>
357363 `;
358364
359365 /**
@@ -536,6 +542,8 @@ export function gamePage(
536542 <p>The engine underneath — the loop, the controls, the grid, the meshes, the textures and
537543 the sounds — is a package, and what is next door is built out of it and shares no line with
538544 this game: ${othersHtml("/")}</p>
539 </section>`,
545 </section>
546
547 ${WEAPONS_SECTION}`,
540548 });
541549 }
src/pages/weapons.test.ts added +113
@@ -0,0 +1,113 @@
1 /**
2 * The weapons section, read the way a visitor gets it — and the one thing this
3 * file is really for: that not a figure in it is typed.
4 *
5 * Two checks carry that. The first walks every row of `src/sheet.ts` and finds
6 * the exact cell it would produce in the rendered HTML, so a page and the
7 * simulation cannot drift a point apart. The second is the harder one, and it
8 * is the same trick `game.test.ts` plays on the policy card: the section is
9 * rendered from a weapons table nobody tuned, and every number on it has to
10 * move — today's Deagle, today's AK and today's prices all gone. A "kills in
11 * one shot" column typed into the page would survive that and fail here.
12 */
13
14 import { expect, test } from "bun:test";
15 import { shotCost } from "../client/combat.ts";
16 import { WEAPONS, type Weapon } from "../client/weapons.ts";
17 import { ALL, damageRows, GROUPS, thrownSaid } from "../sheet.ts";
18 import { escape } from "./shell.ts";
19 import { STYLE, weaponsSection, WEAPONS_SECTION } from "./weapons.ts";
20
21 /** A weapons table nobody tuned: one gun, and not one of today's numbers on it. */
22 const TOY: Weapon = {
23 ...WEAPONS.ak47,
24 name: "Toybox", kind: "rifle", price: 12_345, damage: 7, armorPenetration: 0.1,
25 rangeModifier: 0.5, rpm: 13, magazine: 3, reserve: 6, reloadTime: 9.9,
26 killReward: 4_321, carrySpeed: 111,
27 };
28
29 test("every weapon in the registry is a row, grenades included", () => {
30 const html = weaponsSection();
31 for (const weapon of Object.values(WEAPONS)) expect(html).toContain(`>${escape(weapon.name)}</td>`);
32 expect(Object.values(WEAPONS).length).toBe(11);
33 // And the stats beside them are the table's own fields, formatted.
34 expect(html).toContain(`<td class="n">$2,700</td>`);
35 expect(html).toContain(`<td class="n">30<span>/90</span></td>`);
36 expect(html).toContain(`<td class="n">2.5 s</td>`);
37 expect(html).toContain(`<td class="n">215<span> u/s</span></td>`);
38 expect(html).toContain(`<td class="n">78%</td>`);
39 });
40
41 test("a thrown weapon has no bullet columns and says what it does instead", () => {
42 const html = weaponsSection();
43 for (const id of ["he", "flash", "smoke"] as const) {
44 expect(html).toContain(`<li><b>${escape(WEAPONS[id].name)}</b> ${escape(thrownSaid(WEAPONS[id]))}</li>`);
45 }
46 // The HE's sentence is its own blast, through the same falloff a round uses.
47 expect(thrownSaid(WEAPONS.he)).toContain("98 in the lap and 49 at 175 units");
48 expect(thrownSaid(WEAPONS.flash)).toContain("1.6 s of white");
49 });
50
51 test("every damage cell in the page is the cell combat.ts computes", () => {
52 const html = weaponsSection();
53 for (const row of damageRows()) {
54 for (const group of GROUPS) {
55 const cost = row.cost[group];
56 expect(cost).toEqual(shotCost(row.weapon, group, 0, row.kit === "armor" ? 100 : 0, row.kit === "armor"));
57 const said = cost.shots === null
58 ? `<span>never</span>`
59 : cost.shots === 1
60 ? `<b class="kills">kills</b>`
61 : `<span>${cost.shots} shots</span>`;
62 expect(html).toContain(`<td class="n">${cost.health} ${said}</td>`);
63 }
64 }
65 });
66
67 test("the page says the Deagle one-taps a helmet everywhere, and the M4 never does", () => {
68 const html = weaponsSection();
69 // Both claims are read off the table rather than written into it: the Deagle
70 // carries its 4× headshot past 100 at the far end of a bullet's flight, and
71 // the M4A4 does not carry it past a helmet at the muzzle.
72 expect(html).toContain(`<td class="n"><b class="kills">all 8,192 u</b></td>`);
73 expect(html).toContain(`<td class="n"><span>never</span></td>`);
74 const rows = damageRows();
75 const deagle = rows.find((r) => r.weapon.id === "deagle" && r.kit === "armor");
76 const m4 = rows.find((r) => r.weapon.id === "m4a4" && r.kit === "armor");
77 expect(deagle?.headKills).toBe(deagle?.reach ?? -1);
78 expect(m4?.headKills).toBe(null);
79 });
80
81 test("handed a table nobody tuned, every figure on the page moves with it", () => {
82 const html = weaponsSection([TOY]);
83 expect(html).toContain("Toybox");
84 expect(html).toContain(`<td class="n">$12,345</td>`);
85 expect(html).toContain(`<td class="n">3<span>/6</span></td>`);
86 expect(html).toContain(`<td class="n">9.9 s</td>`);
87 // The damage columns are the toy's, computed the same way, and none of them
88 // is a number today's table produces.
89 const head = shotCost(TOY, "head", 0, 100, true);
90 expect(html).toContain(`<td class="n">${head.health} <span>${head.shots} shots</span></td>`);
91 for (const gone of ["AK-47", "Desert Eagle", "$2,700", "$4,750", "all 8,192 u"]) {
92 expect(html).not.toContain(gone);
93 }
94 });
95
96 test("a weapon named in markup is escaped, like anything else the page prints", () => {
97 const html = weaponsSection([{ ...TOY, name: `<script>alert(1)</script>` }]);
98 expect(html).not.toContain("<script>");
99 expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
100 });
101
102 test("the tables scroll in their own box, reachable by a keyboard", () => {
103 const html = weaponsSection();
104 expect(html.match(/<div class="scroll" tabindex="0" role="region"/g)?.length).toBe(2);
105 expect(STYLE).toContain("#weapons .scroll { overflow-x:auto;");
106 // The page never scrolls sideways for them: the width is on the table.
107 expect(STYLE).toContain("#weapons table { min-width:40rem;");
108 });
109
110 test("the section is built once, not per request", () => {
111 expect(WEAPONS_SECTION).toBe(weaponsSection());
112 expect(weaponsSection(ALL)).toBe(WEAPONS_SECTION);
113 });
src/pages/weapons.ts added +173
@@ -0,0 +1,173 @@
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.
4 *
5 * It renders `src/sheet.ts` and holds no figure of its own. Every stat cell is
6 * a field of `WEAPONS`, every damage cell is `src/client/combat.ts` firing a
7 * shot into a whole body, and the assumptions under it are made of the same
8 * constants the arithmetic used — so a number changed in `src/client/weapons.ts`
9 * moves this page with it and the page cannot disagree with the fight. That is
10 * the only reason this file exists: a hand-typed "kills in one shot" column
11 * would be a claim about the simulation rather than a reading of it.
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.
17 *
18 * Every cell is escaped on the way out like anywhere else markup is made here,
19 * even though nothing in it came from a visitor: the escaping is the boundary,
20 * not a judgement about today's inputs.
21 */
22
23 import {
24 ALL, ASSUMED, damageRows, GROUPS, round, seconds, thrownSaid,
25 type DamageRow, type Kit,
26 } from "../sheet.ts";
27 import type { ShotCost } from "../client/combat.ts";
28 import { isThrown, type Weapon } from "../client/weapons.ts";
29 import { escape } from "./shell.ts";
30
31 /**
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.
37 */
38 export const STYLE = `
39 #weapons .scroll { overflow-x:auto; border:1px solid var(--line); border-radius:.45rem; margin:.5rem 0; }
40 #weapons .scroll:focus-visible { outline:2px solid var(--ct); outline-offset:2px; }
41 #weapons table { min-width:40rem; font-size:.82rem; }
42 #weapons th, #weapons td { padding:.3rem .5rem; white-space:nowrap; }
43 #weapons th.n, #weapons td.n { text-align:right; }
44 #weapons td.name { font-weight:600; }
45 #weapons td span { color:var(--dim); }
46 #weapons b.kills { color:var(--t); font-weight:600; }
47 #weapons ul.said { list-style:none; padding:0; margin:.5rem 0; font-size:.86rem; }
48 #weapons ul.said li { padding:.35rem 0; border-bottom:1px solid var(--line); }
49 #weapons ul.said b { color:var(--text); }
50 #weapons ul.assumed { color:var(--dim); font-size:.8rem; margin:.5rem 0; padding-left:1.1rem; }
51 `;
52
53 /** A number as this page writes one: grouped by thousands, and escaped. */
54 const num = (n: number): string => escape(n.toLocaleString("en"));
55
56 /** A count of units, and the one place the unit is named: `8,192 u`. */
57 const units = (n: number): string => `${escape(round(n))} u`;
58
59 /** Money, the way the buy menu writes it. */
60 const money = (n: number): string => `$${num(n)}`;
61
62 /** A share of the damage, as a whole percent: what armour does not stop. */
63 const percent = (share: number): string => `${num(Math.round(share * 100))}%`;
64
65 /** A cell with nothing in it, which is a weapon the column is not about. */
66 const NONE = `<td class="n">—</td>`;
67
68 /**
69 * One weapon's stat row. A thrown weapon has no bullet, so the five columns
70 * about one are empty rather than zero: an HE with "0 damage" in a damage
71 * column would be a false reading of a grenade that takes 98 off somebody, and
72 * what it really does is the sentence under the table.
73 */
74 function statRow(weapon: Weapon): string {
75 const thrown = isThrown(weapon.id);
76 return `<tr><td class="name">${escape(weapon.name)}</td><td>${escape(weapon.kind)}</td>` +
77 `<td class="n">${money(weapon.price)}</td>` +
78 `${thrown ? NONE : `<td class="n">${num(weapon.damage)}</td>`}` +
79 `${thrown ? NONE : `<td class="n">${percent(weapon.armorPenetration)}</td>`}` +
80 `${thrown || weapon.rpm === 0 ? NONE : `<td class="n">${num(weapon.rpm)}</td>`}` +
81 `${thrown || weapon.magazine === 0 ? NONE : `<td class="n">${num(weapon.magazine)}<span>/${num(weapon.reserve)}</span></td>`}` +
82 `${thrown || weapon.reloadTime === 0 ? NONE : `<td class="n">${escape(seconds(weapon.reloadTime))}</td>`}` +
83 `<td class="n">${num(weapon.carrySpeed)}<span> u/s</span></td>` +
84 `<td class="n">${money(weapon.killReward)}</td></tr>`;
85 }
86
87 /** The whole stat table: one row a weapon, in the registry's own order. */
88 const statTable = (weapons: readonly Weapon[]): string =>
89 `<div class="scroll" tabindex="0" role="region" aria-label="Every weapon's numbers">
90 <table><thead><tr><th>weapon</th><th>kind</th><th class="n">price</th><th class="n">damage</th>
91 <th class="n">armour pen</th><th class="n">rpm</th><th class="n">mag/spare</th><th class="n">reload</th>
92 <th class="n">carry</th><th class="n">kill pays</th></tr></thead>
93 <tbody>${weapons.map(statRow).join("")}</tbody></table>
94 </div>`;
95
96 /** What the shots column says: one is a kill, and nought is a body still standing. */
97 const shotsSaid = (shots: number | null): string =>
98 shots === null ? `<span>never</span>` : shots === 1 ? `<b class="kills">kills</b>` : `<span>${num(shots)} shots</span>`;
99
100 /** One place on the body: what one shot takes off it, and how many it takes. */
101 const costCell = (cost: ShotCost): string => `<td class="n">${num(cost.health)} ${shotsSaid(cost.shots)}</td>`;
102
103 /** What the body of a damage row is wearing, in the words the buy menu uses. */
104 const WORN: Record<Kit, string> = { none: "no armour", armor: "kevlar + helmet" };
105
106 /**
107 * How far one shot to the head still kills from. A weapon whose answer is its
108 * own reach kills at every distance it can be fired over, which is the claim
109 * the Deagle is tuned to make and the one this column exists to check.
110 */
111 const killRangeCell = (row: DamageRow): string =>
112 row.headKills === null
113 ? `<td class="n"><span>never</span></td>`
114 : row.headKills >= row.reach
115 ? `<td class="n"><b class="kills">all ${units(row.reach)}</b></td>`
116 : `<td class="n">${units(row.headKills)}</td>`;
117
118 const damageRow = (row: DamageRow): string =>
119 `<tr><td class="name">${escape(row.weapon.name)}</td><td>${escape(WORN[row.kit])}</td>` +
120 GROUPS.map((group) => costCell(row.cost[group])).join("") +
121 killRangeCell(row) +
122 `</tr>`;
123
124 const damageTable = (weapons: readonly Weapon[]): string =>
125 `<div class="scroll" tabindex="0" role="region" aria-label="What one shot of each weapon does">
126 <table><thead><tr><th>weapon</th><th>body</th><th class="n">head</th><th class="n">chest</th>
127 <th class="n">leg</th><th class="n">one head shot kills to</th></tr></thead>
128 <tbody>${damageRows(weapons).map(damageRow).join("")}</tbody></table>
129 </div>`;
130
131 /** The thrown three, each in the one sentence its own numbers make. */
132 const thrownList = (weapons: readonly Weapon[]): string =>
133 `<ul class="said">${weapons
134 .filter((weapon) => isThrown(weapon.id))
135 .map((weapon) => `<li><b>${escape(weapon.name)}</b> ${escape(thrownSaid(weapon))}</li>`)
136 .join("")}</ul>`;
137
138 /** The assumptions, under the tables, where the figures they hold up are. */
139 const assumptions = (): string =>
140 `<ul class="assumed">${ASSUMED.map((line) => `<li>${escape(line)}</li>`).join("")}</ul>`;
141
142 /**
143 * The section, whole. It takes the weapons rather than reading the import so a
144 * test can hand it a table nobody tuned and read back a page with none of
145 * today's numbers on it — which is how "the figures are the simulation's" is
146 * checked rather than asserted.
147 */
148 export function weaponsSection(weapons: readonly Weapon[] = ALL): string {
149 return ` <section id="weapons">
150 <h2>Every weapon, and what it does to a person</h2>
151 <p>The numbers the fight is decided by, rendered from the table itself. The left of it is
152 <code>src/client/weapons.ts</code> — what a weapon costs, what it fires and how fast it lets
153 you walk — and the right of it is <code>src/client/combat.ts</code>, the same arithmetic a
154 bullet in a round goes through, fired at a body standing here. Nothing on this page is typed
155 in: change a figure in the table and this moves with it.</p>
156 ${statTable(weapons)}
157 ${thrownList(weapons)}
158 <h3>What one shot does to a person</h3>
159 <p>One shot, at the muzzle, into a whole body: the health it takes, and how many of them that
160 body takes before it goes down. The last column is the range question — how far one shot to
161 the head still kills from, searched against the simulation rather than solved, and capped at
162 what the weapon can reach at all.</p>
163 ${damageTable(weapons)}
164 ${assumptions()}
165 </section>`;
166 }
167
168 /**
169 * 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.
172 */
173 export const WEAPONS_SECTION = weaponsSection();
src/rl/arena.ts +3 −2
@@ -13,6 +13,7 @@
1313 */
1414
1515 import { decode, toInput, type Intent } from "../client/action.ts";
16 import { FULL_ARMOR, FULL_HEALTH } from "../client/combat.ts";
1617 import { makeBrain, botThink, ride, type BotBrain } from "../client/bots.ts";
1718 import { useShipped } from "../client/shipped.ts";
1819 import { openCells, type Level } from "../client/level.ts";
@@ -138,8 +139,8 @@ const equip = (p: Player, weapon: "ak47" | "m4a4"): void => {
138139 p.primary = weapon;
139140 p.weapon = weapon;
140141 p.ammo = { [weapon]: { mag: 30, reserve: 90 }, knife: { mag: 0, reserve: 0 } };
141 p.health = 100;
142 p.armor = 100;
142 p.health = FULL_HEALTH;
143 p.armor = FULL_ARMOR;
143144 p.helmet = true;
144145 p.alive = true;
145146 p.vel = [0, 0, 0];
src/server.test.ts +16 −5
@@ -1051,10 +1051,21 @@ test("every file in static/ is gzipped once, at start-up, and held beside its by
10511051
10521052 /**
10531053 * What a first visit transfers to a browser that accepts gzip, per page, and
1054 * what each may not pass. Measured 2026-09-21 on this tree, compressed: `/`
1055 * 6,758 bytes against 18,662 uncompressed, `/range` 5,693 against 14,945,
1056 * `/example` 4,180 against 10,761 — 16.2 kB where 43.3 kB used to go, before
1057 * the bundle each of them then downloads.
1054 * what each may not pass. Measured 2026-09-21 on this tree, compressed and on
1055 * an empty database: `/` 9,683 bytes against 32,047 uncompressed, `/range`
1056 * 6,631 against 17,655, `/example` 4,490 against 11,637 — before the bundle
1057 * each of them then downloads. `/` is measured again by the test itself
1058 * against the database these tests filled, which is the page at its heaviest:
1059 * 10,032 gzipped against 34,300.
1060 *
1061 * `/` grew 1,879 gzipped (9,947 raw) for #199, and the whole of that growth is
1062 * the weapons table: every weapon in the registry with its numbers, and what
1063 * one shot of each does to a person, rendered server-side into the first paint
1064 * rather than fetched. It is a table of figures, which is the shape gzip is
1065 * worst at and a reader is best at, and it is what the page is *for* — the
1066 * three issues before it argued about numbers nobody could see. The budget
1067 * below was raised from 9,000 to 12,000 with it, which keeps the headroom it
1068 * had: about a fifth over today, so a page that doubles still fails here.
10581069 *
10591070 * The budgets are those numbers with room for a paragraph and a control or two,
10601071 * the same way `src/games.ts` writes a bundle's ceiling: tight enough that a
@@ -1066,7 +1077,7 @@ test("every file in static/ is gzipped once, at start-up, and held beside its by
10661077 * tests above posted, so it is the page at its heaviest rather than at its
10671078 * emptiest.
10681079 */
1069 const BUDGET: Record<string, number> = { "/": 9000, "/range": 7500, "/example": 5500 };
1080 const BUDGET: Record<string, number> = { "/": 12000, "/range": 7500, "/example": 5500 };
10701081
10711082 test("a first visit stays inside its byte budget, compressed", async () => {
10721083 for (const [path, budget] of Object.entries(BUDGET)) {
src/sheet.ts added +142
@@ -0,0 +1,142 @@
1 /**
2 * The weapons sheet: every number the game is tuned by, and what each weapon
3 * does to a person, as plain data. Nothing here is markup and nothing here is
4 * a line of a terminal — `src/pages/weapons.ts` renders it as a table and
5 * `src/terminal/screen.ts` renders it as columns, and both read this.
6 *
7 * Every figure is *computed*, which is the whole point of the file: the stat
8 * columns are the rows of `WEAPONS` in `src/client/weapons.ts` handed through
9 * unchanged, and the damage columns are `src/client/combat.ts` — the same
10 * `applyHit` a bullet in a round goes through, fired into the same body. A
11 * number typed here could disagree with the fight; a number taken from there
12 * cannot, so change a figure in `weapons.ts` and what is printed moves with it.
13 *
14 * The assumptions are constants below rather than prose, because whoever
15 * renders this has to be able to *say* them: the body is whole, the figures are
16 * taken at the muzzle, and range is answered separately by `headKills` — the
17 * distance one shot to the head still kills from, which is the question
18 * "does the Deagle one-tap everywhere" in a number.
19 */
20
21 import { FULL_ARMOR, FULL_HEALTH, killRange, shotCost, type ShotCost } from "./client/combat.ts";
22 import {
23 blastDamage, blindSeconds, HITGROUP, isThrown, reachOf, throughArmor, WEAPONS,
24 type Weapon,
25 } from "./client/weapons.ts";
26
27 /**
28 * The distance the damage figures are taken at, in units: the muzzle against
29 * the body. It is the one distance at which a weapon's own table is its whole
30 * story — everything past it is falloff, and falloff is what `headKills` below
31 * answers instead of a second column of the same shape.
32 */
33 export const AT_RANGE = 0;
34
35 /**
36 * The three places a player aims at, in the order they are printed. The other
37 * two hitgroups in `HITGROUP` are real and are not columns: an arm is a chest
38 * with a different name to a table this wide, and a stomach is named in the
39 * assumptions below instead of doubling the width of every row.
40 */
41 export type Place = "head" | "chest" | "leg";
42 export const GROUPS: readonly Place[] = ["head", "chest", "leg"];
43
44 /** How a body is dressed for a row: nothing at all, or a vest and a helmet. */
45 export type Kit = "none" | "armor";
46
47 /** Both, in the order they are printed: the bare body first. */
48 export const KITS: readonly Kit[] = ["none", "armor"];
49
50 /** What one row of the damage table knows: one weapon, one body, three places. */
51 export type DamageRow = {
52 weapon: Weapon;
53 kit: Kit;
54 /** What one shot costs, by place, at `AT_RANGE`. */
55 cost: Record<Place, ShotCost>;
56 /**
57 * The farthest distance one shot to the head still kills this body from, in
58 * units, or null when it never does. `reach` is what the weapon can reach at
59 * all, so the two being equal is "everywhere it reaches".
60 */
61 headKills: number | null;
62 reach: number;
63 };
64
65 /** The guns, in the table's own order: everything that is not thrown. */
66 export const gunsOf = (weapons: readonly Weapon[]): Weapon[] => weapons.filter((w) => !isThrown(w.id));
67
68 /** Every weapon in the registry, in the order the table lists them. */
69 export const ALL: readonly Weapon[] = Object.values(WEAPONS);
70
71 /**
72 * What a shot of this weapon does to a body wearing this kit, place by place.
73 * The costs are `shotCost`'s, which fires the shots rather than dividing: a
74 * vest wearing down between two bullets is counted the way the round counts it.
75 */
76 export function damageRow(weapon: Weapon, kit: Kit): DamageRow {
77 const armor = kit === "armor" ? FULL_ARMOR : 0;
78 const helmet = kit === "armor";
79 const cost = Object.fromEntries(
80 GROUPS.map((group) => [group, shotCost(weapon, group, AT_RANGE, armor, helmet)]),
81 ) as Record<Place, ShotCost>;
82 return { weapon, kit, cost, headKills: killRange(weapon, "head", armor, helmet), reach: reachOf(weapon) };
83 }
84
85 /** Every gun against both bodies: the damage table, whole, in printing order. */
86 export const damageRows = (weapons: readonly Weapon[] = ALL): DamageRow[] =>
87 gunsOf(weapons).flatMap((weapon) => KITS.map((kit) => damageRow(weapon, kit)));
88
89 /**
90 * What a thrown weapon does, in one sentence made of its own numbers: the HE's
91 * falloff through `blastDamage` and `throughArmor`, the flashbang's curve
92 * through `blindSeconds`, and the cloud's clock. They are the same functions
93 * `src/client/grenade.ts` calls when one of these goes off in a round, asked at
94 * the distances worth printing — the centre, half the reach, and the edge.
95 *
96 * A weapon that is not thrown has nothing to say here and says nothing.
97 */
98 export function thrownSaid(weapon: Weapon): string {
99 const { blast, flash, smoke } = weapon;
100 if (blast !== undefined) {
101 const centre = Math.round(blastDamage(blast, 0));
102 const half = Math.round(blastDamage(blast, blast.radius / 2));
103 const vested = throughArmor(blastDamage(blast, 0), FULL_ARMOR, blast.armorPenetration).health;
104 return `${centre} in the lap and ${half} at ${round(blast.radius / 2)} units, nothing from ` +
105 `${round(blast.radius)} away; ${vested} through a vest at the centre. Fuse ${seconds(blast.fuse)}.`;
106 }
107 if (flash !== undefined) {
108 const full = blindSeconds(flash, 0, 0);
109 const half = blindSeconds(flash, 0, flash.radius / 2);
110 return `takes sight, not health: ${seconds(full)} of white looking straight at it, ` +
111 `${seconds(half)} from ${round(flash.radius / 2)} units, and none at all past ` +
112 `${round(flash.awayAngle)}° or ${round(flash.radius)} units. Fuse ${seconds(flash.fuse)}.`;
113 }
114 if (smoke !== undefined) {
115 return `takes a sightline from everybody and health from nobody: a ball ${round(smoke.radius)} units ` +
116 `across the radius, up in ${seconds(smoke.grow)}, standing ${seconds(smoke.life)} and thinning for ` +
117 `its last ${seconds(smoke.fade)}. Fuse ${seconds(smoke.fuse)}.`;
118 }
119 return "";
120 }
121
122 /** A count of units or degrees, grouped by thousands: 8,192 rather than 8192. */
123 export const round = (n: number): string => Math.round(n).toLocaleString("en");
124
125 /** A duration, to a tenth: the resolution every clock in the weapons table has. */
126 export const seconds = (n: number): string => `${Math.round(n * 10) / 10} s`;
127
128 /**
129 * The assumptions every figure above is taken under, said once so that whoever
130 * prints the table can print them beside it. They are sentences made of the
131 * same constants the arithmetic used — a body's health, a vest's points, the
132 * hitgroup multipliers — so none of them can claim something the numbers do not.
133 */
134 export const ASSUMED: readonly string[] = [
135 `A whole body is ${FULL_HEALTH} health, and a vest is ${FULL_ARMOR} points of armour bought whole.`,
136 `Damage is at the muzzle. A shot to the head is ×${HITGROUP.head}, the chest ×${HITGROUP.chest}, ` +
137 `the stomach ×${HITGROUP.stomach} and a leg ×${HITGROUP.leg}, and it falls with range at the ` +
138 `weapon's own range modifier.`,
139 `A vest covers the chest and the stomach; a helmet covers the head; nothing covers a leg, ` +
140 `so the two leg columns are the same number twice.`,
141 `Shots to kill are fired rather than divided, so a vest wearing down between them counts.`,
142 ];
src/terminal/keys.test.ts +6 −3
@@ -66,7 +66,7 @@ const data: Data = {
6666 const view = (over: Partial<View> = {}): View =>
6767 ({ tab: "standings", cursor: 0, note: "", where: "local", ssh: undefined, ...over });
6868
69 test("tab cycles standings → matches → policy → today → runs → standings, and the cursor resets each time", () => {
69 test("tab cycles standings → matches → policy → today → runs → weapons → standings, and the cursor resets each time", () => {
7070 const first = press(view({ cursor: 1 }), "\t", data);
7171 expect(first.view.tab).toBe("matches");
7272 expect(first.view.cursor).toBe(0);
@@ -80,9 +80,12 @@ test("tab cycles standings → matches → policy → today → runs → standin
8080 expect(fourth.view.tab).toBe("runs");
8181 expect(fourth.view.cursor).toBe(0);
8282 const fifth = press({ ...fourth.view, cursor: 1 }, "\t", data);
83 expect(fifth.view.tab).toBe("standings");
83 expect(fifth.view.tab).toBe("weapons");
8484 expect(fifth.view.cursor).toBe(0);
85 expect(fourth.command).toEqual({ kind: "nothing" });
85 const sixth = press(fifth.view, "\t", data);
86 expect(sixth.view.tab).toBe("standings");
87 expect(sixth.view.cursor).toBe(0);
88 expect(sixth.command).toEqual({ kind: "nothing" });
8689 });
8790
8891 test("j and the down arrow move down and stop at the last row", () => {
src/terminal/keys.ts +4 −1
@@ -22,7 +22,10 @@ export type Press = { view: View; command: Command };
2222
2323 const nothing = (view: View): Press => ({ view, command: { kind: "nothing" } });
2424
25 /** The next tab in the cycle: standings → matches → policy → today → runs → standings. */
25 /**
26 * The next tab in the cycle: standings → matches → policy → today → runs →
27 * weapons → standings.
28 */
2629 export const nextTab = (view: View): View["tab"] => TABS[(TABS.indexOf(view.tab) + 1) % TABS.length]!;
2730
2831 export function press(view: View, key: string, data: Data): Press {
src/terminal/screen.test.ts +29 −3
@@ -10,6 +10,8 @@ import type { Card } from "../client/weights.ts";
1010 import type { MatchRow, RunRow, Standing } from "../db.ts";
1111 import { IDLE_SECONDS } from "../db.ts";
1212 import { clamp, plain, printable, rowsOf, screen, type Data, type View } from "./screen.ts";
13 import { WEAPONS } from "../client/weapons.ts";
14 import { damageRows, GROUPS } from "../sheet.ts";
1315 import { dailyOn } from "../daily.ts";
1416
1517 const CARD: Card = {
@@ -102,15 +104,16 @@ test("the header says where we are and how many matches there are, plural when i
102104 expect(text(view({ where: "preview of #32" }))[0]).toBe("cs preview of #32 · 4 matches · 2 runs");
103105 });
104106
105 test("the tab strip names all five, and the highlight follows the tab you are on", () => {
106 expect(text(view())[2]).toBe(" standings matches policy today runs ");
107 test("the tab strip names all six, and the highlight follows the tab you are on", () => {
108 expect(text(view())[2]).toBe(" standings matches policy today runs weapons ");
107109 expect(screen(view({ tab: "standings" }), data())[2]).toContain("\x1b[7m standings \x1b[0m");
108110 expect(screen(view({ tab: "matches" }), data())[2]).toContain("\x1b[7m matches \x1b[0m");
109111 expect(screen(view({ tab: "policy" }), data())[2]).toContain("\x1b[7m policy \x1b[0m");
112 expect(screen(view({ tab: "weapons" }), data())[2]).toContain("\x1b[7m weapons \x1b[0m");
110113 expect(screen(view({ tab: "today" }), data())[2]).toContain("\x1b[7m today \x1b[0m");
111114 expect(screen(view({ tab: "runs" }), data())[2]).toContain("\x1b[7m runs \x1b[0m");
112115 // And only ever one of them.
113 for (const tab of ["standings", "matches", "policy", "today", "runs"] as const) {
116 for (const tab of ["standings", "matches", "policy", "today", "runs", "weapons"] as const) {
114117 expect(screen(view({ tab }), data())[2]!.match(/\x1b\[7m/g)?.length).toBe(1);
115118 }
116119 });
@@ -295,6 +298,29 @@ test("the today tab is the match of the day: the id, the map, the rule and the b
295298 expect(text(view({ tab: "today" }), data({ today: [] })).join("\n")).toContain("nobody has played it yet");
296299 });
297300
301 test("the weapons tab is the page's table, out of the same computed sheet", () => {
302 const lines = text(view({ tab: "weapons" }));
303 // Every weapon in the registry, grenades included, with its own numbers.
304 for (const weapon of Object.values(WEAPONS)) expect(lines.some((l) => l.includes(weapon.name))).toBe(true);
305 expect(lines.some((l) => l.startsWith(" AK-47 rifle $2,700 36 78% 600 30/90 2.5 s 215 u/s"))).toBe(true);
306 // And the damage columns are `src/client/combat.ts`, not a number typed here:
307 // every row of the sheet is a line, cell for cell.
308 for (const row of damageRows()) {
309 const said = GROUPS.map((group) => {
310 const { health, shots } = row.cost[group];
311 return `${health} (${shots === null ? "—" : shots})`;
312 });
313 const line = lines.find((l) =>
314 l.startsWith(` ${row.weapon.name.padEnd(15)}${(row.kit === "armor" ? "kevlar + helmet" : "no armour").padEnd(17)}`));
315 expect(line).toBeDefined();
316 for (const cell of said) expect(line).toContain(cell);
317 }
318 // The Deagle's claim, in the terminal as on the page.
319 expect(lines.some((l) => l.startsWith(" Desert Eagle kevlar + helmet") && l.endsWith("all 8,192 u"))).toBe(true);
320 // There is nothing to point at on this tab, so there is no cursor on it.
321 expect(rowsOf("weapons", data())).toBe(0);
322 });
323
298324 test("the runs tab lists the range's runs, with the drill, the accuracy and the seed", () => {
299325 const lines = screen(view({ tab: "runs" }), data()).map(plain);
300326 const board = lines.join("\n");
src/terminal/screen.ts +67 −6
@@ -14,20 +14,24 @@
1414 */
1515
1616 import type { Card } from "../client/weights.ts";
17 import { ALL, ASSUMED, damageRows, GROUPS, round, seconds, thrownSaid, type DamageRow } from "../sheet.ts";
18 import { isThrown, type Weapon } from "../client/weapons.ts";
1719 import { type Daily, RANKED_BY } from "../daily.ts";
1820 import type { DayRun, Liveness, MatchRow, RunRow, Standing } from "../db.ts";
1921 import { displayName, headshotShare, hitShare, liveness, ratio } from "../db.ts";
2022 import { matchOf } from "../matchid.ts";
2123
22 export type Tab = "standings" | "matches" | "policy" | "today" | "runs";
24 export type Tab = "standings" | "matches" | "policy" | "today" | "runs" | "weapons";
2325
2426 /**
25 * The five tabs in the order tab cycles them. `runs` is the range's board — a
26 * second game keeps scores here now, so the terminal lists them the way it
27 * lists matches — and it is last so that the four a person already knows are
28 * the same four presses away they always were.
27 * The six tabs in the order tab cycles them. The four a person already knows
28 * are first and in their old places, so they are the same presses away they
29 * always were, and the two that arrived together are after them: `runs` is the
30 * range's board — a second game keeps scores here now, so the terminal lists
31 * them the way it lists matches — and `weapons` is the table the game is tuned
32 * by, which is read rather than played and so comes last.
2933 */
30 export const TABS: readonly Tab[] = ["standings", "matches", "policy", "today", "runs"];
34 export const TABS: readonly Tab[] = ["standings", "matches", "policy", "today", "runs", "weapons"];
3135
3236 export type View = {
3337 tab: Tab;
@@ -130,6 +134,62 @@ function policyLines(card: Card): string[] {
130134 ];
131135 }
132136
137 /**
138 * The weapons, the same table the page renders and out of the same data: the
139 * numbers the game is tuned by, and what one shot of each does to a person.
140 * `src/sheet.ts` computes both — the stats are the registry's own fields and
141 * the damage is `src/client/combat.ts` — so this screen and that page cannot
142 * say different things about the same gun, and neither can disagree with the
143 * fight. Nothing here is typed: change a figure in `src/client/weapons.ts` and
144 * these columns move with it.
145 *
146 * There is no cursor on it and nothing to press: it is a table to read, like
147 * the policy card, so `rowsOf` gives it no rows.
148 */
149 function weaponLines(weapons: readonly Weapon[] = ALL): string[] {
150 const cell = (row: DamageRow, group: (typeof GROUPS)[number]): string => {
151 const { health, shots } = row.cost[group];
152 return `${health} (${shots === null ? "—" : shots})`;
153 };
154 const lines = [
155 ` ${esc}1mthe numbers the game is tuned by${esc}0m`,
156 "",
157 `${esc}2m ${pad("weapon", 15)}${pad("kind", 8)}${pad("price", 8)}${pad("dmg", 6)}${pad("pen", 6)}` +
158 `${pad("rpm", 6)}${pad("mag", 8)}${pad("reload", 8)}carry${esc}0m`,
159 ];
160 for (const w of weapons) {
161 const bullet = isThrown(w.id);
162 lines.push(
163 ` ${pad(printable(w.name), 15)}${pad(w.kind, 8)}${pad(`$${w.price.toLocaleString("en")}`, 8)}` +
164 `${pad(bullet ? "—" : String(w.damage), 6)}${pad(bullet ? "—" : `${Math.round(w.armorPenetration * 100)}%`, 6)}` +
165 `${pad(bullet || w.rpm === 0 ? "—" : String(w.rpm), 6)}${pad(bullet || w.magazine === 0 ? "—" : `${w.magazine}/${w.reserve}`, 8)}` +
166 `${pad(bullet || w.reloadTime === 0 ? "—" : seconds(w.reloadTime), 8)}${w.carrySpeed} u/s`,
167 );
168 }
169 lines.push("");
170 for (const w of weapons.filter((one) => isThrown(one.id)))
171 lines.push(` ${esc}2m${pad(printable(w.name), 15)}${esc}0m${thrownSaid(w)}`);
172 lines.push(
173 "",
174 ` ${esc}1mwhat one shot does to a person${esc}0m ${esc}2mdamage (shots to kill), at the muzzle${esc}0m`,
175 "",
176 `${esc}2m ${pad("weapon", 15)}${pad("body", 17)}${pad("head", 12)}${pad("chest", 12)}${pad("leg", 12)}head kills to${esc}0m`,
177 );
178 for (const row of damageRows(weapons))
179 lines.push(
180 ` ${pad(printable(row.weapon.name), 15)}${pad(row.kit === "armor" ? "kevlar + helmet" : "no armour", 17)}` +
181 `${pad(cell(row, "head"), 12)}${pad(cell(row, "chest"), 12)}${pad(cell(row, "leg"), 12)}` +
182 (row.headKills === null
183 ? "never"
184 : row.headKills >= row.reach
185 ? `all ${round(row.reach)} u`
186 : `${round(row.headKills)} u`),
187 );
188 lines.push("");
189 for (const line of ASSUMED) lines.push(`${esc}2m ${line}${esc}0m`);
190 return lines;
191 }
192
133193 /**
134194 * The day's board, the same one the page shows and in the same order: rounds
135195 * won, then kills, then damage, one row a player, their best run. The match id
@@ -244,6 +304,7 @@ export function screen(view: View, data: Data): string[] {
244304 lines.push(header(view.tab));
245305 lines.push("");
246306 if (view.tab === "runs") lines.push(...runLines(data.runs, cursor));
307 else if (view.tab === "weapons") lines.push(...weaponLines());
247308 else if (view.tab === "today") lines.push(...todayLines(data.daily, data.today));
248309 else if (view.tab === "policy") lines.push(...policyLines(data.card));
249310 else if (view.tab === "standings") lines.push(...standingsLines(data.standings, cursor));
src/tty.test.ts +6 −4
@@ -123,7 +123,7 @@ test("the first paint is the standings, under the environment and the match coun
123123
124124 const lines = tty.frame();
125125 expect(lines[0]).toBe("cs local · 2 matches · 0 runs");
126 expect(lines[2]).toBe(" standings matches policy today runs ");
126 expect(lines[2]).toBe(" standings matches policy today runs weapons ");
127127 expect(tty.marked()).toStartWith("▸ ace");
128128 expect(lines.some((line) => line.startsWith(" bo"))).toBe(true);
129129 expect(tty.exits).toEqual([]);
@@ -148,11 +148,11 @@ test("a preview says which pull it is, and production says production", () => {
148148 expect(session({ env: { FORGE_APP_ENV: "preview" } }).frame()[0]).toBe("cs preview of #? · 0 matches · 0 runs");
149149 });
150150
151 test("tab cycles the five tabs, and every press draws exactly one frame", () => {
151 test("tab cycles the six tabs, and every press draws exactly one frame", () => {
152152 const tty = session({ seed: (db) => { play(db, "ace"); } });
153153 expect(tty.frame()[4]).toStartWith(" player matches");
154154 tty.press("\t");
155 expect(tty.frame()[2]).toBe(" standings matches policy today runs ");
155 expect(tty.frame()[2]).toBe(" standings matches policy today runs weapons ");
156156 expect(tty.frame()[4]).toStartWith(" player score");
157157 tty.press("\t");
158158 expect(tty.frame()[4]).toBe(" the policy the bots fight with");
@@ -161,8 +161,10 @@ test("tab cycles the five tabs, and every press draws exactly one frame", () =>
161161 tty.press("\t");
162162 expect(tty.frame()[4]).toStartWith(" shooter drill");
163163 tty.press("\t");
164 expect(tty.frame()[4]).toBe(" the numbers the game is tuned by");
165 tty.press("\t");
164166 expect(tty.frame()[4]).toStartWith(" player matches");
165 expect(tty.writes.length).toBe(6);
167 expect(tty.writes.length).toBe(7);
166168 });
167169
168170 test("j and k move the cursor and stop at both ends of the list", () => {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs