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

forked from trevato/cs · proposes · 4 behind

The shooter's entry sheds its first concept: the buy panel becomes its own module #257

Merged opened this

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

Merged

torque/cs-engine:torque/250-the-shooter-s-entry-sheds-its-first-conc → main 2 commits fork

  • Merged. torque/250-the-shooter-s-entry-sheds-its-first-conc was merged into main as 2ee39fd.

Review

Sign in to review this change.
6 files changed +326 −31
README.md +6
@@ -656,6 +656,12 @@ never checked itself.
656656 the round will let them have it, and what handing it over does to their wallet
657657 and their kit — `priceOf`, `canBuy`, `buy`, the pocket limits in `CARRY` and
658658 the `Ammo` a fresh gun comes with),
659 `buy` (the panel that economy is spent through: the shelf a side is shown —
660 its guns, the armour, the kit only a CT can carry — the price `economy` gives
661 for the body in front of you, and which tiles the wallet and the round leave
662 pressable. It takes the element to draw into, the round and the player, and a
663 call that says which item was pressed; it reaches for no document and buys
664 nothing, so `main` spends the money in the one place that holds a world),
659665 `bomb` (the objective: `giveBomb`, `dropBomb`, `updateBomb` and the
660666 `useProgress` a readout draws its bar from — carrying it, planting it on a
661667 site, cutting the wire with a kit or without one; it hands back what the
src/client/buy.test.ts added +178
@@ -0,0 +1,178 @@
1 /**
2 * The buy panel's own tests, which are what `main.test.ts` used to assert
3 * through a whole booted frame: the shelf a side is shown, the markup it is
4 * drawn as — to the character, for a real world in its freeze time — what a
5 * price is for the body in front of you, which tiles a wallet and a round leave
6 * pressable, and what a press hands back.
7 *
8 * The page under it is the engine's `fakePage`: one element to draw into and a
9 * `createElement` that makes buttons, which is the whole of what this module
10 * borrows from a browser. Most of it needs no world at all — a `Market` is an
11 * object with a phase in it and a `Buyer` is the kit and the wallet, the way
12 * `economy.test.ts` writes them — and the exact-markup test uses a real
13 * `createWorld`, because a module that only works against its own fixtures has
14 * proved nothing.
15 */
16
17 import { expect, test } from "bun:test";
18 import { fakePage, type FakeElement } from "../engine/index.ts";
19 import { drawBuy, HINT, offers, type Counter } from "./buy.ts";
20 import { emptyAmmo, type BuyItem, type Buyer, type Market } from "./economy.ts";
21 import { DE_FORGE } from "./map.ts";
22 import { SHORT } from "./match.ts";
23 import { ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS } from "./weapons.ts";
24 import { createWorld } from "./world.ts";
25
26 const freeze: Market = { match: { phase: "freeze" } };
27 const live: Market = { match: { phase: "live" } };
28
29 const buyer = (over: Partial<Buyer> = {}): Buyer => ({
30 team: "ct", alive: true, money: 5000,
31 armor: 0, helmet: false, defuseKit: false,
32 weapon: "usp", primary: null, secondary: "usp",
33 ammo: { knife: { mag: 0, reserve: 0 }, usp: emptyAmmo("usp") },
34 pocket: { he: 0, flash: 0, smoke: 0 },
35 ...over,
36 });
37
38 /** A panel to draw into, the buttons it made, and every item a press handed back. */
39 function shop(): {
40 panel: FakeElement;
41 counter: Counter;
42 pressed: BuyItem[];
43 tiles(): FakeElement[];
44 labels(): (string | null)[];
45 /** The tile whose label starts with this word, which is how a test names one. */
46 tile(label: string): FakeElement;
47 } {
48 const page = fakePage({ ids: ["buy"] });
49 const panel = page.by("buy");
50 const pressed: BuyItem[] = [];
51 const tiles = (): FakeElement[] => panel.querySelector(".grid")?.children ?? [];
52 return {
53 panel,
54 pressed,
55 tiles,
56 labels: () => tiles().map((tile) => tile.textContent),
57 // By label and not by index: a weapon added to the table adds a tile, and a
58 // test about the armour should not move when one is.
59 tile: (label) => {
60 const found = tiles().find((tile) => tile.textContent?.startsWith(label));
61 if (found === undefined) throw new Error(`no tile for ${label}`);
62 return found;
63 },
64 counter: {
65 panel,
66 button: () => page.document.createElement("button"),
67 press: (item) => void pressed.push(item),
68 },
69 };
70 }
71
72 test("the shelf is this side's guns, the armour, and the kit only a CT can carry", () => {
73 // Every buyable weapon in the table, in the table's order, which is how the
74 // smoke #165 added reached the panel without a line in here: the shelf is
75 // `weapons.ts`'s list and not a second copy of it.
76 expect(offers("ct").map((offer) => offer.item))
77 .toEqual(["usp", "deagle", "mp9", "m4a4", "awp", "he", "flash", "smoke", "armor", "helmet", "kit"]);
78 // A T's pistol is the Glock and its rifle is the AK, and there is no kit on the
79 // shelf at all: a tile a side can never press is a tile that should not be there.
80 expect(offers("t").map((offer) => offer.item))
81 .toEqual(["glock", "deagle", "mp9", "ak47", "awp", "he", "flash", "smoke", "armor", "helmet"]);
82 expect(offers("t").map((offer) => offer.label)).not.toContain("Defuse kit");
83 });
84
85 test("the panel a real world in its freeze time is drawn as, to the character", () => {
86 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 3);
87 expect(world.match.phase).toBe("freeze");
88 expect(world.me.money).toBe(800);
89 const it = shop();
90 drawBuy(it.counter, world, world.me);
91
92 expect(it.panel.innerHTML).toBe(
93 `<h2>Buy <small>$800</small></h2><div class="grid"></div>` +
94 `<p class="hint">${HINT}</p>`,
95 );
96 // Eleven tiles, each a `<button>` — a press that submits nothing — labelled
97 // with the price this body pays, and dead exactly where 800 dollars cannot
98 // reach.
99 expect(it.tiles().map((tile) => tile.type)).toEqual(Array(11).fill("button"));
100 expect(it.tiles().map((tile) => [tile.textContent, tile.disabled])).toEqual([
101 ["USP-S · $200", false],
102 ["Desert Eagle · $700", false],
103 ["MP9 · $1250", true],
104 ["M4A4 · $3100", true],
105 ["AWP · $4750", true],
106 ["HE Grenade · $300", false],
107 ["Flashbang · $200", false],
108 ["Smoke Grenade · $300", false],
109 ["Kevlar · $650", false],
110 ["Kevlar + Helmet · $1000", true],
111 ["Defuse kit · $400", false],
112 ]);
113 });
114
115 test("drawing again is the panel again, not a second one under it", () => {
116 const it = shop();
117 const me = buyer();
118 drawBuy(it.counter, freeze, me);
119 const drawn = it.tiles().length;
120 expect(drawn).toBe(offers("ct").length);
121 me.money = 3000;
122 drawBuy(it.counter, freeze, me);
123 expect(it.tiles().length).toBe(drawn);
124 expect(it.panel.innerHTML).toContain("$3000");
125 });
126
127 test("a price is for the body in front of you: a vest on is a helmet at helmet price", () => {
128 const it = shop();
129 drawBuy(it.counter, freeze, buyer({ armor: 100 }));
130 expect(it.labels()).toContain(`Kevlar + Helmet · $${HELMET_PRICE}`);
131 // The vest is already on, so it is priced at nothing — and a tile at nothing is
132 // pressable, although `economy.ts` refuses the buy. That is today's behaviour
133 // and this pull moved it rather than fixing it: see #250.
134 expect(it.labels()).toContain("Kevlar · $0");
135 expect(it.tile("Kevlar ·").disabled).toBe(false);
136 // Bare, the helmet is the vest as well and costs both.
137 const bare = shop();
138 drawBuy(bare.counter, freeze, buyer());
139 expect(bare.labels()).toContain(`Kevlar + Helmet · $${ARMOR_PRICE + HELMET_PRICE}`);
140 expect(bare.labels()).toContain(`Defuse kit · $${DEFUSE_KIT_PRICE}`);
141 });
142
143 test("a wallet that cannot reach a price is a dead tile, to the dollar", () => {
144 const it = shop();
145 drawBuy(it.counter, freeze, buyer({ money: WEAPONS.awp.price - 1 }));
146 expect(it.tile(`AWP · $${WEAPONS.awp.price}`).disabled).toBe(true);
147 const rich = shop();
148 drawBuy(rich.counter, freeze, buyer({ money: WEAPONS.awp.price }));
149 expect(rich.tile(`AWP · $${WEAPONS.awp.price}`).disabled).toBe(false);
150 });
151
152 test("outside the freeze time every tile is dead, and the prices are still readable", () => {
153 const it = shop();
154 drawBuy(it.counter, live, buyer({ money: 16_000 }));
155 expect(it.tiles().length).toBe(offers("ct").length);
156 expect(it.tiles().every((tile) => tile.disabled)).toBe(true);
157 expect(it.labels()).toContain(`AWP · $${WEAPONS.awp.price}`);
158 // And to a body, wherever the round is: a corpse has nothing to spend it on.
159 const dead = shop();
160 drawBuy(dead.counter, freeze, buyer({ alive: false, money: 16_000 }));
161 expect(dead.tiles().every((tile) => tile.disabled)).toBe(true);
162 });
163
164 test("a press hands the item back and spends nothing itself", () => {
165 const it = shop();
166 const me = buyer();
167 drawBuy(it.counter, freeze, me);
168 it.tile("USP-S").fire("click");
169 it.tile("Kevlar + Helmet").fire("click");
170 expect(it.pressed).toEqual(["usp", "helmet"]);
171 // The wallet and the kit are the caller's to change: this module reads them.
172 expect(me.money).toBe(5000);
173 expect(me.helmet).toBe(false);
174 // A dead tile is dead because a browser will not click it, not because the
175 // panel checks twice — so what is asserted here is that one press is one item.
176 it.tile("USP-S").fire("click");
177 expect(it.pressed).toEqual(["usp", "helmet", "usp"]);
178 });
src/client/buy.ts added +97
@@ -0,0 +1,97 @@
1 /**
2 * The buy panel: the shop as a player reads it. What is on the shelf is this
3 * side's guns, the armour, and the kit only a CT can carry; what each one costs
4 * is `economy.ts`'s answer for *this* body — a vest already on is a helmet at
5 * helmet price — and whether a tile can be pressed is the wallet and the round
6 * together, which is the same `canBuy` a buy itself is refused by. Drawn from
7 * scratch every time it opens and again after every press, so the prices and
8 * the money in the heading are the prices and the money right now.
9 *
10 * It is not a browser and it is not the frame. It takes the element to draw
11 * into, the two things a price is worked out from — the round and the player —
12 * and the two calls it cannot make itself: one that makes a `<button>` and one
13 * that says which item was pressed. `main.ts` is the only file that knows where
14 * that element came from; nothing here reaches for a document, a host or a
15 * world. A press does not buy: it says what was wanted and hands it back, so
16 * the wallet is still spent in one place — the frame, on the world it is
17 * simulating — and a tile left over from a match that has ended cannot reach
18 * into a match that has not begun.
19 *
20 * Nothing rendered here comes from outside the program: every label is a
21 * constant of `weapons.ts` or of this file, and the only value interpolated is
22 * a number off the player. No name, no field, nothing a person typed is in the
23 * markup, which is why there is no escaping in it to read.
24 */
25
26 import type { Listens } from "../engine/index.ts";
27 import { canBuy, priceOf, type BuyItem, type Buyer, type Market } from "./economy.ts";
28 import type { Team } from "./match.ts";
29 import { buyable } from "./weapons.ts";
30
31 /** One thing on the shelf: what a player reads, and what a press of it buys. */
32 export type Offer = { item: BuyItem; label: string };
33
34 /**
35 * The element the panel is drawn into, as far as this file is concerned: the
36 * markup it is given, and the grid it just wrote into that markup.
37 * `unknown` because a browser's own `querySelector` is wider than any subset.
38 */
39 export type Shelf = {
40 innerHTML: string;
41 querySelector(selectors: string): unknown;
42 appendChild(child: unknown): void;
43 };
44
45 /** One tile in the grid: a button, its label, whether it can be pressed. */
46 export type Tile = Listens & { type: string; textContent: string | null; disabled: boolean };
47
48 /**
49 * What the page has to lend the panel: the element, a way to make a button, and
50 * what a press means. `press` is the whole of what this module hands back —
51 * the item wanted, once per press — and the caller decides what it costs.
52 */
53 export type Counter = {
54 panel: Shelf;
55 /** A fresh `<button>`, unattached. `document.createElement("button")` is one. */
56 button(): Tile;
57 press(item: BuyItem): void;
58 };
59
60 /** What the panel says under the grid: the two keys that close it, and the one rule. */
61 export const HINT = "B or Escape closes. You can only buy in the freeze time.";
62
63 /**
64 * The shelf, in the order it is drawn: this side's guns and grenades as
65 * `weapons.ts` lists them, then the armour, then the defuse kit — which is on
66 * the shelf for a CT and not on it at all for a T, because a T cannot carry one
67 * and a tile that is never pressable is a tile that should not be there.
68 */
69 export function offers(team: Team): Offer[] {
70 const guns: Offer[] = buyable(team).map((weapon) => ({ item: weapon.id, label: weapon.name }));
71 const kit: Offer[] = team === "ct" ? [{ item: "kit", label: "Defuse kit" }] : [];
72 return [...guns, { item: "armor", label: "Kevlar" }, { item: "helmet", label: "Kevlar + Helmet" }, ...kit];
73 }
74
75 /**
76 * Draw the panel: the heading with what is left to spend, one tile per offer
77 * with the price this body pays for it, and the hint. A tile is dead when the
78 * wallet cannot reach the price or when the round is not one a buy is allowed
79 * in at all — the panel shows the prices either way rather than emptying
80 * itself, because what the next freeze time costs is worth reading.
81 */
82 export function drawBuy(counter: Counter, market: Market, player: Buyer): void {
83 const open = canBuy(market, player);
84 counter.panel.innerHTML =
85 `<h2>Buy <small>$${player.money}</small></h2><div class="grid"></div>` +
86 `<p class="hint">${HINT}</p>`;
87 const grid = counter.panel.querySelector(".grid") as Shelf;
88 for (const { item, label } of offers(player.team)) {
89 const price = priceOf(item, player);
90 const tile = counter.button();
91 tile.type = "button";
92 tile.textContent = `${label} · $${price}`;
93 tile.disabled = price > player.money || !open;
94 tile.addEventListener("click", () => counter.press(item));
95 grid.appendChild(tile);
96 }
97 }
src/client/main.test.ts +11 −4
@@ -2,7 +2,10 @@
22 * The frame itself, booted against the stub page in `stage.test-helper.ts`:
33 * the ids it looks up, the match a click starts, how many fixed steps one frame
44 * is worth, what the buy overlay does to the input, what losing the pointer
5 * means, and what it posts to the leaderboard. Nothing here is a browser and
5 * means, and what it posts to the leaderboard. The buy panel itself is not in
6 * here: `buy.test.ts` holds the shelf and the prices, and what is left below is
7 * the wiring — the keys that open and close it, the pointer it lets go, and the
8 * money a press costs the world. Nothing here is a browser and
69 * nothing is timed by a real clock — the test decides when a frame happens and
710 * how long it took.
811 *
@@ -291,24 +294,28 @@ test("the mouse turns the view, and while scoped it turns it by the zoom's share
291294 expect(world.me.yaw).toBeCloseTo(plainFrom - 0.484, 9);
292295 });
293296
294 test("B opens the buy overlay: it is rebuilt, it lets the pointer go and it takes the focus", () => {
297 test("B opens the buy overlay: it is drawn, it lets the pointer go and it takes the focus", () => {
295298 const stage = playing();
296299 stage.lock(true);
297300 stage.press("KeyB");
298301 const buy = stage.by("buy");
299302 expect(buy.hidden).toBe(false);
300 expect(buy.innerHTML).toContain("Buy");
303 // What is in the panel is `buy.test.ts`'s — the shelf, the prices, which tiles
304 // are dead. Here it is that it was drawn at all, that the pointer was let go on
305 // purpose, and that the first tile has the focus.
301306 const grid = buy.querySelector(".grid");
302307 expect(grid?.children.length).toBeGreaterThan(4);
303308 expect(grid?.children[0]?.focused).toBe(1);
304309 expect(stage.document.pointerLockElement).toBe(null);
305310
306 // Buying takes the money, beeps, and the panel is rebuilt with the new prices.
311 // A press is wired to the world this frame is simulating: the money goes, the
312 // beep plays, and the panel is drawn again with what is left.
307313 const world = (stage.step(FRAME), worldOf(stage));
308314 const before = world?.me.money ?? 0;
309315 grid?.children[0]?.fire("click");
310316 expect(world?.me.money).toBeLessThan(before);
311317 expect(stage.sounds).toContain("beep");
318 expect(buy.innerHTML).toContain(`$${world?.me.money}`);
312319
313320 // Escape closes it, and so does pressing B again.
314321 stage.window.fire("keydown", { code: "Escape" });
src/client/main.ts +21 −27
@@ -5,6 +5,9 @@
55 * the frame's data and the engine's renderer draws that — built here, from this
66 * game's atlas and this game's meshes — and the HUD is painted on top. The page's overlays — menu, buy, pause and the summary a match ends on — are
77 * plain DOM, so they are reachable with a keyboard and big enough for a thumb.
8 * The buy panel is one of them and is not in here: `buy.ts` draws the shelf out
9 * of the round and the wallet, and what this file keeps is the element, the keys
10 * that open and close it, and what a press costs the world.
811 *
912 * A match ends on a scoreboard and not on a menu. The frame that sees
1013 * `match_over` stops the run and, out of the world's final state before
@@ -85,8 +88,9 @@ import { drawPreview, previewText } from "./preview.ts";
8588 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
8689 import { usePlan, usePolicy } from "./bots.ts";
8790 import { createSpectator, cycle, follow } from "./spectate.ts";
88 import { buyable, WEAPONS } from "./weapons.ts";
89 import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts";
91 import { WEAPONS } from "./weapons.ts";
92 import { drawBuy } from "./buy.ts";
93 import { buy, canBuy, type BuyItem } from "./economy.ts";
9094 import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts";
9195 import { unpack } from "../engine/index.ts";
9296
@@ -536,32 +540,22 @@ export function boot(host: Host): Frame {
536540 }
537541
538542 // --- the buy menu, rebuilt whenever it opens so the prices are honest
539 function renderBuy(): void {
543 /**
544 * The shelf, the prices and what a wallet can reach are `buy.ts`'s. What is
545 * here is the wiring: the element it draws into, the button factory, and what
546 * a press costs — the only half of it that touches the world, and the world is
547 * read at the press rather than captured, so a tile left over from a match
548 * that has ended spends nothing.
549 */
550 const renderBuy = (): void => {
540551 if (world === null) return;
541 const me = world.me;
542 const items: [BuyItem, string][] = [
543 ...buyable(me.team).map((w) => [w.id, w.name] as [BuyItem, string]),
544 ["armor", "Kevlar"],
545 ["helmet", "Kevlar + Helmet"],
546 ...(me.team === "ct" ? ([["kit", "Defuse kit"]] as [BuyItem, string][]) : []),
547 ];
548 buyPanel.innerHTML =
549 `<h2>Buy <small>$${me.money}</small></h2><div class="grid"></div>` +
550 `<p class="hint">B or Escape closes. You can only buy in the freeze time.</p>`;
551 const grid = buyPanel.querySelector(".grid") as Panel;
552 for (const [item, label] of items) {
553 const price = priceOf(item, me);
554 const button = doc.createElement("button");
555 button.type = "button";
556 button.textContent = `${label} · $${price}`;
557 button.disabled = price > me.money || !canBuy(world, me);
558 button.addEventListener("click", () => {
559 if (world === null) return;
560 if (buy(world, world.me, item)) audio.beep(700, 0.05);
561 renderBuy();
562 });
563 grid.appendChild(button);
564 }
552 drawBuy({ panel: buyPanel, button: () => doc.createElement("button"), press: pressBuy }, world, world.me);
553 };
554
555 function pressBuy(item: BuyItem): void {
556 if (world === null) return;
557 if (buy(world, world.me, item)) audio.beep(700, 0.05);
558 renderBuy();
565559 }
566560
567561 function openBuy(open: boolean): void {
src/games.ts +13
@@ -465,6 +465,19 @@ export type Game = {
465465 * #216 measured, and nothing outside `src/client/`, `src/engine/trace.ts` and
466466 * `src/pages/game.ts` changed — the range and the example ship no world, no
467467 * weapons and no cloud. Measured 2026-09-21.
468 * #250 moved the buy panel out of the entry into `src/client/buy.ts` — the
469 * shelf, the prices and which tiles a wallet and a round leave pressable — and
470 * the app grew 199 raw / 71 gzipped bytes for it: 98,665 → 98,864 raw and
471 * 38,895 → 38,966 gzipped, measured on top of #165 rather than under it. It is
472 * a move and not a feature, so those bytes are the seam itself: a module
473 * boundary the minifier will not inline across, the object the frame lends the
474 * panel, and the hint sentence and the shelf now named rather than written
475 * inline. Nothing was added to the shelf to get them — the smoke #165 put in
476 * the weapon table arrived on the panel through `buyable`, which is the seam
477 * working rather than a line anybody wrote. It fits the 103,000 / 41,000 #165
478 * set with 4,136 raw and 2,034 gzipped to spare, so no ceiling moved. Neither
479 * other bundle changed a byte: nothing outside `src/client/` did. Measured
480 * 2026-09-21.
468481 */
469482 export const GAMES: readonly Game[] = [
470483 {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs