@cs2dak/core 0.2.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 +21 -0
- package/README.md +7 -0
- package/package.json +20 -0
- package/src/economy.test.ts +51 -0
- package/src/economy.ts +76 -0
- package/src/index.test.ts +184 -0
- package/src/index.ts +49 -0
- package/src/loader.ts +48 -0
- package/src/normalize.ts +158 -0
- package/src/qa.ts +198 -0
- package/src/scoreboard.ts +288 -0
- package/src/signals.ts +232 -0
- package/src/timeline.ts +209 -0
- package/src/utils.ts +133 -0
- package/src/weapons.test.ts +32 -0
- package/src/weapons.ts +93 -0
- package/src/workspace.ts +726 -0
package/src/signals.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computeLeagueMeanV2,
|
|
3
|
+
computeValueAccountsRR,
|
|
4
|
+
rrValueAccountsV2Lite
|
|
5
|
+
} from "@rivalhub/rival-rating";
|
|
6
|
+
import type { AccountSignalsV2, DemoPackage, ValueAccountsWeights } from "@cs2dak/contract";
|
|
7
|
+
import type { RRResultV2 } from "@rivalhub/rival-rating";
|
|
8
|
+
import { normalizeDemoPackage } from "./normalize.js";
|
|
9
|
+
import {
|
|
10
|
+
type BuyDeltaBuckets,
|
|
11
|
+
type ManStateBuckets,
|
|
12
|
+
type ObjectiveBuckets,
|
|
13
|
+
type UtilityBuckets,
|
|
14
|
+
BUY_DELTA_EVEN_THRESHOLD,
|
|
15
|
+
getOrInit,
|
|
16
|
+
zeroBuyDelta,
|
|
17
|
+
zeroManState,
|
|
18
|
+
clutchSplitV2,
|
|
19
|
+
sumDamageForPlayer,
|
|
20
|
+
openingKillsForPlayer,
|
|
21
|
+
openingDeathsForPlayer,
|
|
22
|
+
multiKillRounds,
|
|
23
|
+
round,
|
|
24
|
+
firstKillMap
|
|
25
|
+
} from "./utils.js";
|
|
26
|
+
|
|
27
|
+
export function deriveAccountSignalsV2(input: unknown): AccountSignalsV2[] {
|
|
28
|
+
const pkg = normalizeDemoPackage(input);
|
|
29
|
+
const statsBySteamId = new Map(pkg.playerStats.map((row) => [row.steamId64, row]));
|
|
30
|
+
const killsByBuyDelta = buildKillsByBuyDelta(pkg);
|
|
31
|
+
const killsByManState = buildKillsByManState(pkg);
|
|
32
|
+
const tradedOpeningDeaths = buildTradedOpeningDeaths(pkg);
|
|
33
|
+
const objective = buildObjectiveSignals(pkg);
|
|
34
|
+
const utility = buildUtilitySignals(pkg);
|
|
35
|
+
|
|
36
|
+
// context 分桶的"数据源是否存在"。源缺失 → 发 null(模型降级为乘子 1.0),
|
|
37
|
+
// 而非零桶——零桶语义是"源在、但该选手无相关样本"。区分二者是 v2 可信展示的前提。
|
|
38
|
+
const buyDeltaAvailable = pkg.playerEconomies.length > 0;
|
|
39
|
+
const manStateAvailable = pkg.rounds.length > 0;
|
|
40
|
+
|
|
41
|
+
return pkg.players.map((player) => {
|
|
42
|
+
const stats = statsBySteamId.get(player.steamId64);
|
|
43
|
+
const playerKills = pkg.kills.filter((kill) => kill.killerSteamId64 === player.steamId64);
|
|
44
|
+
const playerDeaths = pkg.kills.filter((kill) => kill.victimSteamId64 === player.steamId64);
|
|
45
|
+
const playerClutches = pkg.clutches.filter((row) => row.clutcherSteamId64 === player.steamId64);
|
|
46
|
+
const rounds = Math.max(stats?.rounds ?? pkg.rounds.length, 0);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
steamId64: player.steamId64,
|
|
50
|
+
rounds,
|
|
51
|
+
sourceVersion: "cs2-demo-analysis-kit/0.3",
|
|
52
|
+
combat: {
|
|
53
|
+
kills: stats?.kills ?? playerKills.length,
|
|
54
|
+
deaths: stats?.deaths ?? playerDeaths.length,
|
|
55
|
+
assists: stats?.assists ?? 0,
|
|
56
|
+
effectiveDamage: stats?.damageHealth ?? sumDamageForPlayer(pkg, player.steamId64),
|
|
57
|
+
openingKills: stats?.firstKillCount ?? openingKillsForPlayer(pkg, player.steamId64),
|
|
58
|
+
openingDeaths: stats?.firstDeathCount ?? openingDeathsForPlayer(pkg, player.steamId64),
|
|
59
|
+
multiKills: {
|
|
60
|
+
two: stats?.twoKillCount ?? multiKillRounds(playerKills, 2),
|
|
61
|
+
three: stats?.threeKillCount ?? multiKillRounds(playerKills, 3),
|
|
62
|
+
four: stats?.fourKillCount ?? multiKillRounds(playerKills, 4),
|
|
63
|
+
five: stats?.fiveKillCount ?? multiKillRounds(playerKills, 5)
|
|
64
|
+
},
|
|
65
|
+
headshotKills: stats?.headshotCount ?? playerKills.filter((kill) => kill.headshot).length,
|
|
66
|
+
wallbangKills: stats?.wallbangKillCount ?? playerKills.filter((kill) => kill.penetratedObjects > 0).length,
|
|
67
|
+
killsByBuyDelta: buyDeltaAvailable ? (killsByBuyDelta.get(player.steamId64) ?? zeroBuyDelta()) : null,
|
|
68
|
+
killsByManState: manStateAvailable ? (killsByManState.get(player.steamId64) ?? zeroManState()) : null
|
|
69
|
+
},
|
|
70
|
+
trade: {
|
|
71
|
+
tradeKills: stats?.tradeKillCount ?? playerKills.filter((kill) => kill.tradeKill).length,
|
|
72
|
+
tradedDeaths: stats?.tradeDeathCount ?? playerDeaths.filter((kill) => kill.tradeDeath).length,
|
|
73
|
+
deaths: stats?.deaths ?? playerDeaths.length,
|
|
74
|
+
tradedOpeningDeaths: tradedOpeningDeaths.get(player.steamId64) ?? 0
|
|
75
|
+
},
|
|
76
|
+
clutch: {
|
|
77
|
+
vsOne: clutchSplitV2(stats?.vsOneCount, stats?.vsOneWonCount, playerClutches, 1),
|
|
78
|
+
vsTwo: clutchSplitV2(stats?.vsTwoCount, stats?.vsTwoWonCount, playerClutches, 2),
|
|
79
|
+
vsThree: clutchSplitV2(stats?.vsThreeCount, stats?.vsThreeWonCount, playerClutches, 3),
|
|
80
|
+
vsFour: clutchSplitV2(stats?.vsFourCount, stats?.vsFourWonCount, playerClutches, 4),
|
|
81
|
+
vsFive: clutchSplitV2(stats?.vsFiveCount, stats?.vsFiveWonCount, playerClutches, 5)
|
|
82
|
+
},
|
|
83
|
+
objective: objective.get(player.steamId64) ?? { plants: 0, defuses: 0, plantsConverted: 0 },
|
|
84
|
+
utility: utility.get(player.steamId64) ?? {
|
|
85
|
+
flashAssists: stats?.flashAssistCount ?? 0,
|
|
86
|
+
enemyFlashDurationSeconds: stats?.enemyFlashDurationSeconds ?? 0,
|
|
87
|
+
teamFlashDurationSeconds: stats?.teamFlashDurationSeconds ?? 0,
|
|
88
|
+
utilityDamage: stats?.utilityDamage ?? 0
|
|
89
|
+
}
|
|
90
|
+
} satisfies AccountSignalsV2;
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function computeAccountRatingsV2(input: unknown): Array<{ signals: AccountSignalsV2; rr: RRResultV2 }> {
|
|
95
|
+
const weights = rrValueAccountsV2Lite as unknown as ValueAccountsWeights;
|
|
96
|
+
const rated = deriveAccountSignalsV2(input).map((signals) => ({
|
|
97
|
+
signals,
|
|
98
|
+
rr: computeValueAccountsRR(signals, weights)
|
|
99
|
+
}));
|
|
100
|
+
|
|
101
|
+
// 联赛锚定(weights.anchor.mode === "league_mean"):整体乘 (1.0 / mean),
|
|
102
|
+
// 使 1.00 = 本场均值。本层一次只处理单场,故"联赛均值"= 本场均值(per-match);
|
|
103
|
+
// 真正的赛季级锚定属于上游跨场聚合层,详见 docs/design/rr-v2-lite.md。
|
|
104
|
+
const mean = computeLeagueMeanV2(rated.map((row) => row.rr));
|
|
105
|
+
const factor = mean > 0 ? 1.0 / mean : 1.0;
|
|
106
|
+
return rated.map((row) => ({
|
|
107
|
+
signals: row.signals,
|
|
108
|
+
rr: { ...row.rr, rr: row.rr.rr * factor }
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildKillsByBuyDelta(pkg: DemoPackage): Map<string, BuyDeltaBuckets> {
|
|
113
|
+
const out = new Map<string, BuyDeltaBuckets>();
|
|
114
|
+
const economyByPlayerRound = new Map(pkg.playerEconomies.map((row) => [`${row.roundNumber}:${row.steamId64}`, row]));
|
|
115
|
+
|
|
116
|
+
for (const kill of pkg.kills) {
|
|
117
|
+
if (!kill.killerSteamId64) continue;
|
|
118
|
+
const killerEconomy = economyByPlayerRound.get(`${kill.roundNumber}:${kill.killerSteamId64}`);
|
|
119
|
+
const victimEconomy = economyByPlayerRound.get(`${kill.roundNumber}:${kill.victimSteamId64}`);
|
|
120
|
+
if (!killerEconomy || !victimEconomy) continue;
|
|
121
|
+
|
|
122
|
+
const buckets = getOrInit(out, kill.killerSteamId64, zeroBuyDelta);
|
|
123
|
+
const delta = killerEconomy.equipmentValue - victimEconomy.equipmentValue;
|
|
124
|
+
if (delta <= -BUY_DELTA_EVEN_THRESHOLD) {
|
|
125
|
+
buckets.disadvantage += 1;
|
|
126
|
+
} else if (delta >= BUY_DELTA_EVEN_THRESHOLD) {
|
|
127
|
+
buckets.advantage += 1;
|
|
128
|
+
} else {
|
|
129
|
+
buckets.even += 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildKillsByManState(pkg: DemoPackage): Map<string, ManStateBuckets> {
|
|
137
|
+
const out = new Map<string, ManStateBuckets>();
|
|
138
|
+
const playersByTeam = {
|
|
139
|
+
teamA: pkg.players.filter((player) => player.teamKey === "teamA").map((player) => player.steamId64),
|
|
140
|
+
teamB: pkg.players.filter((player) => player.teamKey === "teamB").map((player) => player.steamId64)
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
for (const roundRow of pkg.rounds) {
|
|
144
|
+
const alive = {
|
|
145
|
+
teamA: new Set(playersByTeam.teamA),
|
|
146
|
+
teamB: new Set(playersByTeam.teamB)
|
|
147
|
+
};
|
|
148
|
+
const roundKills = pkg.kills
|
|
149
|
+
.filter((kill) => kill.roundNumber === roundRow.roundNumber)
|
|
150
|
+
.sort((a, b) => a.tick - b.tick);
|
|
151
|
+
|
|
152
|
+
for (const kill of roundKills) {
|
|
153
|
+
if (kill.killerSteamId64 && kill.killerTeamKey && kill.killerTeamKey !== kill.victimTeamKey) {
|
|
154
|
+
const buckets = getOrInit(out, kill.killerSteamId64, zeroManState);
|
|
155
|
+
const killerAlive = alive[kill.killerTeamKey].size;
|
|
156
|
+
const victimAlive = alive[kill.victimTeamKey].size;
|
|
157
|
+
const diff = killerAlive - victimAlive;
|
|
158
|
+
if (diff < 0) {
|
|
159
|
+
buckets.manDown += 1;
|
|
160
|
+
} else if (diff > 0) {
|
|
161
|
+
buckets.manUp += 1;
|
|
162
|
+
} else {
|
|
163
|
+
buckets.even += 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
alive[kill.victimTeamKey].delete(kill.victimSteamId64);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildTradedOpeningDeaths(pkg: DemoPackage): Map<string, number> {
|
|
174
|
+
const out = new Map<string, number>();
|
|
175
|
+
for (const kill of firstKillMap(pkg).values()) {
|
|
176
|
+
if (kill.tradeDeath) {
|
|
177
|
+
out.set(kill.victimSteamId64, (out.get(kill.victimSteamId64) ?? 0) + 1);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function buildObjectiveSignals(pkg: DemoPackage): Map<string, ObjectiveBuckets> {
|
|
184
|
+
const out = new Map<string, ObjectiveBuckets>();
|
|
185
|
+
const roundWinner = new Map(pkg.rounds.map((round) => [round.roundNumber, round.winnerTeamKey]));
|
|
186
|
+
|
|
187
|
+
for (const bomb of pkg.bombs) {
|
|
188
|
+
if (!bomb.actorSteamId64) continue;
|
|
189
|
+
const buckets = getOrInit(out, bomb.actorSteamId64, () => ({ plants: 0, defuses: 0, plantsConverted: 0 }));
|
|
190
|
+
if (bomb.type === "planted") {
|
|
191
|
+
buckets.plants += 1;
|
|
192
|
+
if (bomb.actorTeamKey && roundWinner.get(bomb.roundNumber) === bomb.actorTeamKey) {
|
|
193
|
+
buckets.plantsConverted = (buckets.plantsConverted ?? 0) + 1;
|
|
194
|
+
}
|
|
195
|
+
} else if (bomb.type === "defused") {
|
|
196
|
+
buckets.defuses += 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return out;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function buildUtilitySignals(pkg: DemoPackage): Map<string, UtilityBuckets> {
|
|
204
|
+
const out = new Map<string, UtilityBuckets>();
|
|
205
|
+
const statsBySteamId = new Map(pkg.playerStats.map((row) => [row.steamId64, row]));
|
|
206
|
+
|
|
207
|
+
for (const player of pkg.players) {
|
|
208
|
+
const stats = statsBySteamId.get(player.steamId64);
|
|
209
|
+
out.set(player.steamId64, {
|
|
210
|
+
flashAssists: stats?.flashAssistCount ?? pkg.kills.filter((kill) => kill.flashAssisterSteamId64 === player.steamId64).length,
|
|
211
|
+
enemyFlashDurationSeconds: stats?.enemyFlashDurationSeconds ?? 0,
|
|
212
|
+
teamFlashDurationSeconds: stats?.teamFlashDurationSeconds ?? 0,
|
|
213
|
+
utilityDamage: stats?.utilityDamage ?? 0
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const blind of pkg.blinds) {
|
|
218
|
+
const buckets = getOrInit(out, blind.flasherSteamId64, () => ({
|
|
219
|
+
flashAssists: 0,
|
|
220
|
+
enemyFlashDurationSeconds: 0,
|
|
221
|
+
teamFlashDurationSeconds: 0,
|
|
222
|
+
utilityDamage: 0
|
|
223
|
+
}));
|
|
224
|
+
if (blind.flasherTeamKey !== blind.flashedTeamKey) {
|
|
225
|
+
buckets.enemyFlashDurationSeconds = round(buckets.enemyFlashDurationSeconds + blind.durationSeconds, 3);
|
|
226
|
+
} else if (blind.flashedSteamId64 !== blind.flasherSteamId64) {
|
|
227
|
+
buckets.teamFlashDurationSeconds = round((buckets.teamFlashDurationSeconds ?? 0) + blind.durationSeconds, 3);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return out;
|
|
232
|
+
}
|
package/src/timeline.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { DemoPackage, EconomyPoint, HeatmapPoint, TimelineEvent } from "@cs2dak/contract";
|
|
2
|
+
import { round, clamp, formatClock, nameForSteamId, normalizeWeapon } from "./utils.js";
|
|
3
|
+
|
|
4
|
+
export function buildTimeline(pkg: DemoPackage): TimelineEvent[] {
|
|
5
|
+
const killEvents = pkg.kills.map<TimelineEvent>((kill, index) => ({
|
|
6
|
+
id: `kill-${index}`,
|
|
7
|
+
roundNumber: kill.roundNumber,
|
|
8
|
+
tick: kill.tick,
|
|
9
|
+
timeSeconds: tickToRoundSeconds(pkg, kill.roundNumber, kill.tick),
|
|
10
|
+
...clockForTick(pkg, kill.roundNumber, kill.tick),
|
|
11
|
+
type: "kill",
|
|
12
|
+
label: `${nameForSteamId(pkg, kill.killerSteamId64) ?? "环境"} 击杀 ${nameForSteamId(pkg, kill.victimSteamId64)}`,
|
|
13
|
+
teamKey: kill.killerTeamKey
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
const bombEvents = pkg.bombs.map<TimelineEvent>((bomb, index) => ({
|
|
17
|
+
id: `bomb-${index}`,
|
|
18
|
+
roundNumber: bomb.roundNumber,
|
|
19
|
+
tick: bomb.tick,
|
|
20
|
+
timeSeconds: tickToRoundSeconds(pkg, bomb.roundNumber, bomb.tick),
|
|
21
|
+
...clockForTick(pkg, bomb.roundNumber, bomb.tick),
|
|
22
|
+
type: "bomb",
|
|
23
|
+
label: bombLabel(pkg, bomb),
|
|
24
|
+
teamKey: bomb.actorTeamKey
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const grenadeEvents = pkg.grenades.map<TimelineEvent>((grenade, index) => ({
|
|
28
|
+
id: `grenade-${index}`,
|
|
29
|
+
roundNumber: grenade.roundNumber,
|
|
30
|
+
tick: grenade.effectTick,
|
|
31
|
+
timeSeconds: tickToRoundSeconds(pkg, grenade.roundNumber, grenade.effectTick),
|
|
32
|
+
...clockForTick(pkg, grenade.roundNumber, grenade.effectTick),
|
|
33
|
+
type: "grenade",
|
|
34
|
+
label: `${nameForSteamId(pkg, grenade.throwerSteamId64) ?? "未知选手"} 投掷${grenadeLabel(grenade.grenade)}`,
|
|
35
|
+
teamKey: grenade.throwerTeamKey
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
const roundEvents = pkg.rounds.map<TimelineEvent>((roundRow) => ({
|
|
39
|
+
id: `round-end-${roundRow.roundNumber}`,
|
|
40
|
+
roundNumber: roundRow.roundNumber,
|
|
41
|
+
tick: roundRow.endTick,
|
|
42
|
+
timeSeconds: tickToRoundSeconds(pkg, roundRow.roundNumber, roundRow.endTick),
|
|
43
|
+
clockPhase: "round-end",
|
|
44
|
+
clockSeconds: 0,
|
|
45
|
+
clockLabel: "结束",
|
|
46
|
+
type: "round-end",
|
|
47
|
+
label: `${sideLabel(roundRow.winnerSide)}获胜 · ${endReasonLabel(roundRow.endReason)}`,
|
|
48
|
+
teamKey: roundRow.winnerTeamKey
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
return [...killEvents, ...bombEvents, ...grenadeEvents, ...roundEvents]
|
|
52
|
+
.sort((a, b) => a.roundNumber - b.roundNumber || a.tick - b.tick || eventSortWeight(a.type) - eventSortWeight(b.type));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function buildEconomy(pkg: DemoPackage): EconomyPoint[] {
|
|
56
|
+
return pkg.rounds.map((roundRow) => {
|
|
57
|
+
const rows = pkg.playerEconomies.filter((row) => row.roundNumber === roundRow.roundNumber);
|
|
58
|
+
const sumForTeam = (teamKey: "teamA" | "teamB") =>
|
|
59
|
+
rows
|
|
60
|
+
.filter((row) => row.teamKey === teamKey)
|
|
61
|
+
.reduce((sum, row) => sum + row.equipmentValue, 0);
|
|
62
|
+
const teamA = sumForTeam("teamA");
|
|
63
|
+
const teamB = sumForTeam("teamB");
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
roundNumber: roundRow.roundNumber,
|
|
67
|
+
teamA,
|
|
68
|
+
teamB,
|
|
69
|
+
advantage: teamA - teamB,
|
|
70
|
+
teamAEconomy: roundRow.teamAEconomy,
|
|
71
|
+
teamBEconomy: roundRow.teamBEconomy,
|
|
72
|
+
winnerTeamKey: roundRow.winnerTeamKey
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function buildHeatmap(pkg: DemoPackage): HeatmapPoint[] {
|
|
78
|
+
// Pre-index sides per round to derive CT/T for any teamKey lookup.
|
|
79
|
+
const roundSides = new Map(
|
|
80
|
+
pkg.rounds.map((r) => [r.roundNumber, { teamA: r.teamASide, teamB: r.teamBSide }])
|
|
81
|
+
);
|
|
82
|
+
const sideFor = (teamKey: string | null, roundNumber: number): "ct" | "t" | null => {
|
|
83
|
+
if (!teamKey) return null;
|
|
84
|
+
const s = roundSides.get(roundNumber);
|
|
85
|
+
if (!s) return null;
|
|
86
|
+
return teamKey === "teamA" ? s.teamA : s.teamB;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const kills = pkg.kills.flatMap<HeatmapPoint>((kill) => {
|
|
90
|
+
const out: HeatmapPoint[] = [
|
|
91
|
+
{
|
|
92
|
+
x: kill.victimPosition.x,
|
|
93
|
+
y: kill.victimPosition.y,
|
|
94
|
+
z: kill.victimPosition.z,
|
|
95
|
+
roundNumber: kill.roundNumber,
|
|
96
|
+
teamKey: kill.victimTeamKey,
|
|
97
|
+
steamId64: kill.victimSteamId64,
|
|
98
|
+
side: sideFor(kill.victimTeamKey, kill.roundNumber),
|
|
99
|
+
kind: "death",
|
|
100
|
+
grenadeType: null
|
|
101
|
+
}
|
|
102
|
+
];
|
|
103
|
+
if (kill.killerPosition) {
|
|
104
|
+
out.push({
|
|
105
|
+
x: kill.killerPosition.x,
|
|
106
|
+
y: kill.killerPosition.y,
|
|
107
|
+
z: kill.killerPosition.z,
|
|
108
|
+
roundNumber: kill.roundNumber,
|
|
109
|
+
teamKey: kill.killerTeamKey,
|
|
110
|
+
steamId64: kill.killerSteamId64,
|
|
111
|
+
side: sideFor(kill.killerTeamKey, kill.roundNumber),
|
|
112
|
+
kind: "kill",
|
|
113
|
+
grenadeType: null
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const grenades = pkg.grenades
|
|
120
|
+
.map<HeatmapPoint>((grenade) => ({
|
|
121
|
+
x: grenade.effectPosition.x,
|
|
122
|
+
y: grenade.effectPosition.y,
|
|
123
|
+
z: grenade.effectPosition.z,
|
|
124
|
+
roundNumber: grenade.roundNumber,
|
|
125
|
+
teamKey: grenade.throwerTeamKey,
|
|
126
|
+
steamId64: grenade.throwerSteamId64,
|
|
127
|
+
side: sideFor(grenade.throwerTeamKey, grenade.roundNumber),
|
|
128
|
+
kind: "grenade",
|
|
129
|
+
grenadeType: grenade.grenade
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
return [...kills, ...grenades].filter((point) => point.x !== 0 || point.y !== 0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function tickToRoundSeconds(pkg: DemoPackage, roundNumber: number, tick: number): number {
|
|
136
|
+
const roundRow = pkg.rounds.find((row) => row.roundNumber === roundNumber);
|
|
137
|
+
if (!roundRow) {
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
return round(Math.max(0, tick - roundRow.freezeEndTick) / pkg.match.tickrate, 2);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function clockForTick(pkg: DemoPackage, roundNumber: number, tick: number): Pick<TimelineEvent, "clockPhase" | "clockSeconds" | "clockLabel"> {
|
|
144
|
+
const roundRow = pkg.rounds.find((row) => row.roundNumber === roundNumber);
|
|
145
|
+
if (!roundRow) {
|
|
146
|
+
return { clockPhase: "round", clockSeconds: 0, clockLabel: "0:00" };
|
|
147
|
+
}
|
|
148
|
+
if (tick < roundRow.freezeEndTick) {
|
|
149
|
+
return { clockPhase: "freeze", clockSeconds: 0, clockLabel: "冻结" };
|
|
150
|
+
}
|
|
151
|
+
const plant = [...pkg.bombs]
|
|
152
|
+
.filter((bomb) => bomb.roundNumber === roundNumber && bomb.type === "planted" && bomb.tick <= tick)
|
|
153
|
+
.sort((a, b) => b.tick - a.tick)[0];
|
|
154
|
+
if (plant) {
|
|
155
|
+
const remaining = clamp(40 - (tick - plant.tick) / pkg.match.tickrate, 0, 40);
|
|
156
|
+
return { clockPhase: "bomb", clockSeconds: round(remaining, 2), clockLabel: formatClock(remaining) };
|
|
157
|
+
}
|
|
158
|
+
const remaining = clamp(115 - (tick - roundRow.freezeEndTick) / pkg.match.tickrate, 0, 115);
|
|
159
|
+
return { clockPhase: "round", clockSeconds: round(remaining, 2), clockLabel: formatClock(remaining) };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function eventSortWeight(type: TimelineEvent["type"]): number {
|
|
163
|
+
if (type === "bomb") return 0;
|
|
164
|
+
if (type === "kill") return 1;
|
|
165
|
+
if (type === "grenade") return 2;
|
|
166
|
+
return 3;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function bombLabel(pkg: DemoPackage, bomb: DemoPackage["bombs"][number]): string {
|
|
170
|
+
const actor = nameForSteamId(pkg, bomb.actorSteamId64) ?? "未知选手";
|
|
171
|
+
const labels: Record<string, string> = {
|
|
172
|
+
planted: "下包",
|
|
173
|
+
defused: "拆包",
|
|
174
|
+
exploded: "爆炸",
|
|
175
|
+
dropped: "掉包",
|
|
176
|
+
picked_up: "捡包",
|
|
177
|
+
plant_begin: "开始下包",
|
|
178
|
+
defuse_begin: "开始拆包"
|
|
179
|
+
};
|
|
180
|
+
return `${actor} ${labels[bomb.type] ?? bomb.type}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function grenadeLabel(type: string): string {
|
|
184
|
+
const labels: Record<string, string> = {
|
|
185
|
+
smoke: "烟雾弹",
|
|
186
|
+
smokegrenade: "烟雾弹",
|
|
187
|
+
flashbang: "闪光弹",
|
|
188
|
+
hegrenade: "手雷",
|
|
189
|
+
molotov: "燃烧弹",
|
|
190
|
+
incgrenade: "燃烧弹",
|
|
191
|
+
decoy: "诱饵弹"
|
|
192
|
+
};
|
|
193
|
+
return labels[normalizeWeapon(type)] ?? type;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function sideLabel(side: "t" | "ct"): string {
|
|
197
|
+
return side === "t" ? "进攻方" : "防守方";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function endReasonLabel(reason: string): string {
|
|
201
|
+
const labels: Record<string, string> = {
|
|
202
|
+
t_win: "歼灭",
|
|
203
|
+
ct_win: "歼灭",
|
|
204
|
+
target_bombed: "爆炸",
|
|
205
|
+
bomb_defused: "拆包",
|
|
206
|
+
target_saved: "时间耗尽"
|
|
207
|
+
};
|
|
208
|
+
return labels[reason] ?? reason;
|
|
209
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import type { AccountSignalsV2, DemoPackage } from "@cs2dak/contract";
|
|
2
|
+
|
|
3
|
+
export type BuyDeltaBuckets = NonNullable<AccountSignalsV2["combat"]["killsByBuyDelta"]>;
|
|
4
|
+
export type ManStateBuckets = NonNullable<AccountSignalsV2["combat"]["killsByManState"]>;
|
|
5
|
+
export type ObjectiveBuckets = AccountSignalsV2["objective"];
|
|
6
|
+
export type UtilityBuckets = AccountSignalsV2["utility"];
|
|
7
|
+
|
|
8
|
+
export const BUY_DELTA_EVEN_THRESHOLD = 1000;
|
|
9
|
+
|
|
10
|
+
export function round(value: number, digits: number): number {
|
|
11
|
+
const factor = 10 ** digits;
|
|
12
|
+
return Math.round(value * factor) / factor;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function clamp(value: number, min: number, max: number): number {
|
|
16
|
+
return Math.min(Math.max(value, min), max);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formatClock(seconds: number): string {
|
|
20
|
+
const clamped = Math.max(0, Math.ceil(seconds));
|
|
21
|
+
const minutes = Math.floor(clamped / 60);
|
|
22
|
+
const remainder = clamped % 60;
|
|
23
|
+
return `${minutes}:${remainder.toString().padStart(2, "0")}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getOrInit<K, V>(map: Map<K, V>, key: K, create: () => V): V {
|
|
27
|
+
const existing = map.get(key);
|
|
28
|
+
if (existing) return existing;
|
|
29
|
+
const next = create();
|
|
30
|
+
map.set(key, next);
|
|
31
|
+
return next;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function groupBy<T, K>(rows: T[], keyFor: (row: T) => K): Map<K, T[]> {
|
|
35
|
+
const out = new Map<K, T[]>();
|
|
36
|
+
for (const row of rows) {
|
|
37
|
+
const key = keyFor(row);
|
|
38
|
+
const bucket = out.get(key) ?? [];
|
|
39
|
+
bucket.push(row);
|
|
40
|
+
out.set(key, bucket);
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function zeroBuyDelta(): BuyDeltaBuckets {
|
|
46
|
+
return { disadvantage: 0, even: 0, advantage: 0 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function zeroManState(): ManStateBuckets {
|
|
50
|
+
return { manDown: 0, even: 0, manUp: 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function firstKillMap(pkg: DemoPackage): Map<number, DemoPackage["kills"][number]> {
|
|
54
|
+
const firstKillByRound = new Map<number, DemoPackage["kills"][number]>();
|
|
55
|
+
for (const kill of [...pkg.kills].sort((a, b) => a.tick - b.tick)) {
|
|
56
|
+
if (!firstKillByRound.has(kill.roundNumber)) {
|
|
57
|
+
firstKillByRound.set(kill.roundNumber, kill);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return firstKillByRound;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function sumDamageForPlayer(pkg: DemoPackage, steamId64: string): number {
|
|
64
|
+
return pkg.damages
|
|
65
|
+
.filter((damage) => damage.attackerSteamId64 === steamId64 && damage.attackerTeamKey !== damage.victimTeamKey)
|
|
66
|
+
.reduce((sum, damage) => sum + damage.healthDamage, 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function openingKillsForPlayer(pkg: DemoPackage, steamId64: string): number {
|
|
70
|
+
return [...firstKillMap(pkg).values()].filter((kill) => kill.killerSteamId64 === steamId64).length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function openingDeathsForPlayer(pkg: DemoPackage, steamId64: string): number {
|
|
74
|
+
return [...firstKillMap(pkg).values()].filter((kill) => kill.victimSteamId64 === steamId64).length;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function multiKillRounds(kills: DemoPackage["kills"], target: number): number {
|
|
78
|
+
const counts = new Map<number, number>();
|
|
79
|
+
for (const kill of kills) {
|
|
80
|
+
counts.set(kill.roundNumber, (counts.get(kill.roundNumber) ?? 0) + 1);
|
|
81
|
+
}
|
|
82
|
+
return [...counts.values()].filter((count) => (target === 5 ? count >= 5 : count === target)).length;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function clutchSplit(
|
|
86
|
+
statsCount: number | undefined,
|
|
87
|
+
statsWon: number | undefined,
|
|
88
|
+
clutches: DemoPackage["clutches"],
|
|
89
|
+
opponentCount: number
|
|
90
|
+
) {
|
|
91
|
+
const rows = clutches.filter((row) => row.opponentCount === opponentCount);
|
|
92
|
+
return {
|
|
93
|
+
count: statsCount ?? rows.length,
|
|
94
|
+
won: statsWon ?? rows.filter((row) => row.won).length
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function clutchSplitV2(
|
|
99
|
+
count: number | undefined,
|
|
100
|
+
won: number | undefined,
|
|
101
|
+
rows: DemoPackage["clutches"],
|
|
102
|
+
opponentCount: number
|
|
103
|
+
) {
|
|
104
|
+
const filtered = rows.filter((row) => row.opponentCount === opponentCount);
|
|
105
|
+
return {
|
|
106
|
+
count: count ?? filtered.length,
|
|
107
|
+
won: won ?? filtered.filter((row) => row.won).length
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function normalizeWeapon(weapon: string): string {
|
|
112
|
+
return weapon.toLowerCase().replace(/^weapon_/, "");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function isNamedWeapon(value: string): boolean {
|
|
116
|
+
return /^[a-z_][a-z0-9_]*$/.test(value);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function isUtilityWeapon(weapon: string): boolean {
|
|
120
|
+
return ["hegrenade", "inferno", "molotov", "incgrenade"].includes(normalizeWeapon(weapon));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function killWeaponName(kill: DemoPackage["kills"][number]): string {
|
|
124
|
+
const active = kill.killerActiveWeapon ? normalizeWeapon(kill.killerActiveWeapon) : "";
|
|
125
|
+
return isNamedWeapon(active) ? active : normalizeWeapon(kill.weapon);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function nameForSteamId(pkg: DemoPackage, steamId: string | null): string | null {
|
|
129
|
+
if (!steamId) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return pkg.players.find((player) => player.steamId64 === steamId)?.name ?? steamId;
|
|
133
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { displayWeaponName } from "./weapons";
|
|
3
|
+
|
|
4
|
+
describe("displayWeaponName", () => {
|
|
5
|
+
it("maps raw codes to canonical display names", () => {
|
|
6
|
+
expect(displayWeaponName("ak47")).toBe("AK-47");
|
|
7
|
+
expect(displayWeaponName("m4a1_silencer")).toBe("M4A1-S");
|
|
8
|
+
expect(displayWeaponName("awp")).toBe("AWP");
|
|
9
|
+
expect(displayWeaponName("deagle")).toBe("Desert Eagle");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("strips the weapon_ prefix and is case-insensitive", () => {
|
|
13
|
+
expect(displayWeaponName("weapon_ak47")).toBe("AK-47");
|
|
14
|
+
expect(displayWeaponName("WEAPON_AWP")).toBe("AWP");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("collapses every knife/bayonet variant to a single label", () => {
|
|
18
|
+
expect(displayWeaponName("knife")).toBe("knife");
|
|
19
|
+
expect(displayWeaponName("knife_karambit")).toBe("knife");
|
|
20
|
+
expect(displayWeaponName("weapon_bayonet")).toBe("knife");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("falls back to the normalized code instead of an unknown placeholder", () => {
|
|
24
|
+
expect(displayWeaponName("weapon_some_future_gun")).toBe("some_future_gun");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("never returns a purely numeric string for a named weapon", () => {
|
|
28
|
+
for (const raw of ["ak47", "m4a1_silencer", "knife_m9_bayonet", "future_gun"]) {
|
|
29
|
+
expect(/^\d+$/.test(displayWeaponName(raw))).toBe(false);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
});
|