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
vesper/cs:vesper/111-teammates-who-call-it-out-what-a-bot-see → main 1 commit fork
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 | ||
| 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. 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 | ||
| 222 | 239 | **There are two maps**, and the menu's **Map** select beside Difficulty picks |
| 223 | 240 | which one a match is played on. `de_forge` is the default and the dust-shaped |
| 224 | 241 | 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. | ||
| 419 | 436 | `buy` (the buy panel's rows — what a side can afford, what it already owns |
| 420 | 437 | and why the rest are dead — read off `world` and handed to `main` as plain data), |
| 421 | 438 | `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`, | |
| 423 | 443 | `press` (the input latch), `loop` (how much simulation one frame is worth: |
| 424 | 444 | the fixed step, the caps, and a pause that banks nothing), |
| 425 | 445 | `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 | ||
| 569 | 589 | answer is the same `siteFor` it always was, which is why the arena and |
| 570 | 590 | `scripts/measure.ts` print the same numbers they printed before. |
| 571 | 591 | |
| 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 | ||
| 572 | 604 | - **What it hears** — the sounds the world already makes, read off the effect |
| 573 | 605 | list once a tick (`src/client/hearing.ts`, and `listen` in `bots.ts`). A |
| 574 | 606 | 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"; | ||
| 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,150 @@ 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 | }); | |
| 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 { | ||
| 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 | |
| @@ -82,6 +82,8 @@ export type BotBrain = { | ||
| 82 | 82 | suspect: Vec3 | null; |
| 83 | 83 | /** The world clock the last time this bot listened: what came after it is news. */ |
| 84 | 84 | heardAt: number; |
| 85 | /** Has it already said it is down to its last shots with the gun it is holding? */ | |
| 86 | saidDry: boolean; | |
| 85 | 87 | plannedFor: string; |
| 86 | 88 | site: Site; // where the side is going: the round's plan, or what was called |
| 87 | 89 | 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 | ||
| 111 | 113 | aimError: 4.5 - skill * 3.6, |
| 112 | 114 | goal: null, path: [], step: 0, repathAt: 0, |
| 113 | 115 | 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: "", | |
| 115 | 117 | site: "A", slot: 0, holding: false, ordered: false, reset: true, |
| 116 | 118 | random: rng(seed), |
| 117 | 119 | obs: new Float32Array(OBS_SIZE), |
| @@ -403,7 +405,16 @@ function listen(world: World, bot: Player, brain: BotBrain): void { | ||
| 403 | 405 | from = pos; |
| 404 | 406 | } |
| 405 | 407 | } |
| 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); | |
| 407 | 418 | } |
| 408 | 419 | |
| 409 | 420 | /** |
| @@ -541,6 +552,16 @@ function follow(bot: Player, brain: BotBrain, input: Input): number | null { | ||
| 541 | 552 | return off; |
| 542 | 553 | } |
| 543 | 554 | |
| 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 | ||
| 544 | 565 | /** One bot's decision for this tick, expressed as the same input a person gives. */ |
| 545 | 566 | export function botThink(world: World, bot: Player, dt: number): Input { |
| 546 | 567 | const brain = brainOf(world, bot); |
| @@ -555,6 +576,7 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 555 | 576 | brain.targetId = null; |
| 556 | 577 | brain.suspect = null; |
| 557 | 578 | brain.heardAt = world.time; |
| 579 | brain.saidDry = false; | |
| 558 | 580 | brain.plannedFor = ""; |
| 559 | 581 | brain.memory.sinceSeen = 9; |
| 560 | 582 | brain.hold = 0; |
| @@ -575,6 +597,13 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 575 | 597 | const target = pickTarget(world, bot, brain); |
| 576 | 598 | if (target !== null) { |
| 577 | 599 | 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); | |
| 578 | 607 | brain.targetId = target.id; |
| 579 | 608 | brain.seenAt = world.time; |
| 580 | 609 | } |
| @@ -589,6 +618,17 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 589 | 618 | applyIntent(world, bot, brain, intent, dt); |
| 590 | 619 | const ammo = bot.ammo[bot.weapon]; |
| 591 | 620 | 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 | } | |
| 592 | 632 | // Two things the policy does not get to decide. A bot that has only just |
| 593 | 633 | // seen you does not shoot yet — that beat is what skill buys. And the |
| 594 | 634 | // 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 { | ||
| 643 | 683 | input.forward = 0; |
| 644 | 684 | input.side = 0; |
| 645 | 685 | 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); | |
| 646 | 690 | } |
| 647 | 691 | } |
| 648 | 692 | 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 @@ | ||
| 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, |
| @@ -1198,3 +1199,48 @@ test.each([...MAP_NAMES])("a seeded headless match on %s reaches a result", (nam | ||
| 1198 | 1199 | // no seed at all. |
| 1199 | 1200 | expect(planted).toBe(true); |
| 1200 | 1201 | }, 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 @@ | ||
| 8 | 8 | */ |
| 9 | 9 | |
| 10 | 10 | 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"; | |
| 11 | 15 | import { applyHit, traceBullet, type Actor } from "./combat.ts"; |
| 12 | 16 | import { stepBeep } from "./cues.ts"; |
| 13 | 17 | import { |
| 14 | 18 | blastDamage, GRENADE, step as stepGrenade, thrown, type Grenade, |
| 15 | 19 | } from "./grenades.ts"; |
| 16 | 20 | 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"; | |
| 18 | 22 | export { DEFAULT_MAP, MAP_NAMES, mapFor } from "./map.ts"; |
| 19 | 23 | import { angleVectors, angleDelta, clamp, dist, dist2d, normalize, rng, sub, type Vec3 } from "./math.ts"; |
| 20 | 24 | import * as rules from "./match.ts"; |
| @@ -193,7 +197,15 @@ export type World = { | ||
| 193 | 197 | damageLog: DamageEntry[][]; |
| 194 | 198 | effects: Effect[]; |
| 195 | 199 | 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; | |
| 197 | 209 | random: () => number; |
| 198 | 210 | /** The layout this round is played on. */ |
| 199 | 211 | map: MapDef; |
| @@ -289,7 +301,7 @@ function buildWorld( | ||
| 289 | 301 | pickups: [], grenades: [], bodies: [], |
| 290 | 302 | damageLog: Array.from({ length: players.length + 1 }, () => |
| 291 | 303 | Array.from({ length: players.length }, () => ({ damage: 0, hits: 0, killed: false }))), |
| 292 | effects: [], feed: [], messages: [], | |
| 304 | effects: [], feed: [], messages: [], chatter: newChatter(players.length), | |
| 293 | 305 | random, map, boxes: map.boxes, bombPlanted: false, pendingBuy: false, |
| 294 | 306 | difficulty, pendingDifficulty: null, |
| 295 | 307 | skipBotBuy: false, |
| @@ -382,11 +394,42 @@ export const alive = (world: World, team: Team): Player[] => | ||
| 382 | 394 | |
| 383 | 395 | export const eyePos = (p: Player): Vec3 => [p.pos[0], p.pos[1], p.pos[2] + eyeHeight(p)]; |
| 384 | 396 | |
| 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 }); | |
| 387 | 399 | if (world.messages.length > 6) world.messages.shift(); |
| 388 | 400 | } |
| 389 | 401 | |
| 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 | ||
| 390 | 433 | /** |
| 391 | 434 | * The person on the radio. It is one line of data — the side, the word, the |
| 392 | 435 | * 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 { | ||
| 411 | 454 | // Last round's radio is last round's: whatever was called, the new round |
| 412 | 455 | // starts on the roll and the person calls again if they still mean it. |
| 413 | 456 | 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 | } | |
| 414 | 465 | if (world.pendingDifficulty !== null) { |
| 415 | 466 | world.difficulty = world.pendingDifficulty; |
| 416 | 467 | world.pendingDifficulty = null; |