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
Check · Review · Land · Live done — Landed and live in production.
torque/cs-engine:torque/199-the-numbers-the-game-is-tuned-by-are-on → main 3 commits fork
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 | ||
| 320 | 320 | are untouched either way: an abandoned match counts the rounds, kills and damage |
| 321 | 321 | it scored, exactly as it did before. |
| 322 | 322 | |
| 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 | ||
| 323 | 344 | ## Playing |
| 324 | 345 | |
| 325 | 346 | <kbd>WASD</kbd> move, <kbd>Space</kbd> jump, <kbd>Ctrl</kbd> crouch, |
src/client/combat.test.ts +69 −2
| @@ -1,8 +1,11 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 6 | import type { Box } from "./level.ts"; |
| 4 | 7 | 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"; | |
| 6 | 9 | |
| 7 | 10 | const actor = (over: Partial<Actor> = {}): Actor => ({ |
| 8 | 11 | 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", () => { | ||
| 71 | 74 | expect(hit.damage).toBe(12); |
| 72 | 75 | expect(hit.killed).toBe(true); |
| 73 | 76 | }); |
| 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 @@ | ||
| 2 | 2 | * Hitboxes and what a bullet does when it reaches one. A body is four boxes |
| 3 | 3 | * stacked from the feet, scaled by how far the player is crouched, so a |
| 4 | 4 | * 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. | |
| 5 | 10 | */ |
| 6 | 11 | |
| 7 | 12 | import type { Box } from "./level.ts"; |
| 8 | 13 | import { dist, rayBox, traceWorld, type Vec3 } from "../engine/index.ts"; |
| 9 | 14 | 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; | |
| 11 | 22 | |
| 12 | 23 | export type Actor = { |
| 13 | 24 | id: number; |
| @@ -113,3 +124,88 @@ export function applyHit( | ||
| 113 | 124 | const { health, armor } = computeDamage(weapon, distance, group, target.armor, target.helmet); |
| 114 | 125 | return { ...applyDamage(target, health, armor), headshot: group === "head" }; |
| 115 | 126 | } |
| 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 @@ | ||
| 11 | 11 | * reach back for either. |
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | import { FULL_ARMOR } from "./combat.ts"; | |
| 14 | 15 | import type { Phase, Team } from "./match.ts"; |
| 15 | 16 | import { |
| 16 | 17 | ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, isThrown, |
| @@ -71,11 +72,11 @@ export function buy(market: Market, player: Buyer, item: BuyItem): boolean { | ||
| 71 | 72 | const price = priceOf(item, player); |
| 72 | 73 | if (!Number.isFinite(price) || price > player.money) return false; |
| 73 | 74 | 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; | |
| 76 | 77 | } else if (item === "helmet") { |
| 77 | 78 | if (player.helmet) return false; |
| 78 | player.armor = 100; | |
| 79 | player.armor = FULL_ARMOR; | |
| 79 | 80 | player.helmet = true; |
| 80 | 81 | } else if (item === "kit") { |
| 81 | 82 | if (player.team !== "ct" || player.defuseKit) return false; |
src/client/weapons.ts +15
| @@ -193,6 +193,21 @@ export const buyable = (team: "t" | "ct"): Weapon[] => | ||
| 193 | 193 | */ |
| 194 | 194 | export const BULLET_RANGE = 8192; |
| 195 | 195 | |
| 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 | ||
| 196 | 211 | export const ARMOR_PRICE = 650; |
| 197 | 212 | export const HELMET_PRICE = 350; |
| 198 | 213 | export const DEFUSE_KIT_PRICE = 400; |
src/client/world.ts +5 −5
| @@ -17,7 +17,7 @@ | ||
| 17 | 17 | */ |
| 18 | 18 | |
| 19 | 19 | 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"; | |
| 21 | 21 | import { emptyAmmo, type Ammo } from "./economy.ts"; |
| 22 | 22 | import { |
| 23 | 23 | blastCentre, blastHits, cloudFrom, cloudGone, cloudRadius, flashHits, spent, stepGrenade, |
| @@ -34,7 +34,7 @@ import { | ||
| 34 | 34 | import * as rules from "./match.ts"; |
| 35 | 35 | import { CS_HULL, CS_MOVE } from "./movement.ts"; |
| 36 | 36 | import { |
| 37 | BULLET_RANGE, WEAPONS, inaccuracy, recoilOffset, RECOIL_SHARE, | |
| 37 | WEAPONS, inaccuracy, reachOf, recoilOffset, RECOIL_SHARE, | |
| 38 | 38 | type ThrownId, type WeaponId, type Weapon, |
| 39 | 39 | } from "./weapons.ts"; |
| 40 | 40 | import { botBuy, botThink, type BotBrain } from "./bots.ts"; |
| @@ -221,7 +221,7 @@ function makePlayer(id: number, team: Team, name: string, bot: boolean): Player | ||
| 221 | 221 | id, team, name, bot, |
| 222 | 222 | pos: [0, 0, 0], vel: [0, 0, 0], yaw: 0, pitch: 0, |
| 223 | 223 | 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, | |
| 225 | 225 | money: 800, |
| 226 | 226 | weapon: secondary, primary: null, secondary, |
| 227 | 227 | ammo: { knife: { mag: 0, reserve: 0 }, [secondary]: emptyAmmo(secondary) }, |
| @@ -364,7 +364,7 @@ export function startRound(world: World): void { | ||
| 364 | 364 | list.splice(Math.floor(world.random() * list.length), 1)[0] ?? spawns[p.team][0] ?? p.pos; |
| 365 | 365 | p.pos = [at[0], at[1], 2]; |
| 366 | 366 | p.vel = [0, 0, 0]; |
| 367 | p.health = 100; | |
| 367 | p.health = FULL_HEALTH; | |
| 368 | 368 | // A grenade survives the round its owner did, like their gun does; one they |
| 369 | 369 | // died holding is left on the floor with them. |
| 370 | 370 | if (!p.alive) p.pocket = { he: 0, flash: 0, smoke: 0 }; |
| @@ -487,7 +487,7 @@ export function shoot(world: World, p: Player): boolean { | ||
| 487 | 487 | p.punch = [p.punch[0] + (rx - p.punch[0]) * 0.5, p.punch[1] + (ry - p.punch[1]) * 0.5]; |
| 488 | 488 | |
| 489 | 489 | const from = eyePos(p); |
| 490 | const range = w.kind === "knife" ? 64 : BULLET_RANGE; | |
| 490 | const range = reachOf(w); | |
| 491 | 491 | const dir = angleVectors(yaw, pitch).forward; |
| 492 | 492 | const to: Vec3 = [from[0] + dir[0] * range, from[1] + dir[1] * range, from[2] + dir[2] * range]; |
| 493 | 493 | 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"; | ||
| 21 | 21 | import type { Result } from "../result.ts"; |
| 22 | 22 | import { CARD, type Card } from "../client/weights.ts"; |
| 23 | 23 | 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"; | |
| 25 | 26 | import { escape } from "./shell.ts"; |
| 26 | 27 | |
| 27 | 28 | 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 | ||
| 274 | 275 | // The map is named before Play, because the sentence names it. |
| 275 | 276 | expect(html).toContain(escape(dailyOn(now).map)); |
| 276 | 277 | }); |
| 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"; | ||
| 17 | 17 | import { mapFrom, mapLink, type MapChoice } from "../mapname.ts"; |
| 18 | 18 | import type { Who } from "../who.ts"; |
| 19 | 19 | import { navFor, noscriptFor, othersHtml } from "./games.ts"; |
| 20 | import { STYLE as WEAPONS_STYLE, WEAPONS_SECTION } from "./weapons.ts"; | |
| 20 | 21 | import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts"; |
| 21 | 22 | |
| 22 | 23 | export { escape }; |
| @@ -158,7 +159,9 @@ export type PageOptions = Pick<ShellOptions, "env" | "pull" | "commit" | "script | ||
| 158 | 159 | * block with this appended and puts that hash in this page's `style-src`, so |
| 159 | 160 | * a rule added here has to be a rule the header knows about. It is a constant |
| 160 | 161 | * 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. | |
| 162 | 165 | */ |
| 163 | 166 | export const STYLE = ` |
| 164 | 167 | #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 | ||
| 202 | 205 | ul.recent a.map { color:var(--dim); text-decoration:none; padding:.35rem .4rem; margin:-.35rem 0; |
| 203 | 206 | border-radius:.3rem; background:#161b24; } |
| 204 | 207 | ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-decoration:underline; } |
| 205 | `; | |
| 208 | ${WEAPONS_STYLE}`; | |
| 206 | 209 | |
| 207 | 210 | /** What the menu's hint under the id field says when the link carried junk. */ |
| 208 | 211 | 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 => ` | ||
| 354 | 357 | or pause with <kbd>P</kbd>, which no browser has a meaning of its own for.</p> |
| 355 | 358 | <p class="hint">Dead, you watch a teammate until the round ends: fire for the next player, |
| 356 | 359 | 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> | |
| 357 | 363 | `; |
| 358 | 364 | |
| 359 | 365 | /** |
| @@ -536,6 +542,8 @@ export function gamePage( | ||
| 536 | 542 | <p>The engine underneath — the loop, the controls, the grid, the meshes, the textures and |
| 537 | 543 | the sounds — is a package, and what is next door is built out of it and shares no line with |
| 538 | 544 | this game: ${othersHtml("/")}</p> |
| 539 | </section>`, | |
| 545 | </section> | |
| 546 | ||
| 547 | ${WEAPONS_SECTION}`, | |
| 540 | 548 | }); |
| 541 | 549 | } |
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("<script>alert(1)</script>"); | |
| 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 @@ | ||
| 13 | 13 | */ |
| 14 | 14 | |
| 15 | 15 | import { decode, toInput, type Intent } from "../client/action.ts"; |
| 16 | import { FULL_ARMOR, FULL_HEALTH } from "../client/combat.ts"; | |
| 16 | 17 | import { makeBrain, botThink, ride, type BotBrain } from "../client/bots.ts"; |
| 17 | 18 | import { useShipped } from "../client/shipped.ts"; |
| 18 | 19 | import { openCells, type Level } from "../client/level.ts"; |
| @@ -138,8 +139,8 @@ const equip = (p: Player, weapon: "ak47" | "m4a4"): void => { | ||
| 138 | 139 | p.primary = weapon; |
| 139 | 140 | p.weapon = weapon; |
| 140 | 141 | 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; | |
| 143 | 144 | p.helmet = true; |
| 144 | 145 | p.alive = true; |
| 145 | 146 | 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 | ||
| 1051 | 1051 | |
| 1052 | 1052 | /** |
| 1053 | 1053 | * 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. | |
| 1058 | 1069 | * |
| 1059 | 1070 | * The budgets are those numbers with room for a paragraph and a control or two, |
| 1060 | 1071 | * 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 | ||
| 1066 | 1077 | * tests above posted, so it is the page at its heaviest rather than at its |
| 1067 | 1078 | * emptiest. |
| 1068 | 1079 | */ |
| 1069 | const BUDGET: Record<string, number> = { "/": 9000, "/range": 7500, "/example": 5500 }; | |
| 1080 | const BUDGET: Record<string, number> = { "/": 12000, "/range": 7500, "/example": 5500 }; | |
| 1070 | 1081 | |
| 1071 | 1082 | test("a first visit stays inside its byte budget, compressed", async () => { |
| 1072 | 1083 | 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 = { | ||
| 66 | 66 | const view = (over: Partial<View> = {}): View => |
| 67 | 67 | ({ tab: "standings", cursor: 0, note: "", where: "local", ssh: undefined, ...over }); |
| 68 | 68 | |
| 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", () => { | |
| 70 | 70 | const first = press(view({ cursor: 1 }), "\t", data); |
| 71 | 71 | expect(first.view.tab).toBe("matches"); |
| 72 | 72 | expect(first.view.cursor).toBe(0); |
| @@ -80,9 +80,12 @@ test("tab cycles standings → matches → policy → today → runs → standin | ||
| 80 | 80 | expect(fourth.view.tab).toBe("runs"); |
| 81 | 81 | expect(fourth.view.cursor).toBe(0); |
| 82 | 82 | const fifth = press({ ...fourth.view, cursor: 1 }, "\t", data); |
| 83 | expect(fifth.view.tab).toBe("standings"); | |
| 83 | expect(fifth.view.tab).toBe("weapons"); | |
| 84 | 84 | 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" }); | |
| 86 | 89 | }); |
| 87 | 90 | |
| 88 | 91 | 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 }; | ||
| 22 | 22 | |
| 23 | 23 | const nothing = (view: View): Press => ({ view, command: { kind: "nothing" } }); |
| 24 | 24 | |
| 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 | */ | |
| 26 | 29 | export const nextTab = (view: View): View["tab"] => TABS[(TABS.indexOf(view.tab) + 1) % TABS.length]!; |
| 27 | 30 | |
| 28 | 31 | 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"; | ||
| 10 | 10 | import type { MatchRow, RunRow, Standing } from "../db.ts"; |
| 11 | 11 | import { IDLE_SECONDS } from "../db.ts"; |
| 12 | 12 | 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"; | |
| 13 | 15 | import { dailyOn } from "../daily.ts"; |
| 14 | 16 | |
| 15 | 17 | const CARD: Card = { |
| @@ -102,15 +104,16 @@ test("the header says where we are and how many matches there are, plural when i | ||
| 102 | 104 | expect(text(view({ where: "preview of #32" }))[0]).toBe("cs preview of #32 · 4 matches · 2 runs"); |
| 103 | 105 | }); |
| 104 | 106 | |
| 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 "); | |
| 107 | 109 | expect(screen(view({ tab: "standings" }), data())[2]).toContain("\x1b[7m standings \x1b[0m"); |
| 108 | 110 | expect(screen(view({ tab: "matches" }), data())[2]).toContain("\x1b[7m matches \x1b[0m"); |
| 109 | 111 | 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"); | |
| 110 | 113 | expect(screen(view({ tab: "today" }), data())[2]).toContain("\x1b[7m today \x1b[0m"); |
| 111 | 114 | expect(screen(view({ tab: "runs" }), data())[2]).toContain("\x1b[7m runs \x1b[0m"); |
| 112 | 115 | // 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) { | |
| 114 | 117 | expect(screen(view({ tab }), data())[2]!.match(/\x1b\[7m/g)?.length).toBe(1); |
| 115 | 118 | } |
| 116 | 119 | }); |
| @@ -295,6 +298,29 @@ test("the today tab is the match of the day: the id, the map, the rule and the b | ||
| 295 | 298 | expect(text(view({ tab: "today" }), data({ today: [] })).join("\n")).toContain("nobody has played it yet"); |
| 296 | 299 | }); |
| 297 | 300 | |
| 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 | ||
| 298 | 324 | test("the runs tab lists the range's runs, with the drill, the accuracy and the seed", () => { |
| 299 | 325 | const lines = screen(view({ tab: "runs" }), data()).map(plain); |
| 300 | 326 | const board = lines.join("\n"); |
src/terminal/screen.ts +67 −6
| @@ -14,20 +14,24 @@ | ||
| 14 | 14 | */ |
| 15 | 15 | |
| 16 | 16 | 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"; | |
| 17 | 19 | import { type Daily, RANKED_BY } from "../daily.ts"; |
| 18 | 20 | import type { DayRun, Liveness, MatchRow, RunRow, Standing } from "../db.ts"; |
| 19 | 21 | import { displayName, headshotShare, hitShare, liveness, ratio } from "../db.ts"; |
| 20 | 22 | import { matchOf } from "../matchid.ts"; |
| 21 | 23 | |
| 22 | export type Tab = "standings" | "matches" | "policy" | "today" | "runs"; | |
| 24 | export type Tab = "standings" | "matches" | "policy" | "today" | "runs" | "weapons"; | |
| 23 | 25 | |
| 24 | 26 | /** |
| 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. | |
| 29 | 33 | */ |
| 30 | export const TABS: readonly Tab[] = ["standings", "matches", "policy", "today", "runs"]; | |
| 34 | export const TABS: readonly Tab[] = ["standings", "matches", "policy", "today", "runs", "weapons"]; | |
| 31 | 35 | |
| 32 | 36 | export type View = { |
| 33 | 37 | tab: Tab; |
| @@ -130,6 +134,62 @@ function policyLines(card: Card): string[] { | ||
| 130 | 134 | ]; |
| 131 | 135 | } |
| 132 | 136 | |
| 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 | ||
| 133 | 193 | /** |
| 134 | 194 | * The day's board, the same one the page shows and in the same order: rounds |
| 135 | 195 | * 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[] { | ||
| 244 | 304 | lines.push(header(view.tab)); |
| 245 | 305 | lines.push(""); |
| 246 | 306 | if (view.tab === "runs") lines.push(...runLines(data.runs, cursor)); |
| 307 | else if (view.tab === "weapons") lines.push(...weaponLines()); | |
| 247 | 308 | else if (view.tab === "today") lines.push(...todayLines(data.daily, data.today)); |
| 248 | 309 | else if (view.tab === "policy") lines.push(...policyLines(data.card)); |
| 249 | 310 | 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 | ||
| 123 | 123 | |
| 124 | 124 | const lines = tty.frame(); |
| 125 | 125 | 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 "); | |
| 127 | 127 | expect(tty.marked()).toStartWith("▸ ace"); |
| 128 | 128 | expect(lines.some((line) => line.startsWith(" bo"))).toBe(true); |
| 129 | 129 | expect(tty.exits).toEqual([]); |
| @@ -148,11 +148,11 @@ test("a preview says which pull it is, and production says production", () => { | ||
| 148 | 148 | expect(session({ env: { FORGE_APP_ENV: "preview" } }).frame()[0]).toBe("cs preview of #? · 0 matches · 0 runs"); |
| 149 | 149 | }); |
| 150 | 150 | |
| 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", () => { | |
| 152 | 152 | const tty = session({ seed: (db) => { play(db, "ace"); } }); |
| 153 | 153 | expect(tty.frame()[4]).toStartWith(" player matches"); |
| 154 | 154 | 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 "); | |
| 156 | 156 | expect(tty.frame()[4]).toStartWith(" player score"); |
| 157 | 157 | tty.press("\t"); |
| 158 | 158 | 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", () => | ||
| 161 | 161 | tty.press("\t"); |
| 162 | 162 | expect(tty.frame()[4]).toStartWith(" shooter drill"); |
| 163 | 163 | tty.press("\t"); |
| 164 | expect(tty.frame()[4]).toBe(" the numbers the game is tuned by"); | |
| 165 | tty.press("\t"); | |
| 164 | 166 | expect(tty.frame()[4]).toStartWith(" player matches"); |
| 165 | expect(tty.writes.length).toBe(6); | |
| 167 | expect(tty.writes.length).toBe(7); | |
| 166 | 168 | }); |
| 167 | 169 | |
| 168 | 170 | test("j and k move the cursor and stop at both ends of the list", () => { |
new above
offline