@cs2dak/core 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/workspace.ts DELETED
@@ -1,726 +0,0 @@
1
- import {
2
- analysisBundleSchema,
3
- matchWorkspaceModelSchema,
4
- type AnalysisBundle,
5
- type DemoPackage,
6
- type EconomyPoint,
7
- type HeatmapPoint,
8
- type MatchWorkspaceModel,
9
- type PlayerScoreboardRow,
10
- type TeamKey,
11
- type WorkspaceKillEvent,
12
- type WorkspaceReplayFrame,
13
- type WorkspaceSpatialPoint
14
- } from "@cs2dak/contract";
15
- import { groupBy, nameForSteamId, round, normalizeWeapon, isNamedWeapon } from "./utils.js";
16
- import { displayWeaponName } from "./weapons.js";
17
- import { normalizeDemoPackage } from "./normalize.js";
18
- import { buildQaReport } from "./qa.js";
19
- import { buildPlayerRoundFacts, buildPlayerIndicators, buildScoreboard } from "./scoreboard.js";
20
- import { computeAccountRatingsV2 } from "./signals.js";
21
- import { buildTimeline, buildEconomy, buildHeatmap } from "./timeline.js";
22
-
23
- export function buildDemoViewModel(bundle: AnalysisBundle) {
24
- return {
25
- title: `${bundle.teams.teamA.name} vs ${bundle.teams.teamB.name}`,
26
- subtitle: `${bundle.mapName} · ${bundle.scoreboard.length} 名选手 · ${bundle.economy.length} 回合`,
27
- map: {
28
- name: bundle.mapName,
29
- radarImageUrl: radarImageUrlForMap(bundle.mapName),
30
- calibrated: bundle.heatmap.length > 0
31
- },
32
- scoreline: `${bundle.teams.teamA.score}:${bundle.teams.teamB.score}`,
33
- teams: bundle.teams,
34
- scoreboard: bundle.scoreboard,
35
- playerIndicators: bundle.playerIndicators,
36
- playerRoundFacts: bundle.playerRoundFacts,
37
- timeline: bundle.timeline,
38
- economy: bundle.economy,
39
- heatmap: bundle.heatmap,
40
- qa: bundle.qa
41
- };
42
- }
43
-
44
- export function buildMatchWorkspaceModel(input: unknown): MatchWorkspaceModel {
45
- const pkg = normalizeDemoPackage(input);
46
- const qa = buildQaReport(pkg);
47
- const playerRoundFacts = buildPlayerRoundFacts(pkg);
48
- const playerIndicators = buildPlayerIndicators(pkg, playerRoundFacts);
49
- const accountRatings = computeAccountRatingsV2(pkg);
50
- const scoreboard = buildScoreboard(pkg, playerIndicators, accountRatings);
51
- const timeline = buildTimeline(pkg);
52
- const economy = buildEconomy(pkg);
53
- const heatmap = buildHeatmap(pkg);
54
-
55
- const bundle: AnalysisBundle = analysisBundleSchema.parse({
56
- version: "cs2-demo-analysis-kit/0.2",
57
- sourceSchemaVersion: pkg.manifest.schemaVersion,
58
- mapName: pkg.match.mapName,
59
- tickrate: pkg.match.tickrate,
60
- teams: {
61
- teamA: { name: pkg.match.teamA.name ?? "Team A", score: pkg.match.teamA.score },
62
- teamB: { name: pkg.match.teamB.name ?? "Team B", score: pkg.match.teamB.score }
63
- },
64
- scoreboard,
65
- playerIndicators,
66
- playerRoundFacts,
67
- timeline,
68
- economy,
69
- heatmap,
70
- qa
71
- });
72
-
73
- const view = buildDemoViewModel(bundle);
74
- const eventsByRound = groupBy(bundle.timeline, (event) => event.roundNumber);
75
- const factsByRound = groupBy(bundle.playerRoundFacts, (fact) => fact.roundNumber);
76
- const factsByPlayer = groupBy(bundle.playerRoundFacts, (fact) => fact.steamId64);
77
- const teamNames = {
78
- teamA: bundle.teams.teamA.name,
79
- teamB: bundle.teams.teamB.name
80
- };
81
-
82
- return matchWorkspaceModelSchema.parse({
83
- version: "cs2-demo-analysis-kit/workspace-0.1",
84
- sourceSchemaVersion: bundle.sourceSchemaVersion,
85
- title: view.title,
86
- subtitle: view.subtitle,
87
- scoreline: view.scoreline,
88
- mapName: bundle.mapName,
89
- teams: bundle.teams,
90
- tabs: [
91
- { key: "overview", label: "总览" },
92
- { key: "rounds", label: "回合" },
93
- { key: "players", label: "选手" },
94
- { key: "economy", label: "经济" },
95
- { key: "map", label: "地图" },
96
- { key: "replay", label: "回放" }
97
- ],
98
- overview: {
99
- kpis: buildWorkspaceKpis(bundle),
100
- story: buildWorkspaceStory(bundle, pkg)
101
- },
102
- scoreboard: bundle.scoreboard,
103
- rounds: pkg.rounds.map((roundRow) => ({
104
- roundNumber: roundRow.roundNumber,
105
- scoreBefore: `${roundRow.teamAScoreBefore}:${roundRow.teamBScoreBefore}`,
106
- winnerTeamKey: roundRow.winnerTeamKey,
107
- winnerSide: roundRow.winnerSide,
108
- endReason: roundRow.endReason,
109
- teamAEconomy: roundRow.teamAEconomy,
110
- teamBEconomy: roundRow.teamBEconomy,
111
- events: eventsByRound.get(roundRow.roundNumber) ?? [],
112
- playerFacts: factsByRound.get(roundRow.roundNumber) ?? []
113
- })),
114
- players: bundle.scoreboard.map((row) => ({
115
- row,
116
- teamName: teamNames[row.teamKey],
117
- summary: buildPlayerSummary(row),
118
- rrBreakdown: [
119
- { key: "combat", label: "Combat", value: row.accountBreakdown.combat },
120
- { key: "trade", label: "Trade", value: row.accountBreakdown.trade },
121
- { key: "clutch", label: "Clutch", value: row.accountBreakdown.clutch },
122
- { key: "objective", label: "Objective", value: row.accountBreakdown.objective },
123
- { key: "utility", label: "Utility", value: row.accountBreakdown.utility }
124
- ],
125
- roundFacts: factsByPlayer.get(row.steamId64) ?? []
126
- })),
127
- economy: bundle.economy,
128
- map: buildWorkspaceMap(pkg, view.map, bundle.heatmap),
129
- replay: buildWorkspaceReplay(pkg),
130
- adminQa: bundle.qa
131
- });
132
- }
133
-
134
- function radarImageUrlForMap(mapName: string): string | null {
135
- const knownMaps = new Set(["de_ancient", "de_anubis", "de_dust2", "de_inferno", "de_mirage", "de_nuke", "de_overpass"]);
136
- // Relative path so it resolves from both http:// dev server and file:// pywebview.
137
- return knownMaps.has(mapName) ? `./maps/radars/${mapName}.png` : null;
138
- }
139
-
140
- function buildWorkspaceKpis(bundle: AnalysisBundle) {
141
- const topRR = bundle.scoreboard[0];
142
- const topAdr = [...bundle.scoreboard].sort((a, b) => b.adr - a.adr)[0];
143
- const tradedDeaths = bundle.scoreboard.reduce((sum, row) => sum + row.tradeKills, 0);
144
- const roundCount = bundle.economy.length;
145
-
146
- return [
147
- {
148
- key: "topRR",
149
- label: "最高 V2 RR",
150
- value: topRR ? topRR.accountRR.toFixed(3) : "0.000",
151
- detail: topRR?.name ?? "暂无选手"
152
- },
153
- {
154
- key: "topADR",
155
- label: "最高 ADR",
156
- value: topAdr ? topAdr.adr.toFixed(1) : "0.0",
157
- detail: topAdr?.name ?? "暂无选手"
158
- },
159
- {
160
- key: "roundCount",
161
- label: "总回合数",
162
- value: roundCount.toString(),
163
- detail: `${bundle.teams.teamA.score}:${bundle.teams.teamB.score}`
164
- },
165
- {
166
- key: "tradeActivity",
167
- label: "补枪参与",
168
- value: tradedDeaths.toString(),
169
- detail: "来自 player-stats / kills"
170
- }
171
- ];
172
- }
173
-
174
- // ── 比赛叙事(overview.story)────────────────────────────────────────────
175
- // 采用「候选 beat 池 + 显著度择优」:每个生成器产出一条候选,按显著度排序取前
176
- // MAX_STORY_BEATS 条;开场 beat 强制置顶,选手类 beat 最多占 MAX_PLAYER_BEATS 条,
177
- // 显著度低于阈值的不强凑。文案为解说式口语。
178
-
179
- const MAX_STORY_BEATS = 6;
180
- const MAX_PLAYER_BEATS = 2;
181
- const MIN_BEAT_SALIENCE = 0.2;
182
- const PLAYER_BEAT_KEYS = new Set(["mvp", "entry", "clutch"]);
183
-
184
- const MAP_DISPLAY_NAMES: Record<string, string> = {
185
- de_ancient: "Ancient",
186
- de_anubis: "Anubis",
187
- de_dust2: "Dust2",
188
- de_inferno: "Inferno",
189
- de_mirage: "Mirage",
190
- de_nuke: "Nuke",
191
- de_overpass: "Overpass",
192
- de_train: "Train",
193
- de_vertigo: "Vertigo"
194
- };
195
-
196
- function mapDisplayName(mapName: string): string {
197
- return MAP_DISPLAY_NAMES[mapName] ?? mapName.replace(/^de_/, "").replace(/^\w/, (c) => c.toUpperCase());
198
- }
199
-
200
- function sideLabelZh(side: "ct" | "t" | null): string {
201
- return side === "ct" ? "CT" : "T";
202
- }
203
-
204
- // ── 文案模板(集中管理,改词只动这里)─────────────────────────────────────
205
- // 上面的 beat 生成器只负责算数据、决定走哪个分支;具体「怎么说」全部收在这里。
206
- // 想调措辞、换口吻、改用词,编辑这个对象即可,无需碰逻辑。
207
- const STORY_COPY = {
208
- headline: {
209
- /** 比分差 → 动词。 */
210
- verb: (margin: number) => (margin >= 8 ? "碾压" : margin <= 2 ? "险胜" : "拿下"),
211
- comeback: (firstHalf: number, secondHalf: number) =>
212
- `上半场只拿到 ${firstHalf} 分被压着打,换边后连下 ${secondHalf} 分完成翻盘`,
213
- frontRunner: (firstHalf: number, secondHalf: number) =>
214
- `上半场就先声夺人拿下 ${firstHalf} 分,下半场再补 ${secondHalf} 分锁死悬念`,
215
- even: (firstHalf: number, secondHalf: number) =>
216
- `上半场拿 ${firstHalf} 分、下半场再添 ${secondHalf} 分`,
217
- overtime: (overtimeWins: number) => `,加时又咬下 ${overtimeWins} 分才分出胜负`,
218
- line: (mapName: string, winner: string, winnerScore: number, loserScore: number, verb: string, loser: string, half: string, overtime: string) =>
219
- `${mapName} 一图,${winner} ${winnerScore}:${loserScore} ${verb} ${loser}——${half}${overtime}。`
220
- },
221
- momentum: {
222
- /** 连胜的攻防上下文短语;非赢家或无 side 时返回空串。 */
223
- context: (sideLabel: string, crossSwitch: boolean, ownedByWinner: boolean) =>
224
- !ownedByWinner ? "" : crossSwitch ? "横跨换边" : `靠 ${sideLabel} 半场`,
225
- line: (startRound: number, endRound: number, length: number, team: string, context: string) =>
226
- `真正拉开差距的是 R${startRound}-R${endRound} 那波 ${length} 连胜,${team}${context}借此锁定胜局。`
227
- },
228
- pistol: {
229
- sweep: "这场比赛几乎已经拿下一半。",
230
- none: "这场胜利完全是靠后面一分分追回来的。",
231
- split: "另一个交还给对手,靠后续长枪局把节奏拉了回来。",
232
- line: (total: number, winner: string, wins: number, tail: string) =>
233
- `${total} 个手枪局 ${winner} 拿下 ${wins} 个,${tail}`
234
- },
235
- clutch: {
236
- line: (who: string, teamTag: string, round: number, opponents: number) =>
237
- `最精彩的残局是 ${who}${teamTag}在 R${round} 的 1v${opponents} 残局翻盘,这种回合最能鼓舞士气。`
238
- },
239
- entry: {
240
- line: (name: string, entryKills: number) =>
241
- `${name} 是队里首杀最多的选手,${entryKills} 次首杀大多由他先行完成。`
242
- },
243
- lowBuy: {
244
- line: (winner: string, count: number, round: number) =>
245
- `${winner} 还在 ${count} 个eco或半起中完成了对对手的翻盘(如 R${round}),这种以弱胜强是对对手经济的严重打击。`
246
- },
247
- closeout: {
248
- reason: {
249
- target_bombed: "C4爆炸",
250
- bomb_defused: "拆弹",
251
- t_win: "正面歼灭",
252
- ct_win: "正面歼灭",
253
- target_saved: "拖到时间耗尽"
254
- } as Record<string, string>,
255
- line: (winner: string, count: number, total: number, reason: string) =>
256
- `${winner} 的胜局里有 ${count}/${total} 个靠${reason}收尾,打法偏好相当鲜明。`
257
- },
258
- mvp: {
259
- dom: {
260
- combat: "纯粹的枪法压制",
261
- trade: "默契的补枪联动",
262
- clutch: "残局里的收割能力",
263
- objective: "对包点目标的推进",
264
- utility: "道具开路的支援"
265
- } as Record<string, string>,
266
- winnerAdrTail: (name: string, adr: string) => `;场均伤害最高的则是 ${name}(ADR ${adr})`,
267
- winner: (name: string, kda: string, adr: string, rr: string, dom: string, adrTail: string) =>
268
- `胜者这边 ${name} 打得最稳,${kda}、场均 ${adr} 伤害,全场最高的 RR ${rr} 主要来自${dom}${adrTail}。`,
269
- loser: (rr: string, kda: string, name: string, team: string, dom: string) =>
270
- `值得一提的是,全场 RR 最高(${rr},${kda})的 ${name} 来自落败的 ${team},凭${dom}撑起了大部分火力,可惜独木难支。`
271
- }
272
- } as const;
273
-
274
- interface StoryBeat {
275
- key: string;
276
- available: boolean;
277
- salience: number;
278
- text: string;
279
- }
280
-
281
- interface StoryContext {
282
- bundle: AnalysisBundle;
283
- pkg: DemoPackage;
284
- mapName: string;
285
- winnerKey: TeamKey;
286
- loserKey: TeamKey;
287
- winner: AnalysisBundle["teams"]["teamA"];
288
- loser: AnalysisBundle["teams"]["teamA"];
289
- /** 赢家在某回合所站的边(ct/t)。 */
290
- sideOfWinner: (roundNumber: number) => "ct" | "t" | null;
291
- /** 赢家 CT 半 / T 半各赢多少分(跨加时聚合,靠 per-round side 判定)。 */
292
- ctWins: number;
293
- tWins: number;
294
- /** 真实换边后的上/下半场与加时赢分(赢家视角)。 */
295
- firstHalfWins: number;
296
- secondHalfWins: number;
297
- overtimeWins: number;
298
- hasOvertime: boolean;
299
- }
300
-
301
- function buildStoryContext(bundle: AnalysisBundle, pkg: DemoPackage): StoryContext {
302
- const winnerKey: TeamKey = bundle.teams.teamA.score >= bundle.teams.teamB.score ? "teamA" : "teamB";
303
- const loserKey: TeamKey = winnerKey === "teamA" ? "teamB" : "teamA";
304
-
305
- const roundsByNumber = new Map(pkg.rounds.map((roundRow) => [roundRow.roundNumber, roundRow]));
306
- const sideOfWinner = (roundNumber: number): "ct" | "t" | null => {
307
- const roundRow = roundsByNumber.get(roundNumber);
308
- if (!roundRow) return null;
309
- return winnerKey === "teamA" ? roundRow.teamASide : roundRow.teamBSide;
310
- };
311
-
312
- // 找真实换边点:side 相对上一回合翻转处即为换边。
313
- const ordered = [...bundle.economy].sort((a, b) => a.roundNumber - b.roundNumber);
314
- const switchRounds: number[] = [];
315
- for (let index = 1; index < ordered.length; index += 1) {
316
- if (sideOfWinner(ordered[index]!.roundNumber) !== sideOfWinner(ordered[index - 1]!.roundNumber)) {
317
- switchRounds.push(ordered[index]!.roundNumber);
318
- }
319
- }
320
- const firstSwitch = switchRounds[0] ?? Infinity;
321
- const overtimeStart = 25; // CS2 常规局 24 回合,加时从 R25 起。
322
-
323
- let ctWins = 0;
324
- let tWins = 0;
325
- let firstHalfWins = 0;
326
- let secondHalfWins = 0;
327
- let overtimeWins = 0;
328
- for (const point of ordered) {
329
- if (point.winnerTeamKey !== winnerKey) continue;
330
- const side = sideOfWinner(point.roundNumber);
331
- if (side === "ct") ctWins += 1;
332
- else if (side === "t") tWins += 1;
333
- if (point.roundNumber >= overtimeStart) overtimeWins += 1;
334
- else if (point.roundNumber < firstSwitch) firstHalfWins += 1;
335
- else secondHalfWins += 1;
336
- }
337
-
338
- return {
339
- bundle,
340
- pkg,
341
- mapName: mapDisplayName(bundle.mapName),
342
- winnerKey,
343
- loserKey,
344
- winner: bundle.teams[winnerKey],
345
- loser: bundle.teams[loserKey],
346
- sideOfWinner,
347
- ctWins,
348
- tWins,
349
- firstHalfWins,
350
- secondHalfWins,
351
- overtimeWins,
352
- hasOvertime: ordered.some((point) => point.roundNumber >= overtimeStart)
353
- };
354
- }
355
-
356
- function buildWorkspaceStory(bundle: AnalysisBundle, pkg: DemoPackage): string[] {
357
- const ctx = buildStoryContext(bundle, pkg);
358
- const candidates = [
359
- headlineBeat(ctx),
360
- momentumBeat(ctx),
361
- pistolBeat(ctx),
362
- clutchBeat(ctx),
363
- entryBeat(ctx),
364
- lowBuyBeat(ctx),
365
- closeoutBeat(ctx),
366
- mvpBeat(ctx)
367
- ].filter((beat): beat is StoryBeat => beat !== null && beat.available);
368
-
369
- const headline = candidates.find((beat) => beat.key === "headline");
370
- const rest = candidates
371
- .filter((beat) => beat.key !== "headline" && beat.salience >= MIN_BEAT_SALIENCE)
372
- .sort((a, b) => b.salience - a.salience);
373
-
374
- // 选手类 beat 限流,避免整段都在夸人。
375
- let playerCount = 0;
376
- const picked: StoryBeat[] = [];
377
- for (const beat of rest) {
378
- if (PLAYER_BEAT_KEYS.has(beat.key)) {
379
- if (playerCount >= MAX_PLAYER_BEATS) continue;
380
- playerCount += 1;
381
- }
382
- picked.push(beat);
383
- }
384
-
385
- return [headline, ...picked]
386
- .filter((beat): beat is StoryBeat => beat !== undefined)
387
- .slice(0, MAX_STORY_BEATS)
388
- .map((beat) => beat.text);
389
- }
390
-
391
- function headlineBeat(ctx: StoryContext): StoryBeat {
392
- const { winner, loser, mapName } = ctx;
393
- const verb = STORY_COPY.headline.verb(winner.score - loser.score);
394
- let half: string;
395
- if (ctx.firstHalfWins <= 4 && ctx.secondHalfWins > ctx.firstHalfWins) {
396
- half = STORY_COPY.headline.comeback(ctx.firstHalfWins, ctx.secondHalfWins);
397
- } else if (ctx.firstHalfWins >= 9) {
398
- half = STORY_COPY.headline.frontRunner(ctx.firstHalfWins, ctx.secondHalfWins);
399
- } else {
400
- half = STORY_COPY.headline.even(ctx.firstHalfWins, ctx.secondHalfWins);
401
- }
402
- const overtime = ctx.hasOvertime ? STORY_COPY.headline.overtime(ctx.overtimeWins) : "";
403
- return {
404
- key: "headline",
405
- available: true,
406
- salience: 1,
407
- text: STORY_COPY.headline.line(mapName, winner.name, winner.score, loser.score, verb, loser.name, half, overtime)
408
- };
409
- }
410
-
411
- function momentumBeat(ctx: StoryContext): StoryBeat | null {
412
- const run = longestWinRun(ctx.bundle.economy);
413
- if (run.length < 4) return null;
414
- const team = run.teamKey === "teamA" ? ctx.bundle.teams.teamA.name : ctx.bundle.teams.teamB.name;
415
- const total = ctx.bundle.teams.teamA.score + ctx.bundle.teams.teamB.score;
416
- const startSide = ctx.sideOfWinner(run.startRound);
417
- const endSide = ctx.sideOfWinner(run.endRound);
418
- const ownedByWinner = run.teamKey === ctx.winnerKey;
419
- const crossSwitch = ownedByWinner && startSide !== null && endSide !== null && startSide !== endSide;
420
- const context = STORY_COPY.momentum.context(sideLabelZh(startSide), crossSwitch, ownedByWinner);
421
- return {
422
- key: "momentum",
423
- available: true,
424
- salience: Math.min(0.95, run.length / total + 0.2),
425
- text: STORY_COPY.momentum.line(run.startRound, run.endRound, run.length, team, context)
426
- };
427
- }
428
-
429
- function pistolBeat(ctx: StoryContext): StoryBeat | null {
430
- const pistols = ctx.bundle.economy.filter(
431
- (roundRow) => roundRow.teamAEconomy === "pistol" || roundRow.teamBEconomy === "pistol"
432
- );
433
- if (pistols.length === 0) return null;
434
- const wins = pistols.filter((roundRow) => roundRow.winnerTeamKey === ctx.winnerKey).length;
435
- const tail = wins === pistols.length ? STORY_COPY.pistol.sweep : wins === 0 ? STORY_COPY.pistol.none : STORY_COPY.pistol.split;
436
- return {
437
- key: "pistol",
438
- available: true,
439
- salience: wins === pistols.length ? 0.7 : wins === 0 ? 0.55 : 0.45,
440
- text: STORY_COPY.pistol.line(pistols.length, ctx.winner.name, wins, tail)
441
- };
442
- }
443
-
444
- function clutchBeat(ctx: StoryContext): StoryBeat | null {
445
- const won = ctx.pkg.clutches.filter((row) => row.won && (row.opponentCount ?? 0) >= 2);
446
- if (won.length === 0) return null;
447
- const top = [...won].sort((a, b) => (b.opponentCount ?? 0) - (a.opponentCount ?? 0))[0]!;
448
- const player = ctx.pkg.players.find((row) => row.steamId64 === top.clutcherSteamId64);
449
- const who = player?.name ?? "某选手";
450
- const teamName = player
451
- ? player.teamKey === "teamA"
452
- ? ctx.bundle.teams.teamA.name
453
- : ctx.bundle.teams.teamB.name
454
- : null;
455
- const teamTag = teamName ? `(${teamName})` : "";
456
- return {
457
- key: "clutch",
458
- available: true,
459
- salience: 0.4 + (top.opponentCount ?? 2) * 0.12,
460
- text: STORY_COPY.clutch.line(who, teamTag, top.roundNumber, top.opponentCount)
461
- };
462
- }
463
-
464
- function entryBeat(ctx: StoryContext): StoryBeat | null {
465
- const topEntry = [...ctx.bundle.scoreboard].sort((a, b) => b.entryKills - a.entryKills)[0];
466
- // 与 MVP 撞人就让位给 MVP(同一人不重复出两条)。
467
- if (!topEntry || topEntry.entryKills < 3 || topEntry.steamId64 === ctx.bundle.scoreboard[0]?.steamId64) {
468
- return null;
469
- }
470
- return {
471
- key: "entry",
472
- available: true,
473
- salience: 0.3 + topEntry.entryKills * 0.05,
474
- text: STORY_COPY.entry.line(topEntry.name, topEntry.entryKills)
475
- };
476
- }
477
-
478
- function lowBuyBeat(ctx: StoryContext): StoryBeat | null {
479
- const steals = ctx.bundle.economy.filter((roundRow) => {
480
- const winnerEconomy = ctx.winnerKey === "teamA" ? roundRow.teamAEconomy : roundRow.teamBEconomy;
481
- const loserEconomy = ctx.winnerKey === "teamA" ? roundRow.teamBEconomy : roundRow.teamAEconomy;
482
- return roundRow.winnerTeamKey === ctx.winnerKey && ["eco", "semi", "force"].includes(winnerEconomy) && loserEconomy === "full";
483
- });
484
- if (steals.length === 0) return null;
485
- return {
486
- key: "lowBuy",
487
- available: true,
488
- salience: 0.25 + steals.length * 0.08,
489
- text: STORY_COPY.lowBuy.line(ctx.winner.name, steals.length, steals[0]!.roundNumber)
490
- };
491
- }
492
-
493
- function closeoutBeat(ctx: StoryContext): StoryBeat | null {
494
- const winnerRounds = ctx.pkg.rounds.filter((roundRow) => roundRow.winnerTeamKey === ctx.winnerKey);
495
- if (winnerRounds.length === 0) return null;
496
- const byReason = new Map<string, number>();
497
- for (const roundRow of winnerRounds) {
498
- byReason.set(roundRow.endReason, (byReason.get(roundRow.endReason) ?? 0) + 1);
499
- }
500
- const [reason, count] = [...byReason.entries()].sort((a, b) => b[1] - a[1])[0] ?? ["", 0];
501
- const ratio = count / winnerRounds.length;
502
- if (ratio < 0.5) return null;
503
- return {
504
- key: "closeout",
505
- available: true,
506
- salience: ratio - 0.25,
507
- text: STORY_COPY.closeout.line(ctx.winner.name, count, winnerRounds.length, STORY_COPY.closeout.reason[reason] ?? reason)
508
- };
509
- }
510
-
511
- function mvpBeat(ctx: StoryContext): StoryBeat | null {
512
- const top = ctx.bundle.scoreboard[0];
513
- if (!top) return null;
514
- const domKey = (Object.entries(top.accountBreakdown).sort((a, b) => b[1] - a[1])[0]?.[0]) ?? "combat";
515
- const dom = STORY_COPY.mvp.dom[domKey] ?? STORY_COPY.mvp.dom.combat!;
516
- const kda = `${top.kills}/${top.deaths}/${top.assists}`;
517
- const rr = top.accountRR.toFixed(2);
518
- if (top.teamKey === ctx.winnerKey) {
519
- const topAdr = [...ctx.bundle.scoreboard].sort((a, b) => b.adr - a.adr)[0];
520
- const adrTail = topAdr && topAdr.steamId64 !== top.steamId64
521
- ? STORY_COPY.mvp.winnerAdrTail(topAdr.name, topAdr.adr.toFixed(0))
522
- : "";
523
- return {
524
- key: "mvp",
525
- available: true,
526
- salience: 0.6,
527
- text: STORY_COPY.mvp.winner(top.name, kda, top.adr.toFixed(0), rr, dom, adrTail)
528
- };
529
- }
530
- // 全场最高分落在败方:点明队属,避免误读成赢家 MVP。
531
- return {
532
- key: "mvp",
533
- available: true,
534
- salience: 0.55,
535
- text: STORY_COPY.mvp.loser(rr, kda, top.name, ctx.loser.name, dom)
536
- };
537
- }
538
-
539
- function longestWinRun(rounds: EconomyPoint[]): { teamKey: TeamKey; startRound: number; endRound: number; length: number } {
540
- let best = { teamKey: "teamA" as TeamKey, startRound: 0, endRound: 0, length: 0 };
541
- let current = { teamKey: "teamA" as TeamKey, startRound: 0, endRound: 0, length: 0 };
542
- for (const roundRow of rounds) {
543
- if (current.length > 0 && current.teamKey === roundRow.winnerTeamKey) {
544
- current = { ...current, endRound: roundRow.roundNumber, length: current.length + 1 };
545
- } else {
546
- current = { teamKey: roundRow.winnerTeamKey, startRound: roundRow.roundNumber, endRound: roundRow.roundNumber, length: 1 };
547
- }
548
- if (current.length > best.length) {
549
- best = current;
550
- }
551
- }
552
- return best;
553
- }
554
-
555
- function buildPlayerSummary(row: PlayerScoreboardRow): string[] {
556
- const summary = [
557
- `${row.kills}/${row.deaths}/${row.assists},ADR ${row.adr.toFixed(1)},KAST ${row.kast.toFixed(1)}%。`,
558
- `V2 RR ${row.accountRR.toFixed(3)},数据可信度 ${(row.confidence * 100).toFixed(0)}%。`
559
- ];
560
- if (row.entryKills > 0) {
561
- summary.push(`贡献 ${row.entryKills} 次首杀。`);
562
- }
563
- if (row.tradeKills > 0) {
564
- summary.push(`完成 ${row.tradeKills} 次补枪。`);
565
- }
566
- if ((row.bombPlantCount ?? 0) + (row.bombDefuseCount ?? 0) > 0) {
567
- summary.push(`目标贡献:${row.bombPlantCount ?? 0} 次下包,${row.bombDefuseCount ?? 0} 次拆包。`);
568
- }
569
- return summary;
570
- }
571
-
572
- function buildWorkspaceMap(pkg: DemoPackage, view: ReturnType<typeof buildDemoViewModel>["map"], heatmap: HeatmapPoint[]) {
573
- const bombPoints: WorkspaceSpatialPoint[] = pkg.bombs
574
- .filter((bomb) => bomb.position && (bomb.position.x !== 0 || bomb.position.y !== 0))
575
- .map((bomb) => ({
576
- x: bomb.position.x,
577
- y: bomb.position.y,
578
- z: bomb.position.z,
579
- roundNumber: bomb.roundNumber,
580
- teamKey: bomb.actorTeamKey,
581
- steamId64: bomb.actorSteamId64,
582
- kind: "bomb",
583
- side: null,
584
- grenadeType: null
585
- }));
586
- const positionPoints: WorkspaceSpatialPoint[] = (pkg.positions1s ?? [])
587
- .filter((row) => row.position && (row.position.x !== 0 || row.position.y !== 0))
588
- .map((row) => ({
589
- x: row.position?.x ?? 0,
590
- y: row.position?.y ?? 0,
591
- z: row.position?.z ?? 0,
592
- roundNumber: row.roundNumber,
593
- teamKey: row.teamKey,
594
- steamId64: row.steamId64,
595
- kind: "position",
596
- side: null,
597
- grenadeType: null
598
- }));
599
- const points: WorkspaceSpatialPoint[] = [
600
- ...heatmap.map((point) => ({ ...point, kind: point.kind })),
601
- ...bombPoints,
602
- ...positionPoints
603
- ];
604
- const count = (kind: WorkspaceSpatialPoint["kind"]) => points.filter((point) => point.kind === kind).length;
605
- const hasPositionData = points.length > 0;
606
-
607
- return {
608
- view,
609
- modes: [
610
- { key: "death", label: "死亡", count: count("death") },
611
- { key: "kill", label: "击杀", count: count("kill") },
612
- { key: "grenade", label: "道具", count: count("grenade") },
613
- { key: "bomb", label: "炸弹", count: count("bomb") },
614
- { key: "position", label: "站位", count: count("position") }
615
- ],
616
- points,
617
- status: {
618
- hasRadar: !!view.radarImageUrl,
619
- hasPositionData,
620
- message: !view.radarImageUrl
621
- ? "该地图暂无雷达底图"
622
- : hasPositionData
623
- ? null
624
- : "该导出包暂无可展示的位置数据"
625
- }
626
- };
627
- }
628
-
629
- function buildWorkspaceReplay(pkg: DemoPackage) {
630
- const replay = pkg.replay;
631
- if (!replay) {
632
- return {
633
- available: false,
634
- sampleRate: null,
635
- tickrate: null,
636
- rounds: [],
637
- capabilities: {
638
- hasDefuseKit: false,
639
- hasBombPosition: false
640
- }
641
- };
642
- }
643
-
644
- const killsByRound = groupBy(pkg.kills, (k) => k.roundNumber);
645
-
646
- let hasDefuseKit = false;
647
- const rounds = replay.rounds.map((roundRow) => ({
648
- roundNumber: roundRow.roundNumber,
649
- startTick: roundRow.startTick,
650
- tickStep: roundRow.tickStep,
651
- frameCount: roundRow.frameCount,
652
- kills: buildRoundKills(pkg, killsByRound.get(roundRow.roundNumber) ?? []),
653
- players: roundRow.players.map((player) => {
654
- const frames: WorkspaceReplayFrame[] = [];
655
- for (let index = 0; index < roundRow.frameCount; index += 1) {
656
- const flags = player.flags[index] ?? 0;
657
- const frame = {
658
- tick: roundRow.startTick + index * roundRow.tickStep,
659
- x: player.x[index] ?? 0,
660
- y: player.y[index] ?? 0,
661
- z: player.z[index] ?? 0,
662
- yaw: player.yaw[index] ?? 0,
663
- hp: player.hp[index] ?? 0,
664
- weapon: weaponNameForIndex(replay.weaponDict, player.weapon[index] ?? -1),
665
- alive: (flags & 1) !== 0,
666
- flashed: (flags & 8) !== 0,
667
- hasDefuseKit: (flags & 4) !== 0
668
- };
669
- if (frame.hasDefuseKit) {
670
- hasDefuseKit = true;
671
- }
672
- frames.push(frame);
673
- }
674
- return {
675
- steamId64: player.steamId64,
676
- name: nameForSteamId(pkg, player.steamId64) ?? player.steamId64,
677
- teamKey: player.teamKey,
678
- side: player.side,
679
- frames
680
- };
681
- })
682
- }));
683
-
684
- return {
685
- available: true,
686
- sampleRate: replay.meta.sampleRate,
687
- tickrate: replay.meta.tickrate,
688
- rounds,
689
- capabilities: {
690
- hasDefuseKit,
691
- hasBombPosition: false
692
- }
693
- };
694
- }
695
-
696
- function weaponNameForIndex(weaponDict: string[], index: number): string | null {
697
- const raw = index >= 0 ? weaponDict[index] ?? null : null;
698
- if (!raw) {
699
- return null;
700
- }
701
- // Only reject purely-numeric entries (untranslated weapon dict indices).
702
- // Display names with spaces (e.g. "M9 Bayonet") are intentionally allowed through
703
- // so displayWeaponName can match them via its knife/bayonet patterns.
704
- const normalized = normalizeWeapon(raw);
705
- return /^\d+$/.test(normalized) ? null : displayWeaponName(raw);
706
- }
707
-
708
- function buildRoundKills(pkg: DemoPackage, kills: DemoPackage["kills"]): WorkspaceKillEvent[] {
709
- return kills.map((kill, index) => {
710
- const activeRaw = kill.killerActiveWeapon;
711
- const weaponRaw = activeRaw && isNamedWeapon(normalizeWeapon(activeRaw)) ? activeRaw : kill.weapon;
712
- return {
713
- id: `kf-${kill.roundNumber}-${kill.tick}-${index}`,
714
- tick: kill.tick,
715
- killerName: nameForSteamId(pkg, kill.killerSteamId64),
716
- killerTeamKey: kill.killerTeamKey,
717
- victimName: nameForSteamId(pkg, kill.victimSteamId64) ?? kill.victimSteamId64,
718
- weapon: displayWeaponName(weaponRaw),
719
- headshot: kill.headshot,
720
- throughSmoke: kill.throughSmoke,
721
- noScope: kill.noScope,
722
- flashAssist: kill.flashAssist,
723
- tradeKill: kill.tradeKill
724
- };
725
- });
726
- }