Split The code is public; the app answers only to the crew.
A bot with an empty primary slot walks over the rifle on the floor, when it is on its way #227
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/216-a-bot-with-an-empty-primary-slot-walks-o → main 1 commit fork
3 files
changed +98
−8
README.md +4
| @@ -709,6 +709,10 @@ the site the last of them died at — the kill feed and the radar, nothing they | ||
| 709 | 709 | could not see. Rolling one site a side rather than one a bot took the plant |
| 710 | 710 | rate from 38% to 65% over twenty seeded `SHORT` matches when it landed. |
| 711 | 711 | |
| 712 | A bot with an empty primary slot now detours to a dropped rifle a few steps | |
| 713 | off its path (`worthTheDetour` in `plan.ts` is the whole arithmetic) but never | |
| 714 | crosses the map, the site, or the bomb carrier's own plan for one. | |
| 715 | ||
| 712 | 716 | The clock is part of it, and both sides read it. `plan.ts` holds the two |
| 713 | 717 | numbers that say how late is late, and nothing else in the code decides it. |
| 714 | 718 | `PUSH_WINDOW` (12 seconds) is the fighting the terrorists can still afford on |
src/client/bots.test.ts +46 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { |
| 3 | applyIntent, botThink, CARD, DIFFICULTIES, makeBrain, policy, restyleBrain, skillBand, think, | |
| 3 | applyIntent, botThink, CARD, DIFFICULTIES, gunGoal, makeBrain, policy, restyleBrain, skillBand, think, | |
| 4 | 4 | } from "./bots.ts"; |
| 5 | 5 | import { DE_FORGE, placeAt, worldOf } from "./map.ts"; |
| 6 | 6 | import { dist2d } from "./math.ts"; |
| @@ -270,6 +270,51 @@ test("a bomb on the floor is still picked up by a living terrorist", () => { | ||
| 270 | 270 | expect(bot.hasBomb).toBe(true); |
| 271 | 271 | }); |
| 272 | 272 | |
| 273 | // --- picking a gun up off the floor ---------------------------------------- | |
| 274 | ||
| 275 | test("a bot with an empty primary slot detours to a rifle a few steps off its path", () => { | |
| 276 | const world = createWorld(RULES, "you", "t", 1, 4242); | |
| 277 | world.match.phase = "live"; | |
| 278 | const bot = world.players.find((p) => p.team === "ct") as Player; | |
| 279 | world.me.alive = false; // nothing for it to fight, nothing to flee | |
| 280 | expect(bot.primary).toBeNull(); // an eco round: too little money to have bought one | |
| 281 | world.pickups.push({ | |
| 282 | weapon: "m4a4", pos: [bot.pos[0] + 80, bot.pos[1], bot.pos[2] + 4], ammo: { mag: 30, reserve: 90 }, | |
| 283 | }); | |
| 284 | for (let t = 0; t < 64 * 6 && bot.primary === null; t++) update(world, 1 / 64, NO_INPUT); | |
| 285 | expect(bot.primary).toBe("m4a4"); | |
| 286 | }); | |
| 287 | ||
| 288 | test("a bot already holding a primary never diverts for one on the floor", () => { | |
| 289 | const world = createWorld(SHORT, "you", "ct", 1, 1); | |
| 290 | const bot = world.players[1] as Player; | |
| 291 | bot.primary = "ak47"; | |
| 292 | world.pickups.push({ | |
| 293 | weapon: "m4a4", pos: [bot.pos[0] + 10, bot.pos[1], bot.pos[2]], ammo: { mag: 30, reserve: 90 }, | |
| 294 | }); | |
| 295 | const goal: [number, number, number] = [123, 456, 0]; | |
| 296 | expect(gunGoal(world, bot, goal)).toBe(goal); | |
| 297 | }); | |
| 298 | ||
| 299 | test("a rifle across the map is not worth crossing for", () => { | |
| 300 | const world = createWorld(SHORT, "you", "ct", 1, 1); | |
| 301 | const bot = world.players[1] as Player; | |
| 302 | bot.primary = null; | |
| 303 | bot.pos = [0, 0, 2]; | |
| 304 | world.pickups.push({ weapon: "ak47", pos: [4000, 4000, 2], ammo: { mag: 30, reserve: 90 } }); | |
| 305 | const goal: [number, number, number] = [100, 0, 2]; | |
| 306 | expect(gunGoal(world, bot, goal)).toBe(goal); | |
| 307 | }); | |
| 308 | ||
| 309 | test("an empty pickups list leaves the goal it was given untouched", () => { | |
| 310 | const world = createWorld(SHORT, "you", "ct", 1, 1); | |
| 311 | const bot = world.players[1] as Player; | |
| 312 | bot.primary = null; | |
| 313 | const goal: [number, number, number] = [123, 456, 0]; | |
| 314 | expect(world.pickups.length).toBe(0); | |
| 315 | expect(gunGoal(world, bot, goal)).toBe(goal); // same reference: nothing was computed | |
| 316 | }); | |
| 317 | ||
| 273 | 318 | // --- the radio ------------------------------------------------------------ |
| 274 | 319 | |
| 275 | 320 | /** |
src/client/bots.ts +48 −7
| @@ -16,14 +16,14 @@ import { fromOther, loudness, reported, type HeardName, type Maker } from "./hea | ||
| 16 | 16 | import { angleDelta, clamp, dist, dist2d, rng, type Vec3 } from "./math.ts"; |
| 17 | 17 | import { findPath, siteAt, walkable, colOf, rowOf } from "./map.ts"; |
| 18 | 18 | import { observe, OBS_SIZE, type Memory } from "./observe.ts"; |
| 19 | import { mustHunt, mustPush, ordersFor, type Site } from "./plan.ts"; | |
| 19 | import { mustHunt, mustPush, ordersFor, worthTheDetour, type Site } from "./plan.ts"; | |
| 20 | 20 | import { |
| 21 | 21 | forward, newActivations, sample, unpack, type Activations, type Policy, |
| 22 | 22 | } from "./policy.ts"; |
| 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, callout, canSee, NO_INPUT } from "./world.ts"; | |
| 26 | import { aimAt, bodyCentre, buy, callout, canSee, NO_INPUT, pickupSlot } from "./world.ts"; | |
| 27 | 27 | |
| 28 | 28 | export { CARD }; |
| 29 | 29 | |
| @@ -290,6 +290,42 @@ function rotateTo(world: World, bot: Player): Site | null { | ||
| 290 | 290 | return site; |
| 291 | 291 | } |
| 292 | 292 | |
| 293 | /** | |
| 294 | * `goal`, or a dropped primary if this bot has an empty primary slot and one | |
| 295 | * is close enough to be worth the walk. `worthTheDetour` (plan.ts) is the | |
| 296 | * whole arithmetic; this only finds the candidates and picks the cheapest. | |
| 297 | * The empty floor — every tick of the training arena — costs one comparison. | |
| 298 | */ | |
| 299 | export function gunGoal(world: World, bot: Player, goal: Vec3): Vec3 { | |
| 300 | if (world.pickups.length === 0 || bot.primary !== null) return goal; | |
| 301 | const toGoal = dist2d(bot.pos, goal); | |
| 302 | let best: Vec3 | null = null; | |
| 303 | let bestDetour = Infinity; | |
| 304 | for (const pickup of world.pickups) { | |
| 305 | if (pickupSlot(pickup.weapon) !== "primary") continue; | |
| 306 | const toGun = dist2d(bot.pos, pickup.pos); | |
| 307 | const gunToGoal = dist2d(pickup.pos, goal); | |
| 308 | if (!worthTheDetour(toGun, gunToGoal, toGoal)) continue; | |
| 309 | const detour = toGun + gunToGoal - toGoal; | |
| 310 | if (detour < bestDetour) { | |
| 311 | bestDetour = detour; | |
| 312 | best = pickup.pos; | |
| 313 | } | |
| 314 | } | |
| 315 | return best ?? goal; | |
| 316 | } | |
| 317 | ||
| 318 | /** | |
| 319 | * How many dropped primaries are on the floor right now — the whole gun | |
| 320 | * signature `repath` needs in its objective string: a rifle appearing or | |
| 321 | * getting taken is a floor that changed, whatever else this bot is doing. | |
| 322 | */ | |
| 323 | function primaryPickupCount(world: World): number { | |
| 324 | let count = 0; | |
| 325 | for (const pickup of world.pickups) if (pickupSlot(pickup.weapon) === "primary") count += 1; | |
| 326 | return count; | |
| 327 | } | |
| 328 | ||
| 293 | 329 | /** Where this bot is trying to be, given what the round is doing. */ |
| 294 | 330 | function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 { |
| 295 | 331 | const bomb = world.bomb; |
| @@ -304,9 +340,11 @@ function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 { | ||
| 304 | 340 | // Hold near the bomb rather than walk onto it. |
| 305 | 341 | return nearby(world, brain, bomb.pos, 300); |
| 306 | 342 | } |
| 343 | // The bomb carrier does not stop for a gun: walking in alone with a rifle | |
| 344 | // costs the round more than walking in with a pistol on time. | |
| 307 | 345 | if (bot.hasBomb) return world.map.sites[brain.site]; |
| 308 | 346 | if (bomb.state === "dropped") return bomb.pos; |
| 309 | return nearby(world, brain, world.map.sites[brain.site], 200); | |
| 347 | return gunGoal(world, bot, nearby(world, brain, world.map.sites[brain.site], 200)); | |
| 310 | 348 | } |
| 311 | 349 | if (bomb.state === "planted") return bomb.pos; |
| 312 | 350 | // The contact rotation is this side reading the round for itself — and it |
| @@ -319,9 +357,9 @@ function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 { | ||
| 319 | 357 | // meets on the way. A death somewhere is better information than a guess, |
| 320 | 358 | // so a contact still wins. |
| 321 | 359 | if (contact === null && hunting(world, bot, brain)) { |
| 322 | return nearby(world, brain, world.map.muster.t, HUNT_SPREAD); | |
| 360 | return gunGoal(world, bot, nearby(world, brain, world.map.muster.t, HUNT_SPREAD)); | |
| 323 | 361 | } |
| 324 | return nearby(world, brain, world.map.sites[contact ?? brain.site], 250); | |
| 362 | return gunGoal(world, bot, nearby(world, brain, world.map.sites[contact ?? brain.site], 250)); | |
| 325 | 363 | } |
| 326 | 364 | |
| 327 | 365 | const nearby = (world: World, brain: BotBrain, at: Vec3, spread: number): Vec3 => { |
| @@ -507,10 +545,13 @@ function repath(world: World, bot: Player, brain: BotBrain): void { | ||
| 507 | 545 | // agree with the roll still has to be a new objective to turn it round. |
| 508 | 546 | // The hunt is part of it too: a counter-terrorist that has just run out of |
| 509 | 547 | // clock is walking to a goal its site does not name, and nothing else in |
| 510 | // this string would have changed to send it there. | |
| 548 | // this string would have changed to send it there. The gun count only | |
| 549 | // matters to a bot with an empty primary slot — one that already has a | |
| 550 | // rifle never diverts, so the floor changing under it is not its news. | |
| 511 | 551 | const objective = |
| 512 | 552 | `${world.bomb.state}:${brain.site}${brain.holding ? ":hold" : ""}` + |
| 513 | `${brain.ordered ? ":called" : ""}${hunting(world, bot, brain) ? ":hunt" : ""}`; | |
| 553 | `${brain.ordered ? ":called" : ""}${hunting(world, bot, brain) ? ":hunt" : ""}` + | |
| 554 | `${bot.primary === null ? `:guns${primaryPickupCount(world)}` : ""}`; | |
| 514 | 555 | const stale = |
| 515 | 556 | brain.path.length === 0 || brain.step >= brain.path.length || |
| 516 | 557 | world.time > brain.repathAt || objective !== brain.plannedFor; |