Split The code is public; the app answers only to the crew.

Radio: three calls the person can make, and four teammates who actually go #102

Merged opened this

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

Merged

vesper/cs:vesper/63-radio-three-calls-the-person-can-make-an → main 4 commits fork 3 threads · 3 unresolved

  • Merged. vesper/63-radio-three-calls-the-person-can-make-an was merged into main as 33775fe.

Review

Sign in to review this change.
15 files changed +603 −43
README.md +38 −4
@@ -158,7 +158,7 @@ wants one, but the running server never reads it.
158158 which gun when it is one you have to ask for), <kbd>B</kbd> opens the buy menu in the freeze
159159 time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns and <kbd>4</kbd> takes
160160 out the HE grenade if you bought one (fire throws it, and the hand goes back to
161 the gun once it is gone), <kbd>Tab</kbd> shows
161 the gun once it is gone), <kbd>Z</kbd> opens the radio, <kbd>Tab</kbd> shows
162162 the scoreboard, <kbd>Esc</kbd> pauses the match on a desktop and `menu` on the
163163 pad does it on a phone. Pausing also opens mute, volume and
164164 look sensitivity, which take effect at once and are remembered for next time.
@@ -167,7 +167,7 @@ Every one of those keys is a default, not a floor: **WASD is not everyone's
167167 WASD** — on an AZERTY board, a Dvorak layout or just a different habit, the
168168 letters printed on the physical keys are not where this game's defaults
169169 expect them. The keys are yours to rebind. A collapsed **Controls** disclosure
170 sits in the menu and again in the pause overlay (the same 16 actions, always
170 sits in the menu and again in the pause overlay (the same 17 actions, always
171171 in sync) — closed by default so it never pushes anything else out of view;
172172 opening it is a plain `<details>`, reachable and toggled with a keyboard on
173173 its own. Click a button to see its action's current key, press it and then press the
@@ -180,6 +180,27 @@ fallback that no rebind can take over, so a mis-click here is never a match
180180 you cannot move in. Rebinding is keyboard only — the touch pad's layout does
181181 not change.
182182
183 **The radio** is <kbd>Z</kbd> — a binding like any other, so it is under
184 Controls with the rest — and it has three calls and no more: **go A**,
185 **go B** and **hold**. The panel is the same kind of overlay as the buy menu —
186 three big buttons, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> call the one they
187 number, <kbd>Z</kbd>, <kbd>Esc</kbd> or **Close** closes it — and the call goes
188 on the message line as `You: go A` so you can see the team heard it. Go A and
189 go B send your four teammates to that site, whatever the round rolled: a
190 terrorist carrying the bomb takes it there exactly as it would have taken it to
191 the plan's site, and anyone already walking turns round on the next tick. It
192 outranks the side's own reasons for leaving the plan too — a counter-terrorist
193 that would have rotated to wherever its team was dying goes where you said
194 instead, for as long as the call stands.
195 Hold leaves the sites alone and stops the push — the side defends where it
196 stands rather than pushing on, repositioning to cover instead of freezing in
197 the open. Only your own side hears it, only one call is live at a time (a
198 second replaces the first), a call stands for twenty seconds and then the side
199 goes back to the site the round rolled, and every new round starts with the
200 radio clear. Being dead does not take the radio away: you are already watching
201 the round through a teammate's eyes, and the four still alive are the ones who
202 can do something about what you can see.
203
183204 The menu's **Difficulty** select picks Easy, Normal or Hard before a match
184205 starts — each one a band of the bots' skill rather than one number, so a
185206 side still runs from fodder to uncomfortable at every setting, just a
@@ -240,6 +261,7 @@ On a touch screen the same actions are a pad, and the view is the aim:
240261 | grenade | <kbd>4</kbd>, then fire | `4 nade`, then the fire bar |
241262 | scoreboard | <kbd>Tab</kbd> | hold `scores` |
242263 | buy | <kbd>B</kbd> | `buy`, and `Close` when you are done |
264 | radio (go A, go B, hold) | <kbd>Z</kbd> | `radio`, then one of the three buttons |
243265 | pause the match, leave it, or change mute/volume/sensitivity | <kbd>Esc</kbd> | `menu` |
244266
245267 Firing is a second finger rather than a button under your aiming thumb because
@@ -289,8 +311,8 @@ with the canvas as you turn; nobody re-walks the grid to draw it.
289311 bounce, fuse and how far a blast reaches), `match` (the rules), `world` (the simulation),
290312 `buy` (the buy panel's rows — what a side can afford, what it already owns
291313 and why the rest are dead — read off `world` and handed to `main` as plain data),
292 `bots`, `plan` (which site a side commits to, and when the carrier has run out
293 of clock), `observe`, `action`, `policy`, `weights`, `models`, `textures`,
314 `bots`, `plan` (which site a side commits to, what the radio can say about
315 it, and when the carrier has run out of clock), `observe`, `action`, `policy`, `weights`, `models`, `textures`,
294316 `press` (the input latch), `loop` (how much simulation one frame is worth:
295317 the fixed step, the caps, and a pause that banks nothing),
296318 `quality` (the render scale the frame time buys),
@@ -373,6 +395,18 @@ headless over twenty seeded `SHORT` matches, before and after: the bomb is
373395 planted in 65% of rounds rather than 38%, and 10% of rounds end on the clock
374396 rather than 37%.
375397
398 A person gets a say over their own side's half of that through the radio
399 (see *Playing*). A call is one line of data on the world — the side, the word,
400 the time — and `plan.ts` reads it in front of the roll: while it stands, that
401 side's bots are handed the called site instead of the rolled one — and told
402 that a person said so, which is what puts it in front of the contact rotation
403 the defence does for itself — and when it expires or the round starts they are
404 handed the roll again. `bots.ts` only
405 asks for its orders and never learns where they came from, and a world nobody
406 has called anything in does not pay for the feature: with the radio silent the
407 answer is the same `siteFor` it always was, which is why the arena and
408 `scripts/measure.ts` print the same numbers they printed before.
409
376410 - **What it hears** — the sounds the world already makes, read off the effect
377411 list once a tick (`src/client/hearing.ts`, and `listen` in `bots.ts`). A
378412 gunshot is the loudest thing on the map and an AWP the loudest gun; a plant
src/client/bind.test.ts +18 −4
@@ -1,10 +1,10 @@
11 import { expect, test } from "bun:test";
2 import { decideBind, labelForCode } from "./bind.ts";
3 import { DEFAULT_BINDINGS } from "./input.ts";
2 import { ACTION_LABELS, decideBind, labelForCode } from "./bind.ts";
3 import { ACTIONS, DEFAULT_BINDINGS } from "./input.ts";
44
55 test("a fresh code nothing else holds is accepted, and only that action moves", () => {
6 const outcome = decideBind("forward", "KeyZ", DEFAULT_BINDINGS);
7 expect(outcome).toEqual({ ok: true, bindings: { ...DEFAULT_BINDINGS, forward: "KeyZ" } });
6 const outcome = decideBind("forward", "KeyG", DEFAULT_BINDINGS);
7 expect(outcome).toEqual({ ok: true, bindings: { ...DEFAULT_BINDINGS, forward: "KeyG" } });
88 });
99
1010 test("a reserved code — the movement and crouch fallback — is refused, and says why", () => {
@@ -46,3 +46,17 @@ test("labelForCode reads a code the way a player would say it", () => {
4646 test("labelForCode never throws on a code it does not recognise — it just shows the code", () => {
4747 expect(labelForCode("IntlBackslash")).toBe("IntlBackslash");
4848 });
49
50 test("the radio is one of the bindable actions: a default key, a label, and rebindable", () => {
51 // It reaches the Controls panel the same way every other action does — the
52 // panel is built from ACTIONS — so being on that list is the whole wiring.
53 expect(ACTIONS).toContain("radio");
54 expect(DEFAULT_BINDINGS.radio).toBe("KeyZ");
55 expect(ACTION_LABELS.radio).toBe("Radio");
56 const outcome = decideBind("radio", "KeyG", DEFAULT_BINDINGS);
57 expect(outcome).toEqual({ ok: true, bindings: { ...DEFAULT_BINDINGS, radio: "KeyG" } });
58 // And it takes part in the collision rule rather than sitting outside it.
59 expect(decideBind("buy", "KeyZ", DEFAULT_BINDINGS)).toEqual({
60 ok: false, reason: "Z is already bound to Radio.",
61 });
62 });
src/client/bind.ts +1 −1
@@ -11,7 +11,7 @@ import { ACTIONS, RESERVED_CODES, type Action } from "./input.ts";
1111 export const ACTION_LABELS: Record<Action, string> = {
1212 forward: "Move forward", back: "Move back", left: "Strafe left", right: "Strafe right",
1313 jump: "Jump", duck: "Crouch", walk: "Walk",
14 reload: "Reload", use: "Use / plant / defuse", buy: "Buy menu",
14 reload: "Reload", use: "Use / plant / defuse", buy: "Buy menu", radio: "Radio",
1515 scoreboard: "Scoreboard", scope: "Scope",
1616 slot1: "Slot 1 — rifle", slot2: "Slot 2 — pistol", slot3: "Slot 3 — knife", slot4: "Slot 4 — grenade",
1717 };
src/client/bots.test.ts +128 −1
@@ -3,9 +3,12 @@ import {
33 applyIntent, botThink, CARD, DIFFICULTIES, makeBrain, policy, restyleBrain, skillBand, think,
44 } from "./bots.ts";
55 import { DE_FORGE } from "./map.ts";
6 import { dist2d } from "./math.ts";
67 import { COMPETITIVE, SHORT } from "./match.ts";
8 import { CALL_LIFE, otherSite } from "./plan.ts";
79 import {
8 aimAt, bodyCentre, createWorld, NO_INPUT, setDifficulty, startRound, update, type Player,
10 aimAt, bodyCentre, createWorld, NO_INPUT, radioCall, setDifficulty, startRound, update,
11 type Player,
912 } from "./world.ts";
1013
1114 const duel = (seed = 2024): { world: ReturnType<typeof createWorld>; me: Player; bot: Player } => {
@@ -266,3 +269,127 @@ test("a bomb on the floor is still picked up by a living terrorist", () => {
266269 expect(world.bomb.state).toBe("carried");
267270 expect(bot.hasBomb).toBe(true);
268271 });
272
273 // --- the radio ------------------------------------------------------------
274
275 /**
276 * A side thinking with nobody to shoot at: the enemy is taken out of the world
277 * so every bot takes the walking branch, which is the one that reads the plan.
278 * The person is a terrorist here, so the terrorists are the side that hears.
279 */
280 function quietSide(seed: number): { world: ReturnType<typeof createWorld>; carrier: Player } {
281 const world = createWorld(SHORT, "you", "t", 5, seed);
282 world.match.phase = "live";
283 for (const p of world.players) {
284 if (p.team === "ct") p.alive = false;
285 p.hasBomb = false;
286 }
287 const carrier = world.players.find((p) => p.team === "t" && p.bot) as Player;
288 carrier.hasBomb = true;
289 return { world, carrier };
290 }
291
292 const thinkAll = (world: ReturnType<typeof createWorld>): void => {
293 for (const p of world.players) if (p.bot && p.alive) botThink(world, p, 1 / 64);
294 };
295
296 test("a bot carrying the bomb obeys a call exactly as it obeys the plan", () => {
297 const { world, carrier } = quietSide(20240);
298 thinkAll(world);
299 expect(carrier.brain?.site).toBe(world.plan.push);
300 expect(carrier.brain?.goal).toEqual(DE_FORGE.sites[world.plan.push]);
301
302 const called = otherSite(world.plan.push);
303 world.time += 1;
304 radioCall(world, called);
305 thinkAll(world); // the next tick, and the bomb is already walking the other way
306 expect(carrier.brain?.site).toBe(called);
307 expect(carrier.brain?.goal).toEqual(DE_FORGE.sites[called]);
308 });
309
310 test("the four teammates turn round on the call, and the other side never hears it", () => {
311 const { world } = quietSide(4242);
312 thinkAll(world);
313 const called = otherSite(world.plan.push);
314 world.time += 1;
315 radioCall(world, called);
316 thinkAll(world);
317 const ts = world.players.filter((p) => p.team === "t" && p.bot);
318 expect(ts.length).toBe(4);
319 for (const t of ts) expect(t.brain?.site).toBe(called);
320 // The counter-terrorists were never told anything: their brains still read
321 // the roll, split across the two sites exactly as they were.
322 for (const ct of world.players.filter((p) => p.team === "ct")) ct.alive = true;
323 thinkAll(world);
324 const cts = world.players.filter((p) => p.team === "ct").map((p) => p.brain?.site);
325 expect(new Set(cts).size).toBe(2);
326 });
327
328 test("a call expires and the side goes back to the plan it rolled", () => {
329 const { world, carrier } = quietSide(1234);
330 const called = otherSite(world.plan.push);
331 radioCall(world, called);
332 thinkAll(world);
333 expect(carrier.brain?.site).toBe(called);
334 world.time += CALL_LIFE + 1;
335 thinkAll(world);
336 expect(carrier.brain?.site).toBe(world.plan.push);
337 });
338
339 test("hold stops the push and leaves nobody standing still in the open", () => {
340 const { world, carrier } = quietSide(777);
341 thinkAll(world);
342 const site = DE_FORGE.sites[world.plan.push];
343 expect(dist2d(carrier.pos, carrier.brain?.goal ?? [0, 0, 0])).toBeGreaterThan(600);
344
345 world.time += 1;
346 radioCall(world, "hold");
347 thinkAll(world);
348 for (const t of world.players.filter((p) => p.team === "t" && p.bot)) {
349 const goal = t.brain?.goal as [number, number, number];
350 // Where it stands, not where the plan was taking it — and a goal it can
351 // still walk to, so a held bot repositions rather than freezing.
352 expect(t.brain?.site).toBe(world.plan.push); // hold does not move the site
353 expect(dist2d(t.pos, goal)).toBeLessThanOrEqual(360);
354 expect(dist2d(goal, site)).toBeGreaterThan(600);
355 expect(t.brain?.path.length).toBeGreaterThan(0);
356 }
357 });
358
359 test("a call outranks the rotation a counter-terrorist would have made on its own", () => {
360 // The person is a counter-terrorist — the side the menu picks by default —
361 // and one of their teammates has just died at B. That is the contact the
362 // defence rotates to on its own, and it is exactly what a person who has
363 // seen something at A is calling against.
364 const world = createWorld(SHORT, "you", "ct", 5, 31);
365 world.match.phase = "live";
366 for (const p of world.players) if (p.team === "t") p.alive = false;
367 const cts = world.players.filter((p) => p.team === "ct" && p.bot);
368 const fallen = cts[0] as Player;
369 fallen.alive = false;
370 fallen.pos = [...DE_FORGE.sites.B] as [number, number, number];
371 world.feed.push({
372 at: world.time, killer: "them", killerId: 9, victim: fallen.name, victimId: fallen.id,
373 weapon: "ak47", headshot: false, killerTeam: "t",
374 });
375
376 const standing = cts.filter((p) => p.alive);
377 const near = (p: Player, site: "A" | "B"): number =>
378 dist2d(p.brain?.goal ?? [0, 0, 0], DE_FORGE.sites[site]);
379 thinkAll(world);
380 // Without a word said, every one of them is on its way to the contact.
381 for (const ct of standing) expect(near(ct, "B")).toBeLessThan(400);
382
383 world.time += 1;
384 radioCall(world, "A");
385 thinkAll(world);
386 for (const ct of standing) {
387 expect(ct.brain?.site).toBe("A");
388 expect(near(ct, "A")).toBeLessThan(400);
389 }
390
391 // And when the call runs out, the side reads the round for itself again.
392 world.time += CALL_LIFE + 1;
393 thinkAll(world);
394 for (const ct of standing) expect(near(ct, "B")).toBeLessThan(400);
395 });
src/client/bots.ts +42 −6
@@ -16,7 +16,7 @@ import { fromOther, loudness, reported, type HeardName, type Maker } from "./hea
1616 import { angleDelta, clamp, dist, dist2d, rng, type Vec3 } from "./math.ts";
1717 import { findPath, siteAt, walkable, colOf, rowOf } from "./map.ts";
1818 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
19 import { mustCommit, siteFor, type Site } from "./plan.ts";
19 import { mustCommit, ordersFor, type Site } from "./plan.ts";
2020 import {
2121 forward, newActivations, sample, unpack, type Activations, type Policy,
2222 } from "./policy.ts";
@@ -83,7 +83,10 @@ export type BotBrain = {
8383 /** The world clock the last time this bot listened: what came after it is news. */
8484 heardAt: number;
8585 plannedFor: string;
86 site: Site; // the round's plan, read once when the round starts
86 site: Site; // where the side is going: the round's plan, or what was called
87 slot: number; // this bot's place in its side's list, counted when the round starts
88 holding: boolean; // told to stop and defend where it stands rather than push
89 ordered: boolean; // the site came from a person on the radio, not from the roll
8790 reset: boolean;
8891 random: () => number;
8992 // What the policy needs to run, one set per bot so nothing is shared.
@@ -109,7 +112,7 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
109112 goal: null, path: [], step: 0, repathAt: 0,
110113 targetId: null, seenAt: 0, burstUntil: 0, restUntil: 0,
111114 strafe: 0, strafeUntil: 0, suspect: null, heardAt: 0, plannedFor: "",
112 site: "A", reset: true,
115 site: "A", slot: 0, holding: false, ordered: false, reset: true,
113116 random: rng(seed),
114117 obs: new Float32Array(OBS_SIZE),
115118 activations: newActivations(PACKED.shape),
@@ -166,6 +169,20 @@ function slotOf(world: World, bot: Player): number {
166169 return slot;
167170 }
168171
172 /**
173 * Read this side's orders onto the brain: where to be, and whether to push
174 * onto it. `plan.ts` answers with the round's roll unless somebody on this
175 * side has called something else — which is all this file knows about it. A
176 * site that changed under a bot already walking is a new objective, and
177 * `repath` notices that on the next tick by itself.
178 */
179 function readOrders(world: World, bot: Player, brain: BotBrain): void {
180 const orders = ordersFor(world.plan, world.radio, bot.team, brain.slot, world.time);
181 brain.site = orders.site;
182 brain.holding = orders.hold;
183 brain.ordered = orders.called;
184 }
185
169186 /** What is left of the walk this bot has already planned, in map units. */
170187 function pathLeft(brain: BotBrain, from: Vec3): number {
171188 let total = 0;
@@ -232,6 +249,12 @@ function rotateTo(world: World, bot: Player): Site | null {
232249 /** Where this bot is trying to be, given what the round is doing. */
233250 function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 {
234251 const bomb = world.bomb;
252 // Told to hold: this is where the round is being fought from now on. The
outdated

Reviewed the diff, ran bun test at 334f17a (394 pass, 0 fail), booted the app from this head (/ answers in 17 ms, CSP / X-Content-Type-Options / Referrer-Policy intact, the #radio overlay and the radio pad button both in the first paint), and read both screenshots. The shape is right: the override lives in plan.ts in front of siteFor, bots.ts only asks for orders, world.radio is one line of plain data that startRound clears, isCall validates at the boundary, and there is no server surface and no new dependency. I mutation-checked the new tests as well — deleting the per-tick readOrders, the hold branch in chooseGoal, the isCall guard, the startRound clear or the KeyZ binding each turns the suite red, so the behaviours really are pinned.

One finding blocks it.

High — the counter-terrorists ignore a call as soon as anyone trades, and CT is the side the menu picks by default. This line is where it goes: brain.site now carries the call, but rotateTo's contact still outranks it, and the kill feed keeps the last five deaths for the whole round, so one teammate dying near a site cancels your radio for the rest of it. Repro against this head: CT side, seed 31, radioCall(world, "A"), then push one feed entry for a CT who died at B; on the next tick all four teammates report brain.site === "A" and walk to B anyway. The T side is unaffected because its branch reads brain.site directly, which is why the carrier tests pass and this slipped through. As it stands the README's "Go A and go B send your four teammates to that site, whatever the round rolled" is not true for the default side.

Fix first: let a live call outrank the contact rotation for the side that called it. ordersFor already knows — return whether the site came from a call (or set a called flag on the brain in readOrders from liveCall) and use brain.site instead of contact ?? brain.site while it stands. A test in bots.test.ts shaped like the repro above — a CT death near the other site, then a call, then thinkAll — fails without it. Keeping the rotation on top is a defensible design instead, but then the README and the panel have to say so and a test has to pin it; what cannot ship is the README promising one thing and the bots doing another.

Low — the radio opens on top of the pause overlay, and closing it takes the mouse back while the match is still paused. openRadio in src/client/main.ts guards only on world and running, and running stays true while paused, so Z on the pause screen stacks the radio over it; Escape then closes the radio and calls lock() with the pause overlay still up and the world still stopped. if (!pause.hidden) return; in openRadio, next to the buyPanel.hidden check onPause already makes, closes it.

Note, not a finding — the preview answered 404 on / and on every other path throughout this review, so I exercised the page from a local boot of this commit instead; the screenshots show it up with the right menu copy at this head, so I read the 404 as the preview being down rather than as anything in the change. Everything else on my list passes: the tty is the leaderboard door and has nothing here to gate, the panel's markup is literals only, the arena's default path costs one null check, and the pad grid wraps the new button at 360px with no horizontal scroll.

253 // goal is a walkable spot a stride away rather than the bot's own feet —
254 // a bot with no path stands still wherever it happened to be, and standing
255 // still in the open is how a hold gets a side killed. Once the bomb is on
256 // the floor or in the ground it outranks anything anyone said.
257 if (brain.holding && bomb.state === "carried") return nearby(world, brain, bot.pos, 250);
235258 if (bot.team === "t") {
236259 if (bomb.state === "planted") {
237260 // Hold near the bomb rather than walk onto it.
@@ -242,7 +265,10 @@ function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 {
242265 return nearby(world, brain, world.map.sites[brain.site], 200);
243266 }
244267 if (bomb.state === "planted") return bomb.pos;
245 const contact = rotateTo(world, bot);
268 // The contact rotation is this side reading the round for itself — and it
outdated

Approved — both findings from the last round are fixed, and I checked the fixes rather than the commit message.

The high one is gone. chooseGoal now reads brain.ordered ? null : rotateTo(world, bot), and ordersFor carries the called flag that says the site came from a person. I re-ran my own repro against 76d5d69 — CT side, seed 31, a teammate dead at B, then radioCall(world, "A") — and on the next tick all three standing teammates report ordered === true, site === "A" and a goal 134–173 units from A instead of ~3.8k away at B. Putting :called into the repath objective string is the right extra catch: a bot that had already rotated off its own site is walking to a goal its site no longer names, so a call that happens to agree with the roll still has to count as a new objective. The README now states the precedence instead of overstating it, and the call still gives way to a plant (bomb.state === "planted" returns above it) and expires back into the side's own rotation.

The low one is gone too. openRadio refuses to open over the pause overlay or the summary card, so Z on a stopped world no longer stacks a panel that hands the pointer lock back on close.

What I verified myself at this head: bun test is 395 pass / 0 fail in a scratch checkout (my clone is untouched), and the three new behaviours are pinned — reverting the brain.ordered ? null : guard, dropping :called from the objective string, or making ordersFor return called: false for a named site each turns the suite red, one or two tests apiece. The app boots from this commit: / answers in 17 ms, HEAD answers like GET, the security floor (CSP, X-Content-Type-Options, Referrer-Policy) is on the response, and the #radio overlay plus the radio pad button are in the first paint. No server surface, no identity or data path touched, no new dependency; isCall validates at the boundary and the panel's markup is literals only. The arena's default path is still one null check with the radio silent, and the ordersFor-equals-siteFor test across 60 seeds × 6 rounds × both sides × five slots now pins the called: false shape too.

Two things for the record, neither blocking. The preview at https://cs--102--forge-trevato-apps.open-platform.sh answered 404 on every path at both heads I reviewed, so I exercised the page from a local boot of this commit instead; the forge's screenshots show it up at 76d5d69 with the radio line in the menu copy, so I read the 404 as the preview host being down, not as anything in the change. And a dead player can still work the radio — defensible either way, worth a line in a later pull if you decide the dead should not be steering the living.

269 // gives way to a person who has just said where to be. Somebody dying at B
270 // is why you called A; it is not an argument against the call.
271 const contact = brain.ordered ? null : rotateTo(world, bot);
246272 return nearby(world, brain, world.map.sites[contact ?? brain.site], 250);
247273 }
248274
@@ -423,7 +449,12 @@ function turnTowards(bot: Player, target: { yaw: number; pitch: number }, speed:
423449 * here, which is a few times a round and never per tick.
424450 */
425451 function repath(world: World, bot: Player, brain: BotBrain): void {
426 const objective = `${world.bomb.state}:${brain.site}`;
452 // `ordered` is part of the objective and not just a tie-break inside
453 // `chooseGoal`: a counter-terrorist that has rotated off its own site is
454 // walking to a goal its site no longer names, so a call that happens to
455 // agree with the roll still has to be a new objective to turn it round.
456 const objective =
457 `${world.bomb.state}:${brain.site}${brain.holding ? ":hold" : ""}${brain.ordered ? ":called" : ""}`;
427458 const stale =
428459 brain.path.length === 0 || brain.step >= brain.path.length ||
429460 world.time > brain.repathAt || objective !== brain.plannedFor;
@@ -461,7 +492,8 @@ export function botThink(world: World, bot: Player, dt: number): Input {
461492 if (brain.reset) {
462493 brain.reset = false;
463494 // The round's plan, not a coin of this bot's own: the side arrives together.
464 brain.site = siteFor(world.plan, bot.team, slotOf(world, bot));
495 brain.slot = slotOf(world, bot);
496 readOrders(world, bot, brain);
465497 brain.goal = null;
466498 brain.path = [];
467499 brain.targetId = null;
@@ -478,6 +510,10 @@ export function botThink(world: World, bot: Player, dt: number): Input {
478510 brain.heardAt = world.time;
479511 return input;
480512 }
513 // Orders can change under a bot mid-round — but only ever because somebody
514 // said something, so a world with a silent radio does not pay for the ones
515 // that do not: the round's roll is already on the brain from `reset`.
516 if (world.radio !== null) readOrders(world, bot, brain);
481517
482518 listen(world, bot, brain);
483519 const target = pickTarget(world, bot, brain);
src/client/input.test.ts +38 −5
@@ -243,16 +243,49 @@ test("Q scopes from the keyboard the same way", () => {
243243 expect(controls.inputAt(controls.take(1), 0).scope).toBe(false);
244244 });
245245
246 test("the radio is one key and one pad button, and opens on the press", () => {
247 const { controls } = harness();
248 const buttons = pad("radio");
249 attachButtons(buttons.root, controls);
250 const window = globals.window as Canvas;
251 let opened = 0;
252 controls.onRadio = () => { opened += 1; };
253
254 window.fire("keydown", { code: "KeyZ" });
255 expect(opened).toBe(1);
256 // The release is not a second opening, and neither is the key repeating.
257 window.fire("keyup", { code: "KeyZ" });
258 window.fire("keydown", { code: "KeyZ", repeat: true });
259 expect(opened).toBe(1);
260
261 buttons.press("radio");
262 expect(opened).toBe(2);
263 buttons.release("radio");
264 expect(opened).toBe(2);
265 });
266
267 test("the radio is a binding like any other: rebound, it answers to the new key", () => {
268 const { controls } = harness({ ...DEFAULT_BINDINGS, radio: "KeyG" });
269 const window = globals.window as Canvas;
270 let opened = 0;
271 controls.onRadio = () => { opened += 1; };
272
273 window.fire("keydown", { code: "KeyZ" });
274 expect(opened).toBe(0);
275 window.fire("keydown", { code: "KeyG" });
276 expect(opened).toBe(1);
277 });
278
246279 // --- bindings: a non-default map moves the game, not the printed letters
247280
248281 test("a non-default map moves on the bound key and does nothing on KeyW", () => {
249 const { controls } = harness({ ...DEFAULT_BINDINGS, forward: "KeyZ" });
282 const { controls } = harness({ ...DEFAULT_BINDINGS, forward: "KeyG" });
250283 const window = globals.window as Canvas;
251284
252285 window.fire("keydown", { code: "KeyW" });
253286 expect(controls.inputAt(controls.take(1), 0).forward).toBe(0);
254287
255 window.fire("keydown", { code: "KeyZ" });
288 window.fire("keydown", { code: "KeyG" });
256289 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
257290 });
258291
@@ -264,16 +297,16 @@ test("a rebind takes effect on the very next key event, with no detach or re-att
264297 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
265298 window.fire("keyup", { code: "KeyW" });
266299
267 controls.setBindings({ ...DEFAULT_BINDINGS, forward: "KeyZ" });
300 controls.setBindings({ ...DEFAULT_BINDINGS, forward: "KeyG" });
268301
269302 window.fire("keydown", { code: "KeyW" });
270303 expect(controls.inputAt(controls.take(1), 0).forward).toBe(0);
271 window.fire("keydown", { code: "KeyZ" });
304 window.fire("keydown", { code: "KeyG" });
272305 expect(controls.inputAt(controls.take(1), 0).forward).toBe(1);
273306 });
274307
275308 test("the arrow keys and C still move and crouch whatever forward is bound to", () => {
276 const { controls } = harness({ ...DEFAULT_BINDINGS, forward: "KeyZ", duck: "KeyU" });
309 const { controls } = harness({ ...DEFAULT_BINDINGS, forward: "KeyG", duck: "KeyU" });
277310 const window = globals.window as Canvas;
278311
279312 window.fire("keydown", { code: "ArrowUp" });
src/client/input.ts +6 −3
@@ -41,6 +41,7 @@ export type Controls = {
4141 */
4242 clear(): void;
4343 onBuy: () => void;
44 onRadio: () => void;
4445 onPause: () => void;
4546 detach(): void;
4647 };
@@ -52,7 +53,7 @@ export type Controls = {
5253 */
5354 export const ACTIONS = [
5455 "forward", "back", "left", "right", "jump", "duck", "walk",
55 "reload", "use", "buy", "scoreboard", "scope",
56 "reload", "use", "buy", "radio", "scoreboard", "scope",
5657 "slot1", "slot2", "slot3", "slot4",
5758 ] as const;
5859 export type Action = typeof ACTIONS[number];
@@ -60,7 +61,7 @@ export type Action = typeof ACTIONS[number];
6061 export const DEFAULT_BINDINGS: Record<Action, string> = {
6162 forward: "KeyW", back: "KeyS", left: "KeyA", right: "KeyD",
6263 jump: "Space", duck: "ControlLeft", walk: "ShiftLeft",
63 reload: "KeyR", use: "KeyE", buy: "KeyB", scoreboard: "Tab", scope: "KeyQ",
64 reload: "KeyR", use: "KeyE", buy: "KeyB", radio: "KeyZ", scoreboard: "Tab", scope: "KeyQ",

Approved at 14d40ab, the merge that lands this on top of #101's rebinding.

The merge did the right thing rather than the cheap thing: the radio is not a hardcoded key any more, it is the seventeenth entry in ACTIONS with a default of KeyZ, a Radio label in ACTION_LABELS, and it takes part in the collision rule both ways (decideBind("buy","KeyZ") is refused with "Z is already bound to Radio"). Because the Controls panel is built from ACTIONS, being on that list is the whole wiring — and the panel copy was changed to say "the radio key again" instead of printing a letter that a rebind could make a lie. The README's action count went 16 → 17 with it.

What I checked at this head, not from the commit message:

  • bun test is 447 pass / 0 fail in a scratch checkout; my clone is untouched.
  • The new wiring is pinned. Changing the radio's default key, removing radio from ACTIONS, dropping its label, deleting the else if (action === "radio") controls.onRadio() branch, or reverting brain.ordered ? null : rotateTo(...) each turns the suite red — one to three tests apiece. The earlier rounds' findings stay fixed: my own CT repro (seed 31, teammate dead at B, then "go A") still sends the side to A, and openRadio still refuses to open over the pause overlay or the summary card.
  • The stored-settings migration is safe for someone who already rebound Z. parseSettings with {"forward":"KeyZ"} keeps forward on Z and hands the radio the first unclaimed default (KeyW here) — 17 actions, 17 distinct codes, nothing doubled. That is #101's documented exhaustive fallback doing its job for a new action, and the Controls panel shows the result, so nobody is left with a key they cannot find.
  • The test edits that swap KeyZ for KeyG in bind.test.ts, input.test.ts and settings.test.ts are correct rather than convenient: those tests need a code no action holds, and Z stopped being one. Their assertions are unchanged.
  • The app boots from this commit: / answers in 15 ms, the security floor (CSP, X-Content-Type-Options, Referrer-Policy) is on the response, the #radio overlay and the radio pad button are in the first paint, and the built bundle carries onRadio, the radio-call buttons and the Radio label. No server surface, no identity or data path touched, no new dependency; isCall validates at the boundary and the panel's markup is literals only. With the radio silent the arena still pays one null check.
  • A dead person keeping the radio is now deliberate, said in the README and pinned by a test. Good call — you are watching through a teammate's eyes and the living four are the ones who can act on what you see.

Two things that do not block the merge. The preview at https://cs--102--forge-trevato-apps.open-platform.sh has answered 404 on every path at all three heads I have reviewed, so I exercised the page from a local boot of each commit instead; the forge's screenshots show it up at 14d40ab with the radio line in the menu copy, so I read the 404 as the preview host being down rather than anything in the change. And the pull description is now stale — it still says 394 pass, still describes Z as a fixed key, and says nothing about the rotation precedence, the dead-player radio or the merge with #101. Worth a refresh before this lands, since that text is what the history will keep.

6465 slot1: "Digit1", slot2: "Digit2", slot3: "Digit3", slot4: "Digit4",
6566 };
6667
@@ -128,10 +129,11 @@ export function createControls(
128129 if (down) press.down(action);
129130 else press.up(action);
130131 if (!down) return;
131 // The two actions that are a menu rather than a movement. `menu` is how a
132 // The actions that are a menu rather than a movement. `menu` is how a
132133 // touch screen reaches the pause overlay at all: pointer lock is the only
133134 // other thing that calls onPause, and a finger never takes the lock.
134135 if (action === "buy") controls.onBuy();
136 else if (action === "radio") controls.onRadio();
135137 else if (action === "menu") controls.onPause();
136138 },
137139 setBindings: (next) => {
@@ -143,6 +145,7 @@ export function createControls(
143145 lookY = 0;
144146 },
145147 onBuy: () => {},
148 onRadio: () => {},
146149 onPause: () => {},
147150 detach: () => {
148151 window.removeEventListener("keydown", onKeyDown);
src/client/main.ts +76 −10
@@ -4,8 +4,8 @@
44 * stepped at a fixed 64 Hz however long the frame took, and then the scene and
55 * the HUD are drawn. How much of that a frame is worth is loop.ts's decision;
66 * this file only knows which overlays mean the match is waiting. The page's
7 * overlays — menu, buy, pause, the match-over card — are plain DOM, so they
8 * are reachable with a keyboard and big enough for a thumb.
7 * overlays — menu, buy, radio, pause, the match-over card — are plain DOM, so
8 * they are reachable with a keyboard and big enough for a thumb.
99 */
1010
1111 import { createAudio } from "./audio.ts";
@@ -20,10 +20,11 @@ import { COMPETITIVE, SHORT, type Rules } from "./match.ts";
2020 import { MAX_SCALE, nextRenderScale } from "./quality.ts";
2121 import { createRenderer, type Camera } from "./renderer.ts";
2222 import { DEFAULT_SETTINGS, loadSettings, saveSettings, SENSITIVITY_MAX, SENSITIVITY_MIN } from "./settings.ts";
23 import { CALL_LIFE, isCall, type Call } from "./plan.ts";
2324 import { nextWatched, watched } from "./spectate.ts";
2425 import { summarize, type MatchSummary } from "./summary.ts";
2526 import {
26 buy, canBuy, createWorld, DIFFICULTIES, eyePos, flinch, setDifficulty, update,
27 buy, canBuy, createWorld, DIFFICULTIES, eyePos, flinch, radioCall, setDifficulty, update,
2728 type Difficulty, type Player, type SoundName, type World,
2829 } from "./world.ts";
2930
@@ -46,6 +47,7 @@ function boot(): void {
4647 const pause = $("pause");
4748 const summaryPanel = $("summary");
4849 const buyPanel = $("buy");
50 const radioPanel = $("radio");
4951 const touchPanel = $("touch");
5052 const stats = $("stats");
5153 const nameField = $<HTMLInputElement>("name");
@@ -296,7 +298,7 @@ function boot(): void {
296298 // Hidden, not removed: an overlay comes and goes every freeze time, and a
297299 // pad that left the flow would drag the page under it up and down with it.
298300 touchPanel.classList.toggle(
299 "blocked", !(pause.hidden && buyPanel.hidden && summaryPanel.hidden));
301 "blocked", !(pause.hidden && buyPanel.hidden && radioPanel.hidden && summaryPanel.hidden));
300302 };
301303
302304 // --- the buy menu: built once, then updated in place so a purchase never
@@ -389,6 +391,64 @@ function boot(): void {
389391
390392 controls.onBuy = () => openBuy(buyPanel.hidden);
391393
394 // --- the radio: three calls and no more, built once like the buy menu and
395 // reached the same two ways — a key and a big button under a thumb.
396 radioPanel.innerHTML =
397 `<h2>Radio</h2>` +
398 `<p class="hint">Your side only. A call stands for ${CALL_LIFE} seconds, then the team goes back to its own plan.</p>` +
399 `<div class="row radio-calls">` +
400 `<button type="button" class="radio-call primary" data-call="A"><b>1</b>Go A</button>` +
401 `<button type="button" class="radio-call primary" data-call="B"><b>2</b>Go B</button>` +
402 `<button type="button" class="radio-call primary" data-call="hold"><b>3</b>Hold</button>` +
403 `</div>` +
404 `<div class="row"><button type="button" id="radio-close">Close</button></div>` +
405 // The key is a binding now, so the panel names the two ways out that no
406 // rebind can move rather than printing a letter that may not be yours.
407 `<p class="hint">Escape or Close closes it, and so does the radio key again. ` +
408 `Hold keeps the team where it stands instead of pushing a site.</p>`;
409 $("radio-close").addEventListener("click", () => openRadio(false));
410
411 /** One call, from a click or a digit typed at the panel. The panel closes on it. */
412 function sendCall(call: string): void {
413 if (world === null || !isCall(call)) return;
414 if (radioCall(world, call)) audio.beep(520, 0.06);
415 openRadio(false);
416 }
417
418 for (const button of Array.from(radioPanel.querySelectorAll<HTMLButtonElement>(".radio-call")))
419 button.addEventListener("click", () => sendCall(button.dataset.call ?? ""));
420
421 // 1, 2 and 3 call the button they number, and never reach the slot-switch
422 // keys on `window` — the panel answers the press before it gets there.
423 const CALL_KEYS: Record<string, Call> = { Digit1: "A", Digit2: "B", Digit3: "hold" };
424 radioPanel.addEventListener("keydown", (event) => {
425 const call = CALL_KEYS[event.code];
426 if (call === undefined || radioPanel.hidden) return;
427 event.stopPropagation();
428 sendCall(call);
429 });
430
431 function openRadio(open: boolean): void {
432 if (world === null) return;
433 // Never over a dialog. The pause overlay and the match-over card mean the
434 // world is stopped, and a radio stacked on top of one would hand the
435 // pointer lock back on close with the match still waiting underneath.
436 if (open && (!running || !pause.hidden || !summaryPanel.hidden)) return;
437 show(radioPanel, open);
438 refreshPad();
439 if (open) {
440 document.exitPointerLock();
441 (radioPanel.querySelector(".radio-call") as HTMLButtonElement | null)?.focus();
442 } else if (running) {
443 lock();
444 }
445 }
446
447 controls.onRadio = () => {
448 // One overlay at a time: the buy menu is the one already over the view.
449 if (buyPanel.hidden) openRadio(radioPanel.hidden);
450 };
451
392452 /**
393453 * Put the match on hold: the overlay goes up and `frame` stops stepping the
394454 * world. The buy panel is closed first because it paints over the pause
@@ -398,6 +458,7 @@ function boot(): void {
398458 function pauseMatch(): void {
399459 if (!running || !pause.hidden) return;
400460 show(buyPanel, false);
461 show(radioPanel, false);
401462 show(pause, true);
402463 refreshPad();
403464 document.exitPointerLock();
@@ -410,7 +471,7 @@ function boot(): void {
410471 };
411472
412473 controls.onPause = () => {
413 if (buyPanel.hidden) pauseMatch();
474 if (buyPanel.hidden && radioPanel.hidden) pauseMatch();
414475 };
415476 // A hidden tab is a pause: requestAnimationFrame is throttled there, so a
416477 // match left running would crawl on without you and be over — or lost — by
@@ -487,6 +548,7 @@ function boot(): void {
487548 show(pause, false);
488549 show(summaryPanel, false);
489550 show(buyPanel, false);
551 show(radioPanel, false);
490552 refreshPad();
491553 audio.resume();
492554 lock();
@@ -516,6 +578,7 @@ function boot(): void {
516578 show(pause, false);
517579 show(summaryPanel, false);
518580 show(buyPanel, false);
581 show(radioPanel, false);
519582 show(menu, true);
520583 refreshPad();
521584 $("play").focus();
@@ -524,7 +587,9 @@ function boot(): void {
524587 $("summary-menu").addEventListener("click", toMenu);
525588 $("again").addEventListener("click", start);
526589 window.addEventListener("keydown", (event) => {
527 if (event.code === "Escape" && !buyPanel.hidden) openBuy(false);
590 if (event.code !== "Escape") return;
591 if (!buyPanel.hidden) openBuy(false);
592 else if (!radioPanel.hidden) openRadio(false);
528593 });
529594 const saved = localStorage.getItem("cs.name");
530595 if (saved !== null && nameField.value === "") nameField.value = saved;
@@ -616,10 +681,10 @@ function boot(): void {
616681 for (let step = 0; step < steps; step += 1) {
617682 const input = controls.inputAt(pressed, step);
618683 input.switchTo = switchTo;
619 // The buy panel is the only overlay a step can run under — the pause
620 // overlay and the summary return above — and a click on a gun there is
621 // a purchase, never a shot.
622 if (!buyPanel.hidden) input.fire = false;
684 // The buy panel and the radio are the only overlays a step can run under
685 // — the pause overlay and the summary return above — and a click on one
686 // of their buttons is a purchase or a call, never a shot.
687 if (!buyPanel.hidden || !radioPanel.hidden) input.fire = false;
623688 if (!world.me.alive && input.fire) cycle = true;
624689 update(world, TICK, input);
625690 }
@@ -642,6 +707,7 @@ function boot(): void {
642707 posted = played;
643708 document.exitPointerLock();
644709 show(buyPanel, false);
710 show(radioPanel, false);
645711 refreshPad();
646712 // The last post is the only one that says the match finished, and the
647713 // score it carries is the one the overlay is about to show.
src/client/plan.test.ts +89 −2
@@ -1,7 +1,8 @@
11 import { expect, test } from "bun:test";
2 import { COMPETITIVE } from "./match.ts";
2 import { COMPETITIVE, type Team } from "./match.ts";
33 import {
4 COMMIT_MARGIN, mustCommit, otherSite, roundPlan, siteFor, timeToPlant, WALK_SPEED,
4 CALL_LIFE, CALLS, callLabel, COMMIT_MARGIN, isCall, liveCall, mustCommit, ordersFor, otherSite,
5 roundPlan, siteFor, timeToPlant, WALK_SPEED, type RadioCall,
56 } from "./plan.ts";
67
78 test("a side gets one site for the round, and every slot on it reads the same one", () => {
@@ -63,3 +64,89 @@ test("the carrier only commits when the clock has no room for a fight left", ()
6364 expect(mustCommit(20, 0, plantTime)).toBe(false);
6465 expect(mustCommit(20, 4000, plantTime)).toBe(true);
6566 });
67
68 // --- the radio ------------------------------------------------------------
69
70 const CALLED = (call: "A" | "B" | "hold", at = 10): RadioCall => ({ team: "ct", call, at });
71
72 test("a call moves the side that made it, and nobody else", () => {
73 const plan = roundPlan(31, 2);
74 const radio = CALLED("A");
75 const cts = [0, 1, 2, 3, 4].map((slot) => ordersFor(plan, radio, "ct", slot, 12));
76 for (const orders of cts) {
77 expect(orders.site).toBe("A");
78 expect(orders.hold).toBe(false);
79 // Said by a person, so it outranks the reasons a side leaves its own plan.
80 expect(orders.called).toBe(true);
81 }
82 // The other side reads the round's roll and never hears a word of it.
83 for (const slot of [0, 1, 2, 3, 4]) {
84 expect(ordersFor(plan, radio, "t", slot, 12).site).toBe(siteFor(plan, "t", slot));
85 }
86 // And "go B" is the same thing the other way round, split stack and all.
87 const b = [0, 1, 2, 3, 4].map((slot) => ordersFor(plan, CALLED("B"), "ct", slot, 12).site);
88 expect(new Set(b)).toEqual(new Set(["B"]));
89 });
90
91 test("hold keeps the site the round rolled and says not to push it", () => {
92 const plan = roundPlan(88, 5);
93 for (const team of ["t", "ct"] as Team[]) {
94 for (const slot of [0, 1, 2, 3, 4]) {
95 const orders = ordersFor(plan, { team, call: "hold", at: 4 }, team, slot, 9);
96 expect(orders.site).toBe(siteFor(plan, team, slot));
97 expect(orders.hold).toBe(true);
98 expect(orders.called).toBe(false); // hold names no site, so none was called
99 }
100 }
101 });
102
103 test("a call stands for a few seconds of the round and then the plan has it back", () => {
104 const plan = roundPlan(12, 7);
105 const radio = CALLED("A", 30);
106 const called = siteFor(plan, "ct", 0) === "A" ? "B" : "A"; // something to see change
107 const orders = (now: number): string => ordersFor(plan, { ...radio, call: called }, "ct", 0, now).site;
108 expect(orders(30)).toBe(called);
109 expect(orders(30 + CALL_LIFE)).toBe(called);
110 expect(orders(30 + CALL_LIFE + 0.001)).toBe(siteFor(plan, "ct", 0));
111 // And an expired call stops outranking anything.
112 expect(ordersFor(plan, { ...radio, call: called }, "ct", 0, 30 + CALL_LIFE + 0.001).called).toBe(false);
113 // Nor does a call reach back to before it was made.
114 expect(orders(29.9)).toBe(siteFor(plan, "ct", 0));
115 });
116
117 test("only the second of two calls is live: the radio holds one and the newest wins", () => {
118 const plan = roundPlan(5, 3);
119 const first: RadioCall = { team: "ct", call: "A", at: 5 };
120 const second: RadioCall = { team: "ct", call: "B", at: 9 };
121 expect(liveCall(first, "ct", 10)).toBe("A");
122 // A world keeps one call, so the second replaces the first rather than
123 // queueing behind it — from then on there is only "B" to read.
124 expect(liveCall(second, "ct", 10)).toBe("B");
125 expect(ordersFor(plan, second, "ct", 0, 10).site).toBe("B");
126 expect(liveCall(null, "ct", 10)).toBe(null);
127 expect(liveCall(second, "t", 10)).toBe(null);
128 });
129
130 test("with nobody on the radio, orders are byte-identical to the old siteFor", () => {
131 for (let seed = 0; seed < 60; seed++) {
132 for (let round = 1; round <= 6; round++) {
133 const plan = roundPlan(seed, round);
134 for (const team of ["t", "ct"] as Team[]) {
135 for (let slot = 0; slot < 5; slot++) {
136 expect(ordersFor(plan, null, team, slot, seed))
137 .toEqual({ site: siteFor(plan, team, slot), hold: false, called: false });
138 }
139 }
140 }
141 }
142 });
143
144 test("three words are calls and nothing else is", () => {
145 expect(CALLS).toEqual(["A", "B", "hold"]);
146 for (const call of CALLS) expect(isCall(call)).toBe(true);
147 for (const junk of ["a", "", "GO A", "C", null, undefined, 1, {}])
148 expect(isCall(junk)).toBe(false);
149 expect(callLabel("A")).toBe("go A");
150 expect(callLabel("B")).toBe("go B");
151 expect(callLabel("hold")).toBe("hold");
152 });
src/client/plan.ts +63
@@ -9,6 +9,11 @@
99 * answer. It is a roll per round and not a constant: a match pushes B in one
1010 * round and A in the next, and another match rolls a different order.
1111 *
12 * A person gets a say over it through the radio: a call from their own side
13 * stands in front of the roll for a few seconds and then falls back to it.
14 * That override lives here, in front of `siteFor`, so the bots keep reading
15 * one answer and never learn where it came from.
16 *
1217 * Pure arithmetic, no map and no world: what it needs is a seed, a round, a
1318 * clock and a distance, so the whole thing can be checked in a test.
1419 */
@@ -60,6 +65,64 @@ export function siteFor(plan: Plan, team: Team, slot: number): Site {
6065 return slot % 2 === 0 ? plan.hold : otherSite(plan.hold);
6166 }
6267
68 // --- the radio ------------------------------------------------------------
69
70 /**
71 * Everything a person can say to their own side, and nothing else: take A,
72 * take B, or stop here and defend it. Three words is the whole vocabulary —
73 * enough to move four teammates, small enough to reach with one key.
74 */
75 export type Call = "A" | "B" | "hold";
76
77 export const CALLS: readonly Call[] = ["A", "B", "hold"];
78
79 /** True for the three words above and nothing else — the boundary's check. */
80 export const isCall = (value: unknown): value is Call =>
81 value === "A" || value === "B" || value === "hold";
82
83 /** One call, live: which side said it, what it said, and when in the round. */
84 export type RadioCall = { team: Team; call: Call; at: number };
85
86 /**
87 * Seconds a call stands before the rolled plan has it back. Long enough to
88 * cross de_forge and set up on the site that was called, short enough that a
89 * rotation you shouted at the start of the round is not still steering the
90 * side when the round has moved on without it.
91 */
92 export const CALL_LIFE = 20;
93
94 /** The call this side is under at `now`, or null: nobody else hears it. */
95 export function liveCall(radio: RadioCall | null, team: Team, now: number): Call | null {
96 if (radio === null || radio.team !== team) return null;
97 if (now < radio.at || now - radio.at > CALL_LIFE) return null;
98 return radio.call;
99 }
100
101 /** What the call reads as on the message line: "go A", "go B", "hold". */
102 export const callLabel = (call: Call): string => (call === "hold" ? "hold" : `go ${call}`);
103
104 /**
105 * What one bot has been told this tick: a site to be at, whether it was told
106 * to stop where it is instead of pushing onto it, and whether the site came
107 * from a person rather than from the roll. That last one matters because a
108 * side reading the plan has its own reasons to leave it — a counter-terrorist
109 * rotates to wherever its team is dying — and somebody who just said "go A"
110 * outranks those reasons for as long as the call stands.
111 *
112 * With no call live — which is every round nobody touches the radio, and every
113 * round the training arena ever plays — this is `siteFor` and two falses,
114 * exactly as before.
115 */
116 export type Orders = { site: Site; hold: boolean; called: boolean };
117
118 export function ordersFor(
119 plan: Plan, radio: RadioCall | null, team: Team, slot: number, now: number,
120 ): Orders {
121 const call = liveCall(radio, team, now);
122 if (call === "A" || call === "B") return { site: call, hold: false, called: true };
123 return { site: siteFor(plan, team, slot), hold: call === "hold", called: false };
124 }
125
63126 /** How fast a bot with a rifle covers a path with nothing in its way. */
64127 export const WALK_SPEED = 170;
65128 /** Slack for a door, a turn, and the couple of seconds any plan is out by. */
src/client/settings.test.ts +3 −3
@@ -44,7 +44,7 @@ test("muted is only ever true for exactly true", () => {
4444 });
4545
4646 test("a whole, valid settings object round-trips", () => {
47 const bindings = { ...DEFAULT_BINDINGS, forward: "KeyZ" };
47 const bindings = { ...DEFAULT_BINDINGS, forward: "KeyG" };
4848 const stored = { muted: true, volume: 0.4, sensitivity: 3.5, difficulty: "hard", bindings };
4949 expect(parseSettings(JSON.stringify(stored))).toEqual(stored);
5050 });
@@ -94,8 +94,8 @@ test("an unknown action in storage is dropped, not carried over", () => {
9494 });
9595
9696 test("a valid partial rebind keeps the rest of the default map", () => {
97 const parsed = parseSettings('{"bindings":{"forward":"KeyZ","left":"KeyX"}}');
98 expect(parsed.bindings).toEqual({ ...DEFAULT_BINDINGS, forward: "KeyZ", left: "KeyX" });
97 const parsed = parseSettings('{"bindings":{"forward":"KeyG","left":"KeyX"}}');
98 expect(parsed.bindings).toEqual({ ...DEFAULT_BINDINGS, forward: "KeyG", left: "KeyX" });
9999 });
100100
101101 test("two actions given the same code: the first in the fixed order keeps it, the second falls back to its own default", () => {
src/client/world.test.ts +53 −1
@@ -5,7 +5,8 @@ import { SHORT, winReward } from "./match.ts";
55 import { WEAPONS } from "./weapons.ts";
66 import {
77 aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, createWorld, currentWeapon, damageDealt,
8 damageTaken, ENVIRONMENT, eyePos, flinch, NO_INPUT, offeredPickup, shoot, startRound, update,
8 damageTaken, ENVIRONMENT, eyePos, flinch, NO_INPUT, offeredPickup, radioCall, shoot, startRound,
9 update,
910 type Input, type Player, type World,
1011 } from "./world.ts";
1112
@@ -882,3 +883,54 @@ test("the planted bomb's damage lands in the ledger with nobody credited", () =>
882883 expect(taken[0].entry.damage).toBe(100);
883884 expect(taken[0].entry.killed).toBe(true);
884885 });
886
887 // --- the radio ------------------------------------------------------------
888
889 test("a call is one line of data on the person's own side, and the last one wins", () => {
890 const world = createWorld(SHORT, "me", "ct", 5, 77);
891 expect(world.radio).toBe(null);
892 world.time = 6;
893 expect(radioCall(world, "A")).toBe(true);
894 expect(world.radio).toEqual({ team: "ct", call: "A", at: 6 });
895 // On the HUD's message line, in the person's own voice.
896 expect(world.messages[world.messages.length - 1]?.text).toBe("You: go A");
897
898 world.time = 9;
899 radioCall(world, "hold");
900 expect(world.radio).toEqual({ team: "ct", call: "hold", at: 9 });
901 expect(world.messages[world.messages.length - 1]?.text).toBe("You: hold");
902
903 // The side is whichever side the person is on, never both.
904 const ts = createWorld(SHORT, "me", "t", 5, 77);
905 radioCall(ts, "B");
906 expect(ts.radio?.team).toBe("t");
907 });
908
909 test("a word that is not one of the three is not a call, and moves nobody", () => {
910 const world = createWorld(SHORT, "me", "ct", 5, 5);
911 radioCall(world, "A");
912 const before = world.radio;
913 const messages = world.messages.length;
914 expect(radioCall(world, "C" as "A")).toBe(false);
915 expect(radioCall(world, "" as "A")).toBe(false);
916 expect(world.radio).toBe(before);
917 expect(world.messages.length).toBe(messages);
918 });
919
920 test("a new round starts with the radio clear", () => {
921 const world = createWorld(SHORT, "me", "ct", 5, 9);
922 world.time = 4;
923 radioCall(world, "B");
924 expect(world.radio).not.toBe(null);
925 startRound(world);
926 expect(world.radio).toBe(null);
927 });
928
929 test("a dead person still has the radio: the living four are who can act on it", () => {
930 const world = createWorld(SHORT, "me", "ct", 5, 3);
931 world.time = 12;
932 world.me.alive = false; // watching the round through a teammate's eyes
933 expect(radioCall(world, "B")).toBe(true);
934 expect(world.radio).toEqual({ team: "ct", call: "B", at: 12 });
935 expect(world.messages[world.messages.length - 1]?.text).toBe("You: go B");
936 });
src/client/world.ts +24 −1
@@ -17,7 +17,7 @@ import { DE_FORGE, siteAt, type MapDef } from "./map.ts";
1717 import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, type Vec3 } from "./math.ts";
1818 import * as rules from "./match.ts";
1919 import { eyeHeight, hullHeight, move, type Body, type Wish, MOVE } from "./physics.ts";
20 import { roundPlan, type Plan } from "./plan.ts";
20 import { callLabel, isCall, roundPlan, type Call, type Plan, type RadioCall } from "./plan.ts";
2121 import { visible } from "./trace.ts";
2222 import {
2323 ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset,
@@ -151,6 +151,12 @@ export type World = {
151151 seed: number;
152152 /** Which site each side commits to this round; rolled in `startRound`. */
153153 plan: Plan;
154 /**
155 * The one call on the radio, or null. One at a time and one side only: a new
156 * call replaces the last, `plan.ts` decides how long it stands, and
157 * `startRound` clears it so a round never starts under yesterday's orders.
158 */
159 radio: RadioCall | null;
154160 bomb: Bomb;
155161 pickups: Pickup[];
156162 grenades: Grenade[]; // the ones in the air, or on the floor with a fuse left
@@ -230,6 +236,7 @@ export function createWorld(
230236 match, players, me, time: 0,
231237 seed: seed >>> 0,
232238 plan: roundPlan(seed, match.round),
239 radio: null,
233240 bomb: { state: "carried", pos: [0, 0, 0], site: null, plantProgress: 0, defuseProgress: 0, defuser: null, beepAt: 0 },
234241 pickups: [], grenades: [], bodies: [],
235242 damageLog: Array.from({ length: players.length + 1 }, () =>
@@ -262,6 +269,19 @@ function say(world: World, text: string): void {
262269 if (world.messages.length > 6) world.messages.shift();
263270 }
264271
272 /**
273 * The person on the radio. It is one line of data — the side, the word, the
274 * time — and the side's plan reader in plan.ts does the rest; nothing here
275 * touches a bot. Anything that is not one of the three words is not a call,
276 * so a button with a typo in it moves nobody.
277 */
278 export function radioCall(world: World, call: Call): boolean {
279 if (!isCall(call)) return false;
280 world.radio = { team: world.me.team, call, at: world.time };
281 say(world, `You: ${callLabel(call)}`);
282 return true;
283 }
284
265285 // --- rounds ---------------------------------------------------------------
266286
267287 export function startRound(world: World): void {
@@ -270,6 +290,9 @@ export function startRound(world: World): void {
270290 // rather than a draw from `world.random`: the training arena steps this same
271291 // world, and a stream that shifted would move numbers that mean something.
272292 world.plan = roundPlan(world.seed, world.match.round);
293 // Last round's radio is last round's: whatever was called, the new round
294 // starts on the roll and the person calls again if they still mean it.
295 world.radio = null;
273296 if (world.pendingDifficulty !== null) {
274297 world.difficulty = world.pendingDifficulty;
275298 world.pendingDifficulty = null;
src/pages/game.test.ts +11
@@ -88,3 +88,14 @@ test("the pause overlay carries the line a lost view raises", () => {
8888 expect(pause).toContain(`class="hint" id="view-lost" hidden`);
8989 expect(pause).toContain("took the 3D view away");
9090 });
91
92 test("the radio has an overlay of its own and a button a thumb can reach", () => {
93 const html = gamePage(db, options);
94 const stage = element(html, "stage");
95 // Empty and hidden in the first paint: main.ts fills it once, the same way
96 // it fills the buy menu, so a call never costs the panel its listeners.
97 expect(stage).toContain(`<div class="overlay" id="radio" hidden></div>`);
98 expect(element(html, "touch")).toContain(`data-action="radio"`);
99 // And the keyboard is told which key it is before a match starts.
100 expect(element(html, "menu")).toContain("<kbd>Z</kbd> opens the radio");
101 });
src/pages/game.ts +13 −2
@@ -141,6 +141,12 @@ button.primary { background:#2563eb; border-color:#2563eb; font-weight:600; }
141141 #buy .buy-item .buy-index { font-size:.7rem; color:var(--dim); }
142142 #buy .buy-item .buy-why { font-size:.72rem; color:var(--dim); min-height:1em; }
143143 #buy #buy-clock { font-variant-numeric:tabular-nums; }
144 /* Three calls, three targets a thumb cannot miss — and the digit that says the
145 same thing from a keyboard sits on the button that does it. */
146 #radio .radio-calls { width:min(32rem,100%); }
147 #radio .radio-call { flex:1 1 8rem; display:flex; flex-direction:column; align-items:center; gap:.1rem;
148 min-height:72px; font-size:1.05rem; }
149 #radio .radio-call b { color:#cfe0ff; font-size:.7rem; font-weight:600; }
144150 #summary .score { font-size:2.2rem; line-height:1.1; color:var(--text); font-variant-numeric:tabular-nums; margin:.1rem 0; }
145151 #summary h2 { font-size:1.8rem; }
146152 #summary #summary-line { color:var(--text); font-size:.95rem; }
@@ -228,13 +234,16 @@ export function gamePage(db: Database, options: PageOptions): string {
228234 <button type="button" id="fullscreen-menu" aria-pressed="false" hidden>Fullscreen</button></div>
229235 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
230236 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>E</kbd> plant, defuse or take a gun · <kbd>B</kbd> buy ·
231 right click or <kbd>Q</kbd> to scope · <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns ·
237 <kbd>Z</kbd> radio · right click or <kbd>Q</kbd> to scope · <kbd>1</kbd><kbd>2</kbd><kbd>3</kbd> guns ·
232238 <kbd>4</kbd> the HE grenade, thrown with fire · <kbd>Tab</kbd> scores ·
233239 <kbd>Esc</kbd> pauses the match, where sound and sensitivity live too. Every key above is
234240 the default — rebind any of them under <b>Controls</b> below.</p>
241 <p class="hint"><kbd>Z</kbd> opens the radio: <b>go A</b>, <b>go B</b> or <b>hold</b>. Your four
242 teammates hear it and turn round — a call stands for twenty seconds and then the side goes
243 back to the plan it rolled. The other side never hears you.</p>
235244 <p class="hint">On a phone: the left of the view is a thumb stick, the right aims, a second
236245 finger on the right fires, and the pad under the view does the rest — guns, the grenade,
237 scores, buy and <code>menu</code>, which pauses the match and is where you leave it from.</p>
246 scores, buy, radio and <code>menu</code>, which pauses the match and is where you leave it from.</p>
238247 <p class="hint">Paused means paused: the world stops while the overlay is up, and so does
239248 tabbing away — the match is waiting for you when you come back.</p>
240249 <details class="controls"><summary>Controls</summary><div id="controls-menu"></div></details>
@@ -272,6 +281,7 @@ export function gamePage(db: Database, options: PageOptions): string {
272281 </div>
273282 </div>
274283 <div class="overlay" id="buy" hidden></div>
284 <div class="overlay" id="radio" hidden></div>
275285 </div>
276286
277287 <div id="touch" hidden>
@@ -289,6 +299,7 @@ export function gamePage(db: Database, options: PageOptions): string {
289299 <button type="button" data-action="use">use</button>
290300 <button type="button" data-action="reload">reload</button>
291301 <button type="button" data-action="buy">buy</button>
302 <button type="button" data-action="radio">radio</button>
292303 <button type="button" data-action="scope">scope</button>
293304 </div>
294305 <div class="pad wide">

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs