cs2df 3.0.0__tar.gz → 3.0.3__tar.gz

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.
@@ -4,6 +4,7 @@ dist/
4
4
  *.d.ts
5
5
  .DS_Store
6
6
  .omc/
7
+ .codegraph/
7
8
  __pycache__/
8
9
  *.py[cod]
9
10
  .pytest_cache/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cs2df
3
- Version: 3.0.0
3
+ Version: 3.0.3
4
4
  Summary: Reference exporter & validator CLI for cs2-demo-format v3 (CS2 demo → ZIP data package)
5
5
  Project-URL: Homepage, https://github.com/Starfie1d1272/cs2-demo-format
6
6
  Project-URL: Repository, https://github.com/Starfie1d1272/cs2-demo-format
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cs2df"
3
- version = "3.0.0"
3
+ version = "3.0.3"
4
4
  description = "Reference exporter & validator CLI for cs2-demo-format v3 (CS2 demo → ZIP data package)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -5,7 +5,7 @@ demoparser2 / pandas stack installed. Heavy imports are deferred into the
5
5
  submodules that need them.
6
6
  """
7
7
 
8
- __version__ = "3.0.0"
8
+ __version__ = "3.0.3"
9
9
 
10
10
  SCHEMA_VERSION = "cs2-demo-format/3.0"
11
11
  EXPORTER_NAME = "cs2df"
@@ -124,6 +124,14 @@ _GRENADE_ITEMS = {
124
124
  "Smoke Grenade", "Flashbang", "High Explosive Grenade",
125
125
  "Incendiary Grenade", "Molotov", "Decoy Grenade",
126
126
  }
127
+ _GRENADE_ITEM_TO_TYPE = {
128
+ "Smoke Grenade": "smoke",
129
+ "Flashbang": "flashbang",
130
+ "High Explosive Grenade": "hegrenade",
131
+ "Incendiary Grenade": "incendiary",
132
+ "Molotov": "molotov",
133
+ "Decoy Grenade": "decoy",
134
+ }
127
135
  _PISTOL_ITEMS = {
128
136
  "Glock-18", "USP-S", "P2000", "Dual Berettas", "P250", "Five-SeveN",
129
137
  "Tec-9", "CZ75-Auto", "Desert Eagle", "R8 Revolver",
@@ -141,27 +149,28 @@ _PRIMARY_ITEMS = {
141
149
  }
142
150
 
143
151
 
144
- def classify_inventory(items: Any) -> tuple[str | None, str | None, int]:
145
- """Return (primaryWeapon, secondaryWeapon, grenadeCount) from an inventory list.
152
+ def classify_inventory(items: Any) -> tuple[str | None, str | None, int, list[str]]:
153
+ """Return (primaryWeapon, secondaryWeapon, grenadeCount, grenades) from an inventory list.
146
154
 
147
155
  primary/secondary are the first matching gun in each slot; grenadeCount counts
148
- grenade items (max 4 per CS2 rules, not clamped here). Non-weapons (knife, C4,
149
- Zeus) and unknown names are ignored.
156
+ grenade items (max 4 per CS2 rules, not clamped here), while grenades preserves
157
+ the normalized held utility types. Non-weapons (knife, C4, Zeus) and unknown
158
+ names are ignored.
150
159
  """
151
160
  if not isinstance(items, (list, tuple)):
152
- return None, None, 0
161
+ return None, None, 0, []
153
162
  primary: str | None = None
154
163
  secondary: str | None = None
155
- grenades = 0
164
+ grenade_types: list[str] = []
156
165
  for it in items:
157
166
  name = str(it or "").strip()
158
167
  if name in _GRENADE_ITEMS:
159
- grenades += 1
168
+ grenade_types.append(_GRENADE_ITEM_TO_TYPE[name])
160
169
  elif primary is None and name in _PRIMARY_ITEMS:
161
170
  primary = name
162
171
  elif secondary is None and name in _PISTOL_ITEMS:
163
172
  secondary = name
164
- return primary, secondary, grenades
173
+ return primary, secondary, len(grenade_types), grenade_types
165
174
 
166
175
 
167
176
  # ── active weapon normalization ───────────────────────────────────────────────
@@ -121,7 +121,8 @@ def _active_event_round_number(round_model: _RoundModel, row: dict) -> int | Non
121
121
  return None
122
122
  tick = int(row.get("tick") or 0)
123
123
  window = round_model.window_for_round(n)
124
- if window is None or tick < window.freeze_end_tick or tick > window.end_tick:
124
+ event_end = round_model.event_end_tick(n)
125
+ if window is None or event_end is None or tick < window.freeze_end_tick or tick > event_end:
125
126
  return None
126
127
  return n
127
128
 
@@ -372,7 +373,8 @@ def build_bombs(raw: dict, players: PlayerDirectory, round_model: _RoundModel) -
372
373
  continue
373
374
  tick = int(r.get("tick") or 0)
374
375
  window = round_model.window_for_round(n)
375
- if window is None or tick < window.freeze_end_tick or tick > window.end_tick:
376
+ event_end = round_model.event_end_tick(n)
377
+ if window is None or event_end is None or tick < window.freeze_end_tick or tick > event_end:
376
378
  continue
377
379
  actor_sid = _sid(r.get("user_steamid") or r.get("steamid") or r.get("userid"))
378
380
  site = bomb_site_from_place(r.get("user_last_place_name"))
@@ -431,7 +433,8 @@ def build_grenades(raw: dict, players: PlayerDirectory, round_model: _RoundModel
431
433
  continue
432
434
  tick = int(r.get("tick") or 0)
433
435
  window = round_model.window_for_round(n)
434
- if window is None or tick < window.freeze_end_tick or tick > window.end_tick:
436
+ event_end = round_model.event_end_tick(n)
437
+ if window is None or event_end is None or tick < window.freeze_end_tick or tick > event_end:
435
438
  continue
436
439
  gtype = str(r.get("_grenade_type") or "")
437
440
  if gtype not in _GRENADE_TYPE_ENUM:
@@ -460,7 +463,7 @@ def build_grenades(raw: dict, players: PlayerDirectory, round_model: _RoundModel
460
463
  if not _is_valid_side(round_model.side_map.get((n, players.team(thrower_sid)), "unknown")):
461
464
  continue
462
465
  if destroy_tick is not None and (
463
- destroy_tick < tick or window is None or destroy_tick > window.end_tick
466
+ destroy_tick < tick or event_end is None or destroy_tick > event_end
464
467
  ):
465
468
  destroy_tick = None
466
469
 
@@ -489,13 +492,13 @@ def build_grenades(raw: dict, players: PlayerDirectory, round_model: _RoundModel
489
492
  for g in out:
490
493
  if g["grenade"] != "molotov" or g["destroyTick"] is not None:
491
494
  continue
492
- window = round_model.window_for_round(g["roundNumber"])
495
+ event_end = round_model.event_end_tick(g["roundNumber"])
493
496
  best = None
494
497
  best_d2 = None
495
498
  for e in expires:
496
499
  if e["used"] or e["rn"] != g["roundNumber"] or e["tick"] < g["effectTick"]:
497
500
  continue
498
- if window is not None and e["tick"] > window.end_tick:
501
+ if event_end is not None and e["tick"] > event_end:
499
502
  continue
500
503
  ep, gp = e["pos"], g["effectPosition"]
501
504
  d2 = (ep["x"] - gp["x"]) ** 2 + (ep["y"] - gp["y"]) ** 2
@@ -521,11 +524,11 @@ def build_grenades(raw: dict, players: PlayerDirectory, round_model: _RoundModel
521
524
  eid = g.pop("_entityId", None)
522
525
  if g["grenade"] != "smoke" or eid is None:
523
526
  continue
524
- window = round_model.window_for_round(g["roundNumber"])
527
+ event_end = round_model.event_end_tick(g["roundNumber"])
525
528
  for e in smoke_index.get((g["roundNumber"], eid), []):
526
529
  if e["tick"] < g["effectTick"]:
527
530
  continue
528
- if window is not None and e["tick"] > window.end_tick:
531
+ if event_end is not None and e["tick"] > event_end:
529
532
  continue
530
533
  g["destroyTick"] = e["tick"]
531
534
  break
@@ -653,7 +656,7 @@ def build_economies(raw: dict, players: PlayerDirectory, round_model: _RoundMode
653
656
  equip = int(_safe_float(r.get("current_equip_value"), 0))
654
657
  start_money = int(_safe_float(r.get("start_balance"), 0))
655
658
  eco_type = _economy_type(spent, start_money, equip, n)
656
- primary, secondary, grenade_count = classify_inventory(r.get("inventory"))
659
+ primary, secondary, grenade_count, grenades = classify_inventory(r.get("inventory"))
657
660
 
658
661
  out.append({
659
662
  "roundNumber": n,
@@ -668,6 +671,7 @@ def build_economies(raw: dict, players: PlayerDirectory, round_model: _RoundMode
668
671
  "primaryWeapon": primary,
669
672
  "secondaryWeapon": secondary,
670
673
  "grenadeCount": grenade_count,
674
+ "grenades": grenades,
671
675
  })
672
676
  team_round_types.setdefault((n, key), []).append(eco_type)
673
677
 
@@ -48,13 +48,14 @@ _GRENADE_EVENTS = [
48
48
  # (raw userid is an entity slot, not a Steam64).
49
49
  _GRENADE_PLAYER_FIELDS = ["steamid", "X", "Y", "Z"]
50
50
 
51
- # Per-frame props for the replay grid. No `inventory`, no `has_c4` see module
52
- # docstring. `balance` is the cash account (replay `money` column);
51
+ # Per-frame props for the replay grid. `inventory` is sampled here so replay
52
+ # consumers can render current held utility; full-tick paths still avoid it.
53
+ # `balance` is the cash account (replay `money` column);
53
54
  # `current_equip_value` is the equipment value (`equipValue` column).
54
55
  _REPLAY_PROPS = [
55
56
  "steamid", "team_num", "X", "Y", "Z", "yaw", "pitch",
56
57
  "health", "armor", "active_weapon_name", "flash_duration",
57
- "balance", "current_equip_value", "has_defuser", "last_place_name",
58
+ "balance", "current_equip_value", "has_defuser", "last_place_name", "inventory",
58
59
  ]
59
60
 
60
61
  # Lean per-frame props for full-tick duel windows.
@@ -129,6 +130,68 @@ def _safe_ticks_df(parser: DemoParser, props: list[str], ticks: list[int]) -> "p
129
130
  return None
130
131
 
131
132
 
133
+ def _rebuild_blinds(parser: DemoParser, detonates: list[dict]) -> list[dict]:
134
+ """Reconstruct per-victim blind rows from flashbang_detonate + flash_duration.
135
+
136
+ Newer demoparser2 builds no longer emit the player_blind game event; the only
137
+ flash signal left is flashbang_detonate (thrower + position) plus the
138
+ per-player flash_duration tick property. A player whose flash_duration jumps
139
+ across a detonate tick was blinded by that flash; the post-detonate value is
140
+ the (remaining ≈ full) blind duration. When several flashes pop on the same
141
+ tick, attribute by closest detonate position.
142
+ """
143
+ det_by_tick: dict[int, list[dict]] = {}
144
+ for d in detonates:
145
+ tick = int(d.get("tick") or 0)
146
+ if tick > 0:
147
+ det_by_tick.setdefault(tick, []).append(d)
148
+ if not det_by_tick:
149
+ return []
150
+ det_ticks = sorted(det_by_tick)
151
+ sample_ticks = sorted({t - 1 for t in det_ticks} | {t + 2 for t in det_ticks})
152
+ df = _safe_ticks_df(parser, ["flash_duration", "X", "Y"], sample_ticks)
153
+ if df is None or df.empty or "flash_duration" not in df.columns:
154
+ return []
155
+
156
+ state: dict[tuple[int, str], tuple[float, float | None, float | None]] = {}
157
+ has_xy = "X" in df.columns and "Y" in df.columns
158
+ for row in df.itertuples(index=False):
159
+ d = row._asdict()
160
+ sid = d.get("steamid")
161
+ if sid is None:
162
+ continue
163
+ fd = float(d.get("flash_duration") or 0.0)
164
+ x = float(d["X"]) if has_xy and d.get("X") is not None else None
165
+ y = float(d["Y"]) if has_xy and d.get("Y") is not None else None
166
+ state[(int(d["tick"]), str(int(sid)))] = (fd, x, y)
167
+
168
+ out: list[dict] = []
169
+ sids = {sid for (_, sid) in state}
170
+ for tick in det_ticks:
171
+ dets = det_by_tick[tick]
172
+ for sid in sids:
173
+ before = state.get((tick - 1, sid), (0.0, None, None))[0]
174
+ after_state = state.get((tick + 2, sid))
175
+ if after_state is None:
176
+ continue
177
+ after, px, py = after_state
178
+ if after <= before + 0.1:
179
+ continue
180
+ det = dets[0]
181
+ if len(dets) > 1 and px is not None and py is not None:
182
+ det = min(
183
+ dets,
184
+ key=lambda d: (float(d.get("x") or 0.0) - px) ** 2 + (float(d.get("y") or 0.0) - py) ** 2,
185
+ )
186
+ out.append({
187
+ "tick": tick,
188
+ "attacker_steamid": det.get("user_steamid"),
189
+ "user_steamid": sid,
190
+ "blind_duration": after,
191
+ })
192
+ return out
193
+
194
+
132
195
  def parse_demo(dem_path: str, *, sample_rate: int = 8, research: bool = False,
133
196
  window_before_ms: int = 2000, window_after_ms: int = 1000,
134
197
  progress: ProgressFn | None = None) -> dict[str, Any]:
@@ -171,6 +234,14 @@ def parse_demo(dem_path: str, *, sample_rate: int = 8, research: bool = False,
171
234
  )
172
235
  _record("parse.roundEvents", stage_started)
173
236
 
237
+ # demoparser2 新版已移除 player_blind 事件:用 flashbang_detonate + flash_duration
238
+ # tick 采样重建逐人致盲数据(旧版仍发 player_blind 时维持原路径)。
239
+ if not g_round["player_blind"]:
240
+ stage_started = time.perf_counter()
241
+ detonates = _safe_event(p, "flashbang_detonate")
242
+ g_round["player_blind"] = _rebuild_blinds(p, detonates)
243
+ _record("parse.blindRebuild", stage_started)
244
+
174
245
  _p("kills", 0.12)
175
246
  stage_started = time.perf_counter()
176
247
  deaths = _safe_event(p, "player_death",
@@ -280,7 +351,7 @@ def parse_demo(dem_path: str, *, sample_rate: int = 8, research: bool = False,
280
351
  stage_started = time.perf_counter()
281
352
  round_ends = g_round["round_end"]
282
353
  step = max(1, tickrate // max(1, sample_rate))
283
- replay_ticks = _build_sample_ticks(round_ends, round_freeze_ends, step)
354
+ replay_ticks = _build_sample_ticks(round_ends, round_freeze_ends, g_round["round_start"], step)
284
355
  replay_df = _safe_ticks_df(p, _REPLAY_PROPS, replay_ticks) if replay_ticks else None
285
356
  _record("parse.replayGrid", stage_started)
286
357
 
@@ -297,7 +368,8 @@ def parse_demo(dem_path: str, *, sample_rate: int = 8, research: bool = False,
297
368
  _p("duel windows (full tick)", 0.78)
298
369
  anchor_ticks = [int(r.get("tick") or 0) for r in deaths + hurts]
299
370
  duel_windows = _merge_windows(anchor_ticks, round_ends, round_freeze_ends,
300
- tickrate, window_before_ms, window_after_ms)
371
+ g_round["round_start"], tickrate,
372
+ window_before_ms, window_after_ms)
301
373
  duel_ticks: list[int] = []
302
374
  for start, end in duel_windows:
303
375
  duel_ticks.extend(range(start, end + 1))
@@ -373,34 +445,50 @@ def _freeze_by_round(round_freeze_ends: list[dict]) -> dict[int, int]:
373
445
  return out
374
446
 
375
447
 
448
+ def _round_starts_by_round(round_starts: list[dict]) -> dict[int, int]:
449
+ out: dict[int, int] = {}
450
+ for r in round_starts:
451
+ rn = int(r.get("total_rounds_played") or 0) + 1
452
+ t = int(r.get("tick") or 0)
453
+ if rn > 0 and t > 0 and rn not in out:
454
+ out[rn] = t
455
+ return out
456
+
457
+
376
458
  def _round_spans(round_ends: list[dict],
377
- round_freeze_ends: list[dict]) -> list[tuple[int, int]]:
378
- """[(freeze_end_tick, end_tick)] for rounds with a valid window."""
459
+ round_freeze_ends: list[dict],
460
+ round_starts: list[dict] | None = None) -> list[tuple[int, int]]:
461
+ """[(freeze_end_tick, event_end_tick)] for rounds with a valid window."""
379
462
  freeze = _freeze_by_round(round_freeze_ends)
463
+ starts = _round_starts_by_round(round_starts or [])
380
464
  spans: list[tuple[int, int]] = []
381
465
  for r in round_ends:
382
466
  rn = int(r.get("total_rounds_played") or 0)
383
467
  end_t = int(r.get("tick") or 0)
468
+ next_start_t = starts.get(rn + 1)
469
+ event_end_t = next_start_t - 1 if next_start_t is not None else end_t
384
470
  start_t = freeze.get(rn, 0)
385
- if start_t > 0 and end_t > start_t:
386
- spans.append((start_t, end_t))
471
+ if start_t > 0 and event_end_t > start_t:
472
+ spans.append((start_t, event_end_t))
387
473
  return spans
388
474
 
389
475
 
390
476
  def _build_sample_ticks(round_ends: list[dict], round_freeze_ends: list[dict],
477
+ round_starts: list[dict],
391
478
  step: int) -> list[int]:
392
- """Sorted unique sample ticks at interval `step` within active play."""
479
+ """Sorted unique sample ticks through the post-round tail."""
393
480
  ticks: list[int] = []
394
- for start_t, end_t in _round_spans(round_ends, round_freeze_ends):
395
- ticks.extend(range(start_t, end_t, step))
481
+ for start_t, end_t in _round_spans(round_ends, round_freeze_ends, round_starts):
482
+ ticks.extend(range(start_t, end_t + 1, step))
396
483
  return sorted(set(ticks))
397
484
 
398
485
 
399
486
  def _merge_windows(anchor_ticks: list[int], round_ends: list[dict],
400
- round_freeze_ends: list[dict], tickrate: int,
487
+ round_freeze_ends: list[dict], round_starts: list[dict],
488
+ tickrate: int,
401
489
  before_ms: int, after_ms: int) -> list[tuple[int, int]]:
402
490
  """Merged [start, end] full-tick combat windows, clamped to round spans."""
403
- spans = _round_spans(round_ends, round_freeze_ends)
491
+ spans = _round_spans(round_ends, round_freeze_ends, round_starts)
404
492
  if not spans or not anchor_ticks:
405
493
  return []
406
494
  before = (before_ms * tickrate) // 1000
@@ -57,6 +57,7 @@ class _RoundModel:
57
57
  # Indexes built in __post_init__; events are resolved per-row across every
58
58
  # builder, so these turn O(rounds) scans into O(1)/O(log rounds) lookups.
59
59
  _by_round: dict[int, _RoundWindow] = field(init=False, repr=False, default_factory=dict)
60
+ _event_end_by_round: dict[int, int] = field(init=False, repr=False, default_factory=dict)
60
61
  _sorted_starts: list[int] = field(init=False, repr=False, default_factory=list)
61
62
  _sorted_windows: list[_RoundWindow] = field(init=False, repr=False, default_factory=list)
62
63
 
@@ -65,6 +66,12 @@ class _RoundModel:
65
66
  ordered = sorted(self.windows, key=lambda w: w.start_tick)
66
67
  self._sorted_windows = ordered
67
68
  self._sorted_starts = [w.start_tick for w in ordered]
69
+ self._event_end_by_round = {}
70
+ for i, window in enumerate(ordered):
71
+ next_start = ordered[i + 1].start_tick if i + 1 < len(ordered) else None
72
+ self._event_end_by_round[window.round_number] = (
73
+ next_start - 1 if next_start is not None else window.end_tick
74
+ )
68
75
 
69
76
  def window_for_round(self, round_number: int) -> _RoundWindow | None:
70
77
  return self._by_round.get(round_number)
@@ -72,15 +79,19 @@ class _RoundModel:
72
79
  def has_round(self, round_number: int) -> bool:
73
80
  return round_number in self._by_round
74
81
 
82
+ def event_end_tick(self, round_number: int) -> int | None:
83
+ return self._event_end_by_round.get(round_number)
84
+
75
85
  def round_for_tick(self, tick: int) -> int | None:
76
86
  # 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).
87
+ # the last window whose start_tick <= tick; confirm tick is before the
88
+ # next round start (or <= end_tick for the final round).
79
89
  i = bisect.bisect_right(self._sorted_starts, tick) - 1
80
90
  if i < 0:
81
91
  return None
82
92
  window = self._sorted_windows[i]
83
- return window.round_number if window.start_tick <= tick <= window.end_tick else None
93
+ event_end = self.event_end_tick(window.round_number)
94
+ return window.round_number if event_end is not None and window.start_tick <= tick <= event_end else None
84
95
 
85
96
  def round_for_event(self, row: dict) -> int | None:
86
97
  tick = int(row.get("tick") or 0)
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
 
14
14
  from typing import Any
15
15
 
16
- from .enums import normalize_weapon_name
16
+ from .enums import classify_inventory, normalize_weapon_name
17
17
  from .events import PlayerDirectory, _sid
18
18
  from .rounds import _RoundModel
19
19
 
@@ -153,6 +153,10 @@ def build_replay(raw: dict, players: PlayerDirectory, round_model: _RoundModel,
153
153
  if str(v).strip()
154
154
  }
155
155
  df["_plidx"] = df["last_place_name"].map(place_map).fillna(-1).astype("int64")
156
+ if "inventory" in df.columns:
157
+ df["_grenades"] = df["inventory"].map(lambda items: classify_inventory(items)[3])
158
+ else:
159
+ df["_grenades"] = [[] for _ in range(len(df))]
156
160
 
157
161
  bomb_ticks, bomb_carrier = build_bomb_carrier_timeline(raw, players)
158
162
  tick_values = df["tick"].to_numpy()
@@ -174,8 +178,11 @@ def build_replay(raw: dict, players: PlayerDirectory, round_model: _RoundModel,
174
178
  })
175
179
 
176
180
  rounds_out: list[dict] = []
177
- for w in sorted(round_model.windows, key=lambda w: w.round_number):
178
- grid = np.arange(w.freeze_end_tick, w.end_tick, step, dtype="int64")
181
+ windows = sorted(round_model.windows, key=lambda w: w.round_number)
182
+ for i, w in enumerate(windows):
183
+ next_start_tick = windows[i + 1].start_tick if i + 1 < len(windows) else None
184
+ stop_tick = next_start_tick if next_start_tick is not None else w.end_tick + 1
185
+ grid = np.arange(w.freeze_end_tick, stop_tick, step, dtype="int64")
179
186
  if len(grid) == 0:
180
187
  continue
181
188
  sl = _slice_by_tick(df, tick_values, int(grid[0]), int(grid[-1]))
@@ -249,6 +256,7 @@ def _player_track(g, grid, pidx: int, bomb_ticks, bomb_carrier) -> dict | None:
249
256
  widx = np.where(present, widx, -1)
250
257
  plidx = aligned["_plidx"].fillna(-1).astype("int64").to_numpy()
251
258
  plidx = np.where(present, plidx, -1)
259
+ grenades = _align_grenades(aligned, present)
252
260
 
253
261
  alive = (hp > 0).astype("int64")
254
262
  has_kit = _num(aligned["has_defuser"], 0.0).to_numpy().astype(bool)
@@ -270,9 +278,24 @@ def _player_track(g, grid, pidx: int, bomb_ticks, bomb_carrier) -> dict | None:
270
278
  "place": plidx.tolist(),
271
279
  "flash": flash.tolist(),
272
280
  "flags": flags.tolist(),
281
+ "grenades": grenades,
273
282
  }
274
283
 
275
284
 
285
+ def _align_grenades(aligned, present) -> list[list[str]]:
286
+ """Forward-fill sampled inventory lists and clear them when no player row exists."""
287
+ if "_grenades" not in aligned.columns:
288
+ return [[] for _ in range(len(aligned))]
289
+ filled = aligned["_grenades"].ffill().bfill()
290
+ out: list[list[str]] = []
291
+ for has_row, value in zip(present, filled, strict=False):
292
+ if not has_row or not isinstance(value, list):
293
+ out.append([])
294
+ else:
295
+ out.append([str(item) for item in value])
296
+ return out
297
+
298
+
276
299
  # ── duels.json ────────────────────────────────────────────────────────────────
277
300
 
278
301
  def build_duels(raw: dict, players: PlayerDirectory, round_model: _RoundModel,
@@ -24,6 +24,7 @@ EPS = 0.02
24
24
  # stream track columns that must all share length == frameCount
25
25
  _REPLAY_COLS = ("x", "y", "z", "yaw", "pitch", "hp", "armor", "money",
26
26
  "equipValue", "weapon", "place", "flash", "flags")
27
+ _REPLAY_OPTIONAL_FRAME_COLS = ("grenades",)
27
28
  _DUEL_COLS = ("x", "y", "z", "yaw", "pitch", "hp", "flash")
28
29
  _SHOT_COLS = ("tick", "weapon", "x", "y", "z", "vx", "vy", "vz", "yaw", "pitch")
29
30
 
@@ -338,7 +339,8 @@ def _check_shots_stream(shots: dict, n_players: int, round_set: set,
338
339
  break
339
340
  ticks = decode_delta(t.get("tick", []))
340
341
  rd = rounds_by_number.get(t.get("roundNumber"))
341
- if rd and ticks and not all(rd.get("freezeEndTick", 0) <= tk <= rd.get("endTick", 0) for tk in ticks):
342
+ event_end = _round_event_end(rounds_by_number, t.get("roundNumber"))
343
+ if rd and ticks and event_end is not None and not all(rd.get("freezeEndTick", 0) <= tk <= event_end for tk in ticks):
342
344
  err(f"{label}: decoded ticks fall outside the round window")
343
345
 
344
346
 
@@ -369,11 +371,13 @@ def _check_replay_stream(replay: dict, n_players: int, round_set: set,
369
371
  step = rd_obj.get("tickStep", 1)
370
372
  if fc and rd:
371
373
  last_tick = start + (fc - 1) * step
372
- if start < rd.get("freezeEndTick", 0) or last_tick > rd.get("endTick", 0):
374
+ event_end = _round_event_end(rounds_by_number, rn)
375
+ if event_end is not None and (start < rd.get("freezeEndTick", 0) or last_tick > event_end):
373
376
  err(f"{label}: frame grid [{start}, {last_tick}] outside round window")
374
377
  for pi, track in enumerate(rd_obj.get("players", [])):
375
378
  tlabel = f"{label} players[{pi}]"
376
379
  _check_track_frames(tlabel, track, _REPLAY_COLS, fc, n_players, err)
380
+ _check_optional_track_frames(tlabel, track, _REPLAY_OPTIONAL_FRAME_COLS, fc, err)
377
381
  for w in track.get("weapon", []):
378
382
  if w != -1 and not (0 <= w < len(wd)):
379
383
  err(f"{tlabel}: weapon index {w} out of weaponDict range")
@@ -390,6 +394,12 @@ def _check_replay_stream(replay: dict, n_players: int, round_set: set,
390
394
  err(f"{label} projectiles[{qi}]: throwerIndex out of range")
391
395
 
392
396
 
397
+ def _check_optional_track_frames(label: str, track: dict, cols: tuple, frame_count: int, err) -> None:
398
+ bad = {c: len(track.get(c, [])) for c in cols if c in track and len(track.get(c, [])) != frame_count}
399
+ if bad:
400
+ err(f"{label}: optional column lengths != frameCount {frame_count}: {bad}")
401
+
402
+
393
403
  def _check_duels_stream(duels: dict, n_players: int, round_set: set,
394
404
  rounds_by_number: dict, err):
395
405
  for wi, w in enumerate(duels.get("windows", [])):
@@ -406,7 +416,8 @@ def _check_duels_stream(duels: dict, n_players: int, round_set: set,
406
416
  step = w.get("tickStep", 1)
407
417
  if fc and rd:
408
418
  last_tick = start + (fc - 1) * step
409
- if start < rd.get("freezeEndTick", 0) or last_tick > rd.get("endTick", 0):
419
+ event_end = _round_event_end(rounds_by_number, rn)
420
+ if event_end is not None and (start < rd.get("freezeEndTick", 0) or last_tick > event_end):
410
421
  err(f"{label}: frame grid [{start}, {last_tick}] outside round window")
411
422
  anchors = w.get("anchors", [])
412
423
  if not anchors:
@@ -491,7 +502,7 @@ def _check_tick_windows(name: str, rows: list, rounds_by_number: dict, err,
491
502
  if not isinstance(tick, int):
492
503
  continue
493
504
  start = round_row.get("freezeEndTick")
494
- end = round_row.get("endTick")
505
+ end = _round_event_end(rounds_by_number, row.get("roundNumber"))
495
506
  if not isinstance(start, int) or not isinstance(end, int):
496
507
  continue
497
508
  if tick < start or tick > end:
@@ -502,6 +513,17 @@ def _check_tick_windows(name: str, rows: list, rounds_by_number: dict, err,
502
513
  err(f"{name}.json: {len(bad)} row(s) have ticks outside their round window; sample: {sample}")
503
514
 
504
515
 
516
+ def _round_event_end(rounds_by_number: dict, round_number) -> int | None:
517
+ round_row = rounds_by_number.get(round_number)
518
+ if not isinstance(round_row, dict):
519
+ return None
520
+ next_round = rounds_by_number.get(round_number + 1) if isinstance(round_number, int) else None
521
+ if isinstance(next_round, dict) and isinstance(next_round.get("startTick"), int):
522
+ return next_round["startTick"] - 1
523
+ end_tick = round_row.get("endTick")
524
+ return end_tick if isinstance(end_tick, int) else None
525
+
526
+
505
527
  def _check_bomb_lifecycle(bombs: list, err):
506
528
  by_round: dict[object, list[dict]] = {}
507
529
  for b in bombs:
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import pandas as pd
4
+
5
+ from cs2df.events import PlayerDirectory
6
+ from cs2df.parse import _build_sample_ticks
7
+ from cs2df.rounds import _RoundModel, _RoundWindow
8
+ from cs2df.streams import build_replay
9
+
10
+
11
+ def _replay_row(tick: int) -> dict:
12
+ return {
13
+ "steamid": "76561198000000001",
14
+ "tick": tick,
15
+ "active_weapon_name": "ak47",
16
+ "last_place_name": "Middle",
17
+ "inventory": [],
18
+ "X": tick,
19
+ "Y": tick + 1,
20
+ "Z": tick + 2,
21
+ "yaw": 90,
22
+ "pitch": 0,
23
+ "balance": 800,
24
+ "current_equip_value": 2700,
25
+ "health": 100,
26
+ "armor": 100,
27
+ "flash_duration": 0,
28
+ "has_defuser": False,
29
+ }
30
+
31
+
32
+ def test_replay_round_extends_to_sample_before_next_round_start():
33
+ players = PlayerDirectory([
34
+ {"steamId64": "76561198000000001", "name": "p1", "teamKey": "teamA"},
35
+ ])
36
+ round_model = _RoundModel(
37
+ windows=[
38
+ _RoundWindow(round_number=1, start_tick=50, freeze_end_tick=100, end_tick=200),
39
+ _RoundWindow(round_number=2, start_tick=300, freeze_end_tick=360, end_tick=460),
40
+ ],
41
+ side_map={},
42
+ )
43
+ raw = {
44
+ "replay_df": pd.DataFrame([_replay_row(tick) for tick in range(100, 360, 16)]),
45
+ "grenade_trajectories": [],
46
+ }
47
+
48
+ replay = build_replay(raw, players, round_model, tickrate=128, sample_rate=8)
49
+
50
+ assert replay is not None
51
+ first_round = replay["rounds"][0]
52
+ last_tick = first_round["startTick"] + (first_round["frameCount"] - 1) * first_round["tickStep"]
53
+ assert last_tick == 292
54
+ assert last_tick < 300
55
+
56
+
57
+ def test_parse_replay_ticks_include_post_round_tail_before_next_start():
58
+ round_ends = [
59
+ {"total_rounds_played": 1, "tick": 200},
60
+ {"total_rounds_played": 2, "tick": 460},
61
+ ]
62
+ round_freeze_ends = [
63
+ {"total_rounds_played": 0, "tick": 100},
64
+ {"total_rounds_played": 1, "tick": 360},
65
+ ]
66
+ round_starts = [
67
+ {"total_rounds_played": 0, "tick": 50},
68
+ {"total_rounds_played": 1, "tick": 300},
69
+ ]
70
+
71
+ ticks = _build_sample_ticks(round_ends, round_freeze_ends, round_starts, step=16)
72
+
73
+ assert 292 in ticks
74
+ assert 300 not in ticks
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ from cs2df.events import PlayerDirectory, build_kills
4
+ from cs2df.rounds import _RoundModel, _RoundWindow
5
+
6
+
7
+ def test_round_for_tick_assigns_post_round_tail_to_previous_round():
8
+ round_model = _RoundModel(
9
+ windows=[
10
+ _RoundWindow(round_number=1, start_tick=50, freeze_end_tick=100, end_tick=200),
11
+ _RoundWindow(round_number=2, start_tick=300, freeze_end_tick=360, end_tick=460),
12
+ ],
13
+ side_map={},
14
+ )
15
+
16
+ assert round_model.round_for_tick(200) == 1
17
+ assert round_model.round_for_tick(250) == 1
18
+ assert round_model.round_for_tick(299) == 1
19
+ assert round_model.round_for_tick(300) == 2
20
+
21
+
22
+ def test_kills_after_round_end_before_next_start_are_kept_in_previous_round():
23
+ players = PlayerDirectory([
24
+ {"steamId64": "76561198000000001", "name": "attacker", "teamKey": "teamA"},
25
+ {"steamId64": "76561198000000002", "name": "victim", "teamKey": "teamB"},
26
+ ])
27
+ round_model = _RoundModel(
28
+ windows=[
29
+ _RoundWindow(round_number=1, start_tick=50, freeze_end_tick=100, end_tick=200),
30
+ _RoundWindow(round_number=2, start_tick=300, freeze_end_tick=360, end_tick=460),
31
+ ],
32
+ side_map={(1, "teamA"): "t", (1, "teamB"): "ct"},
33
+ )
34
+ raw = {
35
+ "deaths": [{
36
+ "tick": 250,
37
+ "user_steamid": "76561198000000002",
38
+ "attacker_steamid": "76561198000000001",
39
+ "weapon": "ak47",
40
+ "user_X": 1,
41
+ "user_Y": 2,
42
+ "user_Z": 3,
43
+ }],
44
+ }
45
+
46
+ kills = build_kills(raw, players, round_model)
47
+
48
+ assert len(kills) == 1
49
+ assert kills[0]["roundNumber"] == 1
50
+ assert kills[0]["tick"] == 250
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+
8
+ ROOT = Path(__file__).resolve().parents[2]
9
+
10
+
11
+ def test_check_versions_script_accepts_current_release_versions():
12
+ script = ROOT / "tools" / "check_versions.py"
13
+
14
+ result = subprocess.run(
15
+ [sys.executable, str(script)],
16
+ cwd=ROOT,
17
+ text=True,
18
+ stdout=subprocess.PIPE,
19
+ stderr=subprocess.PIPE,
20
+ check=False,
21
+ )
22
+
23
+ assert result.returncode == 0, result.stdout + result.stderr
@@ -30,7 +30,7 @@ wheels = [
30
30
 
31
31
  [[package]]
32
32
  name = "cs2df"
33
- version = "3.0.0"
33
+ version = "3.0.3"
34
34
  source = { editable = "." }
35
35
  dependencies = [
36
36
  { name = "demoparser2" },
File without changes
File without changes
File without changes
File without changes