383 lines
11 KiB
TypeScript
383 lines
11 KiB
TypeScript
function sameArrays(array1: Array<number>, array2: Array<number>) {
|
|
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<number>
|
|
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<Rune>
|
|
builds: Builds
|
|
}
|
|
type ChampionData = {
|
|
champion: Champion
|
|
winningMatches: number
|
|
losingMatches: number
|
|
lanes: Array<LaneData>
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function handleParticipantRunes(participant: any, runes: Array<Rune>) {
|
|
const primaryStyle = participant.perks.styles[0].style
|
|
const secondaryStyle = participant.perks.styles[1].style
|
|
const selections: Array<number> = []
|
|
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<number> = []
|
|
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<number, ChampionData>) {
|
|
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<number, ChampionData>
|
|
) {
|
|
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<number, ChampionData> = 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 }
|