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
Check · Review · Land · Live done — Landed and live in production.
scout/cs:scout/146-the-measurement-cannot-say-who-is-winnin → main 1 commit fork
4 files
changed +65
−6
README.md +8
| @@ -679,6 +679,7 @@ and the same shipped policy: | ||
| 679 | 679 | | defused | 23 | 19 | |
| 680 | 680 | | ends on the clock | 22 (**11%**) | 14 (**7%**) | |
| 681 | 681 | | defuse rate | 18% of planted | 18% of planted | |
| 682 | | side split | T 56% (117) · CT 44% (91) | T 44% (90) · CT 56% (114) | | |
| 682 | 683 | | median round | **123 s** | **93 s** | |
| 683 | 684 | | longest round | 144 s | 145 s | |
| 684 | 685 | |
| @@ -687,6 +688,13 @@ which is why this is a script and not a test. Before the two sides read the | ||
| 687 | 688 | clock (#105) the `de_forge` run was `detonated 103 · time 55 · elimination 39 · |
| 688 | 689 | defused 19` over 216 rounds: a quarter of rounds decided by nothing at all. |
| 689 | 690 | |
| 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 | ||
| 690 | 698 | **`de_cinder` is eleven points off `de_forge`'s plant rate, and that number is |
| 691 | 699 | accepted rather than tuned away.** What it is measuring is the map's geometry |
| 692 | 700 | 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 | ||
| 89 | 89 | update(world, TICK, NO_INPUT); |
| 90 | 90 | const phase = world.match.phase; |
| 91 | 91 | 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 }); | |
| 95 | 95 | } |
| 96 | 96 | if (phase === "freeze" && prevPhase === "over") roundStart = world.time; |
| 97 | 97 | prevPhase = phase; |
src/client/rounds.test.ts +42 −2
| @@ -1,8 +1,13 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import type { Team } from "./match.ts"; | |
| 2 | 3 | import { reportLines, summarize, type RoundRecord } from "./rounds.ts"; |
| 3 | 4 | |
| 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 }); | |
| 6 | 11 | |
| 7 | 12 | test("an empty list summarizes to zeros, not NaN or a thrown error", () => { |
| 8 | 13 | const report = summarize([]); |
| @@ -11,6 +16,8 @@ test("an empty list summarizes to zeros, not NaN or a thrown error", () => { | ||
| 11 | 16 | plantRate: 0, |
| 12 | 17 | reasons: { elimination: 0, detonated: 0, defused: 0, time: 0 }, |
| 13 | 18 | defuseRate: 0, |
| 19 | wins: { t: 0, ct: 0 }, | |
| 20 | winShare: { t: 0, ct: 0 }, | |
| 14 | 21 | medianSeconds: 0, |
| 15 | 22 | longestSeconds: 0, |
| 16 | 23 | }); |
| @@ -60,6 +67,39 @@ test("no bombs planted leaves the defuse rate at zero, not NaN", () => { | ||
| 60 | 67 | expect(summarize(rounds).defuseRate).toBe(0); |
| 61 | 68 | }); |
| 62 | 69 | |
| 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 | ||
| 63 | 103 | test("the report renders to one line a stat, with every reason named", () => { |
| 64 | 104 | const rounds = [round("detonated", true, 130), round("defused", true, 90)]; |
| 65 | 105 | const lines = reportLines(summarize(rounds)); |
src/client/rounds.ts +12 −1
| @@ -5,10 +5,11 @@ | ||
| 5 | 5 | * ever stepping a world. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import type { EndReason } from "./match.ts"; | |
| 8 | import type { EndReason, Team } from "./match.ts"; | |
| 9 | 9 | |
| 10 | 10 | /** One round, boiled down to the numbers a report needs and nothing else. */ |
| 11 | 11 | export type RoundRecord = { |
| 12 | winner: Team; | |
| 12 | 13 | reason: EndReason; |
| 13 | 14 | planted: boolean; // did the bomb go down this round |
| 14 | 15 | seconds: number; // freeze start to the round being decided |
| @@ -19,6 +20,8 @@ export type Report = { | ||
| 19 | 20 | plantRate: number; // fraction of rounds the bomb went down, 0 if none played |
| 20 | 21 | reasons: Record<EndReason, number>; |
| 21 | 22 | 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 | |
| 22 | 25 | medianSeconds: number; |
| 23 | 26 | longestSeconds: number; |
| 24 | 27 | }; |
| @@ -36,9 +39,11 @@ function median(values: number[]): number { | ||
| 36 | 39 | |
| 37 | 40 | export function summarize(rounds: RoundRecord[]): Report { |
| 38 | 41 | const reasons = Object.fromEntries(REASONS.map((r) => [r, 0])) as Record<EndReason, number>; |
| 42 | const wins: Record<Team, number> = { t: 0, ct: 0 }; | |
| 39 | 43 | let planted = 0; |
| 40 | 44 | for (const round of rounds) { |
| 41 | 45 | reasons[round.reason] += 1; |
| 46 | wins[round.winner] += 1; | |
| 42 | 47 | if (round.planted) planted += 1; |
| 43 | 48 | } |
| 44 | 49 | const seconds = rounds.map((r) => r.seconds); |
| @@ -47,6 +52,11 @@ export function summarize(rounds: RoundRecord[]): Report { | ||
| 47 | 52 | plantRate: rounds.length === 0 ? 0 : planted / rounds.length, |
| 48 | 53 | reasons, |
| 49 | 54 | 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 | }, | |
| 50 | 60 | medianSeconds: median(seconds), |
| 51 | 61 | longestSeconds: seconds.length === 0 ? 0 : Math.max(...seconds), |
| 52 | 62 | }; |
| @@ -62,6 +72,7 @@ export function reportLines(report: Report): string[] { | ||
| 62 | 72 | `ends detonated ${report.reasons.detonated} · time ${report.reasons.time} · ` + |
| 63 | 73 | `elimination ${report.reasons.elimination} · defused ${report.reasons.defused}`, |
| 64 | 74 | `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})`, | |
| 65 | 76 | `round length median ${report.medianSeconds.toFixed(0)}s, longest ${report.longestSeconds.toFixed(0)}s`, |
| 66 | 77 | ]; |
| 67 | 78 | } |
new above
offline