Compare commits

...
51 Commits
Author SHA1 Message Date
vosonandCursor 282029706b Add Baixi, Crystal Maiden, and Furion streamers to the web roster.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 09:32:14 +08:00
vosonandCursor 820c3fb1f1 Ship item nicknames, Tiny fear override, and ensure-match API.
Add 冰眼/蛇矛 aliases, correct Tiny fears to Hydra's Breath, and fix production match detail POST 405.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 04:05:48 +08:00
vosonandCursor cafd0651b1 Fix player sync empty overwrites and force CSS cache refresh.
Keep D1 recent/heroes/peers/rank when OpenDota is partial; bump site to 0.6.55 and shorten style.css cache so「我」layout is not stuck on stale CSS.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 01:46:13 +08:00
vosonandCursor 41d272ba29 Add Pages Steam secret put helpers for climperor-relations.
Documents staging via keyzoo so production /api/auth/steam gets STEAM_API_KEY and SESSION_SECRET.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 01:32:17 +08:00
vosonandCursor f5b7011c45 Ship Steam login, D1 player sync, and cached「我」dashboard.
Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-01 01:24:30 +08:00
vosonandCursor 4a61aeeb26 Add PC post-match player pages with opt-in public OSS sync.
Generate /players/{account_id}[/{match_id}] locally after POST_GAME via OpenDota; publish to OSS only when public_share is enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 11:01:32 +08:00
vosonandCursor d6f7c3f0f5 Keep shop/item meta in git and fix category icon filenames.
Drop the accidental itemcat_*.png.png downloads by stripping the
existing .png suffix before download_icons appends one. Leave daily
leaderboards/streamers and ignored OpenDota/STRATZ caches uncommitted.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 10:36:26 +08:00
vosonandCursor 1e44c0eb9b Show recent item patch notes under the shop detail panel.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 10:18:17 +08:00
vosonandCursor 961574037e Rename patch sidebar to AI reading and drop the disclaimer.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 10:14:00 +08:00
vosonandCursor 3ee4ba7e84 Clarify 7.41e reading copy and call out Rapier vs Zeus Aghs.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 10:09:29 +08:00
vosonandCursor 30218790ce Add 7.41e patch notes with a hand-authored reading sidebar.
Ship Climperor Web 0.6.13: latest-patch summary panel, icon/innate fallbacks, and layout/scroll fixes; keep shared ability badges in git.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-31 10:06:09 +08:00
vosonandCursor a257f96d94 v0.6.9: stop serving match-template crops as hero portraits.
Export and OSS upload require wide Heroes-page cards only; bump cache stamp to wide2 so clients drop polluted squares.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 23:24:42 +08:00
vosonandCursor ee180f3519 Skip slow proPlayers retries on watchlist pro-match runs.
Use a single fail-fast /proPlayers probe so rate-limited runners start the
15-pro refresh batch immediately from watchlist names.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 23:10:54 +08:00
vosonandCursor c5a342cb5d Survive OpenDota proPlayers 429 during watchlist pro refresh.
Continue with watchlist names when /proPlayers is rate-limited, and soft-fail
the pro-matches step so daily can still refresh leaderboards and streamers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 23:10:32 +08:00
vosonandCursor 76fa474b7d Fail fast on OpenDota 429 in hero_matches fetch.
Stop the long retry chain after three consecutive 429s and restore prior
hero cells so a burned IP no longer stalls the whole daily tier.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 23:06:45 +08:00
vosonandCursor f035e2c233 Soft-fail OpenDota hero stats/matches on daily rate limits.
Keep restored cache and continue the tier so a burned runner IP cannot
block leaderboards, pro-match rotation, or streamer refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 23:02:35 +08:00
vosonandCursor 0f7e7b68dd Rotate OpenDota pro-match refresh to stay under rate limits.
Daily now refreshes the 15 oldest watchlist pros, keeps prior cache for the
rest, fails fast on consecutive 429s, and still writes a partial result.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 22:59:34 +08:00
vosonandCursor 3b7d00b32b v0.6.8: add Emo and 奶绿 streamers; center items shop layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 20:34:05 +08:00
vosonandCursor 633a6f16b9 Use upload-artifact@v3 on Gitea refresh workflows.
v4 is unsupported on this Gitea host and was failing the job after a successful refresh; keep continue-on-error so summary upload cannot mask refresh success.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 18:48:12 +08:00
vosonandCursor 2c4e2c2653 Fall back when runner lacks python venv/ensurepip.
Linux self-hosted runners without python3-venv fail job-local venv; use pip --break-system-packages so refresh can still install deps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 18:45:58 +08:00
vosonandCursor e0a8228707 Fix web refresh Actions on PEP 668 system Python.
Use a job-local venv so pip install no longer fails with externally-managed-environment on the Mac mini runner.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 18:42:45 +08:00
vosonandCursor 11dbb73b5c v0.6.7: narrow matches column so player filter stays visible.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:37:22 +08:00
vosonandCursor 43e8409c0e v0.6.6: size drawer from hero grid; drop skill video side bars.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:20:51 +08:00
vosonandCursor 22a9cf256d v0.6.5: size hero detail drawer from viewport; add PC post-lock item tips.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:08:32 +08:00
vosonandCursor fefd8c7823 v0.6.4: show match id on match cards.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 11:29:29 +08:00
vosonandCursor 79ce5cfd2e v0.6.3: hero detail bottom drawer that pushes role tags, matches page summary dedupe.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 09:18:31 +08:00
vosonandCursor aac4593cc5 v0.6.2: hero page site foot, dismiss detail on empty click, drop patches version footer.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 07:45:03 +08:00
vosonandCursor 1aa6710e39 v0.6.1: show hero English name and abbr on detail page.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 07:33:48 +08:00
vosonandCursor 7c9e54e5eb v0.6.0: mark Climperor Web shell and SEO milestone.
Brand title uses self-hosted Noto Serif SC; document minor vs patch SITE_VERSION bumps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 07:25:04 +08:00
vosonandCursor 544ea42d40 v0.5.115: add History routing and SEO prerender for Climperor Web.
Path URLs, crawlable hero/mechanics pages, and sitemap make the static site indexable while keeping SPA hydration.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 04:55:33 +08:00
vosonandCursor 38f46ad2ea v0.5.114: add Douyu streamers with fan enrichment and live probe.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 02:42:47 +08:00
vosonandCursor fdd926e9bb v0.5.112: keep skill demos at 16:9 and harden web refresh deploy triggers.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 01:48:01 +08:00
vosonandCursor 28858c0703 v0.5.111: sort streamers by live status then follower count.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 01:32:01 +08:00
vosonandCursor fe1b13b7c1 v0.5.110: stop carrying stale live badges; probe live status in local serve.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 01:11:13 +08:00
vosonandCursor d6212169af v0.5.109: hero role toolbar, skill side-by-side layout, taller detail panel.
Move role filters between grid and detail, fix skill video/text layout and detail height jump, unify item dividers, drop OpenDota matchup cross, sync docs and assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 00:48:07 +08:00
vosonandCursor 09f0a0b4a0 v0.5.87: fill ability loc tokens and tidy skill detail layout.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 20:32:37 +08:00
vosonandCursor ad7f5d0e1a v0.5.86: place matches summary below the pager.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:44:49 +08:00
vosonandCursor ef31e43d6c v0.5.85: move matches page summary next to pager.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:36:22 +08:00
vosonandCursor b01552ee6e v0.5.84: matches origin filter, mobile gate, refresh reliability.
Ship Web refresh cache/lock, mobile demand gate, matches 职业/国服 filter, and related site updates through 0.5.84.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:31:55 +08:00
vosonandCursor 7681fdb069 v0.5.72: lower default streamer unmute volume.
Avoid blasting to max when users unmute highlight clips; keep native slider control afterward.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:06:00 +08:00
vosonandCursor d2cfcd7461 v0.5.71: matches tab, streamer viewport video load, matchup cross-check.
Add pro watchlist matches page; load streamer clips by viewport tier with posters; harden STRATZ matchup refresh and OpenDota cross hints.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:03:49 +08:00
vosonandCursor d9c7b4b7b1 Add star-player matches tab and watchlist-driven fetch.
Ship top-level #/matches with pro watchlist defaults, bump site to 0.5.71, and document the flow in AGENTS/README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 16:03:45 +08:00
vosonandCursor 09bd5aabb3 Ignore legacy streamer video leftovers
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:50:23 +08:00
vosonandCursor 9c5aa5b610 Reorganize repository into pc web shared monorepo
Separate the local recognition, web publishing, and shared data paths while preserving direct script execution and existing site content.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 14:29:08 +08:00
vosonandCursor 96a9312194 Add Cloudflare traffic helper scripts and commercialization notes.
Track reusable CF analytics utilities and the commercial prospects document while keeping local probe/sample artifacts untracked.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 12:44:31 +08:00
vosonandCursor 3ec8007077 v0.5.59: item counter evidence for fears, catch up Web features to site version.
Ship OpenDota counter-stats reordering for feared items, finalize SITE_VERSION/docs for rankings/streamers/trends/matches/mechanics and draft archetypes, and ignore regenerable Web data caches.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 02:11:49 +08:00
vosonandCursor 37769580f5 Enrich Feishu daily digest with auto-refresh status.
Include Gitea web-daily/weekly/patch conclusions and Cloudflare Pages deploys; ship the refresh workflows so those signals exist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 21:17:23 +08:00
vosonandCursor 9f484166d4 Add daily Feishu digest for dota2.refining.dev traffic.
Gitea Actions runs notify_site_traffic.py at 09:00 CST using Cloudflare GraphQL and a Feishu webhook (secrets from keyzoo).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 15:26:07 +08:00
vosonandCursor 9898dd39fb v0.4.2: host preview icons on OSS for slim Pages deploys.
Add STATIC_ASSET_BASE so Cloudflare Pages ships ~2MB HTML/JS/data; icons served from climperor OSS via _oss_static_assets.py. New machines can deploy without local fetch scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:28:01 +08:00
vosonandCursor 74deb17888 Add OSS ability-video upload helpers and ignore runtime artifacts.
Commit _oss_*.py ops scripts alongside existing _cf_* helpers; gitignore bucket marker and upload PID.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 20:31:00 +08:00
vosonandCursor 124bdbb7c7 v0.4.1: OSS skill videos, site version footer, and mail contact.
Host ability demos on Aliyun OSS instead of Pages bundles; add patches-page version label and top-right mailto link; improve spirit bear portrait export.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 20:26:30 +08:00
409 changed files with 56840 additions and 13834 deletions
+12
View File
@@ -1,2 +1,14 @@
# Copy to .env (gitignored). Get a free token at https://stratz.com/api
# Or inject via keyzoo asset `refining/stratz` (api_key → KEYZOO_ASSET_API_KEY).
# Gitea Actions web-weekly uses the same name as a repo Actions secret.
STRATZ_API_TOKEN=
# Optional OpenDota API key (higher rate limit for fetch_hero_matches.py --workers).
# OPENDOTA_API_KEY=
# Optional local deploy / OSS (or keyzoo inject). Gitea web-* workflows use
# CLOUDFLARE_* + OSS_* as Actions secrets (see README「定时刷新」).
# CLOUDFLARE_EMAIL=
# CLOUDFLARE_API_KEY=
# OSS_ACCESS_KEY_ID=
# OSS_ACCESS_KEY_SECRET=
+42
View File
@@ -0,0 +1,42 @@
# Daily Feishu digest for dota2.refining.dev traffic + auto-refresh status.
# Repo secrets: CLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY, FEISHU_WEBHOOK_URL
# Built-in secrets.GITEA_TOKEN is used to list same-repo Actions runs (no custom
# GITEA_* secret — that prefix is reserved).
name: site-traffic-notify
on:
schedule:
# 09:00 China = 01:00 UTC
- cron: "0 1 * * *"
workflow_dispatch:
inputs:
day:
description: "YYYY-MM-DD (empty = yesterday CST)"
required: false
type: string
jobs:
notify:
# Mac mini Gitea runner (labels: self-hosted, macOS, ARM64, darwin-arm64)
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Post Feishu digest
env:
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
FEISHU_WEBHOOK_URL: ${{ secrets.FEISHU_WEBHOOK_URL }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: https://gitea.refining.dev
run: |
set -e
PYTHON=python3
command -v python3 >/dev/null || PYTHON=python
if [ -n "${{ github.event.inputs.day }}" ]; then
"$PYTHON" web/notify_site_traffic.py --day "${{ github.event.inputs.day }}"
else
"$PYTHON" web/notify_site_traffic.py
fi
+73
View File
@@ -0,0 +1,73 @@
# Daily Climperor web data refresh + deploy when changed.
# Repo secrets: STRATZ_API_TOKEN (optional for daily), CLOUDFLARE_EMAIL,
# CLOUDFLARE_API_KEY, OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET
# Runner: same self-hosted Mac mini as site-traffic-notify (needs python3, node/npx).
# daily also runs fetch_streamers.py (Douyin soft-fail; keeps previous counts on miss).
name: web-daily
on:
schedule:
# 06:00 China = 22:00 UTC previous calendar day
- cron: "0 22 * * *"
workflow_dispatch:
concurrency:
group: climperor-web-full-refresh
cancel-in-progress: false
jobs:
refresh:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Restore refresh cache
uses: actions/cache@v4
with:
path: web/.refresh-cache
key: web-refresh-${{ runner.os }}-${{ hashFiles('web/data/patches.json') }}-${{ github.run_id }}
restore-keys: |
web-refresh-${{ runner.os }}-${{ hashFiles('web/data/patches.json') }}-
web-refresh-${{ runner.os }}-
- name: Install deps
run: |
set -e
PYTHON=python3
command -v python3 >/dev/null || PYTHON=python
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
# Prefer job-local venv (PEP 668). Some runners lack ensurepip/python3-venv.
rm -rf .venv
if "$PYTHON" -m venv .venv; then
.venv/bin/pip install -q -r web/requirements.txt
echo "CLIMPEROR_PY=${PWD}/.venv/bin/python" >> "$GITHUB_ENV"
else
echo "venv unavailable; pip --break-system-packages"
"$PYTHON" -m pip install -q --break-system-packages -r web/requirements.txt
echo "CLIMPEROR_PY=${PYTHON}" >> "$GITHUB_ENV"
fi
- name: Refresh daily tier
env:
REFRESH_CACHE_ENABLED: "1"
REFRESH_RUN_ID: ${{ github.run_id }}
STRATZ_API_TOKEN: ${{ secrets.STRATZ_API_TOKEN }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
run: |
set -e
"${CLIMPEROR_PY}" web/refresh_web.py --tier daily
- name: Save refresh summary
if: always()
continue-on-error: true
# v4 artifact API unsupported on Gitea; v3 still uploads for notify digests.
uses: actions/upload-artifact@v3
with:
name: web-daily-refresh-${{ github.run_id }}
path: web/.refresh/summary.json
if-no-files-found: warn
+71
View File
@@ -0,0 +1,71 @@
# Periodic patch-list check; fetch details + version-linked data only when new.
# Repo secrets: CLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY,
# OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET
# (STRATZ not required for this tier)
name: web-patch
on:
schedule:
# Every 6 hours UTC
- cron: "0 */6 * * *"
workflow_dispatch:
concurrency:
group: climperor-web-patch-refresh
cancel-in-progress: false
jobs:
refresh:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Restore refresh cache
uses: actions/cache@v4
with:
path: web/.refresh-cache
key: web-refresh-${{ runner.os }}-${{ hashFiles('web/data/patches.json') }}-${{ github.run_id }}
restore-keys: |
web-refresh-${{ runner.os }}-${{ hashFiles('web/data/patches.json') }}-
web-refresh-${{ runner.os }}-
- name: Install deps
run: |
set -e
PYTHON=python3
command -v python3 >/dev/null || PYTHON=python
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
# Prefer job-local venv (PEP 668). Some runners lack ensurepip/python3-venv.
rm -rf .venv
if "$PYTHON" -m venv .venv; then
.venv/bin/pip install -q -r web/requirements.txt
echo "CLIMPEROR_PY=${PWD}/.venv/bin/python" >> "$GITHUB_ENV"
else
echo "venv unavailable; pip --break-system-packages"
"$PYTHON" -m pip install -q --break-system-packages -r web/requirements.txt
echo "CLIMPEROR_PY=${PYTHON}" >> "$GITHUB_ENV"
fi
- name: Refresh patch tier
env:
REFRESH_CACHE_ENABLED: "1"
REFRESH_RUN_ID: ${{ github.run_id }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
run: |
set -e
"${CLIMPEROR_PY}" web/refresh_web.py --tier patch
- name: Save refresh summary
if: always()
continue-on-error: true
# v4 artifact API unsupported on Gitea; v3 still uploads for notify digests.
uses: actions/upload-artifact@v3
with:
name: web-patch-refresh-${{ github.run_id }}
path: web/.refresh/summary.json
if-no-files-found: warn
+71
View File
@@ -0,0 +1,71 @@
# Weekly Climperor web meta refresh (STRATZ + items) + deploy when changed.
# Repo secrets: STRATZ_API_TOKEN, CLOUDFLARE_EMAIL, CLOUDFLARE_API_KEY,
# OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET
name: web-weekly
on:
schedule:
# Tuesday 07:00 China = Monday 23:00 UTC
- cron: "0 23 * * 1"
workflow_dispatch:
concurrency:
group: climperor-web-full-refresh
cancel-in-progress: false
jobs:
refresh:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Restore refresh cache
uses: actions/cache@v4
with:
path: web/.refresh-cache
key: web-refresh-${{ runner.os }}-${{ hashFiles('web/data/patches.json') }}-${{ github.run_id }}
restore-keys: |
web-refresh-${{ runner.os }}-${{ hashFiles('web/data/patches.json') }}-
web-refresh-${{ runner.os }}-
- name: Install deps
run: |
set -e
PYTHON=python3
command -v python3 >/dev/null || PYTHON=python
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
# Prefer job-local venv (PEP 668). Some runners lack ensurepip/python3-venv.
rm -rf .venv
if "$PYTHON" -m venv .venv; then
.venv/bin/pip install -q -r web/requirements.txt
echo "CLIMPEROR_PY=${PWD}/.venv/bin/python" >> "$GITHUB_ENV"
else
echo "venv unavailable; pip --break-system-packages"
"$PYTHON" -m pip install -q --break-system-packages -r web/requirements.txt
echo "CLIMPEROR_PY=${PYTHON}" >> "$GITHUB_ENV"
fi
- name: Refresh weekly tier
env:
REFRESH_CACHE_ENABLED: "1"
REFRESH_RUN_ID: ${{ github.run_id }}
STRATZ_API_TOKEN: ${{ secrets.STRATZ_API_TOKEN }}
CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }}
CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }}
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
run: |
set -e
"${CLIMPEROR_PY}" web/refresh_web.py --tier weekly
- name: Save refresh summary
if: always()
continue-on-error: true
# v4 artifact API unsupported on Gitea; v3 still uploads for notify digests.
uses: actions/upload-artifact@v3
with:
name: web-weekly-refresh-${{ github.run_id }}
path: web/.refresh/summary.json
if-no-files-found: warn
+42 -15
View File
@@ -8,11 +8,16 @@ venv/
Thumbs.db
*.log
# OSS ability-video upload runtime (regenerable)
.oss_ability_videos_bucket
oss_upload.pid
# Runtime / debug artifacts (regenerable)
preview/
results/
failures/
samples/raw/
pc/preview/
pc/results/
pc/failures/
pc/samples/raw/
pc/player_pages/
_tools/
_tmp_*
_cmp_*
@@ -20,18 +25,40 @@ plans/
*_log.txt
# Regenerable data caches / reports (re-fetch or re-run scripts)
data/matchups.json
data/synergies.json
data/stratz_matchups.json
data/relations_audit.json
data/relations_import_report.json
data/relations_patch_*
shared/data/synergies.json
shared/data/stratz_matchups.json
shared/data/relations_audit.json
shared/data/relations_import_report.json
shared/data/relations_patch_*
web/data/stratz_hero_meta.json
web/data/stratz_matchup_tops.json
web/data/item_counter_stats.json
web/data/hero_stats.json
web/data/hero_matches.json
web/data/pro_matches.json
web/data/pro_builds.json
web/data/_jugg_times_sample.json
web/.refresh/
web/.refresh-cache/
# Large regenerable CDN media (fetch scripts)
assets/ability_videos/
assets/ability_icons/
assets/hero_portraits/
assets/item_icons/
web/assets/ability_videos/
# Regenerable Steam CDN ability icons; keep bundled shared badges in git.
web/assets/ability_icons/*
!web/assets/ability_icons/innate.png
!web/assets/ability_icons/talent_tree.png
web/assets/hero_portraits/
web/assets/item_icons/
# Curated streamer highlight clips (large; sync via OSS, not git)
web/assets/streamer_videos/
# Legacy pre-monorepo leftovers pending manual deletion
assets/streamer_videos/
# Static site export (export_relations_site.py)
dist/
web/dist/
# Local wrangler / Pages Functions runtime
.wrangler/
# Local probes / one-off diagnostics
_probe_item_fears_api.py
+200 -96
View File
@@ -8,136 +8,239 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
- 中文名:上分帝
- 英文名 / 仓库目录:Climperor / `climperor`
- 模板来源:**仅** Steam CDN 官方头像(`templates/cdn/`
- 模板来源:**仅** Steam CDN 官方头像(`pc/templates/cdn/`
- **已放弃** real 实拍模板库补充;不要再引入 `templates/real/` 或入库流水线
**上分帝 Web**`web/`)是独立子项目:英雄克制/搭档、排行、主播、走势、机制、物品、版本等内容站点,与局内选将识别解耦。文档与注释统称「上分帝 Web」,勿再称「关系预览」;历史脚本名(如 `serve_relations.py``dist/relations/`)暂保留。
## Monorepo 布局
```
climperor/
├── pc/ 局内选将识别(GSI + 截屏 + OpenCV + overlay
├── web/ 上分帝 Web(前端 frontend/ + 数据流水线 + 部署运维)
└── shared/ 共享包(英雄表/关系/标签/HTTP/路径常量),pc 与 web 都依赖它
```
**跨目录 import 约定**`pc/``web/``shared/` 下的每个脚本顶部都有相同两行引导(`parents[1]` 恒为仓库根),之后即可 `from shared import ...` / `from shared.paths import ...`;运行方式不变,一律在仓库根执行 `python web/xxx.py``python pc/xxx.py``python shared/xxx.py`。**shared 不反向依赖 pc/ 或 web/**`shared/grid.py` 的 cv2/numpy 为懒加载,Web 侧与 CI 无需 opencv。路径常量单一来源是 `shared/paths.py``pc/common.py` re-export PC 所需常量,PC 侧 `from common import ...` 调用不变。
## 目录与模块
### pc/(局内选将识别)
| 路径 | 职责 |
|------|------|
| `common.py` | 配置 IO、槽位几何、裁切、NCC 匹配、天梯遮罩、CDN 模板加载 |
| `recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
| `draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
| `gsi_watch.py` / `gsi_setup.py` | GSI 监听与 cfg 安装 |
| `http_utils.py` | 所有 fetch 脚本共用的 HTTP 请求、图标下载、Valve datafeed 加载(统一 UA / 超时) |
| `fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量) |
| `fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `data/heroes.json`(含基础属性/血蓝;保留已有 `aliases` |
| `relations.py` | 定性克制/搭档边读写与名称解析 |
| `import_relations_xlsx.py` | 从选将笔记 xlsx 种子导入 `data/relations.json` |
| `fetch_stratz.py` | 拉取 STRATZ 对位优势 / 搭档协同 → `data/stratz_matchups.json` / `data/synergies.json`(需 token`--mode matchups\|synergies` |
| `fetch_matchups.py` | 拉取 OpenDota 对位优势 → `data/matchups.json`(供 `audit_relations.py` 交叉审计) |
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 |
| `recommend.py` | 分路过滤;按克制/被克制/搭档边打分,输出 Top-3 |
| `serve_relations.py` | 游戏外关系/物品只读预览(`web/relations/`;改 `data/relations.json` |
| `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`data.json 快照 + 前端 + 图片;`--with-videos` 含视频 |
| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;凭据经 keyzoo 注入或 env |
| `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入 |
| `fetch_hero_portraits.py` | 拉取官网横版头像 → `assets/hero_portraits/`(关系预览) |
| `fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `data/hero_items.json` + `assets/item_icons/` |
| `fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory+ 合成图 → `data/item_shop.json` + 图标 |
| `fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `data/items_meta.json``%token%` 用 special_values 填数) |
| `loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}` |
| `fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `data/hero_abilities.json``has_scepter`/`has_shard` 只信 Valve 显式 flagValve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN |
| `fetch_ability_videos.py` | 官网技能演示 webm/mp4Steam CDN;限速)→ `assets/ability_videos/`A 杖/魔晶赋予技能(`granted_by_scepter`/`granted_by_shard`CDN 名用 `<hero>_aghanims_scepter`/`<hero>_aghanims_shard`,本地文件名仍用 ability key |
| `item_fears.py` | 规则推导「英雄怕的装备」→ `data/hero_item_fears.json` |
| `autocalibrate.py` / `calibrate.py` | ROI 自动 / 手动标定 |
| `capture.py` | 屏幕捕获 |
| `overlay.py` | 顶栏角色标签 + 选将网格左上角青标 Top-3(点击穿透) |
| `evaluate.py` | 按 `samples/labels.json` 批量评测 |
| `roles.py` / `grid.py` / `modes.py` | 位置字、禁用网格、模式字 |
| `config.json` | 相对坐标、阈值、GSI、recommend 参数 |
| `data/heroes.json` | 英雄表(id/key/attr/roles/aliases/abbr/tags + 基础属性/初始血蓝等;由 `fetch_cdn_templates.py` 生成 |
| `data/patches.json` | 近一年版本列表 + 逐版本详情(`patches`/`lookup`/`details`;由 `fetch_patches.py` 生成,预览版本页只读 |
| `data/relations.json` | 定性克制边 + 搭档边(可提交;手改 JSON,预览页只读 |
| `data/hero_grid_order.json` | 预览页四列网格顺序 |
| `data/hero_items.json` | 核心成品装备缓存(预览页只读;由 `fetch_hero_items.py` 生成) |
| `data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
| `data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
| `data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta) |
| `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成 |
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;预览「怕」行) |
| `web/relations/` | 关系只读预览页静态资源(`index.html` / `app.js` / `style.css` / `router.js` |
| `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索 |
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成 |
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
| `assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边 |
| `assets/hero_portraits/` | 官网横版头像(关系预览;默认 wide 面部构图,非匹配模板) |
| `assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,关系预览用 |
| `assets/item_icons/` | Steam CDN 装备图标(关系预览常用装备) |
| `assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
| `assets/ability_icons/` | Steam CDN 技能图标(关系预览;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 |
| `assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等,技能详情底栏冷却图标) |
| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取 |
| `pc/common.py` | 配置 IO、槽位几何、裁切、NCC 匹配、天梯遮罩、CDN 模板加载re-export `shared.paths` 常量 |
| `pc/recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
| `pc/draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
| `pc/gsi_watch.py` / `pc/gsi_setup.py` | GSI 监听与 cfg 安装`POST_GAME` 触发 `player_pages` 赛后建页 |
| `pc/player_pages.py` | 赛后轮询 OpenDota → `pc/player_pages/{account_id}/`profile + match JSON);`enrich_profile_recent` 写近 20 场 + `career`/`recent_20`/`top_heroes`/`activity_180`/`peers`/`availability``get_profile_for_web` 缓存优先(`enrich_ttl_seconds` 默认 600,与 Pages `isStale` 对齐:未过期直出、过期后台刷新、冷启动同步一次);详情 `ensure_match_detail` 懒加载;`public_share` 时 POST `/api/players/publish` 入队;**不**进 recommend |
| `pc/player_stats.py` | 玩家聚合公式(KDA/胜率/生涯/近 N 场/180 天热力图);空 OpenDota 不覆盖旧 career |
| `pc/fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `shared/data/heroes.json`(含基础属性/血蓝;保留已有 `aliases` |
| `pc/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
| `pc/item_suggest.py` | 本人锁定后:`hero_items` 核心装 + 敌方 tags/画像定性应对装(不读 fears/STRATZ 统计) |
| `pc/draft_archetypes.py` | 规则阵容画像(推进/全球流/敌我缺口)+ `analysis` / `reasons` 文案(不接 AI |
| `pc/autocalibrate.py` / `pc/calibrate.py` | ROI 自动 / 手动标定 |
| `pc/capture.py` | 屏幕捕获 |
| `pc/overlay.py` | 网格「克/搭/补」方标 + 阵容分析横条 + 锁定后装备图标条(点击穿透;不再在顶栏头像下画定位) |
| `pc/evaluate.py` | 按 `pc/samples/labels.json` 批量评测 |
| `pc/roles.py` / `pc/modes.py` | 位置字、模式字(禁用网格在 `shared/grid.py` |
| `pc/config.json` | 相对坐标、阈值、GSI、recommend 参数(`relations_path` 指向 `shared/data/relations.json` |
| `pc/templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成 |
| `pc/templates/roles/` / `pc/templates/modes/` | 位置 / 模式辅助模板 |
| `pc/assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边) |
| `pc/requirements.txt` | PC 侧依赖(opencv/numpy/mss/openpyxl |
### shared/(共享包)
| 路径 | 职责 |
|------|------|
| `shared/paths.py` | 全部路径常量单一来源(`ROOT`/`SHARED_DATA`/`HEROES_JSON`/`TEMPLATES_CDN`/`PC_PLAYER_PAGES`/`DATA`/`WEB_FRONTEND`/各 Web 资产目录);纯常量、零第三方依赖 |
| `shared/grid.py` | 英雄表 `hero_table()`、选人网格布局与禁用读取(cv2/numpy 懒加载,Web/CI 侧只取表不触发) |
| `shared/relations.py` | 定性克制/搭档边读写与名称解析;默认路径 `shared/data/relations.json` |
| `shared/hero_tags.py` | 中文定位 tags(核心/辅助/…/幻象) |
| `shared/http_utils.py` | 所有 fetch 脚本共用的 HTTP 请求、图标下载、Valve datafeed 加载(统一 UA / 超时;429/5xx 指数退避重试) |
| `shared/import_relations_xlsx.py` | 从选将笔记 xlsx 种子导入 `shared/data/relations.json` |
| `shared/fetch_stratz.py` | 拉取 STRATZ 对位优势 / 搭档协同 → `shared/data/stratz_matchups.json` / `shared/data/synergies.json`(需 token`--mode matchups\|synergies`;供 `audit_relations.py` |
| `shared/audit_relations.py` | 用 STRATZ 对位/搭档缓存审计 `relations.json`,列出缺失英雄与候选边(不改写 relations;Web 对位页只读 `stratz_matchup_tops.json` |
| `shared/data/heroes.json` | 英雄表(id/key/attr/roles/aliases/abbr/tags + 基础属性/初始血蓝等;由 `pc/fetch_cdn_templates.py` 生成 |
| `shared/data/relations.json` | 定性克制边 + 搭档边(可提交;手改 JSON,Web 站点只读) |
### web/(上分帝 Web
| 路径 | 职责 |
|------|------|
| `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 detailsstdout JSON |
| `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 |
| `web/fetch_stratz_meta.py` | 拉取 STRATZ 各勋章段位 `winWeek`(近 N 周 pick/win + 同段位最近 1 周分路,`positionIds`+ 对位 Top → `web/data/stratz_hero_meta.json` / `web/data/stratz_matchup_tops.json`(需 token**仅上分帝 Web**;勿进 recommend / relations)。对位为**全局聚合**(无段位/分路/周过滤);weekly 默认全量刷新,`--resume-matchups` 仅中断续跑;失败保留旧值并标 `stale` |
| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;History 深度路径 SPA fallback 回 `index.html``/api/live-status` 调用 `fetch_streamer_live.probe_streamers` 做真实探测,内存缓存 60s,失败标 `stale`/不显示直播角标;`GET /api/players/me` 登录本人 enrich`GET /api/players/{account_id}[/{match_id}]``pc/player_pages/``POST /api/players/enrich` / `ensure-match` 本机补拉近期与详情;`POST /api/players/publish` 本地 no-opSteam OpenID`/api/auth/steam` / `callback` / `me` / `logout`(需 `STEAM_API_KEY` + `SESSION_SECRET`,镜像 `web/steam_auth.py`);`/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range |
| `web/steam_auth.py` | Steam OpenID + 签名 Cookie 会话(本地 serve 用;与 `web/frontend/functions/api/auth/*` 对齐 |
| `web/frontend/functions/api/auth/` | Pages FunctionsSteam 登录 / 回调 / me / logoutCookie `climperor_steam`Env`STEAM_API_KEY``SESSION_SECRET` |
| `web/export_relations_site.py` | 导出上分帝 Web 为纯静态站点 → `web/dist/relations/`data.json 快照 + 前端 + 图片;`SITE_VERSION` / `SITE_ORIGIN``web/frontend/config.js` 同步;`--ability-video-base` / `--static-asset-base` / `--site-origin``config.js`;设 `--static-asset-base` 时不拷贝图标进 dist;调用 `seo_prerender.py` 写英雄/机制预渲染 HTML + `sitemap.xml` / `llms.txt`;拷贝 `_redirects` / `robots.txt``--with-videos` 可选本地拷贝技能/主播视频,生产部署勿用 |
| `web/seo_prerender.py` | 导出期 SEO/GEO:注入 title/description/canonical/OG/JSON-LD 与 `#seo-prerender` 正文;生成全英雄 `/heroes/{key}`、机制 `/mechanics/{effect}`、顶层页、`sitemap.xml``llms.txt` |
| `web/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env |
| `web/refresh_web.py` | 上分帝 Web 数据分层刷新编排(`daily`/`weekly`/`patch`/`all`);忽略纯时间戳与 `is_live` 的业务摘要有变更才 OSS upload + `deploy_relations.py`(亦监视 frontend / `relations.json` / `heroes.json` / 网格顺序);写 `web/.refresh/summary.json`(步骤/耗时/stale/部署;`--dry-run` 只打印不写盘、不 restore/save 缓存);同 runner 文件锁串行发布,patch 遇全量刷新则延后并标 `skipped``--skip-deploy` / `--skip-oss` / `--force-deploy``daily`stats/排行/比赛/pro/主播/主播开播探测 + patch check`weekly`STRATZ meta + 物品 + counter`patch`:仅 check`has_new` 时详情 + abilities/商店/meta/fears |
| `web/refresh_cache.py` | Gitea Actions 刷新状态缓存桥:文件锁内从 runner `~/.cache/climperor-web-refresh/` 恢复/保存,`web/.refresh-cache/` 供 Actions cache 冷启动备份;覆盖所有定时生成数据与 `patches.json`,主播只合并抓取字段,保留 git 中手工名单 |
| `web/notify_site_traffic.py` | 上分帝 Web 日报 → 飞书 webhook(访问估数 + Gitea `web-daily`/`weekly`/`patch` 运行结论/`REFRESH_SUMMARY` + Pages 当日生产部署 + 生产数据新鲜度与 live API 探针;`--dry-run` 只打印卡片) |
| `.gitea/workflows/site-traffic-notify.yml` | 每日 09:00 CST 跑 `web/notify_site_traffic.py`Secrets`CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` / `FEISHU_WEBHOOK_URL`;内置 `GITEA_TOKEN` 用于查询同仓 Actions |
| `.gitea/workflows/web-daily.yml` | 每日 06:00 CST`web/refresh_web.py --tier daily`self-hosted;需 CF + OSS SecretsSTRATZ 可选 |
| `.gitea/workflows/web-weekly.yml` | 周二 07:00 CST`web/refresh_web.py --tier weekly`(需 `STRATZ_API_TOKEN` + CF + OSS |
| `.gitea/workflows/web-patch.yml` | 每 6 小时:`web/refresh_web.py --tier patch``fetch_patches.py --check`,有新版本才拉详情与连带;需 CF + OSS) |
| `web/_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入) |
| `web/_oss_ability_videos.py` | 阿里云 OSS 桶 `climperor` 建桶 / CORS / 同步 `web/assets/ability_videos/``ability-video/`(凭据经 keyzoo `digitevents/voson-RAM` 注入);`_oss_static_assets.py` 同步图标等静态图;`_oss_fix_public.py` / `_oss_launch_upload.py` 为配套辅助 |
| `web/fetch_hero_portraits.py` | 拉取官网横版头像 → `web/assets/hero_portraits/`(上分帝 Web |
| `web/fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `web/data/hero_items.json` + `web/assets/item_icons/` |
| `web/fetch_hero_stats.py` | 拉取 OpenDota 各段位场次/胜场 → `web/data/hero_stats.json`(**仅上分帝 Web**;勿写入 relations/heroes,勿进 recommend |
| `web/fetch_hero_matches.py` | 拉取同英雄近期比赛 + 终局出装/加点 → `web/data/hero_matches.json``--source league\|public\|both`;合并后保留最近 N 场胜局,默认 10;天梯需传奇及以上;公开列表过滤 bot/Turbo、仅 ranked lobby`--public-region china` 优先国服;`--workers` 详情并发 + 凑满即停;可选 `OPENDOTA_API_KEY`;增量补缺失/不足 N 场;`--enrich-item-times` 补购买时间;**仅上分帝 Web**;勿进 recommend |
| `web/fetch_pro_matches.py` | 按明星名单拉近期联赛/锦标赛对局 → `web/data/pro_matches.json`(默认读 `web/data/pro_player_watchlist.json``--include-pubs` 另拉天梯 lobby 7;每种 lobby 各保留 `--limit` 场;`--refresh-limit N``fetched_at` 只刷新最陈旧 N 人并保留其余;连续 429 熔断后仍写盘;可选 `OPENDOTA_API_KEY``--players` 覆盖整文件且不做轮换;`--all-pros``/proPlayers` 盲抽;含终局出装/加点;进 `refresh_web` daily 且带 `--include-pubs --refresh-limit 15`;**仅上分帝 Web「比赛」**;勿进 recommend |
| `web/fetch_leaderboards.py` | 拉取 Valve Immortal 四区榜 Top100 → `web/data/leaderboards.json`(**仅上分帝 Web「排行」**;无 MMR/account_id;勿进 recommend |
| `web/fetch_streamers.py` | 从抖音 / 斗鱼主页补全 `web/data/streamers.json` 的昵称/签名/关注粉丝获赞(播放)/头像(手工名单 + 直播间/主页 URL;抖音支持 `v.douyin.com` 短链;斗鱼优先 `v.douyu.com/author/<hash>`(兼容 `author-video`)的 `$DATA`,仅房间号时从直播间 HTML 解析 `up_id` 再拉作者页,失败才回退 `betard`;失败保留旧值;**不**探测开播(由 `fetch_streamer_live.py` 负责);**仅上分帝 Web「主播」**;勿进 recommend;进 `refresh_web` daily |
| `web/fetch_streamer_live.py` | 探测主播真实在播状态回写 `web/data/streamers.json``is_live`/`live_probed_at`:抖音解析直播间 SSR 页 `roomStore.roomInfo.room.status`(2 在播 / 4 下播;预热 cookie + ~1s 间隔;web_rid 校验),B 站走 `Room/get_info``live_status==1` 在播,轮播算下播),斗鱼走 `betard/{room_id}``show_status==1` 在播,`videoLoop==1` 轮播算下播);探测失败清为 `is_live:false` 并去掉 `live_probed_at`(与 `/api/live-status` 一致,不沿用旧直播中)、始终 exit 0;`--ids a,b` 限范围、`--dry-run` 只打印;仅 Web;进 `refresh_web` daily(角标以访问触发的 live API 为准,daily 仅作 data.json 兜底) |
| `web/frontend/functions/api/live-status.js` | Pages Function `GET /api/live-status`:访问触发的在播探测(逻辑同 `fetch_streamer_live.py`,含抖音 / B 站 / 斗鱼),读 `data.json``streamers.streamers`Cache API 固定键 + isolate 内 in-flight 合并(5 分钟新鲜窗口,**无 KV**);抖音从数据中心 IP 失败属预期 → 失败主播一律 `is_live:false` + `stale:true`(**不**沿用旧的直播中);全失败回 `stale-override` 空角标表或 `error`,永不 500;导出时拷贝 `functions/`**部署须 `cwd=dist` 跑 wrangler**Functions 相对 cwd 解析) |
| `web/frontend/functions/api/mobile-demand.js` | Pages Function `GET\|POST /api/mobile-demand`:移动端「催更」需求计数(Cache API 存 `count`,**无 KV**;边缘竞态/驱逐可能少计或重置);本机 `serve_relations.py``web/.refresh/mobile_demand.json`;前端 `mobile-gate.js` 用 UA 识别手机/平板并拦截,`localStorage` 同设备只 POST 一次 |
| `web/frontend/functions/api/players/publish.js` | Pages Function `POST /api/players/publish`:校验后投递 QueueD1/R2 由 Worker 写入);可选 `PLAYER_PAGES_PUBLISH_SECRET`**不**进主 `data.json` / recommend |
| `web/frontend/functions/api/players/me.js` | `GET /api/players/me`:登录本人 D1 档案;过期则 Queue 刷新并标 `stale` |
| `web/frontend/functions/api/players/ensure-match.js` | `POST /api/players/ensure-match`:本人或 `public_share` 懒加载比赛详情(OpenDota → 规范化 → R2);静态路由须优先于 `[account_id]`,否则 POST 会 405 |
| `web/cloudflare/` | 多用户数据层:D1 migrations、`player-sync` Worker、`provision.py` / `deploy_worker.py`;资源名见 `web/cloudflare/README.md` |
| `web/frontend/mobile-gate.js` | 移动端门禁(`<head>` 早载):`html.mobile-client` + 催更按钮;设 `window.__CLIMPEROR_MOBILE__``app.js` 跳过桌面 boot |
| `web/fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory+ 合成图 → `web/data/item_shop.json` + 图标 |
| `web/fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `web/data/items_meta.json``%token%` 用 special_values 填数;查询类 tags 共用 `mechanic_tags.py`;合并 `item_alias_overrides.json``aliases` |
| `web/mechanic_tags.py` | 上分帝 Web「机制」页共用标签(弱/强驱散、缠绕/缴械/沉默/锁闭/眩晕/妖术/破坏、睡眠/恐惧/嘲讽/致盲/束缚、隐身/虚无/吹风)+ 中文 labels;语义为「施加该效果」 |
| `web/loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}`(键 casefold;魔晶/神杖 `%bonus_<sv>%``values_shard`/`values_scepter` |
| `web/fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `web/data/hero_abilities.json`;每条 ability 另有「施加」类 `tags`(与 `dispellable` 区分;`--tags-only` 可只重算);`has_scepter`/`has_shard` 只信 Valve 显式 flagValve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `web/assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN |
| `web/fetch_ability_videos.py` | 官网技能演示 webm/mp4Steam CDN;限速)→ `web/assets/ability_videos/`A 杖/魔晶赋予技能(`granted_by_scepter`/`granted_by_shard`CDN 名用 `<hero>_aghanims_scepter`/`<hero>_aghanims_shard`,本地文件名仍用 ability key |
| `web/fetch_item_counter_stats.py` | OpenDota Explorer 近场次聚合:对阵英雄时敌方终局装备购买率/胜率,与同窗口该装备全局队伍基线作差 → `web/data/item_counter_stats.json`(可再生成缓存;观测证据,不作因果结论;weekly 软失败) |
| `web/item_fears.py` | 规则推导「英雄怕的装备」→ `web/data/hero_item_fears.json`;可选读取 `item_counter_stats.json`,对全部英雄的已有机制候选小幅调序;统计发现的新组合须经机制复核后写入 overrides |
| `web/data/patches.json` | 近一年版本列表 + 逐版本详情(`patches`/`lookup`/`details`;由 `fetch_patches.py` 生成,Web 版本页只读) |
| `web/data/patch_summaries.json` | 手写「AI 解读」(仅保留最新版条目;Cursor 审阅后写入;进 `INPUT_WATCH`**不**进定时生成 / recommend |
| `web/data/hero_grid_order.json` | Web 站点四列网格顺序 |
| `web/data/hero_items.json` | 核心成品装备缓存(Web + PC 锁定后核心装推荐只读;由 `fetch_hero_items.py` 生成) |
| `web/data/hero_stats.json` | 英雄各段位 pick/win + `totals` + `window_*`OpenDota **近约 7 天**;Web「走势」Tab:胜率/上场率/场次;冠绝与超凡样本合并展示;由 `fetch_hero_stats.py` 生成;**不**参与局内推荐) |
| `web/data/hero_matches.json` | 同英雄近期比赛列表 + 终局出装/加点 + 可选购买时间(OpenDota;`--source league\|public\|both`Web「近期比赛」Tab;由 `fetch_hero_matches.py` 生成;**不**参与局内推荐) |
| `web/data/pro_player_watchlist.json` | 明星选手 OpenDota `account_id` 手工名单(对照 Dotabuff / Liquipedia;含现役战队席位与昔日国服明星;外号:查理斯→ChaliceSomnus 即 MaybeCN/EU/SA 等);`fetch_pro_matches.py` 默认只拉此名单;队名可过期,以 id 为准;**不**进 recommend |
| `web/data/pro_matches.json` | 明星选手近期联赛/锦标赛(可选天梯)对局(`by_pro`/`by_hero` + 终局出装/加点 + `lobby_type`/`origin`;由 `fetch_pro_matches.py` 生成;Web 顶层「比赛」侧栏筛「全部 / 职业 / 国服」+ 选手,与英雄详情「近期比赛」合并展示;进 daily;**不**参与局内推荐) |
| `web/data/leaderboards.json` | Valve Immortal 四区 Top100`china`/`europe`/`americas`/`se_asia`;仅排名+昵称等;由 `fetch_leaderboards.py` 生成;Web「排行」选手榜只读;**不**参与局内推荐) |
| `web/data/streamers.json` | 主播目录(手工 `live_url`/profile URL + 常用英雄 + 可选精选视频 `video`/`video_title` + 可选 `video_poster`/`video_fit`/`video_crop`/`video_aspect`;抖音 profile 补全;平台含抖音 / B 站 / 斗鱼;`platform_meta`;Web 卡片:头像行 = 头像 \| 昵称+账号/获赞粉丝 \|「关注」,签名(`signature`)独立全宽行(最长 3 行);在播时粉环 +「直播」角标叠在环底(抖音式 `bottom:-6px`,无间距);列表排序:先 `is_live` 再粉丝数降序;视口分档滚播(远处不拉、近处 metadata、中部 `canplay` 且单路 `preload=auto`+ 同名 JPG 封面占位;有 `live_url` 时点头像进直播间;**「直播」角标只信本轮成功探测**(线上 `/api/live-status` 边缘缓存 5 分钟;本地 `serve_relations` 同逻辑缓存 60s`stale`/失败不显示角标;`data.json` daily 仅作首屏兜底直至接口返回;`live_url` 仅作点击入口);由 `fetch_streamers.py` 补全;Web「主播」只读;**不**参与局内推荐) |
| `web/data/stratz_hero_meta.json` | STRATZ 各勋章段位近 N 周 pick/win`weeks`/`latest`+ **同段位最近 1 周分路**`winWeek`+`positionIds`+ `meta_board`(由 `fetch_stratz_meta.py` 生成;Web 英雄详情「走势」优先 + 顶层「走势」`/trends` 近 N 周榜;**不**参与局内推荐) |
| `web/data/stratz_matchup_tops.json` | STRATZ 对位/协同 Topcounters/countered/synergies;由 `fetch_stratz_meta.py` 生成;全局聚合 + `scope`/`stale`/`fetched_at`Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) |
| `web/data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
| `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
| `web/data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta |
| `web/data/item_alias_overrides.json` | 装备中文简称/俗称(合并进 `items_meta.aliases`;物品页搜索与悬停;勿与 `name_loc` 重复) |
| `web/data/ability_tag_overrides.json` | 技能「施加」类 tags 手工加减(合并进 hero_abilities;仅 Web 机制页) |
| `web/data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears |
| `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags` |
| `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations |
| `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) |
| `web/frontend/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js` / `mobile-gate.js` / `_redirects` / `robots.txt` / `functions/`);`config.js``SITE_VERSION``SITE_ORIGIN``ABILITY_VIDEO_BASE``STATIC_ASSET_BASE``PLAYERS_ASSET_BASE`;英雄页底部(无详情时)显示 `v{SITE_VERSION}` 与数据更新时间;技能演示按官网 16:9(有空间加宽至约 720px,`contain` 不裁左右);移动端由 `mobile-gate.js` 拦截(搜索/AI 爬虫 UA 跳过) |
| `web/frontend/router.js` | History 路径路由:`parseHash` / `serializeHash`(操作 pathname+search/ `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层 `/home\|heroes\|rankings\|streamers\|matches\|players\|trends\|mechanics\|items\|patches` / 登录后「我」`/home[/{match_id}]` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal `/rankings[/region]` / 明星比赛 `/matches[/account_id][?origin=pro\|china][&page=N]` / PC 赛后玩家页 `/players/{account_id}[/{match_id}]`(本机 API 或 OSS `players/`;默认私有) / 主播 `/streamers` / 走势 `/trends[/bracket][?sort=pr]` / 机制 `/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `/rankings/meta` 与 hash `#/...` 兼容) |
| `web/assets/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) |
| `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
| `web/assets/role_icons/` | Valve 选人定位筛选图标(透明 PNG;英雄页定位栏;本地 `/role-icon/`,线上 OSS `role-icon/` |
| `web/assets/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝 + `rank_star_1..5` 星级叠层;玩家页具体段位;走势段位选择器只用勋章) |
| `web/assets/item_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) |
| `web/assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
| `web/assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存,目录 gitignore);**例外提交**共用徽章 `innate.png` / `talent_tree.png`(先天/天赋树;CDN 无独立图标时前端与本地 serve 回退用) |
| `web/assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等;`icon_damage.png` 等战斗属性图标;平台 logo) |
| `web/assets/streamer_avatars/` | 主播头像缓存(由 `fetch_streamers.py` 写入;上分帝 Web「主播」) |
| `web/assets/streamer_videos/` | 主播精选高光 mp4 + 同名 JPG 封面(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/` |
| `web/assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor``ability-video/` 前缀托管) |
运行时产物(**勿提交**,见 `.gitignore`):
- `samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下
- `preview/` — 标定 / sheet 预览
- `dist/` — 静态站点导出(`export_relations_site.py`
- `results/` — 每局 JSON
- `failures/``--truth` 调试用错识裁切
- `data/matchups.json` / `synergies.json` / `stratz_matchups.json` / `relations_audit.json` 等 — 可再生成缓存与报告
- `assets/ability_videos/` — 官网技能演示(体积大,可再拉取)
- `pc/samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下
- `pc/player_pages/` — 赛后玩家主页/比赛 JSON(OpenDota;默认私有;同意公开后入队 Cloudflare D1/R2,不进主 data.json
- `pc/preview/` — 标定 / sheet 预览
- `web/dist/` — 静态站点导出(`export_relations_site.py`
- `pc/results/` — 每局 JSON
- `pc/failures/` `--truth` 调试用错识裁切
- `shared/data/synergies.json` / `stratz_matchups.json` / `relations_audit.json` 等 — 可再生成缓存与报告
- `web/data/hero_stats.json` / `hero_matches.json` / `stratz_*.json` 等 — 可再生成缓存
- `web/assets/ability_videos/` — 官网技能演示(体积大,可再拉取)
- `web/assets/streamer_videos/` — 主播精选高光(体积大,手工放入后 OSS 同步)
## 数据流
```
Dota 2 GSI → gsi_watch.py (:3223)
→ 全量 payload → samples/raw/<matchid>/gsi.jsonl(可关)
→ DraftSession 轮询截屏 → samples/raw/<matchid>/
Dota 2 GSI → pc/gsi_watch.py (:3223)
→ 全量 payload → pc/samples/raw/<matchid>/gsi.jsonl(可关)
→ DraftSession 轮询截屏 → pc/samples/raw/<matchid>/
→ recognize_image (CDN 模板 + 可选天梯遮罩)
→ roles(分路字)/ grid(禁用 + 单元格)/ modes
→ recommend Top-3data/relations.json 定性边,本人槽位只信 GSI
→ overlay 角色标签 + 网格青标(可选)
→ results/draft_*.json + 终端时间线
→ roles(分路字)/ shared.grid(禁用 + 单元格)/ modes
→ recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI
→ overlay 网格克/搭/补 + 阵容分析条;本人锁定后改推装备图标条(可选)
pc/results/draft_*.json + 终端时间线
→ POST_GAMEplayer_pages 轮询 OpenDota → pc/player_pages/(可选 publish → Queue → D1/R2
```
## 技术约束(修改前必读)
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
- **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。
- **克制 / 搭档数据**定性边存 `data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `data/heroes.json`预览三视图:克制 / 被克制 / 搭档。
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json``item_fears.py` 规则映射到英雄弱点。大段技能文案只进 `data/hero_abilities.json` / `items_meta.json`**不要**塞进 `heroes.json`本阶段仅关系预览展示,**不对局内出装推荐**
- **克制 / 搭档数据**机制克制/搭档只用定性边存 `shared/data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `shared/data/heroes.json`Web 英雄页三视图:克制 / 被克制 / 搭档。OpenDota 段位胜率/场次单独存 `web/data/hero_stats.json`(近约 7 天窗;上场率 = 出场/(Σ出场/10);冠绝样本过小时与超凡合并),**仅**上分帝 Web 英雄详情「走势」**无 STRATZ 时兜底**。STRATZ 周胜率/分路/`meta_board``web/data/stratz_hero_meta.json`(三卡与分路均取 **当前选中勋章 · 最近 1 周** `winWeek`;近 8 周列表从新到旧;分路在 8 周列表上方),对位 Top 存 `web/data/stratz_matchup_tops.json`(英雄详情「走势」与顶层「走势」榜 `/trends` 优先用 STRATZ;「对位」Tab 只读),**禁止**合并进 relations/heroes,禁止 `pc/recommend.py` 读取。同英雄近期比赛出装/加点单独存 `web/data/hero_matches.json`**仅** Web「近期比赛」Tab,禁止进 recommend。Valve Immortal 四区榜单独存 `web/data/leaderboards.json`,**仅** Web「排行」页选手榜,禁止进 recommend。主播目录单独存 `web/data/streamers.json`(手工名单 + 抖音 profile 补全;平台含抖音 / B 站 / 斗鱼),**仅** Web 顶层「主播」与英雄详情「主播」Tab,禁止进 recommend;由 `refresh_web` daily 软失败刷新粉丝等字段(不阻断整档)。
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json``item_fears.py` 规则映射到英雄弱点。`fetch_item_counter_stats.py` 的对阵购买率/胜率差仅作为观测证据:须减去同装备全局基线,对全部英雄的已有机制候选只允许小幅调序;统计发现的新组合即使同时满足 `games≥100`、购买率提升 `≥3pp`、条件胜率差 `≥1.5pp`、两项双比例检验 `z≥1.96`,仍须确认机制成立后手工写入 overrides,禁止直接把高相关当因果克制。大段技能文案只进 `web/data/hero_abilities.json` / `items_meta.json`**不要**塞进 `heroes.json`Web「怕」行只读 `hero_item_fears.json`**禁止**把 fears / counter_stats 统计胜率写入 `pc/recommend.py``pc/item_suggest.py` 评分。PC 局内出装仅允许:`pc/item_suggest.py` 在本人锁定后只读 `hero_items.json`(核心相对热度)+ 定性敌方 tags/画像规则表 + `items_meta` 名称 + 本地 `item_icons`;仍禁止 STRATZ / hero_stats / matches。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;机制 tags **禁止**写入 relations
- **机制查询页**:顶层 `/mechanics[/{effect}]`;侧栏弱/强驱散 + 核心控制;结果为施加该效果的技能与物品。数据边界:仅 Web。
- **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
- **宁可不认,不可乱认**`min_score` + `min_margin` 双门控;不确定就 `null`
- **平台**:面向 Windows;截屏依赖无边框/窗口模式。
- **上分帝 Web 定时刷新**Gitea Actionsself-hosted)跑 `web/refresh_web.py`;易变 STRATZ/stats/主播粉丝等 **不回写 git**,由 `web/refresh_cache.py` 跨 checkout 保存增量状态。生成 JSON 必须原子替换;HTTP 200 空数据不得覆盖旧缓存;只有业务字段变化才部署,资产变化必须先成功同步 OSS。每轮 `REFRESH_SUMMARY` 作为 Actions artifact,飞书以 workflow + summary + 生产 freshness 三联校验。数据-only 刷新不 bump `SITE_VERSION`。技能视频与手工 `relations.json` 不进定时。禁止把 STRATZ / hero_stats / matches / leaderboards / streamers 写入 recommend。
- **PC 赛后玩家页**:默认私有,写 `pc/player_pages/`;仅 `player_pages.public_share=true` 时 POST 入队由 Worker 写 D1/R2PC **不**内置云密钥)。Web `/players/{account_id}[/{match_id}]` 本机优先 `/api/players/...`,公网读 D1(+R2 详情);登录「我」`/home``/api/players/me`(生产 D1 缓存 + Queue 刷新;本机 `profile.json` TTL 缓存,语义对齐)。首次同步可慢,TTL 内后续直出缓存;R2 仅完整比赛详情,不承担主页加速。有公开 `account_id` 的昵称可点进主页并补拉近 20 场,详情懒加载;隐私局可借本机 GSI `samples/raw` 发现;**禁止**塞进主 `data.json`**禁止**进 recommend。需玩家开启「公开比赛数据」才能稳定拿到他人 ID。
- **Steam 登录**OpenID 确认本人 `account_id`;顶栏登录后出现「我」`/home`(复用玩家页)。本机 `serve_relations` 与 CF Pages 均需 `STEAM_API_KEY` + `SESSION_SECRET`;登录只解锁本人身份/历史,不能绕过他人隐私。勿把会话密钥写进 git / `data.json`
- **多用户玩家数据**:生产用 Cloudflare **D1**(档案/统计索引)+ **Queue/Worker**OpenDota 同步)+ 可选 **R2**(完整比赛 JSON);本机仍写 `pc/player_pages/`。他人主页须 `public_share`;勿把玩家战绩打进主 `data.json` / recommend。
## 开发命令
```powershell
pip install -r requirements.txt
python fetch_cdn_templates.py
python import_relations_xlsx.py # 可选:从选将笔记 xlsx 导入关系
python fetch_hero_portraits.py # 官网横版头像(关系预览)
python fetch_hero_items.py # OpenDota 热门装备缓存(关系预览
python fetch_item_shop.py # 商店分类目录(物品页
python fetch_items_meta.py # 装备描述 + 机制标签
python fetch_hero_abilities.py # 英雄技能 / 驱散汇总
python fetch_hero_abilities.py --icons-only # 缓存技能图标到 assets/ability_icons/
python fetch_ability_videos.py # 官网技能演示(限速;默认 webm
python item_fears.py # 英雄怕的装备
python fetch_patches.py # 近一年版本日志 + 详情(版本页
python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店
python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages
python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev
python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
python gsi_setup.py --check
python gsi_watch.py --once
python recognize.py samples/raw/<>.png --sheet
python evaluate.py
pip install -r pc/requirements.txt
pip install -r web/requirements.txt # 可选:本机 refresh_web / OSSCI 会装)
python pc/fetch_cdn_templates.py
python shared/import_relations_xlsx.py # 可选:从选将笔记 xlsx 导入关系
python web/fetch_hero_portraits.py # 官网横版头像(上分帝 Web
python web/fetch_hero_items.py # OpenDota 热门装备缓存(上分帝 Web
python web/fetch_hero_stats.py # OpenDota 各段位胜率/场次(上分帝 Web 走势兜底)
python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top(走势/对位/Meta;需 token
python web/fetch_hero_matches.py # 同英雄近期比赛出装/加点(上分帝 Web;可 --heroes antimage--workers 6--enrich-item-times
python web/fetch_pro_matches.py # 明星选手近期联赛对局(默认 watchlist--include-pubs 含天梯;--refresh-limit 15 轮换;可 --players Ame,898754153
python web/fetch_leaderboards.py # Valve Immortal 四区 Top100(排行页)
python web/fetch_streamers.py # 抖音主播主页补全(主播页;手工名单;亦由 daily 定时软失败刷新
python web/fetch_streamer_live.py # 探测真实在播状态回写 is_live(主播页角标;daily--dry-run 只打印)
python web/fetch_item_shop.py # 商店分类目录(物品页
python web/fetch_items_meta.py # 装备描述 + 机制标签
python web/fetch_hero_abilities.py # 英雄技能 / 驱散汇总
python web/fetch_hero_abilities.py --tags-only # 只重算技能「施加」类 tags
python web/fetch_hero_abilities.py --icons-only # 缓存技能图标到 web/assets/ability_icons/
python web/fetch_ability_videos.py # 官网技能演示(限速;默认 webm)
python web/fetch_item_counter_stats.py # OpenDota 对阵装备相对全局基线(可再生成缓存)
python web/item_fears.py # 英雄怕的装备
python web/fetch_patches.py # 近一年版本日志 + 详情(版本页)
python web/fetch_patches.py --check # 只比对列表与本地 detailsstdout JSONhas_new
python web/refresh_web.py --tier patch --skip-deploy --skip-oss # 本机试跑某档(daily/weekly/patch/all
python web/serve_relations.py # 上分帝 Web 本地开发服务(英雄 / 排行 / 主播 / 走势 / 机制 / 物品 / 版本)
python web/export_relations_site.py # 导出静态站点到 web/dist/relations/(可部署 Pages
python web/export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com --static-asset-base https://climperor.oss-cn-shanghai.aliyuncs.com
python web/deploy_relations.py # 部署 web/dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev;视频与图标走 OSS
python web/notify_site_traffic.py --dry-run # 上分帝 Web 日活摘要(飞书;需 CF + webhook env / keyzoo
python web/_oss_static_assets.py upload # 图标/portrait 变更后同步 OSSkeyzoo voson-RAM
python web/_cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态
# Gitea Actionsself-hosted):web-daily / web-weekly / web-patchSecrets 见 README「定时刷新」与各 workflow 头注释
python pc/gsi_setup.py --check
python pc/gsi_watch.py --once
python pc/recognize.py pc/samples/raw/<>.png --sheet
python pc/evaluate.py
```
## 修改原则
- 只改任务所需逻辑,避免无关重构与大范围格式化。
- 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。
- 改匹配阈值或裁切时,用 `evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `DESIGN.md`
- GSI cfg 时同步核对 `gsi_setup.py` 与 Dota `gamestate_integration` 目录
-预览路由形态(URL 段 / query 参数 / 默认值)时同步 `web/relations/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`serve_relations.py` 无需改;`export_relations_site.py` 的导出文件列表须含 `router.js`
-`data/heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc`);`roles``overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案
- 改匹配阈值或裁切时,用 `pc/evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `ARCHITECTURE.md`
-上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度
- GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录
-Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。History 深度链接依赖 `web/frontend/_redirects`Cloudflare)与 `web/serve_relations.py` 的 SPA fallback(含 `players`);预渲染路径变更时同步 `web/seo_prerender.py` 与导出文件列表(含 `router.js` / `_redirects` / `robots.txt`
-`shared/data/heroes.json` 结构时同步 `shared/grid.py`(依赖 `attr` / `name_loc`);`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `pc/fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` / `item_suggest` 缺口与应对装共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。
- 发版上分帝 Web 时同步 bump `web/export_relations_site.py``SITE_VERSION``web/frontend/config.js` 的同名变量,以及 `index.html``style.css`/`mobile-gate.js`/`config.js`/`router.js`/`app.js``?v=` 缓存戳;并写 `CHANGELOG.md``SITE_VERSION` 语义:末位 = 增量 UI/修复;中段 = 壳层 / 路由 / 可索引或其它阶段性能力成型(如 `0.5.x``0.6.0`);数据-only 刷新不 bump。
- 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py`
## 文档分工
@@ -145,7 +248,8 @@ python evaluate.py
| 文件 | 内容 |
|------|------|
| `README.md` | 安装与操作手册 |
| `DESIGN.md` | 背景、选型、关键实测与决策记录 |
| `ARCHITECTURE.md` | 背景、选型、关键实测与决策记录 |
| `DESIGN.md` | 上分帝 Web 视觉设计规范(令牌 + 使用规则;[design.md](https://github.com/google-labs-code/design.md) 格式) |
| `AGENTS.md` | 本文件:给协作者 / AI 的约束 |
| `CHANGELOG.md` | 面向用户的版本变更 |
+271
View File
@@ -0,0 +1,271 @@
# 上分帝(Climperor)—— 方案与实施细节
`README.md` 是操作手册;本文记录背景、选型与关键决策。协作约束见 `AGENTS.md`。上分帝 Web 视觉设计规范见 `DESIGN.md`
---
## 1. 背景与目标
### 要解决的问题
在 Dota 2 选将阶段(以及进入游戏后)**几秒内自动获取双方 10 个英雄**,服务天梯选将辅助或赛后分析。
### 为什么 GSI 做不到
| 场景 | GSI 可获得的阵容数据 |
|------|---------------------|
| 排位 / 普通 All Pick | 仅自己的 `hero.id``draft` 通常为空 |
| Captains Mode | 历史上有部分 pick/ban,不稳定 |
| 观战 / 裁判视角 | 阵容字段较全 |
| 赛后 | 需依赖 OpenDota 等外部 API |
Valve 已明确关闭普通玩家视角的实时 draft
[#9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562)、
[#7193](https://github.com/ValveSoftware/Dota2-Gameplay/issues/7193)not planned)。
### 候选方案
| 方案 | 结论 |
|------|------|
| 官方 GSI | 拿不到双方 pick |
| Overwolf GEP | 备选,偏重 |
| 读游戏内存 | **排除**(合规) |
| 截屏 + 模板匹配 | **选定** |
| 整图问多模态大模型 | 排除为主路径(实测整图几乎全错;裁顶栏后仍不如白名单匹配) |
社区同类工具共同点:**裁固定 ROI + 模板匹配 / 小模型**,输出约束在英雄白名单内。
---
## 2. 技术方案
### 处理流程
```
截图 → 相对坐标裁 10 格 →(天梯则遮罩段位条)→ CDN NCC 匹配
→ score + margin 双门控 → hero_key | null
```
### 关键决策
**① 相对坐标**
横向 `(cx - W/2) / H`,纵向与宽高 `/ H`。同宽高比下分辨率无关;21:9 等需锚点或重标定。
**② 单一 CDN 模板库**
`templates/cdn/{hero}.png` 来自 Steam 官方头像,按顶栏实际窗口裁切后写入。
曾尝试「CDN 兜底 + real 实拍」双层库。实测修正 CDN 裁切后 **CDN-only 即可全对**
继续攒 real 库性价比低,已放弃(见 §3 发现二)。皮肤靠会话策略,不堆变体模板。
**③ 宁可不认,不可乱认**
- `score >= min_score`(默认 0.45
- `margin = Top1 Top2 >= min_margin`(默认 0.04
**④ 会话层处理皮肤与淡入**
- 选人阶段:多帧确认、允许更高分改判(`revise_gain`
- 决策阶段:只补空槽、禁止改判(避免皮肤顶栏覆盖默认脸结论)
- best 帧优先选人阶段默认脸(`recognized` 相同时)
- 本人英雄以 GSI `hero` 覆盖视觉结果(只覆盖自己的槽)
**⑤ 截图按对局归档**
GSI 会话写入 `samples/raw/{matchid}/`;手动 `capture.py` 仍写 `samples/raw/` 根目录。
结果 JSON 仍为 `results/draft_<时间戳>.json`(内含 `match_id`)。
**⑥ GSI 全量落盘**
`gsi.dump_payloads`(默认开)把每包 POST body 追加到 `samples/raw/{matchid}/gsi.jsonl`
行格式 `{"t": <unix>, "payload": <原文>}`。内容上限仍是 cfg 订阅字段 + 普通玩家视角;
不能靠落盘补出双方 pick。CLI`--dump-gsi` / `--no-dump-gsi`
---
## 3. 关键实测(2026-07-25
环境:2560×1440 无边框;人机 + 天梯。
### 发现一:CDN 裁切窗口决定上限
初版按正方形取中心,比例与顶栏 111×88 不符,均分约 0.65。
网格搜索得到正确窗口 **x0=38, w=182, 全高**(源 256×144),均分约 0.94。
写入 `fetch_cdn_templates.py``CROP_X0/CROP_X1` 后,**CDN-only 30/30 / 40/40**。
> 兜底层差时先查素材处理,再考虑堆数据。
### 发现二:不必维护 real 库
CDN-only 开箱即用后,「打几十局攒默认脸」不再是主路径。项目改为只维护 CDN。
后续审计(迁出前原型库)进一步确认:
- 标注集上大量 `score=1.000` 往往是「从同帧裁进 real 再评同帧」的自证,夸大了 real 贡献。
- 剩余 real 与 CDN(天梯遮罩)多数 ≥0.85,近乎 CDN 副本;明显偏离的极少(如军团指挥官一类边缘格)。
- **近期问题局**(宙斯缺槽、斯拉达 CDN 分低、敌法末位、皮肤帧风行者 0.415)主要靠
**多帧会话 + 决策禁改判 + GSI 补自己** 凑齐,不是靠 real。
- 皮肤变体极多,入库皮肤模板不合理;段位条/徽章每人不同,匹配时已用遮罩忽略,
也不该为不同段位重复存图。
### 发现三:自动标定靠玩家颜色条
10 条固定玩家色定位槽位;中心用中位数槽距拟合(剔除被身后头像污染的宽条);
头像下沿用「格内列 vs 格间空隙」亮度差,勿用逐行差分(易误判到名字行)。
### 发现四:天梯段位条用遮罩
GSI 不含可靠 lobby 类型时,用右下金色勋章检测(`has_ranked_overlay`),
匹配时屏蔽底部 32% + 右侧 22%。人机无勋章走全图匹配。
### 发现五:禁用名单不靠模板
网格按主属性分块 + 客户端本地化名行优先排布;禁用/已选卡片对比度塌陷
std 约 821 vs 正常 ≥33)。`data/heroes.json` 需含 `attr` / `name_loc`
另含手工维护的中文口语 `aliases`(重拉 CDN 表时合并保留,暂未接入推荐展示)。
格数对不上时 `ok=False`,不返回残缺名单。
### 发现六:位置与「我」
定位局位置字用二值 IoU(非 OCR)。「我」优先 GSI `team_slot`
否则姓名亮度相对差(最亮比次亮 ≥ 25)兜底。
### 发现七:客户端顶栏时机(会话策略前提)
这些是实战纠正后的事实,改 `DraftSession` 时不要违背:
| 现象 | 含义 |
|------|------|
| 顶栏默认脸 → 皮肤 | **全员选完后**才换皮肤立绘,不是一进决策就换 |
| 本机已进 `STRATEGY_TIME` | 别人可能还在选;**不能**一进决策就永久停视觉 |
| 空槽红旗倒计时 | 只出现在**未选**槽;揭晓后消失,不是挡脸主因 |
| GSI | **没有**「皮肤已加载」字段;无法精确卡「人选完、皮肤前」单帧 |
因此正确节奏是:选人 + 决策前期持续读,直到确认 10/10 或 `strategy_tail_polls` 用尽;
凑齐后停视觉,再短等 GSI 补本人英雄。单帧皮肤弱识别(如决策 3D 模型挡顶栏)属预期,
靠会话回填,不靠降阈值或堆皮肤模板。
### 发现八:揭晓淡入需要可改判
首帧揭晓常半透明 + 段位条,易误认(例:剑圣揭晓瞬间被认成幽鬼)。
`confirm_polls` 后若仍禁止改判会锁死错误。选人阶段允许
`score ≥ was_score + revise_gain` 的稳定新结果覆盖;决策阶段则关闭改判。
---
## 4. 代码结构
```
climperor/
├── pc/ # 局内选将识别(GSI + 截屏 + OpenCV
│ ├── common.py / recognize.py / draft_session.py / gsi_watch.py
│ ├── recommend.py / item_suggest.py / draft_archetypes.py / roles.py / modes.py / overlay.py
│ ├── config.json / templates/ / assets/role_icons/ / samples/
│ └── requirements.txt
├── web/ # 上分帝 Web(前端、数据流水线、部署)
│ ├── frontend/ # 原 web/relations/
│ ├── data/ / assets/ / dist/
│ ├── fetch_*.py / item_fears.py / mechanic_tags.py / loc_format.py
│ ├── serve_relations.py / export_relations_site.py / deploy_relations.py
│ ├── refresh_web.py / notify_site_traffic.py / _cf_* / _oss_* / _gitea_*
│ └── requirements.txt
├── shared/ # 只被 pc/web 依赖,绝不反向依赖
│ ├── paths.py / grid.py / relations.py / hero_tags.py / http_utils.py
│ ├── import_relations_xlsx.py / audit_relations.py / fetch_stratz.py
│ └── data/heroes.json / data/relations.json
├── .gitea/workflows/ # site-traffic-notify + web-daily/weekly/patch
└── README.md / AGENTS.md / ARCHITECTURE.md / DESIGN.md / CHANGELOG.md
```
运行时目录 `pc/preview/``pc/results/``pc/failures/``pc/samples/raw/`(含按对局子目录)不入库。上分帝 Web 易变数据(STRATZ meta、hero_stats 等)可由 Gitea Actions 定时拉取后直部 Pages,不必入库。
### 主要配置
| 字段 | 含义 | 默认 |
|------|------|------|
| `match.min_score` / `min_margin` | 识别门控 | 0.45 / 0.04 |
| `match.ranked_mask` | 天梯遮罩比例 | bottom 0.32, right 0.22 |
| `gsi.port` | 监听端口 | 3223 |
| `gsi.confirm_polls` | 连续同结果帧数 | 2 |
| `gsi.revise_gain` | 选人阶段改判所需分数增益 | 0.15 |
| `gsi.strategy_tail_polls` | 决策阶段继续视觉轮询 | 8 |
| `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 |
| `gsi.require_foreground` | 仅当前台为 `dota2.exe` 时截屏识别 | true |
| `gsi.dump_payloads` | 全量 GSI JSONL 落盘 | true |
| `recommend.enabled` | 选将全网格克/搭/补推荐 | true |
| `recommend.top_n` | 标记数量上限;`≤0` 不截断 | 0 |
| `recommend.min_enemies` | 开始推荐所需敌方锁人数 | 1 |
| `recommend.min_heroes_for_gaps` | 缺口/补位注入所需锁人数 | 2 |
| `recommend.archetypes` | 推进/全球流/缺口规则画像 | true |
| `recommend.relations_path` | 定性关系文件 | `shared/data/relations.json` |
| `recommend.role_tags` | 分路→角色标签过滤(1–5 号位;非定位局不过滤) | 见 config.json |
| `recommend.items.*` | 锁定后核心装+应对装(`hero_items` + 定性规则) | 见 config.json |
| `overlay.enabled` | 选将网格「克/搭/补」+ 分析条 + 装备图标条 | true |
| `overlay.mark_size_rel` / `mark_pad_rel` / `mark_gap_rel` | 网格克/搭/补方标尺寸、内边距、间距 | 0.018 / 0.004 / 0.002 |
| `overlay.counter_color` / `synergy_color` / `fill_color` / `mark_text_color` | 克 / 搭 / 补 / 文字色 | `#2ec4b6` / `#e9a825` / `#9b7ebd` / `#0b1220` |
| `overlay.analysis_*` | 阵容分析横条位置/高度/字号/底色字色 | 见 config.json |
| `overlay.items_*` / `item_*` | 锁定后装备图标条位置/尺寸/底色 | 见 config.json |
---
## 5. GSI 自动化
```
Dota 2 (-gamestateintegration)
→ POST → pc/gsi_watch.py :3223
→ pc/samples/raw/{matchid}/gsi.jsonl(可选)
→ DraftSession 轮询 recognize + roles/grid/modes
→ pc/results/draft_*.json
```
要点:按 `matchid` 去重;截图进 `pc/samples/raw/{matchid}/`;识别在工作线程;
未标定时降级为只截图。
天梯 AP 选人按 2/2/1 成批揭晓,本轮结束前互不可见——因此必须在
**HERO_SELECTION** 跟踪,不能只在决策时间截一张终局图;决策前期仍读几帧
以吃到最后一人揭晓(见发现七)。
---
## 6. 选将全网格「克 / 搭 / 补」推荐
- **触发**:敌方至少锁定 1 人(`min_enemies`)后开始;本人锁人后清空标记。
- **分路**:定位匹配画面字(`roles.py`+ GSI `team_slot` 定位本人;不做截屏认自己。有分路则按 `role_tags` 过滤候选;非定位局(读不到分路字)则全英雄表。
- **关系模型**:机制克制/搭档(`shared/data/relations.json`),不用胜率/场次——版本会变,机制边相对稳。Web 英雄详情「走势」Tab 优先 STRATZ(`web/data/stratz_hero_meta.json`):勋章条切换 8 档 → **最近 1 周**三卡(胜率/上场率/场次)→ 1~5 号位分路胜率 → **近 8 周**逐周列表(日期、胜率、周环比、区间缩放横条、场次;新→旧);OpenDota(`web/data/hero_stats.json`,近约 7 天)作兜底。「对位」Tab 展示 STRATZ 数值 Top`web/data/stratz_matchup_tops.json`),与网格定性「克/怕/搭」分开展示;均不进局内推荐。
- **阵容画像(规则,不接 AI)**:[`pc/draft_archetypes.py`](pc/draft_archetypes.py) 识别敌方推进/全球流、敌我 tag 缺口(缺控制/爆发/输出/先手);输出一句 `analysis`(如 `敌:缺控制·偏推进 | 我:缺爆发`)与每英雄短 `reasons`
- **纳入**
- **克**:关系克制边;推进/全球流应对表(如美杜莎对推进);惩戒敌方缺口
- **搭**:与已锁己有搭档边
- **补**:填我方缺口(`min_heroes_for_gaps` 默认 2 才注入缺口类标记)
- **打分**:关系边为主;阵容应对 / 缺口小幅加分;敌方 tags 软加分仅排序。
- **展示**:网格左上角全量相关格标「克」(青)/「搭」(琥珀)/「补」(紫灰);叠加层横条显示阵容分析;原因进日志与 `results` JSON,不写在每个格子旁。
- **上分帝 Web**`web/serve_relations.py` 本地开发服务;仿选将网格查看克制/被克制/搭档;数据手改 `shared/data/relations.json` 或从 xlsx 导入。
另有顶级 **排行**、**走势**`/trends[/bracket]`,近 8 周高胜率/上场率榜)、**物品**、**版本** 页;目录见 `web/data/item_shop.json` / `web/data/patches.json` / `web/data/leaderboards.json`
英雄详情子标签含 **走势**(见上;`/heroes/<key>/trends`)、**对位**(克制/被克/搭档三列 STRATZ Top`/heroes/<key>/matchups`)、**近期比赛**`/heroes/<key>/matches`)。排行页为 Immortal 四区选手榜。
- **Web History 路由 + SEO 预渲染**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
标签筛选 / 搜索)双向同步进路径 URL(`/heroes/axe/core?tags=核心&q=axe``/heroes/axe/trends` 等)。
`#/...` 书签在装载时 `replaceState` 迁到路径。写 URL 用 `history.pushState`/`replaceState`
(静默,不触发 `popstate`);前进/后退靠 `popstate`。Cloudflare `_redirects`
`serve_relations.py` 对未知深度路径回退 `index.html`。导出(`seo_prerender.py`)为首页、
顶层页、全英雄与机制效果写可抓取 HTML + `sitemap.xml` / `llms.txt`
搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在
`web/frontend/router.js``app.js` 只在 `main()``installRouter` + 各 state 变更点
`syncStateToUrl`
- **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `shared/data/heroes.json`;不接实时 AI。
---
## 7. 与 dota2-hex
独立验证项目。可选:Rust 重写并入,或本地旁路 HTTP 回传。
合规同 `dota2-hex`**屏幕可见信息 only**。
---
## 8. 参考
- [Valve #9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562)
- [Valve #14915](https://github.com/ValveSoftware/Dota2-Gameplay/issues/14915)(开发者 OCR ≈85%
- [Overwolf Dota 2 GEP](https://dev.overwolf.com/ow-native/live-game-data-gep/supported-games/dota-2/)
- Steam CDN`https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png`
+1126 -35
View File
File diff suppressed because it is too large Load Diff
+326 -215
View File
@@ -1,256 +1,367 @@
# 上分帝(Climperor)—— 方案与实施细节
`README.md` 是操作手册;本文记录背景、选型与关键决策。协作约束见 `AGENTS.md`
---
version: alpha
name: Climperor Web
description: Dark Dota-inspired information panels for the Climperor Web desktop site.
colors:
primary: "#5EC8FF"
secondary: "#8FA3BC"
tertiary: "#3DCE7A"
neutral: "#0B1018"
surface: "#0E1622"
surface-raised: "#101824"
on-surface: "#E8EEF7"
on-surface-muted: "#8FA3BC"
border: "rgba(140, 170, 210, 0.18)"
accent: "#5EC8FF"
good: "#3DCE7A"
bad: "#E07A2F"
syn: "#7EC8FF"
danger: "#E86A6A"
gold: "#E8C878"
live: "#FE2C55"
panel: "rgba(14, 22, 34, 0.96)"
panel-soft: "rgba(8, 12, 20, 0.55)"
on-accent: "#0B0D10"
typography:
brand:
fontFamily: "Noto Serif SC, Songti SC, STSong, SimSun, serif"
fontSize: 18px
fontWeight: 700
lineHeight: 1.3
letterSpacing: 0.16em
display:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 32px
fontWeight: 800
lineHeight: 1.1
letterSpacing: 0.02em
page-title:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 22px
fontWeight: 800
lineHeight: 1.2
letterSpacing: 0.02em
title-lg:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 18px
fontWeight: 700
lineHeight: 1.3
letterSpacing: 0.04em
title-md:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 16px
fontWeight: 700
lineHeight: 1.35
letterSpacing: 0.02em
body-lg:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 15px
fontWeight: 400
lineHeight: 1.5
body-md:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 14px
fontWeight: 400
lineHeight: 1.5
body-sm:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 13px
fontWeight: 500
lineHeight: 1.45
label-md:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 13px
fontWeight: 600
lineHeight: 1.3
letterSpacing: 0.06em
label-sm:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 12px
fontWeight: 600
lineHeight: 1.3
letterSpacing: 0.04em
numeric-lg:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 28px
fontWeight: 700
lineHeight: 1.1
letterSpacing: 0.02em
numeric-md:
fontFamily: "Segoe UI, Microsoft YaHei UI, PingFang SC, sans-serif"
fontSize: 14px
fontWeight: 700
lineHeight: 1.2
rounded:
sm: 4px
md: 8px
lg: 12px
xl: 16px
full: 9999px
spacing:
xs: 4px
sm: 8px
md: 12px
lg: 16px
xl: 24px
2xl: 32px
3xl: 48px
gutter: 16px
page-y: 28px
content-read: 820px
content-standard: 880px
content-wide: 1080px
content-data: 1200px
components:
button-primary:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-accent}"
rounded: "{rounded.md}"
padding: 12px
height: 40px
button-primary-hover:
backgroundColor: "#7AD4FF"
button-secondary:
backgroundColor: "{colors.surface}"
textColor: "{colors.on-surface}"
rounded: "{rounded.md}"
padding: 12px
height: 40px
button-secondary-active:
backgroundColor: "{colors.primary}"
textColor: "{colors.on-accent}"
tab-main:
backgroundColor: "{colors.surface}"
textColor: "{colors.secondary}"
typography: "{typography.body-lg}"
height: 42px
padding: 10px
tab-main-active:
backgroundColor: "{colors.surface-raised}"
textColor: "{colors.on-surface}"
input-search:
backgroundColor: "{colors.neutral}"
textColor: "{colors.on-surface}"
rounded: "{rounded.md}"
height: 40px
padding: 12px
panel-card:
backgroundColor: "{colors.surface}"
textColor: "{colors.on-surface}"
rounded: "{rounded.lg}"
padding: 16px
chip-filter:
backgroundColor: "{colors.surface}"
textColor: "{colors.secondary}"
rounded: "{rounded.full}"
padding: 8px
chip-filter-active:
backgroundColor: "{colors.surface-raised}"
textColor: "{colors.on-surface}"
mark-good:
backgroundColor: "{colors.neutral}"
textColor: "{colors.good}"
mark-bad:
backgroundColor: "{colors.neutral}"
textColor: "{colors.bad}"
mark-syn:
backgroundColor: "{colors.neutral}"
textColor: "{colors.syn}"
badge-gold:
backgroundColor: "{colors.neutral}"
textColor: "{colors.gold}"
badge-live:
backgroundColor: "{colors.live}"
textColor: "#FFFFFF"
badge-danger:
backgroundColor: "{colors.neutral}"
textColor: "{colors.danger}"
panel-raised:
backgroundColor: "{colors.panel}"
textColor: "{colors.on-surface}"
panel-soft:
backgroundColor: "{colors.panel-soft}"
textColor: "{colors.on-surface-muted}"
divider:
backgroundColor: "{colors.surface}"
textColor: "{colors.border}"
---
## 1. 背景与目标
# Climperor Web Design System
### 要解决的问题
## Overview
在 Dota 2 选将阶段(以及进入游戏后)**几秒内自动获取双方 10 个英雄**,服务天梯选将辅助或赛后分析
上分帝 Web 是面向桌面浏览器的 Dota 2 信息站:深色蓝黑底、高信息密度、冷静可读。气质接近官方客户端与选人界面——偏工具面板,不走社交 feed 或明亮营销站
### 为什么 GSI 做不到
目标感受:
| 场景 | GSI 可获得的阵容数据 |
|------|---------------------|
| 排位 / 普通 All Pick | 仅自己的 `hero.id``draft` 通常为空 |
| Captains Mode | 历史上有部分 pick/ban,不稳定 |
| 观战 / 裁判视角 | 阵容字段较全 |
| 赛后 | 需依赖 OpenDota 等外部 API |
- **Dense but calm**:列表、表格、网格可挤满视口,但字号、行高、间距仍保持清晰节奏。
- **Semantic color first**:青色强调当前选择与导航;绿 / 橙 / 青蓝分别表示克制 / 被克 / 搭档;粉红仅用于直播态。
- **Desktop shell**`100dvh` 外框 + 页内滚动;手机 / 平板由 `mobile-gate` 拦截,不为真移动端另做一套 IA。
Valve 已明确关闭普通玩家视角的实时 draft
[#9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562)、
[#7193](https://github.com/ValveSoftware/Dota2-Gameplay/issues/7193)not planned)。
实现源:`web/frontend/style.css` 的 CSS 变量必须与本文件令牌一一对应。工程决策见 `ARCHITECTURE.md`
### 候选方案
## Colors
| 方案 | 结论 |
|------|------|
| 官方 GSI | 拿不到双方 pick |
| Overwolf GEP | 备选,偏重 |
| 读游戏内存 | **排除**(合规) |
| 截屏 + 模板匹配 | **选定** |
| 整图问多模态大模型 | 排除为主路径(实测整图几乎全错;裁顶栏后仍不如白名单匹配) |
调色盘以近黑海军为底,浅冷灰为正文,单一青色作交互主色。
社区同类工具共同点:**裁固定 ROI + 模板匹配 / 小模型**,输出约束在英雄白名单内
- **Primary (`#5EC8FF`)**:主交互与选中态(主 Tab 底线、侧栏选中填充、焦点环、选中英雄描边)
- **Secondary (`#8FA3BC`)**:次要文案、标签、时间戳、未选中控件。
- **Tertiary (`#3DCE7A`)**:正向语义(克制、胜率上升、胜场)。
- **Neutral (`#0B1018`)**:页面底色;表面层用更深的半透明面板叠在渐变背景上。
- **Bad / Danger / Gold / Live / Syn**:业务辨识色,不得拿来当通用强调色。
---
对比度要求:正文与底至少 WCAG AA;muted 文案仅用于辅助信息,不用在关键操作标签上。
## 2. 技术方案
## Typography
### 处理流程
系统栈:`Segoe UI` + `Microsoft YaHei UI` + `PingFang SC`。正文与控件优先清晰,不引入全站装饰字体。
```
截图 → 相对坐标裁 10 格 →(天梯则遮罩段位条)→ CDN NCC 匹配
→ score + margin 双门控 → hero_key | null
```
品牌例外:顶栏 / 移动端门禁的「上分帝」使用 `brand`(自托管 Noto Serif SC 子集,仅 U+4E0A / U+5206 / U+5E1D),宋体气质贴合「帝」字;正文仍走系统无衬线栈。禁止把衬线栈扩到导航、页标题或表格。
### 关键决策
| Token | 用途 |
|-------|------|
| `brand` | 仅品牌字标「上分帝」(顶栏 + mobile-gate |
| `display` | 版本号等少数展示数字(约 32px,勿再放大到 40px) |
| `page-title` | 各顶层页标题(排行 / 走势 / 机制 / 主播 / 物品等) |
| `title-lg` / `title-md` | 区段标题、英雄名、物品名 |
| `body-lg` / `body-md` / `body-sm` | 导航、正文、说明 |
| `label-md` / `label-sm` | 表头、分区标签、徽章 |
| `numeric-*` | 表格数字与指标卡,配合 `tabular-nums` |
**① 相对坐标**
字重只用 400 / 500 / 600 / 700 / 800。禁止 `650``13.5px` 等孤立值。默认行高正文 1.5,标题 1.1–1.3;勿留 `line-height: normal` 在可读正文上。
横向 `(cx - W/2) / H`,纵向与宽高 `/ H`。同宽高比下分辨率无关;21:9 等需锚点或重标定。
## Layout
**② 单一 CDN 模板库**
桌面壳:顶栏固定高度区 + 可滚动 `main.board`;英雄页的英雄网格与固定高 `#detail`
之间放置一行定位筛选。选中英雄后 `#detail` 使用共享固定高度:按视口预留顶栏 + **完整** `#columns`
英雄网格实测高度 + 定位标签后取余,再 `clamp` 到约 `280640px`
CSS 回退 `min(640px, calc(100dvh - 26rem))`)。技能 Tab 不驱动抽屉高度,
内容在抽屉内 flex/滚动;Tab 切换不跳动;长 Tab(走势/对位等)同理在详情区内滚动。
技能演示片源为官网 16:9;有横向空间时加宽(上限约 720px / 列宽 58%),
`aspect-ratio: 16/9` + `object-fit: contain`,勿用拉满高度的 `cover` 裁左右。
`templates/cdn/{hero}.png` 来自 Steam 官方头像,按顶栏实际窗口裁切后写入。
### 顶部壳(紧凑单层)
曾尝试「CDN 兜底 + real 实拍」双层库。实测修正 CDN 裁切后 **CDN-only 即可全对**
继续攒 real 库性价比低,已放弃(见 §3 发现二)。皮肤靠会话策略,不堆变体模板。
顶栏只保留主行,禁止品牌 / 主导航 / 搜索三者各自「漂浮居中」造成大块空洞:
**③ 宁可不认,不可乱认**
1. **首行 `PrimaryRow`**`display: grid; grid-template-columns: 1fr auto 1fr`(或等价 flex)。
- 左:品牌(logo + 标题)
- 中:主导航分段控件(真正几何居中)
- 右:工具区(英雄/物品搜索 + 邮件入口),`justify-content: end`
视觉语言轻度借鉴 Dota 2 国服官网导航,但不复刻门户式大顶栏:
- `score >= min_score`(默认 0.45
- `margin = Top1 Top2 >= min_margin`(默认 0.04
- 顶栏使用近黑金属渐变与上下 1px 冷色高光,形成连续横梁;总高控制在约 60px。
- 品牌区仅用克制的暗红内光形成视觉锚点,不在 Logo 右侧增加分隔线;不使用视频 Logo、金色奖项条或厚重贴图。
- 主导航保持现代无边框文字 Tab;相邻 Tab 仅用低对比斜切细线分隔,不做梯形按钮。
- 主交互色仍为 `primary` 青色;Dota 红只用于品牌区,不替代全站选中态。
- 宽屏主行采用 44px 控件高:主导航 16px / 最小 76px 宽,品牌 Logo 32px。`lg` 断点恢复 40px、15px 的紧凑规格。
**④ 会话层处理皮肤与淡入**
断点降级:
- 选人阶段:多帧确认、允许更高分改判(`revise_gain`
- 决策阶段:只补空槽、禁止改判(避免皮肤顶栏覆盖默认脸结论)
- best 帧优先选人阶段默认脸(`recognized` 相同时)
- 本人英雄以 GSI `hero` 覆盖视觉结果(只覆盖自己的槽)
- `lg` ≤1200px:Tab 水平内边距收紧;搜索宽约 160–180px
- `md` ≤900px:隐藏品牌标题,仅留 logo;搜索再缩短
- `sm` ≤640px:极窄桌面可隐藏整块品牌
**⑤ 截图按对局归档**
内容宽度档位(命名,勿再散落魔法数):
GSI 会话写入 `samples/raw/{matchid}/`;手动 `capture.py` 仍写 `samples/raw/` 根目录。
结果 JSON 仍为 `results/draft_<时间戳>.json`(内含 `match_id`)。
**⑥ GSI 全量落盘**
`gsi.dump_payloads`(默认开)把每包 POST body 追加到 `samples/raw/{matchid}/gsi.jsonl`
行格式 `{"t": <unix>, "payload": <原文>}`。内容上限仍是 cfg 订阅字段 + 普通玩家视角;
不能靠落盘补出双方 pick。CLI`--dump-gsi` / `--no-dump-gsi`
---
## 3. 关键实测(2026-07-25
环境:2560×1440 无边框;人机 + 天梯。
### 发现一:CDN 裁切窗口决定上限
初版按正方形取中心,比例与顶栏 111×88 不符,均分约 0.65。
网格搜索得到正确窗口 **x0=38, w=182, 全高**(源 256×144),均分约 0.94。
写入 `fetch_cdn_templates.py``CROP_X0/CROP_X1` 后,**CDN-only 30/30 / 40/40**。
> 兜底层差时先查素材处理,再考虑堆数据。
### 发现二:不必维护 real 库
CDN-only 开箱即用后,「打几十局攒默认脸」不再是主路径。项目改为只维护 CDN。
后续审计(迁出前原型库)进一步确认:
- 标注集上大量 `score=1.000` 往往是「从同帧裁进 real 再评同帧」的自证,夸大了 real 贡献。
- 剩余 real 与 CDN(天梯遮罩)多数 ≥0.85,近乎 CDN 副本;明显偏离的极少(如军团指挥官一类边缘格)。
- **近期问题局**(宙斯缺槽、斯拉达 CDN 分低、敌法末位、皮肤帧风行者 0.415)主要靠
**多帧会话 + 决策禁改判 + GSI 补自己** 凑齐,不是靠 real。
- 皮肤变体极多,入库皮肤模板不合理;段位条/徽章每人不同,匹配时已用遮罩忽略,
也不该为不同段位重复存图。
### 发现三:自动标定靠玩家颜色条
10 条固定玩家色定位槽位;中心用中位数槽距拟合(剔除被身后头像污染的宽条);
头像下沿用「格内列 vs 格间空隙」亮度差,勿用逐行差分(易误判到名字行)。
### 发现四:天梯段位条用遮罩
GSI 不含可靠 lobby 类型时,用右下金色勋章检测(`has_ranked_overlay`),
匹配时屏蔽底部 32% + 右侧 22%。人机无勋章走全图匹配。
### 发现五:禁用名单不靠模板
网格按主属性分块 + 客户端本地化名行优先排布;禁用/已选卡片对比度塌陷
std 约 821 vs 正常 ≥33)。`data/heroes.json` 需含 `attr` / `name_loc`
另含手工维护的中文口语 `aliases`(重拉 CDN 表时合并保留,暂未接入推荐展示)。
格数对不上时 `ok=False`,不返回残缺名单。
### 发现六:位置与「我」
定位局位置字用二值 IoU(非 OCR)。「我」优先 GSI `team_slot`
否则姓名亮度相对差(最亮比次亮 ≥ 25)兜底。
### 发现七:客户端顶栏时机(会话策略前提)
这些是实战纠正后的事实,改 `DraftSession` 时不要违背:
| 现象 | 含义 |
|------|------|
| 顶栏默认脸 → 皮肤 | **全员选完后**才换皮肤立绘,不是一进决策就换 |
| 本机已进 `STRATEGY_TIME` | 别人可能还在选;**不能**一进决策就永久停视觉 |
| 空槽红旗倒计时 | 只出现在**未选**槽;揭晓后消失,不是挡脸主因 |
| GSI | **没有**「皮肤已加载」字段;无法精确卡「人选完、皮肤前」单帧 |
因此正确节奏是:选人 + 决策前期持续读,直到确认 10/10 或 `strategy_tail_polls` 用尽;
凑齐后停视觉,再短等 GSI 补本人英雄。单帧皮肤弱识别(如决策 3D 模型挡顶栏)属预期,
靠会话回填,不靠降阈值或堆皮肤模板。
### 发现八:揭晓淡入需要可改判
首帧揭晓常半透明 + 段位条,易误认(例:剑圣揭晓瞬间被认成幽鬼)。
`confirm_polls` 后若仍禁止改判会锁死错误。选人阶段允许
`score ≥ was_score + revise_gain` 的稳定新结果覆盖;决策阶段则关闭改判。
---
## 4. 代码结构
```
climperor/
├── config.json
├── data/ # heroes / relations / items / abilities / fears …
├── common.py / recognize.py / draft_session.py
├── gsi_watch.py / gsi_setup.py / overlay.py
├── recommend.py / relations.py / roles.py / grid.py / modes.py
├── http_utils.py / loc_format.py / hero_tags.py / item_fears.py
├── fetch_cdn_templates.py / fetch_hero_portraits.py / fetch_hero_items.py
├── fetch_item_shop.py / fetch_items_meta.py / fetch_hero_abilities.py
├── fetch_ability_videos.py / fetch_stratz.py / audit_relations.py
├── fetch_patches.py / import_relations_xlsx.py / serve_relations.py
├── autocalibrate.py / calibrate.py / capture.py / evaluate.py
├── web/relations/ # 关系 / 物品 / 版本只读预览页
├── templates/cdn|roles|modes/
├── assets/ # portraits / icons / videos(部分 gitignore
└── samples/labels.json # 评测真值;raw/<matchid>/ 见 .gitignore
```
运行时目录 `preview/``results/``failures/``samples/raw/`(含按对局子目录)不入库。
### 主要配置
| 字段 | 含义 | 默认 |
| 档位 | 宽度 | 页面 |
|------|------|------|
| `match.min_score` / `min_margin` | 识别门控 | 0.45 / 0.04 |
| `match.ranked_mask` | 天梯遮罩比例 | bottom 0.32, right 0.22 |
| `gsi.port` | 监听端口 | 3223 |
| `gsi.confirm_polls` | 连续同结果帧数 | 2 |
| `gsi.revise_gain` | 选人阶段改判所需分数增益 | 0.15 |
| `gsi.strategy_tail_polls` | 决策阶段继续视觉轮询 | 8 |
| `gsi.strategy_gsi_wait` | 视觉结束后等待本人 GSI 英雄 | 3.0 |
| `gsi.require_foreground` | 仅当前台为 `dota2.exe` 时截屏识别 | true |
| `gsi.dump_payloads` | 全量 GSI JSONL 落盘 | true |
| `recommend.enabled` | 选将 Top-3 推荐 | true |
| `recommend.top_n` | 推荐数量 | 3 |
| `recommend.relations_path` | 定性关系文件 | `data/relations.json` |
| `recommend.role_tags` | 分路→角色标签过滤(1–5 号位) | 见 config.json |
| `overlay.enabled` | 选将顶栏角色标签悬浮层 | true |
| `overlay.y_gap_rel` / `icon_h_rel` / `icon_gap_rel` | 标签相对屏幕高的间距与尺寸 | 0.008 / 0.016 / 0.002 |
| `overlay.mark_size_rel` / `mark_pad_rel` | 网格青标方标尺寸与内边距 | 0.018 / 0.004 |
| `overlay.rec_color` / `rec_text_color` | 青标底色 / 名次文字色 | `#2ec4b6` / `#0b1220` |
| full | 100% | 英雄网格 |
| read | 主列 `min(820px)` 页内居中;宽屏「AI 解读」贴主列右侧(不参与居中),宽度随可用侧栏约 280–420px,sticky + 视口内自滚动 | 版本 |
| standard | `min(880px, calc(100% - 48px))` | 排行、主播、机制 |
| wide | `min(1080px, …)` | 走势 |
| data | `min(1200px, …)`(居中时两侧预留侧栏) | 比赛、玩家「我」主页 |
---
间距尺度:4 / 8 / 12 / 16 / 24 / 32 / 48px。页内 gutter 默认 16px;列表页上下 `page-y` 28px。侧栏与主列间距 20px。
## 5. GSI 自动化
断点收敛为三档:
```
Dota 2 (-gamestateintegration)
→ POST → gsi_watch.py :3223
→ samples/raw/{matchid}/gsi.jsonl(可选)
→ DraftSession 轮询 recognize + roles/grid/modes
→ results/draft_*.json
```
- `sm` ≤640px:极窄桌面(列堆叠、双列网格)
- `md` ≤900px:aside 改上下堆叠、英雄两列
- `lg` ≤1200px:物品详情上移、列间距收紧
要点:按 `matchid` 去重;截图进 `samples/raw/{matchid}/`;识别在工作线程;
未标定时降级为只截图。
手机不走上述断点,由 UA 门禁接管。
天梯 AP 选人按 2/2/1 成批揭晓,本轮结束前互不可见——因此必须在
**HERO_SELECTION** 跟踪,不能只在决策时间截一张终局图;决策前期仍读几帧
以吃到最后一人揭晓(见发现七)。
## Elevation & Depth
---
深度靠色调分层与细边框,不用厚重投影:
## 6. 选将 Top-3 推荐
1. 页面渐变底
2. 半透明顶栏 / 面板(`panel-soft`
3. 实心一点的浮层(`panel`、物品 sticky 详情、天赋 popover
- **分路**:定位匹配画面字(`roles.py`+ GSI `team_slot` 定位本人;不做截屏认自己
- **关系模型**:机制克制/搭档(`data/relations.json`),不用胜率/场次——版本会变,机制边相对稳。
- **三视图**:克制(我克谁)/ 被克制(谁克我)/ 搭档;被克制由有向克制边反查。
- **打分**:候选 +1×克到的敌 / −1×被敌克 / +1×己方搭档;再按分路角色标签过滤。
- **展示**:网格左上角青色方标 + 名次 1/2/3(区别于 Plus 右上三角)。
- **预览**`serve_relations.py` 仿选将网格只读查看;数据手改 `data/relations.json` 或从 xlsx 导入。
另有顶级 **物品** 页(基础/升级,网格↔竖式布局),目录见 `data/item_shop.json`
- **预览 Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe` 等)。选 Hash
而非 Path 路由:静态导出(GitHub Pages)无需 rewrite、`serve_relations.py` 零改动、
相对路径子站亦可用。写 URL 用 `history.pushState`/`replaceState`(静默,不触发
`hashchange`,无回环);前进/后退靠 `hashchange` 监听重 parse + 校验 + render。
搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在
`web/relations/router.js``app.js` 只在 `main()``installRouter` + 各 state 变更点
`syncStateToUrl`
- **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `data/heroes.json`
选中态用 inset 底线、填充主色或 1–2px `accent` 描边;阴影仅用于浮层(popover / 气泡),模糊约 2432px、黑色 45% 透明度
---
## Shapes
## 7. 与 dota2-hex
圆角语言偏克制:
独立验证项目。可选:Rust 重写并入,或本地旁路 HTTP 回传。
合规同 `dota2-hex`**屏幕可见信息 only**。
- `sm` 4px:表格内图标、小徽章
- `md` 8px:输入框、多数按钮、指标卡
- `lg` 12px:详情面板、机制卡、主内容卡
- `xl` 16px:仅大卡片(主播卡)需要时使用,勿扩散到所有面板
- `full`:筛选 pill、关注按钮
---
禁止同一视图混用锐角与大圆角而无语义理由。
## 8. 参考
## Components
- [Valve #9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562)
- [Valve #14915](https://github.com/ValveSoftware/Dota2-Gameplay/issues/14915)(开发者 OCR ≈85%
- [Overwolf Dota 2 GEP](https://dev.overwolf.com/ow-native/live-game-data-gep/supported-games/dota-2/)
- Steam CDN`https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png`
### 玩家主页(Identity / Snapshot / Dashboard
登录后「我」与 `/players/{id}` 共用**紧凑数据仪表盘**语言,不做小黑盒式社交 feed:
- **Identity strip**:整行;`panel-soft` + 细边框 + `radius-lg`;头像 `full` 圆形 56px;昵称 + 段位勋章同行。勋章容器约 64px(OpenDota 图标含画布留白,光学尺寸对齐头像),具体星级用 `rank_icon_*` + `rank_star_*` 叠层表达(如传奇四),不并排写「传奇4」文字;冠绝榜位可保留 `#N`
- **Snapshot 摘要区**:生涯与近 N 场并排(窄屏堆叠);指标卡 `radius-md`、数字 `tabular-nums`、紧凑五列网格;空数据整组隐藏,禁止显示伪 0% 胜率。
- **Analysis 次级区**:宽屏双栏——左常用英雄列表,右 180 天活动 + 队友;`panel-soft` 分块,避免每块独占整页高度。
- **近期比赛列表**:左英雄/KDA(弹性占满),右「时长/相对时间」与「胜负/#ID」两列双行紧簇右对齐(上:时长·胜负,下:相对时间·#ID);禁止再插 `1fr` 空隙把时长悬在行中;胜负语义色 + 左侧 3px 细边;整行可点。
- **十人详情**:返回控件放在比赛摘要卡上方(非卡内):`surface-raised` + 细边框 + `radius-md`,高 36px,左侧 18px SVG 左箭头 +「返回玩家主页」;hover 用 `primary` 描边/字色。摘要卡仅比赛 ID + 时间 · 时长 · 胜方。天辉/夜魇共享可见列头;昵称列封顶约 16rem(超长截断 + title),勿用弹性列挤占指标;参战/伤害/经济/KDA 四等分占满中间剩余宽度,装备靠右,跨队对齐。胜负始终用 `good` / `danger`,不得把天辉/夜魇阵营色混作胜负色。
- **活动热力图**:按周分列、星期分行的 7×N 网格,使用绿色四级强度并提供「少—多」图例;标注「最近 180 天样本」。
- 主列宽度用 `data`(1200);外层容器须为 1200 内容宽度预留 padding,不得以更小的父级 `max-width` 截断。900–1200px 保持摘要五列与十人详情横向对比,只在空间确实不足时堆叠。
- **加载**:有生涯/近场数据时首屏直出,勿因 TTL `stale` 再转圈或清空页面;空档案才显示「正在同步」并轮询。软过期只静默再拉 `/me`(生产 Queue / 本机后台 enrich),禁止 `loadKey=null` 闪「加载中…」。Cloudflare Worker 在 OpenDota 空/残缺时不得覆盖已有近场/英雄/队友/段位。
### 导航
- **Main tabs**:无边框文字 Tab,透明背景;相邻项以低对比斜切细线分隔。未选 muted,hover 仅提亮文字,禁止施加矩形 / 渐变底以免与斜切线冲突;选中使用高对比文字 + 居中 44px、2px 高的 `primary` 下划线。宽屏点击区高 44px、字号 16px、最小宽 76px,与搜索 / 邮件对齐;`lg` 断点收敛至 40px / 15px。禁止重新包回连续按钮外框或梯形按钮底。登录后「我」出现在「英雄」前。
- **Detail tabs**:下划线式,字号 `body-md`
- **Aside filters**(地区 / 机制效果):竖向 filled;选中 `primary` 底 + `on-accent` 字。
- **顶栏邮件**40×40 图标按钮,muted;hover 提亮。放在右侧工具区,不绝对悬浮到视口角。
### 英雄定位筛选
位于英雄网格和详情面板之间,使用 Valve 选人筛选符号与中文标签的紧凑斜角按钮。
「全部」用中性网格符号;核心 / 辅助 / 推进 / 逃生 / 爆发 / 先手 / 控制 / 耐久使用对应
Valve 图标。支持多选筛选(`active`);选中英雄时,其定位以同色选中态(`matched`
高亮,不单独在详情区再放 Chip。当前不展示「幻象」筛选,勿与主导航混排。
### 搜索
高 40px,圆角 `md`,左侧放大镜;focus 时边框与光晕用 `primary`。默认宽约 200px,落在首行右侧工具区(非 absolute 贴边);物品页换物品搜索,其他页隐藏搜索。
### 面板 / 卡片
共享 `.ui-panel`:边框 `border`、背景 `panel-soft`、圆角 `lg`、内边距 16px。空态统一 `.ui-empty`:居中 muted、`body-md`、上下 48px。
### 表格
表头 `label-sm` muted;单元格 `body-md`;数字 `tabular-nums`。行 hover 轻微提亮,不加斑马纹。
### 焦点
所有可交互控件:`outline: 2px solid primary; outline-offset: 2px``.ui-focus`)。最小点击高度 3640px。
### 业务标记(不可通用化)
- 克 / 怕 / 搭 → `good` / `bad` / `syn`
- 直播环与关注按钮 → `live`
- 装备金价 → `gold`
## Do's and Don'ts
- Do 改视觉前先更新本文件令牌,再改 `style.css` 变量与引用。
- Do 用命名宽度档位与三档断点;Don't 再加孤立 `max-width: Npx`
- Do 每个顶层页提供清晰的页标题语义(可见 `h1`/`h2` 或等价 `.page-title`)。
- Don't 用 `font-weight: 650` 或非尺度字号(如 `13.5px`)。
- Don't 把直播粉、胜负红、金价黄当作通用 CTA。
- Don't 为真移动端在现有碎片 `@media` 上硬撑;门禁解除前保持桌面壳。
- Do 保持英雄底栏固定高与列表页整板滚动两种壳模型,勿发明第三种。
- Don't 在 data-only 刷新里 bump `SITE_VERSION`;视觉发版才同步 config / export / `?v=` 缓存戳。末位用于增量改动;中段留给壳层 / 路由 / 可索引等阶段性成型(见 `AGENTS.md`)。
+99 -36
View File
@@ -4,7 +4,7 @@ Dota 2 **天梯选将识别**:从选人 / 决策时间截图中识别双方 10
本地、离线、秒级;模板来自 Steam CDN 官方头像。GSI 只负责触发时机,阵容靠视觉读顶栏。
设计背景见 [DESIGN.md](DESIGN.md);协作约定见 [AGENTS.md](AGENTS.md);变更记录见 [CHANGELOG.md](CHANGELOG.md)。
架构与方案背景见 [ARCHITECTURE.md](ARCHITECTURE.md);上分帝 Web 视觉设计规范见 [DESIGN.md](DESIGN.md);协作约定见 [AGENTS.md](AGENTS.md);变更记录见 [CHANGELOG.md](CHANGELOG.md)。
## 原理
@@ -21,56 +21,119 @@ Dota 2 **天梯选将识别**:从选人 / 决策时间截图中识别双方 10
## 安装
```powershell
pip install -r requirements.txt
python fetch_cdn_templates.py # 一次性:data/heroes.json(含基础属性/血蓝)+ templates/cdn/
pip install -r pc/requirements.txt
python pc/fetch_cdn_templates.py # 一次性:shared/data/heroes.json(含基础属性/血蓝)+ pc/templates/cdn/
# 可选:从选将笔记 xlsx 导入定性克制/搭档
# python import_relations_xlsx.py path\to\dota2.xlsx
# python shared/import_relations_xlsx.py path\to\dota2.xlsx
```
## 克制 / 被克制 / 搭档(游戏外)
仓库为 monorepo`pc/`(局内选将识别)、`web/`(上分帝 Web)、`shared/`(共享包)。脚本一律在仓库根执行,如 `python pc/gsi_watch.py``python web/serve_relations.py`
## 上分帝 Web(游戏外)
上分帝 Web 是独立子项目(`web/`):英雄克制/搭档、排行、主播、物品、版本等内容站点,与局内选将识别解耦。
```powershell
python fetch_hero_portraits.py # 官网竖版头像 → assets/hero_portraits/
python fetch_hero_items.py # OpenDota 热度 + Valve 中文名 + Steam 图标 → data/hero_items.json
python fetch_item_shop.py # 官网商店 11 列目录 → data/item_shop.json
python fetch_items_meta.py # 装备描述 + 机制标签 → data/items_meta.json
python fetch_hero_abilities.py # 英雄技能 / 驱散汇总 → data/hero_abilities.json
python fetch_ability_videos.py # 官网技能演示 webm(限速)→ assets/ability_videos/
python item_fears.py # 规则推导「怕的装备」→ data/hero_item_fears.json
python fetch_patches.py # 近一年版本日志 + 详情 → data/patches.json(版本页)
python serve_relations.py # 只读预览 http://127.0.0.1:8765(改 JSON 后刷新)
python web/fetch_hero_portraits.py # 官网竖版头像 → web/assets/hero_portraits/
python web/fetch_hero_items.py # OpenDota 热度 + Valve 中文名 + Steam 图标 → web/data/hero_items.json
python web/fetch_hero_stats.py # OpenDota 各段位胜率/场次 → web/data/hero_stats.jsonWeb 走势兜底)
python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top → web/data/(需 token;对位默认全量刷新,--resume-matchups 仅续跑)
python web/fetch_hero_matches.py # 英雄近期比赛出装/加点(含购买时间)→ web/data/hero_matches.json
# 既有行补时间:python web/fetch_hero_matches.py --enrich-item-times [--heroes juggernaut]
python web/fetch_pro_matches.py # 明星选手近期联赛/锦标赛 → web/data/pro_matches.json(默认 watchlist
# 含天梯:python web/fetch_pro_matches.py --include-pubs --limit 8
# 指定选手(会整文件覆盖):python web/fetch_pro_matches.py --players Ame,Yatoro --limit 12
python web/fetch_item_shop.py # 官网商店 11 列目录 → web/data/item_shop.json
python web/fetch_items_meta.py # 装备描述 + 机制标签 → web/data/items_meta.json
python web/fetch_hero_abilities.py # 英雄技能 / 驱散汇总 → web/data/hero_abilities.json
python web/fetch_ability_videos.py # 官网技能演示 webm(限速)→ web/assets/ability_videos/
python web/fetch_item_counter_stats.py # OpenDota 对阵购买率/胜率相对全局基线 → 可再生成缓存
python web/item_fears.py # 规则推导「怕的装备」→ web/data/hero_item_fears.json
python web/fetch_patches.py # 近一年版本日志 + 详情 → web/data/patches.json(版本页)
python web/fetch_leaderboards.py # Valve Immortal 四区 Top100 → web/data/leaderboards.json(排行页)
python web/fetch_streamers.py # 抖音主播主页补全 → web/data/streamers.json(主播页;手工名单)
python web/fetch_streamer_live.py # 探测真实在播状态 → is_live/live_probed_at(主播页角标;--dry-run 只打印)
python web/serve_relations.py # 本地开发服务 http://127.0.0.1:8765(改 JSON 后刷新)
```
预览页带 **Hash 路由**`#/heroes/axe/core``#/items/black_king_bar``#/patches/7.41``#/patches` = 最新);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages 或 Cloudflare Pages
Web 站点带 **History 路径路由**`/heroes/axe/core``/heroes/axe/trends``/heroes/axe/matchups``/heroes/axe/matches``/trends/legend``/rankings``/matches`(明星比赛,可 `/matches/898754153``/matches?origin=china``/matches?page=2`)、`/items/black_king_bar``/patches/7.41``/patches` = 最新`/rankings` = 中国区);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。`#/...` 书签会自动迁移到路径。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。导出时预渲染英雄/机制等页并生成 `sitemap.xml`SEO);Cloudflare Pages 用 `_redirects` 做 SPA fallback
### 部署到 Cloudflare Pages
```powershell
python deploy_relations.py
python web/deploy_relations.py
# 串联:导出 → 资产完整性预检 → wrangler 直传 → 绑定自定义域名
# 凭据经 keyzoo 注入(refining/cloudflare 的 Global API Key);
# 或手动设 $env:CLOUDFLARE_EMAIL / $env:CLOUDFLARE_API_KEY 后运行
# 默认项目 climperor-relations、域名 dota2.refining.dev(可用 --project-name / --domain 覆盖)
python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态
# 技能演示视频与静态图标默认走阿里云 OSS 桶 climperorPages 只部署 ~2MB HTML/JS/data
python web/_oss_static_assets.py upload # patch/新英雄后同步图标到 OSS
python web/_cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态)
```
仿局内选将四列网格(图标来自 [dota2.com/heroes](https://www.dota2.com/heroes) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**版本变更**(该英雄近一年各版本改动)。
### 定时刷新(Gitea Actions
仓库 [`.gitea/workflows/`](.gitea/workflows/) 在 **self-hosted** runner(与 `site-traffic-notify` 同一 Mac mini)上分层拉数;`web/refresh_web.py` 对 JSON 做忽略纯时间戳的业务摘要,**有业务变更才** `_oss_static_assets.py upload` + `deploy_relations.py``web/refresh_cache.py` 在发布锁内通过 runner 持久目录恢复/保存全部定时数据与补丁状态,Actions cache 另作冷启动备份;daily/weekly 串行,patch 与全量刷新冲突时延后到下一轮。数据-only 刷新**不** bump `SITE_VERSION``data.json` / `index.html` 已不缓存)。
| Workflow | 时间(CST | 档位 | 拉取内容 |
|----------|-------------|------|----------|
| `web-daily` | 每天 06:00 | `daily` | `hero_stats``leaderboards``hero_matches`league)、`pro_matches``streamers`(抖音粉丝等,失败保留旧值);再做 patch check |
| `web-weekly` | 周二 07:00 | `weekly` | `stratz_meta``hero_items``items_meta`、OpenDota 对阵装备证据、`item_fears` |
| `web-patch` | 每 6 小时 | `patch` | `fetch_patches.py --check`;仅 `has_new` 时拉详情 + abilities/商店/items_meta/fears |
**版本检测:** `--check` 只请求 Valve 版本列表,与本地 `web/data/patches.json``details` 比对,stdout 一行 JSON`has_new` / `new_versions` / `latest`)。无新版本则跳过详情与部署。
**可靠性与验收:** 每次刷新都会把 `REFRESH_SUMMARY` 写入
`web/.refresh/summary.json` 并上传为 Actions artifact,记录步骤、耗时、变更、
部署和 stale 状态。飞书日报同时校验 Actions 摘要、生产 `data.json` 新鲜度及
`/api/live-status`;部署后会验证本次 `refresh_run_id`、英雄数据和 Pages
Function。STRATZ / OpenDota 异常空响应不会覆盖上一份有效缓存,OSS 资产未同步时
也不会继续发布 Pages。
**本机试跑:**
```powershell
pip install -r web/requirements.txt
python web/fetch_patches.py --check
python web/refresh_web.py --tier patch --dry-run
python web/refresh_web.py --tier patch --skip-deploy --skip-oss
# 真实拉数+部署(需 CF / OSS env 或 keyzoo):
# python web/refresh_web.py --tier daily
```
**Runner 要求:** `python3`(或 `python`)、`node`/`npx`wrangler)、出网访问 OpenDota / STRATZ / Valve / Cloudflare / 阿里云 OSS。
**Gitea Secrets**(仓库 Settings → Actions → Secrets):
| Secret | 用途 |
|--------|------|
| `CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` | 部署 Pages(与 `site-traffic-notify` 共用;可用 `web/_gitea_actions_secrets.py` 写入) |
| `STRATZ_API_TOKEN` | `web-weekly`(及 daily 内若触发连带时不需要) |
| `OSS_ACCESS_KEY_ID` / `OSS_ACCESS_KEY_SECRET` | 图标变更时上传 OSS |
手工数据(`shared/data/relations.json`、overrides)与技能视频(GB 级)**不**进定时;新英雄仍需本机 `pc/fetch_cdn_templates.py` + `web/fetch_hero_portraits.py`。冒烟:先对 `web-daily`、再对 `web-patch`**Run workflow**;第二次无业务变化时应显示 `deployed=false`,且生产探针无超时项。
仿局内选将四列网格(图标来自 [dota2.com/heroes](https://www.dota2.com/heroes) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**走势**(STRATZ 优先:勋章条 + 最近 1 周三卡 + 分路 + 近 8 周列表;OpenDota 兜底)、**对位**STRATZ 克制/被克/搭档 Top)、**近期比赛**(终局出装/加点)、**主播**(常玩该英雄的收录主播)、**版本变更**(该英雄近一年各版本改动)。
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
顶部另有 **物品** 页:对齐 [官网商店物品](https://www.dota2.com.cn/items/index.htm) 的 **11 列竖排**(基础分类 5 列 + 合成分类 6 列),点选后详情显示在右侧(含合成组件)。目录来自 `data/item_shop.json``python fetch_item_shop.py`)。
顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `data/patches.json``python fetch_patches.py`,近一年窗口可用 `--days` / `--since` 调)
数据在 `data/relations.json`(英雄关系,不用胜率);选将 Top-3 推荐读同一份文件。热门装备来自 OpenDota 统计;被克装备由描述标签 + 规则推导(可改 `data/item_tag_overrides.json` 后重跑 `fetch_items_meta.py``item_fears.py`)。**尚无对局内出装推荐**
顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `web/data/leaderboards.json``python web/fetch_leaderboards.py`)。
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + B 站);列表先按是否在播、再按粉丝数降序;抖音式卡片布局为头像行(头像 \| 昵称 + 抖音号/获赞/粉丝或 B 站 UID/关注/粉丝 \|「关注」)+ 签名独立全宽行(最长 3 行)+ 常用英雄 + 可选精选高光视频(视口分档:远处不拉、近处 metadata、滚入中部 `canplay` 静音自动播放,同时仅 1 路全量缓冲;同名 JPG 封面作占位;有 `live_url` 时点头像进直播间;在播时粉环 +「直播」角标叠在环底、无间距——线上由访客触发的 `/api/live-status` 边缘函数刷新(缓存 5 分钟合并请求),本地 `serve_relations.py` 同步真实探测(内存缓存 60s);角标只信本轮成功探测,`stale`/失败不沿用旧「直播中」;`data.json` 内 daily 探测值仅作接口返回前的首屏兜底);数据来自 `web/data/streamers.json``python web/fetch_streamers.py` 补全;亦由 `web/refresh_web --tier daily` 每日软失败刷新)。视频与封面放 `web/assets/streamer_videos/`gitignore),本地经 `/streamer-video/` 提供,部署前用 `web/_oss_static_assets.py upload` 同步至 OSS `streamer-video/`
顶部另有 **比赛** 页(`/matches[/account_id][?origin=pro|china][&page=N]`,默认全部类型、每页 20 场):侧栏可筛职业/国服与选手;明星选手 watchlist 近期联赛/锦标赛与天梯对局(出装/加点;卡片标职业/天梯/国服);名单见 `web/data/pro_player_watchlist.json`,数据由 `python web/fetch_pro_matches.py --include-pubs` 拉取(亦进 daily
顶部另有 **走势** 页:近 8 周各段位高胜率 / 上场率榜(`/trends[/bracket]`STRATZ 周胜率数据)。
顶部另有 **物品** 页:对齐 [官网商店物品](https://www.dota2.com.cn/items/index.htm) 的 **11 列竖排**(基础分类 5 列 + 合成分类 6 列),点选后详情显示在右侧(含合成组件)。目录来自 `web/data/item_shop.json``python web/fetch_item_shop.py`)。
顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `web/data/patches.json``python web/fetch_patches.py`,近一年窗口可用 `--days` / `--since` 调)。
数据在 `shared/data/relations.json`(英雄关系,不用胜率);选将全网格「克/搭/补」推荐读同一份文件,并用 `pc/draft_archetypes.py` 规则识别推进/全球流与敌我缺口(不接 AI)。热门装备来自 OpenDota 统计。**走势**优先 STRATZ`web/data/stratz_hero_meta.json`,按勋章近 8 周 + 最近 1 周分路;`python web/fetch_stratz_meta.py`,需 token),OpenDota 各段位胜率/上场率/场次在 `web/data/hero_stats.json`(近约 7 天兜底;不进推荐)。**对位**数值 Top 仅用 `web/data/stratz_matchup_tops.json`(STRATZ 全局聚合相对优势,非走势页段位/周口径;与网格定性克/搭分开展示)。被克装备以描述标签 + 规则为主;`web/fetch_item_counter_stats.py` 另算敌方终局装备相对同装备全局基线的购买率提升/胜率差,对全部英雄的已有候选小幅调序;装备卡有数据时显示对阵该英雄的敌方队伍终局装备出现率。统计显著的新组合仍须确认机制成立后写入 `web/data/hero_fear_overrides.json`,避免“高相关但不克制”的误报。可改 `web/data/item_tag_overrides.json` 后重跑 `web/fetch_items_meta.py``web/item_fears.py`。**尚无对局内出装推荐**。
## 快速开始(GSI 自动跟踪)
```powershell
python gsi_setup.py # 写入 Dota GSI 配置
python pc/gsi_setup.py # 写入 Dota GSI 配置
# Steam → Dota 2 → 属性 → 启动项加上 -gamestateintegration,重启游戏
python gsi_watch.py
python pc/gsi_watch.py
```
进入英雄选择后自动跟踪;定位匹配下会按本局分路 + 敌方/己方关系在英雄网格左上角标出 **Top 3** 青色名次方标(需 `data/relations.json`)。凑齐 10 人或超时后写出 `results/draft_<时间戳>.json`(该目录已 gitignore)。
会话截图按对局落在 `samples/raw/<matchid>/`;默认同时把每包 GSI JSON 追加到同目录 `gsi.jsonl``--no-dump-gsi` 可关)。
进入英雄选择后自动跟踪;敌方锁 ≥1 人后按关系与阵容画像在英雄网格标出 **克 / 搭 / 补**,并显示一句阵容分析(需 `shared/data/relations.json`;定位局按分路过滤)。凑齐 10 人或超时后写出 `pc/results/draft_<时间戳>.json`(该目录已 gitignore)。
会话截图按对局落在 `pc/samples/raw/<matchid>/`;默认同时把每包 GSI JSON 追加到同目录 `gsi.jsonl``--no-dump-gsi` 可关)。
> 主菜单收不到 GSI 是正常的:客户端**第一次载入对局后**才开始推送。看到 `[gsi] connected` 才算链路通。
@@ -79,30 +142,30 @@ python gsi_watch.py
### 截图
```powershell
python capture.py # 单张 → samples/raw/
python capture.py --loop 300 3 # 每 3 秒一张,持续 300 秒
python pc/capture.py # 单张 → pc/samples/raw/
python pc/capture.py --loop 300 3 # 每 3 秒一张,持续 300 秒
```
GSI 跟踪时自动写入 `samples/raw/<matchid>/draft_*.png`(含 milestone / best)。
GSI 跟踪时自动写入 `pc/samples/raw/<matchid>/draft_*.png`(含 milestone / best)。
需无边框窗口或窗口模式(独占全屏可能黑帧)。
### 标定 ROI(一次性)
```powershell
python autocalibrate.py samples/raw/<matchid>/<某帧>.png
# 失败时退路:python calibrate.py samples/raw/<matchid>/<某帧>.png
python pc/autocalibrate.py pc/samples/raw/<matchid>/<某帧>.png
# 失败时退路:python pc/calibrate.py pc/samples/raw/<matchid>/<某帧>.png
```
### 识别 / 评测
```powershell
python recognize.py samples/raw/<matchid>/<>.png
python recognize.py samples/raw/<matchid>/<>.png --sheet
python recognize.py samples/raw/<matchid>/<>.png --truth hero1,...,hero10
python evaluate.py # 按 samples/labels.json 批量评测
python pc/recognize.py pc/samples/raw/<matchid>/<>.png
python pc/recognize.py pc/samples/raw/<matchid>/<>.png --sheet
python pc/recognize.py pc/samples/raw/<matchid>/<>.png --truth hero1,...,hero10
python pc/evaluate.py # 按 pc/samples/labels.json 批量评测
```
辅助脚本:`roles.py`(位置字)、`grid.py`(禁用名单)、`modes.py`(模式字)。
辅助脚本:`pc/roles.py`(位置字)、`shared/grid.py`(禁用名单)、`pc/modes.py`(模式字)。
## 截图要求
@@ -114,7 +177,7 @@ python evaluate.py # 按 samples/labels.json 批量评测
- 宽高比差异大(如 21:9)时可能需重标定
- 客户端改网格排序后 `grid.py``ok=False`,不会瞎报禁用名单
- 新英雄上线后重跑 `fetch_cdn_templates.py`;版本更新后重跑 `fetch_patches.py`
- 新英雄上线后重跑 `fetch_cdn_templates.py`(不在定时任务内);版本列表由 Gitea `web-patch` 自动检测,有更新才拉 `fetch_patches.py` 详情
- 位置文字模板取自简体中文客户端
- 决策界面 3D 模型挡住顶栏时单帧会很弱,依赖会话多帧回填
- 普通玩家 GSI 只有自己的英雄 id,不能代替读顶栏
-5608
View File
File diff suppressed because it is too large Load Diff
-42
View File
@@ -1,42 +0,0 @@
{
"meta": {
"note": "Manual add/remove tags merged after auto-tagging in fetch_items_meta.py. Prefer offensive mechanics for fear mapping."
},
"items": {
"diffusal_blade": { "add": ["basic_dispel", "mana_burn"], "remove": ["illusion_clear"] },
"disperser": { "add": ["basic_dispel", "mana_burn"], "remove": ["illusion_clear"] },
"nullifier": { "add": ["basic_dispel"], "remove": [] },
"cyclone": { "add": ["basic_dispel"], "remove": [] },
"wind_waker": { "add": ["basic_dispel"], "remove": [] },
"satanic": { "add": [], "remove": ["basic_dispel"] },
"lotus_orb": { "add": ["spell_reflect"], "remove": ["basic_dispel", "mana_burn"] },
"guardian_greaves": { "add": [], "remove": ["basic_dispel"] },
"manta": { "add": [], "remove": ["basic_dispel", "illusion_clear"] },
"black_king_bar": { "add": ["magic_immune"], "remove": ["basic_dispel", "strong_dispel"] },
"aeon_disk": { "add": [], "remove": ["strong_dispel", "basic_dispel"] },
"sphere": { "add": ["spell_block"], "remove": [] },
"monkey_king_bar": { "add": ["true_strike"], "remove": [] },
"silver_edge": { "add": ["break", "invis_break"], "remove": [] },
"spirit_vessel": { "add": ["heal_reduce"], "remove": [] },
"bfury": { "add": ["illusion_clear"], "remove": [] },
"radiance": { "add": ["illusion_clear"], "remove": [] },
"maelstrom": { "add": ["illusion_clear"], "remove": ["true_strike"] },
"mjollnir": { "add": ["illusion_clear"], "remove": ["true_strike"] },
"gungir": { "add": ["root", "illusion_clear"], "remove": [] },
"gem": { "add": ["invis_detect"], "remove": [] },
"dust": { "add": ["invis_detect"], "remove": [] },
"desolator": { "add": ["armor_reduce"], "remove": [] },
"solar_crest": { "add": ["armor_reduce"], "remove": [] },
"orchid": { "add": ["silence"], "remove": ["mana_burn"] },
"bloodthorn": { "add": ["silence", "true_strike"], "remove": [] },
"sheepstick": { "add": ["hex"], "remove": ["illusion_clear", "silence"] },
"rod_of_atos": { "add": ["root"], "remove": [] },
"mask_of_madness": { "add": [], "remove": ["silence"] },
"necronomicon": { "add": ["invis_detect"], "remove": ["basic_dispel", "mana_burn", "break"] },
"necronomicon_2": { "add": ["invis_detect"], "remove": ["basic_dispel", "mana_burn", "break"] },
"necronomicon_3": { "add": ["invis_detect"], "remove": ["basic_dispel", "mana_burn", "break"] },
"witch_blade": { "add": [], "remove": ["true_strike"] },
"devastator": { "add": ["armor_reduce"], "remove": ["true_strike"] },
"essence_distiller": { "add": [], "remove": ["invis_detect"] }
}
}
+165
View File
@@ -0,0 +1,165 @@
# 上分帝(Climperor)商业化前景
基于当前产品形态(局内选将识别 + 关系/物品/版本知识站)与 Dota 2 市场结构的判断。
- 分析日期:2026-07-28
- 主战场假设:**中国大陆简中天梯玩家**(Overwolf 需科学上网,大陆侧基本不可用)
---
## 总判断
**大陆市场窗口更清晰——可赚钱的利基工具,仍非融资级平台。**
痛点真实、合规路径清晰。Overwolf 系助手在中国大陆需科学上网,实际不可用,局内免费广告竞品几乎空窗。主战场应锁定简中天梯用户;天花板仍受 Dota 付费层规模限制,适合「社区/内容 + Freemium」小而稳生意。
| 维度 | 评级 |
|------|------|
| 规模化潜力 | 中低 |
| 大陆利基变现 | 偏高 |
| 合规护城河 | 高 |
| 本地化窗口 | 中高 |
---
## 一、市场与需求
### TAM 量级(以大陆为主)
全球 Steam 并发约 42–60 万(2026 上半年均值波动,来源:Steam Charts / Steambase)。上分帝的有效市场是简中客户端天梯玩家。认真打定位局、愿为选将决策付费的人仍窄,但大陆侧几乎没有可用的 Overwolf 级局内助手,付费转化阻力低于海外。
### 真实痛点
Valve 已关闭普通玩家视角的实时 draft GSI。大陆玩家的替代路径主要是网页查表、直播间口播、自制笔记——选将阶段几秒内很难用。识别 + Overlay 填的是「局内实时」空缺,不是和 Overwolf 抢同一批用户。
### 渠道前提
产品已天然适配大陆:简体位置字模板、中文 aliases/tags、定性理由文案、国内可访问的知识站(Cloudflare Pages + 阿里云 OSS)。分发与内容都不必依赖科学上网。**不要走 Overwolf 商店分发。**
### 产品资产
| 资产层 | 现状 | 商业含义 |
|--------|------|----------|
| 局内识别 + Overlay | GSI 触发 + CDN 模板匹配 + Top-3 青标 | 差异化入口;安装成本高,转化漏斗长 |
| 定性关系数据 | `relations.json` 克制/搭档边 + 理由 | 可成为内容 IP;维护成本高,胜率库难替代 |
| 知识站 | 英雄/物品/版本,已部署 `dota2.refining.dev` | 获客与品牌面;可走广告/会员,不必装客户端 |
| 合规边界 | 仅 GSI + 截屏,禁读内存 | 降低封号恐惧,利于信任与渠道合作 |
---
## 二、竞争格局(大陆视角)
| 玩家 | 模式 | 大陆可用性 | 对上分帝的压力 |
|------|------|------------|----------------|
| Overwolf DotaPlus | 免费 + 广告;选禁建议 | 需科学上网,基本不可用 | 海外强竞品;大陆侧可忽略 |
| Valve Dota Plus | ~$3.99/月官方订阅 | 可用(Steam) | 官方光环;偏生涯/助手,无双方阵容识别 |
| Dotabuff / Stratz | 网页 + Plus 订阅 | 访问不稳 / 体验差 | 赛后数据强;选将实时几乎无威胁 |
| 中文 Wiki / 攻略站 / B 站 | 流量 + 广告 / 内容 | 完全可用 | 知识站主竞品;局内 overlay 仍空缺 |
| 本地脚本 / 群文件工具 | 免费、分散、常违规 | 可用但信任差 | 识别可被复刻;合规+体验可拉开差距 |
**相对优势:** 大陆「局内实时选将助手」几乎空白。① 合规视觉方案填补 GSI draft 空洞;② 中文定性克制(非胜率表);③ 识别→推荐→知识站闭环,且全链路不依赖科学上网。真正要赢的是中文内容站与信任感,不是打赢 Overwolf。
### 附录:大陆中文 Wiki / 攻略渠道
玩家实际查资料时,并不是单一站点垄断,而是「MAX+ 查数 + NGA/B 站看说法 + 官网百科对技能」。
#### 百科 / 官方资料
| 站点 | 地址 | 定位 |
|------|------|------|
| 刀塔百科 | https://wiki.dota2.com.cn/ | 国服官方向中文 Wiki(英雄/机制资料) |
| 完美世界官网 | https://www.dota2.com.cn/ | 英雄页、物品页、版本公告、活动 |
| Liquipedia | https://liquipedia.net/dota2/ | 赛事/机制权威 Wiki;英文为主,大陆可访问但不算中文站 |
#### 数据 + 攻略一体
| 站点 | 地址 | 定位 |
|------|------|------|
| MAX+ | https://maxjia.com/ | 战绩 + 英雄胜率/出装 + 社区攻略,移动端心智最强 |
#### 社区 UGC
| 站点 | 地址 | 定位 |
|------|------|------|
| NGA 刀塔区 | https://bbs.nga.cn/thread.php?fid=321 | 长文攻略、版本讨论、精华帖;深度最高 |
| 百度 Dota2 吧 | 贴吧 | 碎片讨论、整活、初级问答,质量参差 |
| B 站 | 搜索「DOTA2 版本 / 上分」 | 事实上最大的攻略形态(视频) |
#### 传统门户(影响力较弱)
- [17173 DOTA2 专区](https://dota2.17173.com/) — 旧式图文攻略,更新慢、常过期
- 游民星空 / 多玩等也曾有专区,现很少当主信息源
**与上分帝的关系:** 知识站主要抢注意力的是 MAX+ 英雄页与 NGA/B 站版本内容;局内实时 Overlay 上述站点都覆盖不到。
---
## 三、商业模式对照
### A. 知识站 Freemium(优先)
- 免费:浏览关系/物品/版本
- 付费:完整理由库、分路定制、导出、无广告、更新优先
- 获客成本低(SEO / Hash 深度链接),与安装客户端解耦
### B. 桌面端一次性 / 年费(优先)
- 识别 + Overlay 基础免费或低价
- 高级:实时推荐、会话复盘、分路过滤强度
- 定价锚:¥68128/季 或 ¥168298/年(对标 Dotabuff / 官方 Plus 心理账户)
### C. 国内渠道获客 + 轻广告(大陆适配)
- 小红书 / B 站 / 抖音 / QQ 群 / 贴吧口碑
- 知识站可挂非侵入广告或赞赏
- 安装包自托管(GitHub Release 或国内网盘/OSS),支付用微信/支付宝
- **不要走 Overwolf 商店**
### D. B2B / 内容合作(远期)
- 教练团、主播选人面板、俱乐部内部工具;版本更新内容授权
- 单客价值高、销售周期长;需产品包装与支持能力
### 收入情景(示意,非承诺)
情景已按「Overwolf 不构成大陆竞争」略上调基准;仍是量级框架,非财务预测。乐观情景仍属个人/小团队生意规模。
| 情景 | 付费用户 | ARPU/年 | 年收入粗估 | 前提 |
|------|----------|---------|------------|------|
| 保守 | 300800 | ¥120200 | ¥5–12 万 | 知识站会员为主,桌面口碑未起量 |
| 基准 | 1.5k4k | ¥150250 | ¥25–80 万 | 大陆局内助手空窗被认知 + 稳定更新 |
| 乐观 | 5k12k | ¥200300 | ¥120300 万 | 头部主播带安装或社区爆款 |
---
## 四、风险与门槛
| 类别 | 说明 |
|------|------|
| 政策 / 合规 | 截屏 overlay 通常比读内存安全,但仍可能被社区视为「选将外挂」。需持续公开合规说明,避免自动化点击/注入。 |
| 产品摩擦 | GSI 配置、无边框、标定、宽高比、皮肤帧——安装到「第一局好用」的路径长,付费转化杀手。 |
| 内容运营 | 定性边与版本同步是人力活;停更即失信。胜率自动源便宜但与产品定位冲突,不能偷懒换库。 |
其他约束:Windows 单平台;TAM 仍窄;大陆无 Overwolf 对手;中文攻略站抢知识流量;识别偶发失败影响信任;安装/支付需自建国内链路。
---
## 五、建议路径
### Now
大陆优先:知识站获客 + 明确「免科学上网」卖点。对外文案直接对比「国外助手要翻墙」。强化版本速览与可分享关系页;桌面端先免费验证识别稳定性,再收费。
### Next
一键安装包(国内下载)+ 微信/支付宝年费。降低 GSI/标定摩擦;收费绑「定位局实时 Top-3 + 会话复盘」。分发走自有安装包/OSS,支付走国内通道——不要依赖 Overwolf 或海外订阅基建。
### Later
主播/社群带量与轻 B2B,避免烧钱扩品类。版本专栏、教练工具包、主播选人面板。护城河在定性数据质量与「大陆能用的局内助手」心智,不在功能堆叠或出海抢 Overwolf。
---
## 一句话结论
在中国大陆,上分帝面对的是「局内实时选将助手近乎空白」的窗口,商业土壤比全球视角更乐观;但仍是利基生意,不是融资级平台。用免翻墙的识别 Overlay 做钩子,用定性关系内容做留存与收费,是最匹配当前资产的路径。
-133
View File
@@ -1,133 +0,0 @@
"""Export the relations preview as a static site (no server needed).
Usage:
python export_relations_site.py [--out dist/relations] [--with-videos]
Copies web/relations/ + a snapshot of the /api/data payload (data.json) +
the referenced image assets into one directory, ready for any static host
(GitHub Pages, Cloudflare Pages, nginx, ...).
Notes:
- Only already-cached assets are exported. For full ability-icon coverage
run `python fetch_hero_abilities.py --icons-only` first.
- For patch-notes names/icons on the 版本 page, run `python fetch_patches.py`
first (downloads referenced item + ability icons into assets/).
- Ability videos (several GB locally) are skipped unless --with-videos is
given; the UI degrades quietly when a video is missing.
"""
from __future__ import annotations
import argparse
import json
import shutil
from pathlib import Path
from common import (
ABILITY_ICONS,
ABILITY_VIDEOS,
ATTR_ICONS,
HERO_PORTRAITS,
ITEM_CAT_ICONS,
ITEM_ICONS,
ROOT,
TEMPLATES_CDN,
UI_ICONS,
)
from serve_relations import WEB_DIR, build_payload
def copy_glob(src: Path, dst: Path, pattern: str = "*.png") -> int:
if not src.is_dir():
return 0
dst.mkdir(parents=True, exist_ok=True)
n = 0
for f in sorted(src.glob(pattern)):
if f.is_file():
shutil.copy2(f, dst / f.name)
n += 1
return n
def main() -> None:
ap = argparse.ArgumentParser(
description="Export relations preview as a static site"
)
ap.add_argument("--out", default=str(ROOT / "dist" / "relations"))
ap.add_argument(
"--with-videos",
action="store_true",
help="also copy assets/ability_videos (several GB)",
)
args = ap.parse_args()
out = Path(args.out).resolve()
if out == ROOT.resolve() or out.parent == out:
raise SystemExit(f"refusing unsafe --out: {out}")
if out.exists():
shutil.rmtree(out)
out.mkdir(parents=True)
# Frontend (uses relative paths — works from any sub-path).
for name in ("index.html", "router.js", "app.js", "style.css"):
shutil.copy2(WEB_DIR / name, out / name)
# Data snapshot (same payload as serve_relations /api/data).
payload = build_payload()
(out / "data.json").write_text(
json.dumps(payload, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8",
)
# Assets.
counts = {
"attr": copy_glob(ATTR_ICONS, out / "attr"),
"item": copy_glob(ITEM_ICONS, out / "item"),
"item-cat": copy_glob(ITEM_CAT_ICONS, out / "item-cat", "itemcat_*.png"),
"ability": copy_glob(ABILITY_ICONS, out / "ability"),
"ui-icon": copy_glob(UI_ICONS, out / "ui-icon"),
}
# Portraits: official wide cards, fall back to CDN match templates.
portrait_dst = out / "portrait"
portrait_dst.mkdir(exist_ok=True)
n_portrait = 0
for hero in payload.get("heroes") or []:
key = hero.get("key")
if not key:
continue
src = HERO_PORTRAITS / f"{key}.png"
if not src.is_file():
src = TEMPLATES_CDN / f"{key}.png"
if src.is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
# Non-hero units in patch lookup (e.g. 熊灵) use bundled portraits too.
for cell in ((payload.get("patch_lookup") or {}).get("heroes") or {}).values():
key = cell.get("key") if isinstance(cell, dict) else None
if not key:
continue
src = HERO_PORTRAITS / f"{key}.png"
if src.is_file() and not (portrait_dst / f"{key}.png").is_file():
shutil.copy2(src, portrait_dst / f"{key}.png")
n_portrait += 1
counts["portrait"] = n_portrait
n_videos = 0
if args.with_videos and ABILITY_VIDEOS.is_dir():
shutil.copytree(ABILITY_VIDEOS, out / "ability-video")
n_videos = sum(1 for f in (out / "ability-video").rglob("*") if f.is_file())
total = sum(f.stat().st_size for f in out.rglob("*") if f.is_file())
print(f"exported static site -> {out}")
for name, n in counts.items():
print(f" {name}/: {n} files")
if args.with_videos:
print(f" ability-video/: {n_videos} files")
print(f" total: {total / 1e6:.1f} MB")
print(f"local check: python -m http.server -d {out} 8080")
print("deploy: upload the directory to any static host (Pages / nginx / ...)")
if __name__ == "__main__":
main()
-82
View File
@@ -1,82 +0,0 @@
"""Fetch OpenDota hero matchups into data/matchups.json (for audit_relations).
Usage:
python fetch_matchups.py
python fetch_matchups.py --delay 1.0
"""
from __future__ import annotations
import argparse
import json
import time
import urllib.error
from datetime import datetime, timezone
from pathlib import Path
from common import DATA
from grid import hero_table
from http_utils import http_json
API = "https://api.opendota.com/api/heroes/{hero_id}/matchups"
OUT = DATA / "matchups.json"
def fetch_one(hero_id: int, timeout: float = 30.0) -> list[dict]:
return http_json(API.format(hero_id=hero_id), timeout=int(timeout))
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--delay", type=float, default=1.0, help="seconds between requests")
ap.add_argument("--out", type=Path, default=OUT)
args = ap.parse_args()
table = hero_table()
ids = sorted({int(h["id"]) for h in table})
by_hero: dict[str, dict] = {}
if args.out.is_file():
try:
prev = json.loads(args.out.read_text(encoding="utf-8"))
by_hero = dict(prev.get("by_hero") or {})
print(f"resuming with {len(by_hero)} heroes already cached", flush=True)
except (OSError, json.JSONDecodeError):
pass
pending = [i for i in ids if str(i) not in by_hero]
print(f"fetching {len(pending)} / {len(ids)} heroes -> {args.out}", flush=True)
for n, hid in enumerate(pending, start=1):
try:
rows = fetch_one(hid)
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as e:
print(f" [{n}/{len(pending)}] hero {hid} failed: {e}", flush=True)
time.sleep(args.delay * 2)
continue
cell: dict[str, dict] = {}
for r in rows:
opp = r.get("hero_id")
games = int(r.get("games_played") or 0)
wins = int(r.get("wins") or 0)
if opp is None or games <= 0:
continue
cell[str(int(opp))] = {"games": games, "wins": wins}
by_hero[str(hid)] = cell
print(f" [{n}/{len(pending)}] hero {hid}: {len(cell)} matchups", flush=True)
args.out.parent.mkdir(parents=True, exist_ok=True)
payload = {
"fetched_at": datetime.now(timezone.utc).isoformat(),
"source": "opendota",
"attribution": "https://opendota.com",
"by_hero": by_hero,
}
args.out.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
time.sleep(args.delay)
print(f"done: {len(by_hero)} heroes in {args.out}", flush=True)
if __name__ == "__main__":
main()
-296
View File
@@ -1,296 +0,0 @@
"""Transparent click-through overlay: role tags + Top-3 recommend badges.
Runs a Tk root on a background thread. DraftSession calls set_roster() and
set_grid_marks(); geometry uses the same relative coords as recognition.
"""
from __future__ import annotations
import json
import sys
import threading
import tkinter as tk
from pathlib import Path
import mss
from common import HEROES_JSON as HEROES_PATH, ROOT, slot_rect_px
ROLE_ICONS_DIR = ROOT / "assets" / "role_icons"
ROLE_ORDER = [
"Carry",
"Support",
"Nuker",
"Disabler",
"Durable",
"Escape",
"Initiator",
"Pusher",
]
CHROMA = "#ff00ff"
DEFAULT_REC_COLOR = "#2ec4b6"
DEFAULT_REC_TEXT = "#0b1220"
def _load_roles_by_key() -> dict[str, list[str]]:
if not HEROES_PATH.exists():
return {}
table = json.loads(HEROES_PATH.read_text(encoding="utf-8"))
return {h["key"]: list(h.get("roles") or []) for h in table}
def _primary_monitor_size() -> tuple[int, int]:
with mss.MSS() as sct:
mon = sct.monitors[1]
return int(mon["width"]), int(mon["height"])
def _enable_click_through(hwnd: int) -> None:
"""Make the window ignore mouse input (Windows)."""
if sys.platform != "win32":
return
import ctypes
user32 = ctypes.windll.user32
GWL_EXSTYLE = -20
WS_EX_LAYERED = 0x00080000
WS_EX_TRANSPARENT = 0x00000020
WS_EX_TOOLWINDOW = 0x00000080
get_long = user32.GetWindowLongW
set_long = user32.SetWindowLongW
style = get_long(hwnd, GWL_EXSTYLE)
set_long(hwnd, GWL_EXSTYLE, style | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW)
class DraftOverlay:
"""Fullscreen transparent overlay drawn above Dota during draft tracking."""
def __init__(self, cfg: dict):
self.cfg = cfg
o = cfg.get("overlay") or {}
self.y_gap_rel = float(o.get("y_gap_rel", 0.008))
self.icon_h_rel = float(o.get("icon_h_rel", 0.016))
self.icon_gap_rel = float(o.get("icon_gap_rel", 0.002))
self.mark_size_rel = float(o.get("mark_size_rel", 0.018))
self.mark_pad_rel = float(o.get("mark_pad_rel", 0.004))
self.rec_color = str(o.get("rec_color", DEFAULT_REC_COLOR))
self.rec_text = str(o.get("rec_text_color", DEFAULT_REC_TEXT))
self.roles_by_key = _load_roles_by_key()
self._roster: dict[int, str] = {}
self._cells: dict[str, dict] = {}
self._marks: dict[str, int] = {}
self._ready = threading.Event()
self._closed = False
self._root: tk.Tk | None = None
self._canvas: tk.Canvas | None = None
self._photos: list[tk.PhotoImage] = []
self._icon_src: dict[str, tk.PhotoImage] = {}
self._thread = threading.Thread(target=self._run, name="draft-overlay", daemon=True)
self._thread.start()
self._ready.wait(timeout=5.0)
def _run(self) -> None:
sw, sh = _primary_monitor_size()
root = tk.Tk()
self._root = root
root.overrideredirect(True)
root.attributes("-topmost", True)
root.geometry(f"{sw}x{sh}+0+0")
root.configure(bg=CHROMA)
try:
root.attributes("-transparentcolor", CHROMA)
except tk.TclError:
pass
canvas = tk.Canvas(root, width=sw, height=sh, bg=CHROMA, highlightthickness=0, bd=0)
canvas.pack(fill="both", expand=True)
self._canvas = canvas
self._screen = (sw, sh)
self._load_icon_sources()
root.update_idletasks()
try:
hwnd = int(root.wm_frame(), 16) if root.wm_frame().startswith("0x") else int(root.winfo_id())
if sys.platform == "win32":
import ctypes
hwnd = ctypes.windll.user32.GetParent(root.winfo_id()) or root.winfo_id()
_enable_click_through(int(hwnd))
except Exception:
pass
root.withdraw()
self._ready.set()
root.mainloop()
try:
root.destroy()
except tk.TclError:
pass
self._closed = True
def _load_icon_sources(self) -> None:
assert self._root is not None
for name in ROLE_ORDER:
path = ROLE_ICONS_DIR / f"{name}.png"
if not path.exists():
continue
try:
self._icon_src[name] = tk.PhotoImage(master=self._root, file=str(path))
except tk.TclError:
continue
def _scaled_icon(self, name: str, target_h: int) -> tk.PhotoImage | None:
src = self._icon_src.get(name)
if src is None or target_h <= 0:
return None
h = max(src.height(), 1)
if target_h >= h:
factor = max(1, round(target_h / h))
img = src.zoom(factor, factor)
else:
factor = max(1, round(h / target_h))
img = src.subsample(factor, factor)
self._photos.append(img)
return img
def set_roster(self, confirmed: dict[int, str]) -> None:
"""Update tags for confirmed slots (hero_key by slot index)."""
roster = {int(k): v for k, v in confirmed.items() if v}
if roster == self._roster:
return
self._roster = dict(roster)
self._schedule_redraw()
def set_grid_marks(
self,
cells: dict[str, dict] | None,
marks: dict[str, int] | list[dict] | None,
) -> None:
"""Cyan Top-3 badges on hero-grid cells. marks: key->rank or suggest_top list."""
cells = {str(k): dict(v) for k, v in (cells or {}).items()}
parsed: dict[str, int] = {}
if isinstance(marks, list):
for item in marks:
key = item.get("key")
rank = item.get("rank")
if key and rank:
parsed[str(key)] = int(rank)
elif marks:
for key, rank in marks.items():
parsed[str(key)] = int(rank)
if cells == self._cells and parsed == self._marks:
return
self._cells = cells
self._marks = parsed
self._schedule_redraw()
def _schedule_redraw(self) -> None:
root = self._root
if root is None or self._closed:
return
try:
root.after(0, self._redraw)
except RuntimeError:
pass
def show(self) -> None:
root = self._root
if root is None or self._closed:
return
try:
root.after(0, root.deiconify)
except RuntimeError:
pass
def hide(self) -> None:
root = self._root
if root is None or self._closed:
return
def _hide() -> None:
if self._canvas is not None:
self._canvas.delete("all")
self._photos.clear()
root.withdraw()
try:
root.after(0, _hide)
except RuntimeError:
pass
def close(self) -> None:
root = self._root
if root is None or self._closed:
return
done = threading.Event()
def _shutdown() -> None:
try:
if self._canvas is not None:
self._canvas.delete("all")
self._photos.clear()
self._icon_src.clear()
root.quit()
finally:
done.set()
try:
root.after(0, _shutdown)
except RuntimeError:
done.set()
done.wait(timeout=2.0)
self._thread.join(timeout=2.0)
self._closed = True
def _redraw(self) -> None:
canvas = self._canvas
if canvas is None:
return
canvas.delete("all")
self._photos.clear()
sw, sh = self._screen
icon_h = max(8, int(round(self.icon_h_rel * sh)))
gap = max(0, int(round(self.icon_gap_rel * sh)))
y_gap = int(round(self.y_gap_rel * sh))
for slot in self.cfg.get("slots") or []:
idx = int(slot["index"])
hero = self._roster.get(idx)
if not hero:
continue
roles = [r for r in ROLE_ORDER if r in set(self.roles_by_key.get(hero, []))]
if not roles:
continue
x, y, w, h = slot_rect_px(slot, self.cfg, sw, sh)
icons = [img for r in roles if (img := self._scaled_icon(r, icon_h)) is not None]
if not icons:
continue
total_w = sum(img.width() for img in icons) + gap * (len(icons) - 1)
cx = x + w / 2
left = int(round(cx - total_w / 2))
top = y + h + y_gap
cursor = left
for img in icons:
canvas.create_image(cursor, top, image=img, anchor="nw")
cursor += img.width() + gap
mark = max(12, int(round(self.mark_size_rel * sh)))
pad = max(2, int(round(self.mark_pad_rel * sh)))
font_size = max(8, int(round(mark * 0.55)))
for key, rank in self._marks.items():
cell = self._cells.get(key)
if not cell:
continue
x0 = int(cell["x0"])
y0 = int(cell["y0"])
x1 = x0 + pad
y1 = y0 + pad
x2 = x1 + mark
y2 = y1 + mark
canvas.create_rectangle(x1, y1, x2, y2, fill=self.rec_color, outline=self.rec_color)
canvas.create_text(
(x1 + x2) / 2,
(y1 + y2) / 2,
text=str(rank),
fill=self.rec_text,
font=("Segoe UI", font_size, "bold"),
)

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Before

Width:  |  Height:  |  Size: 5.8 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

+4 -1
View File
@@ -12,9 +12,12 @@ Writes slot geometry into config.json and preview/autocalibrate_check.png.
"""
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import time
import cv2
import numpy as np
+4
View File
@@ -11,6 +11,10 @@ and stored as resolution-independent relative coordinates.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import time
import cv2
+5 -2
View File
@@ -10,11 +10,14 @@ Notes:
- Frames are saved as PNG at native resolution, named cap_HHMMSS.png.
"""
import json
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
import time
import cv2
import mss
import numpy as np
+16 -20
View File
@@ -1,30 +1,26 @@
"""Shared helpers: config IO, slot geometry, crop preprocessing."""
"""PC-side helpers: config IO, slot geometry, crop preprocessing, NCC matching.
ROOT is the pc/ directory: every runtime path built from it (samples/,
preview/, results/, failures/, templates/, assets/role_icons/) stays inside
the PC subproject. Shared locations (heroes.json, CDN templates) are
re-exported from shared.paths so existing ``from common import X`` call
sites keep working.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
from pathlib import Path
import cv2
import numpy as np
ROOT = Path(__file__).parent
from shared.paths import HEROES_JSON, TEMPLATES_CDN # noqa: F401 (re-export)
ROOT = Path(__file__).resolve().parent
CONFIG_PATH = ROOT / "config.json"
DATA = ROOT / "data"
HEROES_JSON = DATA / "heroes.json"
TEMPLATES_CDN = ROOT / "templates" / "cdn"
# Landscape cards from dota2.com/heroes (Steam CDN heroes/{key}.png); UI only.
HERO_PORTRAITS = ROOT / "assets" / "hero_portraits"
# Primary-attribute icons from dota2.com (dota_react/icons/hero_*.png); UI only.
ATTR_ICONS = ROOT / "assets" / "attr_icons"
# Item icons from Steam CDN (dota_react/items/{key}.png); relations preview only.
ITEM_ICONS = ROOT / "assets" / "item_icons"
# Shop category header icons from dota2.com.cn/items/images/itemcat_*.png.
ITEM_CAT_ICONS = ROOT / "assets" / "item_cat_icons"
# Ability icons from Steam CDN (dota_react/abilities/{key}.png); relations preview only.
ABILITY_ICONS = ROOT / "assets" / "ability_icons"
# Generic UI glyphs from dota2.com.cn (herostatic/icons/*.png); relations preview only.
UI_ICONS = ROOT / "assets" / "ui_icons"
# Official ability demo clips from dota2.com (dota_react/abilities/{hero}/{ability}.webm).
ABILITY_VIDEOS = ROOT / "assets" / "ability_videos"
def load_config() -> dict:
+46 -10
View File
@@ -102,16 +102,28 @@
"min_iou": 0.55
},
"recommend": {
"comment": "Top-3 from qualitative relations.json: +counter / -countered / +synergy.",
"comment": "Full-grid 克/搭/补 from relations + draft_archetypes (push/global/gaps). After self-lock: core+answer items. top_n<=0 = no cap. No AI.",
"enabled": true,
"top_n": 3,
"relations_path": "data/relations.json",
"top_n": 0,
"min_enemies": 1,
"min_heroes_for_gaps": 2,
"archetypes": true,
"relations_path": "shared/data/relations.json",
"role_tags": {
"1": ["Carry"],
"2": ["Carry", "Nuker", "Escape"],
"3": ["Initiator", "Durable", "Carry"],
"4": ["Support"],
"5": ["Support"]
},
"items": {
"comment": "After self hero locked: core from hero_items + qualitative answers vs enemies. No fears/STRATZ stats.",
"enabled": true,
"hero_items_path": "web/data/hero_items.json",
"items_meta_path": "web/data/items_meta.json",
"core_n": 3,
"answer_n": 3,
"max_total": 6
}
},
"gsi": {
@@ -134,16 +146,40 @@
"target_slots": 10,
"dump_payloads": true
},
"overlay": {
"comment": "Role tags under top-bar + cyan Top-3 recommend marks on the hero grid.",
"player_pages": {
"comment": "POST_GAME: poll OpenDota → local pc/player_pages/{account_id}/. public_share=true POSTs /api/players/publish (queue→D1/R2). recent_limit=recent N matches; recent_days=GSI discovery window; enrich_ttl_seconds=web cache TTL (default 600, same as Pages). Default private.",
"enabled": true,
"public_share": false,
"recent_limit": 20,
"recent_days": 14,
"enrich_ttl_seconds": 600,
"poll_attempts": 12,
"poll_base_seconds": 30,
"publish_url": "https://dota2.refining.dev/api/players/publish",
"publish_secret": "",
"local_web_origin": "http://127.0.0.1:8765"
},
"overlay": {
"comment": "克/搭/补 marks + lineup analysis banner + post-lock item icon bar (no role tags under top-bar).",
"enabled": true,
"y_gap_rel": 0.008,
"icon_h_rel": 0.016,
"icon_gap_rel": 0.002,
"mark_size_rel": 0.018,
"mark_pad_rel": 0.004,
"rec_color": "#2ec4b6",
"rec_text_color": "#0b1220"
"mark_gap_rel": 0.002,
"counter_color": "#2ec4b6",
"synergy_color": "#e9a825",
"fill_color": "#9b7ebd",
"mark_text_color": "#0b1220",
"analysis_y_rel": 0.12,
"analysis_h_rel": 0.028,
"analysis_font_rel": 0.014,
"analysis_bg": "#1a2332",
"analysis_fg": "#e8eef7",
"items_y_rel": 0.16,
"item_icon_h_rel": 0.036,
"item_gap_rel": 0.01,
"item_core_bg": "#1a2332",
"item_answer_bg": "#2a1f14",
"item_reason_fg": "#e8eef7"
},
"calibrated_from": "draft_141704.png"
}
+265
View File
@@ -0,0 +1,265 @@
"""Rule-based draft lineup archetypes and gap analysis (no AI).
Detects push / global enemy shapes, enemy & ally tag gaps, answer heroes,
and short Chinese analysis / reason strings for recommend marks.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from collections import Counter
from typing import Iterable
from shared.hero_tags import TAG_ORDER
# Strong push cores: one hit can flag push even before 2+ Pusher tags.
PUSH_CORE = frozenset({
"lycan",
"furion",
"broodmother",
"chen",
"enchantress",
"visage",
"beastmaster",
"naga_siren",
"lone_druid",
"undying",
})
GLOBAL_SET = frozenset({
"furion",
"spectre",
"wisp",
"abyssal_underlord",
"zuus",
"ancient_apparition",
"spirit_breaker",
"storm_spirit",
"rattletrap",
})
HARD_GLOBAL = frozenset({
"furion",
"spectre",
"wisp",
})
# Archetype -> answer hero keys (marked 克 with reason 对推进 / 对全球流).
ARCHETYPE_ANSWERS: dict[str, tuple[str, ...]] = {
"push": (
"medusa",
"terrorblade",
"naga_siren",
"jakiro",
"gyrocopter",
"dragon_knight",
"shredder",
),
"global": (
"storm_spirit",
"anti_mage",
"riki",
"bounty_hunter",
"queenofpain",
"ember_spirit",
),
}
ARCHETYPE_REASON = {
"push": "对推进",
"global": "对全球流",
}
ARCHETYPE_LABEL = {
"push": "偏推进",
"global": "全球流",
}
# Gaps we report (user-facing). 输出 is proxied by 核心.
GAP_TAGS = ("控制", "爆发", "核心", "先手")
GAP_DISPLAY = {
"控制": "控制",
"爆发": "爆发",
"核心": "输出",
"先手": "先手",
}
# Enemy gap -> candidate tags that punish it (marked 克).
ENEMY_GAP_PUNISH: dict[str, tuple[str, ...]] = {
"控制": ("控制", "先手"),
"爆发": ("耐久", "核心"),
"核心": ("爆发", "控制"),
"先手": ("先手", "爆发"),
}
MAX_REASONS = 3
MAX_REASON_LEN = 12
def tag_profile(keys: Iterable[str], tags_by_key: dict[str, list[str]]) -> dict[str, int]:
counts: Counter[str] = Counter()
for key in keys:
for tag in tags_by_key.get(key) or []:
if tag in TAG_ORDER:
counts[tag] += 1
return {t: counts[t] for t in TAG_ORDER if counts.get(t)}
def detect_archetypes(
enemies: list[str],
tags_by_key: dict[str, list[str]],
*,
push_tag_min: int = 2,
) -> list[str]:
"""Return ordered archetype ids present in the enemy lineup."""
out: list[str] = []
push_n = sum(1 for e in enemies if "推进" in (tags_by_key.get(e) or []))
if push_n >= push_tag_min or any(e in PUSH_CORE for e in enemies):
out.append("push")
global_hits = [e for e in enemies if e in GLOBAL_SET]
if len(global_hits) >= 2 or any(e in HARD_GLOBAL for e in enemies):
out.append("global")
return out
def detect_gaps(
profile: dict[str, int],
*,
hero_count: int,
min_heroes: int = 2,
) -> list[str]:
"""Return missing GAP_TAGS when enough heroes are locked."""
if hero_count < max(1, int(min_heroes)):
return []
missing = []
for tag in GAP_TAGS:
if int(profile.get(tag) or 0) <= 0:
missing.append(tag)
return missing
def format_analysis(
*,
archetypes: list[str],
enemy_gaps: list[str],
ally_gaps: list[str],
ally_count: int,
) -> str:
"""One short Chinese lineup summary (may be empty)."""
enemy_bits: list[str] = []
for arch in archetypes:
lab = ARCHETYPE_LABEL.get(arch)
if lab and lab not in enemy_bits:
enemy_bits.append(lab)
for gap in enemy_gaps:
disp = GAP_DISPLAY.get(gap, gap)
bit = f"{disp}"
if bit not in enemy_bits:
enemy_bits.append(bit)
ally_bits: list[str] = []
if ally_count <= 0:
if enemy_bits:
ally_bits.append("缺口尚不明")
else:
for gap in ally_gaps:
disp = GAP_DISPLAY.get(gap, gap)
bit = f"{disp}"
if bit not in ally_bits:
ally_bits.append(bit)
parts: list[str] = []
if enemy_bits:
parts.append("敌:" + "·".join(enemy_bits))
if ally_bits:
parts.append("我:" + "·".join(ally_bits))
text = " | ".join(parts)
if len(text) > 40:
text = text[:39] + ""
return text
def _trim_reason(s: str) -> str:
s = (s or "").strip()
if len(s) <= MAX_REASON_LEN:
return s
return s[: MAX_REASON_LEN - 1] + ""
def collect_reasons(
*,
names: dict[str, str],
beats: list[dict],
with_allies: list[dict],
archetype_hits: list[str],
punish_gaps: list[str],
fill_gaps: list[str],
) -> list[str]:
"""Build up to MAX_REASONS short reason phrases for one candidate."""
reasons: list[str] = []
def add(phrase: str) -> None:
p = _trim_reason(phrase)
if p and p not in reasons and len(reasons) < MAX_REASONS:
reasons.append(p)
# Prefer one signal per mark type (克 / 补 / 搭) before extras.
for edge in beats[:1]:
add(f"{names.get(edge['enemy'], edge['enemy'])}")
for gap in fill_gaps[:1]:
add(f"{GAP_DISPLAY.get(gap, gap)}")
for edge in with_allies[:1]:
add(f"{names.get(edge['ally'], edge['ally'])}")
for arch in archetype_hits:
add(ARCHETYPE_REASON.get(arch, arch))
for gap in punish_gaps:
add(f"打缺{GAP_DISPLAY.get(gap, gap)}")
for edge in beats[1:]:
add(f"{names.get(edge['enemy'], edge['enemy'])}")
for gap in fill_gaps[1:]:
add(f"{GAP_DISPLAY.get(gap, gap)}")
for edge in with_allies[1:]:
add(f"{names.get(edge['ally'], edge['ally'])}")
return reasons
def answer_for_candidate(
key: str,
cand_tags: list[str],
*,
archetypes: list[str],
enemy_gaps: list[str],
ally_gaps: list[str],
) -> tuple[list[str], list[str], list[str]]:
"""Return (archetype_hits, punish_gaps, fill_gaps) that apply to this hero."""
tag_set = set(cand_tags or [])
arch_hits = [a for a in archetypes if key in ARCHETYPE_ANSWERS.get(a, ())]
punish = []
for gap in enemy_gaps:
wanted = ENEMY_GAP_PUNISH.get(gap) or ()
if tag_set.intersection(wanted):
punish.append(gap)
fill = [g for g in ally_gaps if g in tag_set]
# 核心 gap displays as 输出; candidate must have 核心 tag to fill.
return arch_hits, punish, fill
__all__ = [
"ARCHETYPE_ANSWERS",
"ARCHETYPE_LABEL",
"ARCHETYPE_REASON",
"ENEMY_GAP_PUNISH",
"GAP_DISPLAY",
"GAP_TAGS",
"answer_for_candidate",
"collect_reasons",
"detect_archetypes",
"detect_gaps",
"format_analysis",
"tag_profile",
]
+162 -26
View File
@@ -30,15 +30,21 @@ Skinned portraits are not templated (too many variants). Instead:
Once ten heroes are confirmed, vision stops and only GSI is waited on for self.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import time
import mss
from capture import grab_frame, is_dota_foreground, raw_dir_for_match, save_frame
from grid import bans, hero_table, read_grid
from shared.grid import bans, hero_table, read_grid
from modes import detect_mode, load_mode_templates
from recognize import recognize_image
from recommend import ally_keys, enemy_keys, load_relations, suggest_top
from item_suggest import suggest_items
from recommend import ally_keys, enemy_keys, load_relations, suggest_marks
from roles import ROLES, detect_roles, load_role_templates
HERO_SELECTION = "DOTA_GAMERULES_STATE_HERO_SELECTION"
@@ -84,11 +90,24 @@ class DraftSession:
self.frame_dir = raw_dir_for_match(None)
rec = cfg.get("recommend") or {}
self.recommend_enabled = bool(rec.get("enabled", True))
self.recommend_top_n = int(rec.get("top_n", 3))
self.recommend_top_n = int(rec.get("top_n", 0))
self.recommend_min_enemies = int(rec.get("min_enemies", 1))
self.recommend_min_heroes_for_gaps = int(rec.get("min_heroes_for_gaps", 2))
self.recommend_archetypes = bool(rec.get("archetypes", True))
self.recommend_role_tags = rec.get("role_tags")
self.relations = load_relations(rec.get("relations_path")) if self.recommend_enabled else None
items_cfg = rec.get("items") or {}
self.recommend_items_enabled = bool(items_cfg.get("enabled", True))
self.recommend_items_core_n = int(items_cfg.get("core_n", 3))
self.recommend_items_answer_n = int(items_cfg.get("answer_n", 3))
self.recommend_items_max_total = int(items_cfg.get("max_total", 6))
self.recommend_hero_items_path = items_cfg.get("hero_items_path")
self.recommend_items_meta_path = items_cfg.get("items_meta_path")
self._rec_warned = False
self._last_rec_sig: tuple | None = None
self._last_enemy_profile: dict = {}
self._last_rec_meta: dict = {}
self._last_item_sig: tuple | None = None
def _push_overlay(self, confirmed: dict[int, str]) -> None:
if self.overlay is None:
@@ -98,12 +117,22 @@ class DraftSession:
except Exception as e: # noqa: BLE001
self.log(f"[draft] overlay update failed: {e}")
def _push_rec_overlay(self, cells: dict | None, picks: list[dict]) -> None:
def _push_rec_overlay(
self,
cells: dict | None,
picks: list[dict],
analysis: str = "",
items: list[dict] | None = None,
) -> None:
if self.overlay is None:
return
try:
marks = {p["key"]: p["rank"] for p in picks}
marks = {p["key"]: list(p.get("labels") or []) for p in picks if p.get("key")}
self.overlay.set_grid_marks(cells or {}, marks)
if hasattr(self.overlay, "set_analysis"):
self.overlay.set_analysis(analysis or "")
if hasattr(self.overlay, "set_items"):
self.overlay.set_items(items if items is not None else [])
except Exception as e: # noqa: BLE001
self.log(f"[draft] rec overlay update failed: {e}")
@@ -115,14 +144,19 @@ class DraftSession:
try:
self.overlay.set_roster({})
self.overlay.set_grid_marks({}, {})
if hasattr(self.overlay, "set_analysis"):
self.overlay.set_analysis("")
if hasattr(self.overlay, "set_items"):
self.overlay.set_items([])
self.overlay.show()
except Exception as e: # noqa: BLE001
self.log(f"[draft] overlay show failed: {e}")
self._last_item_sig = None
if self.recommend_enabled and not self._rec_warned:
rel = self.relations or {}
if not rel.get("counters") and not rel.get("synergies"):
self._rec_warned = True
self.log("[rec] relations empty — edit data/relations.json "
self.log("[rec] relations empty — edit shared/data/relations.json "
"or run import_relations_xlsx.py")
started = time.monotonic()
pending: dict[int, tuple[str, int]] = {}
@@ -138,6 +172,7 @@ class DraftSession:
"cells": None,
"mode": None,
"recommendations": [],
"item_recommendations": [],
}
polls = 0
last_frame = None
@@ -229,8 +264,8 @@ class DraftSession:
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
timeline.append(event)
self._log_event(event, info)
if state == HERO_SELECTION:
self._refresh_recommendations(confirmed, info, gsi_fn)
# Hero marks in selection; item bar after self-lock (incl. strategy tail).
self._refresh_recommendations(confirmed, info, gsi_fn)
n = len(confirmed)
if self.keep_frames and n in (4, 8, 10) and n not in saved_milestones:
@@ -255,6 +290,10 @@ class DraftSession:
if self.overlay is not None:
try:
self.overlay.set_grid_marks({}, {})
if hasattr(self.overlay, "set_analysis"):
self.overlay.set_analysis("")
if hasattr(self.overlay, "set_items"):
self.overlay.set_items([])
self.overlay.hide()
except Exception as e: # noqa: BLE001
self.log(f"[draft] overlay hide failed: {e}")
@@ -362,24 +401,70 @@ class DraftSession:
self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable "
f"(contrast margin {res['margin']}) - {names}")
def _refresh_item_recommendations(
self,
confirmed: dict,
info: dict,
*,
self_hero: str,
enemies: list[str],
force: bool = False,
) -> None:
"""After self-lock: clear hero marks and show core + answer items."""
analysis = (self._last_rec_meta or {}).get("analysis") or ""
if not self.recommend_items_enabled:
if info.get("recommendations") or info.get("item_recommendations") or self._last_rec_sig != ("locked",):
info["recommendations"] = []
info["item_recommendations"] = []
self._last_rec_sig = ("locked",)
self._last_item_sig = ("locked-off",)
self._push_rec_overlay(info.get("cells"), [], analysis, [])
return
sig = (self_hero, tuple(enemies))
if not force and sig == self._last_item_sig:
return
self._last_item_sig = sig
self._last_rec_sig = ("locked",)
info["recommendations"] = []
items = suggest_items(
self_hero,
enemies,
core_n=self.recommend_items_core_n,
answer_n=self.recommend_items_answer_n,
max_total=self.recommend_items_max_total,
hero_items_path=self.recommend_hero_items_path,
items_meta_path=self.recommend_items_meta_path,
)
info["item_recommendations"] = items
self._push_rec_overlay(info.get("cells"), [], analysis, items)
if items:
bits = [
f"{it.get('name_loc') or it['key']}({it.get('reason') or it.get('kind')})"
for it in items
]
self.log(
f"[rec:items] {self.hero_names.get(self_hero, self_hero)} vs "
f"{[self.hero_names.get(e, e) for e in enemies]}: {', '.join(bits)}"
)
def _refresh_recommendations(self, confirmed: dict, info: dict, gsi_fn, *, force: bool = False) -> None:
if not self.recommend_enabled:
return
if not self.relations:
return
gsi = (gsi_fn() or {}) if gsi_fn else {}
self_slot = gsi_slot(gsi) or info.get("self_slot")
self_team = gsi.get("team") or info.get("self_team")
role = info["roles"].get(self_slot) if self_slot else None
position = role["position"] if role else None
# Stop suggesting once you have locked a hero.
if self_slot and confirmed.get(self_slot):
if info.get("recommendations") or self._last_rec_sig is not None:
info["recommendations"] = []
self._last_rec_sig = ("locked",)
self._push_rec_overlay(info.get("cells"), [])
return
self_hero = (confirmed.get(self_slot) if self_slot else None) or gsi.get("hero")
enemies = enemy_keys(confirmed, self_team)
# Once locked, switch from hero marks to item icon bar.
if self_slot and self_hero:
self._refresh_item_recommendations(
confirmed, info, self_hero=self_hero, enemies=enemies, force=force,
)
return
if not self.relations and not self.recommend_archetypes:
return
allies = ally_keys(confirmed, self_team, self_slot)
exclude = set(confirmed.values())
if info.get("unavailable"):
@@ -388,11 +473,15 @@ class DraftSession:
if not force and sig == self._last_rec_sig:
return
self._last_rec_sig = sig
if position is None or (not enemies and not allies):
self._last_item_sig = None
if len(enemies) < self.recommend_min_enemies:
info["recommendations"] = []
self._push_rec_overlay(info.get("cells"), [])
info["item_recommendations"] = []
self._last_enemy_profile = {}
self._last_rec_meta = {}
self._push_rec_overlay(info.get("cells"), [], "", [])
return
picks = suggest_top(
result = suggest_marks(
position=position,
enemies=enemies,
allies=allies,
@@ -400,14 +489,39 @@ class DraftSession:
relations=self.relations,
top_n=self.recommend_top_n,
role_tags=self.recommend_role_tags,
min_enemies=self.recommend_min_enemies,
min_heroes_for_gaps=self.recommend_min_heroes_for_gaps,
archetypes_enabled=self.recommend_archetypes,
)
picks = result.get("marks") or []
profile = result.get("enemy_profile") or {}
analysis = result.get("analysis") or ""
info["recommendations"] = picks
self._push_rec_overlay(info.get("cells"), picks)
if picks:
names = ", ".join(f"{p['rank']}.{p['name_loc']}({p['score']:+d})" for p in picks)
info["item_recommendations"] = []
self._last_enemy_profile = profile
self._last_rec_meta = {
"analysis": analysis,
"enemy_archetypes": list(result.get("enemy_archetypes") or []),
"enemy_gaps": list(result.get("enemy_gaps") or []),
"ally_gaps": list(result.get("ally_gaps") or []),
"ally_profile": dict(result.get("ally_profile") or {}),
}
self._push_rec_overlay(info.get("cells"), picks, analysis, [])
if picks or analysis:
bits = []
for p in picks[:12]:
labs = "".join(p.get("labels") or [])
why = "".join(p.get("reasons") or [])
extra = f":{why}" if why else ""
bits.append(f"{p['name_loc']}[{labs}]{extra}")
pos_s = f"pos{position}" if position is not None else "all"
ally_n = [self.hero_names.get(a, a) for a in allies]
enemy_n = [self.hero_names.get(e, e) for e in enemies]
self.log(f"[rec] pos{position} with {ally_n} vs {enemy_n}: {names}")
analysis_s = analysis or "-"
self.log(
f"[rec] {pos_s} {analysis_s} | with {ally_n} vs {enemy_n}: "
f"{len(picks)} marks — {', '.join(bits)}"
)
def _absorb_picks(self, result: dict, pending: dict, confirmed: dict,
scores: dict, *, allow_revise: bool = True,
@@ -541,7 +655,14 @@ class DraftSession:
"position": role["position"] if role else None,
"enemies": enemies,
"allies": allies,
"enemy_profile": dict(self._last_enemy_profile or {}),
"ally_profile": dict((self._last_rec_meta or {}).get("ally_profile") or {}),
"enemy_archetypes": list((self._last_rec_meta or {}).get("enemy_archetypes") or []),
"enemy_gaps": list((self._last_rec_meta or {}).get("enemy_gaps") or []),
"ally_gaps": list((self._last_rec_meta or {}).get("ally_gaps") or []),
"analysis": (self._last_rec_meta or {}).get("analysis") or "",
"picks": info.get("recommendations") or [],
"items": info.get("item_recommendations") or [],
},
}
if info["unavailable"] is not None:
@@ -613,10 +734,25 @@ def describe(summary: dict) -> list[str]:
if summary.get("bans_loc"):
lines.append(f"bans : {len(summary['bans_loc'])} - {', '.join(summary['bans_loc'])}")
rec = summary.get("recommendations") or {}
analysis = rec.get("analysis") or ""
if analysis:
lines.append(f"draft : {analysis}")
picks = rec.get("picks") or []
if picks:
bits = ", ".join(f"{p['rank']}.{p.get('name_loc') or loc(p['key'], names)}" for p in picks)
lines.append(f"rec : {bits}")
bits = []
for p in picks[:15]:
labs = "".join(p.get("labels") or []) or "?"
why = "".join(p.get("reasons") or [])
name = p.get("name_loc") or loc(p["key"], names)
bits.append(f"{name}[{labs}]" + (f"({why})" if why else ""))
lines.append(f"rec : {len(picks)}{', '.join(bits)}")
items = rec.get("items") or []
if items:
bits = [
f"{it.get('name_loc') or it.get('key')}({it.get('reason') or it.get('kind')})"
for it in items
]
lines.append(f"items : {', '.join(bits)}")
return lines
+5 -1
View File
@@ -7,8 +7,12 @@ Usage:
python evaluate.py
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
import cv2
@@ -4,21 +4,25 @@ Usage:
python fetch_cdn_templates.py
Writes:
data/heroes.json - hero id / key / English name / roles / aliases / base stats
shared/data/heroes.json - hero id / key / English name / roles / aliases / base stats
templates/cdn/{key}.png - face-centered square crop resized to canonical size
Preserves manually curated `aliases` / `abbr` from an existing heroes.json on rewrite.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
import cv2
import numpy as np
from common import HEROES_JSON, TEMPLATES_CDN, load_config
from hero_tags import tags_for_hero
from http_utils import fetch_hero_list, http_bytes, http_json
from shared.hero_tags import tags_for_hero
from shared.http_utils import fetch_hero_list, http_bytes, http_json
# Steam CDN landscape hero art; cropped to a face window for top-bar matching.
IMG_URL = "https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png"
@@ -98,14 +102,13 @@ def stats_from_opendota(raw: dict) -> dict:
"vision_day": int(_num(raw, "day_vision", 1800)),
"vision_night": int(_num(raw, "night_vision", 800)),
}
# turn_rate is null in OpenDota for heroes using the game default — omit
# rather than inventing 0.6 so the UI only shows an explicit value.
# OpenDota leaves turn_rate null for the engine default (0.6); match
# dota2.com.cn and always surface a value for the combat panel.
turn = raw.get("turn_rate")
if turn is not None:
try:
result["turn_rate"] = _round1(float(turn))
except (TypeError, ValueError):
pass
try:
result["turn_rate"] = _round1(float(turn)) if turn is not None else 0.6
except (TypeError, ValueError):
result["turn_rate"] = 0.6
return result
+4 -1
View File
@@ -10,10 +10,13 @@ After installing, add -gamestateintegration to Dota 2's launch options
(Steam library -> right-click Dota 2 -> Properties) and restart the game.
"""
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import re
from common import load_config
CFG_NAME = "gamestate_integration_climperor.cfg"
+25 -3
View File
@@ -21,12 +21,15 @@ With gsi.dump_payloads (default on), every POST body is appended to
samples/raw/<matchid>/gsi.jsonl for later analysis.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import mss
@@ -34,6 +37,7 @@ from capture import append_gsi_payload, grab_frame, is_dota_foreground, raw_dir_
from common import ROOT, load_config, load_template_library
from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc
from overlay import DraftOverlay
from player_pages import POST_GAME, schedule_post_game
from recognize import recognize_image
from roles import detect_roles
@@ -74,10 +78,12 @@ class Watcher:
if calibrated and bool((cfg.get("overlay") or {}).get("enabled", True)):
try:
self.overlay = DraftOverlay(cfg)
print("[draft] role-tag overlay ready", flush=True)
print("[draft] overlay ready", flush=True)
except Exception as e: # noqa: BLE001
print(f"[draft] overlay disabled: {e}", flush=True)
self.overlay = None
# Dedup post-game player-page jobs per match (in addition to module lock).
self._player_page_matches: set[str] = set()
def on_payload(self, payload: dict) -> None:
if not self.connected:
@@ -120,7 +126,11 @@ class Watcher:
print(f"[gsi] map keys: {sorted(m.keys())}", flush=True)
print(f"[gsi] player keys: {sorted(p.keys())}", flush=True)
print(f"[gsi] self: {self.self_info} -> top-bar slot {gsi_slot(self.self_info)}", flush=True)
prev = self.last_state
self.last_state = state
# First transition into POST_GAME → build local /players pages.
if state == POST_GAME and prev != POST_GAME:
self._maybe_schedule_player_page(match_id)
if state not in self.trigger_states:
return
@@ -141,6 +151,18 @@ class Watcher:
except OSError as e:
print(f"[gsi] dump failed: {e}", flush=True)
def _maybe_schedule_player_page(self, match_id: str) -> None:
if not match_id or match_id == "no-match":
return
if match_id in self._player_page_matches:
return
self._player_page_matches.add(match_id)
schedule_post_game(
self.cfg,
match_id=match_id,
account_id=self.self_info.get("accountid"),
)
def track(self, match_id: str, state: str, key: str) -> dict | None:
"""Follow the draft from here to the end, recording every reveal."""
if not self.busy.acquire(blocking=False):
+298
View File
@@ -0,0 +1,298 @@
"""Draft item suggestions: hero core builds + qualitative answers vs enemies.
Core items come from web/data/hero_items.json (relative popularity).
Answer items are rule-mapped from enemy tags / push-global archetypes.
Does not read hero_item_fears stats or STRATZ.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
from functools import lru_cache
from draft_archetypes import detect_archetypes, tag_profile
from shared.grid import hero_table
from shared.hero_tags import tags_for_hero
from shared.paths import DATA, ROOT
DEFAULT_HERO_ITEMS = DATA / "hero_items.json"
DEFAULT_ITEMS_META = DATA / "items_meta.json"
# Heroes that commonly pick from fog / invis — qualitative only.
INVIS_HEROES = frozenset({
"riki",
"bounty_hunter",
"clinkz",
"weaver",
"nyx_assassin",
"templar_assassin",
"mirana",
"treant",
"windrunner",
"slark",
"invoker",
"sand_king",
})
# Soft heal / sustain cores — vessel answers.
HEAL_HEROES = frozenset({
"omniknight",
"winter_wyvern",
"bane",
"undying",
"abaddon",
"oracle",
"chen",
"io",
"wisp",
"dazzle",
"warlock",
})
# Fallback Chinese names when catalogs miss an entry.
_FALLBACK_NAMES = {
"black_king_bar": "黑皇杖",
"bfury": "狂战斧",
"disperser": "散魂剑",
"gem": "真视宝石",
"dust": "显影之尘",
"pipe": "洞察烟斗",
"eternal_shroud": "永世护盾",
"travel_boots": "远行鞋",
"crimson_guard": "赤红甲",
"spirit_vessel": "魂之灵瓮",
}
def _resolve_path(path: str | Path | None, default: Path) -> Path:
if not path:
return default
p = Path(path)
if not p.is_absolute():
p = ROOT / p
return p
@lru_cache(maxsize=4)
def _load_hero_items(path_str: str) -> dict:
path = Path(path_str)
if not path.exists():
return {}
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
@lru_cache(maxsize=4)
def _load_items_meta(path_str: str) -> dict[str, str]:
path = Path(path_str)
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
items = data.get("items") if isinstance(data, dict) else None
if not isinstance(items, dict):
return {}
out: dict[str, str] = {}
for row in items.values():
if not isinstance(row, dict):
continue
key = row.get("key")
if not key:
continue
name = row.get("name_loc") or row.get("dname") or key
out[str(key)] = str(name)
return out
def _tags_by_key() -> dict[str, list[str]]:
return {
h["key"]: list(h.get("tags") or []) or tags_for_hero(h["key"], h.get("roles"))
for h in hero_table()
}
def _name_for(
key: str,
*,
catalog: dict[str, dict],
meta_names: dict[str, str],
) -> str:
row = catalog.get(key) or {}
return (
str(row.get("name_loc") or "")
or meta_names.get(key)
or _FALLBACK_NAMES.get(key)
or key
)
def _catalog_from_hero_items(data: dict) -> dict[str, dict]:
"""id-str -> row and also key -> row for lookups."""
items = data.get("items") or {}
by_key: dict[str, dict] = {}
for row in items.values():
if isinstance(row, dict) and row.get("key"):
by_key[str(row["key"])] = row
return by_key
def core_items_for_hero(
self_hero: str,
data: dict,
*,
core_n: int = 3,
catalog: dict[str, dict] | None = None,
meta_names: dict[str, str] | None = None,
) -> list[dict]:
"""Top relative-popularity finished items for one hero."""
if not self_hero or core_n <= 0:
return []
by_hero = data.get("by_hero") or {}
rows = list(by_hero.get(self_hero) or [])
items_by_id = data.get("items") or {}
catalog = catalog if catalog is not None else _catalog_from_hero_items(data)
meta_names = meta_names or {}
out: list[dict] = []
seen: set[str] = set()
for row in rows:
if len(out) >= core_n:
break
iid = str(row.get("id") if isinstance(row, dict) else row)
meta = items_by_id.get(iid) or items_by_id.get(int(iid) if iid.isdigit() else iid) or {}
key = str(meta.get("key") or "")
if not key or key in seen:
continue
seen.add(key)
out.append({
"key": key,
"name_loc": _name_for(key, catalog=catalog, meta_names=meta_names),
"kind": "core",
"reason": "常用",
})
return out
def answer_items_for_enemies(
enemies: list[str],
*,
answer_n: int = 3,
catalog: dict[str, dict] | None = None,
meta_names: dict[str, str] | None = None,
tags_by_key: dict[str, list[str]] | None = None,
) -> list[dict]:
"""Qualitative counter items from enemy tags / archetypes."""
if answer_n <= 0 or not enemies:
return []
tags_by_key = tags_by_key or _tags_by_key()
catalog = catalog or {}
meta_names = meta_names or {}
profile = tag_profile(enemies, tags_by_key)
arches = detect_archetypes(enemies, tags_by_key)
candidates: list[tuple[str, str]] = []
control_n = int(profile.get("控制") or 0) + int(profile.get("先手") or 0)
if control_n >= 2:
candidates.append(("black_king_bar", "克控制"))
if int(profile.get("幻象") or 0) >= 1 or any(
"幻象" in (tags_by_key.get(e) or []) for e in enemies
):
candidates.append(("bfury", "清幻象"))
candidates.append(("disperser", "打幻象"))
if any(e in INVIS_HEROES for e in enemies):
candidates.append(("gem", "克隐身"))
candidates.append(("dust", "显影"))
if int(profile.get("爆发") or 0) >= 2:
candidates.append(("pipe", "克魔法"))
candidates.append(("eternal_shroud", "魔抗"))
if any(e in HEAL_HEROES for e in enemies):
candidates.append(("spirit_vessel", "克回复"))
if "push" in arches:
candidates.append(("crimson_guard", "抗推进"))
candidates.append(("travel_boots", "对推进"))
if "global" in arches:
candidates.append(("travel_boots", "对全球流"))
out: list[dict] = []
seen: set[str] = set()
for key, reason in candidates:
if key in seen:
continue
seen.add(key)
out.append({
"key": key,
"name_loc": _name_for(key, catalog=catalog, meta_names=meta_names),
"kind": "answer",
"reason": reason,
})
if len(out) >= answer_n:
break
return out
def suggest_items(
self_hero: str | None,
enemies: list[str] | None,
*,
core_n: int = 3,
answer_n: int = 3,
max_total: int = 6,
hero_items_path: str | Path | None = None,
items_meta_path: str | Path | None = None,
) -> list[dict]:
"""Merge answer items then core items; dedupe by key; soft-fail to []."""
if not self_hero or max_total <= 0:
return []
hi_path = _resolve_path(hero_items_path, DEFAULT_HERO_ITEMS)
meta_path = _resolve_path(items_meta_path, DEFAULT_ITEMS_META)
data = _load_hero_items(str(hi_path))
if not data:
return []
catalog = _catalog_from_hero_items(data)
meta_names = _load_items_meta(str(meta_path))
answers = answer_items_for_enemies(
list(enemies or []),
answer_n=answer_n,
catalog=catalog,
meta_names=meta_names,
)
cores = core_items_for_hero(
self_hero,
data,
core_n=core_n,
catalog=catalog,
meta_names=meta_names,
)
out: list[dict] = []
seen: set[str] = set()
for row in answers + cores:
key = row.get("key")
if not key or key in seen:
continue
seen.add(key)
out.append(row)
if len(out) >= max_total:
break
return out
__all__ = [
"DEFAULT_HERO_ITEMS",
"answer_items_for_enemies",
"core_items_for_hero",
"suggest_items",
]
+3
View File
@@ -1,7 +1,10 @@
"""Read the game-mode label under the top-center timer (e.g. 全英雄选择)."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import cv2
import numpy as np
+410
View File
@@ -0,0 +1,410 @@
"""Transparent click-through overlay: 克/搭/补 marks + analysis bar + item icons.
Runs a Tk root on a background thread. DraftSession calls set_grid_marks(),
set_analysis(), and set_items(); geometry uses relative coords.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import threading
import tkinter as tk
import mss
from shared.paths import ITEM_ICONS
CHROMA = "#ff00ff"
DEFAULT_COUNTER_COLOR = "#2ec4b6"
DEFAULT_SYNERGY_COLOR = "#e9a825"
DEFAULT_FILL_COLOR = "#9b7ebd"
DEFAULT_MARK_TEXT = "#0b1220"
DEFAULT_ANALYSIS_BG = "#1a2332"
DEFAULT_ANALYSIS_FG = "#e8eef7"
DEFAULT_ITEM_CORE_BG = "#1a2332"
DEFAULT_ITEM_ANSWER_BG = "#2a1f14"
DEFAULT_ITEM_REASON_FG = "#e8eef7"
LABEL_ORDER = ("", "", "")
LABEL_COLORS = {
"": "counter",
"": "synergy",
"": "fill",
}
def _primary_monitor_size() -> tuple[int, int]:
with mss.MSS() as sct:
mon = sct.monitors[1]
return int(mon["width"]), int(mon["height"])
def _enable_click_through(hwnd: int) -> None:
"""Make the window ignore mouse input (Windows)."""
if sys.platform != "win32":
return
import ctypes
user32 = ctypes.windll.user32
GWL_EXSTYLE = -20
WS_EX_LAYERED = 0x00080000
WS_EX_TRANSPARENT = 0x00000020
WS_EX_TOOLWINDOW = 0x00000080
get_long = user32.GetWindowLongW
set_long = user32.SetWindowLongW
style = get_long(hwnd, GWL_EXSTYLE)
set_long(hwnd, GWL_EXSTYLE, style | WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOOLWINDOW)
class DraftOverlay:
"""Fullscreen transparent overlay drawn above Dota during draft tracking."""
def __init__(self, cfg: dict):
self.cfg = cfg
o = cfg.get("overlay") or {}
self.mark_size_rel = float(o.get("mark_size_rel", 0.018))
self.mark_pad_rel = float(o.get("mark_pad_rel", 0.004))
self.mark_gap_rel = float(o.get("mark_gap_rel", 0.002))
self.counter_color = str(o.get("counter_color", o.get("rec_color", DEFAULT_COUNTER_COLOR)))
self.synergy_color = str(o.get("synergy_color", DEFAULT_SYNERGY_COLOR))
self.fill_color = str(o.get("fill_color", DEFAULT_FILL_COLOR))
self.mark_text = str(o.get("mark_text_color", o.get("rec_text_color", DEFAULT_MARK_TEXT)))
self.analysis_y_rel = float(o.get("analysis_y_rel", 0.12))
self.analysis_h_rel = float(o.get("analysis_h_rel", 0.028))
self.analysis_font_rel = float(o.get("analysis_font_rel", 0.014))
self.analysis_bg = str(o.get("analysis_bg", DEFAULT_ANALYSIS_BG))
self.analysis_fg = str(o.get("analysis_fg", DEFAULT_ANALYSIS_FG))
self.items_y_rel = float(o.get("items_y_rel", 0.16))
self.item_icon_h_rel = float(o.get("item_icon_h_rel", 0.036))
self.item_gap_rel = float(o.get("item_gap_rel", 0.01))
self.item_core_bg = str(o.get("item_core_bg", DEFAULT_ITEM_CORE_BG))
self.item_answer_bg = str(o.get("item_answer_bg", DEFAULT_ITEM_ANSWER_BG))
self.item_reason_fg = str(o.get("item_reason_fg", DEFAULT_ITEM_REASON_FG))
self.item_icons_dir = Path(o.get("item_icons_dir") or ITEM_ICONS)
self._cells: dict[str, dict] = {}
self._marks: dict[str, list[str]] = {}
self._analysis = ""
self._items: list[dict] = []
self._ready = threading.Event()
self._closed = False
self._root: tk.Tk | None = None
self._canvas: tk.Canvas | None = None
self._photos: list[tk.PhotoImage] = []
self._icon_src: dict[str, tk.PhotoImage] = {}
self._thread = threading.Thread(target=self._run, name="draft-overlay", daemon=True)
self._thread.start()
self._ready.wait(timeout=5.0)
def _label_fill(self, label: str) -> str:
kind = LABEL_COLORS.get(label)
if kind == "synergy":
return self.synergy_color
if kind == "fill":
return self.fill_color
return self.counter_color
def _run(self) -> None:
sw, sh = _primary_monitor_size()
root = tk.Tk()
self._root = root
root.overrideredirect(True)
root.attributes("-topmost", True)
root.geometry(f"{sw}x{sh}+0+0")
root.configure(bg=CHROMA)
try:
root.attributes("-transparentcolor", CHROMA)
except tk.TclError:
pass
canvas = tk.Canvas(root, width=sw, height=sh, bg=CHROMA, highlightthickness=0, bd=0)
canvas.pack(fill="both", expand=True)
self._canvas = canvas
self._screen = (sw, sh)
root.update_idletasks()
try:
hwnd = int(root.wm_frame(), 16) if root.wm_frame().startswith("0x") else int(root.winfo_id())
if sys.platform == "win32":
import ctypes
hwnd = ctypes.windll.user32.GetParent(root.winfo_id()) or root.winfo_id()
_enable_click_through(int(hwnd))
except Exception:
pass
root.withdraw()
self._ready.set()
root.mainloop()
try:
root.destroy()
except tk.TclError:
pass
self._closed = True
def set_roster(self, confirmed: dict[int, str]) -> None:
"""No-op kept for DraftSession compatibility (role tags under avatars removed)."""
return
def set_analysis(self, text: str | None) -> None:
"""Short lineup analysis banner (empty clears)."""
value = (text or "").strip()
if value == self._analysis:
return
self._analysis = value
self._schedule_redraw()
def set_items(self, items: list[dict] | None) -> None:
"""Item icon bar after self-lock: [{key, name_loc, kind, reason}, ...]."""
parsed: list[dict] = []
for row in items or []:
key = row.get("key")
if not key:
continue
parsed.append({
"key": str(key),
"name_loc": str(row.get("name_loc") or key),
"kind": str(row.get("kind") or "core"),
"reason": str(row.get("reason") or ""),
})
if parsed == self._items:
return
self._items = parsed
self._schedule_redraw()
def set_grid_marks(
self,
cells: dict[str, dict] | None,
marks: dict[str, list[str] | int] | list[dict] | None,
) -> None:
"""克/搭/补 badges on hero-grid cells.
marks: key->labels list, key->legacy rank int, or suggest_marks list.
"""
cells = {str(k): dict(v) for k, v in (cells or {}).items()}
parsed: dict[str, list[str]] = {}
if isinstance(marks, list):
for item in marks:
key = item.get("key")
if not key:
continue
labels = item.get("labels")
if labels:
parsed[str(key)] = [str(x) for x in labels if x in LABEL_ORDER]
elif item.get("rank"):
parsed[str(key)] = [""]
elif marks:
for key, val in marks.items():
if isinstance(val, (list, tuple)):
labs = [str(x) for x in val if x in LABEL_ORDER]
elif isinstance(val, int) and val > 0:
labs = [""]
elif isinstance(val, str) and val in LABEL_ORDER:
labs = [val]
else:
labs = []
if labs:
parsed[str(key)] = labs
if cells == self._cells and parsed == self._marks:
return
self._cells = cells
self._marks = parsed
self._schedule_redraw()
def _schedule_redraw(self) -> None:
root = self._root
if root is None or self._closed:
return
try:
root.after(0, self._redraw)
except RuntimeError:
pass
def show(self) -> None:
root = self._root
if root is None or self._closed:
return
try:
root.after(0, root.deiconify)
except RuntimeError:
pass
def hide(self) -> None:
root = self._root
if root is None or self._closed:
return
def _hide() -> None:
if self._canvas is not None:
self._canvas.delete("all")
self._photos.clear()
self._analysis = ""
self._items = []
root.withdraw()
try:
root.after(0, _hide)
except RuntimeError:
pass
def close(self) -> None:
root = self._root
if root is None or self._closed:
return
done = threading.Event()
def _shutdown() -> None:
try:
if self._canvas is not None:
self._canvas.delete("all")
self._photos.clear()
self._icon_src.clear()
root.quit()
finally:
done.set()
try:
root.after(0, _shutdown)
except RuntimeError:
done.set()
done.wait(timeout=2.0)
self._thread.join(timeout=2.0)
self._closed = True
def _load_item_src(self, key: str) -> tk.PhotoImage | None:
assert self._root is not None
if key in self._icon_src:
return self._icon_src[key]
path = self.item_icons_dir / f"{key}.png"
if not path.exists():
return None
try:
img = tk.PhotoImage(master=self._root, file=str(path))
except tk.TclError:
return None
self._icon_src[key] = img
return img
def _scaled_item_icon(self, key: str, target_h: int) -> tk.PhotoImage | None:
src = self._load_item_src(key)
if src is None or target_h <= 0:
return None
h = max(src.height(), 1)
if target_h >= h:
factor = max(1, round(target_h / h))
img = src.zoom(factor, factor)
else:
factor = max(1, round(h / target_h))
img = src.subsample(factor, factor)
self._photos.append(img)
return img
def _draw_items(self, canvas: tk.Canvas, sw: int, sh: int) -> None:
if not self._items:
return
icon_h = max(20, int(round(self.item_icon_h_rel * sh)))
gap = max(4, int(round(self.item_gap_rel * sw)))
reason_font = max(9, int(round(0.011 * sh)))
pad = max(4, int(round(0.003 * sh)))
top = int(round(self.items_y_rel * sh))
cells: list[tuple[dict, tk.PhotoImage | None, int, int]] = []
for row in self._items:
img = self._scaled_item_icon(row["key"], icon_h)
iw = img.width() if img is not None else icon_h
ih = img.height() if img is not None else icon_h
cells.append((row, img, iw, ih))
cell_w = max((iw for _, _, iw, _ in cells), default=icon_h) + 2 * pad
# Extra width for short Chinese reason under icon.
cell_w = max(cell_w, reason_font * 4 + 2 * pad)
cell_h = max((ih for _, _, _, ih in cells), default=icon_h) + reason_font + 3 * pad
total_w = len(cells) * cell_w + gap * max(0, len(cells) - 1)
left = max(0, (sw - total_w) // 2)
for i, (row, img, iw, ih) in enumerate(cells):
x0 = left + i * (cell_w + gap)
y0 = top
x1 = x0 + cell_w
y1 = y0 + cell_h
bg = self.item_answer_bg if row.get("kind") == "answer" else self.item_core_bg
canvas.create_rectangle(x0, y0, x1, y1, fill=bg, outline=bg)
cx = (x0 + x1) / 2
if img is not None:
canvas.create_image(cx, y0 + pad + ih / 2, image=img, anchor="center")
else:
name = row.get("name_loc") or row["key"]
canvas.create_text(
cx,
y0 + pad + icon_h / 2,
text=name[:4],
fill=self.item_reason_fg,
font=("Microsoft YaHei UI", max(8, reason_font - 1), "bold"),
)
reason = (row.get("reason") or "").strip() or ("应对" if row.get("kind") == "answer" else "常用")
canvas.create_text(
cx,
y1 - pad - reason_font / 2,
text=reason[:6],
fill=self.item_reason_fg,
font=("Microsoft YaHei UI", reason_font),
)
def _redraw(self) -> None:
canvas = self._canvas
if canvas is None:
return
canvas.delete("all")
self._photos.clear()
sw, sh = self._screen
if self._analysis:
bar_h = max(18, int(round(self.analysis_h_rel * sh)))
bar_y = int(round(self.analysis_y_rel * sh))
font_size = max(10, int(round(self.analysis_font_rel * sh)))
pad_x = max(12, int(round(0.01 * sw)))
# Estimate text width roughly; keep banner centered and readable.
approx_w = min(sw - 2 * pad_x, max(200, int(len(self._analysis) * font_size * 0.95) + 2 * pad_x))
x0 = (sw - approx_w) // 2
y0 = bar_y
x1 = x0 + approx_w
y1 = y0 + bar_h
canvas.create_rectangle(x0, y0, x1, y1, fill=self.analysis_bg, outline=self.analysis_bg)
canvas.create_text(
(x0 + x1) / 2,
(y0 + y1) / 2,
text=self._analysis,
fill=self.analysis_fg,
font=("Microsoft YaHei UI", font_size, "bold"),
)
self._draw_items(canvas, sw, sh)
mark = max(12, int(round(self.mark_size_rel * sh)))
pad = max(2, int(round(self.mark_pad_rel * sh)))
mark_gap = max(1, int(round(self.mark_gap_rel * sh)))
font_size = max(8, int(round(mark * 0.55)))
for key, labels in self._marks.items():
cell = self._cells.get(key)
if not cell:
continue
ordered = [lab for lab in LABEL_ORDER if lab in labels]
if not ordered:
continue
x0 = int(cell["x0"])
y0 = int(cell["y0"])
x1 = x0 + pad
y1 = y0 + pad
for i, lab in enumerate(ordered):
bx1 = x1 + i * (mark + mark_gap)
by1 = y1
bx2 = bx1 + mark
by2 = by1 + mark
fill = self._label_fill(lab)
canvas.create_rectangle(bx1, by1, bx2, by2, fill=fill, outline=fill)
canvas.create_text(
(bx1 + bx2) / 2,
(by1 + by2) / 2,
text=lab,
fill=self.mark_text,
font=("Microsoft YaHei UI", font_size, "bold"),
)
+1199
View File
File diff suppressed because it is too large Load Diff
+327
View File
@@ -0,0 +1,327 @@
"""Player homepage aggregates (career / recent20 / heroes / activity / peers).
Pure helpers used by pc/player_pages.py. Not used by recommend / item_suggest.
"""
from __future__ import annotations
from typing import Any
def _int(v: Any, default: int = 0) -> int:
try:
return int(v)
except (TypeError, ValueError):
return default
def _float(v: Any, default: float = 0.0) -> float:
try:
return float(v)
except (TypeError, ValueError):
return default
def kda(kills: int, deaths: int, assists: int) -> float:
return round((kills + assists) / max(deaths, 1), 1)
def winrate(wins: int, losses: int) -> float | None:
total = wins + losses
if total <= 0:
return None
return round(wins / total * 1000) / 10
def aggregate_from_rows(rows: list[dict], *, limit: int | None = None) -> dict:
"""Aggregate match summary rows into a stats snapshot."""
sample = list(rows)
if limit is not None:
sample = sample[: max(0, limit)]
wins = 0
losses = 0
kills = deaths = assists = 0
gpm_sum = xpm_sum = dmg_sum = 0
gpm_n = xpm_n = dmg_n = 0
heroes: list[dict] = []
for r in sample:
if not isinstance(r, dict):
continue
if r.get("won"):
wins += 1
else:
losses += 1
k = _int(r.get("kills"))
d = _int(r.get("deaths"))
a = _int(r.get("assists"))
kills += k
deaths += d
assists += a
if r.get("gpm") is not None:
gpm_sum += _int(r.get("gpm"))
gpm_n += 1
if r.get("xpm") is not None:
xpm_sum += _int(r.get("xpm"))
xpm_n += 1
if r.get("hero_damage") is not None:
dmg_sum += _int(r.get("hero_damage"))
dmg_n += 1
heroes.append(
{
"match_id": _int(r.get("match_id")),
"hero_id": r.get("hero_id"),
"hero_key": r.get("hero_key"),
"hero_name_loc": r.get("hero_name_loc"),
"won": bool(r.get("won")),
}
)
n = wins + losses
out = {
"sample": n,
"wins": wins,
"losses": losses,
"winrate": winrate(wins, losses),
"kills": kills,
"deaths": deaths,
"assists": assists,
"kda": kda(kills, deaths, assists) if n else None,
"avg_kills": round(kills / n, 1) if n else None,
"avg_deaths": round(deaths / n, 1) if n else None,
"avg_assists": round(assists / n, 1) if n else None,
"avg_gpm": round(gpm_sum / gpm_n) if gpm_n else None,
"avg_xpm": round(xpm_sum / xpm_n) if xpm_n else None,
"avg_hero_damage": round(dmg_sum / dmg_n) if dmg_n else None,
"heroes": heroes,
}
return out
def career_from_opendota(wl: dict | None, totals: list | None) -> dict | None:
"""Build career block from OpenDota /wl + /totals. None when empty/private."""
wl = wl if isinstance(wl, dict) else {}
wins = _int(wl.get("win"))
losses = _int(wl.get("lose"))
if wins <= 0 and losses <= 0:
return None
by_field: dict[str, dict] = {}
if isinstance(totals, list):
for row in totals:
if isinstance(row, dict) and row.get("field"):
by_field[str(row["field"])] = row
def sum_of(field: str) -> int:
return _int((by_field.get(field) or {}).get("sum"))
def n_of(field: str) -> int:
return _int((by_field.get(field) or {}).get("n"))
n = wins + losses
kills = sum_of("kills")
deaths = sum_of("deaths")
assists = sum_of("assists")
gpm_n = n_of("gold_per_min")
xpm_n = n_of("xp_per_min")
dmg_n = n_of("hero_damage")
return {
"games": n,
"wins": wins,
"losses": losses,
"winrate": winrate(wins, losses),
"kills": kills,
"deaths": deaths,
"assists": assists,
"kda": kda(kills, deaths, assists) if n else None,
"avg_kills": round(kills / n, 1) if n else None,
"avg_deaths": round(deaths / n, 1) if n else None,
"avg_assists": round(assists / n, 1) if n else None,
"avg_gpm": round(sum_of("gold_per_min") / gpm_n) if gpm_n else None,
"avg_xpm": round(sum_of("xp_per_min") / xpm_n) if xpm_n else None,
"avg_hero_damage": round(sum_of("hero_damage") / dmg_n) if dmg_n else None,
"source": "opendota",
}
def top_heroes_from_opendota(
rows: list | None,
*,
hero_lookup: dict[int, dict],
limit: int = 5,
) -> list[dict]:
if not isinstance(rows, list):
return []
scored: list[tuple[int, dict]] = []
for row in rows:
if not isinstance(row, dict):
continue
games = _int(row.get("games"))
if games <= 0:
continue
hid = _int(row.get("hero_id"))
hero = hero_lookup.get(hid) or {}
wins = _int(row.get("win"))
last = row.get("last_played")
try:
last_i = int(last) if last is not None else None
except (TypeError, ValueError):
last_i = None
scored.append(
(
games,
{
"hero_id": hid or None,
"hero_key": hero.get("key"),
"hero_name_loc": hero.get("name_loc") or hero.get("key"),
"games": games,
"wins": wins,
"winrate": winrate(wins, max(0, games - wins)),
"last_played": last_i,
},
)
)
scored.sort(key=lambda t: (-t[0], -(_int(t[1].get("last_played")))))
return [item for _, item in scored[: max(1, limit)]]
def peers_from_opendota(rows: list | None, *, limit: int = 8) -> list[dict]:
if not isinstance(rows, list):
return []
out: list[dict] = []
for row in rows:
if not isinstance(row, dict):
continue
aid = _int(row.get("account_id"))
games = _int(row.get("games"))
if aid <= 0 or games <= 0:
continue
wins = _int(row.get("win"))
name = row.get("personaname")
if not isinstance(name, str) or not name.strip():
name = f"玩家 {aid}"
avatar = row.get("avatarfull") or row.get("avatar")
if not isinstance(avatar, str):
avatar = None
out.append(
{
"account_id": aid,
"personaname": name.strip(),
"avatar": avatar,
"games": games,
"wins": wins,
"winrate": winrate(wins, max(0, games - wins)),
}
)
if len(out) >= limit:
break
return out
def activity_from_matches(rows: list | None, *, days: int = 180) -> dict | None:
"""Build 180-day activity heatmap + sample highs from OpenDota /matches."""
if not isinstance(rows, list) or not rows:
return None
by_day: dict[str, dict] = {}
max_kills = max_assists = max_gpm = None
wins = losses = 0
for row in rows:
if not isinstance(row, dict):
continue
st = row.get("start_time")
try:
st_i = int(st) if st is not None else 0
except (TypeError, ValueError):
st_i = 0
if st_i <= 0:
continue
# UTC day key YYYY-MM-DD
from datetime import datetime, timezone
day = datetime.fromtimestamp(st_i, tz=timezone.utc).strftime("%Y-%m-%d")
cell = by_day.setdefault(day, {"games": 0, "wins": 0})
cell["games"] += 1
player_slot = _int(row.get("player_slot"))
radiant_win = bool(row.get("radiant_win"))
won = radiant_win if player_slot < 128 else not radiant_win
if won:
cell["wins"] += 1
wins += 1
else:
losses += 1
kills = _int(row.get("kills"))
assists = _int(row.get("assists"))
gpm = _int(row.get("gold_per_min"))
hero_id = _int(row.get("hero_id")) or None
if max_kills is None or kills > max_kills["value"]:
max_kills = {"value": kills, "hero_id": hero_id, "match_id": _int(row.get("match_id"))}
if max_assists is None or assists > max_assists["value"]:
max_assists = {
"value": assists,
"hero_id": hero_id,
"match_id": _int(row.get("match_id")),
}
if gpm > 0 and (max_gpm is None or gpm > max_gpm["value"]):
max_gpm = {"value": gpm, "hero_id": hero_id, "match_id": _int(row.get("match_id"))}
days_list = [
{"date": d, "games": v["games"], "wins": v["wins"]}
for d, v in sorted(by_day.items())
]
return {
"days": days,
"sample": wins + losses,
"wins": wins,
"losses": losses,
"winrate": winrate(wins, losses),
"heatmap": days_list,
"highs": {
"kills": max_kills,
"assists": max_assists,
"gpm": max_gpm,
},
"label": f"最近 {days} 天样本",
}
def merge_availability(
*,
opendota_recent_n: int,
career: dict | None,
steam_history_status: int | None,
fetched_at: str,
) -> dict:
"""Describe whether match history is public / syncing / private."""
od_public = opendota_recent_n > 0 or bool(career and career.get("games"))
steam_allowed = steam_history_status in (1,) # 1 = success
steam_denied = steam_history_status == 15
if od_public:
status = "public"
complete = True
note = None
elif steam_allowed and not od_public:
status = "syncing"
complete = False
note = "Steam 已公开,OpenDota 同步中"
elif steam_denied:
status = "private"
complete = False
note = "未公开比赛数据"
else:
status = "unknown"
complete = False
note = "暂无公开战绩"
return {
"status": status,
"complete": complete,
"opendota_public": od_public,
"steam_history_status": steam_history_status,
"source": "opendota+steam",
"fetched_at": fetched_at,
"note": note,
"stale": False,
}
def should_keep_old_career(old: dict | None, new: dict | None) -> dict | None:
"""HTTP 200 empty must not wipe a previously populated career."""
if new:
return new
if old and isinstance(old, dict) and _int(old.get("games")) > 0:
return old
return new
+5 -1
View File
@@ -12,8 +12,12 @@ misrecognized crops to failures/ (debug only, gitignored).
recognize_image() is the reusable entry point used by gsi_watch.py.
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
import time
import cv2
+329
View File
@@ -0,0 +1,329 @@
"""Draft suggestions from qualitative hero relations + lineup archetypes.
Mark available heroes with / / :
relation counters, push/global answers, punish enemy gaps
synergy with locked allies
fill ally tag gaps
Role-queue filters by position tags; otherwise all heroes are candidates.
Also returns a short analysis string and per-mark reasons (no AI).
Data: shared/data/relations.json + draft_archetypes rules.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from draft_archetypes import (
answer_for_candidate,
collect_reasons,
detect_archetypes,
detect_gaps,
format_analysis,
tag_profile,
)
from shared.grid import hero_table
from shared.hero_tags import tags_for_hero
from shared.relations import DEFAULT_RELATIONS, indexes, load_relations
DEFAULT_ROLE_TAGS = {
"1": ["Carry"],
"2": ["Carry", "Nuker", "Escape"],
"3": ["Initiator", "Durable", "Carry"],
"4": ["Support"],
"5": ["Support"],
}
# Soft boosts when candidate tags address a prominent enemy profile face.
# Never creates marks alone.
_PROFILE_BOOSTS: dict[str, tuple[str, ...]] = {
"爆发": ("耐久", "逃生"),
"推进": ("控制", "先手"),
"先手": ("逃生", "控制"),
"控制": ("逃生", "耐久"),
"核心": ("控制", "先手"),
"辅助": ("核心", "先手"),
}
_SOFT_BOOST = 0.25
_ARCH_BOOST = 0.5
_GAP_BOOST = 0.35
def _maps() -> tuple[dict[str, str], dict[str, list[str]], dict[str, list[str]]]:
table = hero_table()
names = {h["key"]: h["name_loc"] for h in table}
roles = {h["key"]: list(h.get("roles") or []) for h in table}
tags = {
h["key"]: list(h.get("tags") or []) or tags_for_hero(h["key"], h.get("roles"))
for h in table
}
return names, roles, tags
def enemy_keys(confirmed: dict[int, str], self_team: str | None) -> list[str]:
if self_team == "radiant":
slots = range(6, 11)
elif self_team == "dire":
slots = range(1, 6)
else:
return []
return [confirmed[s] for s in slots if confirmed.get(s)]
def ally_keys(confirmed: dict[int, str], self_team: str | None, self_slot: int | None = None) -> list[str]:
"""Teammates already locked (excludes your own slot)."""
if self_team == "radiant":
slots = range(1, 6)
elif self_team == "dire":
slots = range(6, 11)
else:
return []
out = []
for s in slots:
if self_slot is not None and s == self_slot:
continue
if confirmed.get(s):
out.append(confirmed[s])
return out
def candidates_for_position(
position: int | None,
*,
roles_by_key: dict[str, list[str]],
role_tags: dict | None = None,
) -> list[str]:
"""Role-queue filter. position=None means all heroes (non-role queue)."""
if position is None:
return list(roles_by_key.keys())
tags_map = role_tags or DEFAULT_ROLE_TAGS
wanted = set(tags_map.get(str(position)) or tags_map.get(position) or [])
if not wanted:
return []
out = []
for key, tags in roles_by_key.items():
if wanted.intersection(tags):
out.append(key)
return out
def enemy_profile(enemies: list[str], tags_by_key: dict[str, list[str]] | None = None) -> dict[str, int]:
"""Count Chinese draft tags across locked enemies."""
if tags_by_key is None:
_, _, tags_by_key = _maps()
return tag_profile(enemies, tags_by_key)
def _profile_soft_boost(cand_tags: list[str], profile: dict[str, int]) -> float:
if not profile or not cand_tags:
return 0.0
cand = set(cand_tags)
boost = 0.0
for face, n in sorted(profile.items(), key=lambda kv: (-kv[1], kv[0])):
if n <= 0:
continue
wanted = _PROFILE_BOOSTS.get(face)
if not wanted:
continue
if cand.intersection(wanted):
boost += _SOFT_BOOST * n
return boost
def _empty_result() -> dict:
return {
"enemy_profile": {},
"ally_profile": {},
"enemy_archetypes": [],
"enemy_gaps": [],
"ally_gaps": [],
"analysis": "",
"marks": [],
}
def suggest_marks(
*,
position: int | None,
enemies: list[str],
allies: list[str] | None = None,
exclude: set[str] | list[str],
relations: dict | None = None,
top_n: int | None = 0,
role_tags: dict | None = None,
min_enemies: int = 1,
min_heroes_for_gaps: int = 2,
archetypes_enabled: bool = True,
**_ignored,
) -> dict:
"""Return 克/搭/补 marks plus lineup analysis.
Requires at least ``min_enemies`` locked enemies. ``top_n`` None/<=0 means
no truncation. ``position`` None = non-role queue (all heroes).
"""
allies = list(allies or [])
enemies = list(enemies or [])
if len(enemies) < max(1, int(min_enemies)):
return _empty_result()
rel = relations if relations is not None else load_relations()
has_rel = bool(rel.get("counters") or rel.get("synergies"))
if not has_rel and not archetypes_enabled:
return _empty_result()
names, roles_by_key, tags_by_key = _maps()
counters_of, countered_by, synergies_of = indexes(rel) if has_rel else ({}, {}, {})
exclude_set = {e for e in exclude if e}
e_profile = tag_profile(enemies, tags_by_key)
a_profile = tag_profile(allies, tags_by_key)
archetypes: list[str] = []
enemy_gaps: list[str] = []
ally_gaps: list[str] = []
if archetypes_enabled:
archetypes = detect_archetypes(enemies, tags_by_key)
enemy_gaps = detect_gaps(
e_profile, hero_count=len(enemies), min_heroes=min_heroes_for_gaps
)
ally_gaps = detect_gaps(
a_profile, hero_count=len(allies), min_heroes=min_heroes_for_gaps
)
analysis = format_analysis(
archetypes=archetypes,
enemy_gaps=enemy_gaps,
ally_gaps=ally_gaps,
ally_count=len(allies),
)
scored: list[tuple[float, str, dict]] = []
for cand in candidates_for_position(position, roles_by_key=roles_by_key, role_tags=role_tags):
if cand in exclude_set:
continue
cand_tags = tags_by_key.get(cand) or []
beats = []
beaten_by = []
with_allies = []
for ek in enemies:
for edge in counters_of.get(cand) or []:
if edge["key"] == ek:
beats.append({"enemy": ek, "reason": edge.get("reason") or ""})
for edge in countered_by.get(cand) or []:
if edge["key"] == ek:
beaten_by.append({"enemy": ek, "reason": edge.get("reason") or ""})
for ak in allies:
for edge in synergies_of.get(cand) or []:
if edge["key"] == ak:
with_allies.append({"ally": ak, "reason": edge.get("reason") or ""})
arch_hits, punish_gaps, fill_gaps = answer_for_candidate(
cand,
cand_tags,
archetypes=archetypes,
enemy_gaps=enemy_gaps,
ally_gaps=ally_gaps,
)
labels: list[str] = []
if beats or arch_hits or punish_gaps:
labels.append("")
if with_allies:
labels.append("")
if fill_gaps:
labels.append("")
if not labels:
continue
reasons = collect_reasons(
names=names,
beats=beats,
with_allies=with_allies,
archetype_hits=arch_hits,
punish_gaps=punish_gaps,
fill_gaps=fill_gaps,
)
soft = _profile_soft_boost(cand_tags, e_profile)
score = (
float(len(beats) - len(beaten_by) + len(with_allies))
+ soft
+ _ARCH_BOOST * len(arch_hits)
+ _GAP_BOOST * (len(punish_gaps) + len(fill_gaps))
)
scored.append((score, cand, {
"labels": labels,
"beats": beats,
"beaten_by": beaten_by,
"with": with_allies,
"reasons": reasons,
"score": round(score, 3),
}))
scored.sort(key=lambda t: (t[0], t[1]), reverse=True)
limit = None if top_n is None or int(top_n) <= 0 else int(top_n)
sliced = scored if limit is None else scored[:limit]
marks = []
for rank, (_score, key, detail) in enumerate(sliced, start=1):
marks.append({
"key": key,
"name_loc": names.get(key, key),
"rank": rank,
**detail,
})
return {
"enemy_profile": e_profile,
"ally_profile": a_profile,
"enemy_archetypes": archetypes,
"enemy_gaps": enemy_gaps,
"ally_gaps": ally_gaps,
"analysis": analysis,
"marks": marks,
}
def suggest_top(
*,
position: int | None,
enemies: list[str],
allies: list[str] | None = None,
exclude: set[str] | list[str],
relations: dict | None = None,
top_n: int | None = 0,
role_tags: dict | None = None,
min_enemies: int = 1,
min_heroes_for_gaps: int = 2,
archetypes_enabled: bool = True,
**_ignored,
) -> list[dict]:
"""Compatibility wrapper: return mark list from ``suggest_marks``."""
return suggest_marks(
position=position,
enemies=enemies,
allies=allies,
exclude=exclude,
relations=relations,
top_n=top_n,
role_tags=role_tags,
min_enemies=min_enemies,
min_heroes_for_gaps=min_heroes_for_gaps,
archetypes_enabled=archetypes_enabled,
**_ignored,
)["marks"]
__all__ = [
"DEFAULT_RELATIONS",
"DEFAULT_ROLE_TAGS",
"ally_keys",
"candidates_for_position",
"enemy_keys",
"enemy_profile",
"load_relations",
"suggest_marks",
"suggest_top",
]
+5
View File
@@ -9,6 +9,11 @@ value+saturation isolates it cleanly. Matching is done on the binary mask
they differ in width, so mask IoU separates them by a wide margin.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import cv2
import numpy as np

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Some files were not shown because too many files have changed in this diff Show More