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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs-engine:vesper/290-radio-three-calls-a-person-makes-and-fou → main 2 commits fork
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 | ||
| 73 | 73 | process; over either, or on any failure, the round gets the local plan, which is |
| 74 | 74 | a plan and not an error. |
| 75 | 75 | |
| 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 | ||
| 76 | 93 | ## What is real and what is not |
| 77 | 94 | |
| 78 | 95 | - **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"; | ||
| 6 | 6 | import type { Sound } from "./world.ts"; |
| 7 | 7 | |
| 8 | 8 | /** |
| 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 | |
| 11 | 11 | * here walk all of them and a name missing from one of those lists is a recipe |
| 12 | 12 | * nobody checks — `smoke` was exactly that until the budget below wanted it. |
| 13 | 13 | */ |
| @@ -15,6 +15,9 @@ const SOUNDS: Sound[] = [ | ||
| 15 | 15 | "step", "beep", "plant", "defuse", "death", "throw", "bounce", "blast", "flash", "smoke", |
| 16 | 16 | ]; |
| 17 | 17 | |
| 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 | ||
| 18 | 21 | /** A device that plays nothing and writes down everything. */ |
| 19 | 22 | function recorder(): SoundDevice & { bursts: Burst[]; tones: Tone[]; resumes: number } { |
| 20 | 23 | const device = { |
| @@ -113,6 +116,19 @@ test("every noise a round makes has a recipe, and a far one is quieter than a ne | ||
| 113 | 116 | } |
| 114 | 117 | }); |
| 115 | 118 | |
| 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 | ||
| 116 | 132 | // --- the budget ----------------------------------------------------------- |
| 117 | 133 | |
| 118 | 134 | /** 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 { | ||
| 168 | 168 | // Your own noise is not placed: it happens at the ear, so there is no |
| 169 | 169 | // side for it to be on and no panner worth building for it. |
| 170 | 170 | 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 | } | |
| 171 | 180 | if (sound === "step") { |
| 172 | 181 | // A foot landing: a scuff of grit through a wide filter with a soft |
| 173 | 182 | // 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 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | 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"; | |
| 3 | 5 | |
| 4 | 6 | const NONE = { x: 0, y: 0 }; |
| 5 | 7 | 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", () => { | ||
| 35 | 37 | expect(CS_WHEEL.up).toBe("jump"); |
| 36 | 38 | expect(CS_WHEEL.down).toBe("jump"); |
| 37 | 39 | 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"]); | |
| 39 | 41 | // F is the page's, not the world's: `main.ts` hears it on `onAction` and asks |
| 40 | 42 | // the browser for the screen, and nothing about an `Input` changes for it. |
| 41 | 43 | expect(CS_KEYS.KeyF).toBe("fullscreen"); |
| @@ -137,3 +139,14 @@ test("three beats two beats one", () => { | ||
| 137 | 139 | test("an action the game does not know is not an input", () => { |
| 138 | 140 | expect(intent(["buy", "nonsense"]).input).toEqual(intent([]).input); |
| 139 | 141 | }); |
| 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> = { | ||
| 57 | 57 | // Nor is this one: X buys last round's loadout again, which is money and not |
| 58 | 58 | // movement, so `main.ts` hears it and `readIntent` never does. |
| 59 | 59 | 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", | |
| 60 | 66 | }; |
| 61 | 67 | |
| 62 | 68 | /** |
| @@ -82,7 +88,7 @@ export const CS_MOUSE: Record<number, string> = { | ||
| 82 | 88 | export const CS_WHEEL: Record<string, string> = { up: "jump", down: "jump" }; |
| 83 | 89 | |
| 84 | 90 | /** 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; | |
| 86 | 92 | |
| 87 | 93 | export type Intent = { |
| 88 | 94 | input: Input; |
src/client/bots.test.ts +58
| @@ -5,6 +5,8 @@ import { | ||
| 5 | 5 | applyIntent, blindness, botBuy, botThink, dry, flashed, makeBrain, nextWeapon, NO_POLICY, pathTo, policy, |
| 6 | 6 | ride, roundPlan, think, usePlan, usePolicy, |
| 7 | 7 | } from "./bots.ts"; |
| 8 | import { radio } from "./radio.ts"; | |
| 9 | import { CALL_LIFE } from "../plan/call.ts"; | |
| 8 | 10 | import type { RoundPlan, TeamPlan } from "../plan/plan.ts"; |
| 9 | 11 | import { grip } from "./spray.ts"; |
| 10 | 12 | 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", () => { | ||
| 520 | 522 | expect(bots(world, "t").every((bot) => bot.brain?.job.site === "B")).toBe(true); |
| 521 | 523 | expect(roundPlan(world)?.t.site).toBe("B"); |
| 522 | 524 | }); |
| 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 { | ||
| 39 | 39 | angleDelta, clamp, dist, forward, newActivations, rng, sample, |
| 40 | 40 | type Activations, type Policy, type Vec3, |
| 41 | 41 | } 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"; | |
| 43 | 44 | import { colOf, findPath, rowOf, siteAt, walkable, worldOf, type Level } from "./level.ts"; |
| 44 | 45 | import { CS_HULL } from "./movement.ts"; |
| 45 | 46 | import { observe, OBS_SIZE, type Memory } from "./observe.ts"; |
| @@ -125,6 +126,8 @@ export type BotBrain = { | ||
| 125 | 126 | /** The plan the job was cut from, and the round it was cut for. */ |
| 126 | 127 | jobPlan: RoundPlan | null; |
| 127 | 128 | jobRound: number; |
| 129 | /** The radio call the job was cut under, or null for the plan on its own. */ | |
| 130 | jobCall: RadioCall | null; | |
| 128 | 131 | /** The clock the job's wait is up at. Set while the round is still frozen. */ |
| 129 | 132 | pushAt: number; |
| 130 | 133 | reset: boolean; |
| @@ -164,7 +167,7 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr | ||
| 164 | 167 | goal: null, path: [], step: 0, repathAt: 0, |
| 165 | 168 | targetId: null, seenAt: 0, burstUntil: 0, restUntil: 0, |
| 166 | 169 | 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, | |
| 168 | 171 | reset: true, |
| 169 | 172 | random: rng(seed), |
| 170 | 173 | obs: new Float32Array(OBS_SIZE), |
| @@ -378,19 +381,27 @@ function brainOf(world: World, bot: Player): BotBrain { | ||
| 378 | 381 | */ |
| 379 | 382 | function jobOf(world: World, bot: Player, brain: BotBrain): Job { |
| 380 | 383 | 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; | |
| 385 | 392 | const mates = world.players.filter((p) => p.team === bot.team); |
| 386 | 393 | 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) => ({ | |
| 388 | 398 | id: mate.id, |
| 389 | 399 | skill: brains[at]?.skill ?? 0, |
| 390 | }))); | |
| 400 | }))), call, world.time); | |
| 391 | 401 | brains.forEach((mate, at) => { |
| 392 | 402 | mate.job = jobs[at] ?? DEFAULT_JOB; |
| 393 | 403 | mate.jobPlan = plan; |
| 404 | mate.jobCall = call; | |
| 394 | 405 | mate.jobRound = world.match.round; |
| 395 | 406 | // The wait runs from the moment the round goes live, and the freeze time is |
| 396 | 407 | // still running: `botThink` pushes this forward on every frozen tick. |
| @@ -685,6 +696,7 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 685 | 696 | // arrived, replaces this on the next line and for the rest of the round. |
| 686 | 697 | brain.job = { ...DEFAULT_JOB, site: brain.random() < 0.5 ? "A" : "B" }; |
| 687 | 698 | brain.jobPlan = null; |
| 699 | brain.jobCall = null; | |
| 688 | 700 | brain.pushAt = 0; |
| 689 | 701 | brain.goal = null; |
| 690 | 702 | 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 | ||
| 1906 | 1906 | expect(stage.by("summary").hidden).toBe(false); |
| 1907 | 1907 | expect(stage.by("sumsaved").textContent).toBe(ROOM_SAVED); |
| 1908 | 1908 | }); |
| 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 ". | ||
| 97 | 97 | import { drawPreview, previewText } from "./preview.ts"; |
| 98 | 98 | import { buildScene, createScene, SKY, type Scene } from "./scene.ts"; |
| 99 | 99 | import { usePlan, usePolicy } from "./bots.ts"; |
| 100 | import { callOf, hears, radio } from "./radio.ts"; | |
| 101 | import type { Call } from "../plan/call.ts"; | |
| 100 | 102 | import { createSpectator, cycle, follow } from "./spectate.ts"; |
| 101 | 103 | import { WEAPONS } from "./weapons.ts"; |
| 102 | 104 | import { drawBuy, HINT, labelOf, offers } from "./buy.ts"; |
| @@ -152,6 +154,8 @@ export const NO_POLICY = | ||
| 152 | 154 | /** What the menu says when this browser cannot open a socket at all. */ |
| 153 | 155 | export const NO_SOCKET = "This browser has no WebSocket, so it cannot join a room. The match above still plays."; |
| 154 | 156 | /** 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."; | |
| 155 | 159 | export const NO_ROOM = "That room could not be joined — check the code, or make a new room."; |
| 156 | 160 | export const NO_MAKE = "A room could not be made right now. Try again in a moment."; |
| 157 | 161 | /** |
| @@ -898,6 +902,19 @@ export function boot(host: Host): Frame { | ||
| 898 | 902 | resumeButton.focus(); |
| 899 | 903 | } |
| 900 | 904 | |
| 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 | ||
| 901 | 918 | /** What Escape and P both mean: close the buy menu if it is open, else pause. */ |
| 902 | 919 | function pauseKey(): void { |
| 903 | 920 | if (!buyPanel.hidden) return openBuy(false); |
| @@ -912,6 +929,8 @@ export function boot(host: Host): Frame { | ||
| 912 | 929 | if (action === "rebuy") return pressRebuy(); |
| 913 | 930 | if (action === "pause") return pauseKey(); |
| 914 | 931 | if (action === "fullscreen") return askFull(); |
| 932 | const call = callOf(action); | |
| 933 | if (call !== null) return pressRadio(call); | |
| 915 | 934 | // The number row, while the shop is open: one key, one tile. The same keys |
| 916 | 935 | // are the weapon pockets with the panel closed, and `readIntent` is what |
| 917 | 936 | // does that — the frame suppresses the switch while the panel is up, so a |
| @@ -1468,8 +1487,11 @@ export function boot(host: Host): Frame { | ||
| 1468 | 1487 | const effect = effectAt(current.effects, i); |
| 1469 | 1488 | if (effect === undefined || effect.at <= lastSound) continue; |
| 1470 | 1489 | 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; | |
| 1472 | 1493 | audio.play(effect.name, effect.pos, eyePos(ears), effect.by === ears.id, ears.yaw); |
| 1494 | } | |
| 1473 | 1495 | else if (effect.kind === "hitmark") audio.hit(effect.headshot); |
| 1474 | 1496 | } |
| 1475 | 1497 | 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"; | ||
| 32 | 32 | import { SHORT } from "./match.ts"; |
| 33 | 33 | import { CHECKPOINT_CHARS } from "../engine/index.ts"; |
| 34 | 34 | 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, | |
| 36 | 36 | withoutEntropy, type Pin, type Run, |
| 37 | 37 | } from "./replay.ts"; |
| 38 | 38 | import { colOf, rowOf, walkable, type Level } from "./level.ts"; |
| @@ -41,6 +41,9 @@ import { cloudRadius } from "./grenade.ts"; | ||
| 41 | 41 | import { SMOKE, WEAPONS } from "./weapons.ts"; |
| 42 | 42 | import { createArena, reset, stepInput } from "../rl/arena.ts"; |
| 43 | 43 | 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"; | |
| 44 | 47 | |
| 45 | 48 | /** |
| 46 | 49 | * 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 | ||
| 392 | 395 | expect(seen.shots.length).toBeGreaterThan(0); // and shots did go out |
| 393 | 396 | }); |
| 394 | 397 | |
| 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 | ||
| 395 | 460 | test("a seeded duel in the training arena plays exactly as it played", () => { |
| 396 | 461 | const run = duelRun(); |
| 397 | 462 | must(run, DUEL_PIN); |
src/client/replay.ts +33 −2
| @@ -22,6 +22,8 @@ | ||
| 22 | 22 | |
| 23 | 23 | import { compare as complain, fold as foldTicks, quantise } from "../engine/index.ts"; |
| 24 | 24 | import { buy, type BuyItem } from "./economy.ts"; |
| 25 | import { radio } from "./radio.ts"; | |
| 26 | import type { Call } from "../plan/call.ts"; | |
| 25 | 27 | import { NO_INPUT, update, type Input, type Player, type World } from "./world.ts"; |
| 26 | 28 | import { cloudRadius, type Cloud, type Grenade } from "./grenade.ts"; |
| 27 | 29 | import type { ThrownId } from "./weapons.ts"; |
| @@ -129,9 +131,10 @@ export function snapshot(world: World): string { | ||
| 129 | 131 | * One beat of the run: from `atTick` on, this is the whole input the person is |
| 130 | 132 | * holding, exactly as a keyboard holds it until a key goes up. `buy` is the |
| 131 | 133 | * 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. | |
| 133 | 136 | */ |
| 134 | export type Beat = { atTick: number; input: Partial<Input>; buy?: BuyItem }; | |
| 137 | export type Beat = { atTick: number; input: Partial<Input>; buy?: BuyItem; call?: Call }; | |
| 135 | 138 | |
| 136 | 139 | /** |
| 137 | 140 | * Forty seconds of somebody playing: eight seconds of freeze with a buy in |
| @@ -263,6 +266,33 @@ export const SWITCH_SCRIPT: Beat[] = [ | ||
| 263 | 266 | { atTick: 1600, input: { forward: 1 } }, |
| 264 | 267 | ]; |
| 265 | 268 | |
| 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 | ||
| 266 | 296 | /** |
| 267 | 297 | * A cursor over the script: hand it a tick and it returns the input held at |
| 268 | 298 | * 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: | ||
| 276 | 306 | const beat = script[at++] as Beat; |
| 277 | 307 | input = { ...NO_INPUT, ...beat.input }; |
| 278 | 308 | if (beat.buy !== undefined) buy(world, player, beat.buy); |
| 309 | if (beat.call !== undefined) radio(world, player, beat.call); | |
| 279 | 310 | } |
| 280 | 311 | return input; |
| 281 | 312 | }; |
src/client/world.ts +12 −3
| @@ -26,6 +26,7 @@ import { | ||
| 26 | 26 | } from "./grenade.ts"; |
| 27 | 27 | import type { Level } from "./level.ts"; |
| 28 | 28 | import type { RoundPlan } from "../plan/plan.ts"; |
| 29 | import type { RadioCall } from "../plan/call.ts"; | |
| 29 | 30 | import { |
| 30 | 31 | angleVectors, angleDelta, clamp, createEffects, dist, expireEffects, eyeHeight, hullHeight, move, |
| 31 | 32 | normalize, pushEffect, rng, segmentSphere, sub, visible, |
| @@ -110,8 +111,10 @@ export type Player = Actor & Body & { | ||
| 110 | 111 | * carries who made it and `audio.ts` decides how loud that is. |
| 111 | 112 | * |
| 112 | 113 | * `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`). | |
| 113 | 116 | */ |
| 114 | export type Sound = GrenadeSound | "step" | "beep" | "plant" | "defuse" | "death"; | |
| 117 | export type Sound = GrenadeSound | "step" | "beep" | "plant" | "defuse" | "death" | "radio"; | |
| 115 | 118 | |
| 116 | 119 | export type Effect = |
| 117 | 120 | | { kind: "tracer"; from: Vec3; to: Vec3; at: number } |
| @@ -239,6 +242,12 @@ export type World = { | ||
| 239 | 242 | * `src/client/bots.ts` puts it here (`usePlan`) and reads it back. |
| 240 | 243 | */ |
| 241 | 244 | 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; | |
| 242 | 251 | random: () => number; |
| 243 | 252 | /** |
| 244 | 253 | * The map this round is played on, whole. Everything that needs to know where |
| @@ -319,7 +328,7 @@ export function createWorld( | ||
| 319 | 328 | match, players, me, time: 0, |
| 320 | 329 | bomb: newBomb(), |
| 321 | 330 | effects: createEffects<Effect>(EFFECT_CAPACITY), grenades: [], clouds: [], |
| 322 | feed: [], messages: [], plan: null, | |
| 331 | feed: [], messages: [], plan: null, radio: null, | |
| 323 | 332 | random, level, bombPlanted: false, pendingBuy: false, |
| 324 | 333 | }; |
| 325 | 334 | startRound(world); |
| @@ -340,7 +349,7 @@ const record = (world: World, e: Effect): void => pushEffect(world.effects, e, e | ||
| 340 | 349 | * moves on the next tick, and an effect the frame reads later must be where the |
| 341 | 350 | * noise was and not where the body is now. |
| 342 | 351 | */ |
| 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 => { | |
| 344 | 353 | record(world, { kind: "sound", at: world.time, pos: [pos[0], pos[1], pos[2]], name, by }); |
| 345 | 354 | }; |
| 346 | 355 |
src/pages/game.ts +4
| @@ -579,6 +579,10 @@ export function gamePage( | ||
| 579 | 579 | { action: "duck", label: "crouch" }, |
| 580 | 580 | { action: "jump", label: "jump" }, |
| 581 | 581 | { 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" }, | |
| 582 | 586 | ], |
| 583 | 587 | [ |
| 584 | 588 | { 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 | ||
| 64 | 64 | export const BOMB_STATES: readonly BombState[] = ["carried", "dropped", "planted", "defused", "detonated"]; |
| 65 | 65 | export const SOUNDS: readonly Sound[] = [ |
| 66 | 66 | "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", | |
| 67 | 71 | ]; |
| 68 | 72 | |
| 69 | 73 | /** The widest frame this module will ever write, so the buffer is made once. */ |
new above
offline