Compare commits

..
50 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
410 changed files with 56705 additions and 13966 deletions
+12
View File
@@ -1,2 +1,14 @@
# Copy to .env (gitignored). Get a free token at https://stratz.com/api # 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= 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 Thumbs.db
*.log *.log
# OSS ability-video upload runtime (regenerable)
.oss_ability_videos_bucket
oss_upload.pid
# Runtime / debug artifacts (regenerable) # Runtime / debug artifacts (regenerable)
preview/ pc/preview/
results/ pc/results/
failures/ pc/failures/
samples/raw/ pc/samples/raw/
pc/player_pages/
_tools/ _tools/
_tmp_* _tmp_*
_cmp_* _cmp_*
@@ -20,18 +25,40 @@ plans/
*_log.txt *_log.txt
# Regenerable data caches / reports (re-fetch or re-run scripts) # Regenerable data caches / reports (re-fetch or re-run scripts)
data/matchups.json shared/data/synergies.json
data/synergies.json shared/data/stratz_matchups.json
data/stratz_matchups.json shared/data/relations_audit.json
data/relations_audit.json shared/data/relations_import_report.json
data/relations_import_report.json shared/data/relations_patch_*
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) # Large regenerable CDN media (fetch scripts)
assets/ability_videos/ web/assets/ability_videos/
assets/ability_icons/ # Regenerable Steam CDN ability icons; keep bundled shared badges in git.
assets/hero_portraits/ web/assets/ability_icons/*
assets/item_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) # 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 -98
View File
@@ -8,138 +8,239 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
- 中文名:上分帝 - 中文名:上分帝
- 英文名 / 仓库目录:Climperor / `climperor` - 英文名 / 仓库目录:Climperor / `climperor`
- 模板来源:**仅** Steam CDN 官方头像(`templates/cdn/` - 模板来源:**仅** Steam CDN 官方头像(`pc/templates/cdn/`
- **已放弃** real 实拍模板库补充;不要再引入 `templates/real/` 或入库流水线 - **已放弃** 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 模板加载 | | `pc/common.py` | 配置 IO、槽位几何、裁切、NCC 匹配、天梯遮罩、CDN 模板加载re-export `shared.paths` 常量 |
| `recognize.py` | 单帧识别;`recognize_image()` 供会话复用 | | `pc/recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
| `draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 | | `pc/draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
| `gsi_watch.py` / `gsi_setup.py` | GSI 监听与 cfg 安装 | | `pc/gsi_watch.py` / `pc/gsi_setup.py` | GSI 监听与 cfg 安装`POST_GAME` 触发 `player_pages` 赛后建页 |
| `http_utils.py` | 所有 fetch 脚本共用的 HTTP 请求、图标下载、Valve datafeed 加载(统一 UA / 超时) | | `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 |
| `fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量) | | `pc/player_stats.py` | 玩家聚合公式(KDA/胜率/生涯/近 N 场/180 天热力图);空 OpenDota 不覆盖旧 career |
| `fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `data/heroes.json`(含基础属性/血蓝;保留已有 `aliases` | | `pc/fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `shared/data/heroes.json`(含基础属性/血蓝;保留已有 `aliases` |
| `relations.py` | 定性克制/搭档边读写与名称解析 | | `pc/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
| `import_relations_xlsx.py` | 从选将笔记 xlsx 种子导入 `data/relations.json` | | `pc/item_suggest.py` | 本人锁定后:`hero_items` 核心装 + 敌方 tags/画像定性应对装(不读 fears/STRATZ 统计) |
| `fetch_stratz.py` | 拉取 STRATZ 对位优势 / 搭档协同 → `data/stratz_matchups.json` / `data/synergies.json`(需 token`--mode matchups\|synergies` | | `pc/draft_archetypes.py` | 规则阵容画像(推进/全球流/敌我缺口)+ `analysis` / `reasons` 文案(不接 AI |
| `fetch_matchups.py` | 拉取 OpenDota 对位优势 → `data/matchups.json`(供 `audit_relations.py` 交叉审计) | | `pc/autocalibrate.py` / `pc/calibrate.py` | ROI 自动 / 手动标定 |
| `audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄 | | `pc/capture.py` | 屏幕捕获 |
| `recommend.py` | 分路过滤;按克制/被克制/搭档边打分,输出 Top-3 | | `pc/overlay.py` | 网格「克/搭/补」方标 + 阵容分析横条 + 锁定后装备图标条(点击穿透;不再在顶栏头像下画定位) |
| `serve_relations.py` | 游戏外关系/物品只读预览(`web/relations/`;改 `data/relations.json` | | `pc/evaluate.py` | 按 `pc/samples/labels.json` 批量评测 |
| `export_relations_site.py` | 导出关系预览为纯静态站点 → `dist/relations/`data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/relations/config.js` 同步;`--ability-video-base``config.js` 指向 OSS 技能视频;`--with-videos` 可选本地拷贝视频,生产部署勿用 | | `pc/roles.py` / `pc/modes.py` | 位置字、模式字(禁用网格在 `shared/grid.py` |
| `deploy_relations.py` | 一键部署关系预览静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 `--ability-video-base` 指向 `climperor` OSS;凭据经 keyzoo 注入或 env | | `pc/config.json` | 相对坐标、阈值、GSI、recommend 参数(`relations_path` 指向 `shared/data/relations.json` |
| `_cf_status.py` | 只读查询 Cloudflare Pages 项目 / 部署 / 自定义域名状态(凭据经 keyzoo 注入 | | `pc/templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成 |
| `fetch_hero_portraits.py` | 拉取官网横版头像 → `assets/hero_portraits/`(关系预览) | | `pc/templates/roles/` / `pc/templates/modes/` | 位置 / 模式辅助模板 |
| `fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `data/hero_items.json` + `assets/item_icons/` | | `pc/assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边) |
| `fetch_item_shop.py` | 官网商店 11 列目录(dota2.com.cn/itemscategory+ 合成图 → `data/item_shop.json` + 图标 | | `pc/requirements.txt` | PC 侧依赖(opencv/numpy/mss/openpyxl |
| `fetch_items_meta.py` | Valve/OpenDota 装备描述 → 机制标签 → `data/items_meta.json``%token%` 用 special_values 填数) |
| `loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}` | ### shared/(共享包)
| `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 自动 / 手动标定 | | `shared/paths.py` | 全部路径常量单一来源(`ROOT`/`SHARED_DATA`/`HEROES_JSON`/`TEMPLATES_CDN`/`PC_PLAYER_PAGES`/`DATA`/`WEB_FRONTEND`/各 Web 资产目录);纯常量、零第三方依赖 |
| `capture.py` | 屏幕捕获 | | `shared/grid.py` | 英雄表 `hero_table()`、选人网格布局与禁用读取(cv2/numpy 懒加载,Web/CI 侧只取表不触发) |
| `overlay.py` | 顶栏角色标签 + 选将网格左上角青标 Top-3(点击穿透) | | `shared/relations.py` | 定性克制/搭档边读写与名称解析;默认路径 `shared/data/relations.json` |
| `evaluate.py` | 按 `samples/labels.json` 批量评测 | | `shared/hero_tags.py` | 中文定位 tags(核心/辅助/…/幻象) |
| `roles.py` / `grid.py` / `modes.py` | 位置字、禁用网格、模式字 | | `shared/http_utils.py` | 所有 fetch 脚本共用的 HTTP 请求、图标下载、Valve datafeed 加载(统一 UA / 超时;429/5xx 指数退避重试) |
| `config.json` | 相对坐标、阈值、GSI、recommend 参数 | | `shared/import_relations_xlsx.py` | 从选将笔记 xlsx 种子导入 `shared/data/relations.json` |
| `data/heroes.json` | 英雄表(id/key/attr/roles/aliases/abbr/tags + 基础属性/初始血蓝等;由 `fetch_cdn_templates.py` 生成 | | `shared/fetch_stratz.py` | 拉取 STRATZ 对位优势 / 搭档协同 → `shared/data/stratz_matchups.json` / `shared/data/synergies.json`(需 token`--mode matchups\|synergies`;供 `audit_relations.py` |
| `data/patches.json` | 近一年版本列表 + 逐版本详情(`patches`/`lookup`/`details`;由 `fetch_patches.py` 生成,预览版本页只读 | | `shared/audit_relations.py` | 用 STRATZ 对位/搭档缓存审计 `relations.json`,列出缺失英雄与候选边(不改写 relations;Web 对位页只读 `stratz_matchup_tops.json` |
| `data/relations.json` | 定性克制边 + 搭档边(可提交;手改 JSON,预览页只读 | | `shared/data/heroes.json` | 英雄表(id/key/attr/roles/aliases/abbr/tags + 基础属性/初始血蓝等;由 `pc/fetch_cdn_templates.py` 生成 |
| `data/hero_grid_order.json` | 预览页四列网格顺序 | | `shared/data/relations.json` | 定性克制边 + 搭档边(可提交;手改 JSON,Web 站点只读) |
| `data/hero_items.json` | 核心成品装备缓存(预览页只读;由 `fetch_hero_items.py` 生成) |
| `data/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) | ### web/(上分帝 Web
| `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` 生成 | | `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 detailsstdout JSON |
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;预览「怕」行) | | `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 |
| `web/relations/` | 关系只读预览页静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js`);`config.js``SITE_VERSION``ABILITY_VIDEO_BASE`;版本页底部显示 `v{SITE_VERSION}` | | `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/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索 | | `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 |
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成 | | `web/steam_auth.py` | Steam OpenID + 签名 Cookie 会话(本地 serve 用;与 `web/frontend/functions/api/auth/*` 对齐 |
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 | | `web/frontend/functions/api/auth/` | Pages FunctionsSteam 登录 / 回调 / me / logoutCookie `climperor_steam`Env`STEAM_API_KEY``SESSION_SECRET` |
| `assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边 | | `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` 可选本地拷贝技能/主播视频,生产部署勿用 |
| `assets/hero_portraits/` | 官网横版头像(关系预览;默认 wide 面部构图,非匹配模板) | | `web/seo_prerender.py` | 导出期 SEO/GEO:注入 title/description/canonical/OG/JSON-LD 与 `#seo-prerender` 正文;生成全英雄 `/heroes/{key}`、机制 `/mechanics/{effect}`、顶层页、`sitemap.xml``llms.txt` |
| `assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,关系预览用 | | `web/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env |
| `assets/item_icons/` | Steam CDN 装备图标(关系预览常用装备) | | `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 |
| `assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) | | `web/refresh_cache.py` | Gitea Actions 刷新状态缓存桥:文件锁内从 runner `~/.cache/climperor-web-refresh/` 恢复/保存,`web/.refresh-cache/` 供 Actions cache 冷启动备份;覆盖所有定时生成数据与 `patches.json`,主播只合并抓取字段,保留 git 中手工名单 |
| `assets/ability_icons/` | Steam CDN 技能图标(关系预览;按需缓存);`innate.png` 先天共用图标;`talent_tree.png` 天赋树触发图标 | | `web/notify_site_traffic.py` | 上分帝 Web 日报 → 飞书 webhook(访问估数 + Gitea `web-daily`/`weekly`/`patch` 运行结论/`REFRESH_SUMMARY` + Pages 当日生产部署 + 生产数据新鲜度与 live API 探针;`--dry-run` 只打印卡片) |
| `assets/ui_icons/` | dota2.com.cn 通用 UI 图标(`cooldown.png` 等,技能详情底栏冷却图标) | | `.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 |
| `assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor``ability-video/` 前缀托管 | | `.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`): 运行时产物(**勿提交**,见 `.gitignore`):
- `samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下 - `pc/samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下
- `preview/` — 标定 / sheet 预览 - `pc/player_pages/` — 赛后玩家主页/比赛 JSON(OpenDota;默认私有;同意公开后入队 Cloudflare D1/R2,不进主 data.json
- `dist/` — 静态站点导出(`export_relations_site.py` - `pc/preview/` — 标定 / sheet 预览
- `results/` — 每局 JSON - `web/dist/` — 静态站点导出(`export_relations_site.py`
- `failures/``--truth` 调试用错识裁切 - `pc/results/` — 每局 JSON
- `data/matchups.json` / `synergies.json` / `stratz_matchups.json` / `relations_audit.json` 等 — 可再生成缓存与报告 - `pc/failures/` `--truth` 调试用错识裁切
- `assets/ability_videos/` — 官网技能演示(体积大,可再拉取) - `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) Dota 2 GSI → pc/gsi_watch.py (:3223)
→ 全量 payload → samples/raw/<matchid>/gsi.jsonl(可关) → 全量 payload → pc/samples/raw/<matchid>/gsi.jsonl(可关)
→ DraftSession 轮询截屏 → samples/raw/<matchid>/ → DraftSession 轮询截屏 → pc/samples/raw/<matchid>/
→ recognize_image (CDN 模板 + 可选天梯遮罩) → recognize_image (CDN 模板 + 可选天梯遮罩)
→ roles(分路字)/ grid(禁用 + 单元格)/ modes → roles(分路字)/ shared.grid(禁用 + 单元格)/ modes
→ recommend Top-3data/relations.json 定性边,本人槽位只信 GSI → recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI
→ overlay 角色标签 + 网格青标(可选) → overlay 网格克/搭/补 + 阵容分析条;本人锁定后改推装备图标条(可选)
→ results/draft_*.json + 终端时间线 pc/results/draft_*.json + 终端时间线
→ POST_GAMEplayer_pages 轮询 OpenDota → pc/player_pages/(可选 publish → Queue → D1/R2
``` ```
## 技术约束(修改前必读) ## 技术约束(修改前必读)
- **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。 - **合规**:仅 GSI + 屏幕截图;**禁止**读进程内存、注入、绕过 VAC。
- **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。 - **GSI 范围**:普通玩家视角拿不到双方 pick;GSI 只作阶段触发、`team_slot`、本人 `hero`、本机 `accountid`/`steamid`。本人顶栏槽位**只信** GSI,不用截屏名字亮度猜测。
- **克制 / 搭档数据**定性边存 `data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `data/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` 规则映射到英雄弱点。大段技能文案只进 `data/hero_abilities.json` / `items_meta.json`**不要**塞进 `heroes.json`本阶段仅关系预览展示,**不对局内出装推荐** - **装备机制 / 怕的装备**:标签与技能汇总来自 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 双层库。 - **模板策略**:只维护 CDN 层。皮肤问题用会话策略(选人可改判、决策 `allow_revise=False`、best 帧偏 HERO_SELECTION),不要为皮肤加模板库,也不要复活 real 双层库。
- **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。 - **顶栏时机**:皮肤在**全员选完后**才上顶栏;本机进决策时别人可能还在选——须保持 `strategy_tail` 视觉,禁止「一进 STRATEGY 就永久停读」。
- **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。 - **坐标**:一律相对坐标(相对屏幕高 / 相对中心),勿写死像素分辨率。
- **宁可不认,不可乱认**`min_score` + `min_margin` 双门控;不确定就 `null` - **宁可不认,不可乱认**`min_score` + `min_margin` 双门控;不确定就 `null`
- **平台**:面向 Windows;截屏依赖无边框/窗口模式。 - **平台**:面向 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 ```powershell
pip install -r requirements.txt pip install -r pc/requirements.txt
python fetch_cdn_templates.py pip install -r web/requirements.txt # 可选:本机 refresh_web / OSSCI 会装)
python import_relations_xlsx.py # 可选:从选将笔记 xlsx 导入关系 python pc/fetch_cdn_templates.py
python fetch_hero_portraits.py # 官网横版头像(关系预览) python shared/import_relations_xlsx.py # 可选:从选将笔记 xlsx 导入关系
python fetch_hero_items.py # OpenDota 热门装备缓存(关系预览 python web/fetch_hero_portraits.py # 官网横版头像(上分帝 Web
python fetch_item_shop.py # 商店分类目录(物品页 python web/fetch_hero_items.py # OpenDota 热门装备缓存(上分帝 Web
python fetch_items_meta.py # 装备描述 + 机制标签 python web/fetch_hero_stats.py # OpenDota 各段位胜率/场次(上分帝 Web 走势兜底)
python fetch_hero_abilities.py # 英雄技能 / 驱散汇总 python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top(走势/对位/Meta;需 token
python fetch_hero_abilities.py --icons-only # 缓存技能图标到 assets/ability_icons/ python web/fetch_hero_matches.py # 同英雄近期比赛出装/加点(上分帝 Web;可 --heroes antimage--workers 6--enrich-item-times
python fetch_ability_videos.py # 官网技能演示(限速;默认 webm python web/fetch_pro_matches.py # 明星选手近期联赛对局(默认 watchlist--include-pubs 含天梯;--refresh-limit 15 轮换;可 --players Ame,898754153
python item_fears.py # 英雄怕的装备 python web/fetch_leaderboards.py # Valve Immortal 四区 Top100(排行页)
python fetch_patches.py # 近一年版本日志 + 详情(版本页 python web/fetch_streamers.py # 抖音主播主页补全(主播页;手工名单;亦由 daily 定时软失败刷新
python serve_relations.py # 浏览器只读预览 英雄关系 / 物品商店 python web/fetch_streamer_live.py # 探测真实在播状态回写 is_live(主播页角标;daily--dry-run 只打印)
python export_relations_site.py # 导出静态站点到 dist/relations/(可部署 Pages python web/fetch_item_shop.py # 商店分类目录(物品页
python export_relations_site.py --ability-video-base https://climperor.oss-cn-shanghai.aliyuncs.com python web/fetch_items_meta.py # 装备描述 + 机制标签
python deploy_relations.py # 部署 dist/relations 到 Cloudflare Pages(凭据经 keyzoo 注入或 env;默认 dota2.refining.dev;技能视频走 OSS python web/fetch_hero_abilities.py # 英雄技能 / 驱散汇总
python _cf_status.py # 只读查 CF Pages 部署 / 自定义域名状态 python web/fetch_hero_abilities.py --tags-only # 只重算技能「施加」类 tags
python gsi_setup.py --check python web/fetch_hero_abilities.py --icons-only # 缓存技能图标到 web/assets/ability_icons/
python gsi_watch.py --once python web/fetch_ability_videos.py # 官网技能演示(限速;默认 webm)
python recognize.py samples/raw/<>.png --sheet python web/fetch_item_counter_stats.py # OpenDota 对阵装备相对全局基线(可再生成缓存)
python evaluate.py 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` - 改匹配阈值或裁切时,用 `pc/evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `ARCHITECTURE.md`
- GSI cfg 时同步核对 `gsi_setup.py` 与 Dota `gamestate_integration` 目录 -上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度
-预览路由形态(URL 段 / query 参数 / 默认值)时同步 `web/relations/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`serve_relations.py` 无需改;`export_relations_site.py` 的导出文件列表须含 `router.js` - GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录
-`data/heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc`);`roles``overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案 -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`
- 发版关系预览时同步 bump `export_relations_site.py``SITE_VERSION``web/relations/config.js` 的同名变量,以及 `index.html` `config.js`/`router.js`/`app.js` `?v=` 缓存戳;并写 `CHANGELOG.md` - `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` - 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py`
## 文档分工 ## 文档分工
@@ -147,7 +248,8 @@ python evaluate.py
| 文件 | 内容 | | 文件 | 内容 |
|------|------| |------|------|
| `README.md` | 安装与操作手册 | | `README.md` | 安装与操作手册 |
| `DESIGN.md` | 背景、选型、关键实测与决策记录 | | `ARCHITECTURE.md` | 背景、选型、关键实测与决策记录 |
| `DESIGN.md` | 上分帝 Web 视觉设计规范(令牌 + 使用规则;[design.md](https://github.com/google-labs-code/design.md) 格式) |
| `AGENTS.md` | 本文件:给协作者 / AI 的约束 | | `AGENTS.md` | 本文件:给协作者 / AI 的约束 |
| `CHANGELOG.md` | 面向用户的版本变更 | | `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`
+1109 -36
View File
File diff suppressed because it is too large Load Diff
+326 -215
View File
@@ -1,256 +1,367 @@
# 上分帝(Climperor)—— 方案与实施细节 ---
version: alpha
`README.md` 是操作手册;本文记录背景、选型与关键决策。协作约束见 `AGENTS.md` 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 可获得的阵容数据 | - **Dense but calm**:列表、表格、网格可挤满视口,但字号、行高、间距仍保持清晰节奏。
|------|---------------------| - **Semantic color first**:青色强调当前选择与导航;绿 / 橙 / 青蓝分别表示克制 / 被克 / 搭档;粉红仅用于直播态。
| 排位 / 普通 All Pick | 仅自己的 `hero.id``draft` 通常为空 | - **Desktop shell**`100dvh` 外框 + 页内滚动;手机 / 平板由 `mobile-gate` 拦截,不为真移动端另做一套 IA。
| Captains Mode | 历史上有部分 pick/ban,不稳定 |
| 观战 / 裁判视角 | 阵容字段较全 |
| 赛后 | 需依赖 OpenDota 等外部 API |
Valve 已明确关闭普通玩家视角的实时 draft 实现源:`web/frontend/style.css` 的 CSS 变量必须与本文件令牌一一对应。工程决策见 `ARCHITECTURE.md`
[#9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562)、
[#7193](https://github.com/ValveSoftware/Dota2-Gameplay/issues/7193)not planned)。
### 候选方案 ## 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`。正文与控件优先清晰,不引入全站装饰字体。
``` 品牌例外:顶栏 / 移动端门禁的「上分帝」使用 `brand`(自托管 Noto Serif SC 子集,仅 U+4E0A / U+5206 / U+5E1D),宋体气质贴合「帝」字;正文仍走系统无衬线栈。禁止把衬线栈扩到导航、页标题或表格。
截图 → 相对坐标裁 10 格 →(天梯则遮罩段位条)→ CDN NCC 匹配
→ score + margin 双门控 → hero_key | null
```
### 关键决策 | 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 - 顶栏使用近黑金属渐变与上下 1px 冷色高光,形成连续横梁;总高控制在约 60px。
- `margin = Top1 Top2 >= min_margin`(默认 0.04 - 品牌区仅用克制的暗红内光形成视觉锚点,不在 Logo 右侧增加分隔线;不使用视频 Logo、金色奖项条或厚重贴图。
- 主导航保持现代无边框文字 Tab;相邻 Tab 仅用低对比斜切细线分隔,不做梯形按钮。
- 主交互色仍为 `primary` 青色;Dota 红只用于品牌区,不替代全站选中态。
- 宽屏主行采用 44px 控件高:主导航 16px / 最小 76px 宽,品牌 Logo 32px。`lg` 断点恢复 40px、15px 的紧凑规格。
**④ 会话层处理皮肤与淡入** 断点降级:
- 选人阶段:多帧确认、允许更高分改判(`revise_gain` - `lg` ≤1200px:Tab 水平内边距收紧;搜索宽约 160–180px
- 决策阶段:只补空槽、禁止改判(避免皮肤顶栏覆盖默认脸结论) - `md` ≤900px:隐藏品牌标题,仅留 logo;搜索再缩短
- best 帧优先选人阶段默认脸(`recognized` 相同时) - `sm` ≤640px:极窄桌面可隐藏整块品牌
- 本人英雄以 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/
├── 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 | | full | 100% | 英雄网格 |
| `match.ranked_mask` | 天梯遮罩比例 | bottom 0.32, right 0.22 | | read | 主列 `min(820px)` 页内居中;宽屏「AI 解读」贴主列右侧(不参与居中),宽度随可用侧栏约 280–420px,sticky + 视口内自滚动 | 版本 |
| `gsi.port` | 监听端口 | 3223 | | standard | `min(880px, calc(100% - 48px))` | 排行、主播、机制 |
| `gsi.confirm_polls` | 连续同结果帧数 | 2 | | wide | `min(1080px, …)` | 走势 |
| `gsi.revise_gain` | 选人阶段改判所需分数增益 | 0.15 | | data | `min(1200px, …)`(居中时两侧预留侧栏) | 比赛、玩家「我」主页 |
| `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` |
--- 间距尺度:4 / 8 / 12 / 16 / 24 / 32 / 48px。页内 gutter 默认 16px;列表页上下 `page-y` 28px。侧栏与主列间距 20px。
## 5. GSI 自动化 断点收敛为三档:
``` - `sm` ≤640px:极窄桌面(列堆叠、双列网格)
Dota 2 (-gamestateintegration) - `md` ≤900px:aside 改上下堆叠、英雄两列
→ POST → gsi_watch.py :3223 - `lg` ≤1200px:物品详情上移、列间距收紧
→ samples/raw/{matchid}/gsi.jsonl(可选)
→ DraftSession 轮询 recognize + roles/grid/modes
→ results/draft_*.json
```
要点:按 `matchid` 去重;截图进 `samples/raw/{matchid}/`;识别在工作线程; 手机不走上述断点,由 UA 门禁接管。
未标定时降级为只截图。
天梯 AP 选人按 2/2/1 成批揭晓,本轮结束前互不可见——因此必须在 ## Elevation & Depth
**HERO_SELECTION** 跟踪,不能只在决策时间截一张终局图;决策前期仍读几帧
以吃到最后一人揭晓(见发现七)。
--- 深度靠色调分层与细边框,不用厚重投影:
## 6. 选将 Top-3 推荐 1. 页面渐变底
2. 半透明顶栏 / 面板(`panel-soft`
3. 实心一点的浮层(`panel`、物品 sticky 详情、天赋 popover
- **分路**:定位匹配画面字(`roles.py`+ GSI `team_slot` 定位本人;不做截屏认自己 选中态用 inset 底线、填充主色或 1–2px `accent` 描边;阴影仅用于浮层(popover / 气泡),模糊约 2432px、黑色 45% 透明度
- **关系模型**:机制克制/搭档(`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`
--- ## Shapes
## 7. 与 dota2-hex 圆角语言偏克制:
独立验证项目。可选:Rust 重写并入,或本地旁路 HTTP 回传。 - `sm` 4px:表格内图标、小徽章
合规同 `dota2-hex`**屏幕可见信息 only**。 - `md` 8px:输入框、多数按钮、指标卡
- `lg` 12px:详情面板、机制卡、主内容卡
- `xl` 16px:仅大卡片(主播卡)需要时使用,勿扩散到所有面板
- `full`:筛选 pill、关注按钮
--- 禁止同一视图混用锐角与大圆角而无语义理由。
## 8. 参考 ## Components
- [Valve #9562](https://github.com/ValveSoftware/Dota2-Gameplay/issues/9562) ### 玩家主页(Identity / Snapshot / Dashboard
- [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/) 登录后「我」与 `/players/{id}` 共用**紧凑数据仪表盘**语言,不做小黑盒式社交 feed:
- Steam CDN`https://cdn.cloudflare.steamstatic.com/apps/dota2/images/dota_react/heroes/{key}.png`
- **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 -37
View File
@@ -4,7 +4,7 @@ Dota 2 **天梯选将识别**:从选人 / 决策时间截图中识别双方 10
本地、离线、秒级;模板来自 Steam CDN 官方头像。GSI 只负责触发时机,阵容靠视觉读顶栏。 本地、离线、秒级;模板来自 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,57 +21,119 @@ Dota 2 **天梯选将识别**:从选人 / 决策时间截图中识别双方 10
## 安装 ## 安装
```powershell ```powershell
pip install -r requirements.txt pip install -r pc/requirements.txt
python fetch_cdn_templates.py # 一次性:data/heroes.json(含基础属性/血蓝)+ templates/cdn/ python pc/fetch_cdn_templates.py # 一次性:shared/data/heroes.json(含基础属性/血蓝)+ pc/templates/cdn/
# 可选:从选将笔记 xlsx 导入定性克制/搭档 # 可选:从选将笔记 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 ```powershell
python fetch_hero_portraits.py # 官网竖版头像 → assets/hero_portraits/ python web/fetch_hero_portraits.py # 官网竖版头像 → web/assets/hero_portraits/
python fetch_hero_items.py # OpenDota 热度 + Valve 中文名 + Steam 图标 → data/hero_items.json python web/fetch_hero_items.py # OpenDota 热度 + Valve 中文名 + Steam 图标 → web/data/hero_items.json
python fetch_item_shop.py # 官网商店 11 列目录 → data/item_shop.json python web/fetch_hero_stats.py # OpenDota 各段位胜率/场次 → web/data/hero_stats.jsonWeb 走势兜底)
python fetch_items_meta.py # 装备描述 + 机制标签 → data/items_meta.json python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top → web/data/(需 token;对位默认全量刷新,--resume-matchups 仅续跑)
python fetch_hero_abilities.py # 英雄技能 / 驱散汇总 → data/hero_abilities.json python web/fetch_hero_matches.py # 英雄近期比赛出装/加点(含购买时间)→ web/data/hero_matches.json
python fetch_ability_videos.py # 官网技能演示 webm(限速)→ assets/ability_videos/ # 既有行补时间:python web/fetch_hero_matches.py --enrich-item-times [--heroes juggernaut]
python item_fears.py # 规则推导「怕的装备」→ data/hero_item_fears.json python web/fetch_pro_matches.py # 明星选手近期联赛/锦标赛 → web/data/pro_matches.json(默认 watchlist
python fetch_patches.py # 近一年版本日志 + 详情 → data/patches.json(版本页) # 含天梯:python web/fetch_pro_matches.py --include-pubs --limit 8
python serve_relations.py # 只读预览 http://127.0.0.1:8765(改 JSON 后刷新) # 指定选手(会整文件覆盖):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 ### 部署到 Cloudflare Pages
```powershell ```powershell
python deploy_relations.py python web/deploy_relations.py
# 串联:导出 → 资产完整性预检 → wrangler 直传 → 绑定自定义域名 # 串联:导出 → 资产完整性预检 → wrangler 直传 → 绑定自定义域名
# 凭据经 keyzoo 注入(refining/cloudflare 的 Global API Key); # 凭据经 keyzoo 注入(refining/cloudflare 的 Global API Key);
# 或手动设 $env:CLOUDFLARE_EMAIL / $env:CLOUDFLARE_API_KEY 后运行 # 或手动设 $env:CLOUDFLARE_EMAIL / $env:CLOUDFLARE_API_KEY 后运行
# 默认项目 climperor-relations、域名 dota2.refining.dev(可用 --project-name / --domain 覆盖) # 默认项目 climperor-relations、域名 dota2.refining.dev(可用 --project-name / --domain 覆盖)
# 技能演示视频默认走阿里云 OSS 桶 climperorPages 只部署 ~42MB 静态资源 # 技能演示视频与静态图标默认走阿里云 OSS 桶 climperorPages 只部署 ~2MB HTML/JS/data
python _cf_status.py # 部署后只读核对(项目 / 部署 / 域名状态) 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`)。 顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `web/data/leaderboards.json``python web/fetch_leaderboards.py`)。
顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `data/patches.json``python fetch_patches.py`,近一年窗口可用 `--days` / `--since` 调) 顶部另有 **主播** 页:手工收录直播间/主页(抖音 + 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/`
数据在 `data/relations.json`(英雄关系,不用胜率);选将 Top-3 推荐读同一份文件。热门装备来自 OpenDota 统计;被克装备由描述标签 + 规则推导(可改 `data/item_tag_overrides.json` 后重跑 `fetch_items_meta.py``item_fears.py`)。**尚无对局内出装推荐** 顶部另有 **比赛** 页(`/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 自动跟踪) ## 快速开始(GSI 自动跟踪)
```powershell ```powershell
python gsi_setup.py # 写入 Dota GSI 配置 python pc/gsi_setup.py # 写入 Dota GSI 配置
# Steam → Dota 2 → 属性 → 启动项加上 -gamestateintegration,重启游戏 # Steam → Dota 2 → 属性 → 启动项加上 -gamestateintegration,重启游戏
python gsi_watch.py python pc/gsi_watch.py
``` ```
进入英雄选择后自动跟踪;定位匹配下会按本局分路 + 敌方/己方关系在英雄网格左上角标出 **Top 3** 青色名次方标(需 `data/relations.json`)。凑齐 10 人或超时后写出 `results/draft_<时间戳>.json`(该目录已 gitignore)。 进入英雄选择后自动跟踪;敌方锁 ≥1 人后按关系与阵容画像在英雄网格标出 **克 / 搭 / 补**,并显示一句阵容分析(需 `shared/data/relations.json`;定位局按分路过滤)。凑齐 10 人或超时后写出 `pc/results/draft_<时间戳>.json`(该目录已 gitignore)。
会话截图按对局落在 `samples/raw/<matchid>/`;默认同时把每包 GSI JSON 追加到同目录 `gsi.jsonl``--no-dump-gsi` 可关)。 会话截图按对局落在 `pc/samples/raw/<matchid>/`;默认同时把每包 GSI JSON 追加到同目录 `gsi.jsonl``--no-dump-gsi` 可关)。
> 主菜单收不到 GSI 是正常的:客户端**第一次载入对局后**才开始推送。看到 `[gsi] connected` 才算链路通。 > 主菜单收不到 GSI 是正常的:客户端**第一次载入对局后**才开始推送。看到 `[gsi] connected` 才算链路通。
@@ -80,30 +142,30 @@ python gsi_watch.py
### 截图 ### 截图
```powershell ```powershell
python capture.py # 单张 → samples/raw/ python pc/capture.py # 单张 → pc/samples/raw/
python capture.py --loop 300 3 # 每 3 秒一张,持续 300 秒 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(一次性) ### 标定 ROI(一次性)
```powershell ```powershell
python autocalibrate.py samples/raw/<matchid>/<某帧>.png python pc/autocalibrate.py pc/samples/raw/<matchid>/<某帧>.png
# 失败时退路:python calibrate.py samples/raw/<matchid>/<某帧>.png # 失败时退路:python pc/calibrate.py pc/samples/raw/<matchid>/<某帧>.png
``` ```
### 识别 / 评测 ### 识别 / 评测
```powershell ```powershell
python recognize.py samples/raw/<matchid>/<>.png python pc/recognize.py pc/samples/raw/<matchid>/<>.png
python recognize.py samples/raw/<matchid>/<>.png --sheet python pc/recognize.py pc/samples/raw/<matchid>/<>.png --sheet
python recognize.py samples/raw/<matchid>/<>.png --truth hero1,...,hero10 python pc/recognize.py pc/samples/raw/<matchid>/<>.png --truth hero1,...,hero10
python evaluate.py # 按 samples/labels.json 批量评测 python pc/evaluate.py # 按 pc/samples/labels.json 批量评测
``` ```
辅助脚本:`roles.py`(位置字)、`grid.py`(禁用名单)、`modes.py`(模式字)。 辅助脚本:`pc/roles.py`(位置字)、`shared/grid.py`(禁用名单)、`pc/modes.py`(模式字)。
## 截图要求 ## 截图要求
@@ -115,7 +177,7 @@ python evaluate.py # 按 samples/labels.json 批量评测
- 宽高比差异大(如 21:9)时可能需重标定 - 宽高比差异大(如 21:9)时可能需重标定
- 客户端改网格排序后 `grid.py``ok=False`,不会瞎报禁用名单 - 客户端改网格排序后 `grid.py``ok=False`,不会瞎报禁用名单
- 新英雄上线后重跑 `fetch_cdn_templates.py`;版本更新后重跑 `fetch_patches.py` - 新英雄上线后重跑 `fetch_cdn_templates.py`(不在定时任务内);版本列表由 Gitea `web-patch` 自动检测,有更新才拉 `fetch_patches.py` 详情
- 位置文字模板取自简体中文客户端 - 位置文字模板取自简体中文客户端
- 决策界面 3D 模型挡住顶栏时单帧会很弱,依赖会话多帧回填 - 决策界面 3D 模型挡住顶栏时单帧会很弱,依赖会话多帧回填
- 普通玩家 GSI 只有自己的英雄 id,不能代替读顶栏 - 普通玩家 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 做钩子,用定性关系内容做留存与收费,是最匹配当前资产的路径。
-170
View File
@@ -1,170 +0,0 @@
"""Export the relations preview as a static site (no server needed).
Usage:
python export_relations_site.py [--out dist/relations] [--with-videos]
[--ability-video-base URL]
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; production deploys point the UI at OSS via --ability-video-base
(or ABILITY_VIDEO_BASE env) instead of bundling videos into Pages.
"""
from __future__ import annotations
import argparse
import json
import os
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
SITE_VERSION = "0.4.1"
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 write_config_js(out: Path, ability_video_base: str, site_version: str) -> None:
"""Write config.js consumed by app.js (SITE_VERSION, ABILITY_VIDEO_BASE)."""
base = (ability_video_base or "").strip().rstrip("/")
ver = (site_version or "").strip()
# JSON-encode so values are safe JS string literals.
ver_lit = json.dumps(ver, ensure_ascii=False)
base_lit = json.dumps(base, ensure_ascii=False)
(out / "config.js").write_text(
f"/* generated by export_relations_site.py — do not edit */\n"
f"var SITE_VERSION = {ver_lit};\n"
f"var ABILITY_VIDEO_BASE = {base_lit};\n",
encoding="utf-8",
)
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)",
)
ap.add_argument(
"--ability-video-base",
default=None,
help="public base URL for ability demos (writes config.js); "
"falls back to ABILITY_VIDEO_BASE env, else empty (same-origin)",
)
args = ap.parse_args()
video_base = (
args.ability_video_base
if args.ability_video_base is not None
else os.environ.get("ABILITY_VIDEO_BASE", "")
)
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).
# `_headers` is a Cloudflare Pages config file (cache TTLs for HTML/JSON/JS).
for name in ("index.html", "router.js", "app.js", "style.css", "_headers"):
src = WEB_DIR / name
if src.is_file():
shutil.copy2(src, out / name)
write_config_js(out, video_base, SITE_VERSION)
# 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" config.js SITE_VERSION={SITE_VERSION!r} ABILITY_VIDEO_BASE={video_base!r}")
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 sys
import time
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import time
import cv2 import cv2
import numpy as np import numpy as np
+4
View File
@@ -11,6 +11,10 @@ and stored as resolution-independent relative coordinates.
""" """
import sys import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import time import time
import cv2 import cv2
+5 -2
View File
@@ -10,11 +10,14 @@ Notes:
- Frames are saved as PNG at native resolution, named cap_HHMMSS.png. - Frames are saved as PNG at native resolution, named cap_HHMMSS.png.
""" """
import json
import sys import sys
import time
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
import time
import cv2 import cv2
import mss import mss
import numpy as np 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 import json
from pathlib import Path
import cv2 import cv2
import numpy as np 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" 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: def load_config() -> dict:
+46 -10
View File
@@ -102,16 +102,28 @@
"min_iou": 0.55 "min_iou": 0.55
}, },
"recommend": { "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, "enabled": true,
"top_n": 3, "top_n": 0,
"relations_path": "data/relations.json", "min_enemies": 1,
"min_heroes_for_gaps": 2,
"archetypes": true,
"relations_path": "shared/data/relations.json",
"role_tags": { "role_tags": {
"1": ["Carry"], "1": ["Carry"],
"2": ["Carry", "Nuker", "Escape"], "2": ["Carry", "Nuker", "Escape"],
"3": ["Initiator", "Durable", "Carry"], "3": ["Initiator", "Durable", "Carry"],
"4": ["Support"], "4": ["Support"],
"5": ["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": { "gsi": {
@@ -134,16 +146,40 @@
"target_slots": 10, "target_slots": 10,
"dump_payloads": true "dump_payloads": true
}, },
"overlay": { "player_pages": {
"comment": "Role tags under top-bar + cyan Top-3 recommend marks on the hero grid.", "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, "enabled": true,
"y_gap_rel": 0.008,
"icon_h_rel": 0.016,
"icon_gap_rel": 0.002,
"mark_size_rel": 0.018, "mark_size_rel": 0.018,
"mark_pad_rel": 0.004, "mark_pad_rel": 0.004,
"rec_color": "#2ec4b6", "mark_gap_rel": 0.002,
"rec_text_color": "#0b1220" "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" "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",
]
+161 -25
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. 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 time
import mss import mss
from capture import grab_frame, is_dota_foreground, raw_dir_for_match, save_frame 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 modes import detect_mode, load_mode_templates
from recognize import recognize_image 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 from roles import ROLES, detect_roles, load_role_templates
HERO_SELECTION = "DOTA_GAMERULES_STATE_HERO_SELECTION" HERO_SELECTION = "DOTA_GAMERULES_STATE_HERO_SELECTION"
@@ -84,11 +90,24 @@ class DraftSession:
self.frame_dir = raw_dir_for_match(None) self.frame_dir = raw_dir_for_match(None)
rec = cfg.get("recommend") or {} rec = cfg.get("recommend") or {}
self.recommend_enabled = bool(rec.get("enabled", True)) 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.recommend_role_tags = rec.get("role_tags")
self.relations = load_relations(rec.get("relations_path")) if self.recommend_enabled else None 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._rec_warned = False
self._last_rec_sig: tuple | None = None 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: def _push_overlay(self, confirmed: dict[int, str]) -> None:
if self.overlay is None: if self.overlay is None:
@@ -98,12 +117,22 @@ class DraftSession:
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
self.log(f"[draft] overlay update failed: {e}") 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: if self.overlay is None:
return return
try: 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) 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 except Exception as e: # noqa: BLE001
self.log(f"[draft] rec overlay update failed: {e}") self.log(f"[draft] rec overlay update failed: {e}")
@@ -115,14 +144,19 @@ class DraftSession:
try: try:
self.overlay.set_roster({}) self.overlay.set_roster({})
self.overlay.set_grid_marks({}, {}) 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() self.overlay.show()
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
self.log(f"[draft] overlay show failed: {e}") self.log(f"[draft] overlay show failed: {e}")
self._last_item_sig = None
if self.recommend_enabled and not self._rec_warned: if self.recommend_enabled and not self._rec_warned:
rel = self.relations or {} rel = self.relations or {}
if not rel.get("counters") and not rel.get("synergies"): if not rel.get("counters") and not rel.get("synergies"):
self._rec_warned = True 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") "or run import_relations_xlsx.py")
started = time.monotonic() started = time.monotonic()
pending: dict[int, tuple[str, int]] = {} pending: dict[int, tuple[str, int]] = {}
@@ -138,6 +172,7 @@ class DraftSession:
"cells": None, "cells": None,
"mode": None, "mode": None,
"recommendations": [], "recommendations": [],
"item_recommendations": [],
} }
polls = 0 polls = 0
last_frame = None last_frame = None
@@ -229,7 +264,7 @@ class DraftSession:
event["frame"] = save_frame(frame, self.frame_dir, prefix="draft") event["frame"] = save_frame(frame, self.frame_dir, prefix="draft")
timeline.append(event) timeline.append(event)
self._log_event(event, info) self._log_event(event, info)
if state == HERO_SELECTION: # Hero marks in selection; item bar after self-lock (incl. strategy tail).
self._refresh_recommendations(confirmed, info, gsi_fn) self._refresh_recommendations(confirmed, info, gsi_fn)
n = len(confirmed) n = len(confirmed)
@@ -255,6 +290,10 @@ class DraftSession:
if self.overlay is not None: if self.overlay is not None:
try: try:
self.overlay.set_grid_marks({}, {}) 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() self.overlay.hide()
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
self.log(f"[draft] overlay hide failed: {e}") self.log(f"[draft] overlay hide failed: {e}")
@@ -362,24 +401,70 @@ class DraftSession:
self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable " self.log(f"[draft] grid: {len(res['unavailable'])} heroes unavailable "
f"(contrast margin {res['margin']}) - {names}") 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: def _refresh_recommendations(self, confirmed: dict, info: dict, gsi_fn, *, force: bool = False) -> None:
if not self.recommend_enabled: if not self.recommend_enabled:
return return
if not self.relations:
return
gsi = (gsi_fn() or {}) if gsi_fn else {} gsi = (gsi_fn() or {}) if gsi_fn else {}
self_slot = gsi_slot(gsi) or info.get("self_slot") self_slot = gsi_slot(gsi) or info.get("self_slot")
self_team = gsi.get("team") or info.get("self_team") self_team = gsi.get("team") or info.get("self_team")
role = info["roles"].get(self_slot) if self_slot else None role = info["roles"].get(self_slot) if self_slot else None
position = role["position"] if role else None position = role["position"] if role else None
# Stop suggesting once you have locked a hero. self_hero = (confirmed.get(self_slot) if self_slot else None) or gsi.get("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
enemies = enemy_keys(confirmed, self_team) 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) allies = ally_keys(confirmed, self_team, self_slot)
exclude = set(confirmed.values()) exclude = set(confirmed.values())
if info.get("unavailable"): if info.get("unavailable"):
@@ -388,11 +473,15 @@ class DraftSession:
if not force and sig == self._last_rec_sig: if not force and sig == self._last_rec_sig:
return return
self._last_rec_sig = sig 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"] = [] 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 return
picks = suggest_top( result = suggest_marks(
position=position, position=position,
enemies=enemies, enemies=enemies,
allies=allies, allies=allies,
@@ -400,14 +489,39 @@ class DraftSession:
relations=self.relations, relations=self.relations,
top_n=self.recommend_top_n, top_n=self.recommend_top_n,
role_tags=self.recommend_role_tags, 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 info["recommendations"] = picks
self._push_rec_overlay(info.get("cells"), picks) info["item_recommendations"] = []
if picks: self._last_enemy_profile = profile
names = ", ".join(f"{p['rank']}.{p['name_loc']}({p['score']:+d})" for p in picks) 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] ally_n = [self.hero_names.get(a, a) for a in allies]
enemy_n = [self.hero_names.get(e, e) for e in enemies] 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, def _absorb_picks(self, result: dict, pending: dict, confirmed: dict,
scores: dict, *, allow_revise: bool = True, scores: dict, *, allow_revise: bool = True,
@@ -541,7 +655,14 @@ class DraftSession:
"position": role["position"] if role else None, "position": role["position"] if role else None,
"enemies": enemies, "enemies": enemies,
"allies": allies, "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 [], "picks": info.get("recommendations") or [],
"items": info.get("item_recommendations") or [],
}, },
} }
if info["unavailable"] is not None: if info["unavailable"] is not None:
@@ -613,10 +734,25 @@ def describe(summary: dict) -> list[str]:
if summary.get("bans_loc"): if summary.get("bans_loc"):
lines.append(f"bans : {len(summary['bans_loc'])} - {', '.join(summary['bans_loc'])}") lines.append(f"bans : {len(summary['bans_loc'])} - {', '.join(summary['bans_loc'])}")
rec = summary.get("recommendations") or {} rec = summary.get("recommendations") or {}
analysis = rec.get("analysis") or ""
if analysis:
lines.append(f"draft : {analysis}")
picks = rec.get("picks") or [] picks = rec.get("picks") or []
if picks: if picks:
bits = ", ".join(f"{p['rank']}.{p.get('name_loc') or loc(p['key'], names)}" for p in picks) bits = []
lines.append(f"rec : {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 return lines
+5 -1
View File
@@ -7,8 +7,12 @@ Usage:
python evaluate.py python evaluate.py
""" """
import json
import sys import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
import cv2 import cv2
@@ -4,21 +4,25 @@ Usage:
python fetch_cdn_templates.py python fetch_cdn_templates.py
Writes: 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 templates/cdn/{key}.png - face-centered square crop resized to canonical size
Preserves manually curated `aliases` / `abbr` from an existing heroes.json on rewrite. Preserves manually curated `aliases` / `abbr` from an existing heroes.json on rewrite.
""" """
import json import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
import cv2 import cv2
import numpy as np import numpy as np
from common import HEROES_JSON, TEMPLATES_CDN, load_config from common import HEROES_JSON, TEMPLATES_CDN, load_config
from hero_tags import tags_for_hero from shared.hero_tags import tags_for_hero
from http_utils import fetch_hero_list, http_bytes, http_json 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. # 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" 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_day": int(_num(raw, "day_vision", 1800)),
"vision_night": int(_num(raw, "night_vision", 800)), "vision_night": int(_num(raw, "night_vision", 800)),
} }
# turn_rate is null in OpenDota for heroes using the game default — omit # OpenDota leaves turn_rate null for the engine default (0.6); match
# rather than inventing 0.6 so the UI only shows an explicit value. # dota2.com.cn and always surface a value for the combat panel.
turn = raw.get("turn_rate") turn = raw.get("turn_rate")
if turn is not None:
try: try:
result["turn_rate"] = _round1(float(turn)) result["turn_rate"] = _round1(float(turn)) if turn is not None else 0.6
except (TypeError, ValueError): except (TypeError, ValueError):
pass result["turn_rate"] = 0.6
return result 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. (Steam library -> right-click Dota 2 -> Properties) and restart the game.
""" """
import re
import sys import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import re
from common import load_config from common import load_config
CFG_NAME = "gamestate_integration_climperor.cfg" 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. samples/raw/<matchid>/gsi.jsonl for later analysis.
""" """
import json
import sys import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
import threading import threading
import time import time
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
import mss 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 common import ROOT, load_config, load_template_library
from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc from draft_session import HERO_SELECTION, DraftSession, describe, gsi_slot, loc
from overlay import DraftOverlay from overlay import DraftOverlay
from player_pages import POST_GAME, schedule_post_game
from recognize import recognize_image from recognize import recognize_image
from roles import detect_roles from roles import detect_roles
@@ -74,10 +78,12 @@ class Watcher:
if calibrated and bool((cfg.get("overlay") or {}).get("enabled", True)): if calibrated and bool((cfg.get("overlay") or {}).get("enabled", True)):
try: try:
self.overlay = DraftOverlay(cfg) self.overlay = DraftOverlay(cfg)
print("[draft] role-tag overlay ready", flush=True) print("[draft] overlay ready", flush=True)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
print(f"[draft] overlay disabled: {e}", flush=True) print(f"[draft] overlay disabled: {e}", flush=True)
self.overlay = None 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: def on_payload(self, payload: dict) -> None:
if not self.connected: if not self.connected:
@@ -120,7 +126,11 @@ class Watcher:
print(f"[gsi] map keys: {sorted(m.keys())}", flush=True) print(f"[gsi] map keys: {sorted(m.keys())}", flush=True)
print(f"[gsi] player keys: {sorted(p.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) 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 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: if state not in self.trigger_states:
return return
@@ -141,6 +151,18 @@ class Watcher:
except OSError as e: except OSError as e:
print(f"[gsi] dump failed: {e}", flush=True) 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: def track(self, match_id: str, state: str, key: str) -> dict | None:
"""Follow the draft from here to the end, recording every reveal.""" """Follow the draft from here to the end, recording every reveal."""
if not self.busy.acquire(blocking=False): 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. 全英雄选择).""" """Read the game-mode label under the top-center timer (e.g. 全英雄选择)."""
import sys
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import cv2 import cv2
import numpy as np 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. recognize_image() is the reusable entry point used by gsi_watch.py.
""" """
import json
import sys import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import json
import time import time
import cv2 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. 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 cv2
import numpy as np 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