@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/insights.ts
ADDED
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
import type { DemoPackage, EvidenceRef, MatchWorkspaceModel, TeamKey } from "@cs2dak/contract";
|
|
2
|
+
import { buildPlayerRoundUtilityFacts, derivePlayerMechanics, type PlayerMechanicsFact } from "@cs2dak/core";
|
|
3
|
+
import type { TriangleBvh } from "@cs2dak/maps";
|
|
4
|
+
import { round } from "./season-metrics.js";
|
|
5
|
+
import { displayWeaponName } from "./weapons.js";
|
|
6
|
+
import { normalizeWeapon } from "./workspace-utils.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* v0.3 洞察派生:个人趋势 / Flash Value / Mistake Review / Buy Quality /
|
|
10
|
+
* 赛事总览。输入是 Studio 已经加载的 {matchId, pkg} 列表(与 cohort 同源),
|
|
11
|
+
* 全部为纯函数派生,不算评分公式、不查数据库。
|
|
12
|
+
*
|
|
13
|
+
* 结论必须可证据化:每条 Mistake / 高光都带 matchId + roundNumber,
|
|
14
|
+
* UI 端可借 onOpenMatch 跳回具体比赛复盘(query-first 原则)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface SeasonInsightsDemo {
|
|
18
|
+
matchId: string;
|
|
19
|
+
pkg: DemoPackage;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── 个人趋势 ────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export interface PlayerTrendPoint {
|
|
25
|
+
matchId: string;
|
|
26
|
+
mapName: string;
|
|
27
|
+
adr: number;
|
|
28
|
+
kast: number;
|
|
29
|
+
/** 首杀数 - 首死数。 */
|
|
30
|
+
fkMinusFd: number;
|
|
31
|
+
utilityDamagePerRound: number;
|
|
32
|
+
clutchAttempts: number;
|
|
33
|
+
clutchWins: number;
|
|
34
|
+
kills: number;
|
|
35
|
+
deaths: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Flash Value ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface TeamFlashIncident extends EvidenceRef {
|
|
41
|
+
victimCount: number;
|
|
42
|
+
totalSeconds: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 单颗(按 flashId 归并)致盲敌方的闪光事件,含同颗队闪秒数与净收益。 */
|
|
46
|
+
export interface EnemyFlashIncident extends EvidenceRef {
|
|
47
|
+
/** 被这颗闪致盲的敌方人数。 */
|
|
48
|
+
victimCount: number;
|
|
49
|
+
/** 这颗闪致盲敌方总秒数。 */
|
|
50
|
+
enemySeconds: number;
|
|
51
|
+
/** 同一颗闪误盲队友的总秒数(无误盲为 0)。 */
|
|
52
|
+
teamSeconds: number;
|
|
53
|
+
/** enemySeconds − teamSeconds,单颗净收益。 */
|
|
54
|
+
netSeconds: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface FlashValueSummary {
|
|
58
|
+
flashesThrown: number;
|
|
59
|
+
enemyBlindSeconds: number;
|
|
60
|
+
teamBlindSeconds: number;
|
|
61
|
+
/** 致盲敌方人次(被白的敌人数量累计,不是秒数)。 */
|
|
62
|
+
enemyBlindVictims: number;
|
|
63
|
+
/** 敌方致盲秒数 / 投掷数;没投过闪为 null。 */
|
|
64
|
+
enemySecondsPerFlash: number | null;
|
|
65
|
+
/** (敌方 - 友方) 致盲秒数 / 投掷数;没投过闪为 null。 */
|
|
66
|
+
netSecondsPerFlash: number | null;
|
|
67
|
+
flashAssists: number;
|
|
68
|
+
/** 最严重的队闪事件(按致盲总秒数降序,最多 10 条)。 */
|
|
69
|
+
worstTeamFlashes: TeamFlashIncident[];
|
|
70
|
+
/** 效果最好的闪光事件(按致盲敌方秒数降序的候选集,UI 可再按净收益排序)。 */
|
|
71
|
+
bestEnemyFlashes: EnemyFlashIncident[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Mistake Review ──────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
export interface MistakeEvidence extends EvidenceRef {
|
|
77
|
+
detail: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface FirstDeathStat {
|
|
81
|
+
count: number;
|
|
82
|
+
attempts: number;
|
|
83
|
+
evidence: MistakeEvidence[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface MistakeReview {
|
|
87
|
+
/** 劣势经济局(eco/semi/force)中首死的回合(参考权重低)。 */
|
|
88
|
+
lowBuyFirstDeaths: FirstDeathStat;
|
|
89
|
+
/** 全枪全弹局(full)首死——最有分析价值的失误信号。 */
|
|
90
|
+
fullBuyFirstDeaths: FirstDeathStat;
|
|
91
|
+
/** Anti-eco 首死:对手 eco/semi 时我方首死。 */
|
|
92
|
+
antiEcoFirstDeaths: FirstDeathStat;
|
|
93
|
+
/** 死亡时间分布(相对 freeze end 的秒数):开局 25s / 25-55s / 55s 后。 */
|
|
94
|
+
deathTiming: { early: number; mid: number; late: number; total: number };
|
|
95
|
+
/** 残局失利(1vN 没打赢)。 */
|
|
96
|
+
clutchLosses: { count: number; evidence: MistakeEvidence[] };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface PlayerSeasonInsights {
|
|
100
|
+
trend: PlayerTrendPoint[];
|
|
101
|
+
flash: FlashValueSummary;
|
|
102
|
+
mistakes: MistakeReview;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface PlayerWeaponStat {
|
|
106
|
+
weapon: string;
|
|
107
|
+
label: string;
|
|
108
|
+
kills: number;
|
|
109
|
+
headshotPercent: number | null;
|
|
110
|
+
killsPerMatch: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface PlayerMechanicsWeaponProfile {
|
|
114
|
+
weapon: string;
|
|
115
|
+
label: string;
|
|
116
|
+
kills: number;
|
|
117
|
+
firstShotAccuracyPercent: number | null;
|
|
118
|
+
sprayAccuracyPercent: number | null;
|
|
119
|
+
medianTtkMs: number | null;
|
|
120
|
+
counterStrafeSuccessPercent: number | null;
|
|
121
|
+
oneTapRatePercent: number | null;
|
|
122
|
+
visualReactionMs: number | null;
|
|
123
|
+
preaimErrorDegrees: number | null;
|
|
124
|
+
headshotPercent: number | null;
|
|
125
|
+
killsPerMatch: number | null;
|
|
126
|
+
percentile: Record<string, string | null>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface PlayerMechanicsProfile {
|
|
130
|
+
overall: PlayerMechanicsWeaponProfile;
|
|
131
|
+
weapons: PlayerMechanicsWeaponProfile[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface PlayerFlashSummaryInput {
|
|
135
|
+
playerKey: string;
|
|
136
|
+
name: string;
|
|
137
|
+
steamIds: string[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface PlayerFlashSummary {
|
|
141
|
+
playerKey: string;
|
|
142
|
+
name: string;
|
|
143
|
+
flashesThrown: number;
|
|
144
|
+
enemyBlindSeconds: number;
|
|
145
|
+
teamBlindSeconds: number;
|
|
146
|
+
enemyBlindVictims: number;
|
|
147
|
+
enemySecondsPerFlash: number | null;
|
|
148
|
+
netSecondsPerFlash: number | null;
|
|
149
|
+
flashAssists: number;
|
|
150
|
+
worstTeamFlashes: TeamFlashIncident[];
|
|
151
|
+
bestEnemyFlashes: EnemyFlashIncident[];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type UtilityDamageKind = "he" | "fire";
|
|
155
|
+
|
|
156
|
+
export interface UtilityValueRow {
|
|
157
|
+
id: string;
|
|
158
|
+
name: string;
|
|
159
|
+
rounds: number;
|
|
160
|
+
flashesThrown: number;
|
|
161
|
+
enemyBlindSeconds: number;
|
|
162
|
+
enemyBlindSecondsRaw?: number;
|
|
163
|
+
enemyBlindSecondsPerFlash: number | null;
|
|
164
|
+
enemyBlindSecondsPerRound: number | null;
|
|
165
|
+
flashAssists: number;
|
|
166
|
+
flashAssistsPerRound: number | null;
|
|
167
|
+
heThrows: number;
|
|
168
|
+
heDamage: number;
|
|
169
|
+
heDamagePerThrow: number | null;
|
|
170
|
+
heDamagePerRound: number | null;
|
|
171
|
+
fireThrows: number;
|
|
172
|
+
fireDamage: number;
|
|
173
|
+
fireDamagePerThrow: number | null;
|
|
174
|
+
fireDamagePerRound: number | null;
|
|
175
|
+
smokesThrown: number;
|
|
176
|
+
smokesPerRound: number | null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export interface UtilityDamageEvidence extends EvidenceRef {
|
|
180
|
+
kind: UtilityDamageKind;
|
|
181
|
+
playerId?: string;
|
|
182
|
+
playerName: string;
|
|
183
|
+
victimCount: number;
|
|
184
|
+
damage: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface UtilityValueSummary {
|
|
188
|
+
players: UtilityValueRow[];
|
|
189
|
+
teams: UtilityValueRow[];
|
|
190
|
+
bestFlashes: Array<EnemyFlashIncident & { playerId?: string; playerName: string }>;
|
|
191
|
+
bestDamageRounds: UtilityDamageEvidence[];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const DEATH_EARLY_SECONDS = 25; // 1:55 -> 1:30
|
|
195
|
+
const DEATH_LATE_SECONDS = 55; // 1:30 -> 1:00;之后为 1:00 后
|
|
196
|
+
const LOW_BUY_TYPES = new Set(["eco", "semi", "force"]);
|
|
197
|
+
const MAX_EVIDENCE = 10;
|
|
198
|
+
/** 最佳闪光候选集容量:因 netSeconds ≤ enemySeconds,按敌方秒数取 Top-N 必含净收益 Top。 */
|
|
199
|
+
const MAX_BEST_FLASH = 15;
|
|
200
|
+
|
|
201
|
+
function tickrateOf(pkg: DemoPackage): number {
|
|
202
|
+
return pkg.match.tickrate ?? 64;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isHeDamageWeapon(weapon: string): boolean {
|
|
206
|
+
return normalizeWeapon(weapon) === "hegrenade";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isFireDamageWeapon(weapon: string): boolean {
|
|
210
|
+
return ["inferno", "molotov", "incgrenade", "incendiary"].includes(normalizeWeapon(weapon));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function utilityRow(id: string, name: string): UtilityValueRow {
|
|
214
|
+
return {
|
|
215
|
+
id,
|
|
216
|
+
name,
|
|
217
|
+
rounds: 0,
|
|
218
|
+
flashesThrown: 0,
|
|
219
|
+
enemyBlindSeconds: 0,
|
|
220
|
+
enemyBlindSecondsPerFlash: null,
|
|
221
|
+
enemyBlindSecondsPerRound: null,
|
|
222
|
+
flashAssists: 0,
|
|
223
|
+
flashAssistsPerRound: null,
|
|
224
|
+
heThrows: 0,
|
|
225
|
+
heDamage: 0,
|
|
226
|
+
heDamagePerThrow: null,
|
|
227
|
+
heDamagePerRound: null,
|
|
228
|
+
fireThrows: 0,
|
|
229
|
+
fireDamage: 0,
|
|
230
|
+
fireDamagePerThrow: null,
|
|
231
|
+
fireDamagePerRound: null,
|
|
232
|
+
smokesThrown: 0,
|
|
233
|
+
smokesPerRound: null,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function finalizeUtilityRow(row: UtilityValueRow): UtilityValueRow {
|
|
238
|
+
const enemyBlindSecondsRaw = row.enemyBlindSecondsRaw ?? row.enemyBlindSeconds;
|
|
239
|
+
return {
|
|
240
|
+
...row,
|
|
241
|
+
enemyBlindSecondsRaw,
|
|
242
|
+
enemyBlindSeconds: round(enemyBlindSecondsRaw, 1),
|
|
243
|
+
enemyBlindSecondsPerFlash: row.flashesThrown > 0 ? round(enemyBlindSecondsRaw / row.flashesThrown, 2) : null,
|
|
244
|
+
enemyBlindSecondsPerRound: row.rounds > 0 ? round(enemyBlindSecondsRaw / row.rounds, 2) : null,
|
|
245
|
+
flashAssistsPerRound: row.rounds > 0 ? round(row.flashAssists / row.rounds, 3) : null,
|
|
246
|
+
heDamagePerThrow: row.heThrows > 0 ? round(row.heDamage / row.heThrows, 2) : null,
|
|
247
|
+
heDamagePerRound: row.rounds > 0 ? round(row.heDamage / row.rounds, 2) : null,
|
|
248
|
+
fireDamagePerThrow: row.fireThrows > 0 ? round(row.fireDamage / row.fireThrows, 2) : null,
|
|
249
|
+
fireDamagePerRound: row.rounds > 0 ? round(row.fireDamage / row.rounds, 2) : null,
|
|
250
|
+
smokesPerRound: row.rounds > 0 ? round(row.smokesThrown / row.rounds, 3) : null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function buildUtilityValueSummary(
|
|
255
|
+
demos: SeasonInsightsDemo[],
|
|
256
|
+
players: PlayerFlashSummaryInput[],
|
|
257
|
+
options: { teamRenames?: Record<string, string> } = {}
|
|
258
|
+
): UtilityValueSummary {
|
|
259
|
+
const bySteamId = new Map<string, PlayerFlashSummaryInput>();
|
|
260
|
+
const playerRows = new Map<string, UtilityValueRow>();
|
|
261
|
+
const teamRows = new Map<string, UtilityValueRow>();
|
|
262
|
+
const bestFlashes: UtilityValueSummary["bestFlashes"] = [];
|
|
263
|
+
const damageEvidence = new Map<string, UtilityDamageEvidence & { victims: Set<string> }>();
|
|
264
|
+
|
|
265
|
+
for (const player of players) {
|
|
266
|
+
playerRows.set(player.playerKey, utilityRow(player.playerKey, player.name));
|
|
267
|
+
for (const steamId of player.steamIds) bySteamId.set(steamId, player);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const teamName = (pkg: DemoPackage, teamKey: TeamKey): string => {
|
|
271
|
+
const raw = teamKey === "teamA" ? (pkg.match.teamA.name ?? "Team A") : (pkg.match.teamB.name ?? "Team B");
|
|
272
|
+
return options.teamRenames?.[raw] ?? raw;
|
|
273
|
+
};
|
|
274
|
+
const ensureTeam = (pkg: DemoPackage, teamKey: TeamKey) => {
|
|
275
|
+
const name = teamName(pkg, teamKey);
|
|
276
|
+
const row = teamRows.get(name) ?? utilityRow(name, name);
|
|
277
|
+
teamRows.set(name, row);
|
|
278
|
+
return row;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
for (const { matchId, pkg } of demos) {
|
|
282
|
+
for (const key of ["teamA", "teamB"] as const) {
|
|
283
|
+
ensureTeam(pkg, key).rounds += pkg.rounds.length;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const stat of pkg.playerStats) {
|
|
287
|
+
const player = bySteamId.get(pkg.players[stat.playerIndex]?.steamId64 ?? "");
|
|
288
|
+
if (!player) continue;
|
|
289
|
+
const row = playerRows.get(player.playerKey)!;
|
|
290
|
+
row.rounds += stat.rounds;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Base utility totals are shared with every DAK consumer, including
|
|
294
|
+
// external product adapters. This view only adds presentation evidence.
|
|
295
|
+
for (const utility of buildPlayerRoundUtilityFacts(pkg)) {
|
|
296
|
+
const player = bySteamId.get(utility.steamId64);
|
|
297
|
+
const packagePlayer = pkg.players.find((candidate) => candidate.steamId64 === utility.steamId64);
|
|
298
|
+
if (!packagePlayer) continue;
|
|
299
|
+
const playerRow = player ? playerRows.get(player.playerKey) : null;
|
|
300
|
+
const teamRow = ensureTeam(pkg, packagePlayer.teamKey);
|
|
301
|
+
for (const row of [playerRow, teamRow]) {
|
|
302
|
+
if (!row) continue;
|
|
303
|
+
row.flashesThrown += utility.flashesThrown;
|
|
304
|
+
row.enemyBlindSeconds += utility.enemyBlindSeconds;
|
|
305
|
+
row.flashAssists += utility.flashAssists;
|
|
306
|
+
row.heThrows += utility.heThrows;
|
|
307
|
+
row.heDamage += utility.heDamage;
|
|
308
|
+
row.fireThrows += utility.fireThrows;
|
|
309
|
+
row.fireDamage += utility.fireDamage;
|
|
310
|
+
row.smokesThrown += utility.smokesThrown;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
type FlashCell = { playerKey: string; playerName: string; roundNumber: number; tick: number; victims: Set<string>; enemySeconds: number; teamSeconds: number };
|
|
315
|
+
const flashCells = new Map<string, FlashCell>();
|
|
316
|
+
for (const blind of pkg.blinds) {
|
|
317
|
+
const flasher = pkg.players[blind.flasherIndex];
|
|
318
|
+
const flashed = pkg.players[blind.flashedIndex];
|
|
319
|
+
if (!flasher || !flashed) continue;
|
|
320
|
+
const player = bySteamId.get(flasher.steamId64);
|
|
321
|
+
const key = `${player?.playerKey ?? `team:${flasher.teamKey}`}:${blind.flashId ?? `${blind.roundNumber}-${Math.round(blind.tick / 16)}`}`;
|
|
322
|
+
const cell = flashCells.get(key) ?? {
|
|
323
|
+
playerKey: player?.playerKey ?? "",
|
|
324
|
+
playerName: player?.name ?? flasher.name,
|
|
325
|
+
roundNumber: blind.roundNumber,
|
|
326
|
+
tick: blind.tick,
|
|
327
|
+
victims: new Set<string>(),
|
|
328
|
+
enemySeconds: 0,
|
|
329
|
+
teamSeconds: 0,
|
|
330
|
+
};
|
|
331
|
+
cell.tick = Math.min(cell.tick, blind.tick);
|
|
332
|
+
if (flasher.teamKey !== flashed.teamKey) {
|
|
333
|
+
cell.enemySeconds += blind.durationSeconds;
|
|
334
|
+
cell.victims.add(flashed.steamId64);
|
|
335
|
+
} else if (flasher.steamId64 !== flashed.steamId64) {
|
|
336
|
+
cell.teamSeconds += blind.durationSeconds;
|
|
337
|
+
}
|
|
338
|
+
flashCells.set(key, cell);
|
|
339
|
+
}
|
|
340
|
+
for (const cell of flashCells.values()) {
|
|
341
|
+
if (cell.enemySeconds <= 0 || !cell.playerKey) continue;
|
|
342
|
+
bestFlashes.push({
|
|
343
|
+
playerName: cell.playerName,
|
|
344
|
+
playerId: cell.playerKey,
|
|
345
|
+
matchId,
|
|
346
|
+
roundNumber: cell.roundNumber,
|
|
347
|
+
tick: cell.tick,
|
|
348
|
+
reason: "该闪光造成的敌方致盲时间位于当前样本前列",
|
|
349
|
+
role: "example",
|
|
350
|
+
victimCount: cell.victims.size,
|
|
351
|
+
enemySeconds: round(cell.enemySeconds, 2),
|
|
352
|
+
teamSeconds: round(cell.teamSeconds, 2),
|
|
353
|
+
netSeconds: round(cell.enemySeconds - cell.teamSeconds, 2),
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const damage of pkg.damages) {
|
|
358
|
+
if (damage.attackerIndex == null) continue;
|
|
359
|
+
const attacker = pkg.players[damage.attackerIndex];
|
|
360
|
+
const victim = pkg.players[damage.victimIndex];
|
|
361
|
+
if (!attacker || !victim || attacker.teamKey === victim.teamKey) continue;
|
|
362
|
+
const kind: UtilityDamageKind | null = isHeDamageWeapon(damage.weapon) ? "he" : isFireDamageWeapon(damage.weapon) ? "fire" : null;
|
|
363
|
+
if (!kind || damage.healthDamage <= 0) continue;
|
|
364
|
+
const player = bySteamId.get(attacker.steamId64);
|
|
365
|
+
if (player) {
|
|
366
|
+
const bucket = kind === "he" ? Math.round(damage.tick / 16) : damage.roundNumber;
|
|
367
|
+
const key = `${kind}:${matchId}:${damage.roundNumber}:${player.playerKey}:${bucket}`;
|
|
368
|
+
const cell = damageEvidence.get(key) ?? {
|
|
369
|
+
kind,
|
|
370
|
+
matchId,
|
|
371
|
+
roundNumber: damage.roundNumber,
|
|
372
|
+
tick: damage.tick,
|
|
373
|
+
reason: `${kind === "he" ? "HE 手雷" : "火焰"}伤害事件位于当前样本前列`,
|
|
374
|
+
role: "example",
|
|
375
|
+
playerId: player.playerKey,
|
|
376
|
+
playerName: player.name,
|
|
377
|
+
victimCount: 0,
|
|
378
|
+
victims: new Set<string>(),
|
|
379
|
+
damage: 0,
|
|
380
|
+
};
|
|
381
|
+
cell.tick = Math.min(cell.tick ?? damage.tick, damage.tick);
|
|
382
|
+
cell.damage += damage.healthDamage;
|
|
383
|
+
cell.victims.add(victim.steamId64);
|
|
384
|
+
cell.victimCount = cell.victims.size;
|
|
385
|
+
damageEvidence.set(key, cell);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
players: [...playerRows.values()]
|
|
392
|
+
.map(finalizeUtilityRow)
|
|
393
|
+
.sort((a, b) => (b.heDamagePerRound ?? 0) + (b.fireDamagePerRound ?? 0) - ((a.heDamagePerRound ?? 0) + (a.fireDamagePerRound ?? 0))),
|
|
394
|
+
teams: [...teamRows.values()]
|
|
395
|
+
.map(finalizeUtilityRow)
|
|
396
|
+
.sort((a, b) => (b.heDamagePerRound ?? 0) + (b.fireDamagePerRound ?? 0) - ((a.heDamagePerRound ?? 0) + (a.fireDamagePerRound ?? 0))),
|
|
397
|
+
bestFlashes: bestFlashes.sort((a, b) => b.enemySeconds - a.enemySeconds).slice(0, MAX_BEST_FLASH),
|
|
398
|
+
bestDamageRounds: [...damageEvidence.values()]
|
|
399
|
+
.sort((a, b) => b.damage - a.damage)
|
|
400
|
+
.slice(0, MAX_EVIDENCE)
|
|
401
|
+
.map(({ victims: _victims, ...row }) => row),
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function addUtilityRow(target: UtilityValueRow, source: UtilityValueRow): void {
|
|
406
|
+
target.rounds += source.rounds;
|
|
407
|
+
target.flashesThrown += source.flashesThrown;
|
|
408
|
+
target.enemyBlindSeconds += source.enemyBlindSecondsRaw ?? source.enemyBlindSeconds;
|
|
409
|
+
target.flashAssists += source.flashAssists ?? 0;
|
|
410
|
+
target.heThrows += source.heThrows;
|
|
411
|
+
target.heDamage += source.heDamage;
|
|
412
|
+
target.fireThrows += source.fireThrows;
|
|
413
|
+
target.fireDamage += source.fireDamage;
|
|
414
|
+
target.smokesThrown += source.smokesThrown;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function sortUtilityRows(rows: UtilityValueRow[]): UtilityValueRow[] {
|
|
418
|
+
return rows
|
|
419
|
+
.map(finalizeUtilityRow)
|
|
420
|
+
.sort((a, b) => (b.heDamagePerRound ?? 0) + (b.fireDamagePerRound ?? 0) - ((a.heDamagePerRound ?? 0) + (a.fireDamagePerRound ?? 0)));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function mergeUtilityValueSummaries(
|
|
424
|
+
summaries: UtilityValueSummary[],
|
|
425
|
+
options: { players?: PlayerFlashSummaryInput[]; teamRenames?: Record<string, string> } = {}
|
|
426
|
+
): UtilityValueSummary {
|
|
427
|
+
const playerRows = new Map<string, UtilityValueRow>();
|
|
428
|
+
const playerTargetBySource = new Map<string, PlayerFlashSummaryInput>();
|
|
429
|
+
if (options.players) {
|
|
430
|
+
for (const player of options.players) {
|
|
431
|
+
playerRows.set(player.playerKey, utilityRow(player.playerKey, player.name));
|
|
432
|
+
playerTargetBySource.set(player.playerKey, player);
|
|
433
|
+
for (const steamId of player.steamIds) playerTargetBySource.set(`steam:${steamId}`, player);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const teamRows = new Map<string, UtilityValueRow>();
|
|
437
|
+
|
|
438
|
+
const ensurePlayer = (row: UtilityValueRow): UtilityValueRow | null => {
|
|
439
|
+
const target = options.players ? playerTargetBySource.get(row.id) : { playerKey: row.id, name: row.name, steamIds: [] };
|
|
440
|
+
if (!target) return null;
|
|
441
|
+
const existing = playerRows.get(target.playerKey) ?? utilityRow(target.playerKey, target.name);
|
|
442
|
+
playerRows.set(target.playerKey, existing);
|
|
443
|
+
return existing;
|
|
444
|
+
};
|
|
445
|
+
const ensureTeam = (row: UtilityValueRow): UtilityValueRow => {
|
|
446
|
+
const name = options.teamRenames?.[row.name] ?? row.name;
|
|
447
|
+
const existing = teamRows.get(name) ?? utilityRow(name, name);
|
|
448
|
+
teamRows.set(name, existing);
|
|
449
|
+
return existing;
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
const keepPlayerEvidence = <T extends { playerId?: string; playerName: string }>(row: T): T | null => {
|
|
453
|
+
if (!options.players) return row;
|
|
454
|
+
const target = row.playerId ? playerTargetBySource.get(row.playerId) : null;
|
|
455
|
+
return target ? { ...row, playerId: target.playerKey, playerName: target.name } : null;
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const bestFlashes: UtilityValueSummary["bestFlashes"] = [];
|
|
459
|
+
const bestDamageRounds: UtilityDamageEvidence[] = [];
|
|
460
|
+
|
|
461
|
+
for (const summary of summaries) {
|
|
462
|
+
for (const row of summary.players) {
|
|
463
|
+
const target = ensurePlayer(row);
|
|
464
|
+
if (target) addUtilityRow(target, row);
|
|
465
|
+
}
|
|
466
|
+
for (const row of summary.teams) addUtilityRow(ensureTeam(row), row);
|
|
467
|
+
for (const flash of summary.bestFlashes) {
|
|
468
|
+
const kept = keepPlayerEvidence(flash);
|
|
469
|
+
if (kept) bestFlashes.push(kept);
|
|
470
|
+
}
|
|
471
|
+
for (const damage of summary.bestDamageRounds) {
|
|
472
|
+
const kept = keepPlayerEvidence(damage);
|
|
473
|
+
if (kept) bestDamageRounds.push(kept);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
players: sortUtilityRows([...playerRows.values()]),
|
|
479
|
+
teams: sortUtilityRows([...teamRows.values()]),
|
|
480
|
+
bestFlashes: bestFlashes.sort((a, b) => b.enemySeconds - a.enemySeconds).slice(0, MAX_BEST_FLASH),
|
|
481
|
+
bestDamageRounds: bestDamageRounds.sort((a, b) => b.damage - a.damage).slice(0, MAX_EVIDENCE),
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** 单选手跨场洞察。steamIds 来自 PlayerSeasonProfile(cohort 已做身份归并)。 */
|
|
486
|
+
export function buildPlayerSeasonInsights(
|
|
487
|
+
demos: SeasonInsightsDemo[],
|
|
488
|
+
steamIds: string[]
|
|
489
|
+
): PlayerSeasonInsights {
|
|
490
|
+
const ids = new Set(steamIds);
|
|
491
|
+
const trend: PlayerTrendPoint[] = [];
|
|
492
|
+
let flashesThrown = 0;
|
|
493
|
+
let enemyBlindSeconds = 0;
|
|
494
|
+
let teamBlindSeconds = 0;
|
|
495
|
+
let flashAssists = 0;
|
|
496
|
+
let enemyBlindVictims = 0;
|
|
497
|
+
const teamFlashes: TeamFlashIncident[] = [];
|
|
498
|
+
const enemyFlashes: EnemyFlashIncident[] = [];
|
|
499
|
+
const lowBuy: FirstDeathStat = { count: 0, attempts: 0, evidence: [] };
|
|
500
|
+
const fullBuy: FirstDeathStat = { count: 0, attempts: 0, evidence: [] };
|
|
501
|
+
const antiEco: FirstDeathStat = { count: 0, attempts: 0, evidence: [] };
|
|
502
|
+
const deathTiming = { early: 0, mid: 0, late: 0, total: 0 };
|
|
503
|
+
const clutchLossEvidence: MistakeEvidence[] = [];
|
|
504
|
+
let clutchLosses = 0;
|
|
505
|
+
|
|
506
|
+
for (const { matchId, pkg } of demos) {
|
|
507
|
+
const stats = pkg.playerStats.filter((row) => {
|
|
508
|
+
const player = pkg.players[row.playerIndex];
|
|
509
|
+
return player != null && ids.has(player.steamId64);
|
|
510
|
+
});
|
|
511
|
+
if (stats.length === 0) continue;
|
|
512
|
+
const sum = (f: (row: (typeof stats)[number]) => number) => stats.reduce((acc, row) => acc + f(row), 0);
|
|
513
|
+
const rounds = Math.max(1, ...stats.map((row) => row.rounds));
|
|
514
|
+
const utilityFacts = buildPlayerRoundUtilityFacts(pkg).filter((fact) => ids.has(fact.steamId64));
|
|
515
|
+
const utilitySum = (key: "utilityDamage" | "enemyBlindSeconds" | "teamBlindSeconds" | "flashAssists" | "flashesThrown" | "enemyBlindVictims") =>
|
|
516
|
+
utilityFacts.reduce((total, fact) => total + fact[key], 0);
|
|
517
|
+
|
|
518
|
+
const clutchAttempts = sum((r) => r.vsOneCount + r.vsTwoCount + r.vsThreeCount + r.vsFourCount + r.vsFiveCount);
|
|
519
|
+
const clutchWins = sum((r) => r.vsOneWonCount + r.vsTwoWonCount + r.vsThreeWonCount + r.vsFourWonCount + r.vsFiveWonCount);
|
|
520
|
+
trend.push({
|
|
521
|
+
matchId,
|
|
522
|
+
mapName: pkg.match.mapName,
|
|
523
|
+
adr: round(sum((r) => r.damageHealth) / rounds, 1),
|
|
524
|
+
kast: round(sum((r) => r.kastRounds) / rounds * 100, 1),
|
|
525
|
+
fkMinusFd: sum((r) => r.firstKillCount) - sum((r) => r.firstDeathCount),
|
|
526
|
+
utilityDamagePerRound: round(utilitySum("utilityDamage") / rounds, 2),
|
|
527
|
+
clutchAttempts,
|
|
528
|
+
clutchWins,
|
|
529
|
+
kills: sum((r) => r.kills),
|
|
530
|
+
deaths: sum((r) => r.deaths)
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
enemyBlindSeconds += utilitySum("enemyBlindSeconds");
|
|
534
|
+
teamBlindSeconds += utilitySum("teamBlindSeconds");
|
|
535
|
+
flashAssists += utilitySum("flashAssists");
|
|
536
|
+
flashesThrown += utilitySum("flashesThrown");
|
|
537
|
+
enemyBlindVictims += utilitySum("enemyBlindVictims");
|
|
538
|
+
|
|
539
|
+
// 队闪事件:同 (round, tick±8) 的同投掷者致盲友方行归并为一颗闪
|
|
540
|
+
const teamBlindRows = pkg.blinds.filter((b) => {
|
|
541
|
+
const flasher = pkg.players[b.flasherIndex];
|
|
542
|
+
const flashed = pkg.players[b.flashedIndex];
|
|
543
|
+
return flasher != null && flashed != null && ids.has(flasher.steamId64)
|
|
544
|
+
&& flasher.teamKey === flashed.teamKey && flasher.steamId64 !== flashed.steamId64;
|
|
545
|
+
});
|
|
546
|
+
const grouped = new Map<string, { roundNumber: number; tick: number; victims: Set<string>; seconds: number }>();
|
|
547
|
+
for (const blind of teamBlindRows) {
|
|
548
|
+
const key = blind.flashId ?? `${blind.roundNumber}-${Math.round(blind.tick / 16)}`;
|
|
549
|
+
const cell = grouped.get(key) ?? { roundNumber: blind.roundNumber, tick: blind.tick, victims: new Set(), seconds: 0 };
|
|
550
|
+
cell.tick = Math.min(cell.tick, blind.tick);
|
|
551
|
+
cell.victims.add(pkg.players[blind.flashedIndex]?.steamId64 ?? "");
|
|
552
|
+
cell.seconds += blind.durationSeconds;
|
|
553
|
+
grouped.set(key, cell);
|
|
554
|
+
}
|
|
555
|
+
for (const cell of grouped.values()) {
|
|
556
|
+
teamFlashes.push({
|
|
557
|
+
matchId,
|
|
558
|
+
roundNumber: cell.roundNumber,
|
|
559
|
+
tick: cell.tick,
|
|
560
|
+
reason: "该闪光误盲队友的时长位于当前样本前列",
|
|
561
|
+
role: "example",
|
|
562
|
+
victimCount: cell.victims.size,
|
|
563
|
+
totalSeconds: round(cell.seconds, 2)
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// 致盲敌方事件:同 flashId 的本人致盲敌方行归并为一颗闪,并扣除同颗误盲队友秒数算净收益
|
|
568
|
+
const enemyBlindRows = pkg.blinds.filter((b) => {
|
|
569
|
+
const flasher = pkg.players[b.flasherIndex];
|
|
570
|
+
const flashed = pkg.players[b.flashedIndex];
|
|
571
|
+
return flasher != null && flashed != null && ids.has(flasher.steamId64)
|
|
572
|
+
&& flasher.teamKey !== flashed.teamKey;
|
|
573
|
+
});
|
|
574
|
+
const enemyGrouped = new Map<string, { roundNumber: number; tick: number; victims: Set<string>; seconds: number }>();
|
|
575
|
+
for (const blind of enemyBlindRows) {
|
|
576
|
+
const key = blind.flashId ?? `${blind.roundNumber}-${Math.round(blind.tick / 16)}`;
|
|
577
|
+
const cell = enemyGrouped.get(key) ?? { roundNumber: blind.roundNumber, tick: blind.tick, victims: new Set(), seconds: 0 };
|
|
578
|
+
cell.tick = Math.min(cell.tick, blind.tick);
|
|
579
|
+
cell.victims.add(pkg.players[blind.flashedIndex]?.steamId64 ?? "");
|
|
580
|
+
cell.seconds += blind.durationSeconds;
|
|
581
|
+
enemyGrouped.set(key, cell);
|
|
582
|
+
}
|
|
583
|
+
for (const [key, cell] of enemyGrouped) {
|
|
584
|
+
const teamSeconds = grouped.get(key)?.seconds ?? 0;
|
|
585
|
+
enemyFlashes.push({
|
|
586
|
+
matchId,
|
|
587
|
+
roundNumber: cell.roundNumber,
|
|
588
|
+
tick: cell.tick,
|
|
589
|
+
reason: "该闪光造成的敌方致盲时间位于当前样本前列",
|
|
590
|
+
role: "example",
|
|
591
|
+
victimCount: cell.victims.size,
|
|
592
|
+
enemySeconds: round(cell.seconds, 2),
|
|
593
|
+
teamSeconds: round(teamSeconds, 2),
|
|
594
|
+
netSeconds: round(cell.seconds - teamSeconds, 2)
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// 劣势经济首死:按回合首杀的受害者判定
|
|
599
|
+
const economyByRound = new Map(
|
|
600
|
+
pkg.playerEconomies
|
|
601
|
+
.filter((row) => {
|
|
602
|
+
const player = pkg.players[row.playerIndex];
|
|
603
|
+
return player != null && ids.has(player.steamId64);
|
|
604
|
+
})
|
|
605
|
+
.map((row) => [row.roundNumber, row.type])
|
|
606
|
+
);
|
|
607
|
+
const killsByRound = new Map<number, typeof pkg.kills>();
|
|
608
|
+
for (const kill of pkg.kills) {
|
|
609
|
+
const list = killsByRound.get(kill.roundNumber) ?? [];
|
|
610
|
+
list.push(kill);
|
|
611
|
+
killsByRound.set(kill.roundNumber, list);
|
|
612
|
+
}
|
|
613
|
+
const freezeByRound = new Map(pkg.rounds.map((row) => [row.roundNumber, row.freezeEndTick]));
|
|
614
|
+
const roundEconomies = new Map(
|
|
615
|
+
pkg.rounds.map((row) => [row.roundNumber, { a: row.teamAEconomy, b: row.teamBEconomy }])
|
|
616
|
+
);
|
|
617
|
+
const tickrate = tickrateOf(pkg);
|
|
618
|
+
|
|
619
|
+
for (const [roundNumber, list] of killsByRound) {
|
|
620
|
+
const sorted = [...list].sort((a, b) => a.tick - b.tick);
|
|
621
|
+
const economy = economyByRound.get(roundNumber);
|
|
622
|
+
// 对手经济:回合行里非我方类型的一侧(两侧相同时无歧义)
|
|
623
|
+
const pair = roundEconomies.get(roundNumber);
|
|
624
|
+
const opponentEconomy =
|
|
625
|
+
economy != null && pair != null ? (economy === pair.a ? pair.b : pair.a) : null;
|
|
626
|
+
const firstDeath = sorted[0];
|
|
627
|
+
const firstDeadVictim = firstDeath != null ? pkg.players[firstDeath.victimIndex] : undefined;
|
|
628
|
+
const meFirstDead = firstDeath != null && firstDeadVictim != null && ids.has(firstDeadVictim.steamId64);
|
|
629
|
+
const isLowBuy = economy != null && LOW_BUY_TYPES.has(economy);
|
|
630
|
+
const isFullBuy = economy === "full";
|
|
631
|
+
const isAntiEco = opponentEconomy === "eco" || opponentEconomy === "semi";
|
|
632
|
+
if (isLowBuy) lowBuy.attempts += 1;
|
|
633
|
+
if (isFullBuy) fullBuy.attempts += 1;
|
|
634
|
+
if (isAntiEco) antiEco.attempts += 1;
|
|
635
|
+
if (meFirstDead && isLowBuy) {
|
|
636
|
+
lowBuy.count += 1;
|
|
637
|
+
lowBuy.evidence.push({ matchId, roundNumber, tick: firstDeath.tick, detail: `${economy} 局首死`, reason: `${economy} 局首死`, role: "example" });
|
|
638
|
+
}
|
|
639
|
+
if (meFirstDead && isFullBuy) {
|
|
640
|
+
fullBuy.count += 1;
|
|
641
|
+
fullBuy.evidence.push({ matchId, roundNumber, tick: firstDeath.tick, detail: "长枪局首死", reason: "长枪局首死", role: "example" });
|
|
642
|
+
}
|
|
643
|
+
if (meFirstDead && isAntiEco) {
|
|
644
|
+
antiEco.count += 1;
|
|
645
|
+
antiEco.evidence.push({ matchId, roundNumber, tick: firstDeath.tick, detail: `对手 ${opponentEconomy} 局首死`, reason: `对手 ${opponentEconomy} 局首死`, role: "example" });
|
|
646
|
+
}
|
|
647
|
+
// 死亡时间分布
|
|
648
|
+
for (const kill of sorted) {
|
|
649
|
+
const victim = pkg.players[kill.victimIndex];
|
|
650
|
+
if (!victim || !ids.has(victim.steamId64)) continue;
|
|
651
|
+
const freeze = freezeByRound.get(roundNumber);
|
|
652
|
+
if (freeze == null) continue;
|
|
653
|
+
const seconds = (kill.tick - freeze) / tickrate;
|
|
654
|
+
deathTiming.total += 1;
|
|
655
|
+
if (seconds < DEATH_EARLY_SECONDS) deathTiming.early += 1;
|
|
656
|
+
else if (seconds < DEATH_LATE_SECONDS) deathTiming.mid += 1;
|
|
657
|
+
else deathTiming.late += 1;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// 残局失利
|
|
662
|
+
for (const clutch of pkg.clutches) {
|
|
663
|
+
const clutcher = pkg.players[clutch.clutcherIndex];
|
|
664
|
+
if (!clutcher || !ids.has(clutcher.steamId64) || clutch.won) continue;
|
|
665
|
+
clutchLosses += 1;
|
|
666
|
+
clutchLossEvidence.push({
|
|
667
|
+
matchId,
|
|
668
|
+
roundNumber: clutch.roundNumber,
|
|
669
|
+
detail: `1v${clutch.opponentCount} 失利(${clutch.killCount} 杀)`,
|
|
670
|
+
reason: `1v${clutch.opponentCount} 残局失利`,
|
|
671
|
+
role: "example"
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
teamFlashes.sort((a, b) => b.totalSeconds - a.totalSeconds);
|
|
677
|
+
enemyFlashes.sort((a, b) => b.enemySeconds - a.enemySeconds);
|
|
678
|
+
|
|
679
|
+
return {
|
|
680
|
+
trend,
|
|
681
|
+
flash: {
|
|
682
|
+
flashesThrown,
|
|
683
|
+
enemyBlindSeconds: round(enemyBlindSeconds, 1),
|
|
684
|
+
teamBlindSeconds: round(teamBlindSeconds, 1),
|
|
685
|
+
enemyBlindVictims,
|
|
686
|
+
enemySecondsPerFlash: flashesThrown > 0 ? round(enemyBlindSeconds / flashesThrown, 2) : null,
|
|
687
|
+
netSecondsPerFlash: flashesThrown > 0
|
|
688
|
+
? round((enemyBlindSeconds - teamBlindSeconds) / flashesThrown, 2)
|
|
689
|
+
: null,
|
|
690
|
+
flashAssists,
|
|
691
|
+
worstTeamFlashes: teamFlashes.slice(0, MAX_EVIDENCE),
|
|
692
|
+
bestEnemyFlashes: enemyFlashes.slice(0, MAX_BEST_FLASH)
|
|
693
|
+
},
|
|
694
|
+
mistakes: {
|
|
695
|
+
lowBuyFirstDeaths: { ...lowBuy, evidence: lowBuy.evidence.slice(0, MAX_EVIDENCE) },
|
|
696
|
+
fullBuyFirstDeaths: { ...fullBuy, evidence: fullBuy.evidence.slice(0, MAX_EVIDENCE) },
|
|
697
|
+
antiEcoFirstDeaths: { ...antiEco, evidence: antiEco.evidence.slice(0, MAX_EVIDENCE) },
|
|
698
|
+
deathTiming,
|
|
699
|
+
clutchLosses: { count: clutchLosses, evidence: clutchLossEvidence.slice(0, MAX_EVIDENCE) }
|
|
700
|
+
}
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function avg(values: Array<number | null>): number | null {
|
|
705
|
+
const nums = values.filter((value): value is number => value != null);
|
|
706
|
+
return nums.length > 0 ? round(nums.reduce((sum, value) => sum + value, 0) / nums.length, 1) : null;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function medianNumber(values: Array<number | null>): number | null {
|
|
710
|
+
const nums = values.filter((value): value is number => value != null).sort((a, b) => a - b);
|
|
711
|
+
if (nums.length === 0) return null;
|
|
712
|
+
const mid = Math.floor(nums.length / 2);
|
|
713
|
+
return nums.length % 2 === 0 ? round((nums[mid - 1]! + nums[mid]!) / 2, 1) : round(nums[mid]!, 1);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function percentileLabel(value: number | null, values: Array<number | null>, lowerIsBetter = false): string | null {
|
|
717
|
+
if (value == null) return null;
|
|
718
|
+
const nums = values.filter((candidate): candidate is number => candidate != null);
|
|
719
|
+
if (nums.length === 0) return null;
|
|
720
|
+
const better = nums.filter((candidate) => lowerIsBetter ? candidate <= value : candidate >= value).length;
|
|
721
|
+
return `当前范围前 ${Math.max(1, Math.round(better / nums.length * 100))}%`;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// 武器卡按真实武器名统计(区分 m4a1_silencer / m4a4),展示层取击杀数前 6,其余并入「其他」
|
|
725
|
+
const MECHANICS_TOP_WEAPONS = 6;
|
|
726
|
+
|
|
727
|
+
function weaponBucket(weapon: string): string {
|
|
728
|
+
return weapon.toLowerCase();
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function weaponBucketLabel(bucket: string): string {
|
|
732
|
+
if (bucket === "all") return "全部武器";
|
|
733
|
+
if (bucket === "other") return "其他";
|
|
734
|
+
return displayWeaponName(bucket);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
export interface MechanicsProfileOptions {
|
|
738
|
+
/** 按地图名提供 .tri BVH;提供后枪法机制的可见性样本走静态 LOS 精确口径。 */
|
|
739
|
+
visibilityFor?: (mapName: string) => TriangleBvh | null;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
export function buildPlayerMechanicsProfile(
|
|
743
|
+
demos: SeasonInsightsDemo[],
|
|
744
|
+
steamIds: string[],
|
|
745
|
+
options: MechanicsProfileOptions = {}
|
|
746
|
+
): PlayerMechanicsProfile {
|
|
747
|
+
const perMatchRows = demos.map(({ pkg }) =>
|
|
748
|
+
derivePlayerMechanics(pkg, { visibility: options.visibilityFor?.(pkg.match.mapName) ?? null })
|
|
749
|
+
);
|
|
750
|
+
return buildPlayerMechanicsProfileFromRows(perMatchRows, steamIds, demos.length);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* 跨场机制合并(投影层):吃预派生的逐场 PlayerMechanicsFact[],与
|
|
755
|
+
* buildPlayerMechanicsProfile 共用同一套分桶 / 百分位口径。facts 缓存命中后
|
|
756
|
+
* 无需重新解析 DemoPackage,是 SQLite/facts 方案下选手档案的聚合入口。
|
|
757
|
+
*/
|
|
758
|
+
export function buildPlayerMechanicsProfileFromRows(
|
|
759
|
+
perMatchRows: PlayerMechanicsFact[][],
|
|
760
|
+
steamIds: string[],
|
|
761
|
+
matchCount: number
|
|
762
|
+
): PlayerMechanicsProfile {
|
|
763
|
+
const ids = new Set(steamIds);
|
|
764
|
+
const byBucket = new Map<string, {
|
|
765
|
+
kills: number;
|
|
766
|
+
first: Array<number | null>;
|
|
767
|
+
spray: Array<number | null>;
|
|
768
|
+
counter: Array<number | null>;
|
|
769
|
+
oneTap: Array<number | null>;
|
|
770
|
+
reaction: Array<number | null>;
|
|
771
|
+
preaim: Array<number | null>;
|
|
772
|
+
ttk: Array<number | null>;
|
|
773
|
+
headshot: number;
|
|
774
|
+
headshotTotal: number;
|
|
775
|
+
}>();
|
|
776
|
+
const allRows: Array<{ bucket: string; first: number | null; spray: number | null; counter: number | null; oneTap: number | null; reaction: number | null; preaim: number | null; ttk: number | null }> = [];
|
|
777
|
+
|
|
778
|
+
const get = (bucket: string) => {
|
|
779
|
+
const current = byBucket.get(bucket) ?? { kills: 0, first: [], spray: [], counter: [], oneTap: [], reaction: [], preaim: [], ttk: [], headshot: 0, headshotTotal: 0 };
|
|
780
|
+
byBucket.set(bucket, current);
|
|
781
|
+
return current;
|
|
782
|
+
};
|
|
783
|
+
|
|
784
|
+
for (const rows of perMatchRows) {
|
|
785
|
+
for (const row of rows.filter((item) => ids.has(item.steamId64))) {
|
|
786
|
+
const bucket = weaponBucket(row.weapon);
|
|
787
|
+
const cell = get(bucket);
|
|
788
|
+
const preaim = row.preaim.medianDegrees;
|
|
789
|
+
cell.kills += row.killCount;
|
|
790
|
+
cell.headshot += row.cleanHeadshotKills;
|
|
791
|
+
cell.headshotTotal += row.cleanKillCount;
|
|
792
|
+
cell.first.push(row.firstShotHit.value);
|
|
793
|
+
cell.spray.push(row.sprayHit?.value ?? null);
|
|
794
|
+
cell.counter.push(row.counterStrafe.value);
|
|
795
|
+
cell.oneTap.push(row.oneTap.value);
|
|
796
|
+
cell.reaction.push(row.reaction.value);
|
|
797
|
+
cell.preaim.push(preaim);
|
|
798
|
+
cell.ttk.push(...row.ttkSamplesMs);
|
|
799
|
+
allRows.push({
|
|
800
|
+
bucket,
|
|
801
|
+
first: row.firstShotHit.value,
|
|
802
|
+
spray: row.sprayHit?.value ?? null,
|
|
803
|
+
counter: row.counterStrafe.value,
|
|
804
|
+
oneTap: row.oneTap.value,
|
|
805
|
+
reaction: row.reaction.value,
|
|
806
|
+
preaim,
|
|
807
|
+
ttk: row.ttk.value
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const toProfile = (bucket: string, cell: ReturnType<typeof get>): PlayerMechanicsWeaponProfile => {
|
|
813
|
+
const first = avg(cell.first);
|
|
814
|
+
const spray = avg(cell.spray);
|
|
815
|
+
const ttk = medianNumber(cell.ttk);
|
|
816
|
+
const counter = avg(cell.counter);
|
|
817
|
+
const oneTap = avg(cell.oneTap);
|
|
818
|
+
const reaction = medianNumber(cell.reaction);
|
|
819
|
+
const preaim = medianNumber(cell.preaim);
|
|
820
|
+
return {
|
|
821
|
+
weapon: bucket,
|
|
822
|
+
label: weaponBucketLabel(bucket),
|
|
823
|
+
kills: cell.kills,
|
|
824
|
+
firstShotAccuracyPercent: first,
|
|
825
|
+
sprayAccuracyPercent: spray,
|
|
826
|
+
medianTtkMs: ttk,
|
|
827
|
+
counterStrafeSuccessPercent: counter,
|
|
828
|
+
oneTapRatePercent: oneTap,
|
|
829
|
+
visualReactionMs: reaction,
|
|
830
|
+
preaimErrorDegrees: preaim,
|
|
831
|
+
headshotPercent: cell.headshotTotal > 0 ? Math.round((cell.headshot / cell.headshotTotal) * 1000) / 10 : null,
|
|
832
|
+
killsPerMatch: matchCount > 0 ? Math.round((cell.kills / matchCount) * 10) / 10 : null,
|
|
833
|
+
percentile: {
|
|
834
|
+
firstShotAccuracy: percentileLabel(first, allRows.map((row) => row.first)),
|
|
835
|
+
sprayAccuracy: percentileLabel(spray, allRows.map((row) => row.spray)),
|
|
836
|
+
medianTtk: percentileLabel(ttk, allRows.map((row) => row.ttk), true),
|
|
837
|
+
counterStrafe: percentileLabel(counter, allRows.map((row) => row.counter)),
|
|
838
|
+
oneTapRate: percentileLabel(oneTap, allRows.map((row) => row.oneTap)),
|
|
839
|
+
visualReaction: percentileLabel(reaction, allRows.map((row) => row.reaction), true),
|
|
840
|
+
preaimError: percentileLabel(preaim, allRows.map((row) => row.preaim), true)
|
|
841
|
+
}
|
|
842
|
+
};
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
const overall = toProfile("all", {
|
|
846
|
+
kills: [...byBucket.values()].reduce((sum, row) => sum + row.kills, 0),
|
|
847
|
+
first: [...byBucket.values()].flatMap((row) => row.first),
|
|
848
|
+
spray: [...byBucket.values()].flatMap((row) => row.spray),
|
|
849
|
+
counter: [...byBucket.values()].flatMap((row) => row.counter),
|
|
850
|
+
oneTap: [...byBucket.values()].flatMap((row) => row.oneTap),
|
|
851
|
+
reaction: [...byBucket.values()].flatMap((row) => row.reaction),
|
|
852
|
+
preaim: [...byBucket.values()].flatMap((row) => row.preaim),
|
|
853
|
+
ttk: [...byBucket.values()].flatMap((row) => row.ttk),
|
|
854
|
+
headshot: [...byBucket.values()].reduce((sum, row) => sum + row.headshot, 0),
|
|
855
|
+
headshotTotal: [...byBucket.values()].reduce((sum, row) => sum + row.headshotTotal, 0)
|
|
856
|
+
});
|
|
857
|
+
// 击杀数前 6 把武器单列,其余合并为「其他」(样本数组直接拼接,口径与单桶一致)
|
|
858
|
+
const ranked = [...byBucket.entries()].sort((a, b) => b[1].kills - a[1].kills || a[0].localeCompare(b[0]));
|
|
859
|
+
const top = ranked.slice(0, MECHANICS_TOP_WEAPONS);
|
|
860
|
+
const rest = ranked.slice(MECHANICS_TOP_WEAPONS);
|
|
861
|
+
const weapons = top.map(([bucket, cell]) => toProfile(bucket, cell));
|
|
862
|
+
if (rest.length > 0) {
|
|
863
|
+
weapons.push(toProfile("other", {
|
|
864
|
+
kills: rest.reduce((sum, [, cell]) => sum + cell.kills, 0),
|
|
865
|
+
first: rest.flatMap(([, cell]) => cell.first),
|
|
866
|
+
spray: rest.flatMap(([, cell]) => cell.spray),
|
|
867
|
+
counter: rest.flatMap(([, cell]) => cell.counter),
|
|
868
|
+
oneTap: rest.flatMap(([, cell]) => cell.oneTap),
|
|
869
|
+
reaction: rest.flatMap(([, cell]) => cell.reaction),
|
|
870
|
+
preaim: rest.flatMap(([, cell]) => cell.preaim),
|
|
871
|
+
ttk: rest.flatMap(([, cell]) => cell.ttk),
|
|
872
|
+
headshot: rest.reduce((sum, [, cell]) => sum + cell.headshot, 0),
|
|
873
|
+
headshotTotal: rest.reduce((sum, [, cell]) => sum + cell.headshotTotal, 0)
|
|
874
|
+
}));
|
|
875
|
+
}
|
|
876
|
+
return { overall, weapons };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
export function buildPlayerWeaponStats(
|
|
880
|
+
demos: SeasonInsightsDemo[],
|
|
881
|
+
steamIds: string[]
|
|
882
|
+
): PlayerWeaponStat[] {
|
|
883
|
+
const ids = new Set(steamIds);
|
|
884
|
+
const rows = new Map<string, { weapon: string; kills: number; headshots: number }>();
|
|
885
|
+
let matchCount = 0;
|
|
886
|
+
for (const { pkg } of demos) {
|
|
887
|
+
let appeared = false;
|
|
888
|
+
for (const kill of pkg.kills) {
|
|
889
|
+
if (kill.killerIndex == null) continue;
|
|
890
|
+
const killer = pkg.players[kill.killerIndex];
|
|
891
|
+
if (!killer || !ids.has(killer.steamId64)) continue;
|
|
892
|
+
appeared = true;
|
|
893
|
+
const weapon = kill.weapon || "unknown";
|
|
894
|
+
const row = rows.get(weapon) ?? { weapon, kills: 0, headshots: 0 };
|
|
895
|
+
row.kills += 1;
|
|
896
|
+
if (kill.headshot) row.headshots += 1;
|
|
897
|
+
rows.set(weapon, row);
|
|
898
|
+
}
|
|
899
|
+
if (appeared) matchCount += 1;
|
|
900
|
+
}
|
|
901
|
+
const denominator = Math.max(1, matchCount);
|
|
902
|
+
return [...rows.values()]
|
|
903
|
+
.map((row) => ({
|
|
904
|
+
weapon: row.weapon,
|
|
905
|
+
label: displayWeaponName(row.weapon),
|
|
906
|
+
kills: row.kills,
|
|
907
|
+
headshotPercent: row.kills > 0 ? round((row.headshots / row.kills) * 100, 1) : null,
|
|
908
|
+
killsPerMatch: round(row.kills / denominator, 2)
|
|
909
|
+
}))
|
|
910
|
+
.sort((a, b) => b.kills - a.kills || a.label.localeCompare(b.label));
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export function buildPlayerFlashSummaries(
|
|
914
|
+
demos: SeasonInsightsDemo[],
|
|
915
|
+
players: PlayerFlashSummaryInput[]
|
|
916
|
+
): PlayerFlashSummary[] {
|
|
917
|
+
const bySteamId = new Map<string, PlayerFlashSummaryInput>();
|
|
918
|
+
const rows = new Map<string, {
|
|
919
|
+
playerKey: string;
|
|
920
|
+
name: string;
|
|
921
|
+
flashesThrown: number;
|
|
922
|
+
enemyBlindSeconds: number;
|
|
923
|
+
teamBlindSeconds: number;
|
|
924
|
+
enemyBlindVictims: number;
|
|
925
|
+
flashAssists: number;
|
|
926
|
+
teamFlashes: TeamFlashIncident[];
|
|
927
|
+
enemyFlashes: EnemyFlashIncident[];
|
|
928
|
+
}>();
|
|
929
|
+
|
|
930
|
+
for (const player of players) {
|
|
931
|
+
rows.set(player.playerKey, {
|
|
932
|
+
playerKey: player.playerKey,
|
|
933
|
+
name: player.name,
|
|
934
|
+
flashesThrown: 0,
|
|
935
|
+
enemyBlindSeconds: 0,
|
|
936
|
+
teamBlindSeconds: 0,
|
|
937
|
+
enemyBlindVictims: 0,
|
|
938
|
+
flashAssists: 0,
|
|
939
|
+
teamFlashes: [],
|
|
940
|
+
enemyFlashes: []
|
|
941
|
+
});
|
|
942
|
+
for (const steamId of player.steamIds) bySteamId.set(steamId, player);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
for (const { matchId, pkg } of demos) {
|
|
946
|
+
for (const utility of buildPlayerRoundUtilityFacts(pkg)) {
|
|
947
|
+
const player = bySteamId.get(utility.steamId64);
|
|
948
|
+
if (!player) continue;
|
|
949
|
+
const row = rows.get(player.playerKey)!;
|
|
950
|
+
row.flashesThrown += utility.flashesThrown;
|
|
951
|
+
row.enemyBlindSeconds += utility.enemyBlindSeconds;
|
|
952
|
+
row.teamBlindSeconds += utility.teamBlindSeconds;
|
|
953
|
+
row.enemyBlindVictims += utility.enemyBlindVictims;
|
|
954
|
+
row.flashAssists += utility.flashAssists;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
type FlashCell = { playerKey: string; roundNumber: number; tick: number; victims: Set<string>; seconds: number };
|
|
958
|
+
const grouped = new Map<string, FlashCell>();
|
|
959
|
+
const enemyGrouped = new Map<string, FlashCell>();
|
|
960
|
+
for (const blind of pkg.blinds) {
|
|
961
|
+
const flasher = pkg.players[blind.flasherIndex];
|
|
962
|
+
const flashed = pkg.players[blind.flashedIndex];
|
|
963
|
+
if (!flasher || !flashed) continue;
|
|
964
|
+
const player = bySteamId.get(flasher.steamId64);
|
|
965
|
+
if (!player) continue;
|
|
966
|
+
const key = `${player.playerKey}:${blind.flashId ?? `${blind.roundNumber}-${Math.round(blind.tick / 16)}`}`;
|
|
967
|
+
const accumulate = (map: Map<string, FlashCell>) => {
|
|
968
|
+
const cell = map.get(key) ?? {
|
|
969
|
+
playerKey: player.playerKey,
|
|
970
|
+
roundNumber: blind.roundNumber,
|
|
971
|
+
tick: blind.tick,
|
|
972
|
+
victims: new Set<string>(),
|
|
973
|
+
seconds: 0
|
|
974
|
+
};
|
|
975
|
+
cell.tick = Math.min(cell.tick, blind.tick);
|
|
976
|
+
cell.victims.add(pkg.players[blind.flashedIndex]?.steamId64 ?? "");
|
|
977
|
+
cell.seconds += blind.durationSeconds;
|
|
978
|
+
map.set(key, cell);
|
|
979
|
+
};
|
|
980
|
+
if (flasher.teamKey !== flashed.teamKey) {
|
|
981
|
+
accumulate(enemyGrouped);
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
if (flasher.steamId64 === flashed.steamId64) continue;
|
|
985
|
+
accumulate(grouped);
|
|
986
|
+
}
|
|
987
|
+
for (const cell of grouped.values()) {
|
|
988
|
+
rows.get(cell.playerKey)!.teamFlashes.push({
|
|
989
|
+
matchId,
|
|
990
|
+
roundNumber: cell.roundNumber,
|
|
991
|
+
tick: cell.tick,
|
|
992
|
+
reason: "该闪光误盲队友的时长位于当前样本前列",
|
|
993
|
+
role: "example",
|
|
994
|
+
victimCount: cell.victims.size,
|
|
995
|
+
totalSeconds: round(cell.seconds, 2)
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
for (const [key, cell] of enemyGrouped) {
|
|
999
|
+
const teamSeconds = grouped.get(key)?.seconds ?? 0;
|
|
1000
|
+
rows.get(cell.playerKey)!.enemyFlashes.push({
|
|
1001
|
+
matchId,
|
|
1002
|
+
roundNumber: cell.roundNumber,
|
|
1003
|
+
tick: cell.tick,
|
|
1004
|
+
reason: "该闪光造成的敌方致盲时间位于当前样本前列",
|
|
1005
|
+
role: "example",
|
|
1006
|
+
victimCount: cell.victims.size,
|
|
1007
|
+
enemySeconds: round(cell.seconds, 2),
|
|
1008
|
+
teamSeconds: round(teamSeconds, 2),
|
|
1009
|
+
netSeconds: round(cell.seconds - teamSeconds, 2)
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
return [...rows.values()].map((row) => {
|
|
1015
|
+
row.teamFlashes.sort((a, b) => b.totalSeconds - a.totalSeconds);
|
|
1016
|
+
row.enemyFlashes.sort((a, b) => b.enemySeconds - a.enemySeconds);
|
|
1017
|
+
return {
|
|
1018
|
+
playerKey: row.playerKey,
|
|
1019
|
+
name: row.name,
|
|
1020
|
+
flashesThrown: row.flashesThrown,
|
|
1021
|
+
enemyBlindSeconds: round(row.enemyBlindSeconds, 1),
|
|
1022
|
+
teamBlindSeconds: round(row.teamBlindSeconds, 1),
|
|
1023
|
+
enemyBlindVictims: row.enemyBlindVictims,
|
|
1024
|
+
enemySecondsPerFlash: row.flashesThrown > 0 ? round(row.enemyBlindSeconds / row.flashesThrown, 2) : null,
|
|
1025
|
+
netSecondsPerFlash: row.flashesThrown > 0
|
|
1026
|
+
? round((row.enemyBlindSeconds - row.teamBlindSeconds) / row.flashesThrown, 2)
|
|
1027
|
+
: null,
|
|
1028
|
+
flashAssists: row.flashAssists,
|
|
1029
|
+
worstTeamFlashes: row.teamFlashes.slice(0, MAX_EVIDENCE),
|
|
1030
|
+
bestEnemyFlashes: row.enemyFlashes.slice(0, MAX_BEST_FLASH)
|
|
1031
|
+
};
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// ── Buy Quality(单场,比赛工作台经济页用)──────────────────────────────────
|
|
1036
|
+
|
|
1037
|
+
export interface BuyQualityRow {
|
|
1038
|
+
economy: string;
|
|
1039
|
+
label: string;
|
|
1040
|
+
rounds: number;
|
|
1041
|
+
wins: number;
|
|
1042
|
+
winRatePercent: number | null;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
export interface MatchBuyQuality {
|
|
1046
|
+
teamA: BuyQualityRow[];
|
|
1047
|
+
teamB: BuyQualityRow[];
|
|
1048
|
+
/** 手枪局之后一回合(conversion)的胜率。 */
|
|
1049
|
+
conversion: { teamA: { rounds: number; wins: number }; teamB: { rounds: number; wins: number } };
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
const ECONOMY_ORDER = ["pistol", "eco", "semi", "force", "full"] as const;
|
|
1053
|
+
const ECONOMY_LABEL: Record<string, string> = {
|
|
1054
|
+
pistol: "手枪局",
|
|
1055
|
+
eco: "Eco",
|
|
1056
|
+
semi: "半起",
|
|
1057
|
+
force: "强起",
|
|
1058
|
+
full: "长枪局"
|
|
1059
|
+
};
|
|
1060
|
+
|
|
1061
|
+
export function buildMatchBuyQuality(economy: MatchWorkspaceModel["economy"]): MatchBuyQuality {
|
|
1062
|
+
const rowsFor = (teamKey: "teamA" | "teamB"): BuyQualityRow[] =>
|
|
1063
|
+
ECONOMY_ORDER.map((type) => {
|
|
1064
|
+
const rounds = economy.filter((p) => (teamKey === "teamA" ? p.teamAEconomy : p.teamBEconomy) === type);
|
|
1065
|
+
const wins = rounds.filter((p) => p.winnerTeamKey === teamKey).length;
|
|
1066
|
+
return {
|
|
1067
|
+
economy: type,
|
|
1068
|
+
label: ECONOMY_LABEL[type],
|
|
1069
|
+
rounds: rounds.length,
|
|
1070
|
+
wins,
|
|
1071
|
+
winRatePercent: rounds.length > 0 ? round((wins / rounds.length) * 100, 1) : null
|
|
1072
|
+
};
|
|
1073
|
+
}).filter((row) => row.rounds > 0);
|
|
1074
|
+
|
|
1075
|
+
const conversionFor = (teamKey: "teamA" | "teamB") => {
|
|
1076
|
+
const ordered = [...economy].sort((a, b) => a.roundNumber - b.roundNumber);
|
|
1077
|
+
let rounds = 0;
|
|
1078
|
+
let wins = 0;
|
|
1079
|
+
for (let i = 0; i < ordered.length - 1; i += 1) {
|
|
1080
|
+
const cur = ordered[i]!;
|
|
1081
|
+
const isPistol = (teamKey === "teamA" ? cur.teamAEconomy : cur.teamBEconomy) === "pistol";
|
|
1082
|
+
if (!isPistol || cur.winnerTeamKey !== teamKey) continue;
|
|
1083
|
+
rounds += 1;
|
|
1084
|
+
if (ordered[i + 1]!.winnerTeamKey === teamKey) wins += 1;
|
|
1085
|
+
}
|
|
1086
|
+
return { rounds, wins };
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
return {
|
|
1090
|
+
teamA: rowsFor("teamA"),
|
|
1091
|
+
teamB: rowsFor("teamB"),
|
|
1092
|
+
conversion: { teamA: conversionFor("teamA"), teamB: conversionFor("teamB") }
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
// ── 赛事报表(Markdown)─────────────────────────────────────────────────────
|
|
1097
|
+
|
|
1098
|
+
/** 单场比赛报告:half-by-half、关键回合、记分板。给主办方发布用。 */
|
|
1099
|
+
export function buildMatchReportMarkdown(model: MatchWorkspaceModel): string {
|
|
1100
|
+
const lines: string[] = [];
|
|
1101
|
+
lines.push(`# ${model.title}`);
|
|
1102
|
+
lines.push("");
|
|
1103
|
+
lines.push(`**${model.mapName}** · 比分 **${model.scoreline}**`);
|
|
1104
|
+
lines.push("");
|
|
1105
|
+
for (const beat of model.overview.story) {
|
|
1106
|
+
lines.push(`> ${beat}`);
|
|
1107
|
+
}
|
|
1108
|
+
lines.push("");
|
|
1109
|
+
lines.push("## 记分板");
|
|
1110
|
+
lines.push("");
|
|
1111
|
+
lines.push("| 选手 | 队伍 | K/D/A | ADR | KAST | RR |");
|
|
1112
|
+
lines.push("|---|---|---|---|---|---|");
|
|
1113
|
+
for (const row of model.scoreboard) {
|
|
1114
|
+
const teamName = row.teamKey === "teamA" ? model.teams.teamA.name : model.teams.teamB.name;
|
|
1115
|
+
lines.push(
|
|
1116
|
+
`| ${row.name} | ${teamName} | ${row.kills}/${row.deaths}/${row.assists} | ${row.adr.toFixed(1)} | ${row.kast.toFixed(1)}% | ${row.accountRR.toFixed(2)} |`
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
lines.push("");
|
|
1120
|
+
lines.push("## 关键回合");
|
|
1121
|
+
lines.push("");
|
|
1122
|
+
const keyRounds = model.rounds.filter(
|
|
1123
|
+
(row) => row.facets && (row.facets.clutch || row.facets.maxKillsByOnePlayer >= 3)
|
|
1124
|
+
);
|
|
1125
|
+
if (keyRounds.length === 0) {
|
|
1126
|
+
lines.push("(本场无 3+ 多杀或残局回合)");
|
|
1127
|
+
}
|
|
1128
|
+
for (const row of keyRounds) {
|
|
1129
|
+
const tags: string[] = [];
|
|
1130
|
+
if (row.facets?.clutch) {
|
|
1131
|
+
tags.push(`1v${row.facets.clutch.opponentCount} 残局${row.facets.clutch.won ? "成功" : "失败"}`);
|
|
1132
|
+
}
|
|
1133
|
+
if ((row.facets?.maxKillsByOnePlayer ?? 0) >= 3) {
|
|
1134
|
+
tags.push(`${row.facets!.maxKillsByOnePlayer} 杀回合`);
|
|
1135
|
+
}
|
|
1136
|
+
lines.push(`- **R${row.roundNumber}**(${row.scoreBefore},${row.winnerSide.toUpperCase()} 胜):${tags.join("、")}`);
|
|
1137
|
+
}
|
|
1138
|
+
lines.push("");
|
|
1139
|
+
lines.push("## 经济与回合");
|
|
1140
|
+
lines.push("");
|
|
1141
|
+
lines.push("| 回合 | 比分 | 胜方 | A 队经济 | B 队经济 | 结束方式 |");
|
|
1142
|
+
lines.push("|---|---|---|---|---|---|");
|
|
1143
|
+
for (const row of model.rounds) {
|
|
1144
|
+
lines.push(
|
|
1145
|
+
`| R${row.roundNumber} | ${row.scoreBefore} | ${row.winnerSide.toUpperCase()} | ${row.teamAEconomy} | ${row.teamBEconomy} | ${row.endReason} |`
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
lines.push("");
|
|
1149
|
+
lines.push(`---\n由 DAK Studio 生成 · cs2-demo-analysis-kit`);
|
|
1150
|
+
return lines.join("\n");
|
|
1151
|
+
}
|