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

Teammates who call it out: what a bot sees, said once, to its own side #164

Closed opened this
Closed without merging

vesper/cs:vesper/111-teammates-who-call-it-out-what-a-bot-see → main 1 commit fork

  • Closed without merging. Nothing was applied to main.

Review

Sign in to review this change.
9 files changed +630 −13
README.md +33 −1
@@ -219,6 +219,23 @@ radio clear. Being dead does not take the radio away: you are already watching
219219 the round through a teammate's eyes, and the four still alive are the ones who
220220 can do something about what you can see.
221221
222 **And the team talks back.** A teammate that can see an enemy says where it
223 is on the same message line — `Krait: enemy — long A` — and it says it in the
224 map's own words: the places are `map.ts`'s named areas, the rooms the layout
225 is actually carved out of, so a callout can only ever name somewhere that
226 exists. Four things get said and no more: a sighting, a noise
227 (`Pike: noise — mid`, worded so it can never be mistaken for one — it is a
228 footstep through a wall and the place is a guess), a teammate about to defuse,
229 and a teammate down to its last ten rounds. **Only your own side ever says
230 anything**: a callout is what the teammate standing there can see, never what
231 the other side's bots know, so it can tell you nothing the radar would have
232 been a wallhack for showing you. It is deliberately quiet — a bot speaks when
233 it *makes* contact rather than for every tick it still has it, no bot says two
234 things within six seconds, the side says at most one thing every four (the
235 line is readable for four), and nobody repeats the same call about the same
236 place for twelve. A round where nothing happens says nothing, and a round
237 restart clears the chatter and leaves the result of the last one on screen.
238
222239 **There are two maps**, and the menu's **Map** select beside Difficulty picks
223240 which one a match is played on. `de_forge` is the default and the dust-shaped
224241 one: both sites along the top of the grid, a mid between them, a long corridor
@@ -419,7 +436,10 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
419436 `buy` (the buy panel's rows — what a side can afford, what it already owns
420437 and why the rest are dead — read off `world` and handed to `main` as plain data),
421438 `bots`, `plan` (which site a side commits to, what the radio can say about
422 it, and when the carrier has run out of clock), `observe`, `action`, `policy`, `weights`, `models`, `textures`,
439 it, and when the carrier has run out of clock),
440 `callout` (what a teammate says out loud, in the map's own words, and the
441 three gaps that keep four of them from filling the screen),
442 `observe`, `action`, `policy`, `weights`, `models`, `textures`,
423443 `press` (the input latch), `loop` (how much simulation one frame is worth:
424444 the fixed step, the caps, and a pause that banks nothing),
425445 `interpolate` (what is drawn between two ticks: a position lerped, a yaw
@@ -569,6 +589,18 @@ has called anything in does not pay for the feature: with the radio silent the
569589 answer is the same `siteFor` it always was, which is why the arena and
570590 `scripts/measure.ts` print the same numbers they printed before.
571591
592 The other half of that radio is the team talking back (see *Playing*).
593 `callout.ts` is the whole rule: the four things a bot may say, the words each
594 is said in, and three gaps — four seconds for the side, six for one bot,
595 twelve before the same call about the same place. `world.ts`'s `callout` is
596 the gate in front of it and it is ordered by price: the person's own side, a
597 round actually being played, then two numbers off the rate limit. Only once
598 all four have said yes does anything name a place or build a string, so a bot
599 that can see an enemy and has nothing new to say costs the arena two
600 comparisons and no allocation — `scripts/measure.ts` prints the same 87 rounds
601 in the same 76 seconds it did before, and `--bench` steps the arena at the
602 same 13k steps a second.
603
572604 - **What it hears** — the sounds the world already makes, read off the effect
573605 list once a tick (`src/client/hearing.ts`, and `listen` in `bots.ts`). A
574606 gunshot is the loudest thing on the map and an AWP the loudest gun; a plant
src/client/bots.test.ts +149 −2
@@ -2,13 +2,13 @@ import { expect, test } from "bun:test";
22 import {
33 applyIntent, botThink, CARD, DIFFICULTIES, makeBrain, policy, restyleBrain, skillBand, think,
44 } from "./bots.ts";
5 import { DE_FORGE } from "./map.ts";
5 import { DE_FORGE, placeAt, worldOf } from "./map.ts";
66 import { dist2d } from "./math.ts";
77 import { COMPETITIVE, SHORT } from "./match.ts";
88 import { CALL_LIFE, HUNT_AT, otherSite } from "./plan.ts";
99 import {
1010 aimAt, bodyCentre, createWorld, NO_INPUT, radioCall, setDifficulty, startRound, update,
11 type Player,
11 type Player, type World,
1212 } from "./world.ts";
1313
1414 const duel = (seed = 2024): { world: ReturnType<typeof createWorld>; me: Player; bot: Player } => {
@@ -589,3 +589,150 @@ test("a defender told to hold keeps holding however low the clock is", () => {
589589 expect(dist2d(ct.pos, ct.brain?.goal ?? [0, 0, 0])).toBeLessThanOrEqual(360);
590590 }
591591 });
592
593 // --- callouts -------------------------------------------------------------
594
595 /**
596 * One teammate and one enemy, six cells apart down mid with nothing between
597 * them, and two more players parked out of the way at the far end of the map.
598 * The teammate is looking straight at the enemy, so `pickTarget`'s own trace
599 * is what decides whether it can see anything — nothing here is handed to it.
600 */
601 function squad(seed = 4114): { world: World; mate: Player; foe: Player } {
602 const world = createWorld(COMPETITIVE, "you", "ct", 2, seed);
603 world.match.phase = "live";
604 const mate = world.players.find((p) => p.bot && p.team === "ct") as Player;
605 const foe = world.players.find((p) => p.team === "t") as Player;
606 for (const p of world.players) {
607 if (p === mate || p === foe) continue;
608 p.pos = [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2];
609 }
610 mate.pos = [...worldOf(DE_FORGE, 22, 14, 2)];
611 foe.pos = [...worldOf(DE_FORGE, 22, 20, 2)];
612 mate.brain = makeBrain(1, 99);
613 mate.brain.reset = false;
614 mate.yaw = aimAt(mate, bodyCentre(foe)).yaw;
615 return { world, mate, foe };
616 }
617
618 const callouts = (world: World): string[] =>
619 world.messages.filter((m) => m.by !== undefined).map((m) => m.text);
620
621 /** Let `bot` think for `seconds` of ticks, the way a round would. */
622 const thinkFor = (world: World, bot: Player, seconds: number): void => {
623 for (let t = 0; t < 64 * seconds; t++) {
624 botThink(world, bot, 1 / 64);
625 world.time += 1 / 64;
626 }
627 };
628
629 test("a teammate that can see an enemy says where it is", () => {
630 const { world, mate, foe } = squad();
631 botThink(world, mate, 1 / 64);
632 expect(callouts(world)).toEqual([`${mate.name}: enemy — ${placeAt(DE_FORGE, foe.pos)}`]);
633 expect(world.messages[world.messages.length - 1]?.by).toBe(mate.id);
634 });
635
636 test("a teammate with nothing in view says nothing", () => {
637 const { world, mate, foe } = squad();
638 // The same two players, with the whole map between them: b site cannot be
639 // seen from mid, and `pickTarget` is the thing that finds that out.
640 foe.pos = [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2];
641 thinkFor(world, mate, 3);
642 expect(callouts(world)).toEqual([]);
643 });
644
645 test("a teammate does not say the same thing twice in a row", () => {
646 const { world, mate } = squad();
647 botThink(world, mate, 1 / 64);
648 expect(callouts(world).length).toBe(1);
649 // Half a minute of staring down the same corridor at the same enemy: the
650 // sighting was news once, and the rate limit and the contact edge between
651 // them keep it at once.
652 thinkFor(world, mate, 30);
653 expect(callouts(world).length).toBe(1);
654 });
655
656 test("nothing the other side's bots know ever reaches the person's feed", () => {
657 const { world, mate, foe } = squad();
658 // The enemy looking straight back down the same corridor, for as long as
659 // the teammate looked down it.
660 foe.brain = makeBrain(1, 77);
661 foe.brain.reset = false;
662 foe.yaw = aimAt(foe, bodyCentre(mate)).yaw;
663 mate.pos = [DE_FORGE.sites.B[0], DE_FORGE.sites.B[1], 2]; // and the teammate out of it
664 thinkFor(world, foe, 30);
665 expect(callouts(world)).toEqual([]);
666 });
667
668 test("a round restart clears the callouts and leaves the round's own line", () => {
669 const { world, mate, foe } = squad();
670 botThink(world, mate, 1 / 64);
671 expect(callouts(world).length).toBe(1);
672 world.messages.push({ at: world.time, text: "Terrorists win — detonated the bomb" });
673 startRound(world);
674 expect(callouts(world)).toEqual([]);
675 expect(world.messages.map((m) => m.text)).toEqual(["Terrorists win — detonated the bomb"]);
676 // And the side starts the round with nothing already said, so the first
677 // sighting of it is news again.
678 world.match.phase = "live";
679 mate.pos = [...worldOf(DE_FORGE, 22, 14, 2)];
680 foe.pos = [...worldOf(DE_FORGE, 22, 20, 2)];
681 mate.yaw = aimAt(mate, bodyCentre(foe)).yaw;
682 botThink(world, mate, 1 / 64); // `reset` and all: this is the round's first tick
683
684 expect(callouts(world).length).toBe(1);
685 });
686
687 test("a teammate about to defuse says so, once", () => {
688 const { world, mate } = squad();
689 world.match.phase = "planted";
690 world.bomb.state = "planted";
691 world.bomb.pos = [...DE_FORGE.sites.A] as [number, number, number];
692 world.bomb.site = "A";
693 mate.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 2];
694 for (const p of world.players) if (p.team === "t") p.alive = false;
695 // The longest defuse in the rules, stood out in full: one attempt, one line.
696 thinkFor(world, mate, COMPETITIVE.defuseTime);
697 expect(callouts(world)).toEqual([`${mate.name}: defusing — A site`]);
698 });
699
700 test("a teammate down to its last shots says so, once, and only in a fight", () => {
701 const { world, mate, foe } = squad();
702 mate.primary = "m4a4";
703 mate.weapon = "m4a4";
704 mate.ammo.m4a4 = { mag: 3, reserve: 0 };
705 // Already in the fight: the sighting has been made, so what is left to say
706 // is the magazine.
707 mate.brain!.targetId = foe.id;
708 mate.brain!.seenAt = world.time - 5;
709 thinkFor(world, mate, 20);
710 expect(callouts(world)).toEqual([`${mate.name}: low on ammo — ${placeAt(DE_FORGE, mate.pos)}`]);
711 });
712
713 test("a noise through a wall does not read as a sighting", () => {
714 const world = createWorld(COMPETITIVE, "you", "ct", 2, 4242);
715 world.match.phase = "live";
716 const mate = world.players.find((p) => p.bot && p.team === "ct") as Player;
717 const runner = world.players.find((p) => p.team === "t") as Player;
718 for (const p of world.players) {
719 if (p === mate || p === runner) continue;
720 p.pos = [DE_FORGE.sites.A[0], DE_FORGE.sites.A[1], 2];
721 }
722 runner.pos = [-448, 1856, 2]; // b link
723 runner.yaw = 90;
724 mate.pos = [64, 1856, 2]; // ct mid, one wall away
725 mate.yaw = 0;
726 mate.brain = makeBrain(1, 99);
727 mate.brain.reset = false;
728 mate.brain.heardAt = world.time;
729 for (let t = 0; t < 64; t++) {
730 botThink(world, mate, 1 / 64);
731 update(world, 1 / 64, NO_INPUT); // the runner's own footsteps, pushed by the world
732 runner.pos = [runner.pos[0], runner.pos[1] + 2, runner.pos[2]];
733 }
734 const said = callouts(world);
735 expect(said.length).toBe(1);
736 expect(said[0]).toContain("noise");
737 expect(said[0]).not.toContain("enemy");
738 });
src/client/bots.ts +47 −3
@@ -23,7 +23,7 @@ import {
2323 import { CARD, PACKED } from "./weights.ts";
2424 import { WEAPONS, type WeaponId } from "./weapons.ts";
2525 import type { Input, Player, World } from "./world.ts";
26 import { aimAt, bodyCentre, buy, canSee, NO_INPUT } from "./world.ts";
26 import { aimAt, bodyCentre, buy, callout, canSee, NO_INPUT } from "./world.ts";
2727
2828 export { CARD };
2929
@@ -82,6 +82,8 @@ export type BotBrain = {
8282 suspect: Vec3 | null;
8383 /** The world clock the last time this bot listened: what came after it is news. */
8484 heardAt: number;
85 /** Has it already said it is down to its last shots with the gun it is holding? */
86 saidDry: boolean;
8587 plannedFor: string;
8688 site: Site; // where the side is going: the round's plan, or what was called
8789 slot: number; // this bot's place in its side's list, counted when the round starts
@@ -111,7 +113,7 @@ export function makeBrain(skill: number, seed: number, useLearned = true): BotBr
111113 aimError: 4.5 - skill * 3.6,
112114 goal: null, path: [], step: 0, repathAt: 0,
113115 targetId: null, seenAt: 0, burstUntil: 0, restUntil: 0,
114 strafe: 0, strafeUntil: 0, suspect: null, heardAt: 0, plannedFor: "",
116 strafe: 0, strafeUntil: 0, suspect: null, heardAt: 0, saidDry: false, plannedFor: "",
115117 site: "A", slot: 0, holding: false, ordered: false, reset: true,
116118 random: rng(seed),
117119 obs: new Float32Array(OBS_SIZE),
@@ -403,7 +405,16 @@ function listen(world: World, bot: Player, brain: BotBrain): void {
403405 from = pos;
404406 }
405407 }
406 if (from !== null) brain.suspect = reported(bot.pos, from, world.map, brain.random);
408 if (from === null) return;
409 const investigating = brain.suspect !== null;
410 brain.suspect = reported(bot.pos, from, world.map, brain.random);
411 // Worth saying when a bot that was looking at nothing suddenly has
412 // somewhere to look, and only then: not while it already has a target — it
413 // has better things to say — and not for the next noise out of a place it
414 // is already walking at. It names where it *thinks* the noise came from,
415 // `reported`'s guess rather than the truth, and the words are not a
416 // sighting's, because this is not one.
417 if (!investigating && brain.targetId === null) callout(world, bot, "noise", brain.suspect);
407418 }
408419
409420 /**
@@ -541,6 +552,16 @@ function follow(bot: Player, brain: BotBrain, input: Input): number | null {
541552 return off;
542553 }
543554
555 /**
556 * Rounds left — magazine and reserve together — at which a bot is on its last
557 * shots and says so. Ten is a burst and a half from a rifle: enough warning to
558 * be worth acting on, few enough that it is not every reload.
559 */
560 const LAST_SHOTS = 10;
561
562 /** Does this weapon even take ammunition? A knife is not out of it. */
563 const magazineFed = (id: WeaponId): boolean => (WEAPONS[id]?.magazine ?? 0) > 0;
564
544565 /** One bot's decision for this tick, expressed as the same input a person gives. */
545566 export function botThink(world: World, bot: Player, dt: number): Input {
546567 const brain = brainOf(world, bot);
@@ -555,6 +576,7 @@ export function botThink(world: World, bot: Player, dt: number): Input {
555576 brain.targetId = null;
556577 brain.suspect = null;
557578 brain.heardAt = world.time;
579 brain.saidDry = false;
558580 brain.plannedFor = "";
559581 brain.memory.sinceSeen = 9;
560582 brain.hold = 0;
@@ -575,6 +597,13 @@ export function botThink(world: World, bot: Player, dt: number): Input {
575597 const target = pickTarget(world, bot, brain);
576598 if (target !== null) {
577599 if (brain.targetId !== target.id) {
600 // Contact, and only contact: a bot that had nobody in its sights now
601 // has somebody, and `pickTarget` has already run the trace that proves
602 // it can see them. Swapping from one enemy to another is the same fight
603 // and says nothing; so is the next tick of the same sighting, which is
604 // why a standoff down one corridor is one line and not ninety seconds
605 // of them.
606 if (brain.targetId === null) callout(world, bot, "spotted", target.pos);
578607 brain.targetId = target.id;
579608 brain.seenAt = world.time;
580609 }
@@ -589,6 +618,17 @@ export function botThink(world: World, bot: Player, dt: number): Input {
589618 applyIntent(world, bot, brain, intent, dt);
590619 const ammo = bot.ammo[bot.weapon];
591620 if (ammo !== undefined && ammo.mag === 0) input.reload = true;
621 // Down to the last few rounds, in a fight, where somebody can still do
622 // something about it. Only ever asked inside the fight — a bot walking an
623 // empty corridor with two bullets left has nothing to tell anyone yet —
624 // and `saidDry` latches, so running dry is one line and not one a reload.
625 // Picking a gun up off the floor unlatches it, which is the only way the
626 // count goes back up inside a round.
627 if (ammo !== undefined && magazineFed(bot.weapon)) {
628 const low = ammo.mag + ammo.reserve <= LAST_SHOTS;
629 if (low && !brain.saidDry) brain.saidDry = callout(world, bot, "dry", bot.pos);
630 else if (!low) brain.saidDry = false;
631 }
592632 // Two things the policy does not get to decide. A bot that has only just
593633 // seen you does not shoot yet — that beat is what skill buys. And the
594634 // policy trained with a rifle, so it holds the trigger down; on a pistol
@@ -643,6 +683,10 @@ function withBomb(world: World, bot: Player, input: Input): Input {
643683 input.forward = 0;
644684 input.side = 0;
645685 input.fire = false;
686 // The one moment a teammate is worth more than a gun: it is standing
687 // still on the bomb with its back to the map. `REPEAT_GAP` is longer
688 // than either defuse time, so one attempt is one line.
689 callout(world, bot, "defusing", bot.pos);
646690 }
647691 }
648692 return input;
src/client/callout.test.ts added +67
@@ -0,0 +1,67 @@
1 import { expect, test } from "bun:test";
2 import {
3 calloutText, canSpeak, clearChatter, isNews, newChatter, noteCallout,
4 CALLOUT_KINDS, REPEAT_GAP, SIDE_GAP, SPEAKER_GAP,
5 } from "./callout.ts";
6
7 test("a sighting and a noise do not read the same", () => {
8 const seen = calloutText("Krait", "spotted", "A site");
9 const heard = calloutText("Krait", "noise", "A site");
10 expect(seen).toContain("Krait");
11 expect(seen).toContain("A site");
12 expect(heard).toContain("A site");
13 expect(heard).not.toBe(seen);
14 // "enemy" is somebody being looked at; a noise never claims to be one.
15 expect(seen).toContain("enemy");
16 expect(heard).not.toContain("enemy");
17 });
18
19 test("a place the map could not name is left off rather than guessed at", () => {
20 expect(calloutText("Pike", "spotted", null)).toBe("Pike: enemy");
21 });
22
23 test("every kind has words of its own", () => {
24 const said = CALLOUT_KINDS.map((kind) => calloutText("Rook", kind, "mid"));
25 expect(new Set(said).size).toBe(CALLOUT_KINDS.length);
26 });
27
28 test("a fresh side may speak, and then not again until the gaps have run", () => {
29 const chatter = newChatter(4);
30 expect(canSpeak(chatter, 1, 0)).toBe(true);
31 noteCallout(chatter, "spotted", 1, "mid", 0);
32
33 // Nobody at all, until the side's own gap is up.
34 expect(canSpeak(chatter, 2, SIDE_GAP - 0.1)).toBe(false);
35 expect(canSpeak(chatter, 2, SIDE_GAP)).toBe(true);
36 // And not this bot again, for longer still: one voice cannot hold the feed.
37 expect(canSpeak(chatter, 1, SIDE_GAP)).toBe(false);
38 expect(canSpeak(chatter, 1, SPEAKER_GAP)).toBe(true);
39 });
40
41 test("the side does not repeat the same call about the same place", () => {
42 const chatter = newChatter(4);
43 noteCallout(chatter, "spotted", 1, "mid", 0);
44 expect(isNews(chatter, "spotted", "mid", REPEAT_GAP - 0.1)).toBe(false);
45 expect(isNews(chatter, "spotted", "mid", REPEAT_GAP)).toBe(true);
46 // A different place, or a different thing to say about it, is news at once.
47 expect(isNews(chatter, "spotted", "A site", 0.1)).toBe(true);
48 expect(isNews(chatter, "noise", "mid", 0.1)).toBe(true);
49 });
50
51 test("clearing forgets everything, in place", () => {
52 const chatter = newChatter(4);
53 const voices = chatter.spokeAt;
54 noteCallout(chatter, "spotted", 3, "mid", 100);
55 expect(canSpeak(chatter, 3, 101)).toBe(false);
56 clearChatter(chatter);
57 expect(chatter.spokeAt).toBe(voices); // the same array, zeroed, never a new one
58 expect(canSpeak(chatter, 3, 101)).toBe(true);
59 expect(isNews(chatter, "spotted", "mid", 101)).toBe(true);
60 });
61
62 test("a speaker the side has no slot for cannot corrupt the record", () => {
63 const chatter = newChatter(2);
64 noteCallout(chatter, "spotted", 9, "mid", 5);
65 expect(chatter.spokeAt.length).toBe(2);
66 expect(chatter.lastAt).toBe(5); // the side still said it
67 });
src/client/callout.ts added +135
@@ -0,0 +1,135 @@
1 /**
2 * What a teammate says out loud, and how often it is allowed to say it.
3 *
4 * A bot already knows where the enemy is — it runs `canSee` against every one
5 * of them every tick to decide whether to shoot. This is the rule that turns a
6 * little of that into a line on the message feed: which words, about where,
7 * and the three gaps that keep four teammates from filling the screen.
8 *
9 * The vocabulary is `map.ts`'s: `placeAt` names the layout's own areas, so a
10 * callout can only ever say a place the map itself carved. There is no table
11 * of coordinates here.
12 *
13 * Nothing in this file touches a world or allocates: it is a clock, three
14 * numbers and a fixed set of words, so `world.ts` can ask "may this bot speak"
15 * for the price of two comparisons on the tick where the answer is no — which
16 * is nearly every tick.
17 */
18
19 /**
20 * Everything a bot can say. Four things, and no more: three of them are
21 * moments the person cannot see for themselves, and the fourth is the one
22 * they can — a sighting — said by the teammate who has it.
23 */
24 export type CalloutKind = "spotted" | "noise" | "defusing" | "dry";
25
26 export const CALLOUT_KINDS: readonly CalloutKind[] = ["spotted", "noise", "defusing", "dry"];
27
28 /**
29 * The words each kind is said with. A sighting and a noise must not read the
30 * same: "enemy" is somebody being looked at down a barrel, "noise" is a
31 * footstep through a wall and says nothing about who or where they are going.
32 */
33 const WORDS: Record<CalloutKind, string> = {
34 spotted: "enemy",
35 noise: "noise",
36 defusing: "defusing",
37 dry: "low on ammo",
38 };
39
40 /**
41 * One line, built only once the rate limit has already said yes. A place the
42 * map could not name is dropped rather than guessed at.
43 */
44 export const calloutText = (name: string, kind: CalloutKind, place: string | null): string =>
45 place === null ? `${name}: ${WORDS[kind]}` : `${name}: ${WORDS[kind]} — ${place}`;
46
47 // --- the rate limit -------------------------------------------------------
48 //
49 // Three gaps, because there are three different ways a radio becomes noise.
50 // The six-message cap in `say` is a backstop and not a limit: six lines a
51 // second is still six lines a second.
52
53 /**
54 * Seconds between any two things the side says. The HUD holds a line for four
55 * seconds (`MESSAGE_FADE` in hud.ts), so a side that talks faster than this
56 * overwrites its own last line before it can be read — one line, readable, is
57 * worth more than three that flicker.
58 */
59 export const SIDE_GAP = 4;
60
61 /**
62 * Seconds before the same bot says anything again. Six, so the four teammates
63 * together can still fill every slot `SIDE_GAP` allows, but no one of them can
64 * hold the feed on its own: a bot in a long fight calls it once and then lets
65 * somebody else talk.
66 */
67 export const SPEAKER_GAP = 6;
68
69 /**
70 * Seconds before the side repeats the same kind of call about the same place.
71 * Twelve is long enough that five teammates walking into the same angle say
72 * "enemy — mid" once between them, and short enough that a site still being
73 * fought over a quarter of a minute later is worth mentioning again.
74 */
75 export const REPEAT_GAP = 12;
76
77 /**
78 * What the side has said lately. Sized once, in `createWorld`, and zeroed in
79 * place by `startRound`: nothing here is ever allocated during a round.
80 */
81 export type Chatter = {
82 /** When the side last said anything at all. */
83 lastAt: number;
84 /** When each player last said anything, by player id. */
85 spokeAt: Float64Array;
86 /** When the side last said each kind, indexed by `CALLOUT_KINDS`. */
87 kindAt: Float64Array;
88 /** The place named by that last call of each kind. */
89 kindPlace: (string | null)[];
90 };
91
92 /** A side that has never said anything, with room for `players` voices. */
93 export const newChatter = (players: number): Chatter => ({
94 lastAt: -Infinity,
95 spokeAt: new Float64Array(players).fill(-Infinity),
96 kindAt: new Float64Array(CALLOUT_KINDS.length).fill(-Infinity),
97 kindPlace: CALLOUT_KINDS.map(() => null),
98 });
99
100 /** Forget it all, in place: a new round starts with nothing already said. */
101 export function clearChatter(chatter: Chatter): void {
102 chatter.lastAt = -Infinity;
103 chatter.spokeAt.fill(-Infinity);
104 chatter.kindAt.fill(-Infinity);
105 chatter.kindPlace.fill(null);
106 }
107
108 /**
109 * The cheap half of the limit: has the side, and this bot, been quiet long
110 * enough? Asked first and on every tick a bot can see somebody, so it looks at
111 * two numbers and nothing else — the place is not even worked out yet.
112 */
113 export const canSpeak = (chatter: Chatter, speakerId: number, now: number): boolean =>
114 now - chatter.lastAt >= SIDE_GAP &&
115 now - (chatter.spokeAt[speakerId] ?? -Infinity) >= SPEAKER_GAP;
116
117 /** The other half: is this actually news, or the same call about the same place? */
118 export function isNews(chatter: Chatter, kind: CalloutKind, place: string | null, now: number): boolean {
119 const at = CALLOUT_KINDS.indexOf(kind);
120 if (at < 0) return false;
121 if (chatter.kindPlace[at] !== place) return true;
122 return now - (chatter.kindAt[at] ?? -Infinity) >= REPEAT_GAP;
123 }
124
125 /** Write down that it was said. The caller has already asked both questions. */
126 export function noteCallout(
127 chatter: Chatter, kind: CalloutKind, speakerId: number, place: string | null, now: number,
128 ): void {
129 const at = CALLOUT_KINDS.indexOf(kind);
130 if (at < 0) return;
131 chatter.lastAt = now;
132 if (speakerId >= 0 && speakerId < chatter.spokeAt.length) chatter.spokeAt[speakerId] = now;
133 chatter.kindAt[at] = now;
134 chatter.kindPlace[at] = place;
135 }
src/client/map.test.ts +50 −1
@@ -1,7 +1,7 @@
11 import { expect, test } from "bun:test";
22 import {
33 buildMap, CELL, cellAt, colOf, DE_CINDER, DE_FORGE, DEFAULT_MAP, findPath, MAP_NAMES, mapFor, MAPS,
4 rowOf, siteAt, walkable, worldOf, type Layout, type MapDef,
4 placeAt, placeLabel, rowOf, siteAt, walkable, worldOf, type Layout, type MapDef,
55 } from "./map.ts";
66
77 /**
@@ -259,3 +259,52 @@ test("two maps built from two layouts hold no state in common", () => {
259259 expect(a).not.toBe(DE_FORGE);
260260 expect(a.grid).not.toBe(DE_FORGE.grid);
261261 });
262
263 // --- places, the vocabulary a callout is said in --------------------------
264
265 test.each(CATALOGUE)("%s names every place it can stand in, out of its own areas", (_name, map) => {
266 expect(map.places.length).toBeGreaterThan(8);
267 // Every walkable cell is inside some area: a layout that carved a room and
268 // forgot to name it would leave a teammate saying "enemy" and nothing else.
269 for (let r = 0; r < map.gridH; r++) {
270 for (let c = 0; c < map.gridW; c++) {
271 if (!walkable(map, c, r)) continue;
272 expect(placeAt(map, worldOf(map, c, r))).not.toBeNull();
273 }
274 }
275 // And nothing outside the grid is anywhere at all.
276 expect(placeAt(map, [1e6, 1e6, 0])).toBeNull();
277 });
278
279 test.each(CATALOGUE)("%s says the sites and the spawns the way a person would", (_name, map) => {
280 expect(placeAt(map, map.sites.A)).toBe("A site");
281 expect(placeAt(map, map.sites.B)).toBe("B site");
282 expect(placeAt(map, map.muster.t)).toBe("T spawn");
283 expect(placeAt(map, map.muster.ct)).toBe("CT spawn");
284 });
285
286 test("a place is the area actually painted there, not the first one to claim it", () => {
287 // Two rooms, the second painted over the first: a point in the overlap is
288 // standing on the second one's cells, so that is what it is called.
289 const map = buildMap({
290 name: "overlap",
291 gridW: 6,
292 gridH: 6,
293 areas: [
294 { name: "long a", rect: [1, 1, 4, 4], fill: "." },
295 { name: "ct mid", rect: [3, 3, 4, 4], fill: "." },
296 ],
297 crates: [],
298 });
299 expect(placeAt(map, worldOf(map, 1, 1))).toBe("long A");
300 expect(placeAt(map, worldOf(map, 4, 4))).toBe("CT mid");
301 });
302
303 test("placeLabel says letters as letters and leaves every other word alone", () => {
304 expect(placeLabel("a site")).toBe("A site");
305 expect(placeLabel("long a")).toBe("long A");
306 expect(placeLabel("b link")).toBe("B link");
307 expect(placeLabel("ct spawn")).toBe("CT spawn");
308 expect(placeLabel("lower tunnel")).toBe("lower tunnel");
309 expect(placeLabel("catwalk")).toBe("catwalk");
310 });
src/client/map.ts +46
@@ -39,6 +39,15 @@ export type Layout = {
3939 export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const;
4040 export type Box = { min: Vec3; max: Vec3; mat: number };
4141
42 /**
43 * One named piece of the map, as a person would say it out loud: the layout's
44 * own area, with its grid rectangle and a label built once when the map is.
45 * This is the whole vocabulary anything in the game has for saying *where* —
46 * there is no second table of places to drift out of step with the areas that
47 * actually carve the rock.
48 */
49 export type Place = { name: string; rect: [number, number, number, number] };
50
4251 /** A built map: the grid, its geometry, and the places bots and spawns care about. */
4352 export type MapDef = {
4453 name: string;
@@ -60,6 +69,12 @@ export type MapDef = {
6069 */
6170 muster: { t: Vec3; ct: Vec3 };
6271 sites: { A: Vec3; B: Vec3 };
72 /**
73 * The layout's areas, last-painted first, so the first rectangle a point
74 * falls in is the one whose cells it is actually standing on — the same
75 * "a later area wins" rule `buildGrid` paints by. `placeAt` reads it.
76 */
77 places: Place[];
6378 };
6479
6580 const DE_FORGE_LAYOUT: Layout = {
@@ -149,6 +164,21 @@ const DE_CINDER_LAYOUT: Layout = {
149164 ],
150165 };
151166
167 /**
168 * The words a layout writes in lower case that a person says as letters: the
169 * sites, and the two sides. "a site" is A site and "ct mid" is CT mid; every
170 * other word of an area name is already the word you would say.
171 */
172 const SPOKEN: Record<string, string> = { a: "A", b: "B", t: "T", ct: "CT" };
173
174 /** An area name as it is said out loud, built once per map and never per tick. */
175 export const placeLabel = (name: string): string =>
176 name.split(" ").map((word) => SPOKEN[word] ?? word).join(" ");
177
178 /** The layout's areas as places, last-painted first (see `MapDef.places`). */
179 const buildPlaces = (layout: Layout): Place[] =>
180 layout.areas.map((area) => ({ name: placeLabel(area.name), rect: area.rect })).reverse();
181
152182 function buildGrid(layout: Layout): Cell[] {
153183 const g: Cell[] = new Array(layout.gridW * layout.gridH).fill("#");
154184 for (const area of layout.areas) {
@@ -259,6 +289,7 @@ export function buildMap(layout: Layout): MapDef {
259289 spawns: { t: [], ct: [] },
260290 muster: { t: [0, 0, 0], ct: [0, 0, 0] },
261291 sites: { A: [0, 0, 0], B: [0, 0, 0] },
292 places: buildPlaces(layout),
262293 };
263294 const floor: Box = {
264295 min: [-half(map.gridW), -half(map.gridH), -32],
@@ -310,6 +341,21 @@ export function siteAt(map: MapDef, pos: Vec3): "A" | "B" | null {
310341 return c === "A" || c === "B" ? c : null;
311342 }
312343
344 /**
345 * What a person would call the place `pos` is in, or null for a point inside
346 * the rock no area ever painted. A scan of a couple of dozen rectangles with
347 * nothing allocated — the label itself was built with the map.
348 */
349 export function placeAt(map: MapDef, pos: Vec3): string | null {
350 const col = colOf(map, pos[0]);
351 const row = rowOf(map, pos[1]);
352 for (const place of map.places) {
353 const [c0, r0, c1, r1] = place.rect;
354 if (col >= c0 && col <= c1 && row >= r0 && row <= r1) return place.name;
355 }
356 return null;
357 }
358
313359 // --- pathfinding ----------------------------------------------------------
314360
315361 /**
src/client/world.test.ts +47 −1
@@ -3,12 +3,13 @@ import { GRENADE } from "./grenades.ts";
33 import { teleported } from "./interpolate.ts";
44 import { dist } from "./math.ts";
55 import { DE_FORGE, MAP_NAMES, MAPS, siteAt } from "./map.ts";
6 import { REPEAT_GAP, SIDE_GAP } from "./callout.ts";
67 import { halfTime, SHORT, winReward, type Rules } from "./match.ts";
78 import { GAIT_CYCLE, STEP_STRIDE } from "./physics.ts";
89 import { WEAPONS, type WeaponId } from "./weapons.ts";
910 import { snapshot, type Saved } from "./saved.ts";
1011 import {
11 aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, createWorld, currentWeapon, damageDealt,
12 aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, callout, createWorld, currentWeapon, damageDealt,
1213 damageTaken, ENVIRONMENT, eyePos, flinch, grenadeCount, grenadeSlot, MAX_GRENADES, newBomb, nextGrenade,
1314 NO_INPUT, offeredPickup, radioCall, restoreWorld, shoot, startRound, update,
1415 type Input, type Player, type World,
@@ -1198,3 +1199,48 @@ test.each([...MAP_NAMES])("a seeded headless match on %s reaches a result", (nam
11981199 // no seed at all.
11991200 expect(planted).toBe(true);
12001201 }, 30_000);
1202
1203 // --- what a teammate says -------------------------------------------------
1204
1205 test("a callout is the person's own side, and nobody else's", () => {
1206 const world = createWorld(SHORT, "me", "ct", 5, 8181);
1207 world.match.phase = "live";
1208 const mate = world.players.find((p) => p.bot && p.team === "ct") as Player;
1209 const enemy = world.players.find((p) => p.team === "t") as Player;
1210 expect(callout(world, enemy, "spotted", DE_FORGE.sites.A)).toBe(false);
1211 expect(callout(world, world.me, "spotted", DE_FORGE.sites.A)).toBe(false);
1212 expect(world.messages.length).toBe(0);
1213 expect(callout(world, mate, "spotted", DE_FORGE.sites.A)).toBe(true);
1214 expect(world.messages[0]?.text).toBe(`${mate.name}: enemy — A site`);
1215 expect(world.messages[0]?.by).toBe(mate.id);
1216 });
1217
1218 test("a teammate stays quiet outside a round being played", () => {
1219 const world = createWorld(SHORT, "me", "ct", 5, 8282);
1220 const mate = world.players.find((p) => p.bot && p.team === "ct") as Player;
1221 expect(world.match.phase).toBe("freeze");
1222 expect(callout(world, mate, "spotted", DE_FORGE.sites.A)).toBe(false);
1223 // Nor over the result of the round that has just ended, which the HUD is
1224 // still holding on screen.
1225 world.match.phase = "over";
1226 expect(callout(world, mate, "spotted", DE_FORGE.sites.A)).toBe(false);
1227 world.match.phase = "planted";
1228 expect(callout(world, mate, "spotted", DE_FORGE.sites.A)).toBe(true);
1229 });
1230
1231 test("the side's callouts are rate limited, not just capped at six", () => {
1232 const world = createWorld(SHORT, "me", "ct", 5, 8383);
1233 world.match.phase = "live";
1234 const mates = world.players.filter((p) => p.bot && p.team === "ct");
1235 expect(mates.length).toBeGreaterThan(2);
1236 // Everyone shouting about the same thing on the same tick: one of them gets
1237 // a line, and the rest of the side is told to wait.
1238 const said = mates.map((mate) => callout(world, mate, "spotted", DE_FORGE.sites.A));
1239 expect(said.filter(Boolean).length).toBe(1);
1240 expect(world.messages.length).toBe(1);
1241 // And the side saying it again, a gap later, is still the same news.
1242 world.time += SIDE_GAP;
1243 expect(callout(world, mates[1] as Player, "spotted", DE_FORGE.sites.A)).toBe(false);
1244 world.time += REPEAT_GAP;
1245 expect(callout(world, mates[1] as Player, "spotted", DE_FORGE.sites.A)).toBe(true);
1246 });
src/client/world.ts +56 −5
@@ -8,13 +8,17 @@
88 */
99
1010 import { addBody, type Corpse } from "./bodies.ts";
11 import {
12 calloutText, canSpeak, clearChatter, isNews, newChatter, noteCallout,
13 type CalloutKind, type Chatter,
14 } from "./callout.ts";
1115 import { applyHit, traceBullet, type Actor } from "./combat.ts";
1216 import { stepBeep } from "./cues.ts";
1317 import {
1418 blastDamage, GRENADE, step as stepGrenade, thrown, type Grenade,
1519 } from "./grenades.ts";
1620 import { pose, remember, rememberPos, type Pose } from "./interpolate.ts";
17 import { DE_FORGE, siteAt, type MapDef } from "./map.ts";
21 import { DE_FORGE, placeAt, siteAt, type MapDef } from "./map.ts";
1822 export { DEFAULT_MAP, MAP_NAMES, mapFor } from "./map.ts";
1923 import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, type Vec3 } from "./math.ts";
2024 import * as rules from "./match.ts";
@@ -193,7 +197,15 @@ export type World = {
193197 damageLog: DamageEntry[][];
194198 effects: Effect[];
195199 feed: FeedEntry[];
196 messages: { at: number; text: string }[];
200 /**
201 * The message line. `by` is the player whose mouth a line came out of, where
202 * it came out of anybody's: a teammate's callout carries it and the round's
203 * own announcements do not, which is how `startRound` tells last round's
204 * chatter from the result of the round the person is still reading.
205 */
206 messages: { at: number; text: string; by?: number }[];
207 /** What the person's side has said lately, and how recently. See callout.ts. */
208 chatter: Chatter;
197209 random: () => number;
198210 /** The layout this round is played on. */
199211 map: MapDef;
@@ -289,7 +301,7 @@ function buildWorld(
289301 pickups: [], grenades: [], bodies: [],
290302 damageLog: Array.from({ length: players.length + 1 }, () =>
291303 Array.from({ length: players.length }, () => ({ damage: 0, hits: 0, killed: false }))),
292 effects: [], feed: [], messages: [],
304 effects: [], feed: [], messages: [], chatter: newChatter(players.length),
293305 random, map, boxes: map.boxes, bombPlanted: false, pendingBuy: false,
294306 difficulty, pendingDifficulty: null,
295307 skipBotBuy: false,
@@ -382,11 +394,42 @@ export const alive = (world: World, team: Team): Player[] =>
382394
383395 export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p)];
384396
385 function say(world: World, text: string): void {
386 world.messages.push({ at: world.time, text });
397 function say(world: World, text: string, by?: number): void {
398 world.messages.push({ at: world.time, text, by });
387399 if (world.messages.length > 6) world.messages.shift();
388400 }
389401
402 /**
403 * A teammate saying something out loud. Four gates, cheapest first, and none
404 * of them allocates until the last one has already said yes:
405 *
406 * 1. The person's own side, and nobody else's. The enemy's bots see the enemy's
407 * half of the map and never get a word onto this feed — a callout is a
408 * teammate talking to you, not a wallhack with a name on it.
409 * 2. A round that is actually being played. A line during the end delay would
410 * paint over the result of the round the person is still reading.
411 * 3. The rate limit's cheap half: has the side, and this bot, been quiet?
412 * Two numbers, which is all a tick where nobody speaks ever pays.
413 * 4. The place, and whether it is news. `placeAt` is a scan of the map's own
414 * areas and the line is one template string — both behind the check that
415 * somebody is about to speak, which is what keeps this out of the arena's
416 * hot loop.
417 *
418 * `about` is the point the call is *about*: where the enemy was seen, where
419 * the noise came from, where the defuser is standing.
420 */
421 export function callout(world: World, speaker: Player, kind: CalloutKind, about: Vec3): boolean {
422 if (speaker.team !== world.me.team || speaker === world.me) return false;
423 const phase = world.match.phase;
424 if (phase !== "live" && phase !== "planted") return false;
425 if (!canSpeak(world.chatter, speaker.id, world.time)) return false;
426 const place = placeAt(world.map, about);
427 if (!isNews(world.chatter, kind, place, world.time)) return false;
428 noteCallout(world.chatter, kind, speaker.id, place, world.time);
429 say(world, calloutText(speaker.name, kind, place), speaker.id);
430 return true;
431 }
432
390433 /**
391434 * The person on the radio. It is one line of data — the side, the word, the
392435 * time — and the side's plan reader in plan.ts does the rest; nothing here
@@ -411,6 +454,14 @@ export function startRound(world: World): void {
411454 // Last round's radio is last round's: whatever was called, the new round
412455 // starts on the roll and the person calls again if they still mean it.
413456 world.radio = null;
457 // And last round's chatter with it. The lines the teammates said go with the
458 // round they were about — an enemy at B is not news any more — while the
459 // round's own announcements stay, because the result of the round that just
460 // ended is what the HUD is holding on screen through this freeze.
461 clearChatter(world.chatter);
462 if (world.messages.some((m) => m.by !== undefined)) {
463 world.messages = world.messages.filter((m) => m.by === undefined);
464 }
414465 if (world.pendingDifficulty !== null) {
415466 world.difficulty = world.pendingDifficulty;
416467 world.pendingDifficulty = null;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs