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 @@
+
+
+
+
+
First Back
+
+
+
{{ group.itemSet.totalGold }}g
+
+
+
+
+ x{{ item.count }}
+
+
+
+
{{ (group.pickrate * 100).toFixed(0) }}%
+
+
+
+
+
+
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
+}