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 +11 -0
- cs2df/cli.py +374 -0
- cs2df/enums.py +269 -0
- cs2df/events.py +881 -0
- cs2df/package.py +203 -0
- cs2df/parse.py +532 -0
- cs2df/rounds.py +328 -0
- cs2df/streams.py +472 -0
- cs2df/validate.py +551 -0
- cs2df-3.0.0.dist-info/METADATA +100 -0
- cs2df-3.0.0.dist-info/RECORD +13 -0
- cs2df-3.0.0.dist-info/WHEEL +4 -0
- cs2df-3.0.0.dist-info/entry_points.txt +2 -0
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
|
+
}
|