feat: first back recording and display (#12)
Record first backs, group them by item sets and show the most popular ones, with gold and %, in the frontend.
This commit is contained in:
107
frontend/components/build/FirstBack.vue
Normal file
107
frontend/components/build/FirstBack.vue
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
firstBacks: FirstBackGroup[]
|
||||||
|
itemMap: Map<number, Item>
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="firstBacks && firstBacks.length > 0" class="item-row">
|
||||||
|
<span class="item-row-label">First Back</span>
|
||||||
|
<div class="first-back-content">
|
||||||
|
<div v-for="(group, index) in firstBacks" :key="index" class="first-back-option">
|
||||||
|
<span class="gold-cost">{{ group.itemSet.totalGold }}g</span>
|
||||||
|
<div class="option-items">
|
||||||
|
<template v-for="item in group.itemSet.items" :key="item.itemId">
|
||||||
|
<div class="item-with-count">
|
||||||
|
<ItemIcon
|
||||||
|
v-if="itemMap.get(item.itemId)"
|
||||||
|
:item="itemMap.get(item.itemId)!"
|
||||||
|
:size="36"
|
||||||
|
class="item-cell"
|
||||||
|
/>
|
||||||
|
<span v-if="item.count > 1" class="item-count">x{{ item.count }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<span class="pickrate">{{ (group.pickrate * 100).toFixed(0) }}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.item-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
opacity: 0.6;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.first-back-content {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.first-back-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-items {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-with-count {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-cell {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-count {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -1px;
|
||||||
|
right: -1px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.55rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0 2px;
|
||||||
|
border-radius: 2px;
|
||||||
|
min-width: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pickrate {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
opacity: 0.6;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gold-cost {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #ffd700;
|
||||||
|
opacity: 0.8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import { getCoreItems, getLateGameItems } from '~/utils/buildHelpers'
|
|||||||
import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue'
|
import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue'
|
||||||
import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue'
|
import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue'
|
||||||
import ItemRow from '~/components/build/ItemRow.vue'
|
import ItemRow from '~/components/build/ItemRow.vue'
|
||||||
|
import FirstBack from '~/components/build/FirstBack.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
builds: Builds
|
builds: Builds
|
||||||
@@ -122,6 +123,13 @@ function selectBuild(index: number): void {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- First Back Section -->
|
||||||
|
<FirstBack
|
||||||
|
v-if="currentBuild.firstBacks && currentBuild.firstBacks.length > 0"
|
||||||
|
:first-backs="currentBuild.firstBacks"
|
||||||
|
:item-map="itemMap"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Core Items Tree (children of start item) -->
|
<!-- Core Items Tree (children of start item) -->
|
||||||
<div v-if="currentBuild.items?.children?.length" class="item-row">
|
<div v-if="currentBuild.items?.children?.length" class="item-row">
|
||||||
<span class="item-row-label">Core</span>
|
<span class="item-row-label">Core</span>
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export default withNuxt([
|
|||||||
MatchupData: 'readonly',
|
MatchupData: 'readonly',
|
||||||
Item: 'readonly',
|
Item: 'readonly',
|
||||||
SummonerSpell: 'readonly',
|
SummonerSpell: 'readonly',
|
||||||
Perk: 'readonly'
|
Perk: 'readonly',
|
||||||
|
FirstBackGroup: 'readonly'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ declare global {
|
|||||||
suppItems: Array<{ count: number; data: number }>
|
suppItems: Array<{ count: number; data: number }>
|
||||||
startItems: Array<{ count: number; data: number }>
|
startItems: Array<{ count: number; data: number }>
|
||||||
pickrate: number
|
pickrate: number
|
||||||
|
firstBacks?: FirstBackGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +57,32 @@ declare global {
|
|||||||
championAlias: string
|
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
|
* Represents lane-specific champion data
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ import {
|
|||||||
treeDeriveTags
|
treeDeriveTags
|
||||||
} from './item_tree'
|
} from './item_tree'
|
||||||
import { PLATFORM_KEYS } from './platform'
|
import { PLATFORM_KEYS } from './platform'
|
||||||
|
import {
|
||||||
|
initItemDict as initFirstBackItemDict,
|
||||||
|
extractFirstBackFromMatch,
|
||||||
|
groupFirstBacksByItemSet,
|
||||||
|
FirstBackData,
|
||||||
|
FirstBackGroup
|
||||||
|
} from './first_back'
|
||||||
|
|
||||||
import { Match, Timeline, Participant, Frame } from './api'
|
import { Match, Timeline, Participant, Frame } from './api'
|
||||||
|
|
||||||
@@ -65,6 +72,9 @@ type Build = {
|
|||||||
suppItems: Array<{ data: number; count: number }>
|
suppItems: Array<{ data: number; count: number }>
|
||||||
boots: Array<{ data: number; count: number }>
|
boots: Array<{ data: number; count: number }>
|
||||||
pickrate?: number
|
pickrate?: number
|
||||||
|
// First back data (collected during processing, grouped in finalize)
|
||||||
|
firstBacksRaw?: FirstBackData[]
|
||||||
|
firstBacks?: FirstBackGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type BuildWithStartItems = {
|
type BuildWithStartItems = {
|
||||||
@@ -78,6 +88,8 @@ type BuildWithStartItems = {
|
|||||||
suppItems: Array<{ data: number; count: number }>
|
suppItems: Array<{ data: number; count: number }>
|
||||||
boots: Array<{ data: number; count: number }>
|
boots: Array<{ data: number; count: number }>
|
||||||
pickrate?: number
|
pickrate?: number
|
||||||
|
firstBacksRaw?: FirstBackData[]
|
||||||
|
firstBacks?: FirstBackGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type Builds = Build[]
|
type Builds = Build[]
|
||||||
@@ -225,7 +237,7 @@ function handleMatchBuilds(
|
|||||||
participantIndex: number,
|
participantIndex: number,
|
||||||
builds: Builds,
|
builds: Builds,
|
||||||
platform?: string
|
platform?: string
|
||||||
) {
|
): Build {
|
||||||
const timeline: Timeline = match.timeline
|
const timeline: Timeline = match.timeline
|
||||||
|
|
||||||
// Find or create the build for this participant's rune configuration
|
// Find or create the build for this participant's rune configuration
|
||||||
@@ -316,6 +328,8 @@ function handleMatchBuilds(
|
|||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
treeMerge(build.items, items)
|
treeMerge(build.items, items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return build
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMatch(match: Match, champions: Map<number, ChampionData>, platform?: string) {
|
function handleMatch(match: Match, champions: Map<number, ChampionData>, platform?: string) {
|
||||||
@@ -411,7 +425,16 @@ function handleMatch(match: Match, champions: Map<number, ChampionData>, platfor
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Items and runes (builds)
|
// 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,
|
startItems,
|
||||||
suppItems: build.suppItems,
|
suppItems: build.suppItems,
|
||||||
boots: build.boots,
|
boots: build.boots,
|
||||||
pickrate: build.pickrate
|
pickrate: build.pickrate,
|
||||||
|
firstBacksRaw: build.firstBacksRaw
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
@@ -489,7 +513,8 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS
|
|||||||
count: c.count,
|
count: c.count,
|
||||||
startItems: [{ data: c.data!, count: c.count }],
|
startItems: [{ data: c.data!, count: c.count }],
|
||||||
suppItems: build.suppItems,
|
suppItems: build.suppItems,
|
||||||
boots: build.boots
|
boots: build.boots,
|
||||||
|
firstBacksRaw: build.firstBacksRaw
|
||||||
})
|
})
|
||||||
c.data = undefined
|
c.data = undefined
|
||||||
}
|
}
|
||||||
@@ -573,6 +598,14 @@ function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems
|
|||||||
const runes = Array.from(runesMap.values())
|
const runes = Array.from(runesMap.values())
|
||||||
runes.sort((a, b) => b.count - a.count)
|
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({
|
merged.push({
|
||||||
runeKeystone: runes[0].selections[0],
|
runeKeystone: runes[0].selections[0],
|
||||||
runes: runes,
|
runes: runes,
|
||||||
@@ -581,7 +614,8 @@ function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems
|
|||||||
count: totalCount,
|
count: totalCount,
|
||||||
startItems: mergeItemCounts(allSimilarBuilds, b => b.startItems),
|
startItems: mergeItemCounts(allSimilarBuilds, b => b.startItems),
|
||||||
suppItems: mergeItemCounts(allSimilarBuilds, b => b.suppItems),
|
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.
|
// all along.
|
||||||
lane.builds = mergeBuildsForRunes(lane.builds as BuildWithStartItems[])
|
lane.builds = mergeBuildsForRunes(lane.builds as BuildWithStartItems[])
|
||||||
cleanupLaneBuilds(lane)
|
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) {
|
for (const lane of champion.lanes) {
|
||||||
@@ -723,6 +770,9 @@ async function makeChampionsStats(client: MongoClient, patch: string, platforms:
|
|||||||
itemDict.set(item.id, item)
|
itemDict.set(item.id, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize first back item dictionary
|
||||||
|
await initFirstBackItemDict()
|
||||||
|
|
||||||
const list = await championList()
|
const list = await championList()
|
||||||
console.log('Generating stats for ' + list.length + ' champions')
|
console.log('Generating stats for ' + list.length + ' champions')
|
||||||
|
|
||||||
|
|||||||
253
match_collector/src/first_back.ts
Normal file
253
match_collector/src/first_back.ts
Normal file
@@ -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<number, number>()
|
||||||
|
|
||||||
|
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<string, FirstBackData[]> = 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user