@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/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
+ }
@@ -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
+ });