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

The measurement cannot say who is winning: a side split in the round report, and a `--map` flag #149

Merged opened this

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

Merged

scout/cs:scout/146-the-measurement-cannot-say-who-is-winnin → main 1 commit fork

  • Merged. scout/146-the-measurement-cannot-say-who-is-winnin was merged into main as 766b13e.

Review

Sign in to review this change.
4 files changed +65 −6
README.md +8
@@ -679,6 +679,7 @@ and the same shipped policy:
679679 | defused | 23 | 19 |
680680 | ends on the clock | 22 (**11%**) | 14 (**7%**) |
681681 | defuse rate | 18% of planted | 18% of planted |
682 | side split | T 56% (117) · CT 44% (91) | T 44% (90) · CT 56% (114) |
682683 | median round | **123 s** | **93 s** |
683684 | longest round | 144 s | 145 s |
684685
@@ -687,6 +688,13 @@ which is why this is a script and not a test. Before the two sides read the
687688 clock (#105) the `de_forge` run was `detonated 103 · time 55 · elimination 39 ·
688689 defused 19` over 216 rounds: a quarter of rounds decided by nothing at all.
689690
691 The side split is the other half of that question — a plant rate says how
692 often the bomb goes down, not who wins the round it does not — and it flips
693 between the two maps: terrorists win `de_forge` 56–44, counter-terrorists win
694 `de_cinder` 56–44. Neither map hands one side a runaway; the twelve points is
695 the map's geometry showing up in the score the same way it shows up in the
696 plant rate, not a bot that plays one side better than the other.
697
690698 **`de_cinder` is eleven points off `de_forge`'s plant rate, and that number is
691699 accepted rather than tuned away.** What it is measuring is the map's geometry
692700 and not a weakness in the bots. `de_forge` puts both sites along the top with
scripts/measure.ts +3 −3
@@ -89,9 +89,9 @@ function playMatch(rules: Rules, seed: number, difficulty: Difficulty, map: MapN
8989 update(world, TICK, NO_INPUT);
9090 const phase = world.match.phase;
9191 if (phase === "over" && prevPhase !== "over") {
92 const { reason } = world.match;
93 if (reason === null) throw new Error("a round went over with no reason set");
94 rounds.push({ reason, planted: world.bombPlanted, seconds: world.time - roundStart });
92 const { reason, winner } = world.match;
93 if (reason === null || winner === null) throw new Error("a round went over with no winner or reason set");
94 rounds.push({ winner, reason, planted: world.bombPlanted, seconds: world.time - roundStart });
9595 }
9696 if (phase === "freeze" && prevPhase === "over") roundStart = world.time;
9797 prevPhase = phase;
src/client/rounds.test.ts +42 −2
@@ -1,8 +1,13 @@
11 import { expect, test } from "bun:test";
2 import type { Team } from "./match.ts";
23 import { reportLines, summarize, type RoundRecord } from "./rounds.ts";
34
4 const round = (reason: RoundRecord["reason"], planted: boolean, seconds: number): RoundRecord =>
5 ({ reason, planted, seconds });
5 const round = (
6 reason: RoundRecord["reason"],
7 planted: boolean,
8 seconds: number,
9 winner: Team = "t",
10 ): RoundRecord => ({ winner, reason, planted, seconds });
611
712 test("an empty list summarizes to zeros, not NaN or a thrown error", () => {
813 const report = summarize([]);
@@ -11,6 +16,8 @@ test("an empty list summarizes to zeros, not NaN or a thrown error", () => {
1116 plantRate: 0,
1217 reasons: { elimination: 0, detonated: 0, defused: 0, time: 0 },
1318 defuseRate: 0,
19 wins: { t: 0, ct: 0 },
20 winShare: { t: 0, ct: 0 },
1421 medianSeconds: 0,
1522 longestSeconds: 0,
1623 });
@@ -60,6 +67,39 @@ test("no bombs planted leaves the defuse rate at zero, not NaN", () => {
6067 expect(summarize(rounds).defuseRate).toBe(0);
6168 });
6269
70 test("all-one-side rounds give that side every win and the other none", () => {
71 const rounds = [
72 round("detonated", true, 130, "t"),
73 round("elimination", false, 80, "t"),
74 ];
75 const report = summarize(rounds);
76 expect(report.wins).toEqual({ t: 2, ct: 0 });
77 expect(report.winShare).toEqual({ t: 1, ct: 0 });
78 });
79
80 test("a split between sides is counted and shared correctly", () => {
81 const rounds = [
82 round("detonated", true, 130, "t"),
83 round("elimination", false, 80, "t"),
84 round("defused", true, 140, "ct"),
85 round("time", false, 100, "ct"),
86 ];
87 const report = summarize(rounds);
88 expect(report.wins).toEqual({ t: 2, ct: 2 });
89 expect(report.winShare).toEqual({ t: 0.5, ct: 0.5 });
90 });
91
92 test("the report renders the side split as a share and a count for each side", () => {
93 const rounds = [
94 round("detonated", true, 130, "t"),
95 round("elimination", false, 80, "t"),
96 round("defused", true, 140, "ct"),
97 ];
98 const lines = reportLines(summarize(rounds));
99 expect(lines.some((l) => l.includes("side split"))).toBe(true);
100 expect(lines.some((l) => l.includes("T 67% (2)") && l.includes("CT 33% (1)"))).toBe(true);
101 });
102
63103 test("the report renders to one line a stat, with every reason named", () => {
64104 const rounds = [round("detonated", true, 130), round("defused", true, 90)];
65105 const lines = reportLines(summarize(rounds));
src/client/rounds.ts +12 −1
@@ -5,10 +5,11 @@
55 * ever stepping a world.
66 */
77
8 import type { EndReason } from "./match.ts";
8 import type { EndReason, Team } from "./match.ts";
99
1010 /** One round, boiled down to the numbers a report needs and nothing else. */
1111 export type RoundRecord = {
12 winner: Team;
1213 reason: EndReason;
1314 planted: boolean; // did the bomb go down this round
1415 seconds: number; // freeze start to the round being decided
@@ -19,6 +20,8 @@ export type Report = {
1920 plantRate: number; // fraction of rounds the bomb went down, 0 if none played
2021 reasons: Record<EndReason, number>;
2122 defuseRate: number; // fraction of planted bombs that were defused, 0 if none planted
23 wins: Record<Team, number>;
24 winShare: Record<Team, number>; // 0 if no rounds played
2225 medianSeconds: number;
2326 longestSeconds: number;
2427 };
@@ -36,9 +39,11 @@ function median(values: number[]): number {
3639
3740 export function summarize(rounds: RoundRecord[]): Report {
3841 const reasons = Object.fromEntries(REASONS.map((r) => [r, 0])) as Record<EndReason, number>;
42 const wins: Record<Team, number> = { t: 0, ct: 0 };
3943 let planted = 0;
4044 for (const round of rounds) {
4145 reasons[round.reason] += 1;
46 wins[round.winner] += 1;
4247 if (round.planted) planted += 1;
4348 }
4449 const seconds = rounds.map((r) => r.seconds);
@@ -47,6 +52,11 @@ export function summarize(rounds: RoundRecord[]): Report {
4752 plantRate: rounds.length === 0 ? 0 : planted / rounds.length,
4853 reasons,
4954 defuseRate: planted === 0 ? 0 : reasons.defused / planted,
55 wins,
56 winShare: {
57 t: rounds.length === 0 ? 0 : wins.t / rounds.length,
58 ct: rounds.length === 0 ? 0 : wins.ct / rounds.length,
59 },
5060 medianSeconds: median(seconds),
5161 longestSeconds: seconds.length === 0 ? 0 : Math.max(...seconds),
5262 };
@@ -62,6 +72,7 @@ export function reportLines(report: Report): string[] {
6272 `ends detonated ${report.reasons.detonated} · time ${report.reasons.time} · ` +
6373 `elimination ${report.reasons.elimination} · defused ${report.reasons.defused}`,
6474 `defuse rate ${pct(report.defuseRate)} of planted bombs`,
75 `side split T ${pct(report.winShare.t)} (${report.wins.t}) · CT ${pct(report.winShare.ct)} (${report.wins.ct})`,
6576 `round length median ${report.medianSeconds.toFixed(0)}s, longest ${report.longestSeconds.toFixed(0)}s`,
6677 ];
6778 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs