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 ADDED
@@ -0,0 +1,11 @@
1
+ """cs2df — reference exporter & validator for cs2-demo-format v3.
2
+
3
+ Keep this module import-light: `cs2df validate` must work without the native
4
+ demoparser2 / pandas stack installed. Heavy imports are deferred into the
5
+ submodules that need them.
6
+ """
7
+
8
+ __version__ = "3.0.0"
9
+
10
+ SCHEMA_VERSION = "cs2-demo-format/3.0"
11
+ EXPORTER_NAME = "cs2df"
cs2df/cli.py ADDED
@@ -0,0 +1,374 @@
1
+ """cs2df CLI — reference exporter & validator for cs2-demo-format v3.
2
+
3
+ Commands:
4
+ cs2df export <demo.dem> [-o out.zip] [--research] [--sample-rate 8] [--compress-level 3]
5
+ cs2df export-batch <dir> [--research] [--sample-rate 8] [--workers N] [--fail-fast] [--descriptive] [--compress-level 3]
6
+ cs2df validate <export.zip> [--spec DIR] [--strict]
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import builtins
13
+ import json
14
+ import os
15
+ import sys
16
+ import time
17
+ from concurrent.futures import ProcessPoolExecutor, as_completed
18
+ from datetime import datetime
19
+ from pathlib import Path
20
+
21
+ DEFAULT_COMPRESS_LEVEL = 3
22
+
23
+
24
+ def main(argv: list[str] | None = None) -> int:
25
+ parser = argparse.ArgumentParser(prog="cs2df",
26
+ description="cs2-demo-format v3 reference exporter & validator")
27
+ sub = parser.add_subparsers(dest="command", required=True)
28
+
29
+ p_exp = sub.add_parser("export", help="export a CS2 .dem to a v3 ZIP package")
30
+ p_exp.add_argument("demo", help="path to the .dem file")
31
+ p_exp.add_argument("-o", "--output", default=None,
32
+ help="output zip path (default: <demo>.zip next to the input)")
33
+ p_exp.add_argument("--research", action="store_true",
34
+ help="also emit duels.json (full-tick combat windows)")
35
+ p_exp.add_argument("--sample-rate", type=int, default=8,
36
+ help="replay stream sample rate in Hz (default 8)")
37
+ p_exp.add_argument("--window-before", type=int, default=2000,
38
+ help="duel window extent before each anchor, ms (default 2000)")
39
+ p_exp.add_argument("--window-after", type=int, default=1000,
40
+ help="duel window extent after each anchor, ms (default 1000)")
41
+ p_exp.add_argument("--compress-level", type=int, default=DEFAULT_COMPRESS_LEVEL,
42
+ help="ZIP DEFLATE compression level 0-9 (default 3)")
43
+ p_exp.add_argument("-q", "--quiet", action="store_true", help="suppress progress output")
44
+
45
+ p_batch = sub.add_parser("export-batch", help="batch-export all .dem files in a directory")
46
+ p_batch.add_argument("directory", help="directory to scan for .dem files (non-recursive)")
47
+ p_batch.add_argument("--research", action="store_true",
48
+ help="also emit duels.json")
49
+ p_batch.add_argument("--sample-rate", type=int, default=8,
50
+ help="replay stream sample rate in Hz (default 8)")
51
+ p_batch.add_argument("--window-before", type=int, default=2000,
52
+ help="duel window extent before each anchor, ms (default 2000)")
53
+ p_batch.add_argument("--window-after", type=int, default=1000,
54
+ help="duel window extent after each anchor, ms (default 1000)")
55
+ p_batch.add_argument("--compress-level", type=int, default=DEFAULT_COMPRESS_LEVEL,
56
+ help="ZIP DEFLATE compression level 0-9 (default 3)")
57
+ p_batch.add_argument("--workers", type=int, default=None,
58
+ help="parallel worker count (default: logical CPU count)")
59
+ p_batch.add_argument("--fail-fast", action="store_true",
60
+ help="stop on first failure")
61
+ p_batch.add_argument("--descriptive", action="store_true",
62
+ help="use descriptive filenames (date_map_teams_score.zip)")
63
+
64
+ p_val = sub.add_parser("validate", help="validate a v3 ZIP package")
65
+ p_val.add_argument("zip", help="path to the .zip file to validate")
66
+ p_val.add_argument("--spec", default=None, help="path to the spec/ directory")
67
+ p_val.add_argument("--strict", action="store_true", help="treat warnings as errors")
68
+
69
+ args = parser.parse_args(argv)
70
+
71
+ if args.command == "export":
72
+ return _cmd_export(args)
73
+ if args.command == "export-batch":
74
+ return _cmd_export_batch(args)
75
+ if args.command == "validate":
76
+ return _cmd_validate(args)
77
+ return 2
78
+
79
+
80
+ def _cmd_export(args) -> int:
81
+ from .package import export_demo
82
+
83
+ dem = Path(args.demo)
84
+ if not dem.exists():
85
+ print(f"ERROR: demo not found: {dem}", file=sys.stderr)
86
+ return 1
87
+ if not _compress_level_ok(args.compress_level):
88
+ print("ERROR: --compress-level must be between 0 and 9", file=sys.stderr)
89
+ return 1
90
+ out = Path(args.output) if args.output else dem.with_suffix(".zip")
91
+
92
+ t0 = time.perf_counter()
93
+ progress = None
94
+ if not args.quiet:
95
+ def progress(stage: str, frac: float) -> None:
96
+ print(f" [{frac * 100:5.1f}%] {stage}")
97
+
98
+ data, _match_meta = export_demo(str(dem), research=args.research,
99
+ sample_rate=args.sample_rate,
100
+ window_before_ms=args.window_before,
101
+ window_after_ms=args.window_after,
102
+ compress_level=args.compress_level,
103
+ progress=progress)
104
+ out.write_bytes(data)
105
+ dt = time.perf_counter() - t0
106
+ print(f"wrote {out} ({len(data) / 1e6:.2f} MB) in {dt:.1f}s")
107
+ return 0
108
+
109
+
110
+ def _cmd_export_batch(args) -> int:
111
+ directory = Path(args.directory)
112
+ if not directory.is_dir():
113
+ print(f"ERROR: not a directory: {directory}", file=sys.stderr)
114
+ return 1
115
+
116
+ demos = sorted(directory.glob("*.dem"))
117
+ if not demos:
118
+ print(f"ERROR: no .dem files found in {directory}", file=sys.stderr)
119
+ return 1
120
+ if not _compress_level_ok(args.compress_level):
121
+ print("ERROR: --compress-level must be between 0 and 9", file=sys.stderr)
122
+ return 1
123
+
124
+ workers = args.workers if args.workers is not None else _default_workers()
125
+ if workers < 1:
126
+ print("ERROR: --workers must be >= 1", file=sys.stderr)
127
+ return 1
128
+
129
+ # Shared export args (picklable, no callbacks).
130
+ export_kwargs = {
131
+ "research": args.research,
132
+ "sample_rate": args.sample_rate,
133
+ "window_before_ms": args.window_before,
134
+ "window_after_ms": args.window_after,
135
+ "compress_level": args.compress_level,
136
+ }
137
+
138
+ report: list[dict] = []
139
+ t0 = time.perf_counter()
140
+ with ProcessPoolExecutor(max_workers=workers) as pool:
141
+ futures = {}
142
+ for dem in demos:
143
+ submitted = time.perf_counter()
144
+ future = pool.submit(_export_one_report, str(dem), str(directory),
145
+ export_kwargs, args.descriptive)
146
+ futures[future] = (dem, submitted)
147
+ for future in as_completed(futures):
148
+ dem, submitted = futures[future]
149
+ try:
150
+ row = future.result()
151
+ except BaseException as exc:
152
+ row = _failed_batch_row(dem, submitted, _format_exception(exc))
153
+ report.append(row)
154
+ if row["ok"]:
155
+ print(f" ok {dem.name} -> {Path(row['zip']).name} "
156
+ f"{row['zipBytes'] / 1e6:.1f}MB {row['durationSeconds']:.1f}s")
157
+ else:
158
+ print(f" FAIL {dem.name}: {row['error']} ({row['durationSeconds']:.1f}s)")
159
+ if args.fail_fast:
160
+ pool.shutdown(wait=False, cancel_futures=True)
161
+ dt = time.perf_counter() - t0
162
+ _write_batch_report(directory, report, dt)
163
+ return 1
164
+
165
+ dt = time.perf_counter() - t0
166
+ _write_batch_report(directory, report, dt)
167
+
168
+ ok = sum(1 for r in report if r["ok"])
169
+ fail = sum(1 for r in report if not r["ok"])
170
+ total_mb = sum(r["demoBytes"] for r in report) / 1e6
171
+ print(f"\n{ok} ok, {fail} failed, {dt:.1f}s total, {total_mb:.1f} MB demo data")
172
+ return 1 if fail else 0
173
+
174
+
175
+ def _cmd_validate(args) -> int:
176
+ from .validate import validate_zip
177
+
178
+ zip_path = Path(args.zip)
179
+ if not zip_path.exists():
180
+ print(f"ERROR: file not found: {zip_path}", file=sys.stderr)
181
+ return 1
182
+ spec_dir = _resolve_spec_dir(args.spec)
183
+ if spec_dir is None:
184
+ print("ERROR: spec directory not found; pass --spec", file=sys.stderr)
185
+ return 1
186
+ ok = validate_zip(zip_path, spec_dir, strict=args.strict)
187
+ return 0 if ok else 1
188
+
189
+
190
+ def _resolve_spec_dir(arg: str | None) -> Path | None:
191
+ if arg:
192
+ p = Path(arg)
193
+ return p if p.exists() else None
194
+ # repo layout: python/src/cs2df/cli.py → ../../../spec
195
+ repo_spec = Path(__file__).resolve().parents[3] / "spec"
196
+ if repo_spec.exists():
197
+ return repo_spec
198
+ return None
199
+
200
+
201
+ # ── batch helpers (module-level for picklability with ProcessPoolExecutor) ──────
202
+
203
+ def _export_one_report(dem_path: str, out_dir: str, export_kwargs: dict,
204
+ descriptive: bool) -> dict:
205
+ """Parse → build → package one demo; return a structured result row."""
206
+ from .package import export_demo
207
+
208
+ dem = Path(dem_path)
209
+ started = time.perf_counter()
210
+ try:
211
+ data, match_meta = export_demo(str(dem), progress=None, **export_kwargs)
212
+ timings = dict(match_meta.get("timingsSeconds") or {})
213
+ if descriptive:
214
+ date_str = _file_date_str(dem)
215
+ name = _build_descriptive_from_meta(match_meta, dem.stem, date_str)
216
+ write_started = time.perf_counter()
217
+ zip_path = _write_unique_zip(Path(out_dir), name, dem.stem, data)
218
+ else:
219
+ name = f"{dem.stem}.zip"
220
+ zip_path = Path(out_dir) / name
221
+ write_started = time.perf_counter()
222
+ zip_path.write_bytes(data)
223
+ timings["batch.writeFile"] = round(time.perf_counter() - write_started, 3)
224
+ duration = time.perf_counter() - started
225
+ return {
226
+ "demo": str(dem),
227
+ "zip": zip_path.name,
228
+ "ok": True,
229
+ "error": None,
230
+ "durationSeconds": round(duration, 3),
231
+ "demoBytes": dem.stat().st_size,
232
+ "zipBytes": len(data),
233
+ "compressLevel": match_meta.get("compressLevel"),
234
+ "timingsSeconds": timings,
235
+ }
236
+ except (KeyboardInterrupt, SystemExit):
237
+ raise
238
+ except BaseException as exc:
239
+ return _failed_batch_row(dem, started, _format_exception(exc))
240
+
241
+
242
+ def _file_date_str(dem: Path) -> str:
243
+ """Return file modification date as YYYY-MM-DD, or 'unknown' on error."""
244
+ try:
245
+ mtime = os.path.getmtime(str(dem))
246
+ return datetime.fromtimestamp(mtime).strftime("%Y-%m-%d")
247
+ except OSError:
248
+ return "unknown"
249
+
250
+
251
+ def _build_descriptive_from_meta(match_meta: dict, stem: str, date_str: str) -> str:
252
+ """Build a descriptive filename: {date}_{map}_{teamA}-vs-{teamB}_{scoreA}-{scoreB}.zip."""
253
+ map_name = _sanitize(match_meta.get("mapName", "unknown"))
254
+ team_a = _sanitize((match_meta.get("teamA") or {}).get("name") or "")
255
+ team_b = _sanitize((match_meta.get("teamB") or {}).get("name") or "")
256
+ score_a = (match_meta.get("teamA") or {}).get("score", 0)
257
+ score_b = (match_meta.get("teamB") or {}).get("score", 0)
258
+
259
+ if team_a and team_b:
260
+ file_stem = f"{date_str}_{map_name}_{team_a}-vs-{team_b}_{score_a}-{score_b}"
261
+ else:
262
+ file_stem = f"{date_str}_{map_name}_{score_a}-{score_b}_{stem}"
263
+ return f"{file_stem}.zip"
264
+
265
+
266
+ def _sanitize(s: str) -> str:
267
+ """Sanitize a string for safe filename use."""
268
+ for ch in r' <>:"/\|?*':
269
+ s = s.replace(ch, '_')
270
+ while '__' in s:
271
+ s = s.replace('__', '_')
272
+ return s.strip('_')
273
+
274
+
275
+ def _write_unique_zip(out_dir: Path, preferred_name: str, stem: str, data: bytes) -> Path:
276
+ """Write bytes without overwriting an existing batch output."""
277
+ preferred = Path(preferred_name)
278
+ suffix = preferred.suffix or ".zip"
279
+ base = preferred.stem or _sanitize(stem) or "export"
280
+ fallback = _sanitize(stem) or "export"
281
+ candidates = [f"{base}{suffix}", f"{base}_{fallback}{suffix}"]
282
+ for i in range(2, 1000):
283
+ candidates.append(f"{base}_{fallback}_{i}{suffix}")
284
+
285
+ for name in candidates:
286
+ path = out_dir / name
287
+ try:
288
+ with builtins.open(path, "xb") as f:
289
+ f.write(data)
290
+ return path
291
+ except FileExistsError:
292
+ continue
293
+ raise FileExistsError(f"could not allocate unique output name for {preferred_name}")
294
+
295
+
296
+ def _failed_batch_row(dem: Path, started: float, error: str) -> dict:
297
+ duration = time.perf_counter() - started
298
+ return {
299
+ "demo": str(dem),
300
+ "zip": None,
301
+ "ok": False,
302
+ "error": error,
303
+ "durationSeconds": round(duration, 3),
304
+ "demoBytes": dem.stat().st_size if dem.exists() else 0,
305
+ "zipBytes": 0,
306
+ "compressLevel": None,
307
+ "timingsSeconds": None,
308
+ }
309
+
310
+
311
+ def _format_exception(exc: BaseException) -> str:
312
+ text = str(exc)
313
+ if text:
314
+ return f"{type(exc).__name__}: {text}"
315
+ return type(exc).__name__
316
+
317
+
318
+ def _mb_per_s(total_bytes: int, duration_seconds: float) -> float:
319
+ if not duration_seconds:
320
+ return 0.0
321
+ return round((total_bytes / 1_000_000) / duration_seconds, 3)
322
+
323
+
324
+ def _compress_level_ok(value: int) -> bool:
325
+ return 0 <= value <= 9
326
+
327
+
328
+ def _aggregate_timings(report: list[dict]) -> dict:
329
+ rows = [r.get("timingsSeconds") for r in report if r.get("ok") and r.get("timingsSeconds")]
330
+ if not rows:
331
+ return {"count": 0, "total": {}, "average": {}}
332
+ keys = sorted({key for row in rows for key in row})
333
+ totals = {
334
+ key: round(sum(float(row.get(key) or 0.0) for row in rows), 3)
335
+ for key in keys
336
+ }
337
+ averages = {key: round(value / len(rows), 3) for key, value in totals.items()}
338
+ return {"count": len(rows), "total": totals, "average": averages}
339
+
340
+
341
+ def _write_batch_report(out_dir: Path, report: list[dict],
342
+ duration_seconds: float) -> None:
343
+ """Write report.json with aggregate stats next to the exported ZIPs."""
344
+ demo_bytes = sum(r["demoBytes"] for r in report)
345
+ zip_bytes = sum(r["zipBytes"] for r in report)
346
+ ok_count = sum(1 for r in report if r["ok"])
347
+ fail_count = sum(1 for r in report if not r["ok"])
348
+
349
+ payload = {
350
+ "createdAt": datetime.now().isoformat(),
351
+ "total": len(report),
352
+ "ok": ok_count,
353
+ "failed": fail_count,
354
+ "durationSeconds": round(duration_seconds, 3),
355
+ "demoBytes": demo_bytes,
356
+ "zipBytes": zip_bytes,
357
+ "demoMegabytesPerSecond": _mb_per_s(demo_bytes, duration_seconds),
358
+ "zipMegabytesPerSecond": _mb_per_s(zip_bytes, duration_seconds),
359
+ "compressionRatio": round(zip_bytes / demo_bytes, 4) if demo_bytes else None,
360
+ "timingsSeconds": _aggregate_timings(report),
361
+ "items": sorted(report, key=lambda r: r["demo"]),
362
+ }
363
+ report_path = out_dir / "report.json"
364
+ report_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2))
365
+ print(f"wrote {report_path}")
366
+
367
+
368
+ def _default_workers() -> int:
369
+ count = getattr(os, "process_cpu_count", os.cpu_count)
370
+ return max(1, count() if callable(count) else (count or 1))
371
+
372
+
373
+ if __name__ == "__main__":
374
+ raise SystemExit(main())
cs2df/enums.py ADDED
@@ -0,0 +1,269 @@
1
+ """Schema-strict enum mappings for cs2-demo-format v3.
2
+
3
+ Provenance: ported from cs2-demo-analysis-kit (originally DrEAmSs59/
4
+ CS2-insight-agent, with the author's permission).
5
+
6
+ Pure data tables — no imports from exporter or demoparser2.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import Any
13
+
14
+ # ── hit groups ────────────────────────────────────────────────────────────────
15
+
16
+ _HITGROUP_ENUM = {"generic", "head", "chest", "stomach", "left_arm", "right_arm",
17
+ "left_leg", "right_leg", "gear", "neck"}
18
+
19
+ _HITGROUP_MAP = {
20
+ "head": "head", "chest": "chest", "stomach": "stomach",
21
+ "leftarm": "left_arm", "left arm": "left_arm", "left_arm": "left_arm",
22
+ "rightarm": "right_arm", "right arm": "right_arm", "right_arm": "right_arm",
23
+ "leftleg": "left_leg", "left leg": "left_leg", "left_leg": "left_leg",
24
+ "rightleg": "right_leg", "right leg": "right_leg", "right_leg": "right_leg",
25
+ "gear": "gear", "neck": "neck", "generic": "generic",
26
+ }
27
+
28
+
29
+ def normalize_hitgroup(raw: str) -> str:
30
+ """Map demoparser2 hitgroup string to hitgroupSchema enum value; fallback 'generic'."""
31
+ return _HITGROUP_MAP.get(str(raw or "").lower().strip(), "generic")
32
+
33
+
34
+ # ── round end reasons ─────────────────────────────────────────────────────────
35
+
36
+ _END_REASON_ENUM = {"t_win", "ct_win", "target_bombed", "bomb_defused", "time_ran_out"}
37
+
38
+ _ROUND_END_REASON_MAP = {
39
+ 1: "target_bombed",
40
+ 7: "bomb_defused",
41
+ 8: "ct_win",
42
+ 9: "t_win",
43
+ 12: "time_ran_out",
44
+ }
45
+
46
+ _ROUND_END_REASON_STR_MAP = {
47
+ "t_killed": "ct_win",
48
+ "ct_killed": "t_win",
49
+ "t_eliminated": "ct_win",
50
+ "ct_eliminated": "t_win",
51
+ "bomb_exploded": "target_bombed",
52
+ "target_bombed": "target_bombed",
53
+ "bomb_defused": "bomb_defused",
54
+ "draw": "time_ran_out",
55
+ "round_draw": "time_ran_out",
56
+ }
57
+
58
+
59
+ def normalize_round_end_reason(raw: Any) -> str:
60
+ """Map demoparser2 round_end.reason (int or str) to v2 endReason enum; fallback 'time_ran_out'."""
61
+ if raw is None or raw == "":
62
+ return "time_ran_out"
63
+ if isinstance(raw, bool):
64
+ return "time_ran_out"
65
+ if isinstance(raw, (int, float)):
66
+ result = _ROUND_END_REASON_MAP.get(int(raw))
67
+ return result if result in _END_REASON_ENUM else "time_ran_out"
68
+ text = str(raw).strip()
69
+ if not text:
70
+ return "time_ran_out"
71
+ key = text.lower().replace(" ", "_")
72
+ if key in _ROUND_END_REASON_STR_MAP:
73
+ mapped = _ROUND_END_REASON_STR_MAP[key]
74
+ return mapped if mapped in _END_REASON_ENUM else "time_ran_out"
75
+ if key in _END_REASON_ENUM:
76
+ return key
77
+ try:
78
+ code = int(text)
79
+ result = _ROUND_END_REASON_MAP.get(code)
80
+ return result if result in _END_REASON_ENUM else "time_ran_out"
81
+ except ValueError:
82
+ return "time_ran_out"
83
+
84
+
85
+ # ── grenades ───────────────────────────────────────────────────────────────────
86
+
87
+ _GRENADE_TYPE_ENUM = {"flashbang", "smoke", "molotov", "incendiary", "hegrenade", "decoy"}
88
+ _GRENADE_WEAPON_TO_TYPE = {
89
+ "smokegrenade": "smoke", "flashbang": "flashbang",
90
+ "hegrenade": "hegrenade", "molotov": "molotov",
91
+ "incgrenade": "incendiary", "decoy": "decoy",
92
+ }
93
+
94
+
95
+ def weapon_to_grenade_type(weapon: str) -> str | None:
96
+ return _GRENADE_WEAPON_TO_TYPE.get(str(weapon or "").strip().lower())
97
+
98
+
99
+ # grenade trajectories: parse_grenades() entity class -> v2 grenade type.
100
+ # Only the *Projectile classes carry a real in-flight trajectory; the held
101
+ # weapon classes (e.g. CMolotovGrenade) sit on the player with NaN coords.
102
+ # Incendiary and molotov both fly as CMolotovProjectile, so both map to
103
+ # "molotov" here — matching how inferno_expire detonations are tagged.
104
+ _GRENADE_PROJECTILE_TO_TYPE = {
105
+ "CSmokeGrenadeProjectile": "smoke",
106
+ "CFlashbangProjectile": "flashbang",
107
+ "CHEGrenadeProjectile": "hegrenade",
108
+ "CMolotovProjectile": "molotov",
109
+ "CDecoyProjectile": "decoy",
110
+ }
111
+
112
+
113
+ def grenade_projectile_to_type(class_name: str) -> str | None:
114
+ return _GRENADE_PROJECTILE_TO_TYPE.get(str(class_name or "").strip())
115
+
116
+
117
+ # ── inventory classification (for player economies) ─────────────────────────────
118
+ #
119
+ # parse_ticks(["inventory"]) returns weapon *display names*. Knives carry skin
120
+ # names (Karambit, M9 Bayonet, ...), so we classify by explicit primary/secondary/
121
+ # grenade sets and ignore everything else (knives, C4, Zeus, unknown).
122
+
123
+ _GRENADE_ITEMS = {
124
+ "Smoke Grenade", "Flashbang", "High Explosive Grenade",
125
+ "Incendiary Grenade", "Molotov", "Decoy Grenade",
126
+ }
127
+ _PISTOL_ITEMS = {
128
+ "Glock-18", "USP-S", "P2000", "Dual Berettas", "P250", "Five-SeveN",
129
+ "Tec-9", "CZ75-Auto", "Desert Eagle", "R8 Revolver",
130
+ }
131
+ _PRIMARY_ITEMS = {
132
+ # SMG
133
+ "MAC-10", "MP9", "MP7", "MP5-SD", "UMP-45", "P90", "PP-Bizon",
134
+ # Rifle / sniper
135
+ "Galil AR", "FAMAS", "AK-47", "M4A4", "M4A1-S", "SSG 08", "SG 553",
136
+ "AUG", "AWP", "G3SG1", "SCAR-20",
137
+ # Shotgun
138
+ "Nova", "XM1014", "Sawed-Off", "MAG-7",
139
+ # LMG
140
+ "M249", "Negev",
141
+ }
142
+
143
+
144
+ def classify_inventory(items: Any) -> tuple[str | None, str | None, int]:
145
+ """Return (primaryWeapon, secondaryWeapon, grenadeCount) from an inventory list.
146
+
147
+ 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.
150
+ """
151
+ if not isinstance(items, (list, tuple)):
152
+ return None, None, 0
153
+ primary: str | None = None
154
+ secondary: str | None = None
155
+ grenades = 0
156
+ for it in items:
157
+ name = str(it or "").strip()
158
+ if name in _GRENADE_ITEMS:
159
+ grenades += 1
160
+ elif primary is None and name in _PRIMARY_ITEMS:
161
+ primary = name
162
+ elif secondary is None and name in _PISTOL_ITEMS:
163
+ secondary = name
164
+ return primary, secondary, grenades
165
+
166
+
167
+ # ── active weapon normalization ───────────────────────────────────────────────
168
+ #
169
+ # demoparser2 tick props expose `active_weapon_name` / `weapon_name` as display
170
+ # names ("AK-47", "M4A1-S", ...). Replay consumers expect stable identifier-like
171
+ # strings, not display text or entity handles.
172
+
173
+ _WEAPON_DISPLAY_TO_CANONICAL = {
174
+ # Pistols
175
+ "glock-18": "glock",
176
+ "usp-s": "usp_silencer",
177
+ "p2000": "hkp2000",
178
+ "dual berettas": "elite",
179
+ "p250": "p250",
180
+ "five-seven": "fiveseven",
181
+ "tec-9": "tec9",
182
+ "cz75-auto": "cz75a",
183
+ "desert eagle": "deagle",
184
+ "r8 revolver": "revolver",
185
+ # SMG
186
+ "mac-10": "mac10",
187
+ "mp9": "mp9",
188
+ "mp7": "mp7",
189
+ "mp5-sd": "mp5sd",
190
+ "ump-45": "ump45",
191
+ "p90": "p90",
192
+ "pp-bizon": "bizon",
193
+ # Rifle / sniper
194
+ "galil ar": "galilar",
195
+ "famas": "famas",
196
+ "ak-47": "ak47",
197
+ "m4a4": "m4a1",
198
+ "m4a1-s": "m4a1_silencer",
199
+ "ssg 08": "ssg08",
200
+ "sg 553": "sg556",
201
+ "aug": "aug",
202
+ "awp": "awp",
203
+ "g3sg1": "g3sg1",
204
+ "scar-20": "scar20",
205
+ # Shotgun / LMG
206
+ "nova": "nova",
207
+ "xm1014": "xm1014",
208
+ "sawed-off": "sawedoff",
209
+ "mag-7": "mag7",
210
+ "m249": "m249",
211
+ "negev": "negev",
212
+ # Utility / equipment
213
+ "smoke grenade": "smokegrenade",
214
+ "flashbang": "flashbang",
215
+ "high explosive grenade": "hegrenade",
216
+ "incendiary grenade": "incgrenade",
217
+ "molotov": "molotov",
218
+ "decoy grenade": "decoy",
219
+ "zeus x27": "taser",
220
+ "c4 explosive": "c4",
221
+ "c4": "c4",
222
+ "knife": "knife",
223
+ "knife_t": "knife_t",
224
+ }
225
+
226
+
227
+ def normalize_weapon_name(raw: Any) -> str | None:
228
+ """Return a stable weapon identifier, or None for handles/unknown values."""
229
+ if isinstance(raw, float) and math.isnan(raw):
230
+ return None
231
+ text = str(raw or "").strip()
232
+ if not text or text.isdigit() or text.lower() in {"nan", "none", "null"}:
233
+ return None
234
+ lower = text.lower()
235
+ if lower.startswith("weapon_"):
236
+ lower = lower[7:]
237
+ if lower in _WEAPON_DISPLAY_TO_CANONICAL:
238
+ return _WEAPON_DISPLAY_TO_CANONICAL[lower]
239
+ ident = lower.replace("-", "_").replace(" ", "_")
240
+ return ident if ident and ident[0].isalpha() and all(c.isalnum() or c == "_" for c in ident) else None
241
+
242
+
243
+ # ── bombs ──────────────────────────────────────────────────────────────────────
244
+ #
245
+ # The bomb_planted `site` field is a per-map bombsite *entity index*, not an A/B
246
+ # constant (dust2 430/431, inferno 81/429, ancient 433/434…), and its ordering
247
+ # does NOT map to A/B (reversed on de_inferno / de_ancient). So A/B is read
248
+ # straight from the demo: the CS2 engine tags each player's current named area
249
+ # in `last_place_name`, which is "BombsiteA" / "BombsiteB" when on a site. This
250
+ # is authoritative, map-agnostic, and needs no per-map calibration.
251
+
252
+ def bomb_site_from_place(place: Any) -> str | None:
253
+ """Map a CS2 place name to "a"/"b"; None if it is not a bombsite area."""
254
+ p = str(place or "").strip().lower()
255
+ if p == "bombsitea":
256
+ return "a"
257
+ if p == "bombsiteb":
258
+ return "b"
259
+ return None
260
+
261
+
262
+ _BOMB_TYPE_MAP = {
263
+ "plant": "plant_begin", "plant_begin": "plant_begin",
264
+ "planted": "planted",
265
+ "defuse": "defuse_begin", "defuse_begin": "defuse_begin",
266
+ "defused": "defused", "defuse_complete": "defused",
267
+ "explode": "exploded", "exploded": "exploded",
268
+ "dropped": "dropped", "picked_up": "picked_up",
269
+ }