Compare commits

...

10 Commits

Author SHA1 Message Date
b2178fec85 fix/match_collector: fix boots handling
All checks were successful
pipeline / lint-and-format (push) Successful in 4m51s
pipeline / build-and-push-images (push) Successful in 54s
2026-05-04 13:57:03 +02:00
ee32060a7f feat/match_collector: set sleep time to 12h
All checks were successful
pipeline / lint-and-format (push) Successful in 10m57s
pipeline / build-and-push-images (push) Successful in 49s
2026-05-01 00:39:24 +02:00
d231ae7c38 fix/match_collector: make sure we have permission to write in /cdragon 2026-05-01 00:39:02 +02:00
5c83e45d2a fix/frontend: format
All checks were successful
pipeline / lint-and-format (push) Successful in 6m16s
pipeline / build-and-push-images (push) Successful in 1m16s
2026-05-01 00:24:53 +02:00
50c0646a93 fix/frontend: center first back items on mobile
Some checks failed
pipeline / lint-and-format (push) Failing after 4m34s
pipeline / build-and-push-images (push) Has been skipped
2026-05-01 00:18:54 +02:00
8263dc1c93 fix/frontend: change rune size on mobile
Some checks failed
pipeline / lint-and-format (push) Failing after 4m31s
pipeline / build-and-push-images (push) Has been skipped
2026-05-01 00:14:18 +02:00
c506bad739 feat/frontend: remove '?' cursors on tooltips
All checks were successful
pipeline / lint-and-format (push) Successful in 4m29s
pipeline / build-and-push-images (push) Successful in 1m33s
2026-04-30 20:17:54 +02:00
a0e2915c3d fix/match_collector: fix splitting of boots and first backs
Some checks failed
pipeline / build-and-push-images (push) Has been cancelled
pipeline / lint-and-format (push) Has started running
2026-04-30 20:16:00 +02:00
c7d0d929be fix/match_collector: change region tagging logic 2026-04-30 17:51:14 +02:00
8ee981b949 fix: fix cdragon cache directory 2026-04-30 16:31:25 +02:00
11 changed files with 116 additions and 35 deletions

View File

@@ -107,4 +107,15 @@ defineProps<{
opacity: 0.8; opacity: 0.8;
white-space: nowrap; white-space: nowrap;
} }
@media only screen and (max-width: 650px) {
.item-row {
align-items: center;
max-width: 100%;
margin: 0 20px;
}
.first-back-content {
justify-content: center;
}
}
</style> </style>

View File

@@ -124,7 +124,6 @@ const itemIconPath = computed(() => CDRAGON_BASE + mapPath(props.item.iconPath))
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--color-on-surface); border: 1px solid var(--color-on-surface);
overflow: hidden; overflow: hidden;
cursor: help;
position: relative; position: relative;
} }

View File

@@ -86,7 +86,11 @@ refreshStyles()
<div class="rune-spacer-bar" /> <div class="rune-spacer-bar" />
<div class="rune-holder" style="align-content: end"> <div class="rune-holder" style="align-content: end">
<div class="rune-slot"> <div class="rune-slot">
<img style="margin: auto" :src="CDRAGON_BASE + mapPath(secondaryStyle.iconPath)" /> <img
class="rune-style-img"
style="margin: auto"
:src="CDRAGON_BASE + mapPath(secondaryStyle.iconPath)"
/>
</div> </div>
<div <div
v-for="(slot, slotIndex) in secondaryStyle.slots.slice(1, 4)" v-for="(slot, slotIndex) in secondaryStyle.slots.slice(1, 4)"
@@ -128,6 +132,10 @@ refreshStyles()
margin-right: 20px; margin-right: 20px;
border: 1px var(--color-on-surface) solid; border: 1px var(--color-on-surface) solid;
} }
.rune-style-img {
width: 48px;
height: 48px;
}
@media only screen and (max-width: 650px) { @media only screen and (max-width: 650px) {
.rune-slot { .rune-slot {
@@ -143,5 +151,9 @@ refreshStyles()
margin-left: 10px; margin-left: 10px;
margin-right: 10px; margin-right: 10px;
} }
.rune-icon {
width: 24px !important;
height: 24px !important;
}
} }
</style> </style>

View File

@@ -114,7 +114,6 @@ const perkIconPath = computed(() => CDRAGON_BASE + mapPath(props.perk.iconPath))
border-radius: 50%; border-radius: 50%;
border: 1px solid var(--color-on-surface); border: 1px solid var(--color-on-surface);
overflow: hidden; overflow: hidden;
cursor: help;
position: relative; position: relative;
} }

View File

@@ -9,14 +9,19 @@ const readFileAsync = promisify(readFile)
const CDRAGON_BASE = 'https://raw.communitydragon.org/' const CDRAGON_BASE = 'https://raw.communitydragon.org/'
// Cache directory - can be configured via environment variable // Cache directory - can be configured via environment variable
// Default to dev/cdragon for development // In development, use dev/data/cdragon relative to project root
// In production, use /cdragon (shared volume)
const getCacheDir = () => { const getCacheDir = () => {
if (process.env.CDRAGON_CACHE_DIR) { if (process.env.CDRAGON_CACHE_DIR) {
return process.env.CDRAGON_CACHE_DIR return process.env.CDRAGON_CACHE_DIR
} }
// Default to dev/cdragon relative to project root // Check if we're in development mode (explicitly set)
if (process.env.NODE_ENV === 'development') {
return join(process.cwd(), '..', 'dev', 'data', 'cdragon') return join(process.cwd(), '..', 'dev', 'data', 'cdragon')
} }
// Default to /cdragon for production (Docker)
return '/cdragon'
}
/** /**
* Get the current patch from the patch.txt file or fallback to 'latest' * Get the current patch from the patch.txt file or fallback to 'latest'

View File

@@ -20,9 +20,14 @@ RUN npm install
COPY --chown=node:node match_collector/. . COPY --chown=node:node match_collector/. .
FROM node:current-alpine FROM node:current-alpine
# Install su-exec for dropping privileges
RUN apk add --no-cache su-exec
RUN mkdir -p /home/node/app && chown -R node:node /home/node/app RUN mkdir -p /home/node/app && chown -R node:node /home/node/app
WORKDIR /home/node/app WORKDIR /home/node/app
USER node
COPY --from=build --chown=node:node /home/node/app/match_collector/node_modules ./node_modules COPY --from=build --chown=node:node /home/node/app/match_collector/node_modules ./node_modules
COPY --from=build --chown=node:node /home/node/app/match_collector/. . COPY --from=build --chown=node:node /home/node/app/match_collector/. .
CMD ["/bin/sh", "-c", "node --import=tsx src/index.ts; sleep 20h"] COPY --chown=node:node match_collector/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Run entrypoint as root to fix permissions, then drop to node user
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["/bin/sh", "-c", "node --import=tsx src/index.ts; sleep 12h"]

View File

@@ -0,0 +1,9 @@
#!/bin/sh
# Fix permissions on the cdragon cache directory if it exists
if [ -d "/cdragon" ]; then
# Ensure the node user owns the cdragon directory
chown -R node:node /cdragon 2>/dev/null || true
fi
# Execute the main command as the node user
exec su-exec node "$@"

View File

@@ -46,7 +46,8 @@ async function downloadCDragonAssets(patch: string) {
console.log(`\n=== Downloading CDragon assets for patch ${cdragonPatch} ===`) console.log(`\n=== Downloading CDragon assets for patch ${cdragonPatch} ===`)
// Get cache directory from environment or use default // Get cache directory from environment or use default
// In development, use a local directory relative to project root; in production (Docker), use /cdragon // In development, use a local directory relative to project root
// In production (Docker), use /cdragon (shared volume with frontend)
const defaultCacheDir = const defaultCacheDir =
process.env.NODE_ENV === 'development' process.env.NODE_ENV === 'development'
? resolve(__dirname, '../../dev/data/cdragon') ? resolve(__dirname, '../../dev/data/cdragon')

View File

@@ -177,7 +177,7 @@ function handleMatchBuilds(
participantIndex: number, participantIndex: number,
builds: Builds, builds: Builds,
platform?: string platform?: string
): Build { ): { build: Build; startItemId: number | undefined } {
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
@@ -185,6 +185,7 @@ function handleMatchBuilds(
build.count += 1 build.count += 1
const items: Array<{ itemId: number; goldAdvantage: GoldAdvantageTag; platform?: string }> = [] const items: Array<{ itemId: number; goldAdvantage: GoldAdvantageTag; platform?: string }> = []
let startItemId: number | undefined = undefined
for (const frame of timeline.info.frames) { for (const frame of timeline.info.frames) {
for (const event of frame.events) { for (const event of frame.events) {
if (event.participantId != participantIndex) continue if (event.participantId != participantIndex) continue
@@ -218,17 +219,10 @@ function handleMatchBuilds(
} }
if (event.type != 'ITEM_PURCHASED') continue if (event.type != 'ITEM_PURCHASED') continue
// Handle boots upgrades
if (
itemInfo.requiredBuffCurrencyName == 'Feats_NoxianBootPurchaseBuff' ||
itemInfo.requiredBuffCurrencyName == 'Feats_SpecialQuestBootBuff'
) {
continue
}
// Handle boots differently // Handle boots differently
if (itemInfo.categories.includes('Boots')) { if (itemInfo.categories.includes('Boots')) {
if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) { // Ignore basic boots, only count Tier 2 boots
if (event.itemId != 1001) {
// Check for bootsFirst // Check for bootsFirst
if (items.length < 2) { if (items.length < 2) {
build.bootsFirstCount += 1 build.bootsFirstCount += 1
@@ -267,9 +261,11 @@ function handleMatchBuilds(
// This tree includes start item as the root, then branching paths // This tree includes start item as the root, then branching paths
if (items.length > 0) { if (items.length > 0) {
treeMerge(build.items, items) treeMerge(build.items, items)
// The first item is the starter item
startItemId = items[0].itemId
} }
return build return { build, startItemId }
} }
function handleMatch(match: Match, champions: Map<number, ChampionData>, platform?: string) { function handleMatch(match: Match, champions: Map<number, ChampionData>, platform?: string) {
@@ -365,14 +361,22 @@ function handleMatch(match: Match, champions: Map<number, ChampionData>, platfor
} }
// Items and runes (builds) // Items and runes (builds)
const build = handleMatchBuilds(match, participant, participantIndex, lane.builds, platform) const { build, startItemId } = handleMatchBuilds(
match,
participant,
participantIndex,
lane.builds,
platform
)
// First back data - store at build level // First back data - store at build level with start item tracking
const firstBackData = extractFirstBackFromMatch(match, participantIndex) const firstBackData = extractFirstBackFromMatch(match, participantIndex)
if (firstBackData) { if (firstBackData) {
if (!build.firstBacksRaw) { if (!build.firstBacksRaw) {
build.firstBacksRaw = [] build.firstBacksRaw = []
} }
// Include the starter item ID for proper filtering when splitting builds
firstBackData.startItemId = startItemId
build.firstBacksRaw.push(firstBackData) build.firstBacksRaw.push(firstBackData)
} }
} }
@@ -445,16 +449,44 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS
console.log(`Warning: for champion ${championName}, start item splits build variant.`) console.log(`Warning: for champion ${championName}, start item splits build variant.`)
const builds = [] const builds = []
for (const c of build.items.children) { for (const c of build.items.children) {
// Calculate the ratio for proportional distribution
const ratio = c.count / build.count
// Proportionally distribute boots counts
const scaledBoots = build.boots.map(b => ({
data: b.data,
count: Math.round(b.count * ratio)
}))
// Proportionally distribute suppItems counts
const scaledSuppItems = build.suppItems.map(s => ({
data: s.data,
count: Math.round(s.count * ratio)
}))
// Proportionally distribute bootsFirstCount
const scaledBootsFirstCount = Math.round(build.bootsFirstCount * ratio)
// Filter firstBacksRaw by starter item
let filteredFirstBacksRaw: FirstBackData[] | undefined
if (build.firstBacksRaw && build.firstBacksRaw.length > 0) {
// Filter by the starter item ID that was tracked when storing firstBacksRaw
filteredFirstBacksRaw = build.firstBacksRaw.filter(fb => fb.startItemId === c.data)
if (filteredFirstBacksRaw.length === 0) {
filteredFirstBacksRaw = undefined
}
}
builds.push({ builds.push({
runeKeystone: build.runeKeystone, runeKeystone: build.runeKeystone,
runes: build.runes, runes: build.runes,
items: c, items: c,
bootsFirstCount: build.bootsFirstCount, bootsFirstCount: scaledBootsFirstCount,
count: c.count, count: c.count,
startItems: [{ data: c.data!, count: c.count }], startItems: [{ data: c.data!, count: c.count }],
suppItems: build.suppItems, suppItems: scaledSuppItems,
boots: build.boots, boots: scaledBoots,
firstBacksRaw: build.firstBacksRaw firstBacksRaw: filteredFirstBacksRaw
}) })
c.data = undefined c.data = undefined
} }

View File

@@ -264,12 +264,8 @@ function deriveTags(node: ItemTree, expectedRegionDistribution?: PlatformCounts)
const totalExpected = REGION_KEYS.reduce((sum, key) => sum + expectedRegionDistribution[key], 0) const totalExpected = REGION_KEYS.reduce((sum, key) => sum + expectedRegionDistribution[key], 0)
if (totalExpected > 0) { if (totalExpected > 0) {
// Tag if the item is significantly more popular in a region (>= 1.5x expected rate) // Tag if one region accounts for >= 60% of the normalized distribution
// and has a minimum absolute percentage (>= 10%) // Normalized value = actual percentage / expected percentage ratio
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 }> = [ const regionTags: Array<{ key: keyof PlatformCounts; tag: ItemTag }> = [
{ key: 'euw', tag: 'region_euw' }, { key: 'euw', tag: 'region_euw' },
{ key: 'eun', tag: 'region_eun' }, { key: 'eun', tag: 'region_eun' },
@@ -277,12 +273,23 @@ function deriveTags(node: ItemTree, expectedRegionDistribution?: PlatformCounts)
{ key: 'kr', tag: 'region_kr' } { key: 'kr', tag: 'region_kr' }
] ]
for (const { key, tag } of regionTags) { // Calculate normalized values (actual/expected ratio) for each region
const normalizedValues = regionTags.map(({ key, tag }) => {
const expectedPct = expectedRegionDistribution[key] / totalExpected const expectedPct = expectedRegionDistribution[key] / totalExpected
const actualPct = node.platformCount[key] / totalRegionCount const actualPct = node.platformCount[key] / totalRegionCount
const normalizedValue = expectedPct > 0 ? actualPct / expectedPct : 0
return { tag, value: normalizedValue }
})
if (actualPct >= expectedPct * SIGNIFICANCE_THRESHOLD && actualPct >= MINIMUM_PCT) { const totalNormalized = normalizedValues.reduce((sum, { value }) => sum + value, 0)
// Tag the region if it accounts for >= 60% of the normalized distribution
if (totalNormalized > 0) {
for (const { tag, value } of normalizedValues) {
if (value / totalNormalized >= 0.6) {
tags.push(tag) tags.push(tag)
break // Only tag the most dominant region
}
} }
} }
} }

View File

@@ -82,6 +82,7 @@ export interface ItemSet {
export interface FirstBackData { export interface FirstBackData {
timestamp: number timestamp: number
itemSet: ItemSet itemSet: ItemSet
startItemId?: number
} }
/** /**