From e05ab8c28b4b1aa3e229dd33c5f14a04fa4a0bf3 Mon Sep 17 00:00:00 2001 From: Halcy0nS Date: Wed, 5 Aug 2026 12:15:43 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20web=E7=AB=AF=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=9C=AC=E5=9C=B05E=20Demo=E5=B9=B6=E5=8F=AF?= =?UTF-8?q?=E8=A7=86=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Local Demo 模式:浏览器上传 .dem 文件 -> 服务端 inspect (地图/玩家/回合数)-> 选择共同玩家 -> 后台复用现有解析链 -> Canvas 回放界面展示。 后端: - server/local_demo_pipeline.py: session 管理、上传校验、Demo inspect、共同玩家交集、后台解析、原子 JSON 输出 - server/config.py: LOCAL_DEMO_DIR/MAX_FILES/MAX_FILE_BYTES/ MAX_TOTAL_BYTES/SESSION_TTL 环境变量配置 - server/web_server.py: /api/local-demos/inspect 和 /api/local-demos/analyze 端点(loopback only),自定义 Request 类放宽 multipart 上传限制,state 新增 source 字段 前端: - app.js: localdemos 平台、inspectLocalDemos (FormData 上传)、 runLocalDemoAnalysis、文件列表/玩家选择 UI 逻辑 - index.html: Local Demo 平台按钮、文件上传控件、inspect 按钮、 玩家选择下拉框 工具与测试: - tools/parse_local_demos.py: CLI 开发工具,调用同一服务层 - server/tests/test_local_demo_web.py: 6 个 Flask 测试(回环限制、 上传限制、inspect、共同玩家、后台任务、session 清理) - server/tests/test_frontend_js.py: Node 测试覆盖上传/inspect/ analyze 前端流程 修复 Codex 遗留的 2 个测试失败: - _local_demo_public_info 返回原始文件名而非 UUID 存储名 - _local_request_is_loopback 清理重复检查 - 测试 URL 构造改为直接使用 result.domain(与前端一致) 端到端验收通过:3 个 de_nuke Demo,52 回合,K/D 1.65,AWP rate 29.6%,JSON 合约全部校验通过。 --- server/config.py | 25 +++ server/local_demo_pipeline.py | 307 ++++++++++++++++++++++++++++ server/static/app.js | 195 +++++++++++++++++- server/templates/index.html | 44 +++- server/tests/test_frontend_js.py | 71 +++++++ server/tests/test_local_demo_web.py | 190 +++++++++++++++++ server/web_server.py | 235 ++++++++++++++++++++- tools/parse_local_demos.py | 143 +++++++++++++ 8 files changed, 1196 insertions(+), 14 deletions(-) create mode 100644 server/local_demo_pipeline.py create mode 100644 server/tests/test_local_demo_web.py create mode 100644 tools/parse_local_demos.py diff --git a/server/config.py b/server/config.py index 3db70ef..8ab0fee 100644 --- a/server/config.py +++ b/server/config.py @@ -28,9 +28,34 @@ def _port_env(name, default): return value +def _bounded_int_env(name, default, minimum, maximum): + try: + value = int(os.getenv(name, str(default))) + except (TypeError, ValueError, OverflowError): + value = int(default) + return max(int(minimum), min(int(maximum), value)) + + DEMO_DIR = _absolute_path_env( "CS_SCOUT_DEMO_DIR", os.path.join(BASE_DIR, "demos_opponents") ) +LOCAL_DEMO_DIR = _absolute_path_env( + "CS_SCOUT_LOCAL_DEMO_DIR", os.path.join(DEMO_DIR, "local_sessions") +) +LOCAL_DEMO_MAX_FILES = _bounded_int_env( + "CS_SCOUT_LOCAL_DEMO_MAX_FILES", 10, 1, 10 +) +LOCAL_DEMO_MAX_FILE_BYTES = _bounded_int_env( + "CS_SCOUT_LOCAL_DEMO_MAX_FILE_BYTES", 1024 ** 3, 1, 1024 ** 3 +) +LOCAL_DEMO_MAX_TOTAL_BYTES = _bounded_int_env( + "CS_SCOUT_LOCAL_DEMO_MAX_TOTAL_BYTES", 4 * 1024 ** 3, + 1, 4 * 1024 ** 3, +) +LOCAL_DEMO_SESSION_TTL_SECONDS = _bounded_int_env( + "CS_SCOUT_LOCAL_DEMO_SESSION_TTL_SECONDS", 24 * 60 * 60, + 60 * 60, 7 * 24 * 60 * 60, +) OUTPUT_DIR = _absolute_path_env( "CS_SCOUT_OUTPUT_DIR", os.path.join(BASE_DIR, "output") ) diff --git a/server/local_demo_pipeline.py b/server/local_demo_pipeline.py new file mode 100644 index 0000000..69667aa --- /dev/null +++ b/server/local_demo_pipeline.py @@ -0,0 +1,307 @@ +"""Local 5E Demo inspection, session storage, and replay parsing.""" + +from __future__ import annotations + +import json +import logging +import re +import shutil +import time +import uuid +from pathlib import Path + +import pandas as pd + +from demoparser2 import DemoParser + +import combat +import config +import maps +import parse +import pipeline +import player_json + + +log = logging.getLogger("local_demo") +SESSION_ID_RE = re.compile(r"^[0-9a-f]{32}$") + + +class LocalDemoError(ValueError): + """A user-correctable local Demo validation or session error.""" + + +def _slug(value: str) -> str: + value = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()) + return value.strip("-._") or "local-player" + + +def _session_root() -> Path: + root = Path(config.LOCAL_DEMO_DIR).resolve() + root.mkdir(parents=True, exist_ok=True) + return root + + +def _session_path(session_id: str) -> Path: + if not isinstance(session_id, str) or not SESSION_ID_RE.fullmatch(session_id): + raise LocalDemoError("无效的本地 Demo 会话") + root = _session_root() + path = (root / session_id).resolve() + if path.parent != root: + raise LocalDemoError("无效的本地 Demo 会话") + return path + + +def create_session() -> tuple[str, Path]: + root = _session_root() + for _ in range(5): + session_id = uuid.uuid4().hex + path = root / session_id + try: + path.mkdir() + except FileExistsError: + continue + return session_id, path + raise LocalDemoError("无法创建本地 Demo 会话") + + +def _validate_paths(paths: list[Path]) -> list[Path]: + if not paths: + raise LocalDemoError("至少需要一个 Demo 文件") + if len(paths) > config.LOCAL_DEMO_MAX_FILES: + raise LocalDemoError( + f"最多同时上传 {config.LOCAL_DEMO_MAX_FILES} 个 Demo" + ) + + checked = [] + total_size = 0 + for raw_path in paths: + path = Path(raw_path).resolve() + if path.suffix.lower() != ".dem": + raise LocalDemoError("只支持 .dem 文件") + if not path.is_file(): + raise LocalDemoError(f"Demo 文件不存在:{path.name}") + size = path.stat().st_size + if size > config.LOCAL_DEMO_MAX_FILE_BYTES: + raise LocalDemoError(f"单个 Demo 文件过大:{path.name}") + total_size += size + checked.append(path) + if total_size > config.LOCAL_DEMO_MAX_TOTAL_BYTES: + raise LocalDemoError("本次上传的 Demo 总大小超过限制") + return checked + + +def _players_at_first_round(parser: DemoParser, rounds: list[dict]) -> dict[str, str]: + if not rounds: + raise LocalDemoError("Demo 中没有可用回合") + frame = parser.parse_ticks( + ["steamid", "name", "team_name"], ticks=[rounds[0]["fe_tick"]] + ) + frame = frame if isinstance(frame, pd.DataFrame) else pd.DataFrame(frame) + required = {"steamid", "name"} + if frame.empty or not required.issubset(frame.columns): + raise LocalDemoError("Demo 中没有可识别的玩家名单") + players = {} + for _, row in frame.iterrows(): + sid = str(row.get("steamid", "")).strip() + if not sid or sid == "nan": + continue + name = str(row.get("name", "")).strip() + if name == "nan": + name = "" + players.setdefault(sid, name) + if not players: + raise LocalDemoError("Demo 中没有可识别的玩家名单") + return players + + +def _inspect_one(path: Path) -> dict: + try: + parser = DemoParser(str(path)) + header = parser.parse_header() + events = dict(parser.parse_events( + ["round_freeze_end", "round_announce_match_start", "round_end"], + other=["tick"], + )) + rounds = parse.get_round_table(events) + players = _players_at_first_round(parser, rounds) + except LocalDemoError: + raise + except Exception as exc: + log.exception("Could not inspect local Demo %s", path) + raise LocalDemoError(f"无法读取 Demo:{path.name}") from exc + + map_name = str(header.get("map_name", "")).strip() + if not map_name: + raise LocalDemoError(f"Demo 没有地图信息:{path.name}") + return { + "path": str(path), + "name": path.name, + "size": path.stat().st_size, + "map": map_name, + "rounds": len(rounds), + "players": players, + } + + +def inspect_demos(paths: list[Path]) -> dict: + checked = _validate_paths(paths) + details = [_inspect_one(path) for path in checked] + maps_found = {item["map"] for item in details} + if len(maps_found) != 1: + raise LocalDemoError("所有 Demo 必须使用同一张地图") + map_name = next(iter(maps_found)) + if map_name not in maps.available_maps(): + raise LocalDemoError(f"地图资源未准备:{map_name}") + + common_ids = set(details[0]["players"]) + for item in details[1:]: + common_ids.intersection_update(item["players"]) + if not common_ids: + raise LocalDemoError("这些 Demo 没有共同出现的玩家") + + players = [] + for sid in sorted(common_ids): + names = [item["players"].get(sid, "") for item in details] + username = next((name for name in names if name), sid) + players.append({ + "steamid": sid, + "username": username, + "appearances": len(details), + }) + return {"map": map_name, "files": details, "players": players} + + +def write_manifest(session_id: str, info: dict, original_names: list[str]) -> None: + session = _session_path(session_id) + if not session.is_dir(): + raise LocalDemoError("本地 Demo 会话不存在") + if len(original_names) != len(info["files"]): + raise LocalDemoError("本地 Demo 会话文件数量不一致") + files = [] + for detail, original_name in zip(info["files"], original_names): + files.append({ + "name": str(original_name)[:255], + "stored_name": Path(detail["path"]).name, + "size": detail["size"], + "rounds": detail["rounds"], + }) + manifest = { + "session_id": session_id, + "created_at": time.time(), + "map": info["map"], + "files": files, + "players": info["players"], + } + temporary = session / ".manifest.json.tmp" + with temporary.open("w", encoding="utf-8") as handle: + json.dump(manifest, handle, ensure_ascii=False, indent=2) + handle.write("\n") + temporary.replace(session / "manifest.json") + + +def load_manifest(session_id: str) -> dict: + session = _session_path(session_id) + manifest_path = session / "manifest.json" + try: + with manifest_path.open(encoding="utf-8") as handle: + manifest = json.load(handle) + except (OSError, json.JSONDecodeError) as exc: + raise LocalDemoError("本地 Demo 会话已失效") from exc + if manifest.get("session_id") != session_id: + raise LocalDemoError("本地 Demo 会话无效") + stored_paths = [] + for item in manifest.get("files", []): + stored_name = item.get("stored_name", "") + if not re.fullmatch(r"[0-9a-f]{32}\.dem", stored_name): + raise LocalDemoError("本地 Demo 会话文件无效") + path = (session / stored_name).resolve() + if path.parent != session or not path.is_file(): + raise LocalDemoError("本地 Demo 会话文件缺失") + stored_paths.append(path) + if not stored_paths: + raise LocalDemoError("本地 Demo 会话没有文件") + manifest["paths"] = stored_paths + return manifest + + +def cleanup_session(session_id: str) -> None: + try: + shutil.rmtree(_session_path(session_id), ignore_errors=True) + except LocalDemoError: + return + + +def cleanup_expired_sessions() -> None: + root = _session_root() + cutoff = time.time() - config.LOCAL_DEMO_SESSION_TTL_SECONDS + for child in root.iterdir(): + if not child.is_dir(): + continue + try: + if child.stat().st_mtime < cutoff: + shutil.rmtree(child, ignore_errors=True) + except OSError: + log.warning("Could not inspect local Demo session %s", child) + + +def run_local_demos( + demo_paths: list[Path], + *, + steamid: str, + username: str, + domain: str, + map_name: str, + output_path: Path, + progress_cb=None, +) -> dict: + rounds = [] + combat_stats = [] + per_demo = [] + total = len(demo_paths) + + for demo_index, raw_path in enumerate(demo_paths): + path = Path(raw_path).resolve() + if progress_cb: + progress_cb(demo_index, total, f"解析 Demo {demo_index + 1}/{total}...") + parsed = parse.parse_demo(str(path), steamid) or [] + pipeline.assemble_round_offset(parsed, demo_index) + rounds.extend(parsed) + + stats = combat.parse_combat_stats(str(path), steamid) + if stats is not None: + combat_stats.append(stats) + per_demo.append({ + "demo": path.name, + "rounds": len(parsed), + "path_points": sum(len(r.get("path", [])) for r in parsed), + "grenades": sum(len(r.get("grenades", [])) for r in parsed), + "death_rounds": sum(r.get("death_t") is not None for r in parsed), + }) + + aggregate = combat.aggregate_combat_stats(combat_stats) + payload = player_json.build( + username, domain, steamid, map_name, rounds, aggregate, + ) + payload["demos_found"] = len(demo_paths) + + output_path = Path(output_path).resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + temporary = output_path.with_name(output_path.name + ".tmp") + with temporary.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + temporary.replace(output_path) + + return { + "output": str(output_path), + "username": username, + "steamid": str(steamid), + "domain": domain, + "map": map_name, + "demos": per_demo, + "total_rounds": payload["round_count"], + "total_path_points": sum(item["path_points"] for item in per_demo), + "total_grenades": sum(item["grenades"] for item in per_demo), + "total_death_rounds": sum(item["death_rounds"] for item in per_demo), + "combat_stats": aggregate, + } diff --git a/server/static/app.js b/server/static/app.js index ca321a0..e747fe0 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -31,10 +31,67 @@ let publicMonitoringEnabled = false; // storage. Browser extensions and password managers still apply their own // form-handling policies. let accessKey = ""; +let localDemoSessionId = ""; +let localDemoPlayers = []; +let localDemoFiles = []; const PLAYBACK_SPEEDS = [1, 2, 4]; const clock = { elapsed: 0, playing: true, speed: 2, last: null, raf: null }; + + +function localDemoReady() { + const select = $("#local-demo-player"); + return activePlatform === "localdemos" && Boolean( + localDemoSessionId && select && select.value + ); +} + +function formatBytes(bytes) { + const value = Number(bytes); + if (!Number.isFinite(value) || value < 0) return "?"; + if (value < 1024 * 1024) return `${Math.round(value / 1024)} KiB`; + if (value < 1024 * 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MiB`; + return `${(value / 1024 / 1024 / 1024).toFixed(2)} GiB`; +} + +function updateLocalDemoFileList(serverFiles = null) { + const list = $("#local-demo-file-list"); + if (!list) return; + list.replaceChildren(); + const files = Array.isArray(serverFiles) ? serverFiles : localDemoFiles; + for (const file of files) { + const item = document.createElement("div"); + item.textContent = `${String(file.name || "Demo")} ? ${formatBytes(file.size)}`; + list.appendChild(item); + } + if (!files.length) list.textContent = "No Demo files selected"; +} + +function updateLocalDemoRunButton() { + const runButton = $("#run"); + if (!runButton || activePlatform !== "localdemos") return; + runButton.disabled = analysisBusy || !localDemoReady(); +} + +function resetLocalDemoState() { + localDemoSessionId = ""; + localDemoPlayers = []; + const select = $("#local-demo-player"); + if (select) { + select.replaceChildren(); + const option = document.createElement("option"); + option.value = ""; + option.textContent = "Inspect Demos first"; + select.appendChild(option); + select.disabled = true; + } + const info = $("#local-demo-info"); + if (info) info.hidden = true; + const map = $("#local-demo-map"); + if (map) map.textContent = ""; + updateLocalDemoRunButton(); +} function localAnalysisEnabled() { return Boolean(document.body && document.body.dataset && document.body.dataset.localAnalysis === "true"); @@ -87,7 +144,8 @@ function setAnalysisBusy(busy) { const runButton = $("#run"); if (runButton) { runButton.disabled = disabled || - (activePlatform === "perfectworld" && !pwaCanAnalyze); + (activePlatform === "perfectworld" && !pwaCanAnalyze) || + (activePlatform === "localdemos" && !localDemoReady()); } for (const button of document.querySelectorAll("[data-analysis-mode]")) { button.disabled = disabled; @@ -97,29 +155,41 @@ function setAnalysisBusy(busy) { } const depth = $("#depth"); if (depth && activePlatform === "perfectworld") depth.disabled = disabled; + const fileInput = $("#local-demo-files"); + if (fileInput) fileInput.disabled = disabled; + const inspectButton = $("#local-demo-inspect"); + if (inspectButton) inspectButton.disabled = disabled || localDemoFiles.length === 0; + const playerSelect = $("#local-demo-player"); + if (playerSelect) playerSelect.disabled = disabled || !localDemoSessionId || !localDemoPlayers.length; + updateLocalDemoRunButton(); } function updatePlatformControls() { const perfectWorld = activePlatform === "perfectworld"; + const localDemos = activePlatform === "localdemos"; for (const button of document.querySelectorAll("[data-platform]")) { const active = button.dataset.platform === activePlatform; button.classList.toggle("active", active); button.setAttribute("aria-pressed", String(active)); } for (const section of document.querySelectorAll("[data-five-e-only]")) { - section.hidden = perfectWorld; + section.hidden = perfectWorld || localDemos; } const hint = $("#pwa-hint"); + for (const section of document.querySelectorAll("[data-local-demos-only]")) { + section.hidden = !localDemos; + } + updateLocalDemoFileList(); if (hint) hint.hidden = !perfectWorld; const mapSelect = $("#map"); - if (mapSelect) mapSelect.disabled = perfectWorld || availableMapNames.length === 0; + if (mapSelect) mapSelect.disabled = perfectWorld || localDemos || availableMapNames.length === 0; const playerLabel = $("#player-input-label"); if (playerLabel) playerLabel.textContent = perfectWorld ? "完美平台用户名" : "5E 用户名"; for (let index = 0; index < 5; index += 1) { const input = $(`#u${index}`); if (!input) continue; - input.readOnly = perfectWorld; - input.setAttribute("aria-readonly", String(perfectWorld)); + input.readOnly = perfectWorld || localDemos; + input.setAttribute("aria-readonly", String(perfectWorld || localDemos)); input.setAttribute( "aria-label", perfectWorld ? `完美平台用户名 ${index + 1}` : @@ -131,6 +201,10 @@ function updatePlatformControls() { const runButton = $("#run"); if (runButton) runButton.textContent = perfectWorld ? "开始分析" : "开始扫描"; const emptyTitle = $("#empty-title"); + if (runButton && localDemos) runButton.textContent = "Start parsing"; + updateLocalDemoRunButton(); + if (localDemos) setStatus("Select local Demos, inspect them, then choose a player."); + if (!localDemos) resetLocalDemoState(); const emptyDescription = $("#empty-description"); if (emptyTitle) emptyTitle.textContent = perfectWorld ? "等待进入完美平台对局" : "等待扫描数据"; if (emptyDescription) { @@ -168,8 +242,8 @@ async function configurePerfectWorld() { async function setPlatform(platform) { if (analysisBusy) return; - if (platform !== "5e" && platform !== "perfectworld") return; - if (activePlatform === "5e" && platform === "perfectworld") { + if (platform !== "5e" && platform !== "perfectworld" && platform !== "localdemos") return; + if (activePlatform === "5e" && platform !== "5e") { fiveEUsernames = Array.from({ length: 5 }, (_item, index) => { const input = $(`#u${index}`); return input ? input.value : ""; @@ -177,6 +251,7 @@ async function setPlatform(platform) { } activePlatform = platform; pwaCanAnalyze = false; + if (platform === "localdemos") resetLocalDemoState(); if (platform === "perfectworld") showPerfectWorldTargets([]); pollEpoch += 1; const epoch = pollEpoch; @@ -185,7 +260,9 @@ async function setPlatform(platform) { resetResults(); updatePlatformControls(); setAnalysisBusy(false); - if (platform === "perfectworld") { + if (platform === "localdemos") { + setStatus("Select local Demos, inspect them, then choose a player."); + } else if (platform === "perfectworld") { setStatus("正在连接完美平台自动侦察…"); try { await configurePerfectWorld(); @@ -312,6 +389,19 @@ function wireControls() { for (const button of platformButtons) { button.addEventListener("click", () => { void setPlatform(button.dataset.platform); }); } + const localFileInput = $("#local-demo-files"); + if (localFileInput) { + localFileInput.addEventListener("change", () => { + localDemoFiles = localFileInput.files ? Array.from(localFileInput.files) : []; + resetLocalDemoState(); + updateLocalDemoFileList(); + setAnalysisBusy(false); + }); + } + const localInspectButton = $("#local-demo-inspect"); + if (localInspectButton) localInspectButton.addEventListener("click", () => { void inspectLocalDemos(); }); + const localPlayerSelect = $("#local-demo-player"); + if (localPlayerSelect) localPlayerSelect.addEventListener("change", updateLocalDemoRunButton); setPlaybackSpeed(clock.speed); setAnalysisMode(analysisMode); updatePlatformControls(); @@ -399,7 +489,7 @@ async function loadMaps() { option.textContent = String(mapName); select.appendChild(option); } - select.disabled = activePlatform === "perfectworld" || mapNames.length === 0; + select.disabled = activePlatform === "perfectworld" || activePlatform === "localdemos" || mapNames.length === 0; if (mapNames.length === 0) setStatus("没有可用地图,请先生成地图资源。"); } catch (error) { availableMapNames = []; @@ -510,7 +600,93 @@ function resetResults() { renderFailures(); } + + +async function inspectLocalDemos() { + const input = $("#local-demo-files"); + if (!input || !input.files || input.files.length === 0) { + setStatus("Select one or more .dem files first."); + return; + } + localDemoFiles = Array.from(input.files); + localDemoSessionId = ""; + localDemoPlayers = []; + setAnalysisBusy(true); + try { + const formData = new FormData(); + for (const file of localDemoFiles) formData.append("demos", file, file.name); + const data = await requestJSON("/api/local-demos/inspect", { + method: "POST", + body: formData + }); + localDemoSessionId = String(data.session_id || ""); + localDemoPlayers = Array.isArray(data.players) ? data.players : []; + updateLocalDemoFileList(Array.isArray(data.files) ? data.files : null); + const map = $("#local-demo-map"); + if (map) map.textContent = `Map: ${String(data.map || "unknown")} ? ${localDemoFiles.length} files`; + const info = $("#local-demo-info"); + if (info) info.hidden = false; + const select = $("#local-demo-player"); + if (select) { + select.replaceChildren(); + for (const player of localDemoPlayers) { + const option = document.createElement("option"); + option.value = String(player.steamid || ""); + option.textContent = `${String(player.username || player.steamid)} (${String(player.steamid)})`; + select.appendChild(option); + } + select.value = localDemoPlayers.length === 1 ? String(localDemoPlayers[0].steamid) : ""; + select.disabled = localDemoPlayers.length === 0; + } + updateLocalDemoRunButton(); + setStatus(`Demo inspection complete: ${localDemoPlayers.length} common players found.`); + } catch (error) { + resetLocalDemoState(); + setStatus(`Demo inspection failed: ${error.message}`); + } finally { + setAnalysisBusy(false); + } +} + +async function runLocalDemoAnalysis() { + const select = $("#local-demo-player"); + if (!localDemoReady() || !select) { + setStatus("Inspect the Demos and select a common player first."); + return; + } + setAnalysisBusy(true); + try { + await requestJSON("/api/local-demos/analyze", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: localDemoSessionId, steamid: select.value }) + }); + lastKnownAnalysisRunning = true; + pollEpoch += 1; + clearPollTimer(); + resetResults(); + setStatus("Local Demo analysis started..."); + await poll(pollEpoch); + } catch (error) { + if (error.status === 409) { + lastKnownAnalysisRunning = true; + pollEpoch += 1; + clearPollTimer(); + resetResults(); + setStatus("Another analysis is already running; restoring progress..."); + await poll(pollEpoch); + return; + } + lastKnownAnalysisRunning = false; + setStatus(`Local Demo analysis failed: ${error.message}`); + setAnalysisBusy(false); + } +} async function runAnalysis() { + if (activePlatform === "localdemos") { + await runLocalDemoAnalysis(); + return; + } if (activePlatform === "perfectworld") { await runPerfectWorldAnalysis(); return; @@ -809,5 +985,6 @@ if (typeof module !== "undefined") { wireControls, setAnalysisMode, setAnalysisBusy, runAnalysis, connectWithEnteredKey, setPlatform, updatePlatformControls, showPerfectWorldTargets, runPerfectWorldAnalysis, + inspectLocalDemos, runLocalDemoAnalysis, }; } diff --git a/server/templates/index.html b/server/templates/index.html index 0f1bb22..6e9ee77 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -309,6 +309,32 @@ font-size: 11px; line-height: 1.55; } + .local-demo-file-list, + .local-demo-info { + margin-top: 8px; + padding: 9px 10px; + border: 1px solid rgba(255, 255, 255, .08); + border-radius: 7px; + background: rgba(255, 255, 255, .035); + color: var(--text-color-secondary); + font-size: 10px; + line-height: 1.55; + overflow-wrap: anywhere; + } + .local-demo-file-list:empty, + .local-demo-info:empty { display: none; } + #local-demo-inspect { + width: 100%; + min-height: 36px; + margin-top: 6px; + padding: 7px 10px; + border: 1px solid var(--border-color); + border-radius: 7px; + background: rgba(255, 255, 255, .06); + color: var(--text-color-primary); + } + #local-demo-inspect:hover:not(:disabled) { background: rgba(255, 255, 255, .12); } + #local-demo-inspect:disabled { cursor: not-allowed; opacity: .48; } #run { width: 100%; min-height: 42px; @@ -590,17 +616,31 @@ {% if local_mode %} + {% endif %} -
+
-
+ +
diff --git a/server/tests/test_frontend_js.py b/server/tests/test_frontend_js.py index 84189ce..d854ec5 100644 --- a/server/tests/test_frontend_js.py +++ b/server/tests/test_frontend_js.py @@ -563,4 +563,75 @@ def test_app_button_views_keep_one_panel_active_and_draw_only_it(): timeout=15, check=False, ) + + +def test_local_demo_upload_inspect_and_analyze_flow(): + script = """ +const { setPlatform, inspectLocalDemos, runLocalDemoAnalysis } = require(%s); + +function element(overrides = {}) { + return Object.assign({ + value: "", disabled: false, hidden: false, files: [], children: [], + dataset: {}, attributes: {}, listeners: {}, + classList: { + toggle() {}, + }, + setAttribute(name, value) { this.attributes[name] = String(value); }, + addEventListener(name, handler) { this.listeners[name] = handler; }, + appendChild(child) { this.children.push(child); }, + replaceChildren(...children) { this.children = children; }, + }, overrides); +} + +const elements = { + "#local-demo-files": element({ files: [{ name: "one.dem", size: 10 }, { name: "two.dem", size: 20 }] }), + "#local-demo-file-list": element(), + "#local-demo-inspect": element(), + "#local-demo-info": element(), + "#local-demo-map": element(), + "#local-demo-player": element(), + "#run": element(), + "#status": element(), + "#failed": element(), +}; +global.document = { + querySelector(selector) { return elements[selector] || null; }, + querySelectorAll() { return []; }, + createElement() { return element(); }, + addEventListener() {}, +}; + +global.FormData = class { + constructor() { this.parts = []; } + append(name, file, filename) { this.parts.push({ name, file, filename }); } +}; +const requests = []; +global.fetch = async (url, options = {}) => { + requests.push({ url, options }); + if (url === "/api/status") return { ok: true, status: 200, async json() { return { status: "idle", message: "", results: [], failed: [] }; } }; + if (url === "/api/local-demos/inspect") return { ok: true, status: 200, async json() { return { session_id: "a".repeat(32), map: "de_nuke", files: [{ name: "one.dem", size: 10 }, { name: "two.dem", size: 20 }], players: [{ steamid: "76561198146001127", username: "L4n", appearances: 2 }] }; } }; + if (url === "/api/local-demos/analyze") return { ok: true, status: 200, async json() { return { status: "started", source: "local_demos" }; } }; + throw new Error("unexpected request " + url); +}; + +(async () => { + await setPlatform("localdemos"); + await inspectLocalDemos(); + if (requests[1].options.body.parts.length !== 2) throw new Error("two files were not appended to FormData"); + if (requests[1].options.headers) throw new Error("multipart request manually set Content-Type"); + if (elements["#local-demo-player"].value !== "76561198146001127") throw new Error("common player was not selected"); + await runLocalDemoAnalysis(); + const analyzeRequest = requests.find(item => item.url === "/api/local-demos/analyze"); + const body = JSON.parse(analyzeRequest.options.body); + if (body.session_id !== "a".repeat(32) || body.steamid !== "76561198146001127") throw new Error("analyze payload is wrong"); +})().catch(error => { console.error(error); process.exitCode = 1; }); +""" % json.dumps(os.path.abspath(APP_JS)) + result = subprocess.run( + [NODE, "-e", script], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout assert result.returncode == 0, result.stderr or result.stdout diff --git a/server/tests/test_local_demo_web.py b/server/tests/test_local_demo_web.py new file mode 100644 index 0000000..7eed7a2 --- /dev/null +++ b/server/tests/test_local_demo_web.py @@ -0,0 +1,190 @@ +import io +import json +import os +import sys +import copy +from pathlib import Path + +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +import local_demo_pipeline +import web_server + + +@pytest.fixture(autouse=True) +def local_environment(monkeypatch, tmp_path): + monkeypatch.setattr(web_server.config, "SECRET_KEY", "") + monkeypatch.setattr(web_server.config, "LOCAL_MODE", True) + monkeypatch.setattr(web_server.config, "HOST", "127.0.0.1") + monkeypatch.setattr(web_server.config, "LOCAL_DEMO_DIR", str(tmp_path / "sessions")) + monkeypatch.setattr(web_server.config, "OUTPUT_DIR", str(tmp_path / "output")) + monkeypatch.setattr(web_server.maps, "available_maps", lambda: ["de_nuke"]) + previous = copy.deepcopy(web_server.state) + with web_server.state_lock: + web_server.state.clear() + web_server.state.update({ + "status": "idle", "message": "", "progress": [], + "results": [], "failed": [], "total_players": 0, + "max_demos": 10, "map": "", "mode": "normal", "source": "5e", + }) + yield + with web_server.state_lock: + web_server.state.clear() + web_server.state.update(previous) + + +def _fake_inspect(paths): + details = [] + for path in paths: + path = Path(path) + details.append({ + "path": str(path), "name": path.name, "size": path.stat().st_size, + "map": "de_nuke", "rounds": 17, + "players": {"76561198146001127": "L4n", "76561198000000001": "Other"}, + }) + return { + "map": "de_nuke", "files": details, + "players": [{"steamid": "76561198146001127", "username": "L4n", "appearances": len(details)}], + } + + +def _upload(client, names=("one.dem", "two.dem")): + data = { + "demos": [(io.BytesIO(b"fake-demo"), name) for name in names], + } + return client.post("/api/local-demos/inspect", data=data, content_type="multipart/form-data") + + +def test_local_demo_routes_are_loopback_only(): + client = web_server.app.test_client() + inspect = client.post( + "/api/local-demos/inspect", + data={"demos": (io.BytesIO(b"demo"), "one.dem")}, + content_type="multipart/form-data", + environ_base={"REMOTE_ADDR": "203.0.113.10"}, + ) + analyze = client.post( + "/api/local-demos/analyze", + json={"session_id": "0" * 32, "steamid": "76561198146001127"}, + environ_base={"REMOTE_ADDR": "203.0.113.10"}, + ) + assert inspect.status_code == 404 + assert analyze.status_code == 404 + + +def test_upload_validation_and_size_limits(monkeypatch): + client = web_server.app.test_client() + assert client.post("/api/local-demos/inspect").status_code == 400 + bad = client.post( + "/api/local-demos/inspect", + data={"demos": (io.BytesIO(b"zip"), "archive.zip")}, + content_type="multipart/form-data", + ) + assert bad.status_code == 400 + + monkeypatch.setattr(web_server.config, "LOCAL_DEMO_MAX_FILES", 1) + too_many = _upload(client) + assert too_many.status_code == 400 + + monkeypatch.setattr(web_server.config, "LOCAL_DEMO_MAX_FILES", 10) + monkeypatch.setattr(web_server.config, "LOCAL_DEMO_MAX_FILE_BYTES", 1) + too_large = _upload(client, ("one.dem",)) + assert too_large.status_code == 400 + + +def test_inspect_returns_map_files_and_common_players(monkeypatch): + monkeypatch.setattr(local_demo_pipeline, "inspect_demos", _fake_inspect) + response = _upload(web_server.app.test_client()) + body = response.get_json() + assert response.status_code == 200 + assert body["map"] == "de_nuke" + assert [item["name"] for item in body["files"]] == ["one.dem", "two.dem"] + assert body["players"] == [{"steamid": "76561198146001127", "username": "L4n", "appearances": 2}] + session = Path(web_server.config.LOCAL_DEMO_DIR) / body["session_id"] + manifest = json.loads((session / "manifest.json").read_text(encoding="utf-8")) + assert len(manifest["files"]) == 2 + assert all(item["stored_name"].endswith(".dem") for item in manifest["files"]) + + +def test_inspect_rejects_map_mismatch_and_no_common_player(monkeypatch): + def mismatch(_paths): + raise local_demo_pipeline.LocalDemoError("all Demos must use one map") + + monkeypatch.setattr(local_demo_pipeline, "inspect_demos", mismatch) + response = _upload(web_server.app.test_client()) + assert response.status_code == 400 + assert "one map" in response.get_json()["error"] + assert list(Path(web_server.config.LOCAL_DEMO_DIR).iterdir()) == [] + + def no_common(_paths): + raise local_demo_pipeline.LocalDemoError("no common player") + + monkeypatch.setattr(local_demo_pipeline, "inspect_demos", no_common) + response = _upload(web_server.app.test_client()) + assert response.status_code == 400 + assert "common player" in response.get_json()["error"] + + +def test_analyze_runs_in_background_writes_replay_data_and_cleans_session(monkeypatch): + monkeypatch.setattr(local_demo_pipeline, "inspect_demos", _fake_inspect) + inspected = _upload(web_server.app.test_client(), ("a.dem", "b.dem")) + session_id = inspected.get_json()["session_id"] + + launched = [] + class CapturedThread: + def __init__(self, target, args=(), daemon=None): + launched.append((target, args, daemon)) + def start(self): + return None + monkeypatch.setattr(web_server.threading, "Thread", CapturedThread) + + def fake_run(paths, *, steamid, username, domain, map_name, output_path, progress_cb=None): + if progress_cb: + progress_cb(0, len(paths), "parsed") + payload = { + "username": username, "steamid": steamid, "map": map_name, + "rounds": [{"round_id": 1, "path": [[1, 2, 0]], "grenades": [{"type": "smoke"}], "death_t": 4}], + "round_count": 1, + } + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(payload), encoding="utf-8") + return {"combat_stats": {}, "total_rounds": 1} + + monkeypatch.setattr(local_demo_pipeline, "run_local_demos", fake_run) + response = web_server.app.test_client().post( + "/api/local-demos/analyze", + json={"session_id": session_id, "steamid": "76561198146001127"}, + ) + assert response.status_code == 200 + assert response.get_json()["source"] == "local_demos" + assert len(launched) == 1 + with web_server.state_lock: + assert web_server.state["source"] == "local_demos" + assert web_server.state["status"] == "running" + + target, args, _daemon = launched[0] + target(*args) + status = web_server.app.test_client().get("/api/status").get_json() + assert status["status"] == "done" + assert status["source"] == "local_demos" + assert status["results"][0]["round_count"] == 1 + assert status["results"][0]["player_json"].startswith("/output/player_local_") + domain = status["results"][0]["domain"] + output = web_server.app.test_client().get(f"/api/player/{domain}") + assert output.status_code == 200 + assert "grenades" in output.get_json()["rounds"][0] + assert output.get_json()["rounds"][0]["death_t"] == 4 + assert not (Path(web_server.config.LOCAL_DEMO_DIR) / session_id).exists() + + +def test_analyze_rejects_invalid_session_or_player(): + client = web_server.app.test_client() + assert client.post( + "/api/local-demos/analyze", + json={"session_id": "bad", "steamid": "76561198146001127"}, + ).status_code == 400 + assert client.post( + "/api/local-demos/analyze", + json={"session_id": "0" * 32, "steamid": "76561198146001127"}, + ).status_code == 400 diff --git a/server/web_server.py b/server/web_server.py index 4c11694..fe5b51f 100644 --- a/server/web_server.py +++ b/server/web_server.py @@ -24,17 +24,39 @@ import ipaddress import tempfile import sys +import uuid from pathlib import Path -from flask import Flask, abort, render_template, request, jsonify, send_from_directory +from flask import Flask, Request, abort, render_template, request, jsonify, send_from_directory +from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.serving import make_server import pipeline import config import maps +import local_demo_pipeline + +JSON_MAX_CONTENT_LENGTH = 16 * 1024 + + +class _Request(Request): + @property + def max_content_length(self): + # Keep the legacy JSON body limit while allowing the dedicated upload + # endpoint to receive the explicitly bounded multipart payload. + if self.path == "/api/local-demos/inspect" and self.mimetype == "multipart/form-data": + return config.LOCAL_DEMO_MAX_TOTAL_BYTES + 16 * 1024 * 1024 + return JSON_MAX_CONTENT_LENGTH + app = Flask(__name__, template_folder=os.path.join(config.BASE_DIR, "templates")) -app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 +app.request_class = _Request +app.config["MAX_CONTENT_LENGTH"] = JSON_MAX_CONTENT_LENGTH + + +@app.errorhandler(RequestEntityTooLarge) +def handle_request_too_large(_error): + return jsonify({"error": "request body is too large"}), 413 ICONS_DIR = os.path.abspath(os.path.join(config.BASE_DIR, "..", "radar", "icons")) GRENADE_ICON_FILES = frozenset({ @@ -47,6 +69,7 @@ CACHE_CONTROL_PATHS = frozenset({ "/api/analyze", "/api/status", "/api/results", "/api/pwa/status", "/api/pwa/config", "/api/pwa/analyze", + "/api/local-demos/inspect", "/api/local-demos/analyze", }) CACHE_CONTROL_PREFIXES = ("/api/player/", "/api/pwa/player/", "/output/") LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) @@ -65,6 +88,7 @@ "max_demos": 10, "map": "", "mode": "normal", + "source": "5e", } state_lock = threading.Lock() @@ -151,6 +175,11 @@ def _loopback_local_mode_enabled(): def _local_analysis_allowed(): """Allow keyless analysis only for the explicit loopback-only local mode.""" + return _local_request_is_loopback() + + +def _local_request_is_loopback(): + """Return true only for a loopback client when local mode is enabled.""" if not _loopback_local_mode_enabled(): return False try: @@ -267,6 +296,7 @@ def api_analyze(): state.update({"status":"running","message":f"开始{mode_label}分析...","progress":[], "results":[],"failed":[],"total_players":len(usernames), "max_demos":max_demos,"map":map_name,"mode":mode}) + state["source"] = "5e" try: worker = threading.Thread( target=_run_analysis, @@ -294,6 +324,137 @@ def api_maps(): return jsonify({"maps": maps.available_maps()}) +def _local_demo_public_info(session_id, info, display_names): + return { + "session_id": session_id, + "map": info["map"], + "files": [ + { + "name": display_names[i], + "size": item["size"], + "rounds": item.get("rounds", 0), + } + for i, item in enumerate(info["files"]) + ], + "players": list(info["players"]), + } + + +@app.route("/api/local-demos/inspect", methods=["POST"]) +def api_local_demos_inspect(): + if not _local_request_is_loopback(): + abort(404) + local_demo_pipeline.cleanup_expired_sessions() + uploads = request.files.getlist("demos") + if not uploads: + return jsonify({"error": "No Demo files uploaded"}), 400 + if len(uploads) > config.LOCAL_DEMO_MAX_FILES: + return jsonify({ + "error": f"Maximum {config.LOCAL_DEMO_MAX_FILES} Demo files", + }), 400 + + display_names = [] + for upload in uploads: + original_name = str(upload.filename or "").strip() + if not original_name or Path(original_name).suffix.lower() != ".dem": + return jsonify({"error": "Only .dem files are supported"}), 400 + display_names.append(Path(original_name).name[:255]) + + session_id = None + try: + session_id, session_dir = local_demo_pipeline.create_session() + paths = [] + for upload in uploads: + stored_path = session_dir / f"{uuid.uuid4().hex}.dem" + upload.save(stored_path) + paths.append(stored_path) + info = local_demo_pipeline.inspect_demos(paths) + local_demo_pipeline.write_manifest(session_id, info, display_names) + return jsonify(_local_demo_public_info(session_id, info, display_names)) + except local_demo_pipeline.LocalDemoError as exc: + if session_id: + local_demo_pipeline.cleanup_session(session_id) + return jsonify({"error": str(exc)}), 400 + except OSError: + log.exception("Could not save or inspect local Demo upload") + if session_id: + local_demo_pipeline.cleanup_session(session_id) + return jsonify({"error": "Could not save or read Demo files"}), 400 + except Exception: + log.exception("Unexpected local Demo inspection failure") + if session_id: + local_demo_pipeline.cleanup_session(session_id) + return jsonify({"error": "Demo inspection failed"}), 400 + + +@app.route("/api/local-demos/analyze", methods=["POST"]) +def api_local_demos_analyze(): + if not _local_request_is_loopback(): + abort(404) + data = request.get_json(silent=True) + if not isinstance(data, dict): + return jsonify({"error": "Request body must be a JSON object"}), 400 + session_id = data.get("session_id") + steamid = data.get("steamid") + if not isinstance(session_id, str) or not local_demo_pipeline.SESSION_ID_RE.fullmatch(session_id): + return jsonify({"error": "Invalid local Demo session"}), 400 + if not isinstance(steamid, str) or not re.fullmatch(r"\d{10,20}", steamid.strip()): + return jsonify({"error": "Invalid SteamID"}), 400 + steamid = steamid.strip() + + try: + manifest = local_demo_pipeline.load_manifest(session_id) + except local_demo_pipeline.LocalDemoError as exc: + return jsonify({"error": str(exc)}), 400 + player = next( + (item for item in manifest.get("players", []) + if str(item.get("steamid", "")) == steamid), + None, + ) + if player is None: + return jsonify({"error": "SteamID is not a common player in this session"}), 400 + + domain = f"local_{session_id}" + with state_lock: + if state["status"] == "running": + return jsonify({"error": "Analysis already running"}), 409 + state.update({ + "status": "running", + "message": "Starting local Demo analysis...", + "progress": [{"id": steamid, "step": 0, "msg": "Queued"}], + "results": [], + "failed": [], + "total_players": 1, + "max_demos": len(manifest["paths"]), + "map": manifest["map"], + "mode": "local_demos", + "source": "local_demos", + }) + try: + worker = threading.Thread( + target=_run_local_demo_analysis, + args=(session_id, manifest, player, domain), + daemon=True, + ) + worker.start() + except Exception: + log.exception("Could not start local Demo analysis worker") + state.update({ + "status": "error", + "message": "Unable to start local Demo analysis", + "progress": [], + "results": [], + "failed": [], + }) + local_demo_pipeline.cleanup_session(session_id) + return jsonify({"error": "Unable to start analysis worker"}), 503 + return jsonify({ + "status": "started", + "source": "local_demos", + "domain": domain, + }) + + def _pwa_request_is_local(): """Perfect World state is desktop-local and must never be exposed remotely.""" if not (config.LOCAL_MODE and config.HOST in LOOPBACK_HOSTS): @@ -400,6 +561,7 @@ def api_status(): "max_demos": saved.get("max_demos", 10), "map": saved.get("map", ""), "mode": saved.get("mode", "normal"), + "source": saved.get("source", "5e"), }) return jsonify(snapshot) @@ -409,7 +571,7 @@ def _load_analysis_summary(): if not os.path.exists(summary_path): return { "results": [], "failed": [], "max_demos": 10, - "map": "", "mode": "normal", + "map": "", "mode": "normal", "source": "5e", } with open(summary_path, encoding="utf-8") as f: summary = json.load(f) @@ -452,6 +614,69 @@ def serve_icons(filename): # ── Background runner ───────────────────────────────────────────────────────── +def _run_local_demo_analysis(session_id, manifest, player, domain): + steamid = str(player["steamid"]) + username = str(player.get("username") or steamid) + demo_paths = list(manifest["paths"]) + + def progress_cb(index, total, message): + with state_lock: + state["message"] = f"[{index + 1}/{total}] {message}" + if state["progress"]: + state["progress"][0].update({"step": min(3, index + 1), "msg": message}) + + try: + output_path = Path(config.OUTPUT_DIR) / f"player_{domain}.json" + summary = local_demo_pipeline.run_local_demos( + demo_paths, + steamid=steamid, + username=username, + domain=domain, + map_name=manifest["map"], + output_path=output_path, + progress_cb=progress_cb, + ) + result = { + "username": username, + "domain": domain, + "player_json": f"/output/player_{domain}.json", + "combat_stats": summary["combat_stats"], + "demos_found": len(demo_paths), + "round_count": summary["total_rounds"], + } + saved_summary = { + "map": manifest["map"], + "max_demos": len(demo_paths), + "mode": "local_demos", + "source": "local_demos", + "failed": [], + "results": [result], + } + pipeline._write_json_atomic( + os.path.join(config.OUTPUT_DIR, "analysis_summary.json"), + saved_summary, + ensure_ascii=False, + indent=2, + ) + with state_lock: + state["status"] = "done" + state["message"] = f"Local Demo analysis complete: {summary['total_rounds']} rounds" + state["progress"] = [{"id": steamid, "step": 4, "msg": "Complete"}] + state["results"] = [result] + state["failed"] = [] + state["source"] = "local_demos" + except Exception: + log.exception("Local Demo analysis failed for %s", steamid) + with state_lock: + state["status"] = "error" + state["message"] = "Local Demo analysis failed; check server logs" + state["results"] = [] + state["failed"] = [{"username": username, "reason": "analysis failed"}] + state["source"] = "local_demos" + finally: + local_demo_pipeline.cleanup_session(session_id) + + def _run_analysis(usernames, map_name, max_demos=10, mode="normal"): def progress_cb(opp_idx, total, username, step, msg): with state_lock: @@ -547,6 +772,10 @@ def _write_startup_info(path, token, port): def _run_development_server(): """Run the local server and optionally report an OS-assigned port.""" + try: + local_demo_pipeline.cleanup_expired_sessions() + except Exception: + log.exception("Could not clean expired local Demo sessions") os.makedirs(config.OUTPUT_DIR, exist_ok=True) server = None try: diff --git a/tools/parse_local_demos.py b/tools/parse_local_demos.py new file mode 100644 index 0000000..b51e15c --- /dev/null +++ b/tools/parse_local_demos.py @@ -0,0 +1,143 @@ +"""Run the active replay parser directly against local 5E Demo files. + +This is a development harness, not a replacement for the web pipeline. It +skips player discovery and downloading, then reuses the same parse, round +offset, combat-stat, and player-JSON stages used by the server. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +REPO_DIR = Path(__file__).resolve().parents[1] +SERVER_DIR = REPO_DIR / "server" +sys.path.insert(0, str(SERVER_DIR)) + +import combat # noqa: E402 +import config # noqa: E402 +import parse # noqa: E402 +import pipeline # noqa: E402 +import player_json # noqa: E402 +from local_demo_pipeline import run_local_demos as service_run_local_demos # noqa: E402 + + +def _slug(value: str) -> str: + value = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()) + return value.strip("-._") or "local-player" + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="直接把本地 5E Demo 输入 CS-Scout 回放解析链" + ) + parser.add_argument( + "demos", nargs="+", type=Path, + help="一个或多个 .dem 文件路径,按输入顺序合并回合", + ) + parser.add_argument("--steamid", required=True, help="目标玩家 SteamID") + parser.add_argument("--username", default=None, help="输出 JSON 中的玩家名") + parser.add_argument("--domain", default=None, help="输出 JSON 使用的 domain") + parser.add_argument("--map", required=True, dest="map_name", help="地图名,例如 de_nuke") + parser.add_argument( + "--output", type=Path, default=None, + help="输出 player JSON;默认写入 server/output/", + ) + return parser + + +def _legacy_run_local_demos( + demo_paths: list[Path], + *, + steamid: str, + username: str, + domain: str, + map_name: str, + output_path: Path, +) -> dict: + rounds = [] + combat_stats = [] + per_demo = [] + + for demo_index, raw_path in enumerate(demo_paths): + path = raw_path.expanduser().resolve() + if path.suffix.lower() != ".dem": + raise ValueError(f"不是 .dem 文件:{path}") + if not path.is_file(): + raise FileNotFoundError(f"Demo 文件不存在:{path}") + + parsed = parse.parse_demo(str(path), steamid) or [] + pipeline.assemble_round_offset(parsed, demo_index) + rounds.extend(parsed) + + stats = combat.parse_combat_stats(str(path), steamid) + if stats is not None: + combat_stats.append(stats) + + per_demo.append({ + "demo": str(path), + "rounds": len(parsed), + "path_points": sum(len(r.get("path", [])) for r in parsed), + "grenades": sum(len(r.get("grenades", [])) for r in parsed), + "death_rounds": sum( + r.get("death_t") is not None for r in parsed + ), + }) + + aggregate = combat.aggregate_combat_stats(combat_stats) + payload = player_json.build( + username, domain, steamid, map_name, rounds, aggregate, + ) + payload["demos_found"] = len(demo_paths) + + output_path = output_path.expanduser().resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + temporary = output_path.with_name(output_path.name + ".tmp") + with temporary.open("w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + handle.write("\n") + temporary.replace(output_path) + + return { + "output": str(output_path), + "username": username, + "steamid": str(steamid), + "map": map_name, + "demos": per_demo, + "total_rounds": payload["round_count"], + "total_path_points": sum(item["path_points"] for item in per_demo), + "total_grenades": sum(item["grenades"] for item in per_demo), + "total_death_rounds": sum(item["death_rounds"] for item in per_demo), + "combat_stats": aggregate, + } + + +def main() -> int: + args = _parser().parse_args() + username = args.username or str(args.steamid) + domain = args.domain or f"local-{_slug(username)}" + output = args.output or ( + Path(config.OUTPUT_DIR) / f"player_{_slug(domain)}.json" + ) + try: + summary = service_run_local_demos( + args.demos, + steamid=str(args.steamid), + username=username, + domain=domain, + map_name=args.map_name, + output_path=output, + ) + except (OSError, ValueError, KeyError) as exc: + print(f"解析失败:{exc}", file=sys.stderr) + return 1 + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 427e7ec2a6e45cbe11c8343549e578c517a1c63a Mon Sep 17 00:00:00 2001 From: Halcy0nS Date: Wed, 5 Aug 2026 13:20:58 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=E5=88=86=E6=9E=90=E6=89=80?= =?UTF-8?q?=E6=9C=89=E5=85=B1=E5=90=8C=E7=8E=A9=E5=AE=B6=E5=B9=B6=E5=88=86?= =?UTF-8?q?=E5=88=AB=E5=B1=95=E7=A4=BA=E5=9B=9E=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不再要求用户选择单名玩家,inspect 后直接 Start parsing 即可 分析所有共同玩家,每人生成独立 player JSON 和回放按钮。 后端: - /api/local-demos/analyze 移除 steamid 参数,遍历 manifest 中 所有共同玩家 - _run_local_demo_analysis 循环调用 run_local_demos,每完成一名 增量更新 state.results - domain 改为 local_{steamid},每人独立 - summary 汇总所有玩家结果 前端: - 移除玩家选择下拉框,inspect 后直接启用 Start parsing - localDemoReady 不再依赖 player select - runLocalDemoAnalysis 只发 session_id 测试: - _fake_inspect 返回 2 名玩家 - analyze 测试验证 2 名玩家均生成回放 JSON - 前端测试验证 analyze 请求不含 steamid --- server/static/app.js | 39 +------ server/templates/index.html | 4 - server/tests/test_frontend_js.py | 7 +- server/tests/test_local_demo_web.py | 35 ++++--- server/web_server.py | 151 ++++++++++++++-------------- 5 files changed, 108 insertions(+), 128 deletions(-) diff --git a/server/static/app.js b/server/static/app.js index e747fe0..7f55172 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -41,10 +41,7 @@ const clock = { elapsed: 0, playing: true, speed: 2, last: null, raf: null }; function localDemoReady() { - const select = $("#local-demo-player"); - return activePlatform === "localdemos" && Boolean( - localDemoSessionId && select && select.value - ); + return activePlatform === "localdemos" && Boolean(localDemoSessionId); } function formatBytes(bytes) { @@ -77,15 +74,6 @@ function updateLocalDemoRunButton() { function resetLocalDemoState() { localDemoSessionId = ""; localDemoPlayers = []; - const select = $("#local-demo-player"); - if (select) { - select.replaceChildren(); - const option = document.createElement("option"); - option.value = ""; - option.textContent = "Inspect Demos first"; - select.appendChild(option); - select.disabled = true; - } const info = $("#local-demo-info"); if (info) info.hidden = true; const map = $("#local-demo-map"); @@ -159,8 +147,6 @@ function setAnalysisBusy(busy) { if (fileInput) fileInput.disabled = disabled; const inspectButton = $("#local-demo-inspect"); if (inspectButton) inspectButton.disabled = disabled || localDemoFiles.length === 0; - const playerSelect = $("#local-demo-player"); - if (playerSelect) playerSelect.disabled = disabled || !localDemoSessionId || !localDemoPlayers.length; updateLocalDemoRunButton(); } @@ -400,8 +386,6 @@ function wireControls() { } const localInspectButton = $("#local-demo-inspect"); if (localInspectButton) localInspectButton.addEventListener("click", () => { void inspectLocalDemos(); }); - const localPlayerSelect = $("#local-demo-player"); - if (localPlayerSelect) localPlayerSelect.addEventListener("change", updateLocalDemoRunButton); setPlaybackSpeed(clock.speed); setAnalysisMode(analysisMode); updatePlatformControls(); @@ -623,21 +607,9 @@ async function inspectLocalDemos() { localDemoPlayers = Array.isArray(data.players) ? data.players : []; updateLocalDemoFileList(Array.isArray(data.files) ? data.files : null); const map = $("#local-demo-map"); - if (map) map.textContent = `Map: ${String(data.map || "unknown")} ? ${localDemoFiles.length} files`; + if (map) map.textContent = `Map: ${String(data.map || "unknown")} — ${localDemoFiles.length} files, ${localDemoPlayers.length} common players`; const info = $("#local-demo-info"); if (info) info.hidden = false; - const select = $("#local-demo-player"); - if (select) { - select.replaceChildren(); - for (const player of localDemoPlayers) { - const option = document.createElement("option"); - option.value = String(player.steamid || ""); - option.textContent = `${String(player.username || player.steamid)} (${String(player.steamid)})`; - select.appendChild(option); - } - select.value = localDemoPlayers.length === 1 ? String(localDemoPlayers[0].steamid) : ""; - select.disabled = localDemoPlayers.length === 0; - } updateLocalDemoRunButton(); setStatus(`Demo inspection complete: ${localDemoPlayers.length} common players found.`); } catch (error) { @@ -649,9 +621,8 @@ async function inspectLocalDemos() { } async function runLocalDemoAnalysis() { - const select = $("#local-demo-player"); - if (!localDemoReady() || !select) { - setStatus("Inspect the Demos and select a common player first."); + if (!localDemoReady()) { + setStatus("Inspect the Demos first."); return; } setAnalysisBusy(true); @@ -659,7 +630,7 @@ async function runLocalDemoAnalysis() { await requestJSON("/api/local-demos/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: localDemoSessionId, steamid: select.value }) + body: JSON.stringify({ session_id: localDemoSessionId }) }); lastKnownAnalysisRunning = true; pollEpoch += 1; diff --git a/server/templates/index.html b/server/templates/index.html index 6e9ee77..f1ea840 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -634,10 +634,6 @@
diff --git a/server/tests/test_frontend_js.py b/server/tests/test_frontend_js.py index d854ec5..53d10a8 100644 --- a/server/tests/test_frontend_js.py +++ b/server/tests/test_frontend_js.py @@ -589,7 +589,6 @@ def test_local_demo_upload_inspect_and_analyze_flow(): "#local-demo-inspect": element(), "#local-demo-info": element(), "#local-demo-map": element(), - "#local-demo-player": element(), "#run": element(), "#status": element(), "#failed": element(), @@ -609,7 +608,7 @@ def test_local_demo_upload_inspect_and_analyze_flow(): global.fetch = async (url, options = {}) => { requests.push({ url, options }); if (url === "/api/status") return { ok: true, status: 200, async json() { return { status: "idle", message: "", results: [], failed: [] }; } }; - if (url === "/api/local-demos/inspect") return { ok: true, status: 200, async json() { return { session_id: "a".repeat(32), map: "de_nuke", files: [{ name: "one.dem", size: 10 }, { name: "two.dem", size: 20 }], players: [{ steamid: "76561198146001127", username: "L4n", appearances: 2 }] }; } }; + if (url === "/api/local-demos/inspect") return { ok: true, status: 200, async json() { return { session_id: "a".repeat(32), map: "de_nuke", files: [{ name: "one.dem", size: 10 }, { name: "two.dem", size: 20 }], players: [{ steamid: "76561198146001127", username: "L4n", appearances: 2 }, { steamid: "76561198000000001", username: "Other", appearances: 2 }] }; } }; if (url === "/api/local-demos/analyze") return { ok: true, status: 200, async json() { return { status: "started", source: "local_demos" }; } }; throw new Error("unexpected request " + url); }; @@ -619,11 +618,11 @@ def test_local_demo_upload_inspect_and_analyze_flow(): await inspectLocalDemos(); if (requests[1].options.body.parts.length !== 2) throw new Error("two files were not appended to FormData"); if (requests[1].options.headers) throw new Error("multipart request manually set Content-Type"); - if (elements["#local-demo-player"].value !== "76561198146001127") throw new Error("common player was not selected"); await runLocalDemoAnalysis(); const analyzeRequest = requests.find(item => item.url === "/api/local-demos/analyze"); const body = JSON.parse(analyzeRequest.options.body); - if (body.session_id !== "a".repeat(32) || body.steamid !== "76561198146001127") throw new Error("analyze payload is wrong"); + if (body.session_id !== "a".repeat(32)) throw new Error("analyze payload missing session_id"); + if (body.steamid !== undefined) throw new Error("analyze payload should not contain steamid"); })().catch(error => { console.error(error); process.exitCode = 1; }); """ % json.dumps(os.path.abspath(APP_JS)) result = subprocess.run( diff --git a/server/tests/test_local_demo_web.py b/server/tests/test_local_demo_web.py index 7eed7a2..5e67fc0 100644 --- a/server/tests/test_local_demo_web.py +++ b/server/tests/test_local_demo_web.py @@ -45,7 +45,10 @@ def _fake_inspect(paths): }) return { "map": "de_nuke", "files": details, - "players": [{"steamid": "76561198146001127", "username": "L4n", "appearances": len(details)}], + "players": [ + {"steamid": "76561198000000001", "username": "Other", "appearances": len(details)}, + {"steamid": "76561198146001127", "username": "L4n", "appearances": len(details)}, + ], } @@ -100,7 +103,10 @@ def test_inspect_returns_map_files_and_common_players(monkeypatch): assert response.status_code == 200 assert body["map"] == "de_nuke" assert [item["name"] for item in body["files"]] == ["one.dem", "two.dem"] - assert body["players"] == [{"steamid": "76561198146001127", "username": "L4n", "appearances": 2}] + assert body["players"] == [ + {"steamid": "76561198000000001", "username": "Other", "appearances": 2}, + {"steamid": "76561198146001127", "username": "L4n", "appearances": 2}, + ] session = Path(web_server.config.LOCAL_DEMO_DIR) / body["session_id"] manifest = json.loads((session / "manifest.json").read_text(encoding="utf-8")) assert len(manifest["files"]) == 2 @@ -154,7 +160,7 @@ def fake_run(paths, *, steamid, username, domain, map_name, output_path, progres monkeypatch.setattr(local_demo_pipeline, "run_local_demos", fake_run) response = web_server.app.test_client().post( "/api/local-demos/analyze", - json={"session_id": session_id, "steamid": "76561198146001127"}, + json={"session_id": session_id}, ) assert response.status_code == 200 assert response.get_json()["source"] == "local_demos" @@ -162,29 +168,32 @@ def fake_run(paths, *, steamid, username, domain, map_name, output_path, progres with web_server.state_lock: assert web_server.state["source"] == "local_demos" assert web_server.state["status"] == "running" + assert web_server.state["total_players"] == 2 target, args, _daemon = launched[0] target(*args) status = web_server.app.test_client().get("/api/status").get_json() assert status["status"] == "done" assert status["source"] == "local_demos" - assert status["results"][0]["round_count"] == 1 - assert status["results"][0]["player_json"].startswith("/output/player_local_") - domain = status["results"][0]["domain"] - output = web_server.app.test_client().get(f"/api/player/{domain}") - assert output.status_code == 200 - assert "grenades" in output.get_json()["rounds"][0] - assert output.get_json()["rounds"][0]["death_t"] == 4 + assert len(status["results"]) == 2 + for result in status["results"]: + assert result["round_count"] == 1 + assert result["player_json"].startswith("/output/player_local_") + domain = result["domain"] + output = web_server.app.test_client().get(f"/api/player/{domain}") + assert output.status_code == 200 + assert "grenades" in output.get_json()["rounds"][0] + assert output.get_json()["rounds"][0]["death_t"] == 4 assert not (Path(web_server.config.LOCAL_DEMO_DIR) / session_id).exists() -def test_analyze_rejects_invalid_session_or_player(): +def test_analyze_rejects_invalid_session(): client = web_server.app.test_client() assert client.post( "/api/local-demos/analyze", - json={"session_id": "bad", "steamid": "76561198146001127"}, + json={"session_id": "bad"}, ).status_code == 400 assert client.post( "/api/local-demos/analyze", - json={"session_id": "0" * 32, "steamid": "76561198146001127"}, + json={"session_id": "0" * 32}, ).status_code == 400 diff --git a/server/web_server.py b/server/web_server.py index fe5b51f..57f1dbc 100644 --- a/server/web_server.py +++ b/server/web_server.py @@ -395,36 +395,27 @@ def api_local_demos_analyze(): if not isinstance(data, dict): return jsonify({"error": "Request body must be a JSON object"}), 400 session_id = data.get("session_id") - steamid = data.get("steamid") if not isinstance(session_id, str) or not local_demo_pipeline.SESSION_ID_RE.fullmatch(session_id): return jsonify({"error": "Invalid local Demo session"}), 400 - if not isinstance(steamid, str) or not re.fullmatch(r"\d{10,20}", steamid.strip()): - return jsonify({"error": "Invalid SteamID"}), 400 - steamid = steamid.strip() try: manifest = local_demo_pipeline.load_manifest(session_id) except local_demo_pipeline.LocalDemoError as exc: return jsonify({"error": str(exc)}), 400 - player = next( - (item for item in manifest.get("players", []) - if str(item.get("steamid", "")) == steamid), - None, - ) - if player is None: - return jsonify({"error": "SteamID is not a common player in this session"}), 400 + players = manifest.get("players", []) + if not players: + return jsonify({"error": "No common players in this session"}), 400 - domain = f"local_{session_id}" with state_lock: if state["status"] == "running": return jsonify({"error": "Analysis already running"}), 409 state.update({ "status": "running", "message": "Starting local Demo analysis...", - "progress": [{"id": steamid, "step": 0, "msg": "Queued"}], + "progress": [], "results": [], "failed": [], - "total_players": 1, + "total_players": len(players), "max_demos": len(manifest["paths"]), "map": manifest["map"], "mode": "local_demos", @@ -433,7 +424,7 @@ def api_local_demos_analyze(): try: worker = threading.Thread( target=_run_local_demo_analysis, - args=(session_id, manifest, player, domain), + args=(session_id, manifest, players), daemon=True, ) worker.start() @@ -451,7 +442,6 @@ def api_local_demos_analyze(): return jsonify({ "status": "started", "source": "local_demos", - "domain": domain, }) @@ -614,67 +604,82 @@ def serve_icons(filename): # ── Background runner ───────────────────────────────────────────────────────── -def _run_local_demo_analysis(session_id, manifest, player, domain): - steamid = str(player["steamid"]) - username = str(player.get("username") or steamid) +def _run_local_demo_analysis(session_id, manifest, players): demo_paths = list(manifest["paths"]) + map_name = manifest["map"] + total_players = len(players) + all_results = [] + all_failed = [] + + for player_index, player in enumerate(players): + steamid = str(player["steamid"]) + username = str(player.get("username") or steamid) + domain = f"local_{steamid}" + + def progress_cb(index, total, message, _pi=player_index, _pu=username): + with state_lock: + state["message"] = f"[{_pi + 1}/{total_players}] {_pu}: {message}" + state["progress"] = [{ + "id": steamid, + "step": min(3, index + 1), + "msg": message, + }] - def progress_cb(index, total, message): - with state_lock: - state["message"] = f"[{index + 1}/{total}] {message}" - if state["progress"]: - state["progress"][0].update({"step": min(3, index + 1), "msg": message}) - - try: - output_path = Path(config.OUTPUT_DIR) / f"player_{domain}.json" - summary = local_demo_pipeline.run_local_demos( - demo_paths, - steamid=steamid, - username=username, - domain=domain, - map_name=manifest["map"], - output_path=output_path, - progress_cb=progress_cb, - ) - result = { - "username": username, - "domain": domain, - "player_json": f"/output/player_{domain}.json", - "combat_stats": summary["combat_stats"], - "demos_found": len(demo_paths), - "round_count": summary["total_rounds"], - } - saved_summary = { - "map": manifest["map"], - "max_demos": len(demo_paths), - "mode": "local_demos", - "source": "local_demos", - "failed": [], - "results": [result], - } - pipeline._write_json_atomic( - os.path.join(config.OUTPUT_DIR, "analysis_summary.json"), - saved_summary, - ensure_ascii=False, - indent=2, - ) - with state_lock: + try: + output_path = Path(config.OUTPUT_DIR) / f"player_{domain}.json" + summary = local_demo_pipeline.run_local_demos( + demo_paths, + steamid=steamid, + username=username, + domain=domain, + map_name=map_name, + output_path=output_path, + progress_cb=progress_cb, + ) + result = { + "username": username, + "domain": domain, + "player_json": f"/output/player_{domain}.json", + "combat_stats": summary["combat_stats"], + "demos_found": len(demo_paths), + "round_count": summary["total_rounds"], + } + all_results.append(result) + with state_lock: + state["results"] = list(all_results) + state["message"] = f"[{player_index + 1}/{total_players}] {username}: done ({summary['total_rounds']} rounds)" + except Exception: + log.exception("Local Demo analysis failed for %s", steamid) + all_failed.append({"username": username, "reason": "analysis failed"}) + with state_lock: + state["failed"] = list(all_failed) + + saved_summary = { + "map": map_name, + "max_demos": len(demo_paths), + "mode": "local_demos", + "source": "local_demos", + "failed": all_failed, + "results": all_results, + } + pipeline._write_json_atomic( + os.path.join(config.OUTPUT_DIR, "analysis_summary.json"), + saved_summary, + ensure_ascii=False, + indent=2, + ) + with state_lock: + if all_results: state["status"] = "done" - state["message"] = f"Local Demo analysis complete: {summary['total_rounds']} rounds" - state["progress"] = [{"id": steamid, "step": 4, "msg": "Complete"}] - state["results"] = [result] - state["failed"] = [] - state["source"] = "local_demos" - except Exception: - log.exception("Local Demo analysis failed for %s", steamid) - with state_lock: + state["message"] = f"Local Demo analysis complete: {len(all_results)} players, {sum(r['round_count'] for r in all_results)} rounds" + else: state["status"] = "error" - state["message"] = "Local Demo analysis failed; check server logs" - state["results"] = [] - state["failed"] = [{"username": username, "reason": "analysis failed"}] - state["source"] = "local_demos" - finally: - local_demo_pipeline.cleanup_session(session_id) + state["message"] = "Local Demo analysis failed for all players" + state["progress"] = [] + state["results"] = all_results + state["failed"] = all_failed + state["source"] = "local_demos" + local_demo_pipeline.cleanup_session(session_id) def _run_analysis(usernames, map_name, max_demos=10, mode="normal"): From 6a8577da09e981e98ed427668c3b8ec84367adcf Mon Sep 17 00:00:00 2001 From: Halcy0nS Date: Wed, 5 Aug 2026 13:27:42 +0800 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20=E5=8F=96=E6=89=80=E6=9C=89Demo?= =?UTF-8?q?=E4=B8=AD=E5=87=BA=E7=8E=B0=E7=9A=84=E7=8E=A9=E5=AE=B6=E5=B9=B6?= =?UTF-8?q?=E9=9B=86=E8=80=8C=E9=9D=9E=E4=BA=A4=E9=9B=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前只取同时出现在所有Demo中的玩家(交集),导致只换了队友或 只打了一场的玩家被排除。改为取所有Demo中出现过的玩家(并集), appearances 字段标记每名玩家出现在几个Demo中。 --- server/local_demo_pipeline.py | 15 ++++++++------- server/tests/test_local_demo_web.py | 8 ++++---- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/server/local_demo_pipeline.py b/server/local_demo_pipeline.py index 69667aa..6f5d3c0 100644 --- a/server/local_demo_pipeline.py +++ b/server/local_demo_pipeline.py @@ -153,20 +153,21 @@ def inspect_demos(paths: list[Path]) -> dict: if map_name not in maps.available_maps(): raise LocalDemoError(f"地图资源未准备:{map_name}") - common_ids = set(details[0]["players"]) - for item in details[1:]: - common_ids.intersection_update(item["players"]) - if not common_ids: - raise LocalDemoError("这些 Demo 没有共同出现的玩家") + all_ids = set() + for item in details: + all_ids.update(item["players"]) + if not all_ids: + raise LocalDemoError("这些 Demo 中没有可识别的玩家") players = [] - for sid in sorted(common_ids): + for sid in sorted(all_ids): names = [item["players"].get(sid, "") for item in details] username = next((name for name in names if name), sid) + appearances = sum(1 for item in details if sid in item["players"]) players.append({ "steamid": sid, "username": username, - "appearances": len(details), + "appearances": appearances, }) return {"map": map_name, "files": details, "players": players} diff --git a/server/tests/test_local_demo_web.py b/server/tests/test_local_demo_web.py index 5e67fc0..89605d4 100644 --- a/server/tests/test_local_demo_web.py +++ b/server/tests/test_local_demo_web.py @@ -123,13 +123,13 @@ def mismatch(_paths): assert "one map" in response.get_json()["error"] assert list(Path(web_server.config.LOCAL_DEMO_DIR).iterdir()) == [] - def no_common(_paths): - raise local_demo_pipeline.LocalDemoError("no common player") + def no_players(_paths): + raise local_demo_pipeline.LocalDemoError("no recognizable players") - monkeypatch.setattr(local_demo_pipeline, "inspect_demos", no_common) + monkeypatch.setattr(local_demo_pipeline, "inspect_demos", no_players) response = _upload(web_server.app.test_client()) assert response.status_code == 400 - assert "common player" in response.get_json()["error"] + assert "players" in response.get_json()["error"] def test_analyze_runs_in_background_writes_replay_data_and_cleans_session(monkeypatch): From dbbb0ae9f5b0a1feecae9fc473b8a4c3e83503af Mon Sep 17 00:00:00 2001 From: Halcy0nS Date: Wed, 5 Aug 2026 13:39:25 +0800 Subject: [PATCH 04/10] =?UTF-8?q?feat:=20=E5=A4=9A=E9=80=89=E7=8E=A9?= =?UTF-8?q?=E5=AE=B6=E5=88=86=E6=9E=90=20+=20=E6=AF=8F=E4=BA=BA=E6=89=8B?= =?UTF-8?q?=E6=9E=AA=E5=B1=80=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 功能1 - 多选玩家: - inspect 后显示带复选框的玩家列表,默认全选 - analyze 端点接受可选 steamids 数组,只分析选中玩家 - 每名玩家显示 appearances/N 标记出场场次 功能2 - 每人手枪局视图: - 每个玩家除了 Buy 按钮外,额外注册一个 Pistol 视图 - 视图工具栏: 手枪局(全员) | 玩家A Buy | 玩家A 手枪局 | ... - buildPlayerCard 支持 rtype 参数生成对应标签 --- server/static/app.js | 75 +++++++++++++++++++++++------ server/templates/index.html | 14 ++++++ server/tests/test_frontend_js.py | 14 +++++- server/tests/test_local_demo_web.py | 37 ++++++++++++++ server/tests/test_web.py | 2 +- server/web_server.py | 20 ++++++-- 6 files changed, 141 insertions(+), 21 deletions(-) diff --git a/server/static/app.js b/server/static/app.js index 7f55172..ff2ab38 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -44,6 +44,11 @@ function localDemoReady() { return activePlatform === "localdemos" && Boolean(localDemoSessionId); } +function getSelectedSteamids() { + return Array.from(document.querySelectorAll("#local-demo-players input:checked")) + .map(cb => cb.value); +} + function formatBytes(bytes) { const value = Number(bytes); if (!Number.isFinite(value) || value < 0) return "?"; @@ -78,6 +83,8 @@ function resetLocalDemoState() { if (info) info.hidden = true; const map = $("#local-demo-map"); if (map) map.textContent = ""; + const list = $("#local-demo-players"); + if (list) list.replaceChildren(); updateLocalDemoRunButton(); } function localAnalysisEnabled() { @@ -607,11 +614,26 @@ async function inspectLocalDemos() { localDemoPlayers = Array.isArray(data.players) ? data.players : []; updateLocalDemoFileList(Array.isArray(data.files) ? data.files : null); const map = $("#local-demo-map"); - if (map) map.textContent = `Map: ${String(data.map || "unknown")} — ${localDemoFiles.length} files, ${localDemoPlayers.length} common players`; + if (map) map.textContent = `Map: ${String(data.map || "unknown")} — ${localDemoFiles.length} files, ${localDemoPlayers.length} players`; + const list = $("#local-demo-players"); + if (list) { + list.replaceChildren(); + for (const player of localDemoPlayers) { + const label = document.createElement("label"); + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.value = String(player.steamid || ""); + cb.checked = true; + cb.addEventListener("change", updateLocalDemoRunButton); + const name = `${String(player.username || player.steamid)} (${player.appearances}/${localDemoFiles.length})`; + label.append(cb, document.createTextNode(name)); + list.appendChild(label); + } + } const info = $("#local-demo-info"); if (info) info.hidden = false; updateLocalDemoRunButton(); - setStatus(`Demo inspection complete: ${localDemoPlayers.length} common players found.`); + setStatus(`Demo inspection complete: ${localDemoPlayers.length} players found.`); } catch (error) { resetLocalDemoState(); setStatus(`Demo inspection failed: ${error.message}`); @@ -625,12 +647,17 @@ async function runLocalDemoAnalysis() { setStatus("Inspect the Demos first."); return; } + const steamids = getSelectedSteamids(); + if (steamids.length === 0) { + setStatus("Select at least one player to analyze."); + return; + } setAnalysisBusy(true); try { await requestJSON("/api/local-demos/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: localDemoSessionId }) + body: JSON.stringify({ session_id: localDemoSessionId, steamids }) }); lastKnownAnalysisRunning = true; pollEpoch += 1; @@ -754,7 +781,7 @@ function stat(label, value, suffix = "") { return item; } -function buildPlayerCard(data, username, color) { +function buildPlayerCard(data, username, color, rtype = "Buy") { const card = document.createElement("article"); card.className = "card player-card"; card.style.borderLeftColor = color; @@ -773,15 +800,15 @@ function buildPlayerCard(data, username, color) { stat("AWP 持有率", combat.awp_rate, "%"), stat("有效回合", data.round_count ?? (Array.isArray(data.rounds) ? data.rounds.length : 0)) ); - const buyLabel = document.createElement("span"); - buyLabel.className = "buy-label"; - buyLabel.textContent = "Buy"; - heading.append(title, stats, buyLabel); + const rtypeLabel = document.createElement("span"); + rtypeLabel.className = "buy-label"; + rtypeLabel.textContent = rtype === "Pistol" ? "Pistol" : "Buy"; + heading.append(title, stats, rtypeLabel); const canvas = document.createElement("canvas"); canvas.className = "replay-canvas"; - canvas.dataset.rtype = "Buy"; - canvas.setAttribute("aria-label", `${username} Buy 回放`); + canvas.dataset.rtype = rtype; + canvas.setAttribute("aria-label", `${username} ${rtype} 回放`); card.append(heading, canvas); return { card, canvas }; } @@ -814,9 +841,9 @@ async function addPlayer(result, epoch = pollEpoch) { const color = PLAYER_COLORS[nextColor % PLAYER_COLORS.length]; nextColor += 1; - const { card, canvas } = buildPlayerCard(data, username, color); - card.id = `buy-${domain}`; - const buyPlayer = new ReplayPlayer(canvas, { + const { card: buyCard, canvas: buyCanvas } = buildPlayerCard(data, username, color, "Buy"); + buyCard.id = `buy-${domain}`; + const buyPlayer = new ReplayPlayer(buyCanvas, { radar: data.radar, transform: data.transform, rounds: data.rounds, @@ -827,10 +854,28 @@ async function addPlayer(result, epoch = pollEpoch) { ensurePistolPlayer(data); const cards = $("#cards"); if (!cards) throw new Error("页面缺少玩家卡片容器"); - cards.appendChild(card); + cards.appendChild(buyCard); allPlayers.push(buyPlayer); sideTargets.push({ player: buyPlayer, rtype: "Buy" }); - registerReplayView(`buy:${domain}`, username, card, buyPlayer, color, `${username} 购买局`); + registerReplayView(`buy:${domain}`, username, buyCard, buyPlayer, color, `${username} 购买局`); + + const pistolRoundsForPlayer = data.rounds.filter(r => r && r.rtype === "Pistol"); + if (pistolRoundsForPlayer.length > 0) { + const { card: pistolCard, canvas: pistolCanvas } = buildPlayerCard(data, username, color, "Pistol"); + pistolCard.id = `pistol-${domain}`; + const pistolPlayer = new ReplayPlayer(pistolCanvas, { + radar: data.radar, + transform: data.transform, + rounds: data.rounds, + side: currentSide, + rtype: "Pistol" + }); + cards.appendChild(pistolCard); + allPlayers.push(pistolPlayer); + sideTargets.push({ player: pistolPlayer, rtype: "Pistol" }); + registerReplayView(`pistol:${domain}`, `${username} 手枪局`, pistolCard, pistolPlayer, color, `${username} 手枪局`); + } + players.set(domain, { data, buyPlayer, color }); for (const round of data.rounds) { diff --git a/server/templates/index.html b/server/templates/index.html index f1ea840..20e5a96 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -335,6 +335,19 @@ } #local-demo-inspect:hover:not(:disabled) { background: rgba(255, 255, 255, .12); } #local-demo-inspect:disabled { cursor: not-allowed; opacity: .48; } + #local-demo-players { + margin-top: 6px; + max-height: 160px; + overflow-y: auto; + } + #local-demo-players label { + display: flex; + align-items: center; + gap: 5px; + padding: 2px 0; + cursor: pointer; + font-size: 11px; + } #run { width: 100%; min-height: 42px; @@ -634,6 +647,7 @@
diff --git a/server/tests/test_frontend_js.py b/server/tests/test_frontend_js.py index 53d10a8..35ded25 100644 --- a/server/tests/test_frontend_js.py +++ b/server/tests/test_frontend_js.py @@ -579,6 +579,7 @@ def test_local_demo_upload_inspect_and_analyze_flow(): setAttribute(name, value) { this.attributes[name] = String(value); }, addEventListener(name, handler) { this.listeners[name] = handler; }, appendChild(child) { this.children.push(child); }, + append(...children) { this.children.push(...children); }, replaceChildren(...children) { this.children = children; }, }, overrides); } @@ -589,14 +590,23 @@ def test_local_demo_upload_inspect_and_analyze_flow(): "#local-demo-inspect": element(), "#local-demo-info": element(), "#local-demo-map": element(), + "#local-demo-players": element(), "#run": element(), "#status": element(), "#failed": element(), }; +const mockCheckboxes = [ + element({ value: "76561198146001127", checked: true }), + element({ value: "76561198000000001", checked: true }), +]; global.document = { querySelector(selector) { return elements[selector] || null; }, - querySelectorAll() { return []; }, + querySelectorAll(selector) { + if (selector === "#local-demo-players input:checked") return mockCheckboxes; + return []; + }, createElement() { return element(); }, + createTextNode(text) { return { textContent: String(text) }; }, addEventListener() {}, }; @@ -622,7 +632,7 @@ def test_local_demo_upload_inspect_and_analyze_flow(): const analyzeRequest = requests.find(item => item.url === "/api/local-demos/analyze"); const body = JSON.parse(analyzeRequest.options.body); if (body.session_id !== "a".repeat(32)) throw new Error("analyze payload missing session_id"); - if (body.steamid !== undefined) throw new Error("analyze payload should not contain steamid"); + if (!Array.isArray(body.steamids) || body.steamids.length === 0) throw new Error("analyze payload must include steamids array"); })().catch(error => { console.error(error); process.exitCode = 1; }); """ % json.dumps(os.path.abspath(APP_JS)) result = subprocess.run( diff --git a/server/tests/test_local_demo_web.py b/server/tests/test_local_demo_web.py index 89605d4..2f2ec2b 100644 --- a/server/tests/test_local_demo_web.py +++ b/server/tests/test_local_demo_web.py @@ -197,3 +197,40 @@ def test_analyze_rejects_invalid_session(): "/api/local-demos/analyze", json={"session_id": "0" * 32}, ).status_code == 400 + + +def test_analyze_with_steamids_filter(monkeypatch): + monkeypatch.setattr(local_demo_pipeline, "inspect_demos", _fake_inspect) + inspected = _upload(web_server.app.test_client(), ("a.dem", "b.dem")) + session_id = inspected.get_json()["session_id"] + + launched = [] + class CapturedThread: + def __init__(self, target, args=(), daemon=None): + launched.append((target, args, daemon)) + def start(self): + return None + monkeypatch.setattr(web_server.threading, "Thread", CapturedThread) + + def fake_run(paths, *, steamid, username, domain, map_name, output_path, progress_cb=None): + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps({"username": username, "steamid": steamid, "rounds": [], "round_count": 0}), encoding="utf-8") + return {"combat_stats": {}, "total_rounds": 0} + + monkeypatch.setattr(local_demo_pipeline, "run_local_demos", fake_run) + response = web_server.app.test_client().post( + "/api/local-demos/analyze", + json={"session_id": session_id, "steamids": ["76561198146001127"]}, + ) + assert response.status_code == 200 + target, args, _daemon = launched[0] + target(*args) + status = web_server.app.test_client().get("/api/status").get_json() + assert len(status["results"]) == 1 + assert status["results"][0]["domain"] == "local_76561198146001127" + + bad = web_server.app.test_client().post( + "/api/local-demos/analyze", + json={"session_id": session_id, "steamids": ["123"]}, + ) + assert bad.status_code == 400 diff --git a/server/tests/test_web.py b/server/tests/test_web.py index b72d541..233dadb 100644 --- a/server/tests/test_web.py +++ b/server/tests/test_web.py @@ -396,7 +396,7 @@ def test_frontend_registers_button_switched_replay_views(): assert response.status_code == 200 source = response.get_data(as_text=True) assert 'registerReplayView("pistol", "手枪局(全员)"' in source - assert 'registerReplayView(`buy:${domain}`, username, card, buyPlayer, color, `${username} 购买局`)' in source + assert 'registerReplayView(`buy:${domain}`, username, buyCard, buyPlayer, color, `${username} 购买局`)' in source assert 'button.setAttribute("aria-pressed", String(active))' in source assert 'const activeView = replayViews.get(activeViewKey)' in source assert 'clock = { elapsed: 0, playing: true, speed: 2' in source diff --git a/server/web_server.py b/server/web_server.py index 57f1dbc..810c603 100644 --- a/server/web_server.py +++ b/server/web_server.py @@ -402,9 +402,23 @@ def api_local_demos_analyze(): manifest = local_demo_pipeline.load_manifest(session_id) except local_demo_pipeline.LocalDemoError as exc: return jsonify({"error": str(exc)}), 400 - players = manifest.get("players", []) - if not players: - return jsonify({"error": "No common players in this session"}), 400 + all_players = manifest.get("players", []) + if not all_players: + return jsonify({"error": "No players in this session"}), 400 + + requested_ids = data.get("steamids") + if requested_ids is not None: + if not isinstance(requested_ids, list) or not all( + isinstance(s, str) and re.fullmatch(r"\d{10,20}", s.strip()) + for s in requested_ids + ): + return jsonify({"error": "Invalid steamids"}), 400 + wanted = {s.strip() for s in requested_ids} + players = [p for p in all_players if str(p.get("steamid", "")) in wanted] + if not players: + return jsonify({"error": "No matching players selected"}), 400 + else: + players = all_players with state_lock: if state["status"] == "running": From 7c47713c60de02a26abd82df0001b0b80548b308 Mon Sep 17 00:00:00 2001 From: Halcy0nS Date: Thu, 6 Aug 2026 11:07:57 +0800 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20=E6=A8=A1=E5=9D=97=E5=8C=96?= =?UTF-8?q?=E6=8A=BD=E7=A6=BB=E6=B8=B2=E6=9F=93=E5=BC=95=E6=93=8E=E5=88=B0?= =?UTF-8?q?=20replay-engine/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 replay.js 中的 ReplayPlayer 类重构为函数式工厂 createReplay(), 从 app.js 中抽出时钟驱动和视图管理逻辑到 createClock() 和 createViewManager()。 - 新建 server/static/replay-engine/{engine,clock,index}.js - engine.js: createReplay() 工厂 + 渲染常量和辅助函数 - clock.js: createClock() RAF 时钟 + createViewManager() 面板切换 - index.js: Node.js require 统一入口 - replay.js: 改为向后兼容 shim,re-export 引擎模块 - app.js: 使用引擎模块替代内联实现,保留导出接口 - index.html: 加载 engine.js + clock.js 脚本 - 新增 2 个引擎模块测试 (createReplay + createClock/createViewManager) - 更新 test_web.py 源码字符串断言 - 全部 157 passed, 10 skipped --- docs/replay-architecture.md | 169 ++++++++++ install_skills.sh | 28 ++ server/static/app.js | 285 ++++++++-------- server/static/replay-engine/clock.js | 231 +++++++++++++ server/static/replay-engine/engine.js | 450 ++++++++++++++++++++++++++ server/static/replay-engine/index.js | 24 ++ server/static/replay.js | 450 +++++--------------------- server/templates/index.html | 2 + server/tests/test_frontend_js.py | 190 +++++++++++ server/tests/test_web.py | 6 +- 10 files changed, 1301 insertions(+), 534 deletions(-) create mode 100644 docs/replay-architecture.md create mode 100644 install_skills.sh create mode 100644 server/static/replay-engine/clock.js create mode 100644 server/static/replay-engine/engine.js create mode 100644 server/static/replay-engine/index.js diff --git a/docs/replay-architecture.md b/docs/replay-architecture.md new file mode 100644 index 0000000..086a952 --- /dev/null +++ b/docs/replay-architecture.md @@ -0,0 +1,169 @@ +# CS-Scout 2D Demo 回放架构拆解 + +> 使用 codebase-design skill 的 deep-module 词汇分析 + +## 数据流总览 + +``` +.dem 文件 + │ demoparser2 (C++ binding) + ▼ +parse.py ──→ 回合表 / 侧别分类 / 位置采样 / 投掷物轨迹 / 死亡时间 + │ + ▼ +player_json.py ──→ 组装 JSON (含 transform + rounds[]) + │ + ▼ +output/player_{domain}.json + │ HTTP /api/player/ + ▼ +app.js ──→ 时钟驱动 + 视图切换 + 侧别/类型过滤 + │ + ▼ +replay.js ReplayPlayer ──→ Canvas 2D 逐帧绘制 +``` + +整个系统分两层:**服务端解析**(Python,一次性产出 JSON)和**浏览器回放**(JS,循环动画渲染)。两层之间的唯一接口是 `player_{domain}.json` 的 schema。 + +--- + +## 模块拆解 + +### 1. parse.py — Demo 解析(深度模块) + +**接口**:`parse_demo(path, steamid) -> list[round_dict]` + +**实现**:内部经过 5 个阶段,调用方完全不需要知道: + +| 阶段 | 函数 | 做什么 | +|------|------|--------| +| 回合表 | `get_round_table(evts)` | 从 `round_announce_match_start` → `round_freeze_end` → `round_end` 事件序列构建回合列表 | +| 分类 | `classify_rounds(parser, rounds, sids)` | 按 CT/T 侧别 + 装备价值分类为 Pistol / Buy / 丢弃 | +| 位置 | `parse_positions(parser, classified, sid)` | 每 8 个 tick 采样 X/Y,输出 `[[t, x, y], ...]` | +| 投掷物 | `parse_grenades_for_rounds(...)` | 解析 projectile 实体的轨迹,截断静止尾巴,计算 land_t 和 expire_t | +| 死亡 | `parse_deaths_for_rounds(evts, classified, sid)` | 从 `player_death` 事件提取死亡 tick | + +**深度体现**:调用方只需要 `path + steamid`,得到的就是完整的回放数据。内部依赖 demoparser2、pandas、numpy,处理 6 种事件类型、5 种投掷物、tick 级时间轴——全部藏在接口后面。 + +**关键设计**: +- 时间都是"从 freeze_end 开始的秒数",不是绝对 tick。这让前端不需要知道 tickrate。 +- 投掷物轨迹会截断静止尾巴(`_landing_index`),否则 smoke 会在落地后继续画 18 秒的静止点。 +- 低经济回合保留 `side` 但 `rtype=None`,不产出路径/投掷物,但不会打断半场追踪。 + +### 2. maps.py — 坐标变换(适配器) + +**接口**:`game_to_pixel(transform, gx, gy) -> (px, py)` + +``` +px = (gx - pos_x) / scale +py = (pos_y - gy) / scale // Y 轴翻转 +``` + +CS2 游戏坐标的原点在左下角、Y 向上;Canvas 像素原点在左上角、Y 向下。`transform` 来自 awpy 的 `meta.json`,含 `{pos_x, pos_y, scale}` 三个值。 + +这个变换在服务端(`player_json.build` 写入 transform)和浏览器(`ReplayPlayer.g2p` 执行变换)都用到,但接口只有一个函数。 + +### 3. replay.js ReplayPlayer — Canvas 渲染引擎(核心深度模块) + +**接口**(极小): + +```javascript +new ReplayPlayer(canvas, { radar, transform, rounds, side, rtype }) +player.drawAt(gameTime) // 在指定游戏时间绘制一帧 +player.setFilter(side, rtype) // 切换 CT/T + Pistol/Buy 过滤 +player.toggleRound(roundId, enabled) // 开关单回合 +player.destroy() // 清理 +``` + +**实现**(复杂):每帧 `drawAt(gameTime)` 做以下事情: + +``` +1. 清空 canvas → 画雷达底图 +2. 遍历所有匹配 (side, rtype) 且未禁用的回合: + a. 画投掷物: + - 飞行中(throw_t ≤ t < land_t)→ 画轨迹弧线 + 空中图标 + - 已落地(land_t ≤ t < expire_t)→ 画半透明范围圈 + 效果图(烟雾/火焰) + b. 画玩家位置: + - 已死亡(t ≥ death_t)→ 画 X 标记,跳过 + - 存活 → 插值位置 → 画圆点 + 移动方向箭头 +3. 插值使用线性插值(_interp),在采样点之间平滑过渡 +``` + +**深度体现**: +- 5 个方法构成完整接口,但背后是 375 行渲染逻辑 +- `_interp` 函数同时服务于玩家位置、投掷物弹头位置、死亡标记 +- `_velocityAt` 从相邻采样点推断速度方向,画箭头 +- 投掷物图标的着色(`tintedGrenadeAsset`)用 canvas 合成模式实现,缓存结果 +- 死亡后玩家消失(不 holdLast),但投掷物弹头在飞行中用 holdLast 保持位置 + +### 4. app.js — 时钟 + 视图编排(协调者) + +**不是深度模块**,而是协调者。它管理: + +**全局时钟**: +``` +PLAYBACK_S = 10秒(动画循环周期) +WINDOW_S = 20秒(游戏时间窗口) +→ 2x 倍速:10 秒动画展示 20 秒游戏内容 +``` + +`requestAnimationFrame` 驱动 `tick()`,每帧: +1. 计算 delta → 推进 `clock.elapsed` +2. `elapsed % PLAYBACK_S` → 循环 +3. `drawAll(gameTime)` → 只画当前激活视图的 ReplayPlayer + +**视图切换**: +- `replayViews` Map 管理所有视图(手枪局全员、每人 Buy、每人 Pistol) +- 同一时刻只有一个视图可见,只有它的 ReplayPlayer 被绘制 +- CT/T 切换通过 `setSide()` → 遍历所有 `sideTargets` 调用 `setFilter(side, rtype)` + +--- + +## 数据契约 + +两层之间的唯一接口是 JSON schema: + +```json +{ + "transform": {"pos_x": -3453, "pos_y": 2887, "scale": 7}, + "radar": "/maps/de_nuke/radar.png", + "rounds": [{ + "side": "CT", + "rtype": "Buy", + "round_id": 1003, + "path": [[0.0, 2585, -344], [0.125, 2590, -340], ...], + "grenades": [{ + "type": "smoke", + "throw_t": 8.1, + "land_t": 9.4, + "arc": [[8.1, 2500, -300], [9.4, 2100, -250]], + "land": [2100, -250], + "expire_t": 27.4 + }], + "death_t": 12.5 + }] +} +``` + +- `path` 中每个点 `[t, x, y]`:t 是从 freeze_end 起的秒数,x/y 是游戏坐标 +- `grenades.arc` 同结构,`land` 是最终落点 +- `death_t` 为 null 或秒数 + +前端完全依赖这个 schema,不需要知道 tick、tickrate、demoparser2 的存在。 + +--- + +## 设计评价 + +**深度好的地方**: +- `parse_demo` → 极简接口,5 阶段处理全隐藏 +- `ReplayPlayer` → 5 个方法,375 行渲染逻辑 +- `game_to_pixel` → 一个函数,解决坐标系翻转 + +**seam 放置合理处**: +- JSON schema 是服务端和浏览器之间的 seam,两侧可以独立演进 +- `ReplayPlayer` 不关心数据来源(5E 在线 / 本地上传 / 完美平台) +- 时钟与渲染分离:`drawAt(gameTime)` 是纯函数式的,不依赖全局状态 + +**浅的地方**: +- `app.js` 的视图编排逻辑偏过程式,但这是 UI 协调者的正常形态,不需要强行加深 diff --git a/install_skills.sh b/install_skills.sh new file mode 100644 index 0000000..2657b1d --- /dev/null +++ b/install_skills.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Install all non-deprecated skills from mattpocock/skills to ~/.claude/skills/ +SRC="/tmp/mattpocock-skills/skills" +DEST="$HOME/.claude/skills" + +installed=0 +updated=0 + +for category in engineering in-progress misc personal productivity; do + for skill in "$SRC/$category"/*/; do + [ -d "$skill" ] || continue + name=$(basename "$skill") + target="$DEST/$name" + if [ -d "$target" ]; then + rm -rf "$target" + updated=$((updated + 1)) + status="UPDATE" + else + installed=$((installed + 1)) + status="NEW" + fi + cp -r "$skill" "$target" + echo "[$status] $name ($category)" + done +done + +echo "" +echo "=== Done: $installed new, $updated updated, $((installed + updated)) total ===" diff --git a/server/static/app.js b/server/static/app.js index ff2ab38..85a6998 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -1,5 +1,16 @@ "use strict"; +// Load the replay engine modules. In Node, require() resolves the bundled +// index. In the browser, engine.js + clock.js load first and populate +// window.__replayEngine before this script runs. +const engine = typeof require === "function" + ? require("./replay-engine/") + : (typeof window !== "undefined" && window.__replayEngine ? window.__replayEngine : {}); +const createReplay = engine.createReplay; +const createClock = engine.createClock; +const createViewManager = engine.createViewManager; +const PLAYBACK_SPEEDS = engine.PLAYBACK_SPEEDS || [1, 2, 4]; + const $ = selector => document.querySelector(selector); const PLAYER_COLORS = ["#ef6aa8", "#55c8ff", "#ffd166", "#63d297", "#b59cff"]; @@ -8,9 +19,6 @@ let loadingDomains = new Set(); let playerLoadAttempts = new Map(); let playerFetchControllers = new Set(); let allPlayers = []; -let sideTargets = []; -let replayViews = new Map(); -let activeViewKey = null; let pistolRounds = []; let pistolPlayer = null; let nextColor = 0; @@ -35,10 +43,107 @@ let localDemoSessionId = ""; let localDemoPlayers = []; let localDemoFiles = []; -const PLAYBACK_SPEEDS = [1, 2, 4]; -const clock = { elapsed: 0, playing: true, speed: 2, last: null, raf: null }; +// --- Engine instances ------------------------------------------------------- +// The clock drives a single requestAnimationFrame loop that draws the active +// view. The view manager handles button-style panel switching so only one +// replay canvas is visible at a time. +const replayClock = createClock({ + playbackS: engine.PLAYBACK_S || 10, + windowS: engine.WINDOW_S || 20, + onTick: gameTime => drawAll(gameTime), + onControlsUpdate: updateClockControls +}); + +// View manager lazily resolves DOM elements via the provider function so it +// can be created at module load time before the DOM is ready. +const viewManager = createViewManager(() => ({ + switcher: $("#view-switcher"), + toolbar: $("#view-toolbar"), + emptyState: $("#empty-state") +})); + +// --- Clock wrappers (exported for test compatibility) ----------------------- + +function playbackSeconds() { + return replayClock.playbackSeconds(); +} + +function windowSeconds() { + return replayClock.windowSeconds(); +} + +function currentGameTime() { + return replayClock.getGameTime(); +} + +function playbackElapsedDelta(realSeconds, speed) { + return replayClock.playbackElapsedDelta(realSeconds, speed); +} +// --- View management wrappers (exported for test compatibility) ------------ +function activateReplayView(viewKey) { + viewManager.activate(viewKey); + drawAll(); +} + +function registerReplayView(viewKey, label, panel, player, color = "", accessibleLabel = label) { + return viewManager.register(viewKey, label, panel, player, color, accessibleLabel); +} + +function drawAll(gameTime = currentGameTime()) { + viewManager.drawActive(gameTime); +} + +// --- Drawing / clock control updates --------------------------------------- + +function updateClockControls() { + const scrub = $("#scrub"); + const label = $("#timelbl"); + const button = $("#playpause"); + if (scrub && document.activeElement !== scrub) { + scrub.value = String(Math.round(replayClock.getElapsed() / playbackSeconds() * 1000)); + } + if (label) label.textContent = `${currentGameTime().toFixed(1)} / ${windowSeconds().toFixed(1)}s`; + if (button) { + const playing = replayClock.isPlaying(); + button.textContent = playing ? "⏸" : "▶"; + button.title = playing ? "暂停" : "播放"; + button.setAttribute("aria-label", playing ? "暂停回放" : "播放回放"); + } +} + +function setPlaybackSpeed(speed) { + const rate = Number(speed); + if (!PLAYBACK_SPEEDS.includes(rate)) return; + replayClock.setSpeed(rate); + for (const button of document.querySelectorAll("[data-playback-speed]")) { + const active = Number(button.dataset.playbackSpeed) === rate; + button.classList.toggle("active", active); + button.setAttribute("aria-pressed", String(active)); + } +} + +function setSide(side) { + if (side !== "CT" && side !== "T") return; + currentSide = side; + const ct = $("#side-ct"); + const t = $("#side-t"); + if (ct) { + const active = side === "CT"; + ct.classList.toggle("active", active); + ct.setAttribute("aria-pressed", String(active)); + } + if (t) { + const active = side === "T"; + t.classList.toggle("active", active); + t.setAttribute("aria-pressed", String(active)); + } + viewManager.setSide(side); + drawAll(); +} + +// --- Business logic --------------------------------------------------------- function localDemoReady() { return activePlatform === "localdemos" && Boolean(localDemoSessionId); @@ -92,37 +197,6 @@ function localAnalysisEnabled() { document.body.dataset.localAnalysis === "true"); } -function playbackSeconds() { - return typeof PLAYBACK_S === "number" && PLAYBACK_S > 0 ? PLAYBACK_S : 10; -} - -function windowSeconds() { - return typeof WINDOW_S === "number" && WINDOW_S > 0 ? WINDOW_S : 20; -} - -function currentGameTime() { - return clock.elapsed / playbackSeconds() * windowSeconds(); -} - -function playbackElapsedDelta(realSeconds, speed = clock.speed) { - const seconds = Number(realSeconds); - const rate = Number(speed); - if (!Number.isFinite(seconds) || seconds < 0 || !PLAYBACK_SPEEDS.includes(rate)) return 0; - return seconds * rate * playbackSeconds() / windowSeconds(); -} - -function setPlaybackSpeed(speed) { - const rate = Number(speed); - if (!PLAYBACK_SPEEDS.includes(rate)) return; - clock.speed = rate; - clock.last = null; - for (const button of document.querySelectorAll("[data-playback-speed]")) { - const active = Number(button.dataset.playbackSpeed) === rate; - button.classList.toggle("active", active); - button.setAttribute("aria-pressed", String(active)); - } -} - function setAnalysisMode(mode) { if (mode !== "normal" && mode !== "fast") return; analysisMode = mode; @@ -288,63 +362,6 @@ async function runPerfectWorldAnalysis() { } } -function drawAll(gameTime = currentGameTime()) { - const activeView = replayViews.get(activeViewKey); - if (!activeView || !activeView.player) return; - try { - activeView.player.drawAt(gameTime); - } catch (error) { - // A malformed player payload must not stop the shared animation clock. - console.error("Replay draw failed", error); - } -} - -function updateClockControls() { - const scrub = $("#scrub"); - const label = $("#timelbl"); - const button = $("#playpause"); - if (scrub && document.activeElement !== scrub) { - scrub.value = String(Math.round(clock.elapsed / playbackSeconds() * 1000)); - } - if (label) label.textContent = `${currentGameTime().toFixed(1)} / ${windowSeconds().toFixed(1)}s`; - if (button) { - button.textContent = clock.playing ? "⏸" : "▶"; - button.title = clock.playing ? "暂停" : "播放"; - button.setAttribute("aria-label", clock.playing ? "暂停回放" : "播放回放"); - } -} - -function tick(timestamp) { - if (clock.last === null) clock.last = timestamp; - const delta = Math.max(0, Math.min((timestamp - clock.last) / 1000, 1)); - clock.last = timestamp; - if (clock.playing) { - clock.elapsed = (clock.elapsed + playbackElapsedDelta(delta)) % playbackSeconds(); - } - drawAll(); - updateClockControls(); - clock.raf = requestAnimationFrame(tick); -} - -function setSide(side) { - if (side !== "CT" && side !== "T") return; - currentSide = side; - const ct = $("#side-ct"); - const t = $("#side-t"); - if (ct) { - const active = side === "CT"; - ct.classList.toggle("active", active); - ct.setAttribute("aria-pressed", String(active)); - } - if (t) { - const active = side === "T"; - t.classList.toggle("active", active); - t.setAttribute("aria-pressed", String(active)); - } - for (const { player, rtype } of sideTargets) player.setFilter(side, rtype); - drawAll(); -} - function wireControls() { const playPause = $("#playpause"); const scrub = $("#scrub"); @@ -355,18 +372,14 @@ function wireControls() { const platformButtons = document.querySelectorAll("[data-platform]"); if (playPause) { playPause.addEventListener("click", () => { - clock.playing = !clock.playing; - clock.last = null; + replayClock.setPlaying(!replayClock.isPlaying()); updateClockControls(); }); } if (scrub) { scrub.addEventListener("input", event => { const value = Number(event.target.value); - if (!Number.isFinite(value)) return; - clock.playing = false; - clock.elapsed = Math.max(0, Math.min(value, 1000)) / 1000 * playbackSeconds(); - clock.last = null; + replayClock.seek(value); drawAll(); updateClockControls(); }); @@ -393,10 +406,10 @@ function wireControls() { } const localInspectButton = $("#local-demo-inspect"); if (localInspectButton) localInspectButton.addEventListener("click", () => { void inspectLocalDemos(); }); - setPlaybackSpeed(clock.speed); + setPlaybackSpeed(replayClock.getSpeed()); setAnalysisMode(analysisMode); updatePlatformControls(); - document.addEventListener("visibilitychange", () => { clock.last = null; }); + document.addEventListener("visibilitychange", () => { replayClock._raw.last = null; }); } async function requestJSON(url, options) { @@ -511,47 +524,6 @@ function schedulePoll(epoch, delay = 2000) { pollTimer = setTimeout(() => poll(epoch), delay); } -function activateReplayView(viewKey) { - if (!replayViews.has(viewKey)) return; - activeViewKey = viewKey; - for (const [key, view] of replayViews) { - const active = key === viewKey; - view.panel.hidden = !active; - view.button.classList.toggle("active", active); - view.button.setAttribute("aria-pressed", String(active)); - } - drawAll(); -} - -function registerReplayView(viewKey, label, panel, player, color = "", accessibleLabel = label) { - if (replayViews.has(viewKey)) return replayViews.get(viewKey); - const switcher = $("#view-switcher"); - const toolbar = $("#view-toolbar"); - const empty = $("#empty-state"); - if (!switcher || !panel || !player) throw new Error("页面缺少回放视图容器"); - - const button = document.createElement("button"); - button.type = "button"; - button.textContent = label; - button.title = label; - button.dataset.viewKey = viewKey; - button.setAttribute("aria-label", accessibleLabel); - button.setAttribute("aria-pressed", "false"); - if (panel.id) button.setAttribute("aria-controls", panel.id); - if (color) button.style.setProperty("--view-color", color); - button.addEventListener("click", () => activateReplayView(viewKey)); - - panel.hidden = true; - switcher.appendChild(button); - const view = { panel, player, button }; - replayViews.set(viewKey, view); - switcher.hidden = false; - if (toolbar) toolbar.hidden = false; - if (empty) empty.hidden = true; - if (activeViewKey === null) activateReplayView(viewKey); - return view; -} - function resetResults() { for (const controller of playerFetchControllers) controller.abort(); for (const player of allPlayers) { @@ -562,14 +534,12 @@ function resetResults() { playerLoadAttempts = new Map(); playerFetchControllers = new Set(); allPlayers = []; - sideTargets = []; - replayViews = new Map(); - activeViewKey = null; pistolRounds = []; pistolPlayer = null; nextColor = 0; serverFailures = []; uiFailures = new Map(); + viewManager.reset(); const cards = $("#cards"); const switcher = $("#view-switcher"); const toolbar = $("#view-toolbar"); @@ -585,14 +555,11 @@ function resetResults() { if (legend) legend.replaceChildren(); if (pistol) pistol.hidden = true; if (empty) empty.hidden = false; - clock.elapsed = 0; - clock.last = null; + replayClock.setElapsed(0); setSide("CT"); renderFailures(); } - - async function inspectLocalDemos() { const input = $("#local-demo-files"); if (!input || !input.files || input.files.length === 0) { @@ -614,7 +581,7 @@ async function inspectLocalDemos() { localDemoPlayers = Array.isArray(data.players) ? data.players : []; updateLocalDemoFileList(Array.isArray(data.files) ? data.files : null); const map = $("#local-demo-map"); - if (map) map.textContent = `Map: ${String(data.map || "unknown")} — ${localDemoFiles.length} files, ${localDemoPlayers.length} players`; + if (map) map.textContent = `Map: ${String(data.map || "unknown")} - ${localDemoFiles.length} files, ${localDemoPlayers.length} players`; const list = $("#local-demo-players"); if (list) { list.replaceChildren(); @@ -739,7 +706,7 @@ function ensurePistolPlayer(data) { const pistol = $("#pistol"); if (!canvas) throw new Error("页面缺少合并手枪局画布"); if (!pistol) throw new Error("页面缺少合并手枪局面板"); - const player = new ReplayPlayer(canvas, { + const player = createReplay(canvas, { radar: data.radar, transform: data.transform, rounds: pistolRounds, @@ -754,7 +721,7 @@ function ensurePistolPlayer(data) { } pistolPlayer = player; allPlayers.push(pistolPlayer); - sideTargets.push({ player: pistolPlayer, rtype: "Pistol" }); + viewManager.addSideTarget(pistolPlayer, "Pistol"); } function addLegendItem(username, color) { @@ -843,7 +810,7 @@ async function addPlayer(result, epoch = pollEpoch) { const { card: buyCard, canvas: buyCanvas } = buildPlayerCard(data, username, color, "Buy"); buyCard.id = `buy-${domain}`; - const buyPlayer = new ReplayPlayer(buyCanvas, { + const buyPlayer = createReplay(buyCanvas, { radar: data.radar, transform: data.transform, rounds: data.rounds, @@ -856,14 +823,14 @@ async function addPlayer(result, epoch = pollEpoch) { if (!cards) throw new Error("页面缺少玩家卡片容器"); cards.appendChild(buyCard); allPlayers.push(buyPlayer); - sideTargets.push({ player: buyPlayer, rtype: "Buy" }); + viewManager.addSideTarget(buyPlayer, "Buy"); registerReplayView(`buy:${domain}`, username, buyCard, buyPlayer, color, `${username} 购买局`); const pistolRoundsForPlayer = data.rounds.filter(r => r && r.rtype === "Pistol"); if (pistolRoundsForPlayer.length > 0) { const { card: pistolCard, canvas: pistolCanvas } = buildPlayerCard(data, username, color, "Pistol"); pistolCard.id = `pistol-${domain}`; - const pistolPlayer = new ReplayPlayer(pistolCanvas, { + const perPlayerPistol = createReplay(pistolCanvas, { radar: data.radar, transform: data.transform, rounds: data.rounds, @@ -871,9 +838,9 @@ async function addPlayer(result, epoch = pollEpoch) { rtype: "Pistol" }); cards.appendChild(pistolCard); - allPlayers.push(pistolPlayer); - sideTargets.push({ player: pistolPlayer, rtype: "Pistol" }); - registerReplayView(`pistol:${domain}`, `${username} 手枪局`, pistolCard, pistolPlayer, color, `${username} 手枪局`); + allPlayers.push(perPlayerPistol); + viewManager.addSideTarget(perPlayerPistol, "Pistol"); + registerReplayView(`pistol:${domain}`, `${username} 手枪局`, pistolCard, perPlayerPistol, color, `${username} 手枪局`); } players.set(domain, { data, buyPlayer, color }); @@ -987,7 +954,7 @@ function boot() { publicMonitoringEnabled = true; void poll(pollEpoch); updateClockControls(); - if (typeof requestAnimationFrame === "function") clock.raf = requestAnimationFrame(tick); + replayClock.start(); } if (typeof document !== "undefined") { diff --git a/server/static/replay-engine/clock.js b/server/static/replay-engine/clock.js new file mode 100644 index 0000000..5118cac --- /dev/null +++ b/server/static/replay-engine/clock.js @@ -0,0 +1,231 @@ +// CS-Scout replay clock and view manager. The clock drives a single +// requestAnimationFrame loop that calls draw on the active view. The view +// manager handles button-style panel switching so only one replay is visible +// at a time. +"use strict"; + +const PLAYBACK_SPEEDS = [1, 2, 4]; + +/** + * Create a shared animation clock. + * + * @param {object} options + * @param {function} options.getGameTime - returns current game time for drawing + * @param {function} options.onTick - called each frame with the game time + * @param {function} options.onControlsUpdate - called each frame to sync scrubber/label + * @param {number} options.playbackS - playback loop duration (default 10) + * @param {number} options.windowS - game time window (default 20) + * @returns {{ start, stop, setPlaying, setSpeed, setElapsed, seek, + * getElapsed, getGameTime, isPlaying, getSpeed, + * playbackElapsedDelta, playbackSeconds, windowSeconds }} + */ +function createClock(options = {}) { + const playbackS = options.playbackS || 10; + const windowS = options.windowS || 20; + const clock = { elapsed: 0, playing: true, speed: 2, last: null, raf: null }; + + function playbackSeconds() { + return typeof playbackS === "number" && playbackS > 0 ? playbackS : 10; + } + + function windowSeconds() { + return typeof windowS === "number" && windowS > 0 ? windowS : 20; + } + + function getGameTime() { + return clock.elapsed / playbackSeconds() * windowSeconds(); + } + + function playbackElapsedDelta(realSeconds, speed = clock.speed) { + const seconds = Number(realSeconds); + const rate = Number(speed); + if (!Number.isFinite(seconds) || seconds < 0 || !PLAYBACK_SPEEDS.includes(rate)) return 0; + return seconds * rate * playbackSeconds() / windowSeconds(); + } + + function tick(timestamp) { + if (clock.last === null) clock.last = timestamp; + const delta = Math.max(0, Math.min((timestamp - clock.last) / 1000, 1)); + clock.last = timestamp; + if (clock.playing) { + clock.elapsed = (clock.elapsed + playbackElapsedDelta(delta)) % playbackSeconds(); + } + if (typeof options.onTick === "function") options.onTick(getGameTime()); + if (typeof options.onControlsUpdate === "function") options.onControlsUpdate(); + clock.raf = requestAnimationFrame(tick); + } + + function start() { + if (typeof requestAnimationFrame === "function") { + clock.raf = requestAnimationFrame(tick); + } + } + + function stop() { + if (clock.raf !== null && typeof cancelAnimationFrame === "function") { + cancelAnimationFrame(clock.raf); + } + clock.raf = null; + } + + function setPlaying(playing) { + clock.playing = playing; + clock.last = null; + } + + function setSpeed(speed) { + const rate = Number(speed); + if (!PLAYBACK_SPEEDS.includes(rate)) return; + clock.speed = rate; + clock.last = null; + } + + function setElapsed(value) { + clock.elapsed = value; + clock.last = null; + } + + function seek(scrubValue) { + const value = Number(scrubValue); + if (!Number.isFinite(value)) return; + clock.playing = false; + clock.elapsed = Math.max(0, Math.min(value, 1000)) / 1000 * playbackSeconds(); + clock.last = null; + } + + return { + start, + stop, + setPlaying, + setSpeed, + setElapsed, + seek, + getElapsed: () => clock.elapsed, + getGameTime, + isPlaying: () => clock.playing, + getSpeed: () => clock.speed, + playbackElapsedDelta, + playbackSeconds, + windowSeconds, + // Expose raw clock for backward compatibility with code that reads clock.elapsed etc. + _raw: clock + }; +} + +/** + * Create a view manager that handles button-style panel switching. + * + * @param {function|null} getElements - function returning { switcher, toolbar, emptyState } + * @returns {{ register, activate, reset, has, getActive, drawActive, setSide, + * addSideTarget, getViews, getSideTargets, getActiveKey }} + */ +function createViewManager(getElements) { + const views = new Map(); + let activeKey = null; + const sideTargets = []; + + function _elems() { + if (typeof getElements === "function") return getElements(); + if (getElements && typeof getElements === "object") return getElements; + return {}; + } + + function register(viewKey, label, panel, player, color = "", accessibleLabel = label) { + if (views.has(viewKey)) return views.get(viewKey); + const { switcher, toolbar, emptyState } = _elems(); + if (!switcher || !panel || !player) throw new Error("页面缺少回放视图容器"); + + const button = document.createElement("button"); + button.type = "button"; + button.textContent = label; + button.title = label; + button.dataset.viewKey = viewKey; + button.setAttribute("aria-label", accessibleLabel); + button.setAttribute("aria-pressed", "false"); + if (panel.id) button.setAttribute("aria-controls", panel.id); + if (color) button.style.setProperty("--view-color", color); + button.addEventListener("click", () => activate(viewKey)); + + panel.hidden = true; + switcher.appendChild(button); + const view = { panel, player, button, viewKey }; + views.set(viewKey, view); + switcher.hidden = false; + if (toolbar) toolbar.hidden = false; + if (emptyState) emptyState.hidden = true; + if (activeKey === null) activate(viewKey); + return view; + } + + function activate(viewKey) { + if (!views.has(viewKey)) return; + activeKey = viewKey; + for (const [key, view] of views) { + const active = key === viewKey; + view.panel.hidden = !active; + view.button.classList.toggle("active", active); + view.button.setAttribute("aria-pressed", String(active)); + } + } + + function has(viewKey) { + return views.has(viewKey); + } + + function getActive() { + return views.get(activeKey) || null; + } + + function drawActive(gameTime) { + const activeView = views.get(activeKey); + if (!activeView || !activeView.player) return; + try { + activeView.player.drawAt(gameTime); + } catch (error) { + // A malformed player payload must not stop the shared animation clock. + console.error("Replay draw failed", error); + } + } + + function setSide(side) { + for (const { player, rtype } of sideTargets) player.setFilter(side, rtype); + } + + function addSideTarget(player, rtype) { + sideTargets.push({ player, rtype }); + } + + function reset() { + activeKey = null; + views.clear(); + sideTargets.length = 0; + } + + function getViews() { return views; } + function getSideTargets() { return sideTargets; } + function getActiveKey() { return activeKey; } + + return { + register, + activate, + has, + getActive, + drawActive, + setSide, + addSideTarget, + reset, + getViews, + getSideTargets, + getActiveKey + }; +} + +const _exports = { createClock, createViewManager, PLAYBACK_SPEEDS }; + +if (typeof module !== "undefined") { + module.exports = _exports; +} +if (typeof window !== "undefined") { + window.__replayEngine = window.__replayEngine || {}; + Object.assign(window.__replayEngine, _exports); +} diff --git a/server/static/replay-engine/engine.js b/server/static/replay-engine/engine.js new file mode 100644 index 0000000..37abd0c --- /dev/null +++ b/server/static/replay-engine/engine.js @@ -0,0 +1,450 @@ +// CS-Scout canvas replay engine core. One external clock drives every instance. +// Twenty seconds of game time are shown on a ten-second loop. Player trails are +// intentionally not persisted between frames. +"use strict"; + +const PLAYBACK_S = 10; +const WINDOW_S = 20; +const DOT_R = 10; +const SIDE_COLOR = { CT: "#55b8ff", T: "#ffd166" }; +const NADE_COLOR = { + smoke: "#dddddd", + flash: "#fff27a", + he: "#ff6b6b", + molotov: "#ff8c42", + decoy: "#9aa0a6" +}; +const NADE_R = { smoke: 90, molotov: 70 }; +const NADE_ICON_SRC = { + smoke: "smokegrenade.svg", + flash: "flashbang.svg", + he: "hegrenade.svg", + molotov: { CT: "incgrenade.svg", T: "molotov_bottle.svg" } +}; +const NADE_EFFECT_SRC = { smoke: "map_smoke.svg", molotov: "inferno.svg" }; +const NADE_ICON_HEIGHT = 22; +const iconCache = new Map(); +const tintedAssetCache = new Map(); + +function grenadeIcon(filename) { + if (!filename || typeof Image === "undefined") return null; + if (!iconCache.has(filename)) { + const image = new Image(); + image.src = `/icons/${filename}`; + iconCache.set(filename, image); + } + return iconCache.get(filename); +} + +function tintedGrenadeAsset(filename, color) { + const image = grenadeIcon(filename); + if (!image || !image.complete || !image.naturalWidth || !image.naturalHeight) return null; + if (typeof document === "undefined" || typeof document.createElement !== "function") return image; + + const cacheKey = `${filename}:${color}`; + if (tintedAssetCache.has(cacheKey)) return tintedAssetCache.get(cacheKey); + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const ctx = canvas.getContext("2d"); + if (!ctx) return image; + ctx.drawImage(image, 0, 0); + ctx.globalCompositeOperation = "source-in"; + ctx.fillStyle = color; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.globalCompositeOperation = "source-over"; + tintedAssetCache.set(cacheKey, canvas); + return canvas; +} + +function finiteNumber(value) { + return typeof value === "number" && Number.isFinite(value); +} + +function validSample(sample) { + return Array.isArray(sample) && sample.length >= 3 && + finiteNumber(sample[0]) && finiteNumber(sample[1]) && finiteNumber(sample[2]); +} + +/** + * Create a replay renderer bound to a single canvas. + * + * @param {HTMLCanvasElement} canvas + * @param {object} options - { radar, transform, rounds, side, rtype } + * @returns {{ drawAt, setFilter, toggleRound, destroy, _rounds, _interp, _velocityAt, + * _drawGrenade, _drawArrow, _drawX, _drawNadeIcon, _drawNadeEffect, + * _nadeIconSource, g2p, cv, ctx, transform, allRounds, side, rtype, + * disabled, destroyed, imgReady, imgFailed, img }} + */ +function createReplay(canvas, options = {}) { + if (!canvas || typeof canvas.getContext !== "function") { + throw new TypeError("createReplay requires a canvas element"); + } + const cv = canvas; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("2D canvas is not available"); + + const state = { + cv, + ctx, + transform: options.transform || {}, + allRounds: Array.isArray(options.rounds) ? options.rounds : [], + side: options.side || "CT", + rtype: options.rtype || "Buy", + disabled: new Set(), + destroyed: false, + imgReady: false, + imgFailed: false, + img: new Image() + }; + + state.img.onload = () => { + if (state.destroyed) return; + const width = state.img.naturalWidth || state.img.width; + const height = state.img.naturalHeight || state.img.height; + if (width > 0 && height > 0) { + cv.width = width; + cv.height = height; + state.imgReady = true; + } + }; + state.img.onerror = () => { + if (!state.destroyed) state.imgFailed = true; + }; + if (typeof options.radar === "string" && options.radar) state.img.src = options.radar; + else state.imgFailed = true; + + function destroy() { + state.destroyed = true; + state.img.onload = null; + state.img.onerror = null; + } + + function g2p(x, y) { + const transform = state.transform || {}; + const scale = transform.scale; + if (!finiteNumber(x) || !finiteNumber(y) || !finiteNumber(transform.pos_x) || + !finiteNumber(transform.pos_y) || !finiteNumber(scale) || scale === 0) return null; + const px = (x - transform.pos_x) / scale; + const py = (transform.pos_y - y) / scale; + return finiteNumber(px) && finiteNumber(py) ? [px, py] : null; + } + + function _rounds() { + return state.allRounds.filter(round => round && round.side === state.side && + round.rtype === state.rtype && !state.disabled.has(round.round_id)); + } + + function setFilter(side, rtype) { + state.side = side; + state.rtype = rtype; + } + + function toggleRound(roundId, enabled) { + if (enabled) state.disabled.delete(roundId); + else state.disabled.add(roundId); + } + + // Interpolate a [[t,x,y], ...] series. `holdLast` is useful for death + // markers and grenade heads, while live players disappear after their final + // position sample. + function _interp(series, gameTime, holdLast = false) { + if (!Array.isArray(series) || !finiteNumber(gameTime)) return null; + let previous = null; + for (const sample of series) { + if (!validSample(sample)) continue; + if (previous === null) { + previous = sample; + if (gameTime < sample[0]) return null; + if (gameTime === sample[0]) return [sample[1], sample[2]]; + continue; + } + if (sample[0] <= previous[0]) { + previous = sample; + continue; + } + if (gameTime <= sample[0]) { + const fraction = Math.max(0, Math.min(1, + (gameTime - previous[0]) / (sample[0] - previous[0]))); + return [ + previous[1] + (sample[1] - previous[1]) * fraction, + previous[2] + (sample[2] - previous[2]) * fraction + ]; + } + previous = sample; + } + if (previous && (holdLast || gameTime === previous[0])) return [previous[1], previous[2]]; + return null; + } + + function _velocityAt(series, gameTime) { + if (!Array.isArray(series) || !finiteNumber(gameTime)) return null; + let previous = null; + for (const sample of series) { + if (!validSample(sample)) continue; + if (previous === null) { + previous = sample; + continue; + } + if (sample[0] <= previous[0]) { + previous = sample; + continue; + } + if (gameTime <= sample[0]) { + return [sample[1] - previous[1], sample[2] - previous[2]]; + } + previous = sample; + } + return null; + } + + function drawAt(gameTime) { + if (state.destroyed || !finiteNumber(gameTime)) return; + const c = state.ctx; + c.clearRect(0, 0, state.cv.width, state.cv.height); + if (state.imgReady) { + c.drawImage(state.img, 0, 0, state.cv.width, state.cv.height); + } else { + c.fillStyle = "#11141e"; + c.fillRect(0, 0, state.cv.width, state.cv.height); + if (state.imgFailed) { + c.save(); + c.fillStyle = "#ff7777"; + c.font = "600 16px system-ui, sans-serif"; + c.textAlign = "center"; + c.textBaseline = "middle"; + c.fillText("雷达图加载失败", state.cv.width / 2, state.cv.height / 2); + c.restore(); + return; + } + } + + for (const round of _rounds()) { + const color = typeof round.color === "string" && round.color ? + round.color : (SIDE_COLOR[state.side] || SIDE_COLOR.CT); + for (const grenade of (Array.isArray(round.grenades) ? round.grenades : [])) { + _drawGrenade(grenade, gameTime); + } + + const path = Array.isArray(round.path) ? round.path : []; + if (finiteNumber(round.death_t) && gameTime >= round.death_t) { + const death = _interp(path, round.death_t, true); + const deathPixel = death && g2p(death[0], death[1]); + if (deathPixel) _drawX(deathPixel, color); + continue; + } + + const position = _interp(path, gameTime); + const pixel = position && g2p(position[0], position[1]); + if (!pixel) continue; + const velocity = _velocityAt(path, gameTime); + if (velocity) { + const scale = state.transform.scale; + const vx = velocity[0] / scale; + const vy = -velocity[1] / scale; + if (finiteNumber(vx) && finiteNumber(vy) && Math.hypot(vx, vy) > 0.5) { + _drawArrow(pixel[0], pixel[1], Math.atan2(vy, vx), color); + } + } + c.save(); + c.globalAlpha = 0.86; + c.fillStyle = color; + c.beginPath(); + c.arc(pixel[0], pixel[1], DOT_R, 0, Math.PI * 2); + c.fill(); + c.restore(); + } + } + + function _drawGrenade(grenade, gameTime) { + if (!grenade || !finiteNumber(grenade.throw_t) || !finiteNumber(grenade.land_t)) return; + const type = grenade.type; + const color = NADE_COLOR[type] || "#ffffff"; + if (gameTime >= grenade.throw_t && gameTime < grenade.land_t) { + const arc = Array.isArray(grenade.arc) ? grenade.arc : []; + const c = state.ctx; + let started = false; + c.save(); + c.strokeStyle = color; + c.lineWidth = 1.5; + c.beginPath(); + for (const sample of arc) { + if (!validSample(sample) || sample[0] > gameTime) continue; + const pixel = g2p(sample[1], sample[2]); + if (!pixel) continue; + if (started) c.lineTo(pixel[0], pixel[1]); + else { + c.moveTo(pixel[0], pixel[1]); + started = true; + } + } + const head = _interp(arc, gameTime, true); + const headPixel = head && g2p(head[0], head[1]); + if (headPixel) { + if (started) c.lineTo(headPixel[0], headPixel[1]); + else { + c.moveTo(headPixel[0], headPixel[1]); + started = true; + } + } + if (started) c.stroke(); + c.restore(); + + if (headPixel) _drawNadeIcon(type, headPixel, color); + return; + } + + if (!finiteNumber(grenade.expire_t) || gameTime < grenade.land_t || gameTime >= grenade.expire_t || + !Array.isArray(grenade.land) || grenade.land.length < 2) return; + const landing = g2p(grenade.land[0], grenade.land[1]); + if (!landing) return; + const c = state.ctx; + c.save(); + let radius = null; + if (NADE_R[type] && finiteNumber(state.transform.scale) && state.transform.scale !== 0) { + radius = NADE_R[type] / Math.abs(state.transform.scale); + c.globalAlpha = 0.28; + c.fillStyle = color; + c.beginPath(); + c.arc(landing[0], landing[1], radius, 0, Math.PI * 2); + c.fill(); + c.globalAlpha = 1; + } + if (radius) _drawNadeEffect(type, landing, radius, color); + c.fillStyle = color; + c.beginPath(); + c.arc(landing[0], landing[1], 4, 0, Math.PI * 2); + c.fill(); + c.restore(); + } + + function _nadeIconSource(type) { + const source = NADE_ICON_SRC[type]; + if (type !== "molotov") return source; + return source[state.side] || source.T; + } + + function _drawNadeIcon(type, point, color) { + // There is deliberately no decoy entry in NADE_ICON_SRC. + const asset = tintedGrenadeAsset(_nadeIconSource(type), color); + if (!asset || !asset.width || !asset.height) return; + const width = NADE_ICON_HEIGHT * asset.width / asset.height; + const c = state.ctx; + c.save(); + c.shadowColor = "rgba(0,0,0,.9)"; + c.shadowBlur = 4; + c.shadowOffsetX = 1; + c.shadowOffsetY = 1; + c.drawImage(asset, point[0] - width / 2, point[1] - NADE_ICON_HEIGHT / 2, + width, NADE_ICON_HEIGHT); + c.restore(); + } + + function _drawNadeEffect(type, point, radius, color) { + const asset = tintedGrenadeAsset(NADE_EFFECT_SRC[type], color); + if (!asset || !asset.width || !asset.height || !finiteNumber(radius) || radius <= 0) return; + const maxSize = radius * 2; + const scale = maxSize / Math.max(asset.width, asset.height); + const width = asset.width * scale; + const height = asset.height * scale; + const c = state.ctx; + c.save(); + c.globalAlpha = type === "smoke" ? 0.72 : 0.82; + c.shadowColor = "rgba(0,0,0,.65)"; + c.shadowBlur = 3; + c.drawImage(asset, point[0] - width / 2, point[1] - height / 2, width, height); + c.restore(); + } + + function _drawArrow(x, y, angle, color) { + const c = state.ctx; + const radius = DOT_R + 8; + c.save(); + c.translate(x, y); + c.rotate(angle); + c.globalAlpha = 0.95; + c.fillStyle = color; + c.beginPath(); + c.moveTo(radius, 0); + c.lineTo(radius - 9, -6); + c.lineTo(radius - 9, 6); + c.closePath(); + c.fill(); + c.restore(); + } + + function _drawX(point, color) { + const c = state.ctx; + const size = DOT_R * 0.85; + c.save(); + c.globalAlpha = 0.92; + c.strokeStyle = color; + c.lineWidth = 2; + c.beginPath(); + c.moveTo(point[0] - size, point[1] - size); + c.lineTo(point[0] + size, point[1] + size); + c.moveTo(point[0] + size, point[1] - size); + c.lineTo(point[0] - size, point[1] + size); + c.stroke(); + c.restore(); + } + + return { + drawAt, + setFilter, + toggleRound, + destroy, + // Exposed for testing parity with the old class-based API + _rounds, + _interp, + _velocityAt, + _drawGrenade, + _drawArrow, + _drawX, + _drawNadeIcon, + _drawNadeEffect, + _nadeIconSource, + g2p, + // State accessors for backward compatibility with class property access + get cv() { return state.cv; }, + get ctx() { return state.ctx; }, + get transform() { return state.transform; }, + set transform(v) { state.transform = v; }, + get allRounds() { return state.allRounds; }, + get side() { return state.side; }, + set side(v) { state.side = v; }, + get rtype() { return state.rtype; }, + set rtype(v) { state.rtype = v; }, + get disabled() { return state.disabled; }, + get destroyed() { return state.destroyed; }, + get imgReady() { return state.imgReady; }, + set imgReady(v) { state.imgReady = v; }, + get imgFailed() { return state.imgFailed; }, + set imgFailed(v) { state.imgFailed = v; }, + get img() { return state.img; } + }; +} + +const _exports = { + createReplay, + PLAYBACK_S, + WINDOW_S, + SIDE_COLOR, + NADE_COLOR, + NADE_R, + NADE_ICON_SRC, + NADE_EFFECT_SRC, + NADE_ICON_HEIGHT, + grenadeIcon, + tintedGrenadeAsset, + finiteNumber, + validSample +}; + +if (typeof module !== "undefined") { + module.exports = _exports; +} +if (typeof window !== "undefined") { + window.__replayEngine = window.__replayEngine || {}; + Object.assign(window.__replayEngine, _exports); +} diff --git a/server/static/replay-engine/index.js b/server/static/replay-engine/index.js new file mode 100644 index 0000000..8afd85f --- /dev/null +++ b/server/static/replay-engine/index.js @@ -0,0 +1,24 @@ +// CS-Scout replay engine unified entry point. +"use strict"; + +const engine = require("./engine"); +const clock = require("./clock"); + +module.exports = { + createReplay: engine.createReplay, + PLAYBACK_S: engine.PLAYBACK_S, + WINDOW_S: engine.WINDOW_S, + SIDE_COLOR: engine.SIDE_COLOR, + NADE_COLOR: engine.NADE_COLOR, + NADE_R: engine.NADE_R, + NADE_ICON_SRC: engine.NADE_ICON_SRC, + NADE_EFFECT_SRC: engine.NADE_EFFECT_SRC, + NADE_ICON_HEIGHT: engine.NADE_ICON_HEIGHT, + grenadeIcon: engine.grenadeIcon, + tintedGrenadeAsset: engine.tintedGrenadeAsset, + finiteNumber: engine.finiteNumber, + validSample: engine.validSample, + createClock: clock.createClock, + createViewManager: clock.createViewManager, + PLAYBACK_SPEEDS: clock.PLAYBACK_SPEEDS +}; diff --git a/server/static/replay.js b/server/static/replay.js index 3c954de..2e5ecfe 100644 --- a/server/static/replay.js +++ b/server/static/replay.js @@ -1,379 +1,85 @@ -// CS-Scout canvas replay engine. One external clock drives every instance. -// Twenty seconds of game time are shown on a ten-second loop. Player trails are -// intentionally not persisted between frames. -const PLAYBACK_S = 10; -const WINDOW_S = 20; -const DOT_R = 10; -const SIDE_COLOR = { CT: "#55b8ff", T: "#ffd166" }; -const NADE_COLOR = { - smoke: "#dddddd", - flash: "#fff27a", - he: "#ff6b6b", - molotov: "#ff8c42", - decoy: "#9aa0a6" -}; -const NADE_R = { smoke: 90, molotov: 70 }; -const NADE_ICON_SRC = { - smoke: "smokegrenade.svg", - flash: "flashbang.svg", - he: "hegrenade.svg", - molotov: { CT: "incgrenade.svg", T: "molotov_bottle.svg" } -}; -const NADE_EFFECT_SRC = { smoke: "map_smoke.svg", molotov: "inferno.svg" }; -const NADE_ICON_HEIGHT = 22; -const iconCache = new Map(); -const tintedAssetCache = new Map(); - -function grenadeIcon(filename) { - if (!filename || typeof Image === "undefined") return null; - if (!iconCache.has(filename)) { - const image = new Image(); - image.src = `/icons/${filename}`; - iconCache.set(filename, image); - } - return iconCache.get(filename); -} - -function tintedGrenadeAsset(filename, color) { - const image = grenadeIcon(filename); - if (!image || !image.complete || !image.naturalWidth || !image.naturalHeight) return null; - if (typeof document === "undefined" || typeof document.createElement !== "function") return image; - - const cacheKey = `${filename}:${color}`; - if (tintedAssetCache.has(cacheKey)) return tintedAssetCache.get(cacheKey); - const canvas = document.createElement("canvas"); - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; - const ctx = canvas.getContext("2d"); - if (!ctx) return image; - ctx.drawImage(image, 0, 0); - ctx.globalCompositeOperation = "source-in"; - ctx.fillStyle = color; - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.globalCompositeOperation = "source-over"; - tintedAssetCache.set(cacheKey, canvas); - return canvas; -} - -function finiteNumber(value) { - return typeof value === "number" && Number.isFinite(value); -} - -function validSample(sample) { - return Array.isArray(sample) && sample.length >= 3 && - finiteNumber(sample[0]) && finiteNumber(sample[1]) && finiteNumber(sample[2]); -} - +// CS-Scout canvas replay engine. This file is now a backward-compatible shim +// that re-exports from static/replay-engine/. The original class-based API +// (ReplayPlayer) is preserved so existing code and tests continue to work +// while new code should prefer createReplay() from the engine module. +"use strict"; + +const engine = typeof require === "function" + ? require("./replay-engine/engine") + : (typeof window !== "undefined" && window.__replayEngine ? window.__replayEngine : null); + +const createReplay = engine.createReplay; +const PLAYBACK_S = engine.PLAYBACK_S; +const WINDOW_S = engine.WINDOW_S; +const SIDE_COLOR = engine.SIDE_COLOR; +const NADE_COLOR = engine.NADE_COLOR; +const NADE_R = engine.NADE_R; +const NADE_ICON_SRC = engine.NADE_ICON_SRC; +const NADE_EFFECT_SRC = engine.NADE_EFFECT_SRC; +const NADE_ICON_HEIGHT = engine.NADE_ICON_HEIGHT; +const grenadeIcon = engine.grenadeIcon; +const tintedGrenadeAsset = engine.tintedGrenadeAsset; +const finiteNumber = engine.finiteNumber; +const validSample = engine.validSample; + +/** + * Backward-compatible class wrapper around createReplay(). New code should + * use createReplay() directly. + */ class ReplayPlayer { constructor(canvas, options = {}) { - if (!canvas || typeof canvas.getContext !== "function") { - throw new TypeError("ReplayPlayer requires a canvas element"); - } - this.cv = canvas; - this.ctx = canvas.getContext("2d"); - if (!this.ctx) throw new Error("2D canvas is not available"); - this.transform = options.transform || {}; - this.allRounds = Array.isArray(options.rounds) ? options.rounds : []; - this.side = options.side || "CT"; - this.rtype = options.rtype || "Buy"; - this.disabled = new Set(); - this.destroyed = false; - this.imgReady = false; - this.imgFailed = false; - this.img = new Image(); - this.img.onload = () => { - if (this.destroyed) return; - const width = this.img.naturalWidth || this.img.width; - const height = this.img.naturalHeight || this.img.height; - if (width > 0 && height > 0) { - this.cv.width = width; - this.cv.height = height; - this.imgReady = true; - } - }; - this.img.onerror = () => { - if (!this.destroyed) this.imgFailed = true; - }; - if (typeof options.radar === "string" && options.radar) this.img.src = options.radar; - else this.imgFailed = true; - } - - destroy() { - this.destroyed = true; - this.img.onload = null; - this.img.onerror = null; - } - - g2p(x, y) { - const transform = this.transform || {}; - const scale = transform.scale; - if (!finiteNumber(x) || !finiteNumber(y) || !finiteNumber(transform.pos_x) || - !finiteNumber(transform.pos_y) || !finiteNumber(scale) || scale === 0) return null; - const px = (x - transform.pos_x) / scale; - const py = (transform.pos_y - y) / scale; - return finiteNumber(px) && finiteNumber(py) ? [px, py] : null; - } - - _rounds() { - return this.allRounds.filter(round => round && round.side === this.side && - round.rtype === this.rtype && !this.disabled.has(round.round_id)); - } - - setFilter(side, rtype) { - this.side = side; - this.rtype = rtype; - } - - toggleRound(roundId, enabled) { - if (enabled) this.disabled.delete(roundId); - else this.disabled.add(roundId); - } - - // Interpolate a [[t,x,y], ...] series. `holdLast` is useful for death - // markers and grenade heads, while live players disappear after their final - // position sample. - _interp(series, gameTime, holdLast = false) { - if (!Array.isArray(series) || !finiteNumber(gameTime)) return null; - let previous = null; - for (const sample of series) { - if (!validSample(sample)) continue; - if (previous === null) { - previous = sample; - if (gameTime < sample[0]) return null; - if (gameTime === sample[0]) return [sample[1], sample[2]]; - continue; - } - if (sample[0] <= previous[0]) { - previous = sample; - continue; - } - if (gameTime <= sample[0]) { - const fraction = Math.max(0, Math.min(1, - (gameTime - previous[0]) / (sample[0] - previous[0]))); - return [ - previous[1] + (sample[1] - previous[1]) * fraction, - previous[2] + (sample[2] - previous[2]) * fraction - ]; - } - previous = sample; - } - if (previous && (holdLast || gameTime === previous[0])) return [previous[1], previous[2]]; - return null; - } - - _velocityAt(series, gameTime) { - if (!Array.isArray(series) || !finiteNumber(gameTime)) return null; - let previous = null; - for (const sample of series) { - if (!validSample(sample)) continue; - if (previous === null) { - previous = sample; - continue; - } - if (sample[0] <= previous[0]) { - previous = sample; - continue; - } - if (gameTime <= sample[0]) { - return [sample[1] - previous[1], sample[2] - previous[2]]; - } - previous = sample; - } - return null; - } - - drawAt(gameTime) { - if (this.destroyed || !finiteNumber(gameTime)) return; - const ctx = this.ctx; - ctx.clearRect(0, 0, this.cv.width, this.cv.height); - if (this.imgReady) { - ctx.drawImage(this.img, 0, 0, this.cv.width, this.cv.height); - } else { - ctx.fillStyle = "#11141e"; - ctx.fillRect(0, 0, this.cv.width, this.cv.height); - if (this.imgFailed) { - ctx.save(); - ctx.fillStyle = "#ff7777"; - ctx.font = "600 16px system-ui, sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillText("雷达图加载失败", this.cv.width / 2, this.cv.height / 2); - ctx.restore(); - return; - } - } - - for (const round of this._rounds()) { - const color = typeof round.color === "string" && round.color ? - round.color : (SIDE_COLOR[this.side] || SIDE_COLOR.CT); - for (const grenade of (Array.isArray(round.grenades) ? round.grenades : [])) { - this._drawGrenade(grenade, gameTime); - } - - const path = Array.isArray(round.path) ? round.path : []; - if (finiteNumber(round.death_t) && gameTime >= round.death_t) { - const death = this._interp(path, round.death_t, true); - const deathPixel = death && this.g2p(death[0], death[1]); - if (deathPixel) this._drawX(deathPixel, color); - continue; - } - - const position = this._interp(path, gameTime); - const pixel = position && this.g2p(position[0], position[1]); - if (!pixel) continue; - const velocity = this._velocityAt(path, gameTime); - if (velocity) { - const scale = this.transform.scale; - const vx = velocity[0] / scale; - const vy = -velocity[1] / scale; - if (finiteNumber(vx) && finiteNumber(vy) && Math.hypot(vx, vy) > 0.5) { - this._drawArrow(pixel[0], pixel[1], Math.atan2(vy, vx), color); - } - } - ctx.save(); - ctx.globalAlpha = 0.86; - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(pixel[0], pixel[1], DOT_R, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } - } - - _drawGrenade(grenade, gameTime) { - if (!grenade || !finiteNumber(grenade.throw_t) || !finiteNumber(grenade.land_t)) return; - const type = grenade.type; - const color = NADE_COLOR[type] || "#ffffff"; - if (gameTime >= grenade.throw_t && gameTime < grenade.land_t) { - const arc = Array.isArray(grenade.arc) ? grenade.arc : []; - const ctx = this.ctx; - let started = false; - ctx.save(); - ctx.strokeStyle = color; - ctx.lineWidth = 1.5; - ctx.beginPath(); - for (const sample of arc) { - if (!validSample(sample) || sample[0] > gameTime) continue; - const pixel = this.g2p(sample[1], sample[2]); - if (!pixel) continue; - if (started) ctx.lineTo(pixel[0], pixel[1]); - else { - ctx.moveTo(pixel[0], pixel[1]); - started = true; - } - } - const head = this._interp(arc, gameTime, true); - const headPixel = head && this.g2p(head[0], head[1]); - if (headPixel) { - if (started) ctx.lineTo(headPixel[0], headPixel[1]); - else { - ctx.moveTo(headPixel[0], headPixel[1]); - started = true; - } - } - if (started) ctx.stroke(); - ctx.restore(); - - if (headPixel) this._drawNadeIcon(type, headPixel, color); - return; - } - - if (!finiteNumber(grenade.expire_t) || gameTime < grenade.land_t || gameTime >= grenade.expire_t || - !Array.isArray(grenade.land) || grenade.land.length < 2) return; - const landing = this.g2p(grenade.land[0], grenade.land[1]); - if (!landing) return; - const ctx = this.ctx; - ctx.save(); - let radius = null; - if (NADE_R[type] && finiteNumber(this.transform.scale) && this.transform.scale !== 0) { - radius = NADE_R[type] / Math.abs(this.transform.scale); - ctx.globalAlpha = 0.28; - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(landing[0], landing[1], radius, 0, Math.PI * 2); - ctx.fill(); - ctx.globalAlpha = 1; - } - if (radius) this._drawNadeEffect(type, landing, radius, color); - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc(landing[0], landing[1], 4, 0, Math.PI * 2); - ctx.fill(); - ctx.restore(); - } - - _nadeIconSource(type) { - const source = NADE_ICON_SRC[type]; - if (type !== "molotov") return source; - return source[this.side] || source.T; - } - - _drawNadeIcon(type, point, color) { - // There is deliberately no decoy entry in NADE_ICON_SRC. - const asset = tintedGrenadeAsset(this._nadeIconSource(type), color); - if (!asset || !asset.width || !asset.height) return; - const width = NADE_ICON_HEIGHT * asset.width / asset.height; - const ctx = this.ctx; - ctx.save(); - ctx.shadowColor = "rgba(0,0,0,.9)"; - ctx.shadowBlur = 4; - ctx.shadowOffsetX = 1; - ctx.shadowOffsetY = 1; - ctx.drawImage(asset, point[0] - width / 2, point[1] - NADE_ICON_HEIGHT / 2, - width, NADE_ICON_HEIGHT); - ctx.restore(); - } - - _drawNadeEffect(type, point, radius, color) { - const asset = tintedGrenadeAsset(NADE_EFFECT_SRC[type], color); - if (!asset || !asset.width || !asset.height || !finiteNumber(radius) || radius <= 0) return; - const maxSize = radius * 2; - const scale = maxSize / Math.max(asset.width, asset.height); - const width = asset.width * scale; - const height = asset.height * scale; - const ctx = this.ctx; - ctx.save(); - ctx.globalAlpha = type === "smoke" ? 0.72 : 0.82; - ctx.shadowColor = "rgba(0,0,0,.65)"; - ctx.shadowBlur = 3; - ctx.drawImage(asset, point[0] - width / 2, point[1] - height / 2, width, height); - ctx.restore(); - } - - _drawArrow(x, y, angle, color) { - const ctx = this.ctx; - const radius = DOT_R + 8; - ctx.save(); - ctx.translate(x, y); - ctx.rotate(angle); - ctx.globalAlpha = 0.95; - ctx.fillStyle = color; - ctx.beginPath(); - ctx.moveTo(radius, 0); - ctx.lineTo(radius - 9, -6); - ctx.lineTo(radius - 9, 6); - ctx.closePath(); - ctx.fill(); - ctx.restore(); - } + const instance = createReplay(canvas, options); + // Copy all methods + this.drawAt = instance.drawAt; + this.setFilter = instance.setFilter; + this.toggleRound = instance.toggleRound; + this.destroy = instance.destroy; + this._rounds = instance._rounds; + this._interp = instance._interp; + this._velocityAt = instance._velocityAt; + this._drawGrenade = instance._drawGrenade; + this._drawArrow = instance._drawArrow; + this._drawX = instance._drawX; + this._drawNadeIcon = instance._drawNadeIcon; + this._drawNadeEffect = instance._drawNadeEffect; + this._nadeIconSource = instance._nadeIconSource; + this.g2p = instance.g2p; + // Property accessors + this._instance = instance; + } + + get cv() { return this._instance.cv; } + get ctx() { return this._instance.ctx; } + get transform() { return this._instance.transform; } + set transform(v) { this._instance.transform = v; } + get allRounds() { return this._instance.allRounds; } + get side() { return this._instance.side; } + set side(v) { this._instance.side = v; } + get rtype() { return this._instance.rtype; } + set rtype(v) { this._instance.rtype = v; } + get disabled() { return this._instance.disabled; } + get destroyed() { return this._instance.destroyed; } + get imgReady() { return this._instance.imgReady; } + set imgReady(v) { this._instance.imgReady = v; } + get imgFailed() { return this._instance.imgFailed; } + set imgFailed(v) { this._instance.imgFailed = v; } + get img() { return this._instance.img; } +} - _drawX(point, color) { - const ctx = this.ctx; - const size = DOT_R * 0.85; - ctx.save(); - ctx.globalAlpha = 0.92; - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(point[0] - size, point[1] - size); - ctx.lineTo(point[0] + size, point[1] + size); - ctx.moveTo(point[0] + size, point[1] - size); - ctx.lineTo(point[0] - size, point[1] + size); - ctx.stroke(); - ctx.restore(); - } +// Expose to global scope when loaded as a + diff --git a/server/tests/test_frontend_js.py b/server/tests/test_frontend_js.py index 35ded25..e519cf3 100644 --- a/server/tests/test_frontend_js.py +++ b/server/tests/test_frontend_js.py @@ -9,6 +9,8 @@ NODE = shutil.which("node") REPLAY_JS = os.path.join(os.path.dirname(__file__), "..", "static", "replay.js") APP_JS = os.path.join(os.path.dirname(__file__), "..", "static", "app.js") +ENGINE_JS = os.path.join(os.path.dirname(__file__), "..", "static", "replay-engine", "engine.js") +CLOCK_JS = os.path.join(os.path.dirname(__file__), "..", "static", "replay-engine", "clock.js") pytestmark = pytest.mark.skipif(NODE is None, reason="Node.js is not installed") @@ -644,3 +646,191 @@ def test_local_demo_upload_inspect_and_analyze_flow(): ) assert result.returncode == 0, result.stderr or result.stdout assert result.returncode == 0, result.stderr or result.stdout + + +def test_engine_create_replay_factory_matches_class_api(): + script = f""" +global.Image = class FakeImage {{ + constructor() {{ + this.complete = false; + this.naturalWidth = 0; + this.naturalHeight = 0; + }} + set src(value) {{ this._src = value; }} +}}; + +const {{ createReplay, NADE_ICON_SRC, NADE_EFFECT_SRC }} = require({json.dumps(os.path.abspath(ENGINE_JS))}); +const calls = {{ lineTo: [], fillText: [] }}; +const ctx = {{ + clearRect() {{}}, fillRect() {{}}, drawImage() {{}}, save() {{}}, restore() {{}}, + beginPath() {{}}, moveTo() {{}}, stroke() {{}}, arc() {{}}, fill() {{}}, + translate() {{}}, rotate() {{}}, closePath() {{}}, + lineTo(x, y) {{ calls.lineTo.push([x, y]); }}, + fillText(text, x, y) {{ calls.fillText.push([text, x, y]); }} +}}; +const canvas = {{ width: 300, height: 150, getContext() {{ return ctx; }} }}; +const rounds = []; +const player = createReplay(canvas, {{ + radar: "/maps/de_test/radar.png", + transform: {{ pos_x: 0, pos_y: 0, scale: 1 }}, + rounds, + side: "CT", + rtype: "Pistol" +}}); + +if (NADE_ICON_SRC.smoke !== "smokegrenade.svg" || + NADE_ICON_SRC.flash !== "flashbang.svg" || + NADE_ICON_SRC.he !== "hegrenade.svg") throw new Error("new flying icons are not mapped"); +if (NADE_EFFECT_SRC.smoke !== "map_smoke.svg" || + NADE_EFFECT_SRC.molotov !== "inferno.svg") throw new Error("landing effects are not mapped"); +if (player._nadeIconSource("molotov") !== "incgrenade.svg") throw new Error("CT incendiary icon missing"); +player.setFilter("T", "Pistol"); +if (player._nadeIconSource("molotov") !== "molotov_bottle.svg") throw new Error("T molotov icon missing"); +player.setFilter("CT", "Pistol"); + +if (player._interp([[5, 10, 20]], 4) !== null) throw new Error("path appeared before first sample"); +if (JSON.stringify(player._interp([[5, 10, 20]], 5)) !== "[10,20]") throw new Error("exact sample missing"); +if (player._interp([[5, 10, 20]], 6) !== null) throw new Error("live path persisted after last sample"); +if (JSON.stringify(player._interp([[5, 10, 20]], 6, true)) !== "[10,20]") throw new Error("held marker missing"); + +rounds.push({{ side: "CT", rtype: "Pistol", round_id: 1, path: [], grenades: [] }}); +if (player._rounds().length !== 1) throw new Error("mutable merged-round reference was lost"); +player.setFilter("T", "Pistol"); +if (player._rounds().length !== 0) throw new Error("side filter did not update"); + +player._drawGrenade({{ + type: "smoke", throw_t: 1, land_t: 3, expire_t: 20, + arc: [[1, 0, 0], [3, 10, 0]], land: [10, 0] +}}, 2); +const lastLine = calls.lineTo[calls.lineTo.length - 1]; +if (JSON.stringify(lastLine) !== "[5,0]") throw new Error("airborne arc did not reach interpolated icon head"); + +player.imgFailed = true; +player.drawAt(0); +if (!calls.fillText.some(call => call[0] === "雷达图加载失败")) throw new Error("radar failure was not visible"); + +player.destroy(); +""" + result = subprocess.run( + [NODE, "-e", script], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + + +def test_clock_and_view_manager_factories(): + script = f""" +const {{ createClock, createViewManager, PLAYBACK_SPEEDS }} = require({json.dumps(os.path.abspath(CLOCK_JS))}); + +// --- Clock --- +let tickCount = 0; +let controlsCount = 0; +const clock = createClock({{ + onTick: () => {{ tickCount += 1; }}, + onControlsUpdate: () => {{ controlsCount += 1; }} +}}); + +if (clock.isPlaying() !== true) throw new Error("clock should start playing"); +if (clock.getSpeed() !== 2) throw new Error("default speed should be 2x"); +if (clock.playbackSeconds() !== 10) throw new Error("default playback seconds should be 10"); +if (clock.windowSeconds() !== 20) throw new Error("default window seconds should be 20"); + +const deltas = [1, 2, 4].map(speed => clock.playbackElapsedDelta(1, speed)); +if (JSON.stringify(deltas) !== "[0.5,1,2]") {{ + throw new Error("unexpected elapsed deltas: " + JSON.stringify(deltas)); +}} +if (clock.playbackElapsedDelta(1, 3) !== 0 || clock.playbackElapsedDelta(-1, 2) !== 0) {{ + throw new Error("invalid playback speed input was accepted"); +}} + +clock.setSpeed(1); +if (clock.getSpeed() !== 1) throw new Error("speed not updated to 1x"); +clock.setSpeed(2); // restore + +clock.setElapsed(5); +if (clock.getElapsed() !== 5) throw new Error("elapsed not set"); +if (clock.getGameTime() !== 10) throw new Error("game time should be 10 for elapsed=5 at 2x"); + +clock.setPlaying(false); +if (clock.isPlaying()) throw new Error("clock should be paused"); + +clock.seek(500); // 50% of 1000 +if (clock.isPlaying()) throw new Error("seek should pause"); +if (clock.getElapsed() !== 5) throw new Error("seek to 500 should set elapsed to 5"); + +// --- View Manager --- +function element(id) {{ + const classes = new Set(); + return {{ + id, hidden: false, children: [], dataset: {{}}, attributes: {{}}, listeners: {{}}, + classList: {{ + toggle(name, enabled) {{ if (enabled) classes.add(name); else classes.delete(name); }}, + contains(name) {{ return classes.has(name); }} + }}, + style: {{ setProperty() {{}} }}, + appendChild(child) {{ this.children.push(child); }}, + setAttribute(name, value) {{ this.attributes[name] = String(value); }}, + addEventListener(name, handler) {{ this.listeners[name] = handler; }} + }}; +}} + +global.document = {{ createElement: () => element() }}; + +const switcher = element("view-switcher"); +const toolbar = element("view-toolbar"); +toolbar.hidden = true; +const emptyState = element("empty-state"); +const vm = createViewManager(() => ({{ switcher, toolbar, emptyState }})); + +const panel1 = element("p1"); +const player1 = {{ drawAt() {{}}, setFilter() {{}}, destroy() {{}} }}; +const panel2 = element("p2"); +const player2 = {{ drawAt() {{}}, setFilter() {{}}, destroy() {{}} }}; + +vm.register("v1", "View 1", panel1, player1, "#ff0000"); +if (panel1.hidden) throw new Error("first view should be active"); +if (switcher.children.length !== 1) throw new Error("button not appended"); + +vm.register("v2", "View 2", panel2, player2, "#00ff00", "View 2 accessible"); +if (panel1.hidden || !panel2.hidden) throw new Error("second view should not steal active"); +if (switcher.children.length !== 2) throw new Error("second button not appended"); +if (switcher.children[1].attributes["aria-label"] !== "View 2 accessible") {{ + throw new Error("accessible label not set"); +}} + +vm.activate("v2"); +if (!panel1.hidden || panel2.hidden) throw new Error("activate did not switch panels"); +if (switcher.children[0].attributes["aria-pressed"] !== "false" || + switcher.children[1].attributes["aria-pressed"] !== "true") {{ + throw new Error("aria-pressed not updated"); +}} + +// Duplicate registration should be ignored +vm.register("v2", "Duplicate", panel2, player2); +if (switcher.children.length !== 2) throw new Error("duplicate registration created extra button"); + +vm.addSideTarget(player1, "Buy"); +vm.addSideTarget(player2, "Pistol"); +let filterCalls = []; +player1.setFilter = (side, rtype) => {{ filterCalls.push(["p1", side, rtype]); }}; +player2.setFilter = (side, rtype) => {{ filterCalls.push(["p2", side, rtype]); }}; +vm.setSide("T"); +if (filterCalls.length !== 2 || filterCalls[0][1] !== "T" || filterCalls[1][1] !== "T") {{ + throw new Error("setSide did not propagate to all targets"); +}} + +vm.reset(); +if (vm.getActive() !== null) throw new Error("reset did not clear active view"); +if (vm.has("v1")) throw new Error("reset did not clear views"); +""" + result = subprocess.run( + [NODE, "-e", script], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout diff --git a/server/tests/test_web.py b/server/tests/test_web.py index 233dadb..3b0a215 100644 --- a/server/tests/test_web.py +++ b/server/tests/test_web.py @@ -397,9 +397,9 @@ def test_frontend_registers_button_switched_replay_views(): source = response.get_data(as_text=True) assert 'registerReplayView("pistol", "手枪局(全员)"' in source assert 'registerReplayView(`buy:${domain}`, username, buyCard, buyPlayer, color, `${username} 购买局`)' in source - assert 'button.setAttribute("aria-pressed", String(active))' in source - assert 'const activeView = replayViews.get(activeViewKey)' in source - assert 'clock = { elapsed: 0, playing: true, speed: 2' in source + assert 'viewManager.drawActive' in source + assert 'replayClock.start()' in source + assert 'createReplay(buyCanvas' in source assert 'requestProtectedJSON("/api/analyze"' in source assert 'requestJSON("/api/status")' in source assert 'requestJSON(`/api/player/${encodeURIComponent(domain)}`' in source From e8cfdbbb086cf918407b817911af140b24d0b979 Mon Sep 17 00:00:00 2001 From: Halcy0nS Date: Thu, 6 Aug 2026 11:17:57 +0800 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=E7=83=AD=E5=8A=9B=E5=9B=BE?= =?UTF-8?q?=E5=8F=A0=E5=8A=A0=20-=20createHeatmap=20=E5=B7=A5=E5=8E=82=20+?= =?UTF-8?q?=20=E5=85=A8=E5=91=98/=E5=8D=95=E4=BA=BA=E5=AF=86=E5=BA=A6?= =?UTF-8?q?=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 server/static/replay-engine/heatmap.js: - createHeatmap(canvas, options) 函数式工厂 - 密度渲染:径向梯度累加 + getImageData 像素级颜色映射 - heatColor(t) 纯函数:8 段渐变色 (透明→蓝→青→绿→黄→橙→红) - markDirty() 支持动态追加数据后重建 - MIN_POINT_DISTANCE 采样去重避免静止期过密 app.js 集成: - 全员热力图:ensureHeatmapPlayer 收集所有玩家 Buy 回合路径 - 单人热力图:addPlayer 为每位玩家创建独立热力图卡片和视图 - CT/T 侧别过滤联动(viewManager.addSideTarget) - resetResults 清理热力图状态 index.html: - 新增 #heatmap 面板和 #heatmap-canvas 画布 - 加载 heatmap.js 脚本 测试: - test_heatmap_color_gradient_mapping:渐变色映射、钳制、单调性 - test_heatmap_factory_collects_and_filters_points:点采集、过滤、drawAt、toggleRound - test_web.py:新增热力图源码字符串和 HTML 元素断言 - 全部 159 passed, 10 skipped --- server/static/app.js | 49 ++++ server/static/replay-engine/heatmap.js | 325 +++++++++++++++++++++++++ server/static/replay-engine/index.js | 6 +- server/templates/index.html | 8 + server/tests/test_frontend_js.py | 122 ++++++++++ server/tests/test_web.py | 5 + 6 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 server/static/replay-engine/heatmap.js diff --git a/server/static/app.js b/server/static/app.js index 85a6998..d0f6f94 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -9,6 +9,7 @@ const engine = typeof require === "function" const createReplay = engine.createReplay; const createClock = engine.createClock; const createViewManager = engine.createViewManager; +const createHeatmap = engine.createHeatmap; const PLAYBACK_SPEEDS = engine.PLAYBACK_SPEEDS || [1, 2, 4]; const $ = selector => document.querySelector(selector); @@ -21,6 +22,8 @@ let playerFetchControllers = new Set(); let allPlayers = []; let pistolRounds = []; let pistolPlayer = null; +let heatmapRounds = []; +let heatmapPlayer = null; let nextColor = 0; let currentSide = "CT"; let serverFailures = []; @@ -536,6 +539,8 @@ function resetResults() { allPlayers = []; pistolRounds = []; pistolPlayer = null; + heatmapRounds = []; + heatmapPlayer = null; nextColor = 0; serverFailures = []; uiFailures = new Map(); @@ -545,6 +550,7 @@ function resetResults() { const toolbar = $("#view-toolbar"); const legend = $("#pistol-legend"); const pistol = $("#pistol"); + const heatmap = $("#heatmap"); const empty = $("#empty-state"); if (cards) cards.replaceChildren(); if (switcher) { @@ -554,6 +560,7 @@ function resetResults() { if (toolbar) toolbar.hidden = true; if (legend) legend.replaceChildren(); if (pistol) pistol.hidden = true; + if (heatmap) heatmap.hidden = true; if (empty) empty.hidden = false; replayClock.setElapsed(0); setSide("CT"); @@ -724,6 +731,30 @@ function ensurePistolPlayer(data) { viewManager.addSideTarget(pistolPlayer, "Pistol"); } +function ensureHeatmapPlayer(data) { + if (heatmapPlayer) return; + const canvas = $("#heatmap-canvas"); + const panel = $("#heatmap"); + if (!canvas) throw new Error("页面缺少热力图画布"); + if (!panel) throw new Error("页面缺少热力图面板"); + const player = createHeatmap(canvas, { + radar: data.radar, + transform: data.transform, + rounds: heatmapRounds, + side: currentSide, + rtype: "Buy" + }); + try { + registerReplayView("heatmap", "热力图(全员)", panel, player, "#ff6b6b"); + } catch (error) { + player.destroy(); + throw error; + } + heatmapPlayer = player; + allPlayers.push(heatmapPlayer); + viewManager.addSideTarget(heatmapPlayer, "Buy"); +} + function addLegendItem(username, color) { const legend = $("#pistol-legend"); if (!legend) return; @@ -819,6 +850,7 @@ async function addPlayer(result, epoch = pollEpoch) { }); try { ensurePistolPlayer(data); + ensureHeatmapPlayer(data); const cards = $("#cards"); if (!cards) throw new Error("页面缺少玩家卡片容器"); cards.appendChild(buyCard); @@ -843,11 +875,28 @@ async function addPlayer(result, epoch = pollEpoch) { registerReplayView(`pistol:${domain}`, `${username} 手枪局`, pistolCard, perPlayerPistol, color, `${username} 手枪局`); } + // Per-player density heatmap + const { card: heatCard, canvas: heatCanvas } = buildPlayerCard(data, username, color, "热力图"); + heatCard.id = `heat-${domain}`; + const heatPlayer = createHeatmap(heatCanvas, { + radar: data.radar, + transform: data.transform, + rounds: data.rounds, + side: currentSide, + rtype: "Buy" + }); + cards.appendChild(heatCard); + allPlayers.push(heatPlayer); + viewManager.addSideTarget(heatPlayer, "Buy"); + registerReplayView(`heat:${domain}`, `${username} 热力图`, heatCard, heatPlayer, color, `${username} 热力图`); + players.set(domain, { data, buyPlayer, color }); for (const round of data.rounds) { if (round && round.rtype === "Pistol") pistolRounds.push({ ...round, color }); + if (round && round.rtype === "Buy") heatmapRounds.push({ ...round }); } + if (heatmapPlayer) heatmapPlayer.markDirty(); addLegendItem(username, color); uiFailures.delete(username); drawAll(); diff --git a/server/static/replay-engine/heatmap.js b/server/static/replay-engine/heatmap.js new file mode 100644 index 0000000..7d1e727 --- /dev/null +++ b/server/static/replay-engine/heatmap.js @@ -0,0 +1,325 @@ +// CS-Scout heatmap overlay engine. Renders position density on the radar +// canvas. Two usage patterns: +// 1. Per-player: pass one player's rounds to visualise their tendencies. +// 2. All-players: pass a shared mutable array that accumulates every +// player's rounds — call markDirty() after appending. +// The heatmap is density-based and static: drawAt(gameTime) ignores the +// game time parameter but accepts it for clock/view-manager compatibility. +"use strict"; + +const _engine = typeof require === "function" + ? require("./engine") + : (typeof window !== "undefined" && window.__replayEngine ? window.__replayEngine : {}); +const finiteNumber = _engine.finiteNumber || (v => typeof v === "number" && Number.isFinite(v)); +const validSample = _engine.validSample || (s => + Array.isArray(s) && s.length >= 3 && finiteNumber(s[0]) && finiteNumber(s[1]) && finiteNumber(s[2])); + +// Radius of each density blob in canvas pixels. +const HEATMAP_RADIUS = 25; +// Minimum pixel distance between consecutive samples to avoid over-sampling +// slow or stationary movement. +const MIN_POINT_DISTANCE = 3; + +// Colour gradient: [position, r, g, b, a] +const HEATMAP_GRADIENT = [ + [0.00, 0, 0, 0, 0], + [0.10, 0, 0, 180, 60], + [0.25, 0, 60, 255, 110], + [0.45, 0, 200, 180, 150], + [0.60, 180, 255, 0, 175], + [0.75, 255, 220, 0, 200], + [0.90, 255, 120, 0, 220], + [1.00, 255, 30, 0, 240] +]; + +/** + * Map a normalised density value [0, 1] to an [r, g, b, a] tuple. + * Pure function — exported for unit testing. + */ +function heatColor(t) { + t = Math.max(0, Math.min(1, t)); + for (let i = 1; i < HEATMAP_GRADIENT.length; i++) { + if (t <= HEATMAP_GRADIENT[i][0]) { + const prev = HEATMAP_GRADIENT[i - 1]; + const curr = HEATMAP_GRADIENT[i]; + const range = curr[0] - prev[0]; + if (range === 0) return [curr[1], curr[2], curr[3], curr[4]]; + const f = (t - prev[0]) / range; + return [ + Math.round(prev[1] + (curr[1] - prev[1]) * f), + Math.round(prev[2] + (curr[2] - prev[2]) * f), + Math.round(prev[3] + (curr[3] - prev[3]) * f), + Math.round(prev[4] + (curr[4] - prev[4]) * f) + ]; + } + } + const last = HEATMAP_GRADIENT[HEATMAP_GRADIENT.length - 1]; + return [last[1], last[2], last[3], last[4]]; +} + +/** + * Create a heatmap renderer bound to a single canvas. + * + * @param {HTMLCanvasElement} canvas + * @param {object} options - { radar, transform, rounds, side, rtype } + * @returns {{ drawAt, setFilter, toggleRound, markDirty, destroy, + * _filteredRounds, _collectPoints, heatColor, + * cv, ctx, transform, allRounds, side, rtype, disabled, + * destroyed, imgReady, imgFailed, img }} + */ +function createHeatmap(canvas, options = {}) { + if (!canvas || typeof canvas.getContext !== "function") { + throw new TypeError("createHeatmap requires a canvas element"); + } + const cv = canvas; + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("2D canvas is not available"); + + const state = { + cv, + ctx, + transform: options.transform || {}, + allRounds: Array.isArray(options.rounds) ? options.rounds : [], + side: options.side || "CT", + rtype: options.rtype || "Buy", + disabled: new Set(), + destroyed: false, + imgReady: false, + imgFailed: false, + img: new Image(), + densityCanvas: null, + densityPoints: null, + densityDirty: true + }; + + state.img.onload = () => { + if (state.destroyed) return; + const width = state.img.naturalWidth || state.img.width; + const height = state.img.naturalHeight || state.img.height; + if (width > 0 && height > 0) { + cv.width = width; + cv.height = height; + state.imgReady = true; + } + }; + state.img.onerror = () => { + if (!state.destroyed) state.imgFailed = true; + }; + if (typeof options.radar === "string" && options.radar) state.img.src = options.radar; + else state.imgFailed = true; + + function destroy() { + state.destroyed = true; + state.img.onload = null; + state.img.onerror = null; + } + + function g2p(x, y) { + const transform = state.transform || {}; + const scale = transform.scale; + if (!finiteNumber(x) || !finiteNumber(y) || !finiteNumber(transform.pos_x) || + !finiteNumber(transform.pos_y) || !finiteNumber(scale) || scale === 0) return null; + const px = (x - transform.pos_x) / scale; + const py = (transform.pos_y - y) / scale; + return finiteNumber(px) && finiteNumber(py) ? [px, py] : null; + } + + function _filteredRounds() { + return state.allRounds.filter(round => round && round.side === state.side && + round.rtype === state.rtype && !state.disabled.has(round.round_id)); + } + + function setFilter(side, rtype) { + state.side = side; + state.rtype = rtype; + state.densityDirty = true; + } + + function toggleRound(roundId, enabled) { + if (enabled) state.disabled.delete(roundId); + else state.disabled.add(roundId); + state.densityDirty = true; + } + + function markDirty() { + state.densityDirty = true; + } + + /** + * Collect pixel-space position samples from all filtered rounds. + * Consecutive samples closer than MIN_POINT_DISTANCE are skipped to + * avoid over-representing stationary periods. + */ + function _collectPoints() { + const points = []; + for (const round of _filteredRounds()) { + const path = Array.isArray(round.path) ? round.path : []; + let prevX = null; + let prevY = null; + for (const sample of path) { + if (!validSample(sample)) continue; + const pixel = g2p(sample[1], sample[2]); + if (!pixel) continue; + if (prevX !== null && Math.hypot(pixel[0] - prevX, pixel[1] - prevY) < MIN_POINT_DISTANCE) continue; + prevX = pixel[0]; + prevY = pixel[1]; + points.push(pixel); + } + } + return points; + } + + /** + * Build an off-screen canvas containing the colour-mapped density image. + * Returns null when document is unavailable (e.g. Node test env) — caller + * should fall back to _drawFallback. + */ + function _buildDensityCanvas(points) { + if (typeof document === "undefined" || typeof document.createElement !== "function") return null; + const w = cv.width; + const h = cv.height; + if (w <= 0 || h <= 0) return null; + const off = document.createElement("canvas"); + off.width = w; + off.height = h; + const offCtx = off.getContext("2d"); + if (!offCtx) return null; + + // Accumulate white radial gradients additively. + offCtx.globalCompositeOperation = "lighter"; + for (const [x, y] of points) { + const grad = offCtx.createRadialGradient(x, y, 0, x, y, HEATMAP_RADIUS); + grad.addColorStop(0, "rgba(255,255,255,0.25)"); + grad.addColorStop(1, "rgba(255,255,255,0)"); + offCtx.fillStyle = grad; + offCtx.beginPath(); + offCtx.arc(x, y, HEATMAP_RADIUS, 0, Math.PI * 2); + offCtx.fill(); + } + + // Map accumulated alpha to heatmap colours. + try { + const imageData = offCtx.getImageData(0, 0, w, h); + const data = imageData.data; + let maxAlpha = 0; + for (let i = 3; i < data.length; i += 4) { + if (data[i] > maxAlpha) maxAlpha = data[i]; + } + if (maxAlpha > 0) { + for (let i = 0; i < data.length; i += 4) { + const alpha = data[i + 3]; + if (alpha === 0) continue; + const [r, g, b, a] = heatColor(alpha / maxAlpha); + data[i] = r; + data[i + 1] = g; + data[i + 2] = b; + data[i + 3] = a; + } + offCtx.putImageData(imageData, 0, 0); + } + } catch (_e) { + // getImageData not available — leave the white gradient as-is. + } + return off; + } + + /** + * Fallback renderer for environments without off-screen canvas support. + * Draws simple additive circles directly on the main context. + */ + function _drawFallback(c, points) { + c.save(); + c.globalCompositeOperation = "lighter"; + for (const [x, y] of points) { + const grad = c.createRadialGradient(x, y, 0, x, y, HEATMAP_RADIUS); + grad.addColorStop(0, "rgba(255,120,0,0.28)"); + grad.addColorStop(1, "rgba(255,0,0,0)"); + c.fillStyle = grad; + c.beginPath(); + c.arc(x, y, HEATMAP_RADIUS, 0, Math.PI * 2); + c.fill(); + } + c.restore(); + } + + function drawAt(gameTime) { + if (state.destroyed) return; + const c = state.ctx; + c.clearRect(0, 0, cv.width, cv.height); + + // Radar background — same behaviour as createReplay. + if (state.imgReady) { + c.drawImage(state.img, 0, 0, cv.width, cv.height); + } else { + c.fillStyle = "#11141e"; + c.fillRect(0, 0, cv.width, cv.height); + if (state.imgFailed) { + c.save(); + c.fillStyle = "#ff7777"; + c.font = "600 16px system-ui, sans-serif"; + c.textAlign = "center"; + c.textBaseline = "middle"; + c.fillText("雷达图加载失败", cv.width / 2, cv.height / 2); + c.restore(); + return; + } + } + + // Rebuild density map when dirty. + if (state.densityDirty) { + const points = _collectPoints(); + if (points.length > 0) { + state.densityCanvas = _buildDensityCanvas(points); + state.densityPoints = state.densityCanvas ? null : points; + } else { + state.densityCanvas = null; + state.densityPoints = null; + } + state.densityDirty = false; + } + + if (state.densityCanvas) { + c.drawImage(state.densityCanvas, 0, 0); + } else if (state.densityPoints && state.densityPoints.length > 0) { + _drawFallback(c, state.densityPoints); + } + } + + return { + drawAt, + setFilter, + toggleRound, + markDirty, + destroy, + _filteredRounds, + _collectPoints, + heatColor, + g2p, + get cv() { return state.cv; }, + get ctx() { return state.ctx; }, + get transform() { return state.transform; }, + set transform(v) { state.transform = v; }, + get allRounds() { return state.allRounds; }, + get side() { return state.side; }, + set side(v) { state.side = v; }, + get rtype() { return state.rtype; }, + set rtype(v) { state.rtype = v; }, + get disabled() { return state.disabled; }, + get destroyed() { return state.destroyed; }, + get imgReady() { return state.imgReady; }, + set imgReady(v) { state.imgReady = v; }, + get imgFailed() { return state.imgFailed; }, + set imgFailed(v) { state.imgFailed = v; }, + get img() { return state.img; } + }; +} + +const _exports = { createHeatmap, heatColor, HEATMAP_RADIUS, HEATMAP_GRADIENT }; + +if (typeof module !== "undefined") { + module.exports = _exports; +} +if (typeof window !== "undefined") { + window.__replayEngine = window.__replayEngine || {}; + Object.assign(window.__replayEngine, _exports); +} diff --git a/server/static/replay-engine/index.js b/server/static/replay-engine/index.js index 8afd85f..9551337 100644 --- a/server/static/replay-engine/index.js +++ b/server/static/replay-engine/index.js @@ -3,6 +3,7 @@ const engine = require("./engine"); const clock = require("./clock"); +const heatmap = require("./heatmap"); module.exports = { createReplay: engine.createReplay, @@ -20,5 +21,8 @@ module.exports = { validSample: engine.validSample, createClock: clock.createClock, createViewManager: clock.createViewManager, - PLAYBACK_SPEEDS: clock.PLAYBACK_SPEEDS + PLAYBACK_SPEEDS: clock.PLAYBACK_SPEEDS, + createHeatmap: heatmap.createHeatmap, + heatColor: heatmap.heatColor, + HEATMAP_RADIUS: heatmap.HEATMAP_RADIUS }; diff --git a/server/templates/index.html b/server/templates/index.html index 55dcfd7..b77a739 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -700,6 +700,13 @@
+
@@ -707,6 +714,7 @@
+ diff --git a/server/tests/test_frontend_js.py b/server/tests/test_frontend_js.py index e519cf3..8a8d948 100644 --- a/server/tests/test_frontend_js.py +++ b/server/tests/test_frontend_js.py @@ -11,6 +11,7 @@ APP_JS = os.path.join(os.path.dirname(__file__), "..", "static", "app.js") ENGINE_JS = os.path.join(os.path.dirname(__file__), "..", "static", "replay-engine", "engine.js") CLOCK_JS = os.path.join(os.path.dirname(__file__), "..", "static", "replay-engine", "clock.js") +HEATMAP_JS = os.path.join(os.path.dirname(__file__), "..", "static", "replay-engine", "heatmap.js") pytestmark = pytest.mark.skipif(NODE is None, reason="Node.js is not installed") @@ -834,3 +835,124 @@ def test_clock_and_view_manager_factories(): check=False, ) assert result.returncode == 0, result.stderr or result.stdout + + +def test_heatmap_color_gradient_mapping(): + script = f""" +const {{ heatColor, HEATMAP_GRADIENT }} = require({json.dumps(os.path.abspath(HEATMAP_JS))}); + +// At 0 the colour should be fully transparent. +const c0 = heatColor(0); +if (c0[3] !== 0) throw new Error("heatColor(0) should have alpha 0, got " + c0[3]); + +// At 1 it should be red-ish with high alpha. +const c1 = heatColor(1); +if (c1[0] < 200 || c1[3] < 200) throw new Error("heatColor(1) should be bright red with high alpha, got " + JSON.stringify(c1)); + +// Clamping works. +const cNeg = heatColor(-0.5); +if (JSON.stringify(cNeg) !== JSON.stringify(c0)) throw new Error("negative t not clamped to 0"); +const cOver = heatColor(1.5); +if (JSON.stringify(cOver) !== JSON.stringify(c1)) throw new Error("t > 1 not clamped to 1"); + +// Midpoint should be between green and yellow stops. +const cMid = heatColor(0.6); +if (cMid[0] < 100 || cMid[1] < 100) throw new Error("midpoint colour unexpectedly dark: " + JSON.stringify(cMid)); + +// Monotonic alpha increase (not strictly, but general upward trend). +const alphaAt = t => heatColor(t)[3]; +const samples = [0, 0.1, 0.25, 0.5, 0.75, 1.0].map(alphaAt); +let nonDecreasing = true; +for (let i = 1; i < samples.length; i++) {{ + if (samples[i] < samples[i - 1] - 5) {{ nonDecreasing = false; break; }} +}} +if (!nonDecreasing) throw new Error("alpha should generally increase: " + JSON.stringify(samples)); + +// Gradient has at least 4 stops. +if (HEATMAP_GRADIENT.length < 4) throw new Error("gradient too short"); +""" + result = subprocess.run( + [NODE, "-e", script], + capture_output=True, text=True, timeout=15, check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout + + +def test_heatmap_factory_collects_and_filters_points(): + script = f""" +global.Image = class FakeImage {{ + constructor() {{ this.complete = false; this.naturalWidth = 0; this.naturalHeight = 0; }} + set src(value) {{ this._src = value; }} +}}; + +const {{ createHeatmap }} = require({json.dumps(os.path.abspath(HEATMAP_JS))}); + +const calls = {{ fillRect: 0, drawImage: 0 }}; +const ctx = {{ + clearRect() {{}}, fillRect() {{ calls.fillRect += 1; }}, drawImage() {{ calls.drawImage += 1; }}, + save() {{}}, restore() {{}}, beginPath() {{}}, arc() {{}}, fill() {{}}, + fillText() {{}}, textAlign: "", textBaseline: "", font: "", + createRadialGradient() {{ return {{ addColorStop() {{}} }}; }}, + getImageData() {{ return {{ data: new Uint8ClampedArray(4) }}; }}, + putImageData() {{}} +}}; +const canvas = {{ width: 300, height: 200, getContext() {{ return ctx; }} }}; + +const rounds = [ + {{ side: "CT", rtype: "Buy", round_id: 1, + path: [[0, 100, 200], [1, 110, 210], [2, 120, 220], [3, 130, 230]] }}, + {{ side: "T", rtype: "Buy", round_id: 2, + path: [[0, 500, 600], [1, 510, 610]] }}, + {{ side: "CT", rtype: "Pistol", round_id: 3, + path: [[0, 300, 400]] }}, +]; + +const hm = createHeatmap(canvas, {{ + radar: "/maps/de_test/radar.png", + transform: {{ pos_x: 0, pos_y: 0, scale: 1 }}, + rounds, + side: "CT", + rtype: "Buy" +}}); + +// Only round 1 matches CT + Buy. +const points = hm._collectPoints(); +if (points.length < 2 || points.length > 4) {{ + throw new Error("expected 2-4 CT Buy points (after min-distance filter), got " + points.length); +}} +// All points should come from round 1 (x in 100-130 range). +for (const [x, y] of points) {{ + if (x < 100 || x > 130) throw new Error("non-CT-Buy point leaked: " + JSON.stringify([x, y])); +}} + +// Switching to T side should collect round 2 points. +hm.setFilter("T", "Buy"); +const tPoints = hm._collectPoints(); +if (tPoints.length === 0) throw new Error("T Buy points missing"); +for (const [x, y] of tPoints) {{ + if (x < 500) throw new Error("non-T-Buy point leaked: " + JSON.stringify([x, y])); +}} + +// Switching to Pistol should collect round 3. +hm.setFilter("CT", "Pistol"); +const pistolPoints = hm._collectPoints(); +if (pistolPoints.length !== 1) throw new Error("expected 1 CT Pistol point, got " + pistolPoints.length); + +// drawAt should draw background and density (or fallback). +hm.imgFailed = true; +hm.drawAt(0); +if (calls.fillRect === 0) throw new Error("drawAt did not draw background"); + +// toggleRound should exclude a round. +hm.setFilter("CT", "Buy"); +hm.toggleRound(1, false); +if (hm._filteredRounds().length !== 0) throw new Error("disabled round was not excluded"); + +hm.destroy(); +if (!hm.destroyed) throw new Error("destroy did not set destroyed flag"); +""" + result = subprocess.run( + [NODE, "-e", script], + capture_output=True, text=True, timeout=15, check=False, + ) + assert result.returncode == 0, result.stderr or result.stdout diff --git a/server/tests/test_web.py b/server/tests/test_web.py index 3b0a215..af5290c 100644 --- a/server/tests/test_web.py +++ b/server/tests/test_web.py @@ -272,6 +272,8 @@ def test_index_contains_unified_replay_layout(): assert 'role="group" aria-label="回放视图选择"' in html assert 'id="pistol"' in html assert 'id="pistol-canvas"' in html + assert 'id="heatmap"' in html + assert 'id="heatmap-canvas"' in html assert 'class="replay-canvas"' in html assert 'id="cards"' in html assert 'id="mode-normal"' in html @@ -397,9 +399,12 @@ def test_frontend_registers_button_switched_replay_views(): source = response.get_data(as_text=True) assert 'registerReplayView("pistol", "手枪局(全员)"' in source assert 'registerReplayView(`buy:${domain}`, username, buyCard, buyPlayer, color, `${username} 购买局`)' in source + assert 'registerReplayView("heatmap", "热力图(全员)"' in source + assert 'registerReplayView(`heat:${domain}`, `${username} 热力图`' in source assert 'viewManager.drawActive' in source assert 'replayClock.start()' in source assert 'createReplay(buyCanvas' in source + assert 'createHeatmap(heatCanvas' in source assert 'requestProtectedJSON("/api/analyze"' in source assert 'requestJSON("/api/status")' in source assert 'requestJSON(`/api/player/${encodeURIComponent(domain)}`' in source From ca71c6ccd0f40ce78234862f35e321cd4fda0671 Mon Sep 17 00:00:00 2001 From: Halcy0nS Date: Thu, 6 Aug 2026 11:48:15 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=E4=BD=BF=E7=94=A8=20demo=20?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E8=A7=86=E8=A7=92=20yaw=20=E6=9B=BF=E4=BB=A3?= =?UTF-8?q?=E9=80=9F=E5=BA=A6=E5=90=91=E9=87=8F=E7=BB=98=E5=88=B6=E6=9C=9D?= =?UTF-8?q?=E5=90=91=E7=AE=AD=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse.py: - parse_positions 新增提取 yaw 字段 - path 样本扩展为 [t, x, y, yaw](yaw 缺失时仍为 [t, x, y]) engine.js: - _interp 改为插值所有元素(向后兼容 3 元素和 4 元素样本) - drawAt 优先使用 yaw 计算箭头角度,无 yaw 时回退到速度向量 - 转换公式: canvas_angle = (yaw - 90) * π / 180 同时修复 IIFE 包裹问题:浏览器中多 + + + + + diff --git a/local-viewer/server.py b/local-viewer/server.py new file mode 100644 index 0000000..62861ff --- /dev/null +++ b/local-viewer/server.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Standalone local Demo viewer - no Flask, no 5E platform. + +A single-file HTTP server that: + 1. Serves the viewer UI (index.html) and static assets (engine modules, radar icons). + 2. Accepts .dem file uploads, inspects them for player lists. + 3. Parses a selected player's demo data into the same JSON format the main + server produces, so the browser replay engine works unchanged. + +Usage: + python local-viewer/server.py [--port 5050] [--host 127.0.0.1] + +Open http://127.0.0.1:5050 in a browser, drag in a .dem file, pick a player, +and watch the replay. +""" +from __future__ import annotations + +import argparse +import json +import logging +import sys +import traceback +import urllib.parse +from http import HTTPStatus +from http.server import HTTPServer, BaseHTTPRequestHandler +from pathlib import Path + +# --- Path setup: make server/ importable --- +REPO = Path(__file__).resolve().parents[1] +SERVER_DIR = REPO / "server" +sys.path.insert(0, str(SERVER_DIR)) + +import combat # noqa: E402 +import config # noqa: E402 +import maps # noqa: E402 +import parse # noqa: E402 +import pipeline # noqa: E402 +import player_json # noqa: E402 +from local_demo_pipeline import ( # noqa: E402 + inspect_demos, + run_local_demos, +) + +log = logging.getLogger("local-viewer") + +# --- Static file roots --- +VIEWER_DIR = Path(__file__).resolve().parent +STATIC_DIR = SERVER_DIR / "static" +MAPS_DIR = SERVER_DIR / "data" / "maps" +ICONS_DIR = REPO / "radar" / "icons" + +# MIME types for common extensions +MIME = { + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", +} + + +class ViewerHandler(BaseHTTPRequestHandler): + """HTTP handler: serve files + demo inspect/parse API.""" + + def log_message(self, fmt, *args): + # Quieter logging - only show non-GET requests. + if self.command != "GET": + super().log_message(fmt, *args) + + # --- File serving --- + + def _serve_file(self, path: Path, mime: str | None = None): + if not path.is_file(): + self._send_error(HTTPStatus.NOT_FOUND, f"Not found: {path.name}") + return + content_type = mime or MIME.get(path.suffix, "application/octet-stream") + data = path.read_bytes() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(data) + + def _send_json(self, status: int, obj: dict): + body = json.dumps(obj, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(body) + + def _send_error(self, status: int, message: str): + self._send_json(status, {"error": message}) + + # --- GET routes --- + + def do_GET(self): + parsed = urllib.parse.urlparse(self.path) + path = parsed.path + + if path == "/" or path == "/index.html": + self._serve_file(VIEWER_DIR / "index.html") + return + + # Viewer app JS + if path == "/app-viewer.js": + self._serve_file(VIEWER_DIR / "app-viewer.js") + return + + # Engine modules and replay.js + if path.startswith("/static/"): + rel = path[len("/static/"):] + self._serve_file(STATIC_DIR / rel) + return + + # Radar images + if path.startswith("/maps/"): + rel = urllib.parse.unquote(path[len("/maps/"):]) + self._serve_file(MAPS_DIR / rel) + return + + # Grenade icons + if path.startswith("/icons/"): + rel = urllib.parse.unquote(path[len("/icons/"):]) + self._serve_file(ICONS_DIR / rel) + return + + # Map list API + if path == "/api/maps": + self._send_json(HTTPStatus.OK, {"maps": maps.available_maps()}) + return + + self._send_error(HTTPStatus.NOT_FOUND, f"Unknown route: {path}") + + # --- POST routes --- + + def do_POST(self): + parsed = urllib.parse.urlparse(self.path) + path = parsed.path + + if path == "/api/inspect": + self._handle_inspect() + return + + if path == "/api/parse": + self._handle_parse() + return + + self._send_error(HTTPStatus.NOT_FOUND, f"Unknown route: {path}") + + # --- Multipart form parsing (minimal, no external deps) --- + + def _read_multipart(self) -> tuple[dict, list[tuple[str, str, bytes]]]: + """Parse multipart/form-data. Returns (fields, files). + + files is a list of (field_name, filename, content_bytes). + """ + content_type = self.headers.get("Content-Type", "") + if "boundary=" not in content_type: + raise ValueError("Missing multipart boundary") + + boundary = content_type.split("boundary=", 1)[1].strip() + # Boundaries may be quoted. + if boundary.startswith('"') and boundary.endswith('"'): + boundary = boundary[1:-1] + + length = int(self.headers.get("Content-Length", 0)) + if length == 0: + raise ValueError("Empty request body") + + body = self.rfile.read(length) + delimiter = b"--" + boundary.encode() + + fields: dict[str, str] = {} + files: list[tuple[str, str, bytes]] = [] + + # Split on boundary + parts = body.split(delimiter) + for part in parts: + # Skip preamble, epilogue, and closing boundary marker + if part in (b"", b"--", b"--\r\n", b"\r\n", b"\r\n--\r\n"): + continue + # Strip leading \r\n + if part.startswith(b"\r\n"): + part = part[2:] + # Strip trailing \r\n + if part.endswith(b"\r\n"): + part = part[:-2] + # Check for closing boundary -- + if part == b"--": + continue + + # Split header and content + if b"\r\n\r\n" not in part: + continue + header_block, content = part.split(b"\r\n\r\n", 1) + + # Parse Content-Disposition + disposition = "" + for line in header_block.split(b"\r\n"): + if line.lower().startswith(b"content-disposition:"): + disposition = line.decode("utf-8", errors="replace") + break + + if not disposition: + continue + + # Extract name and filename + name = "" + filename = None + for field in disposition.split(";"): + field = field.strip() + if field.startswith("name="): + name = field[5:].strip('"') + elif field.startswith("filename="): + filename = field[9:].strip('"') + + if filename is not None: + files.append((name, filename, content)) + else: + fields[name] = content.decode("utf-8", errors="replace") + + return fields, files + + def _handle_inspect(self): + try: + fields, files = self._read_multipart() + except ValueError as exc: + self._send_error(HTTPStatus.BAD_REQUEST, str(exc)) + return + + dem_files = [(name, Path(filename), content) + for name, filename, content in files + if filename.lower().endswith(".dem")] + if not dem_files: + self._send_error(HTTPStatus.BAD_REQUEST, "No .dem files uploaded") + return + + # Write to temp files for inspection + import tempfile + import shutil + + tmp_dir = Path(tempfile.mkdtemp(prefix="localviewer_")) + try: + demo_paths = [] + for i, (_, filename, content) in enumerate(dem_files): + p = tmp_dir / f"demo_{i}.dem" + p.write_bytes(content) + demo_paths.append(p) + + result = inspect_demos(demo_paths) + # Don't send absolute paths to the browser. + for f in result["files"]: + f["name"] = Path(f["path"]).name + del f["path"] + self._send_json(HTTPStatus.OK, result) + except Exception as exc: + log.warning("Inspect failed: %s", exc) + self._send_error(HTTPStatus.BAD_REQUEST, str(exc)) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + def _handle_parse(self): + try: + fields, files = self._read_multipart() + except ValueError as exc: + self._send_error(HTTPStatus.BAD_REQUEST, str(exc)) + return + + dem_files = [(name, Path(filename), content) + for name, filename, content in files + if filename.lower().endswith(".dem")] + if not dem_files: + self._send_error(HTTPStatus.BAD_REQUEST, "No .dem files uploaded") + return + + steamid = fields.get("steamid", "") + username = fields.get("username", steamid) + map_name = fields.get("map", "") + + if not steamid: + self._send_error(HTTPStatus.BAD_REQUEST, "Missing steamid") + return + if not map_name: + self._send_error(HTTPStatus.BAD_REQUEST, "Missing map") + return + + import tempfile + import shutil + + tmp_dir = Path(tempfile.mkdtemp(prefix="localviewer_")) + try: + demo_paths = [] + for i, (_, filename, content) in enumerate(dem_files): + p = tmp_dir / f"demo_{i}.dem" + p.write_bytes(content) + demo_paths.append(p) + + map_data = maps.load_map(map_name) + output_path = tmp_dir / "player.json" + + summary = run_local_demos( + demo_paths, + steamid=steamid, + username=username, + domain="local-viewer", + map_name=map_name, + output_path=output_path, + ) + + with output_path.open(encoding="utf-8") as f: + player_json_data = json.load(f) + + # Add radar path for the browser + player_json_data["radar"] = f"/maps/{map_name}/radar.png" + player_json_data["transform"] = map_data["transform"] + + self._send_json(HTTPStatus.OK, player_json_data) + except Exception as exc: + log.warning("Parse failed: %s\n%s", exc, traceback.format_exc()) + self._send_error(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc)) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Standalone local Demo viewer") + parser.add_argument("--host", default="127.0.0.1", help="Bind address (default: 127.0.0.1)") + parser.add_argument("--port", type=int, default=5050, help="Port (default: 5050)") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") + + available = maps.available_maps() + if not available: + log.error("No map data found in %s. Run setup_maps.py first.", MAPS_DIR) + return 1 + log.info("Available maps: %s", ", ".join(available)) + + server = HTTPServer((args.host, args.port), ViewerHandler) + url = f"http://{args.host}:{args.port}" + log.info("Local viewer ready at %s", url) + print(f"\n >>> Open {url} in your browser <<<\n") + + try: + server.serve_forever() + except KeyboardInterrupt: + log.info("Shutting down.") + server.shutdown() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 25c9a227e2f248903b2c5625d0ec5d4357a501f3 Mon Sep 17 00:00:00 2001 From: Halcy0nS Date: Sat, 8 Aug 2026 15:47:32 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20=E6=B5=81=E5=BC=8F=20multipart=20?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E6=94=AF=E6=8C=81=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E5=A4=A7=20Demo=20=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - protocol_version 改为 HTTP/1.1,支持浏览器 Expect: 100-continue - _read_multipart 替换为流式 _parse_multipart_streaming 文件内容直接写入临时文件,不再把整个 1GB demo 读进内存 - _handle_inspect/_handle_parse 改用流式解析 --- local-viewer/server.py | 245 ++++++++++++++++++++++++++--------------- 1 file changed, 157 insertions(+), 88 deletions(-) diff --git a/local-viewer/server.py b/local-viewer/server.py index 62861ff..8290493 100644 --- a/local-viewer/server.py +++ b/local-viewer/server.py @@ -64,6 +64,9 @@ class ViewerHandler(BaseHTTPRequestHandler): """HTTP handler: serve files + demo inspect/parse API.""" + # HTTP/1.1 so browsers can upload large files with Expect: 100-continue. + protocol_version = "HTTP/1.1" + def log_message(self, fmt, *args): # Quieter logging - only show non-GET requests. if self.command != "GET": @@ -152,19 +155,19 @@ def do_POST(self): self._send_error(HTTPStatus.NOT_FOUND, f"Unknown route: {path}") - # --- Multipart form parsing (minimal, no external deps) --- + # --- Multipart form parsing (streaming, no external deps) --- - def _read_multipart(self) -> tuple[dict, list[tuple[str, str, bytes]]]: - """Parse multipart/form-data. Returns (fields, files). + def _parse_multipart_streaming(self, tmp_dir: Path) -> tuple[dict, list[Path]]: + """Stream-parse multipart/form-data, writing file parts to tmp_dir. - files is a list of (field_name, filename, content_bytes). + Returns (fields, file_paths). Each file is written directly to disk + so 1 GB+ demos don't need to fit in RAM. """ content_type = self.headers.get("Content-Type", "") if "boundary=" not in content_type: raise ValueError("Missing multipart boundary") boundary = content_type.split("boundary=", 1)[1].strip() - # Boundaries may be quoted. if boundary.startswith('"') and boundary.endswith('"'): boundary = boundary[1:-1] @@ -172,87 +175,158 @@ def _read_multipart(self) -> tuple[dict, list[tuple[str, str, bytes]]]: if length == 0: raise ValueError("Empty request body") - body = self.rfile.read(length) - delimiter = b"--" + boundary.encode() - + boundary_bytes = b"--" + boundary.encode() + # The \r\n before a boundary (except the very first one) is part of + # the previous part's content terminator. + buf = b"" + remaining = length fields: dict[str, str] = {} - files: list[tuple[str, str, bytes]] = [] - - # Split on boundary - parts = body.split(delimiter) - for part in parts: - # Skip preamble, epilogue, and closing boundary marker - if part in (b"", b"--", b"--\r\n", b"\r\n", b"\r\n--\r\n"): - continue - # Strip leading \r\n - if part.startswith(b"\r\n"): - part = part[2:] - # Strip trailing \r\n - if part.endswith(b"\r\n"): - part = part[:-2] - # Check for closing boundary -- - if part == b"--": - continue - - # Split header and content - if b"\r\n\r\n" not in part: - continue - header_block, content = part.split(b"\r\n\r\n", 1) - - # Parse Content-Disposition - disposition = "" - for line in header_block.split(b"\r\n"): - if line.lower().startswith(b"content-disposition:"): - disposition = line.decode("utf-8", errors="replace") + file_paths: list[Path] = [] + file_idx = 0 + + def read_more(need: int): + nonlocal buf, remaining + while len(buf) < need and remaining > 0: + chunk_size = min(65536, remaining) + chunk = self.rfile.read(chunk_size) + if not chunk: + break + buf += chunk + remaining -= len(chunk) + + # Read until we find the boundary + # State machine: HEADER -> BODY -> (boundary) -> HEADER or DONE + state = "preamble" + current_file = None + current_field_name = None + current_filename = None + header_lines: list[str] = [] + + while remaining > 0 or buf: + if state == "preamble": + # Find first boundary + idx = buf.find(boundary_bytes) + if idx == -1: + # Keep last len(boundary_bytes)-1 bytes in case boundary + # spans chunks + keep = len(boundary_bytes) - 1 + if len(buf) > keep: + buf = buf[-keep:] if keep > 0 else b"" + read_more(65536) + continue + buf = buf[idx + len(boundary_bytes):] + state = "header" + header_lines = [] + + elif state == "header": + # Find header/body separator \r\n\r\n + idx = buf.find(b"\r\n\r\n") + if idx == -1: + read_more(65536) + if not buf: + break + continue + header_data = buf[:idx].decode("utf-8", errors="replace") + buf = buf[idx + 4:] + header_lines = header_data.split("\r\n") + + current_field_name = None + current_filename = None + for line in header_lines: + if line.lower().startswith("content-disposition:"): + for field in line.split(";"): + field = field.strip() + if field.startswith("name="): + current_field_name = field[5:].strip('"') + elif field.startswith("filename="): + current_filename = field[9:].strip('"') + + if current_filename is not None: + # File part - open a temp file + if current_file: + current_file.close() + fp = tmp_dir / f"upload_{file_idx}.dem" + current_file = fp.open("wb") + file_paths.append(fp) + file_idx += 1 + else: + # Field part - accumulate in buffer + pass + state = "body" + + elif state == "body": + # Look for the next boundary + # The boundary is preceded by \r\n + search_buf = b"\r\n" + boundary_bytes + idx = buf.find(search_buf) + if idx == -1: + # No boundary found yet - write/keep everything except + # the last len(search_buf)-1 bytes (might be partial boundary) + safe = len(buf) - (len(search_buf) - 1) + if safe > 0: + data = buf[:safe] + buf = buf[safe:] + if current_file: + current_file.write(data) + elif current_field_name is not None: + # Accumulate field value (should be small) + fields.setdefault(current_field_name, "") + fields[current_field_name] += data.decode("utf-8", errors="replace") + read_more(65536) + if not buf and remaining == 0: + # End of body without boundary - write remaining + if current_file: + current_file.write(buf) + buf = b"" + break + continue + # Found boundary - write data up to it + data = buf[:idx] + buf = buf[idx + len(search_buf):] + if current_file: + current_file.write(data) + current_file.close() + current_file = None + elif current_field_name is not None: + fields[current_field_name] = data.decode("utf-8", errors="replace") + + # Check if this is the closing boundary (--) + if buf[:2] == b"--": + break + state = "header" + header_lines = [] + + if not buf and remaining == 0: + break + if not buf: + read_more(65536) + if not buf: break - if not disposition: - continue - - # Extract name and filename - name = "" - filename = None - for field in disposition.split(";"): - field = field.strip() - if field.startswith("name="): - name = field[5:].strip('"') - elif field.startswith("filename="): - filename = field[9:].strip('"') - - if filename is not None: - files.append((name, filename, content)) - else: - fields[name] = content.decode("utf-8", errors="replace") + if current_file: + current_file.close() - return fields, files + return fields, file_paths def _handle_inspect(self): + import tempfile + import shutil + + tmp_dir = Path(tempfile.mkdtemp(prefix="localviewer_")) try: - fields, files = self._read_multipart() + fields, file_paths = self._parse_multipart_streaming(tmp_dir) except ValueError as exc: + shutil.rmtree(tmp_dir, ignore_errors=True) self._send_error(HTTPStatus.BAD_REQUEST, str(exc)) return - dem_files = [(name, Path(filename), content) - for name, filename, content in files - if filename.lower().endswith(".dem")] - if not dem_files: + if not file_paths: + shutil.rmtree(tmp_dir, ignore_errors=True) self._send_error(HTTPStatus.BAD_REQUEST, "No .dem files uploaded") return - # Write to temp files for inspection - import tempfile - import shutil - - tmp_dir = Path(tempfile.mkdtemp(prefix="localviewer_")) try: - demo_paths = [] - for i, (_, filename, content) in enumerate(dem_files): - p = tmp_dir / f"demo_{i}.dem" - p.write_bytes(content) - demo_paths.append(p) - - result = inspect_demos(demo_paths) + result = inspect_demos(file_paths) # Don't send absolute paths to the browser. for f in result["files"]: f["name"] = Path(f["path"]).name @@ -265,16 +339,19 @@ def _handle_inspect(self): shutil.rmtree(tmp_dir, ignore_errors=True) def _handle_parse(self): + import tempfile + import shutil + + tmp_dir = Path(tempfile.mkdtemp(prefix="localviewer_")) try: - fields, files = self._read_multipart() + fields, file_paths = self._parse_multipart_streaming(tmp_dir) except ValueError as exc: + shutil.rmtree(tmp_dir, ignore_errors=True) self._send_error(HTTPStatus.BAD_REQUEST, str(exc)) return - dem_files = [(name, Path(filename), content) - for name, filename, content in files - if filename.lower().endswith(".dem")] - if not dem_files: + if not file_paths: + shutil.rmtree(tmp_dir, ignore_errors=True) self._send_error(HTTPStatus.BAD_REQUEST, "No .dem files uploaded") return @@ -283,28 +360,20 @@ def _handle_parse(self): map_name = fields.get("map", "") if not steamid: + shutil.rmtree(tmp_dir, ignore_errors=True) self._send_error(HTTPStatus.BAD_REQUEST, "Missing steamid") return if not map_name: + shutil.rmtree(tmp_dir, ignore_errors=True) self._send_error(HTTPStatus.BAD_REQUEST, "Missing map") return - import tempfile - import shutil - - tmp_dir = Path(tempfile.mkdtemp(prefix="localviewer_")) try: - demo_paths = [] - for i, (_, filename, content) in enumerate(dem_files): - p = tmp_dir / f"demo_{i}.dem" - p.write_bytes(content) - demo_paths.append(p) - map_data = maps.load_map(map_name) output_path = tmp_dir / "player.json" - summary = run_local_demos( - demo_paths, + run_local_demos( + file_paths, steamid=steamid, username=username, domain="local-viewer",