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