diff --git a/.gitignore b/.gitignore index 4ba5a74..759e39e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ node_modules .env .DS_Store lol-cli +lol-cli-macos-arm64 +lol-cli-win-x64.exe +.last_search.json diff --git a/docs/screenshot.png b/docs/screenshot.png new file mode 100644 index 0000000..a034129 Binary files /dev/null and b/docs/screenshot.png differ diff --git a/src/api.js b/src/api.js index 9e8b82a..ce6edc6 100644 --- a/src/api.js +++ b/src/api.js @@ -1,5 +1,12 @@ const axios = require('axios'); +// API stats tracking +const apiStats = { + pending: 0, +}; + +const getApiStats = () => ({ ...apiStats }); + const RIOT_API_KEY = process.env.RIOT_API_KEY; if (!RIOT_API_KEY) { @@ -14,9 +21,49 @@ api.interceptors.request.use((config) => { return config; }); +const retryWithBackoff = async (fn, maxRetries = 5, baseDelay = 1000) => { + apiStats.pending++; + try { + let lastError; + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + // Only retry on rate limit (429) + if (error.response?.status !== 429) { + handleApiError(error); // Transform and re-throw non-429 errors + } + + // Use Retry-After header if present, otherwise exponential backoff with jitter + const retryAfter = error.response?.headers?.['retry-after']; + let delay; + if (retryAfter) { + // Parse Retry-After (could be seconds or HTTP-date) + // Cap at 30 seconds to prevent excessive waits from malformed headers + const parsed = parseInt(retryAfter, 10); + const rawDelay = isNaN(parsed) ? Math.max(1000, new Date(retryAfter) - Date.now()) : parsed * 1000; + delay = Math.min(30000, rawDelay); + } else { + // Exponential backoff with jitter (50-100% of calculated delay) + delay = baseDelay * Math.pow(2, attempt) * (0.5 + Math.random() * 0.5); + } + + await new Promise(res => setTimeout(res, delay)); + } + } + throw lastError || new Error('Max retries exceeded'); + } finally { + apiStats.pending--; + } +}; + const handleApiError = (error) => { if (error.response) { const { status, data } = error.response; + if (status === 429) { + throw error; // Re-throw original error for retry logic + } if (status === 403) { throw new Error('Forbidden: Invalid API Key or insufficient permissions.'); } @@ -24,9 +71,9 @@ const handleApiError = (error) => { throw new Error('Not Found: The requested resource was not found.'); } throw new Error(data.status ? data.status.message : 'An API error occurred.'); - } else { - throw new Error('An unexpected error occurred.'); } + // Re-throw network errors (no response) to preserve original error info + throw error; }; const getRegionalPlatform = (region) => { @@ -43,14 +90,12 @@ const getRegionalPlatform = (region) => { }; const getSummonerByPuuid = async (region, puuid) => { - try { + return retryWithBackoff(async () => { const response = await api.get( `https://${region}.api.riotgames.com/lol/summoner/v4/summoners/by-puuid/${puuid}` ); return response.data; - } catch (error) { - handleApiError(error); - } + }); }; const getSummonerDataByRiotId = async (region, riotId) => { @@ -64,15 +109,12 @@ const getSummonerDataByRiotId = async (region, riotId) => { const accountRegion = getRegionalPlatform(region); const accountApiUrl = `https://${accountRegion}.api.riotgames.com`; - let puuid; - try { - const response = await api.get( - `${accountApiUrl}/riot/account/v1/accounts/by-riot-id/${encodeURIComponent(gameName)}/${tagLine}` + const response = await retryWithBackoff(async () => { + return api.get( + `${accountApiUrl}/riot/account/v1/accounts/by-riot-id/${encodeURIComponent(gameName)}/${encodeURIComponent(tagLine)}` ); - puuid = response.data.puuid; - } catch (error) { - handleApiError(error); - } + }); + const puuid = response?.data?.puuid; if (!puuid) { throw new Error('Could not retrieve PUUID for the given Riot ID.'); @@ -84,72 +126,74 @@ const getSummonerDataByRiotId = async (region, riotId) => { const getMatchHistory = async (region, puuid) => { - try { + return retryWithBackoff(async () => { const response = await api.get( `https://${getRegionalPlatform(region)}.api.riotgames.com/lol/match/v5/matches/by-puuid/${puuid}/ids?start=0&count=10` ); return response.data; - } catch (error) { - handleApiError(error); - } + }); }; const getMatchDetails = async (region, matchId) => { - try { + return retryWithBackoff(async () => { const response = await api.get( `https://${getRegionalPlatform(region)}.api.riotgames.com/lol/match/v5/matches/${matchId}` ); return response.data; - } catch (error) { - handleApiError(error); - } + }); }; const getMatchTimeline = async (region, matchId) => { - try { + return retryWithBackoff(async () => { const response = await api.get( `https://${getRegionalPlatform(region)}.api.riotgames.com/lol/match/v5/matches/${matchId}/timeline` ); return response.data; - } catch (error) { - handleApiError(error); - } + }); }; const getRankedData = async (region, puuid) => { - try { - const response = await api.get( - `https://${region}.api.riotgames.com/lol/league/v4/entries/by-puuid/${puuid}` - ); - return response.data; - } catch (error) { - handleApiError(error); - } + return retryWithBackoff(async () => { + const response = await api.get( + `https://${region}.api.riotgames.com/lol/league/v4/entries/by-puuid/${puuid}` + ); + return response.data; + }); }; const getChampionMastery = async (region, puuid, championId) => { - try { - const response = await api.get( - `https://${region}.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-puuid/${puuid}/by-champion/${championId}` - ); - return response.data; - } catch (error) { - handleApiError(error); - } + return retryWithBackoff(async () => { + const response = await api.get( + `https://${region}.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-puuid/${puuid}/by-champion/${championId}` + ); + return response.data; + }); }; -const getLiveGame = async (region, encryptedPUUID) => { +const getTopChampionMasteries = async (region, puuid, count = 5) => { + return retryWithBackoff(async () => { + const response = await api.get( + `https://${region}.api.riotgames.com/lol/champion-mastery/v4/champion-masteries/by-puuid/${puuid}/top?count=${count}` + ); + return response.data; + }); +}; + +const getLiveGame = async (region, puuid) => { + return retryWithBackoff(async () => { try { - const response = await api.get( - `https://${region}.api.riotgames.com/lol/spectator/v5/active-games/by-summoner/${encryptedPUUID}` - ); - return response.data; + const response = await api.get( + `https://${region}.api.riotgames.com/lol/spectator/v5/active-games/by-summoner/${puuid}` + ); + return response.data; } catch (error) { - if (error.response && error.response.status === 404) { - return null; // Not in a game, return null instead of throwing - } - handleApiError(error); // For all other errors, use the handler + // 404 means no active game - return null instead of throwing + if (error.response?.status === 404) { + return null; + } + throw error; // Let retryWithBackoff handle other errors } + }); }; module.exports = { @@ -159,5 +203,7 @@ module.exports = { getMatchTimeline, getRankedData, getChampionMastery, + getTopChampionMasteries, getLiveGame, -}; \ No newline at end of file + getApiStats, +}; diff --git a/src/index.js b/src/index.js index 569bbc6..82a5116 100755 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,14 @@ #!/usr/bin/env node const path = require('path'); -require('dotenv').config({ path: path.resolve(path.dirname(process.execPath), '.env') }); +const envPath = process.pkg + ? path.resolve(path.dirname(process.execPath), '.env') + : path.resolve(__dirname, '..', '.env'); +require('dotenv').config({ path: envPath }); const { Command } = require('commander'); const blessed = require('blessed'); -const { getSummonerDataByRiotId, getMatchHistory, getMatchDetails, getMatchTimeline } = require('./api'); +const { getSummonerDataByRiotId, getMatchHistory, getMatchDetails, getRankedData, getTopChampionMasteries } = require('./api'); const { createSearchScreen } = require('./tui'); +const { getLastSearch, saveLastSearch, getMatchCache, saveMatchCache, getMonitoredAccounts, addMonitoredAccount, removeMonitoredAccount, setActiveAccountIndex, getAccountDataCache, saveAccountDataCache, loadCachedAccountByRiotId, addRankSnapshot } = require('./utils'); const { createResultsScreen } = require('./resultsTui'); const { createLoadingScreen } = require('./loadingTui'); @@ -21,24 +25,290 @@ program.parse(process.argv); const options = program.opts(); if (!program.args.length) { + // Helper function to load account data from cache (for instant switching) + const loadAccountDataFromCache = (puuid) => { + const accountCache = getAccountDataCache(puuid); + if (!accountCache) return null; + + const matchCache = getMatchCache(puuid); + const matches = matchCache.matches.map(m => ({ + matchId: m.matchId, + gameCreation: m.gameCreation, + details: m.details, + })); + + return { + summoner: accountCache.summoner, + matches, + rankedData: accountCache.rankedData || [], + topMasteries: accountCache.topMasteries || [], + }; + }; + + // Lightweight refresh for background updates + // Fetches fresh mutable data + checks for new matches (additive) + const refreshAccountData = async (region, puuid) => { + // 1. Fetch fresh mutable data in parallel + // Use [] as fallback for array data to maintain consistent downstream handling + const [rankedData, topMasteries, matchHistory] = await Promise.all([ + getRankedData(region, puuid).catch(() => []), + getTopChampionMasteries(region, puuid, 5).catch(() => []), + getMatchHistory(region, puuid).catch(() => []), + ]); + + // 2. Check for new matches (additive) + let newMatches = []; + if (matchHistory) { + const matchCache = getMatchCache(puuid); + const cachedMatchIds = new Set(matchCache.matches.map(m => m.matchId)); + const newMatchIds = matchHistory.filter(id => !cachedMatchIds.has(id)); + + // Fetch details only for NEW matches (immutable data we don't have yet) + // Note: Timelines are NOT cached - they are fetched on-demand when viewing + for (const matchId of newMatchIds) { + try { + const matchDetails = await getMatchDetails(region, matchId); + // Validate match data structure before adding + if (!matchDetails?.info?.gameCreation) { + continue; // Skip malformed match data + } + newMatches.push({ + matchId, + gameCreation: matchDetails.info.gameCreation, + details: matchDetails, + }); + } catch (e) { + // Skip failed fetches - one match failure shouldn't break the refresh + if (process.env.DEBUG) { + console.error(`[DEBUG] Failed to fetch match ${matchId}:`, e.message); + } + } + } + + // 3. Add new matches to cache (preserving all existing cached matches) + if (newMatches.length > 0) { + const allMatches = [...newMatches, ...matchCache.matches]; + allMatches.sort((a, b) => b.gameCreation - a.gameCreation); + matchCache.matches = allMatches; + matchCache.region = region; + saveMatchCache(puuid, matchCache); + } + } + + // 4. Update account data cache with fresh mutable data + // Only update cache if we got actual data (not empty fallback from API failure) + const hasRankedData = rankedData.length > 0; + const hasMasteryData = topMasteries.length > 0; + if (hasRankedData || hasMasteryData) { + const accountCache = getAccountDataCache(puuid); + if (accountCache) { + saveAccountDataCache(puuid, { + summoner: accountCache.summoner, + rankedData: hasRankedData ? rankedData : accountCache.rankedData, + topMasteries: hasMasteryData ? topMasteries : accountCache.topMasteries, + }); + } + } + + // 5. Capture rank snapshot for historical tracking + if (hasRankedData) { + addRankSnapshot(puuid, rankedData); + } + + return { + rankedData, + topMasteries, + newMatches, + hasUpdates: hasRankedData || newMatches.length > 0, + }; + }; + + // Helper function to load account data (full API fetch) + const loadAccountData = async (region, riotId, screen, loading) => { + loading.update(5, 'Fetching summoner data...'); + const summoner = await getSummonerDataByRiotId(region, riotId); + + loading.update(10, 'Fetching player data...'); + const [rankedData, topMasteries, matchHistory] = await Promise.all([ + getRankedData(region, summoner.puuid).catch(() => []), + getTopChampionMasteries(region, summoner.puuid, 5).catch(() => []), + getMatchHistory(region, summoner.puuid).catch(() => []), + ]); + + const matchCache = getMatchCache(summoner.puuid); + const cachedMatchIds = new Set(matchCache.matches.map(m => m.matchId)); + const newMatchIds = matchHistory.filter(id => !cachedMatchIds.has(id)); + const totalNewMatches = newMatchIds.length; + + const newMatches = []; + if (totalNewMatches === 0) { + loading.update(100, 'All matches cached, loading from disk...'); + } else { + loading.update(15, `Found ${totalNewMatches} new match(es) to fetch...`); + // Note: Timelines are NOT cached - they are fetched on-demand when viewing + for (let i = 0; i < totalNewMatches; i++) { + const matchId = newMatchIds[i]; + loading.update(null, `Fetching new match ${i + 1}/${totalNewMatches}...`); + + try { + const matchDetails = await getMatchDetails(region, matchId); + // Validate match data structure before adding + if (!matchDetails?.info?.gameCreation) { + continue; // Skip malformed match data + } + newMatches.push({ + matchId, + gameCreation: matchDetails.info.gameCreation, + details: matchDetails, + }); + } catch (e) { + // Skip failed match fetches, continue with remaining matches + continue; + } + + const progress = 15 + (((i + 1) / totalNewMatches) * 85); + loading.update(progress); + } + } + + const allCachedMatches = [...newMatches, ...matchCache.matches]; + allCachedMatches.sort((a, b) => b.gameCreation - a.gameCreation); + + matchCache.matches = allCachedMatches; + matchCache.region = region; + saveMatchCache(summoner.puuid, matchCache); + + const matches = allCachedMatches.map(m => ({ + matchId: m.matchId, + gameCreation: m.gameCreation, + details: m.details, + })); + + // Save account data to cache for instant switching + saveAccountDataCache(summoner.puuid, { summoner, rankedData, topMasteries }); + + // Capture rank snapshot for historical tracking + addRankSnapshot(summoner.puuid, rankedData); + + loading.update(100, 'All data loaded.'); + + return { summoner, matches, rankedData, topMasteries }; + }; + + // Helper to handle results from createResultsScreen + const handleScreenResult = (result) => { + if (!result) { + return { action: 'EXIT' }; + } + if (result === 'BACK') { + return { action: 'BACK' }; + } + if (result.action === 'SWITCH_ACCOUNT') { + const updatedMonitored = setActiveAccountIndex(result.accountIndex); + const account = updatedMonitored.accounts[result.accountIndex]; + if (!account) { + return { action: 'BACK' }; + } + return { + action: 'SWITCH', + accountIndex: result.accountIndex, + region: account.region, + riotId: account.riotId, + puuid: account.puuid, + }; + } + if (result.action === 'REMOVE_ACCOUNT') { + const updatedMonitored = removeMonitoredAccount(result.puuid); + if (updatedMonitored.accounts.length === 0) { + return { action: 'BACK' }; + } + const account = updatedMonitored.accounts[updatedMonitored.activeIndex]; + return { + action: 'SWITCH', + accountIndex: updatedMonitored.activeIndex, + region: account.region, + riotId: account.riotId, + puuid: account.puuid, + }; + } + return { action: 'EXIT' }; + }; + const main = async () => { let region, riotId; let cliOptions = { ...options }; + let skipSearch = false; + let currentAccountIndex = 0; + let switchToPuuid = null; // Track puuid when switching for cache-first loading + + // Check for monitored accounts on startup (skip search if available and no CLI args) + const initialMonitored = getMonitoredAccounts(); + if (!cliOptions.region && !cliOptions.riotId && initialMonitored.accounts.length > 0) { + skipSearch = true; + currentAccountIndex = initialMonitored.activeIndex; + const account = initialMonitored.accounts[currentAccountIndex]; + region = account.region; + riotId = account.riotId; + switchToPuuid = account.puuid; + } while (true) { - if (!cliOptions.region || !cliOptions.riotId) { - const searchData = await createSearchScreen(); + // Determine if we need to show search screen + if (!skipSearch && !cliOptions.region && !cliOptions.riotId) { + const lastSearch = getLastSearch(); + const monitoredAccounts = getMonitoredAccounts(); + const searchData = await createSearchScreen(lastSearch || {}, { accountCount: monitoredAccounts.accounts.length }); if (!searchData || searchData === 'EXIT' || !searchData.riotId) { console.log('Exiting.'); break; } region = searchData.region; riotId = searchData.riotId; - } else { + saveLastSearch(riotId, region); + } else if (cliOptions.region && cliOptions.riotId) { region = cliOptions.region; riotId = cliOptions.riotId; cliOptions = {}; } + // else: skipSearch is true, region/riotId already set + + skipSearch = false; // Reset for next iteration + + // Try cache-first loading when switching accounts + if (switchToPuuid) { + const cachedData = loadAccountDataFromCache(switchToPuuid); + if (cachedData && cachedData.matches.length > 0) { + // Use cached data - instant switch, no loading screen + // Note: currentAccountIndex is already set correctly from handleScreenResult or initial startup + const monitoredData = getMonitoredAccounts(); + + // Create closure to capture current puuid for refresh callback + const puuidForRefresh = switchToPuuid; + const result = await createResultsScreen(cachedData.summoner, cachedData.matches, region, cachedData.rankedData, cachedData.topMasteries, { + monitoredAccounts: monitoredData, + currentAccountIndex, + refreshCallback: () => refreshAccountData(region, puuidForRefresh), + refreshAccountCallback: refreshAccountData, + }); + + const handled = handleScreenResult(result); + if (handled.action === 'EXIT') { + break; + } else if (handled.action === 'BACK') { + switchToPuuid = null; + continue; + } else if (handled.action === 'SWITCH') { + currentAccountIndex = handled.accountIndex; + region = handled.region; + riotId = handled.riotId; + switchToPuuid = handled.puuid; + skipSearch = true; + continue; + } + } + // No cache or empty matches - fall through to full load + switchToPuuid = null; + } const screen = blessed.screen({ smartCSR: true, @@ -49,36 +319,90 @@ if (!program.args.length) { screen.render(); try { - loading.update(10, 'Fetching summoner data...'); - const summoner = await getSummonerDataByRiotId(region, riotId); - - loading.update(30, 'Fetching match history...'); - const matchHistory = await getMatchHistory(region, summoner.puuid); - - const matches = []; - for (let i = 0; i < matchHistory.length; i++) { - const matchId = matchHistory[i]; - const progress = 30 + (i / matchHistory.length) * 60; - loading.update(progress, `Fetching details for match ${i + 1}/${matchHistory.length}...`); - - const matchDetails = await getMatchDetails(region, matchId); - const matchTimeline = await getMatchTimeline(region, matchId); - matches.push({ details: matchDetails, timeline: matchTimeline }); - } + const { summoner, matches, rankedData, topMasteries } = await loadAccountData(region, riotId, screen, loading); + + // Auto-save to monitored accounts + const monitoredData = addMonitoredAccount(summoner, region, riotId); + currentAccountIndex = monitoredData.activeIndex; - loading.update(100, 'All data loaded.'); loading.destroy(); - - screen.destroy(); // Destroy the screen used for loading + screen.destroy(); - const result = await createResultsScreen(summoner, matches, region); - if (result !== 'BACK') { + // Create closure to capture puuid for refresh callbacks + const puuidForRefresh = summoner.puuid; + const result = await createResultsScreen(summoner, matches, region, rankedData, topMasteries, { + monitoredAccounts: monitoredData, + currentAccountIndex, + refreshCallback: () => refreshAccountData(region, puuidForRefresh), + refreshAccountCallback: refreshAccountData, + }); + + const handled = handleScreenResult(result); + if (handled.action === 'EXIT') { break; + } else if (handled.action === 'BACK') { + continue; + } else if (handled.action === 'SWITCH') { + currentAccountIndex = handled.accountIndex; + region = handled.region; + riotId = handled.riotId; + switchToPuuid = handled.puuid; + skipSearch = true; + continue; } } catch (error) { - screen.destroy(); - console.error('\nError:', error.message); - break; + // Try to load from cache if API failed (offline mode) + const cachedData = loadCachedAccountByRiotId(riotId, region); + if (cachedData) { + loading.destroy(); + screen.destroy(); + + // Use cached data in offline mode + // Note: Don't overwrite currentAccountIndex - it's already set correctly + // from the previous iteration or initial startup + const monitoredData = getMonitoredAccounts(); + + // Find the puuid for this account for refresh callbacks + const offlineAccount = monitoredData.accounts.find( + a => a.riotId.toLowerCase() === riotId.toLowerCase() && a.region === region + ); + const offlinePuuid = offlineAccount?.puuid; + + const result = await createResultsScreen( + cachedData.summoner, + cachedData.matches, + region, + cachedData.rankedData, + cachedData.topMasteries, + { + monitoredAccounts: monitoredData, + currentAccountIndex, + // Pass refresh callbacks to allow recovery when connectivity returns + refreshCallback: offlinePuuid ? () => refreshAccountData(region, offlinePuuid) : null, + refreshAccountCallback: refreshAccountData, + isOffline: true, + } + ); + + const handled = handleScreenResult(result); + if (handled.action === 'EXIT') { + break; + } else if (handled.action === 'BACK') { + continue; + } else if (handled.action === 'SWITCH') { + currentAccountIndex = handled.accountIndex; + region = handled.region; + riotId = handled.riotId; + switchToPuuid = handled.puuid; + skipSearch = true; + continue; + } + } else { + // No cache available, show error + screen.destroy(); + console.error('\nError:', error.message); + break; + } } } }; diff --git a/src/loadingTui.js b/src/loadingTui.js index 1f17774..2da1e5b 100644 --- a/src/loadingTui.js +++ b/src/loadingTui.js @@ -80,8 +80,13 @@ const createLoadingScreen = (screen) => { }); return { + // Update progress bar and/or message. + // - progress: number (0-100) to set progress, or null/undefined to skip progress update + // - msg: optional string to update the loading message update: (progress, msg) => { - progressBar.setProgress(progress); + if (progress !== null && progress !== undefined) { + progressBar.setProgress(progress); + } if (msg) { message.setContent(msg); } diff --git a/src/masteryTui.js b/src/masteryTui.js index 374ebcf..083fbd3 100644 --- a/src/masteryTui.js +++ b/src/masteryTui.js @@ -109,7 +109,7 @@ const createMasteryScreen = (parentScreen, summoner, championName, masteryData) width: '90%', top: 10, left: 'center', - content: '{center}{bold}b{/bold}=Back{/center}', + content: '{center}{bold}b{/bold}/{bold}backspace{/bold}=Back{/center}', tags: true, }); @@ -117,7 +117,7 @@ const createMasteryScreen = (parentScreen, summoner, championName, masteryData) parentScreen.render(); modal.on('keypress', (ch, key) => { - if (['escape', 'q', 'b'].includes(key.name)) { + if (['escape', 'q', 'b', 'backspace'].includes(key.name)) { modal.destroy(); parentScreen.render(); resolve(); diff --git a/src/opgg.js b/src/opgg.js new file mode 100644 index 0000000..38dff84 --- /dev/null +++ b/src/opgg.js @@ -0,0 +1,262 @@ +// src/opgg.js +// OP.GG MCP API client for historical rank data +const axios = require('axios'); + +const OPGG_MCP_ENDPOINT = 'https://mcp-api.op.gg/mcp'; +const DEFAULT_TIMEOUT = 10000; +const DEFAULT_YEARS_HISTORY = 2; + +// Region mapping: Riot region codes → OP.GG region codes +const REGION_MAP = { + 'EUW1': 'EUW', + 'EUN1': 'EUNE', + 'NA1': 'NA', + 'KR': 'KR', + 'BR1': 'BR', + 'JP1': 'JP', + 'LA1': 'LAN', + 'LA2': 'LAS', + 'OC1': 'OCE', + 'RU': 'RU', + 'TR1': 'TR', + 'PH2': 'PH', + 'SG2': 'SG', + 'TH2': 'TH', + 'TW2': 'TW', + 'VN2': 'VN', + 'ME1': 'ME' +}; + +// Season ID → Year mapping (based on OP.GG data) +// Season IDs are internal OP.GG identifiers +const SEASON_YEARS = { + 33: 2026, // S16 + 32: 2025, // S15 Split 3 + 31: 2025, // S15 Split 2 + 30: 2025, // S15 Split 1 + 29: 2024, // S14 Split 3 + 28: 2024, // S14 Split 2 + 27: 2024, // S14 Split 1 + 26: 2023, // S13 Split 3 + 25: 2023, // S13 Split 2 + 24: 2023, // S13 Split 1 + 23: 2022, // S12 + 22: 2022, + 21: 2022, + 19: 2022, + 18: 2021, + 17: 2021, + 16: 2020, + 15: 2020, + 14: 2019, + 13: 2019 +}; + +/** + * Call OP.GG MCP tool via JSON-RPC 2.0 + * @param {string} toolName - MCP tool name + * @param {Object} args - Tool arguments + * @returns {Object} MCP response result + */ +async function callMcpTool(toolName, args) { + const request = { + jsonrpc: '2.0', + id: Date.now(), + method: 'tools/call', + params: { name: toolName, arguments: args } + }; + + const response = await axios.post(OPGG_MCP_ENDPOINT, request, { + headers: { 'Content-Type': 'application/json' }, + timeout: DEFAULT_TIMEOUT + }); + + if (response.data.error) { + const error = new Error(response.data.error.message); + error.code = response.data.error.code; + throw error; + } + + return response.data.result; +} + +/** + * Get summoner profile with historical ranks from OP.GG + * @param {string} region - Riot region code (EUW1, NA1, etc.) + * @param {string} gameName - Riot ID game name + * @param {string} tagLine - Riot ID tag line + * @param {number} yearsBack - How many years of history to retrieve + * @returns {Object|null} Parsed summoner data or null if not found + */ +async function getSummonerProfile(region, gameName, tagLine, yearsBack = DEFAULT_YEARS_HISTORY) { + try { + const result = await callMcpTool('lol_get_summoner_profile', { + game_name: gameName, + tag_line: tagLine, + region: REGION_MAP[region] || region, + lang: 'en_US' + }); + + if (!result?.content?.[0]?.text) return null; + return parseOpggResponse(result.content[0].text, yearsBack); + } catch (error) { + // Player not found is expected, return null + if (error.message?.toLowerCase().includes('not found')) { + return null; + } + // For other errors, log and return null (graceful degradation) + console.error(`OP.GG API error for ${gameName}#${tagLine}:`, error.message); + return null; + } +} + +/** + * Extract historical ranks from OP.GG response text + * @param {string} text - Raw OP.GG response text + * @param {number} yearsBack - How many years of history to retrieve + * @returns {Object} Structured rank data + */ +function parseOpggResponse(text, yearsBack = DEFAULT_YEARS_HISTORY) { + const currentYear = new Date().getFullYear(); + const minYear = currentYear - yearsBack; + + const result = { + current: { solo: null, flex: null }, + peak: { solo: null, flex: null }, + history: [] // Array of { year, seasonId, solo, flex } + }; + + // Parse current ranks from LeagueStat entries + const currentSoloMatch = text.match(/LeagueStat\("SOLORANKED",TierInfo\("(\w+)",(\d+),(\d+)/); + const currentFlexMatch = text.match(/LeagueStat\("FLEXRANKED",TierInfo\("(\w+)",(\d+),(\d+)/); + + if (currentSoloMatch) { + result.current.solo = formatRankObject(currentSoloMatch[1], currentSoloMatch[2], currentSoloMatch[3]); + } + if (currentFlexMatch) { + result.current.flex = formatRankObject(currentFlexMatch[1], currentFlexMatch[2], currentFlexMatch[3]); + } + + // Parse current season peak ranks from RankEntrie1 (high_rank_info) + const peakSoloMatch = text.match(/RankEntrie1\("SOLORANKED",RankInfo\("(\w+)",(\d+),(\d+)/); + const peakFlexMatch = text.match(/RankEntrie1\("FLEXRANKED",RankInfo\("(\w+)",(\d+),(\d+)/); + + if (peakSoloMatch) { + result.peak.solo = formatRankObject(peakSoloMatch[1], peakSoloMatch[2], peakSoloMatch[3]); + } + if (peakFlexMatch) { + result.peak.flex = formatRankObject(peakFlexMatch[1], peakFlexMatch[2], peakFlexMatch[3]); + } + + // Parse previous season tiers + // Format: PreviousSeasonTier(seasonId,[RankEntrie("SOLORANKED",RankInfo(...),...),...]) + const seasonPattern = /PreviousSeasonTier\((\d+),\[([^\]]+)\]/g; + let match; + + while ((match = seasonPattern.exec(text)) !== null) { + const seasonId = parseInt(match[1]); + const year = SEASON_YEARS[seasonId]; + + // Skip if year is unknown or too old + if (!year || year < minYear) continue; + + const seasonData = match[2]; + const entry = { year, seasonId, solo: null, flex: null }; + + // Extract SOLORANKED end-of-season rank + const soloRankMatch = seasonData.match(/RankEntrie\("SOLORANKED",RankInfo\("(\w+)",(\d+),(\d+)/); + if (soloRankMatch) { + entry.solo = formatRankObject(soloRankMatch[1], soloRankMatch[2]); + } + + // Extract FLEXRANKED end-of-season rank + const flexRankMatch = seasonData.match(/RankEntrie\("FLEXRANKED",RankInfo\("(\w+)",(\d+),(\d+)/); + if (flexRankMatch) { + entry.flex = formatRankObject(flexRankMatch[1], flexRankMatch[2]); + } + + // Only add if we have at least one rank + if (entry.solo || entry.flex) { + result.history.push(entry); + } + } + + // Sort history by year descending (most recent first) + result.history.sort((a, b) => b.year - a.year || b.seasonId - a.seasonId); + + return result; +} + +/** + * Format rank data from tier/division/lp into a structured object + * @param {string} tier - Rank tier (IRON, BRONZE, SILVER, GOLD, PLATINUM, EMERALD, DIAMOND, MASTER, GRANDMASTER, CHALLENGER) + * @param {string|number} division - Division number (1-4) + * @param {string|number|null} lp - League points (optional) + * @returns {Object} Formatted rank object { rank, tier, division, lp } + */ +function formatRankObject(tier, division, lp = null) { + const divisions = ['I', 'II', 'III', 'IV']; + const divIndex = parseInt(division) - 1; + const divStr = divisions[divIndex] || ''; + + // Master+ tiers don't have divisions + const isMasterPlus = ['MASTER', 'GRANDMASTER', 'CHALLENGER'].includes(tier); + const rankStr = isMasterPlus ? tier : `${tier} ${divStr}`.trim(); + + return { + rank: rankStr, + tier: tier, + division: isMasterPlus ? null : divStr, + lp: lp !== null ? parseInt(lp) : null + }; +} + +/** + * Get historical ranks for multiple players in parallel + * @param {Array} players - Array of { region, gameName, tagLine } + * @param {number} yearsBack - How many years of history + * @returns {Map} Map of "gameName#tagLine" → rank data + */ +async function getMultiplePlayersRanks(players, yearsBack = DEFAULT_YEARS_HISTORY) { + const results = new Map(); + + const promises = players.map(async (player) => { + try { + const data = await getSummonerProfile(player.region, player.gameName, player.tagLine, yearsBack); + if (data) { + const key = `${player.gameName}#${player.tagLine}`; + results.set(key, data); + } + } catch (error) { + // Silent fail for individual players - don't break the batch + } + }); + + await Promise.all(promises); + return results; +} + +/** + * Convert OP.GG region code to Riot region code + * @param {string} opggRegion - OP.GG region code + * @returns {string} Riot region code + */ +function opggToRiotRegion(opggRegion) { + const reverseMap = Object.entries(REGION_MAP).reduce((acc, [riot, opgg]) => { + acc[opgg] = riot; + return acc; + }, {}); + return reverseMap[opggRegion] || opggRegion; +} + +module.exports = { + getSummonerProfile, + getMultiplePlayersRanks, + parseOpggResponse, + formatRankObject, + REGION_MAP, + SEASON_YEARS, + DEFAULT_YEARS_HISTORY, + DEFAULT_TIMEOUT, + opggToRiotRegion +}; diff --git a/src/resultsTui.js b/src/resultsTui.js index bfb604c..354bb1f 100644 --- a/src/resultsTui.js +++ b/src/resultsTui.js @@ -1,9 +1,30 @@ const blessed = require('blessed'); -const { getRankedData, getChampionMastery, getLiveGame } = require('./api'); -const { formatSummary, formatMatchDetails } = require('./ui'); -const { getItemData, getChampionData, getSummonerSpellData, getRuneData, getQueueData } = require('./utils'); +const stringWidth = require('string-width'); +const { getRankedData, getChampionMastery, getLiveGame, getApiStats, getMatchTimeline } = require('./api'); +const { formatSummary, formatMatchDetails, formatRankedInfo, formatChampionStats, formatMasteryDisplay, formatRankPreview, colorizeRank, formatLPGraph, formatCompactHistoricalRanks } = require('./ui'); +const { delay, getItemData, getChampionData, getSummonerSpellData, getRuneData, getQueueData, getParticipantRankCache, saveParticipantRankCache, getRankedOnlyPreference, setRankedOnlyPreference, getTimeScopePreference, setTimeScopePreference, cycleTimeScope, launchSpectate, getMonitoredAccounts, setActiveAccountIndex, getRankAtTime, getRankHistory, getOpggCache, saveOpggCache } = require('./utils'); const { createMasteryScreen } = require('./masteryTui'); const { createTimelineScreen } = require('./timelineTui'); +const opgg = require('./opgg'); + +// Pad string to target display width (handles full-width characters correctly) +const padEndByWidth = (str, targetWidth) => { + const currentWidth = stringWidth(str); + if (currentWidth >= targetWidth) return str; + return str + ' '.repeat(targetWidth - currentWidth); +}; + +// Truncate string by display width (handles CJK characters correctly) +const truncateByWidth = (str, maxWidth) => { + let width = 0; + let i = 0; + for (; i < str.length; i++) { + const charWidth = stringWidth(str[i]); + if (width + charWidth > maxWidth) break; + width += charWidth; + } + return i < str.length ? str.substring(0, i) + '~' : str; +}; // Color scheme for consistency const colors = { @@ -18,6 +39,10 @@ const colors = { yellow: '#f1fa8c', }; +// Track live game state for all monitored accounts (puuid -> { inGame: boolean }) +// Module-level to persist across account switches +const accountLiveGameState = new Map(); + const rankToScore = (rank) => { const tiers = { 'IRON': 0, 'BRONZE': 1, 'SILVER': 2, 'GOLD': 3, 'PLATINUM': 4, 'EMERALD': 5, 'DIAMOND': 6, 'MASTER': 7, 'GRANDMASTER': 8, 'CHALLENGER': 9 }; const divisions = { 'IV': 0, 'III': 1, 'II': 2, 'I': 3 }; @@ -27,7 +52,7 @@ const rankToScore = (rank) => { }; const scoreToRank = (score) => { - if (score === 0) return 'Unranked'; + if (score <= 0) return 'Unranked'; const tiers = ['IRON', 'BRONZE', 'SILVER', 'GOLD', 'PLATINUM', 'EMERALD', 'DIAMOND', 'MASTER', 'GRANDMASTER', 'CHALLENGER']; const divisions = ['IV', 'III', 'II', 'I']; const tierIndex = Math.floor(score / 4); @@ -38,33 +63,26 @@ const scoreToRank = (score) => { return `${tier} ${division}`; }; -const colorizeRank = (rank) => { - if (!rank) return `{white-fg}Unranked{/white-fg}`; - const tier = rank.split(' ')[0]; - const rankColors = { - 'CHALLENGER': colors.yellow, - 'GRANDMASTER': colors.red, - 'MASTER': colors.pink, - 'DIAMOND': colors.cyan, - 'EMERALD': colors.green, - 'PLATINUM': '#8be9fd', - 'GOLD': colors.yellow, - 'SILVER': colors.fg, - 'BRONZE': '#cd7f32', - 'IRON': '#a9a9a9', - 'Unranked': colors.fg, - }; - const color = rankColors[tier] || colors.fg; - return `{${color}-fg}${rank}{/${color}-fg}`; -}; +const createResultsScreen = async (summoner, matches, region, rankedData, topMasteries, options = {}) => { + const { monitoredAccounts = null, currentAccountIndex = 0, refreshCallback = null, refreshAccountCallback = null, isOffline: initialOffline = false } = options; -const createResultsScreen = async (summoner, matches, region) => { + // Track offline state - can change when connectivity recovers + let isOffline = initialOffline; + + // Make these mutable so background refresh can update them + let currentRankedData = rankedData; + let currentTopMasteries = topMasteries; + let currentMatches = matches; + const totalAccounts = monitoredAccounts?.accounts?.length || 1; + const hasMultipleAccounts = totalAccounts > 1; const itemData = await getItemData(); const spellData = await getSummonerSpellData(); const runeData = await getRuneData(); const queueData = await getQueueData(); + const championData = await getChampionData(); const itemMap = itemData.data; const spellMap = new Map(Object.values(spellData.data).map(s => [s.key, s.name])); + const championMap = new Map(Object.values(championData.data).map(c => [c.key, c.name])); const runeMap = new Map(); runeData.forEach(tree => { runeMap.set(tree.id, tree.name); @@ -76,16 +94,271 @@ const createResultsScreen = async (summoner, matches, region) => { }); const queueMap = new Map(queueData.map(q => [q.queueId, q.description])); + const getShortQueueName = (desc, queueId) => { + if (queueId === 1700) return 'Arena'; + if (desc.includes('Ranked Solo')) return 'Ranked'; + if (desc.includes('Ranked Flex')) return 'Flex'; + if (desc.includes('ARAM')) return 'ARAM'; + if (desc.includes('Draft')) return 'Normal'; + if (desc.includes('Blind')) return 'Blind'; + return 'Other'; + }; + + // Ranked filter constants and helpers + const RANKED_QUEUE_IDS = [420, 440]; // Solo/Duo, Flex + + const isMatchRanked = (match) => { + return RANKED_QUEUE_IDS.includes(match.details.info.queueId); + }; + + // Time scope filter helpers + const isMatchInTimeScope = (match, scope) => { + if (scope === 'all' || scope === 'last10') return true; + const now = Date.now(); + const gameTime = match.details.info.gameCreation; + if (scope === 'daily') { + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + return gameTime >= startOfToday.getTime(); + } + if (scope === 'weekly') return (now - gameTime) <= 7 * 24 * 60 * 60 * 1000; + return true; + }; + + // Filter LP snapshots by time scope (mirrors match filtering logic) + const filterSnapshotsByTimeScope = (snapshots, scope, matches) => { + if (!snapshots || snapshots.length === 0) return snapshots; + if (scope === 'all') return snapshots; + + if (scope === 'daily') { + const startOfToday = new Date(); + startOfToday.setHours(0, 0, 0, 0); + const startMs = startOfToday.getTime(); + return snapshots.filter(s => new Date(s.timestamp).getTime() >= startMs); + } + + if (scope === 'weekly') { + const weekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); + return snapshots.filter(s => new Date(s.timestamp).getTime() >= weekAgo); + } + + if (scope === 'last10') { + // Get oldest timestamp from last 10 ranked games + const rankedMatches = matches + .filter(m => [420, 440].includes(m.details?.info?.queueId)) + .slice(0, 10); + if (rankedMatches.length === 0) return []; + const oldestMatchTime = rankedMatches[rankedMatches.length - 1].details.info.gameCreation; + return snapshots.filter(s => new Date(s.timestamp).getTime() >= oldestMatchTime); + } + + return snapshots; + }; + + const getLPGraphLabel = (scope) => { + if (scope === 'daily') return ' LP Progression (Today) '; + if (scope === 'weekly') return ' LP Progression (7 Days) '; + if (scope === 'last10') return ' LP Progression (Last 10) '; + return ' LP Progression (Solo/Duo) '; + }; + + const getTimeScopeLabel = (scope, count) => { + if (scope === 'last10') return `Last ${Math.min(count, 10)} Games`; + if (scope === 'daily') return `Today - ${count} Games`; + if (scope === 'weekly') return `Last 7 Days - ${count} Games`; + return `${count} Games`; + }; + + // Load participant rank cache from disk + const diskRankCache = getParticipantRankCache(summoner.puuid); + // Ensure ranks object exists for later mutations + if (!diskRankCache.ranks) { + diskRankCache.ranks = {}; + } + const RANK_TTL = 60 * 60 * 1000; // 1 hour in milliseconds + + const playerRankCache = {}; // Cache by PUUID (persists across matches) + const matchRankCache = {}; // Cache computed match averages + + // Pre-populate playerRankCache from disk cache + const ranks = diskRankCache.ranks || {}; + Object.entries(ranks).forEach(([puuid, data]) => { + playerRankCache[puuid] = data.rank; + }); + + // Pre-populate current user's rank from rankedData (used by Ranked box) + // This ensures Match Ranks panel shows same rank as Ranked box + if (rankedData && rankedData.length > 0) { + const soloQueue = rankedData.find(q => q.queueType === 'RANKED_SOLO_5x5'); + const rankString = soloQueue ? `${soloQueue.tier} ${soloQueue.rank}` : 'Unranked'; + playerRankCache[summoner.puuid] = rankString; + diskRankCache.ranks[summoner.puuid] = { rank: rankString, fetchedAt: new Date().toISOString() }; + } + + // Helper to fetch and cache a player's rank (reduces duplication across fetch functions) + const fetchAndCacheRank = async (puuid) => { + const rankData = await getRankedData(region, puuid); + const soloQueue = rankData.find(q => q.queueType === 'RANKED_SOLO_5x5'); + const rankString = soloQueue ? `${soloQueue.tier} ${soloQueue.rank}` : 'Unranked'; + playerRankCache[puuid] = rankString; + diskRankCache.ranks[puuid] = { rank: rankString, fetchedAt: new Date().toISOString() }; + return rankString; + }; + + // Helper to fetch OP.GG history for multiple players in parallel (disk cache only) + const fetchMultipleOpggHistory = async (participants) => { + const results = new Map(); + const playersToFetch = []; + + for (const p of participants) { + // Check disk cache first (no TTL - historical data is permanent) + const diskCached = getOpggCache(p.puuid); + if (diskCached) { + results.set(p.puuid, diskCached); + } else { + const gameName = p.riotIdGameName || p.riotId?.split('#')[0]; + const tagLine = p.riotIdTagline || p.riotId?.split('#')[1]; + if (gameName && tagLine) { + playersToFetch.push({ puuid: p.puuid, gameName, tagLine }); + } + } + } + + // Fetch uncached players in parallel + if (playersToFetch.length > 0) { + const promises = playersToFetch.map(async ({ puuid, gameName, tagLine }) => { + try { + const data = await opgg.getSummonerProfile(region, gameName, tagLine, 2); + if (data) { + saveOpggCache(puuid, data); // Save to disk + results.set(puuid, data); + } + } catch (error) { + // Silent fail for individual players + } + }); + await Promise.all(promises); + } + + return results; + }; + + // Helper to compute match average from cached ranks + const computeMatchAverage = (matchIndex) => { + const participants = currentMatches[matchIndex].details.info.participants; + const ranks = {}; + let totalScore = 0; + let rankedCount = 0; + let allCached = true; + + for (const p of participants) { + if (playerRankCache[p.puuid] !== undefined) { + ranks[p.puuid] = playerRankCache[p.puuid]; + if (ranks[p.puuid] !== 'N/A' && ranks[p.puuid] !== 'Unranked') { + totalScore += rankToScore(ranks[p.puuid]); + rankedCount++; + } + } else { + allCached = false; + } + } + + // Always store the computed average, even if partial + // This ensures UI shows something while waiting for fresh fetches + if (Object.keys(ranks).length > 0) { + matchRankCache[matchIndex] = { + players: ranks, + average: rankedCount > 0 ? scoreToRank(totalScore / rankedCount) : '...', + complete: allCached, // Track if all ranks are fetched + }; + } + return allCached; + }; + + // Pre-compute match averages from cached data + for (let i = 0; i < currentMatches.length; i++) { + computeMatchAverage(i); + } + + const fetchMatchRanks = async (matchIndex, skipDelay = false) => { + // Only skip if we have a complete cache + if (matchRankCache[matchIndex]?.complete) return; + + const ranks = {}; + let totalScore = 0; + let rankedCount = 0; + let hasFailures = false; + const participants = currentMatches[matchIndex].details.info.participants; + + for (const p of participants) { + // Check PUUID cache first + if (playerRankCache[p.puuid] !== undefined) { + ranks[p.puuid] = playerRankCache[p.puuid]; + if (ranks[p.puuid] !== 'N/A' && ranks[p.puuid] !== 'Unranked') { + totalScore += rankToScore(ranks[p.puuid]); + rankedCount++; + } + continue; + } - const rankCache = {}; // Cache for player ranks and average rank + try { + const rankString = await fetchAndCacheRank(p.puuid); + ranks[p.puuid] = rankString; + if (rankString !== 'Unranked') { + totalScore += rankToScore(rankString); + rankedCount++; + } + } catch (error) { + if (process.env.DEBUG) { + console.error(`[FetchMatch] Failed to fetch rank for ${p.riotIdGameName || p.puuid.substring(0, 20)}:`, error.message || error); + } + ranks[p.puuid] = '...'; + hasFailures = true; + } + if (!skipDelay) await delay(50); + } + + matchRankCache[matchIndex] = { + players: ranks, + average: rankedCount > 0 ? scoreToRank(totalScore / rankedCount) : '...', + complete: !hasFailures, + }; + }; + + // Find stale ranks (older than TTL) that need refresh + const getStalePlayerPuuids = () => { + const now = Date.now(); + const stalePuuids = new Set(); + + for (const match of matches) { + for (const p of match.details.info.participants) { + const cached = diskRankCache.ranks[p.puuid]; + const fetchTime = cached ? new Date(cached.fetchedAt).getTime() : NaN; + if (!cached || isNaN(fetchTime) || (now - fetchTime > RANK_TTL)) { + stalePuuids.add(p.puuid); + } + } + } + return Array.from(stalePuuids); + }; return new Promise((resolve) => { + let isScreenDestroyed = false; + let isRefreshing = false; + let refreshPromise = null; // Promise-based lock for background refresh + let liveGameTimer = null; // Timer for live game time updates + let currentLiveGameData = null; // Store for spectate functionality + const screen = blessed.screen({ smartCSR: true, title: `Stats for ${summoner.name} [${region}]`, fullUnicode: true, }); + // Warn if terminal is too small for optimal display + const MIN_HEIGHT = 45; + const isTerminalSmall = screen.height < MIN_HEIGHT; + const layout = blessed.box({ parent: screen, width: '100%', @@ -98,22 +371,102 @@ const createResultsScreen = async (summoner, matches, region) => { blessed.text({ parent: layout, - top: 1, + top: 0, left: 'center', - content: `Summoner: {bold}${summoner.name}{/bold} | Region: {bold}${region}{/bold}`, + content: `Summoner: {bold}${summoner.name}{/bold} [Lvl ${summoner.summonerLevel}] | Region: {bold}${region}{/bold}`, tags: true, style: { fg: colors.cyan, }, }); - const summaryBox = blessed.box({ + // Account list indicator (row 1, right-aligned) + // Format: Accounts: > Name1 (R1) | Name2 (R2) | Name3 (R3) + const formatAccountList = () => { + if (!hasMultipleAccounts) return ''; + const accounts = monitoredAccounts.accounts; + const parts = accounts.map((acc, idx) => { + // Truncate name to 12 display columns (handles CJK characters) + let name = acc.riotId.split('#')[0]; + if (stringWidth(name) > 12) name = truncateByWidth(name, 11); + // Shorten region display + const regionShort = acc.region.replace(/1$/, ''); + const display = `${name} (${regionShort})`; + if (idx === currentAccountIndex) { + return `{cyan-fg}>${display}{/cyan-fg}`; // Selected: cyan with > + } + return ` ${display}`; // Unselected: default yellow, space padding + }); + return `Accounts: ${parts.join(' | ')}`; + }; + + const accountIndicator = blessed.text({ parent: layout, - width: '100%', + top: 1, + right: 1, + content: formatAccountList(), + tags: true, + style: { + fg: colors.yellow, + }, + }); + + // Refresh status indicator (left side, below header) + // Also shows terminal size warning if too small + const getInitialStatusContent = () => { + if (isTerminalSmall) return `{yellow-fg}Terminal too small (${screen.height} rows, need ${MIN_HEIGHT}){/yellow-fg}`; + if (isOffline) return '{yellow-fg}Offline Mode{/yellow-fg}'; + return ''; + }; + const refreshIndicator = blessed.text({ + parent: layout, + top: 1, + left: 1, + content: getInitialStatusContent(), + tags: true, + style: { + fg: colors.yellow, + }, + }); + + // API stats indicator (shows request count and rate limits) + const apiStatsIndicator = blessed.text({ + parent: layout, + top: 1, + left: 20, + content: '', + tags: true, + style: { + fg: colors.fg, + }, + }); + + // Update API stats display + const updateApiStatsDisplay = () => { + if (isScreenDestroyed) return; + const stats = getApiStats(); + let content = stats.pending > 0 ? `${stats.pending} pending` : ''; + apiStatsIndicator.setContent(content); + }; + + // Initial update and periodic refresh of API stats (every 2 seconds) + updateApiStatsDisplay(); + const apiStatsTimer = setInterval(() => { + if (!isScreenDestroyed) { + updateApiStatsDisplay(); + screen.render(); + } + }, 2000); + + // Row 1: Ranked Info (left) | LP Graph (right, when not in live game) + const rankedBox = blessed.box({ + parent: layout, + width: '50%', height: 9, - top: 3, + top: 2, left: 0, - content: formatSummary(summoner, matches), + label: ' Ranked ', + content: formatRankedInfo(rankedData), tags: true, border: { type: 'line', @@ -124,14 +477,43 @@ const createResultsScreen = async (summoner, matches, region) => { } }); - const liveGameBox = blessed.box({ + // Load filter preferences early (needed for LP graph initial render) + let rankedOnly = getRankedOnlyPreference(); + let timeScope = getTimeScopePreference(); + + // LP Progression Graph (top right, visible when NOT in live game) + const rankHistory = getRankHistory(summoner.puuid); + const filteredSnapshots = filterSnapshotsByTimeScope( + rankHistory.snapshots, timeScope, currentMatches + ); + const lpGraphBox = blessed.box({ parent: layout, - width: '30%', + width: '50%', height: 9, - top: 3, + top: 2, right: 0, + label: getLPGraphLabel(timeScope), + content: formatLPGraph(filteredSnapshots, 50, 7), + tags: true, + border: { + type: 'line', + fg: colors.purple, + }, + style: { + border: { fg: colors.purple } + } + }); + + // Live Game and Live Ranks panels - positioned at bottom, side by side + const liveGameBox = blessed.box({ + parent: layout, + width: '50%', + height: 15, + bottom: 1, // Above footer + left: 0, label: ' Live Game ', tags: true, + hidden: true, // Start hidden, show only when in game border: { type: 'line', fg: colors.purple, @@ -141,71 +523,412 @@ const createResultsScreen = async (summoner, matches, region) => { } }); + const liveRanksBox = blessed.box({ + parent: layout, + width: '50%', + height: 15, // 13 lines content + 2 for borders + bottom: 1, // Above footer + right: 0, + label: ' Live Ranks ', + tags: true, + hidden: true, // Start hidden, show only when in game + scrollable: true, + keys: true, + vi: true, + border: { + type: 'line', + fg: colors.purple, + }, + style: { + border: { fg: colors.purple } + } + }); + + // Row 2: Summary (left) | Top Masteries (right) + const summaryBox = blessed.box({ + parent: layout, + width: '50%', + height: 7, + top: 11, + left: 0, + label: ` Last ${currentMatches.length} Games `, + content: formatSummary(summoner, currentMatches), + tags: true, + border: { + type: 'line', + fg: colors.purple, + }, + style: { + border: { fg: colors.purple } + } + }); + + const masteryBox = blessed.box({ + parent: layout, + width: '50%', + height: 7, + top: 11, + right: 0, + label: ' Top Champion Masteries ', + content: formatMasteryDisplay(topMasteries, championMap), + tags: true, + border: { + type: 'line', + fg: colors.purple, + }, + style: { + border: { fg: colors.purple } + } + }); + + // Row 3: Champion Stats (full width) + const championStatsBox = blessed.box({ + parent: layout, + width: '100%', + height: 7, + top: 18, + left: 0, + label: ' Champion Stats (Recent Games) ', + content: formatChampionStats(currentMatches, summoner.puuid), + tags: true, + border: { + type: 'line', + fg: colors.purple, + }, + style: { + border: { fg: colors.purple } + } + }); + + // Fetch ranks for all live game participants (uses cache when available) + const fetchLiveGameRanks = async (participants) => { + const ranks = {}; + for (const p of participants) { + // Check cache first + if (playerRankCache[p.puuid] !== undefined) { + ranks[p.puuid] = playerRankCache[p.puuid]; + continue; + } + try { + const rankString = await fetchAndCacheRank(p.puuid); + ranks[p.puuid] = rankString; + } catch (error) { + ranks[p.puuid] = '...'; + } + await delay(100); // Rate limit: 100ms between requests + } + return ranks; + }; + + // Format live game ranks display with team separation + const formatLiveRanks = (participants, ranks, currentPuuid, queueId, opggData = {}) => { + const formatPlayer = (p) => { + // Get player name from riotId (format: "name#tag") + const rawName = p.riotId?.split('#')[0] || '???'; + const name = padEndByWidth(truncateByWidth(rawName, 10), 11); + + // Get champion name + const champName = padEndByWidth(truncateByWidth(championMap.get(String(p.championId)) || 'Unknown', 8), 9); + + // Get current rank + const rank = ranks[p.puuid] || '...'; + + // Get historical ranks from OP.GG (compact format) + const playerOpgg = opggData.get?.(p.puuid); + const history = playerOpgg ? formatCompactHistoricalRanks(playerOpgg, 2) : ''; + + // Highlight current user + const prefix = p.puuid === currentPuuid ? '{cyan-fg}>{/cyan-fg}' : ' '; + + // Format: Name Champion Rank | History + const rankDisplay = colorizeRank(rank); + if (history) { + return `${prefix}${name} ${champName} ${rankDisplay} ${history}`; + } + return `${prefix}${name} ${champName} ${rankDisplay}`; + }; + + // Handle Arena mode (8 solo players with teamId 1-8) + if (queueId === 1700) { + let content = '{purple-fg}Arena Players{/purple-fg}\n'; + content += participants.map(formatPlayer).join('\n'); + return content; + } + + // Standard 5v5 mode + const blueTeam = participants.filter(p => p.teamId === 100); + const redTeam = participants.filter(p => p.teamId === 200); + + // Header row matching column widths + const headerRow = `{bold} ${'NAME'.padEnd(11)} ${'CHAMP'.padEnd(10)}RANK{/bold}`; + const separatorLine = '{gray-fg}' + '─'.repeat(40) + '{/gray-fg}'; + + let content = '{cyan-fg}Blue Team{/cyan-fg}\n'; + content += headerRow + '\n'; + content += separatorLine + '\n'; + content += blueTeam.map(formatPlayer).join('\n'); + content += '\n\n{red-fg}Red Team{/red-fg}\n'; + content += headerRow + '\n'; + content += separatorLine + '\n'; + content += redTeam.map(formatPlayer).join('\n'); + return content; + }; + + // Track if we've already fetched ranks for the current live game + let liveGameRanksFetched = false; + let currentLiveGameId = null; + let liveGameAvgRank = null; // Store computed average rank for live game + const checkLiveGame = async () => { - const liveGameData = await getLiveGame(region, summoner.puuid); + // Guard: Don't run if screen is destroyed + if (isScreenDestroyed) return; + + // Clear any existing live game timer before potentially creating a new one + if (liveGameTimer) { + clearInterval(liveGameTimer); + liveGameTimer = null; + } + + let liveGameData = null; + try { + liveGameData = await getLiveGame(region, summoner.puuid); + } catch (error) { + // API failed - hide live game panels gracefully + liveGameBox.hidden = true; + liveRanksBox.hidden = true; + // Restore match list height (15 is the correct height when no live game) + matchList.height = 19; + rankPreviewBox.height = 19; + // Show appropriate error message based on error type + if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT' || error.code === 'ENOTFOUND') { + refreshIndicator.setContent('{yellow-fg}Network error{/}'); + } else if (error.response?.status === 502 || error.response?.status === 503) { + refreshIndicator.setContent('{yellow-fg}API unavailable{/}'); + } else if (error.message?.toLowerCase().includes('apikey') || + error.message?.toLowerCase().includes('api key') || + error.response?.status === 403) { + refreshIndicator.setContent('{red-fg}API key invalid{/}'); + } + screen.render(); + return; // Exit gracefully + } + + // Guard: Check again after async operation + if (isScreenDestroyed) return; + + // Store for spectate functionality + currentLiveGameData = liveGameData; + if (liveGameData) { + liveGameBox.hidden = false; + liveRanksBox.hidden = false; + // Reduce match list height to make room for live panels at bottom + matchList.height = Math.max(6, screen.height - 25 - 16); + rankPreviewBox.height = Math.max(6, screen.height - 25 - 16); + const participant = liveGameData.participants.find(p => p.puuid === summoner.puuid); if (participant) { - const championData = await getChampionData(); - const championMap = new Map(Object.values(championData.data).map(c => [c.key, c.name])); const championName = championMap.get(String(participant.championId)) || 'Unknown'; - + const spell1 = spellMap.get(String(participant.spell1Id)) || 'N/A'; const spell2 = spellMap.get(String(participant.spell2Id)) || 'N/A'; const keystone = runeMap.get(participant.perks.perkIds[0]) || 'N/A'; const secondaryTree = runeMap.get(participant.perks.perkSubStyle) || 'N/A'; - + const queueName = queueMap.get(liveGameData.gameQueueConfigId) || 'Unknown Queue'; - const role = liveGameData.gameQueueConfigId === 1700 - ? 'Arena' + const role = liveGameData.gameQueueConfigId === 1700 + ? 'Arena' : participant.teamPosition || participant.individualPosition || 'N/A'; const updateGameTime = () => { + // Guard: Don't update if screen is destroyed + if (isScreenDestroyed) return; + const startTime = new Date(liveGameData.gameStartTime); const now = new Date(); const diff = now - startTime; const minutes = Math.floor(diff / 60000); const seconds = Math.floor((diff % 60000) / 1000); const formattedTime = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - + + const avgRankDisplay = liveGameAvgRank ? colorizeRank(liveGameAvgRank) : '...'; const content = ` Queue: ${queueName}\n` + ` Champion: ${championName}\n` + ` Role: ${role}\n` + ` Time: ${formattedTime}\n` + ` Spells: ${spell1}, ${spell2}\n` + ` Keystone: ${keystone}\n` + - ` Secondary: ${secondaryTree}`; + ` Secondary: ${secondaryTree}\n` + + ` Avg Rank: ${avgRankDisplay}`; liveGameBox.setContent(content); screen.render(); }; updateGameTime(); - const timer = setInterval(updateGameTime, 1000); + // Store timer reference for cleanup - no screen.on('destroy') here + liveGameTimer = setInterval(updateGameTime, 1000); + } + + // Fetch and display ranks for live game participants (only once per game) + if (currentLiveGameId !== liveGameData.gameId) { + currentLiveGameId = liveGameData.gameId; + liveGameRanksFetched = false; + liveGameAvgRank = null; // Reset average for new game + liveRanksBox.setContent(' Loading ranks...'); + screen.render(); + } + + if (!liveGameRanksFetched) { + liveGameRanksFetched = true; + const gameIdAtFetch = liveGameData.gameId; + const queueIdAtFetch = liveGameData.gameQueueConfigId; + const participantsAtFetch = liveGameData.participants; - screen.on('destroy', () => { - clearInterval(timer); + // Fetch ranks and OP.GG history in parallel (non-blocking) + Promise.all([ + fetchLiveGameRanks(participantsAtFetch), + fetchMultipleOpggHistory(participantsAtFetch) + ]).then(([ranks, opggData]) => { + // Guard against stale data: only update if still viewing same game + if (isScreenDestroyed || currentLiveGameId !== gameIdAtFetch) return; + + // Compute average rank (excluding Unranked and N/A) + let totalScore = 0; + let rankedCount = 0; + Object.values(ranks).forEach(rank => { + if (rank !== 'N/A' && rank !== 'Unranked') { + totalScore += rankToScore(rank); + rankedCount++; + } + }); + liveGameAvgRank = rankedCount > 0 ? scoreToRank(totalScore / rankedCount) : null; + + const content = formatLiveRanks(participantsAtFetch, ranks, summoner.puuid, queueIdAtFetch, opggData); + liveRanksBox.setContent(content); + screen.render(); + }).catch(() => { + if (isScreenDestroyed || currentLiveGameId !== gameIdAtFetch) return; + liveRanksBox.setContent(' Failed to load ranks'); + screen.render(); }); } + } else { + liveGameBox.hidden = true; + liveGameBox.setContent(''); + liveRanksBox.hidden = true; + liveRanksBox.setContent(''); + // Restore match list height when no live game + matchList.height = 19; + rankPreviewBox.height = 19; + // Reset live game tracking + currentLiveGameId = null; + liveGameRanksFetched = false; + liveGameAvgRank = null; + } + + // Track state for current account's game-end detection + const wasInGame = accountLiveGameState.get(summoner.puuid)?.inGame || false; + const isInGame = !!liveGameData; + accountLiveGameState.set(summoner.puuid, { inGame: isInGame }); + + // Current account's game just ended - trigger UI refresh + if (wasInGame && !isInGame && !modalOpen && !isRefreshing) { + // Don't await - let it run in background + runBackgroundRefresh().catch(() => {}); + } + + if (!isScreenDestroyed) { + // Update footer to show/hide spectate option + const isAnyExpanded = Object.values(expanded).some(v => v); + footer.setContent(getFooterContent(isAnyExpanded)); + screen.render(); + } + }; + + // Only check live game if not in offline mode + if (!isOffline) { + checkLiveGame(); + } + + // Check live game status for OTHER monitored accounts (not current) + // Detects when any OTHER account finishes a game and refreshes their cache + // Current account is handled by checkLiveGame() with state tracking + const checkAllAccountsLiveGame = async () => { + if (isScreenDestroyed || !refreshAccountCallback) return; + + const monitoredData = getMonitoredAccounts(); + const otherAccounts = monitoredData.accounts.filter(a => a.puuid !== summoner.puuid); + + // Check all accounts in parallel for faster response + const results = await Promise.allSettled( + otherAccounts.map(async (account) => { + const liveGame = await getLiveGame(account.region, account.puuid); + return { account, liveGame }; + }) + ); + + if (isScreenDestroyed) return; + + // Process results and trigger refreshes for accounts whose games ended + for (const result of results) { + if (result.status !== 'fulfilled') continue; + + const { account, liveGame } = result.value; + const wasInGame = accountLiveGameState.get(account.puuid)?.inGame || false; + const isInGame = !!liveGame; + + // Update state + accountLiveGameState.set(account.puuid, { inGame: isInGame }); + + // Game just ended for this OTHER account - refresh its cache silently + if (wasInGame && !isInGame) { + // Fire and forget - don't await to avoid blocking + refreshAccountCallback(account.region, account.puuid).catch(() => {}); + } } - screen.render(); }; - checkLiveGame(); + // Periodic live game check every 30 seconds (with guard) + // Checks ALL monitored accounts and detects game endings + // Skip in offline mode + const liveGameCheckInterval = isOffline ? null : setInterval(async () => { + if (!isScreenDestroyed) { + try { + // Check current account's live game for UI display + await checkLiveGame(); + // Check all accounts for game end detection + await checkAllAccountsLiveGame(); + } catch (error) { + // Rate limit or API error - hide panels to avoid stale data + currentLiveGameData = null; + liveGameBox.hidden = true; + liveGameBox.setContent(''); + liveRanksBox.hidden = true; + liveRanksBox.setContent(''); + // Show error hint to user + if (error.message?.includes('apikey') || error.message?.includes('API key')) { + refreshIndicator.setContent('{red-fg}API key invalid{/red-fg}'); + } + screen.render(); + } + } + }, 30000); const matchList = blessed.list({ parent: layout, - width: '100%', - top: 12, - bottom: 1, + width: '50%', + top: 25, + height: 19, + left: 0, items: [], border: { type: 'line', fg: colors.purple, }, - label: ' Match History (Select to expand) ', + label: ` Match History - ${currentMatches.length} Games (Select to expand) `, mouse: true, keys: true, vi: true, @@ -217,13 +940,62 @@ const createResultsScreen = async (summoner, matches, region) => { }, }); + // Rank preview panel (right side, 50% width) + const rankPreviewBox = blessed.box({ + parent: layout, + width: '50%', + top: 25, + height: 19, + right: 0, + label: ' Match Ranks ', + tags: true, + border: { + type: 'line', + fg: colors.purple, + }, + style: { + border: { fg: colors.purple }, + }, + }); + + // Preview panel update function + let updatePreviewPanel = null; // Will be defined after listIndexMap is created + + const getFooterContent = (isExpanded) => { + let content = ''; + if (hasMultipleAccounts) { + content += 'Tab: Switch | '; + } + // Ranked filter indicator - show current state + content += rankedOnly + ? '{yellow-fg}r: Ranked{/yellow-fg}' + : 'r: All'; + // Time scope indicator - show current state + if (!isExpanded) { + const currentLabels = { 'all': 'All', 'last10': 'Last 10', 'daily': 'Today', 'weekly': 'Week' }; + const isActive = timeScope !== 'all'; + content += isActive + ? ` | {yellow-fg}t: ${currentLabels[timeScope]}{/yellow-fg}` + : ` | t: ${currentLabels[timeScope]}`; + } + content += ' | d: Remove | c: Connect | m: Mastery'; + if (currentLiveGameData && !isOffline) { + content += ' | {green-fg}s: Spectate{/green-fg}'; + } + if (isExpanded) { + content += ' | b/backspace: Back | t: Timeline'; + } + content += ' | q: Quit'; + return content; + }; + const footer = blessed.box({ parent: layout, width: '100%', height: 1, bottom: 0, left: 'center', - content: 'b: Back | m: Mastery | t: Timeline | q: Quit', + content: getFooterContent(false), tags: true, style: { fg: colors.orange, @@ -233,54 +1005,280 @@ const createResultsScreen = async (summoner, matches, region) => { const expanded = {}; let listIndexMap = []; - const updateList = () => { - const items = []; - listIndexMap = []; - let isAnyExpanded = false; - matches.forEach((match, index) => { - const participant = match.details.info.participants.find(p => p.puuid === summoner.puuid); - const role = (match.details.info.queueId === 1700 - ? 'Arena' - : (participant.teamPosition || participant.individualPosition || '')).padEnd(10); - const avgRank = rankCache[index] ? `Avg Rank: ${colorizeRank(rankCache[index].average)}`.padEnd(40) : ''.padEnd(40); - const gameDate = new Date(match.details.info.gameCreation).toLocaleDateString().padEnd(12); - let resultText; - if (!participant.win && match.details.info.gameDuration < 300) { - resultText = 'Remake'; - } else { - resultText = participant.win ? 'Win' : 'Loss'; + // Track OP.GG data fetch state per match to avoid duplicate fetches + const opggFetchedForMatch = {}; + + // Define updatePreviewPanel now that listIndexMap exists + updatePreviewPanel = () => { + const listIndex = matchList.selected; + let matchIndex = null; + + // Find actual match index (handle detail lines) + for (let i = listIndex; i >= 0; i--) { + if (listIndexMap[i] !== null && listIndexMap[i] !== undefined) { + matchIndex = listIndexMap[i]; + break; + } + } + + if (matchIndex === null) { + rankPreviewBox.setContent(''); + return; + } + + const match = currentMatches[matchIndex]; + const rankData = matchRankCache[matchIndex]?.players; + // Build map of historical ranks for all monitored accounts in this match + const historicalRanks = {}; + const monitoredPuuids = new Set(monitoredAccounts?.accounts?.map(a => a.puuid) || []); + if (match?.details?.info?.participants) { + for (const p of match.details.info.participants) { + if (monitoredPuuids.has(p.puuid)) { + const snapshot = getRankAtTime(p.puuid, match.details.info.gameCreation); + if (snapshot) { + historicalRanks[p.puuid] = snapshot; } - const paddedResult = resultText.padEnd(7); - const championName = participant.championName.padEnd(16); - const kda = `KDA: ${participant.kills}/${participant.deaths}/${participant.assists}`; - let coloredResult; - if (resultText === 'Win') { - coloredResult = `{green-fg}${paddedResult}{/green-fg}`; - } else if (resultText === 'Loss') { - coloredResult = `{red-fg}${paddedResult}{/red-fg}`; - } else { - coloredResult = `{grey-fg}${paddedResult}{/grey-fg}`; + } + } + } + + // Build OP.GG history map from disk cache + const opggHistoryMap = new Map(); + if (match?.details?.info?.participants) { + for (const p of match.details.info.participants) { + const diskCached = getOpggCache(p.puuid); + if (diskCached) { + opggHistoryMap.set(p.puuid, diskCached); + } + } + } + + rankPreviewBox.setContent(formatRankPreview(match, rankData, summoner, historicalRanks, opggHistoryMap)); + screen.render(); + + // Fetch OP.GG history in background if not already fetched for this match + if (!opggFetchedForMatch[matchIndex] && match?.details?.info?.participants) { + opggFetchedForMatch[matchIndex] = true; + fetchMultipleOpggHistory(match.details.info.participants).then(() => { + if (isScreenDestroyed) return; + // Re-render preview panel with updated OP.GG data + const currentListIndex = matchList.selected; + let currentMatchIndex = null; + for (let i = currentListIndex; i >= 0; i--) { + if (listIndexMap[i] !== null && listIndexMap[i] !== undefined) { + currentMatchIndex = listIndexMap[i]; + break; } - const summary = `${gameDate}${coloredResult} - ${championName}${role}${kda} ${avgRank}`; - items.push(summary); - listIndexMap.push(index); - - if (expanded[index]) { - isAnyExpanded = true; - const details = formatMatchDetails(match, summoner, itemMap, spellMap, runeMap, screen.width, rankCache[index].players); - details.split('\n').forEach(line => { - items.push(line); - listIndexMap.push(null); - }); + } + // Only update if user is still viewing the same match + if (currentMatchIndex === matchIndex) { + const updatedOpggMap = new Map(); + for (const p of match.details.info.participants) { + const diskCached = getOpggCache(p.puuid); + if (diskCached) { + updatedOpggMap.set(p.puuid, diskCached); + } } + rankPreviewBox.setContent(formatRankPreview(match, rankData, summoner, historicalRanks, updatedOpggMap)); + screen.render(); + } + }).catch(() => { + // Silent fail - OP.GG data is supplementary }); - matchList.setItems(items); + } + }; - if (isAnyExpanded) { - footer.setContent('b: Back | m: Mastery | t: Timeline | q: Quit'); + // Hook navigation events for preview updates + matchList.key(['up', 'down', 'j', 'k', 'pageup', 'pagedown', 'home', 'end'], () => { + process.nextTick(updatePreviewPanel); + }); + + matchList.on('element click', () => { + process.nextTick(updatePreviewPanel); + }); + + const getFilteredMatches = () => { + let matches = currentMatches; + if (rankedOnly) matches = matches.filter(isMatchRanked); + if (timeScope === 'last10') { + matches = matches.slice(0, 10); + } else if (timeScope !== 'all') { + matches = matches.filter(m => isMatchInTimeScope(m, timeScope)); + } + return matches; + }; + + const setPanelVisibility = (visible) => { + rankedBox.hidden = !visible; + summaryBox.hidden = !visible; + masteryBox.hidden = !visible; // Mastery always follows panel visibility + championStatsBox.hidden = !visible; + rankPreviewBox.hidden = !visible; + matchList.top = visible ? 25 : 2; + matchList.width = visible ? '50%' : '100%'; // Full width when expanded + + // Handle live game panels, LP graph, and match list height + const isLiveGame = !!currentLiveGameData; + if (!visible) { + // When expanded, hide all top-right panels and use full height + liveGameBox.hidden = true; + liveRanksBox.hidden = true; + lpGraphBox.hidden = true; + matchList.height = '90%'; + } else { + // When collapsed, show LP graph always (top-right) and live panels when in game (bottom) + liveGameBox.hidden = !isLiveGame; + liveRanksBox.hidden = !isLiveGame; + lpGraphBox.hidden = false; // LP graph always visible (no layout conflict with live panels) + matchList.height = isLiveGame ? Math.max(6, screen.height - 25 - 16) : 19; + rankPreviewBox.height = isLiveGame ? Math.max(6, screen.height - 25 - 16) : 19; + } + }; + + const updatePanels = (filteredMatches) => { + summaryBox.setContent(formatSummary(summoner, filteredMatches)); + championStatsBox.setContent(formatChampionStats(filteredMatches, summoner.puuid)); + + // Build filter label: combine ranked and time scope indicators + const count = filteredMatches.length; + let summaryLabel, statsLabel; + + if (rankedOnly && timeScope !== 'all') { + // Both filters active + const timePart = timeScope === 'last10' ? `Last ${Math.min(count, 10)}` : + timeScope === 'daily' ? 'Today' : 'Week'; + summaryLabel = ` Ranked + ${timePart} - ${count} Games `; + statsLabel = ` Ranked + ${timePart} Stats (${count} Games) `; + } else if (rankedOnly) { + summaryLabel = ` Ranked Only - ${count} Games `; + statsLabel = ` Ranked Stats (${count} Games) `; + } else if (timeScope !== 'all') { + summaryLabel = ` ${getTimeScopeLabel(timeScope, count)} `; + statsLabel = ` ${getTimeScopeLabel(timeScope, count)} Stats `; + } else { + summaryLabel = ` Last ${count} Games `; + statsLabel = ' Champion Stats (Recent Games) '; + } + + summaryBox.setLabel(summaryLabel); + championStatsBox.setLabel(statsLabel); + }; + + // Centralized border color update for filter states (ranked or time scope) + const updateAllBorderColors = () => { + if (isScreenDestroyed) return; + // Yellow border indicates ranked-only filter is active + const borderColor = rankedOnly ? colors.yellow : colors.purple; + + // Update all panel borders + rankedBox.style.border.fg = borderColor; + lpGraphBox.style.border.fg = borderColor; + liveGameBox.style.border.fg = borderColor; + liveRanksBox.style.border.fg = borderColor; + summaryBox.style.border.fg = borderColor; + masteryBox.style.border.fg = borderColor; + championStatsBox.style.border.fg = borderColor; + rankPreviewBox.style.border.fg = borderColor; + matchList.style.border.fg = borderColor; + + // Force full screen redraw - use alloc() to batch the redraw + // instead of calling clearPos() 9 times which causes flicker + screen.alloc(); + screen.render(); + }; + + const updateList = () => { + const items = []; + listIndexMap = []; + let isAnyExpanded = false; + + const displayMatches = getFilteredMatches(); + + // Handle empty state + if (displayMatches.length === 0) { + items.push('{gray-fg}No ranked games in last 10 matches{/gray-fg}'); + listIndexMap.push(null); } else { - footer.setContent('b: Back | m: Mastery | q: Quit'); + displayMatches.forEach((match) => { + const realIndex = currentMatches.indexOf(match); // Map back to current index + const participant = match.details.info.participants.find(p => p.puuid === summoner.puuid); + if (!participant) return; // Skip malformed match data + const queueId = match.details.info.queueId; + const queueDesc = queueMap.get(queueId) || ''; + const queueLabel = getShortQueueName(queueDesc, queueId).padEnd(10); + const roleMap = { TOP: 'TOP', JUNGLE: 'JGL', MIDDLE: 'MID', BOTTOM: 'BOT', UTILITY: 'SUP' }; + const rawRole = queueId === 1700 ? 'Arena' : (participant.teamPosition || participant.individualPosition || ''); + const role = (rawRole === 'Invalid' || rawRole === '' || queueId === 450) ? ' ' : (roleMap[rawRole] || rawRole).padEnd(6); + const avgRank = matchRankCache[realIndex] + ? `Avg: ${colorizeRank(matchRankCache[realIndex].average.padEnd(12))}` + : ''; + const gameDate = new Date(match.details.info.gameCreation).toLocaleDateString().padEnd(12); + let resultText; + if (!participant.win && match.details.info.gameDuration < 300) { + resultText = 'Remake'; + } else { + resultText = participant.win ? 'Win' : 'Loss'; + } + const paddedResult = resultText.padEnd(7); + const championName = participant.championName.padEnd(16); + const kdaValue = `${participant.kills}/${participant.deaths}/${participant.assists}`.padEnd(8); + const kdaRatio = (participant.kills + participant.assists) / Math.max(participant.deaths, 1); + let coloredKda; + if (kdaRatio >= 4.0) { + coloredKda = `{green-fg}${kdaValue}{/green-fg}`; + } else if (kdaRatio <= 1.5) { + coloredKda = `{red-fg}${kdaValue}{/red-fg}`; + } else { + coloredKda = kdaValue; + } + const kda = `KDA: ${coloredKda}`; + let coloredResult; + if (resultText === 'Win') { + coloredResult = `{green-fg}${paddedResult}{/green-fg}`; + } else if (resultText === 'Loss') { + coloredResult = `{red-fg}${paddedResult}{/red-fg}`; + } else { + coloredResult = `{grey-fg}${paddedResult}{/grey-fg}`; + } + const rankDisplay = avgRank; + const summary = `${gameDate}${coloredResult} - ${championName}${queueLabel}${role}${kda} ${rankDisplay}`; + items.push(summary); + listIndexMap.push(realIndex); + + if (expanded[realIndex]) { + isAnyExpanded = true; + const details = formatMatchDetails(match, summoner, itemMap, spellMap, runeMap, screen.width, matchRankCache[realIndex]?.players); + details.split('\n').forEach(line => { + items.push(line); + listIndexMap.push(null); + }); + } + }); } + + // Update label to show filter status + const count = displayMatches.length; + const total = currentMatches.length; + let label; + if (rankedOnly && timeScope !== 'all') { + const timePart = timeScope === 'last10' ? `Last ${Math.min(count, 10)}` : + timeScope === 'daily' ? 'Today' : 'Week'; + label = ` Ranked + ${timePart} - ${count}/${total} Games `; + } else if (rankedOnly) { + label = ` Ranked Only - ${count}/${total} Games `; + } else if (timeScope !== 'all') { + label = ` ${getTimeScopeLabel(timeScope, count)} (${count}/${total}) `; + } else { + label = ` Match History - ${total} Games `; + } + matchList.setLabel(label); + + matchList.setItems(items); + + // Hide/show panels based on expansion state + setPanelVisibility(!isAnyExpanded); + + footer.setContent(getFooterContent(isAnyExpanded)); screen.render(); }; @@ -290,13 +1288,14 @@ const createResultsScreen = async (summoner, matches, region) => { const currentlySelected = matchList.selected; expanded[matchIndex] = !expanded[matchIndex]; - if (expanded[matchIndex] && !rankCache[matchIndex]) { - const loading = blessed.box({ - parent: screen, - top: 'center', - left: 'center', - height: 1, - width: 20, + if (expanded[matchIndex] && !matchRankCache[matchIndex]?.complete) { + // Show ephemeral loading label + const loading = blessed.box({ + parent: screen, + top: 'center', + left: 'center', + height: 1, + width: 20, content: 'Loading ranks...', style: { bg: colors.bg, @@ -305,24 +1304,29 @@ const createResultsScreen = async (summoner, matches, region) => { }); screen.render(); - const ranks = {}; - let totalScore = 0; - const participants = matches[matchIndex].details.info.participants; - const delay = ms => new Promise(res => setTimeout(res, ms)); - - for (const p of participants) { - const rankData = await getRankedData(region, p.puuid); - const soloQueue = rankData.find(q => q.queueType === 'RANKED_SOLO_5x5'); - const rankString = soloQueue ? `${soloQueue.tier} ${soloQueue.rank}` : 'Unranked'; - ranks[p.puuid] = rankString; - totalScore += rankToScore(rankString); - await delay(100); - } - rankCache[matchIndex] = { - players: ranks, - average: scoreToRank(totalScore / participants.length), + // Helper to destroy loading box safely + const destroyLoading = () => { + if (!isScreenDestroyed && !loading.destroyed) { + loading.destroy(); + screen.render(); + } }; - loading.destroy(); + + // Fallback timeout (2 seconds) in case fetch hangs + const loadingTimeout = setTimeout(destroyLoading, 2000); + + // Fetch ranks in background (don't await - non-blocking) + fetchMatchRanks(matchIndex).then(() => { + clearTimeout(loadingTimeout); + destroyLoading(); + if (isScreenDestroyed) return; + updateList(); + updatePreviewPanel(); + screen.render(); + }).catch(() => { + clearTimeout(loadingTimeout); + destroyLoading(); + }); } updateList(); @@ -343,7 +1347,7 @@ const createResultsScreen = async (summoner, matches, region) => { } if (matchIndex !== null) { - const match = matches[matchIndex]; + const match = currentMatches[matchIndex]; const participant = match.details.info.participants.find(p => p.puuid === summoner.puuid); const loading = blessed.box({ @@ -382,31 +1386,475 @@ const createResultsScreen = async (summoner, matches, region) => { } if (matchIndex !== null && expanded[matchIndex]) { - const match = matches[matchIndex]; + // Match is expanded - show timeline + const match = currentMatches[matchIndex]; modalOpen = true; await createTimelineScreen(screen, match); modalOpen = false; screen.render(); matchList.focus(); + } else { + // No match expanded - toggle time scope filter + timeScope = cycleTimeScope(timeScope); + setTimeScopePreference(timeScope); + + // Collapse any expanded matches (clean state) + Object.keys(expanded).forEach(key => { + expanded[key] = false; + }); + + // Update LP graph with filtered snapshots + const updatedHistory = getRankHistory(summoner.puuid); + const filteredSnapshots = filterSnapshotsByTimeScope( + updatedHistory.snapshots, timeScope, currentMatches + ); + lpGraphBox.setLabel(getLPGraphLabel(timeScope)); + lpGraphBox.setContent(formatLPGraph(filteredSnapshots, 50, 7)); + + // Update all UI components with filtered data + const filteredMatches = getFilteredMatches(); + updatePanels(filteredMatches); + updateList(); + updateAllBorderColors(); + + // Reset selection to top + matchList.select(0); + matchList.focus(); } }); + // r: Toggle ranked filter + screen.key('r', () => { + if (modalOpen) return; + // Toggle filter and persist to disk + rankedOnly = !rankedOnly; + setRankedOnlyPreference(rankedOnly); - screen.key(['escape', 'q', 'C-c'], () => { + // Collapse any expanded matches (clean state) + Object.keys(expanded).forEach(key => { + expanded[key] = false; + }); + + // Update all UI components with filtered data + const filteredMatches = getFilteredMatches(); + updatePanels(filteredMatches); + updateList(); + updateAllBorderColors(); + + // Reset selection to top + matchList.select(0); + matchList.focus(); + }); + + // s: Launch spectate mode + screen.key('s', () => { + if (modalOpen) return; + if (!currentLiveGameData) return; // No live game to spectate + + const { gameId, observers } = currentLiveGameData; + if (!observers?.encryptionKey) return; + + launchSpectate(gameId, observers.encryptionKey, region); + }); + + // Save rank cache before exit + const saveCacheAndExit = (result) => { + isScreenDestroyed = true; + + // Clear all timers to prevent race conditions + if (liveGameTimer) { + clearInterval(liveGameTimer); + liveGameTimer = null; + } + if (liveGameCheckInterval) { + clearInterval(liveGameCheckInterval); + } + if (apiStatsTimer) { + clearInterval(apiStatsTimer); + } + + saveParticipantRankCache(summoner.puuid, diskRankCache); screen.destroy(); - resolve(); + resolve(result); + }; + + screen.key(['escape', 'q', 'C-c'], () => { + saveCacheAndExit(); }); - screen.key('b', () => { + screen.key(['b', 'backspace', 'delete'], () => { if (modalOpen) return; - screen.destroy(); - resolve('BACK'); + + // Check if any match is expanded + const isAnyExpanded = Object.values(expanded).some(v => v); + + if (isAnyExpanded) { + // Collapse all expanded matches + Object.keys(expanded).forEach(key => { + expanded[key] = false; + }); + updateList(); + screen.render(); + } + // No-op when nothing is expanded + }); + + // Tab: Switch to next account + screen.key('tab', () => { + if (modalOpen || !hasMultipleAccounts) return; + const nextIndex = (currentAccountIndex + 1) % totalAccounts; + // Use try-finally to ensure screen is destroyed even if setActiveAccountIndex fails + try { + setActiveAccountIndex(nextIndex); + } finally { + saveCacheAndExit({ action: 'SWITCH_ACCOUNT', accountIndex: nextIndex }); + } }); + // Shift+Tab: Switch to previous account + screen.key('S-tab', () => { + if (modalOpen || !hasMultipleAccounts) return; + const prevIndex = (currentAccountIndex - 1 + totalAccounts) % totalAccounts; + // Use try-finally to ensure screen is destroyed even if setActiveAccountIndex fails + try { + setActiveAccountIndex(prevIndex); + } finally { + saveCacheAndExit({ action: 'SWITCH_ACCOUNT', accountIndex: prevIndex }); + } + }); + + // c: Connect/add another account (go to search screen) + screen.key('c', () => { + if (modalOpen) return; + saveCacheAndExit('BACK'); + }); + + // d: Remove current account from monitored list + screen.key('d', () => { + if (modalOpen) return; + + // Show confirmation dialog + const confirmBox = blessed.box({ + parent: screen, + top: 'center', + left: 'center', + width: 50, + height: 7, + border: { type: 'line', fg: colors.red }, + style: { bg: colors.bg, fg: colors.fg }, + label: ' Remove Account ', + tags: true, + }); + + blessed.text({ + parent: confirmBox, + top: 1, + left: 'center', + content: `Remove {bold}${summoner.name}{/bold} from monitored accounts?`, + tags: true, + style: { fg: colors.fg }, + }); + + blessed.text({ + parent: confirmBox, + top: 3, + left: 'center', + content: '{green-fg}y{/green-fg}: Yes | {red-fg}n{/red-fg}: No', + tags: true, + style: { fg: colors.fg }, + }); + + screen.render(); + + const confirmHandler = (ch, key) => { + if (key.name === 'y') { + screen.unkey(['y', 'n', 'escape'], confirmHandler); + confirmBox.destroy(); + screen.render(); + matchList.focus(); + accountLiveGameState.delete(summoner.puuid); // Clean up Map entry + saveCacheAndExit({ action: 'REMOVE_ACCOUNT', puuid: summoner.puuid }); + } else if (key.name === 'n' || key.name === 'escape') { + screen.unkey(['y', 'n', 'escape'], confirmHandler); + confirmBox.destroy(); + screen.render(); + matchList.focus(); + } + }; + + screen.key(['y', 'n', 'escape'], confirmHandler); + }); + + // Background account data refresh function with async lock + const runBackgroundRefresh = async () => { + // Return existing promise if refresh already in progress (async lock) + if (refreshPromise) return refreshPromise; + if (!refreshCallback || isScreenDestroyed) return; + + isRefreshing = true; + refreshPromise = (async () => { + refreshIndicator.setContent('{yellow-fg}Refreshing...{/yellow-fg}'); + screen.render(); + + try { + const freshData = await refreshCallback(); + if (isScreenDestroyed) return; + + if (freshData.hasUpdates) { + // Update ranked box with fresh data + if (freshData.rankedData) { + currentRankedData = freshData.rankedData; + rankedBox.setContent(formatRankedInfo(currentRankedData)); + // Refresh LP graph with updated rank history (respecting current timeScope) + const updatedHistory = getRankHistory(summoner.puuid); + const filteredSnapshots = filterSnapshotsByTimeScope( + updatedHistory.snapshots, timeScope, currentMatches + ); + lpGraphBox.setLabel(getLPGraphLabel(timeScope)); + lpGraphBox.setContent(formatLPGraph(filteredSnapshots, 50, 7)); + } + + // Update masteries with fresh data + if (freshData.topMasteries) { + currentTopMasteries = freshData.topMasteries; + masteryBox.setContent(formatMasteryDisplay(currentTopMasteries, championMap)); + } + + // Add new matches to display (prepend to existing) + if (freshData.newMatches?.length > 0) { + const newMatchObjects = freshData.newMatches.map(m => ({ + details: m.details, + timeline: m.timeline, + })); + + // Shift indices in matchRankCache and expanded to account for prepended matches + const shiftAmount = newMatchObjects.length; + + // Shift matchRankCache indices + const shiftedRankCache = {}; + for (const [key, value] of Object.entries(matchRankCache)) { + shiftedRankCache[parseInt(key) + shiftAmount] = value; + } + for (const key of Object.keys(matchRankCache)) { + delete matchRankCache[key]; + } + Object.assign(matchRankCache, shiftedRankCache); + + // Shift expanded indices + const shiftedExpanded = {}; + for (const [key, value] of Object.entries(expanded)) { + shiftedExpanded[parseInt(key) + shiftAmount] = value; + } + for (const key of Object.keys(expanded)) { + delete expanded[key]; + } + Object.assign(expanded, shiftedExpanded); + + // Remember current selection to adjust after prepending + const currentSelection = matchList.selected; + + currentMatches = [...newMatchObjects, ...currentMatches]; + + // Recompute summary and champion stats with new matches + const filteredMatches = getFilteredMatches(); + updatePanels(filteredMatches); + updateList(); + + // Adjust selection to maintain user's view of the same match + // Account for new entries being prepended to the list + // Clamp to valid range to prevent out-of-bounds selection + if (currentSelection >= 0 && matchList.items.length > 0) { + const newIndex = Math.min(currentSelection + shiftAmount, matchList.items.length - 1); + matchList.select(Math.max(0, newIndex)); + } + + // Fetch ranks for new matches (indices 0 to shiftAmount-1) + let anyComputed = false; + for (let i = 0; i < shiftAmount; i++) { + // First try to compute from existing cache + if (computeMatchAverage(i)) { + anyComputed = true; + } else { + // If not all players cached, fetch ranks asynchronously + fetchMatchRanks(i, true).then(() => { + if (isScreenDestroyed) return; + updateList(); + updatePreviewPanel(); + screen.render(); + }).catch(() => {}); + } + } + // Refresh UI if any averages were computed from cache + if (anyComputed) { + updateList(); + updatePreviewPanel(); + screen.render(); + } + } + + // Also refresh live game state during background refresh + await checkLiveGame(); + + // Handle offline recovery - show special message when coming back online + if (isOffline) { + isOffline = false; + refreshIndicator.setContent('{green-fg}Back online!{/green-fg}'); + } else { + refreshIndicator.setContent('{green-fg}Updated!{/green-fg}'); + } + setTimeout(() => { + if (!isScreenDestroyed) { + refreshIndicator.setContent(''); + screen.render(); + } + }, 2000); + } else { + // Even without content updates, recover from offline mode if refresh succeeded + if (isOffline) { + isOffline = false; + refreshIndicator.setContent('{green-fg}Back online!{/green-fg}'); + setTimeout(() => { + if (!isScreenDestroyed) { + refreshIndicator.setContent(''); + screen.render(); + } + }, 2000); + } else { + refreshIndicator.setContent(''); + } + } + screen.render(); + } catch (error) { + // Clear live game state on refresh failure to avoid stale data + currentLiveGameData = null; + liveGameBox.hidden = true; + liveGameBox.setContent(''); + + if (!isScreenDestroyed) { + refreshIndicator.setContent('{red-fg}Refresh failed{/red-fg}'); + setTimeout(() => { + if (!isScreenDestroyed) { + refreshIndicator.setContent(''); + screen.render(); + } + }, 2000); + } + } finally { + isRefreshing = false; + refreshPromise = null; + } + })(); + + return refreshPromise; + }; + updateList(); + + // Apply filter states on initial load (ranked or time scope) + if (rankedOnly || timeScope !== 'all') { + const filteredMatches = getFilteredMatches(); + updatePanels(filteredMatches); + updateAllBorderColors(); + } + matchList.focus(); + process.nextTick(updatePreviewPanel); screen.render(); + + // Auto-trigger account data refresh if callback provided (for cached account loading) + if (refreshCallback) { + setTimeout(runBackgroundRefresh, 300); + } + + // Background rank refresh for stale data + const refreshStaleRanks = async () => { + const stalePuuids = getStalePlayerPuuids(); + if (stalePuuids.length === 0) return; + + for (let idx = 0; idx < stalePuuids.length; idx++) { + const puuid = stalePuuids[idx]; + if (isScreenDestroyed) return; + + try { + await fetchAndCacheRank(puuid); + } catch (error) { + if (process.env.DEBUG) { + console.error(`[BG Refresh] Failed to fetch rank for ${puuid.substring(0, 20)}:`, error.message || error); + } + // On rate limit (429), wait for reset and retry this puuid + if (error.response?.status === 429) { + const retryAfter = error.response?.headers?.['retry-after']; + const waitTime = retryAfter ? parseInt(retryAfter, 10) * 1000 : 30000; + await delay(Math.min(waitTime, 60000)); // Cap at 60s + idx--; // Retry this puuid + continue; + } + } + + if (isScreenDestroyed) return; + + // After each fetch, try to compute any newly-completable match averages + let anyNewComputed = false; + for (let i = 0; i < currentMatches.length; i++) { + if (!matchRankCache[i] || !matchRankCache[i].complete) { + computeMatchAverage(i); + anyNewComputed = true; + } + } + if (anyNewComputed) { + updateList(); + updatePreviewPanel(); + screen.render(); + } + + await delay(100); // 100ms between requests (10/second, safe margin for other API calls) + } + }; + + // Start background refresh after initial render (fire and forget) + refreshStaleRanks().catch(() => {}); + + // Pre-fetch OP.GG historical data for all match participants in background + const preloadOpggHistory = async () => { + // Collect unique participants across all matches + const seenPuuids = new Set(); + const participantsToFetch = []; + + for (const match of currentMatches) { + for (const p of match.details.info.participants) { + if (seenPuuids.has(p.puuid)) continue; + seenPuuids.add(p.puuid); + + // Skip if already cached on disk + if (getOpggCache(p.puuid)) continue; + + const gameName = p.riotIdGameName; + const tagLine = p.riotIdTagline; + if (gameName && tagLine) { + participantsToFetch.push({ puuid: p.puuid, gameName, tagLine }); + } + } + } + + // Fetch in batches to avoid overwhelming the API + const BATCH_SIZE = 5; + for (let i = 0; i < participantsToFetch.length; i += BATCH_SIZE) { + if (isScreenDestroyed) return; + const batch = participantsToFetch.slice(i, i + BATCH_SIZE); + await Promise.all(batch.map(async ({ puuid, gameName, tagLine }) => { + try { + const data = await opgg.getSummonerProfile(region, gameName, tagLine, 2); + if (data) saveOpggCache(puuid, data); + } catch (error) { + // Silent fail + } + })); + } + }; + + // Start OP.GG pre-fetch in background (fire and forget) + preloadOpggHistory().catch(() => {}); }); }; diff --git a/src/timelineTui.js b/src/timelineTui.js index ad8f3dc..4459d7b 100644 --- a/src/timelineTui.js +++ b/src/timelineTui.js @@ -8,6 +8,32 @@ const formatTimestamp = (timestamp) => { }; const createTimelineScreen = async (parentScreen, match) => { + // Guard against missing timeline data (can occur when API fetch fails) + if (!match.timeline?.info?.frames) { + return new Promise((resolve) => { + const modal = blessed.box({ + parent: parentScreen, + top: 'center', + left: 'center', + width: 40, + height: 7, + border: 'line', + label: ' {bold}Timeline{/bold} ', + tags: true, + grabKeys: true, + keys: true, + content: '\n Timeline data unavailable.\n Press any key to close.', + }); + modal.key(['escape', 'q', 'b', 'backspace', 'enter', 'space'], () => { + modal.destroy(); + parentScreen.render(); + resolve(); + }); + modal.focus(); + parentScreen.render(); + }); + } + const championData = await getChampionData(); const championMap = new Map(Object.values(championData.data).map(c => [c.id, c.name])); const participantMap = new Map(match.details.info.participants.map(p => [p.participantId, { name: p.summonerName, champion: championMap.get(p.championName), teamId: p.teamId }])); @@ -52,7 +78,7 @@ const createTimelineScreen = async (parentScreen, match) => { left: 'center', width: '100%-2', height: 1, - content: '{center}{bold}b{/bold}=Back | {bold}q{/bold}=Quit{/center}', + content: '{center}{bold}b{/bold}/{bold}backspace{/bold}=Back | {bold}q{/bold}=Quit{/center}', tags: true, }); @@ -131,7 +157,7 @@ const createTimelineScreen = async (parentScreen, match) => { eventLog.scrollTo(0); }, 0); - eventLog.key(['escape', 'q', 'b'], () => { + eventLog.key(['escape', 'q', 'b', 'backspace'], () => { modal.destroy(); parentScreen.render(); resolve(); diff --git a/src/tui.js b/src/tui.js index fd9081b..6236b12 100644 --- a/src/tui.js +++ b/src/tui.js @@ -1,6 +1,11 @@ const blessed = require('blessed'); +const { detectRegionFromRiotId } = require('./utils'); + +const REGIONS = ['NA1', 'KR', 'EUW1', 'EUN1', 'JP1', 'BR1', 'LA1', 'LA2', 'OC1', 'RU', 'TR1']; + +const createSearchScreen = (defaults = {}, options = {}) => { + const { accountCount = 0 } = options; -const createSearchScreen = () => { return new Promise((resolve) => { const screen = blessed.screen({ smartCSR: true, @@ -43,6 +48,19 @@ const createSearchScreen = () => { }, }); + // Account indicator (if monitored accounts exist) + if (accountCount > 0) { + blessed.text({ + parent: layout, + top: 1, + right: 1, + content: `${accountCount} account${accountCount > 1 ? 's' : ''} saved`, + style: { + fg: colors.yellow, + }, + }); + } + const form = blessed.form({ parent: layout, width: '80%', @@ -109,7 +127,7 @@ const createSearchScreen = () => { height: 3, top: 4, left: 15, - items: ['NA1', 'KR', 'EUW1', 'EUN1', 'JP1', 'BR1', 'LA1', 'LA2', 'OC1', 'RU', 'TR1'], + items: REGIONS, mouse: true, keys: true, lockKeys: true, @@ -177,6 +195,18 @@ const createSearchScreen = () => { }, }); + // Auto-detect status message + const autoDetectStatus = blessed.text({ + parent: form, + top: 4, + right: 3, + content: '', + tags: true, + style: { + fg: colors.green, + }, + }); + // Footer with key hints blessed.text({ parent: layout, @@ -188,9 +218,32 @@ const createSearchScreen = () => { }, }); + if (defaults.riotId) { + riotIdInput.setValue(defaults.riotId); + } + if (defaults.region) { + const regionIndex = REGIONS.indexOf(defaults.region); + if (regionIndex !== -1) { + regionList.select(regionIndex); + } + } + riotIdInput.focus(); riotIdInput.key('enter', () => { + const riotId = riotIdInput.getValue(); + const detectedRegion = detectRegionFromRiotId(riotId); + if (detectedRegion) { + const regionIndex = REGIONS.indexOf(detectedRegion); + if (regionIndex !== -1) { + regionList.select(regionIndex); + autoDetectStatus.setContent(`(Auto: ${detectedRegion})`); + screen.render(); + } + } else { + autoDetectStatus.setContent(''); + screen.render(); + } regionList.focus(); }); diff --git a/src/ui.js b/src/ui.js index bd2e3c2..2d7d073 100644 --- a/src/ui.js +++ b/src/ui.js @@ -1,5 +1,48 @@ const asciichart = require('asciichart'); +// Get display width of a string (CJK chars = 2 columns) +const getDisplayWidth = (str) => { + let width = 0; + for (const char of str) { + const code = char.charCodeAt(0); + // CJK characters and full-width forms take 2 columns + if ((code >= 0x4E00 && code <= 0x9FFF) || // CJK Unified Ideographs + (code >= 0xAC00 && code <= 0xD7AF) || // Hangul Syllables + (code >= 0xFF00 && code <= 0xFFEF) || // Full-width forms + (code >= 0x3000 && code <= 0x303F) || // CJK Punctuation + (code >= 0x3040 && code <= 0x309F) || // Hiragana + (code >= 0x30A0 && code <= 0x30FF)) { // Katakana + width += 2; + } else { + width += 1; + } + } + return width; +}; + +// Truncate string to fit within maxWidth display columns +const truncateToWidth = (str, maxWidth) => { + let width = 0; + let result = ''; + for (const char of str) { + const charWidth = getDisplayWidth(char); + if (width + charWidth > maxWidth) break; + result += char; + width += charWidth; + } + return result; +}; + +// Pad string to targetWidth display columns +const padToWidth = (str, targetWidth) => { + const currentWidth = getDisplayWidth(str); + const padding = Math.max(0, targetWidth - currentWidth); + return str + ' '.repeat(padding); +}; + +// Calculate KDA ratio for a participant +const calculateKdaRatio = (p) => (p.kills + p.assists) / Math.max(p.deaths, 1); + const formatSummary = (summoner, matches) => { if (matches.length === 0) { return `{bold}{green-fg}No recent games found.{/green-fg}{/bold}`; @@ -10,11 +53,17 @@ const formatSummary = (summoner, matches) => { let totalDeaths = 0; let totalAssists = 0; let totalDuration = 0; + let validGames = 0; const championCounts = {}; matches.forEach(match => { const participant = match.details.info.participants.find(p => p.puuid === summoner.puuid); if (participant) { + // Skip remakes - games under 5 minutes where player lost + const isRemake = !participant.win && match.details.info.gameDuration < 300; + if (isRemake) return; + + validGames++; if (participant.win) totalWins++; totalKills += participant.kills; totalDeaths += participant.deaths; @@ -24,44 +73,158 @@ const formatSummary = (summoner, matches) => { } }); - const winRate = ((totalWins / matches.length) * 100).toFixed(2); + const winRate = validGames > 0 ? ((totalWins / validGames) * 100).toFixed(2) : '0.00'; const overallKda = totalDeaths > 0 ? ((totalKills + totalAssists) / totalDeaths).toFixed(2) : 'Perfect'; const sortedChampions = Object.entries(championCounts).sort((a, b) => b[1] - a[1]); const mostPlayed = sortedChampions.slice(0, 3).map(([name, count]) => `${name} (${count})`).join(', '); - const hours = Math.floor(totalDuration / 3600); - const minutes = Math.floor((totalDuration % 3600) / 60); - const timePlayed = `${hours}h ${minutes}m`; + const avgDuration = validGames > 0 ? Math.floor(totalDuration / validGames) : 0; + const avgMinutes = Math.floor(avgDuration / 60); + const avgSeconds = avgDuration % 60; + const avgGameTime = `${avgMinutes}m ${avgSeconds}s`; - const labels = ["Win Rate:", "Overall KDA:", "Most Played:", "Time Played:"]; + const labels = ["Win Rate:", "Overall KDA:", "Most Played:", "Avg Game Time:"]; const longestLabel = Math.max(...labels.map(l => l.length)); - return `{bold}{green-fg}Last ${matches.length} Games Summary:{/green-fg}{/bold}\n` + - `${labels[0].padEnd(longestLabel)} {bold}${winRate}%{/bold}\n` + + return `${labels[0].padEnd(longestLabel)} {bold}${winRate}%{/bold}\n` + `${labels[1].padEnd(longestLabel)} {bold}${overallKda}{/bold}\n` + `${labels[2].padEnd(longestLabel)} {bold}${mostPlayed}{/bold}\n` + - `${labels[3].padEnd(longestLabel)} {bold}${timePlayed}{/bold}`; + `${labels[3].padEnd(longestLabel)} {bold}${avgGameTime}{/bold}`; }; const colorizeRank = (rank) => { - if (!rank) return 'Unranked'; + if (!rank) return '{white-fg}Unranked{/white-fg}'; const tier = rank.split(' ')[0]; const colors = { - 'CHALLENGER': 'yellow-fg', - 'GRANDMASTER': 'red-fg', - 'MASTER': 'magenta-fg', - 'DIAMOND': 'cyan-fg', - 'EMERALD': 'green-fg', - 'PLATINUM': 'blue-fg', - 'GOLD': 'yellow-fg', - 'SILVER': 'white-fg', - 'BRONZE': '#CD7F32-fg', - 'IRON': 'grey-fg', - 'Unranked': 'white-fg' + 'CHALLENGER': '#f1fa8c', + 'GRANDMASTER': '#ff5555', + 'MASTER': '#ff79c6', + 'DIAMOND': '#8be9fd', + 'EMERALD': '#50fa7b', + 'PLATINUM': '#8be9fd', + 'GOLD': '#f1fa8c', + 'SILVER': '#f8f8f2', + 'BRONZE': '#cd7f32', + 'IRON': '#a9a9a9', + 'Unranked': '#f8f8f2' }; - const color = colors[tier] || 'white-fg'; - return `{${color}}${rank}{/${color}}`; + const color = colors[tier] || '#f8f8f2'; + return `{${color}-fg}${rank}{/${color}-fg}`; +}; + +// Get color for a tier +const getTierColor = (tier) => { + const colors = { + 'CHALLENGER': '#f1fa8c', + 'GRANDMASTER': '#ff5555', + 'MASTER': '#ff79c6', + 'DIAMOND': '#8be9fd', + 'EMERALD': '#50fa7b', + 'PLATINUM': '#8be9fd', + 'GOLD': '#f1fa8c', + 'SILVER': '#f8f8f2', + 'BRONZE': '#cd7f32', + 'IRON': '#a9a9a9' + }; + return colors[tier] || '#f8f8f2'; +}; + +/** + * Format historical ranks from OP.GG data for display + * @param {Object} opggData - Data from opgg.getSummonerProfile() + * @param {number} maxYears - Max years of history to show + * @returns {string} Formatted string like "S25: SILVER IV | S25 Flex: BRONZE I | S24: GOLD II" + */ +const formatHistoricalRanks = (opggData, maxYears = 2) => { + if (!opggData?.history?.length) return ''; + + const parts = []; + const currentYear = new Date().getFullYear(); + + opggData.history.forEach(entry => { + // Only include entries within maxYears + if (entry.year < currentYear - maxYears) return; + + const seasonLabel = `S${entry.year.toString().slice(-2)}`; + + if (entry.solo) { + const color = getTierColor(entry.solo.tier); + parts.push(`{${color}-fg}${seasonLabel}: ${entry.solo.rank}{/${color}-fg}`); + } + if (entry.flex) { + const color = getTierColor(entry.flex.tier); + parts.push(`{${color}-fg}${seasonLabel} Flex: ${entry.flex.rank}{/${color}-fg}`); + } + }); + + return parts.join(' | '); +}; + +/** + * Format historical ranks in compact form for live game display + * @param {Object} opggData - Data from opgg.getSummonerProfile() + * @param {number} maxYears - Max years of history to show + * @returns {string} Compact format like "S25: S4 | S24: G2" (SILVER IV, GOLD II) + */ +const formatCompactHistoricalRanks = (opggData, maxYears = 2) => { + if (!opggData?.history?.length) return ''; + + // Tier abbreviations + const tierAbbr = { + 'CHALLENGER': 'C', 'GRANDMASTER': 'GM', 'MASTER': 'M', + 'DIAMOND': 'D', 'EMERALD': 'E', 'PLATINUM': 'P', + 'GOLD': 'G', 'SILVER': 'S', 'BRONZE': 'B', 'IRON': 'I' + }; + + // Division to number (IV -> 4, III -> 3, etc.) + const divToNum = { 'I': '1', 'II': '2', 'III': '3', 'IV': '4' }; + + const parts = []; + const currentYear = new Date().getFullYear(); + + opggData.history.forEach(entry => { + if (entry.year < currentYear - maxYears) return; + + const seasonLabel = `S${entry.year.toString().slice(-2)}`; + + if (entry.solo) { + const abbr = tierAbbr[entry.solo.tier] || entry.solo.tier[0]; + const div = entry.solo.division ? divToNum[entry.solo.division] || '' : ''; + const color = getTierColor(entry.solo.tier); + parts.push(`{${color}-fg}${seasonLabel}: ${abbr}${div}{/${color}-fg}`); + } + if (entry.flex) { + const abbr = tierAbbr[entry.flex.tier] || entry.flex.tier[0]; + const div = entry.flex.division ? divToNum[entry.flex.division] || '' : ''; + const color = getTierColor(entry.flex.tier); + parts.push(`{${color}-fg}${seasonLabel}F: ${abbr}${div}{/${color}-fg}`); + } + }); + + return parts.join(' | '); +}; + +/** + * Format current rank with optional peak rank + * @param {Object} opggData - Data from opgg.getSummonerProfile() + * @returns {string} Formatted current rank with peak if different + */ +const formatCurrentWithPeak = (opggData) => { + if (!opggData?.current?.solo) return ''; + + const current = opggData.current.solo; + const peak = opggData.peak?.solo; + + let result = colorizeRank(current.rank); + + // Show peak if it's different from current + if (peak && peak.rank !== current.rank) { + const peakColor = getTierColor(peak.tier); + result += ` (Peak: {${peakColor}-fg}${peak.rank}{/${peakColor}-fg})`; + } + + return result; }; const padStringWithTags = (str, length) => { @@ -111,14 +274,17 @@ const formatMatchDetails = (match, summoner, itemMap, spellMap, runeMap, termina `Deaths: ${createBar(participant.deaths, 'red')}\n` + `Assists: ${createBar(participant.assists, 'magenta')}`; - // Gold Graph - const participantId = participant.participantId; - const goldFrames = match.timeline.info.frames.map(frame => frame.participantFrames[String(participantId)].totalGold); - const graphWidth = Math.floor(terminalWidth * 0.8); - const resampledGold = resampleData(goldFrames, graphWidth); - const goldChart = asciichart.plot(resampledGold, { height: 8 }); - const xAxis = createXAxis(Math.floor(match.details.info.gameDuration / 60), graphWidth); - const goldGraph = `{bold}Gold Generation:{/bold}\n{yellow-fg}${goldChart}{/yellow-fg}\n${xAxis}`; + // Gold Graph (only if timeline data is available - timelines are fetched on-demand) + let goldGraph = ''; + if (match.timeline?.info?.frames) { + const participantId = participant.participantId; + const goldFrames = match.timeline.info.frames.map(frame => frame.participantFrames[String(participantId)].totalGold); + const graphWidth = Math.floor(terminalWidth * 0.8); + const resampledGold = resampleData(goldFrames, graphWidth); + const goldChart = asciichart.plot(resampledGold, { height: 8 }); + const xAxis = createXAxis(Math.floor(match.details.info.gameDuration / 60), graphWidth); + goldGraph = `{bold}Gold Generation:{/bold}\n{yellow-fg}${goldChart}{/yellow-fg}\n${xAxis}`; + } // Items const items = [ @@ -149,26 +315,43 @@ const formatMatchDetails = (match, summoner, itemMap, spellMap, runeMap, termina let rankText = ''; if (rankData) { rankText = `{bold}Players:{/bold}\n`; - const team1 = match.details.info.participants.slice(0, 5); - const team2 = match.details.info.participants.slice(5, 10); + const allParticipants = match.details.info.participants; + const team1 = allParticipants.slice(0, 5); + const team2 = allParticipants.slice(5, 10); + + // Absolute thresholds for KDA highlighting + const greenThreshold = 4.0; + const redThreshold = 1.5; // Calculate the longest name for dynamic padding - const longestName = Math.max(...match.details.info.participants.map(p => `${p.riotIdGameName}#${p.riotIdTagline}`.length)); + const longestName = Math.max(...allParticipants.map(p => `${p.riotIdGameName}#${p.riotIdTagline}`.length)); const formatTeam = (team) => { return team.map(p => { - const rank = rankData[p.puuid] || 'Unranked'; + const rank = rankData[p.puuid] || '...'; const fullRiotId = `${p.riotIdGameName}#${p.riotIdTagline}`.padEnd(longestName + 2); const champion = p.championName.padEnd(16); - const kda = `${p.kills}/${p.deaths}/${p.assists}`.padEnd(12); - return `${fullRiotId} ${champion} ${kda} ${colorizeRank(rank)}`; + const kdaStr = `${p.kills}/${p.deaths}/${p.assists}`; + const playerKda = calculateKdaRatio(p); + // Color-code KDA: green for good performance, red for poor performance + let coloredKda; + if (playerKda >= greenThreshold) { + coloredKda = `{green-fg}${kdaStr.padEnd(12)}{/green-fg}`; + } else if (playerKda <= redThreshold) { + coloredKda = `{red-fg}${kdaStr.padEnd(12)}{/red-fg}`; + } else { + coloredKda = kdaStr.padEnd(12); + } + return `${fullRiotId} ${champion} ${coloredKda} ${colorizeRank(rank)}`; }).join('\n'); }; rankText += `{cyan-fg}Blue Team:{/cyan-fg}\n${formatTeam(team1)}\n`; rankText += `{red-fg}Red Team:{/red-fg}\n${formatTeam(team2)}`; } - return `\n${frame('Details', detailsSummary)}\n\n${frame('KDA', kdaGraph)}\n\n${frame('Gold', goldGraph)}\n\n${frame('Items', itemsText)}\n\n${frame('Spells', spellsText)}\n\n${frame('Runes', runesText)}\n\n${frame('Ranks', rankText)}\n`; + // Build output, conditionally including gold graph only if timeline data was available + const goldSection = goldGraph ? `\n\n${frame('Gold', goldGraph)}` : ''; + return `\n${frame('Details', detailsSummary)}\n\n${frame('KDA', kdaGraph)}${goldSection}\n\n${frame('Items', itemsText)}\n\n${frame('Spells', spellsText)}\n\n${frame('Runes', runesText)}\n\n${frame('Ranks', rankText)}\n`; }; // Helper functions for graphs const resampleData = (data, targetWidth) => { @@ -199,7 +382,360 @@ const createXAxis = (durationMinutes, width) => { }; +const formatRankedInfo = (rankedData) => { + const soloQueue = rankedData?.find(q => q.queueType === 'RANKED_SOLO_5x5'); + const flexQueue = rankedData?.find(q => q.queueType === 'RANKED_FLEX_SR'); + + const formatQueue = (queue, name) => { + if (!queue) { + return `{bold}${name}:{/bold} Unranked`; + } + const division = ['MASTER', 'GRANDMASTER', 'CHALLENGER'].includes(queue.tier) ? '' : ` ${queue.rank}`; + const colorizedRank = colorizeRank(`${queue.tier}${division}`); + const lp = `${queue.leaguePoints} LP`; + const wins = queue.wins; + const losses = queue.losses; + const winRate = (wins + losses) > 0 ? ((wins / (wins + losses)) * 100).toFixed(0) : '0'; + return `{bold}${name}:{/bold} ${colorizedRank} - ${lp}\n${wins}W ${losses}L (${winRate}%)`; + }; + + return formatQueue(soloQueue, 'Solo/Duo') + '\n\n' + formatQueue(flexQueue, 'Flex'); +}; + +const formatChampionStats = (matches, puuid) => { + const champStats = {}; + + matches.forEach(match => { + const participant = match.details.info.participants.find(p => p.puuid === puuid); + if (participant) { + // Skip remakes - games under 5 minutes where player lost + const isRemake = !participant.win && match.details.info.gameDuration < 300; + if (isRemake) return; + + const name = participant.championName; + if (!champStats[name]) { + champStats[name] = { games: 0, wins: 0, kills: 0, deaths: 0, assists: 0 }; + } + champStats[name].games++; + if (participant.win) champStats[name].wins++; + champStats[name].kills += participant.kills; + champStats[name].deaths += participant.deaths; + champStats[name].assists += participant.assists; + } + }); + + const sorted = Object.entries(champStats) + .sort((a, b) => b[1].games - a[1].games) + .slice(0, 5); + + if (sorted.length === 0) return 'No champion data available'; + + const header = 'Champion'.padEnd(14) + 'Games'.padEnd(7) + 'Win%'.padEnd(7) + 'KDA'; + const lines = sorted.map(([name, stats]) => { + const games = stats.games || 1; // Defensive check for division by zero + const winRate = ((stats.wins / games) * 100).toFixed(0) + '%'; + const avgK = (stats.kills / games).toFixed(1); + const avgD = (stats.deaths / games).toFixed(1); + const avgA = (stats.assists / games).toFixed(1); + const winColor = stats.wins / games >= 0.5 ? 'green' : 'red'; + return `${name.padEnd(14)}${String(stats.games).padEnd(7)}{${winColor}-fg}${winRate.padEnd(7)}{/${winColor}-fg}${avgK}/${avgD}/${avgA}`; + }); + + return `{bold}${header}{/bold}\n${lines.join('\n')}`; +}; + +const formatMasteryDisplay = (masteries, championMap) => { + if (!masteries || masteries.length === 0) return 'No mastery data available'; + + const lines = masteries.map((m, index) => { + const champName = championMap?.get(String(m.championId)) || `Champion ${m.championId}`; + const level = `Lvl ${m.championLevel}`; + const points = m.championPoints.toLocaleString() + ' pts'; + return `${(index + 1)}. ${champName.padEnd(14)} ${level.padEnd(6)} ${points}`; + }); + + return lines.join('\n'); +}; + +const formatRankPreview = (match, rankData, summoner, historicalRanks = {}, opggHistoryMap = new Map()) => { + if (!match) return ''; + + const participants = match.details.info.participants; + const queueId = match.details.info.queueId; + const team1 = participants.filter(p => p.teamId === 100); + const team2 = participants.filter(p => p.teamId === 200); + + // Absolute thresholds for KDA highlighting + const greenThreshold = 4.0; + const redThreshold = 1.5; + + // Tier abbreviations for compact display + const tierAbbr = { + 'CHALLENGER': 'C', 'GRANDMASTER': 'GM', 'MASTER': 'M', + 'DIAMOND': 'D', 'EMERALD': 'E', 'PLATINUM': 'P', + 'GOLD': 'G', 'SILVER': 'S', 'BRONZE': 'B', 'IRON': 'I' + }; + const divToNum = { 'I': '1', 'II': '2', 'III': '3', 'IV': '4' }; + + const roleMap = { TOP: 'TOP', JUNGLE: 'JGL', MIDDLE: 'MID', BOTTOM: 'BOT', UTILITY: 'SUP', ARENA: '---' }; + + // Header row matching column widths + const headerRow = `{bold} ${'NAME'.padEnd(11)} ${'ROLE'.padEnd(5)}${'CHAMP'.padEnd(10)}${'K/D/A'.padEnd(10)}${'CURRENT'.padEnd(13)}HISTORY{/bold}`; + const separatorLine = '{gray-fg}' + '─'.repeat(58) + '{/gray-fg}'; + + const formatPlayer = (p) => { + const isCurrentUser = p.puuid === summoner.puuid; + // Use historical rank for monitored accounts if available, otherwise use current rank + const playerHistoricalRank = historicalRanks[p.puuid]; + let rank; + if (playerHistoricalRank) { + rank = `${playerHistoricalRank.tier} ${playerHistoricalRank.rank}`; + } else { + rank = rankData?.[p.puuid] || '...'; + } + const rawName = p.riotIdGameName || p.summonerName || '???'; + const name = padToWidth(truncateToWidth(rawName, 10), 11); // 10 cols max, pad to 11 + const champ = padToWidth(truncateToWidth(p.championName, 8), 9); + const kdaStr = `${p.kills}/${p.deaths}/${p.assists}`; + const playerKda = calculateKdaRatio(p); + // Color-code KDA: green for good performance, red for poor performance + let coloredKda; + if (playerKda >= greenThreshold) { + coloredKda = `{green-fg}${kdaStr.padEnd(9)}{/green-fg}`; + } else if (playerKda <= redThreshold) { + coloredKda = `{red-fg}${kdaStr.padEnd(9)}{/red-fg}`; + } else { + coloredKda = kdaStr.padEnd(9); + } + const rawRole = queueId === 1700 ? 'ARENA' : (p.teamPosition || p.individualPosition || ''); + const role = roleMap[rawRole] || rawRole.substring(0, 3); + const prefix = isCurrentUser ? '>' : ' '; + const coloredRank = padStringWithTags(colorizeRank(rank), 12); + + // Get OP.GG historical data (compact format: S25: G2 | S24: S4) + const opggData = opggHistoryMap.get?.(p.puuid); + let historyStr = ''; + if (opggData?.history?.length > 0) { + const currentYear = new Date().getFullYear(); + const parts = []; + opggData.history.slice(0, 3).forEach(entry => { // Limit to 3 entries for space + if (entry.year < currentYear - 2) return; + const seasonLabel = `S${entry.year.toString().slice(-2)}`; + if (entry.solo) { + const abbr = tierAbbr[entry.solo.tier] || entry.solo.tier[0]; + const div = entry.solo.division ? divToNum[entry.solo.division] || '' : ''; + const color = getTierColor(entry.solo.tier); + parts.push(`{${color}-fg}${seasonLabel}:${abbr}${div}{/${color}-fg}`); + } + }); + if (parts.length > 0) { + historyStr = ` ${parts.join(' ')}`; + } + } + + return `${prefix}${name} ${role.padEnd(4)} ${champ} ${coloredKda} ${coloredRank}${historyStr}`; + }; + + let content = '{cyan-fg}Blue Team{/cyan-fg}\n'; + content += headerRow + '\n'; + content += separatorLine + '\n'; + content += team1.map(formatPlayer).join('\n'); + content += '\n\n{red-fg}Red Team{/red-fg}\n'; + content += headerRow + '\n'; + content += separatorLine + '\n'; + content += team2.map(formatPlayer).join('\n'); + + return content; +}; + +// Convert rank to absolute LP score for graph visualization +// IRON IV 0 LP = 0, SILVER I 50 LP = 1350, GOLD IV 0 LP = 1600 +const rankToAbsoluteLP = (tier, rank, lp) => { + const tiers = { 'IRON': 0, 'BRONZE': 1, 'SILVER': 2, 'GOLD': 3, 'PLATINUM': 4, 'EMERALD': 5, 'DIAMOND': 6, 'MASTER': 7, 'GRANDMASTER': 8, 'CHALLENGER': 9 }; + const divisions = { 'IV': 0, 'III': 1, 'II': 2, 'I': 3 }; + const tierScore = (tiers[tier] || 0) * 400; + const divisionScore = (divisions[rank] || 0) * 100; + return tierScore + divisionScore + (lp || 0); +}; + +// Convert absolute LP back to rank abbreviation (e.g., 1250 -> "G4 50") +const absoluteLPToRankAbbr = (absoluteLP) => { + const tiers = ['I', 'B', 'S', 'G', 'P', 'E', 'D', 'M', 'GM', 'C']; + const divisions = ['4', '3', '2', '1']; + + // Clamp to valid range + absoluteLP = Math.max(0, absoluteLP); + + const tierIndex = Math.min(Math.floor(absoluteLP / 400), 9); + const tierAbbr = tiers[tierIndex]; + + // Master+ tiers have no divisions - show tier + total LP + if (tierIndex >= 7) { + const lpInTier = absoluteLP - (tierIndex * 400); + return `${tierAbbr}${lpInTier}`; + } + + const lpInTier = absoluteLP % 400; + const divisionIndex = Math.min(Math.floor(lpInTier / 100), 3); + const divisionNum = divisions[divisionIndex]; + const lpInDivision = lpInTier % 100; + + return `${tierAbbr}${divisionNum} ${lpInDivision}`; +}; + +// Format LP progression graph from rank history snapshots +const formatLPGraph = (snapshots, width, height, queueType = 'RANKED_SOLO_5x5') => { + if (!snapshots || snapshots.length === 0) { + return 'No LP changes in this period'; + } + + // Filter for the specified queue type and sort by timestamp + const filtered = snapshots + .filter(s => s.queueType === queueType) + .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); + + if (filtered.length === 0) { + return 'No ranked games found'; + } + + if (filtered.length === 1) { + const s = filtered[0]; + return `Current: ${s.tier} ${s.rank} ${s.leaguePoints} LP\n(Play more ranked games to see progression)`; + } + + // Convert snapshots to absolute LP values + const lpValues = filtered.map(s => rankToAbsoluteLP(s.tier, s.rank, s.leaguePoints)); + + // Resample data if we have more points than width allows + const graphWidth = Math.min(width - 10, 40); // Leave room for Y-axis labels + const data = lpValues.length > graphWidth ? resampleData(lpValues, graphWidth) : lpValues; + + // Generate the chart with empty Y-axis labels + const chartHeight = Math.min(height - 2, 5); // Leave room for W/L markers + const chart = asciichart.plot(data, { + height: chartHeight, + format: () => ' ' // 6 spaces to maintain alignment + }); + + // Get first and last rank labels + const firstLP = data[0]; + const lastLP = data[data.length - 1]; + const firstRankLabel = absoluteLPToRankAbbr(firstLP).padStart(6); + const lastRankLabel = ' ' + absoluteLPToRankAbbr(lastLP); + + // Parse chart lines and add inline rank labels + const chartLines = chart.split('\n'); + + // Find which row has the first data point (leftmost chart character after y-axis) + // and which row has the last data point (rightmost chart character) + let firstPointRow = -1; + let lastPointRow = -1; + const yAxisWidth = 7; // 6 chars for label + 1 separator space + + // Characters that mark actual data points (exclude │ which is just a connector) + // asciichart uses: ┐ ┘ ┌ └ ─ │ ╮ ╯ ╭ ╰ but │ only connects points vertically + const dataPointChars = /[┐┘┌└╮╯╭╰┼┤├─]/; + + // Determine trend direction to know where first data point should be + const isUpwardTrend = data[0] < data[data.length - 1]; + + // Find row with first data point (leftmost column, exclude vertical connectors) + if (isUpwardTrend) { + // First point is at bottom, iterate from bottom to top + for (let i = chartLines.length - 1; i >= 0; i--) { + const line = chartLines[i]; + if (line.length > yAxisWidth) { + const firstChar = line[yAxisWidth]; + if (firstChar && dataPointChars.test(firstChar)) { + firstPointRow = i; + break; + } + } + } + } else { + // First point is at top or same level, iterate from top to bottom + for (let i = 0; i < chartLines.length; i++) { + const line = chartLines[i]; + if (line.length > yAxisWidth) { + const firstChar = line[yAxisWidth]; + if (firstChar && dataPointChars.test(firstChar)) { + firstPointRow = i; + break; + } + } + } + } + + // Find row with last data point (rightmost column, exclude vertical connectors) + let maxCol = 0; + for (let i = 0; i < chartLines.length; i++) { + const line = chartLines[i]; + for (let j = line.length - 1; j >= yAxisWidth; j--) { + if (dataPointChars.test(line[j])) { + if (j > maxCol) { + maxCol = j; + lastPointRow = i; + } + break; + } + } + } + + // Add labels to the appropriate rows + const modifiedLines = chartLines.map((line, i) => { + let newLine = line; + if (i === firstPointRow) { + // Replace empty y-axis label with first rank + newLine = firstRankLabel + line.slice(6); + } + if (i === lastPointRow) { + // Append last rank label to the end + newLine = newLine + lastRankLabel; + } + return newLine; + }); + + const modifiedChart = modifiedLines.join('\n'); + + // Build win/loss markers line using blessed tags + // We need to align markers with the data points in the chart + let markers = ''; + const chartWidth = data.length; + + // Generate markers from resampled data array to match chart points + for (let i = 1; i < data.length; i++) { + const prevLP = data[i - 1]; + const currLP = data[i]; + + if (currLP > prevLP) { + markers += '{green-fg}W{/green-fg}'; + } else if (currLP < prevLP) { + markers += '{red-fg}L{/red-fg}'; + } else { + markers += '-'; + } + } + + // Add colored markers below the chart (aligned with data points) + // The chart has a Y-axis label, so we pad accordingly + const markerPadding = ' '.repeat(yAxisWidth + 1); + const markerLine = markerPadding + markers; + + return modifiedChart + '\n' + markerLine; +}; + module.exports = { formatSummary, formatMatchDetails, -}; \ No newline at end of file + formatRankedInfo, + formatChampionStats, + formatMasteryDisplay, + formatRankPreview, + colorizeRank, + rankToAbsoluteLP, + formatLPGraph, + formatHistoricalRanks, + formatCompactHistoricalRanks, + formatCurrentWithPeak, + getTierColor, +}; diff --git a/src/utils.js b/src/utils.js index a908d87..509d8de 100644 --- a/src/utils.js +++ b/src/utils.js @@ -1,93 +1,776 @@ const axios = require('axios'); const fs = require('fs'); const path = require('path'); +const os = require('os'); + +// Promise-based delay utility +const delay = ms => new Promise(res => setTimeout(res, ms)); + +// Atomic file write: write to temp file, then rename to target +// Uses unique temp filename to prevent race conditions between concurrent processes +const atomicWriteFileSync = (filePath, content) => { + const tempFile = `${filePath}.${process.pid}.${Date.now()}.tmp`; + try { + fs.writeFileSync(tempFile, content); + fs.renameSync(tempFile, filePath); + } finally { + // Clean up temp file if rename failed (file may or may not exist) + try { fs.unlinkSync(tempFile); } catch (e) { /* ignore - file may have been renamed successfully */ } + } +}; + +// Generic cache file reader with validation +const readCacheFile = (filePath, defaultValue, validator = () => true) => { + try { + if (fs.existsSync(filePath)) { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + if (validator(data)) return data; + } + } catch (e) { + // Corrupted cache, return default + } + return typeof defaultValue === 'function' ? defaultValue() : defaultValue; +}; const CACHE_FILE = path.resolve(__dirname, '../.item_cache.json'); + +// Per-account cache directories +const LOL_CLI_DIR = path.join(os.homedir(), '.lol-cli'); +const ACCOUNTS_DIR = path.join(LOL_CLI_DIR, 'accounts'); +const MONITORED_ACCOUNTS_FILE = path.join(LOL_CLI_DIR, 'monitored_accounts.json'); const CHAMPION_CACHE_FILE = path.resolve(__dirname, '../.champion_cache.json'); const SPELL_CACHE_FILE = path.resolve(__dirname, '../.spell_cache.json'); const RUNE_CACHE_FILE = path.resolve(__dirname, '../.rune_cache.json'); const QUEUE_CACHE_FILE = path.resolve(__dirname, '../.queue_cache.json'); +const LAST_SEARCH_FILE = process.pkg + ? path.resolve(path.dirname(process.execPath), '.last_search.json') + : path.resolve(__dirname, '../.last_search.json'); const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours +const MAX_CACHED_MATCHES = 100; // Limit matches per account to prevent unbounded growth +const MAX_RANK_SNAPSHOTS = 500; // Limit rank history snapshots per account + +let cachedVersion = null; +let versionFetchedAt = 0; +const VERSION_TTL = 3600000; // 1 hour const getLatestVersion = async () => { + if (cachedVersion && Date.now() - versionFetchedAt < VERSION_TTL) { + return cachedVersion; + } const versionsResponse = await axios.get('https://ddragon.leagueoflegends.com/api/versions.json'); - return versionsResponse.data[0]; + cachedVersion = versionsResponse.data[0]; + versionFetchedAt = Date.now(); + return cachedVersion; }; const getChampionData = async () => { + // Check mtime BEFORE parsing JSON to avoid wasted CPU on stale cache + let cacheExists = false; + let cacheIsFresh = false; if (fs.existsSync(CHAMPION_CACHE_FILE)) { - const stats = fs.statSync(CHAMPION_CACHE_FILE); - if (new Date() - new Date(stats.mtime) < CACHE_DURATION) { + cacheExists = true; + try { + const stats = fs.statSync(CHAMPION_CACHE_FILE); + cacheIsFresh = (new Date() - stats.mtime) < CACHE_DURATION; + } catch (e) { + // Stat failed, treat as stale + } + } + + // If cache is fresh, parse and return + if (cacheIsFresh) { + try { return JSON.parse(fs.readFileSync(CHAMPION_CACHE_FILE, 'utf-8')); + } catch (e) { + // Parse failed, fetch fresh data + } + } + + // Try to fetch fresh data + try { + const latestVersion = await getLatestVersion(); + const response = await axios.get(`https://ddragon.leagueoflegends.com/cdn/${latestVersion}/data/en_US/champion.json`); + atomicWriteFileSync(CHAMPION_CACHE_FILE, JSON.stringify(response.data)); + return response.data; + } catch (error) { + // If fetch fails but we have cache (stale), parse and return as fallback + if (cacheExists) { + try { + return JSON.parse(fs.readFileSync(CHAMPION_CACHE_FILE, 'utf-8')); + } catch (e) { + // Both fetch and cache parse failed + } } + throw error; } - const latestVersion = await getLatestVersion(); - const response = await axios.get(`https://ddragon.leagueoflegends.com/cdn/${latestVersion}/data/en_US/champion.json`); - fs.writeFileSync(CHAMPION_CACHE_FILE, JSON.stringify(response.data)); - return response.data; }; const getSummonerSpellData = async () => { + // Check mtime BEFORE parsing JSON to avoid wasted CPU on stale cache + let cacheExists = false; + let cacheIsFresh = false; if (fs.existsSync(SPELL_CACHE_FILE)) { - const stats = fs.statSync(SPELL_CACHE_FILE); - if (new Date() - new Date(stats.mtime) < CACHE_DURATION) { + cacheExists = true; + try { + const stats = fs.statSync(SPELL_CACHE_FILE); + cacheIsFresh = (new Date() - stats.mtime) < CACHE_DURATION; + } catch (e) { + // Stat failed, treat as stale + } + } + + // If cache is fresh, parse and return + if (cacheIsFresh) { + try { return JSON.parse(fs.readFileSync(SPELL_CACHE_FILE, 'utf-8')); + } catch (e) { + // Parse failed, fetch fresh data } } - const latestVersion = await getLatestVersion(); - const response = await axios.get(`https://ddragon.leagueoflegends.com/cdn/${latestVersion}/data/en_US/summoner.json`); - fs.writeFileSync(SPELL_CACHE_FILE, JSON.stringify(response.data)); - return response.data; + + try { + const latestVersion = await getLatestVersion(); + const response = await axios.get(`https://ddragon.leagueoflegends.com/cdn/${latestVersion}/data/en_US/summoner.json`); + atomicWriteFileSync(SPELL_CACHE_FILE, JSON.stringify(response.data)); + return response.data; + } catch (error) { + // If fetch fails but we have cache (stale), parse and return as fallback + if (cacheExists) { + try { + return JSON.parse(fs.readFileSync(SPELL_CACHE_FILE, 'utf-8')); + } catch (e) { + // Both fetch and cache parse failed + } + } + throw error; + } }; const getRuneData = async () => { + // Check mtime BEFORE parsing JSON to avoid wasted CPU on stale cache + let cacheExists = false; + let cacheIsFresh = false; if (fs.existsSync(RUNE_CACHE_FILE)) { - const stats = fs.statSync(RUNE_CACHE_FILE); - if (new Date() - new Date(stats.mtime) < CACHE_DURATION) { + cacheExists = true; + try { + const stats = fs.statSync(RUNE_CACHE_FILE); + cacheIsFresh = (new Date() - stats.mtime) < CACHE_DURATION; + } catch (e) { + // Stat failed, treat as stale + } + } + + // If cache is fresh, parse and return + if (cacheIsFresh) { + try { return JSON.parse(fs.readFileSync(RUNE_CACHE_FILE, 'utf-8')); + } catch (e) { + // Parse failed, fetch fresh data } } - const latestVersion = await getLatestVersion(); - const response = await axios.get(`https://ddragon.leagueoflegends.com/cdn/${latestVersion}/data/en_US/runesReforged.json`); - fs.writeFileSync(RUNE_CACHE_FILE, JSON.stringify(response.data)); - return response.data; + + try { + const latestVersion = await getLatestVersion(); + const response = await axios.get(`https://ddragon.leagueoflegends.com/cdn/${latestVersion}/data/en_US/runesReforged.json`); + atomicWriteFileSync(RUNE_CACHE_FILE, JSON.stringify(response.data)); + return response.data; + } catch (error) { + // If fetch fails but we have cache (stale), parse and return as fallback + if (cacheExists) { + try { + return JSON.parse(fs.readFileSync(RUNE_CACHE_FILE, 'utf-8')); + } catch (e) { + // Both fetch and cache parse failed + } + } + throw error; + } }; const getQueueData = async () => { + // Check mtime BEFORE parsing JSON to avoid wasted CPU on stale cache + let cacheExists = false; + let cacheIsFresh = false; if (fs.existsSync(QUEUE_CACHE_FILE)) { - const stats = fs.statSync(QUEUE_CACHE_FILE); - if (new Date() - new Date(stats.mtime) < CACHE_DURATION) { + cacheExists = true; + try { + const stats = fs.statSync(QUEUE_CACHE_FILE); + cacheIsFresh = (new Date() - stats.mtime) < CACHE_DURATION; + } catch (e) { + // Stat failed, treat as stale + } + } + + // If cache is fresh, parse and return + if (cacheIsFresh) { + try { return JSON.parse(fs.readFileSync(QUEUE_CACHE_FILE, 'utf-8')); + } catch (e) { + // Parse failed, fetch fresh data } } - const response = await axios.get('https://static.developer.riotgames.com/docs/lol/queues.json'); - fs.writeFileSync(QUEUE_CACHE_FILE, JSON.stringify(response.data)); - return response.data; + + try { + const response = await axios.get('https://static.developer.riotgames.com/docs/lol/queues.json'); + atomicWriteFileSync(QUEUE_CACHE_FILE, JSON.stringify(response.data)); + return response.data; + } catch (error) { + // If fetch fails but we have cache (stale), parse and return as fallback + if (cacheExists) { + try { + return JSON.parse(fs.readFileSync(QUEUE_CACHE_FILE, 'utf-8')); + } catch (e) { + // Both fetch and cache parse failed + } + } + throw error; + } }; const getItemData = async () => { + // Check mtime BEFORE parsing JSON to avoid wasted CPU on stale cache + let cacheExists = false; + let cacheIsFresh = false; if (fs.existsSync(CACHE_FILE)) { - const stats = fs.statSync(CACHE_FILE); - const lastModified = new Date(stats.mtime); - if (new Date() - lastModified < CACHE_DURATION) { - const data = fs.readFileSync(CACHE_FILE, 'utf-8'); - return JSON.parse(data); + cacheExists = true; + try { + const stats = fs.statSync(CACHE_FILE); + cacheIsFresh = (new Date() - stats.mtime) < CACHE_DURATION; + } catch (e) { + // Stat failed, treat as stale } } - const latestVersion = await getLatestVersion(); - const itemResponse = await axios.get(`https://ddragon.leagueoflegends.com/cdn/${latestVersion}/data/en_US/item.json`); - const itemData = itemResponse.data; + // If cache is fresh, parse and return + if (cacheIsFresh) { + try { + return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8')); + } catch (e) { + // Parse failed, fetch fresh data + } + } + + try { + const latestVersion = await getLatestVersion(); + const itemResponse = await axios.get(`https://ddragon.leagueoflegends.com/cdn/${latestVersion}/data/en_US/item.json`); + const itemData = itemResponse.data; + atomicWriteFileSync(CACHE_FILE, JSON.stringify(itemData)); + return itemData; + } catch (error) { + // If fetch fails but we have cache (stale), parse and return as fallback + if (cacheExists) { + try { + return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf-8')); + } catch (e) { + // Both fetch and cache parse failed + } + } + throw error; + } +}; + +const getLastSearch = () => { + try { + if (fs.existsSync(LAST_SEARCH_FILE)) { + return JSON.parse(fs.readFileSync(LAST_SEARCH_FILE, 'utf-8')); + } + } catch (error) { + return null; + } + return null; +}; + +const saveLastSearch = (riotId, region) => { + try { + atomicWriteFileSync(LAST_SEARCH_FILE, JSON.stringify({ riotId, region })); + } catch (error) { + // Intentionally silent: saving search history is non-critical functionality. + // Failure should not interrupt the user's workflow. + } +}; + +const detectRegionFromRiotId = (riotId) => { + if (!riotId || !riotId.includes('#')) return null; + + const tagline = riotId.split('#')[1]?.toUpperCase(); + if (!tagline) return null; + + const taglineToRegion = { + 'NA': 'NA1', 'NA1': 'NA1', + 'EUW': 'EUW1', 'EUW1': 'EUW1', + 'EUNE': 'EUN1', 'EUN1': 'EUN1', + 'KR': 'KR', + 'JP': 'JP1', 'JP1': 'JP1', + 'BR': 'BR1', 'BR1': 'BR1', + 'LAN': 'LA1', 'LA1': 'LA1', + 'LAS': 'LA2', 'LA2': 'LA2', + 'OCE': 'OC1', 'OC1': 'OC1', + 'RU': 'RU', + 'TR': 'TR1', 'TR1': 'TR1', + }; + + return taglineToRegion[tagline] || null; +}; + +// Per-account cache functions +const ensureCacheDir = (puuid) => { + // Validate puuid to prevent directory traversal + if (!puuid || typeof puuid !== 'string' || /[\/\\]/.test(puuid)) { + throw new Error('Invalid puuid'); + } + const accountDir = path.join(ACCOUNTS_DIR, puuid); + if (!fs.existsSync(accountDir)) { + fs.mkdirSync(accountDir, { recursive: true }); + } + return accountDir; +}; - fs.writeFileSync(CACHE_FILE, JSON.stringify(itemData)); +const getMatchCache = (puuid) => { + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'matches.json'); + return readCacheFile( + cacheFile, + () => ({ version: 1, puuid, region: null, lastUpdated: null, matches: [] }), + (data) => data.version === 1 && data.puuid === puuid && Array.isArray(data.matches) + ); +}; - return itemData; +const saveMatchCache = (puuid, data) => { + try { + ensureCacheDir(puuid); + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'matches.json'); + data.version = 1; + data.puuid = puuid; + data.lastUpdated = new Date().toISOString(); + // Limit cache size to prevent unbounded growth + if (data.matches && data.matches.length > MAX_CACHED_MATCHES) { + data.matches = data.matches.slice(0, MAX_CACHED_MATCHES); + } + atomicWriteFileSync(cacheFile, JSON.stringify(data, null, 2)); + } catch (error) { + // Silent fail - caching is non-critical + } +}; + +const getParticipantRankCache = (puuid) => { + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'participant_ranks.json'); + return readCacheFile( + cacheFile, + { version: 1, ranks: {} }, + (data) => data.version === 1 && data.ranks && typeof data.ranks === 'object' + ); +}; + +const saveParticipantRankCache = (puuid, data) => { + try { + ensureCacheDir(puuid); + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'participant_ranks.json'); + data.version = 1; + atomicWriteFileSync(cacheFile, JSON.stringify(data, null, 2)); + } catch (error) { + // Silent fail - caching is non-critical + } +}; + +// Account data cache (summoner, ranked, masteries) for instant switching +const getAccountDataCache = (puuid) => { + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'account_data.json'); + return readCacheFile( + cacheFile, + null, + (data) => data.version === 1 && data.summoner + ); +}; + +const saveAccountDataCache = (puuid, data) => { + try { + ensureCacheDir(puuid); + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'account_data.json'); + const cacheData = { + version: 1, + summoner: data.summoner, + rankedData: data.rankedData, + topMasteries: data.topMasteries, + lastFetched: new Date().toISOString(), + }; + atomicWriteFileSync(cacheFile, JSON.stringify(cacheData, null, 2)); + } catch (error) { + // Silent fail - caching is non-critical + } +}; + +// Monitored accounts functions +const ensureLolCliDir = () => { + if (!fs.existsSync(LOL_CLI_DIR)) { + fs.mkdirSync(LOL_CLI_DIR, { recursive: true }); + } +}; + +const getMonitoredAccounts = () => { + const data = readCacheFile( + MONITORED_ACCOUNTS_FILE, + { version: 1, activeIndex: 0, accounts: [] }, + (d) => d.version === 1 && Array.isArray(d.accounts) + ); + // Clamp activeIndex to valid range (handles manual editing or account removal) + const originalIndex = data.activeIndex; + if (data.accounts.length === 0) { + data.activeIndex = 0; + } else if (data.activeIndex >= data.accounts.length) { + data.activeIndex = data.accounts.length - 1; + } else if (data.activeIndex < 0) { + data.activeIndex = 0; + } + // Persist corrected index to disk if it was clamped + if (data.activeIndex !== originalIndex) { + saveMonitoredAccounts(data); + } + return data; +}; + +const saveMonitoredAccounts = (data) => { + try { + ensureLolCliDir(); + data.version = 1; + atomicWriteFileSync(MONITORED_ACCOUNTS_FILE, JSON.stringify(data, null, 2)); + } catch (error) { + // Silent fail - non-critical + } +}; + +const addMonitoredAccount = (summoner, region, riotId) => { + const data = getMonitoredAccounts(); + const existingIndex = data.accounts.findIndex(a => a.puuid === summoner.puuid); + + const accountEntry = { + puuid: summoner.puuid, + riotId: riotId, + region: region, + summonerLevel: summoner.summonerLevel, + name: summoner.name, + addedAt: existingIndex >= 0 ? data.accounts[existingIndex].addedAt : new Date().toISOString(), + lastViewed: new Date().toISOString(), + }; + + if (existingIndex >= 0) { + // Update existing account + data.accounts[existingIndex] = accountEntry; + data.activeIndex = existingIndex; + } else { + // Add new account + data.accounts.push(accountEntry); + data.activeIndex = data.accounts.length - 1; + } + + saveMonitoredAccounts(data); + return data; +}; + +const removeMonitoredAccount = (puuid) => { + const data = getMonitoredAccounts(); + const index = data.accounts.findIndex(a => a.puuid === puuid); + + if (index >= 0) { + data.accounts.splice(index, 1); + // Adjust activeIndex if needed + if (data.accounts.length === 0) { + data.activeIndex = 0; + } else if (data.activeIndex >= data.accounts.length) { + data.activeIndex = data.accounts.length - 1; + } else if (data.activeIndex > index) { + data.activeIndex--; + } + saveMonitoredAccounts(data); + + // Clean up orphaned cache files for this account + try { + const accountDir = path.join(ACCOUNTS_DIR, puuid); + if (fs.existsSync(accountDir)) { + fs.rmSync(accountDir, { recursive: true }); + } + } catch (e) { + // Silent fail - cleanup is non-critical + } + } + + return data; +}; + +const setActiveAccountIndex = (index) => { + const data = getMonitoredAccounts(); + if (index >= 0 && index < data.accounts.length) { + data.activeIndex = index; + data.accounts[index].lastViewed = new Date().toISOString(); + saveMonitoredAccounts(data); + } + return data; +}; + +// Get the rankedOnly preference (global setting) +const getRankedOnlyPreference = () => { + const data = getMonitoredAccounts(); + return data.rankedOnly || false; +}; + +// Save the rankedOnly preference +const setRankedOnlyPreference = (value) => { + const data = getMonitoredAccounts(); + data.rankedOnly = value; + saveMonitoredAccounts(data); +}; + +// Time scope filter constants and helpers +const TIME_SCOPES = ['all', 'last10', 'daily', 'weekly']; + +const getTimeScopePreference = () => { + const data = getMonitoredAccounts(); + return TIME_SCOPES.includes(data.timeScope) ? data.timeScope : 'all'; +}; + +const setTimeScopePreference = (value) => { + const data = getMonitoredAccounts(); + data.timeScope = TIME_SCOPES.includes(value) ? value : 'all'; + saveMonitoredAccounts(data); +}; + +const cycleTimeScope = (current) => { + const idx = TIME_SCOPES.indexOf(current); + return TIME_SCOPES[(idx + 1) % TIME_SCOPES.length]; +}; + +// Load cached account data by riotId (for offline mode) +const loadCachedAccountByRiotId = (riotId, region) => { + const monitored = getMonitoredAccounts(); + const account = monitored.accounts.find( + a => a.riotId.toLowerCase() === riotId.toLowerCase() && a.region === region + ); + if (!account) return null; + + const accountData = getAccountDataCache(account.puuid); + // Account data is required for offline mode (contains summoner info) + if (!accountData) { + return null; + } + + // Match cache is optional - we can still show account info without matches + const matchCache = getMatchCache(account.puuid); + const matches = matchCache?.matches || []; + + return { + summoner: accountData.summoner, + matches: matches, + rankedData: accountData.rankedData, + topMasteries: accountData.topMasteries, + isOffline: true + }; +}; + +// Rank history functions for LP progress tracking +const getRankHistory = (puuid) => { + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'rank_history.json'); + return readCacheFile( + cacheFile, + { version: 1, puuid, snapshots: [] }, + (data) => data.version === 1 && data.puuid === puuid && Array.isArray(data.snapshots) + ); +}; + +const saveRankHistory = (puuid, data) => { + try { + ensureCacheDir(puuid); + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'rank_history.json'); + data.version = 1; + data.puuid = puuid; + atomicWriteFileSync(cacheFile, JSON.stringify(data, null, 2)); + } catch (error) { + // Silent fail - caching is non-critical + } +}; + +const addRankSnapshot = (puuid, rankedData) => { + if (!rankedData || !Array.isArray(rankedData)) return; + + const history = getRankHistory(puuid); + const now = new Date().toISOString(); + + for (const queue of rankedData) { + if (!queue.queueType || !queue.tier) continue; + + // Find the most recent snapshot for this queue type + const lastSnapshot = history.snapshots + .filter(s => s.queueType === queue.queueType) + .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp))[0]; + + // Only add snapshot if rank changed (tier, rank, or LP) + // Also prevent duplicate snapshots within 60 seconds (handles concurrent calls) + const recentThreshold = 60 * 1000; // 60 seconds + const isRecent = lastSnapshot && + (new Date(now).getTime() - new Date(lastSnapshot.timestamp).getTime()) < recentThreshold; + + const hasChanged = !lastSnapshot || + lastSnapshot.tier !== queue.tier || + lastSnapshot.rank !== queue.rank || + lastSnapshot.leaguePoints !== queue.leaguePoints; + + // Skip if identical data was added recently (deduplication for concurrent calls) + if (isRecent && !hasChanged) { + continue; + } + + if (hasChanged) { + history.snapshots.push({ + timestamp: now, + queueType: queue.queueType, + tier: queue.tier, + rank: queue.rank, + leaguePoints: queue.leaguePoints, + wins: queue.wins, + losses: queue.losses, + }); + } + } + + // Limit snapshot history to prevent unbounded growth + if (history.snapshots.length > MAX_RANK_SNAPSHOTS) { + // Sort by timestamp (oldest first) and keep only the most recent + history.snapshots.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); + history.snapshots = history.snapshots.slice(-MAX_RANK_SNAPSHOTS); + } + + saveRankHistory(puuid, history); +}; + +const getRankAtTime = (puuid, timestamp, queueType = 'RANKED_SOLO_5x5') => { + const history = getRankHistory(puuid); + if (!history.snapshots || history.snapshots.length === 0) return null; + + // Filter snapshots for the specified queue type + const queueSnapshots = history.snapshots + .filter(s => s.queueType === queueType) + .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); + + if (queueSnapshots.length === 0) return null; + + // Find the closest snapshot at or before the given timestamp + const targetTime = new Date(timestamp).getTime(); + let closestSnapshot = null; + + for (const snapshot of queueSnapshots) { + const snapshotTime = new Date(snapshot.timestamp).getTime(); + if (snapshotTime <= targetTime) { + closestSnapshot = snapshot; + } else { + break; + } + } + + // If no snapshot before this time, return the earliest snapshot + // (represents their rank before we started tracking) + if (!closestSnapshot && queueSnapshots.length > 0) { + closestSnapshot = queueSnapshots[0]; + } + + return closestSnapshot; +}; + +// OP.GG historical rank cache functions (no TTL - historical data is permanent) +const getOpggCache = (puuid) => { + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'opgg_ranks.json'); + return readCacheFile( + cacheFile, + null, + (data) => data.version === 1 // Only check version, no TTL for historical data + ); +}; + +const saveOpggCache = (puuid, data) => { + try { + ensureCacheDir(puuid); + const cacheFile = path.join(ACCOUNTS_DIR, puuid, 'opgg_ranks.json'); + const cacheData = { + version: 1, + puuid, + fetchedAt: new Date().toISOString(), + ...data + }; + atomicWriteFileSync(cacheFile, JSON.stringify(cacheData, null, 2)); + } catch (error) { + // Silent fail - caching is non-critical + } +}; + +// Launch League of Legends spectator mode +const launchSpectate = (gameId, encryptionKey, region) => { + // Validate inputs to prevent shell injection + // gameId should be numeric (can be large, so use string pattern) + if (!/^\d+$/.test(String(gameId))) { + throw new Error('Invalid gameId: must be numeric'); + } + // encryptionKey should be alphanumeric with possible special chars used by Riot + if (!/^[a-zA-Z0-9/+=]+$/.test(encryptionKey)) { + throw new Error('Invalid encryptionKey: contains invalid characters'); + } + // region should match known LoL regions + const validRegions = ['NA1', 'EUW1', 'EUN1', 'KR', 'JP1', 'BR1', 'LA1', 'LA2', 'OC1', 'RU', 'TR1']; + if (!validRegions.includes(region.toUpperCase())) { + throw new Error('Invalid region'); + } + + const server = `spectator.${region.toLowerCase()}.lol.pvp.net:8080`; + + if (process.platform === 'darwin') { + // macOS - use the OP.GG command structure + const cmd = `if test -d /Applications/League\\ of\\ Legends.app/Contents/LoL/Game/ ; then ` + + `cd /Applications/League\\ of\\ Legends.app/Contents/LoL/Game/ && ` + + `chmod +x ./LeagueofLegends.app/Contents/MacOS/LeagueofLegends ; else ` + + `cd /Applications/League\\ of\\ Legends.app/Contents/LoL/RADS/solutions/lol_game_client_sln/releases/ && ` + + `cd $(ls -1vr -d */ | head -1) && cd deploy && ` + + `chmod +x ./LeagueofLegends.app/Contents/MacOS/LeagueofLegends ; fi && ` + + `riot_launched=true ./LeagueofLegends.app/Contents/MacOS/LeagueofLegends ` + + `"spectator ${server} ${encryptionKey} ${gameId} ${region}" "-UseRads" "-GameBaseDir=.."`; + + require('child_process').exec(cmd, (error) => { + if (error) console.error('Failed to launch spectate:', error.message); + }); + } else if (process.platform === 'win32') { + // Windows + const cmd = `"C:\\Riot Games\\League of Legends\\Game\\League of Legends.exe" ` + + `"spectator ${server} ${encryptionKey} ${gameId} ${region}"`; + require('child_process').exec(cmd); + } }; module.exports = { + delay, getItemData, getChampionData, getSummonerSpellData, getRuneData, getQueueData, + getLastSearch, + saveLastSearch, + detectRegionFromRiotId, + getMatchCache, + saveMatchCache, + getParticipantRankCache, + saveParticipantRankCache, + getAccountDataCache, + saveAccountDataCache, + getMonitoredAccounts, + saveMonitoredAccounts, + addMonitoredAccount, + removeMonitoredAccount, + setActiveAccountIndex, + getRankedOnlyPreference, + setRankedOnlyPreference, + getTimeScopePreference, + setTimeScopePreference, + cycleTimeScope, + loadCachedAccountByRiotId, + launchSpectate, + getRankHistory, + saveRankHistory, + addRankSnapshot, + getRankAtTime, + getOpggCache, + saveOpggCache, };