diff --git a/server/src/plugins/lolstats/index.ts b/server/src/plugins/lolstats/index.ts index 0f1f05c..c110451 100644 --- a/server/src/plugins/lolstats/index.ts +++ b/server/src/plugins/lolstats/index.ts @@ -2,7 +2,7 @@ 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, renewSummoner, REGIONS } from './opgg-api.js'; +import { getProfile, getMatches, getMatchDetail, renewSummoner, REGIONS, GAME_MODES, getChampionTierList } from './opgg-api.js'; import type { RecentSearch } from './types.js'; // ── Recent searches ── @@ -120,6 +120,26 @@ const lolstatsPlugin: Plugin = { } }); + // ── Game Modes ── + app.get('/api/lolstats/modes', (_req, res) => { + res.json(GAME_MODES); + }); + + // ── Champion Tier List by Mode ── + app.get('/api/lolstats/tierlist', async (req, res) => { + const { mode, region } = req.query as Record; + if (!mode || !region) { + return res.status(400).json({ error: 'mode and region required' }); + } + try { + const tierList = await getChampionTierList(mode, region); + res.json(tierList); + } catch (e: any) { + console.error('[LoLStats] Tier list error:', e.message); + res.status(502).json({ error: e.message }); + } + }); + // ── Recent Searches ── app.get('/api/lolstats/recent', (_req, res) => { res.json(getRecent()); diff --git a/server/src/plugins/lolstats/opgg-api.ts b/server/src/plugins/lolstats/opgg-api.ts index dade649..6c4e3a1 100644 --- a/server/src/plugins/lolstats/opgg-api.ts +++ b/server/src/plugins/lolstats/opgg-api.ts @@ -7,10 +7,12 @@ import type { RegionInfo, SummonerProfile, MatchEntry, MatchParticipant, - MatchParticipantStats, TeamStat, + MatchParticipantStats, TeamStat, GameModeInfo, ChampionTierEntry, + ChampionTierList, } from './types.js'; const OPGG_API = 'https://lol-api-summoner.op.gg/api'; +const CHAMPION_API = 'https://lol-api-champion.op.gg/api'; const MCP_URL = 'https://mcp-api.op.gg/mcp'; let rpcId = 1; @@ -690,3 +692,87 @@ async function getMatchDetailMCP( const parsed = parseClassNotation(raw); return parsed?.game_detail ?? parsed?.data?.game_detail ?? null; } + +// ══════════════════════════════════════════════════════════════ +// Champion Tier List per Game Mode +// ══════════════════════════════════════════════════════════════ + +export const GAME_MODES: GameModeInfo[] = [ + { key: 'aram', label: 'ARAM' }, + { key: 'arena', label: 'Arena' }, + { key: 'urf', label: 'URF' }, + { key: 'nexus_blitz', label: 'Nexus Blitz' }, + { key: 'ranked', label: 'Ranked' }, + { key: 'flex', label: 'Flex' }, +]; + +const tierListCache = new Map(); +const TIER_CACHE_TTL = 30 * 60 * 1000; // 30 minutes + +export async function getChampionTierList( + mode: string, region: string, +): Promise { + await loadChampionMap(); + const regionLower = region.toLowerCase(); + const cacheKey = `${regionLower}:${mode}`; + + const cached = tierListCache.get(cacheKey); + if (cached && cached.expires > Date.now()) { + return cached.data; + } + + const url = `${CHAMPION_API}/${regionLower}/champions/${mode}?hl=en_US`; + console.log(`[LoLStats] Fetching champion tier list: ${mode} @ ${region}`); + + const res = await fetch(url, { headers: { 'Accept': 'application/json' } }); + if (!res.ok) { + throw new Error(`Champion tier list HTTP ${res.status}`); + } + + const json = await res.json() as any; + const data = json?.data; + + if (!Array.isArray(data)) { + throw new Error('Invalid champion tier list response'); + } + + const isArena = mode === 'arena'; + + const champions: ChampionTierEntry[] = data.map((c: any) => { + const stats = c.average_stats ?? {}; + const tierData = stats.tier_data ?? {}; + const entry: ChampionTierEntry = { + champion_id: c.id, + champion_name: champName(c.id), + tier: tierData.tier ?? stats.tier ?? 5, + rank: tierData.rank ?? stats.rank ?? 999, + win_rate: isArena + ? (stats.play > 0 ? stats.win / stats.play : null) + : (stats.win_rate ?? null), + pick_rate: stats.pick_rate ?? 0, + ban_rate: stats.ban_rate ?? null, + kda: stats.kda ?? null, + play: stats.play ?? 0, + }; + + if (isArena) { + entry.average_placement = stats.play > 0 ? stats.total_place / stats.play : undefined; + entry.first_place_rate = stats.play > 0 ? stats.first_place / stats.play : undefined; + } + + return entry; + }); + + champions.sort((a, b) => a.rank - b.rank); + + const result: ChampionTierList = { + mode, + region: regionLower, + champions, + fetched_at: Date.now(), + }; + + tierListCache.set(cacheKey, { data: result, expires: Date.now() + TIER_CACHE_TTL }); + console.log(`[LoLStats] Cached ${champions.length} champions for ${mode}@${region}`); + return result; +} diff --git a/server/src/plugins/lolstats/types.ts b/server/src/plugins/lolstats/types.ts index c287e78..a8896b6 100644 --- a/server/src/plugins/lolstats/types.ts +++ b/server/src/plugins/lolstats/types.ts @@ -132,6 +132,34 @@ export interface MatchDetailResponse { match: MatchEntry; } +// ── Champion Tier List (Game Modes) ── + +export interface GameModeInfo { + key: string; // "aram", "arena", etc. + label: string; // "ARAM", "Arena", etc. +} + +export interface ChampionTierEntry { + champion_id: number; + champion_name: string; + tier: number; // 0-5 (0=OP/S-tier, 5=niedrigste) + rank: number; // 1=bester + win_rate: number | null; + pick_rate: number; + ban_rate: number | null; + kda: number | null; + play: number; + average_placement?: number; // Arena-spezifisch + first_place_rate?: number; // Arena-spezifisch +} + +export interface ChampionTierList { + mode: string; + region: string; + champions: ChampionTierEntry[]; + fetched_at: number; +} + // ── Recent search persistence ── export interface RecentSearch { diff --git a/web/src/plugins/lolstats/LolstatsTab.tsx b/web/src/plugins/lolstats/LolstatsTab.tsx index 1b1e5a3..3b9006e 100644 --- a/web/src/plugins/lolstats/LolstatsTab.tsx +++ b/web/src/plugins/lolstats/LolstatsTab.tsx @@ -9,6 +9,13 @@ interface LeagueStat { game_type: string; win: number | null; lose: number | nul 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 GameModeInfo { key: string; label: string; } +interface ChampionTierEntry { + champion_id: number; champion_name: string; tier: number; rank: number; + win_rate: number | null; pick_rate: number; ban_rate: number | null; + kda: number | null; play: number; + average_placement?: number; first_place_rate?: number; +} interface SummonerProfile { game_name: string; tagline: string; level: number; profile_image_url: string; @@ -114,15 +121,42 @@ export default function LolstatsTab({ data }: { data: any }) { const [renewing, setRenewing] = useState(false); const [lastUpdated, setLastUpdated] = useState(null); + // Tier list state + const [tierMode, setTierMode] = useState('aram'); + const [tierRegion, setTierRegion] = useState('EUW'); + const [tierList, setTierList] = useState([]); + const [tierLoading, setTierLoading] = useState(false); + const [tierError, setTierError] = useState(null); + const [gameModes, setGameModes] = useState([]); + const [tierSearch, setTierSearch] = useState(''); + const [showAllTiers, setShowAllTiers] = useState(false); + const searchRef = useRef(null); const currentSearchRef = useRef<{ gameName: string; tagLine: string; region: string } | null>(null); - // Load regions + // Load regions + modes useEffect(() => { fetch('/api/lolstats/regions').then(r => r.json()).then(setRegions).catch(() => {}); fetch('/api/lolstats/recent').then(r => r.json()).then(setRecentSearches).catch(() => {}); + fetch('/api/lolstats/modes').then(r => r.json()).then(setGameModes).catch(() => {}); }, []); + // Load tier list when mode/region changes + useEffect(() => { + if (!tierMode) return; + setTierLoading(true); + setTierError(null); + setShowAllTiers(false); + fetch(`/api/lolstats/tierlist?mode=${tierMode}®ion=${tierRegion}`) + .then(r => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json(); + }) + .then(data => setTierList(data.champions ?? [])) + .catch(e => setTierError(e.message)) + .finally(() => setTierLoading(false)); + }, [tierMode, tierRegion]); + // SSE data useEffect(() => { if (!data) return; @@ -538,6 +572,101 @@ export default function LolstatsTab({ data }: { data: any }) {

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

)} + + {/* Champion Tier List — always visible */} +
+
Champion Tier List
+ +
+
+ {gameModes.map(m => ( + + ))} +
+ + setTierSearch(e.target.value)} + /> +
+ + {tierLoading && ( +
+
+ Lade Tier List... +
+ )} + {tierError &&
{tierError}
} + + {!tierLoading && !tierError && tierList.length > 0 && ( + <> +
+
+ # + Champion + Tier + {tierMode === 'arena' ? 'Win' : 'Win %'} + Pick % + {tierMode !== 'arena' && Ban %} + {tierMode === 'arena' ? 'Avg Place' : 'KDA'} +
+ {tierList + .filter(c => !tierSearch || c.champion_name.toLowerCase().includes(tierSearch.toLowerCase())) + .slice(0, showAllTiers ? undefined : 50) + .map(c => { + const tierLabels = ['OP', '1', '2', '3', '4', '5']; + return ( +
+ {c.rank} + + {c.champion_name} + {c.champion_name} + + + {tierLabels[c.tier] ?? c.tier} + + + {c.win_rate != null ? `${(c.win_rate * 100).toFixed(1)}%` : '-'} + + + {(c.pick_rate * 100).toFixed(1)}% + + {tierMode !== 'arena' && ( + + {c.ban_rate != null ? `${(c.ban_rate * 100).toFixed(1)}%` : '-'} + + )} + + {tierMode === 'arena' + ? (c.average_placement?.toFixed(1) ?? '-') + : (c.kda?.toFixed(2) ?? '-')} + +
+ ); + })} +
+ {!showAllTiers && tierList.length > 50 && ( + + )} + + )} +
); } diff --git a/web/src/plugins/lolstats/lolstats.css b/web/src/plugins/lolstats/lolstats.css index 337f28e..59503c4 100644 --- a/web/src/plugins/lolstats/lolstats.css +++ b/web/src/plugins/lolstats/lolstats.css @@ -490,6 +490,133 @@ } .lol-load-more:hover { border-color: var(--accent); color: var(--text-normal); } +/* ══════════════════════════════════════════════ + Champion Tier List + ══════════════════════════════════════════════ */ + +.lol-tier-section { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--bg-tertiary); +} + +.lol-tier-controls { + display: flex; + gap: 8px; + align-items: center; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.lol-tier-modes { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.lol-tier-mode-btn { + padding: 6px 14px; + border: 1px solid var(--bg-tertiary); + border-radius: 16px; + background: var(--bg-secondary); + color: var(--text-muted); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} +.lol-tier-mode-btn:hover { border-color: var(--accent); color: var(--text-normal); } +.lol-tier-mode-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.lol-tier-filter { + padding: 6px 12px; + border: 1px solid var(--bg-tertiary); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-normal); + font-size: 12px; + outline: none; + width: 140px; + margin-left: auto; +} +.lol-tier-filter:focus { border-color: var(--accent); } +.lol-tier-filter::placeholder { color: var(--text-faint); } + +/* Tier Table */ +.lol-tier-table { + display: flex; + flex-direction: column; + gap: 2px; +} + +.lol-tier-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + font-size: 10px; + font-weight: 600; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.lol-tier-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 6px; + background: var(--bg-secondary); + font-size: 13px; + color: var(--text-muted); + transition: background 0.15s; +} +.lol-tier-row:hover { background: var(--bg-tertiary); } + +/* Tier row accent borders */ +.lol-tier-row.tier-0 { border-left: 3px solid #f4c874; } +.lol-tier-row.tier-1 { border-left: 3px solid #d4a017; } +.lol-tier-row.tier-2 { border-left: 3px solid #576cce; } +.lol-tier-row.tier-3 { border-left: 3px solid #28b29e; } +.lol-tier-row.tier-4 { border-left: 3px solid var(--bg-tertiary); } +.lol-tier-row.tier-5 { border-left: 3px solid var(--bg-tertiary); opacity: 0.7; } + +/* Column widths */ +.lol-tier-col-rank { width: 32px; text-align: center; font-weight: 600; flex-shrink: 0; } +.lol-tier-col-champ { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + font-weight: 500; + color: var(--text-normal); +} +.lol-tier-col-champ img { + width: 28px; height: 28px; + border-radius: 50%; + flex-shrink: 0; +} +.lol-tier-col-tier { width: 36px; text-align: center; font-weight: 700; font-size: 12px; flex-shrink: 0; } +.lol-tier-col-wr { width: 60px; text-align: center; flex-shrink: 0; } +.lol-tier-col-pr { width: 60px; text-align: center; flex-shrink: 0; } +.lol-tier-col-br { width: 60px; text-align: center; flex-shrink: 0; } +.lol-tier-col-kda { width: 50px; text-align: center; flex-shrink: 0; } + +/* Tier badge colors */ +.tier-badge-0 { color: #f4c874; } +.tier-badge-1 { color: #d4a017; } +.tier-badge-2 { color: #576cce; } +.tier-badge-3 { color: #28b29e; } +.tier-badge-4 { color: var(--text-faint); } +.tier-badge-5 { color: var(--text-faint); } + /* ── Responsive ── */ @media (max-width: 640px) { .lol-search { flex-wrap: wrap; } @@ -498,4 +625,8 @@ .lol-match-meta { margin-left: 0; text-align: left; } .lol-match-items { flex-wrap: wrap; } .lol-profile { flex-wrap: wrap; } + .lol-tier-controls { flex-direction: column; align-items: stretch; } + .lol-tier-filter { width: 100%; margin-left: 0; } + .lol-tier-col-br { display: none; } + .lol-tier-col-kda { display: none; } }