function sameArrays(array1 : Array, array2 : Array) { if(array1.length != array2.length) return false; for(let e of array1) { if(!array2.includes(e)) return false; } return true; } import { MongoClient } from "mongodb"; import { ItemTree, treeInit, treeMerge, treeCutBranches, treeSort } 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) { let toRemove : Array<{count:number}> = [] for(let item of array) { if((item.count/totalGames) < percentage) { toRemove.push(item) } } for(let tr of toRemove) { array.splice(array.indexOf(tr), 1) } } type Rune = { count: number primaryStyle: number secondaryStyle: number selections: Array pickrate?: number }; type Builds = { tree: ItemTree start: Array<{data: number, count: number}> bootsFirst: number boots: Array<{data: number, count: number}> lateGame: Array<{data: number, count: number}> suppItems?: Array<{data: number, count: number}> } type Champion = { id: Number name: String alias: String } type LaneData = { data: string count: number winningMatches: number losingMatches: number winrate: number pickrate: number runes: Array builds: Builds } type ChampionData = { champion: Champion winningMatches: number losingMatches: number lanes: Array } function handleParticipantRunes(participant, runes: Array) { const primaryStyle = participant.perks.styles[0].style const secondaryStyle = participant.perks.styles[1].style const selections : Array = [] for(let style of participant.perks.styles) { for(let perk of style.selections) { selections.push(perk.perk) } } const gameRunes : Rune = {count:1, primaryStyle: primaryStyle, secondaryStyle: secondaryStyle, selections: selections}; let addRunes = true; for(let rune of runes) { if(rune.primaryStyle == gameRunes.primaryStyle && rune.secondaryStyle == gameRunes.secondaryStyle && sameArrays(rune.selections, gameRunes.selections)) { rune.count++; addRunes = false; break; } } if(addRunes) runes.push(gameRunes) } function handleMatchItems(timeline, participant: any, participantIndex : number, builds: Builds) { const items : Array = [] for(let frame of timeline.info.frames) { for(let 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; } let itemInfo = itemDict.get(event.itemId) // Handle bounty of worlds destroy as upgrade if(event.type == "ITEM_DESTROYED") { if(event.itemId == 3867) { let 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 = builds.suppItems.find((x) => x.data == suppItem) if(already == undefined) builds.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) { builds.bootsFirst += 1 } // Add to boots const already = builds.boots.find((x) => x.data == event.itemId) if(already == undefined) builds.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 if(itemInfo.to.length != 0 && items.length >= 1) continue; items.push(event.itemId) } } // Core items treeMerge(builds.tree, items.slice(1, 4)) // Start items if(items.length >= 1) { const already = builds.start.find((x) => x.data == items[0]) if(already == undefined) builds.start.push({count:1, data:items[0]}) else already.count += 1 } // Late game items for(let item of items.slice(3)) { const already = builds.lateGame.find((x) => x.data == item) if(already == undefined) builds.lateGame.push({count:1, data:item}) else already.count += 1 } } function handleMatch(match: any, champions : Map) { let participantIndex = 0; for(let 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) { const builds : Builds = {tree:treeInit(), start: [], bootsFirst: 0, boots: [], lateGame: [], suppItems: []} lane = {count:1, data: participant.teamPosition, runes:[], builds:builds, winningMatches: 0, losingMatches: 0, winrate: 0, pickrate: 0} champion.lanes.push(lane) } else lane.count += 1 // Winrate if(participant.win) { champion.winningMatches++; lane.winningMatches++; } else { champion.losingMatches++; lane.losingMatches++; } // Runes handleParticipantRunes(participant, lane.runes) // Items handleMatchItems(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 (let match of allMatches) { process.stdout.write("\rComputing champion stats, game entry " + currentMatch + "/" + totalMatches + " ... ") currentMatch += 1; handleMatch(match, champions) } return totalMatches } async function finalizeChampionStats(champion: ChampionData, totalMatches: number) { let totalChampionMatches = champion.winningMatches + champion.losingMatches; arrayRemovePercentage(champion.lanes, totalChampionMatches, 0.2) champion.lanes.sort((a, b) => b.count - a.count) // Filter runes to keep 3 most played for(let lane of champion.lanes) { const runes = lane.runes runes.sort((a, b) => b.count - a.count) if(runes.length > 3) runes.splice(3, runes.length - 3) // Compute runes pickrate for(let rune of runes) rune.pickrate = rune.count / lane.count; } for(let lane of champion.lanes) { const builds = lane.builds // Cut item tree branches to keep only 4 branches every time and with percentage threshold builds.tree.count = lane.count; treeCutBranches(builds.tree, 4, 0.05) treeSort(builds.tree) // Cut item start, to only 4 and with percentage threshold arrayRemovePercentage(builds.start, lane.count, 0.05) builds.start.sort((a, b) => b.count - a.count) if(builds.start.length > 4) builds.start.splice(4, builds.start.length - 4) // Remove boots that are not within percentage threshold arrayRemovePercentage(builds.boots, lane.count, 0.05) builds.boots.sort((a, b) => b.count - a.count) builds.bootsFirst /= lane.count // Cut supp items below 2 and percentage threshold arrayRemovePercentage(builds.suppItems, lane.count, 0.05) builds.suppItems.sort((a, b) => b.count - a.count) if(builds.suppItems.length > 2) builds.suppItems.splice(2, builds.suppItems.length - 2) // Delete supp items if empty if(builds.suppItems.length == 0) delete builds.suppItems builds.lateGame.sort((a, b) => b.count - a.count) } for(let lane of champion.lanes) { lane.winrate = lane.winningMatches / lane.count lane.pickrate = lane.count / totalMatches } 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) { var globalItems = await itemList() for(let 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(let 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(let 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}