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/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