@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/LICENSE +7 -0
- package/package.json +8 -7
- package/src/double-awp.test.ts +29 -0
- package/src/double-awp.ts +50 -0
- package/src/duel.test.ts +190 -0
- package/src/duel.ts +359 -0
- package/src/findings.test.ts +62 -0
- package/src/findings.ts +174 -0
- package/src/index.test.ts +41 -8
- package/src/index.ts +90 -6
- package/src/insights.test.ts +522 -0
- package/src/insights.ts +1151 -0
- package/src/leaderboard.test.ts +48 -74
- package/src/map-roles.test.ts +131 -0
- package/src/map-roles.ts +219 -0
- package/src/player-map-pool.ts +28 -0
- package/src/player.test.ts +139 -110
- package/src/player.ts +66 -28
- package/src/radar-field.ts +121 -0
- package/src/replay-clock.test.ts +33 -0
- package/src/replay-clock.ts +61 -0
- package/src/season-metrics.ts +5 -0
- package/src/season-validation.test.ts +28 -8
- package/src/series.test.ts +7 -23
- package/src/series.ts +0 -19
- package/src/tactical-labels.ts +80 -0
- package/src/team.test.ts +19 -67
- package/src/team.ts +307 -127
- package/src/test-fixtures.ts +191 -0
- package/src/tournament-compat.ts +591 -0
- package/src/trails.test.ts +63 -0
- package/src/trails.ts +119 -0
- package/src/weapons.ts +1 -1
- package/src/workspace-utils.ts +2 -12
- package/src/workspace.ts +395 -83
- package/src/labels.ts +0 -3
package/src/team.ts
CHANGED
|
@@ -1,137 +1,317 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
+
}
|