LoLStats: Champion Tier List pro Game Mode (ARAM, Arena, URF, etc.)

Neue op.gg Champion API (lol-api-champion.op.gg) fuer Tier-Daten.
30-Min In-Memory Cache pro Mode+Region. Neuer Bereich unterhalb
der Match History mit Mode-Tabs, Champion-Grid, Tier-Badges und
Filter. Arena-Mode zeigt Avg Placement statt KDA.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-03-09 00:36:08 +01:00
parent 9093f12259
commit 905f156d47
5 changed files with 397 additions and 3 deletions

View file

@ -2,7 +2,7 @@ import type express from 'express';
import type { Plugin, PluginContext } from '../../core/plugin.js'; import type { Plugin, PluginContext } from '../../core/plugin.js';
import { sseBroadcast } from '../../core/sse.js'; import { sseBroadcast } from '../../core/sse.js';
import { getState, setState } from '../../core/persistence.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'; import type { RecentSearch } from './types.js';
// ── Recent searches ── // ── 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<string, string>;
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 ── // ── Recent Searches ──
app.get('/api/lolstats/recent', (_req, res) => { app.get('/api/lolstats/recent', (_req, res) => {
res.json(getRecent()); res.json(getRecent());

View file

@ -7,10 +7,12 @@
import type { import type {
RegionInfo, SummonerProfile, MatchEntry, MatchParticipant, RegionInfo, SummonerProfile, MatchEntry, MatchParticipant,
MatchParticipantStats, TeamStat, MatchParticipantStats, TeamStat, GameModeInfo, ChampionTierEntry,
ChampionTierList,
} from './types.js'; } from './types.js';
const OPGG_API = 'https://lol-api-summoner.op.gg/api'; 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'; const MCP_URL = 'https://mcp-api.op.gg/mcp';
let rpcId = 1; let rpcId = 1;
@ -690,3 +692,87 @@ async function getMatchDetailMCP(
const parsed = parseClassNotation(raw); const parsed = parseClassNotation(raw);
return parsed?.game_detail ?? parsed?.data?.game_detail ?? null; 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<string, { data: ChampionTierList; expires: number }>();
const TIER_CACHE_TTL = 30 * 60 * 1000; // 30 minutes
export async function getChampionTierList(
mode: string, region: string,
): Promise<ChampionTierList> {
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;
}

View file

@ -132,6 +132,34 @@ export interface MatchDetailResponse {
match: MatchEntry; 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 ── // ── Recent search persistence ──
export interface RecentSearch { export interface RecentSearch {

View file

@ -9,6 +9,13 @@ interface LeagueStat { game_type: string; win: number | null; lose: number | nul
interface LadderRank { rank: number | null; total: number | null; } 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 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 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 { interface SummonerProfile {
game_name: string; tagline: string; level: number; profile_image_url: string; 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 [renewing, setRenewing] = useState(false);
const [lastUpdated, setLastUpdated] = useState<string | null>(null); const [lastUpdated, setLastUpdated] = useState<string | null>(null);
// Tier list state
const [tierMode, setTierMode] = useState('aram');
const [tierRegion, setTierRegion] = useState('EUW');
const [tierList, setTierList] = useState<ChampionTierEntry[]>([]);
const [tierLoading, setTierLoading] = useState(false);
const [tierError, setTierError] = useState<string | null>(null);
const [gameModes, setGameModes] = useState<GameModeInfo[]>([]);
const [tierSearch, setTierSearch] = useState('');
const [showAllTiers, setShowAllTiers] = useState(false);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
const currentSearchRef = useRef<{ gameName: string; tagLine: string; region: string } | null>(null); const currentSearchRef = useRef<{ gameName: string; tagLine: string; region: string } | null>(null);
// Load regions // Load regions + modes
useEffect(() => { useEffect(() => {
fetch('/api/lolstats/regions').then(r => r.json()).then(setRegions).catch(() => {}); 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/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}&region=${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 // SSE data
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;
@ -538,6 +572,101 @@ export default function LolstatsTab({ data }: { data: any }) {
<p>Gib einen Summoner Name#Tag ein und wähle die Region</p> <p>Gib einen Summoner Name#Tag ein und wähle die Region</p>
</div> </div>
)} )}
{/* Champion Tier List — always visible */}
<div className="lol-tier-section">
<div className="lol-section-title">Champion Tier List</div>
<div className="lol-tier-controls">
<div className="lol-tier-modes">
{gameModes.map(m => (
<button
key={m.key}
className={`lol-tier-mode-btn ${tierMode === m.key ? 'active' : ''}`}
onClick={() => setTierMode(m.key)}
>
{m.label}
</button>
))}
</div>
<select
className="lol-search-region"
value={tierRegion}
onChange={e => setTierRegion(e.target.value)}
>
{regions.map(r => <option key={r.code} value={r.code}>{r.code}</option>)}
</select>
<input
className="lol-tier-filter"
placeholder="Filter champion..."
value={tierSearch}
onChange={e => setTierSearch(e.target.value)}
/>
</div>
{tierLoading && (
<div className="lol-loading">
<div className="lol-spinner" />
Lade Tier List...
</div>
)}
{tierError && <div className="lol-error">{tierError}</div>}
{!tierLoading && !tierError && tierList.length > 0 && (
<>
<div className="lol-tier-table">
<div className="lol-tier-header">
<span className="lol-tier-col-rank">#</span>
<span className="lol-tier-col-champ">Champion</span>
<span className="lol-tier-col-tier">Tier</span>
<span className="lol-tier-col-wr">{tierMode === 'arena' ? 'Win' : 'Win %'}</span>
<span className="lol-tier-col-pr">Pick %</span>
{tierMode !== 'arena' && <span className="lol-tier-col-br">Ban %</span>}
<span className="lol-tier-col-kda">{tierMode === 'arena' ? 'Avg Place' : 'KDA'}</span>
</div>
{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 (
<div key={c.champion_id} className={`lol-tier-row tier-${c.tier}`}>
<span className="lol-tier-col-rank">{c.rank}</span>
<span className="lol-tier-col-champ">
<img src={champImg(c.champion_name)} alt={c.champion_name} />
{c.champion_name}
</span>
<span className={`lol-tier-col-tier tier-badge-${c.tier}`}>
{tierLabels[c.tier] ?? c.tier}
</span>
<span className="lol-tier-col-wr">
{c.win_rate != null ? `${(c.win_rate * 100).toFixed(1)}%` : '-'}
</span>
<span className="lol-tier-col-pr">
{(c.pick_rate * 100).toFixed(1)}%
</span>
{tierMode !== 'arena' && (
<span className="lol-tier-col-br">
{c.ban_rate != null ? `${(c.ban_rate * 100).toFixed(1)}%` : '-'}
</span>
)}
<span className="lol-tier-col-kda">
{tierMode === 'arena'
? (c.average_placement?.toFixed(1) ?? '-')
: (c.kda?.toFixed(2) ?? '-')}
</span>
</div>
);
})}
</div>
{!showAllTiers && tierList.length > 50 && (
<button className="lol-load-more" onClick={() => setShowAllTiers(true)}>
Alle {tierList.length} Champions anzeigen
</button>
)}
</>
)}
</div>
</div> </div>
); );
} }

View file

@ -490,6 +490,133 @@
} }
.lol-load-more:hover { border-color: var(--accent); color: var(--text-normal); } .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 ── */ /* ── Responsive ── */
@media (max-width: 640px) { @media (max-width: 640px) {
.lol-search { flex-wrap: wrap; } .lol-search { flex-wrap: wrap; }
@ -498,4 +625,8 @@
.lol-match-meta { margin-left: 0; text-align: left; } .lol-match-meta { margin-left: 0; text-align: left; }
.lol-match-items { flex-wrap: wrap; } .lol-match-items { flex-wrap: wrap; }
.lol-profile { 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; }
} }