@cs2dak/presentation 1.0.0 → 2.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/src/team.ts CHANGED
@@ -1,137 +1,317 @@
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);
1
+ import { round } from "./season-metrics.js";
2
+ import { displayWeaponName } from "./weapons.js";
3
+ import type { DemoPackage, TeamMapRoleMatrix } from "@cs2dak/contract";
4
+
5
+ export interface TeamComparisonPlayerRow {
6
+ teamName: string;
7
+ steamId64: string;
8
+ name: string;
9
+ rr: number | null;
10
+ adr: number | null;
11
+ kast: number | null;
12
+ kpr: number | null;
13
+ dpr: number | null;
33
14
  }
34
15
 
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
- };
16
+ /** 该队某场比赛的概览(赛前侦察用,可点跳回放)。 */
17
+ export interface TeamComparisonSideMatch {
18
+ matchId: string;
19
+ mapName: string;
20
+ opponent: string;
21
+ roundsWon: number;
22
+ roundsLost: number;
23
+ won: boolean;
43
24
  }
44
25
 
45
- function metricValues(members: TeamMemberSummary[], key: LeaderboardMetricKey): number[] {
46
- return members.map((member) => member.metrics[key]).filter((value): value is number => value != null);
26
+ export interface TeamComparisonSide {
27
+ teamName: string;
28
+ matchCount: number;
29
+ players: TeamComparisonPlayerRow[];
30
+ weaponPreference: Array<{ weapon: string; label: string; kills: number; sharePercent: number }>;
31
+ economyWinRate: Array<{ economyType: string; rounds: number; wins: number; winRatePercent: number | null }>;
32
+ /** 该队在 cohort 内打过的比赛(按场次自然序)。 */
33
+ matches: TeamComparisonSideMatch[];
47
34
  }
48
35
 
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
36
+ export interface TeamComparisonModel {
37
+ /** 0.2:服务"赛前侦察"——两队各自跨全部己方比赛聚合,无需互相交手;去掉旧的噪声 evidence,改 per-team 比赛列表。 */
38
+ version: "cs2-demo-analysis-kit/team-comparison-0.2";
39
+ teams: [TeamComparisonSide, TeamComparisonSide] | [];
40
+ radar: Array<{ metric: string; label: string; a: number | null; b: number | null; delta: number | null }>;
41
+ /** cohort 内全部队伍(按场次降序),供 UI 选 A/B 两队。 */
42
+ availableTeams: Array<{ name: string; matches: number }>;
43
+ }
44
+
45
+ /** 队伍总览只编排已有跨场事实;不推导强弱项、因果或对策。 */
46
+ export interface TeamOverviewModel {
47
+ version: "cs2-demo-analysis-kit/team-overview-0.2";
48
+ teamName: string;
49
+ matchCount: number;
50
+ wins: number;
51
+ losses: number;
52
+ roundsWon: number;
53
+ roundsLost: number;
54
+ maps: Array<{ mapName: string; matches: number; wins: number; losses: number; roundsWon: number; roundsLost: number }>;
55
+ roster: TeamComparisonPlayerRow[];
56
+ weaponPreference: TeamComparisonSide["weaponPreference"];
57
+ economyWinRate: TeamComparisonSide["economyWinRate"];
58
+ matches: TeamComparisonSideMatch[];
59
+ /** Declaration-neutral map/side responsibility summary; consumers may render the full matrices separately. */
60
+ roleMatrixSummary: Array<{ mapName: string; side: "t" | "ct"; status: TeamMapRoleMatrix["status"]; positionConcentration: number | null; unstableCoverage: boolean }>;
61
+ }
62
+
63
+ export interface TeamComparisonInput {
64
+ matchId: string;
65
+ pkg: DemoPackage;
66
+ }
67
+
68
+ export interface TeamComparisonFacts {
69
+ matchId: string;
70
+ mapName: string;
71
+ teams: Record<"teamA" | "teamB", string>;
72
+ players: Array<{ steamId64: string; name: string; teamKey: "teamA" | "teamB" }>;
73
+ playerStats: Array<{
74
+ playerSteamId64: string;
75
+ rounds: number;
76
+ kills: number;
77
+ deaths: number;
78
+ damageHealth: number;
79
+ kastRounds: number;
80
+ }>;
81
+ kills: Array<{ killerSteamId64: string | null; roundNumber: number; tick: number; weapon: string }>;
82
+ rounds: Array<{ teamAEconomy: string; teamBEconomy: string; winnerTeamKey: "teamA" | "teamB" }>;
83
+ }
84
+
85
+ function teamName(pkg: DemoPackage, key: string): string {
86
+ return key === "teamA" ? (pkg.match.teamA.name ?? "Team A") : (pkg.match.teamB.name ?? "Team B");
87
+ }
88
+
89
+ function averageNullable(values: Array<number | null | undefined>): number | null {
90
+ const nums = values.filter((value): value is number => value != null && Number.isFinite(value));
91
+ return nums.length > 0 ? round(nums.reduce((sum, value) => sum + value, 0) / nums.length, 2) : null;
92
+ }
93
+
94
+ export function extractTeamComparisonFacts(input: TeamComparisonInput): TeamComparisonFacts {
95
+ const { pkg } = input;
96
+ return {
97
+ matchId: input.matchId,
98
+ mapName: pkg.match.mapName,
99
+ teams: {
100
+ teamA: teamName(pkg, "teamA"),
101
+ teamB: teamName(pkg, "teamB")
123
102
  },
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)
103
+ players: pkg.players.map((player) => ({
104
+ steamId64: player.steamId64,
105
+ name: player.name,
106
+ teamKey: player.teamKey
107
+ })),
108
+ playerStats: pkg.playerStats.map((stat) => {
109
+ const player = pkg.players[stat.playerIndex];
110
+ return {
111
+ playerSteamId64: player?.steamId64 ?? "",
112
+ rounds: stat.rounds,
113
+ kills: stat.kills,
114
+ deaths: stat.deaths,
115
+ damageHealth: stat.damageHealth,
116
+ kastRounds: stat.kastRounds
117
+ };
118
+ }).filter((row) => row.playerSteamId64 !== ""),
119
+ kills: pkg.kills.map((kill) => ({
120
+ killerSteamId64: kill.killerIndex != null ? (pkg.players[kill.killerIndex]?.steamId64 ?? null) : null,
121
+ roundNumber: kill.roundNumber,
122
+ tick: kill.tick,
123
+ weapon: kill.weapon
124
+ })),
125
+ rounds: pkg.rounds.map((roundRow) => ({
126
+ teamAEconomy: roundRow.teamAEconomy,
127
+ teamBEconomy: roundRow.teamBEconomy,
128
+ winnerTeamKey: roundRow.winnerTeamKey
129
+ }))
130
+ };
131
+ }
132
+
133
+ /**
134
+ * @param inputs cohort 内全部比赛的 facts
135
+ * @param requestedPair 指定要对比的两队名(如 UI 选了 A/B);缺省取场次最多的两队。
136
+ * 两队**无需互相交手**——各自跨全部己方比赛聚合,服务赛前侦察。
137
+ */
138
+ export function buildTeamComparisonFromFacts(
139
+ inputs: TeamComparisonFacts[],
140
+ requestedPair?: [string, string]
141
+ ): TeamComparisonModel {
142
+ const matchCounts = new Map<string, number>();
143
+ for (const input of inputs) {
144
+ for (const teamName of [input.teams.teamA, input.teams.teamB]) {
145
+ matchCounts.set(teamName, (matchCounts.get(teamName) ?? 0) + 1);
135
146
  }
147
+ }
148
+ const availableTeams = [...matchCounts.entries()]
149
+ .map(([name, matches]) => ({ name, matches }))
150
+ .sort((a, b) => b.matches - a.matches || a.name.localeCompare(b.name));
151
+
152
+ const empty = (): TeamComparisonModel => ({
153
+ version: "cs2-demo-analysis-kit/team-comparison-0.2",
154
+ teams: [],
155
+ radar: [],
156
+ availableTeams
136
157
  });
158
+
159
+ const valid = (name: string | undefined): name is string => name != null && matchCounts.has(name);
160
+ const teamNames =
161
+ requestedPair && valid(requestedPair[0]) && valid(requestedPair[1]) && requestedPair[0] !== requestedPair[1]
162
+ ? [requestedPair[0], requestedPair[1]]
163
+ : availableTeams.slice(0, 2).map((team) => team.name);
164
+ if (teamNames.length < 2) return empty();
165
+
166
+ const sides = teamNames.map((name): TeamComparisonSide => {
167
+ const playerRows = new Map<string, TeamComparisonPlayerRow & { rounds: number; kills: number; deaths: number; damage: number; kastRounds: number }>();
168
+ const weaponKills = new Map<string, number>();
169
+ const economy = new Map<string, { rounds: number; wins: number }>();
170
+ const matches: TeamComparisonSideMatch[] = [];
171
+ for (const input of inputs) {
172
+ const playerBySteam = new Map(input.players.map((player) => [player.steamId64, player]));
173
+ for (const [teamKey, candidate] of [["teamA", input.teams.teamA], ["teamB", input.teams.teamB]] as const) {
174
+ if (candidate !== name) continue;
175
+ let roundsWon = 0;
176
+ let roundsLost = 0;
177
+ for (const round of input.rounds) {
178
+ if (round.winnerTeamKey === teamKey) roundsWon += 1;
179
+ else roundsLost += 1;
180
+ }
181
+ matches.push({
182
+ matchId: input.matchId,
183
+ mapName: input.mapName,
184
+ opponent: teamKey === "teamA" ? input.teams.teamB : input.teams.teamA,
185
+ roundsWon,
186
+ roundsLost,
187
+ won: roundsWon > roundsLost
188
+ });
189
+ for (const stat of input.playerStats) {
190
+ const player = playerBySteam.get(stat.playerSteamId64);
191
+ if (!player || player.teamKey !== teamKey) continue;
192
+ const current = playerRows.get(player.steamId64) ?? {
193
+ teamName: name,
194
+ steamId64: player.steamId64,
195
+ name: player.name,
196
+ rr: null,
197
+ adr: null,
198
+ kast: null,
199
+ kpr: null,
200
+ dpr: null,
201
+ rounds: 0,
202
+ kills: 0,
203
+ deaths: 0,
204
+ damage: 0,
205
+ kastRounds: 0
206
+ };
207
+ current.rounds += stat.rounds;
208
+ current.kills += stat.kills;
209
+ current.deaths += stat.deaths;
210
+ current.damage += stat.damageHealth;
211
+ current.kastRounds += stat.kastRounds;
212
+ playerRows.set(player.steamId64, current);
213
+ }
214
+ for (const kill of input.kills) {
215
+ if (kill.killerSteamId64 == null) continue;
216
+ const killer = playerBySteam.get(kill.killerSteamId64);
217
+ if (!killer || killer.teamKey !== teamKey) continue;
218
+ const weapon = killWeaponLabel(kill.weapon);
219
+ weaponKills.set(weapon, (weaponKills.get(weapon) ?? 0) + 1);
220
+ }
221
+ for (const round of input.rounds) {
222
+ const type = teamKey === "teamA" ? round.teamAEconomy : round.teamBEconomy;
223
+ const cell = economy.get(type) ?? { rounds: 0, wins: 0 };
224
+ cell.rounds += 1;
225
+ if (round.winnerTeamKey === teamKey) cell.wins += 1;
226
+ economy.set(type, cell);
227
+ }
228
+ }
229
+ }
230
+ const players = [...playerRows.values()].map((row) => ({
231
+ teamName: row.teamName,
232
+ steamId64: row.steamId64,
233
+ name: row.name,
234
+ rr: row.rounds > 0 ? round((row.kills - row.deaths) / row.rounds + row.damage / row.rounds / 100, 3) : null,
235
+ adr: row.rounds > 0 ? round(row.damage / row.rounds, 1) : null,
236
+ kast: row.rounds > 0 ? round(row.kastRounds / row.rounds * 100, 1) : null,
237
+ kpr: row.rounds > 0 ? round(row.kills / row.rounds, 3) : null,
238
+ dpr: row.rounds > 0 ? round(row.deaths / row.rounds, 3) : null
239
+ })).sort((a, b) => (b.rr ?? 0) - (a.rr ?? 0));
240
+ const totalWeaponKills = [...weaponKills.values()].reduce((sum, value) => sum + value, 0);
241
+ return {
242
+ teamName: name,
243
+ matchCount: matches.length,
244
+ players,
245
+ weaponPreference: [...weaponKills.entries()]
246
+ .map(([weapon, kills]) => ({ weapon, label: displayWeaponName(weapon), kills, sharePercent: totalWeaponKills > 0 ? round(kills / totalWeaponKills * 100, 1) : 0 }))
247
+ .sort((a, b) => b.kills - a.kills)
248
+ .slice(0, 8),
249
+ economyWinRate: [...economy.entries()]
250
+ .map(([economyType, cell]) => ({ economyType, rounds: cell.rounds, wins: cell.wins, winRatePercent: cell.rounds > 0 ? round(cell.wins / cell.rounds * 100, 1) : null }))
251
+ .sort((a, b) => a.economyType.localeCompare(b.economyType)),
252
+ matches
253
+ };
254
+ }) as [TeamComparisonSide, TeamComparisonSide];
255
+ const radar = [
256
+ { metric: "rr", label: "RR", a: averageNullable(sides[0].players.map((row) => row.rr)), b: averageNullable(sides[1].players.map((row) => row.rr)) },
257
+ { metric: "adr", label: "ADR", a: averageNullable(sides[0].players.map((row) => row.adr)), b: averageNullable(sides[1].players.map((row) => row.adr)) },
258
+ { metric: "kast", label: "KAST", a: averageNullable(sides[0].players.map((row) => row.kast)), b: averageNullable(sides[1].players.map((row) => row.kast)) },
259
+ { metric: "kpr", label: "KPR", a: averageNullable(sides[0].players.map((row) => row.kpr)), b: averageNullable(sides[1].players.map((row) => row.kpr)) },
260
+ { metric: "dpr", label: "DPR", a: averageNullable(sides[0].players.map((row) => row.dpr)), b: averageNullable(sides[1].players.map((row) => row.dpr)) }
261
+ ].map((row) => ({ ...row, delta: row.a != null && row.b != null ? round(row.a - row.b, 2) : null }));
262
+ return { version: "cs2-demo-analysis-kit/team-comparison-0.2", teams: sides, radar, availableTeams };
263
+ }
264
+
265
+ export function buildTeamComparison(
266
+ inputs: TeamComparisonInput[],
267
+ requestedPair?: [string, string]
268
+ ): TeamComparisonModel {
269
+ return buildTeamComparisonFromFacts(inputs.map(extractTeamComparisonFacts), requestedPair);
270
+ }
271
+
272
+ export function buildTeamOverviewFromFacts(inputs: TeamComparisonFacts[], teamName: string, roleMatrices: TeamMapRoleMatrix[] = []): TeamOverviewModel | null {
273
+ const opponent = inputs
274
+ .flatMap((input) => [input.teams.teamA, input.teams.teamB])
275
+ .find((name) => name !== teamName);
276
+ if (!opponent) return null;
277
+ const comparison = buildTeamComparisonFromFacts(inputs, [teamName, opponent]);
278
+ const side = comparison.teams.find((row) => row.teamName === teamName);
279
+ if (!side) return null;
280
+
281
+ const maps = new Map<string, { mapName: string; matches: number; wins: number; losses: number; roundsWon: number; roundsLost: number }>();
282
+ let roundsWon = 0;
283
+ let roundsLost = 0;
284
+ for (const match of side.matches) {
285
+ const row = maps.get(match.mapName) ?? { mapName: match.mapName, matches: 0, wins: 0, losses: 0, roundsWon: 0, roundsLost: 0 };
286
+ row.matches += 1;
287
+ row.wins += match.won ? 1 : 0;
288
+ row.losses += match.won ? 0 : 1;
289
+ row.roundsWon += match.roundsWon;
290
+ row.roundsLost += match.roundsLost;
291
+ roundsWon += match.roundsWon;
292
+ roundsLost += match.roundsLost;
293
+ maps.set(match.mapName, row);
294
+ }
295
+ return {
296
+ version: "cs2-demo-analysis-kit/team-overview-0.2",
297
+ teamName,
298
+ matchCount: side.matchCount,
299
+ wins: side.matches.filter((match) => match.won).length,
300
+ losses: side.matches.filter((match) => !match.won).length,
301
+ roundsWon,
302
+ roundsLost,
303
+ maps: [...maps.values()].sort((a, b) => b.matches - a.matches || a.mapName.localeCompare(b.mapName)),
304
+ roster: side.players,
305
+ weaponPreference: side.weaponPreference,
306
+ economyWinRate: side.economyWinRate,
307
+ matches: side.matches,
308
+ roleMatrixSummary: roleMatrices
309
+ .filter((matrix) => matrix.teamKey === teamName)
310
+ .map((matrix) => ({ mapName: matrix.mapName, side: matrix.side, status: matrix.status, positionConcentration: matrix.positionConcentration, unstableCoverage: matrix.unstableCoverage }))
311
+ .sort((a, b) => a.mapName.localeCompare(b.mapName) || a.side.localeCompare(b.side)),
312
+ };
313
+ }
314
+
315
+ function killWeaponLabel(weapon: string): string {
316
+ return weapon.toLowerCase().replace(/^weapon_/, "");
137
317
  }
@@ -0,0 +1,191 @@
1
+ import type { SeasonCohortBundle, SeasonPlayerRow } from "@cs2dak/contract";
2
+ import type { PrismAxisKey, PrismAxisResult, PrismResult } from "@rivalhub/rival-rating";
3
+
4
+ const AXES = [
5
+ "firepower",
6
+ "opening",
7
+ "clutch",
8
+ "sniping",
9
+ "survival",
10
+ "utility",
11
+ "trading",
12
+ "entry"
13
+ ] as const;
14
+
15
+ function prism(seed: number, steamId64: string, mapCount: number): PrismResult {
16
+ const axes = {} as Record<PrismAxisKey, PrismAxisResult>;
17
+ for (const [index, key] of AXES.entries()) {
18
+ axes[key] = {
19
+ involvementRaw: seed + index,
20
+ efficiencyRaw: seed + index / 10,
21
+ hasSignal: true,
22
+ availableSignalWeight: 1,
23
+ z: (seed + index) / 10,
24
+ percentile: Math.min(98, 30 + seed * 9 + index * 3)
25
+ };
26
+ }
27
+ return {
28
+ steamId64,
29
+ mapCount,
30
+ weightsVersion: "test-prism",
31
+ rrPercentile: 40 + seed * 8,
32
+ axes
33
+ };
34
+ }
35
+
36
+ function player(index: number, overrides: Partial<SeasonPlayerRow> = {}): SeasonPlayerRow {
37
+ const kills = 20 + index * 5;
38
+ const deaths = index === 4 ? 0 : 18 + index * 2;
39
+ const totalRounds = 48;
40
+ const steamId64 = `7656119800000000${index}`;
41
+ const base: SeasonPlayerRow = {
42
+ playerKey: `steam:${steamId64}`,
43
+ steamIds: [steamId64],
44
+ primarySteamId64: steamId64,
45
+ externalUserId: null,
46
+ name: `Player ${index}`,
47
+ teamKeys: ["teamA"],
48
+ mapCount: 2 + index,
49
+ rrV1: 0.9 + index * 0.08,
50
+ rrV1Percentile: 35 + index * 10,
51
+ indicators: {
52
+ steamId64,
53
+ totalRounds,
54
+ kills,
55
+ deaths,
56
+ assists: 5 + index,
57
+ kpr: kills / totalRounds,
58
+ dpr: deaths / totalRounds,
59
+ apr: (5 + index) / totalRounds,
60
+ adr: 65 + index * 6,
61
+ hsPercent: 42 + index,
62
+ kast: 66 + index * 3,
63
+ survivalRate: deaths === 0 ? 1 : 1 - deaths / totalRounds,
64
+ twoKillRounds: 3 + index,
65
+ threeKillRounds: 1 + index,
66
+ fourKillRounds: index % 2,
67
+ fiveKillRounds: 0,
68
+ multiKillRate: (4 + index) / totalRounds,
69
+ firstKillCount: 2 + index,
70
+ firstDeathCount: 1 + index,
71
+ firstKillRate: (2 + index) / totalRounds,
72
+ firstDeathRate: (1 + index) / totalRounds,
73
+ openingDuelRate: (3 + index * 2) / totalRounds,
74
+ openingDuelWinRate: (2 + index) / (3 + index * 2),
75
+ tradeKillCount: 2 + index,
76
+ tradeDeathCount: 1,
77
+ tradeKillRate: (2 + index) / totalRounds,
78
+ tradeDeathRate: 1 / totalRounds,
79
+ clutchAttempts: index,
80
+ clutchWins: Math.max(0, index - 1),
81
+ clutchWinRate: index > 0 ? (index - 1) / index : 0,
82
+ clutchFrequency: index / totalRounds,
83
+ clutchScore: index * 2,
84
+ clutchScoreRate: (index * 2) / totalRounds,
85
+ vsOne: { count: index, won: Math.max(0, index - 1) },
86
+ vsTwo: { count: 0, won: 0 },
87
+ vsThree: { count: 0, won: 0 },
88
+ vsFour: { count: 0, won: 0 },
89
+ vsFive: { count: 0, won: 0 },
90
+ awpKills: index,
91
+ awpKillsPerRound: index / totalRounds,
92
+ awpKillRate: index / Math.max(1, kills),
93
+ sniperKills: index,
94
+ sniperKillRate: index / Math.max(1, kills),
95
+ awpMultiKillRate: null,
96
+ awpDuelWinRate: null,
97
+ utilityDamage: 20 + index * 5,
98
+ utilityDamagePerRound: (20 + index * 5) / totalRounds,
99
+ flashAssistCount: index,
100
+ flashAssistPerRound: index / totalRounds,
101
+ blindDurationTotal: index * 2,
102
+ blindDurationPerRound: (index * 2) / totalRounds,
103
+ enemyFlashDurationSeconds: index * 3,
104
+ enemyFlashDurationPerRound: (index * 3) / totalRounds,
105
+ teamFlashDurationSeconds: index,
106
+ teamFlashDurationPerRound: index / totalRounds,
107
+ grenadeCount: 10 + index,
108
+ grenadeCountPerRound: (10 + index) / totalRounds,
109
+ ecoRoundCount: 4,
110
+ forceRoundCount: 5,
111
+ fullBuyRoundCount: 30,
112
+ pistolRoundCount: 4,
113
+ avgEquipmentValue: 3800 + index * 100,
114
+ combatDeathCount: deaths,
115
+ bombDeathCount: 0,
116
+ wallbangKillCount: index,
117
+ roundSwingTotal: index * 1.5,
118
+ roundSwingPerKill: kills > 0 ? (index * 1.5) / kills : null
119
+ },
120
+ weaponHighlights: {
121
+ steamId64,
122
+ totalKills: kills,
123
+ weapons: [
124
+ {
125
+ weapon: "ak47",
126
+ kills: kills - index,
127
+ headshotKills: Math.floor((kills - index) / 2),
128
+ tradeKills: index,
129
+ noScopeKills: 0,
130
+ throughSmokeKills: 1,
131
+ wallbangKills: index,
132
+ penetratedObjects: index
133
+ },
134
+ {
135
+ weapon: "awp",
136
+ kills: index,
137
+ headshotKills: 0,
138
+ tradeKills: 0,
139
+ noScopeKills: index > 2 ? 1 : 0,
140
+ throughSmokeKills: 0,
141
+ wallbangKills: 0,
142
+ penetratedObjects: 0
143
+ }
144
+ ],
145
+ highlights: {
146
+ wallbangKills: index,
147
+ noScopeKills: index > 2 ? 1 : 0,
148
+ throughSmokeKills: 1,
149
+ collateralKills: 0
150
+ }
151
+ },
152
+ accountRR: 1 + index * 0.1,
153
+ accountRRRaw: 0.95 + index * 0.1,
154
+ accountBreakdown: {
155
+ combat: 0.5 + index,
156
+ trade: 0.4 + index,
157
+ mapControl: 0.3 + index,
158
+ clutch: 0.2 + index,
159
+ objective: 0.1 + index,
160
+ utility: index
161
+ },
162
+ accountContextStatus: {
163
+ buyDelta: "available",
164
+ manState: "available"
165
+ },
166
+ prism: index === 6 ? null : prism(index, steamId64, 2 + index),
167
+ confidence: 0.6 + index * 0.05,
168
+ perMatch: [
169
+ { matchId: "m2", steamId64, accountRR: 1 + index * 0.1, rrV1: 0.9 + index * 0.08 },
170
+ { matchId: "m1", steamId64, accountRR: 0.95 + index * 0.1, rrV1: 0.85 + index * 0.08 }
171
+ ]
172
+ };
173
+ return { ...base, ...overrides };
174
+ }
175
+
176
+ export function buildTestSeasonCohortBundle(): SeasonCohortBundle {
177
+ return {
178
+ version: "cs2-demo-analysis-kit/cohort-1.0",
179
+ matchCount: 2,
180
+ weightsVersion: "test-weights",
181
+ players: [1, 2, 3, 4, 5, 6].map((i) => player(i)),
182
+ provenance: {
183
+ cohortVersion: "cs2-demo-analysis-kit/cohort-1.0",
184
+ sourceSchemaVersion: "cs2-demo-format/3.0",
185
+ matches: [
186
+ { matchId: "m1", sourceDemoHash: "hash-1" },
187
+ { matchId: "m2", sourceDemoHash: "hash-2" }
188
+ ]
189
+ }
190
+ };
191
+ }