Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 37 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ web_server.py (Flask, port 5000)
output/player_{domain}.json + output/analysis_summary.json
Browser fetches /api/player/<domain> → ReplayPlayer canvas (9s loop, CT/T × Pistol/Full/Eco)
Browser fetches /api/player/<domain> → ReplayPlayer canvas (looping, unified CT/T; merged-pistol + per-player Buy)
```

### Module Map (`server/`)
Expand All @@ -40,7 +40,7 @@ Browser fetches /api/player/<domain> → ReplayPlayer canvas (9s loop, CT/T × P
| `maps.py` | Runtime map loader: `load_map(name)`, `game_to_pixel(transform,gx,gy)`, `available_maps()` |
| `setup_maps.py` | One-time: pull radar.png + transform from awpy into `data/maps/<map>/` |
| `parse.py` | `get_round_table`, `classify_rounds` (3-type CT/T), `parse_positions`, `parse_grenades_for_rounds`, `parse_demo` (merger) |
| `combat.py` | `parse_combat_stats` (K/D + CT AWP), `aggregate_combat_stats` |
| `combat.py` | `parse_combat_stats` (K/D + AWP-hold rate), `aggregate_combat_stats` |
| `player_json.py` | `build(...)` — assembles per-player JSON from rounds + combat |
| `pipeline.py` | `run(usernames, map_name, ...)`, demo dedup index, `cleanup_demos`, `download_and_extract`, `assemble_round_offset` |
| `api_client.py` | 5E scraping: `search_player`, `get_demos_by_domain(domain, map_name, count)`, `get_steamid_for_player`, `download_demo` |
Expand Down Expand Up @@ -87,12 +87,18 @@ if total `.dem` size > 30 GB, deletes oldest files until ≤ 10 GB.
- `game_to_pixel(transform, gx, gy)` = `((gx-pos_x)/scale, (pos_y-gy)/scale)` — Y axis inverted.
- `available_maps()` lists dirs under `MAPS_DIR` that contain `meta.json`.

### Round Classification (parse.classify_rounds) — 3 types, CT and T
### Round Classification (parse.classify_rounds) — 2 kept types, CT and T

At each `round_freeze_end`, for the target steamid: `side` ∈ {CT, T}. Economy:
- **Pistol** — first round of each half-segment (side flips into a new half).
- **Full** — team-avg `current_equip_value` ≥ `EQ_FULL_BUY` (3800).
- **Eco** — otherwise. (No Force Buy in 2.0.)
At each `round_freeze_end`, for the target steamid: `side` ∈ {CT, T}. Economy is judged on
the target's **own** `current_equip_value` (not team average):
- **Pistol** — first round of each half-segment (side flips into a new half). Always kept,
regardless of equip.
- **Buy** — non-pistol with personal equip ≥ `EQ_BUY_MIN` (2000).
- Non-pistol with personal equip < 2000 → **dropped**: `rtype=None`, excluded from JSON.
The round still carries its real `side` so half-segment (pistol) tracking isn't broken.

Downstream `parse_positions`/`parse_grenades_for_rounds`/`parse_deaths_for_rounds` filter on
`r["side"] and r["rtype"]`, so dropped rounds produce no path/grenades/death.

### Position Sampling (parse.parse_positions)

Expand All @@ -108,9 +114,11 @@ Types: smoke/flash/he/molotov/decoy. Durations: smoke 18s, molotov 7s, decoy 15s
### Combat Stats (combat.py)

- **K/D** — global scoreboard: `kills_total`/`deaths_total` at last `round_end` tick; averaged across demos.
- **AWP rate** — CT-side AWP kills / CT-side total kills. CT rounds via `classify_rounds` `fe_tick`/`end_tick`.
`SNIPER_EVENT_NAMES = {"awp","ssg08","g3sg1","scar20"}` (plain event `weapon` names).
Aggregated as `sum(awp_kills)/sum(ct_kills)`.
- **AWP rate** — **AWP-hold rate**: rounds where the player ever held an AWP / total rounds played
(both sides, any economy). "Held" = the `inventory` tick field (weapon display names) contains
`"AWP"` at any sample tick across the round window. Per demo returns `{awp_rounds, total_rounds}`;
aggregated as `sum(awp_rounds)/sum(total_rounds)`. (Replaces the old CT-side AWP-kill ratio, which
was too sparse and read ~0% for most players.)

### Endpoints (web_server.py)

Expand All @@ -121,6 +129,7 @@ Types: smoke/flash/he/molotov/decoy. Durations: smoke 18s, molotov 7s, decoy 15s
- `GET /api/player/<domain>` — that player's `player_{domain}.json` (404 if missing).
- `GET /api/results` — raw `analysis_summary.json` (no path normalization in 2.0).
- `GET /output/<file>` — serves output JSON. `GET /maps/<path>` — serves radar from `MAPS_DIR`.
- `GET /icons/<path>` — serves grenade icon SVGs from `ICONS_DIR` (repo-root `radar/icons/`).
- `GET /` — `index.html`.

### JSON Contract
Expand All @@ -143,7 +152,7 @@ Types: smoke/flash/he/molotov/decoy. Durations: smoke 18s, molotov 7s, decoy 15s
]
}
```
`side` ∈ {CT,T}; `rtype` ∈ {Pistol,Full,Eco}; all `t` are seconds from freeze_end; coords are game coords.
`side` ∈ {CT,T}; `rtype` ∈ {Pistol,Buy}; all `t` are seconds from freeze_end; coords are game coords.

`output/analysis_summary.json`:
```json
Expand All @@ -155,16 +164,24 @@ Types: smoke/flash/he/molotov/decoy. Durations: smoke 18s, molotov 7s, decoy 15s

### Frontend (templates/index.html + static/app.js + static/replay.js)

- Sticky 50px header + left sidebar (map `<select>`, 5 username inputs, depth, key, scan button,
status, failed list) + right panel of player cards.
- Sticky 50px header (with a **unified CT/T toggle** + global play/pause + scrubber) + left sidebar
(map `<select>`, 5 username inputs, depth, key, scan button, status, failed list) + main panel.
- Main panel = a **merged pistol overlay** on top (`#pistol`: one canvas overlaying *all* scanned
players' Pistol rounds, each player a distinct color + legend) followed by per-player cards.
- The header CT/T toggle drives `side` on **every** canvas at once (merged pistol + each card's buy
canvas); one side is shown at a time. There are no per-card tabs.
- `app.js`: `loadMaps()` fills the dropdown; `run()` POSTs `/api/analyze`; `poll()` hits `/api/status`
every 2s and adds a card per result via `/api/player/<domain>`.
- Each card: K/D, AWP%, round count; CT/T tabs; 3 canvases (Pistol/Full/Eco). Switching tab calls
`setFilter(side, rtype)` on each `ReplayPlayer`.
every 2s and adds a card per result via `/api/player/<domain>`. Each card: K/D, AWP-hold %, round
count, and a single **Buy** canvas. The merged overlay grows a shared `pistolRounds` array as
players load; the toggle calls `setFilter(side, fixedRtype)` on each registered `ReplayPlayer`.
- `replay.js` `ReplayPlayer(canvas, {radar, transform, rounds, side, rtype})`:
overlays all matching rounds on a `PLAYBACK_S=9`s loop (45s game time, 5× speed). **No fading trails.**
Draws grenade in-flight arcs, landing dots, and range circles (smoke/molotov) during `[land_t, expire_t]`.
Methods: `setFilter`, `toggleRound`, `start`, `stop`. `static/replay_test.html` is a standalone fixture.
overlays all matching rounds on a `PLAYBACK_S` loop (`WINDOW_S` game time accelerated). **No fading
trails.** Per-round `color` overrides the side color (used by the merged pistol overlay). Draws
grenade in-flight arcs, landing dots, and range circles (smoke/molotov) during `[land_t, expire_t]`.
While a nade is airborne (`[throw_t, land_t)`) it also draws a **white SVG icon** at the arc head
(smoke/flash/he/molotov, served from `/icons/<file>.svg`, drop-shadow for contrast); the icon
disappears on detonation (no decoy icon). Source SVGs live in repo-root `radar/icons/`.
Methods: `setFilter`, `toggleRound`, `drawAt`, `_drawNadeIcon`. `static/replay_test.html` is a standalone fixture.

### Known Data Limitations

Expand Down Expand Up @@ -232,7 +249,7 @@ Always cast result to `pd.DataFrame` and cast `steamid` to `str`.

| File | Purpose |
|------|---------|
| `server/config.py` | `HOST/PORT/SECRET_KEY`, paths, `MAPS_DIR`, `TICK_RATE=64`, `WINDOW_S=45`, `SAMPLE_EVERY=8`, `EQ_FULL_BUY=3800` |
| `server/config.py` | `HOST/PORT/SECRET_KEY`, paths, `MAPS_DIR`, `TICK_RATE=64`, `WINDOW_S=20`, `SAMPLE_EVERY=8`, `EQ_BUY_MIN=2000` (per-player buy floor; `EQ_FULL_BUY` legacy/unused) |
| `server/data/maps/<map>/radar.png` | Radar background (generated by setup_maps.py) |
| `server/data/maps/<map>/meta.json` | Coordinate transform `{pos_x,pos_y,scale}` |
| `server/demos_opponents/` | Downloaded .dem files + `.demo_index.json` |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# 设计:个人买局分类 + 持狙占比 + 合并布局

日期:2026-06-25
状态:已批准设计,待写实现计划

## 背景与目标

CS-Scout 2.0 当前把回合分为 Pistol/Full/Eco(按全队平均装备值),每名对手一张卡片、
三宫格(Pistol/Full/Eco)、每卡各自带 CT/T tabs;AWP 率口径为"仅 CT 方 AWP 击杀 / CT 方
总击杀",数据稀疏导致几乎总是 0%。

本次改动三件事:

1. 回合分类改为**个人判定**,去掉 Eco,丢弃个人装备过低的局,只保留 **Pistol** 和 **Buy**。
2. AWP 率改为**持狙回合占比**,修掉一直 0% 的问题。
3. 前端重构:顶部**统一 CT/T 切换**;**手枪局把 5 名对手合并到一张图**置顶;每名对手卡片只留
一张**买局**图。

## 1. 回合分类(`parse.py` `classify_rounds`)

判定点不变:每回合 freeze_end 快照,字段 `current_equip_value`。

- **改个人判定**:用目标玩家个人的 `current_equip_value`,不再算全队平均。
- 类型 3 种 → 2 种:
- **Pistol** — 每半场首回合(现有 `pistol_num` / 换边逻辑不变;无条件保留,不受 2000 门槛限制)。
- **Buy** — 非手枪局且个人装备 `>= EQ_BUY_MIN`(2000)。
- 非手枪局且个人装备 `< 2000` → **丢弃**:`rtype = None`,不进入 JSON。
- **换边检测不能被丢弃局打断**:手枪局判定依赖 `prev_side`。因此即使某局被丢弃,`side` 仍按
真实 CT/T 计算并用于 `prev_side` 跟踪,只把 `rtype` 置 `None` 表示丢弃。否则下一局会被
误判为半场首局(=手枪局)。
- **下游过滤**:`parse_positions` / `parse_grenades_for_rounds` / `parse_deaths_for_rounds`
当前的 `active = [r for r in classified if r["side"]]` 改为
`if r["side"] and r["rtype"]`,从而排除被丢弃的局。
- `config.py` 新增 `EQ_BUY_MIN = 2000`。`EQ_FULL_BUY` 不再被 `classify_rounds` 使用(保留常量,
避免触碰旧引用;如确认无其他引用可一并删除)。

## 2. AWP 率 → 持狙回合占比(`combat.py`)

- 废弃旧指标(CT 方 AWP 击杀 / CT 方击杀)。
- 新指标:**持狙回合数 / 总回合数 × 100**。
- **"持狙"判定**:在该回合的采样 tick 上读目标的 `inventory`(demoparser2 tick 字段,返回武器名
列表),任一采样点的 inventory 含 AWP(匹配 `awp` / `weapon_awp`,大小写不敏感)即记该回合为
持狙回合。
- 实现前先用真实 demo 验证 `inventory` 字段可用及其武器名格式。
- **回退方案**:若 `inventory` 不可用,退化为"该回合内目标有 AWP 击杀"(player_death,
attacker=目标 且 weapon=awp,tick 落在回合窗口)。
- **分母 = 该玩家出场的全部回合**(CT + T,不限经济,含被分类层丢弃的 eco 局)。即用
`classify_rounds` 结果里 `side` 非空的全部回合,不要求 `rtype`。
- `parse_combat_stats` 返回 `{kd, awp_rounds, total_rounds}`。
- `aggregate_combat_stats`:`awp_rate = round(sum(awp_rounds)/sum(total_rounds)*100, 1)`,
分母 0 时为 0.0。`kd` 聚合不变。

## 3. 前端布局

### 统一 CT/T 切换(`index.html` header + `app.js`)
- 在 header 的 `.ctrls`(与播放/进度条同排)放一个 CT/T 切换(两个按钮,默认 CT)。
- 该开关控制页面上**所有** canvas 的 side:手枪合并图 + 每个对手买局图。一次只看一边。
- 移除每张卡片内的 CT/T tabs。

### 手枪局合并区(`#pistol`,置于 `#cards` 上方)
- **一张大 canvas**,叠加本次扫描到的最多 5 名对手的**手枪局**路径。
- **每名玩家一种颜色** + 文字图例(玩家名 ↔ 颜色)。
- 受顶部统一开关控制:显示当前 side 的手枪局。
- 实现:构造合并轮次列表 = 各玩家 `rounds` 中 `rtype=="Pistol"` 的局,每局打上该玩家颜色 tag,
作为单个 `ReplayPlayer` 的 `rounds`,`rtype="Pistol"`。

### 每名对手卡片(`#cards`)
- 保留:玩家名、K/D、AWP%(持狙占比)、回合数。
- 去掉:CT/T tabs、Pistol/Full/Eco 三宫格。
- 只留**一张 canvas**,叠加该玩家所有 **Buy** 局,`rtype="Buy"`,按 side 上色。

### `replay.js`(最小改动)
- 支持 per-round 颜色覆盖:dot/X/arrow 颜色取 `r.color || SIDE_COLOR[this.side]`
(把 `col` 计算移入按 round 循环内)。单人买局图不带 `color`,仍按 side 上色;合并图每局带
`color`。
- `setFilter(side, rtype)` 不变。统一开关切 side 时,对每个 `ReplayPlayer` 调用
`setFilter(side, 该图固定 rtype)`(合并图固定 Pistol,买局图固定 Buy)。
- `RTYPES = ["Pistol","Full","Eco"]` 常量移除/改写为新布局逻辑。

## 影响文件

| 文件 | 改动 |
|------|------|
| `server/config.py` | 新增 `EQ_BUY_MIN = 2000` |
| `server/parse.py` | `classify_rounds` 个人判定 + 丢弃逻辑;3 个下游过滤条件加 `and r["rtype"]` |
| `server/combat.py` | AWP 改持狙占比(inventory 检测 + 回退);返回值与聚合调整 |
| `server/templates/index.html` | header 加统一 CT/T 开关;main 加 `#pistol` 区 |
| `server/static/app.js` | 新布局:合并手枪图、单买局图、统一开关接线;移除三宫格/每卡 tabs |
| `server/static/replay.js` | per-round 颜色覆盖 |
| `server/player_json.py` | 无需改(rtype 自然变 Pistol/Buy) |

## 验证

- 单元/集成:对 fixture demo `demos_analysis/g161-...de_mirage.dem` 验证
`classify_rounds` 只产出 Pistol/Buy/None,个人判定生效;`parse_combat_stats` 持狙占比对已知
AWPer(如 76561199755381652,26 次 AWP 击杀)> 0。
- 视觉:重启 `web_server.py`,跑一次真实扫描或用 `replay_test.html`,确认顶部统一开关切换全部
canvas、手枪合并图多色叠加、每卡单买局图正常。

## 不做(YAGNI)

- 不恢复 Force Buy 档。
- 不做按回合逐个开关(每图整体叠加即可)。
- 不改下载/管线/5E 抓取逻辑。
4 changes: 4 additions & 0 deletions radar/icons/flashbang.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions radar/icons/hegrenade.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading