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/rounds.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Formal round model — strict round semantics (see cs2-demo-format contract).
|
|
2
|
+
|
|
3
|
+
Provenance: ported from cs2-demo-analysis-kit (originally DrEAmSs59/
|
|
4
|
+
CS2-insight-agent, with the author's permission). rounds.json is unchanged
|
|
5
|
+
between v2 and v3, so this module is a near-verbatim port.
|
|
6
|
+
|
|
7
|
+
Rules enforced here:
|
|
8
|
+
- roundNumber starts at 1 and is continuous; warmup / round 0 is dropped.
|
|
9
|
+
- side is only "t" | "ct"; "unknown" in a formal round is a producer error.
|
|
10
|
+
- teamKey is only "teamA" | "teamB".
|
|
11
|
+
|
|
12
|
+
Every downstream builder filters events through `_RoundModel` so warmup rows
|
|
13
|
+
never leak into kills/damages/positions etc.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import bisect
|
|
19
|
+
import math
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from .enums import normalize_round_end_reason
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ── tiny helpers (duplicated from exporter to avoid circular import) ──────────
|
|
26
|
+
|
|
27
|
+
def _sid(val) -> str | None:
|
|
28
|
+
"""Parse 17-digit SteamID64 from any demoparser2 representation."""
|
|
29
|
+
if val is None or val == 0:
|
|
30
|
+
return None
|
|
31
|
+
if isinstance(val, float) and math.isnan(val):
|
|
32
|
+
return None
|
|
33
|
+
if isinstance(val, str) and val.strip().lower() in {"", "0", "nan", "none"}:
|
|
34
|
+
return None
|
|
35
|
+
s = str(int(val))
|
|
36
|
+
return s if len(s) == 17 and s.isdigit() else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _rn(row: dict) -> int:
|
|
40
|
+
return int(row.get("total_rounds_played") or 0)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── round model ────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class _RoundWindow:
|
|
47
|
+
round_number: int
|
|
48
|
+
start_tick: int
|
|
49
|
+
freeze_end_tick: int
|
|
50
|
+
end_tick: int
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class _RoundModel:
|
|
55
|
+
windows: list[_RoundWindow]
|
|
56
|
+
side_map: dict[tuple[int, str], str]
|
|
57
|
+
# Indexes built in __post_init__; events are resolved per-row across every
|
|
58
|
+
# builder, so these turn O(rounds) scans into O(1)/O(log rounds) lookups.
|
|
59
|
+
_by_round: dict[int, _RoundWindow] = field(init=False, repr=False, default_factory=dict)
|
|
60
|
+
_sorted_starts: list[int] = field(init=False, repr=False, default_factory=list)
|
|
61
|
+
_sorted_windows: list[_RoundWindow] = field(init=False, repr=False, default_factory=list)
|
|
62
|
+
|
|
63
|
+
def __post_init__(self) -> None:
|
|
64
|
+
self._by_round = {w.round_number: w for w in self.windows}
|
|
65
|
+
ordered = sorted(self.windows, key=lambda w: w.start_tick)
|
|
66
|
+
self._sorted_windows = ordered
|
|
67
|
+
self._sorted_starts = [w.start_tick for w in ordered]
|
|
68
|
+
|
|
69
|
+
def window_for_round(self, round_number: int) -> _RoundWindow | None:
|
|
70
|
+
return self._by_round.get(round_number)
|
|
71
|
+
|
|
72
|
+
def has_round(self, round_number: int) -> bool:
|
|
73
|
+
return round_number in self._by_round
|
|
74
|
+
|
|
75
|
+
def round_for_tick(self, tick: int) -> int | None:
|
|
76
|
+
# Windows are sorted by start_tick and non-overlapping: the candidate is
|
|
77
|
+
# the last window whose start_tick <= tick; confirm tick is within it
|
|
78
|
+
# (ticks falling in inter-round gaps resolve to None, as before).
|
|
79
|
+
i = bisect.bisect_right(self._sorted_starts, tick) - 1
|
|
80
|
+
if i < 0:
|
|
81
|
+
return None
|
|
82
|
+
window = self._sorted_windows[i]
|
|
83
|
+
return window.round_number if window.start_tick <= tick <= window.end_tick else None
|
|
84
|
+
|
|
85
|
+
def round_for_event(self, row: dict) -> int | None:
|
|
86
|
+
tick = int(row.get("tick") or 0)
|
|
87
|
+
if tick > 0:
|
|
88
|
+
return self.round_for_tick(tick)
|
|
89
|
+
raw_round = _rn(row)
|
|
90
|
+
fallback = raw_round + 1
|
|
91
|
+
return fallback if fallback > 0 else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _event_steamid(row: dict) -> str | None:
|
|
95
|
+
"""Steam64 from demoparser2 player extras (not raw userid entity slot)."""
|
|
96
|
+
for key in ("user_steamid", "steamid", "attacker_steamid"):
|
|
97
|
+
sid = _sid(row.get(key))
|
|
98
|
+
if sid is not None:
|
|
99
|
+
return sid
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── round builder ──────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def build_rounds(
|
|
106
|
+
raw: dict, team_map: dict[str, str]
|
|
107
|
+
) -> tuple[list[dict], _RoundModel]:
|
|
108
|
+
"""Return (rounds_list, side_map) from raw demoparser2 output.
|
|
109
|
+
|
|
110
|
+
side_map[(roundNumber, teamKey)] = "t" | "ct"
|
|
111
|
+
|
|
112
|
+
total_rounds_played at round_freeze_end/round_start = N-1 for round N
|
|
113
|
+
(rounds completed so far), so we store at actual_round = n + 1.
|
|
114
|
+
total_rounds_played at round_end = N (the round that just completed).
|
|
115
|
+
"""
|
|
116
|
+
freeze_tick: dict[int, int] = {}
|
|
117
|
+
for r in raw.get("round_freeze_ends", []):
|
|
118
|
+
n = _rn(r)
|
|
119
|
+
t = int(r.get("tick") or 0)
|
|
120
|
+
actual_round = n + 1
|
|
121
|
+
if actual_round > 0 and t > 0:
|
|
122
|
+
freeze_tick[actual_round] = t
|
|
123
|
+
|
|
124
|
+
start_tick: dict[int, int] = {}
|
|
125
|
+
for r in raw.get("round_starts", []):
|
|
126
|
+
n = _rn(r)
|
|
127
|
+
t = int(r.get("tick") or 0)
|
|
128
|
+
actual_round = n + 1
|
|
129
|
+
if actual_round > 0 and t > 0 and actual_round not in start_tick:
|
|
130
|
+
start_tick[actual_round] = t
|
|
131
|
+
|
|
132
|
+
team_a_score = 0
|
|
133
|
+
team_b_score = 0
|
|
134
|
+
out: list[dict] = []
|
|
135
|
+
side_map: dict[tuple[int, str], str] = {}
|
|
136
|
+
windows: list[_RoundWindow] = []
|
|
137
|
+
|
|
138
|
+
# A single round can emit multiple round_end events (e.g. a bogus warmup /
|
|
139
|
+
# restart end fired before the real one). Keep only the latest-tick end per
|
|
140
|
+
# round number so the same round is never counted twice.
|
|
141
|
+
best_by_round: dict[int, dict] = {}
|
|
142
|
+
for r in raw.get("round_ends", []):
|
|
143
|
+
n = _rn(r)
|
|
144
|
+
if n <= 0:
|
|
145
|
+
continue
|
|
146
|
+
prev = best_by_round.get(n)
|
|
147
|
+
if prev is None or int(r.get("tick") or 0) > int(prev.get("tick") or 0):
|
|
148
|
+
best_by_round[n] = r
|
|
149
|
+
round_ends_sorted = [best_by_round[n] for n in sorted(best_by_round)]
|
|
150
|
+
|
|
151
|
+
for r in round_ends_sorted:
|
|
152
|
+
n = _rn(r)
|
|
153
|
+
if n <= 0:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
end_tick = int(r.get("tick") or 0)
|
|
157
|
+
s_tick = start_tick.get(n, 0)
|
|
158
|
+
fz_tick = freeze_tick.get(n, 0)
|
|
159
|
+
|
|
160
|
+
# A real round must end after its own freeze period; an end at or before
|
|
161
|
+
# freeze-end is a bogus warmup/restart event — drop it (don't score it).
|
|
162
|
+
if fz_tick > 0 and 0 < end_tick <= fz_tick:
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
team_a_side, team_b_side = _sides_for_round(raw, team_map, n, fz_tick)
|
|
166
|
+
side_map[(n, "teamA")] = team_a_side
|
|
167
|
+
side_map[(n, "teamB")] = team_b_side
|
|
168
|
+
|
|
169
|
+
# v2: startTick, freezeEndTick, endTick must all be >= 1
|
|
170
|
+
if s_tick <= 0 or fz_tick <= 0 or end_tick <= 0:
|
|
171
|
+
winner_raw = str(r.get("winner") or "").lower()
|
|
172
|
+
if winner_raw in ("t", "2"):
|
|
173
|
+
winner_key = "teamA" if team_a_side == "t" else "teamB"
|
|
174
|
+
elif winner_raw in ("ct", "3"):
|
|
175
|
+
winner_key = "teamA" if team_a_side == "ct" else "teamB"
|
|
176
|
+
else:
|
|
177
|
+
winner_key = None
|
|
178
|
+
|
|
179
|
+
if winner_key == "teamA":
|
|
180
|
+
team_a_score += 1
|
|
181
|
+
elif winner_key == "teamB":
|
|
182
|
+
team_b_score += 1
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
winner_raw = str(r.get("winner") or "").lower()
|
|
186
|
+
if winner_raw in ("t", "2"):
|
|
187
|
+
winner_side = "t"
|
|
188
|
+
winner_key = "teamA" if team_a_side == "t" else "teamB"
|
|
189
|
+
elif winner_raw in ("ct", "3"):
|
|
190
|
+
winner_side = "ct"
|
|
191
|
+
winner_key = "teamA" if team_a_side == "ct" else "teamB"
|
|
192
|
+
else:
|
|
193
|
+
winner_side = None
|
|
194
|
+
winner_key = None
|
|
195
|
+
|
|
196
|
+
if not winner_key or not winner_side:
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
end_reason = normalize_round_end_reason(r.get("reason"))
|
|
200
|
+
|
|
201
|
+
out.append({
|
|
202
|
+
"roundNumber": n,
|
|
203
|
+
"startTick": s_tick,
|
|
204
|
+
"freezeEndTick": fz_tick,
|
|
205
|
+
"endTick": end_tick,
|
|
206
|
+
"teamASide": team_a_side,
|
|
207
|
+
"teamBSide": team_b_side,
|
|
208
|
+
"teamAScoreBefore": team_a_score,
|
|
209
|
+
"teamBScoreBefore": team_b_score,
|
|
210
|
+
"teamAEconomy": None,
|
|
211
|
+
"teamBEconomy": None,
|
|
212
|
+
"winnerTeamKey": winner_key,
|
|
213
|
+
"winnerSide": winner_side,
|
|
214
|
+
"endReason": end_reason,
|
|
215
|
+
})
|
|
216
|
+
windows.append(_RoundWindow(n, s_tick, fz_tick, end_tick))
|
|
217
|
+
|
|
218
|
+
if winner_key == "teamA":
|
|
219
|
+
team_a_score += 1
|
|
220
|
+
elif winner_key == "teamB":
|
|
221
|
+
team_b_score += 1
|
|
222
|
+
|
|
223
|
+
# Replace any None economy with "semi"
|
|
224
|
+
for rd in out:
|
|
225
|
+
if rd["teamAEconomy"] is None:
|
|
226
|
+
rd["teamAEconomy"] = "semi"
|
|
227
|
+
if rd["teamBEconomy"] is None:
|
|
228
|
+
rd["teamBEconomy"] = "semi"
|
|
229
|
+
|
|
230
|
+
return out, _RoundModel(windows=windows, side_map=side_map)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ── side inference ─────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
def _sides_for_round(
|
|
236
|
+
raw: dict,
|
|
237
|
+
team_map: dict[str, str],
|
|
238
|
+
round_number: int,
|
|
239
|
+
freeze_end_tick: int | None = None,
|
|
240
|
+
) -> tuple[str, str]:
|
|
241
|
+
"""Infer teamA/teamB side from freeze samples; formula is only fallback."""
|
|
242
|
+
sampled = _sampled_side_for_round(raw, team_map, round_number, freeze_end_tick)
|
|
243
|
+
if sampled is not None:
|
|
244
|
+
return sampled
|
|
245
|
+
|
|
246
|
+
start_side_by_team = _starting_side_by_team(raw, team_map)
|
|
247
|
+
team_a_initial = start_side_by_team.get("teamA", "t")
|
|
248
|
+
team_b_initial = "ct" if team_a_initial == "t" else "t"
|
|
249
|
+
if round_number <= 12:
|
|
250
|
+
team_a_side = team_a_initial
|
|
251
|
+
elif round_number <= 24:
|
|
252
|
+
team_a_side = "ct" if team_a_initial == "t" else "t"
|
|
253
|
+
else:
|
|
254
|
+
ot_block = (round_number - 25) // 3
|
|
255
|
+
if ot_block % 2 == 0:
|
|
256
|
+
team_a_side = "ct" if team_a_initial == "t" else "t"
|
|
257
|
+
else:
|
|
258
|
+
team_a_side = team_a_initial
|
|
259
|
+
team_b_side = team_b_initial if team_a_side == team_a_initial else team_a_initial
|
|
260
|
+
return team_a_side, team_b_side
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _sampled_side_for_round(
|
|
264
|
+
raw: dict,
|
|
265
|
+
team_map: dict[str, str],
|
|
266
|
+
round_number: int,
|
|
267
|
+
freeze_end_tick: int | None,
|
|
268
|
+
) -> tuple[str, str] | None:
|
|
269
|
+
expected_tick = (freeze_end_tick or 0) + 16
|
|
270
|
+
counts: dict[str, dict[str, int]] = {"teamA": {"t": 0, "ct": 0}, "teamB": {"t": 0, "ct": 0}}
|
|
271
|
+
|
|
272
|
+
for row in raw.get("round_side_samples", []):
|
|
273
|
+
tick = int(row.get("tick") or 0)
|
|
274
|
+
if expected_tick > 16 and tick != expected_tick:
|
|
275
|
+
continue
|
|
276
|
+
if expected_tick <= 16:
|
|
277
|
+
raw_round = _rn(row)
|
|
278
|
+
if raw_round and raw_round + 1 != round_number:
|
|
279
|
+
continue
|
|
280
|
+
sid = _sid(row.get("steamid"))
|
|
281
|
+
key = team_map.get(sid or "")
|
|
282
|
+
if key not in counts:
|
|
283
|
+
continue
|
|
284
|
+
try:
|
|
285
|
+
team_num = int(row.get("team_num") or 0)
|
|
286
|
+
except (TypeError, ValueError):
|
|
287
|
+
continue
|
|
288
|
+
side = "t" if team_num == 2 else "ct" if team_num == 3 else None
|
|
289
|
+
if side:
|
|
290
|
+
counts[key][side] += 1
|
|
291
|
+
|
|
292
|
+
team_a_side = _majority_side(counts["teamA"])
|
|
293
|
+
team_b_side = _majority_side(counts["teamB"])
|
|
294
|
+
if team_a_side and team_b_side and team_a_side != team_b_side:
|
|
295
|
+
return team_a_side, team_b_side
|
|
296
|
+
if team_a_side:
|
|
297
|
+
return team_a_side, "ct" if team_a_side == "t" else "t"
|
|
298
|
+
if team_b_side:
|
|
299
|
+
return ("ct" if team_b_side == "t" else "t"), team_b_side
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _majority_side(side_counts: dict[str, int]) -> str | None:
|
|
304
|
+
if side_counts["t"] == 0 and side_counts["ct"] == 0:
|
|
305
|
+
return None
|
|
306
|
+
if side_counts["t"] == side_counts["ct"]:
|
|
307
|
+
return None
|
|
308
|
+
return "t" if side_counts["t"] > side_counts["ct"] else "ct"
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _starting_side_by_team(raw: dict, team_map: dict[str, str]) -> dict[str, str]:
|
|
312
|
+
counts: dict[str, dict[str, int]] = {"teamA": {"t": 0, "ct": 0}, "teamB": {"t": 0, "ct": 0}}
|
|
313
|
+
for row in raw.get("player_info", []):
|
|
314
|
+
sid = _sid(row.get("steamid"))
|
|
315
|
+
key = team_map.get(sid or "")
|
|
316
|
+
if key not in counts:
|
|
317
|
+
continue
|
|
318
|
+
try:
|
|
319
|
+
team_num = int(row.get("team_num") or 0)
|
|
320
|
+
except (TypeError, ValueError):
|
|
321
|
+
continue
|
|
322
|
+
side = "t" if team_num == 2 else "ct" if team_num == 3 else None
|
|
323
|
+
if side:
|
|
324
|
+
counts[key][side] += 1
|
|
325
|
+
out: dict[str, str] = {}
|
|
326
|
+
for key, side_counts in counts.items():
|
|
327
|
+
out[key] = "t" if side_counts["t"] >= side_counts["ct"] else "ct"
|
|
328
|
+
return out
|