@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/workspace.ts
CHANGED
|
@@ -9,11 +9,15 @@ import {
|
|
|
9
9
|
type TeamKey,
|
|
10
10
|
type WorkspaceKillEvent,
|
|
11
11
|
type WorkspaceReplayFrame,
|
|
12
|
+
type WorkspaceReplayLoadout,
|
|
13
|
+
type WorkspaceReplayRound,
|
|
12
14
|
type WorkspaceSpatialPoint
|
|
13
15
|
} from "@cs2dak/contract";
|
|
16
|
+
import { decodeDelta } from "@cs2dak/contract";
|
|
14
17
|
import { groupBy, nameForSteamId, round, normalizeWeapon, isNamedWeapon } from "./workspace-utils.js";
|
|
15
18
|
import { displayWeaponName } from "./weapons.js";
|
|
16
|
-
import { analyzeDemoPackage, normalizeDemoPackage } from "@cs2dak/core";
|
|
19
|
+
import { activeDamages, analyzeDemoPackage, normalizeDemoPackage } from "@cs2dak/core";
|
|
20
|
+
import { getMapCalibration, hasLowerLevel } from "@cs2dak/maps";
|
|
17
21
|
|
|
18
22
|
export function buildDemoViewModel(bundle: AnalysisBundle) {
|
|
19
23
|
return {
|
|
@@ -22,6 +26,7 @@ export function buildDemoViewModel(bundle: AnalysisBundle) {
|
|
|
22
26
|
map: {
|
|
23
27
|
name: bundle.mapName,
|
|
24
28
|
radarImageUrl: radarImageUrlForMap(bundle.mapName),
|
|
29
|
+
lowerRadarImageUrl: lowerRadarImageUrlForMap(bundle.mapName),
|
|
25
30
|
calibrated: bundle.heatmap.length > 0
|
|
26
31
|
},
|
|
27
32
|
scoreline: `${bundle.teams.teamA.score}:${bundle.teams.teamB.score}`,
|
|
@@ -62,6 +67,8 @@ export function buildMatchWorkspaceModel(input: unknown): MatchWorkspaceModel {
|
|
|
62
67
|
{ key: "rounds", label: "回合" },
|
|
63
68
|
{ key: "players", label: "选手" },
|
|
64
69
|
{ key: "economy", label: "经济" },
|
|
70
|
+
{ key: "weapons", label: "武器" },
|
|
71
|
+
{ key: "duels", label: "对位" },
|
|
65
72
|
{ key: "map", label: "地图" },
|
|
66
73
|
{ key: "replay", label: "回放" }
|
|
67
74
|
],
|
|
@@ -79,7 +86,8 @@ export function buildMatchWorkspaceModel(input: unknown): MatchWorkspaceModel {
|
|
|
79
86
|
teamAEconomy: roundRow.teamAEconomy,
|
|
80
87
|
teamBEconomy: roundRow.teamBEconomy,
|
|
81
88
|
events: eventsByRound.get(roundRow.roundNumber) ?? [],
|
|
82
|
-
playerFacts: factsByRound.get(roundRow.roundNumber) ?? []
|
|
89
|
+
playerFacts: factsByRound.get(roundRow.roundNumber) ?? [],
|
|
90
|
+
facets: buildRoundFacets(pkg, roundRow.roundNumber)
|
|
83
91
|
})),
|
|
84
92
|
players: bundle.scoreboard.map((row) => ({
|
|
85
93
|
row,
|
|
@@ -88,6 +96,7 @@ export function buildMatchWorkspaceModel(input: unknown): MatchWorkspaceModel {
|
|
|
88
96
|
rrBreakdown: [
|
|
89
97
|
{ key: "combat", label: "Combat", value: row.accountBreakdown.combat },
|
|
90
98
|
{ key: "trade", label: "Trade", value: row.accountBreakdown.trade },
|
|
99
|
+
{ key: "mapControl", label: "MapControl", value: row.accountBreakdown.mapControl },
|
|
91
100
|
{ key: "clutch", label: "Clutch", value: row.accountBreakdown.clutch },
|
|
92
101
|
{ key: "objective", label: "Objective", value: row.accountBreakdown.objective },
|
|
93
102
|
{ key: "utility", label: "Utility", value: row.accountBreakdown.utility }
|
|
@@ -95,16 +104,140 @@ export function buildMatchWorkspaceModel(input: unknown): MatchWorkspaceModel {
|
|
|
95
104
|
roundFacts: factsByPlayer.get(row.steamId64) ?? []
|
|
96
105
|
})),
|
|
97
106
|
economy: bundle.economy,
|
|
107
|
+
weapons: buildWorkspaceWeapons(pkg),
|
|
108
|
+
duels: buildWorkspaceDuels(pkg, bundle),
|
|
98
109
|
map: buildWorkspaceMap(pkg, view.map, bundle.heatmap),
|
|
99
110
|
replay: buildWorkspaceReplay(pkg),
|
|
100
111
|
adminQa: bundle.qa
|
|
101
112
|
});
|
|
102
113
|
}
|
|
103
114
|
|
|
115
|
+
/** 回合筛选与时间轴锚点的派生事实(v0.2 query-first)。 */
|
|
116
|
+
function buildRoundFacets(pkg: DemoPackage, roundNumber: number) {
|
|
117
|
+
const kills = pkg.kills
|
|
118
|
+
.filter((kill) => kill.roundNumber === roundNumber)
|
|
119
|
+
.sort((a, b) => a.tick - b.tick);
|
|
120
|
+
const bombs = pkg.bombs.filter((bomb) => bomb.roundNumber === roundNumber);
|
|
121
|
+
const plant = bombs.find((bomb) => bomb.type === "planted") ?? null;
|
|
122
|
+
const defuse = bombs.find((bomb) => bomb.type === "defused") ?? null;
|
|
123
|
+
const clutch = pkg.clutches.find((row) => row.roundNumber === roundNumber) ?? null;
|
|
124
|
+
const firstKill = kills[0] ?? null;
|
|
125
|
+
|
|
126
|
+
const killsByPlayer = new Map<string, number>();
|
|
127
|
+
for (const kill of kills) {
|
|
128
|
+
const killerPlayer = kill.killerIndex != null ? pkg.players[kill.killerIndex] : undefined;
|
|
129
|
+
const victimPlayer = pkg.players[kill.victimIndex];
|
|
130
|
+
if (!killerPlayer || !victimPlayer || killerPlayer.teamKey === victimPlayer.teamKey) continue;
|
|
131
|
+
killsByPlayer.set(killerPlayer.steamId64, (killsByPlayer.get(killerPlayer.steamId64) ?? 0) + 1);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
bombSite: plant?.site ?? null,
|
|
136
|
+
bombPlantTick: plant?.tick ?? null,
|
|
137
|
+
bombDefuseTick: defuse?.tick ?? null,
|
|
138
|
+
firstKillTick: firstKill?.tick ?? null,
|
|
139
|
+
firstKillSteamId64: firstKill && firstKill.killerIndex != null ? (pkg.players[firstKill.killerIndex]?.steamId64 ?? null) : null,
|
|
140
|
+
firstKillTeamKey: firstKill && firstKill.killerIndex != null ? (pkg.players[firstKill.killerIndex]?.teamKey ?? null) : null,
|
|
141
|
+
clutch: clutch
|
|
142
|
+
? {
|
|
143
|
+
steamId64: pkg.players[clutch.clutcherIndex]?.steamId64 ?? "",
|
|
144
|
+
teamKey: pkg.players[clutch.clutcherIndex]?.teamKey ?? "",
|
|
145
|
+
opponentCount: clutch.opponentCount,
|
|
146
|
+
won: clutch.won,
|
|
147
|
+
tick: clutch.tick
|
|
148
|
+
}
|
|
149
|
+
: null,
|
|
150
|
+
maxKillsByOnePlayer: Math.max(0, ...killsByPlayer.values()),
|
|
151
|
+
wallbangKills: kills.filter((kill) => kill.penetratedObjects > 0).length,
|
|
152
|
+
throughSmokeKills: kills.filter((kill) => kill.throughSmoke).length
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 比赛级武器统计:击杀来自 kills.json,伤害来自 damages.json(healthDamage 口径)。 */
|
|
157
|
+
function buildWorkspaceWeapons(pkg: DemoPackage) {
|
|
158
|
+
const killsByWeapon = groupBy(
|
|
159
|
+
pkg.kills.filter((kill) => isNamedWeapon(kill.weapon)),
|
|
160
|
+
(kill) => normalizeWeapon(kill.weapon)
|
|
161
|
+
);
|
|
162
|
+
const damageByWeapon = new Map<string, number>();
|
|
163
|
+
for (const damage of activeDamages(pkg)) {
|
|
164
|
+
if (!isNamedWeapon(damage.weapon)) continue;
|
|
165
|
+
const key = normalizeWeapon(damage.weapon);
|
|
166
|
+
damageByWeapon.set(key, (damageByWeapon.get(key) ?? 0) + damage.healthDamage);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return [...killsByWeapon.entries()]
|
|
170
|
+
.map(([weapon, kills]) => {
|
|
171
|
+
const killerCounts = new Map<string, number>();
|
|
172
|
+
for (const kill of kills) {
|
|
173
|
+
if (kill.killerIndex == null) continue;
|
|
174
|
+
const killer = pkg.players[kill.killerIndex];
|
|
175
|
+
if (killer) killerCounts.set(killer.steamId64, (killerCounts.get(killer.steamId64) ?? 0) + 1);
|
|
176
|
+
}
|
|
177
|
+
const topKiller = [...killerCounts.entries()].sort((a, b) => b[1] - a[1])[0] ?? null;
|
|
178
|
+
const headshots = kills.filter((kill) => kill.headshot).length;
|
|
179
|
+
return {
|
|
180
|
+
weapon,
|
|
181
|
+
label: displayWeaponName(weapon),
|
|
182
|
+
kills: kills.length,
|
|
183
|
+
headshotPercent: kills.length > 0 ? round((headshots / kills.length) * 100, 1) : null,
|
|
184
|
+
damage: damageByWeapon.get(weapon) ?? 0,
|
|
185
|
+
wallbangKills: kills.filter((kill) => kill.penetratedObjects > 0).length,
|
|
186
|
+
noScopeKills: kills.filter((kill) => kill.noScope).length,
|
|
187
|
+
throughSmokeKills: kills.filter((kill) => kill.throughSmoke).length,
|
|
188
|
+
topKillerName: topKiller ? nameForSteamId(pkg, topKiller[0]) : null,
|
|
189
|
+
topKillerKills: topKiller ? topKiller[1] : 0
|
|
190
|
+
};
|
|
191
|
+
})
|
|
192
|
+
.sort((a, b) => b.kills - a.kills);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** 对位:10x10 击杀矩阵(teamA 在前)+ 开局对枪统计(来自 playerRoundFacts)。 */
|
|
196
|
+
function buildWorkspaceDuels(pkg: DemoPackage, bundle: AnalysisBundle) {
|
|
197
|
+
const players = [...bundle.scoreboard]
|
|
198
|
+
.sort((a, b) => (a.teamKey === b.teamKey ? b.accountRR - a.accountRR : a.teamKey === "teamA" ? -1 : 1))
|
|
199
|
+
.map((row) => ({ steamId64: row.steamId64, name: row.name, teamKey: row.teamKey }));
|
|
200
|
+
const indexBySteamId = new Map(players.map((player, index) => [player.steamId64, index]));
|
|
201
|
+
|
|
202
|
+
const matrix = players.map(() => players.map(() => 0));
|
|
203
|
+
for (const kill of pkg.kills) {
|
|
204
|
+
if (kill.killerIndex == null) continue;
|
|
205
|
+
const killer = pkg.players[kill.killerIndex];
|
|
206
|
+
const victim = pkg.players[kill.victimIndex];
|
|
207
|
+
if (!killer || !victim) continue;
|
|
208
|
+
const killerIdx = indexBySteamId.get(killer.steamId64);
|
|
209
|
+
const victimIdx = indexBySteamId.get(victim.steamId64);
|
|
210
|
+
if (killerIdx == null || victimIdx == null) continue;
|
|
211
|
+
matrix[killerIdx][victimIdx] += 1;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const factsByPlayer = groupBy(bundle.playerRoundFacts, (fact) => fact.steamId64);
|
|
215
|
+
const openings = players.map((player) => {
|
|
216
|
+
const facts = factsByPlayer.get(player.steamId64) ?? [];
|
|
217
|
+
const openingKills = facts.filter((fact) => fact.openingDuel === "won").length;
|
|
218
|
+
const openingDeaths = facts.filter((fact) => fact.openingDuel === "lost").length;
|
|
219
|
+
const total = openingKills + openingDeaths;
|
|
220
|
+
return {
|
|
221
|
+
...player,
|
|
222
|
+
openingKills,
|
|
223
|
+
openingDeaths,
|
|
224
|
+
winRatePercent: total > 0 ? round((openingKills / total) * 100, 1) : null
|
|
225
|
+
};
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
return { players, matrix, openings };
|
|
229
|
+
}
|
|
230
|
+
|
|
104
231
|
function radarImageUrlForMap(mapName: string): string | null {
|
|
105
|
-
|
|
232
|
+
// 有标定即有底图:apps 的 public/maps/radars/ 与 MAP_CALIBRATIONS 保持同套地图。
|
|
106
233
|
// Relative path so it resolves from both http:// dev server and file:// pywebview.
|
|
107
|
-
return
|
|
234
|
+
return getMapCalibration(mapName) ? `./maps/radars/${mapName}.png` : null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 双层地图(标定带 lowerLevelMaxUnits)的下层底图({map}_lower.png)。 */
|
|
238
|
+
function lowerRadarImageUrlForMap(mapName: string): string | null {
|
|
239
|
+
const calibration = getMapCalibration(mapName);
|
|
240
|
+
return calibration && hasLowerLevel(calibration) ? `./maps/radars/${mapName}_lower.png` : null;
|
|
108
241
|
}
|
|
109
242
|
|
|
110
243
|
function buildWorkspaceKpis(bundle: AnalysisBundle) {
|
|
@@ -177,11 +310,11 @@ function sideLabelZh(side: "ct" | "t" | null): string {
|
|
|
177
310
|
const STORY_COPY = {
|
|
178
311
|
headline: {
|
|
179
312
|
/** 比分差 → 动词。 */
|
|
180
|
-
verb: (margin: number) => (margin >= 8 ? "
|
|
313
|
+
verb: (margin: number) => (margin >= 8 ? "大比分战胜" : margin <= 2 ? "险胜" : "战胜"),
|
|
181
314
|
comeback: (firstHalf: number, secondHalf: number) =>
|
|
182
|
-
|
|
315
|
+
`上半场拿到 ${firstHalf} 分,换边后取得 ${secondHalf} 分完成比分反超`,
|
|
183
316
|
frontRunner: (firstHalf: number, secondHalf: number) =>
|
|
184
|
-
|
|
317
|
+
`上半场拿到 ${firstHalf} 分,下半场再取得 ${secondHalf} 分`,
|
|
185
318
|
even: (firstHalf: number, secondHalf: number) =>
|
|
186
319
|
`上半场拿 ${firstHalf} 分、下半场再添 ${secondHalf} 分`,
|
|
187
320
|
overtime: (overtimeWins: number) => `,加时又咬下 ${overtimeWins} 分才分出胜负`,
|
|
@@ -193,26 +326,26 @@ const STORY_COPY = {
|
|
|
193
326
|
context: (sideLabel: string, crossSwitch: boolean, ownedByWinner: boolean) =>
|
|
194
327
|
!ownedByWinner ? "" : crossSwitch ? "横跨换边" : `靠 ${sideLabel} 半场`,
|
|
195
328
|
line: (startRound: number, endRound: number, length: number, team: string, context: string) =>
|
|
196
|
-
|
|
329
|
+
`${team} 在 R${startRound}-R${endRound} 取得 ${length} 连胜${context ? `(${context})` : ""},这是本场最长连胜段。`
|
|
197
330
|
},
|
|
198
331
|
pistol: {
|
|
199
|
-
sweep: "
|
|
200
|
-
none: "
|
|
201
|
-
split: "
|
|
332
|
+
sweep: "两次手枪局均取胜。",
|
|
333
|
+
none: "两次手枪局均未取胜,胜局来自后续回合。",
|
|
334
|
+
split: "双方各取一次手枪局。",
|
|
202
335
|
line: (total: number, winner: string, wins: number, tail: string) =>
|
|
203
336
|
`${total} 个手枪局 ${winner} 拿下 ${wins} 个,${tail}`
|
|
204
337
|
},
|
|
205
338
|
clutch: {
|
|
206
339
|
line: (who: string, teamTag: string, round: number, opponents: number) =>
|
|
207
|
-
|
|
340
|
+
`样本中人数规模最大的残局是 ${who}${teamTag}在 R${round} 完成的 1v${opponents}。`
|
|
208
341
|
},
|
|
209
342
|
entry: {
|
|
210
343
|
line: (name: string, entryKills: number) =>
|
|
211
|
-
`${name}
|
|
344
|
+
`${name} 记录到 ${entryKills} 次首杀,为队内最多。`
|
|
212
345
|
},
|
|
213
346
|
lowBuy: {
|
|
214
347
|
line: (winner: string, count: number, round: number) =>
|
|
215
|
-
`${winner}
|
|
348
|
+
`${winner} 在 ${count} 个低经济回合中取胜,代表回合为 R${round}。`
|
|
216
349
|
},
|
|
217
350
|
closeout: {
|
|
218
351
|
reason: {
|
|
@@ -223,21 +356,22 @@ const STORY_COPY = {
|
|
|
223
356
|
target_saved: "拖到时间耗尽"
|
|
224
357
|
} as Record<string, string>,
|
|
225
358
|
line: (winner: string, count: number, total: number, reason: string) =>
|
|
226
|
-
`${winner}
|
|
359
|
+
`${winner} 的胜局中有 ${count}/${total} 个以${reason}结束。`
|
|
227
360
|
},
|
|
228
361
|
mvp: {
|
|
229
362
|
dom: {
|
|
230
|
-
combat: "
|
|
231
|
-
trade: "
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
363
|
+
combat: "Combat 项",
|
|
364
|
+
trade: "Trade 项",
|
|
365
|
+
mapControl: "MapControl 项",
|
|
366
|
+
clutch: "Clutch 项",
|
|
367
|
+
objective: "Objective 项",
|
|
368
|
+
utility: "Utility 项"
|
|
235
369
|
} as Record<string, string>,
|
|
236
370
|
winnerAdrTail: (name: string, adr: string) => `;场均伤害最高的则是 ${name}(ADR ${adr})`,
|
|
237
371
|
winner: (name: string, kda: string, adr: string, rr: string, dom: string, adrTail: string) =>
|
|
238
|
-
|
|
372
|
+
`胜方 RR 最高的是 ${name}(${kda}、ADR ${adr}、RR ${rr}),六账户分解中${dom}最突出${adrTail}。`,
|
|
239
373
|
loser: (rr: string, kda: string, name: string, team: string, dom: string) =>
|
|
240
|
-
|
|
374
|
+
`全场 RR 最高的是落败方 ${team} 的 ${name}(${rr},${kda}),六账户分解中${dom}最突出。`
|
|
241
375
|
}
|
|
242
376
|
} as const;
|
|
243
377
|
|
|
@@ -415,7 +549,7 @@ function clutchBeat(ctx: StoryContext): StoryBeat | null {
|
|
|
415
549
|
const won = ctx.pkg.clutches.filter((row) => row.won && (row.opponentCount ?? 0) >= 2);
|
|
416
550
|
if (won.length === 0) return null;
|
|
417
551
|
const top = [...won].sort((a, b) => (b.opponentCount ?? 0) - (a.opponentCount ?? 0))[0]!;
|
|
418
|
-
const player = ctx.pkg.players
|
|
552
|
+
const player = ctx.pkg.players[top.clutcherIndex];
|
|
419
553
|
const who = player?.name ?? "某选手";
|
|
420
554
|
const teamName = player
|
|
421
555
|
? player.teamKey === "teamA"
|
|
@@ -547,29 +681,15 @@ function buildWorkspaceMap(pkg: DemoPackage, view: ReturnType<typeof buildDemoVi
|
|
|
547
681
|
y: bomb.position.y,
|
|
548
682
|
z: bomb.position.z,
|
|
549
683
|
roundNumber: bomb.roundNumber,
|
|
550
|
-
teamKey: bomb.
|
|
551
|
-
steamId64: bomb.
|
|
684
|
+
teamKey: bomb.actorIndex != null ? (pkg.players[bomb.actorIndex]?.teamKey ?? null) : null,
|
|
685
|
+
steamId64: bomb.actorIndex != null ? (pkg.players[bomb.actorIndex]?.steamId64 ?? null) : null,
|
|
552
686
|
kind: "bomb",
|
|
553
687
|
side: null,
|
|
554
688
|
grenadeType: null
|
|
555
689
|
}));
|
|
556
|
-
const positionPoints: WorkspaceSpatialPoint[] = (pkg.positions1s ?? [])
|
|
557
|
-
.filter((row) => row.position && (row.position.x !== 0 || row.position.y !== 0))
|
|
558
|
-
.map((row) => ({
|
|
559
|
-
x: row.position?.x ?? 0,
|
|
560
|
-
y: row.position?.y ?? 0,
|
|
561
|
-
z: row.position?.z ?? 0,
|
|
562
|
-
roundNumber: row.roundNumber,
|
|
563
|
-
teamKey: row.teamKey,
|
|
564
|
-
steamId64: row.steamId64,
|
|
565
|
-
kind: "position",
|
|
566
|
-
side: null,
|
|
567
|
-
grenadeType: null
|
|
568
|
-
}));
|
|
569
690
|
const points: WorkspaceSpatialPoint[] = [
|
|
570
691
|
...heatmap.map((point) => ({ ...point, kind: point.kind })),
|
|
571
|
-
...bombPoints
|
|
572
|
-
...positionPoints
|
|
692
|
+
...bombPoints
|
|
573
693
|
];
|
|
574
694
|
const count = (kind: WorkspaceSpatialPoint["kind"]) => points.filter((point) => point.kind === kind).length;
|
|
575
695
|
const hasPositionData = points.length > 0;
|
|
@@ -605,51 +725,127 @@ function buildWorkspaceReplay(pkg: DemoPackage) {
|
|
|
605
725
|
tickrate: null,
|
|
606
726
|
rounds: [],
|
|
607
727
|
capabilities: {
|
|
608
|
-
hasDefuseKit: false
|
|
609
|
-
hasBombPosition: false
|
|
728
|
+
hasDefuseKit: false
|
|
610
729
|
}
|
|
611
730
|
};
|
|
612
731
|
}
|
|
613
732
|
|
|
614
733
|
const killsByRound = groupBy(pkg.kills, (k) => k.roundNumber);
|
|
734
|
+
const grenadesByRound = groupBy(pkg.grenades, (g) => g.roundNumber);
|
|
735
|
+
const bombsByRound = groupBy(pkg.bombs, (b) => b.roundNumber);
|
|
736
|
+
const roundsByNumber = new Map(pkg.rounds.map((r) => [r.roundNumber, r]));
|
|
737
|
+
const endTickByRound = new Map(pkg.rounds.map((r) => [r.roundNumber, r.endTick]));
|
|
738
|
+
const sortedPackageRounds = [...pkg.rounds].sort((a, b) => a.startTick - b.startTick);
|
|
739
|
+
const targetEndTickByRound = new Map<number, number>();
|
|
740
|
+
sortedPackageRounds.forEach((round, index) => {
|
|
741
|
+
targetEndTickByRound.set(
|
|
742
|
+
round.roundNumber,
|
|
743
|
+
sortedPackageRounds[index + 1]?.startTick ?? round.endTick
|
|
744
|
+
);
|
|
745
|
+
});
|
|
746
|
+
const economyByRoundPlayer = new Map(pkg.playerEconomies.map((row) => [`${row.roundNumber}:${row.playerIndex}`, row]));
|
|
615
747
|
|
|
616
748
|
let hasDefuseKit = false;
|
|
617
|
-
const rounds = replay.rounds.map((roundRow) =>
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
749
|
+
const rounds = replay.rounds.map((roundRow) => {
|
|
750
|
+
const sourceRound = roundsByNumber.get(roundRow.roundNumber);
|
|
751
|
+
const sideForTeam = (teamKey: TeamKey | null | undefined) => {
|
|
752
|
+
if (!sourceRound || !teamKey) return null;
|
|
753
|
+
return teamKey === "teamA" ? sourceRound.teamASide : sourceRound.teamBSide;
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
const officialEndTick = endTickByRound.get(roundRow.roundNumber);
|
|
757
|
+
const targetEndTick = targetEndTickByRound.get(roundRow.roundNumber) ?? officialEndTick;
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
roundNumber: roundRow.roundNumber,
|
|
761
|
+
startTick: roundRow.startTick,
|
|
762
|
+
freezeEndTick: sourceRound?.freezeEndTick ?? roundRow.startTick,
|
|
763
|
+
tickStep: roundRow.tickStep,
|
|
764
|
+
frameCount: roundRow.frameCount,
|
|
765
|
+
officialEndTick,
|
|
766
|
+
targetEndTick,
|
|
767
|
+
kills: buildRoundKills(pkg, killsByRound.get(roundRow.roundNumber) ?? []),
|
|
768
|
+
grenades: (grenadesByRound.get(roundRow.roundNumber) ?? []).map((row) => ({
|
|
769
|
+
grenade: row.grenade,
|
|
770
|
+
throwerSide: sideForTeam(pkg.players[row.throwerIndex]?.teamKey),
|
|
771
|
+
throwTick: row.throwTick,
|
|
772
|
+
effectTick: row.effectTick,
|
|
773
|
+
destroyTick: row.destroyTick,
|
|
774
|
+
throwX: row.throwPosition.x,
|
|
775
|
+
throwY: row.throwPosition.y,
|
|
776
|
+
effectX: row.effectPosition.x,
|
|
777
|
+
effectY: row.effectPosition.y,
|
|
778
|
+
effectZ: row.effectPosition.z
|
|
779
|
+
})),
|
|
780
|
+
// v2.3+ 导出包才带飞行轨迹;旧包置空,渲染端按"无数据"处理
|
|
781
|
+
// v3 起 x/y/z 为差分编码(首元素绝对值 + 后续 delta),前缀和解码后 × coordScale 还原游戏单位
|
|
782
|
+
projectiles: (roundRow.projectiles ?? []).map((proj) => ({
|
|
783
|
+
grenade: proj.grenade,
|
|
784
|
+
startTick: proj.startTick,
|
|
785
|
+
x: decodeDelta(proj.x).map((v) => v * (replay.meta.coordScale || 1)),
|
|
786
|
+
y: decodeDelta(proj.y).map((v) => v * (replay.meta.coordScale || 1)),
|
|
787
|
+
z: decodeDelta(proj.z ?? []).map((v) => v * (replay.meta.coordScale || 1))
|
|
788
|
+
})),
|
|
789
|
+
bomb: buildRoundBomb(bombsByRound.get(roundRow.roundNumber) ?? []),
|
|
790
|
+
groundBombs: buildGroundBombs(
|
|
791
|
+
bombsByRound.get(roundRow.roundNumber) ?? [],
|
|
792
|
+
targetEndTick ?? roundRow.startTick + roundRow.frameCount * roundRow.tickStep
|
|
793
|
+
),
|
|
794
|
+
groundDefusers: buildGroundDefusers(
|
|
795
|
+
roundRow,
|
|
796
|
+
replay.meta.coordScale || 1,
|
|
797
|
+
sourceRound?.freezeEndTick ?? roundRow.startTick,
|
|
798
|
+
targetEndTick ?? roundRow.startTick + roundRow.frameCount * roundRow.tickStep
|
|
799
|
+
),
|
|
800
|
+
players: roundRow.players.map((player) => {
|
|
801
|
+
// v3 起 x/y/z/yaw 为差分编码,flash 改为独立列(0.1 秒单位,flags 不再含致盲位)
|
|
802
|
+
const coordScale = replay.meta.coordScale || 1;
|
|
803
|
+
const angleScale = replay.meta.angleScale || 1;
|
|
804
|
+
const xs = decodeDelta(player.x);
|
|
805
|
+
const ys = decodeDelta(player.y);
|
|
806
|
+
const zs = decodeDelta(player.z);
|
|
807
|
+
const yaws = decodeDelta(player.yaw);
|
|
808
|
+
const replayPlayer = pkg.players[player.playerIndex];
|
|
809
|
+
const economy = economyByRoundPlayer.get(`${roundRow.roundNumber}:${player.playerIndex}`);
|
|
810
|
+
// 此位选手本回合是否戴头盔(player-economies 逐回合数据,全帧相同)
|
|
811
|
+
const hasHelmet = economy?.hasHelmet ?? false;
|
|
812
|
+
const loadout = loadoutForEconomy(economy);
|
|
813
|
+
const frames: WorkspaceReplayFrame[] = [];
|
|
814
|
+
for (let index = 0; index < roundRow.frameCount; index += 1) {
|
|
815
|
+
const flags = player.flags[index] ?? 0;
|
|
816
|
+
const frame = {
|
|
817
|
+
tick: roundRow.startTick + index * roundRow.tickStep,
|
|
818
|
+
x: (xs[index] ?? 0) * coordScale,
|
|
819
|
+
y: (ys[index] ?? 0) * coordScale,
|
|
820
|
+
z: (zs[index] ?? 0) * coordScale,
|
|
821
|
+
yaw: (yaws[index] ?? 0) / angleScale,
|
|
822
|
+
hp: player.hp[index] ?? 0,
|
|
823
|
+
armor: player.armor?.[index] ?? 0,
|
|
824
|
+
weapon: weaponNameForIndex(replay.weaponDict, player.weapon[index] ?? -1),
|
|
825
|
+
grenades: normalizeHeldGrenades((player as { grenades?: unknown[][] }).grenades?.[index]),
|
|
826
|
+
alive: (flags & 1) !== 0,
|
|
827
|
+
flashed: (player.flash?.[index] ?? 0) > 0,
|
|
828
|
+
flashRemainingSeconds: Math.max(0, (player.flash?.[index] ?? 0) / 10),
|
|
829
|
+
hasDefuseKit: (flags & 4) !== 0,
|
|
830
|
+
hasBomb: (flags & 2) !== 0,
|
|
831
|
+
hasHelmet
|
|
832
|
+
};
|
|
833
|
+
if (frame.hasDefuseKit) {
|
|
834
|
+
hasDefuseKit = true;
|
|
835
|
+
}
|
|
836
|
+
frames.push(frame);
|
|
641
837
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
})
|
|
838
|
+
return {
|
|
839
|
+
steamId64: replayPlayer?.steamId64 ?? String(player.playerIndex),
|
|
840
|
+
name: replayPlayer?.name ?? `Player ${player.playerIndex}`,
|
|
841
|
+
teamKey: replayPlayer?.teamKey ?? "teamA",
|
|
842
|
+
side: sideForTeam(replayPlayer?.teamKey) ?? "t",
|
|
843
|
+
loadout,
|
|
844
|
+
frames
|
|
845
|
+
};
|
|
846
|
+
})
|
|
847
|
+
};
|
|
848
|
+
});
|
|
653
849
|
|
|
654
850
|
return {
|
|
655
851
|
available: true,
|
|
@@ -657,12 +853,118 @@ function buildWorkspaceReplay(pkg: DemoPackage) {
|
|
|
657
853
|
tickrate: replay.meta.tickrate,
|
|
658
854
|
rounds,
|
|
659
855
|
capabilities: {
|
|
660
|
-
hasDefuseKit
|
|
661
|
-
hasBombPosition: false
|
|
856
|
+
hasDefuseKit
|
|
662
857
|
}
|
|
663
858
|
};
|
|
664
859
|
}
|
|
665
860
|
|
|
861
|
+
const HELD_GRENADE_TYPES = new Set(["flashbang", "smoke", "molotov", "incendiary", "hegrenade", "decoy"]);
|
|
862
|
+
|
|
863
|
+
function loadoutForEconomy(economy: DemoPackage["playerEconomies"][number] | undefined): WorkspaceReplayLoadout {
|
|
864
|
+
const maybeGrenades = (economy as { grenades?: unknown } | undefined)?.grenades;
|
|
865
|
+
const rawGrenades = Array.isArray(maybeGrenades) ? maybeGrenades : [];
|
|
866
|
+
return {
|
|
867
|
+
primaryWeapon: economy?.primaryWeapon ?? null,
|
|
868
|
+
secondaryWeapon: economy?.secondaryWeapon ?? null,
|
|
869
|
+
grenadeCount: economy?.grenadeCount ?? 0,
|
|
870
|
+
grenades: rawGrenades.filter((value): value is WorkspaceReplayLoadout["grenades"][number] =>
|
|
871
|
+
typeof value === "string" && HELD_GRENADE_TYPES.has(value)
|
|
872
|
+
)
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function normalizeHeldGrenades(value: unknown): WorkspaceReplayLoadout["grenades"] {
|
|
877
|
+
if (!Array.isArray(value)) return [];
|
|
878
|
+
return value.filter((item): item is WorkspaceReplayLoadout["grenades"][number] =>
|
|
879
|
+
typeof item === "string" && HELD_GRENADE_TYPES.has(item)
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** 从 bombs.json 取该回合 C4 锚点:plant 位置定格,defuse/explode 决定终态。 */
|
|
884
|
+
function buildRoundBomb(events: DemoPackage["bombs"]): WorkspaceReplayRound["bomb"] {
|
|
885
|
+
const plant = events.find((event) => event.type === "planted");
|
|
886
|
+
if (!plant) return null;
|
|
887
|
+
return {
|
|
888
|
+
plantTick: plant.tick,
|
|
889
|
+
x: plant.position.x,
|
|
890
|
+
y: plant.position.y,
|
|
891
|
+
z: plant.position.z,
|
|
892
|
+
defuseTick: events.find((event) => event.type === "defused")?.tick ?? null,
|
|
893
|
+
explodeTick: events.find((event) => event.type === "exploded")?.tick ?? null
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** dropped → 下一次 picked_up / planted / exploded / defused 构成一段地面区间。 */
|
|
898
|
+
function buildGroundBombs(events: DemoPackage["bombs"], roundEndTick: number): WorkspaceReplayRound["groundBombs"] {
|
|
899
|
+
const sorted = [...events].sort((a, b) => a.tick - b.tick);
|
|
900
|
+
const intervals: { startTick: number; endTick: number; x: number; y: number; z: number }[] = [];
|
|
901
|
+
for (let i = 0; i < sorted.length; i += 1) {
|
|
902
|
+
if (sorted[i].type !== "dropped") continue;
|
|
903
|
+
const drop = sorted[i];
|
|
904
|
+
// 找下一个相关事件:picked_up / planted / defused / exploded
|
|
905
|
+
const next = sorted.slice(i + 1).find((e) =>
|
|
906
|
+
e.type === "picked_up" || e.type === "planted" || e.type === "defused" || e.type === "exploded"
|
|
907
|
+
);
|
|
908
|
+
intervals.push({
|
|
909
|
+
startTick: drop.tick,
|
|
910
|
+
endTick: next?.tick ?? roundEndTick,
|
|
911
|
+
x: drop.position.x,
|
|
912
|
+
y: drop.position.y,
|
|
913
|
+
z: drop.position.z
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
return intervals;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* 拆弹器掉落区间:CS2 中 kit 只在持有者阵亡时落地(不可手动丢弃)。
|
|
921
|
+
* 逐帧扫描每名玩家的存活位(flags&1)与持 kit 位(flags&4):
|
|
922
|
+
* - 阵亡掉落:上一帧"存活且持 kit" → 本帧"阵亡" ⇒ 在上一帧位置生成一个落地 kit;
|
|
923
|
+
* - 被捡起:某玩家存活且 kit 由无变有(freezeEnd 之后,排除买阶段初始持有)⇒ 关闭最早一个落地 kit(FIFO)。
|
|
924
|
+
* 未被捡起的落地 kit 持续到回合结束(符合"钳子一直留在地上")。
|
|
925
|
+
*/
|
|
926
|
+
function buildGroundDefusers(
|
|
927
|
+
roundRow: NonNullable<DemoPackage["replay"]>["rounds"][number],
|
|
928
|
+
coordScale: number,
|
|
929
|
+
freezeEndTick: number,
|
|
930
|
+
roundEndTick: number
|
|
931
|
+
): WorkspaceReplayRound["groundDefusers"] {
|
|
932
|
+
const decoded = roundRow.players.map((p) => ({
|
|
933
|
+
x: decodeDelta(p.x),
|
|
934
|
+
y: decodeDelta(p.y),
|
|
935
|
+
z: decodeDelta(p.z),
|
|
936
|
+
flags: p.flags
|
|
937
|
+
}));
|
|
938
|
+
const result: WorkspaceReplayRound["groundDefusers"] = [];
|
|
939
|
+
const active: Array<{ startTick: number; x: number; y: number; z: number }> = [];
|
|
940
|
+
for (let i = 1; i < roundRow.frameCount; i += 1) {
|
|
941
|
+
const tick = roundRow.startTick + i * roundRow.tickStep;
|
|
942
|
+
if (tick < freezeEndTick) continue;
|
|
943
|
+
for (const p of decoded) {
|
|
944
|
+
const prev = p.flags[i - 1] ?? 0;
|
|
945
|
+
const cur = p.flags[i] ?? 0;
|
|
946
|
+
const prevAlive = (prev & 1) !== 0;
|
|
947
|
+
const prevKit = (prev & 4) !== 0;
|
|
948
|
+
const curAlive = (cur & 1) !== 0;
|
|
949
|
+
const curKit = (cur & 4) !== 0;
|
|
950
|
+
if (prevAlive && prevKit && !curAlive) {
|
|
951
|
+
active.push({
|
|
952
|
+
startTick: tick,
|
|
953
|
+
x: (p.x[i - 1] ?? 0) * coordScale,
|
|
954
|
+
y: (p.y[i - 1] ?? 0) * coordScale,
|
|
955
|
+
z: (p.z[i - 1] ?? 0) * coordScale
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
if (curAlive && !prevKit && curKit && active.length > 0) {
|
|
959
|
+
const dropped = active.shift()!;
|
|
960
|
+
result.push({ ...dropped, endTick: tick });
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
for (const dropped of active) result.push({ ...dropped, endTick: roundEndTick });
|
|
965
|
+
return result;
|
|
966
|
+
}
|
|
967
|
+
|
|
666
968
|
function weaponNameForIndex(weaponDict: string[], index: number): string | null {
|
|
667
969
|
const raw = index >= 0 ? weaponDict[index] ?? null : null;
|
|
668
970
|
if (!raw) {
|
|
@@ -679,18 +981,28 @@ function buildRoundKills(pkg: DemoPackage, kills: DemoPackage["kills"]): Workspa
|
|
|
679
981
|
return kills.map((kill, index) => {
|
|
680
982
|
const activeRaw = kill.killerActiveWeapon;
|
|
681
983
|
const weaponRaw = activeRaw && isNamedWeapon(normalizeWeapon(activeRaw)) ? activeRaw : kill.weapon;
|
|
984
|
+
const killerSteamId = kill.killerIndex != null ? pkg.players[kill.killerIndex]?.steamId64 ?? null : null;
|
|
985
|
+
const victimSteamId = pkg.players[kill.victimIndex]?.steamId64 ?? "";
|
|
986
|
+
const killerTeamKey = kill.killerIndex != null ? (pkg.players[kill.killerIndex]?.teamKey ?? null) : null;
|
|
682
987
|
return {
|
|
683
988
|
id: `kf-${kill.roundNumber}-${kill.tick}-${index}`,
|
|
684
989
|
tick: kill.tick,
|
|
685
|
-
killerName: nameForSteamId(pkg,
|
|
686
|
-
killerTeamKey
|
|
687
|
-
victimName: nameForSteamId(pkg,
|
|
990
|
+
killerName: nameForSteamId(pkg, killerSteamId),
|
|
991
|
+
killerTeamKey,
|
|
992
|
+
victimName: nameForSteamId(pkg, victimSteamId) ?? victimSteamId,
|
|
688
993
|
weapon: displayWeaponName(weaponRaw),
|
|
689
994
|
headshot: kill.headshot,
|
|
690
995
|
throughSmoke: kill.throughSmoke,
|
|
691
996
|
noScope: kill.noScope,
|
|
692
997
|
flashAssist: kill.flashAssist,
|
|
693
|
-
tradeKill: kill.tradeKill
|
|
998
|
+
tradeKill: kill.tradeKill,
|
|
999
|
+
wallbang: kill.penetratedObjects > 0,
|
|
1000
|
+
killerX: kill.killerPosition?.x ?? null,
|
|
1001
|
+
killerY: kill.killerPosition?.y ?? null,
|
|
1002
|
+
killerZ: kill.killerPosition?.z ?? null,
|
|
1003
|
+
victimX: kill.victimPosition.x,
|
|
1004
|
+
victimY: kill.victimPosition.y,
|
|
1005
|
+
victimZ: kill.victimPosition.z
|
|
694
1006
|
};
|
|
695
1007
|
});
|
|
696
1008
|
}
|
package/src/labels.ts
DELETED