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

forked from trevato/cs

Buying never takes the mouse off the game: number keys in the buy menu, and a rebuy #274

Merged opened this

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

Merged

vesper/cs-engine:vesper/153-buying-never-takes-the-mouse-off-the-gam → main 1 commit fork

  • Merged. vesper/153-buying-never-takes-the-mouse-off-the-gam was merged into main as 578d2fc.

Review

Sign in to review this change.
14 files changed +753 −83
README.md +19 −5
@@ -348,7 +348,14 @@ the body it was measured against is a rumour.
348348 <kbd>G</kbd> throws the HE grenade in your pocket and <kbd>T</kbd> the
349349 flashbang, if you bought them,
350350 <kbd>E</kbd> plants or defuses, <kbd>B</kbd> opens the buy menu in the freeze
351 time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns — and **a switch costs CS's deploy
351 time — **and buying never takes the mouse off the game**: the panel opens with
352 the pointer still locked, every tile is printed with the key that buys it (the
353 number row, <kbd>1</kbd> to <kbd>9</kbd>, then <kbd>0</kbd> and <kbd>-</kbd>,
354 left to right), <kbd>X</kbd> buys last round's loadout again as far as the money
355 goes, and <kbd>B</kbd> or <kbd>Esc</kbd> closes. The tiles are still buttons, so
356 a thumb taps them and a keyboard tabs to them, and with the panel open the
357 trigger and the weapon keys are off the player while your feet still work —
358 <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns — and **a switch costs CS's deploy
352359 time**: the carry speed changes on the tick you press the key, so tapping
353360 <kbd>3</kbd> runs at the knife's 250 u/s straight away, but the weapon you come
354361 back to cannot fire, scope or reload until it is up (a pistol 0.7 s, a rifle or
@@ -748,10 +755,17 @@ never checked itself.
748755 the `Ammo` a fresh gun comes with),
749756 `buy` (the panel that economy is spent through: the shelf a side is shown —
750757 its guns, the armour, the kit only a CT can carry — the price `economy` gives
751 for the body in front of you, and which tiles the wallet and the round leave
752 pressable. It takes the element to draw into, the round and the player, and a
753 call that says which item was pressed; it reaches for no document and buys
754 nothing, so `main` spends the money in the one place that holds a world),
758 for the body in front of you, which tiles the wallet and the round leave
759 pressable, the key each tile is printed with, and the one control that is not
760 an item: last round's loadout, bought again. It takes the element to draw
761 into, the round and the player, and the calls that say what was pressed; it
762 reaches for no document and buys nothing, so `main` spends the money in the
763 one place that holds a world),
764 `loadout` (what you bought, and buying it again: a round's purchases in the
765 order they were made, rolled over on the round, and the rebuy that walks that
766 list through `economy`'s `buy` — stepping over a gun you survived with and
767 stopping at the first thing the money cannot reach — plus the one line it
768 says about it),
755769 `report` (the round on its way to the leaderboard: a world turned into the
756770 result the server takes, posted as the round is scored, and the one that did
757771 not land kept for another attempt on the policy `pending` decides — the
scripts/build.test.ts +1 −1
@@ -45,7 +45,7 @@ function fixture(): { dir: string; entries: Entry[]; source: (n: string, t: stri
4545
4646 test("the entry table is what the app ships today, each with the size it may not pass", () => {
4747 expect(ENTRIES).toEqual([
48 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 103_000, gzip: 41_000 } },
48 { name: "app", entry: "src/client/main.ts", ceiling: { raw: 110_000, gzip: 43_500 } },
4949 { name: "range", entry: "src/range/main.ts", ceiling: { raw: 37_000, gzip: 15_500 } },
5050 { name: "example", entry: "src/example/main.ts", ceiling: { raw: 23_500, gzip: 10_200 } },
5151 ]);
src/client/binding.test.ts +29 −1
@@ -1,5 +1,5 @@
11 import { expect, test } from "bun:test";
2 import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts";
2 import { CS_KEYS, CS_MOUSE, CS_TAP, NUMBER_ROW, readIntent, slotAction, slotIndex } from "./binding.ts";
33
44 const NONE = { x: 0, y: 0 };
55 const intent = (actions: string[], stick = NONE) => readIntent(new Set(actions), stick);
@@ -34,6 +34,34 @@ test("the bindings are the ones the keyboard has always had", () => {
3434 // and no browser has a meaning of its own for it to take the window back with.
3535 expect(CS_KEYS.KeyP).toBe("pause");
3636 expect(intent(["pause"])).toEqual(intent([]));
37 // X is the page's too: it buys last round's loadout again, which is money and
38 // not movement, so nothing about an `Input` changes for it either.
39 expect(CS_KEYS.KeyX).toBe("rebuy");
40 expect(intent(["rebuy"])).toEqual(intent([]));
41 });
42
43 test("the number row is one row of keys, bound in the order it is printed", () => {
44 // Eleven keys, left to right, and the code of each is what a browser calls
45 // that key: the buy panel prints them on its tiles in this order.
46 expect([...NUMBER_ROW]).toEqual(["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-"]);
47 const codes = ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "Digit0", "Minus"];
48 expect(codes.map((code) => CS_KEYS[code])).toEqual(NUMBER_ROW.map((_, i) => slotAction(i)));
49 // And the way back, which is how `main.ts` turns a press into the tile under
50 // it: every one of them, and nothing else.
51 expect(codes.map((code) => slotIndex(CS_KEYS[code] ?? ""))).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
52 expect(slotIndex("forward")).toBeNull();
53 expect(slotIndex("slot12")).toBeNull();
54 expect(slotIndex("slot")).toBeNull();
55 });
56
57 test("only the first three of the row are pockets: the rest are the panel's and the world has never heard of them", () => {
58 expect(intent(["slot1"]).slotWanted).toBe(1);
59 expect(intent(["slot2"]).slotWanted).toBe(2);
60 expect(intent(["slot3"]).slotWanted).toBe(3);
61 for (const action of ["slot4", "slot9", "slot10", "slot11"]) {
62 expect(intent([action]).slotWanted).toBeNull();
63 expect(intent([action])).toEqual(intent([]));
64 }
3765 });
3866
3967 test("nothing held is nothing asked for", () => {
src/client/binding.ts +38 −1
@@ -8,6 +8,37 @@
88 import { clamp } from "../engine/index.ts";
99 import { NO_INPUT, type Input } from "./world.ts";
1010
11 /**
12 * The number row, left to right, printed as the key prints itself. It is one
13 * list because it is one row of keys with two meanings, and which one is meant
14 * is the frame's question rather than the keyboard's: with the buy panel open
15 * every key of it presses the tile it is printed on, and with the panel closed
16 * the first three are the weapon pockets and the rest do nothing.
17 *
18 * Eleven, because a CT's shelf is eleven tiles — five guns, three grenades, the
19 * vest, the vest and helmet, and the kit — and a tile with no key on it is a
20 * tile you need the mouse for, which is the whole of what #153 was about.
21 * `src/client/buy.ts` prints these on the tiles and `src/client/main.ts` reads
22 * one back to an index in the same list, so the number on the screen and the
23 * key that buys it cannot drift.
24 */
25 export const NUMBER_ROW = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0", "-"] as const;
26
27 /** The `KeyboardEvent.code` of each of those keys, in the same order. */
28 const NUMBER_CODES = [
29 "Digit1", "Digit2", "Digit3", "Digit4", "Digit5",
30 "Digit6", "Digit7", "Digit8", "Digit9", "Digit0", "Minus",
31 ] as const;
32
33 /** What the nth key of the number row presses: `slot1` is the first of them. */
34 export const slotAction = (index: number): string => `slot${index + 1}`;
35
36 /** Which key of the number row an action is, or null for anything else. */
37 export function slotIndex(action: string): number | null {
38 const at = NUMBER_ROW.findIndex((_, index) => slotAction(index) === action);
39 return at === -1 ? null : at;
40 }
41
1142 /** `KeyboardEvent.code` to action. Today's bindings, moved rather than tuned. */
1243 export const CS_KEYS: Record<string, string> = {
1344 KeyW: "forward", KeyS: "back", KeyA: "left", KeyD: "right",
@@ -15,7 +46,7 @@ export const CS_KEYS: Record<string, string> = {
1546 Space: "jump", ControlLeft: "duck", KeyC: "duck", ShiftLeft: "walk",
1647 KeyR: "reload", KeyE: "use", KeyG: "grenade", KeyT: "flash", KeyV: "smoke",
1748 KeyB: "buy", Tab: "scoreboard",
18 Digit1: "slot1", Digit2: "slot2", Digit3: "slot3",
49 ...Object.fromEntries(NUMBER_CODES.map((code, index) => [code, slotAction(index)])),
1950 // Neither of these is a thing the world knows about: `main.ts` hears them on
2051 // `onAction`, and `readIntent` below never sees either. F asks the browser for
2152 // the screen; P pauses, which is what Escape means too — and P is a key no
@@ -23,6 +54,9 @@ export const CS_KEYS: Record<string, string> = {
2354 // window back, which is what Escape does on a Mac.
2455 KeyF: "fullscreen",
2556 KeyP: "pause",
57 // Nor is this one: X buys last round's loadout again, which is money and not
58 // movement, so `main.ts` hears it and `readIntent` never does.
59 KeyX: "rebuy",
2660 };
2761
2862 /** Left fires, right scopes. */
@@ -66,6 +100,9 @@ export function readIntent(held: ReadonlySet<string>, stick: { x: number; y: num
66100 return {
67101 input,
68102 scoreboard: down("scoreboard"),
103 // Three pockets, and only three: the rest of the number row belongs to the
104 // buy panel, and a world that has never heard of `slot4` cannot be steered
105 // by the key that buys the fourth tile.
69106 slotWanted: down("slot3") ? 3 : down("slot2") ? 2 : down("slot1") ? 1 : null,
70107 };
71108 }
src/client/buy.test.ts +86 −25
@@ -15,8 +15,9 @@
1515 */
1616
1717 import { expect, test } from "bun:test";
18 import { NUMBER_ROW } from "./binding.ts";
1819 import { fakePage, type FakeElement } from "../engine/index.ts";
19 import { drawBuy, HINT, offers, type Counter } from "./buy.ts";
20 import { drawBuy, HINT, labelOf, offers, REBUY_LABEL, type Counter } from "./buy.ts";
2021 import { emptyAmmo, type BuyItem, type Buyer, type Market } from "./economy.ts";
2122 import { DE_FORGE } from "./map.ts";
2223 import { SHORT } from "./match.ts";
@@ -40,24 +41,39 @@ function shop(): {
4041 panel: FakeElement;
4142 counter: Counter;
4243 pressed: BuyItem[];
44 /** How many times the rebuy control was pressed. */
45 rebuys(): number;
4346 tiles(): FakeElement[];
47 /** The one control under the grid: last round's loadout, bought again. */
48 again(): FakeElement;
4449 labels(): (string | null)[];
45 /** The tile whose label starts with this word, which is how a test names one. */
50 /** Whether any tile's label holds this, whatever number it was printed with. */
51 says(part: string): boolean;
52 /** The tile whose label holds this word, which is how a test names one. */
4653 tile(label: string): FakeElement;
4754 } {
4855 const page = fakePage({ ids: ["buy"] });
4956 const panel = page.by("buy");
5057 const pressed: BuyItem[] = [];
58 let again = 0;
5159 const tiles = (): FakeElement[] => panel.querySelector(".grid")?.children ?? [];
5260 return {
5361 panel,
5462 pressed,
63 rebuys: () => again,
5564 tiles,
65 again: () => {
66 const found = panel.querySelector(".row")?.children[0];
67 if (found === undefined) throw new Error("no rebuy control");
68 return found;
69 },
5670 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.
71 says: (part) => tiles().some((tile) => tile.textContent?.includes(part) === true),
72 // By what the label says and not by index: a weapon added to the table adds
73 // a tile and moves every number after it, and a test about the armour should
74 // not move when one is.
5975 tile: (label) => {
60 const found = tiles().find((tile) => tile.textContent?.startsWith(label));
76 const found = tiles().find((tile) => tile.textContent?.includes(label));
6177 if (found === undefined) throw new Error(`no tile for ${label}`);
6278 return found;
6379 },
@@ -65,6 +81,7 @@ function shop(): {
6581 panel,
6682 button: () => page.document.createElement("button"),
6783 press: (item) => void pressed.push(item),
84 again: () => void (again += 1),
6885 },
6986 };
7087 }
@@ -91,25 +108,35 @@ test("the panel a real world in its freeze time is drawn as, to the character",
91108
92109 expect(it.panel.innerHTML).toBe(
93110 `<h2>Buy <small>$800</small></h2><div class="grid"></div>` +
94 `<p class="hint">${HINT}</p>`,
111 `<div class="row"></div><p class="hint">${HINT}</p>`,
95112 );
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.
113 // Eleven tiles, each a `<button>` — a press that submits nothing — printed
114 // with the key that presses it and the price this body pays, and dead exactly
115 // where 800 dollars cannot reach. The eleventh key is the one after 0, because
116 // a CT's shelf is one longer than the digits and a tile with no key on it is a
117 // tile you need the mouse for.
99118 expect(it.tiles().map((tile) => tile.type)).toEqual(Array(11).fill("button"));
100119 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],
120 ["1 · USP-S · $200", false],
121 ["2 · Desert Eagle · $700", false],
122 ["3 · MP9 · $1250", true],
123 ["4 · M4A4 · $3100", true],
124 ["5 · AWP · $4750", true],
125 ["6 · HE Grenade · $300", false],
126 ["7 · Flashbang · $200", false],
127 ["8 · Smoke Grenade · $300", false],
128 ["9 · Kevlar · $650", false],
129 ["0 · Kevlar + Helmet · $1000", true],
130 ["- · Defuse kit · $400", false],
112131 ]);
132 // And the number a tile is printed with is the key `binding.ts` binds to that
133 // index: the panel and the keyboard read one list, so they cannot disagree.
134 expect(it.tiles().map((tile) => tile.textContent?.split(" · ")[0]))
135 .toEqual([...NUMBER_ROW]);
136 // Under them, the control that is not an item. Nothing was bought last round
137 // here, so it is dead rather than live and empty.
138 expect(it.again().textContent).toBe(REBUY_LABEL);
139 expect(it.again().disabled).toBe(true);
113140 });
114141
115142 test("drawing again is the panel again, not a second one under it", () => {
@@ -127,17 +154,17 @@ test("drawing again is the panel again, not a second one under it", () => {
127154 test("a price is for the body in front of you: a vest on is a helmet at helmet price", () => {
128155 const it = shop();
129156 drawBuy(it.counter, freeze, buyer({ armor: 100 }));
130 expect(it.labels()).toContain(`Kevlar + Helmet · $${HELMET_PRICE}`);
157 expect(it.says(`Kevlar + Helmet · $${HELMET_PRICE}`)).toBe(true);
131158 // The vest is already on, so it is priced at nothing — and a tile at nothing is
132159 // pressable, although `economy.ts` refuses the buy. That is today's behaviour
133160 // and this pull moved it rather than fixing it: see #250.
134 expect(it.labels()).toContain("Kevlar · $0");
161 expect(it.says("Kevlar · $0")).toBe(true);
135162 expect(it.tile("Kevlar ·").disabled).toBe(false);
136163 // Bare, the helmet is the vest as well and costs both.
137164 const bare = shop();
138165 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}`);
166 expect(bare.says(`Kevlar + Helmet · $${ARMOR_PRICE + HELMET_PRICE}`)).toBe(true);
167 expect(bare.says(`Defuse kit · $${DEFUSE_KIT_PRICE}`)).toBe(true);
141168 });
142169
143170 test("a wallet that cannot reach a price is a dead tile, to the dollar", () => {
@@ -154,7 +181,7 @@ test("outside the freeze time every tile is dead, and the prices are still reada
154181 drawBuy(it.counter, live, buyer({ money: 16_000 }));
155182 expect(it.tiles().length).toBe(offers("ct").length);
156183 expect(it.tiles().every((tile) => tile.disabled)).toBe(true);
157 expect(it.labels()).toContain(`AWP · $${WEAPONS.awp.price}`);
184 expect(it.says(`AWP · $${WEAPONS.awp.price}`)).toBe(true);
158185 // And to a body, wherever the round is: a corpse has nothing to spend it on.
159186 const dead = shop();
160187 drawBuy(dead.counter, freeze, buyer({ alive: false, money: 16_000 }));
@@ -176,3 +203,37 @@ test("a press hands the item back and spends nothing itself", () => {
176203 it.tile("USP-S").fire("click");
177204 expect(it.pressed).toEqual(["usp", "helmet", "usp"]);
178205 });
206
207 test("the rebuy control is live only when there is a loadout and a round to buy it in", () => {
208 const it = shop();
209 // Something was bought last round, and the round is one you can buy in.
210 drawBuy(it.counter, freeze, buyer(), ["ak47", "armor"]);
211 expect(it.again().disabled).toBe(false);
212 it.again().fire("click");
213 expect(it.rebuys()).toBe(1);
214 // It spends nothing itself, the way a tile does not: the caller buys.
215 expect(it.pressed).toEqual([]);
216
217 // The same loadout outside the freeze time is a dead control, like every tile
218 // beside it, and so is an empty one in the freeze time.
219 const late = shop();
220 drawBuy(late.counter, live, buyer(), ["ak47"]);
221 expect(late.again().disabled).toBe(true);
222 const nothing = shop();
223 drawBuy(nothing.counter, freeze, buyer(), []);
224 expect(nothing.again().disabled).toBe(true);
225 });
226
227 test("the hint under the grid says the keys, and an item answers to the name the shelf gave it", () => {
228 const it = shop();
229 drawBuy(it.counter, freeze, buyer());
230 expect(it.panel.innerHTML).toContain(HINT);
231 expect(HINT).toContain("number row");
232 expect(HINT).toContain("X");
233 expect(HINT).toContain("B or Escape closes");
234 // The sentence a rebuy says is built out of these, so they are the shelf's
235 // names and not a second table of them.
236 expect(labelOf("ak47", "t")).toBe("AK-47");
237 expect(labelOf("helmet", "ct")).toBe("Kevlar + Helmet");
238 expect(labelOf("kit", "ct")).toBe("Defuse kit");
239 });
src/client/buy.ts +61 −14
@@ -7,10 +7,17 @@
77 * scratch every time it opens and again after every press, so the prices and
88 * the money in the heading are the prices and the money right now.
99 *
10 * Every tile is printed with the key that presses it — the number row, left to
11 * right, from `binding.ts` — because the panel is read with the mouse still on
12 * the game: the hand that buys is the hand that is walking out of spawn, and a
13 * tile you can only reach by pointing at it is a tile that costs a pointer
14 * round-trip. Under them is the one control that is not an item: last round's
15 * loadout, bought again.
16 *
1017 * It is not a browser and it is not the frame. It takes the element to draw
1118 * 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
19 * and the calls it cannot make itself: one that makes a `<button>`, one that
20 * says which item was pressed, and one that says a rebuy was asked for. `main.ts` is the only file that knows where
1421 * that element came from; nothing here reaches for a document, a host or a
1522 * world. A press does not buy: it says what was wanted and hands it back, so
1623 * the wallet is still spent in one place — the frame, on the world it is
@@ -23,6 +30,7 @@
2330 * markup, which is why there is no escaping in it to read.
2431 */
2532
33 import { NUMBER_ROW } from "./binding.ts";
2634 import type { Listens } from "../engine/index.ts";
2735 import { canBuy, priceOf, type BuyItem, type Buyer, type Market } from "./economy.ts";
2836 import type { Team } from "./match.ts";
@@ -49,16 +57,37 @@ export type Tile = Listens & { type: string; textContent: string | null; disable
4957 * What the page has to lend the panel: the element, a way to make a button, and
5058 * what a press means. `press` is the whole of what this module hands back —
5159 * the item wanted, once per press — and the caller decides what it costs.
60 * `again` is the same thing for the one control that is not an item: last
61 * round's loadout, which the caller buys back through the same `buy()`.
5262 */
5363 export type Counter = {
5464 panel: Shelf;
5565 /** A fresh `<button>`, unattached. `document.createElement("button")` is one. */
5666 button(): Tile;
5767 press(item: BuyItem): void;
68 again(): void;
5869 };
5970
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.";
71 /**
72 * What the panel says under the grid: the keys, then the one rule. It reads the
73 * way the menu's hint line reads, and it says the number row rather than naming
74 * eleven keys, because each tile is printed with the key that presses it.
75 */
76 export const HINT =
77 "The number row buys the tile it is printed on · X buys last round's loadout again · " +
78 "B or Escape closes. You can only buy in the freeze time.";
79
80 /** The control under the grid, and the key it answers to. */
81 export const REBUY_LABEL = "X · Rebuy last round";
82
83 /** The key printed on the nth tile, or "" for a shelf longer than the row. */
84 export const keyFor = (index: number): string => NUMBER_ROW[index] ?? "";
85
86 /** What a tile says: the key that presses it, its name, and what this body pays. */
87 export const tileLabel = (index: number, label: string, price: number): string => {
88 const key = keyFor(index);
89 return `${key === "" ? "" : `${key} · `}${label} · $${price}`;
90 };
6291
6392 /**
6493 * The shelf, in the order it is drawn: this side's guns and grenades as
@@ -72,26 +101,44 @@ export function offers(team: Team): Offer[] {
72101 return [...guns, { item: "armor", label: "Kevlar" }, { item: "helmet", label: "Kevlar + Helmet" }, ...kit];
73102 }
74103
104 /** The name this shelf gives an item, which is the name a sentence about it uses. */
105 export const labelOf = (item: BuyItem, team: Team): string =>
106 offers(team).find((offer) => offer.item === item)?.label ?? item;
107
75108 /**
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.
109 * Draw the panel: the heading with what is left to spend, one tile per offer —
110 * numbered with the key that presses it — the rebuy control, and the hint. A
111 * tile is dead when the wallet cannot reach the price or when the round is not
112 * one a buy is allowed in at all — the panel shows the prices either way rather
113 * than emptying itself, because what the next freeze time costs is worth
114 * reading.
115 *
116 * `last` is what was bought the round before, and the whole of what this module
117 * wants to know about it is whether there is any of it: a rebuy with nothing to
118 * buy back is a dead control rather than a live one that does nothing.
81119 */
82 export function drawBuy(counter: Counter, market: Market, player: Buyer): void {
120 export function drawBuy(counter: Counter, market: Market, player: Buyer, last: readonly BuyItem[] = []): void {
83121 const open = canBuy(market, player);
84122 counter.panel.innerHTML =
85123 `<h2>Buy <small>$${player.money}</small></h2><div class="grid"></div>` +
86 `<p class="hint">${HINT}</p>`;
124 `<div class="row"></div><p class="hint">${HINT}</p>`;
87125 const grid = counter.panel.querySelector(".grid") as Shelf;
88 for (const { item, label } of offers(player.team)) {
126 // The index is the tile's place on the screen and the key that presses it,
127 // read out of one list: `main.ts` turns the key back into this same index of
128 // this same `offers`, so the number on a tile is the item it buys.
129 offers(player.team).forEach(({ item, label }, index) => {
89130 const price = priceOf(item, player);
90131 const tile = counter.button();
91132 tile.type = "button";
92 tile.textContent = `${label} · $${price}`;
133 tile.textContent = tileLabel(index, label, price);
93134 tile.disabled = price > player.money || !open;
94135 tile.addEventListener("click", () => counter.press(item));
95136 grid.appendChild(tile);
96 }
137 });
138 const again = counter.button();
139 again.type = "button";
140 again.textContent = REBUY_LABEL;
141 again.disabled = !open || last.length === 0;
142 again.addEventListener("click", () => counter.again());
143 (counter.panel.querySelector(".row") as Shelf).appendChild(again);
97144 }
src/client/loadout.test.ts added +136
@@ -0,0 +1,136 @@
1 /**
2 * The wallet's memory: what a round bought, what the round after buys back, and
3 * the sentence it says about it. No page and no frame — a `Rounds` is a phase
4 * and a round number, and a `Buyer` is the kit and the wallet, the way
5 * `economy.test.ts` and `buy.test.ts` write them.
6 */
7
8 import { expect, test } from "bun:test";
9 import { emptyAmmo, type Buyer } from "./economy.ts";
10 import { atRound, createLoadout, noteBuy, rebuy, saidRebuy, type Rounds } from "./loadout.ts";
11 import { ARMOR_PRICE, HELMET_PRICE, WEAPONS } from "./weapons.ts";
12
13 const round = (n: number, phase: "freeze" | "live" = "freeze"): Rounds => ({ match: { phase, round: n } });
14
15 const buyer = (over: Partial<Buyer> = {}): Buyer => ({
16 team: "t", alive: true, money: 5000,
17 armor: 0, helmet: false, defuseKit: false,
18 weapon: "glock", primary: null, secondary: "glock",
19 ammo: { knife: { mag: 0, reserve: 0 }, glock: emptyAmmo("glock") },
20 pocket: { he: 0, flash: 0, smoke: 0 },
21 ...over,
22 });
23
24 test("a round's purchases become the next round's rebuy, in the order they were made", () => {
25 const loadout = createLoadout();
26 atRound(loadout, 1);
27 noteBuy(loadout, "ak47");
28 noteBuy(loadout, "armor");
29 noteBuy(loadout, "smoke");
30 // Nothing to buy back yet: round one is the first round anybody bought in.
31 expect(loadout.last).toEqual([]);
32
33 atRound(loadout, 2);
34 expect(loadout.last).toEqual(["ak47", "armor", "smoke"]);
35 expect(loadout.bought).toEqual([]);
36 });
37
38 test("a round nobody bought anything in is a rebuy of nothing, not of two rounds ago", () => {
39 const loadout = createLoadout();
40 atRound(loadout, 1);
41 noteBuy(loadout, "ak47");
42 atRound(loadout, 2);
43 atRound(loadout, 3);
44 expect(loadout.last).toEqual([]);
45 expect(saidRebuy(rebuy(round(3), buyer(), loadout), "t"))
46 .toBe("Nothing to rebuy: you bought nothing last round.");
47 });
48
49 test("a rebuy buys last round's loadout again, through the shop, and says what it bought", () => {
50 const loadout = createLoadout();
51 atRound(loadout, 1);
52 noteBuy(loadout, "ak47");
53 noteBuy(loadout, "helmet");
54 atRound(loadout, 2);
55
56 const me = buyer({ money: WEAPONS.ak47.price + ARMOR_PRICE + HELMET_PRICE });
57 const done = rebuy(round(2), me, loadout);
58 expect(done.bought).toEqual(["ak47", "helmet"]);
59 expect(done.short).toBeNull();
60 // The kit is the kit the shop hands over, because the shop is what handed it:
61 // the gun in your hands, the vest on, the helmet on, and nothing left.
62 expect(me.primary).toBe("ak47");
63 expect(me.helmet).toBe(true);
64 expect(me.money).toBe(0);
65 expect(saidRebuy(done, "t")).toBe("Rebought AK-47, Kevlar + Helmet.");
66 // And it is this round's loadout now, so the round after buys the same again.
67 expect(loadout.bought).toEqual(["ak47", "helmet"]);
68 });
69
70 test("a rebuy stops where the money runs out, and says what it could not afford", () => {
71 const loadout = createLoadout();
72 atRound(loadout, 1);
73 noteBuy(loadout, "ak47");
74 noteBuy(loadout, "helmet");
75 atRound(loadout, 2);
76
77 const me = buyer({ money: WEAPONS.ak47.price + 100 });
78 const done = rebuy(round(2), me, loadout);
79 expect(done.bought).toEqual(["ak47"]);
80 expect(done.short).toBe("helmet");
81 expect(me.money).toBe(100);
82 expect(me.helmet).toBe(false);
83 expect(saidRebuy(done, "t")).toBe("Rebought AK-47 — not enough for Kevlar + Helmet.");
84
85 // And a wallet that cannot reach the first thing on the list buys none of it.
86 const broke = buyer({ money: 10 });
87 const none = rebuy(round(2), broke, createLoadoutWith(["ak47", "armor"]));
88 expect(none.bought).toEqual([]);
89 expect(broke.money).toBe(10);
90 expect(saidRebuy(none, "t")).toBe("Nothing rebought — not enough for AK-47.");
91 });
92
93 /** A loadout that has already played a round: `last` is this list. */
94 function createLoadoutWith(last: ("ak47" | "armor")[]) {
95 const loadout = createLoadout();
96 atRound(loadout, 1);
97 for (const item of last) noteBuy(loadout, item);
98 atRound(loadout, 2);
99 return loadout;
100 }
101
102 test("a gun you survived the round with is not bought twice", () => {
103 const loadout = createLoadoutWith(["ak47", "armor"]);
104 // Still holding the rifle, still wearing the vest: a rebuy is a no-op that
105 // spends nothing, and it says so rather than pretending it bought something.
106 const me = buyer({ money: 5000, primary: "ak47", armor: 100 });
107 const done = rebuy(round(2), me, loadout);
108 expect(done.bought).toEqual([]);
109 expect(done.short).toBeNull();
110 expect(me.money).toBe(5000);
111 expect(saidRebuy(done, "t")).toBe("Nothing to rebuy: you still have last round's kit.");
112 });
113
114 test("armour you already have is stepped over rather than stopped at", () => {
115 const loadout = createLoadout();
116 atRound(loadout, 1);
117 noteBuy(loadout, "armor");
118 noteBuy(loadout, "ak47");
119 atRound(loadout, 2);
120 // The vest survived the round and the rifle did not: the vest is refused by
121 // the shop, which is not the money running out, so the rifle is still bought.
122 const me = buyer({ money: WEAPONS.ak47.price, armor: 100 });
123 const done = rebuy(round(2), me, loadout);
124 expect(done.bought).toEqual(["ak47"]);
125 expect(me.primary).toBe("ak47");
126 });
127
128 test("a rebuy outside the freeze time, or dead, buys nothing at all", () => {
129 const loadout = createLoadoutWith(["ak47"]);
130 const me = buyer({ money: 5000 });
131 expect(rebuy(round(2, "live"), me, loadout).bought).toEqual([]);
132 expect(me.money).toBe(5000);
133 const dead = buyer({ money: 5000, alive: false });
134 expect(rebuy(round(2), dead, createLoadoutWith(["ak47"])).bought).toEqual([]);
135 expect(dead.money).toBe(5000);
136 });
src/client/loadout.ts added +115
@@ -0,0 +1,115 @@
1 /**
2 * What you bought, and buying it again. A round's purchases are written down in
3 * the order they were made; the round after, that list is what one key buys
4 * back — the same items in the same order, as far as the money goes.
5 *
6 * It spends through `buy()` in `economy.ts` and through nothing else: a rebuy is
7 * the same purchases a player would have made by hand, made faster, so every
8 * refusal the shop already has — the phase, the wallet, a pocket that is full, a
9 * kit a T cannot carry — is the refusal a rebuy gets. What is added here is one
10 * rule of its own: a gun you are still holding is not bought twice, because you
11 * survived the round with it and money for a second copy of it is money burnt.
12 *
13 * It knows nothing about a page. The frame tells it which round it is on, the
14 * panel tells it what was pressed, and what comes back is a plain result and a
15 * sentence — which the frame shows in the HUD's message line and the panel
16 * announces, and neither of them composes itself.
17 */
18
19 import { labelOf } from "./buy.ts";
20 import { buy, canBuy, priceOf, type BuyItem, type Buyer, type Market } from "./economy.ts";
21 import type { Team } from "./match.ts";
22
23 /**
24 * The round a rebuy happens in: whether the shop is open, which is `Market`, and
25 * which round it is, which is what makes last round's list last round's. A
26 * `World` satisfies it, and this file cannot reach back for one.
27 */
28 export type Rounds = Market & { match: { round: number } };
29
30 /** What was bought this round, what was bought the round before, and which round that is. */
31 export type Loadout = {
32 /** The round `bought` is being written for. */
33 round: number;
34 /** This round's purchases, oldest first. */
35 bought: BuyItem[];
36 /** The round before's, which is what a rebuy buys back. */
37 last: BuyItem[];
38 };
39
40 export const createLoadout = (): Loadout => ({ round: 0, bought: [], last: [] });
41
42 /**
43 * A round has begun. What was bought in the one before it becomes the rebuy and
44 * the new round starts with an empty list — called every frame, because a round
45 * where nothing was bought is a rebuy of nothing and a memory that rolled over
46 * only when somebody bought something would hand out a two-round-old loadout.
47 */
48 export function atRound(loadout: Loadout, round: number): void {
49 if (round === loadout.round) return;
50 loadout.last = loadout.bought;
51 loadout.bought = [];
52 loadout.round = round;
53 }
54
55 /** One purchase that went through, written down in the order it was made. */
56 export function noteBuy(loadout: Loadout, item: BuyItem): void {
57 loadout.bought.push(item);
58 }
59
60 /** What a rebuy did: what it bought, what the money ran out on, and what it had to buy. */
61 export type Rebought = {
62 /** The items bought back, in the order they were bought the first time. */
63 bought: BuyItem[];
64 /** The first item the wallet could not reach, which is where it stopped. */
65 short: BuyItem | null;
66 /** How many items last round's loadout held, so "nothing to rebuy" can say so. */
67 had: number;
68 };
69
70 /** Do you already hold this gun? A second one of it is money for nothing. */
71 const holds = (player: Buyer, item: BuyItem): boolean =>
72 player.primary === item || player.secondary === item;
73
74 /**
75 * Buy last round's loadout again, in the order it was bought, and stop at the
76 * first thing the money cannot reach — which is the order that gets the gun
77 * before the vest, because that is the order a player bought them in.
78 *
79 * Anything else the shop refuses — armour already on, a pocket already holding
80 * its grenade — is stepped over rather than stopped at: you have it, which is
81 * not a reason to abandon the rest of the list.
82 */
83 export function rebuy(market: Rounds, player: Buyer, loadout: Loadout): Rebought {
84 atRound(loadout, market.match.round);
85 const done: Rebought = { bought: [], short: null, had: loadout.last.length };
86 if (!canBuy(market, player)) return done;
87 for (const item of loadout.last) {
88 if (holds(player, item)) continue;
89 if (buy(market, player, item)) {
90 loadout.bought.push(item);
91 done.bought.push(item);
92 continue;
93 }
94 // It did not happen. The wallet is why when the price is out of reach, and
95 // that is where a rebuy stops; anything else is a thing you already have.
96 if (priceOf(item, player) > player.money) {
97 done.short = item;
98 break;
99 }
100 }
101 return done;
102 }
103
104 /** The one line a rebuy says: what it bought, or what it could not afford. */
105 export function saidRebuy({ bought, short, had }: Rebought, team: Team): string {
106 const names = bought.map((item) => labelOf(item, team)).join(", ");
107 if (short !== null)
108 return bought.length === 0
109 ? `Nothing rebought — not enough for ${labelOf(short, team)}.`
110 : `Rebought ${names} — not enough for ${labelOf(short, team)}.`;
111 if (bought.length > 0) return `Rebought ${names}.`;
112 return had === 0
113 ? "Nothing to rebuy: you bought nothing last round."
114 : "Nothing to rebuy: you still have last round's kit.";
115 }
src/client/main.test.ts +111 −13
@@ -32,6 +32,7 @@ import { expect, test } from "bun:test";
3232 import { generateLevel, nameOf, seedFrom, seedOf } from "./generate.ts";
3333 import type { Level } from "./level.ts";
3434 import { boot, COPIED, gameMeshes, NOT_AN_ID, NOT_A_MAP, NOT_COPIED, type Frame } from "./main.ts";
35 import { HINT } from "./buy.ts";
3536 import { DE_FORGE } from "./map.ts";
3637 import { endRound, SHORT } from "./match.ts";
3738 import { LAYERS, materialTextures } from "./materials.ts";
@@ -295,34 +296,127 @@ test("the mouse turns the view, and while scoped it turns it by the zoom's share
295296 expect(world.me.yaw).toBeCloseTo(plainFrom - 0.484, 9);
296297 });
297298
298 test("B opens the buy overlay: it is drawn, it lets the pointer go and it takes the focus", () => {
299 test("B opens the buy overlay: it is drawn, it keeps the pointer and the shelf takes the focus", () => {
299300 const stage = playing();
300301 stage.lock(true);
301302 stage.press("KeyB");
302303 const buy = stage.by("buy");
304 const shelf = stage.by("buyshelf");
303305 expect(buy.hidden).toBe(false);
304306 // What is in the panel is `buy.test.ts`'s — the shelf, the prices, which tiles
305 // are dead. Here it is that it was drawn at all, that the pointer was let go on
306 // purpose, and that the first tile has the focus.
307 const grid = buy.querySelector(".grid");
307 // are dead. Here it is that it was drawn at all, that the pointer is still the
308 // game's, and that the focus is on the shelf rather than on a tile: a focused
309 // tile is a tile Space presses, and Space is jump.
310 const grid = shelf.querySelector(".grid");
308311 expect(grid?.children.length).toBeGreaterThan(4);
309 expect(grid?.children[0]?.focused).toBe(1);
310 expect(stage.document.pointerLockElement).toBe(null);
312 expect(grid?.children[0]?.focused).toBe(0);
313 expect(shelf.focused).toBe(1);
314 expect(stage.document.pointerLockElement).toBe(stage.scene);
315 // And the line under it says what opened and what the keys are, in the live
316 // region the page renders: with no cursor to move into the panel, this is what
317 // a screen reader is told instead.
318 const world = (stage.step(FRAME), worldOf(stage));
319 expect(stage.by("buysays").textContent).toContain(`$${world?.me.money}`);
320 expect(stage.by("buysays").textContent).toContain(HINT);
311321
312322 // A press is wired to the world this frame is simulating: the money goes, the
313323 // beep plays, and the panel is drawn again with what is left.
314 const world = (stage.step(FRAME), worldOf(stage));
315324 const before = world?.me.money ?? 0;
316325 grid?.children[0]?.fire("click");
317326 expect(world?.me.money).toBeLessThan(before);
318327 expect(stage.sounds).toContain("beep");
319 expect(buy.innerHTML).toContain(`$${world?.me.money}`);
328 expect(shelf.innerHTML).toContain(`$${world?.me.money}`);
320329
321 // Escape closes it, and so does pressing B again.
330 // Escape closes it, and so does pressing B again — and neither touches the
331 // pointer, which is still on the game it never left.
322332 stage.window.fire("keydown", { code: "Escape" });
323333 expect(buy.hidden).toBe(true);
324334 stage.press("KeyB");
325335 expect(buy.hidden).toBe(false);
336 expect(stage.document.pointerLockElement).toBe(stage.scene);
337 expect(stage.scene.locks).toBe(1);
338 });
339
340 test("a number key buys the tile it is printed on, and one the wallet cannot reach buys nothing", () => {
341 const stage = playing();
342 stage.lock(true);
343 stage.step(FRAME);
344 const world = worldOf(stage);
345 if (world === null) throw new Error("the first frame drew nothing");
346 stage.press("KeyB");
347 const shelf = stage.by("buyshelf");
348 const tiles = () => shelf.querySelector(".grid")?.children ?? [];
349 // The fifth tile is the AWP at $4750 and the freeze-time wallet is $800: the
350 // key for it is a key that does nothing, which is what a dead tile is.
351 expect(tiles()[4]?.textContent).toContain("5 · ");
352 const before = world.me.money;
353 stage.press("Digit5");
354 expect(world.me.money).toBe(before);
355 expect(world.me.primary).toBeNull();
356
357 // The first is this side's pistol, and the number buys exactly it: the same
358 // money, the same kit and the same beep a click on that tile costs.
359 const [key, name] = (tiles()[0]?.textContent ?? "").split(" · ");
360 expect(key).toBe("1");
361 stage.press("Digit1");
362 expect(world.me.money).toBeLessThan(before);
363 expect(stage.by("buysays").textContent).toBe(`Bought ${name}. $${world.me.money} left.`);
364 expect(stage.sounds).toContain("beep");
365
366 // And the key is the panel's only while the panel is up: with it closed the
367 // same key is the weapon pocket it has always been, and buys nothing.
368 const spent = world.me.money;
369 stage.press("KeyB");
370 expect(stage.by("buy").hidden).toBe(true);
371 stage.press("Digit1");
372 expect(world.me.money).toBe(spent);
373 });
374
375 test("X buys last round's loadout again, in order, and stops when the money runs out", () => {
376 const stage = playing();
377 stage.lock(true);
378 stage.step(FRAME);
379 const world = worldOf(stage);
380 if (world === null) throw new Error("the first frame drew nothing");
381 const me = world.me;
382 // Round one: a rifle and a vest, bought with the keys, in that order.
383 me.money = 10_000;
384 stage.press("KeyB");
385 const rifle = me.team === "t" ? "ak47" : "m4a4";
386 stage.press("Digit4");
387 stage.press("Digit9");
388 expect(me.primary).toBe(rifle);
389 expect(me.armor).toBeGreaterThan(0);
390 stage.press("KeyB");
391
392 // A round later, with nothing left of it: the same list, in the same order.
393 // The round is scored through the match's own rules and the frames after it
394 // carry the end delay, so the world under test sees a real next freeze time.
395 endRound(world.match, me.team === "t" ? "ct" : "t", "elimination");
396 for (let frame = 0; frame < 60 && world.match.round === 1; frame++) stage.step(250);
397 expect(world.match.round).toBe(2);
398 expect(world.match.phase).toBe("freeze");
399 me.primary = null;
400 me.armor = 0;
401 me.money = WEAPONS[rifle].price + 100;
402 stage.press("KeyX");
403 expect(world.me.primary).toBe(rifle);
404 // The vest was next and the wallet could not reach it, so the line says so —
405 // in the HUD's message line, where a player who never opened the panel is
406 // looking, and in the panel's own line.
407 expect(me.armor).toBe(0);
408 const said = world.messages[world.messages.length - 1]?.text ?? "";
409 expect(said).toContain("Rebought");
410 expect(said).toContain("not enough for Kevlar");
411 expect(stage.by("buysays").textContent).toBe(said);
412 expect(stage.sounds).toContain("beep");
413
414 // And a rebuy outside the freeze time is not a rebuy: `canBuy` is the one
415 // gate, whether the panel is open or the key was pressed on its own.
416 world.match.phase = "live";
417 const left = me.money;
418 stage.press("KeyX");
419 expect(me.money).toBe(left);
326420 });
327421
328422 test("the buy overlay open suppresses fire, but not movement", () => {
@@ -353,7 +447,7 @@ test("the buy overlay open suppresses fire, but not movement", () => {
353447 expect(stage.sounds.some((s) => s.startsWith("shot:"))).toBe(true);
354448 });
355449
356 test("losing the pointer pauses a running match, and does not while the buy panel is open", () => {
450 test("losing the pointer pauses a running match, buy panel open or not", () => {
357451 const stage = playing();
358452 stage.lock(true);
359453 stage.lock(false);
@@ -364,12 +458,16 @@ test("losing the pointer pauses a running match, and does not while the buy pane
364458 expect(stage.by("pause").hidden).toBe(true);
365459 expect(stage.scene.locks).toBe(2);
366460
367 // Opening the buy menu drops the lock on purpose: that is not a pause.
461 // The buy menu no longer drops the lock, so a lock that goes while it is open
462 // is a real loss — alt-tab, a browser policy — and the match stops behind the
463 // panel, which closes rather than sitting over the pause.
368464 stage.lock(true);
369465 stage.press("KeyB");
370 stage.lock(false);
371466 expect(stage.by("buy").hidden).toBe(false);
372 expect(stage.by("pause").hidden).toBe(true);
467 expect(stage.document.pointerLockElement).toBe(stage.scene);
468 stage.lock(false);
469 expect(stage.by("buy").hidden).toBe(true);
470 expect(stage.by("pause").hidden).toBe(false);
373471 });
374472
375473 test("Escape pauses and keeps the screen where the browser hands the key over", async () => {
src/client/main.ts +111 −16
@@ -7,7 +7,10 @@
77 * plain DOM, so they are reachable with a keyboard and big enough for a thumb.
88 * The buy panel is one of them and is not in here: `buy.ts` draws the shelf out
99 * 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.
10 * that open and close it, and what a press costs the world. It opens without
11 * letting the pointer go — the number row buys the tile it is printed on and X
12 * buys last round's loadout again (`loadout.ts`), so a round is bought blind,
13 * walking, with the mouse still on the game.
1114 *
1215 * A match ends on a scoreboard and not on a menu. The frame that sees
1316 * `match_over` stops the run and, out of the world's final state before
@@ -64,7 +67,7 @@
6467 */
6568
6669 import { createAudio, type Audio } from "./audio.ts";
67 import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent } from "./binding.ts";
70 import { CS_KEYS, CS_MOUSE, CS_TAP, readIntent, slotIndex } from "./binding.ts";
6871 import { drawHud, type Paint } from "./hud.ts";
6972 import {
7073 attachButtons, clamp, createControls, createEscape, createFullscreen, createLoop, createRenderer,
@@ -92,8 +95,9 @@ import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
9295 import { usePlan, usePolicy } from "./bots.ts";
9396 import { createSpectator, cycle, follow } from "./spectate.ts";
9497 import { WEAPONS } from "./weapons.ts";
95 import { drawBuy } from "./buy.ts";
96 import { buy, canBuy, type BuyItem } from "./economy.ts";
98 import { drawBuy, HINT, labelOf, offers } from "./buy.ts";
99 import { buy, canBuy, priceOf, type BuyItem } from "./economy.ts";
100 import { atRound, createLoadout, noteBuy, rebuy, saidRebuy } from "./loadout.ts";
97101 import { createWorld, eyePos, say as showMessage, update, type Player, type World } from "./world.ts";
98102 import { unpack } from "../engine/index.ts";
99103
@@ -313,6 +317,14 @@ export function boot(host: Host): Frame {
313317 const menu = by("menu");
314318 const pause = by("pause");
315319 const buyPanel = by("buy");
320 // The two halves of that panel the page renders and the client fills: the
321 // element `buy.ts` draws the shelf into — which takes the focus when the
322 // panel opens, so Tab from there is the first tile — and the line under it,
323 // which is a live region and so is *announced*: with the pointer kept on the
324 // game there is no cursor to move into the panel, and this is what a screen
325 // reader hears instead.
326 const buyShelf = by("buyshelf");
327 const buySays = by("buysays");
316328 const touchPanel = by("touch");
317329 const stats = by("stats");
318330 // The one line about whether what you played is on that board. It is a live
@@ -557,6 +569,14 @@ export function boot(host: Host): Frame {
557569 }
558570
559571 // --- the buy menu, rebuilt whenever it opens so the prices are honest
572 /**
573 * What you bought this round and the round before, which is what X buys back.
574 * It is per match — `start` below hands out a fresh one — and the frame rolls
575 * it over on the round, because a round nobody bought anything in is a rebuy
576 * of nothing rather than a rebuy of two rounds ago.
577 */
578 let loadout = createLoadout();
579
560580 /**
561581 * The shelf, the prices and what a wallet can reach are `buy.ts`'s. What is
562582 * here is the wiring: the element it draws into, the button factory, and what
@@ -566,26 +586,80 @@ export function boot(host: Host): Frame {
566586 */
567587 const renderBuy = (): void => {
568588 if (world === null) return;
569 drawBuy({ panel: buyPanel, button: () => doc.createElement("button"), press: pressBuy }, world, world.me);
589 drawBuy(
590 { panel: buyShelf, button: () => doc.createElement("button"), press: pressBuy, again: pressRebuy },
591 world,
592 world.me,
593 loadout.last,
594 );
570595 };
571596
597 /** What the panel's live region says, which is the only announcement it makes. */
598 const sayBuy = (line: string): void => void (buySays.textContent = line);
599
600 /**
601 * One purchase, however it was asked for: a tile pressed, a key of the number
602 * row, or a rebuy stepping through last round's list. `buy()` is the one place
603 * money is spent, and this is the one place that calls it.
604 */
572605 function pressBuy(item: BuyItem): void {
573606 if (world === null) return;
574 if (buy(world, world.me, item)) audio.beep(700, 0.05);
607 const me = world.me;
608 if (buy(world, me, item)) {
609 noteBuy(loadout, item);
610 audio.beep(700, 0.05);
611 sayBuy(`Bought ${labelOf(item, me.team)}. $${me.money} left.`);
612 } else {
613 // A key for a tile the wallet cannot reach buys nothing and costs nothing:
614 // the panel says why, the way a dead tile says it by being dead.
615 sayBuy(`No ${labelOf(item, me.team)}: it costs $${priceOf(item, me)} and you have $${me.money}.`);
616 }
575617 renderBuy();
576618 }
577619
620 /**
621 * A key of the number row while the panel is open: the tile it is printed on.
622 * The index is read back out of the same `offers` the panel drew itself from,
623 * so the number on the screen and the item bought are one list.
624 */
625 function buyTile(index: number): void {
626 if (world === null || buyPanel.hidden) return;
627 const offer = offers(world.me.team)[index];
628 if (offer !== undefined) pressBuy(offer.item);
629 }
630
631 /**
632 * X: last round's loadout again, as far as the money goes. It is the same key
633 * whether the panel is open or not — buying blind is the point — and it says
634 * what it did in the HUD's message line, where a player who never opened the
635 * panel is looking, and in the panel's live region for a screen reader.
636 */
637 function pressRebuy(): void {
638 if (world === null || !running || !canBuy(world, world.me)) return;
639 const done = rebuy(world, world.me, loadout);
640 if (done.bought.length > 0) audio.beep(700, 0.05);
641 const line = saidRebuy(done, world.me.team);
642 showMessage(world, line);
643 sayBuy(line);
644 if (!buyPanel.hidden) renderBuy();
645 }
646
647 /**
648 * Open or close the shop. The pointer is not touched either way, which is the
649 * whole of #153: the mouse stays on the game, the keys do the buying, and a
650 * round no longer costs two trips to the desktop. The focus goes to the shelf
651 * rather than to a tile — a focused tile is a tile that Space and Enter press,
652 * and Space is jump — so a screen reader is told what opened by the live
653 * region under it, and Tab from there is the first thing on the shelf.
654 */
578655 function openBuy(open: boolean): void {
579656 if (world === null) return;
580657 if (open && !canBuy(world, world.me)) return;
581658 show(buyPanel, open);
582 if (open) {
583 renderBuy();
584 doc.exitPointerLock();
585 (buyPanel.querySelector("button") as Panel | null)?.focus();
586 } else if (running) {
587 void sceneCanvas.requestPointerLock();
588 }
659 if (!open) return;
660 renderBuy();
661 sayBuy(`Buy menu: $${world.me.money} to spend. ${HINT}`);
662 buyShelf.focus();
589663 }
590664
591665 /**
@@ -624,8 +698,15 @@ export function boot(host: Host): Frame {
624698 controls.onAction = (action, down) => {
625699 if (!down) return;
626700 if (action === "buy") return openBuy(buyPanel.hidden);
701 if (action === "rebuy") return pressRebuy();
627702 if (action === "pause") return pauseKey();
628703 if (action === "fullscreen") return askFull();
704 // The number row, while the shop is open: one key, one tile. The same keys
705 // are the weapon pockets with the panel closed, and `readIntent` is what
706 // does that — the frame suppresses the switch while the panel is up, so a
707 // key that buys the fourth tile is not also a hand reaching for a gun.
708 const tile = slotIndex(action);
709 if (tile !== null && !buyPanel.hidden) return buyTile(tile);
629710 // Dead, the trigger is the only thing left to press: it walks the camera
630711 // along your side, and the aim button walks it back. The touch pad's fire
631712 // is the same action, so a phone gets it without a button of its own.
@@ -635,9 +716,13 @@ export function boot(host: Host): Frame {
635716 };
636717 // The pointer went away and we are still in a match: however it went — Escape
637718 // on a browser that keeps the key, a window losing focus, a browser policy —
638 // the match stops rather than playing on without a mouse.
719 // the match stops rather than playing on without a mouse. The buy panel is no
720 // longer one of the ways it goes, so a lock lost while the shop is open is a
721 // real loss: the shop closes and the match stops behind it.
639722 controls.onUnlock = () => {
640 if (running && buyPanel.hidden) pauseMatch();
723 if (!running) return;
724 show(buyPanel, false);
725 pauseMatch();
641726 };
642727
643728 // --- results, sent as the match goes rather than only at the end
@@ -864,6 +949,9 @@ export function boot(host: Host): Frame {
864949 pauseId.textContent = `Match ${matchId} on ${level.name}.`;
865950 share.setAttribute("href", `/?match=${matchId}&map=${choice}`);
866951 world = createWorld(level, rules, name, team, 5, seed);
952 // A fresh match buys from scratch: the last match's loadout is not this
953 // match's first rebuy.
954 loadout = createLoadout();
867955 plannedRound = 0;
868956 posted = -1;
869957 postedFinal = false;
@@ -1068,6 +1156,9 @@ export function boot(host: Host): Frame {
10681156 // A round nobody has planned yet — the first of a match, or the one that
10691157 // just started — is planned here, on the frame that noticed it.
10701158 if (world.match.round !== plannedRound) planFor(world);
1159 // And the same frame rolls the wallet's memory over: what was bought in the
1160 // round that just ended is what X buys back in this one.
1161 atRound(loadout, world.match.round);
10711162
10721163 const me = world.me;
10731164 const [dx, dy] = controls.takeLook();
@@ -1089,8 +1180,12 @@ export function boot(host: Host): Frame {
10891180 slotWanted === 1 ? me.primary : null;
10901181
10911182 const steps = loop.advance(elapsed);
1183 // Buying keeps the pointer, so the keys have to be kept off the player: with
1184 // the panel open the trigger is dead and the number row buys instead of
1185 // switching weapons. Movement is not suppressed, and never was — walking out
1186 // of spawn while you buy is the point.
10921187 for (let step = 0; step < steps; step++)
1093 update(world, TICK, buyPanel.hidden ? input : { ...input, fire: false });
1188 update(world, TICK, buyPanel.hidden ? input : { ...input, fire: false, switchTo: null });
10941189
10951190 // The camera, and with it whose eyes this frame is drawn and heard from.
10961191 // It is moved after the steps and before anything reads it, and it is the
src/client/stage.test-helper.ts +1 −1
@@ -207,7 +207,7 @@ export type FakeStage = {
207207 * contract between the page and this file, so `src/server.test.ts` checks the
208208 * served page against this same list rather than a second copy of it.
209209 */
210 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full"];
210 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "prep", "resume", "quit", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full"];
211211
212212 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
213213 const HIDDEN = ["pause", "summary", "buy", "touch", "full"];
src/games.ts +8 −2
@@ -97,8 +97,14 @@ export const GAMES: readonly Game[] = [
9797 href: "/",
9898 label: "cs",
9999 entry: "src/client/main.ts",
100 /** Raised by #165, which put a smoke grenade — and a cloud a bot cannot see through — in the game. */
101 ceiling: { raw: 103_000, gzip: 41_000 },
100 /**
101 * Raised by #153, which made the buy panel keyboard-driven: the number row
102 * on the tiles, the rebuy and the loadout it remembers. 103,334 raw and
103 * 40,719 gzipped, measured by `bun run scripts/build.ts` — 334 bytes over
104 * the ceiling #165 set for the smoke grenade, and worth it for a round that
105 * is bought without the pointer leaving the game.
106 */
107 ceiling: { raw: 110_000, gzip: 43_500 },
102108 assets: [
103109 {
104110 name: "weights",
src/pages/actions.test.ts +10 −2
@@ -169,8 +169,16 @@ const SURFACES: readonly Surface[] = [
169169 {
170170 why: "a phone plays the weapon it bought: `src/client/economy.ts` equips a weapon the moment " +
171171 "it is bought, and Buy is on the pad. Three slot buttons beside the six already there is a " +
172 "trade nobody has made.",
173 actions: ["slot1", "slot2", "slot3"],
172 "trade nobody has made. The rest of the number row is the buy panel's — with the panel open " +
173 "each key presses the tile it is printed on — and those tiles are buttons a thumb taps, so a " +
174 "phone reaches every one of them without a key.",
175 actions: ["slot1", "slot2", "slot3", "slot4", "slot5", "slot6", "slot7", "slot8", "slot9", "slot10", "slot11"],
176 },
177 {
178 why: "the buy panel draws its own Rebuy control beside the tiles (`src/client/buy.ts`), which " +
179 "is the button a thumb presses; X is that same call from the keyboard, for a hand that is " +
180 "already walking out of spawn.",
181 actions: ["rebuy"],
174182 },
175183 {
176184 why: "the right mouse button, and the pad has no second trigger: a phone fires from the hip. " +
src/pages/game.ts +27 −2
@@ -205,6 +205,13 @@ a.share:hover, a.share:focus-visible { background:#27303f; text-decoration:under
205205 ul.recent a.map { color:var(--dim); text-decoration:none; padding:.35rem .4rem; margin:-.35rem 0;
206206 border-radius:.3rem; background:#161b24; }
207207 ul.recent a.map:hover, ul.recent a.map:focus-visible { color:var(--ct); text-decoration:underline; }
208 /* The buy panel's shelf: the column src/client/buy.ts fills, sized here so the
209 grid inside it has a width to be a fraction of. It takes the focus when the
210 panel opens and is not a control, so the ring is drawn on it and nothing
211 else — a focused tile would be a tile Space presses, and Space is jump. */
212 #buyshelf { display:flex; flex-direction:column; align-items:center; gap:.75rem; width:min(40rem,100%); }
213 #buyshelf:focus { outline:none; }
214 #buyshelf:focus-visible { outline:2px solid var(--ct); outline-offset:.35rem; border-radius:.45rem; }
208215 ${WEAPONS_STYLE}`;
209216
210217 /** What the menu's hint under the id field says when the link carried junk. */
@@ -348,7 +355,8 @@ const menu = (visitor: Who, asked: Asked, daily: Daily): string => `
348355 <p class="hint" id="prep">Preparing textures…</p>
349356 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
350357 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang · <kbd>V</kbd> throw a smoke ·
351 <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy ·
358 <kbd>E</kbd> plant or defuse · <kbd>B</kbd> buy — the buy menu keeps the mouse on the game, the
359 number row buys the tile it is printed on and <kbd>X</kbd> buys last round's loadout again ·
352360 <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns · <kbd>Tab</kbd> scores · <kbd>F</kbd> full screen ·
353361 <kbd>Esc</kbd> or <kbd>P</kbd> pause</p>
354362 <p class="hint">Full screen keeps the screen through a pause where a browser lets it:
@@ -424,6 +432,23 @@ export const planHtml = (planning: boolean): string =>
424432 * every pause with the one that is true for the browser it turned out to be:
425433 * whether Escape kept the screen, or took it and Resume will put it back.
426434 */
435 /**
436 * The buy panel's frame, and the only part of it this page renders: the shelf
437 * `src/client/buy.ts` draws into, and the line under it.
438 *
439 * The shelf takes the focus when the panel opens and is a group with a name, so
440 * what opened is announced and Tab from it is the first tile — the panel keeps
441 * the pointer lock now, so there is no cursor to move into it and the focus is
442 * the whole of the way in. The line is a live region, rendered here rather than
443 * written by the client into markup it also replaces: a region a redraw
444 * destroys is a region a screen reader never hears change, so it is outside
445 * everything `drawBuy` rewrites and it is server-rendered empty.
446 */
447 const BUY = `
448 <div id="buyshelf" tabindex="-1" role="group" aria-label="Buy"></div>
449 <p class="hint" id="buysays" role="status" aria-live="polite"></p>
450 `;
451
427452 const PAUSE = `
428453 <h2>Paused</h2>
429454 <div class="row"><button type="button" class="primary" id="resume">Resume</button><button type="button" id="quit">Leave match</button></div>
@@ -508,7 +533,7 @@ export function gamePage(
508533 // After the pause, so a match that ends while it is up is read over it
509534 // rather than under it. `src/client/main.ts` hides the pause anyway.
510535 { id: "summary", html: summary(visitor), hidden: true },
511 { id: "buy", html: "", hidden: true },
536 { id: "buy", html: BUY, hidden: true },
512537 ],
513538 }),
514539 sectionsHtml: `${thisMatch(asked)}

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs