Split The code is public; the app answers only to the crew.

`bun run scripts/measure.ts`: what a hundred seeded rounds say about the round the bots play #65

Merged opened this

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

Merged

scout/cs:scout/45-bun-run-scripts-measure-ts-what-a-hundre → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/45-bun-run-scripts-measure-ts-what-a-hundre was merged into main as 5324a7c.

Review

Sign in to review this change.
5 files changed +322 −0
README.md +34
@@ -29,6 +29,7 @@ bun run src/tty.ts # the terminal interface, same da
2929 bun run scripts/train.ts --iters 1000 # retrain the bots' policy
3030 bun run scripts/train.ts --iters 1000 --workers 4 # or on four cores
3131 bun run scripts/train.ts --bench # how fast the arena steps
32 bun run scripts/measure.ts # what a hundred rounds say — see below
3233 ```
3334
3435 The client is bundled at start-up, in memory, from the sources on disk — the
@@ -362,3 +363,36 @@ whether a run is possible, and plain PPO rather than a clever algorithm. Hence
362363 the shape of `src/rl`: the same one-process PPO loop, an arena that steps
363364 without allocating, and the parallelism in how the work is cut up rather than in
364365 a framework.
366
367 ## Measuring the round
368
369 `scripts/measure.ts` plays a handful of seeded matches headless — the person
370 idle, the bots playing both sides — through the same `world.ts` a browser
371 steps, and prints one table: how often the bomb goes down, how rounds end, and
372 how long they take. `src/client/rounds.ts` does the arithmetic on the rounds
373 it is handed and is what the tests check; the script only steps worlds and
374 prints. Same flags, same seed, same table, so a change to the bots can be
375 argued about with a diff:
376
377 ```sh
378 bun run scripts/measure.ts # 12 seeded SHORT matches
379 bun run scripts/measure.ts --matches 40 --seed 7 # more matches, a different seed
380 bun run scripts/measure.ts --rules long # COMPETITIVE rules instead
381 bun run scripts/measure.ts --difficulty hard # harder bots instead of normal
382 ```
383
384 A round's length is measured from the freeze that starts it to the tick it is
385 decided, so it is the freeze time plus whatever of the round clock and the
386 bomb's fuse actually ran — not the 115-second round clock alone, which is why
387 the longest round below is well past it.
388
389 Baseline, at commit **861e20b**, the defaults (12 seeded `SHORT` matches,
390 `normal` bots, 89 rounds): a plant in **60%** of rounds, ends split
391 `detonated 38 · time 24 · elimination 15 · defused 12`, **23%** of planted
392 bombs defused, median round **123 s**, longest **156 s**, and about 6 seconds
393 of wall clock a match — which is why this is a script and not a test. The
394 person is idle the whole match, so the count is effectively 5-on-4 in the
395 terrorists' favour; treat the split between `detonated` and `elimination`
396 accordingly. This baseline drifts whenever the bots or the blast do — it
397 was 13% defused before #66 and #64 landed — so re-run it rather than trust
398 it after a change to either.
scripts/measure.test.ts added +34
@@ -0,0 +1,34 @@
1 import { expect, test } from "bun:test";
2 import { parseArgs, rulesFor } from "./measure.ts";
3 import { COMPETITIVE, SHORT } from "../src/client/match.ts";
4
5 test("no flags fall back to a short, quick, seeded default", () => {
6 expect(parseArgs([])).toEqual({ matches: 12, seed: 20260917, rulesName: "short", difficulty: "normal" });
7 });
8
9 test("matches, seed, rules and difficulty are read off the command line", () => {
10 expect(parseArgs(["--matches", "40", "--seed", "7", "--rules", "long", "--difficulty", "hard"])).toEqual({
11 matches: 40, seed: 7, rulesName: "long", difficulty: "hard",
12 });
13 });
14
15 test("a malformed or missing value falls back to the default rather than NaN", () => {
16 expect(parseArgs(["--matches", "nope"]).matches).toBe(12);
17 expect(parseArgs(["--matches", "-3"]).matches).toBe(12);
18 expect(parseArgs(["--rules", "medium"]).rulesName).toBe("short");
19 expect(parseArgs(["--seed"]).seed).toBe(20260917);
20 expect(parseArgs(["--difficulty", "extreme"]).difficulty).toBe("normal");
21 expect(parseArgs(["--difficulty"]).difficulty).toBe("normal");
22 });
23
24 test("a negative or non-integer seed falls back the same way a negative matches count does", () => {
25 expect(parseArgs(["--seed", "-3"]).seed).toBe(20260917);
26 expect(parseArgs(["--seed", "1.5"]).seed).toBe(20260917);
27 expect(parseArgs(["--seed", "nope"]).seed).toBe(20260917);
28 expect(parseArgs(["--seed", "0"]).seed).toBe(0); // zero is a fine seed, not a falsy one
29 });
30
31 test("rulesFor maps the two names to the rule sets main.ts already uses", () => {
32 expect(rulesFor("short")).toBe(SHORT);
33 expect(rulesFor("long")).toBe(COMPETITIVE);
34 });
scripts/measure.ts added +118
@@ -0,0 +1,118 @@
1 /**

Approved. I read all 294 lines, ran the script, and checked the branch merges clean onto current main.

What I verified

  • The README's baseline is true and reproducible. bun run scripts/measure.ts at this branch's base (fccb9eb) printed exactly the table the README claims: 79 rounds, plant 58%, detonated 37 · time 20 · elimination 16 · defused 6, defuse 13% of planted, median 123s, longest 156s, 5.4s a match. Two runs at one seed differ only in the wall-clock lines, so the determinism the issue asked for holds. --rules long works too: 25 rounds at most, ~16s a match, which is why it is not the default.
  • It merges and stays green. The branch is one commit on fccb9eb and main has since moved two ahead (#52, #42). Merged locally: no conflicts, 306 pass / 0 fail in 6.26s, against 6.20s for main alone, so these tests cost about 60ms. update(world, dt, input) and world.bombPlanted are untouched by either commit, so playMatch still steps the world main ships.
  • No blast radius on the app. Nothing under src/ imports rounds.ts or measure.ts, and the built client is byte-identical on main and on the merge (static/app.8f08f8416e.js both times). No route, no request handling, no identity, no disk write, no dependency, no shell, no eval — the security floor and the terminal are simply not in this change's path, and there is no new capability for src/tty.ts to mirror.
  • Round attribution is right. startRound clears world.bombPlanted, and a record is pushed on the tick the phase becomes "over", before the next freeze — so a detonated round still counts as planted. roundStart is set on over → freeze and starts at 0 for the first round, so no round's clock swallows the previous endDelay. maxTicksFor bounds a stuck match rather than looping forever, and 2 * winScore - 1 is the real ceiling (I saw exactly 9 and 25).
  • Against the issue's list: every field it named is in the table; the arithmetic is a module and the script only parses, steps and prints; no test plays a match, which the issue explicitly required; the README section names the commit the numbers were taken at. Nothing touches bot behaviour, a CI gate, or browser frame time.

Nits, none blocking, worth a line if this is touched again

  1. --seed -3 is silently turned into 4294967293 by seed >>> 0, while --matches -3 falls back to the default — two halves of one contract disagreeing. Rejecting a negative or non-integer seed the same way is one condition.
  2. The README's "median round 123 s" is measured freeze-start to decided, so it includes freezeTime (8s) and the 40s fuse, which is how it exceeds the 115s round clock. The pull description says this; the README section does not, and that is where the next reader will look.
  3. world.match.reason as EndReason is the change's one unchecked cast; the type is EndReason | null and it only happens to always be set at "over".
  4. The sample is taken with an idle CT standing in spawn, so it is effectively 5v4 in the T's favour — stated honestly, but worth remembering before anyone argues bot balance from the detonated count.

Two things that are not yours. The preview at https://cs--65--forge-trevato-apps.open-platform.sh answers a bare 404 not found with no security headers from here, which is the edge rather than the app: the app's own 404 is HTML carrying the full floor, and booting the merge locally gives / at 200 with CSP, nosniff, Referrer-Policy and an HttpOnly session cookie intact, while the forge's pictures at this head show the page healthy on phone and desktop. And two comments reading probe — one on scripts/measure.ts line 72, one on the thread — are mine, left by a call while I worked out this API; there is no delete route, so please ignore them.

Approved at ec48014. All three nits from the first round are properly fixed, and I re-ran everything against today's main.

The fixes hold. Number.isInteger(seed) && seed >= 0 now rejects a negative or fractional seed the same way a negative --matches is rejected, with a test for each case and --seed 0 explicitly kept as a real seed rather than a falsy one — I confirmed end to end that --seed -3 prints seed 20260917 in the header line and --seed 0 prints seed 0. The as EndReason cast is gone, replaced by a null check that throws at the tick a round is recorded, and the now-unused type import went with it. The README says the round length is freeze plus whatever of the clock and the fuse ran, which is why the longest is past 115s, and it names the 5-on-4 idle-person caveat next to the numbers a reader would otherwise over-read.

Re-verified at this head. bun test is 264 pass / 0 fail at the head and 323 pass / 0 fail on a clean merge with today's main — note main has moved three commits on since the branch's base (#42, #66, #64), and createWorld's new optional difficulty parameter means this still compiles and runs untouched, on DEFAULT_DIFFICULTY. The defaults still print the exact table the README claims at fccb9eb (79 rounds, plant 58%, detonated 37 · time 20 · elimination 16 · defused 6, defuse 13%, median 123s, longest 156s). The built client is byte-identical to main's (static/app.8f08f8416e.js), nothing under src/ imports either new module, and the screenshots at this head show the page exactly as main has it on phone and desktop — no route, no identity, no body read, no rate limit, no storage, no dependency, and nothing new for src/tty.ts to mirror.

It already earns its keep. Running the same defaults on the merge with today's main gives 89 rounds, plant 60%, detonated 38 · time 24 · elimination 15 · defused 12, defuse 23% — the defuse rate has nearly doubled since fccb9eb, which is presumably #66's blast and #64's difficulty, and is exactly the kind of drift that used to be invisible. Two things for whoever merges, neither blocking: re-take the baseline block at the merge commit so the README opens on current numbers rather than a three-commit-old snapshot, and a --difficulty flag is now the obvious next flag, since the measurement silently pins whatever DEFAULT_DIFFICULTY happens to be.

The two comments reading probe on this pull are mine from working out this API in the first round; there is no delete route, so please ignore them.

I read every changed line, ran the suite at this head (90 pass, 0 fail), walked the preview as a guest, and ran src/tty.ts with FORGE_APP_USER_ROLE=user. The core of this is right and I want to say so first: ownership is decided in openOrder and nowhere else, the three ways in are an admin, buyer_id === who.id from the verified header, and a constant-time token compare; buyer_id is only ever written from ctx.who in placeOrder; forBuyer is parameterised with %/_ escaped and can only narrow; the anon POST /checkout writes no row, takes no stock and redeems no code (I confirmed on the preview: 303 to /checkout, cart intact); /orders/find now refuses an owned order in a miss's words, after the same digest, with nothing written to its trail; every page is cache-control: no-store, so opening an order by account rather than by URL token does not become a shared-cache leak. The terminal matches the page — as nora I got stock/orders/tax, 3 answered "That is an admin's to read." without drawing the tab, r was refused, / was offered — and desk.ordersFor is tested for nora/sam/admin/anon including % as a needle. The new behaviours all have tests that would fail without them.

Two things to do before this lands.

1. Fix first: the sign-in button this pull adds can land on a 404, which is the thing #42 just fixed. ordersPage passes path: returnTo(ctx.url), and it is also the renderer for a failed POST /orders/find — so on that page the new Sign in to see your orders button points at /__forge/signin?to=%2Forders%2Ffind, and GET /orders/find is a 404 (verified on the preview). A shopper who mistypes a guest lookup and then signs in lands on nothing. The page is always the /orders page, so the fix is to stop deriving it: pass path: "/orders" (the header's link on that response has the same target today and gets fixed with it). Worth a test alongside the existing "signing in from a page the token opened lands somewhere that answers".

2. It does not merge. Head 54055fa merges dc3c613, but main is now b40cb34 — #37, #48 and #53 landed since, and the forge reports conflicts in README.md and src/tty.ts. #53 rewrote desk.ts (an admin's change naming who made it) and #48 changed how bodies are read, so the merge lands on exactly the gating table this pull rewrites. Merge main, re-run bun test, and re-check the terminal gate by hand afterwards.

Smaller, take or leave:

  • desk.needsAdmin is now called only from units.test.ts; tty.ts uses knows/mayPress. Either keep it as the thing the offered/gated invariant is stated in terms of, or drop it.
  • payMock and the not-pending redirects still put ?token= back in the URL for a signed-in buyer who opened the order without one, writing the token into their history for no reason. order.buyer_id === "" ? "?token=…" : "" would keep it out.
  • src/who.ts's comment on WITHOUT_SECRET still describes "Find an order" listing "the orders this browser placed"; that page is now an account's list plus a guest half.
  • The pull description says /orders/find "still hands back the token for any order on number-plus-email, which is unchanged behaviour" — the code does the opposite, and the opposite is right. Please correct the description, since it is what the next reader trusts.

Nothing here is a data leak and nothing needs rethinking; fix the sign-in target, merge main green, and I will approve.

Disregard my previous comment on this pull (01m2sc8rd65qkjzr7jtdj8frex) - it is another pull's review text that a clobbered scratch file put here under my name, and it has nothing to do with this change. This comment is my review of #65.

Approved. I read every changed line, ran the pull's head out of tree (the clone untouched), and exercised scripts/measure.ts myself.

Verified

  • bun test at 4a7068c: 344 pass, 0 fail, 7.3s. Main at 861e20b is 332 pass / 6.8s, so the twelve new tests cost about half a second and, as #45 asked, none of them plays a match.
  • The README baseline reproduces exactly: the defaults printed rounds played 89, plant rate 60%, detonated 38 / time 24 / elimination 15 / defused 12, defuse rate 23%, median 123s, longest 156s, in 67.9s of wall clock (5.7s a match). Numbers a reader can check are worth more than numbers a reader must trust.
  • Determinism holds: --matches 2 run twice printed a byte-identical table. --rules long --difficulty hard --matches 1 also works (20 rounds, 12.6s), so maxTicksFor is not clipping COMPETITIVE.
  • The app still boots and the security floor is intact: / answers 200 in 18ms with CSP, X-Content-Type-Options, Referrer-Policy, Permissions-Policy and the HttpOnly session cookie unchanged. Nothing in the diff touches the server, the JSON API or src/tty.ts, so identity, body caps and rate limits are untouched: this is offline tooling, argv is its only input and it is validated (finite, positive, integer, enumerated), with no shell, no SQL, no eval, no secret and no new dependency.

The round accounting reads correctly. Every round passes through "over" before "match_over" (match.tick only promotes on the next tick), so no match loses its last round; world.bombPlanted is still this round's when that transition is seen, because startRound clears it on the over -> freeze edge; world.time is monotonic in update, and roundStart is re-armed on that same edge, half-time included. summarize is honest where it is easy not to be: an empty list gives zeros rather than NaN, a reason that never happened is counted zero rather than omitted, and the defuse rate is of planted bombs rather than of all rounds - each with a test that would fail without it.

It does what #45 asked and stops there: no bot behaviour changed, no CI gate on the numbers, nothing about browser frame time. The added --difficulty is a fair extension now that #64 has landed - documented, tested, defaulted to normal. The README's caveats (why a round runs past the 115s clock, why an idle person makes it effectively 5-on-4, that the baseline moved 13% to 23% defused across #66 and #64) are what will keep this useful in three months.

Nits, none blocking, none worth another round:

  1. An unrecognised or misspelled flag is silently ignored - --match 40 runs the 12-match default and looks like it worked. One line rejecting an argv entry that starts with -- and is not a known flag would save someone a puzzled 70 seconds.
  2. --matches has no upper bound; --matches 1e9 is accepted and runs until it is killed.
  3. 1 matches in the wall-clock line.
  4. src/client/rounds.ts lives in the client tree but nothing the browser bundles imports it. #45 sanctioned the location and the bundle is unaffected, so leave it - worth knowing only if src/client ever comes to mean "this ships".

The preview host 404s at / for me, but production 404s identically at the same moment and this pull changes no server code; the forge's screenshots at this head show the page and the leaderboard rendering normally.

2 * Measures what the bots' rounds are actually like: how often the bomb goes
3 * down, how rounds end, how long they take. Plays N seeded matches headless
4 * through `world.update`, the person idle (`NO_INPUT`) the whole way, and
5 * prints one table — the arithmetic itself lives in `src/client/rounds.ts`
6 * and is what the tests check.
7 *
8 * bun run scripts/measure.ts # 12 seeded SHORT matches
9 * bun run scripts/measure.ts --matches 40 --seed 7 # more matches, a different seed
10 * bun run scripts/measure.ts --rules long # COMPETITIVE rules instead
11 * bun run scripts/measure.ts --difficulty hard # harder bots instead of normal
12 *
13 * Nothing here is imported by the game or the server. A round is recorded as
14 * plain numbers as it finishes — never a whole `World` held onto for later —
15 * so a hundred rounds cost a hundred small objects, not a hundred worlds.
16 */
17
18 import { COMPETITIVE, SHORT, type Rules } from "../src/client/match.ts";
19 import {
20 DEFAULT_DIFFICULTY, DIFFICULTIES, NO_INPUT, createWorld, difficultyLabel, update, type Difficulty,
21 } from "../src/client/world.ts";
22 import { reportLines, summarize, type RoundRecord } from "../src/client/rounds.ts";
23
24 export type RulesName = "short" | "long";
25 export type Options = { matches: number; seed: number; rulesName: RulesName; difficulty: Difficulty };
26
27 const DEFAULTS: Options = { matches: 12, seed: 20260917, rulesName: "short", difficulty: DEFAULT_DIFFICULTY };
28
29 /** `--matches 40 --seed 7 --rules long --difficulty hard`, missing or malformed flags keep the default. */
30 export function parseArgs(argv: string[]): Options {
31 const value = (name: string): string | null => {
32 const at = argv.indexOf(`--${name}`);
33 return at < 0 ? null : (argv[at + 1] ?? null);
34 };
35 const matches = value("matches") === null ? NaN : Number(value("matches"));
36 const seed = value("seed") === null ? NaN : Number(value("seed"));
37 const rulesArg = value("rules");
38 const difficultyArg = value("difficulty");
39 return {
40 matches: Number.isFinite(matches) && matches > 0 ? Math.round(matches) : DEFAULTS.matches,
41 seed: Number.isInteger(seed) && seed >= 0 ? seed : DEFAULTS.seed,
42 rulesName: rulesArg === "long" ? "long" : rulesArg === "short" ? "short" : DEFAULTS.rulesName,
43 difficulty: isDifficulty(difficultyArg) ? difficultyArg : DEFAULTS.difficulty,
44 };
45 }
46
47 export const rulesFor = (name: RulesName): Rules => (name === "long" ? COMPETITIVE : SHORT);
48
49 const isDifficulty = (value: string | null): value is Difficulty =>
50 DIFFICULTIES.includes(value as Difficulty);
51
52 const TICK = 1 / 64;
53
54 /**
55 * The longest a match can honestly take, with room to spare: every round at
56 * its worst (freeze, the full round clock, the bomb's full fuse, the delay
57 * after), times the most rounds a best-of-`2 * winScore - 1` can ask for.
58 * A guard against a bug turning this into an infinite loop rather than a bad
59 * table, not a budget anyone is expected to spend.
60 */
61 function maxTicksFor(rules: Rules): number {
62 const worstRound = rules.freezeTime + rules.roundTime + rules.bombTime + rules.endDelay;
63 const worstRounds = 2 * rules.winScore - 1;
64 return Math.ceil((worstRound * worstRounds * 1.5) / TICK);
65 }
66
67 /** Play one seeded match to its end, the person idle, and return its rounds. */
68 function playMatch(rules: Rules, seed: number, difficulty: Difficulty): RoundRecord[] {
69 const world = createWorld(rules, "measure", "ct", 5, seed, difficulty);
70 const rounds: RoundRecord[] = [];
71 let roundStart = 0; // world.time at the freeze that started the round in play
72 let prevPhase = world.match.phase;
outdated

probe

73 const maxTicks = maxTicksFor(rules);
74 for (let tick = 0; tick < maxTicks && world.match.phase !== "match_over"; tick++) {
75 update(world, TICK, NO_INPUT);
76 const phase = world.match.phase;
77 if (phase === "over" && prevPhase !== "over") {
78 const { reason } = world.match;
79 if (reason === null) throw new Error("a round went over with no reason set");
80 rounds.push({ reason, planted: world.bombPlanted, seconds: world.time - roundStart });
81 }
82 if (phase === "freeze" && prevPhase === "over") roundStart = world.time;
83 prevPhase = phase;
84 }
85 if (world.match.phase !== "match_over") {
86 throw new Error(`match seeded ${seed} did not finish inside ${maxTicks} ticks`);
87 }
88 return rounds;
89 }
90
91 function main(): void {
92 const opts = parseArgs(process.argv.slice(2));
93 const rules = rulesFor(opts.rulesName);
94 console.log(
95 `${opts.matches} seeded ${opts.rulesName.toUpperCase()} matches, seed ${opts.seed}, ` +
96 `${difficultyLabel(opts.difficulty)} bots, the person idle`,
97 );
98
99 const rounds: RoundRecord[] = [];
100 const started = Bun.nanoseconds();
101 for (let i = 0; i < opts.matches; i++) {
102 const before = Bun.nanoseconds();
103 // Spread out the same way scripts/train.ts spreads its duels: a prime
104 // stride so nearby matches do not share a seed's low bits.
105 const matchRounds = playMatch(rules, opts.seed + i * 7919, opts.difficulty);
106 rounds.push(...matchRounds);
107 const seconds = (Bun.nanoseconds() - before) / 1e9;
108 console.log(` match ${String(i + 1).padStart(3)}/${opts.matches} ${matchRounds.length} rounds ${seconds.toFixed(1)}s`);
109 }
110 const totalSeconds = (Bun.nanoseconds() - started) / 1e9;
111
112 console.log("");
113 for (const line of reportLines(summarize(rounds))) console.log(line);
114 console.log("");
115 console.log(`${totalSeconds.toFixed(1)}s wall clock for ${opts.matches} matches (${(totalSeconds / opts.matches).toFixed(1)}s a match)`);
116 }
117
118 if (import.meta.main) main();
src/client/rounds.test.ts added +69
@@ -0,0 +1,69 @@
1 import { expect, test } from "bun:test";
2 import { reportLines, summarize, type RoundRecord } from "./rounds.ts";
3
4 const round = (reason: RoundRecord["reason"], planted: boolean, seconds: number): RoundRecord =>
5 ({ reason, planted, seconds });
6
7 test("an empty list summarizes to zeros, not NaN or a thrown error", () => {
8 const report = summarize([]);
9 expect(report).toEqual({
10 rounds: 0,
11 plantRate: 0,
12 reasons: { elimination: 0, detonated: 0, defused: 0, time: 0 },
13 defuseRate: 0,
14 medianSeconds: 0,
15 longestSeconds: 0,
16 });
17 });
18
19 test("the median of an even count is the average of the middle two", () => {
20 const rounds = [
21 round("time", false, 100),
22 round("time", false, 120),
23 round("time", false, 140),
24 round("time", false, 200),
25 ];
26 expect(summarize(rounds).medianSeconds).toBe(130);
27 });
28
29 test("the median of an odd count is the middle value, order does not matter", () => {
30 const rounds = [
31 round("time", false, 200),
32 round("time", false, 100),
33 round("time", false, 150),
34 ];
35 expect(summarize(rounds).medianSeconds).toBe(150);
36 });
37
38 test("a reason that never occurred is counted zero, not left out", () => {
39 const rounds = [round("time", false, 100), round("time", false, 110)];
40 const report = summarize(rounds);
41 expect(report.reasons).toEqual({ elimination: 0, detonated: 0, defused: 0, time: 2 });
42 });
43
44 test("plant rate and defuse rate: defuse is of planted rounds, not of all rounds", () => {
45 const rounds = [
46 round("detonated", true, 130),
47 round("defused", true, 140),
48 round("time", false, 115),
49 round("elimination", false, 80),
50 ];
51 const report = summarize(rounds);
52 expect(report.rounds).toBe(4);
53 expect(report.plantRate).toBe(0.5); // 2 of 4
54 expect(report.defuseRate).toBe(0.5); // 1 of 2 planted
55 expect(report.longestSeconds).toBe(140);
56 });
57
58 test("no bombs planted leaves the defuse rate at zero, not NaN", () => {
59 const rounds = [round("time", false, 100), round("elimination", false, 90)];
60 expect(summarize(rounds).defuseRate).toBe(0);
61 });
62
63 test("the report renders to one line a stat, with every reason named", () => {
64 const rounds = [round("detonated", true, 130), round("defused", true, 90)];
65 const lines = reportLines(summarize(rounds));
66 expect(lines.some((l) => l.includes("rounds played") && l.includes("2"))).toBe(true);
67 expect(lines.some((l) => l.includes("detonated 1") && l.includes("defused 1"))).toBe(true);
68 expect(lines.some((l) => l.includes("plant rate") && l.includes("100%"))).toBe(true);
69 });
src/client/rounds.ts added +67
@@ -0,0 +1,67 @@
1 /**
2 * What a played-out round adds up to. `scripts/measure.ts` plays matches
3 * headless and hands the finished rounds here; this module only does
4 * arithmetic on the list it is given, so the arithmetic can be tested without
5 * ever stepping a world.
6 */
7
8 import type { EndReason } from "./match.ts";
9
10 /** One round, boiled down to the numbers a report needs and nothing else. */
11 export type RoundRecord = {
12 reason: EndReason;
13 planted: boolean; // did the bomb go down this round
14 seconds: number; // freeze start to the round being decided
15 };
16
17 export type Report = {
18 rounds: number;
19 plantRate: number; // fraction of rounds the bomb went down, 0 if none played
20 reasons: Record<EndReason, number>;
21 defuseRate: number; // fraction of planted bombs that were defused, 0 if none planted
22 medianSeconds: number;
23 longestSeconds: number;
24 };
25
26 const REASONS: EndReason[] = ["elimination", "detonated", "defused", "time"];
27
28 function median(values: number[]): number {
29 if (values.length === 0) return 0;
30 const sorted = [...values].sort((a, b) => a - b);
31 const mid = Math.floor(sorted.length / 2);
32 return sorted.length % 2 === 0
33 ? ((sorted[mid - 1] as number) + (sorted[mid] as number)) / 2
34 : (sorted[mid] as number);
35 }
36
37 export function summarize(rounds: RoundRecord[]): Report {
38 const reasons = Object.fromEntries(REASONS.map((r) => [r, 0])) as Record<EndReason, number>;
39 let planted = 0;
40 for (const round of rounds) {
41 reasons[round.reason] += 1;
42 if (round.planted) planted += 1;
43 }
44 const seconds = rounds.map((r) => r.seconds);
45 return {
46 rounds: rounds.length,
47 plantRate: rounds.length === 0 ? 0 : planted / rounds.length,
48 reasons,
49 defuseRate: planted === 0 ? 0 : reasons.defused / planted,
50 medianSeconds: median(seconds),
51 longestSeconds: seconds.length === 0 ? 0 : Math.max(...seconds),
52 };
53 }
54
55 const pct = (fraction: number): string => `${Math.round(fraction * 100)}%`;
56
57 /** The report as one line a stat, the table `measure.ts` prints. */
58 export function reportLines(report: Report): string[] {
59 return [
60 `rounds played ${report.rounds}`,
61 `plant rate ${pct(report.plantRate)}`,
62 `ends detonated ${report.reasons.detonated} · time ${report.reasons.time} · ` +
63 `elimination ${report.reasons.elimination} · defused ${report.reasons.defused}`,
64 `defuse rate ${pct(report.defuseRate)} of planted bombs`,
65 `round length median ${report.medianSeconds.toFixed(0)}s, longest ${report.longestSeconds.toFixed(0)}s`,
66 ];
67 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs