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.
- {cs2df-3.0.0 → cs2df-3.0.3}/.gitignore +1 -0
- {cs2df-3.0.0 → cs2df-3.0.3}/PKG-INFO +1 -1
- {cs2df-3.0.0 → cs2df-3.0.3}/pyproject.toml +1 -1
- {cs2df-3.0.0 → cs2df-3.0.3}/src/cs2df/__init__.py +1 -1
- {cs2df-3.0.0 → cs2df-3.0.3}/src/cs2df/enums.py +17 -8
- {cs2df-3.0.0 → cs2df-3.0.3}/src/cs2df/events.py +13 -9
- {cs2df-3.0.0 → cs2df-3.0.3}/src/cs2df/parse.py +102 -14
- {cs2df-3.0.0 → cs2df-3.0.3}/src/cs2df/rounds.py +14 -3
- {cs2df-3.0.0 → cs2df-3.0.3}/src/cs2df/streams.py +26 -3
- {cs2df-3.0.0 → cs2df-3.0.3}/src/cs2df/validate.py +26 -4
- cs2df-3.0.3/tests/test_replay_boundaries.py +74 -0
- cs2df-3.0.3/tests/test_round_windows.py +50 -0
- cs2df-3.0.3/tests/test_version_consistency.py +23 -0
- {cs2df-3.0.0 → cs2df-3.0.3}/uv.lock +1 -1
- {cs2df-3.0.0 → cs2df-3.0.3}/README.md +0 -0
- {cs2df-3.0.0 → cs2df-3.0.3}/src/cs2df/cli.py +0 -0
- {cs2df-3.0.0 → cs2df-3.0.3}/src/cs2df/package.py +0 -0
- {cs2df-3.0.0 → cs2df-3.0.3}/tests/test_cli_batch.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cs2df
|
|
3
|
-
Version: 3.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
|
|
@@ -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)
|
|
149
|
-
Zeus) and unknown
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
52
|
-
#
|
|
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
|
-
|
|
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]
|
|
378
|
-
|
|
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
|
|
386
|
-
spans.append((start_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
|
|
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],
|
|
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
|
|
78
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|