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
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/111-teammates-who-call-it-out-what-a-bot-see → main 4 commits fork
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 | ||
| 219 | 219 | the round through a teammate's eyes, and the four still alive are the ones who |
| 220 | 220 | can do something about what you can see. |
| 221 | 221 | |
| 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 | ||
| 222 | 240 | **There are two maps**, and the menu's **Map** select beside Difficulty picks |
| 223 | 241 | which one a match is played on. `de_forge` is the default and the dust-shaped |
| 224 | 242 | 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. | ||
| 402 | 420 | ## Shape |
| 403 | 421 | |
| 404 | 422 | - `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 | |
| 407 | 433 | of numbers through one builder, and everything that reads a grid takes the |
| 408 | 434 | map rather than reaching for one; the catalogue — `MAPS`, `MAP_NAMES`, |
| 409 | 435 | `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. | ||
| 419 | 445 | `buy` (the buy panel's rows — what a side can afford, what it already owns |
| 420 | 446 | and why the rest are dead — read off `world` and handed to `main` as plain data), |
| 421 | 447 | `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`, | |
| 423 | 452 | `press` (the input latch), `loop` (how much simulation one frame is worth: |
| 424 | 453 | the fixed step, the caps, and a pause that banks nothing), |
| 425 | 454 | `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 | ||
| 569 | 598 | answer is the same `siteFor` it always was, which is why the arena and |
| 570 | 599 | `scripts/measure.ts` print the same numbers they printed before. |
| 571 | 600 | |
| 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 | ||
| 572 | 614 | - **What it hears** — the sounds the world already makes, read off the effect |
| 573 | 615 | list once a tick (`src/client/hearing.ts`, and `listen` in `bots.ts`). A |
| 574 | 616 | gunshot is the loudest thing on the map and an AWP the loudest gun; a plant |
package.json +2 −2
| @@ -5,9 +5,9 @@ | ||
| 5 | 5 | "scripts": { |
| 6 | 6 | "start": "bun run src/server.ts", |
| 7 | 7 | "build": "bun run scripts/build.ts", |
| 8 | "test": "bun test", | |
| 8 | "test": "bun test --timeout 20000", | |
| 9 | 9 | "typecheck": "tsc --noEmit", |
| 10 | "check": "tsc --noEmit && bun test" | |
| 10 | "check": "tsc --noEmit && bun test --timeout 20000" | |
| 11 | 11 | }, |
| 12 | 12 | "devDependencies": { |
| 13 | 13 | "@types/bun": "^1.4.2", |
src/client/bots.test.ts +96 −2
| @@ -2,13 +2,13 @@ import { expect, test } from "bun:test"; | ||
| 2 | 2 | import { |
| 3 | 3 | applyIntent, botThink, CARD, DIFFICULTIES, makeBrain, policy, restyleBrain, skillBand, think, |
| 4 | 4 | } from "./bots.ts"; |
| 5 | import { DE_FORGE } from "./map.ts"; | |
| 5 | import { DE_FORGE, placeAt, worldOf } from "./map.ts"; | |
| 6 | 6 | import { dist2d } from "./math.ts"; |
| 7 | 7 | import { COMPETITIVE, SHORT } from "./match.ts"; |
| 8 | 8 | import { CALL_LIFE, HUNT_AT, otherSite } from "./plan.ts"; |
| 9 | 9 | import { |
| 10 | 10 | aimAt, bodyCentre, createWorld, NO_INPUT, radioCall, setDifficulty, startRound, update, |
| 11 | type Player, | |
| 11 | type Player, type World, | |
| 12 | 12 | } from "./world.ts"; |
| 13 | 13 | |
| 14 | 14 | 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", () => { | ||
| 589 | 589 | expect(dist2d(ct.pos, ct.brain?.goal ?? [0, 0, 0])).toBeLessThanOrEqual(360); |
| 590 | 590 | } |
| 591 | 591 | }); |
| 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 { | ||
| 23 | 23 | import { CARD, PACKED } from "./weights.ts"; |
| 24 | 24 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 25 | 25 | 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"; | |
| 27 | 27 | |
| 28 | 28 | export { CARD }; |
| 29 | 29 | |
| @@ -575,6 +575,13 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 575 | 575 | const target = pickTarget(world, bot, brain); |
| 576 | 576 | if (target !== null) { |
| 577 | 577 | 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); | |
| 578 | 585 | brain.targetId = target.id; |
| 579 | 586 | brain.seenAt = world.time; |
| 580 | 587 | } |
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 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { |
| 3 | 3 | 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, | |
| 5 | 5 | } from "./map.ts"; |
| 6 | 6 | |
| 7 | 7 | /** |
| @@ -259,3 +259,52 @@ test("two maps built from two layouts hold no state in common", () => { | ||
| 259 | 259 | expect(a).not.toBe(DE_FORGE); |
| 260 | 260 | expect(a.grid).not.toBe(DE_FORGE.grid); |
| 261 | 261 | }); |
| 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 = { | ||
| 39 | 39 | export const MAT = { FLOOR: 0, WALL: 1, CRATE: 2, SITE: 3, METAL: 4 } as const; |
| 40 | 40 | export type Box = { min: Vec3; max: Vec3; mat: number }; |
| 41 | 41 | |
| 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 | ||
| 42 | 51 | /** A built map: the grid, its geometry, and the places bots and spawns care about. */ |
| 43 | 52 | export type MapDef = { |
| 44 | 53 | name: string; |
| @@ -60,6 +69,12 @@ export type MapDef = { | ||
| 60 | 69 | */ |
| 61 | 70 | muster: { t: Vec3; ct: Vec3 }; |
| 62 | 71 | 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[]; | |
| 63 | 78 | }; |
| 64 | 79 | |
| 65 | 80 | const DE_FORGE_LAYOUT: Layout = { |
| @@ -149,6 +164,21 @@ const DE_CINDER_LAYOUT: Layout = { | ||
| 149 | 164 | ], |
| 150 | 165 | }; |
| 151 | 166 | |
| 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 | ||
| 152 | 182 | function buildGrid(layout: Layout): Cell[] { |
| 153 | 183 | const g: Cell[] = new Array(layout.gridW * layout.gridH).fill("#"); |
| 154 | 184 | for (const area of layout.areas) { |
| @@ -259,6 +289,7 @@ export function buildMap(layout: Layout): MapDef { | ||
| 259 | 289 | spawns: { t: [], ct: [] }, |
| 260 | 290 | muster: { t: [0, 0, 0], ct: [0, 0, 0] }, |
| 261 | 291 | sites: { A: [0, 0, 0], B: [0, 0, 0] }, |
| 292 | places: buildPlaces(layout), | |
| 262 | 293 | }; |
| 263 | 294 | const floor: Box = { |
| 264 | 295 | min: [-half(map.gridW), -half(map.gridH), -32], |
| @@ -310,6 +341,21 @@ export function siteAt(map: MapDef, pos: Vec3): "A" | "B" | null { | ||
| 310 | 341 | return c === "A" || c === "B" ? c : null; |
| 311 | 342 | } |
| 312 | 343 | |
| 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 | ||
| 313 | 359 | // --- pathfinding ---------------------------------------------------------- |
| 314 | 360 | |
| 315 | 361 | /** |
src/client/world.test.ts +47 −1
| @@ -3,12 +3,13 @@ import { GRENADE } from "./grenades.ts"; | ||
| 3 | 3 | import { teleported } from "./interpolate.ts"; |
| 4 | 4 | import { dist } from "./math.ts"; |
| 5 | 5 | import { DE_FORGE, MAP_NAMES, MAPS, siteAt } from "./map.ts"; |
| 6 | import { REPEAT_GAP, SIDE_GAP } from "./callout.ts"; | |
| 6 | 7 | import { halfTime, SHORT, winReward, type Rules } from "./match.ts"; |
| 7 | 8 | import { GAIT_CYCLE, STEP_STRIDE } from "./physics.ts"; |
| 8 | 9 | import { WEAPONS, type WeaponId } from "./weapons.ts"; |
| 9 | 10 | import { snapshot, type Saved } from "./saved.ts"; |
| 10 | 11 | import { |
| 11 | aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, createWorld, currentWeapon, damageDealt, | |
| 12 | aimAt, applyInput, bodyCentre, BOMB_BLAST_RADIUS, buy, callout, createWorld, currentWeapon, damageDealt, | |
| 12 | 13 | damageTaken, ENVIRONMENT, eyePos, flinch, grenadeCount, grenadeSlot, MAX_GRENADES, newBomb, nextGrenade, |
| 13 | 14 | NO_INPUT, offeredPickup, radioCall, restoreWorld, shoot, startRound, update, |
| 14 | 15 | type Input, type Player, type World, |
| @@ -1207,3 +1208,48 @@ test.each([...MAP_NAMES])("a seeded headless match on %s reaches a result", (nam | ||
| 1207 | 1208 | // no seed at all. |
| 1208 | 1209 | expect(planted).toBe(true); |
| 1209 | 1210 | }, 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 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | import { addBody, type Corpse } from "./bodies.ts"; |
| 11 | import { | |
| 12 | canSpeak, clearChatter, isNews, newChatter, noteCallout, sightingText, type Chatter, | |
| 13 | } from "./callout.ts"; | |
| 11 | 14 | import { applyHit, traceBullet, type Actor } from "./combat.ts"; |
| 12 | 15 | import { stepBeep } from "./cues.ts"; |
| 13 | 16 | import { |
| 14 | 17 | blastDamage, GRENADE, step as stepGrenade, thrown, type Grenade, |
| 15 | 18 | } from "./grenades.ts"; |
| 16 | 19 | 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"; | |
| 18 | 21 | export { DEFAULT_MAP, MAP_NAMES, mapFor } from "./map.ts"; |
| 19 | 22 | import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, type Vec3 } from "./math.ts"; |
| 20 | 23 | import * as rules from "./match.ts"; |
| @@ -193,7 +196,15 @@ export type World = { | ||
| 193 | 196 | damageLog: DamageEntry[][]; |
| 194 | 197 | effects: Effect[]; |
| 195 | 198 | 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; | |
| 197 | 208 | random: () => number; |
| 198 | 209 | /** The layout this round is played on. */ |
| 199 | 210 | map: MapDef; |
| @@ -289,7 +300,7 @@ function buildWorld( | ||
| 289 | 300 | pickups: [], grenades: [], bodies: [], |
| 290 | 301 | damageLog: Array.from({ length: players.length + 1 }, () => |
| 291 | 302 | Array.from({ length: players.length }, () => ({ damage: 0, hits: 0, killed: false }))), |
| 292 | effects: [], feed: [], messages: [], | |
| 303 | effects: [], feed: [], messages: [], chatter: newChatter(players.length), | |
| 293 | 304 | random, map, boxes: map.boxes, bombPlanted: false, pendingBuy: false, |
| 294 | 305 | difficulty, pendingDifficulty: null, |
| 295 | 306 | skipBotBuy: false, |
| @@ -381,11 +392,43 @@ export const alive = (world: World, team: Team): Player[] => | ||
| 381 | 392 | |
| 382 | 393 | export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p)]; |
| 383 | 394 | |
| 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 }); | |
| 386 | 397 | if (world.messages.length > 6) world.messages.shift(); |
| 387 | 398 | } |
| 388 | 399 | |
| 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 | ||
| 389 | 432 | /** |
| 390 | 433 | * The person on the radio. It is one line of data — the side, the word, the |
| 391 | 434 | * 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 { | ||
| 410 | 453 | // Last round's radio is last round's: whatever was called, the new round |
| 411 | 454 | // starts on the roll and the person calls again if they still mean it. |
| 412 | 455 | 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 | } | |
| 413 | 464 | if (world.pendingDifficulty !== null) { |
| 414 | 465 | world.difficulty = world.pendingDifficulty; |
| 415 | 466 | world.pendingDifficulty = null; |