diff --git a/frontend/components/build/FirstBack.vue b/frontend/components/build/FirstBack.vue new file mode 100644 index 0000000..c1c9e9b --- /dev/null +++ b/frontend/components/build/FirstBack.vue @@ -0,0 +1,107 @@ + + + + + diff --git a/frontend/components/build/Viewer.vue b/frontend/components/build/Viewer.vue index ba4c818..544f134 100644 --- a/frontend/components/build/Viewer.vue +++ b/frontend/components/build/Viewer.vue @@ -3,6 +3,7 @@ import { getCoreItems, getLateGameItems } from '~/utils/buildHelpers' import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue' import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue' import ItemRow from '~/components/build/ItemRow.vue' +import FirstBack from '~/components/build/FirstBack.vue' const props = defineProps<{ builds: Builds @@ -122,6 +123,13 @@ function selectBuild(index: number): void { /> + + +
Core diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 8d07441..7d4ec2f 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -35,7 +35,8 @@ export default withNuxt([ MatchupData: 'readonly', Item: 'readonly', SummonerSpell: 'readonly', - Perk: 'readonly' + Perk: 'readonly', + FirstBackGroup: 'readonly' } }, rules: { diff --git a/frontend/types/api.ts b/frontend/types/api.ts index 74811ca..4210b07 100644 --- a/frontend/types/api.ts +++ b/frontend/types/api.ts @@ -27,6 +27,7 @@ declare global { suppItems: Array<{ count: number; data: number }> startItems: Array<{ count: number; data: number }> pickrate: number + firstBacks?: FirstBackGroup[] } /** @@ -56,6 +57,32 @@ declare global { championAlias: string } + /** + * Represents an item in a first back item set + */ + interface FirstBackItemSetEntry { + itemId: number + count: number + } + + /** + * Represents an item set (combination of items) + */ + interface ItemSet { + items: FirstBackItemSetEntry[] + totalGold: number + } + + /** + * Represents a grouped first back by item set + */ + interface FirstBackGroup { + itemSet: ItemSet + count: number + pickrate: number + avgTimestamp: number + } + /** * Represents lane-specific champion data */ diff --git a/match_collector/src/champion_stat.ts b/match_collector/src/champion_stat.ts index d3b865b..08a883c 100644 --- a/match_collector/src/champion_stat.ts +++ b/match_collector/src/champion_stat.ts @@ -12,6 +12,13 @@ import { treeDeriveTags } from './item_tree' import { PLATFORM_KEYS } from './platform' +import { + initItemDict as initFirstBackItemDict, + extractFirstBackFromMatch, + groupFirstBacksByItemSet, + FirstBackData, + FirstBackGroup +} from './first_back' import { Match, Timeline, Participant, Frame } from './api' @@ -65,6 +72,9 @@ type Build = { suppItems: Array<{ data: number; count: number }> boots: Array<{ data: number; count: number }> pickrate?: number + // First back data (collected during processing, grouped in finalize) + firstBacksRaw?: FirstBackData[] + firstBacks?: FirstBackGroup[] } type BuildWithStartItems = { @@ -78,6 +88,8 @@ type BuildWithStartItems = { suppItems: Array<{ data: number; count: number }> boots: Array<{ data: number; count: number }> pickrate?: number + firstBacksRaw?: FirstBackData[] + firstBacks?: FirstBackGroup[] } type Builds = Build[] @@ -225,7 +237,7 @@ function handleMatchBuilds( participantIndex: number, builds: Builds, platform?: string -) { +): Build { const timeline: Timeline = match.timeline // Find or create the build for this participant's rune configuration @@ -316,6 +328,8 @@ function handleMatchBuilds( if (items.length > 0) { treeMerge(build.items, items) } + + return build } function handleMatch(match: Match, champions: Map, platform?: string) { @@ -411,7 +425,16 @@ function handleMatch(match: Match, champions: Map, platfor } // Items and runes (builds) - handleMatchBuilds(match, participant, participantIndex, lane.builds, platform) + const build = handleMatchBuilds(match, participant, participantIndex, lane.builds, platform) + + // First back data - store at build level + const firstBackData = extractFirstBackFromMatch(match, participantIndex) + if (firstBackData) { + if (!build.firstBacksRaw) { + build.firstBacksRaw = [] + } + build.firstBacksRaw.push(firstBackData) + } } } @@ -473,7 +496,8 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS startItems, suppItems: build.suppItems, boots: build.boots, - pickrate: build.pickrate + pickrate: build.pickrate, + firstBacksRaw: build.firstBacksRaw } ] } else { @@ -489,7 +513,8 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS count: c.count, startItems: [{ data: c.data!, count: c.count }], suppItems: build.suppItems, - boots: build.boots + boots: build.boots, + firstBacksRaw: build.firstBacksRaw }) c.data = undefined } @@ -573,6 +598,14 @@ function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems const runes = Array.from(runesMap.values()) runes.sort((a, b) => b.count - a.count) + // Merge first backs raw data + const firstBacksRaw: FirstBackData[] = [] + for (const build of allSimilarBuilds) { + if (build.firstBacksRaw) { + firstBacksRaw.push(...build.firstBacksRaw) + } + } + merged.push({ runeKeystone: runes[0].selections[0], runes: runes, @@ -581,7 +614,8 @@ function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems count: totalCount, startItems: mergeItemCounts(allSimilarBuilds, b => b.startItems), suppItems: mergeItemCounts(allSimilarBuilds, b => b.suppItems), - boots: mergeItemCounts(allSimilarBuilds, b => b.boots) + boots: mergeItemCounts(allSimilarBuilds, b => b.boots), + firstBacksRaw: firstBacksRaw.length > 0 ? firstBacksRaw : undefined }) } @@ -659,6 +693,19 @@ async function finalizeChampionStats(champion: ChampionData, totalMatches: numbe // all along. lane.builds = mergeBuildsForRunes(lane.builds as BuildWithStartItems[]) cleanupLaneBuilds(lane) + + // Process first backs at build level - group by item set + for (const build of lane.builds) { + if (build.firstBacksRaw && build.firstBacksRaw.length > 0) { + build.firstBacks = groupFirstBacksByItemSet(build.firstBacksRaw) + // Keep only top 7 groups + if (build.firstBacks!.length > 7) { + build.firstBacks = build.firstBacks!.slice(0, 7) + } + // Clean up raw data to save space + delete build.firstBacksRaw + } + } } for (const lane of champion.lanes) { @@ -723,6 +770,9 @@ async function makeChampionsStats(client: MongoClient, patch: string, platforms: itemDict.set(item.id, item) } + // Initialize first back item dictionary + await initFirstBackItemDict() + const list = await championList() console.log('Generating stats for ' + list.length + ' champions') diff --git a/match_collector/src/first_back.ts b/match_collector/src/first_back.ts new file mode 100644 index 0000000..b10f481 --- /dev/null +++ b/match_collector/src/first_back.ts @@ -0,0 +1,253 @@ +import { Match, Timeline } from './api' + +// Item dictionary for gold information +const itemDict = new Map< + number, + { + price: number + priceTotal: number + to: number[] + categories: string[] + requiredBuffCurrencyName?: string + name?: string + } +>() + +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 +} + +export async function initItemDict() { + if (itemDict.size > 0) return + const globalItems = await itemList() + for (const item of globalItems) { + itemDict.set(item.id, item) + } +} + +// Get item gold value +function getItemGold(itemId: number): number { + const item = itemDict.get(itemId) + if (!item) return 0 + return item.priceTotal || 0 +} + +// Check if item should be tracked for first back +function shouldTrackItem(itemId: number): boolean { + const item = itemDict.get(itemId) + if (!item) return false + + // Skip some consumables and trinkets + if (item.name == 'Health Potion') return false + if (item.name == 'Control Ward') return false + if (item.categories?.includes('Trinket')) return false + + return true +} + +// A single back event with all items purchased +export type BackEvent = { + timestamp: number + items: Array<{ + itemId: number + gold: number + }> + totalGold: number // Total gold value of items bought +} + +// Item set - a unique combination of items bought together +export type ItemSet = { + items: Array<{ itemId: number; count: number }> // Items with quantities + totalGold: number // Total gold value of items +} + +// First back data with item set +export type FirstBackData = { + timestamp: number + itemSet: ItemSet +} + +// Grouped first back by item set +export type FirstBackGroup = { + // The item set combination + itemSet: ItemSet + // Stats for this item set + count: number + pickrate: number // Overall pickrate for this item set + avgTimestamp: number +} + +// Create a unique key for an item set (sorted by itemId for consistency) +function itemSetKey(items: Array<{ itemId: number; count: number }>): string { + const sorted = [...items].sort((a, b) => a.itemId - b.itemId) + return sorted.map(i => `${i.itemId}x${i.count}`).join(',') +} + +// Parse all backs from a match timeline for a specific participant +export function parseBacksFromTimeline(timeline: Timeline, participantIndex: number): BackEvent[] { + const backs: BackEvent[] = [] + let currentBack: BackEvent | null = null + let lastPurchaseTimestamp = 0 + const BACK_TIMEOUT = 30000 // 30 seconds - if no purchase for 30s, consider back ended + + for (const frame of timeline.info.frames) { + for (const event of frame.events) { + if (event.participantId !== participantIndex) continue + + if (event.type === 'ITEM_PURCHASED') { + if (!shouldTrackItem(event.itemId)) continue + + const timestamp = event.timestamp + + // Start new back if: + // 1. No current back, or + // 2. More than BACK_TIMEOUT since last purchase + if (!currentBack || timestamp - lastPurchaseTimestamp > BACK_TIMEOUT) { + // Save previous back if exists + if (currentBack && currentBack.items.length > 0) { + backs.push(currentBack) + } + + // Start new back + currentBack = { + timestamp, + items: [], + totalGold: 0 + } + } + + // Add item to current back + const itemGold = getItemGold(event.itemId) + currentBack.items.push({ + itemId: event.itemId, + gold: itemGold + }) + currentBack.totalGold += itemGold + lastPurchaseTimestamp = timestamp + } + + if (event.type === 'ITEM_UNDO' && currentBack) { + // Handle undo - remove last item + if (currentBack.items.length > 0) { + const lastItem = currentBack.items.pop() + if (lastItem) { + currentBack.totalGold -= lastItem.gold + } + } + } + } + } + + // Don't forget the last back + if (currentBack && currentBack.items.length > 0) { + backs.push(currentBack) + } + + return backs +} + +// Get the first back (excluding starting items purchase at game start) +export function getFirstBack(backs: BackEvent[]): BackEvent | null { + // Filter out the initial purchase (usually within first minute) + // and get the first real back + const MIN_GAME_TIME = 60000 // 1 minute - ignore purchases before this + + for (const back of backs) { + if (back.timestamp >= MIN_GAME_TIME) { + return back + } + } + + return null +} + +// Convert a back event to an item set +function backToItemSet(back: BackEvent): ItemSet { + const itemCounts = new Map() + + for (const item of back.items) { + const existing = itemCounts.get(item.itemId) || 0 + itemCounts.set(item.itemId, existing + 1) + } + + const items = Array.from(itemCounts.entries()).map(([itemId, count]) => ({ + itemId, + count + })) + + return { + items, + totalGold: back.totalGold + } +} + +// Group first backs by item set +export function groupFirstBacksByItemSet(firstBacks: FirstBackData[]): FirstBackGroup[] { + const totalBacks = firstBacks.length + + // Group by item set + const itemSetGroups: Map = new Map() + + for (const back of firstBacks) { + const key = itemSetKey(back.itemSet.items) + + if (!itemSetGroups.has(key)) { + itemSetGroups.set(key, []) + } + itemSetGroups.get(key)!.push(back) + } + + // Build result + const result: FirstBackGroup[] = [] + + for (const backs of itemSetGroups.values()) { + // Use the first back's item set (they're all the same) + const itemSet = backs[0].itemSet + const avgTimestamp = backs.reduce((sum, b) => sum + b.timestamp, 0) / backs.length + + result.push({ + itemSet, + count: backs.length, + pickrate: backs.length / totalBacks, + avgTimestamp + }) + } + + // Sort by count (most common item sets first) + result.sort((a, b) => b.count - a.count) + + return result +} + +// Extract first back data from a match for a participant +export function extractFirstBackFromMatch( + match: Match, + participantIndex: number +): FirstBackData | null { + const timeline = match.timeline + if (!timeline) return null + + const backs = parseBacksFromTimeline(timeline, participantIndex) + const firstBack = getFirstBack(backs) + + if (!firstBack) return null + + const itemSet = backToItemSet(firstBack) + + return { + timestamp: firstBack.timestamp, + itemSet + } +} + +export default { + initItemDict, + parseBacksFromTimeline, + getFirstBack, + groupFirstBacksByItemSet, + extractFirstBackFromMatch +}