cs2df 3.0.0__py3-none-any.whl
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.
- cs2df/__init__.py +11 -0
- cs2df/cli.py +374 -0
- cs2df/enums.py +269 -0
- cs2df/events.py +881 -0
- cs2df/package.py +203 -0
- cs2df/parse.py +532 -0
- cs2df/rounds.py +328 -0
- cs2df/streams.py +472 -0
- cs2df/validate.py +551 -0
- cs2df-3.0.0.dist-info/METADATA +100 -0
- cs2df-3.0.0.dist-info/RECORD +13 -0
- cs2df-3.0.0.dist-info/WHEEL +4 -0
- cs2df-3.0.0.dist-info/entry_points.txt +2 -0
cs2df/events.py
ADDED
|
@@ -0,0 +1,881 @@
|
|
|
1
|
+
"""v3 event-file builders (row-oriented JSON files).
|
|
2
|
+
|
|
3
|
+
All files reference players by `playerIndex` (row index into players.json).
|
|
4
|
+
Per-row teamKey/side fields were removed in v3 — they are derivable from
|
|
5
|
+
playerIndex + rounds.json — but team/side VALIDITY is still enforced here so
|
|
6
|
+
warmup or unresolvable rows never leak into the package.
|
|
7
|
+
|
|
8
|
+
Provenance: ported from cs2-demo-analysis-kit (originally DrEAmSs59/
|
|
9
|
+
CS2-insight-agent, with the author's permission) and reshaped for v3.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import math
|
|
15
|
+
import re
|
|
16
|
+
from collections import defaultdict
|
|
17
|
+
|
|
18
|
+
from .enums import (
|
|
19
|
+
normalize_hitgroup, classify_inventory, normalize_weapon_name,
|
|
20
|
+
bomb_site_from_place, _BOMB_TYPE_MAP, _GRENADE_TYPE_ENUM,
|
|
21
|
+
)
|
|
22
|
+
from .rounds import _RoundModel, _event_steamid
|
|
23
|
+
|
|
24
|
+
_STEAMID_RE = re.compile(r"^\d{17}$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ── helper primitives ─────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
def _safe_float(val, default: float = 0.0) -> float:
|
|
30
|
+
if val is None:
|
|
31
|
+
return default
|
|
32
|
+
try:
|
|
33
|
+
f = float(val)
|
|
34
|
+
return default if (math.isnan(f) or math.isinf(f)) else f
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
return default
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _safe_float_nullable(val) -> float | None:
|
|
40
|
+
if val is None:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
f = float(val)
|
|
44
|
+
return None if (math.isnan(f) or math.isinf(f)) else f
|
|
45
|
+
except (TypeError, ValueError):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _sid(val) -> str | None:
|
|
50
|
+
s = str(val or "").strip()
|
|
51
|
+
if s.endswith(".0"):
|
|
52
|
+
s = s[:-2]
|
|
53
|
+
return s if s and s not in ("0", "nan", "None") else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _is_valid_steamid(s) -> bool:
|
|
57
|
+
return isinstance(s, str) and bool(_STEAMID_RE.match(s))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _is_valid_side(s) -> bool:
|
|
61
|
+
return s in ("t", "ct")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _is_valid_teamkey(s) -> bool:
|
|
65
|
+
return s in ("teamA", "teamB")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _raw(row: dict, k: str):
|
|
69
|
+
return row.get(k) if row.get(k) is not None else row.get(k.lower())
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _pos(row: dict, xk="X", yk="Y", zk="Z") -> dict:
|
|
73
|
+
"""Non-nullable integer vec3; NaN/missing → 0."""
|
|
74
|
+
return {
|
|
75
|
+
"x": int(round(_safe_float(_raw(row, xk)))),
|
|
76
|
+
"y": int(round(_safe_float(_raw(row, yk)))),
|
|
77
|
+
"z": int(round(_safe_float(_raw(row, zk)))),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _pos_nullable(row: dict, xk="X", yk="Y", zk="Z") -> dict | None:
|
|
82
|
+
xv = _safe_float_nullable(_raw(row, xk))
|
|
83
|
+
yv = _safe_float_nullable(_raw(row, yk))
|
|
84
|
+
zv = _safe_float_nullable(_raw(row, zk))
|
|
85
|
+
if xv is None and yv is None and zv is None:
|
|
86
|
+
return None
|
|
87
|
+
return {"x": int(round(xv or 0.0)), "y": int(round(yv or 0.0)), "z": int(round(zv or 0.0))}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _b(val) -> bool:
|
|
91
|
+
if isinstance(val, bool):
|
|
92
|
+
return val
|
|
93
|
+
try:
|
|
94
|
+
return int(val or 0) != 0
|
|
95
|
+
except (TypeError, ValueError):
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _event_entity_id(row: dict) -> int | None:
|
|
100
|
+
for key in ("entityid", "entity_id", "grenade_entity_id"):
|
|
101
|
+
val = row.get(key)
|
|
102
|
+
if val is None:
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
return int(val)
|
|
106
|
+
except (TypeError, ValueError):
|
|
107
|
+
continue
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _event_round_number(round_model: _RoundModel, row: dict) -> int | None:
|
|
112
|
+
n = round_model.round_for_event(row)
|
|
113
|
+
if n is None:
|
|
114
|
+
return None
|
|
115
|
+
return n if round_model.has_round(n) else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _active_event_round_number(round_model: _RoundModel, row: dict) -> int | None:
|
|
119
|
+
n = _event_round_number(round_model, row)
|
|
120
|
+
if n is None:
|
|
121
|
+
return None
|
|
122
|
+
tick = int(row.get("tick") or 0)
|
|
123
|
+
window = round_model.window_for_round(n)
|
|
124
|
+
if window is None or tick < window.freeze_end_tick or tick > window.end_tick:
|
|
125
|
+
return None
|
|
126
|
+
return n
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class PlayerDirectory:
|
|
130
|
+
"""players.json rows + sid → (playerIndex, teamKey) lookups."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, rows: list[dict]):
|
|
133
|
+
self.rows = rows
|
|
134
|
+
self.index_by_sid: dict[str, int] = {r["steamId64"]: i for i, r in enumerate(rows)}
|
|
135
|
+
self.team_by_sid: dict[str, str] = {r["steamId64"]: r["teamKey"] for r in rows}
|
|
136
|
+
|
|
137
|
+
def idx(self, sid: str | None) -> int | None:
|
|
138
|
+
if sid is None:
|
|
139
|
+
return None
|
|
140
|
+
return self.index_by_sid.get(sid)
|
|
141
|
+
|
|
142
|
+
def team(self, sid: str | None) -> str | None:
|
|
143
|
+
if sid is None:
|
|
144
|
+
return None
|
|
145
|
+
return self.team_by_sid.get(sid)
|
|
146
|
+
|
|
147
|
+
def team_of_index(self, idx: int) -> str:
|
|
148
|
+
return self.rows[idx]["teamKey"]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ── players / match ───────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
def build_players(raw: dict) -> PlayerDirectory:
|
|
154
|
+
team_num_to_key = {2: "teamA", 3: "teamB"}
|
|
155
|
+
seen: set[str] = set()
|
|
156
|
+
out: list[dict] = []
|
|
157
|
+
for r in raw.get("player_info", []):
|
|
158
|
+
sid = _sid(r.get("steamid"))
|
|
159
|
+
if not sid or sid in seen or not _is_valid_steamid(sid):
|
|
160
|
+
continue
|
|
161
|
+
seen.add(sid)
|
|
162
|
+
team_key = team_num_to_key.get(int(r.get("team_num") or 0))
|
|
163
|
+
if not team_key:
|
|
164
|
+
continue
|
|
165
|
+
out.append({"steamId64": sid, "name": str(r.get("name") or sid), "teamKey": team_key})
|
|
166
|
+
# Stable, deterministic order: teamA before teamB, then by steamId64.
|
|
167
|
+
out.sort(key=lambda p: (p["teamKey"], p["steamId64"]))
|
|
168
|
+
return PlayerDirectory(out)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def build_match(raw: dict, rounds: list[dict]) -> dict:
|
|
172
|
+
hdr = raw.get("header", {})
|
|
173
|
+
team_a_score = sum(1 for r in rounds if r["winnerTeamKey"] == "teamA")
|
|
174
|
+
team_b_score = sum(1 for r in rounds if r["winnerTeamKey"] == "teamB")
|
|
175
|
+
team_a_name = (raw.get("team_a_name") or str(hdr.get("team_name_t") or "")).strip() or None
|
|
176
|
+
team_b_name = (raw.get("team_b_name") or str(hdr.get("team_name_ct") or "")).strip() or None
|
|
177
|
+
|
|
178
|
+
duration = _safe_float(hdr.get("playback_time"), default=0.0)
|
|
179
|
+
if not duration:
|
|
180
|
+
last_tick = max((r["endTick"] for r in rounds if r.get("endTick")), default=0)
|
|
181
|
+
tickrate = max(int(raw.get("tickrate") or 64), 1)
|
|
182
|
+
duration = round(last_tick / tickrate, 1)
|
|
183
|
+
if not duration or duration <= 0:
|
|
184
|
+
duration = 1.0
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
"mapName": str(hdr.get("map_name") or "unknown"),
|
|
188
|
+
"tickrate": raw.get("tickrate", 64),
|
|
189
|
+
"durationSeconds": duration,
|
|
190
|
+
"serverName": str(hdr.get("server_name") or "").strip() or None,
|
|
191
|
+
"source": "demo",
|
|
192
|
+
"teamA": {"teamKey": "teamA", "name": team_a_name, "score": team_a_score},
|
|
193
|
+
"teamB": {"teamKey": "teamB", "name": team_b_name, "score": team_b_score},
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# ── kills ─────────────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
def build_kills(raw: dict, players: PlayerDirectory, round_model: _RoundModel) -> list[dict]:
|
|
200
|
+
out = []
|
|
201
|
+
for r in raw.get("deaths", []):
|
|
202
|
+
n = _active_event_round_number(round_model, r)
|
|
203
|
+
if n is None:
|
|
204
|
+
continue
|
|
205
|
+
victim_sid = _sid(r.get("user_steamid"))
|
|
206
|
+
victim_idx = players.idx(victim_sid)
|
|
207
|
+
if victim_idx is None:
|
|
208
|
+
continue
|
|
209
|
+
# victim must resolve to a formal side this round
|
|
210
|
+
if not _is_valid_side(round_model.side_map.get((n, players.team(victim_sid)), "unknown")):
|
|
211
|
+
continue
|
|
212
|
+
weapon = str(r.get("weapon") or "")
|
|
213
|
+
if not weapon:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
killer_sid = _sid(r.get("attacker_steamid"))
|
|
217
|
+
killer_idx = players.idx(killer_sid)
|
|
218
|
+
assist_idx = players.idx(_sid(r.get("assister_steamid")))
|
|
219
|
+
flash_assist = _b(r.get("assistedflash"))
|
|
220
|
+
flash_assister_idx = assist_idx if flash_assist else None
|
|
221
|
+
|
|
222
|
+
out.append({
|
|
223
|
+
"roundNumber": n,
|
|
224
|
+
"tick": int(r.get("tick") or 0),
|
|
225
|
+
"killerIndex": killer_idx,
|
|
226
|
+
"victimIndex": victim_idx,
|
|
227
|
+
"assisterIndex": assist_idx,
|
|
228
|
+
"flashAssisterIndex": flash_assister_idx,
|
|
229
|
+
"weapon": weapon,
|
|
230
|
+
"killerActiveWeapon": normalize_weapon_name(r.get("attacker_active_weapon")),
|
|
231
|
+
"victimActiveWeapon": normalize_weapon_name(r.get("user_active_weapon")),
|
|
232
|
+
"headshot": _b(r.get("headshot")),
|
|
233
|
+
"flashAssist": flash_assist and flash_assister_idx is not None,
|
|
234
|
+
"tradeKill": False,
|
|
235
|
+
"tradeDeath": False,
|
|
236
|
+
"throughSmoke": _b(r.get("thrusmoke")),
|
|
237
|
+
"noScope": _b(r.get("noscope")),
|
|
238
|
+
"penetratedObjects": int(r.get("penetrated_objects") or r.get("penetrated") or 0),
|
|
239
|
+
"killerPosition": _pos_nullable(r, "attacker_X", "attacker_Y", "attacker_Z"),
|
|
240
|
+
"victimPosition": _pos(r, "user_X", "user_Y", "user_Z"),
|
|
241
|
+
})
|
|
242
|
+
_annotate_trades(out)
|
|
243
|
+
return out
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _annotate_trades(kills: list[dict], trade_window_ticks: int = 384) -> None:
|
|
247
|
+
"""Mark tradeKill / tradeDeath within a rolling 6-second window (384 ticks at 64hz)."""
|
|
248
|
+
for i, kill in enumerate(kills):
|
|
249
|
+
if kill["killerIndex"] is None:
|
|
250
|
+
continue
|
|
251
|
+
for j in range(i - 1, max(i - 20, -1), -1):
|
|
252
|
+
prev = kills[j]
|
|
253
|
+
if kill["tick"] - prev["tick"] > trade_window_ticks:
|
|
254
|
+
break
|
|
255
|
+
if prev["killerIndex"] == kill["victimIndex"]:
|
|
256
|
+
kills[i]["tradeKill"] = True
|
|
257
|
+
kills[j]["tradeDeath"] = True
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ── damages ───────────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
def build_damages(raw: dict, players: PlayerDirectory, round_model: _RoundModel) -> list[dict]:
|
|
264
|
+
out: list[dict] = []
|
|
265
|
+
remaining_health: dict[tuple[int, int], int] = defaultdict(lambda: 100)
|
|
266
|
+
|
|
267
|
+
for r in sorted(raw.get("hurts", []), key=lambda row: int(row.get("tick") or 0)):
|
|
268
|
+
n = round_model.round_for_event(r)
|
|
269
|
+
if n is None:
|
|
270
|
+
continue
|
|
271
|
+
vic_sid = _sid(r.get("user_steamid"))
|
|
272
|
+
vic_idx = players.idx(vic_sid)
|
|
273
|
+
if vic_idx is None or not _is_valid_steamid(vic_sid):
|
|
274
|
+
continue
|
|
275
|
+
if not _is_valid_side(round_model.side_map.get((n, players.team(vic_sid)), "unknown")):
|
|
276
|
+
continue
|
|
277
|
+
weapon = str(r.get("weapon") or "")
|
|
278
|
+
if not weapon:
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
atk_idx = players.idx(_sid(r.get("attacker_steamid")))
|
|
282
|
+
|
|
283
|
+
raw_dmg = int(r.get("dmg_health") or 0)
|
|
284
|
+
health_key = (n, vic_idx)
|
|
285
|
+
health_before = remaining_health[health_key]
|
|
286
|
+
health_dmg = min(max(raw_dmg, 0), health_before)
|
|
287
|
+
remaining_health[health_key] = health_before - health_dmg
|
|
288
|
+
armor_after = min(int(r.get("armor") or 0), 100)
|
|
289
|
+
|
|
290
|
+
out.append({
|
|
291
|
+
"roundNumber": n,
|
|
292
|
+
"tick": int(r.get("tick") or 0),
|
|
293
|
+
"attackerIndex": atk_idx,
|
|
294
|
+
"victimIndex": vic_idx,
|
|
295
|
+
"weapon": weapon,
|
|
296
|
+
"hitgroup": normalize_hitgroup(r.get("hitgroup")),
|
|
297
|
+
"healthDamage": health_dmg,
|
|
298
|
+
"healthDamageRaw": raw_dmg,
|
|
299
|
+
"armorDamage": int(r.get("dmg_armor") or 0),
|
|
300
|
+
"victimHealthBefore": health_before,
|
|
301
|
+
"victimArmorAfter": armor_after,
|
|
302
|
+
"attackerPosition": _pos_nullable(r, "attacker_X", "attacker_Y", "attacker_Z"),
|
|
303
|
+
"victimPosition": _pos(r, "user_X", "user_Y", "user_Z"),
|
|
304
|
+
})
|
|
305
|
+
return out
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ── blinds ────────────────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
def build_blinds(raw: dict, players: PlayerDirectory, round_model: _RoundModel,
|
|
311
|
+
flash_lookup: dict | None = None) -> list[dict]:
|
|
312
|
+
flash_lookup = flash_lookup or {}
|
|
313
|
+
out = []
|
|
314
|
+
for r in raw.get("blinds", []):
|
|
315
|
+
n = round_model.round_for_event(r)
|
|
316
|
+
if n is None:
|
|
317
|
+
continue
|
|
318
|
+
flasher_sid = _sid(r.get("attacker_steamid"))
|
|
319
|
+
flashed_sid = _sid(r.get("user_steamid"))
|
|
320
|
+
flasher_idx = players.idx(flasher_sid)
|
|
321
|
+
flashed_idx = players.idx(flashed_sid)
|
|
322
|
+
if flasher_idx is None or flashed_idx is None:
|
|
323
|
+
continue
|
|
324
|
+
if not _is_valid_side(round_model.side_map.get((n, players.team(flasher_sid)), "unknown")):
|
|
325
|
+
continue
|
|
326
|
+
if not _is_valid_side(round_model.side_map.get((n, players.team(flashed_sid)), "unknown")):
|
|
327
|
+
continue
|
|
328
|
+
|
|
329
|
+
dur = min(_safe_float(r.get("blind_duration") or r.get("duration"), default=0.0), 6.0)
|
|
330
|
+
tick = int(r.get("tick") or 0)
|
|
331
|
+
out.append({
|
|
332
|
+
"roundNumber": n,
|
|
333
|
+
"tick": tick,
|
|
334
|
+
"flashId": flash_lookup.get((n, tick)),
|
|
335
|
+
"flasherIndex": flasher_idx,
|
|
336
|
+
"flashedIndex": flashed_idx,
|
|
337
|
+
"durationSeconds": round(dur, 3),
|
|
338
|
+
})
|
|
339
|
+
return out
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# ── bombs ─────────────────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
def build_bombs(raw: dict, players: PlayerDirectory, round_model: _RoundModel) -> list[dict]:
|
|
345
|
+
out = []
|
|
346
|
+
# A/B from the actor's last_place_name at plant; reused for defuse/explode.
|
|
347
|
+
round_site: dict[int, str] = {}
|
|
348
|
+
for r in raw.get("bomb_planted", []):
|
|
349
|
+
n = round_model.round_for_event(r)
|
|
350
|
+
site = bomb_site_from_place(r.get("user_last_place_name"))
|
|
351
|
+
if n is not None and site is not None:
|
|
352
|
+
round_site[n] = site
|
|
353
|
+
_ROUND_SITE_TYPES = {"planted", "defused", "exploded", "defuse_begin"}
|
|
354
|
+
|
|
355
|
+
event_sources = [
|
|
356
|
+
("bomb_planted", "planted"),
|
|
357
|
+
("bomb_defused", "defused"),
|
|
358
|
+
("bomb_exploded", "exploded"),
|
|
359
|
+
("bomb_beginplant", "plant"),
|
|
360
|
+
("bomb_begindefuse", "defuse"),
|
|
361
|
+
("bomb_dropped", "dropped"),
|
|
362
|
+
("bomb_pickup", "picked_up"),
|
|
363
|
+
]
|
|
364
|
+
|
|
365
|
+
for rows_key, ev_type in event_sources:
|
|
366
|
+
v3_type = _BOMB_TYPE_MAP.get(ev_type)
|
|
367
|
+
if v3_type is None:
|
|
368
|
+
continue
|
|
369
|
+
for r in raw.get(rows_key, []):
|
|
370
|
+
n = round_model.round_for_event(r)
|
|
371
|
+
if n is None:
|
|
372
|
+
continue
|
|
373
|
+
tick = int(r.get("tick") or 0)
|
|
374
|
+
window = round_model.window_for_round(n)
|
|
375
|
+
if window is None or tick < window.freeze_end_tick or tick > window.end_tick:
|
|
376
|
+
continue
|
|
377
|
+
actor_sid = _sid(r.get("user_steamid") or r.get("steamid") or r.get("userid"))
|
|
378
|
+
site = bomb_site_from_place(r.get("user_last_place_name"))
|
|
379
|
+
if site is None and v3_type in _ROUND_SITE_TYPES:
|
|
380
|
+
site = round_site.get(n)
|
|
381
|
+
out.append({
|
|
382
|
+
"roundNumber": n,
|
|
383
|
+
"tick": tick,
|
|
384
|
+
"type": v3_type,
|
|
385
|
+
"site": site,
|
|
386
|
+
"actorIndex": players.idx(actor_sid),
|
|
387
|
+
"position": _pos(r, "user_X", "user_Y", "user_Z"),
|
|
388
|
+
})
|
|
389
|
+
out.sort(key=lambda x: (x["roundNumber"], x["tick"]))
|
|
390
|
+
return out
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ── grenades ─────────────────────────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
def build_grenades(raw: dict, players: PlayerDirectory, round_model: _RoundModel) -> list[dict]:
|
|
396
|
+
throws: list[dict] = []
|
|
397
|
+
for r in raw.get("grenade_throws", []):
|
|
398
|
+
tick = int(r.get("tick") or 0)
|
|
399
|
+
n = round_model.round_for_tick(tick)
|
|
400
|
+
if n is None:
|
|
401
|
+
continue
|
|
402
|
+
gtype = str(r.get("grenade") or "")
|
|
403
|
+
if gtype not in _GRENADE_TYPE_ENUM:
|
|
404
|
+
continue
|
|
405
|
+
destroy_tick = int(r.get("destroy_tick") or 0)
|
|
406
|
+
eid = r.get("grenade_entity_id")
|
|
407
|
+
gid = f"{int(eid)}-{tick}" if eid is not None else None
|
|
408
|
+
throws.append({
|
|
409
|
+
"rn": n,
|
|
410
|
+
"tick": tick,
|
|
411
|
+
"destroy_tick": destroy_tick if destroy_tick > 0 else None,
|
|
412
|
+
"gtype": gtype,
|
|
413
|
+
"sid": _event_steamid(r),
|
|
414
|
+
"eid": gid,
|
|
415
|
+
"pos": _pos(r),
|
|
416
|
+
})
|
|
417
|
+
throws.sort(key=lambda t: t["tick"])
|
|
418
|
+
|
|
419
|
+
def _match_throw(round_num: int, gtype: str, effect_tick: int, thrower_sid: str | None) -> dict | None:
|
|
420
|
+
pool = [
|
|
421
|
+
t for t in throws
|
|
422
|
+
if t["rn"] == round_num and t["gtype"] == gtype and t["tick"] <= effect_tick
|
|
423
|
+
and (thrower_sid is None or t["sid"] == thrower_sid)
|
|
424
|
+
]
|
|
425
|
+
return max(pool, key=lambda t: t["tick"]) if pool else None
|
|
426
|
+
|
|
427
|
+
out = []
|
|
428
|
+
for r in raw.get("grenade_detonations", []):
|
|
429
|
+
n = round_model.round_for_event(r)
|
|
430
|
+
if n is None:
|
|
431
|
+
continue
|
|
432
|
+
tick = int(r.get("tick") or 0)
|
|
433
|
+
window = round_model.window_for_round(n)
|
|
434
|
+
if window is None or tick < window.freeze_end_tick or tick > window.end_tick:
|
|
435
|
+
continue
|
|
436
|
+
gtype = str(r.get("_grenade_type") or "")
|
|
437
|
+
if gtype not in _GRENADE_TYPE_ENUM:
|
|
438
|
+
continue
|
|
439
|
+
|
|
440
|
+
thrower_sid = _event_steamid(r)
|
|
441
|
+
matched = _match_throw(n, gtype, tick, thrower_sid)
|
|
442
|
+
detonate_entity_id = _event_entity_id(r)
|
|
443
|
+
if matched:
|
|
444
|
+
thrower_sid = thrower_sid or matched["sid"]
|
|
445
|
+
throw_pos = matched["pos"]
|
|
446
|
+
throw_tick = matched["tick"]
|
|
447
|
+
destroy_tick = None if gtype == "smoke" else matched["destroy_tick"]
|
|
448
|
+
grenade_id = matched["eid"]
|
|
449
|
+
else:
|
|
450
|
+
throw_pos = _pos(r)
|
|
451
|
+
throw_tick = tick
|
|
452
|
+
destroy_tick = None
|
|
453
|
+
grenade_id = f"{detonate_entity_id}-{tick}" if detonate_entity_id is not None else None
|
|
454
|
+
|
|
455
|
+
if throw_tick <= 0 or tick <= 0:
|
|
456
|
+
continue
|
|
457
|
+
thrower_idx = players.idx(thrower_sid)
|
|
458
|
+
if thrower_idx is None:
|
|
459
|
+
continue
|
|
460
|
+
if not _is_valid_side(round_model.side_map.get((n, players.team(thrower_sid)), "unknown")):
|
|
461
|
+
continue
|
|
462
|
+
if destroy_tick is not None and (
|
|
463
|
+
destroy_tick < tick or window is None or destroy_tick > window.end_tick
|
|
464
|
+
):
|
|
465
|
+
destroy_tick = None
|
|
466
|
+
|
|
467
|
+
out.append({
|
|
468
|
+
"roundNumber": n,
|
|
469
|
+
"grenadeId": grenade_id,
|
|
470
|
+
"throwTick": throw_tick,
|
|
471
|
+
"effectTick": tick,
|
|
472
|
+
"destroyTick": destroy_tick,
|
|
473
|
+
"_entityId": detonate_entity_id,
|
|
474
|
+
"grenade": gtype,
|
|
475
|
+
"throwerIndex": thrower_idx,
|
|
476
|
+
"throwPosition": throw_pos,
|
|
477
|
+
"effectPosition": _pos(r),
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
# molotov burn end: pair with the nearest later inferno_expire in-round.
|
|
481
|
+
expires: list[dict] = []
|
|
482
|
+
for r in raw.get("inferno_expires", []):
|
|
483
|
+
n = round_model.round_for_event(r)
|
|
484
|
+
t = int(r.get("tick") or 0)
|
|
485
|
+
if n is None or t <= 0:
|
|
486
|
+
continue
|
|
487
|
+
expires.append({"rn": n, "tick": t, "pos": _pos(r), "used": False})
|
|
488
|
+
expires.sort(key=lambda e: e["tick"])
|
|
489
|
+
for g in out:
|
|
490
|
+
if g["grenade"] != "molotov" or g["destroyTick"] is not None:
|
|
491
|
+
continue
|
|
492
|
+
window = round_model.window_for_round(g["roundNumber"])
|
|
493
|
+
best = None
|
|
494
|
+
best_d2 = None
|
|
495
|
+
for e in expires:
|
|
496
|
+
if e["used"] or e["rn"] != g["roundNumber"] or e["tick"] < g["effectTick"]:
|
|
497
|
+
continue
|
|
498
|
+
if window is not None and e["tick"] > window.end_tick:
|
|
499
|
+
continue
|
|
500
|
+
ep, gp = e["pos"], g["effectPosition"]
|
|
501
|
+
d2 = (ep["x"] - gp["x"]) ** 2 + (ep["y"] - gp["y"]) ** 2
|
|
502
|
+
if best is None or d2 < best_d2:
|
|
503
|
+
best, best_d2 = e, d2
|
|
504
|
+
if best is not None:
|
|
505
|
+
best["used"] = True
|
|
506
|
+
g["destroyTick"] = best["tick"]
|
|
507
|
+
|
|
508
|
+
# smoke lifetime from smokegrenade_expired (same entity id).
|
|
509
|
+
smoke_index: dict[tuple[int, int], list[dict]] = {}
|
|
510
|
+
for r in raw.get("smoke_expires", []):
|
|
511
|
+
n = round_model.round_for_event(r)
|
|
512
|
+
t = int(r.get("tick") or 0)
|
|
513
|
+
eid = _event_entity_id(r)
|
|
514
|
+
if n is None or t <= 0 or eid is None:
|
|
515
|
+
continue
|
|
516
|
+
smoke_index.setdefault((n, eid), []).append({"tick": t})
|
|
517
|
+
for lst in smoke_index.values():
|
|
518
|
+
lst.sort(key=lambda e: e["tick"])
|
|
519
|
+
|
|
520
|
+
for g in out:
|
|
521
|
+
eid = g.pop("_entityId", None)
|
|
522
|
+
if g["grenade"] != "smoke" or eid is None:
|
|
523
|
+
continue
|
|
524
|
+
window = round_model.window_for_round(g["roundNumber"])
|
|
525
|
+
for e in smoke_index.get((g["roundNumber"], eid), []):
|
|
526
|
+
if e["tick"] < g["effectTick"]:
|
|
527
|
+
continue
|
|
528
|
+
if window is not None and e["tick"] > window.end_tick:
|
|
529
|
+
continue
|
|
530
|
+
g["destroyTick"] = e["tick"]
|
|
531
|
+
break
|
|
532
|
+
return out
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ── clutches ──────────────────────────────────────────────────────────────────
|
|
536
|
+
|
|
537
|
+
def build_clutches(kills: list[dict], rounds: list[dict],
|
|
538
|
+
players: PlayerDirectory) -> list[dict]:
|
|
539
|
+
"""Detect 1vN situations: one alive player vs N enemies at some point in the round."""
|
|
540
|
+
out: list[dict] = []
|
|
541
|
+
rounds_by_n = {r["roundNumber"]: r for r in rounds}
|
|
542
|
+
|
|
543
|
+
kills_by_round: dict[int, list[dict]] = {}
|
|
544
|
+
for k in kills:
|
|
545
|
+
kills_by_round.setdefault(k["roundNumber"], []).append(k)
|
|
546
|
+
|
|
547
|
+
team_indexes: dict[str, set[int]] = {"teamA": set(), "teamB": set()}
|
|
548
|
+
for i, p in enumerate(players.rows):
|
|
549
|
+
team_indexes[p["teamKey"]].add(i)
|
|
550
|
+
|
|
551
|
+
for rn, rnd in rounds_by_n.items():
|
|
552
|
+
rnd_kills = sorted(kills_by_round.get(rn, []), key=lambda x: x["tick"])
|
|
553
|
+
if not rnd_kills:
|
|
554
|
+
continue
|
|
555
|
+
|
|
556
|
+
clutch_detected: set[int] = set()
|
|
557
|
+
dead: set[int] = set()
|
|
558
|
+
for k in rnd_kills:
|
|
559
|
+
dead.add(k["victimIndex"])
|
|
560
|
+
a_alive = team_indexes["teamA"] - dead
|
|
561
|
+
b_alive = team_indexes["teamB"] - dead
|
|
562
|
+
|
|
563
|
+
for team_key, own_alive, opp_alive in (
|
|
564
|
+
("teamA", a_alive, b_alive),
|
|
565
|
+
("teamB", b_alive, a_alive),
|
|
566
|
+
):
|
|
567
|
+
if len(own_alive) != 1 or len(opp_alive) < 1:
|
|
568
|
+
continue
|
|
569
|
+
idx = next(iter(own_alive))
|
|
570
|
+
if idx in clutch_detected:
|
|
571
|
+
continue
|
|
572
|
+
clutch_detected.add(idx)
|
|
573
|
+
remaining_kills = sum(
|
|
574
|
+
1 for kk in rnd_kills
|
|
575
|
+
if kk["tick"] >= k["tick"] and kk["killerIndex"] == idx
|
|
576
|
+
)
|
|
577
|
+
out.append({
|
|
578
|
+
"roundNumber": rn,
|
|
579
|
+
"tick": k["tick"],
|
|
580
|
+
"clutcherIndex": idx,
|
|
581
|
+
"opponentCount": len(opp_alive),
|
|
582
|
+
"won": rnd["winnerTeamKey"] == team_key,
|
|
583
|
+
"survived": idx not in dead,
|
|
584
|
+
"killCount": min(remaining_kills, 5),
|
|
585
|
+
})
|
|
586
|
+
return out
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# ── economies ─────────────────────────────────────────────────────────────────
|
|
590
|
+
|
|
591
|
+
_ECO_ORDER = ["pistol", "eco", "semi", "force", "full"]
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _is_pistol_round(round_number: int) -> bool:
|
|
595
|
+
# CS2 MR12 pistol rounds are R1 and R13; OT halves start with high money.
|
|
596
|
+
return round_number in (1, 13)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _is_pistol_conversion_round(round_number: int, team_key: str, rounds: list[dict]) -> bool:
|
|
600
|
+
previous_pistol = round_number - 1
|
|
601
|
+
if previous_pistol not in (1, 13):
|
|
602
|
+
return False
|
|
603
|
+
previous_round = next(
|
|
604
|
+
(row for row in rounds if row.get("roundNumber") == previous_pistol), None)
|
|
605
|
+
return previous_round is not None and previous_round.get("winnerTeamKey") == team_key
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _economy_type(money_spent: int, start_money: int, equipment_value: int,
|
|
609
|
+
round_number: int) -> str:
|
|
610
|
+
if _is_pistol_round(round_number):
|
|
611
|
+
return "pistol"
|
|
612
|
+
if equipment_value >= 4000:
|
|
613
|
+
return "full"
|
|
614
|
+
if money_spent < 1000 and equipment_value < 1000:
|
|
615
|
+
return "eco"
|
|
616
|
+
if start_money > 0 and money_spent / start_money >= 0.80:
|
|
617
|
+
return "force"
|
|
618
|
+
return "semi"
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _team_economy_vote(types: list[str]) -> str:
|
|
622
|
+
if not types:
|
|
623
|
+
return "semi"
|
|
624
|
+
counts = {t: types.count(t) for t in _ECO_ORDER}
|
|
625
|
+
max_count = max(counts.values())
|
|
626
|
+
for t in _ECO_ORDER:
|
|
627
|
+
if counts[t] == max_count:
|
|
628
|
+
return t
|
|
629
|
+
return "semi"
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def build_economies(raw: dict, players: PlayerDirectory, round_model: _RoundModel,
|
|
633
|
+
rounds: list[dict]) -> list[dict]:
|
|
634
|
+
freeze_tick_to_round = {w.freeze_end_tick: w.round_number for w in round_model.windows}
|
|
635
|
+
|
|
636
|
+
out = []
|
|
637
|
+
team_round_types: dict[tuple[int, str], list[str]] = {}
|
|
638
|
+
|
|
639
|
+
for r in raw.get("economy_raw", []):
|
|
640
|
+
tick = int(r.get("tick") or 0)
|
|
641
|
+
n = freeze_tick_to_round.get(tick, 0)
|
|
642
|
+
if n <= 0:
|
|
643
|
+
continue
|
|
644
|
+
sid = _sid(r.get("steamid"))
|
|
645
|
+
idx = players.idx(sid)
|
|
646
|
+
if idx is None:
|
|
647
|
+
continue
|
|
648
|
+
key = players.team(sid)
|
|
649
|
+
if not _is_valid_side(round_model.side_map.get((n, key), "unknown")):
|
|
650
|
+
continue
|
|
651
|
+
|
|
652
|
+
spent = int(_safe_float(r.get("cash_spent_this_round"), 0))
|
|
653
|
+
equip = int(_safe_float(r.get("current_equip_value"), 0))
|
|
654
|
+
start_money = int(_safe_float(r.get("start_balance"), 0))
|
|
655
|
+
eco_type = _economy_type(spent, start_money, equip, n)
|
|
656
|
+
primary, secondary, grenade_count = classify_inventory(r.get("inventory"))
|
|
657
|
+
|
|
658
|
+
out.append({
|
|
659
|
+
"roundNumber": n,
|
|
660
|
+
"playerIndex": idx,
|
|
661
|
+
"startMoney": start_money,
|
|
662
|
+
"moneySpent": spent,
|
|
663
|
+
"equipmentValue": equip,
|
|
664
|
+
"type": eco_type,
|
|
665
|
+
"hasArmor": bool(int(_safe_float(r.get("armor"), 0)) > 0),
|
|
666
|
+
"hasHelmet": bool(_b(r.get("has_helmet"))),
|
|
667
|
+
"hasDefuseKit": bool(_b(r.get("has_defuser"))),
|
|
668
|
+
"primaryWeapon": primary,
|
|
669
|
+
"secondaryWeapon": secondary,
|
|
670
|
+
"grenadeCount": grenade_count,
|
|
671
|
+
})
|
|
672
|
+
team_round_types.setdefault((n, key), []).append(eco_type)
|
|
673
|
+
|
|
674
|
+
round_by_number = {r["roundNumber"]: r for r in rounds}
|
|
675
|
+
for (rn, key), types in team_round_types.items():
|
|
676
|
+
rd = round_by_number.get(rn)
|
|
677
|
+
if rd is None:
|
|
678
|
+
continue
|
|
679
|
+
vote = _team_economy_vote(types)
|
|
680
|
+
if _is_pistol_conversion_round(rn, key, rounds):
|
|
681
|
+
vote = "full"
|
|
682
|
+
if key == "teamA":
|
|
683
|
+
rd["teamAEconomy"] = vote
|
|
684
|
+
elif key == "teamB":
|
|
685
|
+
rd["teamBEconomy"] = vote
|
|
686
|
+
|
|
687
|
+
for rd in rounds:
|
|
688
|
+
if rd.get("teamAEconomy") in (None, "unknown"):
|
|
689
|
+
rd["teamAEconomy"] = "semi"
|
|
690
|
+
if rd.get("teamBEconomy") in (None, "unknown"):
|
|
691
|
+
rd["teamBEconomy"] = "semi"
|
|
692
|
+
|
|
693
|
+
return out
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
# ── player-stats ──────────────────────────────────────────────────────────────
|
|
697
|
+
|
|
698
|
+
def build_player_stats(raw: dict, players: PlayerDirectory, round_model: _RoundModel,
|
|
699
|
+
rounds: list[dict], kills_list: list[dict],
|
|
700
|
+
blinds_list: list[dict], damages_list: list[dict],
|
|
701
|
+
clutches_list: list[dict]) -> list[dict]:
|
|
702
|
+
total_rounds = len(rounds)
|
|
703
|
+
stats: dict[int, dict] = {}
|
|
704
|
+
|
|
705
|
+
def _get(idx: int) -> dict:
|
|
706
|
+
if idx not in stats:
|
|
707
|
+
stats[idx] = {
|
|
708
|
+
"playerIndex": idx,
|
|
709
|
+
"rounds": total_rounds,
|
|
710
|
+
"kills": 0, "deaths": 0, "assists": 0,
|
|
711
|
+
"damageHealth": 0, "damageArmor": 0,
|
|
712
|
+
"utilityDamage": 0,
|
|
713
|
+
"headshotCount": 0,
|
|
714
|
+
"firstKillCount": 0, "firstDeathCount": 0,
|
|
715
|
+
"tradeKillCount": 0, "tradeDeathCount": 0,
|
|
716
|
+
"noScopeKillCount": 0,
|
|
717
|
+
"wallbangKillCount": 0,
|
|
718
|
+
"collateralKillCount": 0,
|
|
719
|
+
"bombPlantCount": 0, "bombDefuseCount": 0,
|
|
720
|
+
"oneKillCount": 0, "twoKillCount": 0, "threeKillCount": 0,
|
|
721
|
+
"fourKillCount": 0, "fiveKillCount": 0,
|
|
722
|
+
"vsOneCount": 0, "vsOneWonCount": 0, "vsOneLostCount": 0,
|
|
723
|
+
"vsTwoCount": 0, "vsTwoWonCount": 0, "vsTwoLostCount": 0,
|
|
724
|
+
"vsThreeCount": 0, "vsThreeWonCount": 0, "vsThreeLostCount": 0,
|
|
725
|
+
"vsFourCount": 0, "vsFourWonCount": 0, "vsFourLostCount": 0,
|
|
726
|
+
"vsFiveCount": 0, "vsFiveWonCount": 0, "vsFiveLostCount": 0,
|
|
727
|
+
"kastRounds": 0,
|
|
728
|
+
"flashAssistCount": 0,
|
|
729
|
+
"enemyFlashDurationSeconds": 0.0,
|
|
730
|
+
"teamFlashDurationSeconds": 0.0,
|
|
731
|
+
"combatDeathCount": 0,
|
|
732
|
+
"bombDeathCount": 0,
|
|
733
|
+
"_rounds_with_kill": set(),
|
|
734
|
+
"_rounds_with_death": set(),
|
|
735
|
+
"_rounds_with_assist": set(),
|
|
736
|
+
"_rounds_traded": set(),
|
|
737
|
+
}
|
|
738
|
+
return stats[idx]
|
|
739
|
+
|
|
740
|
+
# Ensure every roster player has a stats row, even with zero events.
|
|
741
|
+
for i in range(len(players.rows)):
|
|
742
|
+
_get(i)
|
|
743
|
+
|
|
744
|
+
# kills / deaths / assists / multi-kills from the canonical kills list
|
|
745
|
+
kills_per_round: dict[int, dict[int, int]] = {}
|
|
746
|
+
for k in kills_list:
|
|
747
|
+
n = k["roundNumber"]
|
|
748
|
+
victim = _get(k["victimIndex"])
|
|
749
|
+
victim["deaths"] += 1
|
|
750
|
+
victim["_rounds_with_death"].add(n)
|
|
751
|
+
killer_idx = k["killerIndex"]
|
|
752
|
+
if killer_idx is not None and killer_idx == k["victimIndex"]:
|
|
753
|
+
killer_idx = None # suicide
|
|
754
|
+
if killer_idx is not None:
|
|
755
|
+
victim["combatDeathCount"] += 1
|
|
756
|
+
killer = _get(killer_idx)
|
|
757
|
+
killer["kills"] += 1
|
|
758
|
+
killer["_rounds_with_kill"].add(n)
|
|
759
|
+
if k["headshot"]:
|
|
760
|
+
killer["headshotCount"] += 1
|
|
761
|
+
if k["noScope"]:
|
|
762
|
+
killer["noScopeKillCount"] += 1
|
|
763
|
+
if k["penetratedObjects"]:
|
|
764
|
+
killer["wallbangKillCount"] += 1
|
|
765
|
+
kills_per_round.setdefault(killer_idx, {})
|
|
766
|
+
kills_per_round[killer_idx][n] = kills_per_round[killer_idx].get(n, 0) + 1
|
|
767
|
+
else:
|
|
768
|
+
victim["bombDeathCount"] += 1
|
|
769
|
+
if k["tradeKill"] and k["killerIndex"] is not None:
|
|
770
|
+
_get(k["killerIndex"])["tradeKillCount"] += 1
|
|
771
|
+
if k["tradeDeath"]:
|
|
772
|
+
victim["tradeDeathCount"] += 1
|
|
773
|
+
victim["_rounds_traded"].add(n)
|
|
774
|
+
if k["assisterIndex"] is not None:
|
|
775
|
+
a = _get(k["assisterIndex"])
|
|
776
|
+
a["assists"] += 1
|
|
777
|
+
a["_rounds_with_assist"].add(n)
|
|
778
|
+
if k["flashAssist"] and k["flashAssisterIndex"] is not None:
|
|
779
|
+
_get(k["flashAssisterIndex"])["flashAssistCount"] += 1
|
|
780
|
+
|
|
781
|
+
# collateral kills: 2+ enemy kills by one killer on the same tick
|
|
782
|
+
collateral_groups: dict[tuple[int, int], int] = {}
|
|
783
|
+
for k in kills_list:
|
|
784
|
+
if k["killerIndex"] is None or k["killerIndex"] == k["victimIndex"]:
|
|
785
|
+
continue
|
|
786
|
+
gkey = (k["killerIndex"], k["tick"])
|
|
787
|
+
collateral_groups[gkey] = collateral_groups.get(gkey, 0) + 1
|
|
788
|
+
for (killer_idx, _tick), cnt in collateral_groups.items():
|
|
789
|
+
if cnt >= 2:
|
|
790
|
+
_get(killer_idx)["collateralKillCount"] += cnt
|
|
791
|
+
|
|
792
|
+
# bomb plant / defuse
|
|
793
|
+
for rows_key, field in (("bomb_planted", "bombPlantCount"),
|
|
794
|
+
("bomb_defused", "bombDefuseCount")):
|
|
795
|
+
for r in raw.get(rows_key, []):
|
|
796
|
+
if _event_round_number(round_model, r) is None:
|
|
797
|
+
continue
|
|
798
|
+
idx = players.idx(_sid(r.get("user_steamid") or r.get("steamid") or r.get("userid")))
|
|
799
|
+
if idx is not None:
|
|
800
|
+
_get(idx)[field] += 1
|
|
801
|
+
|
|
802
|
+
# first kill / first death per round
|
|
803
|
+
first_kills: dict[int, int] = {}
|
|
804
|
+
first_deaths: dict[int, int] = {}
|
|
805
|
+
for k in sorted(kills_list, key=lambda x: x["tick"]):
|
|
806
|
+
n = k["roundNumber"]
|
|
807
|
+
if k["killerIndex"] is not None and n not in first_kills:
|
|
808
|
+
first_kills[n] = k["killerIndex"]
|
|
809
|
+
if n not in first_deaths:
|
|
810
|
+
first_deaths[n] = k["victimIndex"]
|
|
811
|
+
for idx in first_kills.values():
|
|
812
|
+
_get(idx)["firstKillCount"] += 1
|
|
813
|
+
for idx in first_deaths.values():
|
|
814
|
+
_get(idx)["firstDeathCount"] += 1
|
|
815
|
+
|
|
816
|
+
# multi-kill buckets
|
|
817
|
+
for idx, per_round in kills_per_round.items():
|
|
818
|
+
s = _get(idx)
|
|
819
|
+
for count in per_round.values():
|
|
820
|
+
if count == 1:
|
|
821
|
+
s["oneKillCount"] += 1
|
|
822
|
+
elif count == 2:
|
|
823
|
+
s["twoKillCount"] += 1
|
|
824
|
+
elif count == 3:
|
|
825
|
+
s["threeKillCount"] += 1
|
|
826
|
+
elif count == 4:
|
|
827
|
+
s["fourKillCount"] += 1
|
|
828
|
+
elif count >= 5:
|
|
829
|
+
s["fiveKillCount"] += 1
|
|
830
|
+
|
|
831
|
+
# damages — anti-enemy only, capped effective damage (matches damages.json)
|
|
832
|
+
util_weapons = {"hegrenade", "inferno", "molotov", "incendiary"}
|
|
833
|
+
for r in damages_list:
|
|
834
|
+
atk = r["attackerIndex"]
|
|
835
|
+
vic = r["victimIndex"]
|
|
836
|
+
if atk is None or atk == vic:
|
|
837
|
+
continue
|
|
838
|
+
if players.team_of_index(atk) == players.team_of_index(vic):
|
|
839
|
+
continue
|
|
840
|
+
s = _get(atk)
|
|
841
|
+
s["damageHealth"] += int(r["healthDamage"])
|
|
842
|
+
s["damageArmor"] += int(r["armorDamage"])
|
|
843
|
+
if str(r["weapon"] or "").lower() in util_weapons:
|
|
844
|
+
s["utilityDamage"] += int(r["healthDamage"])
|
|
845
|
+
|
|
846
|
+
# flash durations
|
|
847
|
+
for blind in blinds_list:
|
|
848
|
+
flasher = blind["flasherIndex"]
|
|
849
|
+
flashed = blind["flashedIndex"]
|
|
850
|
+
dur = float(blind["durationSeconds"] or 0)
|
|
851
|
+
if players.team_of_index(flasher) != players.team_of_index(flashed):
|
|
852
|
+
_get(flasher)["enemyFlashDurationSeconds"] += dur
|
|
853
|
+
else:
|
|
854
|
+
_get(flasher)["teamFlashDurationSeconds"] += dur
|
|
855
|
+
|
|
856
|
+
# KAST: kill / assist / survived / traded
|
|
857
|
+
all_rounds = {r["roundNumber"] for r in rounds}
|
|
858
|
+
for s in stats.values():
|
|
859
|
+
survived = all_rounds - s["_rounds_with_death"]
|
|
860
|
+
kast = (s["_rounds_with_kill"] | s["_rounds_with_assist"]
|
|
861
|
+
| survived | s["_rounds_traded"])
|
|
862
|
+
s["kastRounds"] = len(kast & all_rounds)
|
|
863
|
+
|
|
864
|
+
# clutch buckets
|
|
865
|
+
for c in clutches_list:
|
|
866
|
+
s = _get(c["clutcherIndex"])
|
|
867
|
+
prefix = ["", "vsOne", "vsTwo", "vsThree", "vsFour", "vsFive"][min(c["opponentCount"], 5)]
|
|
868
|
+
s[f"{prefix}Count"] += 1
|
|
869
|
+
s[f"{prefix}WonCount" if c["won"] else f"{prefix}LostCount"] += 1
|
|
870
|
+
|
|
871
|
+
out = []
|
|
872
|
+
for idx in sorted(stats.keys()):
|
|
873
|
+
s = stats[idx]
|
|
874
|
+
row = {k: v for k, v in s.items() if not k.startswith("_")}
|
|
875
|
+
row["adr"] = round(s["damageHealth"] / max(total_rounds, 1), 2)
|
|
876
|
+
row["kast"] = round(s["kastRounds"] / max(total_rounds, 1) * 100, 3)
|
|
877
|
+
row["averageUtilityDamagePerRound"] = round(s["utilityDamage"] / max(total_rounds, 1), 2)
|
|
878
|
+
row["enemyFlashDurationSeconds"] = round(row["enemyFlashDurationSeconds"], 3)
|
|
879
|
+
row["teamFlashDurationSeconds"] = round(row["teamFlashDurationSeconds"], 3)
|
|
880
|
+
out.append(row)
|
|
881
|
+
return out
|