Split The code is public; the app answers only to the crew.
Thirty per cent of rounds end on the clock: bots that feel the round timer #137
Check · Review · Land · Live done — Landed and live in production.
vesper/cs:vesper/105-thirty-per-cent-of-rounds-end-on-the-clo → main 4 commits fork 2 threads · 2 unresolved
README.md +36 −16
| @@ -455,12 +455,24 @@ number rather than by each bot for itself, so the five terrorists arrive | ||
| 455 | 455 | together and the carrier is not walking in alone. The counter-terrorists stack |
| 456 | 456 | that roll three and two, so neither site is ever empty, and they turn towards |
| 457 | 457 | the site the last of them died at — the kill feed and the radar, nothing they |
| 458 | could not see. The clock is part of it: once what is left of the round is no | |
| 459 | more than the walk plus the plant, the carrier stops trading ground for duels | |
| 460 | and goes, with the policy still aiming and still firing on the way. Measured | |
| 461 | headless over twenty seeded `SHORT` matches, before and after: the bomb is | |
| 462 | planted in 65% of rounds rather than 38%, and 10% of rounds end on the clock | |
| 463 | rather than 37%. | |
| 458 | could not see. Rolling one site a side rather than one a bot took the plant | |
| 459 | rate from 38% to 65% over twenty seeded `SHORT` matches when it landed. | |
| 460 | ||
| 461 | The clock is part of it, and both sides read it. `plan.ts` holds the two | |
| 462 | numbers that say how late is late, and nothing else in the code decides it. | |
| 463 | `PUSH_WINDOW` (12 seconds) is the fighting the terrorists can still afford on | |
| 464 | the way in: once what is left of the round is the walk, the plant and that | |
| 465 | window, the whole side — not just the carrier — stops trading ground for duels | |
| 466 | and walks onto the site, with the policy still aiming and still firing on the | |
| 467 | way. `HUNT_AT` (42 seconds) is the mirror of it: with the clock that low and no | |
| 468 | bomb on the floor, the terrorists are not coming to the corner a defender is | |
| 469 | sitting in, so the defence leaves it and sweeps the half of the map they came | |
| 470 | from (`muster` in `map.ts`, the middle of a side's spawn). A call outranks | |
| 471 | both: *hold* stops the push where it stands, and *go A* names the room to hunt | |
| 472 | in. Measured headless over 30 seeded `SHORT` matches on seed 41, before and | |
| 473 | after: rounds that end on the clock fall from **25% to 9%**, the plant rate | |
| 474 | holds at 61–62%, and detonations hold at 103–105 — what those rounds turn into | |
| 475 | is a fight somebody wins rather than a timer nobody does. | |
| 464 | 476 | |
| 465 | 477 | A person gets a say over their own side's half of that through the radio |
| 466 | 478 | (see *Playing*). A call is one line of data on the world — the side, the word, |
| @@ -574,16 +586,24 @@ decided, so it is the freeze time plus whatever of the round clock and the | ||
| 574 | 586 | bomb's fuse actually ran — not the 115-second round clock alone, which is why |
| 575 | 587 | the longest round below is well past it. |
| 576 | 588 | |
| 577 | Baseline, at commit **861e20b**, the defaults (12 seeded `SHORT` matches, | |
| 578 | `normal` bots, 89 rounds): a plant in **60%** of rounds, ends split | |
| 579 | `detonated 38 · time 24 · elimination 15 · defused 12`, **23%** of planted | |
| 580 | bombs defused, median round **123 s**, longest **156 s**, and about 6 seconds | |
| 581 | of wall clock a match — which is why this is a script and not a test. The | |
| 582 | person is idle the whole match, so the count is effectively 5-on-4 in the | |
| 583 | terrorists' favour; treat the split between `detonated` and `elimination` | |
| 584 | accordingly. This baseline drifts whenever the bots or the blast do — it | |
| 585 | was 13% defused before #66 and #64 landed — so re-run it rather than trust | |
| 586 | it after a change to either. | |
| 589 | Baseline, `--matches 30 --seed 41`, `normal` bots, 223 rounds: a plant in | |
| 590 | **62%** of rounds, ends split `detonated 105 · time 21 · elimination 72 · | |
| 591 | defused 25`, **18%** of planted bombs defused, median round **123 s**, longest | |
| 592 | **144 s**, and about 6 seconds of wall clock a match — which is why this is a | |
| 593 | script and not a test. Before the two sides read the clock (#105) the same run | |
| 594 | was `detonated 103 · time 55 · elimination 39 · defused 19` over 216 rounds: | |
| 595 | a quarter of rounds decided by nothing at all. | |
| 596 | ||
| 597 | Two things to hold on to when reading that table. The person is idle the whole | |
| 598 | match, so the count is effectively 5-on-4 in the terrorists' favour — and once | |
| 599 | the sides swap at half time, the idle person is a terrorist standing in spawn, | |
| 600 | sometimes holding the bomb. Those rounds cannot be planted and cannot end in a | |
| 601 | terrorist win; 43 of the 55 rounds that ended on the clock in the run above | |
| 602 | were that. What ends them now is a counter-terrorist that has run out of clock | |
| 603 | walking over to find out why nobody has come, which is most of why | |
| 604 | `elimination` nearly doubled. The rest of the baseline drifts whenever the | |
| 605 | bots or the blast do — it was 13% defused before #66 and #64 landed — so | |
| 606 | re-run it rather than trust it after a change to either. | |
| 587 | 607 | |
| 588 | 608 | ## Measuring the tick |
| 589 | 609 |
src/client/bots.test.ts +197 −1
| @@ -5,7 +5,7 @@ import { | ||
| 5 | 5 | import { DE_FORGE } from "./map.ts"; |
| 6 | 6 | import { dist2d } from "./math.ts"; |
| 7 | 7 | import { COMPETITIVE, SHORT } from "./match.ts"; |
| 8 | import { CALL_LIFE, otherSite } from "./plan.ts"; | |
| 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 | 11 | type Player, |
| @@ -393,3 +393,199 @@ test("a call outranks the rotation a counter-terrorist would have made on its ow | ||
| 393 | 393 | thinkAll(world); |
| 394 | 394 | for (const ct of standing) expect(near(ct, "B")).toBeLessThan(400); |
| 395 | 395 | }); |
| 396 | ||
| 397 | // --- the clock ------------------------------------------------------------ | |
| 398 | ||
| 399 | /** | |
| 400 | * The terrorists walking in with the bomb and the clock wherever the test puts | |
| 401 | * it, with one counter-terrorist standing in front of the carrier so that the | |
| 402 | * branch every terrorist is in is the fight — the one that trades ground for a | |
| 403 | * duel. Nothing is planted, and the person is a terrorist so that their side | |
| 404 | * is the one the radio reaches. | |
| 405 | */ | |
| 406 | function underFire(secondsLeft: number, seed = 20240): { | |
| 407 | world: ReturnType<typeof createWorld>; carrier: Player; enemy: Player; | |
| 408 | } { | |
| 409 | const world = createWorld(SHORT, "you", "t", 3, seed); | |
| 410 | world.match.phase = "live"; | |
| 411 | world.match.timer = secondsLeft; | |
| 412 | const carrier = world.players.find((p) => p.team === "t" && p.bot && p.hasBomb) as Player; | |
| 413 | const enemy = world.players.find((p) => p.team === "ct") as Player; | |
| 414 | for (const p of world.players) if (p.team === "ct" && p.id !== enemy.id) p.alive = false; | |
| 415 | // Close enough to be unmissable, and the carrier already looking at it. | |
| 416 | enemy.pos = [carrier.pos[0] + 160, carrier.pos[1], carrier.pos[2]]; | |
| 417 | carrier.yaw = aimAt(carrier, bodyCentre(enemy)).yaw; | |
| 418 | return { world, carrier, enemy }; | |
| 419 | } | |
| 420 | ||
| 421 | test("with the clock low and no plant, a terrorist's feet go to the site", () => { | |
| 422 | const { world, carrier } = underFire(25); | |
| 423 | botThink(world, carrier, 1 / 64); | |
| 424 | expect(carrier.brain?.targetId).not.toBeNull(); // it really is in a fight | |
| 425 | // The policy still has the gun; the plan has taken the feet, and they are | |
| 426 | // walking to the site the round rolled. | |
| 427 | expect(carrier.brain?.goal).toEqual(DE_FORGE.sites[carrier.brain!.site]); | |
| 428 | expect(carrier.brain?.path.length).toBeGreaterThan(0); | |
| 429 | }); | |
| 430 | ||
| 431 | test("with the clock high the same fight is the policy's to move", () => { | |
| 432 | const { world, carrier } = underFire(110); | |
| 433 | botThink(world, carrier, 1 / 64); | |
| 434 | expect(carrier.brain?.targetId).not.toBeNull(); // the same fight, one number apart | |
| 435 | expect(carrier.brain?.goal).toBeNull(); // nothing asked the plan for a path | |
| 436 | }); | |
| 437 | ||
| 438 | test("the escort feels the clock too, not just the bot holding the bomb", () => { | |
| 439 | const { world, carrier } = underFire(25); | |
| 440 | const escort = world.players.find((p) => p.team === "t" && p.bot && p.id !== carrier.id) as Player; | |
| 441 | escort.pos = [...carrier.pos] as [number, number, number]; | |
| 442 | escort.yaw = carrier.yaw; | |
| 443 | botThink(world, escort, 1 / 64); | |
| 444 | expect(escort.brain?.targetId).not.toBeNull(); | |
| 445 | expect(dist2d(escort.brain?.goal ?? [0, 0, 0], DE_FORGE.sites[escort.brain!.site])).toBeLessThan(400); | |
| 446 | }); | |
| 447 | ||
| 448 | test("a call is answered on a hard push: the push goes where the person said", () => { | |
| 449 | const { world, carrier } = underFire(25); | |
| 450 | const called = otherSite(world.plan.push); | |
| 451 | world.time += 1; | |
| 452 | radioCall(world, called); | |
| 453 | botThink(world, carrier, 1 / 64); | |
| 454 | expect(carrier.brain?.site).toBe(called); | |
| 455 | expect(carrier.brain?.goal).toEqual(DE_FORGE.sites[called]); | |
| 456 | }); | |
| 457 | ||
| 458 | test("hold outranks the clock: a bot told to stand still is not dragged off", () => { | |
| 459 | const { world, carrier } = underFire(25); | |
| 460 | world.time += 1; | |
| 461 | radioCall(world, "hold"); | |
| 462 | botThink(world, carrier, 1 / 64); | |
| 463 | expect(carrier.brain?.holding).toBe(true); | |
| 464 | // It fights where it was told to stand: nothing asked the plan for a path | |
| 465 | // to the site, which is what the same clock does to a bot nobody called. | |
| 466 | expect(carrier.brain?.goal).toBeNull(); | |
| 467 | }); | |
| 468 | ||
| 469 | test("once the bomb is down the clock stops moving the terrorists' feet", () => { | |
| 470 | const { world, carrier } = underFire(25); | |
| 471 | // The same round one moment later: the bomb is on the floor at the site and | |
| 472 | // the carrier is in the fight it was already in. | |
| 473 | world.bomb = { | |
| 474 | ...world.bomb, | |
| 475 | state: "planted", | |
| 476 | pos: [...DE_FORGE.sites[carrier.brain?.site ?? "A"]] as [number, number, number], | |
| 477 | }; | |
| 478 | carrier.hasBomb = false; | |
| 479 | botThink(world, carrier, 1 / 64); | |
| 480 | expect(carrier.brain?.targetId).not.toBeNull(); | |
| 481 | // There is nothing left to be late for. Time pressure is off, so nothing | |
| 482 | // walks a terrorist at the bomb in the middle of a duel it is having — | |
| 483 | // holding near a planted bomb is not the same thing as standing on it. | |
| 484 | expect(carrier.brain?.goal).toBeNull(); | |
| 485 | }); | |
| 486 | ||
| 487 | /** The defence with nothing to shoot at and nothing on the floor to defend. */ | |
| 488 | function quietDefence(secondsLeft: number, seed = 31): ReturnType<typeof createWorld> { | |
| 489 | const world = createWorld(SHORT, "you", "ct", 3, seed); | |
| 490 | world.match.phase = "live"; | |
| 491 | world.match.timer = secondsLeft; | |
| 492 | for (const p of world.players) if (p.team === "t") p.alive = false; | |
| 493 | return world; | |
| 494 | } | |
| 495 | ||
| 496 | const defenders = (world: ReturnType<typeof createWorld>): Player[] => | |
| 497 | world.players.filter((p) => p.team === "ct" && p.bot); | |
| 498 | ||
| 499 | test("with the clock low and no plant, the defence stops holding and goes looking", () => { | |
| 500 | const late = quietDefence(HUNT_AT - 1); | |
| 501 | thinkAll(late); | |
| 502 | for (const ct of defenders(late)) { | |
| 503 | const goal = ct.brain?.goal ?? [0, 0, 0]; | |
| 504 | // Nobody has come to the corner it was sitting in, so it walks at the half | |
| 505 | // of the map the terrorists came from — and it walks, rather than teleports. | |
| 506 | expect(dist2d(goal, DE_FORGE.muster.t)).toBeLessThan(1000); | |
| 507 | expect(dist2d(goal, DE_FORGE.sites[ct.brain!.site])).toBeGreaterThan(2000); | |
| 508 | expect(ct.brain?.path.length).toBeGreaterThan(0); | |
| 509 | } | |
| 510 | ||
| 511 | const early = quietDefence(HUNT_AT + 1); | |
| 512 | thinkAll(early); | |
| 513 | for (const ct of defenders(early)) { | |
| 514 | expect(dist2d(ct.brain?.goal ?? [0, 0, 0], DE_FORGE.sites[ct.brain!.site])).toBeLessThan(400); | |
| 515 | } | |
| 516 | }); | |
| 517 | ||
| 518 | test("the defence leaves its angle the tick the clock says so, not at the next repath", () => { | |
| 519 | const world = quietDefence(HUNT_AT + 10); | |
| 520 | thinkAll(world); | |
| 521 | const holding = defenders(world).map((ct) => { | |
| 522 | const brain = ct.brain as NonNullable<Player["brain"]>; | |
| 523 | // A path it is part-way through and a repath it is nowhere near due for: | |
| 524 | // nothing about this bot is stale, which is the whole point of the test. | |
| 525 | expect(dist2d(brain.goal ?? [0, 0, 0], DE_FORGE.sites[brain.site])).toBeLessThan(400); | |
| 526 | expect(brain.path.length).toBeGreaterThan(0); | |
| 527 | expect(brain.step).toBeLessThan(brain.path.length); | |
| 528 | expect(world.time).toBeLessThan(brain.repathAt); | |
| 529 | return { ct, brain, plannedFor: brain.plannedFor }; | |
| 530 | }); | |
| 531 | expect(holding.length).toBeGreaterThan(0); | |
| 532 | ||
| 533 | // Eleven seconds of clock and not a tick of anything else. The objective it | |
| 534 | // planned for has to say it is hunting, or the defence sits on its angle for | |
| 535 | // the rest of its repath window with the round running out. | |
| 536 | world.match.timer = HUNT_AT - 1; | |
| 537 | thinkAll(world); | |
| 538 | for (const { brain, plannedFor } of holding) { | |
| 539 | expect(brain.plannedFor).not.toBe(plannedFor); | |
| 540 | expect(dist2d(brain.goal ?? [0, 0, 0], DE_FORGE.muster.t)).toBeLessThan(1000); | |
| 541 | } | |
| 542 | }); | |
| 543 | ||
| 544 | test("a call turns a rotating defender round inside its repath window", () => { | |
| 545 | // The person is a counter-terrorist, a teammate has just died at B, and the | |
| 546 | // side is rotating there on its own. The person calls A — which is the site | |
| 547 | // this bot was rolled onto anyway, so nothing about its orders changes | |
| 548 | // except that a person said them. | |
| 549 | const world = quietDefence(SHORT.roundTime); // nothing hunting at this clock | |
| 550 | const fallen = defenders(world)[0] as Player; | |
| 551 | fallen.alive = false; | |
| 552 | fallen.pos = [...DE_FORGE.sites.B] as [number, number, number]; | |
| 553 | world.feed.push({ | |
| 554 | at: world.time, killer: "them", killerId: 9, victim: fallen.name, victimId: fallen.id, | |
| 555 | weapon: "ak47", headshot: false, killerTeam: "t", | |
| 556 | }); | |
| 557 | thinkAll(world); | |
| 558 | const rotated = defenders(world).filter((p) => p.alive && p.brain?.site === "A"); | |
| 559 | expect(rotated.length).toBeGreaterThan(0); | |
| 560 | for (const ct of rotated) { | |
| 561 | expect(dist2d(ct.brain?.goal ?? [0, 0, 0], DE_FORGE.sites.B)).toBeLessThan(400); | |
| 562 | expect(world.time).toBeLessThan(ct.brain!.repathAt); // the path is fresh | |
| 563 | } | |
| 564 | ||
| 565 | radioCall(world, "A"); | |
| 566 | thinkAll(world); | |
| 567 | for (const ct of rotated) { | |
| 568 | expect(ct.brain?.site).toBe("A"); // the call named the site it already had | |
| 569 | expect(dist2d(ct.brain?.goal ?? [0, 0, 0], DE_FORGE.sites.A)).toBeLessThan(400); | |
| 570 | } | |
| 571 | }); | |
| 572 | ||
| 573 | test("a bomb on the floor is what the defence is for: no hunt once it is planted", () => { | |
| 574 | const world = quietDefence(HUNT_AT - 1); | |
| 575 | world.bomb = { ...world.bomb, state: "planted", pos: [...DE_FORGE.sites.A] as [number, number, number] }; | |
| 576 | thinkAll(world); | |
| 577 | for (const ct of defenders(world)) { | |
| 578 | expect(dist2d(ct.brain?.goal ?? [0, 0, 0], DE_FORGE.sites.A)).toBeLessThan(200); | |
| 579 | } | |
| 580 | }); | |
| 581 | ||
| 582 | test("a defender told to hold keeps holding however low the clock is", () => { | |
| 583 | const world = quietDefence(HUNT_AT - 1); | |
| 584 | world.time += 1; | |
| 585 | radioCall(world, "hold"); | |
| 586 | thinkAll(world); | |
| 587 | for (const ct of defenders(world)) { | |
| 588 | expect(ct.brain?.holding).toBe(true); | |
| 589 | expect(dist2d(ct.pos, ct.brain?.goal ?? [0, 0, 0])).toBeLessThanOrEqual(360); | |
| 590 | } | |
| 591 | }); |
src/client/bots.ts +67 −11
| @@ -16,7 +16,7 @@ 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 { mustCommit, ordersFor, type Site } from "./plan.ts"; | |
| 19 | import { mustHunt, mustPush, ordersFor, type Site } from "./plan.ts"; | |
| 20 | 20 | import { |
| 21 | 21 | forward, newActivations, sample, unpack, type Activations, type Policy, |
| 22 | 22 | } from "./policy.ts"; |
| @@ -199,18 +199,62 @@ function pathLeft(brain: BotBrain, from: Vec3): number { | ||
| 199 | 199 | const DETOUR = 1.3; |
| 200 | 200 | |
| 201 | 201 | /** |
| 202 | * Has the carrier run out of clock? What is left of the round is no more than | |
| 203 | * the walk to its site plus the plant, so a duel on the way is a round lost on | |
| 204 | * the timer. The bot that reads true here keeps its gun — the policy still | |
| 205 | * points it and still fires it — but its feet stop negotiating. | |
| 202 | * How far this bot still has to walk to where the round wants it, in seconds | |
| 203 | * of clock: the longer of the crow flight around a wall and what is left of | |
| 204 | * the path it has already planned. | |
| 206 | 205 | */ |
| 207 | function committed(world: World, bot: Player, brain: BotBrain): boolean { | |
| 208 | if (!bot.hasBomb || world.match.phase !== "live") return false; | |
| 206 | function walkLeft(world: World, bot: Player, brain: BotBrain): number { | |
| 209 | 207 | const crow = dist2d(bot.pos, world.map.sites[brain.site]) * DETOUR; |
| 210 | const walk = Math.max(crow, pathLeft(brain, bot.pos)); | |
| 211 | return mustCommit(world.match.timer, walk, world.match.rules.plantTime); | |
| 208 | return Math.max(crow, pathLeft(brain, bot.pos)); | |
| 212 | 209 | } |
| 213 | 210 | |
| 211 | /** | |
| 212 | * Has a person told this bot to stand still? "Hold" is the one call the clock | |
| 213 | * does not overrule: a teammate that walked off mid-sentence because the timer | |
| 214 | * said so is a teammate you cannot use. The other two calls need no exception | |
| 215 | * — a bot under time pressure walks to `brain.site`, and a call is what | |
| 216 | * `brain.site` says — so somebody who calls A at 0:20 gets the push, at A. | |
| 217 | */ | |
| 218 | const heldInPlace = (brain: BotBrain): boolean => brain.holding; | |
| 219 | ||
| 220 | /** | |
| 221 | * Time pressure on the terrorists. What is left of the round is no more than | |
| 222 | * the walk to the site, the plant, and `PUSH_WINDOW` seconds of fighting — so | |
| 223 | * a duel on the way is a round lost on the timer. The whole side reads it, not | |
| 224 | * just the carrier: an escort still trading angles in mid is the carrier | |
| 225 | * walking in alone. A bot that reads true keeps its gun — the policy still | |
| 226 | * points it and still fires it — but its feet stop negotiating and take it to | |
| 227 | * the site. | |
| 228 | */ | |
| 229 | function pushing(world: World, bot: Player, brain: BotBrain): boolean { | |
| 230 | if (bot.team !== "t" || world.match.phase !== "live" || heldInPlace(brain)) return false; | |
| 231 | if (world.bomb.state === "planted") return false; | |
I anchor this on the line that needs the test. I checked this out at What holds up. Fix this first. Second, same kind. Small. Not asking for anything here, for the record. The issue's "median round comes down" box is not met — 123 s both sides of the change — and elimination nearly doubles, 39 to 72. Both are stated plainly in the pull with reasons I reproduced and agree with: a detonation is the plant plus a 40 s fuse and This is close. One test for the post-plant guard, one for the hunt's objective string, and I am happy. |
||
| 232 | return mustPush(world.match.timer, walkLeft(world, bot, brain), world.match.rules.plantTime); | |
| 233 | } | |
| 234 | ||
| 235 | /** | |
| 236 | * Time pressure on the counter-terrorists, which is the mirror of it: with the | |
| 237 | * clock this low and no bomb on the floor, the terrorists are not coming to | |
| 238 | * the corner this bot is sitting in — they are somewhere else, running it out. | |
| 239 | * So it stops holding and goes to look, and `chooseGoal` says where. | |
| 240 | */ | |
| 241 | function hunting(world: World, bot: Player, brain: BotBrain): boolean { | |
| 242 | if (bot.team !== "ct" || world.match.phase !== "live") return false; | |
| 243 | // Both calls outrank this one, and for the same reason: a hunt is a guess | |
| 244 | // about which room the terrorists are in, and a person who has just said | |
| 245 | // "go A" or "hold" has made that guess already, out loud. | |
| 246 | if (heldInPlace(brain) || brain.ordered) return false; | |
| 247 | if (world.bomb.state === "planted") return false; | |
| 248 | return mustHunt(world.match.timer); | |
| 249 | } | |
| 250 | ||
| 251 | /** | |
| 252 | * How wide a hunt fans out around the terrorists' own half. Enough that five | |
| 253 | * counter-terrorists sweep it rather than queue down one corridor into the | |
| 254 | * same angle, and not so much that they stop covering each other. | |
| 255 | */ | |
| 256 | const HUNT_SPREAD = 700; | |
| 257 | ||
| 214 | 258 | /** A death this far from a site centre is a fight over that site. */ |
| 215 | 259 | const CONTACT_RANGE = 1600; |
| 216 | 260 | |
| @@ -269,6 +313,14 @@ function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 { | ||
| 269 | 313 | // gives way to a person who has just said where to be. Somebody dying at B |
| 270 | 314 | // is why you called A; it is not an argument against the call. |
| 271 | 315 | const contact = brain.ordered ? null : rotateTo(world, bot); |
| 316 | // Nothing has happened and the clock is nearly out: whatever the terrorists | |
| 317 | // are doing they are not doing it here, or this bot would have seen them by | |
| 318 | // now. So it walks at the half of the map they came from and takes what it | |
| 319 | // meets on the way. A death somewhere is better information than a guess, | |
| 320 | // so a contact still wins. | |
| 321 | if (contact === null && hunting(world, bot, brain)) { | |
| 322 | return nearby(world, brain, world.map.muster.t, HUNT_SPREAD); | |
| 323 | } | |
| 272 | 324 | return nearby(world, brain, world.map.sites[contact ?? brain.site], 250); |
| 273 | 325 | } |
| 274 | 326 | |
| @@ -453,8 +505,12 @@ function repath(world: World, bot: Player, brain: BotBrain): void { | ||
| 453 | 505 | // `chooseGoal`: a counter-terrorist that has rotated off its own site is |
| 454 | 506 | // walking to a goal its site no longer names, so a call that happens to |
| 455 | 507 | // agree with the roll still has to be a new objective to turn it round. |
| 508 | // The hunt is part of it too: a counter-terrorist that has just run out of | |
| 509 | // 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. | |
| 456 | 511 | const objective = |
| 457 | `${world.bomb.state}:${brain.site}${brain.holding ? ":hold" : ""}${brain.ordered ? ":called" : ""}`; | |
| 512 | `${world.bomb.state}:${brain.site}${brain.holding ? ":hold" : ""}` + | |
| 513 | `${brain.ordered ? ":called" : ""}${hunting(world, bot, brain) ? ":hunt" : ""}`; | |
| 458 | 514 | const stale = |
| 459 | 515 | brain.path.length === 0 || brain.step >= brain.path.length || |
| 460 | 516 | world.time > brain.repathAt || objective !== brain.plannedFor; |
| @@ -540,7 +596,7 @@ export function botThink(world: World, bot: Player, dt: number): Input { | ||
| 540 | 596 | // decisions and each decision is worth one pull. |
| 541 | 597 | const held = WEAPONS[bot.weapon]?.automatic !== false || brain.hold === SKIP - 1; |
| 542 | 598 | input.fire = intent.fire && held && world.time - brain.seenAt > brain.reaction; |
| 543 | if (committed(world, bot, brain)) { | |
| 599 | if (pushing(world, bot, brain)) { | |
| 544 | 600 | // Out of clock: the policy keeps the gun, the plan takes the feet. It |
| 545 | 601 | // runs to the site shooting at whatever it is looking at rather than |
| 546 | 602 | // trading ground for a duel it cannot afford to win slowly. |
src/client/map.test.ts +16
| @@ -10,6 +10,22 @@ test("the map has two sites and two spawns", () => { | ||
| 10 | 10 | expect(siteAt(DE_FORGE, DE_FORGE.sites.B)).toBe("B"); |
| 11 | 11 | }); |
| 12 | 12 | |
| 13 | test("each side musters in its own spawn, on a cell you can walk to", () => { | |
| 14 | for (const team of ["t", "ct"] as const) { | |
| 15 | const at = DE_FORGE.muster[team]; | |
| 16 | expect(cellAt(DE_FORGE, colOf(DE_FORGE, at[0]), rowOf(DE_FORGE, at[1]))).toBe(team === "t" ? "t" : "c"); | |
| 17 | expect(walkable(DE_FORGE, colOf(DE_FORGE, at[0]), rowOf(DE_FORGE, at[1]))).toBe(true); | |
| 18 | // And it is somewhere a bot can reach from either site, which is what | |
| 19 | // a counter-terrorist out of clock asks the pathfinder for. | |
| 20 | for (const site of [DE_FORGE.sites.A, DE_FORGE.sites.B]) { | |
| 21 | expect(findPath(DE_FORGE, site, at).length).toBeGreaterThan(0); | |
| 22 | } | |
| 23 | } | |
| 24 | // The two halves of the map really are two halves: T at the bottom of the | |
| 25 | // grid, CT at the top, the sites between them. | |
| 26 | expect(DE_FORGE.muster.t[1]).toBeLessThan(DE_FORGE.muster.ct[1]); | |
| 27 | }); | |
| 28 | ||
| 13 | 29 | test("the border is solid all the way round", () => { |
| 14 | 30 | for (let c = 0; c < DE_FORGE.gridW; c++) { |
| 15 | 31 | expect(cellAt(DE_FORGE, c, 0)).toBe("#"); |
src/client/map.ts +13
| @@ -43,6 +43,17 @@ export type MapDef = { | ||
| 43 | 43 | /** Everything solid, in one list: the renderer draws it and bullets stop in it. */ |
| 44 | 44 | boxes: Box[]; |
| 45 | 45 | spawns: { t: Vec3[]; ct: Vec3[] }; |
| 46 | /** | |
| 47 | * The middle of each side's spawn: one point to name the half of the map a | |
| 48 | * side comes from. A site is where the round is decided; this is where the | |
| 49 | * other side must be when it is not there yet, which is what a bot with a | |
| 50 | * running-out clock and nothing to shoot at wants to know. | |
| 51 | * | |
| 52 | * Both halves, because it is a fact about the map and `spawns` already has | |
| 53 | * both — the average of a list this file has built either way. Only `t` is | |
| 54 | * read today, by a counter-terrorist out of clock in `bots.ts`. | |
| 55 | */ | |
| 56 | muster: { t: Vec3; ct: Vec3 }; | |
| 46 | 57 | sites: { A: Vec3; B: Vec3 }; |
| 47 | 58 | }; |
| 48 | 59 | |
| @@ -187,6 +198,7 @@ export function buildMap(layout: Layout): MapDef { | ||
| 187 | 198 | grid: buildGrid(layout), |
| 188 | 199 | boxes: [], |
| 189 | 200 | spawns: { t: [], ct: [] }, |
| 201 | muster: { t: [0, 0, 0], ct: [0, 0, 0] }, | |
| 190 | 202 | sites: { A: [0, 0, 0], B: [0, 0, 0] }, |
| 191 | 203 | }; |
| 192 | 204 | const floor: Box = { |
| @@ -203,6 +215,7 @@ export function buildMap(layout: Layout): MapDef { | ||
| 203 | 215 | ...mergeSolid(map, (c) => c === "X", 0, 128, MAT.CRATE), |
| 204 | 216 | ]; |
| 205 | 217 | map.spawns = { t: cellsOf(map, "t"), ct: cellsOf(map, "c") }; |
| 218 | map.muster = { t: centre(map.spawns.t), ct: centre(map.spawns.ct) }; | |
| 206 | 219 | map.sites = { A: centre(cellsOf(map, "A")), B: centre(cellsOf(map, "B")) }; |
| 207 | 220 | return map; |
| 208 | 221 | } |
src/client/plan.test.ts +25 −9
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | 2 | import { COMPETITIVE, type Team } from "./match.ts"; |
| 3 | 3 | import { |
| 4 | CALL_LIFE, CALLS, callLabel, COMMIT_MARGIN, isCall, liveCall, mustCommit, ordersFor, otherSite, | |
| 5 | roundPlan, siteFor, timeToPlant, WALK_SPEED, type RadioCall, | |
| 4 | CALL_LIFE, CALLS, callLabel, COMMIT_MARGIN, HUNT_AT, isCall, liveCall, mustHunt, mustPush, | |
| 5 | ordersFor, otherSite, PUSH_WINDOW, roundPlan, siteFor, timeToPlant, WALK_SPEED, type RadioCall, | |
| 6 | 6 | } from "./plan.ts"; |
| 7 | 7 | |
| 8 | 8 | test("a side gets one site for the round, and every slot on it reads the same one", () => { |
| @@ -53,16 +53,32 @@ test("the time a plant needs is the walk, the plant itself and a little slack", | ||
| 53 | 53 | expect(timeToPlant(-500, plantTime)).toBe(timeToPlant(0, plantTime)); |
| 54 | 54 | }); |
| 55 | 55 | |
| 56 | test("the carrier only commits when the clock has no room for a fight left", () => { | |
| 56 | test("the terrorists push once the clock is the walk, the plant and one fight", () => { | |
| 57 | 57 | const { plantTime, roundTime } = COMPETITIVE; |
| 58 | 58 | const walk = 2400; // roughly T spawn to a site |
| 59 | expect(mustCommit(roundTime, walk, plantTime)).toBe(false); | |
| 60 | expect(mustCommit(timeToPlant(walk, plantTime) + 0.1, walk, plantTime)).toBe(false); | |
| 61 | expect(mustCommit(timeToPlant(walk, plantTime), walk, plantTime)).toBe(true); | |
| 59 | const latest = timeToPlant(walk, plantTime) + PUSH_WINDOW; | |
| 60 | expect(mustPush(roundTime, walk, plantTime)).toBe(false); | |
| 61 | expect(mustPush(latest + 0.1, walk, plantTime)).toBe(false); | |
| 62 | expect(mustPush(latest, walk, plantTime)).toBe(true); | |
| 63 | // The window is the fight the side may still have on the way, so a push | |
| 64 | // starts exactly that many seconds before the last possible plant. | |
| 65 | expect(latest - timeToPlant(walk, plantTime)).toBeCloseTo(PUSH_WINDOW, 5); | |
| 62 | 66 | // Standing on the site is the cheapest walk there is, so it is the last |
| 63 | // thing anyone has to hurry for; the far end of the map is the first. | |
| 64 | expect(mustCommit(20, 0, plantTime)).toBe(false); | |
| 65 | expect(mustCommit(20, 4000, plantTime)).toBe(true); | |
| 67 | // thing anyone has to hurry for; the far end of the map is the first. At the | |
| 68 | // moment the far one has to leave, the near one still has time to fight. | |
| 69 | const far = timeToPlant(4000, plantTime) + PUSH_WINDOW; | |
| 70 | expect(mustPush(far, 4000, plantTime)).toBe(true); | |
| 71 | expect(mustPush(far, 0, plantTime)).toBe(false); | |
| 72 | }); | |
| 73 | ||
| 74 | test("the counter-terrorists stop holding at one number, whatever the walk", () => { | |
| 75 | expect(mustHunt(HUNT_AT + 0.1)).toBe(false); | |
| 76 | expect(mustHunt(HUNT_AT)).toBe(true); | |
| 77 | expect(mustHunt(0)).toBe(true); | |
| 78 | // Late enough that the terrorists have somewhere to be, early enough that | |
| 79 | // a counter-terrorist can cross the map and still meet them. | |
| 80 | expect(HUNT_AT).toBeLessThan(COMPETITIVE.roundTime / 2); | |
| 81 | expect(HUNT_AT).toBeGreaterThan(COMPETITIVE.plantTime + COMPETITIVE.bombTime / 2); | |
| 66 | 82 | }); |
| 67 | 83 | |
| 68 | 84 | // --- the radio ------------------------------------------------------------ |
src/client/plan.ts +55 −8
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | * The round's plan: which site each side commits to, and when the carrier has | |
| 3 | * run out of time to argue about it. | |
| 2 | * The round's plan: which site each side commits to, and what the round clock | |
| 3 | * running down says each of them should do about it. | |
| 4 | 4 | * |
| 5 | 5 | * A side that rolls a site per bot is five people walking five ways, and the |
| 6 | 6 | * one holding the bomb walks in alone — which is why a third of rounds used to |
| @@ -132,11 +132,58 @@ export const COMMIT_MARGIN = 3; | ||
| 132 | 132 | export const timeToPlant = (distance: number, plantTime: number): number => |
| 133 | 133 | Math.max(0, distance) / WALK_SPEED + plantTime + COMMIT_MARGIN; |
| 134 | 134 | |
| 135 | // --- time pressure -------------------------------------------------------- | |
| 136 | // | |
| 137 | // A quarter of rounds used to end on the clock: the terrorists traded angles | |
| 138 | // on the way in until there was no round left, and the counter-terrorists held | |
| 139 | // a corner that was correct at 1:30 and a loss at 0:20 while the last man on | |
| 140 | // the other side ran the clock out somewhere else entirely. Nobody was reading | |
| 141 | // the clock. | |
| 142 | // | |
| 143 | // So both sides read it here, and the two numbers that say how late is late | |
| 144 | // are the two constants below. Raise them and the round resolves sooner and | |
| 145 | // more people die walking into an angle; lower them and the round runs out. | |
| 146 | // Nothing else in the codebase decides this — `scripts/measure.ts` is how you | |
| 147 | // find out what moving one of them costs (30 matches on seed 41 is the run | |
| 148 | // the README quotes). | |
| 149 | ||
| 135 | 150 | /** |
| 136 | * True when the round clock no longer has room for a fight on the way: what is | |
| 137 | * left is not much more than the walk plus the plant. The carrier that reads | |
| 138 | * true keeps its gun — the policy still points it — but stops trading ground | |
| 139 | * for a duel and goes. | |
| 151 | * Seconds of fighting the terrorists may still spend on the way in before the | |
| 152 | * walk itself is all the clock has room for. Inside that window the side | |
| 153 | * executes — the plan takes its feet and the policy keeps its gun — instead of | |
| 154 | * negotiating for ground it has no time to win. | |
| 155 | * | |
| 156 | * Twelve, and not more, because a side that gives up on its duels early gives | |
| 157 | * up on winning them. At 28 the same 30 matches ended 6 fewer rounds in a | |
| 158 | * detonation and 4 more on the clock; with the window at 28 and no hunt on the | |
| 159 | * other side, the plant rate itself fell 5 points, to terrorists walking out | |
| 160 | * of fights they were in the middle of winning. | |
| 140 | 161 | */ |
| 141 | export const mustCommit = (secondsLeft: number, distance: number, plantTime: number): boolean => | |
| 142 | secondsLeft <= timeToPlant(distance, plantTime); | |
| 162 | export const PUSH_WINDOW = 12; | |
| 163 | ||
| 164 | /** | |
| 165 | * True when a terrorist `distance` from its site should be walking onto it | |
| 166 | * rather than trading with whatever it can see. With the window at zero this | |
| 167 | * is the last moment a plant is still arithmetically possible; the window is | |
| 168 | * what makes it a decision taken in time rather than one taken too late. | |
| 169 | */ | |
| 170 | export const mustPush = (secondsLeft: number, distance: number, plantTime: number): boolean => | |
| 171 | secondsLeft <= timeToPlant(distance, plantTime) + PUSH_WINDOW; | |
| 172 | ||
| 173 | /** | |
| 174 | * Seconds left on the round clock below which a counter-terrorist with no | |
| 175 | * bomb to defend stops holding and goes looking. Holding an angle is correct | |
| 176 | * while running the clock down is the terrorists' problem; it stops being | |
| 177 | * correct once it is the only thing they are still doing. What "looking" | |
| 178 | * means is `bots.ts`'s business — this only says when. | |
| 179 | * | |
| 180 | * Forty-two leaves about seventy seconds of walking, which is the far end of | |
| 181 | * de_forge and back: it is the number that turned a quarter of rounds ending | |
| 182 | * in nothing into a tenth. Fifty-five is worse at both ends of the table — | |
| 183 | * the defence leaves early enough to hand over the site (detonations 105 to | |
| 184 | * 123, defuse rate 18% to 10%) and still ends more rounds on the clock, 12%. | |
| 185 | */ | |
| 186 | export const HUNT_AT = 42; | |
| 187 | ||
| 188 | /** True when what is left of the round is too little to spend standing still. */ | |
| 189 | export const mustHunt = (secondsLeft: number): boolean => secondsLeft <= HUNT_AT; |
Approved. Both things I asked for are here, and I checked them the way I checked the first round — by deleting the line and watching the test fall over.
The two gaps are closed.
if (world.bomb.state === "planted") return false;frompushingand once the bomb is down the clock stops moving the terrorists' feet fails. It is a good test: it puts the carrier in the duel it was already in, plants the bomb under it, and assertsbrain.goalstays null, so the thing being pinned is that the policy keeps the feet — not merely that the bot ends up somewhere near the bomb, whichchooseGoalwould have given you either way.:huntfromrepath's objective and the defence leaves its angle the tick the clock says so fails. The setup is the right one: it asserts up front that nothing about the bot is stale — part-way through a path,world.time < repathAt— so the only thing that can turn it round is the objective string, and then it moves the clock and nothing else.:called, which was unpinned before this pull ever touched the file: drop it and a call turns a rotating defender round inside its repath window fails. That was not asked for and it is the harder of the three to set up, since the call has to name the site the bot already had for the test to mean anything. Nice.muster.ctnow says in the type why both halves exist and which one is read. That answers it.The rest, re-checked at
7ff09e6.tsc --noEmit && bun testgreen: 538 pass, 0 fail.src/client/bots.tsandsrc/client/plan.tsare byte-identical toc2dea07, so the tables I reproduced last round still stand as measured — base4161986:216 rounds · plant 61% · detonated 103 · time 55 · elimination 39 · defused 19 · median 123s · longest 156s; this branch:223 · 62% · 105 · 21 · 72 · 25 · 123s · 144s. A quarter of rounds decided by nothing becomes a tenth, with the plant rate and the detonation count holding — the improvement is not bought from the terrorists.Security, for the record. This pull touches four client modules and the README; no route, no request handler, no
src/tty.ts, no dependency. I booted this head and put a request through it:/answers 200 in 19 ms carrying the floorsrc/server.tsdeclares —default-src 'self',nosniff,strict-origin-when-cross-origin,permissions-policy, session cookieHttpOnly; SameSite=Lax— and a cross-originPOST /api/matchis refused 403 before it reads a body. The preview address and the production address both return the platform's 404 from inside my sandbox, production included, so that is my egress and not your branch; the forge's pictures at this head render the page on phone and desktop with the pull's own banner on it.One judgement I will leave as a judgement, not a request. #105's "the median round comes down" box is still unticked at 123 s, and the pull says so plainly rather than quietly. I do not think anything is owed there: the median is now made of rounds that end in a detonation — a plant plus a 40-second fuse — which is
match.tsand explicitly out of that issue's scope, and a round that ends because somebody won it at 123 s is not the same complaint as a round that ends because nobody did. Elimination nearly doubling is the honest cost and it is written down in the README next to the table, where the next person to run the script will find it.