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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/63-radio-three-calls-the-person-can-make-an → main 4 commits fork 3 threads · 3 unresolved
README.md +38 −4
| @@ -158,7 +158,7 @@ wants one, but the running server never reads it. | ||
| 158 | 158 | which gun when it is one you have to ask for), <kbd>B</kbd> opens the buy menu in the freeze |
| 159 | 159 | time, <kbd>1</kbd>/<kbd>2</kbd>/<kbd>3</kbd> switch guns and <kbd>4</kbd> takes |
| 160 | 160 | 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 | |
| 162 | 162 | the scoreboard, <kbd>Esc</kbd> pauses the match on a desktop and `menu` on the |
| 163 | 163 | pad does it on a phone. Pausing also opens mute, volume and |
| 164 | 164 | 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 | ||
| 167 | 167 | WASD** — on an AZERTY board, a Dvorak layout or just a different habit, the |
| 168 | 168 | letters printed on the physical keys are not where this game's defaults |
| 169 | 169 | 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 | |
| 171 | 171 | in sync) — closed by default so it never pushes anything else out of view; |
| 172 | 172 | opening it is a plain `<details>`, reachable and toggled with a keyboard on |
| 173 | 173 | 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 | ||
| 180 | 180 | you cannot move in. Rebinding is keyboard only — the touch pad's layout does |
| 181 | 181 | not change. |
| 182 | 182 | |
| 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 | ||
| 183 | 204 | The menu's **Difficulty** select picks Easy, Normal or Hard before a match |
| 184 | 205 | starts — each one a band of the bots' skill rather than one number, so a |
| 185 | 206 | 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: | ||
| 240 | 261 | | grenade | <kbd>4</kbd>, then fire | `4 nade`, then the fire bar | |
| 241 | 262 | | scoreboard | <kbd>Tab</kbd> | hold `scores` | |
| 242 | 263 | | 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 | | |
| 243 | 265 | | pause the match, leave it, or change mute/volume/sensitivity | <kbd>Esc</kbd> | `menu` | |
| 244 | 266 | |
| 245 | 267 | 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. | ||
| 289 | 311 | bounce, fuse and how far a blast reaches), `match` (the rules), `world` (the simulation), |
| 290 | 312 | `buy` (the buy panel's rows — what a side can afford, what it already owns |
| 291 | 313 | 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`, | |
| 294 | 316 | `press` (the input latch), `loop` (how much simulation one frame is worth: |
| 295 | 317 | the fixed step, the caps, and a pause that banks nothing), |
| 296 | 318 | `quality` (the render scale the frame time buys), |
| @@ -373,6 +395,18 @@ headless over twenty seeded `SHORT` matches, before and after: the bomb is | ||
| 373 | 395 | planted in 65% of rounds rather than 38%, and 10% of rounds end on the clock |
| 374 | 396 | rather than 37%. |
| 375 | 397 | |
| 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 | ||
| 376 | 410 | - **What it hears** — the sounds the world already makes, read off the effect |
| 377 | 411 | list once a tick (`src/client/hearing.ts`, and `listen` in `bots.ts`). A |
| 378 | 412 | 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 @@ | ||
| 1 | 1 | 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"; | |
| 4 | 4 | |
| 5 | 5 | 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" } }); | |
| 8 | 8 | }); |
| 9 | 9 | |
| 10 | 10 | 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", () => { | ||
| 46 | 46 | test("labelForCode never throws on a code it does not recognise — it just shows the code", () => { |
| 47 | 47 | expect(labelForCode("IntlBackslash")).toBe("IntlBackslash"); |
| 48 | 48 | }); |
| 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"; | ||
| 11 | 11 | export const ACTION_LABELS: Record<Action, string> = { |
| 12 | 12 | forward: "Move forward", back: "Move back", left: "Strafe left", right: "Strafe right", |
| 13 | 13 | 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", | |
| 15 | 15 | scoreboard: "Scoreboard", scope: "Scope", |
| 16 | 16 | slot1: "Slot 1 — rifle", slot2: "Slot 2 — pistol", slot3: "Slot 3 — knife", slot4: "Slot 4 — grenade", |
| 17 | 17 | }; |
src/client/bots.test.ts +128 −1
| @@ -3,9 +3,12 @@ import { | ||
| 3 | 3 | applyIntent, botThink, CARD, DIFFICULTIES, makeBrain, policy, restyleBrain, skillBand, think, |
| 4 | 4 | } from "./bots.ts"; |
| 5 | 5 | import { DE_FORGE } from "./map.ts"; |
| 6 | import { dist2d } from "./math.ts"; | |
| 6 | 7 | import { COMPETITIVE, SHORT } from "./match.ts"; |
| 8 | import { CALL_LIFE, otherSite } from "./plan.ts"; | |
| 7 | 9 | 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, | |
| 9 | 12 | } from "./world.ts"; |
| 10 | 13 | |
| 11 | 14 | 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", () => { | ||
| 266 | 269 | expect(world.bomb.state).toBe("carried"); |
| 267 | 270 | expect(bot.hasBomb).toBe(true); |
| 268 | 271 | }); |
| 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 | ||
| 16 | 16 | import { angleDelta, clamp, dist, dist2d, rng, type Vec3 } from "./math.ts"; |
| 17 | 17 | import { findPath, siteAt, walkable, colOf, rowOf } from "./map.ts"; |
| 18 | 18 | 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"; | |
| 20 | 20 | import { |
| 21 | 21 | forward, newActivations, sample, unpack, type Activations, type Policy, |
| 22 | 22 | } from "./policy.ts"; |
| @@ -83,7 +83,10 @@ export type BotBrain = { | ||
| 83 | 83 | /** The world clock the last time this bot listened: what came after it is news. */ |
| 84 | 84 | heardAt: number; |
| 85 | 85 | 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 | |
| 87 | 90 | reset: boolean; |
| 88 | 91 | random: () => number; |
| 89 | 92 | // 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 | ||
| 109 | 112 | goal: null, path: [], step: 0, repathAt: 0, |
| 110 | 113 | targetId: null, seenAt: 0, burstUntil: 0, restUntil: 0, |
| 111 | 114 | 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, | |
| 113 | 116 | random: rng(seed), |
| 114 | 117 | obs: new Float32Array(OBS_SIZE), |
| 115 | 118 | activations: newActivations(PACKED.shape), |
| @@ -166,6 +169,20 @@ function slotOf(world: World, bot: Player): number { | ||
| 166 | 169 | return slot; |
| 167 | 170 | } |
| 168 | 171 | |
| 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 | ||
| 169 | 186 | /** What is left of the walk this bot has already planned, in map units. */ |
| 170 | 187 | function pathLeft(brain: BotBrain, from: Vec3): number { |
| 171 | 188 | let total = 0; |
| @@ -232,6 +249,12 @@ function rotateTo(world: World, bot: Player): Site | null { | ||
| 232 | 249 | /** Where this bot is trying to be, given what the round is doing. */ |
| 233 | 250 | function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 { |
| 234 | 251 | const bomb = world.bomb; |
| 252 | // Told to hold: this is where the round is being fought from now on. The | |
| 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); | |
| 235 | 258 | if (bot.team === "t") { |
| 236 | 259 | if (bomb.state === "planted") { |
| 237 | 260 | // Hold near the bomb rather than walk onto it. |
| @@ -242,7 +265,10 @@ function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 { | ||
| 242 | 265 | return nearby(world, brain, world.map.sites[brain.site], 200); |
| 243 | 266 | } |
| 244 | 267 | 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. The low one is gone too. What I verified myself at this head: 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); | |
| 246 | 272 | return nearby(world, brain, world.map.sites[contact ?? brain.site], 250); |
| 247 | 273 | } |
| 248 | 274 | |
| @@ -423,7 +449,12 @@ function turnTowards(bot: Player, target: { yaw: number; pitch: number }, speed: | ||
| 423 | 449 | * here, which is a few times a round and never per tick. |
| 424 | 450 | */ |
| 425 | 451 | 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" : ""}`; | |
| 427 | 458 | const stale = |
| 428 | 459 | brain.path.length === 0 || brain.step >= brain.path.length || |
| 429 | 460 | world.time > brain.repathAt || objective !== brain.plannedFor; |
| @@ -461,7 +492,8 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 461 | 492 | if (brain.reset) { |
| 462 | 493 | brain.reset = false; |
| 463 | 494 | // 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); | |
| 465 | 497 | brain.goal = null; |
| 466 | 498 | brain.path = []; |
| 467 | 499 | brain.targetId = null; |
| @@ -478,6 +510,10 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 478 | 510 | brain.heardAt = world.time; |
| 479 | 511 | return input; |
| 480 | 512 | } |
| 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); | |
| 481 | 517 | |
| 482 | 518 | listen(world, bot, brain); |
| 483 | 519 | 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", () => { | ||
| 243 | 243 | expect(controls.inputAt(controls.take(1), 0).scope).toBe(false); |
| 244 | 244 | }); |
| 245 | 245 | |
| 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 | ||
| 246 | 279 | // --- bindings: a non-default map moves the game, not the printed letters |
| 247 | 280 | |
| 248 | 281 | 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" }); | |
| 250 | 283 | const window = globals.window as Canvas; |
| 251 | 284 | |
| 252 | 285 | window.fire("keydown", { code: "KeyW" }); |
| 253 | 286 | expect(controls.inputAt(controls.take(1), 0).forward).toBe(0); |
| 254 | 287 | |
| 255 | window.fire("keydown", { code: "KeyZ" }); | |
| 288 | window.fire("keydown", { code: "KeyG" }); | |
| 256 | 289 | expect(controls.inputAt(controls.take(1), 0).forward).toBe(1); |
| 257 | 290 | }); |
| 258 | 291 | |
| @@ -264,16 +297,16 @@ test("a rebind takes effect on the very next key event, with no detach or re-att | ||
| 264 | 297 | expect(controls.inputAt(controls.take(1), 0).forward).toBe(1); |
| 265 | 298 | window.fire("keyup", { code: "KeyW" }); |
| 266 | 299 | |
| 267 | controls.setBindings({ ...DEFAULT_BINDINGS, forward: "KeyZ" }); | |
| 300 | controls.setBindings({ ...DEFAULT_BINDINGS, forward: "KeyG" }); | |
| 268 | 301 | |
| 269 | 302 | window.fire("keydown", { code: "KeyW" }); |
| 270 | 303 | expect(controls.inputAt(controls.take(1), 0).forward).toBe(0); |
| 271 | window.fire("keydown", { code: "KeyZ" }); | |
| 304 | window.fire("keydown", { code: "KeyG" }); | |
| 272 | 305 | expect(controls.inputAt(controls.take(1), 0).forward).toBe(1); |
| 273 | 306 | }); |
| 274 | 307 | |
| 275 | 308 | 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" }); | |
| 277 | 310 | const window = globals.window as Canvas; |
| 278 | 311 | |
| 279 | 312 | window.fire("keydown", { code: "ArrowUp" }); |
src/client/input.ts +6 −3
| @@ -41,6 +41,7 @@ export type Controls = { | ||
| 41 | 41 | */ |
| 42 | 42 | clear(): void; |
| 43 | 43 | onBuy: () => void; |
| 44 | onRadio: () => void; | |
| 44 | 45 | onPause: () => void; |
| 45 | 46 | detach(): void; |
| 46 | 47 | }; |
| @@ -52,7 +53,7 @@ export type Controls = { | ||
| 52 | 53 | */ |
| 53 | 54 | export const ACTIONS = [ |
| 54 | 55 | "forward", "back", "left", "right", "jump", "duck", "walk", |
| 55 | "reload", "use", "buy", "scoreboard", "scope", | |
| 56 | "reload", "use", "buy", "radio", "scoreboard", "scope", | |
| 56 | 57 | "slot1", "slot2", "slot3", "slot4", |
| 57 | 58 | ] as const; |
| 58 | 59 | export type Action = typeof ACTIONS[number]; |
| @@ -60,7 +61,7 @@ export type Action = typeof ACTIONS[number]; | ||
| 60 | 61 | export const DEFAULT_BINDINGS: Record<Action, string> = { |
| 61 | 62 | forward: "KeyW", back: "KeyS", left: "KeyA", right: "KeyD", |
| 62 | 63 | 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 What I checked at this head, not from the commit message:
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. |
||
| 64 | 65 | slot1: "Digit1", slot2: "Digit2", slot3: "Digit3", slot4: "Digit4", |
| 65 | 66 | }; |
| 66 | 67 | |
| @@ -128,10 +129,11 @@ export function createControls( | ||
| 128 | 129 | if (down) press.down(action); |
| 129 | 130 | else press.up(action); |
| 130 | 131 | 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 | |
| 132 | 133 | // touch screen reaches the pause overlay at all: pointer lock is the only |
| 133 | 134 | // other thing that calls onPause, and a finger never takes the lock. |
| 134 | 135 | if (action === "buy") controls.onBuy(); |
| 136 | else if (action === "radio") controls.onRadio(); | |
| 135 | 137 | else if (action === "menu") controls.onPause(); |
| 136 | 138 | }, |
| 137 | 139 | setBindings: (next) => { |
| @@ -143,6 +145,7 @@ export function createControls( | ||
| 143 | 145 | lookY = 0; |
| 144 | 146 | }, |
| 145 | 147 | onBuy: () => {}, |
| 148 | onRadio: () => {}, | |
| 146 | 149 | onPause: () => {}, |
| 147 | 150 | detach: () => { |
| 148 | 151 | window.removeEventListener("keydown", onKeyDown); |
src/client/main.ts +76 −10
| @@ -4,8 +4,8 @@ | ||
| 4 | 4 | * stepped at a fixed 64 Hz however long the frame took, and then the scene and |
| 5 | 5 | * the HUD are drawn. How much of that a frame is worth is loop.ts's decision; |
| 6 | 6 | * 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. | |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import { createAudio } from "./audio.ts"; |
| @@ -20,10 +20,11 @@ import { COMPETITIVE, SHORT, type Rules } from "./match.ts"; | ||
| 20 | 20 | import { MAX_SCALE, nextRenderScale } from "./quality.ts"; |
| 21 | 21 | import { createRenderer, type Camera } from "./renderer.ts"; |
| 22 | 22 | import { DEFAULT_SETTINGS, loadSettings, saveSettings, SENSITIVITY_MAX, SENSITIVITY_MIN } from "./settings.ts"; |
| 23 | import { CALL_LIFE, isCall, type Call } from "./plan.ts"; | |
| 23 | 24 | import { nextWatched, watched } from "./spectate.ts"; |
| 24 | 25 | import { summarize, type MatchSummary } from "./summary.ts"; |
| 25 | 26 | import { |
| 26 | buy, canBuy, createWorld, DIFFICULTIES, eyePos, flinch, setDifficulty, update, | |
| 27 | buy, canBuy, createWorld, DIFFICULTIES, eyePos, flinch, radioCall, setDifficulty, update, | |
| 27 | 28 | type Difficulty, type Player, type SoundName, type World, |
| 28 | 29 | } from "./world.ts"; |
| 29 | 30 | |
| @@ -46,6 +47,7 @@ function boot(): void { | ||
| 46 | 47 | const pause = $("pause"); |
| 47 | 48 | const summaryPanel = $("summary"); |
| 48 | 49 | const buyPanel = $("buy"); |
| 50 | const radioPanel = $("radio"); | |
| 49 | 51 | const touchPanel = $("touch"); |
| 50 | 52 | const stats = $("stats"); |
| 51 | 53 | const nameField = $<HTMLInputElement>("name"); |
| @@ -296,7 +298,7 @@ function boot(): void { | ||
| 296 | 298 | // Hidden, not removed: an overlay comes and goes every freeze time, and a |
| 297 | 299 | // pad that left the flow would drag the page under it up and down with it. |
| 298 | 300 | touchPanel.classList.toggle( |
| 299 | "blocked", !(pause.hidden && buyPanel.hidden && summaryPanel.hidden)); | |
| 301 | "blocked", !(pause.hidden && buyPanel.hidden && radioPanel.hidden && summaryPanel.hidden)); | |
| 300 | 302 | }; |
| 301 | 303 | |
| 302 | 304 | // --- the buy menu: built once, then updated in place so a purchase never |
| @@ -389,6 +391,64 @@ function boot(): void { | ||
| 389 | 391 | |
| 390 | 392 | controls.onBuy = () => openBuy(buyPanel.hidden); |
| 391 | 393 | |
| 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 | ||
| 392 | 452 | /** |
| 393 | 453 | * Put the match on hold: the overlay goes up and `frame` stops stepping the |
| 394 | 454 | * world. The buy panel is closed first because it paints over the pause |
| @@ -398,6 +458,7 @@ function boot(): void { | ||
| 398 | 458 | function pauseMatch(): void { |
| 399 | 459 | if (!running || !pause.hidden) return; |
| 400 | 460 | show(buyPanel, false); |
| 461 | show(radioPanel, false); | |
| 401 | 462 | show(pause, true); |
| 402 | 463 | refreshPad(); |
| 403 | 464 | document.exitPointerLock(); |
| @@ -410,7 +471,7 @@ function boot(): void { | ||
| 410 | 471 | }; |
| 411 | 472 | |
| 412 | 473 | controls.onPause = () => { |
| 413 | if (buyPanel.hidden) pauseMatch(); | |
| 474 | if (buyPanel.hidden && radioPanel.hidden) pauseMatch(); | |
| 414 | 475 | }; |
| 415 | 476 | // A hidden tab is a pause: requestAnimationFrame is throttled there, so a |
| 416 | 477 | // match left running would crawl on without you and be over — or lost — by |
| @@ -487,6 +548,7 @@ function boot(): void { | ||
| 487 | 548 | show(pause, false); |
| 488 | 549 | show(summaryPanel, false); |
| 489 | 550 | show(buyPanel, false); |
| 551 | show(radioPanel, false); | |
| 490 | 552 | refreshPad(); |
| 491 | 553 | audio.resume(); |
| 492 | 554 | lock(); |
| @@ -516,6 +578,7 @@ function boot(): void { | ||
| 516 | 578 | show(pause, false); |
| 517 | 579 | show(summaryPanel, false); |
| 518 | 580 | show(buyPanel, false); |
| 581 | show(radioPanel, false); | |
| 519 | 582 | show(menu, true); |
| 520 | 583 | refreshPad(); |
| 521 | 584 | $("play").focus(); |
| @@ -524,7 +587,9 @@ function boot(): void { | ||
| 524 | 587 | $("summary-menu").addEventListener("click", toMenu); |
| 525 | 588 | $("again").addEventListener("click", start); |
| 526 | 589 | 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); | |
| 528 | 593 | }); |
| 529 | 594 | const saved = localStorage.getItem("cs.name"); |
| 530 | 595 | if (saved !== null && nameField.value === "") nameField.value = saved; |
| @@ -616,10 +681,10 @@ function boot(): void { | ||
| 616 | 681 | for (let step = 0; step < steps; step += 1) { |
| 617 | 682 | const input = controls.inputAt(pressed, step); |
| 618 | 683 | 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; | |
| 623 | 688 | if (!world.me.alive && input.fire) cycle = true; |
| 624 | 689 | update(world, TICK, input); |
| 625 | 690 | } |
| @@ -642,6 +707,7 @@ function boot(): void { | ||
| 642 | 707 | posted = played; |
| 643 | 708 | document.exitPointerLock(); |
| 644 | 709 | show(buyPanel, false); |
| 710 | show(radioPanel, false); | |
| 645 | 711 | refreshPad(); |
| 646 | 712 | // The last post is the only one that says the match finished, and the |
| 647 | 713 | // score it carries is the one the overlay is about to show. |
src/client/plan.test.ts +89 −2
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { COMPETITIVE } from "./match.ts"; | |
| 2 | import { COMPETITIVE, type Team } from "./match.ts"; | |
| 3 | 3 | 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, | |
| 5 | 6 | } from "./plan.ts"; |
| 6 | 7 | |
| 7 | 8 | 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", () | ||
| 63 | 64 | expect(mustCommit(20, 0, plantTime)).toBe(false); |
| 64 | 65 | expect(mustCommit(20, 4000, plantTime)).toBe(true); |
| 65 | 66 | }); |
| 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 @@ | ||
| 9 | 9 | * answer. It is a roll per round and not a constant: a match pushes B in one |
| 10 | 10 | * round and A in the next, and another match rolls a different order. |
| 11 | 11 | * |
| 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 | * | |
| 12 | 17 | * Pure arithmetic, no map and no world: what it needs is a seed, a round, a |
| 13 | 18 | * clock and a distance, so the whole thing can be checked in a test. |
| 14 | 19 | */ |
| @@ -60,6 +65,64 @@ export function siteFor(plan: Plan, team: Team, slot: number): Site { | ||
| 60 | 65 | return slot % 2 === 0 ? plan.hold : otherSite(plan.hold); |
| 61 | 66 | } |
| 62 | 67 | |
| 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 | ||
| 63 | 126 | /** How fast a bot with a rifle covers a path with nothing in its way. */ |
| 64 | 127 | export const WALK_SPEED = 170; |
| 65 | 128 | /** 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", () => { | ||
| 44 | 44 | }); |
| 45 | 45 | |
| 46 | 46 | test("a whole, valid settings object round-trips", () => { |
| 47 | const bindings = { ...DEFAULT_BINDINGS, forward: "KeyZ" }; | |
| 47 | const bindings = { ...DEFAULT_BINDINGS, forward: "KeyG" }; | |
| 48 | 48 | const stored = { muted: true, volume: 0.4, sensitivity: 3.5, difficulty: "hard", bindings }; |
| 49 | 49 | expect(parseSettings(JSON.stringify(stored))).toEqual(stored); |
| 50 | 50 | }); |
| @@ -94,8 +94,8 @@ test("an unknown action in storage is dropped, not carried over", () => { | ||
| 94 | 94 | }); |
| 95 | 95 | |
| 96 | 96 | 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" }); | |
| 99 | 99 | }); |
| 100 | 100 | |
| 101 | 101 | 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"; | ||
| 5 | 5 | import { WEAPONS } from "./weapons.ts"; |
| 6 | 6 | import { |
| 7 | 7 | 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, | |
| 9 | 10 | type Input, type Player, type World, |
| 10 | 11 | } from "./world.ts"; |
| 11 | 12 | |
| @@ -882,3 +883,54 @@ test("the planted bomb's damage lands in the ledger with nobody credited", () => | ||
| 882 | 883 | expect(taken[0].entry.damage).toBe(100); |
| 883 | 884 | expect(taken[0].entry.killed).toBe(true); |
| 884 | 885 | }); |
| 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"; | ||
| 17 | 17 | import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, type Vec3 } from "./math.ts"; |
| 18 | 18 | import * as rules from "./match.ts"; |
| 19 | 19 | 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"; | |
| 21 | 21 | import { visible } from "./trace.ts"; |
| 22 | 22 | import { |
| 23 | 23 | ARMOR_PRICE, DEFUSE_KIT_PRICE, HELMET_PRICE, WEAPONS, inaccuracy, recoilOffset, |
| @@ -151,6 +151,12 @@ export type World = { | ||
| 151 | 151 | seed: number; |
| 152 | 152 | /** Which site each side commits to this round; rolled in `startRound`. */ |
| 153 | 153 | 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; | |
| 154 | 160 | bomb: Bomb; |
| 155 | 161 | pickups: Pickup[]; |
| 156 | 162 | grenades: Grenade[]; // the ones in the air, or on the floor with a fuse left |
| @@ -230,6 +236,7 @@ export function createWorld( | ||
| 230 | 236 | match, players, me, time: 0, |
| 231 | 237 | seed: seed >>> 0, |
| 232 | 238 | plan: roundPlan(seed, match.round), |
| 239 | radio: null, | |
| 233 | 240 | bomb: { state: "carried", pos: [0, 0, 0], site: null, plantProgress: 0, defuseProgress: 0, defuser: null, beepAt: 0 }, |
| 234 | 241 | pickups: [], grenades: [], bodies: [], |
| 235 | 242 | damageLog: Array.from({ length: players.length + 1 }, () => |
| @@ -262,6 +269,19 @@ function say(world: World, text: string): void { | ||
| 262 | 269 | if (world.messages.length > 6) world.messages.shift(); |
| 263 | 270 | } |
| 264 | 271 | |
| 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 | ||
| 265 | 285 | // --- rounds --------------------------------------------------------------- |
| 266 | 286 | |
| 267 | 287 | export function startRound(world: World): void { |
| @@ -270,6 +290,9 @@ export function startRound(world: World): void { | ||
| 270 | 290 | // rather than a draw from `world.random`: the training arena steps this same |
| 271 | 291 | // world, and a stream that shifted would move numbers that mean something. |
| 272 | 292 | 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; | |
| 273 | 296 | if (world.pendingDifficulty !== null) { |
| 274 | 297 | world.difficulty = world.pendingDifficulty; |
| 275 | 298 | world.pendingDifficulty = null; |
src/pages/game.test.ts +11
| @@ -88,3 +88,14 @@ test("the pause overlay carries the line a lost view raises", () => { | ||
| 88 | 88 | expect(pause).toContain(`class="hint" id="view-lost" hidden`); |
| 89 | 89 | expect(pause).toContain("took the 3D view away"); |
| 90 | 90 | }); |
| 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; } | ||
| 141 | 141 | .buy-item .buy-index { font-size:.7rem; color:var(--dim); } |
| 142 | 142 | .buy-item .buy-why { font-size:.72rem; color:var(--dim); min-height:1em; } |
| 143 | 143 | -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-calls { width:min(32rem,100%); } | |
| 147 | .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-call b { color:; font-size:.7rem; font-weight:600; } | |
| 144 | 150 | .score { font-size:2.2rem; line-height:1.1; color:var(--text); font-variant-numeric:tabular-nums; margin:.1rem 0; } |
| 145 | 151 | h2 { font-size:1.8rem; } |
| 146 | 152 | -line { color:var(--text); font-size:.95rem; } |
| @@ -228,13 +234,16 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 228 | 234 | <button type="button" id="fullscreen-menu" aria-pressed="false" hidden>Fullscreen</button></div> |
| 229 | 235 | <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk · |
| 230 | 236 | 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 · | |
| 232 | 238 | <kbd>4</kbd> the HE grenade, thrown with fire · <kbd>Tab</kbd> scores · |
| 233 | 239 | <kbd>Esc</kbd> pauses the match, where sound and sensitivity live too. Every key above is |
| 234 | 240 | 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> | |
| 235 | 244 | <p class="hint">On a phone: the left of the view is a thumb stick, the right aims, a second |
| 236 | 245 | 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> | |
| 238 | 247 | <p class="hint">Paused means paused: the world stops while the overlay is up, and so does |
| 239 | 248 | tabbing away — the match is waiting for you when you come back.</p> |
| 240 | 249 | <details class="controls"><summary>Controls</summary><div id="controls-menu"></div></details> |
| @@ -272,6 +281,7 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 272 | 281 | </div> |
| 273 | 282 | </div> |
| 274 | 283 | <div class="overlay" id="buy" hidden></div> |
| 284 | <div class="overlay" id="radio" hidden></div> | |
| 275 | 285 | </div> |
| 276 | 286 | |
| 277 | 287 | <div id="touch" hidden> |
| @@ -289,6 +299,7 @@ export function gamePage(db: Database, options: PageOptions): string { | ||
| 289 | 299 | <button type="button" data-action="use">use</button> |
| 290 | 300 | <button type="button" data-action="reload">reload</button> |
| 291 | 301 | <button type="button" data-action="buy">buy</button> |
| 302 | <button type="button" data-action="radio">radio</button> | |
| 292 | 303 | <button type="button" data-action="scope">scope</button> |
| 293 | 304 | </div> |
| 294 | 305 | <div class="pad wide"> |
Reviewed the diff, ran
bun testat 334f17a (394 pass, 0 fail), booted the app from this head (/answers in 17 ms, CSP / X-Content-Type-Options / Referrer-Policy intact, the#radiooverlay and theradiopad button both in the first paint), and read both screenshots. The shape is right: the override lives inplan.tsin front ofsiteFor,bots.tsonly asks for orders,world.radiois one line of plain data thatstartRoundclears,isCallvalidates at the boundary, and there is no server surface and no new dependency. I mutation-checked the new tests as well — deleting the per-tickreadOrders, the hold branch inchooseGoal, theisCallguard, thestartRoundclear or theKeyZbinding 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.sitenow carries the call, butrotateTo'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 reportbrain.site === "A"and walk to B anyway. The T side is unaffected because its branch readsbrain.sitedirectly, 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.
ordersForalready knows — return whether the site came from a call (or set acalledflag on the brain inreadOrdersfromliveCall) and usebrain.siteinstead ofcontact ?? brain.sitewhile it stands. A test inbots.test.tsshaped like the repro above — a CT death near the other site, then a call, thenthinkAll— 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.
openRadioinsrc/client/main.tsguards only onworldandrunning, andrunningstays true while paused, so Z on the pause screen stacks the radio over it; Escape then closes the radio and callslock()with the pause overlay still up and the world still stopped.if (!pause.hidden) return;inopenRadio, next to thebuyPanel.hiddencheckonPausealready 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.