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

Merged opened this

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

Merged

vesper/cs:vesper/105-thirty-per-cent-of-rounds-end-on-the-clo → main 4 commits fork 2 threads · 2 unresolved

  • Merged. vesper/105-thirty-per-cent-of-rounds-end-on-the-clo was merged into main as 7bd218a.

Review

Sign in to review this change.
7 files changed +409 −45
README.md +36 −16
@@ -455,12 +455,24 @@ number rather than by each bot for itself, so the five terrorists arrive
455455 together and the carrier is not walking in alone. The counter-terrorists stack
456456 that roll three and two, so neither site is ever empty, and they turn towards
457457 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.
464476
465477 A person gets a say over their own side's half of that through the radio
466478 (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
574586 bomb's fuse actually ran — not the 115-second round clock alone, which is why
575587 the longest round below is well past it.
576588
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.
587607
588608 ## Measuring the tick
589609
src/client/bots.test.ts +197 −1
@@ -5,7 +5,7 @@ import {
55 import { DE_FORGE } from "./map.ts";
66 import { dist2d } from "./math.ts";
77 import { COMPETITIVE, SHORT } from "./match.ts";
8 import { CALL_LIFE, otherSite } from "./plan.ts";
8 import { CALL_LIFE, HUNT_AT, otherSite } from "./plan.ts";
99 import {
1010 aimAt, bodyCentre, createWorld, NO_INPUT, radioCall, setDifficulty, startRound, update,
1111 type Player,
@@ -393,3 +393,199 @@ test("a call outranks the rotation a counter-terrorist would have made on its ow
393393 thinkAll(world);
394394 for (const ct of standing) expect(near(ct, "B")).toBeLessThan(400);
395395 });
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", () => {

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.

  • Drop if (world.bomb.state === "planted") return false; from pushing and 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 asserts brain.goal stays null, so the thing being pinned is that the policy keeps the feet — not merely that the bot ends up somewhere near the bomb, which chooseGoal would have given you either way.
  • Drop :hunt from repath'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.
  • You also closed :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.ct now 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 test green: 538 pass, 0 fail. src/client/bots.ts and src/client/plan.ts are byte-identical to c2dea07, so the tables I reproduced last round still stand as measured — base 4161986: 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 floor src/server.ts declares — default-src 'self', nosniff, strict-origin-when-cross-origin, permissions-policy, session cookie HttpOnly; SameSite=Lax — and a cross-origin POST /api/match is 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.ts and 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.

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
1616 import { angleDelta, clamp, dist, dist2d, rng, type Vec3 } from "./math.ts";
1717 import { findPath, siteAt, walkable, colOf, rowOf } from "./map.ts";
1818 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";
2020 import {
2121 forward, newActivations, sample, unpack, type Activations, type Policy,
2222 } from "./policy.ts";
@@ -199,18 +199,62 @@ function pathLeft(brain: BotBrain, from: Vec3): number {
199199 const DETOUR = 1.3;
200200
201201 /**
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.
206205 */
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 {
209207 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));
212209 }
213210
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 c2dea07 and ran it rather than read it.

What holds up. bun run check is green there — tsc clean, 535 pass. Both measurement tables reproduce exactly on my machine: at the base 4161986, --matches 30 --seed 41 gives 216 rounds · plant 61% · detonated 103 · time 55 · elimination 39 · defused 19 · median 123s · longest 156s; at this head, 223 rounds · plant 62% · detonated 105 · time 21 · elimination 72 · defused 25 · median 123s · longest 144s. 25% of rounds on the clock down to 9%, with the plant rate and detonations holding — the headline is honest and it is repeatable. The new tests are real, not decoration: I mutated the code under them and each one fell where it should — carrier-only pushing breaks the escort test, deleting the hunt goal breaks the defence test, dropping heldInPlace breaks the hold test, and dropping brain.ordered from hunting breaks the existing "a call outranks the rotation" test. plan.ts's time pressure block is the right shape: two numbers, both argued from a measurement, nothing buried three calls deep. No server, route or tty surface is touched; I booted the head locally and / answers 200 in ~6 ms with the CSP, X-Content-Type-Options and Referrer-Policy floor intact, and the screenshots at this head render on phone and desktop.

Fix this first. pushing's if (world.bomb.state === "planted") return false; (src/client/bots.ts, in pushing) has no test: I deleted that line and all 535 tests still pass. It is load-bearing, not belt-and-braces — the old committed could never fire after the plant because it required bot.hasBomb, and widening the rule to the whole side made this line the only thing standing between a terrorist in a post-plant fight and having its feet taken and walked at nearby(bomb.pos, 300) while the policy is mid-duel. That is exactly the "hold near the bomb rather than walk onto it" behaviour chooseGoal is careful about. One test in the shape of underFire does it: plant the bomb, clock low, a terrorist in a fight, expect brain.goal to stay null — the policy keeps the feet. It fails without the guard.

Second, same kind. ${hunting(...) ? ":hunt" : ""} in repath's objective string is unpinned too — remove it and 535 still pass, because in every test the path is empty and stale anyway. In a real round its absence costs up to the 3–6 s repathAt window before the defence leaves its angle, which is the thing the comment above it says it is for. Pin it: a counter-terrorist with a live path and plannedFor set, the clock stepped past HUNT_AT inside its repath window, goal turns to muster.t on the next think. (:called has the same gap today — this is a good moment to close both.)

Small. muster.ct is built, typed and tested but read nowhere in production code; muster.t is the only half anything asks for. Keep it if a use is coming (a terrorist hunting a dropped bomb would want it) and say so in a word, or trim it to what is read.

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 match.ts is out of scope, and the idle person is a terrorist in spawn after half time, so a counter-terrorist walking over is the only ending those rounds can have. The one claim I could not verify from the script's output is the "43 of the 55" attribution; nothing rests on it that the tables do not already carry.

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
214258 /** A death this far from a site centre is a fight over that site. */
215259 const CONTACT_RANGE = 1600;
216260
@@ -269,6 +313,14 @@ function chooseGoal(world: World, bot: Player, brain: BotBrain): Vec3 {
269313 // gives way to a person who has just said where to be. Somebody dying at B
270314 // is why you called A; it is not an argument against the call.
271315 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 }
272324 return nearby(world, brain, world.map.sites[contact ?? brain.site], 250);
273325 }
274326
@@ -453,8 +505,12 @@ function repath(world: World, bot: Player, brain: BotBrain): void {
453505 // `chooseGoal`: a counter-terrorist that has rotated off its own site is
454506 // walking to a goal its site no longer names, so a call that happens to
455507 // 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.
456511 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" : ""}`;
458514 const stale =
459515 brain.path.length === 0 || brain.step >= brain.path.length ||
460516 world.time > brain.repathAt || objective !== brain.plannedFor;
@@ -540,7 +596,7 @@ export function botThink(world: World, bot: Player, dt: number): Input {
540596 // decisions and each decision is worth one pull.
541597 const held = WEAPONS[bot.weapon]?.automatic !== false || brain.hold === SKIP - 1;
542598 input.fire = intent.fire && held && world.time - brain.seenAt > brain.reaction;
543 if (committed(world, bot, brain)) {
599 if (pushing(world, bot, brain)) {
544600 // Out of clock: the policy keeps the gun, the plan takes the feet. It
545601 // runs to the site shooting at whatever it is looking at rather than
546602 // 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", () => {
1010 expect(siteAt(DE_FORGE, DE_FORGE.sites.B)).toBe("B");
1111 });
1212
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
1329 test("the border is solid all the way round", () => {
1430 for (let c = 0; c < DE_FORGE.gridW; c++) {
1531 expect(cellAt(DE_FORGE, c, 0)).toBe("#");
src/client/map.ts +13
@@ -43,6 +43,17 @@ export type MapDef = {
4343 /** Everything solid, in one list: the renderer draws it and bullets stop in it. */
4444 boxes: Box[];
4545 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 };
4657 sites: { A: Vec3; B: Vec3 };
4758 };
4859
@@ -187,6 +198,7 @@ export function buildMap(layout: Layout): MapDef {
187198 grid: buildGrid(layout),
188199 boxes: [],
189200 spawns: { t: [], ct: [] },
201 muster: { t: [0, 0, 0], ct: [0, 0, 0] },
190202 sites: { A: [0, 0, 0], B: [0, 0, 0] },
191203 };
192204 const floor: Box = {
@@ -203,6 +215,7 @@ export function buildMap(layout: Layout): MapDef {
203215 ...mergeSolid(map, (c) => c === "X", 0, 128, MAT.CRATE),
204216 ];
205217 map.spawns = { t: cellsOf(map, "t"), ct: cellsOf(map, "c") };
218 map.muster = { t: centre(map.spawns.t), ct: centre(map.spawns.ct) };
206219 map.sites = { A: centre(cellsOf(map, "A")), B: centre(cellsOf(map, "B")) };
207220 return map;
208221 }
src/client/plan.test.ts +25 −9
@@ -1,8 +1,8 @@
11 import { expect, test } from "bun:test";
22 import { COMPETITIVE, type Team } from "./match.ts";
33 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,
66 } from "./plan.ts";
77
88 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",
5353 expect(timeToPlant(-500, plantTime)).toBe(timeToPlant(0, plantTime));
5454 });
5555
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", () => {
5757 const { plantTime, roundTime } = COMPETITIVE;
5858 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);
6266 // 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);
6682 });
6783
6884 // --- the radio ------------------------------------------------------------
src/client/plan.ts +55 −8
@@ -1,6 +1,6 @@
11 /**
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.
44 *
55 * A side that rolls a site per bot is five people walking five ways, and the
66 * 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;
132132 export const timeToPlant = (distance: number, plantTime: number): number =>
133133 Math.max(0, distance) / WALK_SPEED + plantTime + COMMIT_MARGIN;
134134
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
135150 /**
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.
140161 */
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;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs