@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
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import type { DemoPackage, TeamKey } from "@cs2dak/contract";
|
|
2
|
+
import {
|
|
3
|
+
buildTournamentAnalytics,
|
|
4
|
+
type TournamentEconomyType,
|
|
5
|
+
type TournamentEntityLabels,
|
|
6
|
+
type TournamentManAdvantage,
|
|
7
|
+
type TournamentMapFacts,
|
|
8
|
+
type TournamentTeamConversionCount,
|
|
9
|
+
type TournamentTeamMapCount,
|
|
10
|
+
type WinLossCount,
|
|
11
|
+
} from "@cs2dak/tournament";
|
|
12
|
+
import { round } from "./season-metrics.js";
|
|
13
|
+
import { displayWeaponName } from "./weapons.js";
|
|
14
|
+
import type { SeasonInsightsDemo } from "./insights.js";
|
|
15
|
+
|
|
16
|
+
// ── Tournament Dashboard legacy view model ─────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface TournamentMapStat {
|
|
19
|
+
mapName: string;
|
|
20
|
+
matches: number;
|
|
21
|
+
tWinRatePercent: number;
|
|
22
|
+
ctWinRatePercent: number;
|
|
23
|
+
pistolTWinRatePercent: number | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TournamentWeaponStat {
|
|
27
|
+
weapon: string;
|
|
28
|
+
label: string;
|
|
29
|
+
kills: number;
|
|
30
|
+
headshotPercent: number | null;
|
|
31
|
+
topPlayerName: string | null;
|
|
32
|
+
topPlayerKills: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TournamentTeamPistolStat {
|
|
36
|
+
teamName: string;
|
|
37
|
+
pistolRounds: number;
|
|
38
|
+
pistolWins: number;
|
|
39
|
+
winRatePercent: number | null;
|
|
40
|
+
conversionRounds: number;
|
|
41
|
+
conversionWins: number;
|
|
42
|
+
conversionPercent: number | null;
|
|
43
|
+
/** 反转换机会数:对手赢下手枪局且存在下一回合的次数。 */
|
|
44
|
+
breakRounds: number;
|
|
45
|
+
/** 反转换:对手赢手枪局后,该队赢了下一回合的次数。 */
|
|
46
|
+
breakWins: number;
|
|
47
|
+
breakRatePercent: number | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 经济对位胜率(按高低经济重排,跨场聚合时 A/B 队伍无意义)。
|
|
52
|
+
* 手枪局不入矩阵(见手枪局表);同档对局对称,lowWinRatePercent 为 null。
|
|
53
|
+
*/
|
|
54
|
+
export interface TournamentEconomyMatrixCell {
|
|
55
|
+
lowEconomy: string;
|
|
56
|
+
highEconomy: string;
|
|
57
|
+
rounds: number;
|
|
58
|
+
/** 低经济方获胜的原始次数;供非展示 consumer 重建比例。 */
|
|
59
|
+
lowEconomyWins: number;
|
|
60
|
+
lowWinRatePercent: number | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface TournamentEcoUpsetStat {
|
|
64
|
+
teamName: string;
|
|
65
|
+
opportunities: number;
|
|
66
|
+
wins: number;
|
|
67
|
+
winRatePercent: number | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TournamentManAdvantageStat {
|
|
71
|
+
advantageAlive: number;
|
|
72
|
+
disadvantageAlive: number;
|
|
73
|
+
advantageLabel: string;
|
|
74
|
+
disadvantageLabel: string;
|
|
75
|
+
opportunities: number;
|
|
76
|
+
advantageWins: number;
|
|
77
|
+
advantageConversionPercent: number | null;
|
|
78
|
+
disadvantageWins: number;
|
|
79
|
+
disadvantageConversionPercent: number | null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TournamentTeamManAdvantageState {
|
|
83
|
+
advantageAlive: number;
|
|
84
|
+
disadvantageAlive: number;
|
|
85
|
+
advantageLabel: string;
|
|
86
|
+
disadvantageLabel: string;
|
|
87
|
+
advantageOpportunities: number;
|
|
88
|
+
advantageWins: number;
|
|
89
|
+
advantageConversionPercent: number | null;
|
|
90
|
+
disadvantageOpportunities: number;
|
|
91
|
+
disadvantageWins: number;
|
|
92
|
+
disadvantageConversionPercent: number | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface TournamentTeamManAdvantageStat {
|
|
96
|
+
teamName: string;
|
|
97
|
+
states: TournamentTeamManAdvantageState[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface TournamentTeamEconomySummary {
|
|
101
|
+
teamName: string;
|
|
102
|
+
maps: number;
|
|
103
|
+
rounds: number;
|
|
104
|
+
roundWins: number;
|
|
105
|
+
roundWinPercent: number | null;
|
|
106
|
+
pistol: {
|
|
107
|
+
rounds: number;
|
|
108
|
+
wins: number;
|
|
109
|
+
winRatePercent: number | null;
|
|
110
|
+
};
|
|
111
|
+
round2: {
|
|
112
|
+
conversionRounds: number;
|
|
113
|
+
conversionWins: number;
|
|
114
|
+
conversionPercent: number | null;
|
|
115
|
+
breakRounds: number;
|
|
116
|
+
breakWins: number;
|
|
117
|
+
breakRatePercent: number | null;
|
|
118
|
+
};
|
|
119
|
+
manAdvantage: TournamentTeamManAdvantageStat;
|
|
120
|
+
smallBuyUpset: {
|
|
121
|
+
opportunities: number;
|
|
122
|
+
wins: number;
|
|
123
|
+
winRatePercent: number | null;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface TournamentInsights {
|
|
128
|
+
matchCount: number;
|
|
129
|
+
roundCount: number;
|
|
130
|
+
maps: TournamentMapStat[];
|
|
131
|
+
weaponKills: TournamentWeaponStat[];
|
|
132
|
+
teamPistols: TournamentTeamPistolStat[];
|
|
133
|
+
economyMatrix: TournamentEconomyMatrixCell[];
|
|
134
|
+
ecoUpsets: TournamentEcoUpsetStat[];
|
|
135
|
+
manAdvantageConversions: TournamentManAdvantageStat[];
|
|
136
|
+
teamManAdvantageConversions: TournamentTeamManAdvantageStat[];
|
|
137
|
+
teamEconomySummaries: TournamentTeamEconomySummary[];
|
|
138
|
+
/** 全部回合的 T / CT 胜率(0-100)。 */
|
|
139
|
+
tWinRatePercent: number;
|
|
140
|
+
ctWinRatePercent: number;
|
|
141
|
+
/** 手枪局赢家把下一回合也拿下的比率。 */
|
|
142
|
+
pistolConversionPercent: number | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Studio 旧 derived-cache 中保存的单图原始事实。新代码只在这里把它
|
|
147
|
+
* 适配为 frozen sufficient facts;跨图 merge 由 @cs2dak/tournament 负责。
|
|
148
|
+
*/
|
|
149
|
+
export interface TournamentFacts {
|
|
150
|
+
matchId: string;
|
|
151
|
+
mapName: string;
|
|
152
|
+
teams: Record<TeamKey, string>;
|
|
153
|
+
players: Array<{ steamId64: string; name: string; teamKey: TeamKey }>;
|
|
154
|
+
kills: Array<{
|
|
155
|
+
roundNumber: number;
|
|
156
|
+
tick: number;
|
|
157
|
+
killerSteamId64: string | null;
|
|
158
|
+
victimSteamId64: string;
|
|
159
|
+
weapon: string;
|
|
160
|
+
headshot: boolean;
|
|
161
|
+
}>;
|
|
162
|
+
rounds: Array<{
|
|
163
|
+
roundNumber: number;
|
|
164
|
+
winnerSide: "t" | "ct";
|
|
165
|
+
winnerTeamKey: TeamKey;
|
|
166
|
+
teamAEconomy: string;
|
|
167
|
+
teamBEconomy: string;
|
|
168
|
+
teamASide?: "t" | "ct";
|
|
169
|
+
teamBSide?: "t" | "ct";
|
|
170
|
+
freezeEndTick?: number;
|
|
171
|
+
endTick?: number;
|
|
172
|
+
}>;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const ANALYSIS_VERSION = "cs2-demo-analysis-kit/1.0.1";
|
|
176
|
+
const SEMANTIC_PROFILE = "dak-stable/1";
|
|
177
|
+
const ECONOMY_RANK: Record<string, number> = { eco: 0, semi: 1, force: 2, full: 3 };
|
|
178
|
+
const MAN_ADVANTAGE_KEYS = ["5v4", "4v5", "5v3", "3v5"] as const satisfies readonly TournamentManAdvantage[];
|
|
179
|
+
|
|
180
|
+
function teamNameForTournament(pkg: DemoPackage, teamKey: TeamKey): string {
|
|
181
|
+
return teamKey === "teamA" ? (pkg.match.teamA.name ?? "Team A") : (pkg.match.teamB.name ?? "Team B");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function extractTournamentFacts(input: SeasonInsightsDemo): TournamentFacts {
|
|
185
|
+
const { pkg } = input;
|
|
186
|
+
return {
|
|
187
|
+
matchId: input.matchId,
|
|
188
|
+
mapName: pkg.match.mapName,
|
|
189
|
+
teams: {
|
|
190
|
+
teamA: teamNameForTournament(pkg, "teamA"),
|
|
191
|
+
teamB: teamNameForTournament(pkg, "teamB")
|
|
192
|
+
},
|
|
193
|
+
players: pkg.players.map((player) => ({
|
|
194
|
+
steamId64: player.steamId64,
|
|
195
|
+
name: player.name,
|
|
196
|
+
teamKey: player.teamKey
|
|
197
|
+
})),
|
|
198
|
+
kills: pkg.kills.map((kill) => ({
|
|
199
|
+
roundNumber: kill.roundNumber,
|
|
200
|
+
tick: kill.tick,
|
|
201
|
+
killerSteamId64: kill.killerIndex != null ? (pkg.players[kill.killerIndex]?.steamId64 ?? null) : null,
|
|
202
|
+
victimSteamId64: pkg.players[kill.victimIndex]?.steamId64 ?? "",
|
|
203
|
+
weapon: kill.weapon || "unknown",
|
|
204
|
+
headshot: kill.headshot
|
|
205
|
+
})).filter((kill) => kill.victimSteamId64 !== ""),
|
|
206
|
+
rounds: pkg.rounds.map((roundRow) => ({
|
|
207
|
+
roundNumber: roundRow.roundNumber,
|
|
208
|
+
winnerSide: roundRow.winnerSide,
|
|
209
|
+
winnerTeamKey: roundRow.winnerTeamKey,
|
|
210
|
+
teamAEconomy: roundRow.teamAEconomy,
|
|
211
|
+
teamBEconomy: roundRow.teamBEconomy,
|
|
212
|
+
teamASide: roundRow.teamASide,
|
|
213
|
+
teamBSide: roundRow.teamBSide,
|
|
214
|
+
freezeEndTick: roundRow.freezeEndTick,
|
|
215
|
+
endTick: roundRow.endTick,
|
|
216
|
+
}))
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function zeroCount(): WinLossCount {
|
|
221
|
+
return { opportunities: 0, wins: 0 };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function zeroTeamMaps(): TournamentTeamMapCount {
|
|
225
|
+
return { rounds: 0, roundWins: 0, tRounds: 0, tWins: 0, ctRounds: 0, ctWins: 0 };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function zeroTeamConversions(): TournamentTeamConversionCount {
|
|
229
|
+
return {
|
|
230
|
+
pistol: zeroCount(),
|
|
231
|
+
round2: { conversion: zeroCount(), break: zeroCount() },
|
|
232
|
+
ecoSemiUpset: zeroCount(),
|
|
233
|
+
manAdvantage: Object.fromEntries(MAN_ADVANTAGE_KEYS.map((key) => [key, zeroCount()])) as Record<TournamentManAdvantage, WinLossCount>,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function addCount(target: WinLossCount, won: boolean): void {
|
|
238
|
+
target.opportunities += 1;
|
|
239
|
+
if (won) target.wins += 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function otherTeam(teamKey: TeamKey): TeamKey {
|
|
243
|
+
return teamKey === "teamA" ? "teamB" : "teamA";
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function roundSides(roundRow: TournamentFacts["rounds"][number]): Record<TeamKey, "t" | "ct"> {
|
|
247
|
+
if (roundRow.teamASide && roundRow.teamBSide) {
|
|
248
|
+
return { teamA: roundRow.teamASide, teamB: roundRow.teamBSide };
|
|
249
|
+
}
|
|
250
|
+
const tTeam = roundRow.winnerSide === "t" ? roundRow.winnerTeamKey : otherTeam(roundRow.winnerTeamKey);
|
|
251
|
+
return { teamA: tTeam === "teamA" ? "t" : "ct", teamB: tTeam === "teamB" ? "t" : "ct" };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function addTeamRound(
|
|
255
|
+
teamMaps: Record<TeamKey, TournamentTeamMapCount>,
|
|
256
|
+
row: TournamentFacts["rounds"][number],
|
|
257
|
+
): void {
|
|
258
|
+
const sides = roundSides(row);
|
|
259
|
+
for (const teamKey of ["teamA", "teamB"] as const) {
|
|
260
|
+
const team = teamMaps[teamKey];
|
|
261
|
+
team.rounds += 1;
|
|
262
|
+
if (row.winnerTeamKey === teamKey) team.roundWins += 1;
|
|
263
|
+
if (sides[teamKey] === "t") {
|
|
264
|
+
team.tRounds += 1;
|
|
265
|
+
if (row.winnerTeamKey === teamKey) team.tWins += 1;
|
|
266
|
+
} else {
|
|
267
|
+
team.ctRounds += 1;
|
|
268
|
+
if (row.winnerTeamKey === teamKey) team.ctWins += 1;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function activeKillsForRound(facts: TournamentFacts, row: TournamentFacts["rounds"][number]): TournamentFacts["kills"] {
|
|
274
|
+
return facts.kills.filter((kill) =>
|
|
275
|
+
kill.roundNumber === row.roundNumber
|
|
276
|
+
&& (row.freezeEndTick == null || kill.tick >= row.freezeEndTick)
|
|
277
|
+
&& (row.endTick == null || kill.tick <= row.endTick)
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function collectManAdvantage(
|
|
282
|
+
teamConversions: Record<TeamKey, TournamentTeamConversionCount>,
|
|
283
|
+
facts: TournamentFacts,
|
|
284
|
+
row: TournamentFacts["rounds"][number],
|
|
285
|
+
): void {
|
|
286
|
+
const playersByTeam = {
|
|
287
|
+
teamA: new Set(facts.players.filter((player) => player.teamKey === "teamA").map((player) => player.steamId64)),
|
|
288
|
+
teamB: new Set(facts.players.filter((player) => player.teamKey === "teamB").map((player) => player.steamId64)),
|
|
289
|
+
};
|
|
290
|
+
const playersBySteam = new Map(facts.players.map((player) => [player.steamId64, player.teamKey]));
|
|
291
|
+
const alive = {
|
|
292
|
+
teamA: new Set(playersByTeam.teamA),
|
|
293
|
+
teamB: new Set(playersByTeam.teamB),
|
|
294
|
+
};
|
|
295
|
+
const seen = new Set<string>();
|
|
296
|
+
for (const kill of [...activeKillsForRound(facts, row)].sort((a, b) => a.tick - b.tick)) {
|
|
297
|
+
const victimTeamKey = playersBySteam.get(kill.victimSteamId64);
|
|
298
|
+
if (!victimTeamKey) continue;
|
|
299
|
+
alive[victimTeamKey].delete(kill.victimSteamId64);
|
|
300
|
+
const teamAAlive = alive.teamA.size;
|
|
301
|
+
const teamBAlive = alive.teamB.size;
|
|
302
|
+
if (teamAAlive === teamBAlive) continue;
|
|
303
|
+
const advantageAlive = Math.max(teamAAlive, teamBAlive);
|
|
304
|
+
const disadvantageAlive = Math.min(teamAAlive, teamBAlive);
|
|
305
|
+
const key = `${advantageAlive}v${disadvantageAlive}` as TournamentManAdvantage;
|
|
306
|
+
if (!MAN_ADVANTAGE_KEYS.includes(key) || seen.has(key)) continue;
|
|
307
|
+
seen.add(key);
|
|
308
|
+
const advantageTeam = teamAAlive > teamBAlive ? "teamA" : "teamB";
|
|
309
|
+
const disadvantageTeam = otherTeam(advantageTeam);
|
|
310
|
+
addCount(teamConversions[advantageTeam].manAdvantage[key], row.winnerTeamKey === advantageTeam);
|
|
311
|
+
const disadvantageKey = `${disadvantageAlive}v${advantageAlive}` as TournamentManAdvantage;
|
|
312
|
+
addCount(teamConversions[disadvantageTeam].manAdvantage[disadvantageKey], row.winnerTeamKey === disadvantageTeam);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function toTournamentMapFacts(facts: TournamentFacts): { facts: TournamentMapFacts; labels: TournamentEntityLabels } {
|
|
317
|
+
const orderedRounds = [...facts.rounds].sort((a, b) => a.roundNumber - b.roundNumber);
|
|
318
|
+
const teamEntityKeys = {
|
|
319
|
+
teamA: `legacy-team:${facts.teams.teamA}`,
|
|
320
|
+
teamB: `legacy-team:${facts.teams.teamB}`,
|
|
321
|
+
} as const;
|
|
322
|
+
const teamMaps = { teamA: zeroTeamMaps(), teamB: zeroTeamMaps() };
|
|
323
|
+
const teamConversions = { teamA: zeroTeamConversions(), teamB: zeroTeamConversions() };
|
|
324
|
+
const pistolSides = { t: zeroCount(), ct: zeroCount() };
|
|
325
|
+
const economyMatrix = new Map<string, { lowEconomy: TournamentEconomyType; highEconomy: TournamentEconomyType; rounds: number; lowEconomyWins: number }>();
|
|
326
|
+
|
|
327
|
+
for (let index = 0; index < orderedRounds.length; index += 1) {
|
|
328
|
+
const row = orderedRounds[index]!;
|
|
329
|
+
addTeamRound(teamMaps, row);
|
|
330
|
+
const next = orderedRounds[index + 1];
|
|
331
|
+
const isPistolEconomy = row.teamAEconomy === "pistol" || row.teamBEconomy === "pistol";
|
|
332
|
+
const isPistol = row.roundNumber === 1 || row.roundNumber === 13 || isPistolEconomy;
|
|
333
|
+
if (isPistolEconomy) {
|
|
334
|
+
addCount(pistolSides.t, row.winnerSide === "t");
|
|
335
|
+
addCount(pistolSides.ct, row.winnerSide === "ct");
|
|
336
|
+
}
|
|
337
|
+
if (isPistol) {
|
|
338
|
+
addCount(teamConversions.teamA.pistol, row.winnerTeamKey === "teamA");
|
|
339
|
+
addCount(teamConversions.teamB.pistol, row.winnerTeamKey === "teamB");
|
|
340
|
+
if (next) {
|
|
341
|
+
addCount(teamConversions[row.winnerTeamKey].round2.conversion, next.winnerTeamKey === row.winnerTeamKey);
|
|
342
|
+
const loserTeamKey = otherTeam(row.winnerTeamKey);
|
|
343
|
+
addCount(teamConversions[loserTeamKey].round2.break, next.winnerTeamKey === loserTeamKey);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const rankA = ECONOMY_RANK[row.teamAEconomy];
|
|
348
|
+
const rankB = ECONOMY_RANK[row.teamBEconomy];
|
|
349
|
+
if (rankA != null && rankB != null) {
|
|
350
|
+
const lowIsA = rankA <= rankB;
|
|
351
|
+
const lowEconomy = (lowIsA ? row.teamAEconomy : row.teamBEconomy) as TournamentEconomyType;
|
|
352
|
+
const highEconomy = (lowIsA ? row.teamBEconomy : row.teamAEconomy) as TournamentEconomyType;
|
|
353
|
+
const key = `${lowEconomy}:${highEconomy}`;
|
|
354
|
+
const cell = economyMatrix.get(key) ?? { lowEconomy, highEconomy, rounds: 0, lowEconomyWins: 0 };
|
|
355
|
+
cell.rounds += 1;
|
|
356
|
+
if (row.winnerTeamKey === (lowIsA ? "teamA" : "teamB")) cell.lowEconomyWins += 1;
|
|
357
|
+
economyMatrix.set(key, cell);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const aWeak = row.teamAEconomy === "eco" || row.teamAEconomy === "semi";
|
|
361
|
+
const bWeak = row.teamBEconomy === "eco" || row.teamBEconomy === "semi";
|
|
362
|
+
if (aWeak && row.teamBEconomy === "full") addCount(teamConversions.teamA.ecoSemiUpset, row.winnerTeamKey === "teamA");
|
|
363
|
+
if (bWeak && row.teamAEconomy === "full") addCount(teamConversions.teamB.ecoSemiUpset, row.winnerTeamKey === "teamB");
|
|
364
|
+
|
|
365
|
+
collectManAdvantage(teamConversions, facts, row);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const playerTeamKeys = new Map(facts.players.map((player) => [player.steamId64, teamEntityKeys[player.teamKey]]));
|
|
369
|
+
const playerWeapons = new Map<string, {
|
|
370
|
+
playerEntityKey: string;
|
|
371
|
+
teamEntityKey: string;
|
|
372
|
+
weapon: string;
|
|
373
|
+
kills: number;
|
|
374
|
+
headshotKills: number;
|
|
375
|
+
}>();
|
|
376
|
+
for (const kill of facts.kills) {
|
|
377
|
+
if (!kill.killerSteamId64) continue;
|
|
378
|
+
const teamEntityKey = playerTeamKeys.get(kill.killerSteamId64);
|
|
379
|
+
if (!teamEntityKey) continue;
|
|
380
|
+
const key = `${kill.killerSteamId64}:${kill.weapon}`;
|
|
381
|
+
const cell = playerWeapons.get(key) ?? {
|
|
382
|
+
playerEntityKey: `steam:${kill.killerSteamId64}`,
|
|
383
|
+
teamEntityKey,
|
|
384
|
+
weapon: kill.weapon,
|
|
385
|
+
kills: 0,
|
|
386
|
+
headshotKills: 0,
|
|
387
|
+
};
|
|
388
|
+
cell.kills += 1;
|
|
389
|
+
if (kill.headshot) cell.headshotKills += 1;
|
|
390
|
+
playerWeapons.set(key, cell);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
facts: {
|
|
395
|
+
semanticProfile: SEMANTIC_PROFILE,
|
|
396
|
+
analysisVersion: ANALYSIS_VERSION,
|
|
397
|
+
mapKey: `legacy-map:${facts.matchId}:${facts.mapName}`,
|
|
398
|
+
matchKey: facts.matchId,
|
|
399
|
+
mapName: facts.mapName,
|
|
400
|
+
teamEntityKeys,
|
|
401
|
+
teamMaps,
|
|
402
|
+
teamConversions,
|
|
403
|
+
economyMatrix: [...economyMatrix.values()],
|
|
404
|
+
pistolSides,
|
|
405
|
+
playerWeapons: [...playerWeapons.values()],
|
|
406
|
+
},
|
|
407
|
+
labels: {
|
|
408
|
+
teams: {
|
|
409
|
+
[teamEntityKeys.teamA]: facts.teams.teamA,
|
|
410
|
+
[teamEntityKeys.teamB]: facts.teams.teamB,
|
|
411
|
+
},
|
|
412
|
+
players: Object.fromEntries(facts.players.map((player) => [`steam:${player.steamId64}`, player.name])),
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function mergeLabels(target: TournamentEntityLabels, source: TournamentEntityLabels): void {
|
|
418
|
+
for (const kind of ["teams", "players"] as const) {
|
|
419
|
+
if (!source[kind]) continue;
|
|
420
|
+
const labels = target[kind] ?? (target[kind] = {});
|
|
421
|
+
for (const [entityKey, label] of Object.entries(source[kind]!)) {
|
|
422
|
+
if (labels[entityKey] == null || label.localeCompare(labels[entityKey]!) < 0) labels[entityKey] = label;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function percent(value: number | null): number | null {
|
|
428
|
+
return value == null ? null : round(value * 100, 1);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function teamManAdvantageState(
|
|
432
|
+
advantage: TournamentMapFacts["teamConversions"]["teamA"]["manAdvantage"]["5v4"],
|
|
433
|
+
disadvantage: TournamentMapFacts["teamConversions"]["teamA"]["manAdvantage"]["4v5"],
|
|
434
|
+
advantageAlive: number,
|
|
435
|
+
disadvantageAlive: number,
|
|
436
|
+
): TournamentTeamManAdvantageState {
|
|
437
|
+
return {
|
|
438
|
+
advantageAlive,
|
|
439
|
+
disadvantageAlive,
|
|
440
|
+
advantageLabel: `${advantageAlive}v${disadvantageAlive}`,
|
|
441
|
+
disadvantageLabel: `${disadvantageAlive}v${advantageAlive}`,
|
|
442
|
+
advantageOpportunities: advantage.opportunities,
|
|
443
|
+
advantageWins: advantage.wins,
|
|
444
|
+
advantageConversionPercent: percent(advantage.opportunities > 0 ? advantage.wins / advantage.opportunities : null),
|
|
445
|
+
disadvantageOpportunities: disadvantage.opportunities,
|
|
446
|
+
disadvantageWins: disadvantage.wins,
|
|
447
|
+
disadvantageConversionPercent: percent(disadvantage.opportunities > 0 ? disadvantage.wins / disadvantage.opportunities : null),
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function legacyManAdvantageRows(analytics: ReturnType<typeof buildTournamentAnalytics>): TournamentManAdvantageStat[] {
|
|
452
|
+
return ([
|
|
453
|
+
["5v4", 5, 4],
|
|
454
|
+
["5v3", 5, 3],
|
|
455
|
+
] as const).filter(([key]) => analytics.totals.manAdvantage[key].opportunities > 0).map(([key, advantageAlive, disadvantageAlive]) => {
|
|
456
|
+
const advantage = analytics.totals.manAdvantage[key];
|
|
457
|
+
const disadvantage = analytics.totals.manAdvantage[`${disadvantageAlive}v${advantageAlive}` as TournamentManAdvantage];
|
|
458
|
+
return {
|
|
459
|
+
advantageAlive,
|
|
460
|
+
disadvantageAlive,
|
|
461
|
+
advantageLabel: `${advantageAlive}v${disadvantageAlive}`,
|
|
462
|
+
disadvantageLabel: `${disadvantageAlive}v${advantageAlive}`,
|
|
463
|
+
opportunities: advantage.opportunities,
|
|
464
|
+
advantageWins: advantage.wins,
|
|
465
|
+
advantageConversionPercent: percent(advantage.rate),
|
|
466
|
+
disadvantageWins: disadvantage.wins,
|
|
467
|
+
disadvantageConversionPercent: percent(disadvantage.rate),
|
|
468
|
+
};
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function legacyTeamManAdvantage(teamName: string, analyticsTeam: ReturnType<typeof buildTournamentAnalytics>["teams"][number]): TournamentTeamManAdvantageStat {
|
|
473
|
+
const states: TournamentTeamManAdvantageState[] = [];
|
|
474
|
+
const state54 = teamManAdvantageState(analyticsTeam.manAdvantage["5v4"], analyticsTeam.manAdvantage["4v5"], 5, 4);
|
|
475
|
+
const state53 = teamManAdvantageState(analyticsTeam.manAdvantage["5v3"], analyticsTeam.manAdvantage["3v5"], 5, 3);
|
|
476
|
+
if (state54.advantageOpportunities > 0 || state54.disadvantageOpportunities > 0) states.push(state54);
|
|
477
|
+
if (state53.advantageOpportunities > 0 || state53.disadvantageOpportunities > 0) states.push(state53);
|
|
478
|
+
return { teamName, states };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function buildLegacyInsights(analytics: ReturnType<typeof buildTournamentAnalytics>): TournamentInsights {
|
|
482
|
+
const maps = analytics.maps.map((map) => ({
|
|
483
|
+
mapName: map.mapName,
|
|
484
|
+
matches: map.mapCount,
|
|
485
|
+
tWinRatePercent: percent(map.t.rate) ?? 0,
|
|
486
|
+
ctWinRatePercent: percent(map.ct.rate) ?? 0,
|
|
487
|
+
pistolTWinRatePercent: percent(map.pistolT.rate),
|
|
488
|
+
}));
|
|
489
|
+
const teamPistols = analytics.teams
|
|
490
|
+
.filter((team) => team.pistol.opportunities > 0)
|
|
491
|
+
.map((team) => ({
|
|
492
|
+
teamName: team.team.displayName,
|
|
493
|
+
pistolRounds: team.pistol.opportunities,
|
|
494
|
+
pistolWins: team.pistol.wins,
|
|
495
|
+
winRatePercent: percent(team.pistol.rate),
|
|
496
|
+
conversionRounds: team.round2.conversion.opportunities,
|
|
497
|
+
conversionWins: team.round2.conversion.wins,
|
|
498
|
+
conversionPercent: percent(team.round2.conversion.rate),
|
|
499
|
+
breakRounds: team.round2.break.opportunities,
|
|
500
|
+
breakWins: team.round2.break.wins,
|
|
501
|
+
breakRatePercent: percent(team.round2.break.rate),
|
|
502
|
+
}))
|
|
503
|
+
.sort((a, b) => b.pistolWins - a.pistolWins || a.teamName.localeCompare(b.teamName));
|
|
504
|
+
const economyMatrix = analytics.economyMatrix
|
|
505
|
+
.map((cell) => ({
|
|
506
|
+
lowEconomy: cell.lowEconomy,
|
|
507
|
+
highEconomy: cell.highEconomy,
|
|
508
|
+
rounds: cell.rounds,
|
|
509
|
+
lowEconomyWins: cell.lowEconomyWins,
|
|
510
|
+
lowWinRatePercent: percent(cell.lowWinRate),
|
|
511
|
+
}))
|
|
512
|
+
.sort((a, b) => b.rounds - a.rounds || a.lowEconomy.localeCompare(b.lowEconomy) || a.highEconomy.localeCompare(b.highEconomy));
|
|
513
|
+
const ecoUpsets = analytics.teams
|
|
514
|
+
.map((team) => ({
|
|
515
|
+
teamName: team.team.displayName,
|
|
516
|
+
opportunities: team.ecoSemiUpset.opportunities,
|
|
517
|
+
wins: team.ecoSemiUpset.wins,
|
|
518
|
+
winRatePercent: percent(team.ecoSemiUpset.rate),
|
|
519
|
+
}))
|
|
520
|
+
.filter((row) => row.opportunities > 0)
|
|
521
|
+
.sort((a, b) => b.wins - a.wins || b.opportunities - a.opportunities || a.teamName.localeCompare(b.teamName));
|
|
522
|
+
const teamManAdvantageConversions = analytics.teams
|
|
523
|
+
.map((team) => legacyTeamManAdvantage(team.team.displayName, team))
|
|
524
|
+
.filter((team) => team.states.length > 0)
|
|
525
|
+
.sort((a, b) => a.teamName.localeCompare(b.teamName));
|
|
526
|
+
const teamEconomySummaries = analytics.teams
|
|
527
|
+
.map((team) => ({
|
|
528
|
+
teamName: team.team.displayName,
|
|
529
|
+
maps: team.mapCount,
|
|
530
|
+
rounds: team.rounds,
|
|
531
|
+
roundWins: team.roundWins,
|
|
532
|
+
roundWinPercent: percent(team.roundWinRate),
|
|
533
|
+
pistol: {
|
|
534
|
+
rounds: team.pistol.opportunities,
|
|
535
|
+
wins: team.pistol.wins,
|
|
536
|
+
winRatePercent: percent(team.pistol.rate),
|
|
537
|
+
},
|
|
538
|
+
round2: {
|
|
539
|
+
conversionRounds: team.round2.conversion.opportunities,
|
|
540
|
+
conversionWins: team.round2.conversion.wins,
|
|
541
|
+
conversionPercent: percent(team.round2.conversion.rate),
|
|
542
|
+
breakRounds: team.round2.break.opportunities,
|
|
543
|
+
breakWins: team.round2.break.wins,
|
|
544
|
+
breakRatePercent: percent(team.round2.break.rate),
|
|
545
|
+
},
|
|
546
|
+
manAdvantage: legacyTeamManAdvantage(team.team.displayName, team),
|
|
547
|
+
smallBuyUpset: {
|
|
548
|
+
opportunities: team.ecoSemiUpset.opportunities,
|
|
549
|
+
wins: team.ecoSemiUpset.wins,
|
|
550
|
+
winRatePercent: percent(team.ecoSemiUpset.rate),
|
|
551
|
+
},
|
|
552
|
+
}))
|
|
553
|
+
.sort((a, b) => (b.roundWinPercent ?? -1) - (a.roundWinPercent ?? -1) || a.teamName.localeCompare(b.teamName));
|
|
554
|
+
const weaponKills = analytics.weapons.slice(0, 10).map((weapon) => ({
|
|
555
|
+
weapon: weapon.weapon,
|
|
556
|
+
label: displayWeaponName(weapon.weapon),
|
|
557
|
+
kills: weapon.kills,
|
|
558
|
+
headshotPercent: percent(weapon.headshotRate),
|
|
559
|
+
topPlayerName: weapon.topPlayer?.displayName ?? null,
|
|
560
|
+
topPlayerKills: weapon.topPlayer?.kills ?? 0,
|
|
561
|
+
}));
|
|
562
|
+
return {
|
|
563
|
+
matchCount: analytics.totals.mapCount,
|
|
564
|
+
roundCount: analytics.totals.roundCount,
|
|
565
|
+
maps,
|
|
566
|
+
weaponKills,
|
|
567
|
+
teamPistols,
|
|
568
|
+
economyMatrix,
|
|
569
|
+
ecoUpsets,
|
|
570
|
+
manAdvantageConversions: legacyManAdvantageRows(analytics),
|
|
571
|
+
teamManAdvantageConversions,
|
|
572
|
+
teamEconomySummaries,
|
|
573
|
+
tWinRatePercent: percent(analytics.totals.t.rate) ?? 0,
|
|
574
|
+
ctWinRatePercent: percent(analytics.totals.ct.rate) ?? 0,
|
|
575
|
+
pistolConversionPercent: percent(analytics.totals.round2Conversion.rate),
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
export function buildTournamentInsightsFromFacts(demos: TournamentFacts[]): TournamentInsights {
|
|
580
|
+
const labels: TournamentEntityLabels = {};
|
|
581
|
+
const mapFacts = demos.map((demo) => {
|
|
582
|
+
const adapted = toTournamentMapFacts(demo);
|
|
583
|
+
mergeLabels(labels, adapted.labels);
|
|
584
|
+
return adapted.facts;
|
|
585
|
+
});
|
|
586
|
+
return buildLegacyInsights(buildTournamentAnalytics(mapFacts, { labels }));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
export function buildTournamentInsights(demos: SeasonInsightsDemo[]): TournamentInsights {
|
|
590
|
+
return buildTournamentInsightsFromFacts(demos.map(extractTournamentFacts));
|
|
591
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { loadDemoPackageFromZip } from "@cs2dak/core";
|
|
5
|
+
import { buildOpeningTrails } from "./trails";
|
|
6
|
+
|
|
7
|
+
describe("buildOpeningTrails", () => {
|
|
8
|
+
it("extracts full-buy opening trails with grenades from the sanitized fixture", async () => {
|
|
9
|
+
const zip = await readFile(
|
|
10
|
+
fileURLToPath(new URL("../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url))
|
|
11
|
+
);
|
|
12
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
13
|
+
const steamId64 = pkg.players[0].steamId64;
|
|
14
|
+
|
|
15
|
+
const model = buildOpeningTrails(pkg, "fixture-match", steamId64);
|
|
16
|
+
|
|
17
|
+
expect(model.available).toBe(true);
|
|
18
|
+
expect(model.mapName).toBe("de_ancient");
|
|
19
|
+
expect(model.steamId64).toBe(steamId64);
|
|
20
|
+
expect(model.windowSeconds).toBe(30);
|
|
21
|
+
// 长枪局存在且轨迹点落在窗口内、时间递增
|
|
22
|
+
expect(model.rounds.length).toBeGreaterThan(0);
|
|
23
|
+
for (const round of model.rounds) {
|
|
24
|
+
expect(round.economyType).toBe("full");
|
|
25
|
+
expect(round.points.length).toBeGreaterThan(0);
|
|
26
|
+
for (let i = 1; i < round.points.length; i += 1) {
|
|
27
|
+
expect(round.points[i].t).toBeGreaterThan(round.points[i - 1].t);
|
|
28
|
+
}
|
|
29
|
+
expect(round.points.at(-1)!.t).toBeLessThanOrEqual(30);
|
|
30
|
+
for (const grenade of round.grenades) {
|
|
31
|
+
expect(grenade.t).toBeLessThanOrEqual(30);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("supports custom window and economy filters", async () => {
|
|
37
|
+
const zip = await readFile(
|
|
38
|
+
fileURLToPath(new URL("../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url))
|
|
39
|
+
);
|
|
40
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
41
|
+
const steamId64 = pkg.players[0].steamId64;
|
|
42
|
+
|
|
43
|
+
const model = buildOpeningTrails(pkg, "fixture-match", steamId64, {
|
|
44
|
+
windowSeconds: 10,
|
|
45
|
+
economyTypes: ["pistol", "full"]
|
|
46
|
+
});
|
|
47
|
+
expect(model.windowSeconds).toBe(10);
|
|
48
|
+
for (const round of model.rounds) {
|
|
49
|
+
expect(["pistol", "full"]).toContain(round.economyType);
|
|
50
|
+
expect(round.points.at(-1)!.t).toBeLessThanOrEqual(10);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("returns unavailable model when replay stream is missing", async () => {
|
|
55
|
+
const zip = await readFile(
|
|
56
|
+
fileURLToPath(new URL("../../../fixtures/input/sample-2026-05-17_de_ancient_Team_Spirit_13-10_Team_Falcons.zip", import.meta.url))
|
|
57
|
+
);
|
|
58
|
+
const pkg = await loadDemoPackageFromZip(zip);
|
|
59
|
+
const model = buildOpeningTrails({ ...pkg, replay: undefined }, "m", pkg.players[0].steamId64);
|
|
60
|
+
expect(model.available).toBe(false);
|
|
61
|
+
expect(model.rounds).toHaveLength(0);
|
|
62
|
+
});
|
|
63
|
+
});
|