[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())
diff --git a/web/cloudflare/_stage_cf_creds.py b/web/cloudflare/_stage_cf_creds.py
new file mode 100644
index 0000000..ae782aa
--- /dev/null
+++ b/web/cloudflare/_stage_cf_creds.py
@@ -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())
diff --git a/web/cloudflare/_stage_steam_key.py b/web/cloudflare/_stage_steam_key.py
new file mode 100644
index 0000000..a5e7a8d
--- /dev/null
+++ b/web/cloudflare/_stage_steam_key.py
@@ -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())
diff --git a/web/cloudflare/_trigger_player_sync.py b/web/cloudflare/_trigger_player_sync.py
new file mode 100644
index 0000000..1938e2a
--- /dev/null
+++ b/web/cloudflare/_trigger_player_sync.py
@@ -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())
diff --git a/web/cloudflare/deploy_worker.py b/web/cloudflare/deploy_worker.py
new file mode 100644
index 0000000..7da92ce
--- /dev/null
+++ b/web/cloudflare/deploy_worker.py
@@ -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())
diff --git a/web/cloudflare/migrations/0001_init.sql b/web/cloudflare/migrations/0001_init.sql
new file mode 100644
index 0000000..fdda42c
--- /dev/null
+++ b/web/cloudflare/migrations/0001_init.sql
@@ -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);
diff --git a/web/cloudflare/player-sync/src/db.js b/web/cloudflare/player-sync/src/db.js
new file mode 100644
index 0000000..ea02878
--- /dev/null
+++ b/web/cloudflare/player-sync/src/db.js
@@ -0,0 +1,296 @@
+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=excluded.rank_tier,
+ leaderboard_rank=excluded.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) {
+ 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) {
+ 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,
+ };
+}
diff --git a/web/cloudflare/player-sync/src/index.js b/web/cloudflare/player-sync/src/index.js
new file mode 100644
index 0000000..e632f80
--- /dev/null
+++ b/web/cloudflare/player-sync/src/index.js
@@ -0,0 +1,326 @@
+/**
+ * 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,
+ 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 fetched = utcNow();
+ const availability = mergeAvailability({
+ opendotaRecentN: Array.isArray(recentRaw) ? recentRaw.length : 0,
+ career,
+ steamHistoryStatus: steamStatus,
+ fetchedAt: fetched,
+ });
+ if (profileBlock.fh_unavailable && availability.status === "unknown") {
+ availability.status = "private";
+ availability.note = "未公开比赛数据";
+ }
+ // Steam public but OpenDota empty → almost always rate-limit; retry queue.
+ if (
+ availability.status === "syncing" &&
+ (!Array.isArray(recentRaw) || recentRaw.length === 0) &&
+ !career
+ ) {
+ 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,
+ };
+
+ await upsertProfile(env.DB, accountId, profile);
+ if (career) await upsertStats(env.DB, accountId, "career", career);
+ await upsertStats(env.DB, accountId, "recent20", aggregateFromRows(recent20, 20));
+ if (activity180) await upsertStats(env.DB, accountId, "recent180", activity180);
+ await replaceHeroes(env.DB, accountId, topHeroes);
+ await replacePeers(env.DB, accountId, peerRows);
+ await upsertMatches(env.DB, accountId, recent20);
+
+ // Optional: store a published match detail into R2 (deduped by match_id).
+ if (msg.kind === "publish_match" && msg.match_id && env.MATCHES) {
+ const matchId = Number(msg.match_id);
+ const match = await odFetch(`/matches/${matchId}`, env);
+ if (match && Array.isArray(match.players)) {
+ const key = `matches/${matchId}.json`;
+ await env.MATCHES.put(key, JSON.stringify(match), {
+ 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" },
+ });
+ },
+};
diff --git a/web/cloudflare/player-sync/src/opendota.js b/web/cloudflare/player-sync/src/opendota.js
new file mode 100644
index 0000000..b5e3a76
--- /dev/null
+++ b/web/cloudflare/player-sync/src/opendota.js
@@ -0,0 +1,93 @@
+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;
+}
diff --git a/web/cloudflare/player-sync/src/stats.js b/web/cloudflare/player-sync/src/stats.js
new file mode 100644
index 0000000..e01de41
--- /dev/null
+++ b/web/cloudflare/player-sync/src/stats.js
@@ -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,
+ };
+}
diff --git a/web/cloudflare/player-sync/test_stats.mjs b/web/cloudflare/player-sync/test_stats.mjs
new file mode 100644
index 0000000..7f7c6fe
--- /dev/null
+++ b/web/cloudflare/player-sync/test_stats.mjs
@@ -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");
diff --git a/web/cloudflare/player-sync/wrangler.toml b/web/cloudflare/player-sync/wrangler.toml
new file mode 100644
index 0000000..f3940cd
--- /dev/null
+++ b/web/cloudflare/player-sync/wrangler.toml
@@ -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)
diff --git a/web/cloudflare/provision.py b/web/cloudflare/provision.py
new file mode 100644
index 0000000..188ea32
--- /dev/null
+++ b/web/cloudflare/provision.py
@@ -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())
diff --git a/web/cloudflare/put_worker_secrets.py b/web/cloudflare/put_worker_secrets.py
new file mode 100644
index 0000000..1a7e765
--- /dev/null
+++ b/web/cloudflare/put_worker_secrets.py
@@ -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())
diff --git a/web/deploy_relations.py b/web/deploy_relations.py
index 352e646..aa53d2e 100644
--- a/web/deploy_relations.py
+++ b/web/deploy_relations.py
@@ -409,6 +409,42 @@ def bind_domain(
ensure_cname(email, api_key, domain, project)
+def check_player_bindings(email: str, api_key: str, account_id: str, project: str) -> None:
+ """Warn (do not abort) if D1/Queue bindings for player pages are missing."""
+ data = cf_api(
+ "GET",
+ f"/accounts/{account_id}/pages/projects/{project}",
+ email=email,
+ api_key=api_key,
+ )
+ if not data.get("success"):
+ print("warning: could not verify Pages player bindings")
+ return
+ prod = ((data.get("result") or {}).get("deployment_configs") or {}).get(
+ "production"
+ ) or {}
+ d1 = prod.get("d1_databases") or {}
+ queues = prod.get("queue_producers") or {}
+ r2 = prod.get("r2_buckets") or {}
+ missing = []
+ if "DB" not in d1:
+ missing.append("D1:DB")
+ if "SYNC_QUEUE" not in queues:
+ missing.append("Queue:SYNC_QUEUE")
+ if missing:
+ print(
+ "warning: Pages missing player bindings "
+ f"{', '.join(missing)} — run python web/cloudflare/provision.py"
+ )
+ else:
+ print("pages player bindings: DB + SYNC_QUEUE ok")
+ if "MATCHES" not in r2:
+ print(
+ "note: R2 MATCHES not bound yet "
+ "(enable R2 in Dashboard, then re-run provision.py)"
+ )
+
+
def main() -> None:
ap = argparse.ArgumentParser(description="Deploy Climperor web site to Cloudflare Pages")
ap.add_argument("--no-export", action="store_true", help="skip re-export, deploy existing dist")
@@ -441,6 +477,7 @@ def main() -> None:
print(f"account_id: {account_id}")
ensure_project(email, api_key, account_id, args.project_name)
+ check_player_bindings(email, api_key, account_id, args.project_name)
if not args.no_export:
run_export(args.ability_video_base, args.static_asset_base)
diff --git a/web/export_relations_site.py b/web/export_relations_site.py
index 003db1b..db2925b 100644
--- a/web/export_relations_site.py
+++ b/web/export_relations_site.py
@@ -55,7 +55,7 @@ from shared.paths import (
from seo_prerender import DEFAULT_SITE_ORIGIN, write_seo_bundle
from serve_relations import WEB_DIR, build_payload
-SITE_VERSION = "0.6.16"
+SITE_VERSION = "0.6.54"
DEFAULT_OSS_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"
diff --git a/web/frontend/app.js b/web/frontend/app.js
index 61c8acd..7eb2535 100644
--- a/web/frontend/app.js
+++ b/web/frontend/app.js
@@ -131,6 +131,7 @@ function siteOrigin() {
}
const PAGE_SEO_LABELS = {
+ home: "我",
heroes: "英雄克制与搭档",
rankings: "Immortal 排行",
streamers: "主播",
@@ -219,6 +220,9 @@ function describeStateForSeo(st) {
} else if (page === "matches") {
title = `明星比赛 — ${brand}`;
description = "明星选手近期职业与国服对局、终局出装与加点。";
+ } else if (page === "home") {
+ title = `我 — ${brand}`;
+ description = "Steam 登录后查看本人近期比赛与战绩。";
} else if (page === "players") {
const aid = st.playerAccountId;
if (aid && st.playerMatchId) {
@@ -270,7 +274,9 @@ function clearSeoPrerender() {
const state = {
data: null,
- page: "heroes", // heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
+ /** Steam session from /api/auth/me; null until first probe. */
+ auth: null,
+ page: "heroes", // home | heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
selectedKey: null,
selectedItemKey: null,
/** Hero-page inspect pane: { type:'skill', id } | { type:'item', key } | null */
@@ -298,6 +304,7 @@ const state = {
_playerProfile: null,
_playerMatch: null,
_playerLoadKey: null,
+ _playerEnrichKey: null,
/** Top-level 走势 page medal bracket */
trendsBracket: "legend",
/** Sort key for trends board: wr_end | pr_end */
@@ -806,6 +813,271 @@ async function fetchPlayerJson(accountId, matchId) {
return null;
}
+/** True when the profile has career or recent rows worth painting. */
+function playerProfileHasStats(profile) {
+ if (!profile || profile.error) return false;
+ const hasCareer = profile.career && Number(profile.career.games) > 0;
+ const hasRecent =
+ (Array.isArray(profile.recent) && profile.recent.length > 0) ||
+ (profile.recent_20 && Number(profile.recent_20.sample) > 0);
+ return !!(hasCareer || hasRecent);
+}
+
+/** Empty shell → hard sync. TTL stale alone is not empty. */
+function playerProfileNeedsRefresh(profile) {
+ return !playerProfileHasStats(profile);
+}
+
+function playerProfileIsSoftStale(profile) {
+ if (!profile) return false;
+ if (profile.stale) return true;
+ if (profile.availability && profile.availability.stale) return true;
+ return false;
+}
+
+/** Local/Pages: backfill profile. force=false uses server TTL cache. */
+async function enrichPlayerProfile(
+ accountId,
+ { includeGsi = true, force = false } = {}
+) {
+ const aid = String(accountId || "");
+ if (!/^\d+$/.test(aid)) return null;
+ try {
+ const res = await fetch("/api/players/enrich", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
+ body: JSON.stringify({
+ account_id: Number(aid),
+ include_gsi: !!includeGsi,
+ force: !!force,
+ }),
+ });
+ if (!res.ok) return null;
+ const data = await res.json();
+ return data && data.profile ? data.profile : null;
+ } catch (_) {
+ return null;
+ }
+}
+
+/** Production: GET /api/players/me (D1). Local serve uses disk TTL cache. */
+async function fetchMyPlayerProfile() {
+ try {
+ const res = await fetch("/api/players/me", {
+ credentials: "same-origin",
+ headers: { Accept: "application/json" },
+ });
+ if (!res.ok) return null;
+ const data = await res.json();
+ if (!data || data.error) return null;
+ return data;
+ } catch (_) {
+ return null;
+ }
+}
+
+/** Poll /me while Worker/local background enrich fills an empty/stale shell. */
+async function pollMyPlayerProfile({
+ attempts = 6,
+ delayMs = 2500,
+ accountId = null,
+} = {}) {
+ let last = null;
+ for (let i = 0; i < attempts; i++) {
+ if (
+ accountId != null &&
+ String(state.playerAccountId || "") !== String(accountId)
+ ) {
+ return last;
+ }
+ last = await fetchMyPlayerProfile();
+ if (last && !playerProfileNeedsRefresh(last)) return last;
+ if (i + 1 < attempts) {
+ await new Promise((r) => setTimeout(r, delayMs));
+ }
+ }
+ return last;
+}
+
+const LOBBY_LABELS = {
+ 0: "普通",
+ 1: "练习",
+ 2: "联赛",
+ 7: "天梯",
+ 9: "勇士联赛",
+};
+
+function lobbyLabel(row) {
+ if (!row) return "";
+ const lt = row.lobby_type;
+ if (lt != null && LOBBY_LABELS[lt]) return LOBBY_LABELS[lt];
+ return "";
+}
+
+function appendStatCards(host, items) {
+ if (!items.length) return;
+ const row = document.createElement("div");
+ row.className = "players-stat-row";
+ for (const it of items) {
+ if (it.value == null || it.value === "") continue;
+ const card = document.createElement("div");
+ card.className = "players-stat-card";
+ card.innerHTML = `${escapeHtml(
+ it.label
+ )}${escapeHtml(
+ String(it.value)
+ )}`;
+ row.appendChild(card);
+ }
+ if (row.childElementCount) host.appendChild(row);
+}
+
+/** Local-only: ensure match detail JSON exists, then return it. */
+async function ensurePlayerMatch(accountId, matchId) {
+ const aid = String(accountId || "");
+ const mid = String(matchId || "");
+ if (!/^\d+$/.test(aid) || !/^\d+$/.test(mid)) {
+ return { match: null, error: "bad id" };
+ }
+ const existing = await fetchPlayerJson(aid, mid);
+ if (existing) return { match: existing, error: "" };
+ try {
+ const res = await fetch("/api/players/ensure-match", {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
+ body: JSON.stringify({ account_id: Number(aid), match_id: Number(mid) }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ return { match: null, error: (data && data.error) || `HTTP ${res.status}` };
+ }
+ return { match: data.match || null, error: data.match ? "" : "empty" };
+ } catch (e) {
+ return { match: null, error: String((e && e.message) || e) };
+ }
+}
+
+function openPlayerPage(accountId, matchId) {
+ const aid = String(accountId || "");
+ if (!/^\d+$/.test(aid)) return;
+ state.page = "players";
+ state.playerAccountId = aid;
+ state.playerMatchId =
+ matchId != null && /^\d+$/.test(String(matchId)) ? String(matchId) : null;
+ state._playerLoadKey = null;
+ state._playerProfile = null;
+ state._playerMatch = null;
+ syncStateToUrl();
+ render();
+}
+
+function authAccountId() {
+ const a = state.auth;
+ if (!a || !a.authenticated) return null;
+ const id = a.account_id;
+ return id != null && /^\d+$/.test(String(id)) ? String(id) : null;
+}
+
+async function fetchAuthMe() {
+ try {
+ const res = await fetch("/api/auth/me", {
+ credentials: "same-origin",
+ headers: { Accept: "application/json" },
+ });
+ if (!res.ok) {
+ state.auth = { authenticated: false };
+ return state.auth;
+ }
+ const data = await res.json();
+ state.auth =
+ data && data.authenticated
+ ? {
+ authenticated: true,
+ steamid: data.steamid || null,
+ account_id: data.account_id,
+ personaname: data.personaname || null,
+ avatar: data.avatar || null,
+ }
+ : { authenticated: false };
+ return state.auth;
+ } catch (_) {
+ state.auth = { authenticated: false };
+ return state.auth;
+ }
+}
+
+function syncAuthChrome() {
+ const loginBtn = $("#steam-login-btn");
+ const userEl = $("#auth-user");
+ const avatar = $("#auth-avatar");
+ const nameEl = $("#auth-name");
+ const homeTab = $("#tab-home");
+ const authed = !!(state.auth && state.auth.authenticated);
+ if (loginBtn) loginBtn.classList.toggle("hidden", authed);
+ if (userEl) userEl.classList.toggle("hidden", !authed);
+ if (homeTab) homeTab.classList.toggle("hidden", !authed);
+ if (authed && userEl) {
+ const name = state.auth.personaname || `玩家 ${state.auth.account_id}`;
+ if (nameEl) nameEl.textContent = name;
+ if (avatar) {
+ if (state.auth.avatar) {
+ avatar.src = state.auth.avatar;
+ avatar.alt = name;
+ avatar.classList.remove("hidden");
+ } else {
+ avatar.removeAttribute("src");
+ avatar.alt = "";
+ avatar.classList.add("hidden");
+ }
+ }
+ }
+}
+
+async function logoutAuth() {
+ try {
+ await fetch("/api/auth/logout", {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { Accept: "application/json" },
+ });
+ } catch (_) {
+ /* ignore */
+ }
+ state.auth = { authenticated: false };
+ if (state.page === "home") {
+ state.page = "heroes";
+ state.playerAccountId = null;
+ state.playerMatchId = null;
+ state._playerLoadKey = null;
+ state._playerProfile = null;
+ state._playerMatch = null;
+ state._playerEnrichKey = null;
+ }
+ syncAuthChrome();
+ syncStateToUrl();
+ render();
+}
+
+function bindAuthChrome() {
+ const logoutBtn = $("#auth-logout");
+ if (logoutBtn) {
+ logoutBtn.addEventListener("click", (e) => {
+ e.preventDefault();
+ logoutAuth();
+ });
+ }
+}
+
+function renderHomeGate(root) {
+ root.replaceChildren();
+ const box = document.createElement("div");
+ box.className = "rankings-empty auth-home-gate";
+ box.innerHTML =
+ '登录 Steam 后可查看本人近期比赛与战绩。
' +
+ 'Steam 登录
';
+ root.appendChild(box);
+}
+
function itemMeta(id) {
const items = state.data.hero_items?.items || {};
return items[String(id)] || null;
@@ -1753,12 +2025,12 @@ const RANK_TIER_MEDALS = [
"immortal",
];
-/** OpenDota rank_tier → { medalKey, stars, label, iconFile }. */
+/** OpenDota rank_tier → { medalKey, stars, label, iconFile, starFile }. */
function parseRankTier(tier) {
const t = Number(tier);
if (!Number.isFinite(t) || t < 10) return null;
const medal = Math.min(8, Math.max(1, Math.floor(t / 10)));
- const stars = Math.max(0, t % 10);
+ const stars = Math.max(0, Math.min(5, t % 10));
const medalKey = RANK_TIER_MEDALS[medal];
if (!medalKey) return null;
const base = BRACKET_LABELS[medalKey] || medalKey;
@@ -1766,16 +2038,50 @@ function parseRankTier(tier) {
if (medalKey === "immortal") {
label = base;
} else if (stars > 0) {
- label = `${base} ${stars}`;
+ label = `${base}${stars}`;
}
return {
medalKey,
stars,
label,
iconFile: BRACKET_RANK_ICON[medalKey] || null,
+ // OpenDota star overlay (rank_star_1..5); immortal has no stars.
+ starFile:
+ medalKey !== "immortal" && stars >= 1 && stars <= 5
+ ? `rank_star_${stars}.png`
+ : null,
};
}
+/**
+ * Medal icon (+ optional star overlay) for a concrete rank_tier.
+ * Text label goes in title/alt only — stars convey 传奇四 etc. visually.
+ */
+function createRankMedalEl(rank, className = "rank-medal") {
+ if (!rank || !rank.iconFile) return null;
+ const wrap = document.createElement("span");
+ wrap.className = className;
+ wrap.title = rank.label;
+ const base = document.createElement("img");
+ base.className = "rank-medal-base";
+ base.src = rankIconSrc(rank.iconFile);
+ base.alt = rank.label;
+ base.loading = "lazy";
+ base.decoding = "async";
+ wrap.appendChild(base);
+ if (rank.starFile) {
+ const stars = document.createElement("img");
+ stars.className = "rank-medal-stars";
+ stars.src = rankIconSrc(rank.starFile);
+ stars.alt = "";
+ stars.setAttribute("aria-hidden", "true");
+ stars.loading = "lazy";
+ stars.decoding = "async";
+ wrap.appendChild(stars);
+ }
+ return wrap;
+}
+
function matchPlayerDisplayName(row) {
// Never show account id. Prefer display_name / pro name (jikroy) / Steam persona.
for (const key of ["display_name", "name", "personaname"]) {
@@ -1916,16 +2222,11 @@ function buildMatchCard(heroKey, row, opts = {}) {
const rankTier = row.rank_tier ?? row.avg_rank_tier;
const rank = parseRankTier(rankTier);
if (rank?.iconFile) {
- const medal = document.createElement("img");
- medal.className = "match-rank-icon";
- medal.src = rankIconSrc(rank.iconFile);
let tip = rank.label;
if (row.leaderboard_rank) tip += ` #${row.leaderboard_rank}`;
if (!row.rank_tier && row.avg_rank_tier) tip += " · 局均段位";
- medal.alt = tip;
- medal.title = tip;
- medal.loading = "lazy";
- player.appendChild(medal);
+ const medal = createRankMedalEl({ ...rank, label: tip }, "rank-medal match-rank-medal");
+ if (medal) player.appendChild(medal);
}
const nameEl = document.createElement(row.account_id ? "a" : "span");
@@ -6075,11 +6376,20 @@ function pctLabel(v) {
return `${Math.round(Number(v) * 100)}%`;
}
+function fmtNetWorth(n) {
+ const v = Number(n);
+ if (!Number.isFinite(v) || v < 0) return "—";
+ return Math.round(v).toLocaleString("zh-CN");
+}
+
function appendPlayerItemIcons(row, itemIds) {
const wrap = document.createElement("div");
wrap.className = "player-match-items";
- (Array.isArray(itemIds) ? itemIds : []).forEach((id) => {
- const meta = itemMetaFromId(id);
+ const ids = Array.isArray(itemIds) ? itemIds.slice(0, 6) : [];
+ // Always 6 slots so the items column width (and metrics alignment) stays stable.
+ while (ids.length < 6) ids.push(0);
+ ids.forEach((id) => {
+ const meta = id ? itemMetaFromId(id) : null;
const cell = document.createElement("span");
cell.className = "player-match-item";
if (meta && meta.key) {
@@ -6090,7 +6400,7 @@ function appendPlayerItemIcons(row, itemIds) {
cell.appendChild(img);
} else {
cell.classList.add("empty");
- cell.title = id != null ? String(id) : "";
+ cell.title = id ? String(id) : "";
}
wrap.appendChild(cell);
});
@@ -6099,9 +6409,11 @@ function appendPlayerItemIcons(row, itemIds) {
function buildPlayerScoreboardTeam(detail, isRadiant) {
const side = document.createElement("section");
- side.className = `player-team ${isRadiant ? "radiant" : "dire"}`;
const team = isRadiant ? detail.radiant || {} : detail.dire || {};
const won = Boolean(detail.radiant_win) === isRadiant;
+ side.className = `player-team ${isRadiant ? "radiant" : "dire"} ${
+ won ? "won" : "lost"
+ }`;
const head = document.createElement("header");
head.className = "player-team-head";
head.innerHTML = `
@@ -6143,29 +6455,77 @@ function buildPlayerScoreboardTeam(detail, isRadiant) {
meta.className = "player-match-meta";
const nameLine = document.createElement("div");
nameLine.className = "player-match-name";
- nameLine.textContent = p.personaname || heroName;
- if (p.is_mvp) {
- const badge = document.createElement("span");
- badge.className = "player-mvp-badge";
- badge.textContent = "MVP";
- nameLine.appendChild(badge);
+ // Prefer Steam persona; never fall back to hero name (hero is in the sub line).
+ const displayName = p.personaname
+ ? String(p.personaname)
+ : p.account_id
+ ? `玩家 ${p.account_id}`
+ : "匿名";
+ if (p.account_id) {
+ const link = document.createElement("a");
+ link.className = "player-match-name-link";
+ link.href = `/players/${p.account_id}`;
+ link.textContent = displayName;
+ link.title = p.personaname
+ ? `${p.personaname} · ${p.account_id}`
+ : `查看玩家 ${p.account_id}`;
+ link.addEventListener("click", (ev) => {
+ ev.preventDefault();
+ openPlayerPage(p.account_id);
+ });
+ nameLine.appendChild(link);
+ } else {
+ const span = document.createElement("span");
+ span.className = "player-match-name-anon";
+ span.textContent = displayName;
+ span.title = "未公开 Steam 昵称";
+ nameLine.appendChild(span);
}
meta.appendChild(nameLine);
const sub = document.createElement("div");
sub.className = "player-match-sub";
- sub.textContent = `Lv.${p.level ?? "—"} · ${heroName}`;
+ const subText = document.createElement("span");
+ subText.textContent = `Lv.${p.level ?? "—"} · ${heroName}`;
+ sub.appendChild(subText);
+ const badges = document.createElement("span");
+ badges.className = "player-match-badges";
+ if (p.party_label) {
+ const party = document.createElement("span");
+ party.className = `player-party-badge party-${String(p.party_label).toLowerCase()}`;
+ party.textContent = `组${p.party_label}`;
+ party.title = "开黑组队(同 party_id)";
+ badges.appendChild(party);
+ }
+ if (p.is_mvp) {
+ const badge = document.createElement("span");
+ badge.className = "player-mvp-badge";
+ badge.textContent = "MVP";
+ badges.appendChild(badge);
+ }
+ if (badges.childElementCount) sub.appendChild(badges);
meta.appendChild(sub);
left.appendChild(meta);
row.appendChild(left);
const metrics = document.createElement("div");
metrics.className = "player-match-metrics";
- metrics.innerHTML = `
- 参战${pctLabel(p.participation)}
- 伤害${pctLabel(p.damage_share)}
- KDA${p.kills ?? 0}/${p.deaths ?? 0}/${p.assists ?? 0}
- 比${p.kda ?? "—"}
- `;
+ const kdaText = `${p.kills ?? 0}/${p.deaths ?? 0}/${p.assists ?? 0}`;
+ const ratio = p.kda != null && p.kda !== "" ? `(${p.kda})` : "";
+ const metricBits = [
+ ["参战率", "参战", pctLabel(p.participation)],
+ ["伤害占比", "伤害", pctLabel(p.damage_share)],
+ ["个人经济(净身价)", "经济", fmtNetWorth(p.net_worth)],
+ ["击杀/死亡/助攻;(K+A)/D", "KDA", `${kdaText}${ratio}`],
+ ];
+ for (const [title, label, value] of metricBits) {
+ const span = document.createElement("span");
+ span.title = title;
+ const em = document.createElement("em");
+ em.textContent = label;
+ span.appendChild(em);
+ span.appendChild(document.createTextNode(String(value)));
+ metrics.appendChild(span);
+ }
row.appendChild(metrics);
appendPlayerItemIcons(row, p.items);
list.appendChild(row);
@@ -6176,102 +6536,506 @@ function buildPlayerScoreboardTeam(detail, isRadiant) {
function renderPlayerMatchDetail(root, detail) {
root.replaceChildren();
+ const shell = document.createElement("div");
+ shell.className = "players-match-shell";
+
const back = document.createElement("button");
back.type = "button";
back.className = "players-back";
- back.textContent = "← 返回主页";
+ back.setAttribute("aria-label", "返回玩家主页");
+ back.innerHTML = `
+
+ 返回玩家主页
+ `;
back.addEventListener("click", () => {
state.playerMatchId = null;
state._playerMatch = null;
+ state._playerLoadKey = null;
syncStateToUrl();
render();
});
- root.appendChild(back);
+ shell.appendChild(back);
const head = document.createElement("header");
head.className = "players-match-head";
- const dur = fmtDuration(detail.duration);
- head.innerHTML = `
- 比赛 ${detail.match_id}
- 时长 ${dur}${detail.radiant_win ? " · 天辉胜" : " · 夜魇胜"}
+ const when = formatFriendlyTime(detail.start_time);
+ const winner = detail.radiant_win ? "天辉胜" : "夜魇胜";
+ const summary = document.createElement("div");
+ summary.className = "players-match-summary";
+ const h2 = document.createElement("h2");
+ h2.className = "page-title";
+ h2.textContent = `比赛 ${detail.match_id}`;
+ summary.appendChild(h2);
+ const sub = document.createElement("p");
+ sub.className = "page-sub";
+ if (when.title) sub.title = when.title;
+ sub.innerHTML = `
+ ${when.text ? escapeHtml(when.text) : "—"}
+ 时长 ${escapeHtml(fmtDuration(detail.duration))}
+ ${winner}
`;
- root.appendChild(head);
+ summary.appendChild(sub);
+ head.appendChild(summary);
+ shell.appendChild(head);
const board = document.createElement("div");
board.className = "player-scoreboard";
+ const columns = document.createElement("div");
+ columns.className = "player-scoreboard-columns";
+ columns.innerHTML = `
+ 玩家 / 英雄
+
+ 参战伤害经济KDA
+
+ 装备
+ `;
+ board.appendChild(columns);
board.appendChild(buildPlayerScoreboardTeam(detail, true));
board.appendChild(buildPlayerScoreboardTeam(detail, false));
- root.appendChild(board);
+ shell.appendChild(board);
+ root.appendChild(shell);
}
function renderPlayerProfile(root, profile) {
root.replaceChildren();
+ const wrap = document.createElement("div");
+ wrap.className = "players-home";
+
+ // --- Identity strip ---
const head = document.createElement("header");
- head.className = "players-profile-head";
- const name = profile.personaname || `玩家 ${profile.account_id}`;
- head.innerHTML = `
- ${name}
- ID ${profile.account_id}${profile.public_share ? " · 已公开" : " · 本机/未公开"}
- `;
- root.appendChild(head);
-
- const recent = Array.isArray(profile.recent) ? profile.recent : [];
- if (!recent.length) {
- const empty = document.createElement("div");
- empty.className = "rankings-empty";
- empty.textContent = "暂无近期比赛";
- root.appendChild(empty);
- return;
+ head.className = "players-identity";
+ const authName =
+ state.page === "home" && state.auth && state.auth.personaname
+ ? String(state.auth.personaname).trim()
+ : "";
+ const authAvatar =
+ state.page === "home" && state.auth && state.auth.avatar
+ ? String(state.auth.avatar)
+ : "";
+ const name =
+ (profile.personaname && String(profile.personaname).trim()) ||
+ authName ||
+ `玩家 ${profile.account_id}`;
+ const avatarUrl = profile.avatar || authAvatar;
+ if (avatarUrl) {
+ const av = document.createElement("img");
+ av.className = "players-identity-avatar";
+ av.src = avatarUrl;
+ av.alt = name;
+ av.width = 56;
+ av.height = 56;
+ head.appendChild(av);
}
-
- const list = document.createElement("div");
- list.className = "players-recent";
- for (const row of recent) {
- const btn = document.createElement("button");
- btn.type = "button";
- btn.className = `players-recent-row ${row.won ? "won" : "lost"}`;
- const heroKey = row.hero_key || (heroById(row.hero_id) || {}).key;
- const heroName =
- row.hero_name_loc ||
- (heroById(row.hero_id) || {}).name_loc ||
- heroKey ||
- "—";
- if (heroKey) {
- const img = document.createElement("img");
- img.className = "players-recent-portrait";
- img.src = portraitSrc(heroKey);
- img.alt = heroName;
- btn.appendChild(img);
+ const idBody = document.createElement("div");
+ idBody.className = "players-identity-body";
+ const titleRow = document.createElement("div");
+ titleRow.className = "players-profile-title";
+ const h2 = document.createElement("h2");
+ h2.className = "page-title";
+ h2.textContent = name;
+ titleRow.appendChild(h2);
+ const rank = parseRankTier(profile.rank_tier);
+ if (rank && rank.iconFile) {
+ let tip = rank.label;
+ if (profile.leaderboard_rank) tip += ` · 榜 #${profile.leaderboard_rank}`;
+ const medal = createRankMedalEl(
+ { ...rank, label: tip },
+ "rank-medal players-rank-medal"
+ );
+ if (medal) titleRow.appendChild(medal);
+ if (rank.medalKey === "immortal" && profile.leaderboard_rank) {
+ const board = document.createElement("span");
+ board.className = "players-rank-board";
+ board.textContent = `#${profile.leaderboard_rank}`;
+ board.title = tip;
+ titleRow.appendChild(board);
}
- const body = document.createElement("div");
- body.className = "players-recent-body";
- body.innerHTML = `
-
- ${heroName}
- ${row.won ? "胜利" : "失败"}
-
-
- ${row.kills ?? 0}/${row.deaths ?? 0}/${row.assists ?? 0}
- ${fmtDuration(row.duration)}
- #${row.match_id}
-
- `;
- btn.appendChild(body);
- btn.addEventListener("click", () => {
- state.playerMatchId = String(row.match_id);
- state._playerMatch = null;
- syncStateToUrl();
- render();
- });
- list.appendChild(btn);
}
- root.appendChild(list);
+ idBody.appendChild(titleRow);
+ const avail = profile.availability || {};
+ const sub = document.createElement("p");
+ sub.className = "page-sub";
+ const bits = [`ID ${profile.account_id}`];
+ if (profile.public_share) bits.push("已公开主页");
+ if (avail.note) bits.push(avail.note);
+ else if (avail.status === "syncing") bits.push("Steam 已公开,OpenDota 同步中");
+ if (profile.stale || (avail && avail.stale)) bits.push("刷新中");
+ sub.textContent = bits.join(" · ");
+ idBody.appendChild(sub);
+ head.appendChild(idBody);
+ wrap.appendChild(head);
+
+ // --- Snapshot: career + recent_20 side by side ---
+ const career = profile.career;
+ const r20 = profile.recent_20;
+ const hasCareer = career && career.games > 0;
+ const hasR20 = r20 && r20.sample > 0;
+ if (hasCareer || hasR20) {
+ const snap = document.createElement("section");
+ snap.className = "players-snapshot";
+ if (hasCareer) {
+ const col = document.createElement("div");
+ col.className = "players-snapshot-col";
+ col.innerHTML = `生涯
`;
+ appendStatCards(col, [
+ { label: "场次", value: career.games },
+ {
+ label: "胜率",
+ value: career.winrate != null ? `${career.winrate}%` : null,
+ },
+ { label: "KDA", value: career.kda },
+ { label: "场均GPM", value: career.avg_gpm },
+ { label: "场均XPM", value: career.avg_xpm },
+ ]);
+ snap.appendChild(col);
+ }
+ if (hasR20) {
+ const col = document.createElement("div");
+ col.className = "players-snapshot-col";
+ col.innerHTML = `近 ${r20.sample} 场
`;
+ appendStatCards(col, [
+ { label: "胜负", value: `${r20.wins}-${r20.losses}` },
+ {
+ label: "胜率",
+ value: r20.winrate != null ? `${r20.winrate}%` : null,
+ },
+ { label: "KDA", value: r20.kda },
+ { label: "场均GPM", value: r20.avg_gpm },
+ { label: "场均XPM", value: r20.avg_xpm },
+ ]);
+ snap.appendChild(col);
+ }
+ wrap.appendChild(snap);
+ }
+
+ // --- Analysis: top heroes | activity / peers ---
+ const topHeroes = Array.isArray(profile.top_heroes) ? profile.top_heroes : [];
+ const activity = profile.activity_180;
+ const peers = Array.isArray(profile.peers) ? profile.peers : [];
+ const hasActivity = activity && activity.sample > 0;
+ if (topHeroes.length || hasActivity || peers.length) {
+ const analysis = document.createElement("section");
+ analysis.className = "players-analysis";
+
+ if (topHeroes.length) {
+ const sec = document.createElement("div");
+ sec.className = "players-analysis-block";
+ sec.innerHTML = `常用英雄
`;
+ const list = document.createElement("div");
+ list.className = "players-top-heroes";
+ for (const h of topHeroes.slice(0, 5)) {
+ const key = h.hero_key || (heroById(h.hero_id) || {}).key;
+ const row = document.createElement("div");
+ row.className = "players-top-hero";
+ if (key) {
+ const img = document.createElement("img");
+ img.src = portraitSrc(key);
+ img.alt = h.hero_name_loc || key;
+ row.appendChild(img);
+ }
+ const meta = document.createElement("div");
+ meta.innerHTML = `${escapeHtml(
+ h.hero_name_loc || key || "—"
+ )}${h.games} 场 · 胜率 ${
+ h.winrate != null ? h.winrate + "%" : "—"
+ }`;
+ row.appendChild(meta);
+ list.appendChild(row);
+ }
+ sec.appendChild(list);
+ analysis.appendChild(sec);
+ }
+
+ const side = document.createElement("div");
+ side.className = "players-analysis-side";
+ if (hasActivity) {
+ const sec = document.createElement("div");
+ sec.className = "players-analysis-block";
+ sec.innerHTML = `${escapeHtml(
+ activity.label || "最近 180 天"
+ )}
`;
+ appendStatCards(sec, [
+ { label: "样本场次", value: activity.sample },
+ {
+ label: "胜率",
+ value: activity.winrate != null ? `${activity.winrate}%` : null,
+ },
+ ]);
+ if (activity.highs) {
+ const highs = document.createElement("div");
+ highs.className = "players-highs";
+ const hb = [];
+ if (activity.highs.kills) hb.push(`最高击杀 ${activity.highs.kills.value}`);
+ if (activity.highs.assists) {
+ hb.push(`最高助攻 ${activity.highs.assists.value}`);
+ }
+ if (activity.highs.gpm) hb.push(`最高GPM ${activity.highs.gpm.value}`);
+ highs.textContent = hb.join(" · ");
+ if (hb.length) sec.appendChild(highs);
+ }
+ if (Array.isArray(activity.heatmap) && activity.heatmap.length) {
+ const heatWrap = document.createElement("div");
+ heatWrap.className = "players-heatmap-wrap";
+ const heat = document.createElement("div");
+ heat.className = "players-heatmap";
+ heat.title = "每天场次(最近 180 天)";
+ heat.setAttribute("aria-label", "最近 180 天每天比赛场次");
+ const maxG = Math.max(
+ 1,
+ ...activity.heatmap.map((d) => Number(d.games) || 0)
+ );
+ for (const d of activity.heatmap) {
+ const cell = document.createElement("span");
+ const g = Number(d.games) || 0;
+ const level = g <= 0 ? 0 : Math.min(4, Math.ceil((g / maxG) * 4));
+ cell.className = `players-heat-cell lv${level}`;
+ cell.title = `${d.date}: ${g} 场`;
+ heat.appendChild(cell);
+ }
+ heatWrap.appendChild(heat);
+ const legend = document.createElement("div");
+ legend.className = "players-heatmap-legend";
+ legend.innerHTML = `
+ 少
+
+
+
+
+
+ 多
+ `;
+ heatWrap.appendChild(legend);
+ sec.appendChild(heatWrap);
+ }
+ side.appendChild(sec);
+ }
+ if (peers.length) {
+ const sec = document.createElement("div");
+ sec.className = "players-analysis-block";
+ sec.innerHTML = `队友
`;
+ const list = document.createElement("div");
+ list.className = "players-peers";
+ for (const p of peers.slice(0, 6)) {
+ const row = document.createElement("button");
+ row.type = "button";
+ row.className = "players-peer";
+ if (p.avatar) {
+ const img = document.createElement("img");
+ img.src = p.avatar;
+ img.alt = "";
+ row.appendChild(img);
+ }
+ const meta = document.createElement("div");
+ meta.innerHTML = `${escapeHtml(
+ p.personaname || "玩家"
+ )}${p.games} 场 · 胜率 ${
+ p.winrate != null ? p.winrate + "%" : "—"
+ }`;
+ row.appendChild(meta);
+ if (p.account_id) {
+ row.addEventListener("click", () => openPlayerPage(p.account_id));
+ }
+ list.appendChild(row);
+ }
+ sec.appendChild(list);
+ side.appendChild(sec);
+ }
+ if (side.childElementCount) analysis.appendChild(side);
+ wrap.appendChild(analysis);
+ }
+
+ const status = document.createElement("div");
+ status.className = "players-enrich-status rankings-empty";
+ status.hidden = true;
+ wrap.appendChild(status);
+
+ // --- Recent matches (primary list) ---
+ const listSec = document.createElement("section");
+ listSec.className = "players-section players-recent-section";
+ listSec.innerHTML = `近期比赛
`;
+ const listHost = document.createElement("div");
+ listHost.className = "players-recent-host";
+ listSec.appendChild(listHost);
+ wrap.appendChild(listSec);
+ root.appendChild(wrap);
+
+ const paintRecent = (recentRows) => {
+ listHost.replaceChildren();
+ const recent = Array.isArray(recentRows) ? recentRows : [];
+ if (!recent.length) {
+ const empty = document.createElement("div");
+ empty.className = "rankings-empty";
+ empty.textContent =
+ (profile.availability && profile.availability.note) ||
+ "暂无近期比赛。请在 Dota 2 设置中开启「公开比赛数据」;隐私局仍需本机 GSI 录像。";
+ listHost.appendChild(empty);
+ return;
+ }
+ const list = document.createElement("div");
+ list.className = "players-recent";
+ for (const row of recent) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = `players-recent-row ${row.won ? "won" : "lost"}`;
+ const heroKey = row.hero_key || (heroById(row.hero_id) || {}).key;
+ const heroName =
+ row.hero_name_loc ||
+ (heroById(row.hero_id) || {}).name_loc ||
+ heroKey ||
+ "—";
+ if (heroKey) {
+ const img = document.createElement("img");
+ img.className = "players-recent-portrait";
+ img.src = portraitSrc(heroKey);
+ img.alt = heroName;
+ btn.appendChild(img);
+ }
+ const body = document.createElement("div");
+ body.className = "players-recent-body";
+ const when = formatFriendlyTime(row.start_time);
+ const kdaText = `${row.kills ?? 0}/${row.deaths ?? 0}/${row.assists ?? 0}${
+ row.kda != null ? `(${row.kda})` : ""
+ }`;
+ const tipBits = [kdaText];
+ const mode = lobbyLabel(row);
+ if (mode) tipBits.push(mode);
+ if (row.gpm != null) tipBits.push(`GPM ${row.gpm}`);
+ if (row.xpm != null) tipBits.push(`XPM ${row.xpm}`);
+ const kdaTip = tipBits.join(" · ");
+ // hero/KDA (grows left) | duration/when | result/id (tight right cluster)
+ body.innerHTML = `
+
+ ${escapeHtml(heroName)}
+ ${escapeHtml(kdaText)}
+
+
+ ${fmtDuration(row.duration)}
+ ${when.text ? escapeHtml(when.text) : "—"}
+
+
+ ${
+ row.won ? "胜利" : "失败"
+ }
+ #${row.match_id}
+
+ `;
+ btn.appendChild(body);
+ btn.addEventListener("click", async () => {
+ status.hidden = false;
+ status.textContent = "加载比赛详情…";
+ const aid = String(profile.account_id);
+ const { match, error } = await ensurePlayerMatch(aid, row.match_id);
+ if (
+ String(state.playerAccountId) !== aid ||
+ (state.page !== "players" && state.page !== "home")
+ ) {
+ return;
+ }
+ if (!match) {
+ status.textContent = error
+ ? `无法加载:${error}`
+ : "无法加载该场(OpenDota 暂无或隐私)";
+ return;
+ }
+ status.hidden = true;
+ state.playerMatchId = String(row.match_id);
+ state._playerMatch = match;
+ state._playerLoadKey = `${aid}:${row.match_id}`;
+ syncStateToUrl();
+ render();
+ });
+ list.appendChild(btn);
+ }
+ listHost.appendChild(list);
+ };
+
+ paintRecent(profile.recent);
+
+ const enrichKey = `enrich:${profile.account_id}`;
+ if (state._playerEnrichKey !== enrichKey) {
+ state._playerEnrichKey = enrichKey;
+ const aid = profile.account_id;
+ // Has stats: keep first paint. Soft TTL refresh is silent (Queue / local bg).
+ if (!playerProfileNeedsRefresh(profile)) {
+ status.hidden = true;
+ if (playerProfileIsSoftStale(profile) && state.page === "home") {
+ void (async () => {
+ const updated = await fetchMyPlayerProfile();
+ if (!updated || !playerProfileHasStats(updated)) return;
+ if (String(state.playerAccountId) !== String(aid)) return;
+ if (state.playerMatchId) return;
+ if (
+ updated.enriched_at === profile.enriched_at &&
+ (updated.recent || []).length === (profile.recent || []).length
+ ) {
+ return;
+ }
+ state._playerProfile = updated;
+ renderPlayerProfile(root, updated);
+ })();
+ }
+ return;
+ }
+ // Empty shell only: poll Worker/local fill, then one local enrich.
+ status.hidden = false;
+ status.textContent = "正在同步战绩…";
+ (async () => {
+ let updated = null;
+ if (state.page === "home") {
+ updated = await pollMyPlayerProfile({ accountId: aid });
+ }
+ if (!updated || playerProfileNeedsRefresh(updated)) {
+ updated = await enrichPlayerProfile(aid, {
+ includeGsi: true,
+ force: false,
+ });
+ }
+ if (String(state.playerAccountId) !== String(aid)) return;
+ if (state.playerMatchId) return;
+ if (updated && playerProfileHasStats(updated)) {
+ state._playerProfile = updated;
+ // Keep loadKey — never flash「加载中…」for a background sync.
+ renderPlayerProfile(root, updated);
+ return;
+ }
+ status.hidden = true;
+ })();
+ }
}
function renderPlayersPage() {
const root = $("#players-body");
if (!root) return;
+ if (state.page === "home") {
+ const aid = authAccountId();
+ if (!aid) {
+ renderHomeGate(root);
+ return;
+ }
+ if (String(state.playerAccountId || "") !== aid) {
+ state.playerAccountId = aid;
+ state._playerLoadKey = null;
+ state._playerProfile = null;
+ state._playerMatch = null;
+ state._playerEnrichKey = null;
+ }
+ }
+
const accountId = state.playerAccountId;
if (!accountId) {
root.innerHTML =
@@ -6302,9 +7066,25 @@ function renderPlayersPage() {
let match = null;
try {
if (state.playerMatchId) {
- match = await fetchPlayerJson(accountId, state.playerMatchId);
+ const ensured = await ensurePlayerMatch(accountId, state.playerMatchId);
+ match = ensured.match;
+ if (!match) {
+ match = await fetchPlayerJson(accountId, state.playerMatchId);
+ }
+ } else if (state.page === "home") {
+ // Leave「加载中」as soon as /me returns (even an empty syncing shell).
+ // Hard enrich runs after first paint inside renderPlayerProfile.
+ profile = await fetchMyPlayerProfile();
} else {
profile = await fetchPlayerJson(accountId);
+ if (!profile) {
+ // Cold miss on /players/{id}: sync once (TTL on later visits).
+ profile = await enrichPlayerProfile(accountId, {
+ includeGsi: true,
+ force: false,
+ });
+ if (profile && profile.error) profile = null;
+ }
}
} catch (_) {
/* empty */
@@ -6321,6 +7101,7 @@ function renderPlayersPage() {
function setPage(page) {
if (
+ page !== "home" &&
page !== "heroes" &&
page !== "rankings" &&
page !== "matches" &&
@@ -6344,6 +7125,19 @@ function setPage(page) {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
+ } else if (page === "home") {
+ state.selectedKey = null;
+ state.selectedItemKey = null;
+ state.inspect = null;
+ const aid = authAccountId();
+ if (String(state.playerAccountId || "") !== String(aid || "")) {
+ state._playerLoadKey = null;
+ state._playerProfile = null;
+ state._playerMatch = null;
+ state._playerEnrichKey = null;
+ }
+ state.playerAccountId = aid;
+ state.playerMatchId = null;
} else if (page === "players") {
state.selectedKey = null;
state.selectedItemKey = null;
@@ -6373,6 +7167,7 @@ function setPage(page) {
}
function syncChrome() {
+ syncAuthChrome();
document.querySelectorAll(".main-tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.page === state.page);
});
@@ -6389,6 +7184,7 @@ function syncChrome() {
const mechanicsView = $("#mechanics-view");
const itemsView = $("#items-view");
const patchesView = $("#patches-view");
+ const showPlayers = state.page === "players" || state.page === "home";
if (heroesTb) heroesTb.classList.toggle("hidden", state.page !== "heroes");
if (detailDrawer) detailDrawer.classList.toggle("hidden", state.page !== "heroes");
if (heroSearch) heroSearch.classList.toggle("hidden", state.page !== "heroes");
@@ -6396,7 +7192,7 @@ function syncChrome() {
if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes");
if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings");
if (matchesView) matchesView.classList.toggle("hidden", state.page !== "matches");
- if (playersView) playersView.classList.toggle("hidden", state.page !== "players");
+ if (playersView) playersView.classList.toggle("hidden", !showPlayers);
if (streamersView) streamersView.classList.toggle("hidden", state.page !== "streamers");
if (trendsView) trendsView.classList.toggle("hidden", state.page !== "trends");
if (mechanicsView) mechanicsView.classList.toggle("hidden", state.page !== "mechanics");
@@ -6416,7 +7212,7 @@ function render() {
renderRankings();
} else if (state.page === "matches") {
renderMatchesPage();
- } else if (state.page === "players") {
+ } else if (state.page === "players" || state.page === "home") {
renderPlayersPage();
} else if (state.page === "streamers") {
renderStreamers();
@@ -6453,6 +7249,7 @@ function applyPatch(patch) {
if (
patch.page &&
[
+ "home",
"heroes",
"rankings",
"matches",
@@ -6540,12 +7337,17 @@ function applyPatch(patch) {
state.matchesPage =
Number.isFinite(mp) && mp >= 1 ? Math.floor(mp) : 1;
}
- // PC post-match player pages.
- if (state.page === "players") {
- const nextAccount =
- patch.playerAccountId && /^\d+$/.test(String(patch.playerAccountId))
- ? String(patch.playerAccountId)
- : null;
+ // PC post-match player pages + logged-in /home.
+ if (state.page === "players" || state.page === "home") {
+ let nextAccount = null;
+ if (state.page === "home") {
+ nextAccount = authAccountId();
+ } else if (
+ patch.playerAccountId &&
+ /^\d+$/.test(String(patch.playerAccountId))
+ ) {
+ nextAccount = String(patch.playerAccountId);
+ }
const nextMatch =
patch.playerMatchId && /^\d+$/.test(String(patch.playerMatchId))
? String(patch.playerMatchId)
@@ -6557,6 +7359,9 @@ function applyPatch(patch) {
state._playerLoadKey = null;
state._playerProfile = null;
state._playerMatch = null;
+ if (nextAccount !== state.playerAccountId) {
+ state._playerEnrichKey = null;
+ }
}
state.playerAccountId = nextAccount;
state.playerMatchId = nextMatch;
@@ -6566,6 +7371,7 @@ function applyPatch(patch) {
state._playerLoadKey = null;
state._playerProfile = null;
state._playerMatch = null;
+ state._playerEnrichKey = null;
}
// Item (items page) — must exist in the shop catalog.
if (patch.itemKey && shopItem(patch.itemKey)) {
@@ -6721,6 +7527,7 @@ async function main() {
);
bindSearch();
bindPageChrome();
+ bindAuthChrome();
bindHeroesDismiss();
const brandLogo = document.querySelector(".brand-logo");
if (brandLogo) brandLogo.src = assetUrl("/ui-icon/dota2_logo_wordmark.png");
@@ -6730,6 +7537,8 @@ async function main() {
const res = await fetch("/data.json");
if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`);
state.data = await res.json();
+ await fetchAuthMe();
+ syncAuthChrome();
state.data.relations = state.data.relations || { counters: [], synergies: [] };
state.data.hero_items = state.data.hero_items || { items: {}, by_hero: {} };
state.data.hero_stats = state.data.hero_stats || {
diff --git a/web/frontend/config.js b/web/frontend/config.js
index 44b21b9..555afa3 100644
--- a/web/frontend/config.js
+++ b/web/frontend/config.js
@@ -1,5 +1,5 @@
/* Local defaults; production export overwrites via export_relations_site.py. */
-var SITE_VERSION = "0.6.16";
+var SITE_VERSION = "0.6.54";
var SITE_ORIGIN = "";
var ABILITY_VIDEO_BASE = "";
var STATIC_ASSET_BASE = "";
diff --git a/web/frontend/functions/api/auth/_steam_common.js b/web/frontend/functions/api/auth/_steam_common.js
new file mode 100644
index 0000000..5de2822
--- /dev/null
+++ b/web/frontend/functions/api/auth/_steam_common.js
@@ -0,0 +1,209 @@
+/**
+ * Shared Steam OpenID + signed-session helpers for Pages Functions.
+ *
+ * Env: STEAM_API_KEY, SESSION_SECRET (required for login/me).
+ * Cookie: climperor_steam (HttpOnly, signed payload).
+ */
+
+const COOKIE_NAME = "climperor_steam";
+const SESSION_DAYS = 30;
+const STEAM_OPENID = "https://steamcommunity.com/openid/login";
+const STEAM_ID_PREFIX = "https://steamcommunity.com/openid/id/";
+
+export function jsonResponse(body, status = 200, extraHeaders = {}) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ "Cache-Control": "no-store",
+ ...extraHeaders,
+ },
+ });
+}
+
+export function envOf(context) {
+ return (context && context.env) || {};
+}
+
+function b64urlEncode(bytes) {
+ let bin = "";
+ const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
+ for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]);
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
+}
+
+function b64urlDecode(str) {
+ const pad = "=".repeat((4 - (str.length % 4)) % 4);
+ const b64 = (str + pad).replace(/-/g, "+").replace(/_/g, "/");
+ const bin = atob(b64);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+
+async function hmacSign(secret, message) {
+ const enc = new TextEncoder();
+ const key = await crypto.subtle.importKey(
+ "raw",
+ enc.encode(secret),
+ { name: "HMAC", hash: "SHA-256" },
+ false,
+ ["sign"]
+ );
+ const sig = await crypto.subtle.sign("HMAC", key, enc.encode(message));
+ return b64urlEncode(sig);
+}
+
+export function steamId64ToAccountId(steamId64) {
+ try {
+ const n = BigInt(String(steamId64));
+ const account = n - 76561197960265728n;
+ if (account <= 0n) return null;
+ return Number(account);
+ } catch {
+ return null;
+ }
+}
+
+export function parseSteamIdFromClaimedId(claimedId) {
+ if (!claimedId || typeof claimedId !== "string") return null;
+ if (!claimedId.startsWith(STEAM_ID_PREFIX)) return null;
+ const id = claimedId.slice(STEAM_ID_PREFIX.length).replace(/\/$/, "");
+ if (!/^\d{17}$/.test(id)) return null;
+ return id;
+}
+
+export async function createSessionToken(secret, payload) {
+ const body = {
+ ...payload,
+ exp: Math.floor(Date.now() / 1000) + SESSION_DAYS * 86400,
+ };
+ const raw = b64urlEncode(new TextEncoder().encode(JSON.stringify(body)));
+ const sig = await hmacSign(secret, raw);
+ return `${raw}.${sig}`;
+}
+
+export async function verifySessionToken(secret, token) {
+ if (!secret || !token || typeof token !== "string") return null;
+ const parts = token.split(".");
+ if (parts.length !== 2) return null;
+ const [raw, sig] = parts;
+ const expect = await hmacSign(secret, raw);
+ if (sig.length !== expect.length) return null;
+ let ok = 0;
+ for (let i = 0; i < sig.length; i++) ok |= sig.charCodeAt(i) ^ expect.charCodeAt(i);
+ if (ok !== 0) return null;
+ try {
+ const json = new TextDecoder().decode(b64urlDecode(raw));
+ const data = JSON.parse(json);
+ if (!data || !data.exp || data.exp < Math.floor(Date.now() / 1000)) return null;
+ if (!data.steamid || !data.account_id) return null;
+ return data;
+ } catch {
+ return null;
+ }
+}
+
+export function readCookie(request, name = COOKIE_NAME) {
+ const header = request.headers.get("Cookie") || "";
+ const parts = header.split(";").map((s) => s.trim());
+ for (const p of parts) {
+ if (p.startsWith(name + "=")) {
+ return decodeURIComponent(p.slice(name.length + 1));
+ }
+ }
+ return null;
+}
+
+function sessionCookieHeader(token, { clear = false } = {}) {
+ if (clear) {
+ return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
+ }
+ const maxAge = SESSION_DAYS * 86400;
+ return `${COOKIE_NAME}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
+}
+
+/** Add Secure on non-localhost. */
+export function sessionCookieHeaderForRequest(request, token, { clear = false } = {}) {
+ let base = sessionCookieHeader(token, { clear });
+ const host = new URL(request.url).hostname;
+ if (host !== "127.0.0.1" && host !== "localhost") {
+ base = base.replace("SameSite=Lax", "Secure; SameSite=Lax");
+ }
+ return base;
+}
+
+export function steamLoginRedirectUrl(origin) {
+ const returnTo = `${origin}/api/auth/steam/callback`;
+ const params = new URLSearchParams({
+ "openid.ns": "http://specs.openid.net/auth/2.0",
+ "openid.mode": "checkid_setup",
+ "openid.return_to": returnTo,
+ "openid.realm": origin,
+ "openid.identity": "http://specs.openid.net/auth/2.0/identifier_select",
+ "openid.claimed_id": "http://specs.openid.net/auth/2.0/identifier_select",
+ });
+ return `${STEAM_OPENID}?${params.toString()}`;
+}
+
+export async function verifySteamOpenId(query) {
+ const mode = query.get("openid.mode");
+ if (mode !== "id_res") return { ok: false, error: "bad mode" };
+ const claimed = query.get("openid.claimed_id");
+ const steamid = parseSteamIdFromClaimedId(claimed);
+ if (!steamid) return { ok: false, error: "bad claimed_id" };
+
+ const body = new URLSearchParams();
+ for (const [k, v] of query.entries()) {
+ if (k.startsWith("openid.")) body.set(k, v);
+ }
+ body.set("openid.mode", "check_authentication");
+
+ const res = await fetch(STEAM_OPENID, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-www-form-urlencoded",
+ "User-Agent": "climperor-steam-auth",
+ },
+ body,
+ });
+ const text = await res.text();
+ if (!/is_valid\s*:\s*true/i.test(text)) {
+ return { ok: false, error: "openid invalid" };
+ }
+ return { ok: true, steamid };
+}
+
+export async function fetchSteamPersona(apiKey, steamid) {
+ if (!apiKey) return { personaname: null, avatar: null };
+ const url = new URL(
+ "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/"
+ );
+ url.searchParams.set("key", apiKey);
+ url.searchParams.set("steamids", steamid);
+ try {
+ const res = await fetch(url.toString(), {
+ headers: { "User-Agent": "climperor-steam-auth" },
+ });
+ if (!res.ok) return { personaname: null, avatar: null };
+ const data = await res.json();
+ const players = data && data.response && data.response.players;
+ const p = Array.isArray(players) && players[0] ? players[0] : null;
+ if (!p) return { personaname: null, avatar: null };
+ return {
+ personaname: p.personaname || null,
+ avatar: p.avatarfull || p.avatarmedium || p.avatar || null,
+ };
+ } catch {
+ return { personaname: null, avatar: null };
+ }
+}
+
+export async function sessionFromRequest(context) {
+ const env = envOf(context);
+ const secret = (env.SESSION_SECRET || "").trim();
+ if (!secret) return null;
+ const token = readCookie(context.request);
+ if (!token) return null;
+ return verifySessionToken(secret, token);
+}
diff --git a/web/frontend/functions/api/auth/logout.js b/web/frontend/functions/api/auth/logout.js
new file mode 100644
index 0000000..6e4afa2
--- /dev/null
+++ b/web/frontend/functions/api/auth/logout.js
@@ -0,0 +1,34 @@
+/**
+ * POST|GET /api/auth/logout — clear session cookie.
+ */
+import { jsonResponse, sessionCookieHeaderForRequest } from "./_steam_common.js";
+
+function clear(context) {
+ const origin = new URL(context.request.url).origin;
+ const wantsHtml = (context.request.headers.get("Accept") || "").includes("text/html");
+ if (wantsHtml || context.request.method === "GET") {
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: `${origin}/`,
+ "Set-Cookie": sessionCookieHeaderForRequest(context.request, "", { clear: true }),
+ "Cache-Control": "no-store",
+ },
+ });
+ }
+ return jsonResponse(
+ { ok: true },
+ 200,
+ {
+ "Set-Cookie": sessionCookieHeaderForRequest(context.request, "", { clear: true }),
+ }
+ );
+}
+
+export async function onRequestGet(context) {
+ return clear(context);
+}
+
+export async function onRequestPost(context) {
+ return clear(context);
+}
diff --git a/web/frontend/functions/api/auth/me.js b/web/frontend/functions/api/auth/me.js
new file mode 100644
index 0000000..9d52dab
--- /dev/null
+++ b/web/frontend/functions/api/auth/me.js
@@ -0,0 +1,22 @@
+/**
+ * GET /api/auth/me — current Steam session (or { authenticated: false }).
+ */
+import { jsonResponse, sessionFromRequest } from "./_steam_common.js";
+
+export async function onRequestGet(context) {
+ try {
+ const session = await sessionFromRequest(context);
+ if (!session) {
+ return jsonResponse({ authenticated: false });
+ }
+ return jsonResponse({
+ authenticated: true,
+ steamid: session.steamid,
+ account_id: session.account_id,
+ personaname: session.personaname || null,
+ avatar: session.avatar || null,
+ });
+ } catch {
+ return jsonResponse({ authenticated: false });
+ }
+}
diff --git a/web/frontend/functions/api/auth/steam.js b/web/frontend/functions/api/auth/steam.js
new file mode 100644
index 0000000..4eea0b8
--- /dev/null
+++ b/web/frontend/functions/api/auth/steam.js
@@ -0,0 +1,13 @@
+/**
+ * GET /api/auth/steam — redirect to Steam OpenID login.
+ */
+import { envOf, steamLoginRedirectUrl } from "./_steam_common.js";
+
+export async function onRequestGet(context) {
+ const env = envOf(context);
+ if (!(env.SESSION_SECRET || "").trim() || !(env.STEAM_API_KEY || "").trim()) {
+ return new Response("Steam login not configured", { status: 503 });
+ }
+ const origin = new URL(context.request.url).origin;
+ return Response.redirect(steamLoginRedirectUrl(origin), 302);
+}
diff --git a/web/frontend/functions/api/auth/steam/callback.js b/web/frontend/functions/api/auth/steam/callback.js
new file mode 100644
index 0000000..0d7aacc
--- /dev/null
+++ b/web/frontend/functions/api/auth/steam/callback.js
@@ -0,0 +1,56 @@
+/**
+ * GET /api/auth/steam/callback — Steam OpenID return_to.
+ */
+import {
+ createSessionToken,
+ envOf,
+ fetchSteamPersona,
+ sessionCookieHeaderForRequest,
+ steamId64ToAccountId,
+ verifySteamOpenId,
+} from "../_steam_common.js";
+
+export async function onRequestGet(context) {
+ const env = envOf(context);
+ const secret = (env.SESSION_SECRET || "").trim();
+ const apiKey = (env.STEAM_API_KEY || "").trim();
+ const origin = new URL(context.request.url).origin;
+
+ if (!secret || !apiKey) {
+ return Response.redirect(`${origin}/?auth=unconfigured`, 302);
+ }
+
+ const url = new URL(context.request.url);
+ let verified;
+ try {
+ verified = await verifySteamOpenId(url.searchParams);
+ } catch {
+ return Response.redirect(`${origin}/?auth=error`, 302);
+ }
+ if (!verified.ok) {
+ return Response.redirect(`${origin}/?auth=denied`, 302);
+ }
+
+ const steamid = verified.steamid;
+ const accountId = steamId64ToAccountId(steamid);
+ if (!accountId) {
+ return Response.redirect(`${origin}/?auth=error`, 302);
+ }
+
+ const persona = await fetchSteamPersona(apiKey, steamid);
+ const token = await createSessionToken(secret, {
+ steamid,
+ account_id: accountId,
+ personaname: persona.personaname,
+ avatar: persona.avatar,
+ });
+
+ return new Response(null, {
+ status: 302,
+ headers: {
+ Location: `${origin}/home`,
+ "Set-Cookie": sessionCookieHeaderForRequest(context.request, token),
+ "Cache-Control": "no-store",
+ },
+ });
+}
diff --git a/web/frontend/functions/api/players/[account_id].js b/web/frontend/functions/api/players/[account_id].js
new file mode 100644
index 0000000..8a4efa0
--- /dev/null
+++ b/web/frontend/functions/api/players/[account_id].js
@@ -0,0 +1,29 @@
+/**
+ * GET /api/players/:account_id — public or self profile from D1.
+ */
+import { sessionFromRequest } from "../auth/_steam_common.js";
+import { jsonResponse, loadPlayerBundle } from "./_db.js";
+
+export async function onRequestGet(context) {
+ try {
+ const env = context.env || {};
+ if (!env.DB) return jsonResponse({ error: "database not configured" }, 503);
+ const accountId = Number(context.params && context.params.account_id);
+ if (!Number.isFinite(accountId) || accountId <= 0) {
+ return jsonResponse({ error: "bad account_id" }, 400);
+ }
+ const session = await sessionFromRequest(context);
+ const self = session && Number(session.account_id) === accountId;
+ const bundle = await loadPlayerBundle(env.DB, accountId);
+ if (!bundle) return jsonResponse({ error: "not found" }, 404);
+ if (!self && !bundle.public_share) {
+ return jsonResponse({ error: "private" }, 404);
+ }
+ return jsonResponse(bundle);
+ } catch (e) {
+ return jsonResponse(
+ { error: "player get failed", detail: String((e && e.message) || e) },
+ 500
+ );
+ }
+}
diff --git a/web/frontend/functions/api/players/[account_id]/[match_id].js b/web/frontend/functions/api/players/[account_id]/[match_id].js
new file mode 100644
index 0000000..869b932
--- /dev/null
+++ b/web/frontend/functions/api/players/[account_id]/[match_id].js
@@ -0,0 +1,49 @@
+/**
+ * GET /api/players/:account_id/:match_id — match detail from R2 (authz via D1).
+ */
+import { sessionFromRequest } from "../../auth/_steam_common.js";
+import { jsonResponse } from "../_db.js";
+
+export async function onRequestGet(context) {
+ try {
+ const env = context.env || {};
+ if (!env.DB) return jsonResponse({ error: "database not configured" }, 503);
+ const accountId = Number(context.params && context.params.account_id);
+ const matchId = Number(context.params && context.params.match_id);
+ if (!Number.isFinite(accountId) || accountId <= 0 || !Number.isFinite(matchId)) {
+ return jsonResponse({ error: "bad ids" }, 400);
+ }
+ const session = await sessionFromRequest(context);
+ const self = session && Number(session.account_id) === accountId;
+ const user = await env.DB.prepare(
+ `SELECT public_share FROM users WHERE account_id = ?`
+ )
+ .bind(accountId)
+ .first();
+ if (!user) return jsonResponse({ error: "not found" }, 404);
+ if (!self && !user.public_share) return jsonResponse({ error: "private" }, 404);
+
+ const row = await env.DB.prepare(
+ `SELECT r2_key FROM player_matches WHERE account_id = ? AND match_id = ?`
+ )
+ .bind(accountId, matchId)
+ .first();
+ const key = (row && row.r2_key) || `matches/${matchId}.json`;
+ if (!env.MATCHES) return jsonResponse({ error: "storage not configured" }, 503);
+ const obj = await env.MATCHES.get(key);
+ if (!obj) return jsonResponse({ error: "match not found" }, 404);
+ const text = await obj.text();
+ return new Response(text, {
+ status: 200,
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ "Cache-Control": "no-store",
+ },
+ });
+ } catch (e) {
+ return jsonResponse(
+ { error: "match get failed", detail: String((e && e.message) || e) },
+ 500
+ );
+ }
+}
\ No newline at end of file
diff --git a/web/frontend/functions/api/players/_db.js b/web/frontend/functions/api/players/_db.js
new file mode 100644
index 0000000..b78284d
--- /dev/null
+++ b/web/frontend/functions/api/players/_db.js
@@ -0,0 +1,151 @@
+/** D1 helpers for Pages Functions (subset of cloudflare/player-sync/src/db.js). */
+
+export function utcNow() {
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
+}
+
+export function jsonResponse(body, status = 200, extra = {}) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ "Cache-Control": "no-store",
+ ...extra,
+ },
+ });
+}
+
+export async function loadPlayerBundle(db, accountId) {
+ if (!db) return null;
+ 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,
+ };
+}
+
+export function isStale(fetchedAt, maxAgeMs = 10 * 60 * 1000) {
+ if (!fetchedAt) return true;
+ const t = Date.parse(fetchedAt);
+ if (!Number.isFinite(t)) return true;
+ return Date.now() - t > maxAgeMs;
+}
+
+export async function enqueueRefresh(env, payload) {
+ if (!env.SYNC_QUEUE) return false;
+ const jobId = crypto.randomUUID();
+ const now = utcNow();
+ if (env.DB) {
+ await env.DB.prepare(
+ `INSERT INTO sync_jobs (id, account_id, kind, match_id, status, attempts, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 'queued', 0, ?, ?)`
+ )
+ .bind(
+ jobId,
+ payload.account_id,
+ payload.kind || "login_refresh",
+ payload.match_id || null,
+ now,
+ now
+ )
+ .run();
+ }
+ await env.SYNC_QUEUE.send({ ...payload, job_id: jobId });
+ return true;
+}
diff --git a/web/frontend/functions/api/players/me.js b/web/frontend/functions/api/players/me.js
new file mode 100644
index 0000000..2ad8eb6
--- /dev/null
+++ b/web/frontend/functions/api/players/me.js
@@ -0,0 +1,92 @@
+/**
+ * GET /api/players/me — logged-in user's profile from D1; enqueue refresh if stale.
+ */
+import { sessionFromRequest } from "../auth/_steam_common.js";
+import {
+ enqueueRefresh,
+ isStale,
+ jsonResponse,
+ loadPlayerBundle,
+} from "./_db.js";
+
+export async function onRequestGet(context) {
+ try {
+ const session = await sessionFromRequest(context);
+ if (!session || !session.account_id) {
+ return jsonResponse({ authenticated: false }, 401);
+ }
+ const env = context.env || {};
+ const accountId = Number(session.account_id);
+ if (!env.DB) {
+ return jsonResponse(
+ { error: "database not configured", account_id: accountId },
+ 503
+ );
+ }
+
+ // Ensure user row exists for first login.
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
+ await env.DB.prepare(
+ `INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
+ VALUES (?, ?, ?, ?, 0, ?, ?)
+ ON CONFLICT(account_id) DO UPDATE SET
+ personaname=COALESCE(excluded.personaname, users.personaname),
+ avatar=COALESCE(excluded.avatar, users.avatar),
+ last_login_at=excluded.last_login_at`
+ )
+ .bind(
+ accountId,
+ String(session.steamid || ""),
+ session.personaname || null,
+ session.avatar || null,
+ now,
+ now
+ )
+ .run();
+
+ let bundle = await loadPlayerBundle(env.DB, accountId);
+ const fetchedAt =
+ (bundle && bundle.availability && bundle.availability.fetched_at) ||
+ (bundle && bundle.enriched_at) ||
+ null;
+ let stale = !bundle || isStale(fetchedAt);
+ if (stale) {
+ await enqueueRefresh(env, {
+ kind: "login_refresh",
+ account_id: accountId,
+ steamid: session.steamid,
+ personaname: session.personaname,
+ avatar: session.avatar,
+ });
+ }
+ if (!bundle) {
+ return jsonResponse({
+ authenticated: true,
+ account_id: accountId,
+ personaname: session.personaname || null,
+ avatar: session.avatar || null,
+ public_share: false,
+ recent: [],
+ career: null,
+ recent_20: null,
+ top_heroes: [],
+ peers: [],
+ activity_180: null,
+ availability: {
+ status: "unknown",
+ note: "正在同步…",
+ complete: false,
+ stale: true,
+ },
+ stale: true,
+ });
+ }
+ if (bundle.availability) bundle.availability.stale = stale;
+ return jsonResponse({ ...bundle, authenticated: true, stale });
+ } catch (e) {
+ return jsonResponse(
+ { error: "me failed", detail: String((e && e.message) || e) },
+ 500
+ );
+ }
+}
diff --git a/web/frontend/functions/api/players/publish.js b/web/frontend/functions/api/players/publish.js
index 3139d92..8cd86a9 100644
--- a/web/frontend/functions/api/players/publish.js
+++ b/web/frontend/functions/api/players/publish.js
@@ -1,35 +1,12 @@
/**
- * Pages Function: POST /api/players/publish
+ * POST /api/players/publish
*
* Body: { account_id, match_id }
- * Optional header: X-Climperor-Publish-Secret (when PLAYER_PAGES_PUBLISH_SECRET set).
+ * Optional header: X-Climperor-Publish-Secret
*
- * Fetches OpenDota match, verifies account_id is in the lobby, normalizes Max+-style
- * JSON, merges profile.recent, PUTs to Aliyun OSS:
- * players/{account_id}/profile.json
- * players/{account_id}/matches/{match_id}.json
- *
- * Secrets (Pages env): OSS_ACCESS_KEY_ID, OSS_ACCESS_KEY_SECRET,
- * optional OSS_BUCKET, OSS_ENDPOINT, PLAYER_PAGES_PUBLISH_SECRET, OPENDOTA_API_KEY.
- *
- * Soft-fail: match not ready → 202; bad membership → 403; never echo secrets.
+ * Enqueues Cloudflare Queue sync (D1 + R2). No longer writes OSS profile JSON.
*/
-
-const OPENDOTA = "https://api.opendota.com/api";
-const DEFAULT_BUCKET = "climperor";
-const DEFAULT_ENDPOINT = "oss-cn-shanghai.aliyuncs.com";
-const RECENT_LIMIT = 30;
-
-function jsonResponse(body, status = 200, extraHeaders = {}) {
- return new Response(JSON.stringify(body), {
- status,
- headers: {
- "Content-Type": "application/json; charset=utf-8",
- "Cache-Control": "no-store",
- ...extraHeaders,
- },
- });
-}
+import { enqueueRefresh, jsonResponse } from "./_db.js";
function envOf(context) {
return (context && context.env) || {};
@@ -40,278 +17,14 @@ function intField(v, fallback = 0) {
return Number.isFinite(n) ? Math.trunc(n) : fallback;
}
-function kda(kills, deaths, assists) {
- return Math.round(((kills + assists) / Math.max(deaths, 1)) * 10) / 10;
-}
-
-function mvpScore(p) {
- const k = intField(p.kills);
- const d = intField(p.deaths);
- const a = intField(p.assists);
- const dmg = intField(p.hero_damage);
- const nw = intField(p.net_worth) || intField(p.gold) + intField(p.gold_spent);
- return (k * 1.5 + a + dmg / 1000 + nw / 2000) / Math.max(d, 1);
-}
-
-function itemIds(player) {
- const out = [];
- for (let i = 0; i < 6; i++) {
- const id = intField(player[`item_${i}`]);
- if (id > 0) out.push(id);
- }
- return out;
-}
-
-function accountInMatch(match, accountId) {
- const players = match.players || [];
- for (const p of players) {
- if (p && intField(p.account_id, -1) === accountId) return true;
- }
- return false;
-}
-
-function utcNow() {
- return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
-}
-
-async function fetchJson(url, { headers } = {}) {
- const res = await fetch(url, {
- headers: { Accept: "application/json", "User-Agent": "climperor-publish", ...(headers || {}) },
- });
- if (!res.ok) {
- const err = new Error(`HTTP ${res.status}`);
- err.status = res.status;
- throw err;
- }
- return res.json();
-}
-
-async function loadHeroMap(opendotaKey) {
- const q = opendotaKey ? `?api_key=${encodeURIComponent(opendotaKey)}` : "";
- try {
- const rows = await fetchJson(`${OPENDOTA}/heroes${q}`);
- const map = new Map();
- if (Array.isArray(rows)) {
- for (const h of rows) {
- if (!h || h.id == null) continue;
- const key = String(h.name || "").replace(/^npc_dota_hero_/, "") || null;
- map.set(intField(h.id), {
- key,
- name_loc: h.localized_name || key,
- });
- }
- }
- return map;
- } catch {
- return new Map();
- }
-}
-
-function normalizeMatch(match, focusAccountId, heroMap) {
- const playersRaw = match.players;
- if (!Array.isArray(playersRaw) || !playersRaw.length) return null;
- const matchId = intField(match.match_id);
- if (matchId <= 0) return null;
-
- const radiantWin = !!match.radiant_win;
- 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 = intField(p.player_slot);
- const isRadiant = slot < 128;
- const side = isRadiant ? 0 : 1;
- const kills = intField(p.kills);
- const deaths = intField(p.deaths);
- const assists = intField(p.assists);
- const heroDamage = intField(p.hero_damage);
- let netWorth = intField(p.net_worth);
- if (netWorth <= 0) netWorth = intField(p.gold) + intField(p.gold_spent);
- teamKills[side] += kills;
- teamNw[side] += netWorth;
- teamDmg[side] += heroDamage;
-
- const heroId = intField(p.hero_id);
- const hero = heroMap.get(heroId) || {};
- let accountId = null;
- if (p.account_id != null) {
- const a = intField(p.account_id, -1);
- accountId = a >= 0 ? a : null;
- }
- let personaname = typeof p.personaname === "string" ? p.personaname.trim() : null;
- if (!personaname) personaname = null;
-
- slim.push({
- account_id: accountId,
- personaname,
- hero_id: heroId,
- hero_key: hero.key || null,
- hero_name_loc: hero.name_loc || hero.key || null,
- level: intField(p.level),
- kills,
- deaths,
- assists,
- kda: kda(kills, deaths, assists),
- hero_damage: heroDamage,
- net_worth: netWorth,
- items: itemIds(p),
- is_radiant: isRadiant,
- won: isRadiant ? radiantWin : !radiantWin,
- _mvp: mvpScore(p),
- _side: side,
- });
- }
-
- if (slim.length < 2) return null;
-
- 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;
- }
-
- let mvp = slim[0];
- for (const p of slim) {
- if (p._mvp > mvp._mvp) mvp = p;
- }
- const mvpAccount = mvp.account_id;
- for (const p of slim) {
- p.is_mvp = mvpAccount != null && p.account_id === mvpAccount;
- delete p._mvp;
- delete p._side;
- }
-
- let startTime = null;
- if (match.start_time != null) {
- const t = intField(match.start_time, -1);
- startTime = t >= 0 ? t : null;
- }
-
- return {
- match_id: matchId,
- start_time: startTime,
- duration: intField(match.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: utcNow(),
- source: "opendota",
- };
-}
-
-function summaryForProfile(detail, accountId) {
- const focus = (detail.players || []).find((p) => p.account_id === accountId);
- if (!focus) return null;
- return {
- match_id: detail.match_id,
- start_time: detail.start_time,
- duration: detail.duration,
- won: !!focus.won,
- hero_id: focus.hero_id,
- hero_key: focus.hero_key,
- hero_name_loc: focus.hero_name_loc,
- kills: focus.kills,
- deaths: focus.deaths,
- assists: focus.assists,
- kda: focus.kda,
- };
-}
-
-async function ossGetJson(env, key) {
- const bucket = env.OSS_BUCKET || DEFAULT_BUCKET;
- const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT;
- const url = `https://${bucket}.${endpoint}/${key}`;
- try {
- const res = await fetch(url, { headers: { Accept: "application/json" } });
- if (!res.ok) return null;
- return await res.json();
- } catch {
- return null;
- }
-}
-
-async function hmacSha1Base64(secret, stringToSign) {
- const enc = new TextEncoder();
- const key = await crypto.subtle.importKey(
- "raw",
- enc.encode(secret),
- { name: "HMAC", hash: "SHA-1" },
- false,
- ["sign"]
- );
- const sig = await crypto.subtle.sign("HMAC", key, enc.encode(stringToSign));
- const bytes = new Uint8Array(sig);
- let bin = "";
- for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
- return btoa(bin);
-}
-
-async function ossPutJson(env, key, obj) {
- const accessKeyId = env.OSS_ACCESS_KEY_ID;
- const accessKeySecret = env.OSS_ACCESS_KEY_SECRET;
- if (!accessKeyId || !accessKeySecret) {
- const err = new Error("OSS credentials missing");
- err.status = 503;
- throw err;
- }
- const bucket = env.OSS_BUCKET || DEFAULT_BUCKET;
- const endpoint = env.OSS_ENDPOINT || DEFAULT_ENDPOINT;
- const body = JSON.stringify(obj);
- const contentType = "application/json; charset=utf-8";
- const date = new Date().toUTCString();
- const resource = `/${bucket}/${key}`;
- // Rely on bucket/prefix public-read policy (no x-oss-object-acl; some buckets disallow ACL).
- const stringToSign = `PUT\n\n${contentType}\n${date}\n${resource}`;
- const signature = await hmacSha1Base64(accessKeySecret, stringToSign);
- const url = `https://${bucket}.${endpoint}/${key}`;
- const res = await fetch(url, {
- method: "PUT",
- headers: {
- "Content-Type": contentType,
- Date: date,
- Authorization: `OSS ${accessKeyId}:${signature}`,
- "Cache-Control": "public, max-age=60",
- },
- body,
- });
- if (!res.ok) {
- const text = await res.text().catch(() => "");
- const err = new Error(`OSS PUT ${res.status}: ${text.slice(0, 200)}`);
- err.status = 502;
- throw err;
- }
-}
-
-function mergeProfile(existing, accountId, summary, personaname) {
- const profile =
- existing && typeof existing === "object"
- ? { ...existing }
- : { account_id: accountId, personaname: null, recent: [] };
- let recent = Array.isArray(profile.recent) ? profile.recent.filter((r) => r && typeof r === "object") : [];
- recent = recent.filter((r) => intField(r.match_id) !== summary.match_id);
- recent.unshift(summary);
- profile.recent = recent.slice(0, RECENT_LIMIT);
- profile.account_id = accountId;
- if (personaname) profile.personaname = personaname;
- profile.public_share = true;
- profile.updated_at = utcNow();
- return profile;
-}
-
export async function onRequestPost(context) {
try {
const env = envOf(context);
const expected = (env.PLAYER_PAGES_PUBLISH_SECRET || "").trim();
if (expected) {
- const got = (context.request.headers.get("X-Climperor-Publish-Secret") || "").trim();
+ const got = (
+ context.request.headers.get("X-Climperor-Publish-Secret") || ""
+ ).trim();
if (got !== expected) {
return jsonResponse({ error: "forbidden" }, 403);
}
@@ -330,70 +43,42 @@ export async function onRequestPost(context) {
return jsonResponse({ error: "account_id and match_id required" }, 400);
}
- const odKey = (env.OPENDOTA_API_KEY || "").trim();
- const q = odKey ? `?api_key=${encodeURIComponent(odKey)}` : "";
- let match;
- try {
- match = await fetchJson(`${OPENDOTA}/matches/${matchId}${q}`);
- } catch (e) {
- if (e && e.status === 404) {
- return jsonResponse(
- { ok: false, pending: true, message: "match not ready on OpenDota" },
- 202
- );
- }
- return jsonResponse({ error: "opendota fetch failed", detail: String(e.message || e) }, 502);
+ if (!env.SYNC_QUEUE) {
+ return jsonResponse({ error: "sync queue not configured" }, 503);
}
- if (!match || !Array.isArray(match.players) || !match.players.length) {
- return jsonResponse(
- { ok: false, pending: true, message: "match incomplete on OpenDota" },
- 202
- );
+ // Mark public_share when PC publishes intentionally.
+ if (env.DB) {
+ const now = new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
+ const steamid = String(BigInt(accountId) + 76561197960265728n);
+ await env.DB.prepare(
+ `INSERT INTO users (account_id, steamid, personaname, avatar, public_share, created_at, last_login_at)
+ VALUES (?, ?, NULL, NULL, 1, ?, ?)
+ ON CONFLICT(account_id) DO UPDATE SET public_share = 1, last_login_at = excluded.last_login_at`
+ )
+ .bind(accountId, steamid, now, now)
+ .run();
}
- if (!accountInMatch(match, accountId)) {
- return jsonResponse({ error: "account not in match" }, 403);
- }
-
- const heroMap = await loadHeroMap(odKey);
- const detail = normalizeMatch(match, accountId, heroMap);
- if (!detail) {
- return jsonResponse({ error: "normalize failed" }, 500);
- }
- const summary = summaryForProfile(detail, accountId);
- if (!summary) {
- return jsonResponse({ error: "focus player missing" }, 500);
- }
-
- let personaname = null;
- for (const p of detail.players) {
- if (p.account_id === accountId && p.personaname) {
- personaname = p.personaname;
- break;
- }
- }
-
- const profileKey = `players/${accountId}/profile.json`;
- const matchKey = `players/${accountId}/matches/${matchId}.json`;
- const existing = await ossGetJson(env, profileKey);
- const profile = mergeProfile(existing, accountId, summary, personaname);
-
- await ossPutJson(env, matchKey, detail);
- await ossPutJson(env, profileKey, profile);
-
- return jsonResponse({
- ok: true,
+ const queued = await enqueueRefresh(env, {
+ kind: "publish_match",
+ account_id: accountId,
+ match_id: matchId,
+ public_share: true,
+ });
+ if (!queued) {
+ return jsonResponse({ error: "enqueue failed" }, 503);
+ }
+ return jsonResponse({
+ ok: true,
+ queued: true,
account_id: accountId,
match_id: matchId,
- profile_key: profileKey,
- match_key: matchKey,
});
} catch (e) {
- const status = (e && e.status) || 500;
return jsonResponse(
{ error: "publish failed", detail: String((e && e.message) || e) },
- status >= 400 && status < 600 ? status : 500
+ 500
);
}
}
diff --git a/web/frontend/index.html b/web/frontend/index.html
index f99c49e..5042b11 100644
--- a/web/frontend/index.html
+++ b/web/frontend/index.html
@@ -43,8 +43,8 @@
}
-
-
+
+
DOTA2 上分帝
@@ -87,6 +87,7 @@
上分帝