cs2df 3.0.0__tar.gz → 3.0.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cs2df
3
- Version: 3.0.0
3
+ Version: 3.0.2
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.2"
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"
@@ -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 ───────────────────────────────────────────────
@@ -653,7 +653,7 @@ def build_economies(raw: dict, players: PlayerDirectory, round_model: _RoundMode
653
653
  equip = int(_safe_float(r.get("current_equip_value"), 0))
654
654
  start_money = int(_safe_float(r.get("start_balance"), 0))
655
655
  eco_type = _economy_type(spent, start_money, equip, n)
656
- primary, secondary, grenade_count = classify_inventory(r.get("inventory"))
656
+ primary, secondary, grenade_count, grenades = classify_inventory(r.get("inventory"))
657
657
 
658
658
  out.append({
659
659
  "roundNumber": n,
@@ -668,6 +668,7 @@ def build_economies(raw: dict, players: PlayerDirectory, round_model: _RoundMode
668
668
  "primaryWeapon": primary,
669
669
  "secondaryWeapon": secondary,
670
670
  "grenadeCount": grenade_count,
671
+ "grenades": grenades,
671
672
  })
672
673
  team_round_types.setdefault((n, key), []).append(eco_type)
673
674
 
@@ -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",
@@ -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()
@@ -249,6 +253,7 @@ def _player_track(g, grid, pidx: int, bomb_ticks, bomb_carrier) -> dict | None:
249
253
  widx = np.where(present, widx, -1)
250
254
  plidx = aligned["_plidx"].fillna(-1).astype("int64").to_numpy()
251
255
  plidx = np.where(present, plidx, -1)
256
+ grenades = _align_grenades(aligned, present)
252
257
 
253
258
  alive = (hp > 0).astype("int64")
254
259
  has_kit = _num(aligned["has_defuser"], 0.0).to_numpy().astype(bool)
@@ -270,9 +275,24 @@ def _player_track(g, grid, pidx: int, bomb_ticks, bomb_carrier) -> dict | None:
270
275
  "place": plidx.tolist(),
271
276
  "flash": flash.tolist(),
272
277
  "flags": flags.tolist(),
278
+ "grenades": grenades,
273
279
  }
274
280
 
275
281
 
282
+ def _align_grenades(aligned, present) -> list[list[str]]:
283
+ """Forward-fill sampled inventory lists and clear them when no player row exists."""
284
+ if "_grenades" not in aligned.columns:
285
+ return [[] for _ in range(len(aligned))]
286
+ filled = aligned["_grenades"].ffill().bfill()
287
+ out: list[list[str]] = []
288
+ for has_row, value in zip(present, filled, strict=False):
289
+ if not has_row or not isinstance(value, list):
290
+ out.append([])
291
+ else:
292
+ out.append([str(item) for item in value])
293
+ return out
294
+
295
+
276
296
  # ── duels.json ────────────────────────────────────────────────────────────────
277
297
 
278
298
  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
 
@@ -374,6 +375,7 @@ def _check_replay_stream(replay: dict, n_players: int, round_set: set,
374
375
  for pi, track in enumerate(rd_obj.get("players", [])):
375
376
  tlabel = f"{label} players[{pi}]"
376
377
  _check_track_frames(tlabel, track, _REPLAY_COLS, fc, n_players, err)
378
+ _check_optional_track_frames(tlabel, track, _REPLAY_OPTIONAL_FRAME_COLS, fc, err)
377
379
  for w in track.get("weapon", []):
378
380
  if w != -1 and not (0 <= w < len(wd)):
379
381
  err(f"{tlabel}: weapon index {w} out of weaponDict range")
@@ -390,6 +392,12 @@ def _check_replay_stream(replay: dict, n_players: int, round_set: set,
390
392
  err(f"{label} projectiles[{qi}]: throwerIndex out of range")
391
393
 
392
394
 
395
+ def _check_optional_track_frames(label: str, track: dict, cols: tuple, frame_count: int, err) -> None:
396
+ bad = {c: len(track.get(c, [])) for c in cols if c in track and len(track.get(c, [])) != frame_count}
397
+ if bad:
398
+ err(f"{label}: optional column lengths != frameCount {frame_count}: {bad}")
399
+
400
+
393
401
  def _check_duels_stream(duels: dict, n_players: int, round_set: set,
394
402
  rounds_by_number: dict, err):
395
403
  for wi, w in enumerate(duels.get("windows", [])):
@@ -30,7 +30,7 @@ wheels = [
30
30
 
31
31
  [[package]]
32
32
  name = "cs2df"
33
- version = "3.0.0"
33
+ version = "3.0.2"
34
34
  source = { editable = "." }
35
35
  dependencies = [
36
36
  { name = "demoparser2" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes