refactor/match_collector: change folder structure
This commit is contained in:
125
match_collector/src/api.ts
Normal file
125
match_collector/src/api.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
type Match = {
|
||||
metadata: {
|
||||
dataVersion: string
|
||||
matchId: string
|
||||
participants: string[]
|
||||
}
|
||||
info: {
|
||||
endOfGameResult: string
|
||||
frameInterval: number
|
||||
gameId: number
|
||||
participants: Participant[]
|
||||
teams: Team[]
|
||||
}
|
||||
timeline: Timeline
|
||||
}
|
||||
|
||||
type Timeline = {
|
||||
metadata: {
|
||||
dataVersion: string
|
||||
matchId: string
|
||||
participants: string[]
|
||||
}
|
||||
info: {
|
||||
endOfGameResult: string
|
||||
frameInterval: number
|
||||
gameId: number
|
||||
participants: {
|
||||
participantId: number
|
||||
puuid: string
|
||||
}[]
|
||||
frames: Frame[]
|
||||
}
|
||||
}
|
||||
|
||||
type Team = {
|
||||
bans: Ban[]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
objectives: any
|
||||
teamId: number
|
||||
win: boolean
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Ban = any
|
||||
|
||||
type Participant = {
|
||||
allInPing: number
|
||||
assistMePings: number
|
||||
assists: number
|
||||
baronKills: number
|
||||
bountyLevel: number
|
||||
champExperience: number
|
||||
champLevel: number
|
||||
championId: number
|
||||
championName: string
|
||||
commandPings: number
|
||||
championTransform: number
|
||||
consumablesPurchased: number
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
challenges: any
|
||||
damageDealtToBuildings: number
|
||||
deaths: number
|
||||
item0: number
|
||||
item1: number
|
||||
item2: number
|
||||
item3: number
|
||||
item4: number
|
||||
item5: number
|
||||
item6: number
|
||||
itemsPurchased: number
|
||||
kills: number
|
||||
lane: string
|
||||
participantId: number
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
perks: any
|
||||
puuid: string
|
||||
summoner1Id: number
|
||||
summoner2Id: number
|
||||
summonerId: string
|
||||
teamId: number
|
||||
teamPosition: string
|
||||
win: boolean
|
||||
}
|
||||
|
||||
type Frame = {
|
||||
events: Event[]
|
||||
participantFrames: {
|
||||
'1': ParticipantFrame
|
||||
'2': ParticipantFrame
|
||||
'3': ParticipantFrame
|
||||
'4': ParticipantFrame
|
||||
'5': ParticipantFrame
|
||||
'6': ParticipantFrame
|
||||
'7': ParticipantFrame
|
||||
'8': ParticipantFrame
|
||||
'9': ParticipantFrame
|
||||
'10': ParticipantFrame
|
||||
}
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
type ParticipantFrame = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
championStats: any
|
||||
currentGold: number
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
damageStats: any
|
||||
goldPerSecond: number
|
||||
jungleMinionsKilled: number
|
||||
level: number
|
||||
minionsKilled: number
|
||||
participantId: number
|
||||
position: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
timeEnemySpentControlled: number
|
||||
totalGold: number
|
||||
xp: number
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type Event = any
|
||||
|
||||
export { Match, Timeline, Team, Ban, Participant, Frame, Event }
|
||||
764
match_collector/src/champion_stat.ts
Normal file
764
match_collector/src/champion_stat.ts
Normal file
@@ -0,0 +1,764 @@
|
||||
import { MongoClient } from 'mongodb'
|
||||
import {
|
||||
ItemTree,
|
||||
GoldAdvantageTag,
|
||||
PlatformCounts,
|
||||
treeInit,
|
||||
treeMerge,
|
||||
treeCutBranches,
|
||||
treeSort,
|
||||
treeMergeTree,
|
||||
areTreeSimilars,
|
||||
treeDeriveTags
|
||||
} from './item_tree'
|
||||
import { PLATFORM_KEYS } from './platform'
|
||||
|
||||
import { Match, Timeline, Participant, Frame } from './api'
|
||||
|
||||
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
|
||||
}
|
||||
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 Build = {
|
||||
runeKeystone: number
|
||||
runes: Array<Rune>
|
||||
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<Rune>
|
||||
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<MatchupData>
|
||||
summonerSpells: Array<{ id: number; count: number; pickrate?: number }>
|
||||
// Region distribution for this lane (used for tag derivation)
|
||||
regionDistribution?: PlatformCounts
|
||||
}
|
||||
type ChampionData = {
|
||||
champion: Champion
|
||||
winningMatches: number
|
||||
losingMatches: number
|
||||
lanes: Array<LaneData>
|
||||
}
|
||||
|
||||
// Helper function to create rune configuration from participant
|
||||
function createRuneConfiguration(participant: Participant): 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)
|
||||
}
|
||||
}
|
||||
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
|
||||
function findOrCreateBuild(builds: Builds, participant: Participant): 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
|
||||
}
|
||||
|
||||
// Calculate gold advantage at the time of item purchase
|
||||
// Returns 'ahead', 'behind', or 'even' based on gold difference
|
||||
function calculateGoldAdvantage(
|
||||
match: Match,
|
||||
frame: Frame,
|
||||
participantIndex: number
|
||||
): GoldAdvantageTag {
|
||||
const GOLD_THRESHOLD = 1000 // 1000 gold difference threshold
|
||||
|
||||
const participantFrames = [
|
||||
frame.participantFrames[1],
|
||||
frame.participantFrames[2],
|
||||
frame.participantFrames[3],
|
||||
frame.participantFrames[4],
|
||||
frame.participantFrames[5],
|
||||
frame.participantFrames[6],
|
||||
frame.participantFrames[7],
|
||||
frame.participantFrames[8],
|
||||
frame.participantFrames[9],
|
||||
frame.participantFrames[10]
|
||||
]
|
||||
|
||||
// Find the participant's team
|
||||
const participantFrame = participantFrames[participantIndex - 1]
|
||||
const participantGold = participantFrame.totalGold
|
||||
if (!participantFrame) return 'even'
|
||||
|
||||
const participant = match.info.participants.find(
|
||||
x => x.participantId == participantFrame.participantId
|
||||
)!
|
||||
|
||||
const opponent = match.info.participants.find(
|
||||
x => x.teamPosition === participant.teamPosition && x.teamId != participant.teamId
|
||||
)
|
||||
if (opponent == undefined) return 'even'
|
||||
|
||||
const opponentGold = participantFrames.find(
|
||||
x => x.participantId == opponent.participantId
|
||||
)!.totalGold
|
||||
|
||||
const goldDiff = participantGold - opponentGold
|
||||
|
||||
if (goldDiff >= GOLD_THRESHOLD) return 'ahead'
|
||||
if (goldDiff <= -GOLD_THRESHOLD) return 'behind'
|
||||
return 'even'
|
||||
}
|
||||
|
||||
function handleMatchBuilds(
|
||||
match: Match,
|
||||
participant: Participant,
|
||||
participantIndex: number,
|
||||
builds: Builds,
|
||||
platform?: string
|
||||
) {
|
||||
const timeline: Timeline = match.timeline
|
||||
|
||||
// Find or create the build for this participant's rune configuration
|
||||
const build = findOrCreateBuild(builds, participant)
|
||||
build.count += 1
|
||||
|
||||
const items: Array<{ itemId: number; goldAdvantage: GoldAdvantageTag; platform?: string }> = []
|
||||
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].itemId == 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
|
||||
|
||||
// Calculate gold advantage at time of purchase
|
||||
const goldAdvantage = calculateGoldAdvantage(match, frame, participantIndex)
|
||||
items.push({ itemId: event.itemId, goldAdvantage, platform })
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
function handleMatch(match: Match, champions: Map<number, ChampionData>, platform?: string) {
|
||||
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: [],
|
||||
regionDistribution: { euw: 0, eun: 0, na: 0, kr: 0 }
|
||||
}
|
||||
champion.lanes.push(lane)
|
||||
} else lane.count += 1
|
||||
|
||||
// Track region distribution for this lane
|
||||
if (lane.regionDistribution && platform) {
|
||||
const platformKey = platform.toLowerCase()
|
||||
const regionKey = PLATFORM_KEYS[platformKey]
|
||||
if (regionKey) {
|
||||
lane.regionDistribution[regionKey]!++
|
||||
}
|
||||
}
|
||||
|
||||
// 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, participant, participantIndex, lane.builds, platform)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMatchList(
|
||||
client: MongoClient,
|
||||
patch: string,
|
||||
champions: Map<number, ChampionData>,
|
||||
platform?: string
|
||||
) {
|
||||
const database = client.db('matches')
|
||||
const collectionName = platform ? `${patch}_${platform}` : patch
|
||||
const matches = database.collection(collectionName)
|
||||
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 as unknown as Match, champions, platform)
|
||||
}
|
||||
|
||||
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<number, number>()
|
||||
|
||||
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<number>()
|
||||
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<string, Rune>()
|
||||
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)
|
||||
|
||||
// Derive tags from purchase patterns (gold advantage, region)
|
||||
treeDeriveTags(build.items, lane.regionDistribution)
|
||||
|
||||
// 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, platforms: 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 (shared across all platforms)
|
||||
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: []
|
||||
})
|
||||
}
|
||||
|
||||
// Process matches from all platforms, merging into the same champions map
|
||||
let totalMatches = 0
|
||||
for (const platform of platforms) {
|
||||
console.log(`\n=== Processing matches from platform: ${platform} ===`)
|
||||
const platformMatches = await handleMatchList(client, patch, champions, platform)
|
||||
totalMatches += platformMatches
|
||||
console.log(`Processed ${platformMatches} matches from ${platform}`)
|
||||
}
|
||||
|
||||
console.log(`\n=== Total matches processed: ${totalMatches} ===`)
|
||||
|
||||
// Finalize and save stats to a single champions collection
|
||||
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 })
|
||||
console.log(`Stats saved to collection: ${patch}`)
|
||||
}
|
||||
|
||||
export default { makeChampionsStats }
|
||||
246
match_collector/src/index.ts
Normal file
246
match_collector/src/index.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
const api_key = process.env.RIOT_API_KEY
|
||||
const sleep_minutes = 12
|
||||
|
||||
import { MongoClient } from 'mongodb'
|
||||
|
||||
import champion_stat from './champion_stat'
|
||||
import { Match } from './api'
|
||||
import { PLATFORMS, getPlatformBaseUrl, getRegionalBaseUrl, getRegionForPlatform } from './platform'
|
||||
|
||||
main()
|
||||
|
||||
async function main() {
|
||||
// Check if we're in development mode with pre-loaded data
|
||||
if (process.env.NODE_ENV === 'development' && process.env.USE_IMPORTED_DATA === 'true') {
|
||||
console.log('MatchCollector - Development mode with pre-loaded data')
|
||||
await runWithPreloadedData()
|
||||
return
|
||||
}
|
||||
|
||||
// Original production mode
|
||||
console.log('MatchCollector - Hello !')
|
||||
const client = await connectToDatabase()
|
||||
const [latestPatch, latestPatchTime] = await fetchLatestPatchDate(client)
|
||||
console.log(
|
||||
'Connected to database, latest patch ' + latestPatch + ' was epoch: ' + latestPatchTime
|
||||
)
|
||||
|
||||
console.log('Using RIOT_API_KEY: ' + api_key)
|
||||
if (api_key != null && api_key != undefined && api_key != '') {
|
||||
// Iterate through all platforms
|
||||
for (const [platform, region] of Object.entries(PLATFORMS)) {
|
||||
console.log(`\n=== Processing platform: ${platform} (region: ${region}) ===`)
|
||||
|
||||
const alreadySeenGameList = await alreadySeenGames(client, latestPatch, platform)
|
||||
console.log(
|
||||
'We already have ' + alreadySeenGameList.length + ' matches for this patch/platform !'
|
||||
)
|
||||
|
||||
const challengerLeague = await fetchChallengerLeague(platform)
|
||||
console.log('ChallengerLeague: got ' + challengerLeague.entries.length + ' entries')
|
||||
|
||||
const gameList: string[] = []
|
||||
let i = 0
|
||||
for (const challenger of challengerLeague.entries) {
|
||||
console.log('Entry ' + i + '/' + challengerLeague.entries.length + ' ...')
|
||||
const puuid = challenger.puuid
|
||||
const challengerGameList = await summonerGameList(puuid, latestPatchTime, region)
|
||||
for (const game of challengerGameList) {
|
||||
if (!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
|
||||
gameList.push(game)
|
||||
}
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
console.log('Games: got ' + gameList.length + ' entries for ' + platform)
|
||||
i = 0
|
||||
for (const game of gameList) {
|
||||
console.log('Entry ' + i + '/' + gameList.length + ' ...')
|
||||
// Determine region from matchId (format: PLATFORM_matchId)
|
||||
// Map platform prefix to regional routing value for match API
|
||||
const matchPlatformPrefix = game.split('_')[0]
|
||||
const matchRegion = getRegionForPlatform(matchPlatformPrefix) || region
|
||||
const gameMatch = await match(game, matchRegion)
|
||||
const gameTimeline = await matchTimeline(game, matchRegion)
|
||||
gameMatch.timeline = gameTimeline
|
||||
await saveMatch(client, gameMatch, latestPatch, platform)
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Generating stats...')
|
||||
await champion_stat.makeChampionsStats(client, latestPatch, Object.keys(PLATFORMS))
|
||||
|
||||
console.log('All done. Closing client.')
|
||||
await client.close()
|
||||
}
|
||||
|
||||
async function handleRateLimit(url: URL): Promise<Response> {
|
||||
let response = await fetch(url)
|
||||
if (response.status == 429) {
|
||||
await new Promise(resolve => setTimeout(resolve, sleep_minutes * 60 * 1000))
|
||||
response = await handleRateLimit(url)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
function handleError(response: Response) {
|
||||
if (!response.ok) {
|
||||
console.log(
|
||||
'Error during fetch(' +
|
||||
response.url +
|
||||
'): STATUS ' +
|
||||
response.status +
|
||||
' (' +
|
||||
response.statusText +
|
||||
')'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
async function connectToDatabase() {
|
||||
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
|
||||
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
|
||||
if (
|
||||
process.env.MONGO_URI != undefined &&
|
||||
process.env.MONGO_URI != null &&
|
||||
process.env.MONGO_URI != ''
|
||||
) {
|
||||
uri = process.env.MONGO_URI
|
||||
}
|
||||
const client = new MongoClient(uri)
|
||||
await client.connect()
|
||||
return client
|
||||
}
|
||||
|
||||
async function fetchLatestPatchDate(client: MongoClient) {
|
||||
const database = client.db('patches')
|
||||
const patches = database.collection('patches')
|
||||
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
|
||||
return [latestPatch!.patch, Math.floor(latestPatch!.date.valueOf() / 1000)]
|
||||
}
|
||||
|
||||
async function fetchChallengerLeague(platform: string) {
|
||||
const queue = 'RANKED_SOLO_5x5'
|
||||
const endpoint = `/lol/league/v4/challengerleagues/by-queue/${queue}`
|
||||
const baseUrl = getPlatformBaseUrl(platform)
|
||||
const url = `${baseUrl}${endpoint}?api_key=${api_key}`
|
||||
|
||||
const challengerLeagueResponse = await handleRateLimit(new URL(url))
|
||||
|
||||
handleError(challengerLeagueResponse)
|
||||
|
||||
const challengerLeague = await challengerLeagueResponse.json()
|
||||
return challengerLeague
|
||||
}
|
||||
|
||||
async function summonerGameList(puuid: string, startTime: string, region: string) {
|
||||
const baseUrl = getRegionalBaseUrl(region)
|
||||
const endpoint = `/lol/match/v5/matches/by-puuid/${puuid}/ids`
|
||||
const url = `${baseUrl}${endpoint}?queue=420&type=ranked&startTime=${startTime}&api_key=${api_key}`
|
||||
|
||||
const gameListResponse = await handleRateLimit(new URL(url))
|
||||
handleError(gameListResponse)
|
||||
const gameList = await gameListResponse.json()
|
||||
|
||||
return gameList
|
||||
}
|
||||
|
||||
async function match(matchId: string, region: string) {
|
||||
const baseUrl = getRegionalBaseUrl(region)
|
||||
const endpoint = `/lol/match/v5/matches/${matchId}`
|
||||
const url = `${baseUrl}${endpoint}?api_key=${api_key}`
|
||||
|
||||
const matchResponse = await handleRateLimit(new URL(url))
|
||||
handleError(matchResponse)
|
||||
const match = await matchResponse.json()
|
||||
|
||||
return match
|
||||
}
|
||||
|
||||
async function matchTimeline(matchId: string, region: string) {
|
||||
const baseUrl = getRegionalBaseUrl(region)
|
||||
const endpoint = `/lol/match/v5/matches/${matchId}/timeline`
|
||||
const url = `${baseUrl}${endpoint}?api_key=${api_key}`
|
||||
|
||||
const timelineResponse = await handleRateLimit(new URL(url))
|
||||
handleError(timelineResponse)
|
||||
const timeline = await timelineResponse.json()
|
||||
|
||||
return timeline
|
||||
}
|
||||
|
||||
async function alreadySeenGames(client: MongoClient, latestPatch: string, platform: string) {
|
||||
const database = client.db('matches')
|
||||
const collectionName = `${latestPatch}_${platform}`
|
||||
const matches = database.collection(collectionName)
|
||||
|
||||
const alreadySeen = await matches.distinct('metadata.matchId')
|
||||
return alreadySeen
|
||||
}
|
||||
|
||||
async function saveMatch(client: MongoClient, match: Match, latestPatch: string, platform: string) {
|
||||
const database = client.db('matches')
|
||||
const collectionName = `${latestPatch}_${platform}`
|
||||
const matches = database.collection(collectionName)
|
||||
await matches.insertOne(match)
|
||||
}
|
||||
|
||||
/**
|
||||
* Development mode function that generates stats from pre-loaded data
|
||||
*/
|
||||
async function runWithPreloadedData() {
|
||||
console.log('Using pre-loaded match data for development')
|
||||
|
||||
const client = await connectToDatabase()
|
||||
try {
|
||||
const [latestPatch] = await fetchLatestPatchDate(client)
|
||||
console.log(`Latest patch: ${latestPatch}`)
|
||||
|
||||
// Check if we have matches for this patch (including platform-specific collections)
|
||||
const matchesDb = client.db('matches')
|
||||
const collections = await matchesDb.listCollections().toArray()
|
||||
const collectionNames = collections.map(c => c.name)
|
||||
|
||||
// Find collections for this patch (both global and platform-specific)
|
||||
const patchCollections = collectionNames.filter(
|
||||
name => name === latestPatch || name.startsWith(`${latestPatch}_`)
|
||||
)
|
||||
|
||||
if (patchCollections.length === 0) {
|
||||
console.error(`❌ No match data found for patch ${latestPatch}`)
|
||||
console.log('💡 Please run the data import script first:')
|
||||
console.log(' node dev/scripts/setup-db.js')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Found ${patchCollections.length} match collection(s): ${patchCollections.join(', ')}`
|
||||
)
|
||||
|
||||
// Extract platforms from collection names (e.g., "15.1_EUW1" -> "EUW1")
|
||||
const platforms = patchCollections
|
||||
.filter(name => name.startsWith(`${latestPatch}_`))
|
||||
.map(name => name.replace(`${latestPatch}_`, ''))
|
||||
|
||||
// Generate stats for each platform
|
||||
if (platforms.length > 0) {
|
||||
await champion_stat.makeChampionsStats(client, latestPatch, platforms)
|
||||
} else {
|
||||
// Fallback for old-style collections without platform suffix
|
||||
await champion_stat.makeChampionsStats(client, latestPatch)
|
||||
}
|
||||
|
||||
console.log('🎉 All stats generated successfully!')
|
||||
console.log('🚀 Your development database is ready for frontend testing!')
|
||||
} catch (error) {
|
||||
console.error('❌ Error in development mode:', error)
|
||||
throw error
|
||||
} finally {
|
||||
await client.close()
|
||||
}
|
||||
}
|
||||
344
match_collector/src/item_tree.ts
Normal file
344
match_collector/src/item_tree.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import {
|
||||
REGION_KEYS,
|
||||
initPlatformCounts,
|
||||
mergePlatformCounts,
|
||||
singlePlatformCount
|
||||
} from './platform'
|
||||
|
||||
import type { PlatformCounts } from './platform'
|
||||
|
||||
type GoldAdvantageTag = 'ahead' | 'behind' | 'even'
|
||||
|
||||
// Item tags that can be derived from purchase patterns
|
||||
type ItemTag = 'ahead' | 'behind' | 'region_euw' | 'region_eun' | 'region_na' | 'region_kr'
|
||||
|
||||
type ItemTree = {
|
||||
data: number | undefined
|
||||
count: number
|
||||
children: Array<ItemTree>
|
||||
|
||||
// Gold advantage tracking
|
||||
boughtWhen: {
|
||||
aheadCount: number
|
||||
behindCount: number
|
||||
evenCount: number
|
||||
meanGold: number
|
||||
}
|
||||
|
||||
// Platform tracking
|
||||
platformCount: PlatformCounts
|
||||
|
||||
// Derived tags for display
|
||||
tags: Array<ItemTag>
|
||||
}
|
||||
|
||||
function treeInit(): ItemTree {
|
||||
return {
|
||||
data: undefined,
|
||||
count: 0,
|
||||
children: [],
|
||||
boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 },
|
||||
platformCount: initPlatformCounts(),
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Merge a node with an item tree
|
||||
*/
|
||||
function nodeMerge(itemtree: ItemTree, node: ItemTree) {
|
||||
const item = node.data
|
||||
const count = node.count
|
||||
let next: ItemTree | null = null
|
||||
|
||||
// Try to find an existing node in this tree level with same item
|
||||
for (const child of itemtree.children) {
|
||||
if (child.data == item) {
|
||||
child.count += 1
|
||||
|
||||
child.boughtWhen.aheadCount += node.boughtWhen.aheadCount
|
||||
child.boughtWhen.evenCount += node.boughtWhen.evenCount
|
||||
child.boughtWhen.behindCount += node.boughtWhen.behindCount
|
||||
|
||||
// Merge platform counts
|
||||
mergePlatformCounts(child.platformCount, node.platformCount)
|
||||
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If not found, add item node at this level
|
||||
if (next == null && item !== undefined) {
|
||||
next = {
|
||||
data: item,
|
||||
count: count,
|
||||
children: [],
|
||||
boughtWhen: { ...node.boughtWhen },
|
||||
platformCount: { ...node.platformCount },
|
||||
tags: []
|
||||
}
|
||||
itemtree.children.push(next)
|
||||
}
|
||||
|
||||
return next!
|
||||
}
|
||||
|
||||
/*
|
||||
* Merge a full build path with an existing item tree
|
||||
*/
|
||||
function treeMerge(
|
||||
itemtree: ItemTree,
|
||||
items: Array<{ itemId: number; goldAdvantage: GoldAdvantageTag; platform?: string }>
|
||||
) {
|
||||
let current = itemtree
|
||||
|
||||
for (const item of items) {
|
||||
current = nodeMerge(current, {
|
||||
data: item.itemId,
|
||||
count: 1,
|
||||
boughtWhen: {
|
||||
aheadCount: item.goldAdvantage == 'ahead' ? 1 : 0,
|
||||
evenCount: item.goldAdvantage == 'even' ? 1 : 0,
|
||||
behindCount: item.goldAdvantage == 'behind' ? 1 : 0,
|
||||
meanGold: 0
|
||||
},
|
||||
children: [],
|
||||
platformCount: item.platform ? singlePlatformCount(item.platform) : initPlatformCounts(),
|
||||
tags: []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function treeCutBranches(itemtree: ItemTree, thresholdCount: number, thresholdPerc: number) {
|
||||
// Remove branches that are above threshold count
|
||||
while (itemtree.children.length > thresholdCount) {
|
||||
const leastUsedBranch = itemtree.children.reduce(
|
||||
(a, b) => (Math.min(a.count, b.count) == a.count ? a : b),
|
||||
{
|
||||
data: undefined,
|
||||
count: +Infinity,
|
||||
children: [],
|
||||
boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 },
|
||||
platformCount: initPlatformCounts(),
|
||||
tags: []
|
||||
}
|
||||
)
|
||||
itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1)
|
||||
}
|
||||
|
||||
// Remove branches that are of too low usage
|
||||
const toRemove: Array<ItemTree> = []
|
||||
for (const child of itemtree.children) {
|
||||
if (child.count / itemtree.count < thresholdPerc) {
|
||||
toRemove.push(child)
|
||||
}
|
||||
}
|
||||
for (const tr of toRemove) {
|
||||
itemtree.children.splice(itemtree.children.indexOf(tr), 1)
|
||||
}
|
||||
|
||||
itemtree.children.map(x => treeCutBranches(x, thresholdCount, thresholdPerc))
|
||||
}
|
||||
|
||||
function treeSort(itemtree: ItemTree) {
|
||||
itemtree.children.sort((a, b) => b.count - a.count)
|
||||
|
||||
for (const item of itemtree.children) {
|
||||
treeSort(item)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Deep clone an ItemTree
|
||||
*/
|
||||
function treeClone(tree: ItemTree): ItemTree {
|
||||
return {
|
||||
data: tree.data,
|
||||
count: tree.count,
|
||||
children: tree.children.map(child => treeClone(child)),
|
||||
boughtWhen: {
|
||||
aheadCount: tree.boughtWhen.aheadCount,
|
||||
behindCount: tree.boughtWhen.behindCount,
|
||||
evenCount: tree.boughtWhen.evenCount,
|
||||
meanGold: tree.boughtWhen.meanGold
|
||||
},
|
||||
platformCount: { ...tree.platformCount },
|
||||
tags: [...tree.tags]
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Merge two ItemTrees into one
|
||||
*/
|
||||
function treeMergeTree(t1: ItemTree, t2: ItemTree): ItemTree {
|
||||
// Merge counts for the root
|
||||
t1.count += t2.count
|
||||
|
||||
// Merge platform counts
|
||||
mergePlatformCounts(t1.platformCount, t2.platformCount)
|
||||
|
||||
// Merge boughtWhen
|
||||
t1.boughtWhen.aheadCount += t2.boughtWhen.aheadCount
|
||||
t1.boughtWhen.evenCount += t2.boughtWhen.evenCount
|
||||
t1.boughtWhen.behindCount += t2.boughtWhen.behindCount
|
||||
|
||||
// Merge children from t2 into t1
|
||||
for (const child2 of t2.children) {
|
||||
// Find matching child in t1 (same data value)
|
||||
const matchingChild = t1.children.find(child1 => child1.data === child2.data)
|
||||
|
||||
if (matchingChild) {
|
||||
// Recursively merge matching children
|
||||
treeMergeTree(matchingChild, child2)
|
||||
} else {
|
||||
// Add a deep copy of child2 to t1
|
||||
t1.children.push(treeClone(child2))
|
||||
}
|
||||
}
|
||||
|
||||
return t1
|
||||
}
|
||||
|
||||
/*
|
||||
* Flatten an ItemTree into a Set of item numbers
|
||||
*/
|
||||
function treeToSet(itemtree: ItemTree): Set<number> {
|
||||
const items: Set<number> = new Set()
|
||||
|
||||
function traverse(node: ItemTree) {
|
||||
if (node.data !== undefined) {
|
||||
items.add(node.data)
|
||||
}
|
||||
for (const child of node.children) {
|
||||
traverse(child)
|
||||
}
|
||||
}
|
||||
|
||||
traverse(itemtree)
|
||||
return items
|
||||
}
|
||||
|
||||
/*
|
||||
* Calculate similarity between two trees as item sets.
|
||||
* Returns a number between 0 and 1, where 1 means identical and 0 means completely different.
|
||||
* Uses Jaccard similarity: |A ∩ B| / |A ∪ B|
|
||||
* Sets included in one another will have similarity close to 1.
|
||||
*/
|
||||
function areTreeSimilars(t1: ItemTree, t2: ItemTree): number {
|
||||
const set1 = treeToSet(t1)
|
||||
const set2 = treeToSet(t2)
|
||||
|
||||
// Handle empty sets
|
||||
if (set1.size === 0 && set2.size === 0) {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// Calculate intersection
|
||||
const intersection = new Set<number>()
|
||||
for (const item of Array.from(set1)) {
|
||||
if (set2.has(item)) {
|
||||
intersection.add(item)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate union
|
||||
const union = new Set<number>()
|
||||
for (const item of Array.from(set1)) {
|
||||
union.add(item)
|
||||
}
|
||||
for (const item of Array.from(set2)) {
|
||||
union.add(item)
|
||||
}
|
||||
|
||||
// Jaccard similarity: |intersection| / |union|
|
||||
const similarity = intersection.size / Math.min(set1.size, set2.size)
|
||||
|
||||
// Ensure result is between 0 and 1
|
||||
return Math.max(0, Math.min(1, similarity))
|
||||
}
|
||||
|
||||
/*
|
||||
* Derive tags for an item based on purchase patterns
|
||||
* Tags are derived when a specific condition is dominant (>= 60% threshold)
|
||||
* For region tags, we compare against expected distribution to find items that are
|
||||
* significantly more popular in a region than expected
|
||||
*/
|
||||
function deriveTags(node: ItemTree, expectedRegionDistribution?: PlatformCounts): void {
|
||||
const tags: Array<ItemTag> = []
|
||||
|
||||
// Derive gold situation tags
|
||||
const totalGoldSituations =
|
||||
node.boughtWhen.aheadCount + node.boughtWhen.behindCount + node.boughtWhen.evenCount
|
||||
if (totalGoldSituations > 0) {
|
||||
const aheadPct = node.boughtWhen.aheadCount / totalGoldSituations
|
||||
const behindPct = node.boughtWhen.behindCount / totalGoldSituations
|
||||
|
||||
// Only tag if there's a dominant pattern (>= 60%)
|
||||
if (aheadPct >= 0.6) {
|
||||
tags.push('ahead')
|
||||
} else if (behindPct >= 0.6) {
|
||||
tags.push('behind')
|
||||
}
|
||||
}
|
||||
|
||||
// Derive region tags by comparing against expected distribution
|
||||
const totalRegionCount = REGION_KEYS.reduce((sum, key) => sum + node.platformCount[key], 0)
|
||||
if (totalRegionCount > 0 && expectedRegionDistribution) {
|
||||
const totalExpected = REGION_KEYS.reduce((sum, key) => sum + expectedRegionDistribution[key], 0)
|
||||
|
||||
if (totalExpected > 0) {
|
||||
// Tag if the item is significantly more popular in a region (>= 1.5x expected rate)
|
||||
// and has a minimum absolute percentage (>= 10%)
|
||||
const SIGNIFICANCE_THRESHOLD = 1.5
|
||||
const MINIMUM_PCT = 0.1
|
||||
|
||||
// Loop through all regions to derive tags
|
||||
const regionTags: Array<{ key: keyof PlatformCounts; tag: ItemTag }> = [
|
||||
{ key: 'euw', tag: 'region_euw' },
|
||||
{ key: 'eun', tag: 'region_eun' },
|
||||
{ key: 'na', tag: 'region_na' },
|
||||
{ key: 'kr', tag: 'region_kr' }
|
||||
]
|
||||
|
||||
for (const { key, tag } of regionTags) {
|
||||
const expectedPct = expectedRegionDistribution[key] / totalExpected
|
||||
const actualPct = node.platformCount[key] / totalRegionCount
|
||||
|
||||
if (actualPct >= expectedPct * SIGNIFICANCE_THRESHOLD && actualPct >= MINIMUM_PCT) {
|
||||
tags.push(tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
node.tags = tags
|
||||
|
||||
// Recursively derive tags for children
|
||||
for (const child of node.children) {
|
||||
deriveTags(child, expectedRegionDistribution)
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Apply tag derivation to an entire tree
|
||||
* expectedRegionDistribution: the total region distribution for the champion/lane,
|
||||
* used to detect items that are region-specific
|
||||
*/
|
||||
function treeDeriveTags(itemtree: ItemTree, expectedRegionDistribution?: PlatformCounts): void {
|
||||
deriveTags(itemtree, expectedRegionDistribution)
|
||||
}
|
||||
|
||||
export {
|
||||
ItemTree,
|
||||
PlatformCounts,
|
||||
GoldAdvantageTag,
|
||||
ItemTag,
|
||||
treeMerge,
|
||||
treeInit,
|
||||
treeCutBranches,
|
||||
treeSort,
|
||||
treeMergeTree,
|
||||
areTreeSimilars,
|
||||
treeDeriveTags
|
||||
}
|
||||
104
match_collector/src/platform.ts
Normal file
104
match_collector/src/platform.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Platform and region configuration for Riot Games API
|
||||
*
|
||||
* Platforms are the server clusters (EUW1, EUN1, NA1, KR)
|
||||
* Regions are the routing values for match API (EUROPE, AMERICAS, ASIA)
|
||||
*/
|
||||
|
||||
// Platform to regional routing value mapping
|
||||
const PLATFORMS: Record<string, string> = {
|
||||
EUW1: 'EUROPE',
|
||||
EUN1: 'EUROPE',
|
||||
NA1: 'AMERICAS',
|
||||
KR: 'ASIA'
|
||||
}
|
||||
|
||||
// Platform counts for tracking item purchases per region
|
||||
interface PlatformCounts {
|
||||
euw: number
|
||||
eun: number
|
||||
na: number
|
||||
kr: number
|
||||
}
|
||||
|
||||
// Platform key mapping for converting platform strings to PlatformCounts keys
|
||||
const PLATFORM_KEYS: Record<string, keyof PlatformCounts> = {
|
||||
euw1: 'euw',
|
||||
eun1: 'eun',
|
||||
na1: 'na',
|
||||
kr: 'kr'
|
||||
}
|
||||
|
||||
// List of all region keys for iteration
|
||||
const REGION_KEYS: Array<keyof PlatformCounts> = ['euw', 'eun', 'na', 'kr']
|
||||
|
||||
/**
|
||||
* Get the base URL for platform-specific API calls (e.g., league-v4)
|
||||
*/
|
||||
function getPlatformBaseUrl(platform: string): string {
|
||||
return `https://${platform.toLowerCase()}.api.riotgames.com`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base URL for regional API calls (e.g., match-v5)
|
||||
*/
|
||||
function getRegionalBaseUrl(region: string): string {
|
||||
return `https://${region.toLowerCase()}.api.riotgames.com`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the regional routing value for a platform
|
||||
* Falls back to the provided default region if platform not found
|
||||
*/
|
||||
function getRegionForPlatform(platform: string): string | undefined {
|
||||
return PLATFORMS[platform]
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize an empty PlatformCounts object
|
||||
*/
|
||||
function initPlatformCounts(): PlatformCounts {
|
||||
return { euw: 0, eun: 0, na: 0, kr: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge platform counts from source into target
|
||||
*/
|
||||
function mergePlatformCounts(target: PlatformCounts, source: PlatformCounts): void {
|
||||
for (const key of REGION_KEYS) {
|
||||
target[key] += source[key]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a platform count with a single platform set to 1
|
||||
*/
|
||||
function singlePlatformCount(platform: string): PlatformCounts {
|
||||
const counts = initPlatformCounts()
|
||||
const key = PLATFORM_KEYS[platform.toLowerCase()]
|
||||
if (key) {
|
||||
counts[key] = 1
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PlatformCounts key for a platform string
|
||||
*/
|
||||
function getPlatformKey(platform: string): keyof PlatformCounts | undefined {
|
||||
return PLATFORM_KEYS[platform.toLowerCase()]
|
||||
}
|
||||
|
||||
export {
|
||||
PLATFORMS,
|
||||
PLATFORM_KEYS,
|
||||
REGION_KEYS,
|
||||
PlatformCounts,
|
||||
getPlatformBaseUrl,
|
||||
getRegionalBaseUrl,
|
||||
getRegionForPlatform,
|
||||
initPlatformCounts,
|
||||
mergePlatformCounts,
|
||||
singlePlatformCount,
|
||||
getPlatformKey
|
||||
}
|
||||
Reference in New Issue
Block a user