From 09396dafce850b861fe0542288b58fdb4ecd0cc2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 21:54:53 +0100 Subject: [PATCH] =?UTF-8?q?feat(lolstats):=20add=20Riot=20API=20hybrid=20?= =?UTF-8?q?=E2=80=94=20show=20ALL=20game=20modes=20(URF,=20Brawl,=20etc.)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit op.gg REST API doesn't track featured game modes (URF, ARAM Mayhem/Brawl). Now uses Riot API for match history when RIOT_API_KEY env var is set, falling back to op.gg REST for profile/ranked stats (no key needed). - Add Riot API match fetcher with region routing (europe/americas/asia/sea) - Add DDragon champion ID→name mapping for Riot API matches - Add queue ID→name mapping (420=Ranked, 450=ARAM, 900=URF, etc.) - Transform Riot match data to existing MatchEntry interface - Batch match detail requests (5 at a time) for rate limit safety - Keep op.gg REST as fallback when no API key is configured Co-Authored-By: Claude Opus 4.6 --- server/src/plugins/lolstats/opgg-api.ts | 203 ++++++++++++++++++++++-- 1 file changed, 190 insertions(+), 13 deletions(-) diff --git a/server/src/plugins/lolstats/opgg-api.ts b/server/src/plugins/lolstats/opgg-api.ts index abdd486..dade649 100644 --- a/server/src/plugins/lolstats/opgg-api.ts +++ b/server/src/plugins/lolstats/opgg-api.ts @@ -1,8 +1,8 @@ /** - * op.gg API Client — uses op.gg REST API as primary data source. - * Falls back to MCP (JSON-RPC 2.0) if REST fails. - * Also handles summoner renewal (data refresh from Riot servers). - * No API key needed. + * Hybrid LoL Stats API Client: + * 1. Riot API for match history (if RIOT_API_KEY set) — shows ALL game modes + * 2. op.gg REST API for profile/ranked stats + renewal — no key needed + * 3. MCP fallback for edge cases */ import type { @@ -14,6 +14,27 @@ const OPGG_API = 'https://lol-api-summoner.op.gg/api'; const MCP_URL = 'https://mcp-api.op.gg/mcp'; let rpcId = 1; +// ── Riot API Config ── + +const RIOT_API_KEY = process.env.RIOT_API_KEY ?? ''; + +/** Map our region codes to Riot regional routing (for Account-V1, Match-V5) */ +const REGION_TO_ROUTING: Record = { + EUW: 'europe', EUNE: 'europe', TR: 'europe', RU: 'europe', + NA: 'americas', BR: 'americas', LAN: 'americas', LAS: 'americas', + KR: 'asia', JP: 'asia', + OCE: 'sea', PH: 'sea', SG: 'sea', TH: 'sea', TW: 'sea', VN: 'sea', +}; + +/** Map queue IDs to human-readable names */ +const QUEUE_NAMES: Record = { + 420: 'SOLORANKED', 440: 'FLEXRANKED', 400: 'NORMAL', 430: 'NORMAL', + 450: 'ARAM', 900: 'URF', 1020: 'ONE_FOR_ALL', 1300: 'NEXUS_BLITZ', + 1400: 'ULTIMATE_SPELLBOOK', 1700: 'ARENA', 1900: 'URF', + 720: 'ARAM', // ARAM Clash + 0: 'CUSTOM', +}; + // ── Regions ── export const REGIONS: RegionInfo[] = [ @@ -177,21 +198,179 @@ export async function getProfile( }; } -// ── Match History via REST API ── +// ── Match History — Riot API (primary) or op.gg REST (fallback) ── export async function getMatches( gameName: string, tagLine: string, region: string, limit = 10, ): Promise { await loadChampionMap(); - const regionLower = region.toLowerCase(); - // Get summoner_id + // Prefer Riot API if key is available — shows ALL game modes (URF, Brawl, etc.) + if (RIOT_API_KEY) { + try { + return await getMatchesRiot(gameName, tagLine, region, limit); + } catch (e: any) { + console.warn(`[LoLStats] Riot API failed (${e.message}), falling back to op.gg`); + } + } + + // Fallback: op.gg REST API (only standard modes) + return getMatchesOpgg(gameName, tagLine, region, limit); +} + +// ── Riot API Match History ── + +async function riotFetch(url: string): Promise { + const res = await fetch(url, { + headers: { 'X-Riot-Token': RIOT_API_KEY }, + }); + if (res.status === 403) throw new Error('Riot API key expired or invalid'); + if (res.status === 429) throw new Error('Riot API rate limited'); + if (!res.ok) throw new Error(`Riot API HTTP ${res.status}`); + return res.json(); +} + +async function getMatchesRiot( + gameName: string, tagLine: string, region: string, limit = 10, +): Promise { + const routing = REGION_TO_ROUTING[region.toUpperCase()] ?? 'europe'; + const baseUrl = `https://${routing}.api.riotgames.com`; + + console.log(`[LoLStats] Fetching matches via Riot API (${routing})`); + + // Step 1: Get PUUID + const account = await riotFetch( + `${baseUrl}/riot/account/v1/accounts/by-riot-id/${encodeURIComponent(gameName)}/${encodeURIComponent(tagLine)}`, + ); + const puuid = account.puuid; + if (!puuid) throw new Error('PUUID not found'); + + // Step 2: Get match IDs + const matchIds: string[] = await riotFetch( + `${baseUrl}/lol/match/v5/matches/by-puuid/${puuid}/ids?start=0&count=${Math.min(limit, 20)}`, + ); + + if (!matchIds.length) { + console.log('[LoLStats] No match IDs from Riot API'); + return []; + } + + console.log(`[LoLStats] Got ${matchIds.length} match IDs, fetching details...`); + + // Step 3: Fetch match details in parallel (batches of 5 to respect rate limits) + const results: MatchEntry[] = []; + const batchSize = 5; + + for (let i = 0; i < matchIds.length; i += batchSize) { + const batch = matchIds.slice(i, i + batchSize); + const details = await Promise.all( + batch.map(id => + riotFetch(`${baseUrl}/lol/match/v5/matches/${id}`) + .catch(e => { console.warn(`[LoLStats] Failed to fetch ${id}:`, e.message); return null; }), + ), + ); + + for (const d of details) { + if (d?.info) results.push(transformRiotMatch(d)); + } + } + + console.log(`[LoLStats] Processed ${results.length} matches from Riot API`); + return results; +} + +function transformRiotMatch(data: any): MatchEntry { + const info = data.info; + const matchId = data.metadata?.matchId ?? ''; + + const participants: MatchParticipant[] = (info.participants ?? []).map((p: any) => ({ + champion_name: p.championName ?? champName(p.championId ?? 0), + champion_id: p.championId ?? 0, + items: [p.item0, p.item1, p.item2, p.item3, p.item4, p.item5, p.item6].filter((x: any) => x != null), + items_names: [], + position: p.teamPosition ?? p.individualPosition ?? '', + team_key: p.teamId === 100 ? 'BLUE' : 'RED', + spells: [p.summoner1Id, p.summoner2Id].filter((x: any) => x != null), + stats: { + kill: p.kills ?? 0, + death: p.deaths ?? 0, + assist: p.assists ?? 0, + minion_kill: p.totalMinionsKilled ?? 0, + neutral_minion_kill: p.neutralMinionsKilled ?? 0, + gold_earned: p.goldEarned ?? 0, + total_damage_dealt_to_champions: p.totalDamageDealtToChampions ?? 0, + total_damage_taken: p.totalDamageTaken ?? 0, + ward_place: p.wardsPlaced ?? 0, + vision_wards_bought_in_game: p.visionWardsBoughtInGame ?? 0, + champion_level: p.champLevel ?? 0, + op_score: 0, + op_score_rank: 0, + result: p.win ? 'WIN' : 'LOSE', + }, + summoner: { + game_name: p.riotIdGameName ?? p.summonerName ?? '', + tagline: p.riotIdTagline ?? '', + }, + })); + + // Build team stats from participant data + const teamKills: Record = { BLUE: 0, RED: 0 }; + const teamGold: Record = { BLUE: 0, RED: 0 }; + const teamWin: Record = { BLUE: false, RED: false }; + + for (const p of participants) { + const tk = p.team_key; + teamKills[tk] = (teamKills[tk] ?? 0) + p.stats.kill; + teamGold[tk] = (teamGold[tk] ?? 0) + p.stats.gold_earned; + if (p.stats.result === 'WIN') teamWin[tk] = true; + } + + const riotTeams = info.teams ?? []; + const teams: TeamStat[] = ['BLUE', 'RED'].map(key => { + const rTeam = riotTeams.find((t: any) => (t.teamId === 100 ? 'BLUE' : 'RED') === key); + return { + key, + banned_champions_names: (rTeam?.bans ?? []).map((b: any) => champName(b.championId ?? 0)), + game_stat: { + is_win: teamWin[key] ?? false, + baron_kill: rTeam?.objectives?.baron?.kills ?? 0, + dragon_kill: rTeam?.objectives?.dragon?.kills ?? 0, + tower_kill: rTeam?.objectives?.tower?.kills ?? 0, + champion_kill: teamKills[key] ?? 0, + gold_earned: teamGold[key] ?? 0, + inhibitor_kill: rTeam?.objectives?.inhibitor?.kills ?? 0, + rift_herald_kill: rTeam?.objectives?.riftHerald?.kills ?? 0, + }, + }; + }); + + // Determine game type from queue ID + const queueId = info.queueId ?? 0; + const gameType = QUEUE_NAMES[queueId] ?? info.gameMode ?? 'UNKNOWN'; + + return { + id: matchId, + created_at: new Date(info.gameCreation ?? 0).toISOString(), + game_length_second: info.gameDuration ?? 0, + game_type: gameType, + game_map: info.mapId === 12 ? 'HOWLING_ABYSS' : info.mapId === 11 ? 'SUMMONERS_RIFT' : `MAP_${info.mapId}`, + average_tier_info: undefined, + participants, + teams, + }; +} + +// ── op.gg REST API Match History ── + +async function getMatchesOpgg( + gameName: string, tagLine: string, region: string, limit = 10, +): Promise { + const regionLower = region.toLowerCase(); const summoner = await lookupSummoner(gameName, tagLine, region); - // Fetch games — NOTE: uses /api/ (no v3!) for games endpoint const gamesUrl = `${OPGG_API}/${regionLower}/summoners/${encodeURIComponent(summoner.summoner_id)}/games?limit=${Math.min(limit, 20)}&game_type=total&hl=en_US`; - console.log(`[LoLStats] Fetching matches via REST API`); + console.log(`[LoLStats] Fetching matches via op.gg REST API`); const res = await fetch(gamesUrl, { headers: { 'Accept': 'application/json' } }); @@ -204,13 +383,11 @@ export async function getMatches( const games = json?.data; if (!Array.isArray(games) || games.length === 0) { - console.log('[LoLStats] No games from REST API'); + console.log('[LoLStats] No games from op.gg REST API'); return []; } - console.log(`[LoLStats] Got ${games.length} games from REST API`); - - // Transform REST API games to our MatchEntry format + console.log(`[LoLStats] Got ${games.length} games from op.gg REST API`); return games.map((game: any) => transformGame(game)); }