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

forked from trevato/cs

Radio: three calls a person makes, and four teammates who go #386

Merged opened this

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

Merged

vesper/cs-engine:vesper/290-radio-three-calls-a-person-makes-and-fou → main 2 commits fork

  • Merged. vesper/290-radio-three-calls-a-person-makes-and-fou was merged into main as acb981d.

Review

Sign in to review this change.
18 files changed +570 −19
README.md +17
@@ -73,6 +73,23 @@ Planning is rate-limited per caller and under a spend ceiling for the whole
7373 process; over either, or on any failure, the round gets the local plan, which is
7474 a plan and not an error.
7575
76 ### The radio: three calls, and your side goes
77
78 <kbd>Z</kbd> says **go A**, <kbd>Q</kbd> **go B** and <kbd>H</kbd> **hold**
79 (on a phone, the three buttons on the left pad). A call stands in front of the
80 round's plan for your side for 20 seconds and then falls back to it: a site call
81 is every bot on your side on that site at once (a stack, at a rush, the lurker
82 too); a hold keeps them where they stand until the call runs out, except the bomb,
83 which never waits. The HUD's message line says what was called and by whom, and
84 your side hears the radio's blip — the other side does not, and its bots never
85 see the call. `src/plan/call.ts` is the override (`steer` and `heed`, plain data
86 beside `plan.ts`), `src/client/radio.ts` makes a call in a world, and
87 `src/client/bots.ts` cuts its jobs from whatever those two say, so a call that
88 expires is the rolled plan again on the next tick. A seeded round with a call in
89 it is pinned in `src/client/replay.test.ts` (`CALL_ROUND`). In a room the radio
90 keys say the radio is for matches against bots: the room's server takes no calls
91 yet.
92
7693 ## What is real and what is not
7794
7895 - **Real.** The game: WebGL2 written by hand, a fixed 64 Hz simulation, Source-style
src/client/audio.test.ts +18 −2
@@ -6,8 +6,8 @@ import type { Burst, SoundDevice, Tone, Vec3 } from "../engine/index.ts";
66 import type { Sound } from "./world.ts";
77
88 /**
9 * Every noise a round can make, which is the whole of `Sound`: the five a
10 * grenade makes and the five the round does. Written once, because three tests
9 * Every noise a round can make in the world, which is the whole of `Sound`
10 * but the radio: the five a grenade makes and the five the round does. Written once, because three tests
1111 * here walk all of them and a name missing from one of those lists is a recipe
1212 * nobody checks — `smoke` was exactly that until the budget below wanted it.
1313 */
@@ -15,6 +15,9 @@ const SOUNDS: Sound[] = [
1515 "step", "beep", "plant", "defuse", "death", "throw", "bounce", "blast", "flash", "smoke",
1616 ];
1717
18 /** The one noise that is not in the world: a call, heard in the ear at one volume. */
19 const RADIO: Sound = "radio";
20
1821 /** A device that plays nothing and writes down everything. */
1922 function recorder(): SoundDevice & { bursts: Burst[]; tones: Tone[]; resumes: number } {
2023 const device = {
@@ -113,6 +116,19 @@ test("every noise a round makes has a recipe, and a far one is quieter than a ne
113116 }
114117 });
115118
119 test("a radio call has a recipe, and it is as loud across the map as beside you", () => {
120 const device = recorder();
121 const audio = createAudio(device);
122 audio.play(RADIO, [0, 0, 0], [0, 0, 0]);
123 audio.play(RADIO, [3000, 0, 0], [0, 0, 0]);
124 expect(device.bursts.length).toBe(2);
125 expect(device.tones.length).toBe(2);
126 expect((device.bursts[1] as Burst).gain).toBe((device.bursts[0] as Burst).gain);
127 expect((device.tones[1] as Tone).gain).toBe((device.tones[0] as Tone).gain);
128 // Not placed: dead centre, whatever the ear's heading.
129 expect((device.tones[1] as Tone).pan).toBeUndefined();
130 });
131
116132 // --- the budget -----------------------------------------------------------
117133
118134 /** What one recipe asks for at zero distance: the peak gain of every voice in it. */
src/client/audio.ts +9
@@ -168,6 +168,15 @@ export function createAudio(device: SoundDevice = createSoundDevice()): Audio {
168168 // Your own noise is not placed: it happens at the ear, so there is no
169169 // side for it to be on and no panner worth building for it.
170170 const pan = own ? undefined : panOf(at, listener, yaw);
171 if (sound === "radio") {
172 // A call on the radio: a crackle of static and a short square blip that
173 // drops a step as it goes. It is not placed and does not carry — it comes
174 // out of the same earpiece for everyone who hears it at all, and who
175 // that is (the caller's side) is the frame's question, not this one's.
176 device.noiseBurst({ frequency: 3000, gain: 0.08, length: 0.06, q: 0.7, rate: 1 });
177 device.tone({ frequency: 1250, slideTo: 950, gain: 0.1, length: 0.16, type: "square", floor: 0.0005, tail: 0.01 });
178 return;
179 }
171180 if (sound === "step") {
172181 // A foot landing: a scuff of grit through a wide filter with a soft
173182 // thump under it. The rate is jittered so a body at a dead run is a
src/client/binding.test.ts +14 −1
@@ -1,5 +1,7 @@
11 import { expect, test } from "bun:test";
22 import { CS_KEYS, CS_MOUSE, CS_TAP, CS_WHEEL, NUMBER_ROW, readIntent, slotAction, slotIndex } from "./binding.ts";
3 import { callOf, RADIO_ACTIONS } from "./radio.ts";
4 import { CALLS } from "../plan/call.ts";
35
46 const NONE = { x: 0, y: 0 };
57 const intent = (actions: string[], stick = NONE) => readIntent(new Set(actions), stick);
@@ -35,7 +37,7 @@ test("the bindings are the ones the keyboard has always had", () => {
3537 expect(CS_WHEEL.up).toBe("jump");
3638 expect(CS_WHEEL.down).toBe("jump");
3739 expect(intent(["jump"]).input.jump).toBe(true);
38 expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade", "flash", "smoke"]);
40 expect([...CS_TAP]).toEqual(["jump", "reload", "buy", "grenade", "flash", "smoke", "goA", "goB", "hold"]);
3941 // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks
4042 // the browser for the screen, and nothing about an `Input` changes for it.
4143 expect(CS_KEYS.KeyF).toBe("fullscreen");
@@ -137,3 +139,14 @@ test("three beats two beats one", () => {
137139 test("an action the game does not know is not an input", () => {
138140 expect(intent(["buy", "nonsense"]).input).toEqual(intent([]).input);
139141 });
142
143 test("the radio is three keys, each a call, and none of them moves the body", () => {
144 expect(CS_KEYS.KeyZ).toBe("goA");
145 expect(CS_KEYS.KeyQ).toBe("goB");
146 expect(CS_KEYS.KeyH).toBe("hold");
147 // Every action the radio makes is bound, and every bound call is one the radio knows.
148 const bound = Object.values(CS_KEYS).filter((action) => callOf(action) !== null);
149 expect(bound.sort()).toEqual(Object.keys(RADIO_ACTIONS).sort());
150 expect(Object.values(RADIO_ACTIONS)).toEqual([...CALLS]);
151 expect(intent(["goA", "goB", "hold"])).toEqual(intent([]));
152 });
src/client/binding.ts +7 −1
@@ -57,6 +57,12 @@ export const CS_KEYS: Record<string, string> = {
5757 // Nor is this one: X buys last round's loadout again, which is money and not
5858 // movement, so `main.ts` hears it and `readIntent` never does.
5959 KeyX: "rebuy",
60 // The radio: three calls to your own side, which `main.ts` hears on
61 // `onAction` and `src/client/radio.ts` makes. None is an `Input` — a call
62 // moves the bots, not the body that made it.
63 KeyZ: "goA",
64 KeyQ: "goB",
65 KeyH: "hold",
6066 };
6167
6268 /**
@@ -82,7 +88,7 @@ export const CS_MOUSE: Record<number, string> = {
8288 export const CS_WHEEL: Record<string, string> = { up: "jump", down: "jump" };
8389
8490 /** On-screen buttons that are a tap, not a hold. */
85 export const CS_TAP = ["jump", "reload", "buy", "grenade", "flash", "smoke"] as const;
91 export const CS_TAP = ["jump", "reload", "buy", "grenade", "flash", "smoke", "goA", "goB", "hold"] as const;
8692
8793 export type Intent = {
8894 input: Input;
src/client/bots.test.ts +58
@@ -5,6 +5,8 @@ import {
55 applyIntent, blindness, botBuy, botThink, dry, flashed, makeBrain, nextWeapon, NO_POLICY, pathTo, policy,
66 ride, roundPlan, think, usePlan, usePolicy,
77 } from "./bots.ts";
8 import { radio } from "./radio.ts";
9 import { CALL_LIFE } from "../plan/call.ts";
810 import type { RoundPlan, TeamPlan } from "../plan/plan.ts";
911 import { grip } from "./spray.ts";
1012 import { colOf, findPath, rowOf, walkable, worldOf } from "./level.ts";
@@ -520,3 +522,59 @@ test("a plan that lands mid-round re-tasks the team it was cut for", () => {
520522 expect(bots(world, "t").every((bot) => bot.brain?.job.site === "B")).toBe(true);
521523 expect(roundPlan(world)?.t.site).toBe("B");
522524 });
525
526 // --- the radio --------------------------------------------------------------
527
528 test("a call puts the caller's side on the site it named, and leaves the other side alone", () => {
529 const world = round();
530 usePlan(world, planned({ site: "A", shape: "fake" }));
531 world.match.phase = "live";
532 const made = radio(world, world.me, "B");
533 expect(made?.side).toBe("ct");
534 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
535 // All four defenders, the fake's lurker too: a call is everyone.
536 expect(bots(world, "ct").map((bot) => bot.brain?.job.site)).toEqual(["B", "B", "B", "B"]);
537 // The attackers never heard it, and play the plan they were handed.
538 expect(bots(world, "t").filter((bot) => bot.brain?.job.site === "A").length).toBeGreaterThan(2);
539 expect(bots(world, "t").some((bot) => bot.brain?.job.site === "B")).toBe(true); // the fake's lurker
540 });
541
542 test("a call expires back to the roll, and a hold keeps a side where it stands until then", () => {
543 const world = round();
544 usePlan(world, planned({ site: "A" }));
545 world.match.phase = "live";
546 world.me.team = "t";
547 radio(world, world.me, "B");
548 const think = (): void => {
549 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
550 };
551 think();
552 const attackers = bots(world, "t");
553 expect(attackers.every((bot) => bot.brain?.job.site === "B")).toBe(true);
554 // One tick past its life, the plan has the side back.
555 world.time += CALL_LIFE + 0.1;
556 think();
557 expect(attackers.every((bot) => bot.brain?.job.site === "A")).toBe(true);
558
559 // A hold: nobody but the bomb moves off before the call runs out.
560 radio(world, world.me, "hold");
561 think();
562 const end = world.time + CALL_LIFE;
563 for (const bot of attackers) {
564 if (bot.hasBomb) continue;
565 expect(bot.brain?.pushAt ?? 0).toBeCloseTo(end, 6);
566 const held = botThink(world, bot, 1 / 64);
567 if (bot.brain?.targetId === null) expect([held.forward, held.side]).toEqual([0, 0]);
568 }
569 });
570
571 test("a call from last round is not this round's call", () => {
572 const world = round();
573 usePlan(world, planned({ site: "A" }));
574 world.match.phase = "live";
575 radio(world, world.me, "B");
576 world.match.round += 1;
577 usePlan(world, { ...planned({ site: "A" }), round: world.match.round });
578 for (const bot of world.players) if (bot !== world.me) botThink(world, bot, 1 / 64);
579 expect(bots(world, "ct").every((bot) => bot.brain?.job.site === "A")).toBe(true);
580 });
src/client/bots.ts +20 −8
@@ -39,7 +39,8 @@ import {
3939 angleDelta, clamp, dist, forward, newActivations, rng, sample,
4040 type Activations, type Policy, type Vec3,
4141 } from "../engine/index.ts";
42 import { assign, DEFAULT_JOB, type Job, type RoundPlan } from "../plan/plan.ts";
42 import { assign, DEFAULT_JOB, type Job, type RoundPlan, type TeamPlan } from "../plan/plan.ts";
43 import { heed, liveCall, steer, type RadioCall } from "../plan/call.ts";
4344 import { colOf, findPath, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts";
4445 import { CS_HULL } from "./movement.ts";
4546 import { observe, OBS_SIZE, type Memory } from "./observe.ts";
@@ -125,6 +126,8 @@ export type BotBrain = {
125126 /** The plan the job was cut from, and the round it was cut for. */
126127 jobPlan: RoundPlan | null;
127128 jobRound: number;
129 /** The radio call the job was cut under, or null for the plan on its own. */
130 jobCall: RadioCall | null;
128131 /** The clock the job's wait is up at. Set while the round is still frozen. */
129132 pushAt: number;
130133 reset: boolean;
@@ -164,7 +167,7 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
164167 goal: null, path: [], step: 0, repathAt: 0,
165168 targetId: null, seenAt: 0, burstUntil: 0, restUntil: 0,
166169 strafe: 0, strafeUntil: 0, suspect: null, plannedFor: "",
167 job: DEFAULT_JOB, jobPlan: null, jobRound: -1, pushAt: 0,
170 job: DEFAULT_JOB, jobPlan: null, jobRound: -1, jobCall: null, pushAt: 0,
168171 reset: true,
169172 random: rng(seed),
170173 obs: new Float32Array(OBS_SIZE),
@@ -378,19 +381,27 @@ function brainOf(world: World, bot: Player): BotBrain {
378381 */
379382 function jobOf(world: World, bot: Player, brain: BotBrain): Job {
380383 const plan = world.plan;
381 if (plan === null) return brain.job;
382 // The plan itself is the stamp: a new one is a new object, and a bot holding
383 // a job cut from another one is a bot with yesterday's orders.
384 if (brain.jobPlan === plan && brain.jobRound === world.match.round) return brain.job;
384 // A call from this side stands in front of the plan while it is live, and
385 // is null again the tick it expires — which is what re-cuts the jobs from
386 // the plan alone: the side falls back to the roll without anybody clearing it.
387 const call = liveCall(world.radio, bot.team, world.time, world.match.round);
388 if (plan === null && call === null) return brain.job;
389 // The plan and the call are the stamp: a new one is a new object, and a bot
390 // holding a job cut from another one is a bot with yesterday's orders.
391 if (brain.jobPlan === plan && brain.jobCall === call && brain.jobRound === world.match.round) return brain.job;
385392 const mates = world.players.filter((p) => p.team === bot.team);
386393 const brains = mates.map((mate) => brainOf(world, mate));
387 const jobs = assign(plan[bot.team], bot.team, mates.map((mate, at) => ({
394 // Nobody planned this world but somebody called: the side goes on the call
395 // from the site this bot had already flipped for.
396 const team: TeamPlan = plan?.[bot.team] ?? { site: brain.job.site, shape: "stack", tempo: 1, save: false };
397 const jobs = heed(assign(steer(team, call?.call ?? null), bot.team, mates.map((mate, at) => ({
388398 id: mate.id,
389399 skill: brains[at]?.skill ?? 0,
390 })));
400 }))), call, world.time);
391401 brains.forEach((mate, at) => {
392402 mate.job = jobs[at] ?? DEFAULT_JOB;
393403 mate.jobPlan = plan;
404 mate.jobCall = call;
394405 mate.jobRound = world.match.round;
395406 // The wait runs from the moment the round goes live, and the freeze time is
396407 // still running: `botThink` pushes this forward on every frozen tick.
@@ -685,6 +696,7 @@ export function botThink(world: World, bot: Player, dt: number): Input {
685696 // arrived, replaces this on the next line and for the rest of the round.
686697 brain.job = { ...DEFAULT_JOB, site: brain.random() < 0.5 ? "A" : "B" };
687698 brain.jobPlan = null;
699 brain.jobCall = null;
688700 brain.pushAt = 0;
689701 brain.goal = null;
690702 brain.path = [];
src/client/main.test.ts +21
@@ -1906,3 +1906,24 @@ test("a page in a room sends nothing to /api/match, through every round and the
19061906 expect(stage.by("summary").hidden).toBe(false);
19071907 expect(stage.by("sumsaved").textContent).toBe(ROOM_SAVED);
19081908 });
1909
1910 test("Z is a call on the radio: the HUD says who said it, your side hears it, and the bots go", () => {
1911 const stage = playing();
1912 stage.step(FRAME);
1913 const world = worldOf(stage);
1914 if (world === null) throw new Error("the first frame drew nothing");
1915 stage.press("KeyZ");
1916 expect(world.radio?.call).toBe("A");
1917 expect(world.radio?.side).toBe(world.me.team);
1918 expect(world.messages.at(-1)?.text).toBe(`Radio — ${world.me.name}: go A`);
1919 stage.step(FRAME);
1920 // Your own call, in your own ear: `own` because the body that made it is yours.
1921 expect(stage.sounds).toContain("sound:radio:own");
1922 const mates = world.players.filter((p) => p.team === world.me.team && p !== world.me);
1923 expect(mates.map((p) => p.brain?.job.site)).toEqual(mates.map(() => "A"));
1924 // Q and H are the other two words, and the latest call is the one standing.
1925 stage.press("KeyQ");
1926 expect(world.radio?.call).toBe("B");
1927 stage.press("KeyH");
1928 expect(world.radio?.call).toBe("hold");
1929 });
src/client/main.ts +23 −1
@@ -97,6 +97,8 @@ import { boardHtml, headline, resultOf, scoreLine, summarize, yourLine } from ".
9797 import { drawPreview, previewText } from "./preview.ts";
9898 import { buildScene, createScene, SKY, type Scene } from "./scene.ts";
9999 import { usePlan, usePolicy } from "./bots.ts";
100 import { callOf, hears, radio } from "./radio.ts";
101 import type { Call } from "../plan/call.ts";
100102 import { createSpectator, cycle, follow } from "./spectate.ts";
101103 import { WEAPONS } from "./weapons.ts";
102104 import { drawBuy, HINT, labelOf, offers } from "./buy.ts";
@@ -152,6 +154,8 @@ export const NO_POLICY =
152154 /** What the menu says when this browser cannot open a socket at all. */
153155 export const NO_SOCKET = "This browser has no WebSocket, so it cannot join a room. The match above still plays.";
154156 /** And what it says when a room could not be made or joined. */
157 /** What a radio key says in a room, whose server does not take calls yet. */
158 export const NO_RADIO = "The radio is for matches against bots — a room's server does not take calls yet.";
155159 export const NO_ROOM = "That room could not be joined — check the code, or make a new room.";
156160 export const NO_MAKE = "A room could not be made right now. Try again in a moment.";
157161 /**
@@ -898,6 +902,19 @@ export function boot(host: Host): Frame {
898902 resumeButton.focus();
899903 }
900904
905 /**
906 * A call on the radio. A room's round is stepped on the server and its wire
907 * carries no calls yet, so there the key says so rather than pretending; in
908 * a match against bots it is `radio`, which the bots on your side hear.
909 */
910 function pressRadio(call: Call): void {
911 if (world === null || !running) return;
912 if (session !== null) return showMessage(world, NO_RADIO);
913 // Played here rather than off the ring: the call is stamped at the clock
914 // the last frame already heard up to, so `playSounds` would pass it by.
915 if (radio(world, world.me, call) !== null) audio.play("radio", world.me.pos, eyePos(world.me), true);
916 }
917
901918 /** What Escape and P both mean: close the buy menu if it is open, else pause. */
902919 function pauseKey(): void {
903920 if (!buyPanel.hidden) return openBuy(false);
@@ -912,6 +929,8 @@ export function boot(host: Host): Frame {
912929 if (action === "rebuy") return pressRebuy();
913930 if (action === "pause") return pauseKey();
914931 if (action === "fullscreen") return askFull();
932 const call = callOf(action);
933 if (call !== null) return pressRadio(call);
915934 // The number row, while the shop is open: one key, one tile. The same keys
916935 // are the weapon pockets with the panel closed, and `readIntent` is what
917936 // does that — the frame suppresses the switch while the panel is up, so a
@@ -1468,8 +1487,11 @@ export function boot(host: Host): Frame {
14681487 const effect = effectAt(current.effects, i);
14691488 if (effect === undefined || effect.at <= lastSound) continue;
14701489 if (effect.kind === "shot") audio.shot(effect.weapon, effect.pos, eyePos(ears), ears.yaw);
1471 else if (effect.kind === "sound")
1490 else if (effect.kind === "sound") {
1491 // A call is on its side's channel: the other side never hears it.
1492 if (effect.name === "radio" && !hears(current, effect.by, ears)) continue;
14721493 audio.play(effect.name, effect.pos, eyePos(ears), effect.by === ears.id, ears.yaw);
1494 }
14731495 else if (effect.kind === "hitmark") audio.hit(effect.headshot);
14741496 }
14751497 lastSound = current.time;
src/client/radio.test.ts added +71
@@ -0,0 +1,71 @@
1 import { expect, test } from "bun:test";
2 import { effectAt } from "../engine/index.ts";
3 import { DE_FORGE } from "./map.ts";
4 import { COMPETITIVE } from "./match.ts";
5 import { callOf, hears, radio, RADIO_ACTIONS } from "./radio.ts";
6 import { createWorld, type Player, type World } from "./world.ts";
7
8 const live = (): World => {
9 const world = createWorld(DE_FORGE, COMPETITIVE, "Vesper", "ct", 5, 11);
10 world.match.phase = "live";
11 world.time = 30;
12 return world;
13 };
14
15 const radioSounds = (world: World) => {
16 const found = [];
17 for (let i = 0; i < world.effects.count; i++) {
18 const effect = effectAt(world.effects, i);
19 if (effect?.kind === "sound" && effect.name === "radio") found.push(effect);
20 }
21 return found;
22 };
23
24 test("an action is a call only when it is one of the radio's three", () => {
25 expect(Object.keys(RADIO_ACTIONS)).toEqual(["goA", "goB", "hold"]);
26 expect(callOf("goA")).toBe("A");
27 expect(callOf("goB")).toBe("B");
28 expect(callOf("hold")).toBe("hold");
29 for (const other of ["fire", "buy", "toString", "__proto__", "constructor", ""]) expect(callOf(other)).toBeNull();
30 });
31
32 test("a call stamps the world, says who said what, and is heard once on the caller's channel", () => {
33 const world = live();
34 const made = radio(world, world.me, "A");
35 expect(made).toEqual({ side: "ct", call: "A", by: "Vesper", at: 30, round: world.match.round });
36 expect(world.radio).toBe(made);
37 expect(world.messages.at(-1)?.text).toBe("Radio — Vesper: go A");
38 const sounds = radioSounds(world);
39 expect(sounds).toHaveLength(1);
40 expect(sounds[0]?.by).toBe(world.me.id);
41 });
42
43 test("only the side that called hears the call", () => {
44 const world = live();
45 radio(world, world.me, "B");
46 const mate = world.players.find((p) => p.team === "ct" && p !== world.me) as Player;
47 const enemy = world.players.find((p) => p.team === "t") as Player;
48 expect(hears(world, world.me.id, world.me)).toBe(true);
49 expect(hears(world, world.me.id, mate)).toBe(true);
50 expect(hears(world, world.me.id, enemy)).toBe(false);
51 expect(hears(world, -1, mate)).toBe(false); // nobody's call is nobody's channel
52 });
53
54 test("a corpse has no radio, and nor does the gap between rounds", () => {
55 const dead = live();
56 dead.me.alive = false;
57 expect(radio(dead, dead.me, "A")).toBeNull();
58 expect(dead.radio).toBeNull();
59 expect(radioSounds(dead)).toHaveLength(0);
60
61 for (const phase of ["over", "match_over"] as const) {
62 const world = live();
63 world.match.phase = phase;
64 expect(radio(world, world.me, "hold")).toBeNull();
65 expect(world.radio).toBeNull();
66 }
67 // The freeze is fine: a call made while buying is the plan for the round.
68 const frozen = live();
69 frozen.match.phase = "freeze";
70 expect(radio(frozen, frozen.me, "hold")?.call).toBe("hold");
71 });
src/client/radio.ts added +45
@@ -0,0 +1,45 @@
1 /**
2 * Making a call on the radio from inside a round: who may, what it stamps on
3 * the world, and who hears it. What a call *does* to the bots is
4 * `src/plan/call.ts`'s — this only puts it where `src/client/bots.ts` reads it.
5 *
6 * A call goes three places at once: `world.radio`, which the bots on the
7 * caller's side read in front of the round's plan; the message line, which
8 * says what was called and by whom; and the effects ring as a `radio` noise,
9 * which `hears` below lets through to the caller's side and nobody else.
10 */
11
12 import { callLine, type Call, type RadioCall } from "../plan/call.ts";
13 import { heard, say, type Player, type World } from "./world.ts";
14
15 /** The actions that make a call, in the order the pad and the table list them. */
16 export const RADIO_ACTIONS: Record<string, Call> = { goA: "A", goB: "B", hold: "hold" };
17
18 /** The call an action makes, or null for an action that is not a call. */
19 export const callOf = (action: string): Call | null =>
20 Object.hasOwn(RADIO_ACTIONS, action) ? (RADIO_ACTIONS[action] as Call) : null;
21
22 /**
23 * Make `call` as `player`. Refused — null, nothing changed — when the caller is
24 * dead or the round is over: a corpse has no radio, and a call into the gap
25 * between rounds would be orders for a round nobody has planned yet.
26 */
27 export function radio(world: World, player: Player, call: Call): RadioCall | null {
28 const phase = world.match.phase;
29 if (!player.alive || phase === "over" || phase === "match_over") return null;
30 const made: RadioCall = { side: player.team, call, by: player.name, at: world.time, round: world.match.round };
31 world.radio = made;
32 say(world, callLine(made));
33 heard(world, player.pos, "radio", player.id);
34 return made;
35 }
36
37 /**
38 * Whether a `radio` noise made by body `by` reaches `ears`. Only the caller's
39 * own side has the channel; a body that is not in the world any more is
40 * nobody's side, and is not heard.
41 */
42 export function hears(world: World, by: number, ears: Player): boolean {
43 const caller = world.players.find((p) => p.id === by);
44 return caller !== undefined && caller.team === ears.team;
45 }
src/client/replay.test.ts +66 −1
@@ -32,7 +32,7 @@ import { DE_FORGE } from "./map.ts";
3232 import { SHORT } from "./match.ts";
3333 import { CHECKPOINT_CHARS } from "../engine/index.ts";
3434 import {
35 compare, digest, fold, reader, snapshot, SMOKE_SCRIPT, SWITCH_SCRIPT, TICK, TICKS_PER_SECOND,
35 CALL_SCRIPT, compare, digest, fold, reader, snapshot, SMOKE_SCRIPT, SWITCH_SCRIPT, TICK, TICKS_PER_SECOND,
3636 withoutEntropy, type Pin, type Run,
3737 } from "./replay.ts";
3838 import { colOf, rowOf, walkable, type Level } from "./level.ts";
@@ -41,6 +41,9 @@ import { cloudRadius } from "./grenade.ts";
4141 import { SMOKE, WEAPONS } from "./weapons.ts";
4242 import { createArena, reset, stepInput } from "../rl/arena.ts";
4343 import { useShipped } from "./shipped.ts";
44 import { usePlan } from "./bots.ts";
45 import { localPlan, type RoundPlan } from "../plan/plan.ts";
46 import { CALL_LIFE } from "../plan/call.ts";
4447
4548 /**
4649 * The bots in every run below think with the policy this repository ships, and
@@ -392,6 +395,68 @@ test("the switching round has the deploy time in it, at the ticks the script say
392395 expect(seen.shots.length).toBeGreaterThan(0); // and shots did go out
393396 });
394397
398 /**
399 * Thirty-six seconds of the same round with the radio in it: seed 7 on
400 * de_forge, played under the local plan the page would have handed the bots,
401 * and "go B" said a second into the live round. `CALL_SCRIPT` in `replay.ts`
402 * says what the person does. The call stands for twenty seconds, so the run
403 * holds all three things the radio claims: the side goes, it goes for as long
404 * as the call stands, and it falls back to the roll when the call expires.
405 */
406 const CALL_SECONDS = 36;
407 const CALL_TICKS = CALL_SECONDS * TICKS_PER_SECOND;
408 const CALL_ROUND = "828091f29089e31bd17f3b3549b05c85327930ad6368614a14d2ff2bd8416cfd";
409 const CALL_ROUND_SECONDS: readonly string[] = [
410 "c858433691b7", "d46389733924", "1d2038791546", "318d01ffb6a5",
411 "0fab6fec7329", "46cbc192435f", "db863794da65", "122780c9b3fd",
412 "b6f005c36423", "49d097b3698c", "a6de3425b6d8", "138be74dc011",
413 "c325823abf65", "517634f7f427", "c257162dec7e", "2933f51759b9",
414 "9afe21de6d31", "73b6e9fb0567", "7a67bcd388ed", "019a9e598777",
415 "9262f6692cd5", "519b45d402cc", "fd7fdbfe1bda", "3316a0f59e5d",
416 "e6591caca140", "c0c5fc016344", "72d252b0a437", "258ab07ee55d",
417 "aed96d88bb34", "01acf05576c3", "2a0859dca917", "fb94dd78506e",
418 "d9f40dcc801e", "a68390985a6a", "f95a721e4f33", "feae8cb528af",
419 ];
420
421 const CALL_PIN: Pin = {
422 label: "round with a radio call in it", constant: "CALL_ROUND",
423 digest: CALL_ROUND, seconds: CALL_ROUND_SECONDS,
424 };
425
426 /** The plan the page would hand round one of this match. */
427 const ROUND_ONE: RoundPlan = localPlan({
428 map: "de_forge", round: 1, score: { t: 0, ct: 0 }, money: { t: 800, ct: 800 }, lastWinner: null,
429 });
430
431 /** The planned round, called or not: the same script, with or without its call. */
432 const plannedRun = (script: typeof CALL_SCRIPT): Run =>
433 withoutEntropy(() => {
434 const world = createWorld(DE_FORGE, SHORT, "me", "ct", 5, 7);
435 usePlan(world, ROUND_ONE);
436 return digest(world, CALL_TICKS, script);
437 });
438
439 test("a seeded round with a radio call in it plays exactly as it played", () => {
440 const run = plannedRun(CALL_SCRIPT);
441 must(run, CALL_PIN);
442 expect(run.digest).toBe(CALL_ROUND);
443 expect(run.ticks).toBe(CALL_TICKS);
444 expect(run.seconds).toEqual([...CALL_ROUND_SECONDS]);
445 });
446
447 test("the call is what moves the round: the same plan unsaid is the same round until it is said", () => {
448 // The rolled plan sends the defenders' side somewhere other than all-on-B, or
449 // this pin would pin nothing.
450 expect(ROUND_ONE.ct.shape === "stack" && ROUND_ONE.ct.site === "B").toBe(false);
451 const unsaid = plannedRun(CALL_SCRIPT.map(({ call: _, ...beat }) => beat));
452 const said = plannedRun(CALL_SCRIPT);
453 // The call goes out on tick 576, second 9: everything before it is one round.
454 expect(said.seconds.slice(0, 9)).toEqual(unsaid.seconds.slice(0, 9));
455 expect(said.digest).not.toBe(unsaid.digest);
456 // And the run is long enough to see it expire: 576 ticks plus the call's life.
457 expect(CALL_TICKS).toBeGreaterThan(576 + CALL_LIFE * TICKS_PER_SECOND);
458 });
459
395460 test("a seeded duel in the training arena plays exactly as it played", () => {
396461 const run = duelRun();
397462 must(run, DUEL_PIN);
src/client/replay.ts +33 −2
@@ -22,6 +22,8 @@
2222
2323 import { compare as complain, fold as foldTicks, quantise } from "../engine/index.ts";
2424 import { buy, type BuyItem } from "./economy.ts";
25 import { radio } from "./radio.ts";
26 import type { Call } from "../plan/call.ts";
2527 import { NO_INPUT, update, type Input, type Player, type World } from "./world.ts";
2628 import { cloudRadius, type Cloud, type Grenade } from "./grenade.ts";
2729 import type { ThrownId } from "./weapons.ts";
@@ -129,9 +131,10 @@ export function snapshot(world: World): string {
129131 * One beat of the run: from `atTick` on, this is the whole input the person is
130132 * holding, exactly as a keyboard holds it until a key goes up. `buy` is the
131133 * one thing a person does that is not an Input — it happens once, on that
132 * tick, and the world refuses it outside the freeze.
134 * tick, and the world refuses it outside the freeze. `call` is the other: a
135 * radio call made once, on that tick, the way a key press makes one.
133136 */
134 export type Beat = { atTick: number; input: Partial<Input>; buy?: BuyItem };
137 export type Beat = { atTick: number; input: Partial<Input>; buy?: BuyItem; call?: Call };
135138
136139 /**
137140 * Forty seconds of somebody playing: eight seconds of freeze with a buy in
@@ -263,6 +266,33 @@ export const SWITCH_SCRIPT: Beat[] = [
263266 { atTick: 1600, input: { forward: 1 } },
264267 ];
265268
269 /**
270 * Thirty seconds with a radio call in them. Every script above leaves the bots
271 * to the round's plan; this one takes it away from them once. The person is a
272 * counter-terrorist: they walk out, and a second into the live round they say
273 * "go B" — so four defenders who were rolled onto their sites all go to B, at
274 * once, for the twenty seconds the call stands, and then fall back to the roll
275 * for the rest of the run. The run is played under a plan (`usePlan` with the
276 * local one, which is what the page does every round), because a call is a
277 * thing said in front of a plan and a round nobody planned is not the round
278 * this pins.
279 */
280 export const CALL_SCRIPT: Beat[] = [
281 { atTick: 0, input: {} },
282 { atTick: 96, input: {}, buy: "armor" },
283 { atTick: 512, input: { forward: 1 } }, // the round goes live
284 { atTick: 576, input: { forward: 1 }, call: "B" }, // one press of the key
285 { atTick: 704, input: { forward: 1, side: 1 } },
286 { atTick: 960, input: { fire: true } },
287 { atTick: 976, input: {} },
288 { atTick: 1088, input: { reload: true } },
289 { atTick: 1096, input: { forward: 1 } },
290 { atTick: 1408, input: { forward: 1, duck: true } },
291 { atTick: 1536, input: { forward: -1 } },
292 { atTick: 1792, input: { side: 1, walk: true } },
293 { atTick: 1920, input: {} },
294 ];
295
266296 /**
267297 * A cursor over the script: hand it a tick and it returns the input held at
268298 * that tick, taking any buy on the way past. The input is a fresh object each
@@ -276,6 +306,7 @@ export function reader(script: readonly Beat[] = SCRIPT): (tick: number, world:
276306 const beat = script[at++] as Beat;
277307 input = { ...NO_INPUT, ...beat.input };
278308 if (beat.buy !== undefined) buy(world, player, beat.buy);
309 if (beat.call !== undefined) radio(world, player, beat.call);
279310 }
280311 return input;
281312 };
src/client/world.ts +12 −3
@@ -26,6 +26,7 @@ import {
2626 } from "./grenade.ts";
2727 import type { Level } from "./level.ts";
2828 import type { RoundPlan } from "../plan/plan.ts";
29 import type { RadioCall } from "../plan/call.ts";
2930 import {
3031 angleVectors, angleDelta, clamp, createEffects, dist, expireEffects, eyeHeight, hullHeight, move,
3132 normalize, pushEffect, rng, segmentSphere, sub, visible,
@@ -110,8 +111,10 @@ export type Player = Actor & Body & {
110111 * carries who made it and `audio.ts` decides how loud that is.
111112 *
112113 * `beep` has no body behind it: it is the bomb, and `by` is -1 for it.
114 * `radio` is a call going out: heard by the caller's side and nobody else,
115 * which the frame decides from `by` (`src/client/radio.ts`).
113116 */
114 export type Sound = GrenadeSound | "step" | "beep" | "plant" | "defuse" | "death";
117 export type Sound = GrenadeSound | "step" | "beep" | "plant" | "defuse" | "death" | "radio";
115118
116119 export type Effect =
117120 | { kind: "tracer"; from: Vec3; to: Vec3; at: number }
@@ -239,6 +242,12 @@ export type World = {
239242 * `src/client/bots.ts` puts it here (`usePlan`) and reads it back.
240243 */
241244 plan: RoundPlan | null;
245 /**
246 * The last call anybody made on the radio, or null. It stands in front of
247 * `plan` for its own side while it is live (`src/plan/call.ts`) and is simply
248 * ignored once it is not, so nothing has to clear it.
249 */
250 radio: RadioCall | null;
242251 random: () => number;
243252 /**
244253 * The map this round is played on, whole. Everything that needs to know where
@@ -319,7 +328,7 @@ export function createWorld(
319328 match, players, me, time: 0,
320329 bomb: newBomb(),
321330 effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], clouds: [],
322 feed: [], messages: [], plan: null,
331 feed: [], messages: [], plan: null, radio: null,
323332 random, level, bombPlanted: false, pendingBuy: false,
324333 };
325334 startRound(world);
@@ -340,7 +349,7 @@ const record = (world: World, e: Effect): void => pushEffect(world.effects, e, e
340349 * moves on the next tick, and an effect the frame reads later must be where the
341350 * noise was and not where the body is now.
342351 */
343 const heard = (world: World, pos: Vec3, name: Sound, by: number): void => {
352 export const heard = (world: World, pos: Vec3, name: Sound, by: number): void => {
344353 record(world, { kind: "sound", at: world.time, pos: [pos[0], pos[1], pos[2]], name, by });
345354 };
346355
src/pages/game.ts +4
@@ -579,6 +579,10 @@ export function gamePage(
579579 { action: "duck", label: "crouch" },
580580 { action: "jump", label: "jump" },
581581 { action: "use", label: "use" },
582 // The radio: a call to your own side, which the bots on it follow.
583 { action: "goA", label: "go A" },
584 { action: "goB", label: "go B" },
585 { action: "hold", label: "hold" },
582586 ],
583587 [
584588 { action: "reload", label: "reload" },
src/plan/call.test.ts added +55
@@ -0,0 +1,55 @@
1 import { expect, test } from "bun:test";
2 import { CALL_LIFE, CALLS, callLabel, callLeft, callLine, heed, isCall, liveCall, parseCall, steer, type RadioCall } from "./call.ts";
3 import { assign, TEMPO_MAX, type TeamPlan } from "./plan.ts";
4
5 const rolled: TeamPlan = { site: "A", shape: "split", tempo: 1, save: true };
6 const said = (over: Partial<RadioCall> = {}): RadioCall => ({ side: "t", call: "B", by: "you", at: 10, round: 3, ...over });
7
8 test("three words, and nothing else gets past the boundary", () => {
9 expect([...CALLS]).toEqual(["A", "B", "hold"]);
10 for (const call of CALLS) expect(parseCall(call)).toBe(call);
11 for (const junk of ["a", "b", "HOLD", "", "C", null, undefined, 1, {}, ["A"], "__proto__"]) {
12 expect(isCall(junk)).toBe(false);
13 expect(parseCall(junk)).toBeNull();
14 }
15 });
16
17 test("a call is live for its own side, this round, for CALL_LIFE seconds and not a tick more", () => {
18 const radio = said();
19 expect(liveCall(radio, "t", 10, 3)).toBe(radio);
20 expect(liveCall(radio, "t", 10 + CALL_LIFE, 3)).toBe(radio);
21 expect(liveCall(radio, "t", 10 + CALL_LIFE + 0.01, 3)).toBeNull();
22 expect(liveCall(radio, "ct", 12, 3)).toBeNull(); // the other side never hears it
23 expect(liveCall(radio, "t", 12, 4)).toBeNull(); // nor does the next round
24 expect(liveCall(radio, "t", 9, 3)).toBeNull(); // nor a clock before it was said
25 expect(liveCall(null, "t", 12, 3)).toBeNull();
26 expect(callLeft(radio, 15)).toBe(CALL_LIFE - 5);
27 expect(callLeft(radio, 100)).toBe(0);
28 });
29
30 test("a site call is the whole side on that site, now; a hold leaves the plan as it was", () => {
31 expect(steer(rolled, "B")).toEqual({ site: "B", shape: "stack", tempo: TEMPO_MAX, save: true });
32 expect(steer(rolled, "hold")).toBe(rolled);
33 expect(steer(rolled, null)).toBe(rolled);
34 });
35
36 test("heeding a site call puts every job on it, the defenders' lurker too", () => {
37 const roster = [0, 1, 2, 3, 4].map((id) => ({ id, skill: id / 5 }));
38 const jobs = assign(steer(rolled, "B"), "ct", roster);
39 expect(jobs.some((job) => job.site === "A")).toBe(true); // a stack still sends a lurker away
40 expect(heed(jobs, said({ side: "ct" }), 12).every((job) => job.site === "B")).toBe(true);
41 expect(heed(jobs, null, 12)).toBe(jobs);
42 });
43
44 test("heeding a hold stretches every wait to the call's end, and shortens none", () => {
45 const jobs = assign(rolled, "t", [0, 1, 2, 3, 4].map((id) => ({ id, skill: id / 5 })));
46 const held = heed(jobs, said({ call: "hold" }), 15);
47 expect(held.map((job) => job.delay)).toEqual(jobs.map((job) => Math.max(job.delay, CALL_LIFE - 5)));
48 expect(held.map((job) => job.site)).toEqual(jobs.map((job) => job.site));
49 });
50
51 test("the HUD's words say what was called and by whom", () => {
52 expect(callLabel("A")).toBe("go A");
53 expect(callLabel("hold")).toBe("hold");
54 expect(callLine(said({ by: "Vesper" }))).toBe("Radio — Vesper: go B");
55 });
src/plan/call.ts added +93
@@ -0,0 +1,93 @@
1 /**
2 * The radio: three things a person can say to their own side, and how long the
3 * side listens. Plain data in, plain data out, beside the plan it overrides —
4 * `plan.ts` decides a round, and a call stands in front of that decision for
5 * `CALL_LIFE` seconds and then falls back to it.
6 *
7 * The bots never learn where their orders came from. `src/client/bots.ts` cuts
8 * jobs out of whatever `steer` and `heed` hand it, exactly as it cuts them out
9 * of a rolled plan, so a call only has to change what the plan says.
10 */
11
12 import { TEMPO_MAX, type Job, type Side, type SiteName, type TeamPlan } from "./plan.ts";
13
14 /**
15 * Take A, take B, or stop where you are. Three words is the whole vocabulary:
16 * enough to move four teammates, few enough that each one is a key.
17 */
18 export type Call = SiteName | "hold";
19
20 export const CALLS: readonly Call[] = ["A", "B", "hold"];
21
22 /** True for the three words above and nothing else — the boundary's check. */
23 export const isCall = (value: unknown): value is Call => CALLS.includes(value as Call);
24
25 /** One call as it was made: which side, what, by whom, and when in which round. */
26 export type RadioCall = {
27 side: Side;
28 call: Call;
29 /** The caller's name, for the HUD. Printed through the page's escaping, never trusted. */
30 by: string;
31 /** World clock when it was said. */
32 at: number;
33 round: number;
34 };
35
36 /**
37 * Seconds a call stands before the rolled plan has it back. Long enough to
38 * cross de_forge and set up on the site that was called, short enough that a
39 * rotation shouted at the start of a round is not still steering the side once
40 * the round has moved on without it.
41 */
42 export const CALL_LIFE = 20;
43
44 /** The call this side is under at `now`, or null: the other side never hears it. */
45 export function liveCall(radio: RadioCall | null, side: Side, now: number, round: number): RadioCall | null {
46 if (radio === null || radio.side !== side || radio.round !== round) return null;
47 if (now < radio.at || now - radio.at > CALL_LIFE) return null;
48 return radio;
49 }
50
51 /** Seconds this call has left, never below zero. */
52 export const callLeft = (radio: RadioCall, now: number): number => Math.max(0, radio.at + CALL_LIFE - now);
53
54 /** What a call reads as: "go A", "go B", "hold". */
55 export const callLabel = (call: Call): string => (call === "hold" ? "hold" : `go ${call}`);
56
57 /** The HUD's line for a call: who said what. */
58 export const callLine = (radio: RadioCall): string => `Radio — ${radio.by}: ${callLabel(radio.call)}`;
59
60 /**
61 * A side's plan with a call in front of it. A site call is the whole side on
62 * that site, at once: a stack, at the fastest tempo, because a person who says
63 * "go B" means now and means everyone. A hold leaves the plan alone — where to
64 * go afterwards is still the roll's — and `heed` is what stops the side.
65 */
66 export function steer(plan: TeamPlan, call: Call | null): TeamPlan {
67 if (call === null || call === "hold") return plan;
68 return { ...plan, site: call, shape: "stack", tempo: TEMPO_MAX };
69 }
70
71 /**
72 * The jobs `assign` cut, with the call's last word on them. A site call puts
73 * every bot on that site — a stack still sends a defender's lurker to the other
74 * one, and four teammates who go means four. A hold keeps each bot where it is
75 * for what is left of the call: the wait the bots already have, stretched to the
76 * call's end, and the bomb carrier is never held because a bomb never waits.
77 */
78 export function heed(jobs: Job[], radio: RadioCall | null, now: number): Job[] {
79 if (radio === null) return jobs;
80 const call = radio.call;
81 if (call === "hold") {
82 const left = callLeft(radio, now);
83 return jobs.map((job) => ({ ...job, delay: Math.max(job.delay, left) }));
84 }
85 return jobs.map((job) => ({ ...job, site: call }));
86 }
87
88 /**
89 * A call as it arrives from outside the simulation — a key, a pad, a script —
90 * or null. The only field a person chooses is the word; the rest is stamped by
91 * whoever makes the call from the world it is made in.
92 */
93 export const parseCall = (value: unknown): Call | null => (isCall(value) ? value : null);
src/rooms/wire.ts +4
@@ -64,6 +64,10 @@ export const PHASES: readonly Phase[] = ["freeze", "live", "planted", "over", "m
6464 export const BOMB_STATES: readonly BombState[] = ["carried", "dropped", "planted", "defused", "detonated"];
6565 export const SOUNDS: readonly Sound[] = [
6666 "beep", "bounce", "blast", "death", "defuse", "flash", "plant", "smoke", "step", "throw",
67 // Appended rather than sorted in: sorted, it would renumber the three after
68 // it for a page still open on the previous deploy, which is the thing the
69 // order above exists to prevent. A room carries no calls yet anyway.
70 "radio",
6771 ];
6872
6973 /** The widest frame this module will ever write, so the buffer is made once. */

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs