Multiple changes

- backend: add summoner spells
- backend: add build variants
- backend: builds are now storing full tree with runes (keystones)
- backend: build trees are split on starter items and merged on runes
- frontend: computing core tree now
- frontend: variant selectors
This commit is contained in:
2026-03-06 23:33:02 +01:00
parent 930cbf5a18
commit 271c2b26d8
14 changed files with 684 additions and 373 deletions

View File

@@ -7,7 +7,15 @@ function sameArrays(array1: Array<number>, array2: Array<number>) {
}
import { MongoClient } from 'mongodb'
import { ItemTree, treeInit, treeMerge, treeCutBranches, treeSort } from './item_tree'
import {
ItemTree,
treeInit,
treeMerge,
treeCutBranches,
treeSort,
treeMergeTree,
areTreeSimilars
} from './item_tree'
const itemDict = new Map()
async function itemList() {
@@ -41,14 +49,32 @@ type Rune = {
selections: Array<number>
pickrate?: number
}
type Builds = {
tree: ItemTree
start: Array<{ data: number; count: number }>
bootsFirst: 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 }>
lateGame: Array<{ data: number; count: number }>
suppItems?: 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
@@ -69,9 +95,9 @@ type LaneData = {
losingMatches: number
winrate: number
pickrate: number
runes: Array<Rune>
builds: Builds
matchups?: Array<MatchupData>
summonerSpells: Array<{ id: number; count: number; pickrate?: number }>
}
type ChampionData = {
champion: Champion
@@ -80,8 +106,9 @@ type ChampionData = {
lanes: Array<LaneData>
}
// Helper function to create rune configuration from participant
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleParticipantRunes(participant: any, runes: Array<Rune>) {
function createRuneConfiguration(participant: any): Rune {
const primaryStyle = participant.perks.styles[0].style
const secondaryStyle = participant.perks.styles[1].style
const selections: Array<number> = []
@@ -90,28 +117,56 @@ function handleParticipantRunes(participant: any, runes: Array<Rune>) {
selections.push(perk.perk)
}
}
const gameRunes: Rune = {
count: 1,
return {
count: 0, // Will be incremented when added to build
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)
}
function handleMatchItems(
// 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
@@ -119,6 +174,10 @@ function handleMatchItems(
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<number> = []
for (const frame of timeline.info.frames) {
for (const event of frame.events) {
@@ -145,8 +204,8 @@ function handleMatchItems(
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 })
const already = build.suppItems.find(x => x.data == suppItem)
if (already == undefined) build.suppItems.push({ count: 1, data: suppItem })
else already.count += 1
}
}
@@ -166,12 +225,12 @@ function handleMatchItems(
if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) {
// Check for bootsFirst
if (items.length < 2) {
builds.bootsFirst += 1
build.bootsFirstCount += 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 })
// 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
}
@@ -188,28 +247,18 @@ function handleMatchItems(
// 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
// 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)
}
}
// 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
// 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)
}
}
@@ -224,23 +273,15 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
// 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,
builds: [],
winningMatches: 0,
losingMatches: 0,
winrate: 0,
pickrate: 0,
summonerSpells: [],
matchups: []
}
champion.lanes.push(lane)
@@ -260,6 +301,18 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
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(
@@ -292,11 +345,8 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
}
}
// Runes
handleParticipantRunes(participant, lane.runes)
// Items
handleMatchItems(match.timeline, participant, participantIndex, lane.builds)
// Items and runes (builds)
handleMatchBuilds(match.timeline, participant, participantIndex, lane.builds)
}
}
@@ -322,50 +372,223 @@ async function handleMatchList(
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)
// 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)
// Filter runes to keep 3 most played
for (const lane of champion.lanes) {
const runes = lane.runes
// 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)
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
}
// Cleaning up builds
cleanupLaneBuilds(lane)
for (const lane of champion.lanes) {
const builds = lane.builds
// 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)
}
// 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)
// 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) {

View File

@@ -81,4 +81,97 @@ function treeSort(itemtree: ItemTree) {
}
}
export { ItemTree, treeMerge, treeInit, treeCutBranches, treeSort }
/*
* Deep clone an ItemTree
*/
function treeClone(tree: ItemTree): ItemTree {
return {
data: tree.data,
count: tree.count,
children: tree.children.map(child => treeClone(child))
}
}
/*
* Merge two ItemTrees into one
*/
function treeMergeTree(t1: ItemTree, t2: ItemTree): ItemTree {
// Merge counts for the root
t1.count += t2.count
// 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))
}
export { ItemTree, treeMerge, treeInit, treeCutBranches, treeSort, treeMergeTree, areTreeSimilars }