diff --git a/server/src/index.ts b/server/src/index.ts index 84bcbc5..a80b870 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -7,6 +7,7 @@ import { loadState, getFullState, getStateDiag } from './core/persistence.js'; import { getPlugins, registerPlugin, getPluginCtx, type PluginContext } from './core/plugin.js'; import radioPlugin from './plugins/radio/index.js'; import soundboardPlugin from './plugins/soundboard/index.js'; +import lolstatsPlugin from './plugins/lolstats/index.js'; // ── Config ── const PORT = Number(process.env.PORT ?? 8080); @@ -122,6 +123,11 @@ async function boot(): Promise { registerPlugin(soundboardPlugin, ctxJukebox); registerPlugin(radioPlugin, ctxRadio); + // lolstats has no Discord bot — use a dummy client (never logged in) + const clientLolstats = createClient(); + const ctxLolstats: PluginContext = { client: clientLolstats, dataDir: DATA_DIR, adminPwd: ADMIN_PWD, allowedGuildIds: ALLOWED_GUILD_IDS }; + registerPlugin(lolstatsPlugin, ctxLolstats); + // Init all plugins for (const p of getPlugins()) { const pCtx = getPluginCtx(p.name)!; diff --git a/server/src/plugins/lolstats/index.ts b/server/src/plugins/lolstats/index.ts new file mode 100644 index 0000000..89d40f7 --- /dev/null +++ b/server/src/plugins/lolstats/index.ts @@ -0,0 +1,134 @@ +import type express from 'express'; +import type { Plugin, PluginContext } from '../../core/plugin.js'; +import { sseBroadcast } from '../../core/sse.js'; +import { getState, setState } from '../../core/persistence.js'; +import { getProfile, getMatches, getMatchDetail, REGIONS } from './opgg-api.js'; +import type { RecentSearch } from './types.js'; + +// ── Recent searches ── + +function getRecent(): RecentSearch[] { + return getState('lolstats_recent', []); +} + +function addRecent(entry: Omit): void { + let list = getRecent(); + // Remove duplicate + list = list.filter( + r => !(r.game_name.toLowerCase() === entry.game_name.toLowerCase() + && r.tag_line.toLowerCase() === entry.tag_line.toLowerCase() + && r.region === entry.region), + ); + // Add to front, cap at 10 + list.unshift({ ...entry, timestamp: Date.now() }); + if (list.length > 10) list.length = 10; + setState('lolstats_recent', list); + sseBroadcast({ type: 'lolstats_recent', plugin: 'lolstats', recentSearches: list }); +} + +// ── Plugin ── + +const lolstatsPlugin: Plugin = { + name: 'lolstats', + version: '1.0.0', + description: 'League of Legends Stats', + + async init(_ctx) { + console.log('[LoLStats] Initialized — using op.gg MCP (no API key needed)'); + }, + + registerRoutes(app: express.Application, _ctx: PluginContext) { + // ── Regions ── + app.get('/api/lolstats/regions', (_req, res) => { + res.json(REGIONS); + }); + + // ── Summoner Profile Lookup ── + app.get('/api/lolstats/profile', async (req, res) => { + const { gameName, tagLine, region } = req.query as Record; + if (!gameName || !tagLine || !region) { + return res.status(400).json({ error: 'gameName, tagLine, region required' }); + } + try { + const profile = await getProfile(gameName, tagLine, region); + + // Save to recent searches + const soloRank = profile.league_stats?.find(l => l.game_type === 'SOLORANKED'); + addRecent({ + game_name: profile.game_name, + tag_line: profile.tagline, + region, + profile_image_url: profile.profile_image_url, + level: profile.level, + tier: soloRank?.tier_info?.tier ?? undefined, + rank: soloRank?.tier_info?.division != null + ? String(soloRank.tier_info.division) + : undefined, + }); + + res.json(profile); + } catch (e: any) { + console.error('[LoLStats] Profile error:', e.message); + const status = e.message.includes('not found') ? 404 : 502; + res.status(status).json({ error: e.message }); + } + }); + + // ── Match History ── + app.get('/api/lolstats/matches', async (req, res) => { + const { gameName, tagLine, region, limit } = req.query as Record; + if (!gameName || !tagLine || !region) { + return res.status(400).json({ error: 'gameName, tagLine, region required' }); + } + try { + const matches = await getMatches(gameName, tagLine, region, Number(limit) || 10); + res.json(matches); + } catch (e: any) { + console.error('[LoLStats] Matches error:', e.message); + res.status(502).json({ error: e.message }); + } + }); + + // ── Match Detail ── + app.get('/api/lolstats/match/:gameId', async (req, res) => { + const { region, createdAt } = req.query as Record; + const { gameId } = req.params; + if (!gameId || !region || !createdAt) { + return res.status(400).json({ error: 'gameId, region, createdAt required' }); + } + try { + const match = await getMatchDetail(gameId, createdAt, region); + res.json(match); + } catch (e: any) { + console.error('[LoLStats] Match detail error:', e.message); + res.status(502).json({ error: e.message }); + } + }); + + // ── Recent Searches ── + app.get('/api/lolstats/recent', (_req, res) => { + res.json(getRecent()); + }); + + app.delete('/api/lolstats/recent', (_req, res) => { + setState('lolstats_recent', []); + sseBroadcast({ type: 'lolstats_recent', plugin: 'lolstats', recentSearches: [] }); + res.json({ ok: true }); + }); + }, + + getSnapshot(_ctx) { + return { + lolstats: { + recentSearches: getRecent(), + regions: REGIONS, + }, + }; + }, + + async destroy() { + console.log('[LoLStats] Destroyed'); + }, +}; + +export default lolstatsPlugin; diff --git a/server/src/plugins/lolstats/opgg-api.ts b/server/src/plugins/lolstats/opgg-api.ts new file mode 100644 index 0000000..4fde86c --- /dev/null +++ b/server/src/plugins/lolstats/opgg-api.ts @@ -0,0 +1,336 @@ +/** + * 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). + * No API key needed. + */ + +import type { + RegionInfo, SummonerProfile, MatchEntry, MatchParticipant, + MatchParticipantStats, TeamStat, +} from './types.js'; + +const MCP_URL = 'https://mcp-api.op.gg/mcp'; +let rpcId = 1; + +// ── Regions ── + +export const REGIONS: RegionInfo[] = [ + { code: 'EUW', label: 'EU West' }, + { code: 'EUNE', label: 'EU Nordic & East' }, + { code: 'NA', label: 'North America' }, + { code: 'KR', label: 'Korea' }, + { code: 'BR', label: 'Brazil' }, + { code: 'LAN', label: 'Latin America North' }, + { code: 'LAS', label: 'Latin America South' }, + { code: 'TR', label: 'Turkey' }, + { code: 'RU', label: 'Russia' }, + { code: 'JP', label: 'Japan' }, + { code: 'OCE', label: 'Oceania' }, + { code: 'PH', label: 'Philippines' }, + { code: 'SG', label: 'Singapore' }, + { code: 'TH', label: 'Thailand' }, + { code: 'TW', label: 'Taiwan' }, + { code: 'VN', label: 'Vietnam' }, +]; + +// ── MCP call helper ── + +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) { + 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 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); + } + + 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; +} + +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( + 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; +} diff --git a/server/src/plugins/lolstats/types.ts b/server/src/plugins/lolstats/types.ts new file mode 100644 index 0000000..c287e78 --- /dev/null +++ b/server/src/plugins/lolstats/types.ts @@ -0,0 +1,146 @@ +// ── op.gg MCP response types ── + +export interface RegionInfo { + code: string; // "EUW", "NA", "KR", etc. + label: string; // "EU West", "North America", etc. +} + +// ── Summoner Profile ── + +export interface TierInfo { + tier: string | null; // "GOLD", "GRANDMASTER", etc. + division: number | null; // 1,2,3,4 + lp: number | null; + tier_image_url?: string; +} + +export interface LeagueStat { + game_type: string; // "SOLORANKED", "FLEXRANKED", "ARENA" + win: number | null; + lose: number | null; + is_hot_streak: boolean; + tier_info: TierInfo; +} + +export interface LadderRank { + rank: number | null; + total: number | null; +} + +export interface ChampionStat { + champion_name: string; + id: number; + play: number; + win: number; + lose: number; + kill: number; + death: number; + assist: number; +} + +export interface MostChampions { + game_type: string; + play: number; + win: number; + lose: number; + champion_stats: ChampionStat[]; +} + +export interface SummonerProfile { + game_name: string; + tagline: string; + level: number; + profile_image_url: string; + league_stats: LeagueStat[]; + ladder_rank: LadderRank; + most_champions: MostChampions; + updated_at: string; +} + +// ── Match History ── + +export interface MatchParticipantStats { + kill: number; + death: number; + assist: number; + minion_kill: number; + neutral_minion_kill: number; + gold_earned: number; + total_damage_dealt_to_champions: number; + total_damage_taken: number; + ward_place: number; + vision_wards_bought_in_game: number; + champion_level: number; + op_score: number; + op_score_rank: number; + result: string; // "WIN" | "LOSE" +} + +export interface MatchParticipant { + champion_name: string; + champion_id: number; + items: number[]; + items_names: string[]; + position: string; // "TOP", "JUNGLE", "MID", "ADC", "SUPPORT" + team_key: string; // "BLUE" | "RED" + spells: number[]; + stats: MatchParticipantStats; + summoner: { + game_name: string; + tagline: string; + puuid?: string; + }; +} + +export interface TeamStat { + key: string; // "BLUE" | "RED" + banned_champions_names: string[]; + game_stat: { + is_win: boolean; + baron_kill: number; + dragon_kill: number; + tower_kill: number; + champion_kill: number; + gold_earned: number; + inhibitor_kill: number; + rift_herald_kill: number; + }; +} + +export interface MatchEntry { + id: string; + created_at: string; + game_length_second: number; + game_type: string; // "SOLORANKED", "FLEXRANKED", "NORMAL", "ARAM" + game_map: string; + average_tier_info?: { tier: string; division: number }; + participants: MatchParticipant[]; + teams: TeamStat[]; +} + +// ── Composite types for API responses ── + +export interface ProfileResponse { + summoner: SummonerProfile; +} + +export interface MatchHistoryResponse { + matches: MatchEntry[]; +} + +export interface MatchDetailResponse { + match: MatchEntry; +} + +// ── Recent search persistence ── + +export interface RecentSearch { + game_name: string; + tag_line: string; + region: string; + profile_image_url: string; + level: number; + tier?: string; + rank?: string; + timestamp: number; +} diff --git a/web/src/App.tsx b/web/src/App.tsx index f382196..b810ca4 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from 'react'; import RadioTab from './plugins/radio/RadioTab'; import SoundboardTab from './plugins/soundboard/SoundboardTab'; +import LolstatsTab from './plugins/lolstats/LolstatsTab'; interface PluginInfo { name: string; @@ -12,6 +13,7 @@ interface PluginInfo { const tabComponents: Record> = { radio: RadioTab, soundboard: SoundboardTab, + lolstats: LolstatsTab, }; export function registerTab(pluginName: string, component: React.FC<{ data: any }>) { @@ -79,6 +81,7 @@ export default function App() { const tabIcons: Record = { radio: '\u{1F30D}', soundboard: '\u{1F3B5}', + lolstats: '\u{2694}\uFE0F', stats: '\u{1F4CA}', events: '\u{1F4C5}', games: '\u{1F3B2}', diff --git a/web/src/plugins/lolstats/LolstatsTab.tsx b/web/src/plugins/lolstats/LolstatsTab.tsx new file mode 100644 index 0000000..8409abe --- /dev/null +++ b/web/src/plugins/lolstats/LolstatsTab.tsx @@ -0,0 +1,479 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import './lolstats.css'; + +// ── Types ── + +interface RegionInfo { code: string; label: string; } +interface TierInfo { tier: string | null; division: number | null; lp: number | null; tier_image_url?: string; } +interface LeagueStat { game_type: string; win: number | null; lose: number | null; is_hot_streak: boolean; tier_info: TierInfo; } +interface LadderRank { rank: number | null; total: number | null; } +interface ChampionStat { champion_name: string; id: number; play: number; win: number; lose: number; kill: number; death: number; assist: number; } +interface MostChampions { game_type: string; play: number; win: number; lose: number; champion_stats: ChampionStat[]; } + +interface SummonerProfile { + game_name: string; tagline: string; level: number; profile_image_url: string; + league_stats: LeagueStat[]; ladder_rank: LadderRank; most_champions: MostChampions; + updated_at: string; +} + +interface MatchParticipant { + champion_name: string; champion_id: number; items: number[]; items_names: string[]; + position: string; team_key: string; spells: number[]; + stats: { + kill: number; death: number; assist: number; minion_kill: number; neutral_minion_kill: number; + gold_earned: number; total_damage_dealt_to_champions: number; total_damage_taken: number; + ward_place: number; vision_wards_bought_in_game: number; champion_level: number; + op_score: number; op_score_rank: number; result: string; + }; + summoner: { game_name: string; tagline: string; }; +} + +interface TeamStat { + key: string; banned_champions_names: string[]; + game_stat: { is_win: boolean; baron_kill: number; dragon_kill: number; tower_kill: number; champion_kill: number; gold_earned: number; }; +} + +interface MatchEntry { + id: string; created_at: string; game_length_second: number; game_type: string; game_map: string; + average_tier_info?: { tier: string; division: number }; + participants: MatchParticipant[]; teams: TeamStat[]; +} + +interface RecentSearch { + game_name: string; tag_line: string; region: string; + profile_image_url: string; level: number; tier?: string; rank?: string; timestamp: number; +} + +// ── Constants ── + +const TIER_COLORS: Record = { + IRON: '#6b6b6b', BRONZE: '#8c6239', SILVER: '#8c8c8c', GOLD: '#d4a017', + PLATINUM: '#28b29e', EMERALD: '#1e9e5e', DIAMOND: '#576cce', + MASTER: '#9d48e0', GRANDMASTER: '#e44c3e', CHALLENGER: '#f4c874', +}; + +const QUEUE_NAMES: Record = { + SOLORANKED: 'Ranked Solo', FLEXRANKED: 'Ranked Flex', + NORMAL: 'Normal', ARAM: 'ARAM', ARENA: 'Arena', URF: 'URF', + BOT: 'Co-op vs AI', +}; + +const DDRAGON = 'https://ddragon.leagueoflegends.com/cdn/15.5.1/img'; + +// ── Helpers ── + +function champImg(name: string): string { + return `${DDRAGON}/champion/${name}.png`; +} + +function timeAgo(iso: string): string { + const sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (sec < 60) return `${sec}s`; + if (sec < 3600) return `${Math.floor(sec / 60)}m`; + if (sec < 86400) return `${Math.floor(sec / 3600)}h`; + return `${Math.floor(sec / 86400)}d`; +} + +function fmtDuration(secs: number): string { + const m = Math.floor(secs / 60); + const s = secs % 60; + return `${m}:${String(s).padStart(2, '0')}`; +} + +function kdaRatio(k: number, d: number, a: number): string { + if (d === 0) return 'Perfect'; + return ((k + a) / d).toFixed(2); +} + +function winRate(w: number, l: number): number { + const total = w + l; + return total > 0 ? Math.round((w / total) * 100) : 0; +} + +function tierDisplay(tier: string | null, div: number | null): string { + if (!tier) return 'Unranked'; + const roman = ['', 'I', 'II', 'III', 'IV']; + return `${tier.charAt(0)}${tier.slice(1).toLowerCase()}${div ? ' ' + (roman[div] ?? div) : ''}`; +} + +// ── Component ── + +export default function LolstatsTab({ data }: { data: any }) { + const [searchText, setSearchText] = useState(''); + const [region, setRegion] = useState('EUW'); + const [regions, setRegions] = useState([]); + + const [profile, setProfile] = useState(null); + const [matches, setMatches] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [recentSearches, setRecentSearches] = useState([]); + const [expandedMatch, setExpandedMatch] = useState(null); + const [matchDetails, setMatchDetails] = useState>({}); + const [loadingMore, setLoadingMore] = useState(false); + + const searchRef = useRef(null); + + // Load regions + useEffect(() => { + fetch('/api/lolstats/regions').then(r => r.json()).then(setRegions).catch(() => {}); + fetch('/api/lolstats/recent').then(r => r.json()).then(setRecentSearches).catch(() => {}); + }, []); + + // SSE data + useEffect(() => { + if (!data) return; + if (data.recentSearches) setRecentSearches(data.recentSearches); + if (data.regions && !regions.length) setRegions(data.regions); + }, [data]); + + // ── Search ── + const doSearch = useCallback(async (gn?: string, tl?: string, rg?: string) => { + let gameName = gn ?? ''; + let tagLine = tl ?? ''; + const searchRegion = rg ?? region; + + if (!gameName) { + // Parse from search text "Name#Tag" + const parts = searchText.split('#'); + gameName = parts[0]?.trim() ?? ''; + tagLine = parts[1]?.trim() ?? ''; + } + + if (!gameName || !tagLine) { + setError('Bitte im Format Name#Tag eingeben'); + return; + } + + setLoading(true); + setError(null); + setProfile(null); + setMatches([]); + setExpandedMatch(null); + setMatchDetails({}); + + try { + const qs = `gameName=${encodeURIComponent(gameName)}&tagLine=${encodeURIComponent(tagLine)}®ion=${searchRegion}`; + + // Fetch profile and matches in parallel + const [profileRes, matchesRes] = await Promise.all([ + fetch(`/api/lolstats/profile?${qs}`), + fetch(`/api/lolstats/matches?${qs}&limit=10`), + ]); + + if (!profileRes.ok) { + const err = await profileRes.json(); + throw new Error(err.error ?? `Fehler ${profileRes.status}`); + } + + const profileData = await profileRes.json(); + setProfile(profileData); + + if (matchesRes.ok) { + const matchesData = await matchesRes.json(); + setMatches(Array.isArray(matchesData) ? matchesData : []); + } + } catch (e: any) { + setError(e.message); + } + setLoading(false); + }, [searchText, region]); + + // ── Load more matches ── + const loadMore = useCallback(async () => { + if (!profile || loadingMore) return; + setLoadingMore(true); + try { + const qs = `gameName=${encodeURIComponent(profile.game_name)}&tagLine=${encodeURIComponent(profile.tagline)}®ion=${region}&limit=20`; + const res = await fetch(`/api/lolstats/matches?${qs}`); + if (res.ok) { + const data = await res.json(); + setMatches(Array.isArray(data) ? data : []); + } + } catch {} + setLoadingMore(false); + }, [profile, region, loadingMore]); + + // ── Expand match detail ── + const toggleMatch = useCallback(async (match: MatchEntry) => { + if (expandedMatch === match.id) { + setExpandedMatch(null); + return; + } + setExpandedMatch(match.id); + + // If we already have full detail (10 participants), skip fetch + if (match.participants?.length >= 10 || matchDetails[match.id]) return; + + try { + const qs = `region=${region}&createdAt=${encodeURIComponent(match.created_at)}`; + const res = await fetch(`/api/lolstats/match/${encodeURIComponent(match.id)}?${qs}`); + if (res.ok) { + const detail = await res.json(); + setMatchDetails(prev => ({ ...prev, [match.id]: detail })); + } + } catch {} + }, [expandedMatch, matchDetails, region]); + + // Recent search click + const onRecentClick = useCallback((r: RecentSearch) => { + setSearchText(`${r.game_name}#${r.tag_line}`); + setRegion(r.region); + doSearch(r.game_name, r.tag_line, r.region); + }, [doSearch]); + + // ── 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]; + if (!me) return null; + + const isWin = me.stats?.result === 'WIN'; + const kda = kdaRatio(me.stats.kill, me.stats.death, me.stats.assist); + const cs = (me.stats.minion_kill ?? 0) + (me.stats.neutral_minion_kill ?? 0); + const csPerMin = match.game_length_second > 0 ? (cs / (match.game_length_second / 60)).toFixed(1) : '0'; + const isExpanded = expandedMatch === match.id; + const detail = matchDetails[match.id] ?? (match.participants?.length >= 10 ? match : null); + + return ( +
+
toggleMatch(match)} + > +
{isWin ? 'W' : 'L'}
+ +
+ {me.champion_name} + {me.stats.champion_level} +
+ +
+
{me.stats.kill}/{me.stats.death}/{me.stats.assist}
+
= 4 ? 'great' : ''}`}> + {kda} KDA +
+
+ +
+ {cs} CS ({csPerMin}/m) + {me.stats.ward_place} wards +
+ +
+ {(me.items_names ?? []).slice(0, 7).map((item, i) => + item ? ( + {item} { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + ) :
+ )} +
+ +
+
{fmtDuration(match.game_length_second)}
+
{QUEUE_NAMES[match.game_type] ?? match.game_type}
+
{timeAgo(match.created_at)} ago
+
+
+ + {/* Expanded detail */} + {isExpanded && detail && ( +
+ {renderMatchDetail(detail, me.summoner?.game_name)} +
+ )} +
+ ); + }; + + // ── Render Match Detail (all 10 players) ── + const renderMatchDetail = (match: MatchEntry, myName?: string) => { + const blue = match.participants?.filter(p => p.team_key === 'BLUE') ?? []; + const red = match.participants?.filter(p => p.team_key === 'RED') ?? []; + const blueWin = match.teams?.find(t => t.key === 'BLUE')?.game_stat?.is_win; + + const renderTeam = (team: MatchParticipant[], isWin: boolean | undefined, label: string) => ( +
+
+ {label} — {isWin ? 'Victory' : 'Defeat'} +
+ {team.map((p, i) => { + const isMe = p.summoner?.game_name?.toLowerCase() === myName?.toLowerCase(); + const cs = (p.stats?.minion_kill ?? 0) + (p.stats?.neutral_minion_kill ?? 0); + return ( +
+ {p.champion_name} + + {p.summoner?.game_name ?? p.champion_name} + + {p.stats?.kill}/{p.stats?.death}/{p.stats?.assist} + {cs} CS + {((p.stats?.total_damage_dealt_to_champions ?? 0) / 1000).toFixed(1)}k + {((p.stats?.gold_earned ?? 0) / 1000).toFixed(1)}k +
+ ); + })} +
+ ); + + return ( + <> + {renderTeam(blue, blueWin, 'Blue Team')} + {renderTeam(red, blueWin === undefined ? undefined : !blueWin, 'Red Team')} + + ); + }; + + // ── Main Render ── + return ( +
+ {/* Search */} +
+ setSearchText(e.target.value)} + onKeyDown={e => e.key === 'Enter' && doSearch()} + /> + + +
+ + {/* Recent searches */} + {recentSearches.length > 0 && ( +
+ {recentSearches.map((r, i) => ( + + ))} +
+ )} + + {/* Error */} + {error &&
{error}
} + + {/* Loading */} + {loading && ( +
+
+ Lade Profil... +
+ )} + + {/* Profile */} + {profile && !loading && ( + <> +
+ +
+

{profile.game_name}#{profile.tagline}

+
Level {profile.level}
+ {profile.ladder_rank?.rank && ( +
+ Ladder Rank #{profile.ladder_rank.rank.toLocaleString()} / {profile.ladder_rank.total?.toLocaleString()} +
+ )} +
+
+ + {/* Ranked Cards */} +
+ {(profile.league_stats ?? []) + .filter(ls => ls.game_type === 'SOLORANKED' || ls.game_type === 'FLEXRANKED') + .map(ls => { + const t = ls.tier_info; + const hasRank = !!t?.tier; + const tierColor = TIER_COLORS[t?.tier ?? ''] ?? 'var(--text-normal)'; + return ( +
+
+ {ls.game_type === 'SOLORANKED' ? 'Ranked Solo/Duo' : 'Ranked Flex'} +
+ {hasRank ? ( + <> +
+ {tierDisplay(t.tier, t.division)} + {t.lp} LP +
+
+ {ls.win}W {ls.lose}L + ({winRate(ls.win ?? 0, ls.lose ?? 0)}%) + {ls.is_hot_streak && 🔥} +
+ + ) : ( +
Unranked
+ )} +
+ ); + })} +
+ + {/* Most Champions */} + {profile.most_champions?.champion_stats?.length > 0 && ( + <> +
Top Champions
+
+ {profile.most_champions.champion_stats.slice(0, 7).map(cs => { + const wr = winRate(cs.win, cs.lose); + const avgKda = cs.play > 0 + ? kdaRatio(cs.kill / cs.play, cs.death / cs.play, cs.assist / cs.play) + : '0'; + return ( +
+ {cs.champion_name} +
+
{cs.champion_name}
+
{cs.play} games · {wr}% WR
+
{avgKda} KDA
+
+
+ ); + })} +
+ + )} + + {/* Match History */} + {matches.length > 0 && ( + <> +
Match History
+
+ {matches.map(m => renderMatch(m))} +
+ {matches.length < 20 && ( + + )} + + )} + + )} + + {/* Empty state */} + {!profile && !loading && !error && ( +
+
⚔️
+

League of Legends Stats

+

Gib einen Summoner Name#Tag ein und wähle die Region

+
+ )} +
+ ); +} diff --git a/web/src/plugins/lolstats/lolstats.css b/web/src/plugins/lolstats/lolstats.css new file mode 100644 index 0000000..84d397f --- /dev/null +++ b/web/src/plugins/lolstats/lolstats.css @@ -0,0 +1,464 @@ +/* ── LoL Stats Plugin ── */ + +.lol-container { + max-width: 920px; + margin: 0 auto; + padding: 16px; + height: 100%; + overflow-y: auto; +} + +/* ── Search ── */ +.lol-search { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.lol-search-input { + flex: 1; + min-width: 0; + padding: 10px 14px; + border: 1px solid var(--bg-tertiary); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-normal); + font-size: 15px; + outline: none; + transition: border-color 0.2s; +} +.lol-search-input:focus { border-color: var(--accent); } +.lol-search-input::placeholder { color: var(--text-faint); } + +.lol-search-region { + padding: 10px 12px; + border: 1px solid var(--bg-tertiary); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-normal); + font-size: 14px; + cursor: pointer; + outline: none; +} + +.lol-search-btn { + padding: 10px 20px; + border: none; + border-radius: 8px; + background: var(--accent); + color: #fff; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: opacity 0.2s; + white-space: nowrap; +} +.lol-search-btn:hover { opacity: 0.85; } +.lol-search-btn:disabled { opacity: 0.4; cursor: not-allowed; } + +/* ── Recent Searches ── */ +.lol-recent { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.lol-recent-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border: 1px solid var(--bg-tertiary); + border-radius: 16px; + background: var(--bg-secondary); + color: var(--text-muted); + font-size: 12px; + cursor: pointer; + transition: border-color 0.2s, color 0.2s; +} +.lol-recent-chip:hover { border-color: var(--accent); color: var(--text-normal); } +.lol-recent-chip img { + width: 18px; height: 18px; border-radius: 50%; +} +.lol-recent-tier { + font-size: 10px; + font-weight: 600; + opacity: 0.7; + text-transform: uppercase; +} + +/* ── Profile Header ── */ +.lol-profile { + display: flex; + align-items: center; + gap: 16px; + padding: 16px; + border-radius: 12px; + background: var(--bg-secondary); + margin-bottom: 12px; +} +.lol-profile-icon { + width: 72px; height: 72px; + border-radius: 12px; + border: 2px solid var(--bg-tertiary); + object-fit: cover; +} +.lol-profile-info h2 { + margin: 0 0 2px; + font-size: 20px; + color: var(--text-normal); +} +.lol-profile-info h2 span { + color: var(--text-faint); + font-weight: 400; + font-size: 14px; +} +.lol-profile-level { + font-size: 12px; + color: var(--text-muted); +} +.lol-profile-ladder { + font-size: 11px; + color: var(--text-faint); +} + +/* ── Ranked Cards ── */ +.lol-ranked-row { + display: flex; + gap: 10px; + margin-bottom: 12px; +} + +.lol-ranked-card { + flex: 1; + padding: 12px 14px; + border-radius: 10px; + background: var(--bg-secondary); + border-left: 4px solid var(--bg-tertiary); +} +.lol-ranked-card.has-rank { border-left-color: var(--tier-color, var(--accent)); } +.lol-ranked-type { + font-size: 11px; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} +.lol-ranked-tier { + font-size: 18px; + font-weight: 700; + color: var(--tier-color, var(--text-normal)); +} +.lol-ranked-lp { + font-size: 13px; + color: var(--text-muted); + margin-left: 4px; + font-weight: 400; +} +.lol-ranked-record { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} +.lol-ranked-wr { + color: var(--text-faint); + margin-left: 4px; +} +.lol-ranked-streak { + color: #e74c3c; + font-size: 11px; + margin-left: 4px; +} + +/* ── Section Headers ── */ +.lol-section-title { + font-size: 13px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin: 16px 0 8px; + padding-left: 2px; +} + +/* ── Most Champions ── */ +.lol-champs { + display: flex; + gap: 8px; + margin-bottom: 12px; + overflow-x: auto; + padding-bottom: 4px; +} +.lol-champ-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 8px; + background: var(--bg-secondary); + min-width: 180px; + flex-shrink: 0; +} +.lol-champ-icon { + width: 36px; height: 36px; + border-radius: 50%; + object-fit: cover; +} +.lol-champ-name { + font-size: 13px; + font-weight: 600; + color: var(--text-normal); +} +.lol-champ-stats { + font-size: 11px; + color: var(--text-muted); +} +.lol-champ-kda { + font-size: 11px; + color: var(--text-faint); +} + +/* ── Match History ── */ +.lol-matches { + display: flex; + flex-direction: column; + gap: 6px; +} + +.lol-match { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + background: var(--bg-secondary); + border-left: 4px solid var(--bg-tertiary); + cursor: pointer; + transition: background 0.15s; +} +.lol-match:hover { background: var(--bg-tertiary); } +.lol-match.win { border-left-color: #2ecc71; } +.lol-match.loss { border-left-color: #e74c3c; } + +.lol-match-result { + width: 28px; + font-size: 11px; + font-weight: 700; + text-align: center; + flex-shrink: 0; +} +.lol-match.win .lol-match-result { color: #2ecc71; } +.lol-match.loss .lol-match-result { color: #e74c3c; } + +.lol-match-champ { + position: relative; + flex-shrink: 0; +} +.lol-match-champ img { + width: 40px; height: 40px; border-radius: 50%; + display: block; +} +.lol-match-champ-level { + position: absolute; + bottom: -2px; right: -2px; + background: var(--bg-deep); + color: var(--text-muted); + font-size: 9px; + font-weight: 700; + width: 16px; height: 16px; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; +} + +.lol-match-kda { + min-width: 80px; + text-align: center; + flex-shrink: 0; +} +.lol-match-kda-nums { + font-size: 14px; + font-weight: 600; + color: var(--text-normal); +} +.lol-match-kda-ratio { + font-size: 11px; + color: var(--text-faint); +} +.lol-match-kda-ratio.perfect { color: #f39c12; } +.lol-match-kda-ratio.great { color: #2ecc71; } + +.lol-match-stats { + display: flex; + flex-direction: column; + gap: 1px; + min-width: 70px; + flex-shrink: 0; +} +.lol-match-stats span { + font-size: 11px; + color: var(--text-muted); +} + +.lol-match-items { + display: flex; + gap: 2px; + flex-shrink: 0; +} +.lol-match-items img { + width: 24px; height: 24px; + border-radius: 4px; + background: var(--bg-deep); +} +.lol-match-item-empty { + width: 24px; height: 24px; + border-radius: 4px; + background: var(--bg-deep); +} + +.lol-match-meta { + margin-left: auto; + text-align: right; + flex-shrink: 0; +} +.lol-match-duration { + font-size: 12px; + color: var(--text-muted); +} +.lol-match-queue { + font-size: 10px; + color: var(--text-faint); +} +.lol-match-ago { + font-size: 10px; + color: var(--text-faint); +} + +/* ── Match Detail (expanded) ── */ +.lol-match-detail { + background: var(--bg-primary); + border-radius: 8px; + padding: 8px; + margin-top: 4px; + margin-bottom: 4px; +} +.lol-match-detail-team { + margin-bottom: 6px; +} +.lol-match-detail-team-header { + font-size: 11px; + font-weight: 600; + padding: 4px 8px; + border-radius: 4px; + margin-bottom: 4px; +} +.lol-match-detail-team-header.win { background: rgba(46,204,113,0.15); color: #2ecc71; } +.lol-match-detail-team-header.loss { background: rgba(231,76,60,0.15); color: #e74c3c; } + +.lol-detail-row { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 8px; + border-radius: 4px; + font-size: 12px; + color: var(--text-muted); +} +.lol-detail-row:hover { background: var(--bg-secondary); } +.lol-detail-row.me { background: rgba(255,255,255,0.04); font-weight: 600; } + +.lol-detail-champ { + width: 24px; height: 24px; border-radius: 50%; +} +.lol-detail-name { + width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-normal); +} +.lol-detail-kda { width: 70px; text-align: center; } +.lol-detail-cs { width: 45px; text-align: center; } +.lol-detail-dmg { width: 55px; text-align: center; } +.lol-detail-gold { width: 55px; text-align: center; } +.lol-detail-items { + display: flex; gap: 1px; +} +.lol-detail-items img { + width: 20px; height: 20px; border-radius: 3px; +} + +/* ── Loading / Error / Empty ── */ +.lol-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 40px; + color: var(--text-muted); + font-size: 14px; +} + +.lol-spinner { + width: 20px; height: 20px; + border: 2px solid var(--bg-tertiary); + border-top-color: var(--accent); + border-radius: 50%; + animation: lol-spin 0.8s linear infinite; +} +@keyframes lol-spin { to { transform: rotate(360deg); } } + +.lol-error { + padding: 16px; + border-radius: 8px; + background: rgba(231,76,60,0.1); + color: #e74c3c; + font-size: 13px; + text-align: center; + margin-bottom: 12px; +} + +.lol-empty { + text-align: center; + padding: 60px 20px; + color: var(--text-faint); +} +.lol-empty-icon { + font-size: 48px; + margin-bottom: 12px; +} +.lol-empty h3 { + margin: 0 0 8px; + color: var(--text-muted); + font-size: 16px; +} +.lol-empty p { + margin: 0; + font-size: 13px; +} + +/* ── Load more ── */ +.lol-load-more { + display: block; + width: 100%; + padding: 10px; + margin-top: 8px; + border: 1px solid var(--bg-tertiary); + border-radius: 8px; + background: transparent; + color: var(--text-muted); + font-size: 13px; + cursor: pointer; + transition: border-color 0.2s, color 0.2s; +} +.lol-load-more:hover { border-color: var(--accent); color: var(--text-normal); } + +/* ── Responsive ── */ +@media (max-width: 640px) { + .lol-search { flex-wrap: wrap; } + .lol-search-input { width: 100%; } + .lol-match { flex-wrap: wrap; gap: 6px; } + .lol-match-meta { margin-left: 0; text-align: left; } + .lol-match-items { flex-wrap: wrap; } + .lol-profile { flex-wrap: wrap; } +}