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:
parent
9093f12259
commit
905f156d47
5 changed files with 397 additions and 3 deletions
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<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 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 }) {
|
|||
<p>Gib einen Summoner Name#Tag ein und wähle die Region</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue