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 #172

Merged opened this

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

Merged

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

  • Merged. vesper/111-teammates-who-call-it-out-what-a-bot-see was merged into main as 75febb4.

Review

Sign in to review this change.
10 files changed +526 −15
README.md +45 −3
@@ -219,6 +219,24 @@ 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. One thing gets said and no more, a sighting, and it is said on
227 **contact** — the tick a bot first sees somebody it was not already seeing —
228 so a ninety-second standoff down one corridor is one line and not ninety
229 seconds of them. A footstep through a wall says nothing at all: a bot that
230 hears something goes and looks, quietly. **Only your own side ever says
231 anything**, and only about an enemy it can actually see: a callout is what the
232 teammate standing there has in its sights, never what the other side's bots
233 know, so it can tell you nothing the radar would have been a wallhack for
234 showing you. It is deliberately quiet — no bot says two things within six
235 seconds, the side says at most one thing every four (the line is readable for
236 four), and nobody calls the same place again for twelve. A round where nothing
237 happens is silent, and a round restart clears the chatter and leaves the
238 result of the last one on screen.
239
222240 **There are two maps**, and the menu's **Map** select beside Difficulty picks
223241 which one a match is played on. `de_forge` is the default and the dust-shaped
224242 one: both sites along the top of the grid, a mid between them, a long corridor
@@ -402,8 +420,16 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
402420 ## Shape
403421
404422 - `src/client/` — the game, in modules named for one thing each: `math`, `map`
405 (a `MapDef` is a value — grid, boxes, spawns, sites — that `buildMap` makes
406 from a layout's areas and crates; `DE_FORGE` and `DE_CINDER` are two tables
423 (a `MapDef` is a value — grid, boxes, spawns, sites, places — that `buildMap`
424 makes from a layout's areas and crates; a **place** is one of those areas
425 under the name a person would say it by, labelled once when the map is built
426 out of the area's own name — `a site` is *A site*, `ct mid` is *CT mid* —
427 and `placeAt(map, pos)` says which one a world point stands in, resolved
428 last-painted-first so it agrees with the cells `buildGrid` actually painted
429 and can never name a room you could not be standing in. That is the whole
430 vocabulary anything in the game has for saying *where*, there is no second
431 table of coordinates beside it, and `map.test.ts` holds every map to it:
432 a walkable cell nothing named is a red test. `DE_FORGE` and `DE_CINDER` are two tables
407433 of numbers through one builder, and everything that reads a grid takes the
408434 map rather than reaching for one; the catalogue — `MAPS`, `MAP_NAMES`,
409435 `DEFAULT_MAP`, `mapFor`, built once each — is the seam a player chooses
@@ -419,7 +445,10 @@ projections they feed cost 0.04 ms of a 16.7 ms frame, measured.
419445 `buy` (the buy panel's rows — what a side can afford, what it already owns
420446 and why the rest are dead — read off `world` and handed to `main` as plain data),
421447 `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`,
448 it, and when the carrier has run out of clock),
449 `callout` (what a teammate says out loud when it sees somebody, in the map's
450 own words, and the three gaps that keep four of them from filling the screen),
451 `observe`, `action`, `policy`, `weights`, `models`, `textures`,
423452 `press` (the input latch), `loop` (how much simulation one frame is worth:
424453 the fixed step, the caps, and a pause that banks nothing),
425454 `interpolate` (what is drawn between two ticks: a position lerped, a yaw
@@ -569,6 +598,19 @@ has called anything in does not pay for the feature: with the radio silent the
569598 answer is the same `siteFor` it always was, which is why the arena and
570599 `scripts/measure.ts` print the same numbers they printed before.
571600
601 The other half of that radio is the team talking back (see *Playing*).
602 `callout.ts` is the whole rule: the one thing a bot may say, the words it is
603 said in, and three gaps — four seconds for the side, six for one bot, twelve
604 before the same place is called again. `world.ts`'s `callout` is the gate in
605 front of it and it is ordered by price: the person's own side, a round
606 actually being played, then two numbers off the rate limit. Only once all
607 three have said yes does anything name a place or build a string, and the
608 whole gate sits behind `bots.ts`'s contact edge — a bot that can already see
609 the enemy it is shooting at does not reach it at all. So the arena pays
610 nothing: `scripts/cost.ts` reports the same 0.12 ms mean and 0.23 ms p95 a
611 tick it did before, and `scripts/measure.ts` plays the same 87 rounds with the
612 same plant rate and the same side split, in the same 78 seconds.
613
572614 - **What it hears** — the sounds the world already makes, read off the effect
573615 list once a tick (`src/client/hearing.ts`, and `listen` in `bots.ts`). A
574616 gunshot is the loudest thing on the map and an AWP the loudest gun; a plant
package.json +2 −2
@@ -5,9 +5,9 @@
55 "scripts": {
66 "start": "bun run src/server.ts",
77 "build": "bun run scripts/build.ts",
8 "test": "bun test",
8 "test": "bun test --timeout 20000",
99 "typecheck": "tsc --noEmit",
10 "check": "tsc --noEmit && bun test"
10 "check": "tsc --noEmit && bun test --timeout 20000"
1111 },
1212 "devDependencies": {
1313 "@types/bun": "^1.4.2",
src/client/bots.test.ts +96 −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,97 @@ 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 });
src/client/bots.ts +8 −1
@@ -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
@@ -575,6 +575,13 @@ export function botThink(world: World, bot: Player, dt: number): Input {
575575 const target = pickTarget(world, bot, brain);
576576 if (target !== null) {
577577 if (brain.targetId !== target.id) {
578 // Contact, and only contact: a bot that had nobody in its sights now
579 // has somebody, and `pickTarget` has already run the trace that proves
580 // it can see them. Swapping from one enemy to another is the same fight
581 // and says nothing; so is the next tick of the same sighting, which is
582 // why a standoff down one corridor is one line and not ninety seconds
583 // of them.
584 if (brain.targetId === null) callout(world, bot, target.pos);
578585 brain.targetId = target.id;
579586 brain.seenAt = world.time;
580587 }
src/client/callout.test.ts added +63
@@ -0,0 +1,63 @@
1 import { expect, test } from "bun:test";
2 import {
3 canSpeak, clearChatter, isNews, newChatter, noteCallout, sightingText,
4 REPEAT_GAP, SIDE_GAP, SPEAKER_GAP,
5 } from "./callout.ts";
6
7 test("a sighting names the teammate and the place, in that order", () => {
8 const said = sightingText("Krait", "A site");
9 expect(said).toBe("Krait: enemy — A site");
10 });
11
12 test("a place the map could not name is left off rather than guessed at", () => {
13 expect(sightingText("Pike", null)).toBe("Pike: enemy");
14 });
15
16 test("the gaps are ordered: the side's, then one bot's, then the same place", () => {
17 // The HUD holds a line for four seconds and draws only the last one, so a
18 // side that talks faster than SIDE_GAP overwrites itself unread.
19 expect(SIDE_GAP).toBe(4);
20 expect(SPEAKER_GAP).toBeGreaterThan(SIDE_GAP);
21 expect(REPEAT_GAP).toBeGreaterThan(SPEAKER_GAP);
22 });
23
24 test("a fresh side may speak, and then not again until the gaps have run", () => {
25 const chatter = newChatter(4);
26 expect(canSpeak(chatter, 1, 0)).toBe(true);
27 noteCallout(chatter, 1, "mid", 0);
28
29 // Nobody at all, until the side's own gap is up.
30 expect(canSpeak(chatter, 2, SIDE_GAP - 0.1)).toBe(false);
31 expect(canSpeak(chatter, 2, SIDE_GAP)).toBe(true);
32 // And not this bot again, for longer still: one voice cannot hold the feed.
33 expect(canSpeak(chatter, 1, SIDE_GAP)).toBe(false);
34 expect(canSpeak(chatter, 1, SPEAKER_GAP)).toBe(true);
35 });
36
37 test("the side does not repeat the same call about the same place", () => {
38 const chatter = newChatter(4);
39 noteCallout(chatter, 1, "mid", 0);
40 expect(isNews(chatter, "mid", REPEAT_GAP - 0.1)).toBe(false);
41 expect(isNews(chatter, "mid", REPEAT_GAP)).toBe(true);
42 // Somewhere else is news at once: that is a second angle, not a repeat.
43 expect(isNews(chatter, "A site", 0.1)).toBe(true);
44 expect(isNews(chatter, null, 0.1)).toBe(true);
45 });
46
47 test("clearing forgets everything, in place", () => {
48 const chatter = newChatter(4);
49 const voices = chatter.spokeAt;
50 noteCallout(chatter, 3, "mid", 100);
51 expect(canSpeak(chatter, 3, 101)).toBe(false);
52 clearChatter(chatter);
53 expect(chatter.spokeAt).toBe(voices); // the same array, zeroed, never a new one
54 expect(canSpeak(chatter, 3, 101)).toBe(true);
55 expect(isNews(chatter, "mid", 101)).toBe(true);
56 });
57
58 test("a speaker the side has no slot for cannot corrupt the record", () => {
59 const chatter = newChatter(2);
60 noteCallout(chatter, 9, "mid", 5);
61 expect(chatter.spokeAt.length).toBe(2);
62 expect(chatter.lastAt).toBe(5); // the side still said it
63 });
src/client/callout.ts added +113
@@ -0,0 +1,113 @@
1 /**
2 * What a teammate says out loud when it sees somebody, and how often it is
3 * allowed to say it.
4 *
5 * A bot already knows where the enemy is — it runs `canSee` against every one
6 * of them every tick to decide whether to shoot. This is the rule that turns a
7 * little of that into a line on the message feed: which words, about where,
8 * and the three gaps that keep four teammates from filling the screen.
9 *
10 * One thing is said and no more: an enemy seen. The vocabulary is `map.ts`'s —
11 * `placeAt` names the layout's own areas, so a callout can only ever say a
12 * place the map itself carved. There is no table of coordinates here.
13 *
14 * Nothing in this file touches a world or allocates: it is a clock, three
15 * numbers and a fixed set of words, so `world.ts` can ask "may this bot speak"
16 * for the price of two comparisons on the tick where the answer is no — which
17 * is nearly every tick.
18 */
19
20 /**
21 * One line, built only once the rate limit has already said yes. A place the
22 * map could not name is dropped rather than guessed at.
23 *
24 * "enemy" is somebody being looked at down a barrel and means exactly that:
25 * nothing in the game says this word about a footstep or a guess.
26 */
27 export const sightingText = (name: string, place: string | null): string =>
28 place === null ? `${name}: enemy` : `${name}: enemy — ${place}`;
29
30 // --- the rate limit -------------------------------------------------------
31 //
32 // Three gaps, because there are three different ways a radio becomes noise:
33 // the side as a whole, one bot on its own, and the same call said twice. The
34 // six-message cap in `say` is a backstop and not a limit — six lines a second
35 // is still six lines a second.
36
37 /**
38 * Seconds between any two things the side says. The HUD holds a line for four
39 * seconds (`MESSAGE_FADE` in hud.ts) and draws only the last one, so a side
40 * that talks faster than this paints over its own previous line before it can
41 * be read: one line, readable, is worth more than three that flicker.
42 */
43 export const SIDE_GAP = 4;
44
45 /**
46 * Seconds before the same bot says anything again. Six — one and a half
47 * `SIDE_GAP`s — so the four teammates together can still fill every slot the
48 * side's gap allows, but no one of them can hold the feed on its own: a bot in
49 * a long fight calls it once and then lets somebody else talk.
50 */
51 export const SPEAKER_GAP = 6;
52
53 /**
54 * Seconds before the side calls the same place again. Twelve — three lines'
55 * worth of `SIDE_GAP` — is long enough that five teammates walking into the
56 * same angle say "enemy — mid" once between them, and short enough that a site
57 * still being fought over a quarter of a minute later is worth mentioning
58 * again.
59 */
60 export const REPEAT_GAP = 12;
61
62 /**
63 * What the side has said lately. Sized once, in `createWorld`, and zeroed in
64 * place by `startRound`: nothing here is ever allocated during a round.
65 */
66 export type Chatter = {
67 /** When the side last said anything at all. */
68 lastAt: number;
69 /** When each player last said anything, by player id. */
70 spokeAt: Float64Array;
71 /** The place the side's last sighting named, and when it named it. */
72 place: string | null;
73 placeAt: number;
74 };
75
76 /** A side that has never said anything, with room for `players` voices. */
77 export const newChatter = (players: number): Chatter => ({
78 lastAt: -Infinity,
79 spokeAt: new Float64Array(players).fill(-Infinity),
80 place: null,
81 placeAt: -Infinity,
82 });
83
84 /** Forget it all, in place: a new round starts with nothing already said. */
85 export function clearChatter(chatter: Chatter): void {
86 chatter.lastAt = -Infinity;
87 chatter.spokeAt.fill(-Infinity);
88 chatter.place = null;
89 chatter.placeAt = -Infinity;
90 }
91
92 /**
93 * The cheap half of the limit: has the side, and this bot, been quiet long
94 * enough? Asked first and on every tick a bot makes contact, so it looks at
95 * two numbers and nothing else — the place is not even worked out yet.
96 */
97 export const canSpeak = (chatter: Chatter, speakerId: number, now: number): boolean =>
98 now - chatter.lastAt >= SIDE_GAP &&
99 now - (chatter.spokeAt[speakerId] ?? -Infinity) >= SPEAKER_GAP;
100
101 /** The other half: is this actually news, or the same call about the same place? */
102 export const isNews = (chatter: Chatter, place: string | null, now: number): boolean =>
103 chatter.place !== place || now - chatter.placeAt >= REPEAT_GAP;
104
105 /** Write down that it was said. The caller has already asked both questions. */
106 export function noteCallout(
107 chatter: Chatter, speakerId: number, place: string | null, now: number,
108 ): void {
109 chatter.lastAt = now;
110 if (speakerId >= 0 && speakerId < chatter.spokeAt.length) chatter.spokeAt[speakerId] = now;
111 chatter.place = place;
112 chatter.placeAt = now;
113 }
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,
@@ -1207,3 +1208,48 @@ test.each([...MAP_NAMES])("a seeded headless match on %s reaches a result", (nam
12071208 // no seed at all.
12081209 expect(planted).toBe(true);
12091210 }, 30_000);
1211
1212 // --- what a teammate says -------------------------------------------------
1213
1214 test("a callout is the person's own side, and nobody else's", () => {
1215 const world = createWorld(SHORT, "me", "ct", 5, 8181);
1216 world.match.phase = "live";
1217 const mate = world.players.find((p) => p.bot && p.team === "ct") as Player;
1218 const enemy = world.players.find((p) => p.team === "t") as Player;
1219 expect(callout(world, enemy, DE_FORGE.sites.A)).toBe(false);
1220 expect(callout(world, world.me, DE_FORGE.sites.A)).toBe(false);
1221 expect(world.messages.length).toBe(0);
1222 expect(callout(world, mate, DE_FORGE.sites.A)).toBe(true);
1223 expect(world.messages[0]?.text).toBe(`${mate.name}: enemy — A site`);
1224 expect(world.messages[0]?.by).toBe(mate.id);
1225 });
1226
1227 test("a teammate stays quiet outside a round being played", () => {
1228 const world = createWorld(SHORT, "me", "ct", 5, 8282);
1229 const mate = world.players.find((p) => p.bot && p.team === "ct") as Player;
1230 expect(world.match.phase).toBe("freeze");
1231 expect(callout(world, mate, DE_FORGE.sites.A)).toBe(false);
1232 // Nor over the result of the round that has just ended, which the HUD is
1233 // still holding on screen.
1234 world.match.phase = "over";
1235 expect(callout(world, mate, DE_FORGE.sites.A)).toBe(false);
1236 world.match.phase = "planted";
1237 expect(callout(world, mate, DE_FORGE.sites.A)).toBe(true);
1238 });
1239
1240 test("the side's callouts are rate limited, not just capped at six", () => {
1241 const world = createWorld(SHORT, "me", "ct", 5, 8383);
1242 world.match.phase = "live";
1243 const mates = world.players.filter((p) => p.bot && p.team === "ct");
1244 expect(mates.length).toBeGreaterThan(2);
1245 // Everyone shouting about the same thing on the same tick: one of them gets
1246 // a line, and the rest of the side is told to wait.
1247 const said = mates.map((mate) => callout(world, mate, DE_FORGE.sites.A));
1248 expect(said.filter(Boolean).length).toBe(1);
1249 expect(world.messages.length).toBe(1);
1250 // And the side saying it again, a gap later, is still the same news.
1251 world.time += SIDE_GAP;
1252 expect(callout(world, mates[1] as Player, DE_FORGE.sites.A)).toBe(false);
1253 world.time += REPEAT_GAP;
1254 expect(callout(world, mates[1] as Player, DE_FORGE.sites.A)).toBe(true);
1255 });
src/client/world.ts +56 −5
@@ -8,13 +8,16 @@
88 */
99
1010 import { addBody, type Corpse } from "./bodies.ts";
11 import {
12 canSpeak, clearChatter, isNews, newChatter, noteCallout, sightingText, type Chatter,
13 } from "./callout.ts";
1114 import { applyHit, traceBullet, type Actor } from "./combat.ts";
1215 import { stepBeep } from "./cues.ts";
1316 import {
1417 blastDamage, GRENADE, step as stepGrenade, thrown, type Grenade,
1518 } from "./grenades.ts";
1619 import { pose, remember, rememberPos, type Pose } from "./interpolate.ts";
17 import { DE_FORGE, MAPS, siteAt, type MapDef } from "./map.ts";
20 import { DE_FORGE, MAPS, placeAt, siteAt, type MapDef } from "./map.ts";
1821 export { DEFAULT_MAP, MAP_NAMES, mapFor } from "./map.ts";
1922 import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, type Vec3 } from "./math.ts";
2023 import * as rules from "./match.ts";
@@ -193,7 +196,15 @@ export type World = {
193196 damageLog: DamageEntry[][];
194197 effects: Effect[];
195198 feed: FeedEntry[];
196 messages: { at: number; text: string }[];
199 /**
200 * The message line. `by` is the player whose mouth a line came out of, where
201 * it came out of anybody's: a teammate's callout carries it and the round's
202 * own announcements do not, which is how `startRound` tells last round's
203 * chatter from the result of the round the person is still reading.
204 */
205 messages: { at: number; text: string; by?: number }[];
206 /** What the person's side has said lately, and how recently. See callout.ts. */
207 chatter: Chatter;
197208 random: () => number;
198209 /** The layout this round is played on. */
199210 map: MapDef;
@@ -289,7 +300,7 @@ function buildWorld(
289300 pickups: [], grenades: [], bodies: [],
290301 damageLog: Array.from({ length: players.length + 1 }, () =>
291302 Array.from({ length: players.length }, () => ({ damage: 0, hits: 0, killed: false }))),
292 effects: [], feed: [], messages: [],
303 effects: [], feed: [], messages: [], chatter: newChatter(players.length),
293304 random, map, boxes: map.boxes, bombPlanted: false, pendingBuy: false,
294305 difficulty, pendingDifficulty: null,
295306 skipBotBuy: false,
@@ -381,11 +392,43 @@ export const alive = (world: World, team: Team): Player[] =>
381392
382393 export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p)];
383394
384 function say(world: World, text: string): void {
385 world.messages.push({ at: world.time, text });
395 function say(world: World, text: string, by?: number): void {
396 world.messages.push({ at: world.time, text, by });
386397 if (world.messages.length > 6) world.messages.shift();
387398 }
388399
400 /**
401 * A teammate saying something out loud. Four gates, cheapest first, and none
402 * of them allocates until the last one has already said yes:
403 *
404 * 1. The person's own side, and nobody else's. The enemy's bots see the enemy's
405 * half of the map and never get a word onto this feed — a callout is a
406 * teammate talking to you, not a wallhack with a name on it.
407 * 2. A round that is actually being played. A line during the end delay would
408 * paint over the result of the round the person is still reading.
409 * 3. The rate limit's cheap half: has the side, and this bot, been quiet?
410 * Two numbers, which is all a tick where nobody speaks ever pays.
411 * 4. The place, and whether it is news. `placeAt` is a scan of the map's own
412 * areas and the line is one template string — both behind the check that
413 * somebody is about to speak, which is what keeps this out of the arena's
414 * hot loop.
415 *
416 * `about` is the point the call is about: where the enemy was seen. The caller
417 * owns the other half of the rule — a sighting is said on contact, and
418 * `bots.ts` is what knows a bot has just made one.
419 */
420 export function callout(world: World, speaker: Player, about: Vec3): boolean {
421 if (speaker.team !== world.me.team || speaker === world.me) return false;
422 const phase = world.match.phase;
423 if (phase !== "live" && phase !== "planted") return false;
424 if (!canSpeak(world.chatter, speaker.id, world.time)) return false;
425 const place = placeAt(world.map, about);
426 if (!isNews(world.chatter, place, world.time)) return false;
427 noteCallout(world.chatter, speaker.id, place, world.time);
428 say(world, sightingText(speaker.name, place), speaker.id);
429 return true;
430 }
431
389432 /**
390433 * The person on the radio. It is one line of data — the side, the word, the
391434 * time — and the side's plan reader in plan.ts does the rest; nothing here
@@ -410,6 +453,14 @@ export function startRound(world: World): void {
410453 // Last round's radio is last round's: whatever was called, the new round
411454 // starts on the roll and the person calls again if they still mean it.
412455 world.radio = null;
456 // And last round's chatter with it. The lines the teammates said go with the
457 // round they were about — an enemy at B is not news any more — while the
458 // round's own announcements stay, because the result of the round that just
459 // ended is what the HUD is holding on screen through this freeze.
460 clearChatter(world.chatter);
461 if (world.messages.some((m) => m.by !== undefined)) {
462 world.messages = world.messages.filter((m) => m.by === undefined);
463 }
413464 if (world.pendingDifficulty !== null) {
414465 world.difficulty = world.pendingDifficulty;
415466 world.pendingDifficulty = null;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs