@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.
- package/LICENSE +21 -0
- package/README.md +4 -0
- package/package.json +23 -0
- package/src/economy.test.ts +15 -0
- package/src/economy.ts +28 -0
- package/src/index.test.ts +19 -0
- package/src/index.ts +10 -0
- package/src/labels.ts +3 -0
- package/src/leaderboard.test.ts +84 -0
- package/src/leaderboard.ts +33 -0
- package/src/player.test.ts +116 -0
- package/src/player.ts +181 -0
- package/src/season-metrics.ts +93 -0
- package/src/season-validation.test.ts +111 -0
- package/src/series.test.ts +81 -0
- package/src/series.ts +213 -0
- package/src/team.test.ts +77 -0
- package/src/team.ts +137 -0
- package/src/weapons.test.ts +32 -0
- package/src/weapons.ts +93 -0
- package/src/workspace-utils.ts +31 -0
- package/src/workspace.ts +696 -0
|
@@ -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
|
+
}
|
package/src/team.test.ts
ADDED
|
@@ -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
|
+
});
|