From 87279933c3da3ca107f04d2801813d0ea62c34d6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 6 Mar 2026 21:41:48 +0100 Subject: [PATCH] refactor(lolstats): switch from MCP to op.gg REST API for fresh data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Profile now fetched via REST API (summoner lookup + summary endpoint) - Match history via REST API games endpoint (proper JSON, no parser) - All 10 players per game returned directly (no separate detail fetch) - DDragon champion ID→name mapping loaded at startup - Fixed summoner_id lookup to use # separator (was using - which failed) - MCP kept as fallback for match detail and edge cases - Frontend: find "me" by summoner name instead of assuming index 0 Co-Authored-By: Claude Opus 4.6 --- server/src/plugins/lolstats/opgg-api.ts | 720 +++++++++++++---------- web/src/plugins/lolstats/LolstatsTab.tsx | 12 +- 2 files changed, 406 insertions(+), 326 deletions(-) diff --git a/server/src/plugins/lolstats/opgg-api.ts b/server/src/plugins/lolstats/opgg-api.ts index 0a06d2c..abdd486 100644 --- a/server/src/plugins/lolstats/opgg-api.ts +++ b/server/src/plugins/lolstats/opgg-api.ts @@ -1,7 +1,7 @@ /** - * op.gg MCP Client — calls the public op.gg MCP server at https://mcp-api.op.gg/mcp - * Uses JSON-RPC 2.0 over StreamableHTTP (MCP protocol). - * Also uses op.gg REST API for summoner renewal (data refresh). + * 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. */ @@ -10,8 +10,8 @@ import type { MatchParticipantStats, TeamStat, } from './types.js'; -const MCP_URL = 'https://mcp-api.op.gg/mcp'; const OPGG_API = 'https://lol-api-summoner.op.gg/api'; +const MCP_URL = 'https://mcp-api.op.gg/mcp'; let rpcId = 1; // ── Regions ── @@ -35,323 +35,61 @@ export const REGIONS: RegionInfo[] = [ { code: 'VN', label: 'Vietnam' }, ]; -// ── MCP call helper ── +// ── DDragon Champion Mapping ── +// Maps champion_id → champion_name for image URLs -async function mcpCall(toolName: string, args: Record): Promise { - const id = rpcId++; - const body = { - jsonrpc: '2.0', - id, - method: 'tools/call', - params: { name: toolName, arguments: args }, - }; +let champMap: Map = new Map(); +let champMapLoaded = false; - const res = await fetch(MCP_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream', - }, - body: JSON.stringify(body), - }); +async function loadChampionMap(): Promise { + if (champMapLoaded) return; + try { + // Get latest DDragon version + const verRes = await fetch('https://ddragon.leagueoflegends.com/api/versions.json'); + const versions = await verRes.json() as string[]; + const ver = versions[0]; - if (!res.ok) { - throw new Error(`MCP HTTP ${res.status}: ${res.statusText}`); + // Fetch champion data + const champRes = await fetch(`https://ddragon.leagueoflegends.com/cdn/${ver}/data/en_US/champion.json`); + const champData = await champRes.json() as any; + + for (const [, champ] of Object.entries(champData.data)) { + champMap.set(Number(champ.key), champ.id); // e.g., 119 → "Draven" + } + + champMapLoaded = true; + console.log(`[LoLStats] Loaded ${champMap.size} champions from DDragon v${ver}`); + } catch (e: any) { + console.error('[LoLStats] Failed to load DDragon champions:', e.message); } - - const json = await res.json() as any; - - if (json.error) { - const msg = json.error.message ?? JSON.stringify(json.error); - throw new Error(msg); - } - - // MCP tools return content as an array of content blocks - const text = json.result?.content?.[0]?.text; - if (!text) throw new Error('Empty MCP response'); - - return text; } -// ── Response parser ── -// op.gg MCP returns a custom class-notation format, not pure JSON. -// We need to parse the structured text into usable data. - -function parseClassNotation(text: string): any { - // The response format is like: - // class ClassName: field1,field2 - // ClassName(Data(...)) - // - // We need to extract the actual data. The data part after the class - // definitions is a nested constructor-call format. - // Strategy: find the last line that starts with the root class name - // and parse the nested parentheses structure. - - const lines = text.split('\n'); - - // Find class definitions and the data line - const classDefs: Map = new Map(); - let dataLine = ''; - - for (const line of lines) { - const classMatch = line.match(/^class (\w+): (.+)$/); - if (classMatch) { - classDefs.set(classMatch[1], classMatch[2].split(',')); - continue; - } - if (line.trim()) { - dataLine = line.trim(); - } - } - - if (!dataLine) throw new Error('No data in MCP response'); - - // Parse the constructor-call notation recursively - return parseConstructor(dataLine, classDefs); +function champName(id: number): string { + return champMap.get(id) ?? `Champion${id}`; } -function parseConstructor(input: string, classDefs: Map): any { - // Try to match ClassName(...) - const ctorMatch = input.match(/^(\w+)\((.+)\)$/s); - if (!ctorMatch) { - // It's a primitive value - return parsePrimitive(input); - } +// ── Summoner Lookup ── - const className = ctorMatch[1]; - const innerContent = ctorMatch[2]; - const fields = classDefs.get(className); - - if (!fields) { - // Unknown class, try to parse inner as a value - return parseConstructor(innerContent, classDefs); - } - - // Split the inner content by top-level commas (respecting nested parens/brackets/strings) - const values = splitTopLevel(innerContent); - - const obj: Record = {}; - for (let i = 0; i < fields.length && i < values.length; i++) { - const fieldName = fields[i].trim(); - const rawVal = values[i].trim(); - - if (fieldName.endsWith('[]')) { - // Array field — clean field name - const cleanName = fieldName.replace('[]', ''); - obj[cleanName] = parseArrayValue(rawVal, classDefs); - } else { - obj[fieldName] = parseValue(rawVal, classDefs); - } - } - - return obj; +interface SummonerLookup { + summoner_id: string; + id: number; + puuid: string; + game_name: string; + tagline: string; + level: number; + profile_image_url: string; + updated_at: string; } -function parseArrayValue(input: string, classDefs: Map): any[] { - // Array notation: [item1,item2,...] or [ClassName(...),ClassName(...)] - if (input === '[]' || input === 'null' || input === 'None') return []; - - if (input.startsWith('[') && input.endsWith(']')) { - const inner = input.slice(1, -1).trim(); - if (!inner) return []; - const items = splitTopLevel(inner); - return items.map(item => parseValue(item.trim(), classDefs)); - } - - // Single item not wrapped in brackets - return [parseValue(input, classDefs)]; -} - -function parseValue(input: string, classDefs: Map): any { - if (!input) return null; - - // Check if it's a constructor call - const ctorMatch = input.match(/^(\w+)\(/); - if (ctorMatch && classDefs.has(ctorMatch[1])) { - return parseConstructor(input, classDefs); - } - - // Check if it's an array - if (input.startsWith('[') && input.endsWith(']')) { - return parseArrayValue(input, classDefs); - } - - return parsePrimitive(input); -} - -function parsePrimitive(input: string): any { - if (input === 'null' || input === 'None') return null; - if (input === 'true' || input === 'True') return true; - if (input === 'false' || input === 'False') return false; - - // Quoted string - if ((input.startsWith('"') && input.endsWith('"')) || - (input.startsWith("'") && input.endsWith("'"))) { - return input.slice(1, -1); - } - - // Number - const num = Number(input); - if (!isNaN(num) && input !== '') return num; - - // Raw string - return input; -} - -function splitTopLevel(input: string): string[] { - const results: string[] = []; - let depth = 0; - let inString = false; - let stringChar = ''; - let current = ''; - - for (let i = 0; i < input.length; i++) { - const ch = input[i]; - const prev = i > 0 ? input[i - 1] : ''; - - if (inString) { - current += ch; - if (ch === stringChar && prev !== '\\') { - inString = false; - } - continue; - } - - if (ch === '"' || ch === "'") { - inString = true; - stringChar = ch; - current += ch; - continue; - } - - if (ch === '(' || ch === '[') { - depth++; - current += ch; - continue; - } - - if (ch === ')' || ch === ']') { - depth--; - current += ch; - continue; - } - - if (ch === ',' && depth === 0) { - results.push(current); - current = ''; - continue; - } - - current += ch; - } - - if (current.trim()) results.push(current); - return results; -} - -// ── Public API functions ── - -export async function getProfile( +async function lookupSummoner( gameName: string, tagLine: string, region: string, -): Promise { - const raw = await mcpCall('lol_get_summoner_profile', { - game_name: gameName, - tag_line: tagLine, - region, - lang: 'en_US', - desired_output_fields: [ - 'data.summoner.game_name', - 'data.summoner.tagline', - 'data.summoner.level', - 'data.summoner.profile_image_url', - 'data.summoner.updated_at', - 'data.summoner.league_stats[].tier_info.{tier,division,lp,tier_image_url}', - 'data.summoner.league_stats[].{game_type,win,lose,is_hot_streak}', - 'data.summoner.ladder_rank.{rank,total}', - 'data.summoner.most_champions.champion_stats[].{champion_name,id,play,win,lose,kill,death,assist}', - 'data.summoner.most_champions.{game_type,play,win,lose}', - ], - }); - - const parsed = parseClassNotation(raw); - // Navigate to summoner data (may be nested in data.summoner or similar) - const summoner = parsed?.summoner ?? parsed?.data?.summoner ?? parsed; - - return { - game_name: summoner.game_name ?? gameName, - tagline: summoner.tagline ?? tagLine, - level: summoner.level ?? 0, - profile_image_url: summoner.profile_image_url ?? '', - league_stats: Array.isArray(summoner.league_stats) ? summoner.league_stats : [], - ladder_rank: summoner.ladder_rank ?? { rank: null, total: null }, - most_champions: summoner.most_champions ?? { game_type: '', play: 0, win: 0, lose: 0, champion_stats: [] }, - updated_at: summoner.updated_at ?? '', - }; -} - -export async function getMatches( - gameName: string, tagLine: string, region: string, limit = 10, -): Promise { - const raw = await mcpCall('lol_list_summoner_matches', { - game_name: gameName, - tag_line: tagLine, - region, - lang: 'en_US', - limit, - desired_output_fields: [ - 'data.game_history[].{id,created_at,game_length_second,game_type,game_map}', - 'data.game_history[].average_tier_info.{tier,division}', - 'data.game_history[].participants[].{champion_name,champion_id,items[],items_names[],position,team_key,spells[]}', - 'data.game_history[].participants[].stats.{kill,death,assist,minion_kill,neutral_minion_kill,gold_earned,total_damage_dealt_to_champions,total_damage_taken,ward_place,vision_wards_bought_in_game,champion_level,op_score,op_score_rank,result}', - 'data.game_history[].participants[].summoner.{game_name,tagline}', - 'data.game_history[].teams[].{key,banned_champions_names[]}', - 'data.game_history[].teams[].game_stat.{is_win,baron_kill,dragon_kill,tower_kill,champion_kill,gold_earned,inhibitor_kill,rift_herald_kill}', - ], - }); - - const parsed = parseClassNotation(raw); - const history = parsed?.game_history ?? parsed?.data?.game_history ?? []; - return Array.isArray(history) ? history : []; -} - -export async function getMatchDetail( - gameId: string, createdAt: string, region: string, -): Promise { - const raw = await mcpCall('lol_get_summoner_game_detail', { - game_id: gameId, - created_at: createdAt, - region, - lang: 'en_US', - desired_output_fields: [ - 'data.game_detail.{id,created_at,game_length_second,game_type,game_map}', - 'data.game_detail.average_tier_info.{tier,division}', - 'data.game_detail.participants[].{champion_name,champion_id,items[],items_names[],position,team_key,spells[]}', - 'data.game_detail.participants[].stats.{kill,death,assist,minion_kill,neutral_minion_kill,gold_earned,total_damage_dealt_to_champions,total_damage_taken,ward_place,vision_wards_bought_in_game,champion_level,op_score,op_score_rank,result}', - 'data.game_detail.participants[].summoner.{game_name,tagline}', - 'data.game_detail.teams[].{key,banned_champions_names[]}', - 'data.game_detail.teams[].game_stat.{is_win,baron_kill,dragon_kill,tower_kill,champion_kill,gold_earned,inhibitor_kill,rift_herald_kill}', - ], - }); - - const parsed = parseClassNotation(raw); - return parsed?.game_detail ?? parsed?.data?.game_detail ?? null; -} - -// ── Summoner Renewal (op.gg REST API) ── -// Triggers a data refresh on op.gg's servers so the MCP returns fresh data. - -/** - * Look up the op.gg-internal summoner_id for a Riot ID. - * Required for the renewal endpoint. - */ -async function getSummonerId( - gameName: string, tagLine: string, region: string, -): Promise { +): Promise { const regionLower = region.toLowerCase(); - const riotId = `${gameName}-${tagLine}`; + // op.gg REST API requires riot_id with # separator (URL-encoded as %23) + const riotId = `${gameName}#${tagLine}`; const url = `${OPGG_API}/v3/${regionLower}/summoners?riot_id=${encodeURIComponent(riotId)}&hl=en_US`; - console.log(`[LoLStats] Looking up summoner_id: ${url}`); + console.log(`[LoLStats] Summoner lookup: ${gameName}#${tagLine} @ ${region}`); const res = await fetch(url, { headers: { 'Accept': 'application/json' }, @@ -362,41 +100,214 @@ async function getSummonerId( } const json = await res.json() as any; - const summonerId = json?.data?.summoner_id ?? json?.data?.id ?? json?.summoner_id; + const data = json?.data; - if (!summonerId) { - // Try to find it in a different structure - const alt = json?.data?.[0]?.summoner_id ?? json?.[0]?.summoner_id; - if (alt) return String(alt); - console.log('[LoLStats] Summoner lookup response:', JSON.stringify(json).slice(0, 500)); - throw new Error('Could not find summoner_id in op.gg response'); + // Response is an array of matches + const entry = Array.isArray(data) ? data[0] : data; + if (!entry?.summoner_id) { + throw new Error('Summoner not found on op.gg'); } - return String(summonerId); + return { + summoner_id: entry.summoner_id, + id: entry.id, + puuid: entry.puuid, + game_name: entry.game_name ?? gameName, + tagline: entry.tagline ?? tagLine, + level: entry.level ?? 0, + profile_image_url: entry.profile_image_url ?? '', + updated_at: entry.updated_at ?? '', + }; } -/** - * Trigger a data renewal on op.gg and poll until complete. - * Returns the last_updated_at timestamp on success. - */ +// ── Profile via REST API ── + +export async function getProfile( + gameName: string, tagLine: string, region: string, +): Promise { + await loadChampionMap(); + const regionLower = region.toLowerCase(); + + // Step 1: Lookup summoner to get summoner_id + const summoner = await lookupSummoner(gameName, tagLine, region); + + // Step 2: Fetch summary (ranked stats, most champions) + const summaryUrl = `${OPGG_API}/${regionLower}/summoners/${encodeURIComponent(summoner.summoner_id)}/summary?hl=en_US`; + let leagueStats: any[] = []; + let ladderRank = { rank: null as number | null, total: null as number | null }; + let mostChampions: any = { game_type: '', play: 0, win: 0, lose: 0, champion_stats: [] }; + + try { + const res = await fetch(summaryUrl, { headers: { 'Accept': 'application/json' } }); + if (res.ok) { + const json = await res.json() as any; + const sum = json?.data?.summoner ?? json?.data; + + if (sum?.league_stats) { + leagueStats = sum.league_stats; + } + if (sum?.ladder_rank) { + ladderRank = sum.ladder_rank; + } + if (sum?.most_champions) { + // Transform champion stats to include champion_name + const mc = sum.most_champions; + if (mc?.champion_stats) { + mc.champion_stats = mc.champion_stats.map((cs: any) => ({ + ...cs, + champion_name: cs.champion_name ?? champName(cs.id ?? cs.champion_id ?? 0), + })); + } + mostChampions = mc; + } + } + } catch (e: any) { + console.warn('[LoLStats] Summary fetch failed:', e.message); + } + + return { + game_name: summoner.game_name, + tagline: summoner.tagline, + level: summoner.level, + profile_image_url: summoner.profile_image_url, + league_stats: leagueStats, + ladder_rank: ladderRank, + most_champions: mostChampions, + updated_at: summoner.updated_at, + }; +} + +// ── Match History via REST API ── + +export async function getMatches( + gameName: string, tagLine: string, region: string, limit = 10, +): Promise { + await loadChampionMap(); + const regionLower = region.toLowerCase(); + + // Get summoner_id + 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`); + + const res = await fetch(gamesUrl, { headers: { 'Accept': 'application/json' } }); + + if (!res.ok) { + console.warn(`[LoLStats] REST games HTTP ${res.status}, falling back to MCP`); + return getMatchesMCP(gameName, tagLine, region, limit); + } + + const json = await res.json() as any; + const games = json?.data; + + if (!Array.isArray(games) || games.length === 0) { + console.log('[LoLStats] No games from REST API'); + return []; + } + + console.log(`[LoLStats] Got ${games.length} games from REST API`); + + // Transform REST API games to our MatchEntry format + return games.map((game: any) => transformGame(game)); +} + +function transformGame(game: any): MatchEntry { + const participants: MatchParticipant[] = (game.participants ?? []).map((p: any) => ({ + champion_name: champName(p.champion_id ?? 0), + champion_id: p.champion_id ?? 0, + items: p.items ?? [], + items_names: (p.items ?? []).map(() => ''), // REST API doesn't include item names + position: p.position ?? '', + team_key: p.team_key ?? '', + spells: p.spells ?? [], + stats: { + kill: p.stats?.kill ?? 0, + death: p.stats?.death ?? 0, + assist: p.stats?.assist ?? 0, + minion_kill: p.stats?.minion_kill ?? 0, + neutral_minion_kill: p.stats?.neutral_minion_kill ?? 0, + gold_earned: p.stats?.gold_earned ?? 0, + total_damage_dealt_to_champions: p.stats?.total_damage_dealt_to_champions ?? 0, + total_damage_taken: p.stats?.total_damage_taken ?? 0, + ward_place: p.stats?.ward_place ?? 0, + vision_wards_bought_in_game: p.stats?.vision_wards_bought_in_game ?? 0, + champion_level: p.stats?.champion_level ?? 0, + op_score: p.stats?.op_score ?? 0, + op_score_rank: p.stats?.op_score_rank ?? 0, + result: p.stats?.result ?? '', + }, + summoner: { + game_name: p.summoner?.game_name ?? '', + tagline: p.summoner?.tagline ?? '', + }, + })); + + const teams: TeamStat[] = (game.teams ?? []).map((t: any) => ({ + key: t.key ?? '', + banned_champions_names: t.banned_champions_names ?? [], + game_stat: { + is_win: t.game_stat?.is_win ?? false, + baron_kill: t.game_stat?.baron_kill ?? 0, + dragon_kill: t.game_stat?.dragon_kill ?? 0, + tower_kill: t.game_stat?.tower_kill ?? 0, + champion_kill: t.game_stat?.champion_kill ?? 0, + gold_earned: t.game_stat?.gold_earned ?? 0, + inhibitor_kill: t.game_stat?.inhibitor_kill ?? 0, + rift_herald_kill: t.game_stat?.rift_herald_kill ?? 0, + }, + })); + + return { + id: game.id ?? '', + created_at: game.created_at ?? '', + game_length_second: game.game_length_second ?? 0, + game_type: game.game_type ?? '', + game_map: game.game_map ?? '', + average_tier_info: game.average_tier_info ?? undefined, + participants, + teams, + }; +} + +// ── Match Detail (REST API returns all 10 players already) ── + +export async function getMatchDetail( + gameId: string, createdAt: string, region: string, +): Promise { + // The REST API already returns all 10 participants per game, + // so for "detail" we don't need a separate call. This function + // is kept for compatibility but uses the MCP as fallback. + try { + return await getMatchDetailMCP(gameId, createdAt, region); + } catch (e: any) { + console.warn('[LoLStats] Match detail MCP fallback failed:', e.message); + return null; + } +} + +// ── Summoner Renewal ── + export async function renewSummoner( gameName: string, tagLine: string, region: string, ): Promise<{ renewed: boolean; last_updated_at: string; message?: string }> { const regionLower = region.toLowerCase(); // Step 1: Get summoner_id - let summonerId: string; + let summoner: SummonerLookup; try { - summonerId = await getSummonerId(gameName, tagLine, region); + summoner = await lookupSummoner(gameName, tagLine, region); } catch (e: any) { console.error('[LoLStats] Summoner lookup failed:', e.message); throw new Error(`Summoner not found on op.gg: ${e.message}`); } - console.log(`[LoLStats] Triggering renewal for summoner_id=${summonerId} region=${regionLower}`); + console.log(`[LoLStats] Triggering renewal for ${summoner.game_name}#${summoner.tagline} (${summoner.summoner_id})`); // Step 2: POST renewal - const renewUrl = `${OPGG_API}/${regionLower}/summoners/${encodeURIComponent(summonerId)}/renewal`; + const renewUrl = `${OPGG_API}/${regionLower}/summoners/${encodeURIComponent(summoner.summoner_id)}/renewal`; const maxPolls = 10; for (let attempt = 0; attempt < maxPolls; attempt++) { @@ -407,7 +318,7 @@ export async function renewSummoner( const json = await res.json() as any; - // Cooldown (already renewed recently) — status 200 but with message + // Cooldown (already renewed recently) if (json.message?.includes('Already renewed') || json.message?.includes('already')) { console.log(`[LoLStats] Already renewed recently, last_updated_at=${json.last_updated_at}`); return { @@ -433,7 +344,6 @@ export async function renewSummoner( await new Promise(r => setTimeout(r, delay)); } - // Timeout — renewal took too long console.warn('[LoLStats] Renewal polling timeout'); return { renewed: false, @@ -441,3 +351,165 @@ export async function renewSummoner( message: 'Renewal timed out — data may still be updating', }; } + +// ══════════════════════════════════════════════════════════════ +// MCP Fallback — kept for match detail and edge cases +// ══════════════════════════════════════════════════════════════ + +async function mcpCall(toolName: string, args: Record): Promise { + const id = rpcId++; + const body = { + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: toolName, arguments: args }, + }; + + const res = await fetch(MCP_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream', + }, + body: JSON.stringify(body), + }); + + if (!res.ok) throw new Error(`MCP HTTP ${res.status}: ${res.statusText}`); + + const json = await res.json() as any; + if (json.error) throw new Error(json.error.message ?? JSON.stringify(json.error)); + + const text = json.result?.content?.[0]?.text; + if (!text) throw new Error('Empty MCP response'); + return text; +} + +// ── MCP class notation parser ── + +function parseClassNotation(text: string): any { + const lines = text.split('\n'); + const classDefs: Map = new Map(); + let dataLine = ''; + + for (const line of lines) { + const classMatch = line.match(/^class (\w+): (.+)$/); + if (classMatch) { + classDefs.set(classMatch[1], classMatch[2].split(',')); + continue; + } + if (line.trim()) dataLine = line.trim(); + } + if (!dataLine) throw new Error('No data in MCP response'); + return parseConstructor(dataLine, classDefs); +} + +function parseConstructor(input: string, classDefs: Map): any { + const ctorMatch = input.match(/^(\w+)\((.+)\)$/s); + if (!ctorMatch) return parsePrimitive(input); + + const className = ctorMatch[1]; + const innerContent = ctorMatch[2]; + const fields = classDefs.get(className); + if (!fields) return parseConstructor(innerContent, classDefs); + + const values = splitTopLevel(innerContent); + const obj: Record = {}; + for (let i = 0; i < fields.length && i < values.length; i++) { + const fieldName = fields[i].trim(); + const rawVal = values[i].trim(); + if (fieldName.endsWith('[]')) { + obj[fieldName.replace('[]', '')] = parseArrayValue(rawVal, classDefs); + } else { + obj[fieldName] = parseValue(rawVal, classDefs); + } + } + return obj; +} + +function parseArrayValue(input: string, classDefs: Map): any[] { + if (input === '[]' || input === 'null' || input === 'None') return []; + if (input.startsWith('[') && input.endsWith(']')) { + const inner = input.slice(1, -1).trim(); + if (!inner) return []; + return splitTopLevel(inner).map(item => parseValue(item.trim(), classDefs)); + } + return [parseValue(input, classDefs)]; +} + +function parseValue(input: string, classDefs: Map): any { + if (!input) return null; + const ctorMatch = input.match(/^(\w+)\(/); + if (ctorMatch && classDefs.has(ctorMatch[1])) return parseConstructor(input, classDefs); + if (input.startsWith('[') && input.endsWith(']')) return parseArrayValue(input, classDefs); + return parsePrimitive(input); +} + +function parsePrimitive(input: string): any { + if (input === 'null' || input === 'None') return null; + if (input === 'true' || input === 'True') return true; + if (input === 'false' || input === 'False') return false; + if ((input.startsWith('"') && input.endsWith('"')) || (input.startsWith("'") && input.endsWith("'"))) + return input.slice(1, -1); + const num = Number(input); + if (!isNaN(num) && input !== '') return num; + return input; +} + +function splitTopLevel(input: string): string[] { + const results: string[] = []; + let depth = 0, inString = false, stringChar = '', current = ''; + for (let i = 0; i < input.length; i++) { + const ch = input[i]; + const prev = i > 0 ? input[i - 1] : ''; + if (inString) { current += ch; if (ch === stringChar && prev !== '\\') inString = false; continue; } + if (ch === '"' || ch === "'") { inString = true; stringChar = ch; current += ch; continue; } + if (ch === '(' || ch === '[') { depth++; current += ch; continue; } + if (ch === ')' || ch === ']') { depth--; current += ch; continue; } + if (ch === ',' && depth === 0) { results.push(current); current = ''; continue; } + current += ch; + } + if (current.trim()) results.push(current); + return results; +} + +// ── MCP-based functions (fallback) ── + +async function getMatchesMCP( + gameName: string, tagLine: string, region: string, limit = 10, +): Promise { + console.log('[LoLStats] Using MCP fallback for matches'); + const raw = await mcpCall('lol_list_summoner_matches', { + game_name: gameName, tag_line: tagLine, region, lang: 'en_US', limit, + desired_output_fields: [ + 'data.game_history[].{id,created_at,game_length_second,game_type,game_map}', + 'data.game_history[].average_tier_info.{tier,division}', + 'data.game_history[].participants[].{champion_name,champion_id,items[],items_names[],position,team_key,spells[]}', + 'data.game_history[].participants[].stats.{kill,death,assist,minion_kill,neutral_minion_kill,gold_earned,total_damage_dealt_to_champions,total_damage_taken,ward_place,vision_wards_bought_in_game,champion_level,op_score,op_score_rank,result}', + 'data.game_history[].participants[].summoner.{game_name,tagline}', + 'data.game_history[].teams[].{key,banned_champions_names[]}', + 'data.game_history[].teams[].game_stat.{is_win,baron_kill,dragon_kill,tower_kill,champion_kill,gold_earned,inhibitor_kill,rift_herald_kill}', + ], + }); + const parsed = parseClassNotation(raw); + const history = parsed?.game_history ?? parsed?.data?.game_history ?? []; + return Array.isArray(history) ? history : []; +} + +async function getMatchDetailMCP( + gameId: string, createdAt: string, region: string, +): Promise { + const raw = await mcpCall('lol_get_summoner_game_detail', { + game_id: gameId, created_at: createdAt, region, lang: 'en_US', + desired_output_fields: [ + 'data.game_detail.{id,created_at,game_length_second,game_type,game_map}', + 'data.game_detail.average_tier_info.{tier,division}', + 'data.game_detail.participants[].{champion_name,champion_id,items[],items_names[],position,team_key,spells[]}', + 'data.game_detail.participants[].stats.{kill,death,assist,minion_kill,neutral_minion_kill,gold_earned,total_damage_dealt_to_champions,total_damage_taken,ward_place,vision_wards_bought_in_game,champion_level,op_score,op_score_rank,result}', + 'data.game_detail.participants[].summoner.{game_name,tagline}', + 'data.game_detail.teams[].{key,banned_champions_names[]}', + 'data.game_detail.teams[].game_stat.{is_win,baron_kill,dragon_kill,tower_kill,champion_kill,gold_earned,inhibitor_kill,rift_herald_kill}', + ], + }); + const parsed = parseClassNotation(raw); + return parsed?.game_detail ?? parsed?.data?.game_detail ?? null; +} diff --git a/web/src/plugins/lolstats/LolstatsTab.tsx b/web/src/plugins/lolstats/LolstatsTab.tsx index fc7fd05..1b1e5a3 100644 --- a/web/src/plugins/lolstats/LolstatsTab.tsx +++ b/web/src/plugins/lolstats/LolstatsTab.tsx @@ -266,10 +266,18 @@ export default function LolstatsTab({ data }: { data: any }) { doSearch(r.game_name, r.tag_line, r.region); }, [doSearch]); + // ── Find "me" in participants ── + const findMe = useCallback((match: MatchEntry): MatchParticipant | null => { + if (!profile) return match.participants?.[0] ?? null; + const myName = profile.game_name.toLowerCase(); + return match.participants?.find( + p => p.summoner?.game_name?.toLowerCase() === myName, + ) ?? match.participants?.[0] ?? null; + }, [profile]); + // ── Render Match Row ── const renderMatch = (match: MatchEntry) => { - // The participant is the target summoner (first and only in list from matches endpoint) - const me = match.participants?.[0]; + const me = findMe(match); if (!me) return null; const isWin = me.stats?.result === 'WIN';