function sameArrays(array1: Array, array2: Array) { if (array1.length != array2.length) return false for (const e of array1) { if (!array2.includes(e)) return false } return true } import { MongoClient } from 'mongodb' import { ItemTree, treeInit, treeMerge, treeCutBranches, treeSort, treeMergeTree, areTreeSimilars } from './item_tree' const itemDict = new Map() async function itemList() { const response = await fetch( 'https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/items.json' ) const list = await response.json() return list } function arrayRemovePercentage( array: Array<{ count: number }>, totalGames: number, percentage: number ) { const toRemove: Array<{ count: number }> = [] for (const item of array) { if (item.count / totalGames < percentage) { toRemove.push(item) } } for (const tr of toRemove) { array.splice(array.indexOf(tr), 1) } } type Rune = { count: number primaryStyle: number secondaryStyle: number selections: Array pickrate?: number } type Build = { runeKeystone: number runes: Array items: ItemTree bootsFirstCount: number bootsFirst?: number count: number suppItems: Array<{ data: number; count: number }> boots: Array<{ data: number; count: number }> pickrate?: number } type BuildWithStartItems = { runeKeystone: number runes: Array items: ItemTree bootsFirst?: number bootsFirstCount: number count: number startItems: Array<{ data: number; count: number }> suppItems: Array<{ data: number; count: number }> boots: Array<{ data: number; count: number }> pickrate?: number } type Builds = Build[] type Champion = { id: number name: string alias: string } type MatchupData = { championId: number winrate: number games: number championName: string championAlias: string } type LaneData = { data: string count: number winningMatches: number losingMatches: number winrate: number pickrate: number builds: Builds matchups?: Array summonerSpells: Array<{ id: number; count: number; pickrate?: number }> } type ChampionData = { champion: Champion winningMatches: number losingMatches: number lanes: Array } // Helper function to create rune configuration from participant // eslint-disable-next-line @typescript-eslint/no-explicit-any function createRuneConfiguration(participant: any): Rune { const primaryStyle = participant.perks.styles[0].style const secondaryStyle = participant.perks.styles[1].style const selections: Array = [] for (const style of participant.perks.styles) { for (const perk of style.selections) { selections.push(perk.perk) } } return { count: 0, // Will be incremented when added to build primaryStyle: primaryStyle, secondaryStyle: secondaryStyle, selections: selections } } // Find or create a build for the given rune keystone // eslint-disable-next-line @typescript-eslint/no-explicit-any function findOrCreateBuild(builds: Builds, participant: any): Build { const keystone = participant.perks.styles[0].selections[0].perk const runeConfig = createRuneConfiguration(participant) // Try to find existing build with matching keystone const existingBuild = builds.find( build => build.runes[0].primaryStyle === runeConfig.primaryStyle && build.runeKeystone === keystone ) if (existingBuild) { // Check if this rune configuration already exists in the build const existingRune = existingBuild.runes.find(rune => sameArrays(rune.selections, runeConfig.selections) ) if (existingRune) { existingRune.count++ } else { existingBuild.runes.push({ ...runeConfig, count: 1 }) } return existingBuild } // Create new build for this keystone const newBuild: Build = { runeKeystone: keystone, runes: [{ ...runeConfig, count: 1 }], items: treeInit(), bootsFirstCount: 0, count: 0, suppItems: [], boots: [] } builds.push(newBuild) return newBuild } function handleMatchBuilds( // eslint-disable-next-line @typescript-eslint/no-explicit-any timeline: any, // eslint-disable-next-line @typescript-eslint/no-explicit-any participant: any, participantIndex: number, builds: Builds ) { // Find or create the build for this participant's rune configuration const build = findOrCreateBuild(builds, participant) build.count += 1 const items: Array = [] for (const frame of timeline.info.frames) { for (const event of frame.events) { if (event.participantId != participantIndex) continue if (event.type == 'ITEM_UNDO') { if (items.length > 0 && items[items.length - 1] == event.beforeId) { items.pop() } continue } const itemInfo = itemDict.get(event.itemId) // Handle bounty of worlds destroy as upgrade if (event.type == 'ITEM_DESTROYED') { if (event.itemId == 3867) { const suppItem: number = itemInfo.to.find( (x: number) => x == participant.item0 || x == participant.item1 || x == participant.item2 || x == participant.item3 || x == participant.item4 || x == participant.item5 || x == participant.item6 ) if (suppItem != undefined) { const already = build.suppItems.find(x => x.data == suppItem) if (already == undefined) build.suppItems.push({ count: 1, data: suppItem }) else already.count += 1 } } } if (event.type != 'ITEM_PURCHASED') continue // Handle boots upgrades if ( itemInfo.requiredBuffCurrencyName == 'Feats_NoxianBootPurchaseBuff' || itemInfo.requiredBuffCurrencyName == 'Feats_SpecialQuestBootBuff' ) { continue } // Handle boots differently if (itemInfo.categories.includes('Boots')) { if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) { // Check for bootsFirst if (items.length < 2) { build.bootsFirstCount += 1 } // Add to boots array const already = build.boots.find(x => x.data == event.itemId) if (already == undefined) build.boots.push({ count: 1, data: event.itemId }) else already.count += 1 } continue } // Check if item should be included if (itemInfo.categories.includes('Consumable')) continue if (itemInfo.categories.includes('Trinket')) continue // Ignore zephyr if (event.itemId == 3172) continue // Ignore Cull as not-first item if (event.itemId == 1083 && items.length >= 1) continue // Ignore non-final items, except when first item bought or support role if (itemInfo.to.length != 0 && (items.length >= 1 || participant.teamPosition === 'UTILITY')) continue items.push(event.itemId) } } // Merge the full item path into the build's item tree // This tree includes start item as the root, then branching paths if (items.length > 0) { treeMerge(build.items, items) } } // eslint-disable-next-line @typescript-eslint/no-explicit-any function handleMatch(match: any, champions: Map) { let participantIndex = 0 for (const participant of match.info.participants) { participantIndex += 1 const championId = participant.championId const champion = champions.get(championId) // Lanes let lane = champion.lanes.find(x => x.data == participant.teamPosition) if (lane == undefined) { lane = { count: 1, data: participant.teamPosition, builds: [], winningMatches: 0, losingMatches: 0, winrate: 0, pickrate: 0, summonerSpells: [], matchups: [] } champion.lanes.push(lane) } else lane.count += 1 // Initialize matchups if not present if (!lane.matchups) { lane.matchups = [] } // Winrate if (participant.win) { champion.winningMatches++ lane.winningMatches++ } else { champion.losingMatches++ lane.losingMatches++ } // Summoner spells let spell1 = lane.summonerSpells.find(x => x.id == participant.summoner1Id) if (spell1 == undefined) { spell1 = { id: participant.summoner1Id, count: 1, pickrate: undefined } lane.summonerSpells.push(spell1) } else spell1.count += 1 let spell2 = lane.summonerSpells.find(x => x.id == participant.summoner2Id) if (spell2 == undefined) { spell2 = { id: participant.summoner2Id, count: 1, pickrate: undefined } lane.summonerSpells.push(spell2) } else spell2.count += 1 // Track counter matchups - find opponent in same lane const opponentTeam = participant.teamId === 100 ? 200 : 100 const opponent = match.info.participants.find( // eslint-disable-next-line @typescript-eslint/no-explicit-any (p: any) => p.teamId === opponentTeam && p.teamPosition === participant.teamPosition ) if (opponent) { const opponentChampionId = opponent.championId // Track this matchup for current champion const matchup = lane.matchups.find(c => c.championId === opponentChampionId) if (matchup) { matchup.games += 1 if (participant.win) { matchup.winrate = (matchup.winrate * (matchup.games - 1) + 1) / matchup.games } else { matchup.winrate = (matchup.winrate * (matchup.games - 1)) / matchup.games } } else { const opponentChampion = champions.get(opponentChampionId) lane.matchups.push({ championId: opponentChampionId, winrate: participant.win ? 1 : 0, games: 1, championName: opponentChampion.champion.name, championAlias: opponentChampion.champion.alias }) } } // Items and runes (builds) handleMatchBuilds(match.timeline, participant, participantIndex, lane.builds) } } async function handleMatchList( client: MongoClient, patch: string, champions: Map ) { const database = client.db('matches') const matches = database.collection(patch) const allMatches = matches.find() const totalMatches: number = await matches.countDocuments() let currentMatch = 0 for await (const match of allMatches) { process.stdout.write( '\rComputing champion stats, game entry ' + currentMatch + '/' + totalMatches + ' ... ' ) currentMatch += 1 handleMatch(match, champions) } return totalMatches } // Split or merge a build/buildtree on starter items // If starter items have a rest-of-tree that is too different, we split // into two variants. // Otherwise, we merge into a ProcessedBuild that has a tree without starters function splitMergeOnStarterItem(build: Build, championName: string): BuildWithStartItems[] { if (build.items.children.length > 2) { console.log( `Warning: We have more than 2 starter items for champion ${championName}. Current algorithm won't work.` ) } if ( build.items.children.length <= 1 || areTreeSimilars(build.items.children[0], build.items.children[1]) >= 0.5 ) { const startItems = [] let items = build.items.children[0] startItems.push({ data: build.items.children[0].data, count: build.items.children[0].count }) build.items.children[0].data = undefined if (build.items.children.length > 1) { startItems.push({ data: build.items.children[1].data, count: build.items.children[1].count }) build.items.children[1].data = undefined items = treeMergeTree(build.items.children[0], build.items.children[1]) } return [ { runeKeystone: build.runeKeystone, runes: build.runes, items, bootsFirstCount: build.bootsFirstCount, count: build.count, startItems, suppItems: build.suppItems, boots: build.boots, pickrate: build.pickrate } ] } else { // Trees are different. We separate into two build variants console.log(`Warning: for champion ${championName}, start item splits build variant.`) const builds = [] for (const c of build.items.children) { builds.push({ runeKeystone: build.runeKeystone, runes: build.runes, items: c, bootsFirstCount: build.bootsFirstCount, count: c.count, startItems: [{ data: c.data, count: c.count }], suppItems: build.suppItems, boots: build.boots }) c.data = undefined } return builds } } // Helper function to merge item counts with same data function mergeItemCounts( builds: BuildWithStartItems[], itemsGetter: (build: BuildWithStartItems) => Array<{ data: number; count: number }> ): Array<{ data: number; count: number }> { const countsMap = new Map() for (const build of builds) { const items = itemsGetter(build) if (!items) continue for (const item of items) { const existing = countsMap.get(item.data) if (existing !== undefined) { countsMap.set(item.data, existing + item.count) } else { countsMap.set(item.data, item.count) } } } return Array.from(countsMap.entries()).map(([data, count]) => ({ data, count })) } // Merge different builds that have the same items (item trees similar) but different // runes (primary style and keystones) function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems[] { const merged: BuildWithStartItems[] = [] const processed = new Set() const sortedBuilds = [...builds].sort((a, b) => b.count - a.count) for (let i = 0; i < sortedBuilds.length; i++) { if (processed.has(i)) continue const currentBuild = sortedBuilds[i] processed.add(i) // Find all builds with similar item trees const similarBuildsIndices: number[] = [] for (let j = i + 1; j < sortedBuilds.length; j++) { if (processed.has(j)) continue const otherBuild = sortedBuilds[j] if (areTreeSimilars(currentBuild.items, otherBuild.items) >= 0.5) { similarBuildsIndices.push(j) processed.add(j) } } // If no similar builds found, just add the current build as-is if (similarBuildsIndices.length === 0) { merged.push(currentBuild) continue } // Merge all similar builds const allSimilarBuilds = [currentBuild, ...similarBuildsIndices.map(idx => sortedBuilds[idx])] const totalCount = allSimilarBuilds.reduce((sum, b) => sum + b.count, 0) // Merge runes - combine all unique rune configurations const runesMap = new Map() for (const build of allSimilarBuilds) { for (const rune of build.runes) { const key = `${rune.primaryStyle}-${rune.selections.join('-')}` const existing = runesMap.get(key) if (existing) { existing.count += rune.count } else { runesMap.set(key, { ...rune }) } } } const runes = Array.from(runesMap.values()) runes.sort((a, b) => b.count - a.count) merged.push({ runeKeystone: runes[0].selections[0], runes: runes, items: currentBuild.items, bootsFirstCount: allSimilarBuilds.reduce((sum, b) => sum + b.bootsFirstCount, 0), count: totalCount, startItems: mergeItemCounts(allSimilarBuilds, b => b.startItems), suppItems: mergeItemCounts(allSimilarBuilds, b => b.suppItems), boots: mergeItemCounts(allSimilarBuilds, b => b.boots) }) } return merged } function cleanupLaneBuilds(lane: LaneData) { // Filter builds to remove variants that are not played enough lane.builds = lane.builds.filter(build => build.count / lane.count >= 0.05) const builds = lane.builds // Sort builds by count builds.sort((a, b) => b.count - a.count) // For each build: prune item tree, clean up boots, calculate percentages for (const build of builds) { // Cut item tree branches to keep only 4 branches every time and with percentage threshold build.items.count = build.count treeCutBranches(build.items, 4, 0.05) treeSort(build.items) // Remove boots that are not within percentage threshold arrayRemovePercentage(build.boots, build.count, 0.05) build.boots.sort((a, b) => b.count - a.count) // Remove support items that are not within percentage threshold arrayRemovePercentage(build.suppItems, build.count, 0.05) build.suppItems.sort((a, b) => b.count - a.count) // Calculate bootsFirst percentage build.bootsFirst = build.bootsFirstCount / build.count // Compute runes pickrate, and filter out to keep only top 3 build.runes.forEach(rune => (rune.pickrate = rune.count / build.count)) build.runes.sort((a, b) => b.count - a.count) if (build.runes.length > 3) build.runes.splice(3, build.runes.length - 3) build.pickrate = build.count / lane.count } } async function finalizeChampionStats(champion: ChampionData, totalMatches: number) { const totalChampionMatches = champion.winningMatches + champion.losingMatches arrayRemovePercentage(champion.lanes, totalChampionMatches, 0.2) champion.lanes.sort((a, b) => b.count - a.count) for (const lane of champion.lanes) { // Summoner spells lane.summonerSpells.forEach(x => (x.pickrate = x.count / lane.count)) lane.summonerSpells.sort((a, b) => b.count - a.count) lane.summonerSpells = lane.summonerSpells.filter(x => x.pickrate >= 0.05) // Cleaning up builds cleanupLaneBuilds(lane) // Now, second stage: clustering and de-clustering // First, we split the builds on starter items, to obtain a BuildWithStartItems. if (lane.data != 'UTILITY') { const newBuilds: BuildWithStartItems[] = [] for (const build of lane.builds) { newBuilds.push(...splitMergeOnStarterItem(build, champion.champion.name)) } lane.builds = newBuilds cleanupLaneBuilds(lane) } // Finally, we merge the builds that are similar but have different keystones. // Now that we split everything that needed to be split, we are sure that we don't need // to have the data per-keystone. We can just merge them back, as it was the same build // all along. lane.builds = mergeBuildsForRunes(lane.builds as BuildWithStartItems[]) cleanupLaneBuilds(lane) } for (const lane of champion.lanes) { lane.winrate = lane.winningMatches / lane.count lane.pickrate = lane.count / totalMatches } // Sort matchups by score (games * winrate) in descending order for (const lane of champion.lanes) { if (lane.matchups && lane.matchups.length > 0) { // Filter out matchups with insufficient games (minimum 5 games) const filteredMatchups = lane.matchups.filter(m => m.games >= 5) // Sort by score (games * (winrate - 0.5)^2) descending filteredMatchups.sort((a, b) => { // Handle special case of exactly 50% winrate if (a.winrate === 0.5 && b.winrate === 0.5) { // Both have 50% winrate, sort by games (more games first) return b.games - a.games } if (a.winrate === 0.5 || b.winrate === 0.5) { // a has 50% winrate, b doesn't - b comes first return b.winrate - a.winrate } if (a.winrate > 0.5 && b.winrate < 0.5) return -1 if (a.winrate < 0.5 && b.winrate > 0.5) return 1 if (a.winrate > 0.5) { return b.games * (b.winrate - 0.5) ** 2 - a.games * (a.winrate - 0.5) ** 2 } else { return -1 * b.games * (0.5 - b.winrate) ** 2 - -1 * a.games * (0.5 - a.winrate) ** 2 } }) // Limit to top matchups (or keep all if we want comprehensive data) lane.matchups = filteredMatchups } } return { name: champion.champion.name, alias: champion.champion.alias.toLowerCase(), id: champion.champion.id, lanes: champion.lanes, winrate: champion.winningMatches / totalChampionMatches, gameCount: totalChampionMatches, pickrate: totalChampionMatches / totalMatches } } async function championList() { const response = await fetch( 'https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json' ) const list = await response.json() return list.slice(1) } async function makeChampionsStats(client: MongoClient, patch: string) { const globalItems = await itemList() for (const item of globalItems) { itemDict.set(item.id, item) } const list = await championList() console.log('Generating stats for ' + list.length + ' champions') // Pre-generate list of champions const champions: Map = new Map() for (const champion of list) { champions.set(champion.id, { champion: { id: champion.id, name: champion.name, alias: champion.alias }, winningMatches: 0, losingMatches: 0, lanes: [] }) } // Loop through all matches to generate stats const totalMatches = await handleMatchList(client, patch, champions) // Finalize and save stats for every champion const database = client.db('champions') const collection = database.collection(patch) for (const champion of list) { const championInfo = await finalizeChampionStats(champions.get(champion.id), totalMatches) await collection.updateOne({ id: champion.id }, { $set: championInfo }, { upsert: true }) } // Create alias-index for better key-find await collection.createIndex({ alias: 1 }) } export default { makeChampionsStats }