Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
282029706b | ||
|
|
820c3fb1f1 | ||
|
|
cafd0651b1 | ||
|
|
41d272ba29 | ||
|
|
f5b7011c45 | ||
|
|
4a61aeeb26 | ||
|
|
d6f7c3f0f5 | ||
|
|
1e44c0eb9b | ||
|
|
961574037e | ||
|
|
3ee4ba7e84 | ||
|
|
30218790ce | ||
|
|
a257f96d94 | ||
|
|
ee180f3519 | ||
|
|
c5a342cb5d | ||
|
|
76fa474b7d | ||
|
|
f035e2c233 | ||
|
|
0f7e7b68dd | ||
|
|
3b7d00b32b | ||
|
|
633a6f16b9 | ||
|
|
2c4e2c2653 | ||
|
|
e0a8228707 | ||
|
|
11dbb73b5c | ||
|
|
43e8409c0e | ||
|
|
22a9cf256d | ||
|
|
fefd8c7823 | ||
|
|
79ce5cfd2e | ||
|
|
aac4593cc5 | ||
|
|
1aa6710e39 | ||
|
|
7c9e54e5eb | ||
|
|
544ea42d40 | ||
|
|
38f46ad2ea | ||
|
|
fdd926e9bb | ||
|
|
28858c0703 | ||
|
|
fe1b13b7c1 | ||
|
|
d6212169af | ||
|
|
09f0a0b4a0 |
@@ -38,7 +38,16 @@ jobs:
|
|||||||
PYTHON=python3
|
PYTHON=python3
|
||||||
command -v python3 >/dev/null || PYTHON=python
|
command -v python3 >/dev/null || PYTHON=python
|
||||||
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
||||||
"$PYTHON" -m pip install -q -r web/requirements.txt
|
# 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
|
- name: Refresh daily tier
|
||||||
env:
|
env:
|
||||||
@@ -51,13 +60,13 @@ jobs:
|
|||||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
PYTHON=python3
|
"${CLIMPEROR_PY}" web/refresh_web.py --tier daily
|
||||||
command -v python3 >/dev/null || PYTHON=python
|
|
||||||
"$PYTHON" web/refresh_web.py --tier daily
|
|
||||||
|
|
||||||
- name: Save refresh summary
|
- name: Save refresh summary
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
continue-on-error: true
|
||||||
|
# v4 artifact API unsupported on Gitea; v3 still uploads for notify digests.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: web-daily-refresh-${{ github.run_id }}
|
name: web-daily-refresh-${{ github.run_id }}
|
||||||
path: web/.refresh/summary.json
|
path: web/.refresh/summary.json
|
||||||
|
|||||||
@@ -37,7 +37,16 @@ jobs:
|
|||||||
PYTHON=python3
|
PYTHON=python3
|
||||||
command -v python3 >/dev/null || PYTHON=python
|
command -v python3 >/dev/null || PYTHON=python
|
||||||
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
||||||
"$PYTHON" -m pip install -q -r web/requirements.txt
|
# 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
|
- name: Refresh patch tier
|
||||||
env:
|
env:
|
||||||
@@ -49,13 +58,13 @@ jobs:
|
|||||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
PYTHON=python3
|
"${CLIMPEROR_PY}" web/refresh_web.py --tier patch
|
||||||
command -v python3 >/dev/null || PYTHON=python
|
|
||||||
"$PYTHON" web/refresh_web.py --tier patch
|
|
||||||
|
|
||||||
- name: Save refresh summary
|
- name: Save refresh summary
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
continue-on-error: true
|
||||||
|
# v4 artifact API unsupported on Gitea; v3 still uploads for notify digests.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: web-patch-refresh-${{ github.run_id }}
|
name: web-patch-refresh-${{ github.run_id }}
|
||||||
path: web/.refresh/summary.json
|
path: web/.refresh/summary.json
|
||||||
|
|||||||
@@ -36,7 +36,16 @@ jobs:
|
|||||||
PYTHON=python3
|
PYTHON=python3
|
||||||
command -v python3 >/dev/null || PYTHON=python
|
command -v python3 >/dev/null || PYTHON=python
|
||||||
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
command -v node >/dev/null || { echo "node/npx required for wrangler deploy"; exit 1; }
|
||||||
"$PYTHON" -m pip install -q -r web/requirements.txt
|
# 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
|
- name: Refresh weekly tier
|
||||||
env:
|
env:
|
||||||
@@ -49,13 +58,13 @@ jobs:
|
|||||||
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
PYTHON=python3
|
"${CLIMPEROR_PY}" web/refresh_web.py --tier weekly
|
||||||
command -v python3 >/dev/null || PYTHON=python
|
|
||||||
"$PYTHON" web/refresh_web.py --tier weekly
|
|
||||||
|
|
||||||
- name: Save refresh summary
|
- name: Save refresh summary
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
continue-on-error: true
|
||||||
|
# v4 artifact API unsupported on Gitea; v3 still uploads for notify digests.
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: web-weekly-refresh-${{ github.run_id }}
|
name: web-weekly-refresh-${{ github.run_id }}
|
||||||
path: web/.refresh/summary.json
|
path: web/.refresh/summary.json
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pc/preview/
|
|||||||
pc/results/
|
pc/results/
|
||||||
pc/failures/
|
pc/failures/
|
||||||
pc/samples/raw/
|
pc/samples/raw/
|
||||||
|
pc/player_pages/
|
||||||
_tools/
|
_tools/
|
||||||
_tmp_*
|
_tmp_*
|
||||||
_cmp_*
|
_cmp_*
|
||||||
@@ -24,7 +25,6 @@ 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)
|
||||||
shared/data/matchups.json
|
|
||||||
shared/data/synergies.json
|
shared/data/synergies.json
|
||||||
shared/data/stratz_matchups.json
|
shared/data/stratz_matchups.json
|
||||||
shared/data/relations_audit.json
|
shared/data/relations_audit.json
|
||||||
@@ -43,7 +43,10 @@ web/.refresh-cache/
|
|||||||
|
|
||||||
# Large regenerable CDN media (fetch scripts)
|
# Large regenerable CDN media (fetch scripts)
|
||||||
web/assets/ability_videos/
|
web/assets/ability_videos/
|
||||||
web/assets/ability_icons/
|
# Regenerable Steam CDN ability icons; keep bundled shared badges in git.
|
||||||
|
web/assets/ability_icons/*
|
||||||
|
!web/assets/ability_icons/innate.png
|
||||||
|
!web/assets/ability_icons/talent_tree.png
|
||||||
web/assets/hero_portraits/
|
web/assets/hero_portraits/
|
||||||
web/assets/item_icons/
|
web/assets/item_icons/
|
||||||
# Curated streamer highlight clips (large; sync via OSS, not git)
|
# Curated streamer highlight clips (large; sync via OSS, not git)
|
||||||
|
|||||||
@@ -33,13 +33,16 @@ climperor/
|
|||||||
| `pc/common.py` | 配置 IO、槽位几何、裁切、NCC 匹配、天梯遮罩、CDN 模板加载;re-export `shared.paths` 常量 |
|
| `pc/common.py` | 配置 IO、槽位几何、裁切、NCC 匹配、天梯遮罩、CDN 模板加载;re-export `shared.paths` 常量 |
|
||||||
| `pc/recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
|
| `pc/recognize.py` | 单帧识别;`recognize_image()` 供会话复用 |
|
||||||
| `pc/draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
|
| `pc/draft_session.py` | 整局选将跟踪、改判、皮肤规避策略 |
|
||||||
| `pc/gsi_watch.py` / `pc/gsi_setup.py` | GSI 监听与 cfg 安装 |
|
| `pc/gsi_watch.py` / `pc/gsi_setup.py` | GSI 监听与 cfg 安装;`POST_GAME` 触发 `player_pages` 赛后建页 |
|
||||||
|
| `pc/player_pages.py` | 赛后轮询 OpenDota → `pc/player_pages/{account_id}/`(profile + match JSON);`enrich_profile_recent` 写近 20 场 + `career`/`recent_20`/`top_heroes`/`activity_180`/`peers`/`availability`;`get_profile_for_web` 缓存优先(`enrich_ttl_seconds` 默认 600,与 Pages `isStale` 对齐:未过期直出、过期后台刷新、冷启动同步一次);详情 `ensure_match_detail` 懒加载;`public_share` 时 POST `/api/players/publish` 入队;**不**进 recommend |
|
||||||
|
| `pc/player_stats.py` | 玩家聚合公式(KDA/胜率/生涯/近 N 场/180 天热力图);空 OpenDota 不覆盖旧 career |
|
||||||
| `pc/fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `shared/data/heroes.json`(含基础属性/血蓝;保留已有 `aliases`) |
|
| `pc/fetch_cdn_templates.py` | 拉取 CDN 头像 + 生成 `shared/data/heroes.json`(含基础属性/血蓝;保留已有 `aliases`) |
|
||||||
| `pc/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
|
| `pc/recommend.py` | 定位局分路过滤;克/搭/补全网格标记;调用 `draft_archetypes` 做推进/全球流/缺口画像与短文案 |
|
||||||
|
| `pc/item_suggest.py` | 本人锁定后:`hero_items` 核心装 + 敌方 tags/画像定性应对装(不读 fears/STRATZ 统计) |
|
||||||
| `pc/draft_archetypes.py` | 规则阵容画像(推进/全球流/敌我缺口)+ `analysis` / `reasons` 文案(不接 AI) |
|
| `pc/draft_archetypes.py` | 规则阵容画像(推进/全球流/敌我缺口)+ `analysis` / `reasons` 文案(不接 AI) |
|
||||||
| `pc/autocalibrate.py` / `pc/calibrate.py` | ROI 自动 / 手动标定 |
|
| `pc/autocalibrate.py` / `pc/calibrate.py` | ROI 自动 / 手动标定 |
|
||||||
| `pc/capture.py` | 屏幕捕获 |
|
| `pc/capture.py` | 屏幕捕获 |
|
||||||
| `pc/overlay.py` | 顶栏角色标签 + 网格「克/搭/补」方标 + 阵容分析横条(点击穿透) |
|
| `pc/overlay.py` | 网格「克/搭/补」方标 + 阵容分析横条 + 锁定后装备图标条(点击穿透;不再在顶栏头像下画定位) |
|
||||||
| `pc/evaluate.py` | 按 `pc/samples/labels.json` 批量评测 |
|
| `pc/evaluate.py` | 按 `pc/samples/labels.json` 批量评测 |
|
||||||
| `pc/roles.py` / `pc/modes.py` | 位置字、模式字(禁用网格在 `shared/grid.py`) |
|
| `pc/roles.py` / `pc/modes.py` | 位置字、模式字(禁用网格在 `shared/grid.py`) |
|
||||||
| `pc/config.json` | 相对坐标、阈值、GSI、recommend 参数(`relations_path` 指向 `shared/data/relations.json`) |
|
| `pc/config.json` | 相对坐标、阈值、GSI、recommend 参数(`relations_path` 指向 `shared/data/relations.json`) |
|
||||||
@@ -52,16 +55,14 @@ climperor/
|
|||||||
|
|
||||||
| 路径 | 职责 |
|
| 路径 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `shared/paths.py` | 全部路径常量单一来源(`ROOT`/`SHARED_DATA`/`HEROES_JSON`/`TEMPLATES_CDN`/`DATA`/`WEB_FRONTEND`/各 Web 资产目录);纯常量、零第三方依赖 |
|
| `shared/paths.py` | 全部路径常量单一来源(`ROOT`/`SHARED_DATA`/`HEROES_JSON`/`TEMPLATES_CDN`/`PC_PLAYER_PAGES`/`DATA`/`WEB_FRONTEND`/各 Web 资产目录);纯常量、零第三方依赖 |
|
||||||
| `shared/grid.py` | 英雄表 `hero_table()`、选人网格布局与禁用读取(cv2/numpy 懒加载,Web/CI 侧只取表不触发) |
|
| `shared/grid.py` | 英雄表 `hero_table()`、选人网格布局与禁用读取(cv2/numpy 懒加载,Web/CI 侧只取表不触发) |
|
||||||
| `shared/relations.py` | 定性克制/搭档边读写与名称解析;默认路径 `shared/data/relations.json` |
|
| `shared/relations.py` | 定性克制/搭档边读写与名称解析;默认路径 `shared/data/relations.json` |
|
||||||
| `shared/hero_tags.py` | 中文定位 tags(核心/辅助/…/幻象) |
|
| `shared/hero_tags.py` | 中文定位 tags(核心/辅助/…/幻象) |
|
||||||
| `shared/http_utils.py` | 所有 fetch 脚本共用的 HTTP 请求、图标下载、Valve datafeed 加载(统一 UA / 超时;429/5xx 指数退避重试) |
|
| `shared/http_utils.py` | 所有 fetch 脚本共用的 HTTP 请求、图标下载、Valve datafeed 加载(统一 UA / 超时;429/5xx 指数退避重试) |
|
||||||
| `shared/import_relations_xlsx.py` | 从选将笔记 xlsx 种子导入 `shared/data/relations.json` |
|
| `shared/import_relations_xlsx.py` | 从选将笔记 xlsx 种子导入 `shared/data/relations.json` |
|
||||||
| `shared/fetch_stratz.py` | 拉取 STRATZ 对位优势 / 搭档协同 → `shared/data/stratz_matchups.json` / `shared/data/synergies.json`(需 token;`--mode matchups\|synergies`;供 `audit_relations.py`) |
|
| `shared/fetch_stratz.py` | 拉取 STRATZ 对位优势 / 搭档协同 → `shared/data/stratz_matchups.json` / `shared/data/synergies.json`(需 token;`--mode matchups\|synergies`;供 `audit_relations.py`) |
|
||||||
| `shared/fetch_matchups.py` | 拉取 OpenDota 对位优势 → `shared/data/matchups.json`(供 `audit_relations.py` 交叉审计) |
|
| `shared/audit_relations.py` | 用 STRATZ 对位/搭档缓存审计 `relations.json`,列出缺失英雄与候选边(不改写 relations;Web 对位页只读 `stratz_matchup_tops.json`) |
|
||||||
| `shared/audit_relations.py` | OpenDota+STRATZ 交叉审计 `relations.json`,列出缺失英雄;另含 Web 对位 Top × OpenDota 的 `web_matchup_tops_cross` 质量段(agree/conflict/weak;不改写 relations) |
|
|
||||||
| `shared/matchup_cross.py` | Web 对位 Top 与 OpenDota 的基线校正交叉分类(供 audit / serve 富化;观测证据,不作机制结论) |
|
|
||||||
| `shared/data/heroes.json` | 英雄表(id/key/attr/roles/aliases/abbr/tags + 基础属性/初始血蓝等;由 `pc/fetch_cdn_templates.py` 生成) |
|
| `shared/data/heroes.json` | 英雄表(id/key/attr/roles/aliases/abbr/tags + 基础属性/初始血蓝等;由 `pc/fetch_cdn_templates.py` 生成) |
|
||||||
| `shared/data/relations.json` | 定性克制边 + 搭档边(可提交;手改 JSON,Web 站点只读) |
|
| `shared/data/relations.json` | 定性克制边 + 搭档边(可提交;手改 JSON,Web 站点只读) |
|
||||||
|
|
||||||
@@ -72,10 +73,13 @@ climperor/
|
|||||||
| `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 details,stdout JSON) |
|
| `web/fetch_patches.py` | 拉取近一年(默认 365 天,`--days`/`--since`)版本列表 + 逐版本 `patchnotes` 详情 → `web/data/patches.json`;构建 id→名称/图标的 `lookup` 并下载引用到的物品/技能图标(`--no-icons` 跳过;`--force` 重抓全量;`--check` 只比对列表与本地 details,stdout JSON) |
|
||||||
| `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 |
|
| `web/requirements.txt` | Web 刷新依赖(`oss2`);Gitea Actions 仅装它 |
|
||||||
| `web/fetch_stratz_meta.py` | 拉取 STRATZ 各勋章段位 `winWeek`(近 N 周 pick/win + 同段位最近 1 周分路,`positionIds`)+ 对位 Top → `web/data/stratz_hero_meta.json` / `web/data/stratz_matchup_tops.json`(需 token;**仅上分帝 Web**;勿进 recommend / relations)。对位为**全局聚合**(无段位/分路/周过滤);weekly 默认全量刷新,`--resume-matchups` 仅中断续跑;失败保留旧值并标 `stale` |
|
| `web/fetch_stratz_meta.py` | 拉取 STRATZ 各勋章段位 `winWeek`(近 N 周 pick/win + 同段位最近 1 周分路,`positionIds`)+ 对位 Top → `web/data/stratz_hero_meta.json` / `web/data/stratz_matchup_tops.json`(需 token;**仅上分帝 Web**;勿进 recommend / relations)。对位为**全局聚合**(无段位/分路/周过滤);weekly 默认全量刷新,`--resume-matchups` 仅中断续跑;失败保留旧值并标 `stale` |
|
||||||
| `web/serve_relations.py` | 上分帝 Web 本地开发服务(`web/frontend/`;改 `web/data/*.json` 后刷新;`/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range) |
|
| `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-op;Steam OpenID:`/api/auth/steam` / `callback` / `me` / `logout`(需 `STEAM_API_KEY` + `SESSION_SECRET`,镜像 `web/steam_auth.py`);`/streamer-video/` 提供主播高光 mp4 与同名 JPG 封面,支持 HTTP Range) |
|
||||||
| `web/export_relations_site.py` | 导出上分帝 Web 为纯静态站点 → `web/dist/relations/`(data.json 快照 + 前端 + 图片;`SITE_VERSION` 常量与 `web/frontend/config.js` 同步;`--ability-video-base` / `--static-asset-base` 写 `config.js` 指向 OSS;设 `--static-asset-base` 时不拷贝图标进 dist;`--with-videos` 可选本地拷贝技能/主播视频,生产部署勿用) |
|
| `web/steam_auth.py` | Steam OpenID + 签名 Cookie 会话(本地 serve 用;与 `web/frontend/functions/api/auth/*` 对齐) |
|
||||||
|
| `web/frontend/functions/api/auth/` | Pages Functions:Steam 登录 / 回调 / me / logout;Cookie `climperor_steam`;Env:`STEAM_API_KEY`、`SESSION_SECRET` |
|
||||||
|
| `web/export_relations_site.py` | 导出上分帝 Web 为纯静态站点 → `web/dist/relations/`(data.json 快照 + 前端 + 图片;`SITE_VERSION` / `SITE_ORIGIN` 与 `web/frontend/config.js` 同步;`--ability-video-base` / `--static-asset-base` / `--site-origin` 写 `config.js`;设 `--static-asset-base` 时不拷贝图标进 dist;调用 `seo_prerender.py` 写英雄/机制预渲染 HTML + `sitemap.xml` / `llms.txt`;拷贝 `_redirects` / `robots.txt`;`--with-videos` 可选本地拷贝技能/主播视频,生产部署勿用) |
|
||||||
|
| `web/seo_prerender.py` | 导出期 SEO/GEO:注入 title/description/canonical/OG/JSON-LD 与 `#seo-prerender` 正文;生成全英雄 `/heroes/{key}`、机制 `/mechanics/{effect}`、顶层页、`sitemap.xml`、`llms.txt` |
|
||||||
| `web/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env) |
|
| `web/deploy_relations.py` | 一键部署上分帝 Web 静态站点到 Cloudflare Pages(导出 + 资产预检 + `wrangler` 直传 + 绑域名;默认 OSS base 指向 `climperor` 桶的视频与静态图;凭据经 keyzoo 注入或 env) |
|
||||||
| `web/refresh_web.py` | 上分帝 Web 数据分层刷新编排(`daily`/`weekly`/`patch`/`all`);忽略纯时间戳的业务摘要有变更才 OSS upload + `deploy_relations.py`;写 `web/.refresh/summary.json`(步骤/耗时/stale/部署);同 runner 文件锁串行发布,patch 遇全量刷新则延后;`--dry-run` / `--skip-deploy` / `--skip-oss` / `--force-deploy`。`daily`:stats/排行/比赛/pro/主播/主播开播探测 + patch check;`weekly`:STRATZ meta + 物品;`patch`:仅 check,`has_new` 时详情 + abilities/商店/meta/fears |
|
| `web/refresh_web.py` | 上分帝 Web 数据分层刷新编排(`daily`/`weekly`/`patch`/`all`);忽略纯时间戳与 `is_live` 的业务摘要有变更才 OSS upload + `deploy_relations.py`(亦监视 frontend / `relations.json` / `heroes.json` / 网格顺序);写 `web/.refresh/summary.json`(步骤/耗时/stale/部署;`--dry-run` 只打印不写盘、不 restore/save 缓存);同 runner 文件锁串行发布,patch 遇全量刷新则延后并标 `skipped`;`--skip-deploy` / `--skip-oss` / `--force-deploy`。`daily`:stats/排行/比赛/pro/主播/主播开播探测 + patch check;`weekly`:STRATZ meta + 物品 + counter;`patch`:仅 check,`has_new` 时详情 + abilities/商店/meta/fears |
|
||||||
| `web/refresh_cache.py` | Gitea Actions 刷新状态缓存桥:文件锁内从 runner `~/.cache/climperor-web-refresh/` 恢复/保存,`web/.refresh-cache/` 供 Actions cache 冷启动备份;覆盖所有定时生成数据与 `patches.json`,主播只合并抓取字段,保留 git 中手工名单 |
|
| `web/refresh_cache.py` | Gitea Actions 刷新状态缓存桥:文件锁内从 runner `~/.cache/climperor-web-refresh/` 恢复/保存,`web/.refresh-cache/` 供 Actions cache 冷启动备份;覆盖所有定时生成数据与 `patches.json`,主播只合并抓取字段,保留 git 中手工名单 |
|
||||||
| `web/notify_site_traffic.py` | 上分帝 Web 日报 → 飞书 webhook(访问估数 + Gitea `web-daily`/`weekly`/`patch` 运行结论/`REFRESH_SUMMARY` + Pages 当日生产部署 + 生产数据新鲜度与 live API 探针;`--dry-run` 只打印卡片) |
|
| `web/notify_site_traffic.py` | 上分帝 Web 日报 → 飞书 webhook(访问估数 + Gitea `web-daily`/`weekly`/`patch` 运行结论/`REFRESH_SUMMARY` + Pages 当日生产部署 + 生产数据新鲜度与 live API 探针;`--dry-run` 只打印卡片) |
|
||||||
| `.gitea/workflows/site-traffic-notify.yml` | 每日 09:00 CST 跑 `web/notify_site_traffic.py`;Secrets:`CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` / `FEISHU_WEBHOOK_URL`;内置 `GITEA_TOKEN` 用于查询同仓 Actions |
|
| `.gitea/workflows/site-traffic-notify.yml` | 每日 09:00 CST 跑 `web/notify_site_traffic.py`;Secrets:`CLOUDFLARE_EMAIL` / `CLOUDFLARE_API_KEY` / `FEISHU_WEBHOOK_URL`;内置 `GITEA_TOKEN` 用于查询同仓 Actions |
|
||||||
@@ -88,49 +92,56 @@ climperor/
|
|||||||
| `web/fetch_hero_items.py` | 拉取 OpenDota 热门装备 → `web/data/hero_items.json` + `web/assets/item_icons/` |
|
| `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_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_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` 场;`--players` 覆盖整文件;`--all-pros` 从 `/proPlayers` 盲抽;含终局出装/加点;进 `refresh_web` daily 且带 `--include-pubs`;**仅上分帝 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_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` 短链;失败保留旧值;**不**探测开播(由 `fetch_streamer_live.py` 负责);**仅上分帝 Web「主播」**;勿进 recommend;进 `refresh_web` daily) |
|
| `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` 在播,轮播算下播);软失败保留旧 `is_live`、始终 exit 0;`--ids a,b` 限范围、`--dry-run` 只打印;仅 Web;进 `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`),读 `data.json` 的 `streamers.streamers`;Cache API 固定键 + isolate 内 in-flight 合并(5 分钟新鲜窗口,**无 KV**);抖音从数据中心 IP 失败属预期 → 单主播沿用 edge 缓存/daily `is_live` 标 `stale`,全失败回陈旧快照(`stale-override`)或空表(`error`),永不 500;导出时拷贝 `functions/`;**部署须 `cwd=dist` 跑 wrangler**(Functions 相对 cwd 解析) |
|
| `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/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`:校验后投递 Queue(D1/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/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_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`) |
|
| `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/mechanic_tags.py` | 上分帝 Web「机制」页共用标签(弱/强驱散、缠绕/缴械/沉默/锁闭/眩晕/妖术/破坏、睡眠/恐惧/嘲讽/致盲/束缚、隐身/虚无/吹风)+ 中文 labels;语义为「施加该效果」 |
|
||||||
| `web/loc_format.py` | Valve 文案共用:去 HTML、填充 `%token%` / `{s:token}` |
|
| `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 显式 flag,Valve 移除升级后残留的 `scepter_loc`/`shard_loc` 文案会被清空;逐级相同的 `cast_points`/`channel_times` 合并为单值(施法前摇/吟唱时间);可选 `--icons` / `--icons-only` 缓存 Steam CDN 技能图标 → `web/assets/ability_icons/`(先天用共用 `innate.png`,不拉 CDN) |
|
| `web/fetch_hero_abilities.py` | Valve herodata 技能/魔晶/神杖/天赋 + 驱散汇总 → `web/data/hero_abilities.json`;每条 ability 另有「施加」类 `tags`(与 `dispellable` 区分;`--tags-only` 可只重算);`has_scepter`/`has_shard` 只信 Valve 显式 flag,Valve 移除升级后残留的 `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/mp4(Steam CDN;限速)→ `web/assets/ability_videos/`;A 杖/魔晶赋予技能(`granted_by_scepter`/`granted_by_shard`)CDN 名用 `<hero>_aghanims_scepter`/`<hero>_aghanims_shard`,本地文件名仍用 ability key |
|
| `web/fetch_ability_videos.py` | 官网技能演示 webm/mp4(Steam 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/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/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/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_grid_order.json` | Web 站点四列网格顺序 |
|
||||||
| `web/data/hero_items.json` | 核心成品装备缓存(Web 站点只读;由 `fetch_hero_items.py` 生成) |
|
| `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_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/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;含现役战队席位与昔日国服明星;外号:查理斯→Chalice,Somnus 即 Maybe;CN/EU/SA 等);`fetch_pro_matches.py` 默认只拉此名单;队名可过期,以 id 为准;**不**进 recommend |
|
| `web/data/pro_player_watchlist.json` | 明星选手 OpenDota `account_id` 手工名单(对照 Dotabuff / Liquipedia;含现役战队席位与昔日国服明星;外号:查理斯→Chalice,Somnus 即 Maybe;CN/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/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/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`;抖音/B 站 profile 补全;`platform_meta`;Web 卡片:头像行 = 头像 \| 昵称+账号/获赞粉丝 \|「关注」,签名(`signature`)独立全宽行(最长 3 行);在播时粉环 +「直播」角标叠在环底(抖音式 `bottom:-6px`,无间距);视口分档滚播(远处不拉、近处 metadata、中部 `canplay` 且单路 `preload=auto`)+ 同名 JPG 封面占位;有 `live_url` 时点头像进直播间;**「直播」角标/动效只信 `is_live` 真实探测**(线上 `/api/live-status` 边缘缓存 5 分钟;`data.json` daily 兜底;`live_url` 仅作点击入口);由 `fetch_streamers.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_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 对位/协同 Top(counters/countered/synergies;由 `fetch_stratz_meta.py` 生成;全局聚合 + `scope`/`stale`/`fetched_at`;Web「对位」Tab;与定性 `relations.json` 分开展示;**不**参与局内推荐) |
|
| `web/data/stratz_matchup_tops.json` | STRATZ 对位/协同 Top(counters/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/item_shop.json` | 商店 11 列目录(官网 basic/upgrade;由 `fetch_item_shop.py` 生成;物品页只读) |
|
||||||
| `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
|
| `web/data/items_meta.json` | 成品装备描述与机制标签(由 `fetch_items_meta.py` 生成) |
|
||||||
| `web/data/item_tag_overrides.json` | 装备标签手工加减(合并进 items_meta) |
|
| `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/ability_tag_overrides.json` | 技能「施加」类 tags 手工加减(合并进 hero_abilities;仅 Web 机制页) |
|
||||||
| `web/data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
| `web/data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
||||||
| `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags`) |
|
| `web/data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成;含 ability `tags`) |
|
||||||
| `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations) |
|
| `web/data/item_counter_stats.json` | OpenDota 对阵装备观测证据缓存(对阵购买率/条件胜率减同装备全局基线;可再生成;不进 recommend / relations) |
|
||||||
| `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) |
|
| `web/data/hero_item_fears.json` | 英雄怕的装备(规则推导;Web「怕」行) |
|
||||||
| `web/frontend/` | 上分帝 Web 前端静态资源(`index.html` / `config.js` / `app.js` / `style.css` / `router.js` / `mobile-gate.js` / `functions/`);`config.js` 含 `SITE_VERSION`、`ABILITY_VIDEO_BASE`、`STATIC_ASSET_BASE`;版本页底部显示 `v{SITE_VERSION}`;移动端由 `mobile-gate.js` 拦截 |
|
| `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` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 `heroes\|rankings\|streamers\|matches\|trends\|mechanics\|items\|patches` / 英雄 + 子标签 `skills\|core\|fears\|trends\|matchups\|matches\|streamers\|patches` / Immortal 地区 `#/rankings[/region]` / 明星比赛 `#/matches[/account_id][?origin=pro\|china][&page=N]`(默认全部类型、每页 20 场) / 主播目录 `#/streamers` / 近 8 周走势榜 `#/trends[/bracket][?sort=pr]`(默认按胜率) / 机制查询 `#/mechanics[/{effect}]`(默认 `basic_dispel`) / 物品 / 版本 / 标签筛选 / 搜索;旧 `stats` / `#/rankings/meta` 别名兼容) |
|
| `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/hero_portraits/` | 官网横版头像(上分帝 Web;默认 wide 面部构图,非匹配模板) |
|
||||||
| `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
|
| `web/assets/attr_icons/` | 官网主属性图标(力量/敏捷/智力/全才,上分帝 Web 用) |
|
||||||
| `web/assets/rank_icons/` | 天梯勋章图标(OpenDota `rank_icon_1..8` 先锋→冠绝;走势段位选择器) |
|
| `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_icons/` | Steam CDN 装备图标(上分帝 Web常用装备) |
|
||||||
| `web/assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
|
| `web/assets/item_cat_icons/` | 官网商店分类图标(`itemcat_*.png`,物品页列头) |
|
||||||
| `web/assets/ability_icons/` | Steam CDN 技能图标(上分帝 Web;按需缓存);`innate.png` 先天共用图标;`talent_tree.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` 等,技能详情底栏冷却图标)+ 平台 logo(`platform_douyin.png`,主播页) |
|
| `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_avatars/` | 主播头像缓存(由 `fetch_streamers.py` 写入;上分帝 Web「主播」) |
|
||||||
| `web/assets/streamer_videos/` | 主播精选高光 mp4 + 同名 JPG 封面(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/`) |
|
| `web/assets/streamer_videos/` | 主播精选高光 mp4 + 同名 JPG 封面(手工放入;上分帝 Web「主播」卡片;勿提交,线上走 OSS `streamer-video/`) |
|
||||||
| `web/assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor` 桶 `ability-video/` 前缀托管) |
|
| `web/assets/ability_videos/` | 官网技能演示视频(webm/mp4;勿提交,由脚本拉取;线上由阿里云 OSS `climperor` 桶 `ability-video/` 前缀托管) |
|
||||||
@@ -138,11 +149,12 @@ climperor/
|
|||||||
运行时产物(**勿提交**,见 `.gitignore`):
|
运行时产物(**勿提交**,见 `.gitignore`):
|
||||||
|
|
||||||
- `pc/samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下
|
- `pc/samples/raw/<matchid>/` — GSI 会话截图与 `gsi.jsonl`;手动 `capture.py` 可写在 `raw/` 根下
|
||||||
|
- `pc/player_pages/` — 赛后玩家主页/比赛 JSON(OpenDota;默认私有;同意公开后入队 Cloudflare D1/R2,不进主 data.json)
|
||||||
- `pc/preview/` — 标定 / sheet 预览
|
- `pc/preview/` — 标定 / sheet 预览
|
||||||
- `web/dist/` — 静态站点导出(`export_relations_site.py`)
|
- `web/dist/` — 静态站点导出(`export_relations_site.py`)
|
||||||
- `pc/results/` — 每局 JSON
|
- `pc/results/` — 每局 JSON
|
||||||
- `pc/failures/` — `--truth` 调试用错识裁切
|
- `pc/failures/` — `--truth` 调试用错识裁切
|
||||||
- `shared/data/matchups.json` / `synergies.json` / `stratz_matchups.json` / `relations_audit.json` 等 — 可再生成缓存与报告
|
- `shared/data/synergies.json` / `stratz_matchups.json` / `relations_audit.json` 等 — 可再生成缓存与报告
|
||||||
- `web/data/hero_stats.json` / `hero_matches.json` / `stratz_*.json` 等 — 可再生成缓存
|
- `web/data/hero_stats.json` / `hero_matches.json` / `stratz_*.json` 等 — 可再生成缓存
|
||||||
- `web/assets/ability_videos/` — 官网技能演示(体积大,可再拉取)
|
- `web/assets/ability_videos/` — 官网技能演示(体积大,可再拉取)
|
||||||
- `web/assets/streamer_videos/` — 主播精选高光(体积大,手工放入后 OSS 同步)
|
- `web/assets/streamer_videos/` — 主播精选高光(体积大,手工放入后 OSS 同步)
|
||||||
@@ -156,23 +168,27 @@ Dota 2 GSI → pc/gsi_watch.py (:3223)
|
|||||||
→ recognize_image (CDN 模板 + 可选天梯遮罩)
|
→ recognize_image (CDN 模板 + 可选天梯遮罩)
|
||||||
→ roles(分路字)/ shared.grid(禁用 + 单元格)/ modes
|
→ roles(分路字)/ shared.grid(禁用 + 单元格)/ modes
|
||||||
→ recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI)
|
→ recommend 克/搭/补(relations + draft_archetypes 规则画像,本人槽位只信 GSI)
|
||||||
→ overlay 角色标签 + 网格克/搭/补 + 阵容分析条(可选)
|
→ overlay 网格克/搭/补 + 阵容分析条;本人锁定后改推装备图标条(可选)
|
||||||
→ pc/results/draft_*.json + 终端时间线
|
→ pc/results/draft_*.json + 终端时间线
|
||||||
|
→ POST_GAME:player_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,不用截屏名字亮度猜测。
|
||||||
- **克制 / 搭档数据**:机制克制/搭档只用定性边存 `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 补全;首版仅抖音),**仅** Web 顶层「主播」与英雄详情「主播」Tab,禁止进 recommend;由 `refresh_web` daily 软失败刷新粉丝等字段(不阻断整档)。
|
- **克制 / 搭档数据**:机制克制/搭档只用定性边存 `shared/data/relations.json`(克制有向 + 搭档无向 + 理由),**不要**用胜率/场次表达机制克制,也**不要**写入 `shared/data/heroes.json`。Web 英雄页三视图:克制 / 被克制 / 搭档。OpenDota 段位胜率/场次单独存 `web/data/hero_stats.json`(近约 7 天窗;上场率 = 出场/(Σ出场/10);冠绝样本过小时与超凡合并),**仅**上分帝 Web 英雄详情「走势」**无 STRATZ 时兜底**。STRATZ 周胜率/分路/`meta_board` 存 `web/data/stratz_hero_meta.json`(三卡与分路均取 **当前选中勋章 · 最近 1 周** `winWeek`;近 8 周列表从新到旧;分路在 8 周列表上方),对位 Top 存 `web/data/stratz_matchup_tops.json`(英雄详情「走势」与顶层「走势」榜 `/trends` 优先用 STRATZ;「对位」Tab 只读),**禁止**合并进 relations/heroes,禁止 `pc/recommend.py` 读取。同英雄近期比赛出装/加点单独存 `web/data/hero_matches.json`,**仅** Web「近期比赛」Tab,禁止进 recommend。Valve Immortal 四区榜单独存 `web/data/leaderboards.json`,**仅** Web「排行」页选手榜,禁止进 recommend。主播目录单独存 `web/data/streamers.json`(手工名单 + 抖音 profile 补全;平台含抖音 / B 站 / 斗鱼),**仅** Web 顶层「主播」与英雄详情「主播」Tab,禁止进 recommend;由 `refresh_web` daily 软失败刷新粉丝等字段(不阻断整档)。
|
||||||
- **装备机制 / 怕的装备**:标签与技能汇总来自 Valve/OpenDota 自动抽取 + `item_tag_overrides.json`;`item_fears.py` 规则映射到英雄弱点。`fetch_item_counter_stats.py` 的对阵购买率/胜率差仅作为观测证据:须减去同装备全局基线,对全部英雄的已有机制候选只允许小幅调序;统计发现的新组合即使同时满足 `games≥100`、购买率提升 `≥3pp`、条件胜率差 `≥1.5pp`、两项双比例检验 `z≥1.96`,仍须确认机制成立后手工写入 overrides,禁止直接把高相关当因果克制。大段技能文案只进 `web/data/hero_abilities.json` / `items_meta.json`,**不要**塞进 `heroes.json`。本阶段仅上分帝 Web 展示,**不对局内出装推荐**。核心装「使用率」为 `hero_items` 列表内相对热度归一化,非绝对出场率、无段位维度。技能/物品「施加」类 tags(机制查询页)与技能 `dispellable`(效果能否被驱散)严格区分;**禁止**写入 recommend / relations。
|
- **装备机制 / 怕的装备**:标签与技能汇总来自 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。
|
- **机制查询页**:顶层 `/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 Actions(self-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。
|
- **上分帝 Web 定时刷新**:Gitea Actions(self-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/R2(PC **不**内置云密钥)。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。
|
||||||
|
|
||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
@@ -186,7 +202,7 @@ python web/fetch_hero_items.py # OpenDota 热门装备缓存(上分帝
|
|||||||
python web/fetch_hero_stats.py # OpenDota 各段位胜率/场次(上分帝 Web 走势兜底)
|
python web/fetch_hero_stats.py # OpenDota 各段位胜率/场次(上分帝 Web 走势兜底)
|
||||||
python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top(走势/对位/Meta;需 token)
|
python web/fetch_stratz_meta.py # STRATZ 周胜率/分路/对位 Top(走势/对位/Meta;需 token)
|
||||||
python web/fetch_hero_matches.py # 同英雄近期比赛出装/加点(上分帝 Web;可 --heroes antimage;--workers 6;--enrich-item-times)
|
python web/fetch_hero_matches.py # 同英雄近期比赛出装/加点(上分帝 Web;可 --heroes antimage;--workers 6;--enrich-item-times)
|
||||||
python web/fetch_pro_matches.py # 明星选手近期联赛对局(默认 watchlist;--include-pubs 含天梯;可 --players Ame,898754153)
|
python web/fetch_pro_matches.py # 明星选手近期联赛对局(默认 watchlist;--include-pubs 含天梯;--refresh-limit 15 轮换;可 --players Ame,898754153)
|
||||||
python web/fetch_leaderboards.py # Valve Immortal 四区 Top100(排行页)
|
python web/fetch_leaderboards.py # Valve Immortal 四区 Top100(排行页)
|
||||||
python web/fetch_streamers.py # 抖音主播主页补全(主播页;手工名单;亦由 daily 定时软失败刷新)
|
python web/fetch_streamers.py # 抖音主播主页补全(主播页;手工名单;亦由 daily 定时软失败刷新)
|
||||||
python web/fetch_streamer_live.py # 探测真实在播状态回写 is_live(主播页角标;daily;--dry-run 只打印)
|
python web/fetch_streamer_live.py # 探测真实在播状态回写 is_live(主播页角标;daily;--dry-run 只打印)
|
||||||
@@ -219,11 +235,12 @@ python pc/evaluate.py
|
|||||||
|
|
||||||
- 只改任务所需逻辑,避免无关重构与大范围格式化。
|
- 只改任务所需逻辑,避免无关重构与大范围格式化。
|
||||||
- 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。
|
- 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。
|
||||||
- 改匹配阈值或裁切时,用 `pc/evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `DESIGN.md`。
|
- 改匹配阈值或裁切时,用 `pc/evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `ARCHITECTURE.md`。
|
||||||
|
- 改上分帝 Web 视觉(色板、字号、间距、圆角、组件态)时先对齐 `DESIGN.md` 令牌,再改 `web/frontend/style.css`;勿引入未入规范的硬编码尺度。
|
||||||
- 改 GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录。
|
- 改 GSI cfg 时同步核对 `pc/gsi_setup.py` 与 Dota `gamestate_integration` 目录。
|
||||||
- 改 Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js`(`parseHash` / `serializeHash`)与 `app.js` 的 `applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`web/serve_relations.py` 无需改;`web/export_relations_site.py` 的导出文件列表须含 `router.js`。
|
- 改 Web 路由形态(URL 段 / query 参数 / 默认值)时同步 `web/frontend/router.js`(`parseHash` / `serializeHash`)与 `app.js` 的 `applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。History 深度链接依赖 `web/frontend/_redirects`(Cloudflare)与 `web/serve_relations.py` 的 SPA fallback(含 `players`);预渲染路径变更时同步 `web/seo_prerender.py` 与导出文件列表(含 `router.js` / `_redirects` / `robots.txt`)。
|
||||||
- 改 `shared/data/heroes.json` 结构时同步 `shared/grid.py`(依赖 `attr` / `name_loc`);`roles` 供 `pc/overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `pc/fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),上分帝 Web 筛选 + 局内 `draft_archetypes` 缺口/推进画像共用;基础属性/血蓝等数值字段供上分帝 Web 详情条,勿塞机制文案。
|
- 改 `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`。
|
- 发版上分帝 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`。
|
||||||
|
|
||||||
## 文档分工
|
## 文档分工
|
||||||
@@ -231,7 +248,8 @@ python pc/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` | 面向用户的版本变更 |
|
||||||
|
|
||||||
|
|||||||
@@ -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 约 8–21 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`
|
||||||
@@ -4,6 +4,593 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- PC 本人锁定英雄后,按阵容推荐装备:核心装(`hero_items` 相对热度)+ 敌方应对装(定性规则),overlay 以图标条展示;结果写入 `recommendations.items`。
|
||||||
|
- 定性克制:远古冰魂 → 瘟疫法师(冰晶爆轰禁疗)。
|
||||||
|
|
||||||
|
## [0.6.57] - 2026-08-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 物品中文简称:`item_alias_overrides.json`(斯嘉蒂之眼→冰眼,怪蛇之息→蛇矛);物品页可搜简称,悬停/详情显示。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 小小「怕的装备」:手工改为含怪蛇之息(`hydras_breath`),去掉清莲宝珠。
|
||||||
|
|
||||||
|
## [0.6.56] - 2026-08-01
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 生产「比赛详情」:补齐 `POST /api/players/ensure-match`(此前落入动态路由仅支持 GET → HTTP 405);详情规范化写入 R2,与本机 `player_pages` 同形。
|
||||||
|
|
||||||
|
## [0.6.55] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家同步:OpenDota 空/残缺响应不再覆盖已有近 20 场、常用英雄、队友与段位。
|
||||||
|
- 样式:`style.css` 缓存改为 60s,并 bump 版本戳,避免同 `?v=` 内容变更仍命中旧 CSS 导致「我」页双栏错乱。
|
||||||
|
- 补齐 OSS `ui-icon`(含 wordmark),避免顶栏 Logo 404。
|
||||||
|
|
||||||
|
## [0.6.54] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 「我」/玩家页:有战绩时不再因 TTL `stale` 轮询十余秒或闪「加载中…」;仅空档案才同步等待。
|
||||||
|
|
||||||
|
## [0.6.53] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 近期比赛:去掉行中弹性空隙;时长/相对时间与胜负/#ID 收成右侧紧簇,避免宽屏悬空。
|
||||||
|
|
||||||
|
## [0.6.52] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 「我」/玩家页:缓存优先——首次可慢,约 10 分钟 TTL 内后续直出(生产 D1 + Queue;本机 `profile.json` + 后台 enrich);前端有新鲜数据时不再强制全量重拉。
|
||||||
|
|
||||||
|
## [0.6.51] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 近期比赛:弹性空白改到时长与相对时间之间,避免两者挤在一起、右侧大片空。
|
||||||
|
|
||||||
|
## [0.6.50] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 十人详情:返回按钮移到比赛摘要卡上方,改为 36px 描边控件 + SVG 左箭头 +「返回玩家主页」。
|
||||||
|
|
||||||
|
## [0.6.49] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 十人详情:昵称列封顶约 16rem,中间剩余宽度四等分给参战/伤害/经济/KDA,避免昵称挤占指标空间。
|
||||||
|
|
||||||
|
## [0.6.48] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 十人详情:去掉独立「返回主页」描边按钮,改为摘要头右侧文字链「← 玩家主页」。
|
||||||
|
|
||||||
|
## [0.6.47] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 十人详情:参战/伤害/经济/KDA 作为居中指标组,不再贴靠右侧装备列。
|
||||||
|
|
||||||
|
## [0.6.46] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家页分析区标题「常用队友」改为「队友」。
|
||||||
|
|
||||||
|
## [0.6.45] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 近期比赛:时长与相对时间改为水平并排(如 `41:54 · 3小时前`),并拉开与英雄列、胜负列的间距。
|
||||||
|
|
||||||
|
## [0.6.44] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家身份条段位勋章放大到 64px(对齐 56px 头像的光学尺寸;OpenDota 图标含画布留白)。
|
||||||
|
|
||||||
|
## [0.6.43] - 2026-07-31
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- 「我」/玩家页摘要区去掉近 20 场英雄肖像条(胜负已由下方近期比赛列表表达)。
|
||||||
|
|
||||||
|
## [0.6.42] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家仪表盘恢复真实 1200px 数据主列;900–1200px 保持五列摘要和十人详情横向比较,近 20 场英雄条改为无滚动条双排网格,180 天活动改为按周排列并补强度图例。
|
||||||
|
- 十人详情增加共享列头,胜负统一使用绿/红语义色;组队/MVP 徽章移至昵称次行,避免挤压长昵称。
|
||||||
|
|
||||||
|
## [0.6.41] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 「我」/玩家页改为紧凑数据仪表盘:身份条 + 生涯/近 N 场并排摘要 + 分析双栏(常用英雄 / 180 天与队友)+ 固定列近期比赛;十人详情摘要头与昵称/指标/装备列对齐统一。
|
||||||
|
|
||||||
|
## [0.6.40] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 「我」近期比赛:时长/时间与英雄/KDA 收成左侧紧凑双列表格(上:英雄+时长,下:KDA+相对时间),胜负靠右,消除中间悬空。
|
||||||
|
|
||||||
|
## [0.6.39] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 「我」近期比赛:时长/时间与英雄名/KDA、胜负/#ID 改为统一双行网格,同行同字号对齐。
|
||||||
|
|
||||||
|
## [0.6.38] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 比赛十人详情:加宽昵称列,空间足够时尽量完整显示玩家名(组队/MVP 徽章不抢缩名称)。
|
||||||
|
|
||||||
|
## [0.6.37] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家段位改为勋章图标 + 星级叠层(如传奇四),不再并排显示「传奇4」文字;冠绝榜位仍显示 `#N`。
|
||||||
|
|
||||||
|
## [0.6.36] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 「我」近期比赛:时长与相对时间改为双行列(上时长、下时间),与英雄/胜负对齐,紧跟英雄信息。
|
||||||
|
|
||||||
|
## [0.6.35] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 「我」近期比赛:时长与相对时间贴近英雄信息,不再挤在右侧。
|
||||||
|
|
||||||
|
## [0.6.34] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 「我」近期比赛:时长与相对时间同列垂直居中对齐;「胜利」/`失败` 用绿/红字;身份条去掉「本机/未公开」。
|
||||||
|
|
||||||
|
## [0.6.33] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 「我」主页丰富:生涯场次/胜率/KDA、近 20 场摘要与英雄条、常用英雄、180 天活动热力图与样本最高、公开常用队友;空数据不显示伪 0%。
|
||||||
|
- 生产多用户数据层:Cloudflare D1 `climperor-users` + Queue `climperor-player-sync` + Worker 异步同步;Pages 已绑定 `DB` / `SYNC_QUEUE`;`GET /api/players/me` 读库并排队刷新。R2 比赛详情桶待 Dashboard 启用后接入。
|
||||||
|
- 本机 enrich 同步写入 `career` / `recent_20` / `top_heroes` / `activity_180` / `peers` / `availability`。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- `/home` 点击近期比赛可进入详情(此前被 `page !== players` 早退)。
|
||||||
|
|
||||||
|
## [0.6.32] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 顶栏登录后 Tab「我的主页」改为「我」。
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 玩家主页补拉 OpenDota 段位(`rank_tier`)与昵称;「我」页展示勋章。近期比赛仍依赖「公开比赛数据」或本机 GSI。
|
||||||
|
|
||||||
|
## [0.6.31] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Steam 登录:顶栏「Steam 登录」;登录后显示头像/昵称与「退出」,并在「英雄」前出现「我」(`/home`,复用玩家战绩页看本人近 2 天比赛)。本机与 Pages 需配置 `STEAM_API_KEY` + `SESSION_SECRET`。
|
||||||
|
|
||||||
|
## [0.6.30] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家主页:补拉 API 不可用时不再闪「补拉跳过」文案;已有列表则静默失败。
|
||||||
|
|
||||||
|
## [0.6.29] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家比赛详情主行显示 Steam 昵称(有公开 ID 时补拉 OpenDota;本人可从 GSI 取名);无昵称显示「匿名」/「玩家 {id}」,不再用英雄名冒充玩家名。
|
||||||
|
|
||||||
|
## [0.6.28] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 玩家页:本场有公开 `account_id` 的昵称可点进 `/players/{id}`;进入主页补拉近 2 天比赛列表(OpenDota + 本机 GSI 目录);点进某场再懒加载十人详情。
|
||||||
|
|
||||||
|
## [0.6.27] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家比赛详情参战/伤害/经济/KDA 强制同一行显示。
|
||||||
|
|
||||||
|
## [0.6.26] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家比赛详情个人经济改为完整数值(如 `25,903`),不再用「万」缩写。
|
||||||
|
|
||||||
|
## [0.6.25] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 玩家比赛详情:每人显示个人经济(净身价);OpenDota 有 `party_id` 且 ≥2 人同组时标注「组A/B…」。
|
||||||
|
|
||||||
|
## [0.6.24] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家比赛详情:OpenDota 隐去 `account_id` 时仍按场内槽位标注 MVP(此前会整场不显示)。
|
||||||
|
|
||||||
|
## [0.6.23] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家比赛详情:参战/伤害/KDA 居中于英雄与装备之间。
|
||||||
|
|
||||||
|
## [0.6.22] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 玩家比赛详情:去掉易混淆的「比」列,KDA 改为 `5/14/15(1.4)`;指标紧贴英雄,空白留给装备前。
|
||||||
|
|
||||||
|
## [0.6.21] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 玩家比赛详情与近期列表显示开赛时间(相对时间,悬停看完整时间)。
|
||||||
|
|
||||||
|
## [0.6.20] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家比赛详情:收紧「参战/伤害/KDA/比」列宽并加大与装备区间距,避免「比」与物品图标重叠。
|
||||||
|
|
||||||
|
## [0.6.19] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家比赛详情:装备位固定补齐 6 格并锁定列宽,避免物品数量不同把参战/伤害/KDA 整列挤歪。
|
||||||
|
|
||||||
|
## [0.6.18] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 玩家比赛详情十人战绩:参战 / 伤害 / KDA / 比改为固定列宽网格对齐,避免数字长短导致错位。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 上分帝 Web 每日刷新明星比赛改为按 `fetched_at` 轮换(默认每次 15 人),遇 OpenDota 连续 429 熔断并保留旧缓存;`hero_stats` / `hero_matches` 限流时 soft-fail 保留缓存,避免整档 daily 被拖死。
|
||||||
|
- 上分帝 Web daily:OpenDota 先单次探测,429/失败则跳过 stats/matches/pro;`hero_stats` 遇 429 不再长退避;部署前拒绝空壳 `hero_stats` / STRATZ 覆盖生产。`fetch_patches` 图标下载 import 修复,可正常入库新版本(含 7.41e)。
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- PC 选将 overlay 不再在顶栏头像下显示定位图标;仍保留网格「克/搭/补」与阵容分析条。
|
||||||
|
|
||||||
|
## [0.6.17] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 版本页宽屏「AI 解读」侧栏随可用右侧空隙加宽(约 280–420px),窄于 1440px 仍上下堆叠。
|
||||||
|
|
||||||
|
## [0.6.16] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- PC 赛后玩家页:进入 `POST_GAME` 后异步拉取 OpenDota,本机生成 `/players/{account_id}` 主页与 `/players/{account_id}/{match_id}` 十人战绩详情(参战/伤害/KDA/装备/MVP);默认私有(`pc/player_pages/`),`player_pages.public_share=true` 时经 `/api/players/publish` 同步到 OSS 公开。
|
||||||
|
|
||||||
|
## [0.6.15] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 物品详情下方展示近一年该物品的版本改动时间线(与英雄「改动」同源 `patches` 数据;无改动则不显示)。
|
||||||
|
|
||||||
|
## [0.6.14] - 2026-07-31
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 版本页侧栏标题改为「AI 解读」,去掉「非官方 · 社区摘要」副标。
|
||||||
|
|
||||||
|
## [0.6.13] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 版本页「AI 解读」:`max-height` 按顶栏留白(`100dvh - 9rem`),sticky 面板不再伸出视口,主栏未滚到底也能独立滚完解读全文。
|
||||||
|
|
||||||
|
## [0.6.12] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 版本页技能图标:补拉补丁引用的 CDN 图标;先天无 CDN 图时用 `innate.png`;加载失败不再留空框(回退先天徽章或移除 img)。
|
||||||
|
|
||||||
|
## [0.6.11] - 2026-07-31
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 版本页:正文单独居中,解读贴在主列右侧;解读侧栏视口内可滚动看完全文。
|
||||||
|
|
||||||
|
## [0.6.10] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 上分帝 Web 版本页:最新版右侧「版本解读」侧栏(手写静态摘要 `patch_summaries.json`,非官方);入库 **7.41e** 补丁详情与解读。
|
||||||
|
|
||||||
|
## [0.6.9] - 2026-07-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 恢复 OSS 英雄横版头像(曾被 96×96 匹配模板误覆盖);静态导出/上传不再回退到 `pc/templates/cdn`,缺图或非宽图会直接失败;头像 URL 缓存戳改为 `v=wide2`。
|
||||||
|
|
||||||
|
## [0.6.8] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 主播:Emo(火女教学)、奶绿(琼英碧灵 / dota2不落)。
|
||||||
|
- 琼英碧灵别名「奶绿」。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 物品页商店网格在视口内居中,详情栏落在右侧空隙,不再把整块商店挤偏。
|
||||||
|
|
||||||
|
## [0.6.7] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 比赛页主列由 1680px 收至 1200px,并按居中布局预留右侧筛选栏宽度,避免选手列表被裁切。
|
||||||
|
|
||||||
|
## [0.6.6] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 英雄详情抽屉改为按 `#columns` 实测高度预留完整网格(含最后一排),剩余给抽屉;技能 Tab 不硬性规定高度,内容随抽屉 flex/滚动。
|
||||||
|
- 技能演示视频在抽屉变矮时宽度随高度收缩,保持 16:9,避免左右黑边。
|
||||||
|
|
||||||
|
## [0.6.5] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 英雄详情抽屉按视口高度计算:预留顶栏、约 24% 英雄网格与定位标签,上限 640px(原 `88dvh`/720 几乎遮满网格)。
|
||||||
|
|
||||||
|
## [0.6.4] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 比赛卡片头部显示比赛 ID(顶层「比赛」页与英雄详情「近期比赛」Tab 共用)。
|
||||||
|
|
||||||
|
## [0.6.3] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 英雄详情改为自底部上滑抽屉:高度展开顶起定位标签(标签不挂在面板上);点击空白或下滑可关闭;去掉标签行全宽底与打开时的网格遮罩条。
|
||||||
|
- 比赛页去掉筛选侧栏重复的「共 X 场 · 第 X/X 页」(仅保留列表底部分页区统计)。
|
||||||
|
|
||||||
|
## [0.6.2] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 英雄页底部居中显示站点版本号与数据更新时间(取各数据集 `fetched_at` 最新值;打开详情时隐藏)。
|
||||||
|
- 点击定位标签上方空白(英雄网格空隙或工具栏空白)可关闭详情面板。
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 版本页底部不再显示站点版本号(改由英雄页底部展示)。
|
||||||
|
|
||||||
|
## [0.6.1] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 英雄详情标题下显示英文名与缩写(如 `Anti-Mage · AM`;无缩写时仅英文名)。
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 站点版本升至 **0.6.0**:汇总 `0.5.x` 已成型能力——History 路径路由与 SEO/GEO 预渲染、设计令牌与顶栏壳、英雄页定位/详情布局、主播多平台与直播探测纠偏、monorepo 与刷新部署可靠性。此后增量 UI/文案仍抬末位;壳层、路由或可索引能力等阶段性成型再抬中段。
|
||||||
|
- 顶栏品牌「上分帝」改用思源宋体(Noto Serif SC)子集,气质更贴「帝」;正文仍为系统无衬线。
|
||||||
|
|
||||||
|
## [0.5.115] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 上分帝 Web SEO / GEO:History 路径路由(兼容旧 `#/...` 书签)、首页 meta/OG/JSON-LD、路由态动态 `title`/`description`、爬虫跳过移动端门禁。
|
||||||
|
- 导出预渲染:各英雄 `/heroes/{key}`、机制 `/mechanics/{effect}` 与顶层页可抓取 HTML;生成 `sitemap.xml` / `robots.txt` / `llms.txt`;Cloudflare `_redirects` SPA fallback;本地 `serve_relations` 同步支持深度链接刷新。
|
||||||
|
|
||||||
|
## [0.5.114] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- 主播资料补全支持斗鱼:`v.douyu.com/author/<hash>`(兼容 `author-video`)拉粉丝/关注/播放/头像;仅有房间号时从直播间 HTML 解析 `up_id` 再拉作者页(失败才回退 `betard`)。斗鱼卡片展示「关注 / 播放 / 粉丝」。
|
||||||
|
- 主播页收录斗鱼「踏上征途167(狗皇)」「天残少年K9」「叶子长青K」、抖音「浙大小鱼王—dota2」与 B 站「Dota2绝中绝」;开播探测支持斗鱼(`betard`)。
|
||||||
|
|
||||||
|
## [0.5.112] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 技能详情演示视频:有横向空间时加宽(上限约 720px),按官网 16:9 原比例显示;去掉左右 `cover` 裁切与过窄的 480px 上限。
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Web 刷新可靠性:前端 / `relations.json` / 英雄表 / 网格顺序变更也会触发部署;主播 `is_live` 翻转不再单独触发整站部署。
|
||||||
|
- 主播直播探测失败统一清为未开播(不再沿用旧的“直播中”兜底);`--dry-run` 不再 restore/save 缓存或写 summary;patch 因全量刷新锁冲突跳过时飞书摘要显示「已跳过」。
|
||||||
|
|
||||||
|
## [0.5.111] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 主播页排序:先按是否在播(在播靠前),再按粉丝数降序;直播状态刷新后同步重排。
|
||||||
|
|
||||||
|
## [0.5.110] - 2026-07-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 主播直播状态:线上与本地预览均执行平台探测;探测失败或过期状态不再沿用旧的“直播中”结果,只有本轮确认开播才显示直播角标。
|
||||||
|
|
||||||
|
## [0.5.109] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 英雄页:定位筛选迁至网格与详情之间(Valve 图标斜角按钮);去掉详情 Chip;未选英雄时隐藏详情占位。
|
||||||
|
- 提高英雄详情固定高度(约至 640px 上限),压缩网格与详情之间的多余空白;Tab 切换高度不跳动。
|
||||||
|
- 技能 Tab:演示视频与文案强制左右排布;去掉图标与详情之间的中间分割线。
|
||||||
|
- 核心装备 / 被克装备:详情区上下分割线统一为全宽。
|
||||||
|
|
||||||
|
## [0.5.108] - 2026-07-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 技能 Tab:演示视频与文案强制左右排布(不再因中间栏偏窄折行上下叠);详情固定高度提至约 560px,常规技能默认可视区内完整展示。
|
||||||
|
|
||||||
|
## [0.5.107] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 技能 Tab:去掉技能图标与详情之间的中间分割线(装备 Tab 保留)。
|
||||||
|
|
||||||
|
## [0.5.106] - 2026-07-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 核心装备 / 被克装备:详情区上下分割线统一为全宽(合成线不再缩在 720px 文案栏内)。
|
||||||
|
|
||||||
|
## [0.5.105] - 2026-07-30
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 技能 / 核心装备 / 被克装备等 Tab 切换时详情区高度不再跳动(恢复固定高 + 禁止压缩)。
|
||||||
|
|
||||||
|
## [0.5.104] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 缩小英雄详情固定高度,给网格与定位栏留更多空间;走势 / 对位等长内容仍在详情区内滚动。
|
||||||
|
|
||||||
|
## [0.5.103] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 未选中英雄时隐藏详情占位条,不再显示提示文案。
|
||||||
|
|
||||||
|
## [0.5.102] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 战斗属性补全转身速率:OpenDota 缺省时按引擎默认 `0.6` 显示(与官网一致);
|
||||||
|
`heroes.json` 回填 82 名英雄。
|
||||||
|
|
||||||
|
## [0.5.101] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 选中英雄时定位栏不再点亮「全部」,只高亮该英雄定位。
|
||||||
|
|
||||||
|
## [0.5.100] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 去掉英雄定位栏上下分割线。
|
||||||
|
|
||||||
|
## [0.5.99] - 2026-07-30
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 英雄详情技能区去掉定位 Chip;选中英雄时在上方定位栏高亮对应选项(不影响网格筛选)。
|
||||||
|
|
||||||
|
## [0.5.98] - 2026-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 英雄详情战斗属性(攻击 / 防御 / 机动性)改为官网同款图标 + 数值横排;图标取自
|
||||||
|
dota2.com.cn `herostatic/stats`,视野显示为「白天 / 夜晚」。
|
||||||
|
|
||||||
|
## [0.5.97] - 2026-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 英雄定位筛选移至英雄网格与详情面板之间,改用 Valve 风格图标与斜角按钮;保留多选,
|
||||||
|
暂不展示「幻象」筛选。
|
||||||
|
|
||||||
|
## [0.5.96] - 2026-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 加长主导航选中下划线至 44px;hover 改为仅文字提亮,移除与斜切分隔冲突的矩形冷光底。
|
||||||
|
|
||||||
|
## [0.5.95] - 2026-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 移除顶栏品牌 Logo 右侧斜线,仅保留暗红内光,减少品牌区与导航之间的装饰噪声。
|
||||||
|
|
||||||
|
## [0.5.94] - 2026-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 放大宽屏顶栏比例:主导航改为 16px / 76px 最小宽 / 44px 点击高,品牌 Logo 提升至
|
||||||
|
32px;≤1200px 自动恢复 15px / 40px 紧凑规格,筛选 Chip 保持次级尺寸。
|
||||||
|
|
||||||
|
## [0.5.93] - 2026-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 顶栏轻度借鉴 Dota 2 国服官网:加入近黑金属横梁、暗红品牌内光与斜切细分隔;
|
||||||
|
保留青色短下划线和定位 Chip,不复刻门户式梯形按钮、厚重贴图与高顶栏。
|
||||||
|
|
||||||
|
## [0.5.92] - 2026-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 主导航放弃连续按钮外框,改为轻量文字 Tab:透明背景、低噪 hover 与青色短下划线选中态;定位筛选继续使用独立 Chip,强化两级导航层次。
|
||||||
|
|
||||||
|
## [0.5.91] - 2026-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 顶部导航改为紧凑双层:首行品牌 | 居中主导航 | 搜索+邮件;次行仅英雄定位筛选。
|
||||||
|
去掉品牌/邮件绝对悬浮与搜索贴边,减少横向空洞;窄桌面收紧 Tab/搜索并隐藏品牌标题。
|
||||||
|
|
||||||
|
## [0.5.90] - 2026-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 对位页底部文案简化为「数据来自 STRATZ(相对优势,全局聚合)。」
|
||||||
|
|
||||||
|
## [0.5.89] - 2026-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 英雄「对位」统一只使用 STRATZ(`stratz_matchup_tops.json`);移除 OpenDota
|
||||||
|
对位交叉与页面「来源一致 / 分歧」提示;`audit_relations` 改为 STRATZ 单源审计。
|
||||||
|
|
||||||
|
## [0.5.88] - 2026-07-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 根目录 `DESIGN.md` 改为上分帝 Web 视觉设计规范([design.md](https://github.com/google-labs-code/design.md) 格式);
|
||||||
|
原技术方案迁至 `ARCHITECTURE.md`。
|
||||||
|
- Web 统一设计令牌:色板、字号层级、4/8 间距、圆角、内容宽度档位与焦点态;
|
||||||
|
排行 / 比赛 / 主播 / 物品补齐页标题;版本号展示降至 32px;断点收敛为 640 / 900 / 1200。
|
||||||
|
|
||||||
|
## [0.5.87] - 2026-07-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 技能/物品文案填充:Valve 中文 `%token%` 与 `special_values` 名称大小写不一致、
|
||||||
|
以及魔晶/神杖 `%bonus_<sv>%` 指向升级通道时不再留下 `?`(如乌尔萨震撼大地
|
||||||
|
「减速4秒」、祸乱蚀脑魔晶冷却)。
|
||||||
|
- 技能详情有演示视频时加宽右侧文案列、收窄视频上限,空间够时技能介绍尽量单行。
|
||||||
|
- 技能详情特殊数值标签不再露出 Valve `<font>` 原文(如「眩晕时间」);数值级差
|
||||||
|
保持单行;冷却/文案同底栏下沉,避免介绍区窄列竖排与多余滚轮。
|
||||||
|
- 技能详情底栏蓝量靠右,不再贴着冷却时间。
|
||||||
|
- 版本页笔记剥离 Valve `<font>` / `<span class="New|Subtitle">` 等标签,只保留换行。
|
||||||
|
|
||||||
## [0.5.86] - 2026-07-29
|
## [0.5.86] - 2026-07-29
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,269 +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` 到约 `280–640px`
|
||||||
|
(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 约 8–21 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 / 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_matchups.py / fetch_stratz.py
|
|
||||||
│ └── data/heroes.json / data/relations.json
|
|
||||||
├── .gitea/workflows/ # site-traffic-notify + web-daily/weekly/patch
|
|
||||||
└── README.md / AGENTS.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 |
|
| 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` | 选将全网格克/搭/补推荐 | 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 |
|
|
||||||
| `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` / `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 |
|
|
||||||
|
|
||||||
---
|
间距尺度:4 / 8 / 12 / 16 / 24 / 32 / 48px。页内 gutter 默认 16px;列表页上下 `page-y` 28px。侧栏与主列间距 20px。
|
||||||
|
|
||||||
## 5. GSI 自动化
|
断点收敛为三档:
|
||||||
|
|
||||||
```
|
- `sm` ≤640px:极窄桌面(列堆叠、双列网格)
|
||||||
Dota 2 (-gamestateintegration)
|
- `md` ≤900px:aside 改上下堆叠、英雄两列
|
||||||
→ POST → pc/gsi_watch.py :3223
|
- `lg` ≤1200px:物品详情上移、列间距收紧
|
||||||
→ pc/samples/raw/{matchid}/gsi.jsonl(可选)
|
|
||||||
→ DraftSession 轮询 recognize + roles/grid/modes
|
|
||||||
→ pc/results/draft_*.json
|
|
||||||
```
|
|
||||||
|
|
||||||
要点:按 `matchid` 去重;截图进 `pc/samples/raw/{matchid}/`;识别在工作线程;
|
手机不走上述断点,由 UA 门禁接管。
|
||||||
未标定时降级为只截图。
|
|
||||||
|
|
||||||
天梯 AP 选人按 2/2/1 成批揭晓,本轮结束前互不可见——因此必须在
|
## Elevation & Depth
|
||||||
**HERO_SELECTION** 跟踪,不能只在决策时间截一张终局图;决策前期仍读几帧
|
|
||||||
以吃到最后一人揭晓(见发现七)。
|
|
||||||
|
|
||||||
---
|
深度靠色调分层与细边框,不用厚重投影:
|
||||||
|
|
||||||
## 6. 选将全网格「克 / 搭 / 补」推荐
|
1. 页面渐变底
|
||||||
|
2. 半透明顶栏 / 面板(`panel-soft`)
|
||||||
|
3. 实心一点的浮层(`panel`、物品 sticky 详情、天赋 popover)
|
||||||
|
|
||||||
- **触发**:敌方至少锁定 1 人(`min_enemies`)后开始;本人锁人后清空标记。
|
选中态用 inset 底线、填充主色或 1–2px `accent` 描边;阴影仅用于浮层(popover / 气泡),模糊约 24–32px、黑色 45% 透明度。
|
||||||
- **分路**:定位匹配画面字(`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 Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
|
|
||||||
标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe`、`#/heroes/axe/trends` 等)。选 Hash
|
|
||||||
而非 Path 路由:静态导出(GitHub Pages)无需 rewrite、`web/serve_relations.py` 零改动、
|
|
||||||
相对路径子站亦可用。写 URL 用 `history.pushState`/`replaceState`(静默,不触发
|
|
||||||
`hashchange`,无回环);前进/后退靠 `hashchange` 监听重 parse + 校验 + render。
|
|
||||||
搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在
|
|
||||||
`web/frontend/router.js`,`app.js` 只在 `main()` 装 `installRouter` + 各 state 变更点
|
|
||||||
调 `syncStateToUrl`。
|
|
||||||
- **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `shared/data/heroes.json`;不接实时 AI。
|
|
||||||
|
|
||||||
---
|
## 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`)。最小点击高度 36–40px。
|
||||||
|
|
||||||
|
### 业务标记(不可通用化)
|
||||||
|
|
||||||
|
- 克 / 怕 / 搭 → `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`)。
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|
||||||
## 原理
|
## 原理
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ python web/fetch_streamer_live.py # 探测真实在播状态 → is_live/live_p
|
|||||||
python web/serve_relations.py # 本地开发服务 http://127.0.0.1:8765(改 JSON 后刷新)
|
python web/serve_relations.py # 本地开发服务 http://127.0.0.1:8765(改 JSON 后刷新)
|
||||||
```
|
```
|
||||||
|
|
||||||
Web 站点带 **Hash 路由**:`#/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`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python web/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
|
||||||
|
|
||||||
@@ -117,12 +117,12 @@ python web/refresh_web.py --tier patch --skip-deploy --skip-oss
|
|||||||
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
|
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
|
||||||
|
|
||||||
顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `web/data/leaderboards.json`(`python web/fetch_leaderboards.py`)。
|
顶部另有 **排行** 页:Valve Immortal 四区榜各 Top 100(中国 / 欧洲 / 美洲 / 东南亚);数据来自 `web/data/leaderboards.json`(`python web/fetch_leaderboards.py`)。
|
||||||
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + B 站);抖音式卡片布局为头像行(头像 \| 昵称 + 抖音号/获赞/粉丝或 B 站 UID/关注/粉丝 \|「关注」)+ 签名独立全宽行(最长 3 行)+ 常用英雄 + 可选精选高光视频(视口分档:远处不拉、近处 metadata、滚入中部 `canplay` 静音自动播放,同时仅 1 路全量缓冲;同名 JPG 封面作占位;有 `live_url` 时点头像进直播间;在播时粉环 +「直播」角标叠在环底、无间距——线上由访客触发的 `/api/live-status` 边缘函数刷新,缓存 5 分钟合并请求;`data.json` 内 `fetch_streamer_live.py` 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/`。
|
顶部另有 **主播** 页:手工收录直播间/主页(抖音 + B 站);列表先按是否在播、再按粉丝数降序;抖音式卡片布局为头像行(头像 \| 昵称 + 抖音号/获赞/粉丝或 B 站 UID/关注/粉丝 \|「关注」)+ 签名独立全宽行(最长 3 行)+ 常用英雄 + 可选精选高光视频(视口分档:远处不拉、近处 metadata、滚入中部 `canplay` 静音自动播放,同时仅 1 路全量缓冲;同名 JPG 封面作占位;有 `live_url` 时点头像进直播间;在播时粉环 +「直播」角标叠在环底、无间距——线上由访客触发的 `/api/live-status` 边缘函数刷新(缓存 5 分钟合并请求),本地 `serve_relations.py` 同步真实探测(内存缓存 60s);角标只信本轮成功探测,`stale`/失败不沿用旧「直播中」;`data.json` 内 daily 探测值仅作接口返回前的首屏兜底);数据来自 `web/data/streamers.json`(`python web/fetch_streamers.py` 补全;亦由 `web/refresh_web --tier daily` 每日软失败刷新)。视频与封面放 `web/assets/streamer_videos/`(gitignore),本地经 `/streamer-video/` 提供,部署前用 `web/_oss_static_assets.py upload` 同步至 OSS `streamer-video/`。
|
||||||
顶部另有 **比赛** 页(`#/matches[/account_id][?origin=pro|china][&page=N]`,默认全部类型、每页 20 场):侧栏可筛职业/国服与选手;明星选手 watchlist 近期联赛/锦标赛与天梯对局(出装/加点;卡片标职业/天梯/国服);名单见 `web/data/pro_player_watchlist.json`,数据由 `python web/fetch_pro_matches.py --include-pubs` 拉取(亦进 daily)。
|
顶部另有 **比赛** 页(`/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 周胜率数据)。
|
顶部另有 **走势** 页:近 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`)。
|
顶部另有 **物品** 页:对齐 [官网商店物品](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` 调)。
|
顶部另有 **版本** 页:默认展示最新版本完整改动(综合 / 物品 / 中立物品 / 英雄),下拉切换近一年其它版本;数据来自 `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 全局聚合相对优势,非走势页段位/周口径;与网格定性克/搭分开展示;有 OpenDota matchups 时页面可提示来源一致/分歧)。被克装备以描述标签 + 规则为主;`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`。**尚无对局内出装推荐**。
|
数据在 `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 自动跟踪)
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,7 @@
|
|||||||
"min_iou": 0.55
|
"min_iou": 0.55
|
||||||
},
|
},
|
||||||
"recommend": {
|
"recommend": {
|
||||||
"comment": "Full-grid 克/搭/补 from relations + draft_archetypes (push/global/gaps). top_n<=0 = no cap. No AI.",
|
"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": 0,
|
"top_n": 0,
|
||||||
"min_enemies": 1,
|
"min_enemies": 1,
|
||||||
@@ -115,6 +115,15 @@
|
|||||||
"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": {
|
||||||
@@ -137,12 +146,22 @@
|
|||||||
"target_slots": 10,
|
"target_slots": 10,
|
||||||
"dump_payloads": true
|
"dump_payloads": true
|
||||||
},
|
},
|
||||||
"overlay": {
|
"player_pages": {
|
||||||
"comment": "Role tags under top-bar + 克/搭/补 marks + lineup analysis banner.",
|
"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,
|
||||||
"mark_gap_rel": 0.002,
|
"mark_gap_rel": 0.002,
|
||||||
@@ -154,7 +173,13 @@
|
|||||||
"analysis_h_rel": 0.028,
|
"analysis_h_rel": 0.028,
|
||||||
"analysis_font_rel": 0.014,
|
"analysis_font_rel": 0.014,
|
||||||
"analysis_bg": "#1a2332",
|
"analysis_bg": "#1a2332",
|
||||||
"analysis_fg": "#e8eef7"
|
"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"
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,7 @@ from capture import grab_frame, is_dota_foreground, raw_dir_for_match, save_fram
|
|||||||
from shared.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 item_suggest import suggest_items
|
||||||
from recommend import ally_keys, enemy_keys, load_relations, suggest_marks
|
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
|
||||||
|
|
||||||
@@ -95,10 +96,18 @@ class DraftSession:
|
|||||||
self.recommend_archetypes = bool(rec.get("archetypes", True))
|
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_enemy_profile: dict = {}
|
||||||
self._last_rec_meta: 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:
|
||||||
@@ -113,6 +122,7 @@ class DraftSession:
|
|||||||
cells: dict | None,
|
cells: dict | None,
|
||||||
picks: list[dict],
|
picks: list[dict],
|
||||||
analysis: str = "",
|
analysis: str = "",
|
||||||
|
items: list[dict] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self.overlay is None:
|
if self.overlay is None:
|
||||||
return
|
return
|
||||||
@@ -121,6 +131,8 @@ class DraftSession:
|
|||||||
self.overlay.set_grid_marks(cells or {}, marks)
|
self.overlay.set_grid_marks(cells or {}, marks)
|
||||||
if hasattr(self.overlay, "set_analysis"):
|
if hasattr(self.overlay, "set_analysis"):
|
||||||
self.overlay.set_analysis(analysis or "")
|
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}")
|
||||||
|
|
||||||
@@ -134,9 +146,12 @@ class DraftSession:
|
|||||||
self.overlay.set_grid_marks({}, {})
|
self.overlay.set_grid_marks({}, {})
|
||||||
if hasattr(self.overlay, "set_analysis"):
|
if hasattr(self.overlay, "set_analysis"):
|
||||||
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"):
|
||||||
@@ -157,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
|
||||||
@@ -248,8 +264,8 @@ 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)
|
||||||
if self.keep_frames and n in (4, 8, 10) and n not in saved_milestones:
|
if self.keep_frames and n in (4, 8, 10) and n not in saved_milestones:
|
||||||
@@ -276,6 +292,8 @@ class DraftSession:
|
|||||||
self.overlay.set_grid_marks({}, {})
|
self.overlay.set_grid_marks({}, {})
|
||||||
if hasattr(self.overlay, "set_analysis"):
|
if hasattr(self.overlay, "set_analysis"):
|
||||||
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}")
|
||||||
@@ -383,26 +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 and not self.recommend_archetypes:
|
|
||||||
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_enemy_profile = {}
|
|
||||||
self._last_rec_meta = {}
|
|
||||||
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"):
|
||||||
@@ -411,11 +473,13 @@ 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
|
||||||
|
self._last_item_sig = None
|
||||||
if len(enemies) < self.recommend_min_enemies:
|
if len(enemies) < self.recommend_min_enemies:
|
||||||
info["recommendations"] = []
|
info["recommendations"] = []
|
||||||
|
info["item_recommendations"] = []
|
||||||
self._last_enemy_profile = {}
|
self._last_enemy_profile = {}
|
||||||
self._last_rec_meta = {}
|
self._last_rec_meta = {}
|
||||||
self._push_rec_overlay(info.get("cells"), [], "")
|
self._push_rec_overlay(info.get("cells"), [], "", [])
|
||||||
return
|
return
|
||||||
result = suggest_marks(
|
result = suggest_marks(
|
||||||
position=position,
|
position=position,
|
||||||
@@ -433,6 +497,7 @@ class DraftSession:
|
|||||||
profile = result.get("enemy_profile") or {}
|
profile = result.get("enemy_profile") or {}
|
||||||
analysis = result.get("analysis") or ""
|
analysis = result.get("analysis") or ""
|
||||||
info["recommendations"] = picks
|
info["recommendations"] = picks
|
||||||
|
info["item_recommendations"] = []
|
||||||
self._last_enemy_profile = profile
|
self._last_enemy_profile = profile
|
||||||
self._last_rec_meta = {
|
self._last_rec_meta = {
|
||||||
"analysis": analysis,
|
"analysis": analysis,
|
||||||
@@ -441,7 +506,7 @@ class DraftSession:
|
|||||||
"ally_gaps": list(result.get("ally_gaps") or []),
|
"ally_gaps": list(result.get("ally_gaps") or []),
|
||||||
"ally_profile": dict(result.get("ally_profile") or {}),
|
"ally_profile": dict(result.get("ally_profile") or {}),
|
||||||
}
|
}
|
||||||
self._push_rec_overlay(info.get("cells"), picks, analysis)
|
self._push_rec_overlay(info.get("cells"), picks, analysis, [])
|
||||||
if picks or analysis:
|
if picks or analysis:
|
||||||
bits = []
|
bits = []
|
||||||
for p in picks[:12]:
|
for p in picks[:12]:
|
||||||
@@ -597,6 +662,7 @@ class DraftSession:
|
|||||||
"ally_gaps": list((self._last_rec_meta or {}).get("ally_gaps") or []),
|
"ally_gaps": list((self._last_rec_meta or {}).get("ally_gaps") or []),
|
||||||
"analysis": (self._last_rec_meta or {}).get("analysis") 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:
|
||||||
@@ -680,6 +746,13 @@ def describe(summary: dict) -> list[str]:
|
|||||||
name = p.get("name_loc") or loc(p["key"], names)
|
name = p.get("name_loc") or loc(p["key"], names)
|
||||||
bits.append(f"{name}[{labs}]" + (f"({why})" if why else ""))
|
bits.append(f"{name}[{labs}]" + (f"({why})" if why else ""))
|
||||||
lines.append(f"rec : {len(picks)} — {', '.join(bits)}")
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -102,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)) if turn is not None else 0.6
|
||||||
result["turn_rate"] = _round1(float(turn))
|
except (TypeError, ValueError):
|
||||||
except (TypeError, ValueError):
|
result["turn_rate"] = 0.6
|
||||||
pass
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,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
|
||||||
|
|
||||||
@@ -77,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:
|
||||||
@@ -123,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
|
||||||
@@ -144,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):
|
||||||
|
|||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Transparent click-through overlay: role tags + 克/搭/补 marks + analysis bar.
|
"""Transparent click-through overlay: 克/搭/补 marks + analysis bar + item icons.
|
||||||
|
|
||||||
Runs a Tk root on a background thread. DraftSession calls set_roster(),
|
Runs a Tk root on a background thread. DraftSession calls set_grid_marks(),
|
||||||
set_grid_marks(), and set_analysis(); geometry uses relative coords.
|
set_analysis(), and set_items(); geometry uses relative coords.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,26 +11,12 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
import json
|
|
||||||
import threading
|
import threading
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
||||||
import mss
|
import mss
|
||||||
|
|
||||||
from common import HEROES_JSON as HEROES_PATH, ROOT, slot_rect_px
|
from shared.paths import ITEM_ICONS
|
||||||
|
|
||||||
ROLE_ICONS_DIR = ROOT / "assets" / "role_icons"
|
|
||||||
|
|
||||||
ROLE_ORDER = [
|
|
||||||
"Carry",
|
|
||||||
"Support",
|
|
||||||
"Nuker",
|
|
||||||
"Disabler",
|
|
||||||
"Durable",
|
|
||||||
"Escape",
|
|
||||||
"Initiator",
|
|
||||||
"Pusher",
|
|
||||||
]
|
|
||||||
|
|
||||||
CHROMA = "#ff00ff"
|
CHROMA = "#ff00ff"
|
||||||
DEFAULT_COUNTER_COLOR = "#2ec4b6"
|
DEFAULT_COUNTER_COLOR = "#2ec4b6"
|
||||||
@@ -39,6 +25,9 @@ DEFAULT_FILL_COLOR = "#9b7ebd"
|
|||||||
DEFAULT_MARK_TEXT = "#0b1220"
|
DEFAULT_MARK_TEXT = "#0b1220"
|
||||||
DEFAULT_ANALYSIS_BG = "#1a2332"
|
DEFAULT_ANALYSIS_BG = "#1a2332"
|
||||||
DEFAULT_ANALYSIS_FG = "#e8eef7"
|
DEFAULT_ANALYSIS_FG = "#e8eef7"
|
||||||
|
DEFAULT_ITEM_CORE_BG = "#1a2332"
|
||||||
|
DEFAULT_ITEM_ANSWER_BG = "#2a1f14"
|
||||||
|
DEFAULT_ITEM_REASON_FG = "#e8eef7"
|
||||||
LABEL_ORDER = ("克", "搭", "补")
|
LABEL_ORDER = ("克", "搭", "补")
|
||||||
LABEL_COLORS = {
|
LABEL_COLORS = {
|
||||||
"克": "counter",
|
"克": "counter",
|
||||||
@@ -47,13 +36,6 @@ LABEL_COLORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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]:
|
def _primary_monitor_size() -> tuple[int, int]:
|
||||||
with mss.MSS() as sct:
|
with mss.MSS() as sct:
|
||||||
mon = sct.monitors[1]
|
mon = sct.monitors[1]
|
||||||
@@ -83,9 +65,6 @@ class DraftOverlay:
|
|||||||
def __init__(self, cfg: dict):
|
def __init__(self, cfg: dict):
|
||||||
self.cfg = cfg
|
self.cfg = cfg
|
||||||
o = cfg.get("overlay") or {}
|
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_size_rel = float(o.get("mark_size_rel", 0.018))
|
||||||
self.mark_pad_rel = float(o.get("mark_pad_rel", 0.004))
|
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.mark_gap_rel = float(o.get("mark_gap_rel", 0.002))
|
||||||
@@ -98,11 +77,17 @@ class DraftOverlay:
|
|||||||
self.analysis_font_rel = float(o.get("analysis_font_rel", 0.014))
|
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_bg = str(o.get("analysis_bg", DEFAULT_ANALYSIS_BG))
|
||||||
self.analysis_fg = str(o.get("analysis_fg", DEFAULT_ANALYSIS_FG))
|
self.analysis_fg = str(o.get("analysis_fg", DEFAULT_ANALYSIS_FG))
|
||||||
self.roles_by_key = _load_roles_by_key()
|
self.items_y_rel = float(o.get("items_y_rel", 0.16))
|
||||||
self._roster: dict[int, str] = {}
|
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._cells: dict[str, dict] = {}
|
||||||
self._marks: dict[str, list[str]] = {}
|
self._marks: dict[str, list[str]] = {}
|
||||||
self._analysis = ""
|
self._analysis = ""
|
||||||
|
self._items: list[dict] = []
|
||||||
self._ready = threading.Event()
|
self._ready = threading.Event()
|
||||||
self._closed = False
|
self._closed = False
|
||||||
self._root: tk.Tk | None = None
|
self._root: tk.Tk | None = None
|
||||||
@@ -137,7 +122,6 @@ class DraftOverlay:
|
|||||||
canvas.pack(fill="both", expand=True)
|
canvas.pack(fill="both", expand=True)
|
||||||
self._canvas = canvas
|
self._canvas = canvas
|
||||||
self._screen = (sw, sh)
|
self._screen = (sw, sh)
|
||||||
self._load_icon_sources()
|
|
||||||
root.update_idletasks()
|
root.update_idletasks()
|
||||||
try:
|
try:
|
||||||
hwnd = int(root.wm_frame(), 16) if root.wm_frame().startswith("0x") else int(root.winfo_id())
|
hwnd = int(root.wm_frame(), 16) if root.wm_frame().startswith("0x") else int(root.winfo_id())
|
||||||
@@ -157,38 +141,9 @@ class DraftOverlay:
|
|||||||
pass
|
pass
|
||||||
self._closed = True
|
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:
|
def set_roster(self, confirmed: dict[int, str]) -> None:
|
||||||
"""Update tags for confirmed slots (hero_key by slot index)."""
|
"""No-op kept for DraftSession compatibility (role tags under avatars removed)."""
|
||||||
roster = {int(k): v for k, v in confirmed.items() if v}
|
return
|
||||||
if roster == self._roster:
|
|
||||||
return
|
|
||||||
self._roster = dict(roster)
|
|
||||||
self._schedule_redraw()
|
|
||||||
|
|
||||||
def set_analysis(self, text: str | None) -> None:
|
def set_analysis(self, text: str | None) -> None:
|
||||||
"""Short lineup analysis banner (empty clears)."""
|
"""Short lineup analysis banner (empty clears)."""
|
||||||
@@ -198,6 +153,24 @@ class DraftOverlay:
|
|||||||
self._analysis = value
|
self._analysis = value
|
||||||
self._schedule_redraw()
|
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(
|
def set_grid_marks(
|
||||||
self,
|
self,
|
||||||
cells: dict[str, dict] | None,
|
cells: dict[str, dict] | None,
|
||||||
@@ -265,6 +238,7 @@ class DraftOverlay:
|
|||||||
self._canvas.delete("all")
|
self._canvas.delete("all")
|
||||||
self._photos.clear()
|
self._photos.clear()
|
||||||
self._analysis = ""
|
self._analysis = ""
|
||||||
|
self._items = []
|
||||||
root.withdraw()
|
root.withdraw()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -296,6 +270,85 @@ class DraftOverlay:
|
|||||||
self._thread.join(timeout=2.0)
|
self._thread.join(timeout=2.0)
|
||||||
self._closed = True
|
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:
|
def _redraw(self) -> None:
|
||||||
canvas = self._canvas
|
canvas = self._canvas
|
||||||
if canvas is None:
|
if canvas is None:
|
||||||
@@ -303,9 +356,6 @@ class DraftOverlay:
|
|||||||
canvas.delete("all")
|
canvas.delete("all")
|
||||||
self._photos.clear()
|
self._photos.clear()
|
||||||
sw, sh = self._screen
|
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))
|
|
||||||
|
|
||||||
if self._analysis:
|
if self._analysis:
|
||||||
bar_h = max(18, int(round(self.analysis_h_rel * sh)))
|
bar_h = max(18, int(round(self.analysis_h_rel * sh)))
|
||||||
@@ -327,26 +377,7 @@ class DraftOverlay:
|
|||||||
font=("Microsoft YaHei UI", font_size, "bold"),
|
font=("Microsoft YaHei UI", font_size, "bold"),
|
||||||
)
|
)
|
||||||
|
|
||||||
for slot in self.cfg.get("slots") or []:
|
self._draw_items(canvas, sw, sh)
|
||||||
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)))
|
mark = max(12, int(round(self.mark_size_rel * sh)))
|
||||||
pad = max(2, int(round(self.mark_pad_rel * sh)))
|
pad = max(2, int(round(self.mark_pad_rel * sh)))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""TTL helpers for player homepage cache-first serving."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PC = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PC))
|
||||||
|
|
||||||
|
from player_pages import ( # noqa: E402
|
||||||
|
profile_fetched_at,
|
||||||
|
profile_has_payload,
|
||||||
|
profile_is_stale,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _iso_ago(seconds: int) -> str:
|
||||||
|
t = datetime.now(timezone.utc) - timedelta(seconds=seconds)
|
||||||
|
return t.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerPagesCacheTests(unittest.TestCase):
|
||||||
|
def test_has_payload_career(self):
|
||||||
|
self.assertTrue(profile_has_payload({"career": {"games": 10}}))
|
||||||
|
self.assertFalse(profile_has_payload({"career": {"games": 0}}))
|
||||||
|
|
||||||
|
def test_has_payload_recent(self):
|
||||||
|
self.assertTrue(profile_has_payload({"recent": [{"match_id": 1}]}))
|
||||||
|
self.assertFalse(profile_has_payload({"recent": []}))
|
||||||
|
|
||||||
|
def test_fetched_at_prefers_availability(self):
|
||||||
|
p = {
|
||||||
|
"availability": {"fetched_at": "2026-01-01T00:00:00Z"},
|
||||||
|
"enriched_at": "2026-01-02T00:00:00Z",
|
||||||
|
}
|
||||||
|
self.assertEqual(profile_fetched_at(p), "2026-01-01T00:00:00Z")
|
||||||
|
|
||||||
|
def test_stale_ttl(self):
|
||||||
|
fresh = {"enriched_at": _iso_ago(60)}
|
||||||
|
old = {"enriched_at": _iso_ago(1200)}
|
||||||
|
self.assertFalse(profile_is_stale(fresh, 600))
|
||||||
|
self.assertTrue(profile_is_stale(old, 600))
|
||||||
|
self.assertTrue(profile_is_stale({}, 600))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""Unit tests for player homepage aggregates."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PC = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PC))
|
||||||
|
|
||||||
|
from player_stats import ( # noqa: E402
|
||||||
|
aggregate_from_rows,
|
||||||
|
career_from_opendota,
|
||||||
|
kda,
|
||||||
|
merge_availability,
|
||||||
|
should_keep_old_career,
|
||||||
|
top_heroes_from_opendota,
|
||||||
|
winrate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerStatsTests(unittest.TestCase):
|
||||||
|
def test_kda_zero_deaths(self):
|
||||||
|
self.assertEqual(kda(5, 0, 5), 10.0)
|
||||||
|
|
||||||
|
def test_winrate_empty(self):
|
||||||
|
self.assertIsNone(winrate(0, 0))
|
||||||
|
self.assertEqual(winrate(1, 1), 50.0)
|
||||||
|
|
||||||
|
def test_aggregate_recent(self):
|
||||||
|
rows = [
|
||||||
|
{"won": True, "kills": 10, "deaths": 2, "assists": 8, "gpm": 500, "hero_id": 1},
|
||||||
|
{"won": False, "kills": 0, "deaths": 10, "assists": 2, "gpm": 300, "hero_id": 2},
|
||||||
|
]
|
||||||
|
out = aggregate_from_rows(rows, limit=20)
|
||||||
|
self.assertEqual(out["sample"], 2)
|
||||||
|
self.assertEqual(out["wins"], 1)
|
||||||
|
self.assertEqual(out["winrate"], 50.0)
|
||||||
|
self.assertEqual(out["avg_gpm"], 400)
|
||||||
|
|
||||||
|
def test_career_empty_private(self):
|
||||||
|
self.assertIsNone(career_from_opendota({"win": 0, "lose": 0}, []))
|
||||||
|
|
||||||
|
def test_career_populated(self):
|
||||||
|
career = career_from_opendota(
|
||||||
|
{"win": 10, "lose": 10},
|
||||||
|
[
|
||||||
|
{"field": "kills", "n": 20, "sum": 100},
|
||||||
|
{"field": "deaths", "n": 20, "sum": 50},
|
||||||
|
{"field": "assists", "n": 20, "sum": 150},
|
||||||
|
{"field": "gold_per_min", "n": 20, "sum": 8000},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(career["games"], 20)
|
||||||
|
self.assertEqual(career["kda"], 5.0)
|
||||||
|
self.assertEqual(career["avg_gpm"], 400)
|
||||||
|
|
||||||
|
def test_keep_old_career_on_empty(self):
|
||||||
|
old = {"games": 100, "wins": 50, "losses": 50}
|
||||||
|
self.assertEqual(should_keep_old_career(old, None), old)
|
||||||
|
new = {"games": 101, "wins": 51, "losses": 50}
|
||||||
|
self.assertEqual(should_keep_old_career(old, new), new)
|
||||||
|
|
||||||
|
def test_top_heroes(self):
|
||||||
|
heroes = top_heroes_from_opendota(
|
||||||
|
[
|
||||||
|
{"hero_id": 1, "games": 5, "win": 3, "last_played": 100},
|
||||||
|
{"hero_id": 2, "games": 10, "win": 4, "last_played": 90},
|
||||||
|
],
|
||||||
|
hero_lookup={1: {"key": "antimage", "name_loc": "敌法"}, 2: {"key": "axe", "name_loc": "斧王"}},
|
||||||
|
limit=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(len(heroes), 1)
|
||||||
|
self.assertEqual(heroes[0]["hero_key"], "axe")
|
||||||
|
|
||||||
|
def test_availability_syncing(self):
|
||||||
|
avail = merge_availability(
|
||||||
|
opendota_recent_n=0,
|
||||||
|
career=None,
|
||||||
|
steam_history_status=1,
|
||||||
|
fetched_at="t",
|
||||||
|
)
|
||||||
|
self.assertEqual(avail["status"], "syncing")
|
||||||
|
private = merge_availability(
|
||||||
|
opendota_recent_n=0,
|
||||||
|
career=None,
|
||||||
|
steam_history_status=15,
|
||||||
|
fetched_at="t",
|
||||||
|
)
|
||||||
|
self.assertEqual(private["status"], "private")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
"""Audit qualitative relations.json against OpenDota + STRATZ.
|
"""Audit qualitative relations.json against STRATZ matchup / synergy caches.
|
||||||
|
|
||||||
Sources:
|
Sources:
|
||||||
- shared/data/matchups.json OpenDota vs winrates
|
|
||||||
- shared/data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups)
|
- shared/data/stratz_matchups.json STRATZ vs advantage (fetch_stratz.py --mode matchups)
|
||||||
- shared/data/synergies.json STRATZ teammate synergies
|
- shared/data/synergies.json STRATZ teammate synergies
|
||||||
- web/data/stratz_matchup_tops.json Web 对位 Top (optional cross-check section)
|
|
||||||
|
Hero matchup numbers for the Web site come only from
|
||||||
|
web/data/stratz_matchup_tops.json (fetch_stratz_meta.py); this audit does not
|
||||||
|
cross-check against OpenDota.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python shared/audit_relations.py
|
python shared/audit_relations.py
|
||||||
@@ -23,14 +25,11 @@ import json
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from shared.grid import hero_table
|
from shared.grid import hero_table
|
||||||
from shared.matchup_cross import audit_matchup_tops
|
from shared.paths import ROOT, SHARED_DATA
|
||||||
from shared.paths import DATA, ROOT, SHARED_DATA
|
|
||||||
from shared.relations import DEFAULT_RELATIONS, load_relations
|
from shared.relations import DEFAULT_RELATIONS, load_relations
|
||||||
|
|
||||||
MATCHUPS = SHARED_DATA / "matchups.json"
|
|
||||||
STRATZ_MATCHUPS = SHARED_DATA / "stratz_matchups.json"
|
STRATZ_MATCHUPS = SHARED_DATA / "stratz_matchups.json"
|
||||||
SYNERGIES = SHARED_DATA / "synergies.json"
|
SYNERGIES = SHARED_DATA / "synergies.json"
|
||||||
STRATZ_MATCHUP_TOPS = DATA / "stratz_matchup_tops.json"
|
|
||||||
# Legacy monorepo-move leftovers (repo-root data/).
|
# Legacy monorepo-move leftovers (repo-root data/).
|
||||||
_LEGACY_DATA = ROOT / "data"
|
_LEGACY_DATA = ROOT / "data"
|
||||||
|
|
||||||
@@ -52,10 +51,6 @@ def _load_json_safe(path: Path) -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _pair_key(a: str, b: str) -> tuple[str, str]:
|
|
||||||
return (a, b) if a <= b else (b, a)
|
|
||||||
|
|
||||||
|
|
||||||
def load_id_maps() -> tuple[dict[int, str], dict[str, int], dict[str, str]]:
|
def load_id_maps() -> tuple[dict[int, str], dict[str, int], dict[str, str]]:
|
||||||
table = hero_table()
|
table = hero_table()
|
||||||
id_to_key = {int(h["id"]): h["key"] for h in table}
|
id_to_key = {int(h["id"]): h["key"] for h in table}
|
||||||
@@ -64,41 +59,6 @@ def load_id_maps() -> tuple[dict[int, str], dict[str, int], dict[str, str]]:
|
|||||||
return id_to_key, key_to_id, names
|
return id_to_key, key_to_id, names
|
||||||
|
|
||||||
|
|
||||||
def matchup_wr(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]:
|
|
||||||
cell = (by_hero.get(str(aid)) or {}).get(str(bid))
|
|
||||||
if not cell:
|
|
||||||
return None, 0
|
|
||||||
games = int(cell.get("games") or 0)
|
|
||||||
wins = int(cell.get("wins") or 0)
|
|
||||||
if games <= 0:
|
|
||||||
return None, 0
|
|
||||||
return wins / games, games
|
|
||||||
|
|
||||||
|
|
||||||
def build_baseline(by_hero: dict) -> dict[int, float]:
|
|
||||||
out: dict[int, float] = {}
|
|
||||||
for hid_s, opps in by_hero.items():
|
|
||||||
tw = tg = 0
|
|
||||||
for cell in opps.values():
|
|
||||||
g = int(cell.get("games") or 0)
|
|
||||||
w = int(cell.get("wins") or 0)
|
|
||||||
tw += w
|
|
||||||
tg += g
|
|
||||||
if tg > 0:
|
|
||||||
out[int(hid_s)] = tw / tg
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def odota_adv(by_hero: dict, baseline: dict[int, float], aid: int, bid: int) -> tuple[float | None, int]:
|
|
||||||
wr, games = matchup_wr(by_hero, aid, bid)
|
|
||||||
if wr is None:
|
|
||||||
return None, 0
|
|
||||||
base = baseline.get(aid)
|
|
||||||
if base is None:
|
|
||||||
return None, games
|
|
||||||
return wr - base, games
|
|
||||||
|
|
||||||
|
|
||||||
def stratz_adv(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]:
|
def stratz_adv(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]:
|
||||||
cell = (by_hero.get(str(aid)) or {}).get(str(bid))
|
cell = (by_hero.get(str(aid)) or {}).get(str(bid))
|
||||||
if not cell:
|
if not cell:
|
||||||
@@ -112,31 +72,23 @@ def stratz_adv(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]:
|
|||||||
def main() -> None:
|
def main() -> None:
|
||||||
ap = argparse.ArgumentParser(description=__doc__)
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
ap.add_argument("--min-games", type=int, default=80)
|
ap.add_argument("--min-games", type=int, default=80)
|
||||||
ap.add_argument("--odota-agree", type=float, default=0.015)
|
|
||||||
ap.add_argument("--odota-disagree", type=float, default=-0.015)
|
|
||||||
ap.add_argument("--stratz-agree", type=float, default=1.0)
|
ap.add_argument("--stratz-agree", type=float, default=1.0)
|
||||||
ap.add_argument("--stratz-disagree", type=float, default=-1.0)
|
ap.add_argument("--stratz-disagree", type=float, default=-1.0)
|
||||||
ap.add_argument("--syn-agree", type=float, default=1.0)
|
ap.add_argument("--syn-agree", type=float, default=1.0)
|
||||||
ap.add_argument("--top", type=int, default=10)
|
ap.add_argument("--suggest-min-adv", type=float, default=2.0)
|
||||||
ap.add_argument("--out", type=Path, default=SHARED_DATA / "relations_audit.json")
|
ap.add_argument("--out", type=Path, default=SHARED_DATA / "relations_audit.json")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
matchups_path = _resolve_cache(MATCHUPS, _LEGACY_DATA / "matchups.json")
|
|
||||||
stratz_path = _resolve_cache(STRATZ_MATCHUPS, _LEGACY_DATA / "stratz_matchups.json")
|
stratz_path = _resolve_cache(STRATZ_MATCHUPS, _LEGACY_DATA / "stratz_matchups.json")
|
||||||
syn_path = _resolve_cache(SYNERGIES, _LEGACY_DATA / "synergies.json")
|
syn_path = _resolve_cache(SYNERGIES, _LEGACY_DATA / "synergies.json")
|
||||||
tops_path = _resolve_cache(STRATZ_MATCHUP_TOPS, _LEGACY_DATA / "stratz_matchup_tops.json")
|
|
||||||
|
|
||||||
id_to_key, key_to_id, names = load_id_maps()
|
_id_to_key, key_to_id, names = load_id_maps()
|
||||||
all_keys = sorted(key_to_id.keys(), key=lambda k: key_to_id[k])
|
all_keys = sorted(key_to_id.keys(), key=lambda k: key_to_id[k])
|
||||||
rel = load_relations()
|
rel = load_relations()
|
||||||
odota = _load_json_safe(matchups_path)
|
|
||||||
stratz = _load_json_safe(stratz_path)
|
stratz = _load_json_safe(stratz_path)
|
||||||
syn_file = _load_json_safe(syn_path)
|
syn_file = _load_json_safe(syn_path)
|
||||||
tops = _load_json_safe(tops_path)
|
|
||||||
by_o = odota.get("by_hero") or {}
|
|
||||||
by_s = stratz.get("by_hero") or {}
|
by_s = stratz.get("by_hero") or {}
|
||||||
by_syn = syn_file.get("by_hero") or {}
|
by_syn = syn_file.get("by_hero") or {}
|
||||||
baseline = build_baseline(by_o)
|
|
||||||
|
|
||||||
counters = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("counters") or []]
|
counters = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("counters") or []]
|
||||||
syns = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("synergies") or []]
|
syns = [(e["a"], e["b"], e.get("reason") or "") for e in rel.get("synergies") or []]
|
||||||
@@ -148,9 +100,7 @@ def main() -> None:
|
|||||||
if aid is None or bid is None:
|
if aid is None or bid is None:
|
||||||
counter_no_data.append({"a": a, "b": b, "why": "unknown_hero"})
|
counter_no_data.append({"a": a, "b": b, "why": "unknown_hero"})
|
||||||
continue
|
continue
|
||||||
o_adv, o_games = odota_adv(by_o, baseline, aid, bid)
|
|
||||||
s_adv, s_games = stratz_adv(by_s, aid, bid)
|
s_adv, s_games = stratz_adv(by_s, aid, bid)
|
||||||
r_o, r_og = odota_adv(by_o, baseline, bid, aid)
|
|
||||||
r_s, r_sg = stratz_adv(by_s, bid, aid)
|
r_s, r_sg = stratz_adv(by_s, bid, aid)
|
||||||
row = {
|
row = {
|
||||||
"a": a,
|
"a": a,
|
||||||
@@ -158,34 +108,23 @@ def main() -> None:
|
|||||||
"b": b,
|
"b": b,
|
||||||
"b_loc": names.get(b, b),
|
"b_loc": names.get(b, b),
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
"opendota_adv": None if o_adv is None else round(o_adv, 4),
|
|
||||||
"opendota_games": o_games,
|
|
||||||
"stratz_adv": None if s_adv is None else round(s_adv, 3),
|
"stratz_adv": None if s_adv is None else round(s_adv, 3),
|
||||||
"stratz_games": s_games,
|
"stratz_games": s_games,
|
||||||
"reverse_opendota_adv": None if r_o is None else round(r_o, 4),
|
|
||||||
"reverse_stratz_adv": None if r_s is None else round(r_s, 3),
|
"reverse_stratz_adv": None if r_s is None else round(r_s, 3),
|
||||||
}
|
}
|
||||||
o_ok = o_adv is not None and o_games >= args.min_games and o_adv >= args.odota_agree
|
|
||||||
o_bad = o_adv is not None and o_games >= args.min_games and o_adv <= args.odota_disagree
|
|
||||||
s_ok = s_adv is not None and s_games >= args.min_games and s_adv >= args.stratz_agree
|
s_ok = s_adv is not None and s_games >= args.min_games and s_adv >= args.stratz_agree
|
||||||
s_bad = s_adv is not None and s_games >= args.min_games and s_adv <= args.stratz_disagree
|
s_bad = (
|
||||||
if o_adv is None and s_adv is None:
|
s_adv is not None and s_games >= args.min_games and s_adv <= args.stratz_disagree
|
||||||
counter_no_data.append({**row, "why": "missing_both"})
|
)
|
||||||
elif o_games < args.min_games and (s_games < args.min_games or s_adv is None):
|
if s_adv is None:
|
||||||
|
counter_no_data.append({**row, "why": "missing_stratz"})
|
||||||
|
elif s_games < args.min_games:
|
||||||
counter_no_data.append({**row, "why": "low_games"})
|
counter_no_data.append({**row, "why": "low_games"})
|
||||||
elif o_ok or s_ok:
|
elif s_ok:
|
||||||
sources = []
|
counter_ok.append({**row, "agree_sources": ["stratz"]})
|
||||||
if o_ok:
|
elif s_bad:
|
||||||
sources.append("opendota")
|
|
||||||
if s_ok:
|
|
||||||
sources.append("stratz")
|
|
||||||
counter_ok.append({**row, "agree_sources": sources})
|
|
||||||
elif o_bad or s_bad:
|
|
||||||
tip = "conflict"
|
tip = "conflict"
|
||||||
if (
|
if r_s is not None and r_sg >= args.min_games and r_s >= args.stratz_agree:
|
||||||
(r_o is not None and r_og >= args.min_games and r_o >= args.odota_agree)
|
|
||||||
or (r_s is not None and r_sg >= args.min_games and r_s >= args.stratz_agree)
|
|
||||||
):
|
|
||||||
tip = "maybe_reverse"
|
tip = "maybe_reverse"
|
||||||
counter_conflict.append({**row, "tip": tip})
|
counter_conflict.append({**row, "tip": tip})
|
||||||
else:
|
else:
|
||||||
@@ -197,7 +136,9 @@ def main() -> None:
|
|||||||
if aid is None or bid is None:
|
if aid is None or bid is None:
|
||||||
syn_missing.append({"a": a, "b": b, "why": "unknown_hero"})
|
syn_missing.append({"a": a, "b": b, "why": "unknown_hero"})
|
||||||
continue
|
continue
|
||||||
cell = (by_syn.get(str(aid)) or {}).get(str(bid)) or (by_syn.get(str(bid)) or {}).get(str(aid))
|
cell = (by_syn.get(str(aid)) or {}).get(str(bid)) or (
|
||||||
|
by_syn.get(str(bid)) or {}
|
||||||
|
).get(str(aid))
|
||||||
if not cell:
|
if not cell:
|
||||||
syn_missing.append(
|
syn_missing.append(
|
||||||
{
|
{
|
||||||
@@ -236,15 +177,11 @@ def main() -> None:
|
|||||||
if akey == bkey or (akey, bkey) in known_counters:
|
if akey == bkey or (akey, bkey) in known_counters:
|
||||||
continue
|
continue
|
||||||
bid = key_to_id[bkey]
|
bid = key_to_id[bkey]
|
||||||
o_adv, o_games = odota_adv(by_o, baseline, aid, bid)
|
|
||||||
s_adv, s_games = stratz_adv(by_s, aid, bid)
|
s_adv, s_games = stratz_adv(by_s, aid, bid)
|
||||||
if (
|
if (
|
||||||
o_adv is not None
|
s_adv is not None
|
||||||
and o_games >= args.min_games
|
|
||||||
and o_adv >= 0.03
|
|
||||||
and s_adv is not None
|
|
||||||
and s_games >= args.min_games
|
and s_games >= args.min_games
|
||||||
and s_adv >= 2.0
|
and s_adv >= args.suggest_min_adv
|
||||||
):
|
):
|
||||||
suggestions.append(
|
suggestions.append(
|
||||||
{
|
{
|
||||||
@@ -252,13 +189,11 @@ def main() -> None:
|
|||||||
"a_loc": names[akey],
|
"a_loc": names[akey],
|
||||||
"b": bkey,
|
"b": bkey,
|
||||||
"b_loc": names[bkey],
|
"b_loc": names[bkey],
|
||||||
"opendota_adv": round(o_adv, 4),
|
|
||||||
"opendota_games": o_games,
|
|
||||||
"stratz_adv": round(s_adv, 3),
|
"stratz_adv": round(s_adv, 3),
|
||||||
"stratz_games": s_games,
|
"stratz_games": s_games,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
suggestions.sort(key=lambda r: (-r["stratz_adv"], -r["opendota_adv"]))
|
suggestions.sort(key=lambda r: (-r["stratz_adv"], -r["stratz_games"]))
|
||||||
suggestions = suggestions[:100]
|
suggestions = suggestions[:100]
|
||||||
|
|
||||||
counters_from: dict[str, int] = {k: 0 for k in all_keys}
|
counters_from: dict[str, int] = {k: 0 for k in all_keys}
|
||||||
@@ -276,9 +211,6 @@ def main() -> None:
|
|||||||
syn_count[b] += 1
|
syn_count[b] += 1
|
||||||
|
|
||||||
missing_cache = {
|
missing_cache = {
|
||||||
"opendota_matchups": [
|
|
||||||
f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_o
|
|
||||||
],
|
|
||||||
"stratz_matchups": [
|
"stratz_matchups": [
|
||||||
f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_s
|
f"{names[k]}({k})" for k in all_keys if str(key_to_id[k]) not in by_s
|
||||||
],
|
],
|
||||||
@@ -310,28 +242,17 @@ def main() -> None:
|
|||||||
"counters_out": counters_from[k],
|
"counters_out": counters_from[k],
|
||||||
"counters_in": counters_to[k],
|
"counters_in": counters_to[k],
|
||||||
"synergies": syn_count[k],
|
"synergies": syn_count[k],
|
||||||
"cross_source_suggestions": suggest_by_hero.get(k, 0),
|
"stratz_suggestions": suggest_by_hero.get(k, 0),
|
||||||
}
|
}
|
||||||
for k in empty + [x for x in thin if x not in empty]
|
for k in empty + [x for x in thin if x not in empty]
|
||||||
],
|
],
|
||||||
key=lambda r: (
|
key=lambda r: (
|
||||||
r["counters_out"] + r["counters_in"] + r["synergies"],
|
r["counters_out"] + r["counters_in"] + r["synergies"],
|
||||||
-r["cross_source_suggestions"],
|
-r["stratz_suggestions"],
|
||||||
r["key"],
|
r["key"],
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
web_tops_audit = None
|
|
||||||
if tops.get("by_hero"):
|
|
||||||
web_tops_audit = audit_matchup_tops(
|
|
||||||
tops,
|
|
||||||
odota,
|
|
||||||
id_to_key=id_to_key,
|
|
||||||
key_to_id=key_to_id,
|
|
||||||
names=names,
|
|
||||||
min_games=args.min_games,
|
|
||||||
)
|
|
||||||
|
|
||||||
def _rel(path: Path) -> str | None:
|
def _rel(path: Path) -> str | None:
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return None
|
return None
|
||||||
@@ -344,12 +265,6 @@ def main() -> None:
|
|||||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"sources": {
|
"sources": {
|
||||||
"relations": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"),
|
"relations": str(DEFAULT_RELATIONS.relative_to(ROOT)).replace("\\", "/"),
|
||||||
"opendota_matchups": {
|
|
||||||
"path": _rel(matchups_path),
|
|
||||||
"source": odota.get("source"),
|
|
||||||
"fetched_at": odota.get("fetched_at"),
|
|
||||||
"heroes": len(by_o),
|
|
||||||
},
|
|
||||||
"stratz_matchups": {
|
"stratz_matchups": {
|
||||||
"path": _rel(stratz_path),
|
"path": _rel(stratz_path),
|
||||||
"source": stratz.get("source"),
|
"source": stratz.get("source"),
|
||||||
@@ -362,20 +277,13 @@ def main() -> None:
|
|||||||
"fetched_at": syn_file.get("fetched_at"),
|
"fetched_at": syn_file.get("fetched_at"),
|
||||||
"heroes": len(by_syn),
|
"heroes": len(by_syn),
|
||||||
},
|
},
|
||||||
"stratz_matchup_tops": {
|
|
||||||
"path": _rel(tops_path),
|
|
||||||
"source": tops.get("source"),
|
|
||||||
"fetched_at": tops.get("fetched_at"),
|
|
||||||
"heroes": len(tops.get("by_hero") or {}),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"thresholds": {
|
"thresholds": {
|
||||||
"min_games": args.min_games,
|
"min_games": args.min_games,
|
||||||
"odota_agree": args.odota_agree,
|
|
||||||
"odota_disagree": args.odota_disagree,
|
|
||||||
"stratz_agree": args.stratz_agree,
|
"stratz_agree": args.stratz_agree,
|
||||||
"stratz_disagree": args.stratz_disagree,
|
"stratz_disagree": args.stratz_disagree,
|
||||||
"syn_agree": args.syn_agree,
|
"syn_agree": args.syn_agree,
|
||||||
|
"suggest_min_adv": args.suggest_min_adv,
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"relations_counters": len(counters),
|
"relations_counters": len(counters),
|
||||||
@@ -401,16 +309,11 @@ def main() -> None:
|
|||||||
},
|
},
|
||||||
"counter_conflicts": sorted(
|
"counter_conflicts": sorted(
|
||||||
counter_conflict,
|
counter_conflict,
|
||||||
key=lambda r: ((r.get("stratz_adv") or 0), (r.get("opendota_adv") or 0)),
|
key=lambda r: (r.get("stratz_adv") or 0),
|
||||||
),
|
),
|
||||||
"counter_agree_both": [
|
"counter_agree": counter_ok,
|
||||||
r
|
"suggest_add_counters": suggestions,
|
||||||
for r in counter_ok
|
|
||||||
if set(r.get("agree_sources") or []) == {"opendota", "stratz"}
|
|
||||||
],
|
|
||||||
"suggest_add_counters_cross_source": suggestions,
|
|
||||||
"synergy_conflicts": syn_conflict,
|
"synergy_conflicts": syn_conflict,
|
||||||
"web_matchup_tops_cross": web_tops_audit,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
args.out.parent.mkdir(parents=True, exist_ok=True)
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -421,26 +324,18 @@ def main() -> None:
|
|||||||
s = report["summary"]
|
s = report["summary"]
|
||||||
print("=== relations audit ===")
|
print("=== relations audit ===")
|
||||||
print(
|
print(
|
||||||
f"counters: agree={s['counter_agree']} (both={len(report['counter_agree_both'])}) "
|
f"counters: agree={s['counter_agree']} "
|
||||||
f"conflict={s['counter_conflict']} weak={s['counter_weak']} no_data={s['counter_no_data']} "
|
f"conflict={s['counter_conflict']} weak={s['counter_weak']} "
|
||||||
f"/ total={s['relations_counters']}"
|
f"no_data={s['counter_no_data']} / total={s['relations_counters']}"
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"synergies: agree={s['synergy_agree']} conflict={s['synergy_conflict']} "
|
f"synergies: agree={s['synergy_agree']} conflict={s['synergy_conflict']} "
|
||||||
f"weak/missing={s['synergy_weak_or_missing']} / total={s['relations_synergies']}"
|
f"weak/missing={s['synergy_weak_or_missing']} / total={s['relations_synergies']}"
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"qualitative gaps: empty={s['heroes_empty_relations']} thin(<={1} edge)={s['heroes_thin_relations']} "
|
f"qualitative gaps: empty={s['heroes_empty_relations']} "
|
||||||
f"/ heroes={s['heroes_total']}"
|
f"thin(<={1} edge)={s['heroes_thin_relations']} / heroes={s['heroes_total']}"
|
||||||
)
|
)
|
||||||
if web_tops_audit:
|
|
||||||
ws = web_tops_audit["summary"]
|
|
||||||
print(
|
|
||||||
"web matchup tops × OpenDota: "
|
|
||||||
f"agree={ws['agree']} conflict={ws['conflict']} weak={ws['weak']} "
|
|
||||||
f"/ rows={ws['counter_rows']} heroes={ws['heroes']}"
|
|
||||||
)
|
|
||||||
print(f" note: {web_tops_audit['manual_review_note']}")
|
|
||||||
print("cache gaps:")
|
print("cache gaps:")
|
||||||
for k, v in missing_cache.items():
|
for k, v in missing_cache.items():
|
||||||
print(f" {k}: {len(v)}")
|
print(f" {k}: {len(v)}")
|
||||||
@@ -449,7 +344,7 @@ def main() -> None:
|
|||||||
if row["counters_out"] + row["counters_in"] + row["synergies"] == 0:
|
if row["counters_out"] + row["counters_in"] + row["synergies"] == 0:
|
||||||
print(
|
print(
|
||||||
f" {row['name_loc']}({row['key']}) "
|
f" {row['name_loc']}({row['key']}) "
|
||||||
f"suggest={row['cross_source_suggestions']}"
|
f"suggest={row['stratz_suggestions']}"
|
||||||
)
|
)
|
||||||
print(f"\nwrote {args.out}")
|
print(f"\nwrote {args.out}")
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,11 @@
|
|||||||
"b": "spirit_breaker",
|
"b": "spirit_breaker",
|
||||||
"reason": ""
|
"reason": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"a": "ancient_apparition",
|
||||||
|
"b": "necrolyte",
|
||||||
|
"reason": "冰晶爆轰禁疗,克死亡脉冲续航"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"a": "antimage",
|
"a": "antimage",
|
||||||
"b": "lina",
|
"b": "lina",
|
||||||
|
|||||||
@@ -1,86 +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 sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import time
|
|
||||||
import urllib.error
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from shared.grid import hero_table
|
|
||||||
from shared.http_utils import http_json
|
|
||||||
from shared.paths import SHARED_DATA
|
|
||||||
|
|
||||||
API = "https://api.opendota.com/api/heroes/{hero_id}/matchups"
|
|
||||||
OUT = SHARED_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()
|
|
||||||
@@ -111,6 +111,8 @@ def block_of_column(cols: list[tuple[int, int]]) -> list[int]:
|
|||||||
Blocks are separated by a visibly wider gutter than the gap between two
|
Blocks are separated by a visibly wider gutter than the gap between two
|
||||||
cards in the same block.
|
cards in the same block.
|
||||||
"""
|
"""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
gaps = [cols[i + 1][0] - cols[i][1] for i in range(len(cols) - 1)]
|
gaps = [cols[i + 1][0] - cols[i][1] for i in range(len(cols) - 1)]
|
||||||
if not gaps:
|
if not gaps:
|
||||||
return [0] * len(cols)
|
return [0] * len(cols)
|
||||||
|
|||||||
@@ -1,281 +0,0 @@
|
|||||||
"""Cross-check STRATZ web matchup tops against OpenDota hero matchups.
|
|
||||||
|
|
||||||
Web-only observation evidence — never merge into relations.json or recommend.
|
|
||||||
|
|
||||||
Statuses:
|
|
||||||
agree — OpenDota baseline-adjusted advantage agrees with STRATZ direction
|
|
||||||
conflict — OpenDota disagrees with STRATZ direction (enough games)
|
|
||||||
weak — OpenDota sample too small or advantage near zero
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
# Defaults aligned with shared/audit_relations.py thresholds.
|
|
||||||
DEFAULT_MIN_GAMES = 80
|
|
||||||
DEFAULT_ODOTA_AGREE = 0.015
|
|
||||||
DEFAULT_ODOTA_DISAGREE = -0.015
|
|
||||||
|
|
||||||
|
|
||||||
def matchup_wr(by_hero: dict, aid: int, bid: int) -> tuple[float | None, int]:
|
|
||||||
cell = (by_hero.get(str(aid)) or {}).get(str(bid))
|
|
||||||
if not cell:
|
|
||||||
return None, 0
|
|
||||||
games = int(cell.get("games") or 0)
|
|
||||||
wins = int(cell.get("wins") or 0)
|
|
||||||
if games <= 0:
|
|
||||||
return None, 0
|
|
||||||
return wins / games, games
|
|
||||||
|
|
||||||
|
|
||||||
def build_baseline(by_hero: dict) -> dict[int, float]:
|
|
||||||
out: dict[int, float] = {}
|
|
||||||
for hid_s, opps in by_hero.items():
|
|
||||||
tw = tg = 0
|
|
||||||
for cell in opps.values():
|
|
||||||
g = int(cell.get("games") or 0)
|
|
||||||
w = int(cell.get("wins") or 0)
|
|
||||||
tw += w
|
|
||||||
tg += g
|
|
||||||
if tg > 0:
|
|
||||||
out[int(hid_s)] = tw / tg
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def odota_adv(
|
|
||||||
by_hero: dict, baseline: dict[int, float], aid: int, bid: int
|
|
||||||
) -> tuple[float | None, int]:
|
|
||||||
wr, games = matchup_wr(by_hero, aid, bid)
|
|
||||||
if wr is None:
|
|
||||||
return None, 0
|
|
||||||
base = baseline.get(aid)
|
|
||||||
if base is None:
|
|
||||||
return None, games
|
|
||||||
return wr - base, games
|
|
||||||
|
|
||||||
|
|
||||||
def classify_cross_source(
|
|
||||||
*,
|
|
||||||
stratz_signed: float,
|
|
||||||
odota_adv_val: float | None,
|
|
||||||
odota_games: int,
|
|
||||||
min_games: int = DEFAULT_MIN_GAMES,
|
|
||||||
odota_agree: float = DEFAULT_ODOTA_AGREE,
|
|
||||||
odota_disagree: float = DEFAULT_ODOTA_DISAGREE,
|
|
||||||
) -> str:
|
|
||||||
"""Classify whether OpenDota agrees with a STRATZ signed advantage.
|
|
||||||
|
|
||||||
``stratz_signed`` > 0 means STRATZ says A is favored vs B (counters).
|
|
||||||
For countered rows the caller should pass the original vs advantage
|
|
||||||
(positive = A still favored), not the negated display value.
|
|
||||||
"""
|
|
||||||
if odota_adv_val is None or odota_games < min_games:
|
|
||||||
return "weak"
|
|
||||||
if stratz_signed >= 0:
|
|
||||||
if odota_adv_val >= odota_agree:
|
|
||||||
return "agree"
|
|
||||||
if odota_adv_val <= odota_disagree:
|
|
||||||
return "conflict"
|
|
||||||
return "weak"
|
|
||||||
if odota_adv_val <= -odota_agree:
|
|
||||||
return "agree"
|
|
||||||
if odota_adv_val >= -odota_disagree:
|
|
||||||
return "conflict"
|
|
||||||
return "weak"
|
|
||||||
|
|
||||||
|
|
||||||
def enrich_entry_cross(
|
|
||||||
entry: dict,
|
|
||||||
*,
|
|
||||||
hero_id: int,
|
|
||||||
kind: str,
|
|
||||||
by_odota: dict,
|
|
||||||
baseline: dict[int, float],
|
|
||||||
min_games: int = DEFAULT_MIN_GAMES,
|
|
||||||
) -> dict:
|
|
||||||
"""Attach ``cross`` quality blob to one counters/countered/synergies row.
|
|
||||||
|
|
||||||
For ``countered`` rows, STRATZ stores negated advantage for display; we
|
|
||||||
restore the original vs-sign for classification (``-advantage``).
|
|
||||||
Synergies skip OpenDota (no teammate WR in matchups.json).
|
|
||||||
"""
|
|
||||||
out = dict(entry)
|
|
||||||
peer = int(entry.get("hero_id") or 0)
|
|
||||||
if kind == "synergies" or peer <= 0 or hero_id <= 0:
|
|
||||||
out["cross"] = {"status": "weak", "reason": "no_odota_synergy"}
|
|
||||||
return out
|
|
||||||
|
|
||||||
o_adv, o_games = odota_adv(by_odota, baseline, hero_id, peer)
|
|
||||||
raw_adv = float(entry.get("advantage") or 0.0)
|
|
||||||
# countered display advantage is already negated; restore original vs sign.
|
|
||||||
stratz_signed = -raw_adv if kind == "countered" else raw_adv
|
|
||||||
status = classify_cross_source(
|
|
||||||
stratz_signed=stratz_signed,
|
|
||||||
odota_adv_val=o_adv,
|
|
||||||
odota_games=o_games,
|
|
||||||
min_games=min_games,
|
|
||||||
)
|
|
||||||
out["cross"] = {
|
|
||||||
"status": status,
|
|
||||||
"opendota_adv": None if o_adv is None else round(o_adv, 4),
|
|
||||||
"opendota_games": o_games,
|
|
||||||
"min_games": min_games,
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def enrich_hero_matchups_cross(
|
|
||||||
cell: dict,
|
|
||||||
*,
|
|
||||||
hero_id: int,
|
|
||||||
by_odota: dict,
|
|
||||||
baseline: dict[int, float],
|
|
||||||
min_games: int = DEFAULT_MIN_GAMES,
|
|
||||||
) -> dict:
|
|
||||||
"""Return a shallow-copied hero matchup cell with per-row ``cross`` fields."""
|
|
||||||
out = {
|
|
||||||
"counters": [
|
|
||||||
enrich_entry_cross(
|
|
||||||
e,
|
|
||||||
hero_id=hero_id,
|
|
||||||
kind="counters",
|
|
||||||
by_odota=by_odota,
|
|
||||||
baseline=baseline,
|
|
||||||
min_games=min_games,
|
|
||||||
)
|
|
||||||
for e in (cell.get("counters") or [])
|
|
||||||
if isinstance(e, dict)
|
|
||||||
],
|
|
||||||
"countered": [
|
|
||||||
enrich_entry_cross(
|
|
||||||
e,
|
|
||||||
hero_id=hero_id,
|
|
||||||
kind="countered",
|
|
||||||
by_odota=by_odota,
|
|
||||||
baseline=baseline,
|
|
||||||
min_games=min_games,
|
|
||||||
)
|
|
||||||
for e in (cell.get("countered") or [])
|
|
||||||
if isinstance(e, dict)
|
|
||||||
],
|
|
||||||
"synergies": [
|
|
||||||
enrich_entry_cross(
|
|
||||||
e,
|
|
||||||
hero_id=hero_id,
|
|
||||||
kind="synergies",
|
|
||||||
by_odota=by_odota,
|
|
||||||
baseline=baseline,
|
|
||||||
min_games=min_games,
|
|
||||||
)
|
|
||||||
for e in (cell.get("synergies") or [])
|
|
||||||
if isinstance(e, dict)
|
|
||||||
],
|
|
||||||
}
|
|
||||||
for k in ("fetched_at", "stale"):
|
|
||||||
if k in cell:
|
|
||||||
out[k] = cell[k]
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def summarize_cross_rows(rows: list[dict]) -> dict[str, int]:
|
|
||||||
counts = {"agree": 0, "conflict": 0, "weak": 0}
|
|
||||||
for e in rows:
|
|
||||||
status = ((e.get("cross") or {}).get("status")) or "weak"
|
|
||||||
if status not in counts:
|
|
||||||
status = "weak"
|
|
||||||
counts[status] += 1
|
|
||||||
return counts
|
|
||||||
|
|
||||||
|
|
||||||
def audit_matchup_tops(
|
|
||||||
tops: dict,
|
|
||||||
odota: dict,
|
|
||||||
*,
|
|
||||||
id_to_key: dict[int, str],
|
|
||||||
key_to_id: dict[str, int],
|
|
||||||
names: dict[str, str],
|
|
||||||
min_games: int = DEFAULT_MIN_GAMES,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Audit STRATZ web tops vs OpenDota; return report section (no file IO)."""
|
|
||||||
by_odota = odota.get("by_hero") or {}
|
|
||||||
baseline = build_baseline(by_odota)
|
|
||||||
by_hero = tops.get("by_hero") or {}
|
|
||||||
|
|
||||||
pairs: list[dict] = []
|
|
||||||
summary = {
|
|
||||||
"heroes": 0,
|
|
||||||
"counter_rows": 0,
|
|
||||||
"agree": 0,
|
|
||||||
"conflict": 0,
|
|
||||||
"weak": 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
for key, cell in sorted(by_hero.items()):
|
|
||||||
if not isinstance(cell, dict):
|
|
||||||
continue
|
|
||||||
hid = key_to_id.get(key)
|
|
||||||
if hid is None:
|
|
||||||
# Prefer embedded id when key map lags new heroes.
|
|
||||||
hid = int(cell.get("id") or 0) or None
|
|
||||||
if hid is None:
|
|
||||||
continue
|
|
||||||
summary["heroes"] += 1
|
|
||||||
enriched = enrich_hero_matchups_cross(
|
|
||||||
cell,
|
|
||||||
hero_id=hid,
|
|
||||||
by_odota=by_odota,
|
|
||||||
baseline=baseline,
|
|
||||||
min_games=min_games,
|
|
||||||
)
|
|
||||||
for kind in ("counters", "countered"):
|
|
||||||
for e in enriched.get(kind) or []:
|
|
||||||
summary["counter_rows"] += 1
|
|
||||||
cross = e.get("cross") or {}
|
|
||||||
status = cross.get("status") or "weak"
|
|
||||||
summary[status] = summary.get(status, 0) + 1
|
|
||||||
peer_id = int(e.get("hero_id") or 0)
|
|
||||||
peer_key = id_to_key.get(peer_id, str(peer_id))
|
|
||||||
pairs.append(
|
|
||||||
{
|
|
||||||
"hero": key,
|
|
||||||
"hero_loc": names.get(key, key),
|
|
||||||
"peer": peer_key,
|
|
||||||
"peer_loc": names.get(peer_key, peer_key),
|
|
||||||
"kind": kind,
|
|
||||||
"stratz_advantage": e.get("advantage"),
|
|
||||||
"stratz_wr": e.get("wr"),
|
|
||||||
"stratz_games": e.get("games"),
|
|
||||||
"status": status,
|
|
||||||
"opendota_adv": cross.get("opendota_adv"),
|
|
||||||
"opendota_games": cross.get("opendota_games"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
conflicts = [p for p in pairs if p["status"] == "conflict"]
|
|
||||||
conflicts.sort(
|
|
||||||
key=lambda r: (
|
|
||||||
abs(float(r.get("stratz_advantage") or 0)),
|
|
||||||
-(int(r.get("opendota_games") or 0)),
|
|
||||||
),
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
agrees = [p for p in pairs if p["status"] == "agree"]
|
|
||||||
agrees.sort(
|
|
||||||
key=lambda r: (
|
|
||||||
abs(float(r.get("stratz_advantage") or 0)),
|
|
||||||
-(int(r.get("opendota_games") or 0)),
|
|
||||||
),
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"summary": summary,
|
|
||||||
"thresholds": {"min_games": min_games},
|
|
||||||
"manual_review_note": (
|
|
||||||
"Dota2ProTracker (7k+ MMR / pro) is a manual high-MMR reference for "
|
|
||||||
"conflict rows; not automated (login-gated)."
|
|
||||||
),
|
|
||||||
"conflicts": conflicts[:100],
|
|
||||||
"agrees_sample": agrees[:40],
|
|
||||||
}
|
|
||||||
@@ -21,6 +21,8 @@ RELATIONS_JSON = SHARED_DATA / "relations.json"
|
|||||||
PC_DIR = ROOT / "pc"
|
PC_DIR = ROOT / "pc"
|
||||||
PC_CONFIG = PC_DIR / "config.json"
|
PC_CONFIG = PC_DIR / "config.json"
|
||||||
TEMPLATES_CDN = PC_DIR / "templates" / "cdn"
|
TEMPLATES_CDN = PC_DIR / "templates" / "cdn"
|
||||||
|
# Post-match player pages (profile + match JSON; gitignored; local/OSS).
|
||||||
|
PC_PLAYER_PAGES = PC_DIR / "player_pages"
|
||||||
|
|
||||||
# Web subproject locations. DATA keeps its historical name: every web-side
|
# Web subproject locations. DATA keeps its historical name: every web-side
|
||||||
# JSON cache lives here (hero_stats, patches, streamers, ...).
|
# JSON cache lives here (hero_stats, patches, streamers, ...).
|
||||||
@@ -34,7 +36,9 @@ WEB_ASSETS = WEB_ROOT / "assets"
|
|||||||
HERO_PORTRAITS = WEB_ASSETS / "hero_portraits"
|
HERO_PORTRAITS = WEB_ASSETS / "hero_portraits"
|
||||||
# Primary-attribute icons from dota2.com (dota_react/icons/hero_*.png); UI only.
|
# Primary-attribute icons from dota2.com (dota_react/icons/hero_*.png); UI only.
|
||||||
ATTR_ICONS = WEB_ASSETS / "attr_icons"
|
ATTR_ICONS = WEB_ASSETS / "attr_icons"
|
||||||
# Item icons from Steam CDN (dota_react/items/{key}.png); Climperor web site only.
|
# Valve hero-selection role filter icons, chroma keyed to transparent PNGs.
|
||||||
|
ROLE_ICONS = WEB_ASSETS / "role_icons"
|
||||||
|
# Item icons from Steam CDN (dota_react/items/{key}.png); Web + PC draft overlay.
|
||||||
ITEM_ICONS = WEB_ASSETS / "item_icons"
|
ITEM_ICONS = WEB_ASSETS / "item_icons"
|
||||||
# Shop category header icons from dota2.com.cn/items/images/itemcat_*.png.
|
# Shop category header icons from dota2.com.cn/items/images/itemcat_*.png.
|
||||||
ITEM_CAT_ICONS = WEB_ASSETS / "item_cat_icons"
|
ITEM_CAT_ICONS = WEB_ASSETS / "item_cat_icons"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Unit tests for STRATZ matchup ranking + OpenDota cross classification.
|
"""Unit tests for STRATZ matchup ranking + cache helpers.
|
||||||
|
|
||||||
Run from repo root:
|
Run from repo root:
|
||||||
python -m unittest shared.tests.test_matchup_cross -v
|
python -m unittest shared.tests.test_stratz_matchups -v
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,11 +13,6 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
|
||||||
from shared.matchup_cross import ( # noqa: E402
|
|
||||||
classify_cross_source,
|
|
||||||
enrich_entry_cross,
|
|
||||||
enrich_hero_matchups_cross,
|
|
||||||
)
|
|
||||||
from web.fetch_stratz_meta import ( # noqa: E402
|
from web.fetch_stratz_meta import ( # noqa: E402
|
||||||
annotate_matchup_cell,
|
annotate_matchup_cell,
|
||||||
build_matchup_file_payload,
|
build_matchup_file_payload,
|
||||||
@@ -125,80 +120,5 @@ class MatchupCacheHelpersTests(unittest.TestCase):
|
|||||||
self.assertIn("antimage", loaded)
|
self.assertIn("antimage", loaded)
|
||||||
|
|
||||||
|
|
||||||
class CrossClassifyTests(unittest.TestCase):
|
|
||||||
def test_agree_conflict_weak(self) -> None:
|
|
||||||
self.assertEqual(
|
|
||||||
classify_cross_source(
|
|
||||||
stratz_signed=5.0, odota_adv_val=0.04, odota_games=200
|
|
||||||
),
|
|
||||||
"agree",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
classify_cross_source(
|
|
||||||
stratz_signed=5.0, odota_adv_val=-0.04, odota_games=200
|
|
||||||
),
|
|
||||||
"conflict",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
classify_cross_source(
|
|
||||||
stratz_signed=5.0, odota_adv_val=0.04, odota_games=10
|
|
||||||
),
|
|
||||||
"weak",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_countered_uses_restored_sign(self) -> None:
|
|
||||||
# Display advantage for countered is +8 (negated); original vs was -8.
|
|
||||||
by_odota = {
|
|
||||||
"1": {
|
|
||||||
"2": {"games": 200, "wins": 80}, # AM wr 40% vs peer
|
|
||||||
}
|
|
||||||
}
|
|
||||||
# Baseline for hero 1 ≈ 0.4 from only this matchup.
|
|
||||||
from shared.matchup_cross import build_baseline
|
|
||||||
|
|
||||||
baseline = build_baseline(by_odota)
|
|
||||||
entry = enrich_entry_cross(
|
|
||||||
{
|
|
||||||
"hero_id": 2,
|
|
||||||
"games": 100,
|
|
||||||
"wins": 40,
|
|
||||||
"advantage": 8.0,
|
|
||||||
"wr": 0.6,
|
|
||||||
},
|
|
||||||
hero_id=1,
|
|
||||||
kind="countered",
|
|
||||||
by_odota=by_odota,
|
|
||||||
baseline=baseline,
|
|
||||||
min_games=80,
|
|
||||||
)
|
|
||||||
# odota_adv = 0.4 - 0.4 = 0 → weak
|
|
||||||
self.assertEqual(entry["cross"]["status"], "weak")
|
|
||||||
|
|
||||||
def test_enrich_hero_keeps_stale_flag(self) -> None:
|
|
||||||
cell = {
|
|
||||||
"counters": [
|
|
||||||
{
|
|
||||||
"hero_id": 94,
|
|
||||||
"games": 100,
|
|
||||||
"wins": 60,
|
|
||||||
"advantage": 3.0,
|
|
||||||
"wr": 0.6,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"countered": [],
|
|
||||||
"synergies": [],
|
|
||||||
"fetched_at": "t0",
|
|
||||||
"stale": True,
|
|
||||||
}
|
|
||||||
out = enrich_hero_matchups_cross(
|
|
||||||
cell,
|
|
||||||
hero_id=1,
|
|
||||||
by_odota={},
|
|
||||||
baseline={},
|
|
||||||
)
|
|
||||||
self.assertTrue(out["stale"])
|
|
||||||
self.assertEqual(out["cross"] if False else out["counters"][0]["cross"]["status"], "weak")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""One-off: enrich a player profile (no secrets printed)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
sys.path.insert(0, str(ROOT / "pc"))
|
||||||
|
|
||||||
|
from player_pages import enrich_profile_recent # noqa: E402
|
||||||
|
|
||||||
|
AID = int(sys.argv[1]) if len(sys.argv) > 1 else 143712136
|
||||||
|
cfg = json.loads((ROOT / "pc" / "config.json").read_text(encoding="utf-8"))
|
||||||
|
p = enrich_profile_recent(cfg, AID, include_gsi=True)
|
||||||
|
print(
|
||||||
|
"name",
|
||||||
|
p.get("personaname"),
|
||||||
|
"rank",
|
||||||
|
p.get("rank_tier"),
|
||||||
|
"lb",
|
||||||
|
p.get("leaderboard_rank"),
|
||||||
|
"recent",
|
||||||
|
len(p.get("recent") or []),
|
||||||
|
)
|
||||||
@@ -37,6 +37,7 @@ ENDPOINT = "https://oss-cn-shanghai.aliyuncs.com"
|
|||||||
REGION = "cn-shanghai"
|
REGION = "cn-shanghai"
|
||||||
ASSET_DIRS = (
|
ASSET_DIRS = (
|
||||||
"attr",
|
"attr",
|
||||||
|
"role-icon",
|
||||||
"rank",
|
"rank",
|
||||||
"item",
|
"item",
|
||||||
"item-cat",
|
"item-cat",
|
||||||
@@ -83,6 +84,51 @@ def _build_staging() -> tuple[Path, dict[str, int]]:
|
|||||||
return staging, counts
|
return staging, counts
|
||||||
|
|
||||||
|
|
||||||
|
def _png_size(path: Path) -> tuple[int, int] | None:
|
||||||
|
"""Return (width, height) for a PNG, or None if unreadable."""
|
||||||
|
try:
|
||||||
|
raw = path.read_bytes()
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
if len(raw) < 24 or raw[:8] != b"\x89PNG\r\n\x1a\n":
|
||||||
|
return None
|
||||||
|
# IHDR: length(4) + type(4) + width(4) + height(4)
|
||||||
|
if raw[12:16] != b"IHDR":
|
||||||
|
return None
|
||||||
|
w = int.from_bytes(raw[16:20], "big")
|
||||||
|
h = int.from_bytes(raw[20:24], "big")
|
||||||
|
return w, h
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_wide_portraits(staging: Path) -> None:
|
||||||
|
"""Refuse top-bar match crops (square ~96×96) before any OSS put.
|
||||||
|
|
||||||
|
Official cards are usually 256×144; Steam occasionally ships half-res
|
||||||
|
128×72 landscape (still fine for the grid). Match templates are square.
|
||||||
|
"""
|
||||||
|
portrait_dir = staging / "portrait"
|
||||||
|
if not portrait_dir.is_dir():
|
||||||
|
raise SystemExit("staging missing portrait/ — run fetch_hero_portraits.py")
|
||||||
|
bad: list[str] = []
|
||||||
|
for path in sorted(portrait_dir.glob("*.png")):
|
||||||
|
size = _png_size(path)
|
||||||
|
if size is None:
|
||||||
|
bad.append(f"{path.name}: unreadable")
|
||||||
|
continue
|
||||||
|
w, h = size
|
||||||
|
# Landscape Heroes cards: aspect ≈ 16:9. Match CDN faces are square.
|
||||||
|
if h <= 0 or w / h < 1.4:
|
||||||
|
bad.append(f"{path.name}: {w}x{h}")
|
||||||
|
if bad:
|
||||||
|
sample = "; ".join(bad[:6])
|
||||||
|
more = f" (+{len(bad) - 6} more)" if len(bad) > 6 else ""
|
||||||
|
raise SystemExit(
|
||||||
|
f"refusing to upload {len(bad)} non-wide portrait(s) "
|
||||||
|
f"(need landscape aspect ≥1.4, not match-template squares): "
|
||||||
|
f"{sample}{more}. Run: python web/fetch_hero_portraits.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _iter_files(root: Path) -> list[Path]:
|
def _iter_files(root: Path) -> list[Path]:
|
||||||
files: list[Path] = []
|
files: list[Path] = []
|
||||||
for sub in ASSET_DIRS:
|
for sub in ASSET_DIRS:
|
||||||
@@ -96,6 +142,7 @@ def _iter_files(root: Path) -> list[Path]:
|
|||||||
def upload(bucket_name: str, *, force: bool = False) -> None:
|
def upload(bucket_name: str, *, force: bool = False) -> None:
|
||||||
staging, counts = _build_staging()
|
staging, counts = _build_staging()
|
||||||
try:
|
try:
|
||||||
|
_assert_wide_portraits(staging)
|
||||||
files = _iter_files(staging)
|
files = _iter_files(staging)
|
||||||
total_bytes = sum(f.stat().st_size for f in files)
|
total_bytes = sum(f.stat().st_size for f in files)
|
||||||
print(f"staging {staging} ({len(files)} files, {total_bytes / 1e6:.1f} MB)")
|
print(f"staging {staging} ({len(files)} files, {total_bytes / 1e6:.1f} MB)")
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Upload web/assets/ui_icons/* to OSS ui-icon/ (no secret echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import mimetypes
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import oss2
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SRC = ROOT / "web" / "assets" / "ui_icons"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ak = os.environ.get("KEYZOO_ASSET_META_ACCESSKEY_ID") or os.environ.get(
|
||||||
|
"OSS_ACCESS_KEY_ID"
|
||||||
|
)
|
||||||
|
sk = os.environ.get("KEYZOO_ASSET_SECRET_ACCESSKEY_SECRET") or os.environ.get(
|
||||||
|
"OSS_ACCESS_KEY_SECRET"
|
||||||
|
)
|
||||||
|
if not ak or not sk:
|
||||||
|
print("missing OSS credentials", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
bucket = oss2.Bucket(oss2.Auth(ak, sk), "https://oss-cn-shanghai.aliyuncs.com", "climperor")
|
||||||
|
n = 0
|
||||||
|
for f in sorted(SRC.iterdir()):
|
||||||
|
if not f.is_file() or f.name.startswith("."):
|
||||||
|
continue
|
||||||
|
key = f"ui-icon/{f.name}"
|
||||||
|
ctype = mimetypes.guess_type(f.name)[0] or "application/octet-stream"
|
||||||
|
bucket.put_object_from_file(
|
||||||
|
key,
|
||||||
|
str(f),
|
||||||
|
headers={"Content-Type": ctype, "Cache-Control": "public, max-age=86400"},
|
||||||
|
)
|
||||||
|
n += 1
|
||||||
|
print(f"uploaded {n} ui-icon files", flush=True)
|
||||||
|
for name in ("dota2_logo_wordmark.png", "dota2_logo.png"):
|
||||||
|
print(f" {name} exists={bucket.object_exists('ui-icon/' + name)}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Probe OpenDota + Steam for a player (no secret echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
AID = 143712136
|
||||||
|
SID = AID + 76561197960265728
|
||||||
|
UA = "climperor-probe"
|
||||||
|
|
||||||
|
|
||||||
|
def _get(url: str) -> tuple[int, object]:
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": UA})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode(errors="replace")[:200]
|
||||||
|
return e.code, {"error": body}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return 0, {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
key = (
|
||||||
|
os.environ.get("STEAM_API_KEY")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
od_key = (os.environ.get("OPENDOTA_API_KEY") or "").strip()
|
||||||
|
print(f"steam_key={bool(key)} opendota_key={bool(od_key)}")
|
||||||
|
|
||||||
|
od = f"https://api.opendota.com/api/players/{AID}"
|
||||||
|
if od_key:
|
||||||
|
od += f"?api_key={urllib.parse.quote(od_key)}"
|
||||||
|
code, data = _get(od)
|
||||||
|
if isinstance(data, dict) and "error" not in data:
|
||||||
|
profile = data.get("profile") if isinstance(data.get("profile"), dict) else {}
|
||||||
|
print(
|
||||||
|
"opendota player",
|
||||||
|
code,
|
||||||
|
"name=",
|
||||||
|
profile.get("personaname") or data.get("personaname"),
|
||||||
|
"rank_tier=",
|
||||||
|
data.get("rank_tier"),
|
||||||
|
"lb=",
|
||||||
|
data.get("leaderboard_rank"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("opendota player", code, data)
|
||||||
|
|
||||||
|
od_r = f"https://api.opendota.com/api/players/{AID}/recentMatches"
|
||||||
|
if od_key:
|
||||||
|
od_r += f"?api_key={urllib.parse.quote(od_key)}"
|
||||||
|
code, data = _get(od_r)
|
||||||
|
if isinstance(data, list):
|
||||||
|
print("opendota recent", code, "n=", len(data))
|
||||||
|
if data:
|
||||||
|
print(" first", data[0].get("match_id"), data[0].get("start_time"))
|
||||||
|
else:
|
||||||
|
print("opendota recent", code, data)
|
||||||
|
|
||||||
|
if key:
|
||||||
|
q = urllib.parse.urlencode({"key": key, "steamids": str(SID)})
|
||||||
|
code, data = _get(
|
||||||
|
f"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?{q}"
|
||||||
|
)
|
||||||
|
players = (((data or {}).get("response") or {}).get("players")) or []
|
||||||
|
if players:
|
||||||
|
p = players[0]
|
||||||
|
print(
|
||||||
|
"steam summary",
|
||||||
|
code,
|
||||||
|
"name=",
|
||||||
|
p.get("personaname"),
|
||||||
|
"loc=",
|
||||||
|
p.get("loccountrycode"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("steam summary", code, data)
|
||||||
|
|
||||||
|
q = urllib.parse.urlencode(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"account_id": AID,
|
||||||
|
"matches_requested": 10,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
code, data = _get(
|
||||||
|
"https://api.steampowered.com/IDOTA2Match_570/GetMatchHistory/v1/?" + q
|
||||||
|
)
|
||||||
|
if isinstance(data, dict):
|
||||||
|
result = (data.get("result") or {})
|
||||||
|
print(
|
||||||
|
"steam match_history",
|
||||||
|
code,
|
||||||
|
"status=",
|
||||||
|
result.get("status"),
|
||||||
|
"statusDetail=",
|
||||||
|
result.get("statusDetail"),
|
||||||
|
"num=",
|
||||||
|
result.get("num_results"),
|
||||||
|
"total=",
|
||||||
|
result.get("total_results"),
|
||||||
|
)
|
||||||
|
matches = result.get("matches") or []
|
||||||
|
if matches:
|
||||||
|
m0 = matches[0]
|
||||||
|
print(" first match_id", m0.get("match_id"), "start", m0.get("start_time"))
|
||||||
|
else:
|
||||||
|
print("steam match_history", code, data)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Launch serve_relations with STEAM_API_KEY / SESSION_SECRET from keyzoo env.
|
||||||
|
|
||||||
|
Maps KEYZOO_ASSET_SECRET_WEB_API_KEY / KEYZOO_ASSET_SECRET_SESSION_SECRET
|
||||||
|
when the plain names are unset. Does not print secret values.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _map_env() -> None:
|
||||||
|
if not (os.environ.get("STEAM_API_KEY") or "").strip():
|
||||||
|
alt = (os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") or "").strip()
|
||||||
|
if alt:
|
||||||
|
os.environ["STEAM_API_KEY"] = alt
|
||||||
|
if not (os.environ.get("SESSION_SECRET") or "").strip():
|
||||||
|
alt = (os.environ.get("KEYZOO_ASSET_SECRET_SESSION_SECRET") or "").strip()
|
||||||
|
if alt:
|
||||||
|
os.environ["SESSION_SECRET"] = alt
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
_map_env()
|
||||||
|
steam_ok = bool((os.environ.get("STEAM_API_KEY") or "").strip())
|
||||||
|
sess_ok = bool((os.environ.get("SESSION_SECRET") or "").strip())
|
||||||
|
print(f"steam_auth configured: steam={steam_ok} session={sess_ok}", flush=True)
|
||||||
|
if not steam_ok or not sess_ok:
|
||||||
|
print("missing STEAM_API_KEY or SESSION_SECRET", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
cmd = [sys.executable, "-u", str(ROOT / "web" / "serve_relations.py"), *sys.argv[1:]]
|
||||||
|
return subprocess.call(cmd, cwd=str(ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Background-start serve_relations with keyzoo-mapped Steam auth env."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
PORT = "8765"
|
||||||
|
|
||||||
|
|
||||||
|
def _map_env() -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
if not (env.get("STEAM_API_KEY") or "").strip():
|
||||||
|
alt = (env.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") or "").strip()
|
||||||
|
if alt:
|
||||||
|
env["STEAM_API_KEY"] = alt
|
||||||
|
if not (env.get("SESSION_SECRET") or "").strip():
|
||||||
|
alt = (env.get("KEYZOO_ASSET_SECRET_SESSION_SECRET") or "").strip()
|
||||||
|
if alt:
|
||||||
|
env["SESSION_SECRET"] = alt
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
env = _map_env()
|
||||||
|
steam_ok = bool((env.get("STEAM_API_KEY") or "").strip())
|
||||||
|
sess_ok = bool((env.get("SESSION_SECRET") or "").strip())
|
||||||
|
print(f"steam_auth configured: steam={steam_ok} session={sess_ok}", flush=True)
|
||||||
|
if not steam_ok or not sess_ok:
|
||||||
|
print("missing STEAM_API_KEY or SESSION_SECRET", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
# Free the port if an old serve is still listening.
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"powershell",
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
(
|
||||||
|
f"$c=Get-NetTCPConnection -LocalPort {PORT} -ErrorAction SilentlyContinue;"
|
||||||
|
"if($c){$c.OwningProcess|Sort-Object -Unique|ForEach-Object{"
|
||||||
|
"Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue}}"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
log_path = ROOT / "web" / ".refresh" / "serve_steam.log"
|
||||||
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
"-u",
|
||||||
|
str(ROOT / "web" / "serve_relations.py"),
|
||||||
|
"--port",
|
||||||
|
PORT,
|
||||||
|
"--no-browser",
|
||||||
|
]
|
||||||
|
flags = 0
|
||||||
|
if sys.platform == "win32":
|
||||||
|
flags = subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined]
|
||||||
|
with log_path.open("ab") as log:
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=str(ROOT),
|
||||||
|
env=env,
|
||||||
|
stdout=log,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
creationflags=flags,
|
||||||
|
close_fds=True,
|
||||||
|
)
|
||||||
|
print(f"started pid={proc.pid} port={PORT}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Smoke-check STEAM_API_KEY / KEYZOO_ASSET_SECRET_WEB_API_KEY (no secret echo)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
k = (os.environ.get("STEAM_API_KEY") or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY") or "").strip()
|
||||||
|
if not k:
|
||||||
|
raise SystemExit("missing key")
|
||||||
|
url = (
|
||||||
|
"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
|
||||||
|
f"?key={k}&steamids=76561198000000000"
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(url, timeout=20) as resp:
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
print("ok" if isinstance(data, dict) and "response" in data else "bad")
|
||||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"account_id": "510534f7f6284344aadaf2f5a0794d48",
|
||||||
|
"d1": {
|
||||||
|
"name": "climperor-users",
|
||||||
|
"id": "9eeb24ba-acc5-4520-b4e7-754ea776394e"
|
||||||
|
},
|
||||||
|
"r2": {
|
||||||
|
"name": "climperor-player-data",
|
||||||
|
"ready": true
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"name": "climperor-player-sync",
|
||||||
|
"id": "371f11c7b4114f9b99ab10062a38ecd7"
|
||||||
|
},
|
||||||
|
"dlq": {
|
||||||
|
"name": "climperor-player-sync-dlq",
|
||||||
|
"id": "f7d1d45494f744e59effafb75ec62249"
|
||||||
|
},
|
||||||
|
"worker": "climperor-player-sync",
|
||||||
|
"pages_project": "climperor-relations"
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Climperor Cloudflare player data layer
|
||||||
|
|
||||||
|
Production multi-user storage for Steam-login player pages:
|
||||||
|
|
||||||
|
| Resource | Name | Role |
|
||||||
|
|----------|------|------|
|
||||||
|
| D1 | `climperor-users` | users, stats, heroes, match index, sync jobs |
|
||||||
|
| R2 | `climperor-player-data` | private full match JSON (`matches/{id}.json`) |
|
||||||
|
| Queue | `climperor-player-sync` | async OpenDota/Steam refresh |
|
||||||
|
| DLQ | `climperor-player-sync-dlq` | failed sync messages |
|
||||||
|
| Worker | `climperor-player-sync` | queue consumer |
|
||||||
|
|
||||||
|
## Provision
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# via keyzoo refining/cloudflare asset
|
||||||
|
python web/cloudflare/provision.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Then deploy the worker:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd web/cloudflare/player-sync
|
||||||
|
npx wrangler@3 deploy
|
||||||
|
npx wrangler@3 secret put STEAM_API_KEY
|
||||||
|
# optional:
|
||||||
|
npx wrangler@3 secret put OPENDOTA_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Pages project `climperor-relations` needs bindings `DB`, `MATCHES`, `SYNC_QUEUE`
|
||||||
|
(see `web/frontend/wrangler.toml`). `provision.py` binds production **and** preview
|
||||||
|
with matching `fail_open`. Auth secrets on Pages (prod+preview):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python web/cloudflare/_stage_steam_auth.py # via keyzoo refining/steam
|
||||||
|
python web/cloudflare/put_pages_secrets.py # via keyzoo refining/cloudflare
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional `PLAYER_PAGES_PUBLISH_SECRET`. Worker also needs `STEAM_API_KEY`
|
||||||
|
(`python web/cloudflare/put_worker_secrets.py` after `_stage_steam_key.py`).
|
||||||
|
|
||||||
|
**R2**: if create returns “enable R2”, open
|
||||||
|
https://dash.cloudflare.com/?to=/:account/r2 once, then re-run `provision.py`
|
||||||
|
and uncomment the `[[r2_buckets]]` block in `player-sync/wrangler.toml`, redeploy Worker.
|
||||||
|
Until then, match list/stats still work via D1; full match JSON download is deferred.
|
||||||
|
|
||||||
|
Ids are recorded in `.resources.json` (no secrets).
|
||||||
|
|
||||||
|
## Local
|
||||||
|
|
||||||
|
`python web/serve_relations.py` continues to use `pc/player_pages/` JSON + enrich
|
||||||
|
(`GET /api/players/me` runs `enrich_profile_recent`).
|
||||||
|
Production `/home` uses `GET /api/players/me` (D1 + queue).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m unittest pc.tests.test_player_stats -v
|
||||||
|
node web/cloudflare/player-sync/test_stats.mjs
|
||||||
|
```
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Backfill D1 from local pc/player_pages profile (+ live OpenDota if needed).
|
||||||
|
|
||||||
|
Used when Worker edge cannot reach OpenDota (429). No secret echo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
ACCOUNT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
DB = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
|
||||||
|
AID = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
|
||||||
|
PROFILE = ROOT / "pc" / "player_pages" / str(AID) / "profile.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _creds() -> tuple[str, str]:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing Cloudflare credentials")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> str:
|
||||||
|
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
|
|
||||||
|
def d1_batch(statements: list[dict]) -> None:
|
||||||
|
email, key = _creds()
|
||||||
|
for st in statements:
|
||||||
|
payload = {"sql": st["sql"]}
|
||||||
|
if "params" in st:
|
||||||
|
payload["params"] = st["params"]
|
||||||
|
data = json.dumps(payload).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT}/d1/database/{DB}/query",
|
||||||
|
data=data,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
out = json.loads(r.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
raise SystemExit(f"D1 {e.code}: {raw[:500]}\nSQL: {st['sql'][:200]}") from e
|
||||||
|
if not out.get("success"):
|
||||||
|
raise SystemExit(json.dumps(out, ensure_ascii=False)[:800])
|
||||||
|
|
||||||
|
|
||||||
|
def esc(v) -> str:
|
||||||
|
if v is None:
|
||||||
|
return "NULL"
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return "1" if v else "0"
|
||||||
|
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
||||||
|
if isinstance(v, float) and (v != v): # NaN
|
||||||
|
return "NULL"
|
||||||
|
return str(v)
|
||||||
|
s = str(v).replace("'", "''")
|
||||||
|
return f"'{s}'"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if not PROFILE.is_file():
|
||||||
|
raise SystemExit(f"missing {PROFILE}")
|
||||||
|
p = json.loads(PROFILE.read_text(encoding="utf-8"))
|
||||||
|
now = utc_now()
|
||||||
|
steamid = str(AID + 76561197960265728)
|
||||||
|
personaname = p.get("personaname") or "refining"
|
||||||
|
avatar = p.get("avatar")
|
||||||
|
public_share = 1 if p.get("public_share") else 0
|
||||||
|
avail = p.get("availability") or {}
|
||||||
|
career = p.get("career") or {}
|
||||||
|
recent20 = p.get("recent_20") or {}
|
||||||
|
activity = p.get("activity_180") or {}
|
||||||
|
top_heroes = p.get("top_heroes") or []
|
||||||
|
peers = p.get("peers") or []
|
||||||
|
recent = (p.get("recent") or [])[:20]
|
||||||
|
|
||||||
|
stmts: list[dict] = []
|
||||||
|
stmts.append(
|
||||||
|
{
|
||||||
|
"sql": (
|
||||||
|
f"INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at) "
|
||||||
|
f"VALUES ({AID}, {esc(steamid)}, {esc(personaname)}, {esc(avatar)}, {public_share}, {esc(now)}, {esc(now)}) "
|
||||||
|
f"ON CONFLICT(account_id) DO UPDATE SET "
|
||||||
|
f"personaname=excluded.personaname, avatar=COALESCE(excluded.avatar, users.avatar), "
|
||||||
|
f"last_login_at=excluded.last_login_at"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stmts.append(
|
||||||
|
{
|
||||||
|
"sql": (
|
||||||
|
f"INSERT INTO player_profiles ("
|
||||||
|
f"account_id, rank_tier, leaderboard_rank, availability_status, availability_note, "
|
||||||
|
f"availability_complete, source, fetched_at, enriched_at, updated_at) VALUES ("
|
||||||
|
f"{AID}, {esc(p.get('rank_tier'))}, {esc(p.get('leaderboard_rank'))}, "
|
||||||
|
f"{esc(avail.get('status') or 'public')}, {esc(avail.get('note'))}, "
|
||||||
|
f"{1 if avail.get('complete', True) else 0}, "
|
||||||
|
f"{esc(avail.get('source') or 'opendota+steam')}, "
|
||||||
|
f"{esc(avail.get('fetched_at') or now)}, {esc(p.get('enriched_at') or now)}, {esc(now)}) "
|
||||||
|
f"ON CONFLICT(account_id) DO UPDATE SET "
|
||||||
|
f"rank_tier=excluded.rank_tier, leaderboard_rank=excluded.leaderboard_rank, "
|
||||||
|
f"availability_status=excluded.availability_status, availability_note=excluded.availability_note, "
|
||||||
|
f"availability_complete=excluded.availability_complete, source=excluded.source, "
|
||||||
|
f"fetched_at=excluded.fetched_at, enriched_at=excluded.enriched_at, updated_at=excluded.updated_at"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def stats_sql(scope: str, stats: dict) -> dict:
|
||||||
|
payload = json.dumps(stats, ensure_ascii=False).replace("'", "''")
|
||||||
|
sample = stats.get("sample") if stats.get("sample") is not None else stats.get("games") or 0
|
||||||
|
return {
|
||||||
|
"sql": (
|
||||||
|
f"INSERT INTO player_stats ("
|
||||||
|
f"account_id, scope, sample, wins, losses, winrate, kills, deaths, assists, kda, "
|
||||||
|
f"avg_kills, avg_deaths, avg_assists, avg_gpm, avg_xpm, avg_hero_damage, payload_json, updated_at) "
|
||||||
|
f"VALUES ({AID}, {esc(scope)}, {int(sample)}, {int(stats.get('wins') or 0)}, "
|
||||||
|
f"{int(stats.get('losses') or 0)}, {esc(stats.get('winrate'))}, "
|
||||||
|
f"{esc(stats.get('kills'))}, {esc(stats.get('deaths'))}, {esc(stats.get('assists'))}, "
|
||||||
|
f"{esc(stats.get('kda'))}, {esc(stats.get('avg_kills'))}, {esc(stats.get('avg_deaths'))}, "
|
||||||
|
f"{esc(stats.get('avg_assists'))}, {esc(stats.get('avg_gpm'))}, {esc(stats.get('avg_xpm'))}, "
|
||||||
|
f"{esc(stats.get('avg_hero_damage'))}, '{payload}', {esc(now)}) "
|
||||||
|
f"ON CONFLICT(account_id, scope) DO UPDATE SET "
|
||||||
|
f"sample=excluded.sample, wins=excluded.wins, losses=excluded.losses, winrate=excluded.winrate, "
|
||||||
|
f"kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda, "
|
||||||
|
f"avg_kills=excluded.avg_kills, avg_deaths=excluded.avg_deaths, avg_assists=excluded.avg_assists, "
|
||||||
|
f"avg_gpm=excluded.avg_gpm, avg_xpm=excluded.avg_xpm, avg_hero_damage=excluded.avg_hero_damage, "
|
||||||
|
f"payload_json=excluded.payload_json, updated_at=excluded.updated_at"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if career:
|
||||||
|
stmts.append(stats_sql("career", career))
|
||||||
|
if recent20:
|
||||||
|
stmts.append(stats_sql("recent20", recent20))
|
||||||
|
if activity:
|
||||||
|
stmts.append(stats_sql("recent180", activity))
|
||||||
|
|
||||||
|
stmts.append({"sql": f"DELETE FROM player_heroes WHERE account_id={AID}"})
|
||||||
|
for h in top_heroes[:8]:
|
||||||
|
stmts.append(
|
||||||
|
{
|
||||||
|
"sql": (
|
||||||
|
f"INSERT INTO player_heroes ("
|
||||||
|
f"account_id, hero_id, hero_key, hero_name_loc, games, wins, winrate, last_played, updated_at) "
|
||||||
|
f"VALUES ({AID}, {int(h.get('hero_id') or 0)}, {esc(h.get('hero_key'))}, "
|
||||||
|
f"{esc(h.get('hero_name_loc'))}, {int(h.get('games') or 0)}, {int(h.get('wins') or 0)}, "
|
||||||
|
f"{esc(h.get('winrate'))}, {esc(h.get('last_played'))}, {esc(now)})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
stmts.append({"sql": f"DELETE FROM player_peers WHERE account_id={AID}"})
|
||||||
|
for peer in peers[:8]:
|
||||||
|
stmts.append(
|
||||||
|
{
|
||||||
|
"sql": (
|
||||||
|
f"INSERT INTO player_peers ("
|
||||||
|
f"account_id, peer_account_id, personaname, avatar, games, wins, winrate, updated_at) "
|
||||||
|
f"VALUES ({AID}, {int(peer.get('account_id') or 0)}, {esc(peer.get('personaname'))}, "
|
||||||
|
f"{esc(peer.get('avatar'))}, {int(peer.get('games') or 0)}, {int(peer.get('wins') or 0)}, "
|
||||||
|
f"{esc(peer.get('winrate'))}, {esc(now)})"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for r in recent:
|
||||||
|
mid = int(r.get("match_id") or 0)
|
||||||
|
if mid <= 0:
|
||||||
|
continue
|
||||||
|
stmts.append(
|
||||||
|
{
|
||||||
|
"sql": (
|
||||||
|
f"INSERT INTO player_matches ("
|
||||||
|
f"account_id, match_id, start_time, duration, won, hero_id, hero_key, hero_name_loc, "
|
||||||
|
f"kills, deaths, assists, kda, gpm, xpm, hero_damage, game_mode, lobby_type, r2_key, updated_at) "
|
||||||
|
f"VALUES ({AID}, {mid}, {esc(r.get('start_time'))}, {esc(r.get('duration'))}, "
|
||||||
|
f"{1 if r.get('won') else 0}, {esc(r.get('hero_id'))}, {esc(r.get('hero_key'))}, "
|
||||||
|
f"{esc(r.get('hero_name_loc'))}, {esc(r.get('kills'))}, {esc(r.get('deaths'))}, "
|
||||||
|
f"{esc(r.get('assists'))}, {esc(r.get('kda'))}, {esc(r.get('gpm'))}, {esc(r.get('xpm'))}, "
|
||||||
|
f"{esc(r.get('hero_damage'))}, {esc(r.get('game_mode'))}, {esc(r.get('lobby_type'))}, "
|
||||||
|
f"NULL, {esc(now)}) "
|
||||||
|
f"ON CONFLICT(account_id, match_id) DO UPDATE SET "
|
||||||
|
f"start_time=excluded.start_time, duration=excluded.duration, won=excluded.won, "
|
||||||
|
f"hero_id=excluded.hero_id, hero_key=excluded.hero_key, hero_name_loc=excluded.hero_name_loc, "
|
||||||
|
f"kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda, "
|
||||||
|
f"gpm=excluded.gpm, xpm=excluded.xpm, hero_damage=excluded.hero_damage, "
|
||||||
|
f"game_mode=excluded.game_mode, lobby_type=excluded.lobby_type, updated_at=excluded.updated_at"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"backfill {AID} from {PROFILE.name}: {len(stmts)} statements …", flush=True)
|
||||||
|
d1_batch(stmts)
|
||||||
|
print("done", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""Diagnose Cloudflare API auth without printing secrets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
|
||||||
|
|
||||||
|
|
||||||
|
def load_staged() -> None:
|
||||||
|
if not STAGED.is_file():
|
||||||
|
return
|
||||||
|
for line in STAGED.read_text(encoding="utf-8").splitlines():
|
||||||
|
if "=" not in line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
os.environ.setdefault(k.strip(), v.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def probe(path: str, email: str, key: str) -> None:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://api.cloudflare.com/client/v4" + path,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"User-Agent": "climperor-cf-diag",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=45) as resp:
|
||||||
|
body = resp.read(120).decode("utf-8", errors="replace")
|
||||||
|
print(f"{path} -> {resp.status} ray={resp.headers.get('cf-ray')} body={body[:80]!r}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read(200).decode("utf-8", errors="replace")
|
||||||
|
print(
|
||||||
|
f"{path} -> HTTP {e.code} ray={e.headers.get('cf-ray') if e.headers else None} "
|
||||||
|
f"body={raw[:120]!r}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{path} -> {type(e).__name__}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
load_staged()
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
print("missing credentials")
|
||||||
|
return 2
|
||||||
|
print(f"email_len={len(email)} key_len={len(key)} email_has_at={'@' in email}")
|
||||||
|
for path in ("/user", "/accounts", "/user/tokens/verify"):
|
||||||
|
probe(path, email, key)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Quick Cloudflare API reachability check (no secrets printed)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
print("missing credentials")
|
||||||
|
return 2
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://api.cloudflare.com/client/v4/user",
|
||||||
|
headers={"X-Auth-Email": email, "X-Auth-Key": key},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
print(f"ok status={resp.status}")
|
||||||
|
return 0
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f"http {e.code}")
|
||||||
|
return 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{type(e).__name__}: {e}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Try R2 create via curl + Global API Key from staged env (no secret echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
BUCKET = "climperor-player-data"
|
||||||
|
|
||||||
|
|
||||||
|
def load() -> tuple[str, str]:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL")
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY")
|
||||||
|
if STAGED.is_file():
|
||||||
|
for line in STAGED.read_text(encoding="utf-8").splitlines():
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
if k.strip() == "CLOUDFLARE_EMAIL":
|
||||||
|
email = v.strip()
|
||||||
|
elif k.strip() == "CLOUDFLARE_API_KEY":
|
||||||
|
key = v.strip()
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing credentials")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def curl_json(method: str, url: str, email: str, key: str, body: dict | None = None) -> tuple[int, str]:
|
||||||
|
cmd = [
|
||||||
|
"curl.exe",
|
||||||
|
"-sS",
|
||||||
|
"-w",
|
||||||
|
"\nHTTP_CODE:%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
"60",
|
||||||
|
"-X",
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
"-H",
|
||||||
|
f"X-Auth-Email: {email}",
|
||||||
|
"-H",
|
||||||
|
f"X-Auth-Key: {key}",
|
||||||
|
"-H",
|
||||||
|
"Content-Type: application/json",
|
||||||
|
]
|
||||||
|
if body is not None:
|
||||||
|
cmd.extend(["-d", json.dumps(body)])
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||||
|
out = proc.stdout or ""
|
||||||
|
code = 0
|
||||||
|
if "HTTP_CODE:" in out:
|
||||||
|
body_text, _, code_s = out.rpartition("HTTP_CODE:")
|
||||||
|
try:
|
||||||
|
code = int(code_s.strip())
|
||||||
|
except ValueError:
|
||||||
|
code = 0
|
||||||
|
out = body_text.strip()
|
||||||
|
else:
|
||||||
|
out = (proc.stderr or out)[:300]
|
||||||
|
return code, out[:400]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email, key = load()
|
||||||
|
base = f"https://api.cloudflare.com/client/v4/accounts/{ACCT}/r2/buckets"
|
||||||
|
code, body = curl_json("GET", f"{base}/{BUCKET}", email, key)
|
||||||
|
print(f"GET bucket -> {code} {body[:160]!r}")
|
||||||
|
if code == 200:
|
||||||
|
print("r2 already exists")
|
||||||
|
return 0
|
||||||
|
code, body = curl_json("POST", base, email, key, {"name": BUCKET})
|
||||||
|
print(f"POST bucket -> {code} {body[:200]!r}")
|
||||||
|
return 0 if code in (200, 201) or "already exists" in body.lower() else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Print Pages deployment_configs keys relevant to bindings (no secrets)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
PROJ = "climperor-relations"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.cloudflare.com/client/v4/accounts/{ACCT}/pages/projects/{PROJ}",
|
||||||
|
headers={"X-Auth-Email": email, "X-Auth-Key": key},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
data = json.load(resp)
|
||||||
|
dc = (data.get("result") or {}).get("deployment_configs") or {}
|
||||||
|
for env in ("production", "preview"):
|
||||||
|
cfg = dc.get(env) or {}
|
||||||
|
print(
|
||||||
|
env,
|
||||||
|
"fail_open=",
|
||||||
|
cfg.get("fail_open"),
|
||||||
|
"placement=",
|
||||||
|
cfg.get("placement"),
|
||||||
|
"d1=",
|
||||||
|
sorted((cfg.get("d1_databases") or {}).keys()),
|
||||||
|
"r2=",
|
||||||
|
sorted((cfg.get("r2_buckets") or {}).keys()),
|
||||||
|
"queues=",
|
||||||
|
sorted((cfg.get("queue_producers") or {}).keys()),
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Dump Pages env_var names/types and latest deployment id (no secret values)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
PROJ = "climperor-relations"
|
||||||
|
|
||||||
|
|
||||||
|
def get(path: str) -> dict:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.cloudflare.com/client/v4{path}",
|
||||||
|
headers={"X-Auth-Email": email, "X-Auth-Key": key},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return json.load(r)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
p = get(f"/accounts/{ACCT}/pages/projects/{PROJ}")["result"]
|
||||||
|
dc = p.get("deployment_configs") or {}
|
||||||
|
for env in ("production", "preview"):
|
||||||
|
ev = (dc.get(env) or {}).get("env_vars") or {}
|
||||||
|
print(env, "keys", sorted(ev.keys()))
|
||||||
|
for k, v in sorted(ev.items()):
|
||||||
|
print(
|
||||||
|
" ",
|
||||||
|
k,
|
||||||
|
"type=",
|
||||||
|
(v or {}).get("type"),
|
||||||
|
"has_value=",
|
||||||
|
"value" in (v or {}),
|
||||||
|
)
|
||||||
|
deps = get(f"/accounts/{ACCT}/pages/projects/{PROJ}/deployments?per_page=2")[
|
||||||
|
"result"
|
||||||
|
]
|
||||||
|
for d0 in deps or []:
|
||||||
|
print(
|
||||||
|
"deployment",
|
||||||
|
d0.get("id"),
|
||||||
|
d0.get("environment"),
|
||||||
|
d0.get("created_on"),
|
||||||
|
d0.get("url"),
|
||||||
|
)
|
||||||
|
if d0.get("id"):
|
||||||
|
det = get(
|
||||||
|
f"/accounts/{ACCT}/pages/projects/{PROJ}/deployments/{d0['id']}"
|
||||||
|
)["result"]
|
||||||
|
# Some APIs nest env under build_config / env_vars
|
||||||
|
for k in sorted(det.keys()):
|
||||||
|
if "env" in k.lower() or k in ("aliases", "is_skipped", "production_branch"):
|
||||||
|
val = det.get(k)
|
||||||
|
s = json.dumps(val, ensure_ascii=False) if not isinstance(val, str) else val
|
||||||
|
print(f" {k}: {s[:240]}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Create R2 bucket, bind Pages, redeploy Worker using keyzoo-injected CF creds."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
WORKER = Path(__file__).resolve().parent / "player-sync"
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
BUCKET = "climperor-player-data"
|
||||||
|
PAGES = "climperor-relations"
|
||||||
|
QUEUE = "climperor-player-sync"
|
||||||
|
D1_ID = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
|
||||||
|
API = "https://api.cloudflare.com/client/v4"
|
||||||
|
RESOURCES = Path(__file__).resolve().parent / ".resources.json"
|
||||||
|
|
||||||
|
|
||||||
|
def creds() -> tuple[str, str]:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing CF credentials")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def api(method: str, path: str, body: dict | None = None, retries: int = 6) -> dict:
|
||||||
|
email, key = creds()
|
||||||
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
last = None
|
||||||
|
for i in range(retries):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
API + path,
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-enable-r2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
last = f"{e.code}: {raw[:200]}"
|
||||||
|
if e.code in (521, 522, 523, 524, 525, 429, 503) and i + 1 < retries:
|
||||||
|
wait = 4 + i * 3
|
||||||
|
print(f"retry {i+1}/{retries} after {e.code}, sleep {wait}s", flush=True)
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise SystemExit(f"CF API {method} {path} -> {last}") from e
|
||||||
|
except Exception as e:
|
||||||
|
last = str(e)
|
||||||
|
if i + 1 < retries:
|
||||||
|
wait = 4 + i * 3
|
||||||
|
print(f"retry {i+1}/{retries} after {type(e).__name__}, sleep {wait}s", flush=True)
|
||||||
|
time.sleep(wait)
|
||||||
|
continue
|
||||||
|
raise SystemExit(f"CF API {method} {path} -> {last}") from e
|
||||||
|
raise SystemExit(f"CF API failed: {last}")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_r2() -> None:
|
||||||
|
try:
|
||||||
|
api("GET", f"/accounts/{ACCT}/r2/buckets/{BUCKET}", retries=3)
|
||||||
|
print(f"r2 exists: {BUCKET}", flush=True)
|
||||||
|
return
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
payload = api("POST", f"/accounts/{ACCT}/r2/buckets", {"name": BUCKET})
|
||||||
|
if not payload.get("success"):
|
||||||
|
err = str(payload.get("errors") or "")
|
||||||
|
if "already exists" in err.lower() or "10004" in err:
|
||||||
|
print(f"r2 exists: {BUCKET}", flush=True)
|
||||||
|
return
|
||||||
|
raise SystemExit(f"r2 create failed: {payload.get('errors')}")
|
||||||
|
print(f"r2 created: {BUCKET}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def bind_pages() -> None:
|
||||||
|
path = f"/accounts/{ACCT}/pages/projects/{PAGES}"
|
||||||
|
project = api("GET", path)["result"]
|
||||||
|
dc = project.get("deployment_configs") or {}
|
||||||
|
prod = dict(dc.get("production") or {})
|
||||||
|
preview = dict(dc.get("preview") or {})
|
||||||
|
fail_open = prod.get("fail_open")
|
||||||
|
if fail_open is None:
|
||||||
|
fail_open = preview.get("fail_open")
|
||||||
|
if fail_open is None:
|
||||||
|
fail_open = False
|
||||||
|
|
||||||
|
def one(base: dict) -> dict:
|
||||||
|
out = {
|
||||||
|
k: v
|
||||||
|
for k, v in base.items()
|
||||||
|
if k not in ("d1_databases", "queue_producers", "r2_buckets", "fail_open")
|
||||||
|
}
|
||||||
|
d1 = dict(base.get("d1_databases") or {})
|
||||||
|
d1["DB"] = {"id": D1_ID}
|
||||||
|
out["d1_databases"] = d1
|
||||||
|
producers = dict(base.get("queue_producers") or {})
|
||||||
|
producers["SYNC_QUEUE"] = {"name": QUEUE}
|
||||||
|
out["queue_producers"] = producers
|
||||||
|
buckets = dict(base.get("r2_buckets") or {})
|
||||||
|
buckets["MATCHES"] = {"name": BUCKET}
|
||||||
|
out["r2_buckets"] = buckets
|
||||||
|
out["fail_open"] = bool(fail_open)
|
||||||
|
return out
|
||||||
|
|
||||||
|
api(
|
||||||
|
"PATCH",
|
||||||
|
path,
|
||||||
|
{
|
||||||
|
"deployment_configs": {
|
||||||
|
"production": one(prod),
|
||||||
|
"preview": one(preview),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
print("pages bindings: DB + SYNC_QUEUE + MATCHES", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def update_resources() -> None:
|
||||||
|
data = {}
|
||||||
|
if RESOURCES.is_file():
|
||||||
|
data = json.loads(RESOURCES.read_text(encoding="utf-8"))
|
||||||
|
data["account_id"] = ACCT
|
||||||
|
data["r2"] = {"name": BUCKET, "ready": True}
|
||||||
|
data.setdefault("d1", {"name": "climperor-users", "id": D1_ID})
|
||||||
|
data.setdefault("queue", {"name": QUEUE})
|
||||||
|
data.setdefault("pages_project", PAGES)
|
||||||
|
data.setdefault("worker", "climperor-player-sync")
|
||||||
|
RESOURCES.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(f"updated {RESOURCES}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def deploy_worker() -> None:
|
||||||
|
email, key = creds()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["CLOUDFLARE_EMAIL"] = email
|
||||||
|
env["CLOUDFLARE_API_KEY"] = key
|
||||||
|
env["CLOUDFLARE_ACCOUNT_ID"] = ACCT
|
||||||
|
print("deploying worker…", flush=True)
|
||||||
|
proc = subprocess.run(
|
||||||
|
"npx --yes wrangler@3 deploy",
|
||||||
|
cwd=str(WORKER),
|
||||||
|
env=env,
|
||||||
|
shell=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise SystemExit(f"worker deploy failed: {proc.returncode}")
|
||||||
|
print("worker deployed", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ensure_r2()
|
||||||
|
bind_pages()
|
||||||
|
update_resources()
|
||||||
|
deploy_worker()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Force Worker HTTP login_refresh for TARGET account."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import runpy
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ["CLIMPEROR_FORCE_HTTP_SYNC"] = "1"
|
||||||
|
runpy.run_path(str(Path(__file__).with_name("_trigger_player_sync.py")), run_name="__main__")
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Deep-probe D1 + OpenDota for one account (no secret echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ACCOUNT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
DB = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
|
||||||
|
AID = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
|
||||||
|
|
||||||
|
|
||||||
|
def _creds() -> tuple[str, str]:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing Cloudflare credentials")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def d1(sql: str) -> list:
|
||||||
|
email, key = _creds()
|
||||||
|
body = json.dumps({"sql": sql}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT}/d1/database/{DB}/query",
|
||||||
|
data=body,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
out = json.loads(r.read().decode())
|
||||||
|
results = out.get("result") or []
|
||||||
|
if results:
|
||||||
|
return results[0].get("results") or []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def od(path: str):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.opendota.com/api{path}",
|
||||||
|
headers={"User-Agent": "climperor-probe"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=25) as r:
|
||||||
|
return json.loads(r.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return {"_error": e.code, "_body": e.read()[:200].decode("utf-8", "replace")}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
print("users", json.dumps(d1(f"SELECT * FROM users WHERE account_id={AID}"), ensure_ascii=False))
|
||||||
|
print(
|
||||||
|
"stats",
|
||||||
|
json.dumps(
|
||||||
|
d1(
|
||||||
|
f"SELECT scope, sample, wins, losses, winrate, length(payload_json) AS plen "
|
||||||
|
f"FROM player_stats WHERE account_id={AID}"
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"matches",
|
||||||
|
json.dumps(
|
||||||
|
d1(f"SELECT COUNT(*) AS n FROM player_matches WHERE account_id={AID}"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"heroes",
|
||||||
|
json.dumps(
|
||||||
|
d1(f"SELECT COUNT(*) AS n FROM player_heroes WHERE account_id={AID}"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"peers",
|
||||||
|
json.dumps(
|
||||||
|
d1(f"SELECT COUNT(*) AS n FROM player_peers WHERE account_id={AID}"),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"profile",
|
||||||
|
json.dumps(
|
||||||
|
d1(
|
||||||
|
f"SELECT account_id, rank_tier, leaderboard_rank, "
|
||||||
|
f"availability_status, availability_note, "
|
||||||
|
f"availability_complete, source, fetched_at, enriched_at "
|
||||||
|
f"FROM player_profiles WHERE account_id={AID}"
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
"stats_all",
|
||||||
|
json.dumps(
|
||||||
|
d1(
|
||||||
|
f"SELECT scope, sample, wins, losses, kda, avg_gpm "
|
||||||
|
f"FROM player_stats WHERE account_id={AID} ORDER BY scope"
|
||||||
|
),
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
player = od(f"/players/{AID}")
|
||||||
|
if isinstance(player, dict) and "profile" in player:
|
||||||
|
p = player.get("profile") or {}
|
||||||
|
print(
|
||||||
|
"OD player",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"personaname": p.get("personaname"),
|
||||||
|
"rank_tier": player.get("rank_tier"),
|
||||||
|
"fh_unavailable": player.get("fh_unavailable"),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("OD player", player)
|
||||||
|
|
||||||
|
wl = od(f"/players/{AID}/wl")
|
||||||
|
print("OD wl", wl)
|
||||||
|
recent = od(f"/players/{AID}/recentMatches")
|
||||||
|
if isinstance(recent, list):
|
||||||
|
print("OD recentMatches", len(recent))
|
||||||
|
if recent:
|
||||||
|
print("OD recent[0].match_id", recent[0].get("match_id"))
|
||||||
|
else:
|
||||||
|
print("OD recentMatches", recent)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Probe which CF API paths work with Global API Key (no secrets printed)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
PATHS = [
|
||||||
|
"/user",
|
||||||
|
"/accounts",
|
||||||
|
f"/accounts/{ACCT}/d1/database",
|
||||||
|
f"/accounts/{ACCT}/queues",
|
||||||
|
f"/accounts/{ACCT}/r2/buckets",
|
||||||
|
f"/accounts/{ACCT}/pages/projects/climperor-relations",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
for path in PATHS:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
"https://api.cloudflare.com/client/v4" + path,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"User-Agent": "climperor-probe",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=40) as resp:
|
||||||
|
print(f"{path} -> {resp.status}")
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
snippet = e.read(80).decode("utf-8", errors="replace").replace("\n", " ")
|
||||||
|
print(f"{path} -> HTTP {e.code} {snippet[:60]!r}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"{path} -> {type(e).__name__}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Hammer R2 create/list until Cloudflare API stops returning 52x."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
BUCKET = "climperor-player-data"
|
||||||
|
API = "https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
|
|
||||||
|
def creds():
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing credentials")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def call(method: str, path: str, body: dict | None = None) -> tuple[int, dict | str]:
|
||||||
|
email, key = creds()
|
||||||
|
data = None if body is None else json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
API + path,
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-retry-r2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
return resp.status, json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
return e.code, raw[:120]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
list_path = f"/accounts/{ACCT}/r2/buckets"
|
||||||
|
get_path = f"/accounts/{ACCT}/r2/buckets/{BUCKET}"
|
||||||
|
for i in range(20):
|
||||||
|
code, payload = call("GET", get_path)
|
||||||
|
print(f"[{i+1}] GET {BUCKET} -> {code}", flush=True)
|
||||||
|
if code == 200:
|
||||||
|
print("bucket ready", flush=True)
|
||||||
|
return 0
|
||||||
|
if code == 404 or (isinstance(payload, dict) and not payload.get("success")):
|
||||||
|
# not found → create
|
||||||
|
code2, payload2 = call("POST", list_path, {"name": BUCKET})
|
||||||
|
print(f"[{i+1}] POST create -> {code2}", flush=True)
|
||||||
|
if code2 in (200, 201):
|
||||||
|
print("created", flush=True)
|
||||||
|
return 0
|
||||||
|
text = str(payload2).lower()
|
||||||
|
if "already exists" in text or "10004" in text:
|
||||||
|
print("exists", flush=True)
|
||||||
|
return 0
|
||||||
|
# also try list
|
||||||
|
code3, payload3 = call("GET", list_path)
|
||||||
|
names = []
|
||||||
|
if isinstance(payload3, dict):
|
||||||
|
buckets = (payload3.get("result") or {}).get("buckets") or payload3.get(
|
||||||
|
"result"
|
||||||
|
)
|
||||||
|
if isinstance(buckets, list):
|
||||||
|
for b in buckets:
|
||||||
|
if isinstance(b, dict):
|
||||||
|
names.append(b.get("name"))
|
||||||
|
print(f"[{i+1}] LIST -> {code3} names={names}", flush=True)
|
||||||
|
if BUCKET in names:
|
||||||
|
print("bucket ready via list", flush=True)
|
||||||
|
return 0
|
||||||
|
time.sleep(5 + (i % 5))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Load staged CF creds into env, run argv command, then delete the staging file."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("usage: _run_with_staged_cf.py <cmd> [args...]", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not STAGED.is_file():
|
||||||
|
print("missing staged cf_creds.env", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
env = os.environ.copy()
|
||||||
|
for line in STAGED.read_text(encoding="utf-8").splitlines():
|
||||||
|
if "=" not in line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
env[k.strip()] = v.strip()
|
||||||
|
try:
|
||||||
|
STAGED.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
env.setdefault("CLOUDFLARE_ACCOUNT_ID", "510534f7f6284344aadaf2f5a0794d48")
|
||||||
|
proc = subprocess.run(sys.argv[1:], cwd=str(Path.cwd()), env=env)
|
||||||
|
return proc.returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Stage Cloudflare email+key for the next local provision (no echo of secrets)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUT = Path(__file__).resolve().parents[1] / ".refresh" / "cf_creds.env"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = (
|
||||||
|
os.environ.get("CLOUDFLARE_EMAIL")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_META_USERNAME")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
key = (
|
||||||
|
os.environ.get("CLOUDFLARE_API_KEY")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_GLOBAL_API_KEY")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not email or not key:
|
||||||
|
print("missing credentials", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
OUT.write_text(
|
||||||
|
f"CLOUDFLARE_EMAIL={email}\nCLOUDFLARE_API_KEY={key}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
os.chmod(OUT, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
print(f"staged {OUT.name}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Stage Steam API key + session secret for Pages/Worker secret put (no echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REFRESH = Path(__file__).resolve().parents[1] / ".refresh"
|
||||||
|
STEAM_TMP = REFRESH / "steam_key.tmp"
|
||||||
|
SESSION_TMP = REFRESH / "session_secret.tmp"
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path: Path, value: str) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(value, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
steam = (
|
||||||
|
os.environ.get("STEAM_API_KEY")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
session = (
|
||||||
|
os.environ.get("SESSION_SECRET")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_SESSION_SECRET")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not steam or not session:
|
||||||
|
print("missing steam key or session secret", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
_write(STEAM_TMP, steam)
|
||||||
|
_write(SESSION_TMP, session)
|
||||||
|
print(
|
||||||
|
f"staged {STEAM_TMP.name} len={len(steam)}; "
|
||||||
|
f"{SESSION_TMP.name} len={len(session)}",
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Stage Steam API key to a temp file for the next CF secret put (no echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUT = Path(__file__).resolve().parents[1] / ".refresh" / "steam_key.tmp"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
key = (
|
||||||
|
os.environ.get("STEAM_API_KEY")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not key:
|
||||||
|
print("missing steam key", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
OUT.write_text(key, encoding="utf-8")
|
||||||
|
try:
|
||||||
|
os.chmod(OUT, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
print(f"staged {OUT.name} len={len(key)}", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""Probe D1 for an account, enqueue login_refresh, wait, probe again.
|
||||||
|
|
||||||
|
Uses Cloudflare Global API Key env (no secret echo).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
WORKER = HERE / "player-sync"
|
||||||
|
ACCOUNT_ID = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
QUEUE_ID = "371f11c7b4114f9b99ab10062a38ecd7"
|
||||||
|
TARGET = int(os.environ.get("CLIMPEROR_SYNC_ACCOUNT_ID", "143712136"))
|
||||||
|
|
||||||
|
|
||||||
|
def _cf_env() -> dict[str, str]:
|
||||||
|
env = os.environ.copy()
|
||||||
|
email = env.get("CLOUDFLARE_EMAIL") or env.get("KEYZOO_ASSET_META_USERNAME")
|
||||||
|
key = env.get("CLOUDFLARE_API_KEY") or env.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing Cloudflare credentials")
|
||||||
|
env["CLOUDFLARE_EMAIL"] = email
|
||||||
|
env["CLOUDFLARE_API_KEY"] = key
|
||||||
|
env["CLOUDFLARE_ACCOUNT_ID"] = ACCOUNT_ID
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def d1_query(sql: str, env: dict[str, str]) -> list:
|
||||||
|
cmd = (
|
||||||
|
f'npx --yes wrangler@3 d1 execute climperor-users --remote --json '
|
||||||
|
f'--command "{sql}"'
|
||||||
|
)
|
||||||
|
r = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(WORKER),
|
||||||
|
env=env,
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if r.returncode != 0:
|
||||||
|
print(r.stderr or r.stdout, file=sys.stderr)
|
||||||
|
raise SystemExit(r.returncode)
|
||||||
|
try:
|
||||||
|
data = json.loads(r.stdout)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(r.stdout)
|
||||||
|
return []
|
||||||
|
if isinstance(data, list) and data:
|
||||||
|
return data[0].get("results") or []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class CfApiError(RuntimeError):
|
||||||
|
def __init__(self, code: int, body: str):
|
||||||
|
super().__init__(f"CF API {code}")
|
||||||
|
self.code = code
|
||||||
|
self.body = body
|
||||||
|
|
||||||
|
|
||||||
|
def cf_api(method: str, path: str, env: dict[str, str], body: dict | None = None) -> dict:
|
||||||
|
url = f"https://api.cloudflare.com/client/v4{path}"
|
||||||
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": env["CLOUDFLARE_EMAIL"],
|
||||||
|
"X-Auth-Key": env["CLOUDFLARE_API_KEY"],
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-trigger-sync",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
raise CfApiError(e.code, raw[:1200]) from e
|
||||||
|
|
||||||
|
|
||||||
|
def probe(env: dict[str, str], label: str) -> None:
|
||||||
|
print(f"\n=== {label} account_id={TARGET} ===", flush=True)
|
||||||
|
users = d1_query(
|
||||||
|
f"SELECT account_id, personaname, last_login_at FROM users "
|
||||||
|
f"WHERE account_id={TARGET}",
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
print("users:", json.dumps(users, ensure_ascii=False), flush=True)
|
||||||
|
jobs = d1_query(
|
||||||
|
f"SELECT kind, status, substr(COALESCE(error,''),1,160) AS error, "
|
||||||
|
f"updated_at FROM sync_jobs WHERE account_id={TARGET} "
|
||||||
|
f"ORDER BY updated_at DESC LIMIT 5",
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
print("sync_jobs:", json.dumps(jobs, ensure_ascii=False), flush=True)
|
||||||
|
matches = d1_query(
|
||||||
|
f"SELECT COUNT(*) AS n, "
|
||||||
|
f"SUM(CASE WHEN r2_key IS NOT NULL AND r2_key != '' THEN 1 ELSE 0 END) AS r2 "
|
||||||
|
f"FROM player_matches WHERE account_id={TARGET}",
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
print("matches:", json.dumps(matches, ensure_ascii=False), flush=True)
|
||||||
|
stats = d1_query(
|
||||||
|
f"SELECT scope, sample, winrate FROM player_stats "
|
||||||
|
f"WHERE account_id={TARGET}",
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
print("stats:", json.dumps(stats, ensure_ascii=False), flush=True)
|
||||||
|
heroes = d1_query(
|
||||||
|
f"SELECT COUNT(*) AS n FROM player_heroes WHERE account_id={TARGET}",
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
print("heroes:", json.dumps(heroes, ensure_ascii=False), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def worker_http_sync(env: dict[str, str], msg: dict) -> str:
|
||||||
|
"""POST Worker fetch handler. Returns 'http'."""
|
||||||
|
print("Worker HTTP sync …", flush=True)
|
||||||
|
try:
|
||||||
|
cf_api(
|
||||||
|
"POST",
|
||||||
|
f"/accounts/{ACCOUNT_ID}/workers/scripts/climperor-player-sync/subdomain",
|
||||||
|
env,
|
||||||
|
{"enabled": True},
|
||||||
|
)
|
||||||
|
except CfApiError as e:
|
||||||
|
print(f"enable subdomain: {e.code} {e.body[:400]}", flush=True)
|
||||||
|
sub_name = ""
|
||||||
|
try:
|
||||||
|
sub = cf_api("GET", f"/accounts/{ACCOUNT_ID}/workers/subdomain", env)
|
||||||
|
sub_name = ((sub.get("result") or {}).get("subdomain") or "").strip()
|
||||||
|
except CfApiError as e:
|
||||||
|
print(f"get subdomain: {e.code} {e.body[:400]}", flush=True)
|
||||||
|
if not sub_name:
|
||||||
|
raise SystemExit("cannot resolve workers.dev subdomain")
|
||||||
|
url = f"https://climperor-player-sync.{sub_name}.workers.dev/"
|
||||||
|
print(f"POST {url}", flush=True)
|
||||||
|
data = json.dumps(msg).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=data,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-trigger-sync",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
print(f"worker HTTP {resp.status} len={len(raw)}", flush=True)
|
||||||
|
print(raw[:800], flush=True)
|
||||||
|
return "http"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
print(f"worker HTTP {e.code}: {raw[:800]}", file=sys.stderr)
|
||||||
|
raise SystemExit(1) from e
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
print(f"worker URL error: {e}", file=sys.stderr)
|
||||||
|
raise SystemExit(1) from e
|
||||||
|
|
||||||
|
|
||||||
|
def enqueue(env: dict[str, str]) -> str:
|
||||||
|
"""Enqueue or HTTP-sync. Returns 'queue' | 'http'."""
|
||||||
|
steamid = str(TARGET + 76561197960265728)
|
||||||
|
msg = {
|
||||||
|
"kind": "login_refresh",
|
||||||
|
"account_id": TARGET,
|
||||||
|
"steamid": steamid,
|
||||||
|
"personaname": "refining",
|
||||||
|
}
|
||||||
|
if os.environ.get("CLIMPEROR_FORCE_HTTP_SYNC", "").strip() in (
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"yes",
|
||||||
|
):
|
||||||
|
return worker_http_sync(env, msg)
|
||||||
|
|
||||||
|
print("\nenqueue login_refresh …", flush=True)
|
||||||
|
# https://developers.cloudflare.com/queues/configuration/javascript-apis/#producer
|
||||||
|
# HTTP: POST /accounts/:account_id/queues/:queue_id/messages
|
||||||
|
attempts = [
|
||||||
|
(
|
||||||
|
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages",
|
||||||
|
{"body": msg},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages",
|
||||||
|
{"messages": [{"body": json.dumps(msg)}]},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages/batch",
|
||||||
|
{"messages": [{"body": msg}]},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
f"/accounts/{ACCOUNT_ID}/queues/{QUEUE_ID}/messages/batch",
|
||||||
|
{"messages": [{"body": json.dumps(msg)}]},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for path, body in attempts:
|
||||||
|
try:
|
||||||
|
out = cf_api("POST", path, env, body)
|
||||||
|
except CfApiError as e:
|
||||||
|
print(f"try {path.split('/')[-1]} HTTP {e.code}: {e.body[:400]}", flush=True)
|
||||||
|
continue
|
||||||
|
ok = bool(out.get("success"))
|
||||||
|
print(f"try {path.split('/')[-1]} success={ok}", flush=True)
|
||||||
|
if ok:
|
||||||
|
return "queue"
|
||||||
|
print(json.dumps(out, ensure_ascii=False)[:600], flush=True)
|
||||||
|
# Last resort: enable workers.dev and POST the Worker fetch handler.
|
||||||
|
return worker_http_sync(env, msg)
|
||||||
|
|
||||||
|
|
||||||
|
def check_worker(env: dict[str, str]) -> None:
|
||||||
|
out = cf_api("GET", f"/accounts/{ACCOUNT_ID}/workers/scripts", env)
|
||||||
|
names = [r.get("id") or r.get("name") for r in (out.get("result") or [])]
|
||||||
|
print("workers:", ", ".join(n for n in names if n)[:500], flush=True)
|
||||||
|
has = "climperor-player-sync" in names
|
||||||
|
print(f"climperor-player-sync deployed={has}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
env = _cf_env()
|
||||||
|
check_worker(env)
|
||||||
|
probe(env, "before")
|
||||||
|
mode = enqueue(env)
|
||||||
|
waits = 2 if mode == "http" else 8
|
||||||
|
for i in range(1, waits + 1):
|
||||||
|
delay = 3 if mode == "http" else 8
|
||||||
|
time.sleep(delay)
|
||||||
|
probe(env, f"after wait #{i} ({i * delay}s)")
|
||||||
|
users = d1_query(
|
||||||
|
f"SELECT account_id FROM users WHERE account_id={TARGET}", env
|
||||||
|
)
|
||||||
|
stats = d1_query(
|
||||||
|
f"SELECT scope FROM player_stats WHERE account_id={TARGET}", env
|
||||||
|
)
|
||||||
|
filled = d1_query(
|
||||||
|
f"SELECT scope, sample FROM player_stats WHERE account_id={TARGET} "
|
||||||
|
f"AND sample > 0",
|
||||||
|
env,
|
||||||
|
)
|
||||||
|
if users and filled:
|
||||||
|
print("\nSYNC OK — user + non-empty stats", flush=True)
|
||||||
|
return 0
|
||||||
|
print("\nSYNC PENDING/FAILED — no non-empty stats after waits", flush=True)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Deploy climperor-player-sync Worker via wrangler (no secret echo)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
WORKER = HERE / "player-sync"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
print("missing Cloudflare credentials", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["CLOUDFLARE_EMAIL"] = email
|
||||||
|
env["CLOUDFLARE_API_KEY"] = key
|
||||||
|
env["CLOUDFLARE_ACCOUNT_ID"] = env.get(
|
||||||
|
"CLOUDFLARE_ACCOUNT_ID", "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
)
|
||||||
|
cmd = "npx --yes wrangler@3 deploy"
|
||||||
|
print("deploying climperor-player-sync …", flush=True)
|
||||||
|
return subprocess.call(cmd, cwd=str(WORKER), env=env, shell=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
-- Climperor multi-user player data (D1 climperor-users)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
account_id INTEGER PRIMARY KEY,
|
||||||
|
steamid TEXT NOT NULL UNIQUE,
|
||||||
|
personaname TEXT,
|
||||||
|
avatar TEXT,
|
||||||
|
public_share INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_login_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_profiles (
|
||||||
|
account_id INTEGER PRIMARY KEY REFERENCES users(account_id),
|
||||||
|
rank_tier INTEGER,
|
||||||
|
leaderboard_rank INTEGER,
|
||||||
|
availability_status TEXT,
|
||||||
|
availability_note TEXT,
|
||||||
|
availability_complete INTEGER NOT NULL DEFAULT 0,
|
||||||
|
source TEXT,
|
||||||
|
fetched_at TEXT,
|
||||||
|
enriched_at TEXT,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_stats (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES users(account_id),
|
||||||
|
scope TEXT NOT NULL, -- career | recent20 | recent180
|
||||||
|
sample INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
losses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
winrate REAL,
|
||||||
|
kills INTEGER,
|
||||||
|
deaths INTEGER,
|
||||||
|
assists INTEGER,
|
||||||
|
kda REAL,
|
||||||
|
avg_kills REAL,
|
||||||
|
avg_deaths REAL,
|
||||||
|
avg_assists REAL,
|
||||||
|
avg_gpm REAL,
|
||||||
|
avg_xpm REAL,
|
||||||
|
avg_hero_damage REAL,
|
||||||
|
payload_json TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, scope)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_heroes (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES users(account_id),
|
||||||
|
hero_id INTEGER NOT NULL,
|
||||||
|
hero_key TEXT,
|
||||||
|
hero_name_loc TEXT,
|
||||||
|
games INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
winrate REAL,
|
||||||
|
last_played INTEGER,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, hero_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_matches (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES users(account_id),
|
||||||
|
match_id INTEGER NOT NULL,
|
||||||
|
start_time INTEGER,
|
||||||
|
duration INTEGER,
|
||||||
|
won INTEGER,
|
||||||
|
hero_id INTEGER,
|
||||||
|
hero_key TEXT,
|
||||||
|
hero_name_loc TEXT,
|
||||||
|
kills INTEGER,
|
||||||
|
deaths INTEGER,
|
||||||
|
assists INTEGER,
|
||||||
|
kda REAL,
|
||||||
|
gpm INTEGER,
|
||||||
|
xpm INTEGER,
|
||||||
|
hero_damage INTEGER,
|
||||||
|
game_mode INTEGER,
|
||||||
|
lobby_type INTEGER,
|
||||||
|
r2_key TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, match_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_player_matches_start
|
||||||
|
ON player_matches(account_id, start_time DESC);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS player_peers (
|
||||||
|
account_id INTEGER NOT NULL REFERENCES users(account_id),
|
||||||
|
peer_account_id INTEGER NOT NULL,
|
||||||
|
personaname TEXT,
|
||||||
|
avatar TEXT,
|
||||||
|
games INTEGER NOT NULL DEFAULT 0,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
winrate REAL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, peer_account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sync_jobs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL, -- login_refresh | publish_match | backfill
|
||||||
|
match_id INTEGER,
|
||||||
|
status TEXT NOT NULL, -- queued | running | done | error
|
||||||
|
attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
lease_until TEXT,
|
||||||
|
error TEXT,
|
||||||
|
next_retry_at TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sync_jobs_status
|
||||||
|
ON sync_jobs(status, next_retry_at);
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
export function utcNow() {
|
||||||
|
return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertUser(db, user) {
|
||||||
|
const now = utcNow();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
|
||||||
|
VALUES (?, ?, ?, ?, COALESCE(?, 0), ?, ?)
|
||||||
|
ON CONFLICT(account_id) DO UPDATE SET
|
||||||
|
steamid=excluded.steamid,
|
||||||
|
personaname=COALESCE(excluded.personaname, users.personaname),
|
||||||
|
avatar=COALESCE(excluded.avatar, users.avatar),
|
||||||
|
public_share=COALESCE(excluded.public_share, users.public_share),
|
||||||
|
last_login_at=excluded.last_login_at`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
user.account_id,
|
||||||
|
String(user.steamid),
|
||||||
|
user.personaname || null,
|
||||||
|
user.avatar || null,
|
||||||
|
user.public_share == null ? null : user.public_share ? 1 : 0,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertProfile(db, accountId, profile) {
|
||||||
|
const now = utcNow();
|
||||||
|
const avail = profile.availability || {};
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO player_profiles (
|
||||||
|
account_id, rank_tier, leaderboard_rank, availability_status, availability_note,
|
||||||
|
availability_complete, source, fetched_at, enriched_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(account_id) DO UPDATE SET
|
||||||
|
rank_tier=COALESCE(excluded.rank_tier, player_profiles.rank_tier),
|
||||||
|
leaderboard_rank=COALESCE(excluded.leaderboard_rank, player_profiles.leaderboard_rank),
|
||||||
|
availability_status=excluded.availability_status,
|
||||||
|
availability_note=excluded.availability_note,
|
||||||
|
availability_complete=excluded.availability_complete,
|
||||||
|
source=excluded.source,
|
||||||
|
fetched_at=excluded.fetched_at,
|
||||||
|
enriched_at=excluded.enriched_at,
|
||||||
|
updated_at=excluded.updated_at`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
accountId,
|
||||||
|
profile.rank_tier ?? null,
|
||||||
|
profile.leaderboard_rank ?? null,
|
||||||
|
avail.status || null,
|
||||||
|
avail.note || null,
|
||||||
|
avail.complete ? 1 : 0,
|
||||||
|
avail.source || "opendota",
|
||||||
|
avail.fetched_at || now,
|
||||||
|
profile.enriched_at || now,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertStats(db, accountId, scope, stats) {
|
||||||
|
if (!stats) return;
|
||||||
|
const now = utcNow();
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO player_stats (
|
||||||
|
account_id, scope, sample, wins, losses, winrate, kills, deaths, assists, kda,
|
||||||
|
avg_kills, avg_deaths, avg_assists, avg_gpm, avg_xpm, avg_hero_damage, payload_json, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(account_id, scope) DO UPDATE SET
|
||||||
|
sample=excluded.sample, wins=excluded.wins, losses=excluded.losses, winrate=excluded.winrate,
|
||||||
|
kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda,
|
||||||
|
avg_kills=excluded.avg_kills, avg_deaths=excluded.avg_deaths, avg_assists=excluded.avg_assists,
|
||||||
|
avg_gpm=excluded.avg_gpm, avg_xpm=excluded.avg_xpm, avg_hero_damage=excluded.avg_hero_damage,
|
||||||
|
payload_json=excluded.payload_json, updated_at=excluded.updated_at`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
accountId,
|
||||||
|
scope,
|
||||||
|
stats.sample ?? stats.games ?? 0,
|
||||||
|
stats.wins ?? 0,
|
||||||
|
stats.losses ?? 0,
|
||||||
|
stats.winrate ?? null,
|
||||||
|
stats.kills ?? null,
|
||||||
|
stats.deaths ?? null,
|
||||||
|
stats.assists ?? null,
|
||||||
|
stats.kda ?? null,
|
||||||
|
stats.avg_kills ?? null,
|
||||||
|
stats.avg_deaths ?? null,
|
||||||
|
stats.avg_assists ?? null,
|
||||||
|
stats.avg_gpm ?? null,
|
||||||
|
stats.avg_xpm ?? null,
|
||||||
|
stats.avg_hero_damage ?? null,
|
||||||
|
JSON.stringify(stats),
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replaceHeroes(db, accountId, heroes) {
|
||||||
|
// Empty OpenDota heroes must not wipe a good cache.
|
||||||
|
if (!Array.isArray(heroes) || heroes.length === 0) return;
|
||||||
|
const now = utcNow();
|
||||||
|
await db.prepare(`DELETE FROM player_heroes WHERE account_id = ?`).bind(accountId).run();
|
||||||
|
for (const h of heroes) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO player_heroes (
|
||||||
|
account_id, hero_id, hero_key, hero_name_loc, games, wins, winrate, last_played, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
accountId,
|
||||||
|
h.hero_id,
|
||||||
|
h.hero_key || null,
|
||||||
|
h.hero_name_loc || null,
|
||||||
|
h.games || 0,
|
||||||
|
h.wins || 0,
|
||||||
|
h.winrate ?? null,
|
||||||
|
h.last_played ?? null,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function replacePeers(db, accountId, peers) {
|
||||||
|
// Empty OpenDota peers must not wipe a good cache.
|
||||||
|
if (!Array.isArray(peers) || peers.length === 0) return;
|
||||||
|
const now = utcNow();
|
||||||
|
await db.prepare(`DELETE FROM player_peers WHERE account_id = ?`).bind(accountId).run();
|
||||||
|
for (const p of peers) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO player_peers (
|
||||||
|
account_id, peer_account_id, personaname, avatar, games, wins, winrate, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
accountId,
|
||||||
|
p.account_id,
|
||||||
|
p.personaname || null,
|
||||||
|
p.avatar || null,
|
||||||
|
p.games || 0,
|
||||||
|
p.wins || 0,
|
||||||
|
p.winrate ?? null,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertMatches(db, accountId, recent) {
|
||||||
|
const now = utcNow();
|
||||||
|
for (const r of recent || []) {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO player_matches (
|
||||||
|
account_id, match_id, start_time, duration, won, hero_id, hero_key, hero_name_loc,
|
||||||
|
kills, deaths, assists, kda, gpm, xpm, hero_damage, game_mode, lobby_type, r2_key, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(account_id, match_id) DO UPDATE SET
|
||||||
|
start_time=excluded.start_time, duration=excluded.duration, won=excluded.won,
|
||||||
|
hero_id=excluded.hero_id, hero_key=excluded.hero_key, hero_name_loc=excluded.hero_name_loc,
|
||||||
|
kills=excluded.kills, deaths=excluded.deaths, assists=excluded.assists, kda=excluded.kda,
|
||||||
|
gpm=excluded.gpm, xpm=excluded.xpm, hero_damage=excluded.hero_damage,
|
||||||
|
game_mode=excluded.game_mode, lobby_type=excluded.lobby_type, updated_at=excluded.updated_at`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
accountId,
|
||||||
|
r.match_id,
|
||||||
|
r.start_time ?? null,
|
||||||
|
r.duration ?? null,
|
||||||
|
r.won ? 1 : 0,
|
||||||
|
r.hero_id ?? null,
|
||||||
|
r.hero_key || null,
|
||||||
|
r.hero_name_loc || null,
|
||||||
|
r.kills ?? null,
|
||||||
|
r.deaths ?? null,
|
||||||
|
r.assists ?? null,
|
||||||
|
r.kda ?? null,
|
||||||
|
r.gpm ?? null,
|
||||||
|
r.xpm ?? null,
|
||||||
|
r.hero_damage ?? null,
|
||||||
|
r.game_mode ?? null,
|
||||||
|
r.lobby_type ?? null,
|
||||||
|
r.r2_key || null,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPlayerBundle(db, accountId) {
|
||||||
|
const user = await db
|
||||||
|
.prepare(`SELECT * FROM users WHERE account_id = ?`)
|
||||||
|
.bind(accountId)
|
||||||
|
.first();
|
||||||
|
if (!user) return null;
|
||||||
|
const profile = await db
|
||||||
|
.prepare(`SELECT * FROM player_profiles WHERE account_id = ?`)
|
||||||
|
.bind(accountId)
|
||||||
|
.first();
|
||||||
|
const statsRows = await db
|
||||||
|
.prepare(`SELECT * FROM player_stats WHERE account_id = ?`)
|
||||||
|
.bind(accountId)
|
||||||
|
.all();
|
||||||
|
const heroes = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM player_heroes WHERE account_id = ? ORDER BY games DESC LIMIT 8`
|
||||||
|
)
|
||||||
|
.bind(accountId)
|
||||||
|
.all();
|
||||||
|
const peers = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM player_peers WHERE account_id = ? ORDER BY games DESC LIMIT 8`
|
||||||
|
)
|
||||||
|
.bind(accountId)
|
||||||
|
.all();
|
||||||
|
const recent = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT * FROM player_matches WHERE account_id = ? ORDER BY start_time DESC LIMIT 20`
|
||||||
|
)
|
||||||
|
.bind(accountId)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const statsByScope = {};
|
||||||
|
for (const row of (statsRows && statsRows.results) || []) {
|
||||||
|
try {
|
||||||
|
statsByScope[row.scope] = row.payload_json
|
||||||
|
? JSON.parse(row.payload_json)
|
||||||
|
: row;
|
||||||
|
} catch {
|
||||||
|
statsByScope[row.scope] = row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
account_id: accountId,
|
||||||
|
personaname: user.personaname,
|
||||||
|
avatar: user.avatar,
|
||||||
|
public_share: !!user.public_share,
|
||||||
|
rank_tier: profile && profile.rank_tier,
|
||||||
|
leaderboard_rank: profile && profile.leaderboard_rank,
|
||||||
|
availability: profile
|
||||||
|
? {
|
||||||
|
status: profile.availability_status,
|
||||||
|
note: profile.availability_note,
|
||||||
|
complete: !!profile.availability_complete,
|
||||||
|
source: profile.source,
|
||||||
|
fetched_at: profile.fetched_at,
|
||||||
|
stale: false,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
career: statsByScope.career || null,
|
||||||
|
recent_20: statsByScope.recent20 || null,
|
||||||
|
activity_180: statsByScope.recent180 || null,
|
||||||
|
top_heroes: ((heroes && heroes.results) || []).map((h) => ({
|
||||||
|
hero_id: h.hero_id,
|
||||||
|
hero_key: h.hero_key,
|
||||||
|
hero_name_loc: h.hero_name_loc,
|
||||||
|
games: h.games,
|
||||||
|
wins: h.wins,
|
||||||
|
winrate: h.winrate,
|
||||||
|
last_played: h.last_played,
|
||||||
|
})),
|
||||||
|
peers: ((peers && peers.results) || []).map((p) => ({
|
||||||
|
account_id: p.peer_account_id,
|
||||||
|
personaname: p.personaname,
|
||||||
|
avatar: p.avatar,
|
||||||
|
games: p.games,
|
||||||
|
wins: p.wins,
|
||||||
|
winrate: p.winrate,
|
||||||
|
})),
|
||||||
|
recent: ((recent && recent.results) || []).map((r) => ({
|
||||||
|
match_id: r.match_id,
|
||||||
|
start_time: r.start_time,
|
||||||
|
duration: r.duration,
|
||||||
|
won: !!r.won,
|
||||||
|
hero_id: r.hero_id,
|
||||||
|
hero_key: r.hero_key,
|
||||||
|
hero_name_loc: r.hero_name_loc,
|
||||||
|
kills: r.kills,
|
||||||
|
deaths: r.deaths,
|
||||||
|
assists: r.assists,
|
||||||
|
kda: r.kda,
|
||||||
|
gpm: r.gpm,
|
||||||
|
xpm: r.xpm,
|
||||||
|
hero_damage: r.hero_damage,
|
||||||
|
game_mode: r.game_mode,
|
||||||
|
lobby_type: r.lobby_type,
|
||||||
|
})),
|
||||||
|
updated_at: (profile && profile.updated_at) || user.last_login_at,
|
||||||
|
enriched_at: profile && profile.enriched_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* Queue consumer: refresh player stats from OpenDota into D1 (+ optional R2 match detail).
|
||||||
|
*
|
||||||
|
* Message shapes:
|
||||||
|
* { kind: "login_refresh"|"backfill", account_id, steamid?, personaname?, avatar? }
|
||||||
|
* { kind: "publish_match", account_id, match_id }
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
loadPlayerBundle,
|
||||||
|
replaceHeroes,
|
||||||
|
replacePeers,
|
||||||
|
upsertMatches,
|
||||||
|
upsertProfile,
|
||||||
|
upsertStats,
|
||||||
|
upsertUser,
|
||||||
|
utcNow,
|
||||||
|
} from "./db.js";
|
||||||
|
import {
|
||||||
|
loadHeroMap,
|
||||||
|
normalizeMatch,
|
||||||
|
odFetch,
|
||||||
|
steamMatchHistoryStatus,
|
||||||
|
summaryFromRecentRow,
|
||||||
|
} from "./opendota.js";
|
||||||
|
import {
|
||||||
|
aggregateFromRows,
|
||||||
|
careerFromOpenDota,
|
||||||
|
mergeAvailability,
|
||||||
|
winrate,
|
||||||
|
} from "./stats.js";
|
||||||
|
|
||||||
|
function winrateGames(wins, games) {
|
||||||
|
return winrate(wins, Math.max(0, games - wins));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAccount(env, msg) {
|
||||||
|
const accountId = Number(msg.account_id);
|
||||||
|
if (!Number.isFinite(accountId) || accountId <= 0) {
|
||||||
|
throw new Error("bad account_id");
|
||||||
|
}
|
||||||
|
const steamid =
|
||||||
|
msg.steamid || String(BigInt(accountId) + 76561197960265728n);
|
||||||
|
await upsertUser(env.DB, {
|
||||||
|
account_id: accountId,
|
||||||
|
steamid,
|
||||||
|
personaname: msg.personaname || null,
|
||||||
|
avatar: msg.avatar || null,
|
||||||
|
public_share: msg.public_share,
|
||||||
|
});
|
||||||
|
|
||||||
|
const heroMap = await loadHeroMap(env);
|
||||||
|
// Serial OpenDota calls — CF edge IPs hit 429 hard under Promise.all.
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
const player = await odFetch(`/players/${accountId}`, env);
|
||||||
|
await sleep(200);
|
||||||
|
const wl = await odFetch(`/players/${accountId}/wl`, env);
|
||||||
|
await sleep(200);
|
||||||
|
const totals = await odFetch(`/players/${accountId}/totals`, env);
|
||||||
|
await sleep(200);
|
||||||
|
const heroes = await odFetch(`/players/${accountId}/heroes`, env);
|
||||||
|
await sleep(200);
|
||||||
|
const peers = await odFetch(`/players/${accountId}/peers`, env);
|
||||||
|
await sleep(200);
|
||||||
|
const recentRaw = await odFetch(`/players/${accountId}/recentMatches`, env);
|
||||||
|
await sleep(200);
|
||||||
|
const matches180 = await odFetch(`/players/${accountId}/matches`, env, {
|
||||||
|
date: 180,
|
||||||
|
significant: 0,
|
||||||
|
});
|
||||||
|
const steamStatus = await steamMatchHistoryStatus(accountId, env);
|
||||||
|
|
||||||
|
const profileBlock = player && player.profile ? player.profile : {};
|
||||||
|
const personaname = profileBlock.personaname || msg.personaname || null;
|
||||||
|
const avatar =
|
||||||
|
profileBlock.avatarfull ||
|
||||||
|
profileBlock.avatarmedium ||
|
||||||
|
profileBlock.avatar ||
|
||||||
|
msg.avatar ||
|
||||||
|
null;
|
||||||
|
await upsertUser(env.DB, {
|
||||||
|
account_id: accountId,
|
||||||
|
steamid,
|
||||||
|
personaname,
|
||||||
|
avatar,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recent = [];
|
||||||
|
if (Array.isArray(recentRaw)) {
|
||||||
|
for (const row of recentRaw) {
|
||||||
|
const summary = summaryFromRecentRow(row, heroMap);
|
||||||
|
if (summary) recent.push(summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recent.sort((a, b) => (b.start_time || 0) - (a.start_time || 0));
|
||||||
|
const recent20 = recent.slice(0, 20);
|
||||||
|
|
||||||
|
let career = careerFromOpenDota(wl, totals);
|
||||||
|
// Do not wipe previous career on empty OpenDota response.
|
||||||
|
if (!career) {
|
||||||
|
const existing = await loadPlayerBundle(env.DB, accountId);
|
||||||
|
if (existing && existing.career && existing.career.games > 0) {
|
||||||
|
career = existing.career;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const topHeroes = [];
|
||||||
|
if (Array.isArray(heroes)) {
|
||||||
|
const scored = heroes
|
||||||
|
.filter((h) => h && Number(h.games) > 0)
|
||||||
|
.sort((a, b) => Number(b.games) - Number(a.games))
|
||||||
|
.slice(0, 5);
|
||||||
|
for (const h of scored) {
|
||||||
|
const hid = Number(h.hero_id) || 0;
|
||||||
|
const meta = heroMap.get(hid) || {};
|
||||||
|
const games = Number(h.games) || 0;
|
||||||
|
const wins = Number(h.win) || 0;
|
||||||
|
topHeroes.push({
|
||||||
|
hero_id: hid || null,
|
||||||
|
hero_key: meta.key || null,
|
||||||
|
hero_name_loc: meta.name_loc || meta.key || null,
|
||||||
|
games,
|
||||||
|
wins,
|
||||||
|
winrate: winrateGames(wins, games),
|
||||||
|
last_played: h.last_played != null ? Number(h.last_played) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerRows = [];
|
||||||
|
if (Array.isArray(peers)) {
|
||||||
|
for (const p of peers.slice(0, 8)) {
|
||||||
|
if (!p || !p.account_id) continue;
|
||||||
|
const games = Number(p.games) || 0;
|
||||||
|
if (games <= 0) continue;
|
||||||
|
const wins = Number(p.win) || 0;
|
||||||
|
peerRows.push({
|
||||||
|
account_id: Number(p.account_id),
|
||||||
|
personaname: p.personaname || `玩家 ${p.account_id}`,
|
||||||
|
avatar: p.avatarfull || p.avatar || null,
|
||||||
|
games,
|
||||||
|
wins,
|
||||||
|
winrate: winrateGames(wins, games),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let activity180 = null;
|
||||||
|
if (Array.isArray(matches180) && matches180.length) {
|
||||||
|
const byDay = new Map();
|
||||||
|
let wins = 0;
|
||||||
|
let losses = 0;
|
||||||
|
let maxKills = null;
|
||||||
|
let maxAssists = null;
|
||||||
|
let maxGpm = null;
|
||||||
|
for (const row of matches180) {
|
||||||
|
if (!row || row.start_time == null) continue;
|
||||||
|
const st = Number(row.start_time);
|
||||||
|
const day = new Date(st * 1000).toISOString().slice(0, 10);
|
||||||
|
const cell = byDay.get(day) || { games: 0, wins: 0 };
|
||||||
|
cell.games += 1;
|
||||||
|
const slot = Number(row.player_slot) || 0;
|
||||||
|
const won = slot < 128 ? !!row.radiant_win : !row.radiant_win;
|
||||||
|
if (won) {
|
||||||
|
cell.wins += 1;
|
||||||
|
wins += 1;
|
||||||
|
} else losses += 1;
|
||||||
|
byDay.set(day, cell);
|
||||||
|
const kills = Number(row.kills) || 0;
|
||||||
|
const assists = Number(row.assists) || 0;
|
||||||
|
const gpm = Number(row.gold_per_min) || 0;
|
||||||
|
const heroId = Number(row.hero_id) || null;
|
||||||
|
const mid = Number(row.match_id) || 0;
|
||||||
|
if (!maxKills || kills > maxKills.value) {
|
||||||
|
maxKills = { value: kills, hero_id: heroId, match_id: mid };
|
||||||
|
}
|
||||||
|
if (!maxAssists || assists > maxAssists.value) {
|
||||||
|
maxAssists = { value: assists, hero_id: heroId, match_id: mid };
|
||||||
|
}
|
||||||
|
if (gpm > 0 && (!maxGpm || gpm > maxGpm.value)) {
|
||||||
|
maxGpm = { value: gpm, hero_id: heroId, match_id: mid };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activity180 = {
|
||||||
|
days: 180,
|
||||||
|
sample: wins + losses,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
winrate: winrate(wins, losses),
|
||||||
|
heatmap: [...byDay.entries()]
|
||||||
|
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||||
|
.map(([date, v]) => ({ date, games: v.games, wins: v.wins })),
|
||||||
|
highs: { kills: maxKills, assists: maxAssists, gpm: maxGpm },
|
||||||
|
label: "最近 180 天样本",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await loadPlayerBundle(env.DB, accountId);
|
||||||
|
const existingRecentN = Array.isArray(existing && existing.recent)
|
||||||
|
? existing.recent.length
|
||||||
|
: 0;
|
||||||
|
const existingR20 =
|
||||||
|
existing && existing.recent_20 ? Number(existing.recent_20.sample) || 0 : 0;
|
||||||
|
const odRecentOk = Array.isArray(recentRaw) && recentRaw.length > 0;
|
||||||
|
|
||||||
|
const fetched = utcNow();
|
||||||
|
const availability = mergeAvailability({
|
||||||
|
opendotaRecentN: odRecentOk ? recentRaw.length : 0,
|
||||||
|
career,
|
||||||
|
steamHistoryStatus: steamStatus,
|
||||||
|
fetchedAt: fetched,
|
||||||
|
});
|
||||||
|
if (profileBlock.fh_unavailable && availability.status === "unknown") {
|
||||||
|
availability.status = "private";
|
||||||
|
availability.note = "未公开比赛数据";
|
||||||
|
}
|
||||||
|
// Cold account + Steam public + empty OpenDota → rate-limit; retry queue.
|
||||||
|
if (
|
||||||
|
!odRecentOk &&
|
||||||
|
!career &&
|
||||||
|
existingRecentN === 0 &&
|
||||||
|
!(existing && existing.career && existing.career.games > 0) &&
|
||||||
|
steamStatus === 1
|
||||||
|
) {
|
||||||
|
throw new Error("OpenDota empty while Steam public — retry");
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = {
|
||||||
|
rank_tier: player && player.rank_tier != null ? Number(player.rank_tier) : null,
|
||||||
|
leaderboard_rank:
|
||||||
|
player && player.leaderboard_rank != null
|
||||||
|
? Number(player.leaderboard_rank)
|
||||||
|
: null,
|
||||||
|
availability,
|
||||||
|
enriched_at: fetched,
|
||||||
|
};
|
||||||
|
// Partial OpenDota (career ok, recent empty): keep prior freshness so /me
|
||||||
|
// stays soft-stale and do not pretend the row is fully refreshed.
|
||||||
|
if (!odRecentOk && (existingR20 > 0 || existingRecentN > 0)) {
|
||||||
|
profile.enriched_at =
|
||||||
|
(existing && existing.enriched_at) || profile.enriched_at;
|
||||||
|
if (existing && existing.availability && existing.availability.fetched_at) {
|
||||||
|
availability.fetched_at = existing.availability.fetched_at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertProfile(env.DB, accountId, profile);
|
||||||
|
if (career) await upsertStats(env.DB, accountId, "career", career);
|
||||||
|
const recentAgg = aggregateFromRows(recent20, 20);
|
||||||
|
if ((recentAgg.sample || 0) > 0) {
|
||||||
|
await upsertStats(env.DB, accountId, "recent20", recentAgg);
|
||||||
|
await upsertMatches(env.DB, accountId, recent20);
|
||||||
|
} else if (existingR20 <= 0 && existingRecentN <= 0) {
|
||||||
|
await upsertStats(env.DB, accountId, "recent20", recentAgg);
|
||||||
|
}
|
||||||
|
if (activity180) await upsertStats(env.DB, accountId, "recent180", activity180);
|
||||||
|
await replaceHeroes(env.DB, accountId, topHeroes);
|
||||||
|
await replacePeers(env.DB, accountId, peerRows);
|
||||||
|
|
||||||
|
// Optional: store published/ensured match detail (normalized) into R2.
|
||||||
|
if (
|
||||||
|
(msg.kind === "publish_match" || msg.kind === "ensure_match") &&
|
||||||
|
msg.match_id &&
|
||||||
|
env.MATCHES
|
||||||
|
) {
|
||||||
|
const matchId = Number(msg.match_id);
|
||||||
|
const match = await odFetch(`/matches/${matchId}`, env);
|
||||||
|
const detail =
|
||||||
|
match && Array.isArray(match.players)
|
||||||
|
? normalizeMatch(match, heroMap, accountId)
|
||||||
|
: null;
|
||||||
|
if (detail) {
|
||||||
|
const key = `matches/${matchId}.json`;
|
||||||
|
await env.MATCHES.put(key, JSON.stringify(detail), {
|
||||||
|
httpMetadata: { contentType: "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
await env.DB.prepare(
|
||||||
|
`UPDATE player_matches SET r2_key = ?, updated_at = ? WHERE account_id = ? AND match_id = ?`
|
||||||
|
)
|
||||||
|
.bind(key, utcNow(), accountId, matchId)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadPlayerBundle(env.DB, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markJob(env, jobId, patch) {
|
||||||
|
if (!jobId) return;
|
||||||
|
const now = utcNow();
|
||||||
|
await env.DB.prepare(
|
||||||
|
`UPDATE sync_jobs SET status = ?, attempts = COALESCE(attempts, 0) + ?,
|
||||||
|
error = ?, lease_until = ?, updated_at = ?
|
||||||
|
WHERE id = ?`
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
patch.status,
|
||||||
|
patch.bumpAttempts ? 1 : 0,
|
||||||
|
patch.error || null,
|
||||||
|
patch.lease_until || null,
|
||||||
|
now,
|
||||||
|
jobId
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async queue(batch, env) {
|
||||||
|
for (const message of batch.messages) {
|
||||||
|
let body = message.body;
|
||||||
|
if (typeof body === "string") {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(body);
|
||||||
|
} catch {
|
||||||
|
message.ack();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const jobId = body && body.job_id;
|
||||||
|
try {
|
||||||
|
if (jobId) {
|
||||||
|
await markJob(env, jobId, {
|
||||||
|
status: "running",
|
||||||
|
lease_until: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await syncAccount(env, body || {});
|
||||||
|
if (jobId) await markJob(env, jobId, { status: "done" });
|
||||||
|
message.ack();
|
||||||
|
} catch (e) {
|
||||||
|
const err = String((e && e.message) || e);
|
||||||
|
if (jobId) {
|
||||||
|
await markJob(env, jobId, {
|
||||||
|
status: "error",
|
||||||
|
bumpAttempts: true,
|
||||||
|
error: err.slice(0, 500),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
message.retry();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Manual HTTP trigger for smoke tests (requires SYNC_HTTP_TOKEN secret).
|
||||||
|
async fetch(request, env) {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return new Response("climperor-player-sync", { status: 200 });
|
||||||
|
}
|
||||||
|
const token = (env.SYNC_HTTP_TOKEN || "").trim();
|
||||||
|
const auth = (request.headers.get("Authorization") || "").trim();
|
||||||
|
if (!token || auth !== `Bearer ${token}`) {
|
||||||
|
return new Response("unauthorized", { status: 401 });
|
||||||
|
}
|
||||||
|
const body = await request.json().catch(() => ({}));
|
||||||
|
const out = await syncAccount(env, body);
|
||||||
|
return new Response(JSON.stringify(out), {
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
const OPENDOTA = "https://api.opendota.com/api";
|
||||||
|
const STEAM_API = "https://api.steampowered.com";
|
||||||
|
|
||||||
|
export async function odFetch(path, env, query = {}) {
|
||||||
|
const url = new URL(`${OPENDOTA}${path}`);
|
||||||
|
for (const [k, v] of Object.entries(query)) {
|
||||||
|
if (v != null) url.searchParams.set(k, String(v));
|
||||||
|
}
|
||||||
|
const key = (env.OPENDOTA_API_KEY || "").trim();
|
||||||
|
if (key) url.searchParams.set("api_key", key);
|
||||||
|
const headers = {
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "climperor-player-sync",
|
||||||
|
};
|
||||||
|
// CF edge IPs are often rate-limited; retry 429/5xx before giving up.
|
||||||
|
let lastStatus = 0;
|
||||||
|
for (let attempt = 0; attempt < 4; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
await new Promise((r) => setTimeout(r, 400 * 2 ** (attempt - 1)));
|
||||||
|
}
|
||||||
|
const res = await fetch(url.toString(), { headers });
|
||||||
|
lastStatus = res.status;
|
||||||
|
if (res.status === 403 || res.status === 404) return null;
|
||||||
|
if (res.status === 429 || res.status >= 500) continue;
|
||||||
|
if (!res.ok) throw new Error(`OpenDota ${res.status} ${path}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
if (lastStatus === 429 || lastStatus >= 500) return null;
|
||||||
|
throw new Error(`OpenDota ${lastStatus} ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function steamMatchHistoryStatus(accountId, env) {
|
||||||
|
const key = (env.STEAM_API_KEY || "").trim();
|
||||||
|
if (!key) return null;
|
||||||
|
const url = new URL(`${STEAM_API}/IDOTA2Match_570/GetMatchHistory/v1/`);
|
||||||
|
url.searchParams.set("key", key);
|
||||||
|
url.searchParams.set("account_id", String(accountId));
|
||||||
|
url.searchParams.set("matches_requested", "1");
|
||||||
|
try {
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
headers: { "User-Agent": "climperor-player-sync" },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
const status = data && data.result && data.result.status;
|
||||||
|
return status == null ? null : Number(status);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summaryFromRecentRow(row, heroMap) {
|
||||||
|
const mid = Number(row.match_id) || 0;
|
||||||
|
if (mid <= 0) return null;
|
||||||
|
const heroId = Number(row.hero_id) || 0;
|
||||||
|
const hero = heroMap.get(heroId) || {};
|
||||||
|
const kills = Number(row.kills) || 0;
|
||||||
|
const deaths = Number(row.deaths) || 0;
|
||||||
|
const assists = Number(row.assists) || 0;
|
||||||
|
const playerSlot = Number(row.player_slot) || 0;
|
||||||
|
const radiantWin = !!row.radiant_win;
|
||||||
|
const isRadiant = playerSlot < 128;
|
||||||
|
return {
|
||||||
|
match_id: mid,
|
||||||
|
start_time: row.start_time != null ? Number(row.start_time) : null,
|
||||||
|
duration: Number(row.duration) || 0,
|
||||||
|
won: isRadiant ? radiantWin : !radiantWin,
|
||||||
|
hero_id: heroId || null,
|
||||||
|
hero_key: hero.key || null,
|
||||||
|
hero_name_loc: hero.name_loc || hero.key || null,
|
||||||
|
kills,
|
||||||
|
deaths,
|
||||||
|
assists,
|
||||||
|
kda: Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10,
|
||||||
|
gpm: row.gold_per_min != null ? Number(row.gold_per_min) || 0 : null,
|
||||||
|
xpm: row.xp_per_min != null ? Number(row.xp_per_min) || 0 : null,
|
||||||
|
hero_damage: row.hero_damage != null ? Number(row.hero_damage) || 0 : null,
|
||||||
|
game_mode: row.game_mode != null ? Number(row.game_mode) : null,
|
||||||
|
lobby_type: row.lobby_type != null ? Number(row.lobby_type) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadHeroMap(env) {
|
||||||
|
const rows = await odFetch("/heroes", env);
|
||||||
|
const map = new Map();
|
||||||
|
if (!Array.isArray(rows)) return map;
|
||||||
|
for (const h of rows) {
|
||||||
|
if (!h || h.id == null) continue;
|
||||||
|
const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null;
|
||||||
|
map.set(Number(h.id), { key, name_loc: h.localized_name || key });
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asInt(v, fallback = 0) {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? Math.trunc(n) : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemIds(player) {
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
const iid = asInt(player[`item_${i}`], 0);
|
||||||
|
if (iid > 0) out.push(iid);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function kda(kills, deaths, assists) {
|
||||||
|
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mvpScore(p) {
|
||||||
|
const k = asInt(p.kills);
|
||||||
|
const d = asInt(p.deaths);
|
||||||
|
const a = asInt(p.assists);
|
||||||
|
const dmg = asInt(p.hero_damage);
|
||||||
|
const nw = asInt(p.net_worth);
|
||||||
|
return (k * 1.5 + a + dmg / 1000.0 + nw / 2000.0) / Math.max(d, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Climperor match-detail JSON (same shape as pc/player_pages.normalize_match). */
|
||||||
|
export function normalizeMatch(match, heroMap, focusAccountId = null) {
|
||||||
|
const playersRaw = match && match.players;
|
||||||
|
if (!Array.isArray(playersRaw) || !playersRaw.length) return null;
|
||||||
|
const matchId = asInt(match.match_id, 0);
|
||||||
|
if (matchId <= 0) return null;
|
||||||
|
|
||||||
|
const radiantWin = !!match.radiant_win;
|
||||||
|
const duration = asInt(match.duration);
|
||||||
|
let startTime = null;
|
||||||
|
if (match.start_time != null) {
|
||||||
|
const t = asInt(match.start_time, NaN);
|
||||||
|
startTime = Number.isFinite(t) ? t : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamKills = [0, 0];
|
||||||
|
const teamNw = [0, 0];
|
||||||
|
const teamDmg = [0, 0];
|
||||||
|
const slim = [];
|
||||||
|
|
||||||
|
for (const p of playersRaw) {
|
||||||
|
if (!p || typeof p !== "object") continue;
|
||||||
|
const slot = asInt(p.player_slot);
|
||||||
|
const isRadiant = slot < 128;
|
||||||
|
const side = isRadiant ? 0 : 1;
|
||||||
|
const kills = asInt(p.kills);
|
||||||
|
const deaths = asInt(p.deaths);
|
||||||
|
const assists = asInt(p.assists);
|
||||||
|
const heroDamage = asInt(p.hero_damage);
|
||||||
|
let netWorth = asInt(p.net_worth);
|
||||||
|
if (netWorth <= 0) netWorth = asInt(p.gold) + asInt(p.gold_spent);
|
||||||
|
teamKills[side] += kills;
|
||||||
|
teamNw[side] += netWorth;
|
||||||
|
teamDmg[side] += heroDamage;
|
||||||
|
|
||||||
|
const heroId = asInt(p.hero_id);
|
||||||
|
const hero = (heroMap && heroMap.get(heroId)) || {};
|
||||||
|
let accountId = null;
|
||||||
|
if (p.account_id != null) {
|
||||||
|
const a = asInt(p.account_id, NaN);
|
||||||
|
accountId = Number.isFinite(a) ? a : null;
|
||||||
|
}
|
||||||
|
let personaname = p.personaname;
|
||||||
|
if (typeof personaname === "string") {
|
||||||
|
personaname = personaname.trim() || null;
|
||||||
|
} else {
|
||||||
|
personaname = null;
|
||||||
|
}
|
||||||
|
let partyId = null;
|
||||||
|
if (p.party_id != null) {
|
||||||
|
const pid = asInt(p.party_id, NaN);
|
||||||
|
if (Number.isFinite(pid) && pid > 0) partyId = pid;
|
||||||
|
}
|
||||||
|
|
||||||
|
slim.push({
|
||||||
|
account_id: accountId,
|
||||||
|
personaname,
|
||||||
|
hero_id: heroId,
|
||||||
|
hero_key: hero.key || null,
|
||||||
|
hero_name_loc: hero.name_loc || hero.key || null,
|
||||||
|
level: asInt(p.level),
|
||||||
|
kills,
|
||||||
|
deaths,
|
||||||
|
assists,
|
||||||
|
kda: kda(kills, deaths, assists),
|
||||||
|
hero_damage: heroDamage,
|
||||||
|
net_worth: netWorth,
|
||||||
|
party_id: partyId,
|
||||||
|
party_label: null,
|
||||||
|
items: itemIds(p),
|
||||||
|
is_radiant: isRadiant,
|
||||||
|
won: isRadiant ? radiantWin : !radiantWin,
|
||||||
|
_mvp: mvpScore(p),
|
||||||
|
_side: side,
|
||||||
|
_slot: slot,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slim.length < 2) return null;
|
||||||
|
|
||||||
|
const partyCounts = new Map();
|
||||||
|
for (const p of slim) {
|
||||||
|
if (typeof p.party_id === "number" && p.party_id > 0) {
|
||||||
|
partyCounts.set(p.party_id, (partyCounts.get(p.party_id) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const partyLabels = new Map();
|
||||||
|
for (const [pid, n] of [...partyCounts.entries()].sort((a, b) => a[0] - b[0])) {
|
||||||
|
if (n >= 2) partyLabels.set(pid, String.fromCharCode(65 + partyLabels.size));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of slim) {
|
||||||
|
const side = p._side;
|
||||||
|
const tk = teamKills[side] || 1;
|
||||||
|
const td = teamDmg[side] || 1;
|
||||||
|
p.participation = Math.round(((p.kills + p.assists) / tk) * 1000) / 1000;
|
||||||
|
p.damage_share = Math.round((p.hero_damage / td) * 1000) / 1000;
|
||||||
|
p.party_label =
|
||||||
|
typeof p.party_id === "number" ? partyLabels.get(p.party_id) || null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mvp = slim.reduce((best, p) => (p._mvp > best._mvp ? p : best), slim[0]);
|
||||||
|
const mvpAccount = mvp.account_id;
|
||||||
|
const mvpSlot = mvp._slot;
|
||||||
|
for (const p of slim) {
|
||||||
|
p.is_mvp =
|
||||||
|
mvpAccount != null ? p.account_id === mvpAccount : p._slot === mvpSlot;
|
||||||
|
delete p._mvp;
|
||||||
|
delete p._side;
|
||||||
|
delete p._slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
match_id: matchId,
|
||||||
|
start_time: startTime,
|
||||||
|
duration,
|
||||||
|
radiant_win: radiantWin,
|
||||||
|
radiant: { kills: teamKills[0], net_worth: teamNw[0] },
|
||||||
|
dire: { kills: teamKills[1], net_worth: teamNw[1] },
|
||||||
|
mvp_account_id: mvpAccount,
|
||||||
|
players: slim,
|
||||||
|
focus_account_id: focusAccountId,
|
||||||
|
fetched_at: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
||||||
|
source: "opendota",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/** Shared aggregate helpers for the player-sync Worker (mirrors pc/player_stats.py). */
|
||||||
|
|
||||||
|
export function kda(kills, deaths, assists) {
|
||||||
|
return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function winrate(wins, losses) {
|
||||||
|
const total = wins + losses;
|
||||||
|
if (total <= 0) return null;
|
||||||
|
return Math.round((wins / total) * 1000) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateFromRows(rows, limit = 20) {
|
||||||
|
const sample = (Array.isArray(rows) ? rows : []).slice(0, limit);
|
||||||
|
let wins = 0;
|
||||||
|
let losses = 0;
|
||||||
|
let kills = 0;
|
||||||
|
let deaths = 0;
|
||||||
|
let assists = 0;
|
||||||
|
let gpmSum = 0;
|
||||||
|
let xpmSum = 0;
|
||||||
|
let dmgSum = 0;
|
||||||
|
let gpmN = 0;
|
||||||
|
let xpmN = 0;
|
||||||
|
let dmgN = 0;
|
||||||
|
const heroes = [];
|
||||||
|
for (const r of sample) {
|
||||||
|
if (!r || typeof r !== "object") continue;
|
||||||
|
if (r.won) wins += 1;
|
||||||
|
else losses += 1;
|
||||||
|
const k = Number(r.kills) || 0;
|
||||||
|
const d = Number(r.deaths) || 0;
|
||||||
|
const a = Number(r.assists) || 0;
|
||||||
|
kills += k;
|
||||||
|
deaths += d;
|
||||||
|
assists += a;
|
||||||
|
if (r.gpm != null) {
|
||||||
|
gpmSum += Number(r.gpm) || 0;
|
||||||
|
gpmN += 1;
|
||||||
|
}
|
||||||
|
if (r.xpm != null) {
|
||||||
|
xpmSum += Number(r.xpm) || 0;
|
||||||
|
xpmN += 1;
|
||||||
|
}
|
||||||
|
if (r.hero_damage != null) {
|
||||||
|
dmgSum += Number(r.hero_damage) || 0;
|
||||||
|
dmgN += 1;
|
||||||
|
}
|
||||||
|
heroes.push({
|
||||||
|
match_id: Number(r.match_id) || 0,
|
||||||
|
hero_id: r.hero_id ?? null,
|
||||||
|
hero_key: r.hero_key || null,
|
||||||
|
hero_name_loc: r.hero_name_loc || null,
|
||||||
|
won: !!r.won,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const n = wins + losses;
|
||||||
|
return {
|
||||||
|
sample: n,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
winrate: winrate(wins, losses),
|
||||||
|
kills,
|
||||||
|
deaths,
|
||||||
|
assists,
|
||||||
|
kda: n ? kda(kills, deaths, assists) : null,
|
||||||
|
avg_kills: n ? Math.round((kills / n) * 10) / 10 : null,
|
||||||
|
avg_deaths: n ? Math.round((deaths / n) * 10) / 10 : null,
|
||||||
|
avg_assists: n ? Math.round((assists / n) * 10) / 10 : null,
|
||||||
|
avg_gpm: gpmN ? Math.round(gpmSum / gpmN) : null,
|
||||||
|
avg_xpm: xpmN ? Math.round(xpmSum / xpmN) : null,
|
||||||
|
avg_hero_damage: dmgN ? Math.round(dmgSum / dmgN) : null,
|
||||||
|
heroes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function careerFromOpenDota(wl, totals) {
|
||||||
|
const wins = Number(wl && wl.win) || 0;
|
||||||
|
const losses = Number(wl && wl.lose) || 0;
|
||||||
|
if (wins <= 0 && losses <= 0) return null;
|
||||||
|
const byField = new Map();
|
||||||
|
if (Array.isArray(totals)) {
|
||||||
|
for (const row of totals) {
|
||||||
|
if (row && row.field) byField.set(String(row.field), row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sumOf = (f) => Number((byField.get(f) || {}).sum) || 0;
|
||||||
|
const nOf = (f) => Number((byField.get(f) || {}).n) || 0;
|
||||||
|
const n = wins + losses;
|
||||||
|
const kills = sumOf("kills");
|
||||||
|
const deaths = sumOf("deaths");
|
||||||
|
const assists = sumOf("assists");
|
||||||
|
const gpmN = nOf("gold_per_min");
|
||||||
|
const xpmN = nOf("xp_per_min");
|
||||||
|
const dmgN = nOf("hero_damage");
|
||||||
|
return {
|
||||||
|
games: n,
|
||||||
|
wins,
|
||||||
|
losses,
|
||||||
|
winrate: winrate(wins, losses),
|
||||||
|
kills,
|
||||||
|
deaths,
|
||||||
|
assists,
|
||||||
|
kda: n ? kda(kills, deaths, assists) : null,
|
||||||
|
avg_kills: n ? Math.round((kills / n) * 10) / 10 : null,
|
||||||
|
avg_deaths: n ? Math.round((deaths / n) * 10) / 10 : null,
|
||||||
|
avg_assists: n ? Math.round((assists / n) * 10) / 10 : null,
|
||||||
|
avg_gpm: gpmN ? Math.round(sumOf("gold_per_min") / gpmN) : null,
|
||||||
|
avg_xpm: xpmN ? Math.round(sumOf("xp_per_min") / xpmN) : null,
|
||||||
|
avg_hero_damage: dmgN ? Math.round(sumOf("hero_damage") / dmgN) : null,
|
||||||
|
source: "opendota",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeAvailability({ opendotaRecentN, career, steamHistoryStatus, fetchedAt }) {
|
||||||
|
const odPublic = opendotaRecentN > 0 || !!(career && career.games);
|
||||||
|
const steamAllowed = steamHistoryStatus === 1;
|
||||||
|
const steamDenied = steamHistoryStatus === 15;
|
||||||
|
let status = "unknown";
|
||||||
|
let complete = false;
|
||||||
|
let note = "暂无公开战绩";
|
||||||
|
if (odPublic) {
|
||||||
|
status = "public";
|
||||||
|
complete = true;
|
||||||
|
note = null;
|
||||||
|
} else if (steamAllowed) {
|
||||||
|
status = "syncing";
|
||||||
|
note = "Steam 已公开,OpenDota 同步中";
|
||||||
|
} else if (steamDenied) {
|
||||||
|
status = "private";
|
||||||
|
note = "未公开比赛数据";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
complete,
|
||||||
|
opendota_public: odPublic,
|
||||||
|
steam_history_status: steamHistoryStatus ?? null,
|
||||||
|
source: "opendota+steam",
|
||||||
|
fetched_at: fetchedAt,
|
||||||
|
note,
|
||||||
|
stale: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/** Node smoke tests for Worker stats helpers (no wrangler). */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
aggregateFromRows,
|
||||||
|
careerFromOpenDota,
|
||||||
|
kda,
|
||||||
|
mergeAvailability,
|
||||||
|
winrate,
|
||||||
|
} from "./src/stats.js";
|
||||||
|
|
||||||
|
assert.equal(kda(10, 0, 5), 15);
|
||||||
|
assert.equal(winrate(0, 0), null);
|
||||||
|
assert.equal(winrate(1, 1), 50);
|
||||||
|
|
||||||
|
const emptyCareer = careerFromOpenDota({ win: 0, lose: 0 }, []);
|
||||||
|
assert.equal(emptyCareer, null);
|
||||||
|
|
||||||
|
const career = careerFromOpenDota(
|
||||||
|
{ win: 2, lose: 1 },
|
||||||
|
[
|
||||||
|
{ field: "kills", sum: 30, n: 3 },
|
||||||
|
{ field: "deaths", sum: 6, n: 3 },
|
||||||
|
{ field: "assists", sum: 15, n: 3 },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert.equal(career.games, 3);
|
||||||
|
assert.equal(career.winrate, 66.7);
|
||||||
|
assert.ok(career.kda > 0);
|
||||||
|
|
||||||
|
const recent = aggregateFromRows(
|
||||||
|
[
|
||||||
|
{ match_id: 1, won: true, kills: 5, deaths: 1, assists: 3, gpm: 500 },
|
||||||
|
{ match_id: 1, won: true, kills: 5, deaths: 1, assists: 3, gpm: 500 }, // duplicate row ok in unit
|
||||||
|
{ match_id: 2, won: false, kills: 0, deaths: 0, assists: 2, gpm: 400 },
|
||||||
|
],
|
||||||
|
20
|
||||||
|
);
|
||||||
|
assert.equal(recent.sample, 3);
|
||||||
|
assert.equal(recent.wins, 2);
|
||||||
|
|
||||||
|
const syncing = mergeAvailability({
|
||||||
|
opendotaRecentN: 0,
|
||||||
|
career: null,
|
||||||
|
steamHistoryStatus: 1,
|
||||||
|
fetchedAt: "2026-07-31T00:00:00Z",
|
||||||
|
});
|
||||||
|
assert.equal(syncing.status, "syncing");
|
||||||
|
assert.match(syncing.note, /OpenDota/);
|
||||||
|
|
||||||
|
const priv = mergeAvailability({
|
||||||
|
opendotaRecentN: 0,
|
||||||
|
career: null,
|
||||||
|
steamHistoryStatus: 15,
|
||||||
|
fetchedAt: "2026-07-31T00:00:00Z",
|
||||||
|
});
|
||||||
|
assert.equal(priv.status, "private");
|
||||||
|
|
||||||
|
console.log("stats.js ok");
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name = "climperor-player-sync"
|
||||||
|
main = "src/index.js"
|
||||||
|
compatibility_date = "2024-11-01"
|
||||||
|
workers_dev = false
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
database_name = "climperor-users"
|
||||||
|
database_id = "9eeb24ba-acc5-4520-b4e7-754ea776394e"
|
||||||
|
|
||||||
|
[[r2_buckets]]
|
||||||
|
binding = "MATCHES"
|
||||||
|
bucket_name = "climperor-player-data"
|
||||||
|
|
||||||
|
[[queues.consumers]]
|
||||||
|
queue = "climperor-player-sync"
|
||||||
|
max_batch_size = 5
|
||||||
|
max_retries = 5
|
||||||
|
dead_letter_queue = "climperor-player-sync-dlq"
|
||||||
|
|
||||||
|
[[queues.producers]]
|
||||||
|
binding = "SYNC_QUEUE"
|
||||||
|
queue = "climperor-player-sync"
|
||||||
|
|
||||||
|
# Secrets (wrangler secret put):
|
||||||
|
# STEAM_API_KEY
|
||||||
|
# OPENDOTA_API_KEY (optional)
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
"""Create Cloudflare D1 / R2 / Queues for Climperor player data and bind Pages.
|
||||||
|
|
||||||
|
Credentials: CLOUDFLARE_EMAIL + CLOUDFLARE_API_KEY (or keyzoo refining/cloudflare).
|
||||||
|
Writes web/cloudflare/.resources.json with ids (no secrets).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
OUT = Path(__file__).resolve().parent / ".resources.json"
|
||||||
|
MIGRATION = Path(__file__).resolve().parent / "migrations" / "0001_init.sql"
|
||||||
|
PAGES_PROJECT = "climperor-relations"
|
||||||
|
D1_NAME = "climperor-users"
|
||||||
|
R2_NAME = "climperor-player-data"
|
||||||
|
QUEUE_NAME = "climperor-player-sync"
|
||||||
|
DLQ_NAME = "climperor-player-sync-dlq"
|
||||||
|
WORKER_NAME = "climperor-player-sync"
|
||||||
|
API = "https://api.cloudflare.com/client/v4"
|
||||||
|
|
||||||
|
|
||||||
|
def creds() -> tuple[str, str]:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing CLOUDFLARE_EMAIL / CLOUDFLARE_API_KEY")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def api(method: str, path: str, body: dict | None = None) -> dict:
|
||||||
|
email, key = creds()
|
||||||
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
API + path,
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-provision",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
payload = json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
raise SystemExit(f"CF API {method} {path} -> {e.code}: {raw[:400]}") from e
|
||||||
|
if not payload.get("success"):
|
||||||
|
raise SystemExit(f"CF API failed: {payload.get('errors')}")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def account_id() -> str:
|
||||||
|
rows = api("GET", "/accounts")["result"]
|
||||||
|
if not rows:
|
||||||
|
raise SystemExit("no Cloudflare accounts")
|
||||||
|
return rows[0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_d1(acct: str) -> str:
|
||||||
|
listed = api("GET", f"/accounts/{acct}/d1/database")["result"] or []
|
||||||
|
for row in listed:
|
||||||
|
if row.get("name") == D1_NAME:
|
||||||
|
print(f"d1 exists: {row['uuid']}")
|
||||||
|
return row["uuid"]
|
||||||
|
created = api(
|
||||||
|
"POST",
|
||||||
|
f"/accounts/{acct}/d1/database",
|
||||||
|
{"name": D1_NAME},
|
||||||
|
)["result"]
|
||||||
|
print(f"d1 created: {created['uuid']}")
|
||||||
|
return created["uuid"]
|
||||||
|
|
||||||
|
|
||||||
|
def run_migration(acct: str, db_id: str) -> None:
|
||||||
|
sql = MIGRATION.read_text(encoding="utf-8")
|
||||||
|
api(
|
||||||
|
"POST",
|
||||||
|
f"/accounts/{acct}/d1/database/{db_id}/query",
|
||||||
|
{"sql": sql},
|
||||||
|
)
|
||||||
|
print("d1 migration applied")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_r2(acct: str) -> bool:
|
||||||
|
"""Return True if bucket exists/created. False if R2 not enabled on account."""
|
||||||
|
try:
|
||||||
|
api("GET", f"/accounts/{acct}/r2/buckets/{R2_NAME}")
|
||||||
|
print(f"r2 exists: {R2_NAME}")
|
||||||
|
return True
|
||||||
|
except SystemExit:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
api("POST", f"/accounts/{acct}/r2/buckets", {"name": R2_NAME})
|
||||||
|
print(f"r2 created: {R2_NAME}")
|
||||||
|
return True
|
||||||
|
except SystemExit as e:
|
||||||
|
msg = str(e)
|
||||||
|
if "already exists" in msg.lower() or "10004" in msg:
|
||||||
|
print(f"r2 exists: {R2_NAME}")
|
||||||
|
return True
|
||||||
|
if "10042" in msg or "enable R2" in msg:
|
||||||
|
print(
|
||||||
|
"r2 skipped: enable R2 in Cloudflare Dashboard "
|
||||||
|
"(https://dash.cloudflare.com/?to=/:account/r2), then re-run"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_queue(acct: str, name: str) -> str:
|
||||||
|
listed = api("GET", f"/accounts/{acct}/queues")["result"] or []
|
||||||
|
# API shape may be {result: [...]} or {result: {queues: [...]}}
|
||||||
|
rows = listed if isinstance(listed, list) else (listed.get("queues") or [])
|
||||||
|
for row in rows:
|
||||||
|
if row.get("queue_name") == name or row.get("name") == name:
|
||||||
|
qid = row.get("queue_id") or row.get("id")
|
||||||
|
print(f"queue exists: {name} ({qid})")
|
||||||
|
return qid
|
||||||
|
created = api("POST", f"/accounts/{acct}/queues", {"queue_name": name})["result"]
|
||||||
|
qid = created.get("queue_id") or created.get("id")
|
||||||
|
print(f"queue created: {name} ({qid})")
|
||||||
|
return qid
|
||||||
|
|
||||||
|
|
||||||
|
def patch_wrangler(d1_id: str) -> None:
|
||||||
|
path = Path(__file__).resolve().parent / "player-sync" / "wrangler.toml"
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
text = text.replace("REPLACE_D1_ID", d1_id)
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
print(f"updated {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def write_resources(
|
||||||
|
acct: str,
|
||||||
|
d1_id: str,
|
||||||
|
queue_id: str | None,
|
||||||
|
dlq_id: str | None,
|
||||||
|
*,
|
||||||
|
r2_ok: bool,
|
||||||
|
) -> None:
|
||||||
|
payload = {
|
||||||
|
"account_id": acct,
|
||||||
|
"d1": {"name": D1_NAME, "id": d1_id},
|
||||||
|
"r2": {"name": R2_NAME, "ready": r2_ok},
|
||||||
|
"queue": {"name": QUEUE_NAME, "id": queue_id},
|
||||||
|
"dlq": {"name": DLQ_NAME, "id": dlq_id},
|
||||||
|
"worker": WORKER_NAME,
|
||||||
|
"pages_project": PAGES_PROJECT,
|
||||||
|
}
|
||||||
|
OUT.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(f"wrote {OUT}")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_queue_soft(acct: str, name: str) -> str | None:
|
||||||
|
try:
|
||||||
|
return ensure_queue(acct, name)
|
||||||
|
except SystemExit as e:
|
||||||
|
print(f"queue skipped ({name}): {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _env_bindings(
|
||||||
|
base: dict,
|
||||||
|
*,
|
||||||
|
d1_id: str,
|
||||||
|
queue_id: str | None,
|
||||||
|
r2_ok: bool,
|
||||||
|
fail_open: bool | None,
|
||||||
|
) -> dict:
|
||||||
|
"""Build one environment's deployment_config with required bindings."""
|
||||||
|
out = {
|
||||||
|
k: v
|
||||||
|
for k, v in base.items()
|
||||||
|
if k
|
||||||
|
not in (
|
||||||
|
"d1_databases",
|
||||||
|
"queue_producers",
|
||||||
|
"r2_buckets",
|
||||||
|
"fail_open",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
d1_bindings = dict(base.get("d1_databases") or {})
|
||||||
|
d1_bindings["DB"] = {"id": d1_id}
|
||||||
|
out["d1_databases"] = d1_bindings
|
||||||
|
if queue_id:
|
||||||
|
producers = dict(base.get("queue_producers") or {})
|
||||||
|
producers["SYNC_QUEUE"] = {"name": QUEUE_NAME}
|
||||||
|
out["queue_producers"] = producers
|
||||||
|
if r2_ok:
|
||||||
|
buckets = dict(base.get("r2_buckets") or {})
|
||||||
|
buckets["MATCHES"] = {"name": R2_NAME}
|
||||||
|
out["r2_buckets"] = buckets
|
||||||
|
# Cloudflare requires fail_open equal on production and preview.
|
||||||
|
if fail_open is not None:
|
||||||
|
out["fail_open"] = bool(fail_open)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def bind_pages(
|
||||||
|
acct: str,
|
||||||
|
d1_id: str,
|
||||||
|
queue_id: str | None,
|
||||||
|
*,
|
||||||
|
r2_ok: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Attach D1 (+ Queue / R2) to Pages production and preview bindings."""
|
||||||
|
path = f"/accounts/{acct}/pages/projects/{PAGES_PROJECT}"
|
||||||
|
try:
|
||||||
|
project = api("GET", path)["result"]
|
||||||
|
except SystemExit as e:
|
||||||
|
print(f"pages bind skipped (project missing?): {e}")
|
||||||
|
return
|
||||||
|
dc = project.get("deployment_configs") or {}
|
||||||
|
prod = dict(dc.get("production") or {})
|
||||||
|
preview = dict(dc.get("preview") or {})
|
||||||
|
fail_open = prod.get("fail_open")
|
||||||
|
if fail_open is None:
|
||||||
|
fail_open = preview.get("fail_open")
|
||||||
|
if fail_open is None:
|
||||||
|
fail_open = False
|
||||||
|
body = {
|
||||||
|
"deployment_configs": {
|
||||||
|
"production": _env_bindings(
|
||||||
|
prod,
|
||||||
|
d1_id=d1_id,
|
||||||
|
queue_id=queue_id,
|
||||||
|
r2_ok=r2_ok,
|
||||||
|
fail_open=fail_open,
|
||||||
|
),
|
||||||
|
"preview": _env_bindings(
|
||||||
|
preview,
|
||||||
|
d1_id=d1_id,
|
||||||
|
queue_id=queue_id,
|
||||||
|
r2_ok=r2_ok,
|
||||||
|
fail_open=fail_open,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
api("PATCH", path, body)
|
||||||
|
print(f"pages bindings updated on {PAGES_PROJECT} (prod+preview)")
|
||||||
|
except SystemExit as e:
|
||||||
|
print(f"pages bind soft-fail (set manually): {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
acct = account_id()
|
||||||
|
print(f"account_id: {acct}")
|
||||||
|
d1_id = ensure_d1(acct)
|
||||||
|
run_migration(acct, d1_id)
|
||||||
|
r2_ok = ensure_r2(acct)
|
||||||
|
queue_id = ensure_queue_soft(acct, QUEUE_NAME)
|
||||||
|
dlq_id = ensure_queue_soft(acct, DLQ_NAME)
|
||||||
|
patch_wrangler(d1_id)
|
||||||
|
bind_pages(acct, d1_id, queue_id, r2_ok=r2_ok)
|
||||||
|
write_resources(acct, d1_id, queue_id, dlq_id, r2_ok=r2_ok)
|
||||||
|
print(
|
||||||
|
"next: deploy worker with wrangler + bind D1/R2/Queue to Pages project "
|
||||||
|
f"{PAGES_PROJECT} (see web/cloudflare/README.md)"
|
||||||
|
)
|
||||||
|
return 0 if d1_id else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Put STEAM_API_KEY + SESSION_SECRET on climperor-relations Pages (prod+preview).
|
||||||
|
|
||||||
|
Reads secrets from env or staged temp files under web/.refresh/ (no secret echo).
|
||||||
|
Prefers `wrangler pages secret put`; falls back to Pages project PATCH.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ACCT = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
PROJ = "climperor-relations"
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
FRONTEND = HERE.parents[0] / "frontend"
|
||||||
|
REFRESH = Path(__file__).resolve().parents[1] / ".refresh"
|
||||||
|
STEAM_TMP = REFRESH / "steam_key.tmp"
|
||||||
|
SESSION_TMP = REFRESH / "session_secret.tmp"
|
||||||
|
|
||||||
|
|
||||||
|
def _creds() -> tuple[str, str]:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
if not email or not key:
|
||||||
|
raise SystemExit("missing Cloudflare credentials")
|
||||||
|
return email, key
|
||||||
|
|
||||||
|
|
||||||
|
def _secret(name: str, env_keys: list[str], staged: Path) -> str:
|
||||||
|
for k in env_keys:
|
||||||
|
v = (os.environ.get(k) or "").strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
if staged.is_file():
|
||||||
|
v = staged.read_text(encoding="utf-8").strip()
|
||||||
|
try:
|
||||||
|
staged.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
raise SystemExit(f"missing {name}")
|
||||||
|
|
||||||
|
|
||||||
|
def cf(method: str, path: str, body: dict | None = None) -> dict:
|
||||||
|
email, key = _creds()
|
||||||
|
data = None if body is None else json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"https://api.cloudflare.com/client/v4{path}",
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={
|
||||||
|
"X-Auth-Email": email,
|
||||||
|
"X-Auth-Key": key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"User-Agent": "climperor-put-pages-secrets",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
raw = e.read().decode("utf-8", errors="replace")
|
||||||
|
raise SystemExit(f"CF API {e.code}: {raw[:500]}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _wrangler_put(name: str, value: str) -> None:
|
||||||
|
email, key = _creds()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["CLOUDFLARE_EMAIL"] = email
|
||||||
|
env["CLOUDFLARE_API_KEY"] = key
|
||||||
|
env["CLOUDFLARE_ACCOUNT_ID"] = ACCT
|
||||||
|
env["CI"] = "true"
|
||||||
|
print(f"wrangler pages secret put {name} …", flush=True)
|
||||||
|
cmd = (
|
||||||
|
f'npx --yes wrangler@3 pages secret put {name} '
|
||||||
|
f'--project-name {PROJ}'
|
||||||
|
)
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
input=value + "\n",
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
env=env,
|
||||||
|
cwd=str(FRONTEND if FRONTEND.is_dir() else HERE),
|
||||||
|
shell=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(proc.stderr or proc.stdout, file=sys.stderr)
|
||||||
|
raise SystemExit(proc.returncode)
|
||||||
|
tail = (proc.stdout or "").strip().splitlines()
|
||||||
|
if tail:
|
||||||
|
print(tail[-1], flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
steam = _secret(
|
||||||
|
"STEAM_API_KEY",
|
||||||
|
["STEAM_API_KEY", "KEYZOO_ASSET_SECRET_WEB_API_KEY"],
|
||||||
|
STEAM_TMP,
|
||||||
|
)
|
||||||
|
session = _secret(
|
||||||
|
"SESSION_SECRET",
|
||||||
|
["SESSION_SECRET", "KEYZOO_ASSET_SECRET_SESSION_SECRET"],
|
||||||
|
SESSION_TMP,
|
||||||
|
)
|
||||||
|
# Official path: wrangler pages secret put (production).
|
||||||
|
_wrangler_put("STEAM_API_KEY", steam)
|
||||||
|
_wrangler_put("SESSION_SECRET", session)
|
||||||
|
|
||||||
|
# Also patch preview so *.pages.dev preview deploys work.
|
||||||
|
proj = cf("GET", f"/accounts/{ACCT}/pages/projects/{PROJ}")
|
||||||
|
result = proj.get("result") or {}
|
||||||
|
dc = result.get("deployment_configs") or {}
|
||||||
|
secrets = {
|
||||||
|
"STEAM_API_KEY": {"type": "secret_text", "value": steam},
|
||||||
|
"SESSION_SECRET": {"type": "secret_text", "value": session},
|
||||||
|
}
|
||||||
|
preview_cfg = dc.get("preview") or {}
|
||||||
|
preview_entry: dict = {"env_vars": secrets}
|
||||||
|
wch = preview_cfg.get("wrangler_config_hash")
|
||||||
|
if wch:
|
||||||
|
preview_entry["wrangler_config_hash"] = wch
|
||||||
|
out = cf(
|
||||||
|
"PATCH",
|
||||||
|
f"/accounts/{ACCT}/pages/projects/{PROJ}",
|
||||||
|
{"deployment_configs": {"preview": preview_entry}},
|
||||||
|
)
|
||||||
|
if not out.get("success"):
|
||||||
|
print("preview patch soft-fail", flush=True)
|
||||||
|
|
||||||
|
check = cf("GET", f"/accounts/{ACCT}/pages/projects/{PROJ}")
|
||||||
|
cdc = (check.get("result") or {}).get("deployment_configs") or {}
|
||||||
|
for env_name in ("production", "preview"):
|
||||||
|
ev = ((cdc.get(env_name) or {}).get("env_vars") or {})
|
||||||
|
names = sorted(ev.keys())
|
||||||
|
types = {
|
||||||
|
k: (ev[k] or {}).get("type") for k in ("STEAM_API_KEY", "SESSION_SECRET")
|
||||||
|
}
|
||||||
|
print(f"{env_name}: env_vars={names} secret_types={types}", flush=True)
|
||||||
|
print("ok", flush=True)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Put STEAM_API_KEY on climperor-player-sync from staged temp or env."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
WORKER = Path(__file__).resolve().parent / "player-sync"
|
||||||
|
STAGED = Path(__file__).resolve().parents[1] / ".refresh" / "steam_key.tmp"
|
||||||
|
|
||||||
|
|
||||||
|
def put_secret(name: str, value: str, env: dict) -> int:
|
||||||
|
print(f"putting secret {name} …", flush=True)
|
||||||
|
proc = subprocess.run(
|
||||||
|
f"npx --yes wrangler@3 secret put {name}",
|
||||||
|
cwd=str(WORKER),
|
||||||
|
env=env,
|
||||||
|
shell=True,
|
||||||
|
input=value + "\n",
|
||||||
|
text=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
print(proc.stderr or proc.stdout, file=sys.stderr)
|
||||||
|
return proc.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
email = os.environ.get("CLOUDFLARE_EMAIL") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_META_USERNAME"
|
||||||
|
)
|
||||||
|
cf_key = os.environ.get("CLOUDFLARE_API_KEY") or os.environ.get(
|
||||||
|
"KEYZOO_ASSET_SECRET_GLOBAL_API_KEY"
|
||||||
|
)
|
||||||
|
steam = (
|
||||||
|
os.environ.get("STEAM_API_KEY")
|
||||||
|
or os.environ.get("KEYZOO_ASSET_SECRET_WEB_API_KEY")
|
||||||
|
or ""
|
||||||
|
).strip()
|
||||||
|
if not steam and STAGED.is_file():
|
||||||
|
steam = STAGED.read_text(encoding="utf-8").strip()
|
||||||
|
try:
|
||||||
|
STAGED.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if not email or not cf_key:
|
||||||
|
print("missing Cloudflare credentials", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
if not steam:
|
||||||
|
print("missing STEAM_API_KEY (stage via _stage_steam_key.py first)", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["CLOUDFLARE_EMAIL"] = email
|
||||||
|
env["CLOUDFLARE_API_KEY"] = cf_key
|
||||||
|
env["CLOUDFLARE_ACCOUNT_ID"] = "510534f7f6284344aadaf2f5a0794d48"
|
||||||
|
code = put_secret("STEAM_API_KEY", steam, env)
|
||||||
|
print("ok" if code == 0 else "failed", flush=True)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||