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 { 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<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 ──
app.get('/api/lolstats/recent', (_req, res) => {
res.json(getRecent());

View file

@ -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<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;
}
// ── 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 {