Ship Steam login, D1 player sync, and cached「我」dashboard.

Players get a fast TTL-backed homepage (local profile / Cloudflare D1) with dense UI polish; login unlocks /home without blocking on every OpenDota refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
voson
2026-08-01 01:24:30 +08:00
co-authored by Cursor
parent 4a61aeeb26
commit f5b7011c45
65 changed files with 7304 additions and 552 deletions
+296
View File
@@ -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,
};
}
+326
View File
@@ -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" },
});
},
};
@@ -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;
}
+143
View File
@@ -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,
};
}
+58
View File
@@ -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");
+27
View File
@@ -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)