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 } 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 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 } // eslint-disable-next-line @typescript-eslint/no-explicit-any function handleParticipantRunes(participant: any, runes: Array) { 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) } } const gameRunes: Rune = { count: 1, primaryStyle: primaryStyle, secondaryStyle: secondaryStyle, selections: selections } let addRunes = true for (const 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) } // eslint-disable-next-line @typescript-eslint/no-explicit-any function handleMatchItems( timeline: any, participant: any, participantIndex: number, builds: Builds ) { 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 = 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 (const 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 } } // 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) { 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 (const 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) { const 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 (const 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 (const rune of runes) rune.pickrate = rune.count / lane.count } for (const 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 (const 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) { 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 }