@cs2dak/presentation 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ /**
2
+ * C 阶段:55 张真实 ZIP 数据验收
3
+ * 验证:排序合理性、null 分布、AWP/R 是否趋零、FK/MK/C 数值范围、
4
+ * 多账号合并、强项/弱项百分位。
5
+ */
6
+ import { readdir, readFile } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { describe, expect, it } from "vitest";
10
+ import { loadDemoPackageFromZip } from "@cs2dak/core";
11
+ import { buildSeasonCohort } from "@cs2dak/cohort";
12
+ import { buildSeasonLeaderboardModel, buildAllPlayerSeasonProfiles } from "./index";
13
+
14
+ const ZIP_DIR = fileURLToPath(new URL("../../../fixtures/output/nju-rivals-2026", import.meta.url));
15
+ const REPORT_FILE = fileURLToPath(new URL("../../../fixtures/output/_c-phase-report.txt", import.meta.url));
16
+ const integrationTimeoutMs = 120_000;
17
+ const reportLines: string[] = [];
18
+
19
+ function report(msg: string) {
20
+ reportLines.push(msg);
21
+ console.log(msg);
22
+ }
23
+
24
+ async function dirExists(path: string): Promise<boolean> {
25
+ try {
26
+ const stat = await (await import("node:fs/promises")).stat(path);
27
+ return stat.isDirectory();
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ async function njuCohort() {
34
+ const names = (await readdir(ZIP_DIR)).filter((n) => n.endsWith(".zip")).sort();
35
+ const demos = await Promise.all(
36
+ names.map(async (name) => ({
37
+ matchId: name.replace(/\.zip$/, ""),
38
+ pkg: await loadDemoPackageFromZip(await readFile(join(ZIP_DIR, name)))
39
+ }))
40
+ );
41
+ return { bundle: buildSeasonCohort(demos), matchCount: names.length };
42
+ }
43
+
44
+ describe("55-ZIP season verification", () => {
45
+ it(
46
+ "builds cohort + leaderboard + profiles from 55 real ZIPs",
47
+ async () => {
48
+ if (!(await dirExists(ZIP_DIR))) {
49
+ report("ZIP 目录不存在,跳过验证(CI 环境无预导出 ZIP)");
50
+ return;
51
+ }
52
+ const { bundle, matchCount } = await njuCohort();
53
+ expect(matchCount).toBeGreaterThanOrEqual(50);
54
+ expect(bundle.players.length).toBeGreaterThan(30);
55
+
56
+ const leaderboard = buildSeasonLeaderboardModel(bundle);
57
+ const profiles = buildAllPlayerSeasonProfiles(bundle);
58
+ expect(leaderboard.rows).toHaveLength(bundle.players.length);
59
+ expect(profiles).toHaveLength(bundle.players.length);
60
+
61
+ // 排序是 React SeasonLeaderboard 组件的职责,不在 builder 侧。
62
+ // 这里只验证数据完整性和范围合理。组件排序测试在 SeasonLeaderboard.test.ts。
63
+
64
+ // AWP/R 应该大部分接近 0(非狙手无 AWP 击杀)
65
+ const awprValues = leaderboard.rows
66
+ .map((r) => r.metrics.awpKillsPerRound)
67
+ .filter((v): v is number => v != null);
68
+ if (awprValues.length > 0) {
69
+ const belowPoint1 = awprValues.filter((v) => v < 0.1).length;
70
+ const pctBelow = (belowPoint1 / awprValues.length) * 100;
71
+ report(`AWP/R: ${awprValues.length} non-null, ${pctBelow.toFixed(0)}% < 0.1`);
72
+ // 非狙手主导的场景,AWP/R 趋近 0 的比例应高
73
+ expect(pctBelow).toBeGreaterThan(50);
74
+ }
75
+
76
+ // FK/MK/C 每 100 回合数值范围:合理范围 0–30,极端值检查
77
+ for (const key of ["firstKillPer100", "multiKillPer100", "clutchPer100"] as const) {
78
+ const values = leaderboard.rows
79
+ .map((r) => r.metrics[key])
80
+ .filter((v): v is number => v != null);
81
+ if (values.length > 0) {
82
+ const sorted = [...values].sort((a, b) => a - b);
83
+ const p95 = sorted[Math.floor(sorted.length * 0.95)];
84
+ report(`${key}: n=${values.length} p50=${sorted[Math.floor(sorted.length * 0.5)].toFixed(1)} p95=${p95.toFixed(1)}`);
85
+ // FK/100r p95 不应超过 30(每 100 回合 30 首杀极端异常)
86
+ expect(p95).toBeLessThan(30);
87
+ }
88
+ }
89
+
90
+ // 选手页前 3 名有有意义的内容
91
+ const top3 = leaderboard.rows
92
+ .filter((r) => r.metrics.rivalhubRR != null)
93
+ .sort((a, b) => (b.metrics.rivalhubRR ?? 0) - (a.metrics.rivalhubRR ?? 0))
94
+ .slice(0, 3);
95
+ for (const row of top3) {
96
+ const profile = profiles.find((p) => p.playerKey === row.playerKey)!;
97
+ expect(profile.rating.rivalhubRR).toBeGreaterThan(0);
98
+ expect(profile.perMatch.length).toBeGreaterThanOrEqual(1);
99
+ if (profile.style) {
100
+ expect(profile.style.axes).toHaveLength(8);
101
+ }
102
+ report(`Top: ${profile.name} RR=${profile.rating.rivalhubRR.toFixed(2)} maps=${profile.mapCount} strengths=[${profile.strengths.join(",")}]`);
103
+ }
104
+
105
+ // 写入报告文件供后续查看
106
+ await (await import("node:fs/promises")).writeFile(REPORT_FILE, reportLines.join("\n") + "\n");
107
+ report(`\n报告已写入 ${REPORT_FILE}`);
108
+ },
109
+ integrationTimeoutMs
110
+ );
111
+ });
@@ -0,0 +1,81 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, it } from "vitest";
4
+ import { loadDemoPackageFromZip } from "@cs2dak/core";
5
+ import { mvpRecommendationSchema, seriesSummarySchema } from "@cs2dak/contract";
6
+ import { buildMatchWorkspaceModel, buildSeriesSummary, recommendMatchMvp } from "./index";
7
+
8
+ async function buildWorkspace() {
9
+ const zip = await readFile(fileURLToPath(new URL("../../../fixtures/input/cs2dak-sanitized-de_ancient.zip", import.meta.url)));
10
+ return buildMatchWorkspaceModel(await loadDemoPackageFromZip(zip));
11
+ }
12
+
13
+ describe("recommendMatchMvp", () => {
14
+ it("ranks candidates from accountRR, HLTV Rating 2.0 and confidence", async () => {
15
+ const model = await buildWorkspace();
16
+ const recommendation = recommendMatchMvp(model);
17
+
18
+ expect(() => mvpRecommendationSchema.parse(recommendation)).not.toThrow();
19
+ expect(recommendation.candidates.length).toBeGreaterThan(0);
20
+ expect(recommendation.recommended.playerKey).toBe(recommendation.candidates[0]?.playerKey);
21
+ expect(recommendation.candidates.map((row) => row.recommendationScore)).toEqual(
22
+ [...recommendation.candidates.map((row) => row.recommendationScore)].sort((a, b) => b - a)
23
+ );
24
+ expect(recommendation.recommended.explanation).toHaveLength(3);
25
+ expect(recommendation).not.toHaveProperty("winnerUserId");
26
+ });
27
+ });
28
+
29
+ describe("buildSeriesSummary", () => {
30
+ it("aggregates counts and round-weighted rates across maps", async () => {
31
+ const model = await buildWorkspace();
32
+ const summary = buildSeriesSummary([
33
+ { matchId: "map-1", model },
34
+ { matchId: "map-2", model }
35
+ ]);
36
+
37
+ expect(() => seriesSummarySchema.parse(summary)).not.toThrow();
38
+ expect(summary.mapCount).toBe(2);
39
+ expect(summary.maps).toHaveLength(2);
40
+ expect(summary.scoreboard).toHaveLength(model.scoreboard.length);
41
+ expect(summary.mvpCandidates.length).toBeGreaterThan(0);
42
+
43
+ const source = model.scoreboard[0]!;
44
+ const aggregate = summary.scoreboard.find((row) => row.playerKey === source.steamId64)!;
45
+ expect(aggregate.mapCount).toBe(2);
46
+ expect(aggregate.kills).toBe(source.kills * 2);
47
+ expect(aggregate.deaths).toBe(source.deaths * 2);
48
+ expect(aggregate.adr).toBe(source.adr);
49
+ expect(aggregate.kast).toBe(source.kast);
50
+ expect(aggregate.perMap).toHaveLength(2);
51
+ });
52
+
53
+ it("rejects an empty series", () => {
54
+ expect(() => buildSeriesSummary([])).toThrow(/at least one/);
55
+ });
56
+
57
+ it("accepts an external team map instead of owning team identity", async () => {
58
+ const model = await buildWorkspace();
59
+ const renamed = {
60
+ ...model,
61
+ teams: {
62
+ teamA: { ...model.teams.teamA, name: "Alias A" },
63
+ teamB: { ...model.teams.teamB, name: "Alias B" }
64
+ }
65
+ };
66
+ const summary = buildSeriesSummary(
67
+ [{ matchId: "map-1", model }, { matchId: "map-2", model: renamed }],
68
+ {
69
+ teamMap: {
70
+ "map-1:teamA": { teamKey: "team-alpha", name: "Team Alpha" },
71
+ "map-2:teamA": { teamKey: "team-alpha", name: "Team Alpha" },
72
+ "map-1:teamB": { teamKey: "team-beta", name: "Team Beta" },
73
+ "map-2:teamB": { teamKey: "team-beta", name: "Team Beta" }
74
+ }
75
+ }
76
+ );
77
+
78
+ expect(summary.teams.map((team) => team.teamKey).sort()).toEqual(["team-alpha", "team-beta"]);
79
+ expect(summary.scoreboard.every((row) => ["Team Alpha", "Team Beta"].includes(row.teamName))).toBe(true);
80
+ });
81
+ });
package/src/series.ts ADDED
@@ -0,0 +1,213 @@
1
+ import {
2
+ mvpRecommendationSchema,
3
+ seriesSummarySchema,
4
+ type MatchWorkspaceModel,
5
+ type MvpCandidate,
6
+ type MvpRecommendation,
7
+ type SeriesMatchInput,
8
+ type SeriesPlayerRow,
9
+ type SeriesSummary
10
+ } from "@cs2dak/contract";
11
+ import { round } from "./season-metrics.js";
12
+
13
+ const MVP_CANDIDATE_LIMIT = 3;
14
+
15
+ interface MvpSource {
16
+ playerKey: string;
17
+ name: string;
18
+ teamName: string;
19
+ rivalhubRR: number;
20
+ hltvRating: number;
21
+ confidence: number;
22
+ }
23
+
24
+ function candidateFrom(source: MvpSource): MvpCandidate {
25
+ const recommendationScore = round(
26
+ source.rivalhubRR * 0.45 + source.hltvRating * 0.4 + source.confidence * 0.15,
27
+ 4
28
+ );
29
+ return {
30
+ ...source,
31
+ recommendationScore,
32
+ explanation: [
33
+ `RivalHub RR ${source.rivalhubRR.toFixed(3)}(45%)`,
34
+ `HLTV Rating 2.0 ${source.hltvRating.toFixed(2)}(40%)`,
35
+ `数据可信度 ${(source.confidence * 100).toFixed(0)}%(15%)`
36
+ ]
37
+ };
38
+ }
39
+
40
+ function rankMvpCandidates(sources: MvpSource[]): MvpCandidate[] {
41
+ return sources
42
+ .map(candidateFrom)
43
+ .sort((a, b) =>
44
+ b.recommendationScore - a.recommendationScore
45
+ || b.rivalhubRR - a.rivalhubRR
46
+ || b.hltvRating - a.hltvRating
47
+ )
48
+ .slice(0, MVP_CANDIDATE_LIMIT);
49
+ }
50
+
51
+ export function recommendMatchMvp(model: MatchWorkspaceModel): MvpRecommendation {
52
+ const candidates = rankMvpCandidates(model.scoreboard.map((row) => ({
53
+ playerKey: row.steamId64,
54
+ name: row.name,
55
+ teamName: model.teams[row.teamKey].name,
56
+ rivalhubRR: row.accountRR,
57
+ hltvRating: row.rr,
58
+ confidence: row.confidence
59
+ })));
60
+ if (candidates.length === 0) throw new Error("match MVP recommendation requires at least one player");
61
+ return mvpRecommendationSchema.parse({
62
+ version: "cs2-demo-analysis-kit/mvp-recommendation-0.1",
63
+ recommended: candidates[0],
64
+ candidates
65
+ });
66
+ }
67
+
68
+ interface SeriesAccumulator {
69
+ playerKey: string;
70
+ name: string;
71
+ teamName: string;
72
+ mapCount: number;
73
+ totalRounds: number;
74
+ kills: number;
75
+ deaths: number;
76
+ assists: number;
77
+ damage: number;
78
+ kastRounds: number;
79
+ rivalhubRRRounds: number;
80
+ hltvRatingRounds: number;
81
+ confidenceRounds: number;
82
+ perMap: SeriesPlayerRow["perMap"];
83
+ }
84
+
85
+ export interface SeriesTeamIdentity {
86
+ teamKey: string;
87
+ name: string;
88
+ }
89
+
90
+ export interface SeriesSummaryOptions {
91
+ /** Key format: `${matchId}:teamA` / `${matchId}:teamB`. Identity remains product-owned. */
92
+ teamMap?: Record<string, SeriesTeamIdentity>;
93
+ }
94
+
95
+ function stableTeamKey(name: string): string {
96
+ return name.trim().toLowerCase().replace(/\s+/g, "-");
97
+ }
98
+
99
+ function teamIdentity(
100
+ matchId: string,
101
+ side: "teamA" | "teamB",
102
+ model: MatchWorkspaceModel,
103
+ options: SeriesSummaryOptions
104
+ ): SeriesTeamIdentity {
105
+ return options.teamMap?.[`${matchId}:${side}`] ?? {
106
+ teamKey: stableTeamKey(model.teams[side].name),
107
+ name: model.teams[side].name
108
+ };
109
+ }
110
+
111
+ export function buildSeriesSummary(
112
+ matches: SeriesMatchInput[],
113
+ options: SeriesSummaryOptions = {}
114
+ ): SeriesSummary {
115
+ if (matches.length === 0) throw new Error("series summary requires at least one match");
116
+
117
+ const players = new Map<string, SeriesAccumulator>();
118
+ const teams = new Map<string, { teamKey: string; name: string; mapsWon: number }>();
119
+
120
+ for (const { matchId, model } of matches) {
121
+ const identity = {
122
+ teamA: teamIdentity(matchId, "teamA", model, options),
123
+ teamB: teamIdentity(matchId, "teamB", model, options)
124
+ };
125
+ for (const team of [identity.teamA, identity.teamB]) {
126
+ if (!teams.has(team.teamKey)) teams.set(team.teamKey, { ...team, mapsWon: 0 });
127
+ }
128
+ const winnerSide = model.teams.teamA.score === model.teams.teamB.score
129
+ ? null
130
+ : model.teams.teamA.score > model.teams.teamB.score ? "teamA" : "teamB";
131
+ if (winnerSide) teams.get(identity[winnerSide].teamKey)!.mapsWon += 1;
132
+
133
+ for (const row of model.scoreboard) {
134
+ const rounds = model.players.find((player) => player.row.steamId64 === row.steamId64)?.roundFacts.length
135
+ ?? model.rounds.length;
136
+ const teamName = identity[row.teamKey].name;
137
+ const acc = players.get(row.steamId64) ?? {
138
+ playerKey: row.steamId64,
139
+ name: row.name,
140
+ teamName,
141
+ mapCount: 0,
142
+ totalRounds: 0,
143
+ kills: 0,
144
+ deaths: 0,
145
+ assists: 0,
146
+ damage: 0,
147
+ kastRounds: 0,
148
+ rivalhubRRRounds: 0,
149
+ hltvRatingRounds: 0,
150
+ confidenceRounds: 0,
151
+ perMap: []
152
+ };
153
+ acc.mapCount += 1;
154
+ acc.totalRounds += rounds;
155
+ acc.kills += row.kills;
156
+ acc.deaths += row.deaths;
157
+ acc.assists += row.assists;
158
+ acc.damage += row.adr * rounds;
159
+ acc.kastRounds += (row.kast / 100) * rounds;
160
+ acc.rivalhubRRRounds += row.accountRR * rounds;
161
+ acc.hltvRatingRounds += row.rr * rounds;
162
+ acc.confidenceRounds += row.confidence * rounds;
163
+ acc.perMap.push({
164
+ matchId,
165
+ mapName: model.mapName,
166
+ rivalhubRR: row.accountRR,
167
+ hltvRating: row.rr,
168
+ adr: row.adr,
169
+ kast: row.kast
170
+ });
171
+ players.set(row.steamId64, acc);
172
+ }
173
+ }
174
+
175
+ const scoreboard = [...players.values()]
176
+ .map((row): SeriesPlayerRow => ({
177
+ playerKey: row.playerKey,
178
+ name: row.name,
179
+ teamName: row.teamName,
180
+ mapCount: row.mapCount,
181
+ totalRounds: row.totalRounds,
182
+ kills: row.kills,
183
+ deaths: row.deaths,
184
+ assists: row.assists,
185
+ adr: round(row.damage / row.totalRounds, 1),
186
+ kast: round((row.kastRounds / row.totalRounds) * 100, 1),
187
+ rivalhubRR: round(row.rivalhubRRRounds / row.totalRounds, 3),
188
+ hltvRating: round(row.hltvRatingRounds / row.totalRounds, 2),
189
+ confidence: round(row.confidenceRounds / row.totalRounds, 3),
190
+ perMap: row.perMap
191
+ }))
192
+ .sort((a, b) => b.rivalhubRR - a.rivalhubRR || b.hltvRating - a.hltvRating);
193
+
194
+ return seriesSummarySchema.parse({
195
+ version: "cs2-demo-analysis-kit/series-summary-0.1",
196
+ mapCount: matches.length,
197
+ maps: matches.map(({ matchId, model }) => ({
198
+ matchId,
199
+ mapName: model.mapName,
200
+ scoreline: model.scoreline,
201
+ teamAName: teamIdentity(matchId, "teamA", model, options).name,
202
+ teamBName: teamIdentity(matchId, "teamB", model, options).name,
203
+ winnerName: model.teams.teamA.score === model.teams.teamB.score
204
+ ? null
205
+ : model.teams.teamA.score > model.teams.teamB.score
206
+ ? teamIdentity(matchId, "teamA", model, options).name
207
+ : teamIdentity(matchId, "teamB", model, options).name
208
+ })),
209
+ teams: [...teams.values()].sort((a, b) => b.mapsWon - a.mapsWon || a.name.localeCompare(b.name)),
210
+ scoreboard,
211
+ mvpCandidates: rankMvpCandidates(scoreboard)
212
+ });
213
+ }
@@ -0,0 +1,77 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+ import { loadDemoPackageFromZip } from "@cs2dak/core";
6
+ import { buildSeasonCohort } from "@cs2dak/cohort";
7
+ import { teamCohortSummarySchema } from "@cs2dak/contract";
8
+ import { buildTeamCohortSummary } from "./index";
9
+
10
+ const fixtureDir = fileURLToPath(new URL("../../../fixtures/input/cohort", import.meta.url));
11
+ const integrationTimeoutMs = 20_000;
12
+ let cohortFixtures: ReturnType<typeof buildCohort> | null = null;
13
+
14
+ async function buildCohort() {
15
+ const names = (await readdir(fixtureDir)).filter((name) => name.endsWith(".zip")).sort();
16
+ const demos = await Promise.all(
17
+ names.map(async (name) => ({
18
+ matchId: name.replace(/\.zip$/, ""),
19
+ pkg: await loadDemoPackageFromZip(await readFile(join(fixtureDir, name)))
20
+ }))
21
+ );
22
+ return buildSeasonCohort(demos);
23
+ }
24
+
25
+ function getCohort() {
26
+ cohortFixtures ??= buildCohort();
27
+ return cohortFixtures;
28
+ }
29
+
30
+ describe("buildTeamCohortSummary", () => {
31
+ it(
32
+ "builds a product-neutral team summary from an externally supplied roster",
33
+ async () => {
34
+ const bundle = await getCohort();
35
+ const roster = bundle.players.slice(0, 5);
36
+ const summary = buildTeamCohortSummary(bundle, {
37
+ teamKey: "rivals-alpha",
38
+ name: "Rivals Alpha",
39
+ playerKeys: roster.map((player) => player.playerKey)
40
+ });
41
+
42
+ expect(() => teamCohortSummarySchema.parse(summary)).not.toThrow();
43
+ expect(summary.teamKey).toBe("rivals-alpha");
44
+ expect(summary.name).toBe("Rivals Alpha");
45
+ expect(summary.members).toHaveLength(5);
46
+ expect(summary.coreMembers).toHaveLength(5);
47
+ expect(summary.averages.rivalhubRR).toBeCloseTo(
48
+ roster.reduce((sum, player) => sum + player.accountRR, 0) / roster.length,
49
+ 2
50
+ );
51
+ expect(summary.leaders.map((leader) => leader.metric)).toEqual([
52
+ "rivalhubRR",
53
+ "adr",
54
+ "kast",
55
+ "firstKillPer100"
56
+ ]);
57
+ expect(summary.performance.firstKills).toBeGreaterThanOrEqual(0);
58
+ expect(summary.performance.firstDeaths).toBeGreaterThanOrEqual(0);
59
+ expect(summary.performance.openingDuelWinRate).toBeGreaterThanOrEqual(0);
60
+ expect(summary.performance.clutchAttempts).toBeGreaterThanOrEqual(summary.performance.clutchWins);
61
+ expect(summary.roleComplementarity.coverageScore).toBeGreaterThanOrEqual(0);
62
+ expect(summary.roleComplementarity.coverageScore).toBeLessThanOrEqual(100);
63
+ expect(summary).not.toHaveProperty("userId");
64
+ },
65
+ integrationTimeoutMs
66
+ );
67
+
68
+ it("rejects an empty or unknown roster", async () => {
69
+ const bundle = await getCohort();
70
+ expect(() =>
71
+ buildTeamCohortSummary(bundle, { teamKey: "empty", name: "Empty", playerKeys: [] })
72
+ ).toThrow(/at least one/);
73
+ expect(() =>
74
+ buildTeamCohortSummary(bundle, { teamKey: "unknown", name: "Unknown", playerKeys: ["missing"] })
75
+ ).toThrow(/not found/);
76
+ }, integrationTimeoutMs);
77
+ });
package/src/team.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { PRISM_AXIS_ORDER, type PrismAxisKey } from "@rivalhub/rival-rating";
2
+ import {
3
+ teamCohortSummarySchema,
4
+ type LeaderboardMetricKey,
5
+ type SeasonCohortBundle,
6
+ type TeamCohortSummary,
7
+ type TeamMemberSummary,
8
+ type TeamRosterInput
9
+ } from "@cs2dak/contract";
10
+ import { buildAllPlayerSeasonProfiles } from "./player.js";
11
+ import { computeSeasonMetrics, round } from "./season-metrics.js";
12
+
13
+ const AXIS_LABEL: Record<PrismAxisKey, string> = {
14
+ firepower: "火力",
15
+ opening: "首杀",
16
+ clutch: "残局",
17
+ sniping: "狙击",
18
+ survival: "生存",
19
+ utility: "道具",
20
+ trading: "补枪",
21
+ entry: "突破"
22
+ };
23
+
24
+ const LEADERS: Array<{ metric: "rivalhubRR" | "adr" | "kast" | "firstKillPer100"; label: string }> = [
25
+ { metric: "rivalhubRR", label: "RR" },
26
+ { metric: "adr", label: "ADR" },
27
+ { metric: "kast", label: "KAST%" },
28
+ { metric: "firstKillPer100", label: "FK/100r" }
29
+ ];
30
+
31
+ function average(values: number[]): number {
32
+ return values.length === 0 ? 0 : round(values.reduce((sum, value) => sum + value, 0) / values.length, 2);
33
+ }
34
+
35
+ function memberSummary(player: SeasonCohortBundle["players"][number]): TeamMemberSummary {
36
+ return {
37
+ playerKey: player.playerKey,
38
+ name: player.name,
39
+ mapCount: player.mapCount,
40
+ confidence: player.confidence,
41
+ metrics: computeSeasonMetrics(player)
42
+ };
43
+ }
44
+
45
+ function metricValues(members: TeamMemberSummary[], key: LeaderboardMetricKey): number[] {
46
+ return members.map((member) => member.metrics[key]).filter((value): value is number => value != null);
47
+ }
48
+
49
+ export function buildTeamCohortSummary(
50
+ bundle: SeasonCohortBundle,
51
+ roster: TeamRosterInput
52
+ ): TeamCohortSummary {
53
+ if (roster.playerKeys.length === 0) throw new Error("team roster requires at least one playerKey");
54
+
55
+ const byKey = new Map(bundle.players.map((player) => [player.playerKey, player]));
56
+ const players = roster.playerKeys.map((playerKey) => {
57
+ const player = byKey.get(playerKey);
58
+ if (!player) throw new Error(`playerKey not found in cohort: ${playerKey}`);
59
+ return player;
60
+ });
61
+ const members = players.map(memberSummary);
62
+ const firstKills = players.reduce((sum, player) => sum + player.indicators.firstKillCount, 0);
63
+ const firstDeaths = players.reduce((sum, player) => sum + player.indicators.firstDeathCount, 0);
64
+ const clutchAttempts = players.reduce((sum, player) => sum + player.indicators.clutchAttempts, 0);
65
+ const clutchWins = players.reduce((sum, player) => sum + player.indicators.clutchWins, 0);
66
+ const profilesByKey = new Map(buildAllPlayerSeasonProfiles(bundle).map((profile) => [profile.playerKey, profile]));
67
+ const styled = players
68
+ .map((player) => profilesByKey.get(player.playerKey)!)
69
+ .filter((profile) => profile.style != null);
70
+
71
+ const teamAxes = styled.length === 0
72
+ ? null
73
+ : PRISM_AXIS_ORDER.map((key) => ({
74
+ key,
75
+ label: AXIS_LABEL[key],
76
+ percentile: average(styled.map((profile) => profile.style!.axes.find((axis) => axis.key === key)!.percentile))
77
+ }));
78
+
79
+ const specialists = styled.length === 0
80
+ ? []
81
+ : PRISM_AXIS_ORDER.map((key) => {
82
+ const top = styled
83
+ .map((profile) => ({
84
+ playerKey: profile.playerKey,
85
+ name: profile.name,
86
+ percentile: profile.style!.axes.find((axis) => axis.key === key)!.percentile
87
+ }))
88
+ .sort((a, b) => b.percentile - a.percentile)[0]!;
89
+ return { key, label: AXIS_LABEL[key], ...top };
90
+ });
91
+ const dominantAxes = new Set(
92
+ styled.map((profile) =>
93
+ [...profile.style!.axes].sort((a, b) => b.percentile - a.percentile)[0]!.key
94
+ )
95
+ );
96
+
97
+ return teamCohortSummarySchema.parse({
98
+ version: "cs2-demo-analysis-kit/team-summary-0.1",
99
+ weightsVersion: bundle.weightsVersion,
100
+ teamKey: roster.teamKey,
101
+ name: roster.name,
102
+ members,
103
+ coreMembers: [...members]
104
+ .sort((a, b) => b.mapCount - a.mapCount || (b.metrics.rivalhubRR ?? 0) - (a.metrics.rivalhubRR ?? 0))
105
+ .slice(0, 5),
106
+ averages: {
107
+ rivalhubRR: average(metricValues(members, "rivalhubRR")),
108
+ hltvRating: average(metricValues(members, "hltvRating")),
109
+ adr: average(metricValues(members, "adr")),
110
+ kd: metricValues(members, "kd").length > 0 ? average(metricValues(members, "kd")) : null,
111
+ kast: average(metricValues(members, "kast")),
112
+ confidence: average(members.map((member) => member.confidence))
113
+ },
114
+ performance: {
115
+ firstKills,
116
+ firstDeaths,
117
+ openingDuelWinRate: firstKills + firstDeaths > 0
118
+ ? round(firstKills / (firstKills + firstDeaths), 4)
119
+ : null,
120
+ clutchAttempts,
121
+ clutchWins,
122
+ clutchWinRate: clutchAttempts > 0 ? round(clutchWins / clutchAttempts, 4) : null
123
+ },
124
+ style: teamAxes == null ? null : { axes: teamAxes },
125
+ leaders: LEADERS.map(({ metric, label }) => {
126
+ const leader = [...members]
127
+ .filter((member) => member.metrics[metric] != null)
128
+ .sort((a, b) => b.metrics[metric]! - a.metrics[metric]!)[0]!;
129
+ return { metric, label, playerKey: leader.playerKey, name: leader.name, value: leader.metrics[metric]! };
130
+ }),
131
+ roleComplementarity: {
132
+ coverageScore: styled.length === 0 ? 0 : round((dominantAxes.size / Math.min(styled.length, 8)) * 100, 1),
133
+ specialists,
134
+ weakAxes: (teamAxes ?? []).filter((axis) => axis.percentile < 50)
135
+ }
136
+ });
137
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { displayWeaponName } from "./weapons";
3
+
4
+ describe("displayWeaponName", () => {
5
+ it("maps raw codes to canonical display names", () => {
6
+ expect(displayWeaponName("ak47")).toBe("AK-47");
7
+ expect(displayWeaponName("m4a1_silencer")).toBe("M4A1-S");
8
+ expect(displayWeaponName("awp")).toBe("AWP");
9
+ expect(displayWeaponName("deagle")).toBe("Desert Eagle");
10
+ });
11
+
12
+ it("strips the weapon_ prefix and is case-insensitive", () => {
13
+ expect(displayWeaponName("weapon_ak47")).toBe("AK-47");
14
+ expect(displayWeaponName("WEAPON_AWP")).toBe("AWP");
15
+ });
16
+
17
+ it("collapses every knife/bayonet variant to a single label", () => {
18
+ expect(displayWeaponName("knife")).toBe("knife");
19
+ expect(displayWeaponName("knife_karambit")).toBe("knife");
20
+ expect(displayWeaponName("weapon_bayonet")).toBe("knife");
21
+ });
22
+
23
+ it("falls back to the normalized code instead of an unknown placeholder", () => {
24
+ expect(displayWeaponName("weapon_some_future_gun")).toBe("some_future_gun");
25
+ });
26
+
27
+ it("never returns a purely numeric string for a named weapon", () => {
28
+ for (const raw of ["ak47", "m4a1_silencer", "knife_m9_bayonet", "future_gun"]) {
29
+ expect(/^\d+$/.test(displayWeaponName(raw))).toBe(false);
30
+ }
31
+ });
32
+ });