Compare commits

..

41 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
686962b678 feat/frontend: add tooltips for runes
All checks were successful
pipeline / lint-and-format (push) Successful in 4m18s
pipeline / build-and-push-images (push) Successful in 1m21s
2026-04-30 13:35:28 +02:00
a467046c55 fix: fix typing errors
All checks were successful
pipeline / lint-and-format (push) Successful in 4m17s
pipeline / build-and-push-images (push) Successful in 1m13s
2026-04-30 13:17:35 +02:00
a0b3e49759 fix: remove unused logs
All checks were successful
pipeline / lint-and-format (push) Successful in 4m35s
pipeline / build-and-push-images (push) Successful in 1m21s
2026-04-30 10:55:40 +02:00
7051ace13f refactor: remove patch_detector and use gameVersion field in match_collector
All checks were successful
pipeline / lint-and-format (push) Successful in 4m20s
pipeline / build-and-push-images (push) Successful in 1m20s
2026-04-30 10:37:42 +02:00
e1ab81854a refactor: make match-collector export its types, and consume them in frontend
All checks were successful
pipeline / lint-and-format (push) Successful in 4m22s
pipeline / build-and-push-images (push) Successful in 2m11s
2026-04-30 00:06:53 +02:00
db2ca353c5 feat: first back recording and display (#12)
All checks were successful
pipeline / lint-and-format (push) Successful in 4m35s
pipeline / build-and-push-images (push) Successful in 1m39s
Record first backs, group them by item sets and show the most popular ones, with gold and %, in the frontend.
2026-04-28 20:10:20 +02:00
7712abe3f0 fix: fix parsing of mercurial in dragon-item-parser
All checks were successful
Dragon Item Parser CI / build-and-test (push) Successful in 13s
pipeline / lint-and-format (push) Successful in 4m46s
pipeline / build-and-push-images (push) Successful in 2m19s
2026-04-27 13:31:18 +02:00
af51d61e0c fix: fix parsing of hextech gunblade and mejais on dragon-item-parser
All checks were successful
Dragon Item Parser CI / build-and-test (push) Successful in 12s
pipeline / lint-and-format (push) Successful in 4m28s
pipeline / build-and-push-images (push) Successful in 2m18s
2026-04-27 12:33:22 +02:00
4a80540243 fix: fix pipeline docker build with new external lib
All checks were successful
pipeline / lint-and-format (push) Successful in 5m45s
pipeline / build-and-push-images (push) Successful in 2m21s
2026-04-27 12:02:40 +02:00
0b2d00ad0b feat: better item tooltips
Some checks failed
Dragon Item Parser CI / build-and-test (push) Successful in 13s
pipeline / lint-and-format (push) Successful in 4m35s
pipeline / build-and-push-images (push) Failing after 25s
2026-04-27 00:31:31 +02:00
0e0a12513e fix: fix lint by using polyfill
All checks were successful
pipeline / lint-and-format (push) Successful in 4m40s
pipeline / build-and-push-images (push) Successful in 2m25s
2026-04-26 01:19:32 +02:00
e82ad73de1 dragon-item-parser: introduce item parser library
Some checks failed
Dragon Item Parser CI / build-and-test (push) Successful in 1m3s
pipeline / lint-and-format (push) Failing after 4m8s
pipeline / build-and-push-images (push) Has been skipped
2026-04-25 23:53:45 +02:00
a98e3c6589 deps/match_collector: dependency bump
All checks were successful
pipeline / lint-and-format (push) Successful in 4m26s
pipeline / build-and-push-images (push) Successful in 56s
2026-04-23 20:04:02 +02:00
c976f340e6 refactor/match_collector: change folder structure
Some checks are pending
pipeline / lint-and-format (push) Successful in 4m51s
pipeline / build-and-push-images (push) Has started running
2026-04-23 18:35:37 +02:00
360be86c10 refactor/match_collector: refactor platform handling logic 2026-04-23 18:08:17 +02:00
a5728a147f feat: tag items depending on region and gold state when bought
All checks were successful
pipeline / lint-and-format (push) Successful in 4m29s
pipeline / build-and-push-images (push) Successful in 1m28s
2026-04-18 21:08:58 +02:00
17024f91a8 fix/dev: fix devscripts for platform 2026-04-18 14:43:56 +02:00
2c774caf5f fix/match_collector: fix platform routing for match api
All checks were successful
pipeline / lint-and-format (push) Successful in 5m24s
pipeline / build-and-push-images (push) Successful in 1m0s
2026-04-17 16:47:26 +02:00
dae65c8fa2 Allow collecting data from EUNE, NA, KR on top of EUW
All checks were successful
pipeline / lint-and-format (push) Successful in 4m44s
pipeline / build-and-push-images (push) Successful in 4m7s
- match_collector: query API and build collections for each platform
- match_collector: aggregate champion stats of each platform in one collection with platform annotations
- frontend: replace stats to count matches in platform-specific collections
- frontend: replace "EUW Challengers" with all supported platforms
- dev: adapted scripts to count match in platforms
2026-04-17 16:25:19 +02:00
0f84b9a707 match_collector: better typing in index.ts 2026-04-17 15:02:15 +02:00
b7435f0884 match_collector: track gold advantage when items are bought
also add api.ts with Riot API types
2026-04-17 10:51:21 +02:00
19a9226dac Add "EUW Challenger only" mention
All checks were successful
pipeline / lint-and-format (push) Successful in 4m17s
pipeline / build-and-push-images (push) Successful in 1m39s
2026-04-05 12:51:23 +02:00
60c7f9bb2c Merge pull request 'frontend-v2: Many changes to frontend (and backend) to unify build and have build variants' (#11) from frontend-v2 into main
All checks were successful
pipeline / lint-and-format (push) Successful in 4m54s
pipeline / build-and-push-images (push) Successful in 3m20s
Reviewed-on: #11
2026-03-06 23:41:22 +00:00
35563c2fc0 frontend: fix mobile layout for runes text 2026-03-07 00:23:34 +01:00
271c2b26d8 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
2026-03-06 23:33:02 +01:00
930cbf5a18 frontend: add item tooltip, refactor with itemicon component 2026-03-01 13:42:01 +01:00
f6cf2c8a8c frontend: fix compactruneselector on mobile 2026-02-28 13:53:37 +01:00
8c9da868f4 frontend: lint and format 2026-02-28 13:47:46 +01:00
45fa841f80 frontend: make sidebar smaller 2026-02-28 13:44:27 +01:00
c362d6b12a frontend: refactor build viewer a bit more 2026-02-28 13:38:14 +01:00
7833780bcb frontend: refactor of the new build viewer
extracting the logic into composables
2026-02-28 13:29:33 +01:00
83 changed files with 12155 additions and 8228 deletions

32
.dockerignore Normal file
View File

@@ -0,0 +1,32 @@
# Dependencies
**/node_modules
# Build outputs
**/dist
**/.output
**/.nuxt
# Database data
dev/data/db
# Git
**/.git
# IDE
**/.idea
**/.vscode
# Environment files
**/.env
**/.env.*
# Logs
**/*.log
**/logs
# Test coverage
**/coverage
# OS files
.DS_Store
Thumbs.db

View File

@@ -0,0 +1,49 @@
name: Dragon Item Parser CI
on:
push:
paths:
- 'dragon-item-parser/**'
branches: [main]
pull_request:
paths:
- 'dragon-item-parser/**'
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: dragon-item-parser
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Check formatting
run: npm run format:check
- name: Run linting
run: npm run lint
- name: Run tests
run: npm test
- name: Build
run: npm run build
- name: Verify build output
run: |
test -f dist/index.js
test -f dist/index.d.ts
test -f dist/item.js
test -f dist/item.d.ts

View File

@@ -43,18 +43,6 @@ jobs:
working-directory: ./match_collector
run: npm run format:check
- name: Install dependencies for patch_detector
working-directory: ./patch_detector
run: npm ci
- name: Lint patch_detector
working-directory: ./patch_detector
run: npm run lint
- name: Check formatting for patch_detector
working-directory: ./patch_detector
run: npm run format:check
build-and-push-images:
needs: lint-and-format
runs-on: ubuntu-latest
@@ -72,25 +60,18 @@ jobs:
- name: Build and push frontend docker image
uses: docker/build-push-action@v6
with:
context: ./frontend
context: .
file: ./frontend/Dockerfile
push: true
tags: |
git.vhaudiquet.fr/vhaudiquet/lolstats-frontend:latest
git.vhaudiquet.fr/vhaudiquet/lolstats-frontend:${{ github.sha }}
- name: Build and push patch_detector docker image
uses: docker/build-push-action@v6
with:
context: ./patch_detector
push: true
tags: |
git.vhaudiquet.fr/vhaudiquet/lolstats-patch_detector:latest
git.vhaudiquet.fr/vhaudiquet/lolstats-patch_detector:${{ github.sha }}
- name: Build and push match_collector docker image
uses: docker/build-push-action@v6
with:
context: ./match_collector
context: .
file: ./match_collector/Dockerfile
push: true
tags: |
git.vhaudiquet.fr/vhaudiquet/lolstats-match_collector:latest

View File

@@ -5,10 +5,9 @@ https://buildpath.win
## Architecture
BuildPath is made of four components:
BuildPath is made of three components:
- a MongoDB document database
- `patch_detector`, a daemon which runs periodically to detect new League of Legends patch from official APIs
- `match_collector`, a daemon which runs periodically to collect matches from League of Legends challenger players on current patch, and generate statistics from those matches, saving them in the database
- `match_collector`, a daemon which runs periodically to collect matches from League of Legends challenger players, automatically organizing them by patch version (extracted from match data), and generating statistics
- `frontend`, which is a Nuxt.JS project hosting an API serving the statistics but also the full web frontend (Vue.JS)
## Development
@@ -19,9 +18,9 @@ Developing BuildPath requires Docker for a local MongoDB instance, and a NodeJS/
Then, for the first-time setup:
```bash
# Install npm and node
sudo apt install npm nodejs # Ubuntu
sudo pacman -S npm nodejs # Arch
sudo dnf install nodejs # Fedora
sudo apt install npm nodejs node-typescript # Ubuntu/Debian
sudo pacman -S npm nodejs typescript # Arch
sudo dnf install nodejs typescript # Fedora
# Install docker. Follow instructions on
# https://docs.docker.com/engine/install/
@@ -30,7 +29,6 @@ sudo dnf install nodejs # Fedora
cd dev && npm i && cd .. # Install dependencies for the dev environment
cd frontend && npm i && cd .. # Install dependencies for frontend
cd match_collector && npm i && cd .. # Install dependencies for match_collector
cd patch_detector && npm i && cd .. # Install dependencies for patch_detector
```
BuildPath needs data to work, either for generating statistics in the `match_collector` or for the frontend.

View File

@@ -1,34 +0,0 @@
#!/usr/bin/env node
import { spawn } from 'child_process';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Cache directory - use dev/cdragon by default
const cacheDir = process.env.CDRAGON_CACHE_DIR || path.join(__dirname, '..', 'data', 'cdragon');
// Dev MongoDB credentials (matching docker-compose.yml defaults)
const mongoUser = process.env.MONGO_USER || 'root';
const mongoPass = process.env.MONGO_PASS || 'password';
const mongoHost = process.env.MONGO_HOST || 'localhost:27017';
// Run patch_detector with the cache directory and dev MongoDB credentials
const patchDetector = spawn('npx', ['tsx', '../patch_detector/index.ts'], {
cwd: path.join(__dirname, '..'),
env: {
...process.env,
NODE_ENV: 'development',
CDRAGON_CACHE_DIR: cacheDir,
MONGO_USER: mongoUser,
MONGO_PASS: mongoPass,
MONGO_HOST: mongoHost
},
stdio: 'inherit'
});
patchDetector.on('close', (code) => {
process.exit(code || 0);
});

View File

@@ -20,23 +20,70 @@ async function setupDatabase() {
// Check if data directory exists and has files
const dataDir = path.join(__dirname, '../data');
const patchFile = path.join(dataDir, "patches.json");
if(!fs.existsSync(dataDir) || !fs.existsSync(patchFile)) {
// Try to get latest patch version from existing data files or database
let latestPatch = await getLatestPatchVersion();
// If no patch found, download snapshot
if (!latestPatch) {
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
console.log('🚫 No data files found. Downloading latest snapshot...');
}
console.log('🚫 No match data found. Downloading latest snapshot...');
await downloadAndExtractSnapshot();
// Try again after download
latestPatch = await getLatestPatchVersion();
}
// Get latest patch version
const latestPatch = await getLatestPatchVersion();
if (!latestPatch) {
console.error('❌ Could not determine latest patch version');
console.log('💡 Make sure you have match data files in the data directory');
process.exit(1);
}
console.log(`🎯 Latest patch version: ${latestPatch}`);
// Check if data directory exists and has files
// Support both old format (patch_matches.json) and new platform-specific format (patch_PLATFORM_matches.json)
// Also support both "XX.Y" and "XX.Y.Z" patch formats in filenames
console.log('🔍 Checking for data files...');
const dataFiles = [
{ path: 'patches.json', required: true, description: 'Patches data' },
{ path: `${latestPatch}_matches.json`, required: true, description: 'Match data' }
];
const platforms = ['EUW1', 'EUN1', 'NA1', 'KR'];
const dataFiles = [];
// Check for platform-specific match files
// Files may be named with either "16.8" or "16.8.1" format
let foundPlatformFiles = [];
for (const platform of platforms) {
// Try both formats: "16.8_PLATFORM.json" and "16.8.1_PLATFORM.json"
const files = fs.readdirSync(dataDir);
const matchFile = files.find(f => {
const match = f.match(/^(\d+\.\d+(?:\.\d+)?)_([A-Z0-9]+)\.json$/);
const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
return match && patchFromName === latestPatch && match[2] === platform;
});
if (matchFile) {
foundPlatformFiles.push(platform);
dataFiles.push({ path: matchFile, required: false, description: `Match data for ${platform}` });
}
}
// If no platform-specific files found, look for old format
if (foundPlatformFiles.length === 0) {
// Try to find any match file for this patch
const files = fs.readdirSync(dataDir);
const matchFile = files.find(f => {
const match = f.match(/^(\d+\.\d+(?:\.\d+)?)(?:_matches)?\.json$/);
const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
return match && patchFromName === latestPatch;
});
if (matchFile) {
dataFiles.push({ path: matchFile, required: true, description: 'Match data' });
} else {
dataFiles.push({ path: `${latestPatch}_matches.json`, required: true, description: 'Match data' });
}
}
let filesExist = true;
for (const file of dataFiles) {
@@ -74,27 +121,41 @@ async function setupDatabase() {
// 4. Wait for MongoDB to be ready
await waitForMongoDB();
// 5. Import patches data
console.log('📦 Importing patches data...');
await importPatchesData();
// 6. Check existing matches count and import if needed
// 5. Check existing matches count and import if needed
console.log('Checking existing matches count...');
// Check for platform-specific collections or fall back to old format
const existingPlatforms = await getExistingPlatforms(latestPatch);
if (existingPlatforms.length > 0) {
console.log(`📊 Found platform-specific collections: ${existingPlatforms.join(', ')}`);
let totalMatches = 0;
for (const platform of existingPlatforms) {
const count = await getMatchCount(latestPatch, platform);
console.log(` ${platform}: ${count} matches`);
totalMatches += count;
}
console.log(`📊 Total matches in database: ${totalMatches}`);
if (totalMatches < 100) {
console.log('📥 Importing matches (this may take a while)...');
await importMatchesData(latestPatch, foundPlatformFiles);
} else {
console.log('✅ Skipping matches import - sufficient data already present');
}
} else {
const matchCount = await getMatchCount(latestPatch);
console.log(`📊 Current matches in database: ${matchCount}`);
if (matchCount < 100) {
console.log('📥 Importing matches (this may take a while)...');
await importMatchesData(latestPatch);
await importMatchesData(latestPatch, foundPlatformFiles);
} else {
console.log('✅ Skipping matches import - sufficient data already present');
}
}
// 7. Fetch CDragon data for the current patch
console.log('🎮 Fetching CDragon data...');
await fetchCDragonData();
// 8. Run match collector to generate stats
// 7. Run match collector to generate stats (this also handles CDragon caching)
console.log('📊 Generating champion stats...');
await generateChampionStats();
@@ -109,48 +170,70 @@ async function setupDatabase() {
}
async function getLatestPatchVersion() {
const dataDir = path.join(__dirname, '../data');
// First, try to get patch from match data files (format: PATCH_PLATFORM.json or PATCH_matches.json)
if (fs.existsSync(dataDir)) {
const files = fs.readdirSync(dataDir);
const patches = new Set();
for (const file of files) {
// Match patterns like "16.8.1_EUW1.json" or "15.1_EUW1.json" or "15.1_matches.json" or "15.1.json"
// Patch version can be either "XX.Y" or "XX.Y.Z" format
const match = file.match(/^(\d+\.\d+(?:\.\d+)?)(?:_[A-Z0-9]+)?(?:_matches)?\.json$/);
if (match) {
// Normalize to "XX.Y" format (strip the third part if present)
const patch = match[1].split('.').slice(0, 2).join('.');
patches.add(patch);
}
}
if (patches.size > 0) {
// Sort patches and return the latest (highest version number)
const sortedPatches = Array.from(patches).sort((a, b) => {
const [aMajor, aMinor] = a.split('.').map(Number);
const [bMajor, bMinor] = b.split('.').map(Number);
if (aMajor !== bMajor) return bMajor - aMajor;
return bMinor - aMinor;
});
return sortedPatches[0];
}
}
// Fallback: try to get from database collections
try {
const filePath = path.join(__dirname, '../data/patches.json');
if(!fs.existsSync(filePath)) {
return null;
const client = new MongoClient(getMongoUri());
await client.connect();
const db = client.db('matches');
const collections = await db.listCollections().toArray();
const collectionNames = collections.map(c => c.name);
const patches = new Set();
for (const name of collectionNames) {
// Collection names are either "patch_platform" or just "patch"
const patch = name.split('_')[0];
if (patch && /^\d+\.\d+$/.test(patch)) {
patches.add(patch);
}
}
const fileContent = fs.readFileSync(filePath, 'utf8');
await client.close();
// Check if it's line-delimited JSON or array format
let patchesData;
if (fileContent.trim().startsWith('[')) {
// Array format
patchesData = JSON.parse(fileContent);
if (!Array.isArray(patchesData)) {
throw new Error('Patches data should be an array');
if (patches.size > 0) {
const sortedPatches = Array.from(patches).sort((a, b) => {
const [aMajor, aMinor] = a.split('.').map(Number);
const [bMajor, bMinor] = b.split('.').map(Number);
if (aMajor !== bMajor) return bMajor - aMajor;
return bMinor - aMinor;
});
return sortedPatches[0];
}
} else {
// Line-delimited JSON format
patchesData = fileContent.split('\n')
.filter(line => line.trim() !== '')
.map(line => JSON.parse(line));
}
// Convert dates to Date objects for proper sorting
patchesData = patchesData.map(patch => ({
...patch,
date: new Date(patch.date.$date || patch.date)
}));
// Sort patches by date (newest first) and get the latest
const sortedPatches = patchesData.sort((a, b) => b.date - a.date);
const latestPatch = sortedPatches[0];
if (!latestPatch || !latestPatch.patch) {
throw new Error('Could not find patch version in patches data');
}
return latestPatch.patch;
} catch (error) {
console.error('❌ Failed to get latest patch version:', error);
throw error;
// Database not available, continue with other methods
}
return null;
}
async function downloadAndExtractSnapshot() {
@@ -217,72 +300,51 @@ async function waitForMongoDB() {
}
}
async function importPatchesData() {
const client = new MongoClient(getMongoUri());
await client.connect();
async function importMatchesData(patchVersion, foundPlatformFiles = []) {
const dataDir = path.join(__dirname, '../data');
const files = fs.readdirSync(dataDir);
try {
const filePath = path.join(__dirname, '../data/patches.json');
const fileContent = fs.readFileSync(filePath, 'utf8');
// If platform-specific files were found, import each one
if (foundPlatformFiles.length > 0) {
for (const platform of foundPlatformFiles) {
// Find the actual file for this platform (could be "16.8_PLATFORM.json" or "16.8.1_PLATFORM.json")
const matchFile = files.find(f => {
const match = f.match(/^(\d+\.\d+(?:\.\d+)?)_([A-Z0-9]+)\.json$/);
const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
return match && patchFromName === patchVersion && match[2] === platform;
});
// Check if it's line-delimited JSON or array format
let patchesData;
if (fileContent.trim().startsWith('[')) {
// Array format
patchesData = JSON.parse(fileContent);
if (!Array.isArray(patchesData)) {
throw new Error('Patches data should be an array');
if (matchFile) {
const matchesFile = path.join(dataDir, matchFile);
const collectionName = `${patchVersion}_${platform}`;
console.log(`📥 Importing matches for ${platform}...`);
execSync(
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
{
stdio: 'inherit',
env: { ...process.env, MONGO_URI: getMongoUri() }
}
);
console.log(`✅ Matches import completed for ${platform}`);
} else {
console.log(`⚠️ No match file found for ${platform}`);
}
}
} else {
// Line-delimited JSON format
patchesData = fileContent.split('\n')
.filter(line => line.trim() !== '')
.map(line => {
const doc = JSON.parse(line);
return convertMongoExtendedJson(doc);
});
}
// Convert any extended JSON in array format too
if (Array.isArray(patchesData)) {
patchesData = patchesData.map(doc => convertMongoExtendedJson(doc));
}
// Sort patches by date (newest first)
patchesData.sort((a, b) => {
const dateA = new Date(a.date || a.date.$date || 0);
const dateB = new Date(b.date || b.date.$date || 0);
return dateB - dateA; // Descending order (newest first)
// Fall back to old format (single file without platform suffix)
// Find any match file for this patch
const matchFile = files.find(f => {
const match = f.match(/^(\d+\.\d+(?:\.\d+)?)(?:_matches)?\.json$/);
const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
return match && patchFromName === patchVersion;
});
const db = client.db('patches');
const collection = db.collection('patches');
// Clear existing data
await collection.deleteMany({});
// Insert sorted data
const result = await collection.insertMany(patchesData);
console.log(`✅ Imported ${result.insertedCount} patches (sorted by date)`);
// Create index
await collection.createIndex({ date: -1 });
console.log('✅ Created patches index');
} catch (error) {
console.error('❌ Failed to import patches:', error);
throw error;
} finally {
await client.close();
}
}
async function importMatchesData(patchVersion) {
const matchesFile = path.join(__dirname, '../data', `${patchVersion}_matches.json`);
if (matchFile) {
const matchesFile = path.join(dataDir, matchFile);
const collectionName = patchVersion;
try {
const result = execSync(
execSync(
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
{
stdio: 'inherit',
@@ -290,6 +352,10 @@ async function importMatchesData(patchVersion) {
}
);
console.log('✅ Matches import completed');
} else {
console.log(`⚠️ No match file found for patch ${patchVersion}`);
}
}
} catch (error) {
console.error('❌ Failed to import matches:', error);
throw error;
@@ -311,12 +377,11 @@ async function generateChampionStats() {
MONGO_HOST: 'localhost'
};
// Run the match collector directly with tsx (TypeScript executor) instead of docker compose
const matchCollectorPath = path.join(__dirname, '../../match_collector/index.ts');
execSync(`npx tsx ${matchCollectorPath}`, {
// Run the match collector
execSync(`npm run dev`, {
stdio: 'inherit',
env: env,
cwd: path.join(__dirname, '../..')
cwd: path.join(__dirname, '../../match_collector')
});
console.log('✅ Champion stats generated');
@@ -326,31 +391,14 @@ async function generateChampionStats() {
}
}
async function fetchCDragonData() {
try {
console.log('🔄 Running CDragon fetcher...');
// Run the fetch-cdragon script
const fetchCDragonPath = path.join(__dirname, 'fetch-cdragon.js');
execSync(`node ${fetchCDragonPath}`, {
stdio: 'inherit',
cwd: path.join(__dirname, '..')
});
console.log('✅ CDragon data fetched');
} catch (error) {
console.error('❌ Failed to fetch CDragon data:', error);
throw error;
}
}
async function getMatchCount(patchVersion) {
async function getMatchCount(patchVersion, platform = null) {
const client = new MongoClient(getMongoUri());
await client.connect();
try {
const db = client.db('matches');
const collection = db.collection(patchVersion);
const collectionName = platform ? `${patchVersion}_${platform}` : patchVersion;
const collection = db.collection(collectionName);
const count = await collection.countDocuments();
return count;
} catch (error) {
@@ -361,6 +409,33 @@ async function getMatchCount(patchVersion) {
}
}
async function getExistingPlatforms(patchVersion) {
const client = new MongoClient(getMongoUri());
await client.connect();
try {
const db = client.db('matches');
const collections = await db.listCollections().toArray();
const collectionNames = collections.map(c => c.name);
const platforms = ['EUW1', 'EUN1', 'NA1', 'KR'];
const existingPlatforms = [];
for (const platform of platforms) {
if (collectionNames.includes(`${patchVersion}_${platform}`)) {
existingPlatforms.push(platform);
}
}
return existingPlatforms;
} catch (error) {
console.error('❌ Failed to get existing platforms:', error);
return [];
} finally {
await client.close();
}
}
function getMongoUri() {
return process.env.MONGO_URI || 'mongodb://root:password@localhost:27017/buildpath?authSource=admin';
}
@@ -476,9 +551,6 @@ if (import.meta.url === `file://${process.argv[1]}`) {
case 'generate-stats':
generateChampionStats().catch(console.error);
break;
case 'import-patches':
importPatchesData().catch(console.error);
break;
case 'match-count':
if (args[1]) {
getMatchCount(args[1]).then(count => console.log(`Match count: ${count}`)).catch(console.error);
@@ -499,7 +571,6 @@ if (import.meta.url === `file://${process.argv[1]}`) {
export {
setupDatabase,
importPatchesData,
importMatchesData,
generateChampionStats,
checkDatabaseStatus,

3
dragon-item-parser/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
*.log

View File

@@ -7,6 +7,9 @@ export default defineConfig([
js.configs.recommended,
tseslint.configs.recommended,
prettier,
{
ignores: ['dist/**', 'node_modules/**']
},
{
rules: {
semi: 'off',

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,53 @@
{
"name": "dragon-item-parser",
"version": "1.0.0",
"description": "Parse League of Legends item stats from CommunityDragon data",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepublishOnly": "npm run build"
},
"keywords": [
"league-of-legends",
"cdragon",
"communitydragon",
"item",
"parser",
"lol"
],
"author": "",
"license": "MIT",
"repository": {
"type": "git",
"url": ""
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.3",
"typescript": "^5.0.0",
"typescript-eslint": "^8.53.1",
"vitest": "^3.0.0"
}
}

View File

@@ -0,0 +1,19 @@
export type {
ItemStats,
CDragonItem,
ItemWithStats,
ItemEffect,
TextSegment,
ScalingValue,
DamageValue,
ParsedDescription,
ItemWithParsedDescription
} from './item.js'
export {
parseItemStats,
parseItem,
parseItems,
parseItemDescription,
parseItemFull,
parseItemsFull
} from './item.js'

View File

@@ -0,0 +1,787 @@
/**
* Item stats parsed from CommunityDragon item description HTML
*/
export interface ItemStats {
// Offensive stats
attackDamage?: number
abilityPower?: number
attackSpeed?: number
criticalStrikeChance?: number
criticalStrikeDamage?: number
lifeSteal?: number
omnivamp?: number
physicalVamp?: number
spellVamp?: number
// Defensive stats
health?: number
armor?: number
magicResist?: number
// Resource stats
mana?: number
baseManaRegen?: number
baseHealthRegen?: number
// Movement stats
moveSpeed?: number
// Ability stats
abilityHaste?: number
// Penetration stats (usually percentages)
armorPenetration?: number
magicPenetration?: number
lethality?: number
magicPenetrationFlat?: number
// Other percentage stats
healAndShieldPower?: number
tenacity?: number
slowResist?: number
}
/**
* Represents a scaling value (e.g., "5% bonus health")
*/
export interface ScalingValue {
value: number
isPercentage: boolean
scaleType:
| 'mana'
| 'health'
| 'ap'
| 'ad'
| 'armor'
| 'mr'
| 'level'
| 'bonusHealth'
| 'bonusMana'
| 'maxHealth'
}
/**
* Represents a damage value with type
*/
export interface DamageValue {
value: number
isPercentage: boolean
damageType: 'magic' | 'physical' | 'true'
}
/**
* Represents a colored text segment
*/
export interface TextSegment {
type:
| 'text'
| 'highlight'
| 'passive'
| 'active'
| 'keyword'
| 'keywordMajor'
| 'keywordStealth'
| 'status'
| 'speed'
| 'scaleMana'
| 'scaleHealth'
| 'scaleAP'
| 'scaleAD'
| 'scaleArmor'
| 'scaleMR'
| 'scaleLevel'
| 'scaleBonusHealth'
| 'scaleBonusMana'
| 'scaleMaxHealth'
| 'spellName'
| 'unique'
| 'rarityMythic'
| 'rarityLegendary'
| 'rarityGeneric'
| 'magicDamage'
| 'physicalDamage'
| 'trueDamage'
| 'healing'
| 'shield'
| 'attention'
| 'onHit'
| 'color'
content: string
color?: string // For custom color spans
scaling?: ScalingValue
damage?: DamageValue
}
/**
* Represents an item effect (passive, active, unique, etc.)
*/
export interface ItemEffect {
type: 'passive' | 'active' | 'unique' | 'mythic' | 'legendary' | 'epic'
name?: string
description: TextSegment[]
isUnique?: boolean
}
/**
* Parsed item description structure
*/
export interface ParsedDescription {
stats: ItemStats
effects: ItemEffect[]
rules?: TextSegment[]
flavorText?: TextSegment[]
rarity?: 'mythic' | 'legendary' | 'epic'
}
/**
* Stat name mappings from CDragon description text to stat keys
*/
const STAT_MAPPINGS: Record<string, keyof ItemStats> = {
'Attack Damage': 'attackDamage',
'Ability Power': 'abilityPower',
'Attack Speed': 'attackSpeed',
'Critical Strike Chance': 'criticalStrikeChance',
'Critical Strike Damage': 'criticalStrikeDamage',
'Life Steal': 'lifeSteal',
Omnivamp: 'omnivamp',
'Physical Vamp': 'physicalVamp',
'Spell Vamp': 'spellVamp',
Health: 'health',
Armor: 'armor',
'Magic Resist': 'magicResist',
Mana: 'mana',
'Base Mana Regen': 'baseManaRegen',
'Base Health Regen': 'baseHealthRegen',
'Move Speed': 'moveSpeed',
'Ability Haste': 'abilityHaste',
'Armor Penetration': 'armorPenetration',
'Magic Penetration': 'magicPenetration',
Lethality: 'lethality',
'Heal and Shield Power': 'healAndShieldPower',
Tenacity: 'tenacity',
'Slow Resist': 'slowResist'
}
/**
* Parse a stat value string to a number
* Handles both flat values (e.g., "25") and percentages (e.g., "25%")
*/
function parseStatValue(valueStr: string): { value: number; isPercentage: boolean } {
const trimmed = valueStr.trim()
const isPercentage = trimmed.includes('%')
const numStr = trimmed.replace('%', '').replace(',', '').trim()
const value = parseFloat(numStr)
return { value, isPercentage }
}
/**
* Extract stats section from the description HTML
*/
function extractStatsSection(description: string): string | null {
const statsMatch = description.match(/<stats>(.*?)<\/stats>/s)
return statsMatch ? statsMatch[1] : null
}
/**
* Parse individual stat lines from the stats section
* Format: <attention> value </attention> statName
*/
function parseStatLines(
statsSection: string
): Array<{ value: number; isPercentage: boolean; statName: string }> {
const results: Array<{ value: number; isPercentage: boolean; statName: string }> = []
// Match patterns like: <attention> 25</attention> Move Speed
// or: <attention> 25%</attention> Attack Speed
const statRegex = /<attention>\s*([^<]+)<\/attention>\s*([^<]+)/g
let match
while ((match = statRegex.exec(statsSection)) !== null) {
const valueStr = match[1]
const statName = match[2].trim()
const { value, isPercentage } = parseStatValue(valueStr)
if (!isNaN(value) && statName) {
results.push({ value, isPercentage, statName })
}
}
return results
}
/**
* Parse item stats from CDragon description HTML
*
* @param description - The HTML description string from CDragon items.json
* @returns Parsed ItemStats object with all recognized stats
*
* @example
* ```ts
* const stats = parseItemStats(
* '<mainText><stats><attention> 25</attention> Move Speed</stats><br><br></mainText>'
* )
* // Returns: { moveSpeed: 25 }
* ```
*/
export function parseItemStats(description: string): ItemStats {
const stats: ItemStats = {}
if (!description) {
return stats
}
const statsSection = extractStatsSection(description)
if (!statsSection) {
return stats
}
const statLines = parseStatLines(statsSection)
for (const { value, statName } of statLines) {
const statKey = STAT_MAPPINGS[statName]
if (statKey) {
// For percentage stats that are stored as decimals (e.g., 25% -> 25)
// We store the percentage value as-is for consistency with game data
stats[statKey] = value
}
}
return stats
}
/**
* Remove HTML tags and get plain text
*/
function stripHtmlTags(html: string): string {
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
/**
* Parse scaling tags and extract scaling info
*/
function parseScalingTag(
tagName: string,
content: string
): { scaling?: ScalingValue; text: string } {
const scaleTypeMap: Record<string, ScalingValue['scaleType']> = {
scaleMana: 'mana',
scaleHealth: 'health',
scaleAP: 'ap',
scaleAD: 'ad',
scaleArmor: 'armor',
scaleMR: 'mr',
scaleLevel: 'level',
scaleBonusHealth: 'bonusHealth',
scaleBonusMana: 'bonusMana',
scaleMaxHealth: 'maxHealth'
}
const scaleType = scaleTypeMap[tagName]
if (!scaleType) {
return { text: content }
}
// Try to extract percentage or flat value
const percentMatch = content.match(/(\d+(?:\.\d+)?)\s*%/)
const flatMatch = content.match(/(\d+(?:\.\d+)?)/)
if (percentMatch) {
return {
scaling: {
value: parseFloat(percentMatch[1]),
isPercentage: true,
scaleType
},
text: content
}
} else if (flatMatch) {
return {
scaling: {
value: parseFloat(flatMatch[1]),
isPercentage: false,
scaleType
},
text: content
}
}
return { text: content }
}
/**
* Parse damage tags (magicDamage, physicalDamage, trueDamage)
*/
function parseDamageTag(tagName: string, content: string): { damage?: DamageValue; text: string } {
const damageTypeMap: Record<string, DamageValue['damageType']> = {
magicDamage: 'magic',
physicalDamage: 'physical',
trueDamage: 'true'
}
const damageType = damageTypeMap[tagName]
if (!damageType) {
return { text: content }
}
// Try to extract damage value
const percentMatch = content.match(/(\d+(?:\.\d+)?)\s*%/)
const flatMatch = content.match(/(\d+(?:\.\d+)?)/)
if (percentMatch) {
return {
damage: {
value: parseFloat(percentMatch[1]),
isPercentage: true,
damageType
},
text: content
}
} else if (flatMatch) {
return {
damage: {
value: parseFloat(flatMatch[1]),
isPercentage: false,
damageType
},
text: content
}
}
return { text: content }
}
/**
* Parse text content with HTML tags into TextSegments
*/
function parseTextSegments(html: string): TextSegment[] {
const segments: TextSegment[] = []
if (!html) return segments
// Process the HTML and convert to segments
// We'll use a simple state machine approach
let remaining = html
let currentText = ''
// Tag type mappings
const tagTypeMap: Record<string, TextSegment['type']> = {
passive: 'passive',
active: 'active',
keyword: 'keyword',
keywordMajor: 'keywordMajor',
keywordStealth: 'keywordStealth',
status: 'status',
speed: 'speed',
scaleMana: 'scaleMana',
scaleHealth: 'scaleHealth',
scaleAP: 'scaleAP',
scaleAD: 'scaleAD',
scaleArmor: 'scaleArmor',
scaleMR: 'scaleMR',
scaleLevel: 'scaleLevel',
scaleBonusHealth: 'scaleBonusHealth',
scaleBonusMana: 'scaleBonusMana',
scaleMaxHealth: 'scaleMaxHealth',
spellName: 'spellName',
attention: 'attention',
magicDamage: 'magicDamage',
physicalDamage: 'physicalDamage',
trueDamage: 'trueDamage',
healing: 'healing',
shield: 'shield',
onHit: 'onHit'
}
// Process tags
while (remaining.length > 0) {
// Check for opening tag
const openMatch = remaining.match(/^<([a-zA-Z]+)(?:\s+color='([^']+)')?\s*>/)
if (openMatch) {
// Push any accumulated text
if (currentText) {
segments.push({ type: 'text', content: currentText })
currentText = ''
}
const tagName = openMatch[1]
const color = openMatch[2]
const fullTag = openMatch[0]
// Find closing tag
const closeTag = new RegExp(`</${tagName}>`, 'i')
const closeMatch = remaining.substring(fullTag.length).match(closeTag)
if (closeMatch && closeMatch.index !== undefined) {
const content = remaining.substring(fullTag.length, fullTag.length + closeMatch.index)
const segmentType = tagTypeMap[tagName] || 'text'
if (tagName === 'font' && color) {
segments.push({ type: 'color', content: stripHtmlTags(content), color })
} else if (tagName.startsWith('scale') && tagTypeMap[tagName]) {
const { scaling, text } = parseScalingTag(tagName, content)
segments.push({
type: segmentType,
content: stripHtmlTags(text),
scaling
})
} else if (tagName.endsWith('Damage') && tagTypeMap[tagName]) {
const { damage, text } = parseDamageTag(tagName, content)
segments.push({
type: segmentType,
content: stripHtmlTags(text),
damage
})
} else if (segmentType !== 'text') {
segments.push({ type: segmentType, content: stripHtmlTags(content) })
} else {
// Unknown tag, just add as text
currentText += stripHtmlTags(content)
}
remaining = remaining.substring(
fullTag.length + (closeMatch.index || 0) + closeMatch[0].length
)
} else {
// No closing tag found, skip the opening tag
remaining = remaining.substring(fullTag.length)
}
} else {
// Add character to current text
currentText += remaining[0]
remaining = remaining.substring(1)
}
}
// Push any remaining text
if (currentText) {
segments.push({ type: 'text', content: currentText })
}
return segments
}
/**
* Extract effects section from description (everything after stats)
*/
function extractEffectsSection(description: string): string {
// Remove stats section first
const withoutStats = description.replace(/<stats>.*?<\/stats>/s, '')
// Remove mainText wrapper
const effects = withoutStats
.replace(/<mainText>/gi, '')
.replace(/<\/mainText>/gi, '')
.replace(/<rules>.*?<\/rules>/gs, '') // Remove rules for now, handle separately
.replace(/<flavorText>.*?<\/flavorText>/gs, '') // Remove flavor for now, handle separately
.trim()
return effects
}
/**
* Parse effects from description HTML
*/
function parseEffects(description: string): ItemEffect[] {
const effects: ItemEffect[] = []
if (!description) return effects
// Extract the effects section (after stats)
const effectsSection = extractEffectsSection(description)
// Split by <br> tags to process line by line
const lines = effectsSection
.split(/<br\s*\/?>/gi)
.map(l => l.trim())
.filter(l => l)
let currentEffect: ItemEffect | null = null
for (const line of lines) {
if (!line) continue
// Check for passive tag at the START of the line (effect header)
// Only treat as a new effect if the line starts with the tag or the tag is the only content
const passiveMatch = line.match(/^<passive>([^<]*)<\/passive>(.*)$/i)
if (passiveMatch) {
const remainingContent = passiveMatch[2].trim()
// If there's content after the tag on the same line, it's part of description
// If the tag is the only content, this is just an effect header
if (!remainingContent) {
// This is an effect header line - start a new effect
if (currentEffect) {
effects.push(currentEffect)
}
currentEffect = {
type: 'passive',
name: passiveMatch[1].trim(),
description: [],
isUnique: false
}
continue
}
// If there's content after, check if it looks like a description (not just a label)
// For now, treat lines starting with passive tag as new effects
if (currentEffect) {
effects.push(currentEffect)
}
currentEffect = {
type: 'passive',
name: passiveMatch[1].trim(),
description: parseTextSegments(remainingContent),
isUnique: false
}
continue
}
// Check for active tag at the START of the line
const activeMatch = line.match(/^<active>([^<]*)<\/active>(.*)$/i)
if (activeMatch) {
const remainingContent = activeMatch[2].trim()
const effectName = activeMatch[1].trim()
// Skip lines that are just "ACTIVE" labels
// This includes: standalone "ACTIVE", or "ACTIVE" followed by cooldown like "(0s)"
if (effectName.toUpperCase() === 'ACTIVE') {
// Skip if it's just "ACTIVE" with no other content, or just a cooldown indicator
if (!remainingContent || remainingContent.match(/^\([^)]*\)\s*$/)) {
continue
}
}
if (!remainingContent) {
// This is an effect header line - start a new effect
if (currentEffect) {
effects.push(currentEffect)
}
currentEffect = {
type: 'active',
name: effectName,
description: [],
isUnique: false
}
continue
}
// If there's content after, it's the description
if (currentEffect) {
effects.push(currentEffect)
}
currentEffect = {
type: 'active',
name: effectName,
description: parseTextSegments(remainingContent),
isUnique: false
}
continue
}
// Check for unique tag at the START of the line
const uniqueMatch = line.match(/^<unique>([^<]*)<\/unique>(.*)$/i)
if (uniqueMatch) {
const remainingContent = uniqueMatch[2].trim()
if (currentEffect) {
effects.push(currentEffect)
}
if (!remainingContent) {
currentEffect = {
type: 'unique',
name: uniqueMatch[1].trim() || undefined,
description: [],
isUnique: true
}
} else {
currentEffect = {
type: 'unique',
name: uniqueMatch[1].trim() || undefined,
description: parseTextSegments(remainingContent),
isUnique: true
}
}
continue
}
// Check for rarity tags
const mythicMatch = line.match(/<rarityMythic>([^<]*)<\/rarityMythic>/i)
if (mythicMatch) {
if (currentEffect) {
effects.push(currentEffect)
}
// Remove the rarity tag from the line before parsing description
const lineWithoutTag = line.replace(/<rarityMythic>[^<]*<\/rarityMythic>/i, '').trim()
currentEffect = {
type: 'mythic',
name: mythicMatch[1].trim() || undefined,
description: parseTextSegments(lineWithoutTag),
isUnique: false
}
continue
}
const legendaryMatch = line.match(/<rarityLegendary>([^<]*)<\/rarityLegendary>/i)
if (legendaryMatch) {
if (currentEffect) {
effects.push(currentEffect)
}
// Remove the rarity tag from the line before parsing description
const lineWithoutTag = line.replace(/<rarityLegendary>[^<]*<\/rarityLegendary>/i, '').trim()
currentEffect = {
type: 'legendary',
name: legendaryMatch[1].trim() || undefined,
description: parseTextSegments(lineWithoutTag),
isUnique: false
}
continue
}
// If we have a current effect, append this line to its description
if (currentEffect) {
const lineSegments = parseTextSegments(line)
currentEffect.description.push(...lineSegments)
} else if (line.trim()) {
// Standalone effect without explicit tag - create as passive
const segments = parseTextSegments(line)
if (segments.length > 0 && segments.some(s => s.content.trim())) {
currentEffect = {
type: 'passive',
description: segments,
isUnique: false
}
}
}
}
// Don't forget the last effect
if (currentEffect) {
effects.push(currentEffect)
}
return effects
}
/**
* Parse rules section from description
*/
function parseRules(description: string): TextSegment[] | undefined {
const rulesMatch = description.match(/<rules>(.*?)<\/rules>/s)
if (!rulesMatch) return undefined
return parseTextSegments(rulesMatch[1])
}
/**
* Parse flavor text section from description
*/
function parseFlavorText(description: string): TextSegment[] | undefined {
const flavorMatch = description.match(/<flavorText>(.*?)<\/flavorText>/s)
if (!flavorMatch) return undefined
return parseTextSegments(flavorMatch[1])
}
/**
* Detect item rarity from description
*/
function detectRarity(description: string): 'mythic' | 'legendary' | 'epic' | undefined {
if (/<rarityMythic>/i.test(description)) return 'mythic'
if (/<rarityLegendary>/i.test(description)) return 'legendary'
if (/<rarityGeneric>/i.test(description)) return 'epic'
return undefined
}
/**
* Parse full item description into structured data
*
* @param description - The HTML description string from CDragon items.json
* @returns ParsedDescription object with stats, effects, rules, and flavor text
*
* @example
* ```ts
* const parsed = parseItemDescription(
* '<mainText><stats><attention> 25</attention> Move Speed</stats><br><br><passive>Enhanced Movement:</passive> +25 Move Speed</mainText>'
* )
* // Returns: { stats: { moveSpeed: 25 }, effects: [...], ... }
* ```
*/
export function parseItemDescription(description: string): ParsedDescription {
return {
stats: parseItemStats(description),
effects: parseEffects(description),
rules: parseRules(description),
flavorText: parseFlavorText(description),
rarity: detectRarity(description)
}
}
/**
* Item data structure from CDragon
*/
export interface CDragonItem {
id: number
name: string
description: string
active?: boolean
inStore?: boolean
from?: number[]
to?: number[]
categories?: string[]
maxStacks?: number
requiredChampion?: string
requiredAlly?: string
price?: number
priceTotal?: number
iconPath: string
}
/**
* Item with parsed stats
*/
export interface ItemWithStats extends CDragonItem {
stats: ItemStats
}
/**
* Item with fully parsed description
*/
export interface ItemWithParsedDescription extends CDragonItem {
parsedDescription: ParsedDescription
}
/**
* Parse a CDragon item and add parsed stats
*/
export function parseItem(item: CDragonItem): ItemWithStats {
return {
...item,
stats: parseItemStats(item.description)
}
}
/**
* Parse an array of CDragon items and add parsed stats
*/
export function parseItems(items: CDragonItem[]): ItemWithStats[] {
return items.map(parseItem)
}
/**
* Parse a CDragon item with full description parsing
*/
export function parseItemFull(item: CDragonItem): ItemWithParsedDescription {
return {
...item,
parsedDescription: parseItemDescription(item.description)
}
}
/**
* Parse an array of CDragon items with full description parsing
*/
export function parseItemsFull(items: CDragonItem[]): ItemWithParsedDescription[] {
return items.map(parseItemFull)
}

View File

@@ -0,0 +1,492 @@
import { describe, it, expect } from 'vitest'
import {
parseItemStats,
parseItem,
parseItems,
parseItemDescription,
parseItemFull
} from '../src/item.js'
describe('parseItemStats', () => {
describe('basic stats', () => {
it('should parse Move Speed', () => {
// Boots (id: 1001)
const description =
'<mainText><stats><attention> 25</attention> Move Speed</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.moveSpeed).toBe(25)
})
it('should parse Attack Damage', () => {
// Long Sword (id: 1036)
const description =
'<mainText><stats><attention> 10</attention> Attack Damage</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.attackDamage).toBe(10)
})
it('should parse Ability Power', () => {
// Amplifying Tome (id: 1052)
const description =
'<mainText><stats><attention> 20</attention> Ability Power</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.abilityPower).toBe(20)
})
it('should parse Health', () => {
// Ruby Crystal (id: 1028)
const description =
'<mainText><stats><attention> 150</attention> Health</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.health).toBe(150)
})
it('should parse Armor', () => {
// Cloth Armor (id: 1029)
const description =
'<mainText><stats><attention> 15</attention> Armor</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.armor).toBe(15)
})
it('should parse Magic Resist', () => {
// Null-Magic Mantle (id: 1033)
const description =
'<mainText><stats><attention> 20</attention> Magic Resist</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.magicResist).toBe(20)
})
it('should parse Mana', () => {
// Sapphire Crystal (id: 1027)
const description =
'<mainText><stats><attention> 300</attention> Mana</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.mana).toBe(300)
})
})
describe('percentage stats', () => {
it('should parse Attack Speed percentage', () => {
// Dagger (id: 1042)
const description =
'<mainText><stats><attention> 10%</attention> Attack Speed</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.attackSpeed).toBe(10)
})
it('should parse Critical Strike Chance', () => {
// Cloak of Agility (id: 1018)
const description =
'<mainText><stats><attention> 15%</attention> Critical Strike Chance</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.criticalStrikeChance).toBe(15)
})
it('should parse Life Steal', () => {
// Vampiric Scepter (id: 1053)
const description =
'<mainText><stats><attention> 15</attention> Attack Damage<br><attention> 7%</attention> Life Steal</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.attackDamage).toBe(15)
expect(stats.lifeSteal).toBe(7)
})
it('should parse Base Mana Regen percentage', () => {
// Faerie Charm (id: 1004)
const description =
'<mainText><stats><attention> 50%</attention> Base Mana Regen</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.baseManaRegen).toBe(50)
})
it('should parse Base Health Regen percentage', () => {
// Rejuvenation Bead (id: 1006)
const description =
'<mainText><stats><attention> 100%</attention> Base Health Regen</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.baseHealthRegen).toBe(100)
})
})
describe('multiple stats', () => {
it("should parse multiple stats from Doran's Blade", () => {
// Doran's Blade (id: 1055)
const description =
'<mainText><stats><attention> 10</attention> Attack Damage<br><attention> 80</attention> Health<br><attention> 2.5%</attention> Omnivamp</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.attackDamage).toBe(10)
expect(stats.health).toBe(80)
expect(stats.omnivamp).toBe(2.5)
})
it("should parse multiple stats from Doran's Ring", () => {
// Doran's Ring (id: 1056)
const description =
'<mainText><stats><attention> 18</attention> Ability Power<br><attention> 90</attention> Health</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.abilityPower).toBe(18)
expect(stats.health).toBe(90)
})
it("should parse multiple stats from Seeker's Armguard", () => {
// Seeker's Armguard (id: 2420)
const description =
'<mainText><stats><attention> 40</attention> Ability Power<br><attention> 25</attention> Armor</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.abilityPower).toBe(40)
expect(stats.armor).toBe(25)
})
it('should parse complex item with many stats', () => {
// Overlord's Bloodmail (id: 2501)
const description =
'<mainText><stats><attention> 30</attention> Attack Damage<br><attention> 550</attention> Health</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.attackDamage).toBe(30)
expect(stats.health).toBe(550)
})
it('should parse item with Ability Haste', () => {
// Unending Despair (id: 2502)
const description =
'<mainText><stats><attention> 400</attention> Health<br><attention> 50</attention> Armor<br><attention> 15</attention> Ability Haste</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.health).toBe(400)
expect(stats.armor).toBe(50)
expect(stats.abilityHaste).toBe(15)
})
it('should parse item with Mana and Ability Haste', () => {
// Blackfire Torch (id: 2503)
const description =
'<mainText><stats><attention> 80</attention> Ability Power<br><attention> 600</attention> Mana<br><attention> 20</attention> Ability Haste</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.abilityPower).toBe(80)
expect(stats.mana).toBe(600)
expect(stats.abilityHaste).toBe(20)
})
})
describe('edge cases', () => {
it('should return empty object for empty description', () => {
const stats = parseItemStats('')
expect(stats).toEqual({})
})
it('should return empty object for description without stats', () => {
const description = '<mainText><stats></stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats).toEqual({})
})
it('should handle description with only passive text', () => {
// Emberknife (id: 1035) - no stats, only passives
const description =
'<mainText><stats></stats><br><br> <passive>7%</passive> Omnivamp against jungle monsters<br><li><passive>Sear:</passive> Damaging jungle monsters burns them for <magicDamage> magic damage</magicDamage> over 5 seconds.</mainText>'
const stats = parseItemStats(description)
expect(stats).toEqual({})
})
it('should handle decimal values', () => {
const description =
'<mainText><stats><attention> 2.5%</attention> Omnivamp</stats><br><br></mainText>'
const stats = parseItemStats(description)
expect(stats.omnivamp).toBe(2.5)
})
})
})
describe('parseItemDescription', () => {
describe('stats parsing', () => {
it('should parse stats from description', () => {
const description =
'<mainText><stats><attention> 75</attention> Attack Damage<br><attention> 25%</attention> Critical Strike Chance</stats><br><br></mainText>'
const parsed = parseItemDescription(description)
expect(parsed.stats.attackDamage).toBe(75)
expect(parsed.stats.criticalStrikeChance).toBe(25)
})
it('should parse Infinity Edge stats', () => {
// Infinity Edge (id: 3031)
const description =
'<mainText><stats><attention> 75</attention> Attack Damage<br><attention> 25%</attention> Critical Strike Chance<br><attention> 30%</attention> Critical Strike Damage</stats><br><br></mainText>'
const parsed = parseItemDescription(description)
expect(parsed.stats.attackDamage).toBe(75)
expect(parsed.stats.criticalStrikeChance).toBe(25)
// Critical Strike Damage is not in our mappings, but should not crash
})
})
describe('effects parsing', () => {
it('should parse passive effect', () => {
// Rabadon's Deathcap (id: 3089)
const description =
'<mainText><stats><attention> 130</attention> Ability Power</stats><br><br><passive>Magical Opus</passive><br>Increases your total <scaleAP>Ability Power by 30%</scaleAP>.</mainText>'
const parsed = parseItemDescription(description)
expect(parsed.effects).toHaveLength(1)
expect(parsed.effects[0].type).toBe('passive')
expect(parsed.effects[0].name).toBe('Magical Opus')
})
it('should parse active effect', () => {
// Zhonya's Hourglass (id: 3157)
const description =
'<mainText><stats><attention> 105</attention> Ability Power<br><attention> 50</attention> Armor</stats><br><br><br> <br><active>Time Stop</active><br>Enter <keyword>Stasis</keyword> for 2.5 seconds.</mainText>'
const parsed = parseItemDescription(description)
expect(parsed.effects).toHaveLength(1)
expect(parsed.effects[0].type).toBe('active')
expect(parsed.effects[0].name).toBe('Time Stop')
})
it('should parse multiple passives', () => {
// Trinity Force (id: 3078)
const description =
'<mainText><stats><attention> 36</attention> Attack Damage<br><attention> 30%</attention> Attack Speed<br><attention> 333</attention> Health<br><attention> 15</attention> Ability Haste</stats><br><br><passive>Spellblade</passive><br>After using an Ability, your next Attack deals <physicalDamage>bonus physical damage</physicalDamage> <OnHit>On-Hit</OnHit>.<br> <br><passive>Quicken</passive><br>Attacking grants <speed>20 Move Speed</speed> for 2 seconds.</mainText>'
const parsed = parseItemDescription(description)
expect(parsed.effects).toHaveLength(2)
expect(parsed.effects[0].type).toBe('passive')
expect(parsed.effects[0].name).toBe('Spellblade')
expect(parsed.effects[1].type).toBe('passive')
expect(parsed.effects[1].name).toBe('Quicken')
})
it('should parse passive with status tag', () => {
// Serylda's Grudge (id: 6694)
const description =
'<mainText><stats><attention> 45</attention> Attack Damage<br><attention> 35%</attention> Armor Penetration<br><attention> 15</attention> Ability Haste</stats><br><br><passive>Bitter Cold</passive><br>Damaging Abilities <status>Slow</status> enemies below 50% Health by 30% for 1 second.</mainText>'
const parsed = parseItemDescription(description)
expect(parsed.effects).toHaveLength(1)
expect(parsed.effects[0].type).toBe('passive')
expect(parsed.effects[0].name).toBe('Bitter Cold')
})
it('should handle item with no effects', () => {
// Infinity Edge has no passive/active tags
const description =
'<mainText><stats><attention> 75</attention> Attack Damage<br><attention> 25%</attention> Critical Strike Chance<br><attention> 30%</attention> Critical Strike Damage</stats><br><br></mainText>'
const parsed = parseItemDescription(description)
expect(parsed.effects).toHaveLength(0)
})
})
describe('text segments', () => {
it('should parse scaleAP tag', () => {
const description =
'<mainText><stats><attention> 130</attention> Ability Power</stats><br><br><passive>Magical Opus</passive><br>Increases your total <scaleAP>Ability Power by 30%</scaleAP>.</mainText>'
const parsed = parseItemDescription(description)
// Find the scaleAP segment
const effect = parsed.effects[0]
const scaleSegment = effect.description.find(s => s.type === 'scaleAP')
expect(scaleSegment).toBeDefined()
expect(scaleSegment?.content).toContain('Ability Power by 30%')
})
it('should parse physicalDamage tag', () => {
const description =
'<mainText><stats><attention> 36</attention> Attack Damage</stats><br><br><passive>Spellblade</passive><br>Deals <physicalDamage>bonus physical damage</physicalDamage>.</mainText>'
const parsed = parseItemDescription(description)
const effect = parsed.effects[0]
const damageSegment = effect.description.find(s => s.type === 'physicalDamage')
expect(damageSegment).toBeDefined()
expect(damageSegment?.content).toContain('bonus physical damage')
})
it('should parse keyword tag', () => {
const description =
'<mainText><stats><attention> 105</attention> Ability Power</stats><br><br><active>Time Stop</active><br>Enter <keyword>Stasis</keyword> for 2.5 seconds.</mainText>'
const parsed = parseItemDescription(description)
const effect = parsed.effects[0]
const keywordSegment = effect.description.find(s => s.type === 'keyword')
expect(keywordSegment).toBeDefined()
expect(keywordSegment?.content).toBe('Stasis')
})
it('should parse speed tag', () => {
const description =
'<mainText><stats><attention> 36</attention> Attack Damage</stats><br><br><passive>Quicken</passive><br>Grants <speed>20 Move Speed</speed>.</mainText>'
const parsed = parseItemDescription(description)
const effect = parsed.effects[0]
const speedSegment = effect.description.find(s => s.type === 'speed')
expect(speedSegment).toBeDefined()
expect(speedSegment?.content).toContain('20 Move Speed')
})
it('should parse status tag', () => {
const description =
'<mainText><stats><attention> 45</attention> Attack Damage</stats><br><br><passive>Bitter Cold</passive><br><status>Slow</status> enemies.</mainText>'
const parsed = parseItemDescription(description)
const effect = parsed.effects[0]
const statusSegment = effect.description.find(s => s.type === 'status')
expect(statusSegment).toBeDefined()
expect(statusSegment?.content).toBe('Slow')
})
})
describe('rarity detection', () => {
it('should detect mythic rarity', () => {
const description =
'<mainText><stats><attention> 65</attention> Attack Damage</stats><br><br><rarityMythic>Mythic</rarityMythic></mainText>'
const parsed = parseItemDescription(description)
expect(parsed.rarity).toBe('mythic')
})
it('should detect legendary rarity', () => {
const description =
'<mainText><stats><attention> 65</attention> Attack Damage</stats><br><br><rarityLegendary>Legendary</rarityLegendary></mainText>'
const parsed = parseItemDescription(description)
expect(parsed.rarity).toBe('legendary')
})
it('should detect epic rarity', () => {
const description =
'<mainText><stats><attention> 65</attention> Attack Damage</stats><br><br><rarityGeneric>Epic</rarityGeneric></mainText>'
const parsed = parseItemDescription(description)
expect(parsed.rarity).toBe('epic')
})
})
describe('rules and flavor text', () => {
it('should parse rules section', () => {
const description =
'<mainText><stats><attention> 25</attention> Move Speed</stats><br><br><rules>Ornn upgrade only</rules></mainText>'
const parsed = parseItemDescription(description)
expect(parsed.rules).toBeDefined()
expect(parsed.rules?.length).toBeGreaterThan(0)
})
it('should parse flavor text section', () => {
const description =
'<mainText><stats><attention> 25</attention> Move Speed</stats><br><br><flavorText>A ancient artifact</flavorText></mainText>'
const parsed = parseItemDescription(description)
expect(parsed.flavorText).toBeDefined()
expect(parsed.flavorText?.length).toBeGreaterThan(0)
})
})
})
describe('parseItem', () => {
it('should parse item and add stats', () => {
const item = {
id: 1001,
name: 'Boots',
description:
'<mainText><stats><attention> 25</attention> Move Speed</stats><br><br></mainText>',
iconPath: '/path/to/icon.png'
}
const result = parseItem(item)
expect(result.id).toBe(1001)
expect(result.name).toBe('Boots')
expect(result.stats.moveSpeed).toBe(25)
})
})
describe('parseItems', () => {
it('should parse multiple items', () => {
const items = [
{
id: 1001,
name: 'Boots',
description:
'<mainText><stats><attention> 25</attention> Move Speed</stats><br><br></mainText>',
iconPath: '/path/to/boots.png'
},
{
id: 1036,
name: 'Long Sword',
description:
'<mainText><stats><attention> 10</attention> Attack Damage</stats><br><br></mainText>',
iconPath: '/path/to/sword.png'
}
]
const results = parseItems(items)
expect(results).toHaveLength(2)
expect(results[0].stats.moveSpeed).toBe(25)
expect(results[1].stats.attackDamage).toBe(10)
})
})
describe('parseItemFull', () => {
it('should parse item with full description', () => {
const item = {
id: 3089,
name: "Rabadon's Deathcap",
description:
'<mainText><stats><attention> 130</attention> Ability Power</stats><br><br><passive>Magical Opus</passive><br>Increases your total <scaleAP>Ability Power by 30%</scaleAP>.</mainText>',
iconPath: '/path/to/icon.png'
}
const result = parseItemFull(item)
expect(result.id).toBe(3089)
expect(result.name).toBe("Rabadon's Deathcap")
expect(result.parsedDescription.stats.abilityPower).toBe(130)
expect(result.parsedDescription.effects).toHaveLength(1)
expect(result.parsedDescription.effects[0].name).toBe('Magical Opus')
})
})
describe('edge case items', () => {
it('should parse Mercurial Scimitar with ACTIVE label before effect name', () => {
const description =
'<mainText><stats><attention> 50</attention> Attack Damage<br><attention> 35</attention> Magic Resist<br><attention> 10%</attention> Life Steal</stats><br><br><br><br> <active>ACTIVE</active><br><active>Quicksilver</active><br>Removes all crowd control debuffs (excluding <keyword>Airborne</keyword>) and grants Move Speed.</mainText>'
const result = parseItemDescription(description)
expect(result.stats.attackDamage).toBe(50)
expect(result.stats.magicResist).toBe(35)
expect(result.stats.lifeSteal).toBe(10)
expect(result.effects).toHaveLength(1)
expect(result.effects[0].type).toBe('active')
expect(result.effects[0].name).toBe('Quicksilver')
expect(result.effects[0].description.length).toBeGreaterThan(0)
expect(result.effects[0].description[0].content).toContain('Removes all crowd control')
})
it('should parse Hextech Gunblade with ACTIVE label and cooldown', () => {
const description =
'<mainText><stats><attention> 80</attention> Ability Power<br><attention> 40</attention> Attack Damage<br><attention> 10%</attention> Omnivamp</stats><br><br><br><br> <active>ACTIVE</active> (0s)<br><active>Lightning Bolt</active><br>Shocks the target enemy champion, dealing magic damage and slowing them by 25% for 1.5 seconds.</mainText>'
const result = parseItemDescription(description)
expect(result.stats.abilityPower).toBe(80)
expect(result.stats.attackDamage).toBe(40)
expect(result.stats.omnivamp).toBe(10)
expect(result.effects).toHaveLength(1)
expect(result.effects[0].type).toBe('active')
expect(result.effects[0].name).toBe('Lightning Bolt')
expect(result.effects[0].description.length).toBeGreaterThan(0)
expect(result.effects[0].description[0].content).toContain('Shocks the target enemy champion')
})
it('should parse Dark Seal with passive name appearing in description', () => {
const description =
'<mainText><stats><attention> 15</attention> Ability Power<br><attention> 50</attention> Health</stats><br><br><passive>Glory</passive><br><keyword>Takedowns</keyword> grant <passive>Glory</passive>, up to 10. 5 <passive>Glory</passive> is lost on death.<br>Gain <scaleAP>4 Ability Power</scaleAP> per <passive>Glory</passive>.</mainText>'
const result = parseItemDescription(description)
expect(result.stats.abilityPower).toBe(15)
expect(result.stats.health).toBe(50)
expect(result.effects).toHaveLength(1)
expect(result.effects[0].type).toBe('passive')
expect(result.effects[0].name).toBe('Glory')
// Description should contain the full text with Glory mentions
const descText = result.effects[0].description.map(d => d.content).join('')
expect(descText).toContain('Takedowns')
expect(descText).toContain('Glory')
expect(descText).toContain('Ability Power')
})
})

View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['test/**/*.test.ts']
}
})

View File

@@ -2,12 +2,23 @@ FROM node:current-alpine AS base
WORKDIR /app
FROM base AS build
COPY package*.json ./
# Copy and build dragon-item-parser first
COPY dragon-item-parser/package*.json ./dragon-item-parser/
COPY dragon-item-parser/tsconfig.json ./dragon-item-parser/
COPY dragon-item-parser/src ./dragon-item-parser/src
WORKDIR /app/dragon-item-parser
RUN npm install && npm run build
# Build the frontend
WORKDIR /app
COPY frontend/package*.json ./frontend/
WORKDIR /app/frontend
RUN npm install
COPY . .
COPY frontend/. .
RUN npm run build
FROM base
COPY --from=build /app/.output /app/.output
COPY --from=build /app/frontend/.output /app/.output
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]

View File

@@ -4,6 +4,41 @@
--color-surface: #312e2c;
--color-on-surface: #b7b8e1;
--color-surface-darker: #1f1d1c;
--color-gold: #ffd700;
/* Tooltip colors */
--tooltip-bg: #312e2c;
--tooltip-border: #4a4543;
--tooltip-header-border: rgba(183, 184, 225, 0.2);
--tooltip-text: #b7b8e1;
--tooltip-text-dim: #8a8b9e;
--tooltip-stat-value: #ffd700;
--tooltip-stat-label: #b7b8e1;
/* Effect type colors */
--tooltip-effect-passive: #4a9eff;
--tooltip-effect-active: #ff6b6b;
--tooltip-effect-unique: #f39c12;
--tooltip-effect-mythic: #ff5252;
--tooltip-effect-legendary: #ff9800;
--tooltip-effect-epic: #ffd54f;
/* Text segment colors */
--tooltip-highlight: #ffd700;
--tooltip-keyword: #ffd700;
--tooltip-keyword-major: #ff8c00;
--tooltip-keyword-stealth: #9b59b6;
--tooltip-status: #e74c3c;
--tooltip-speed: #5dade2;
--tooltip-scaling: #5dade2;
--tooltip-magic-damage: #9b59b6;
--tooltip-physical-damage: #e67e22;
--tooltip-true-damage: #ffffff;
--tooltip-healing: #2ecc71;
--tooltip-shield: #3498db;
--tooltip-onhit: #5dade2;
--tooltip-spellname: #1abc9c;
--tooltip-flavor: #6a9fff;
}
/* Font setting */

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { debounce, isEmpty } from '~/utils/helpers'
import type { ChampionSummary, LaneData, ChampionData } from 'match_collector'
// Constants
const CHAMPIONS_API_URL = '/api/champions'

View File

@@ -41,8 +41,8 @@ const championDescription = computed(() => championData.value?.title || '')
<div style="display: flex; width: fit-content">
<div class="champion-title-img-container">
<NuxtImg
width="160"
height="160"
width="100"
height="100"
class="champion-title-img"
:src="
CDRAGON_BASE +
@@ -54,13 +54,15 @@ const championDescription = computed(() => championData.value?.title || '')
</div>
<div id="ct-info-container">
<h1>{{ championName }}</h1>
<h3 id="ct-desc">{{ championDescription }}</h3>
<h1 style="font-size: 1.5rem">{{ championName }}</h1>
<h3 id="ct-desc" style="font-size: 1rem">{{ championDescription }}</h3>
<div id="ct-basic-stat-container">
<h2 class="ct-basic-stat">{{ winrate }}% win.</h2>
<h2 class="ct-basic-stat ct-basic-stat-margin">{{ pickrate }}% pick.</h2>
<h2 class="ct-basic-stat">{{ gameCount }} games</h2>
<h2 style="font-size: 1.2rem" class="ct-basic-stat">{{ winrate }}% win.</h2>
<h2 style="font-size: 1.2rem" class="ct-basic-stat ct-basic-stat-margin">
{{ pickrate }}% pick.
</h2>
<h2 style="font-size: 1.2rem" class="ct-basic-stat">{{ gameCount }} games</h2>
</div>
</div>
</div>
@@ -68,15 +70,15 @@ const championDescription = computed(() => championData.value?.title || '')
<style>
.champion-title-img-container {
width: 160px;
height: 160px;
width: 100px;
height: 100px;
overflow: hidden;
border: 1px solid var(--color-on-surface);
}
.champion-title-img {
width: 160px;
height: 160px;
width: 100px;
height: 100px;
transform: translate(4px, 4px) scale(1.2, 1.2);
user-select: none;
@@ -93,7 +95,7 @@ const championDescription = computed(() => championData.value?.title || '')
margin-top: 5px;
}
#ct-basic-stat-container {
margin-top: 30px;
margin-top: 16px;
display: flex;
}

View File

@@ -1,23 +1,17 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
interface SummonerSpellData {
id: number
count: number
pickrate: number
}
const { summonerSpellMap } = useSummonerSpellMap()
const props = defineProps<{
spells: Array<SummonerSpellData>
summonerSpellMap: Map<number, SummonerSpell>
defineProps<{
summonerSpells: Array<{ id: number; count: number; pickrate: number }>
}>()
</script>
<template>
<div class="summoner-spells-section">
<h3 class="section-title">Summoner Spells</h3>
<div class="summoner-spells-row">
<div v-for="(spell, i) in props.spells.slice(0, 2)" :key="i" class="summoner-spell-item">
<div v-for="(spell, i) in summonerSpells" :key="i" class="summoner-spell-item">
<NuxtImg
v-if="summonerSpellMap.get(spell.id)"
class="summoner-spell-img"
@@ -33,27 +27,21 @@ const props = defineProps<{
<style scoped>
.summoner-spells-section {
display: flex;
flex-direction: column;
}
.section-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 10px;
color: #4a9eff;
text-transform: uppercase;
letter-spacing: 0.5px;
flex-direction: row;
}
.summoner-spells-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 8px;
}
.summoner-spell-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
gap: 2px;
}
.summoner-spell-img {
@@ -72,21 +60,17 @@ const props = defineProps<{
}
.spell-pickrate {
font-size: 0.7rem;
font-size: 0.65rem;
color: var(--color-on-surface);
opacity: 0.7;
}
/* Responsive: Mobile */
@media only screen and (max-width: 900px) {
.section-title {
font-size: 0.9rem;
}
.summoner-spell-img,
.summoner-spell-placeholder {
width: 36px;
height: 36px;
width: 32px;
height: 32px;
}
}
</style>

View File

@@ -1,18 +1,26 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import type { Perk, Item } from '~/types/cdragon'
const props = defineProps<{
keystoneId: number
itemId: number
keystore: Map<number, Perk>
itemMap: Map<number, Item>
pickrate: number
selected: boolean
index: number
}>()
const emit = defineEmits<{
select: [index: number]
}>()
</script>
<template>
<div class="build-variant-selector">
<div :class="['build-variant-card', { selected: true }]">
<div :class="['build-variant-card', { selected }]" @click="emit('select', index)">
<div class="variant-content">
<!-- Keystone -->
<NuxtImg

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import type { PerkStyle, Perk } from '~/types/cdragon'
interface RuneBuild {
count: number
primaryStyle: number
@@ -9,23 +11,22 @@ interface RuneBuild {
pickrate: number
}
interface PerkStyle {
id: number
iconPath: string
}
const props = defineProps<{
runes: Array<RuneBuild>
primaryStyles: Array<PerkStyle>
secondaryStyles: Array<PerkStyle>
keystoneIds: Array<number>
perks: Map<number, Perk>
selectedIndex: number
perkStyles: Map<number, PerkStyle>
}>()
const selectedIndex = ref(0)
const emit = defineEmits<{
select: [index: number]
}>()
function select(index: number) {
emit('select', index)
selectedIndex.value = index
}
</script>
<template>
@@ -33,24 +34,24 @@ const emit = defineEmits<{
<div
v-for="(rune, index) in props.runes"
:key="index"
:class="['compact-rune-option', { active: index === props.selectedIndex }]"
@click="emit('select', index)"
:class="['compact-rune-option', { active: index === selectedIndex }]"
@click="select(index)"
>
<div class="compact-rune-content">
<NuxtImg
v-if="primaryStyles[index]"
v-if="runes[index].primaryStyle"
class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(primaryStyles[index].iconPath)"
:src="CDRAGON_BASE + mapPath(perkStyles.get(runes[index].primaryStyle)?.iconPath!)"
/>
<NuxtImg
v-if="keystoneIds[index] && props.perks.get(keystoneIds[index])"
v-if="perks.get(runes[index].selections[0])"
class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(props.perks.get(keystoneIds[index])!.iconPath)"
:src="CDRAGON_BASE + mapPath(perks.get(runes[index].selections[0])!.iconPath)"
/>
<NuxtImg
v-if="secondaryStyles[index]"
v-if="runes[index].secondaryStyle"
class="compact-rune-img"
:src="CDRAGON_BASE + mapPath(secondaryStyles[index].iconPath)"
:src="CDRAGON_BASE + mapPath(perkStyles.get(runes[index].secondaryStyle)?.iconPath!)"
/>
</div>
<span class="compact-rune-pickrate">{{ (rune.pickrate * 100).toFixed(1) }}%</span>
@@ -118,4 +119,11 @@ const emit = defineEmits<{
opacity: 0.9;
color: #4a9eff;
}
/* Mobile fix: Remove negative margin to prevent overlap with runes */
@media only screen and (max-width: 900px) {
.compact-rune-selector {
margin-top: 15px;
}
}
</style>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { FirstBackGroup } from 'match_collector'
import type { Item } from '~/types/cdragon'
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;
}
@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>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import type { Item } from '~/types/cdragon'
interface ItemData {
data: number
@@ -21,15 +21,17 @@ const maxItems = computed(() => props.maxItems ?? props.items.length)
<div class="item-row">
<span class="item-row-label">{{ label }}</span>
<div class="item-row-content">
<div v-for="item in items.slice(0, maxItems)" :key="item.data" class="item-cell">
<NuxtImg
<template v-for="item in items.slice(0, maxItems)" :key="item.data">
<ItemIcon
v-if="itemMap.get(item.data)"
class="item-img"
:src="CDRAGON_BASE + mapPath(itemMap.get(item.data)!.iconPath)"
:item="itemMap.get(item.data)!"
:show-pickrate="true"
:pickrate="item.count / totalCount"
:size="48"
class="item-cell"
/>
<div v-else class="item-placeholder" />
<span class="item-pickrate">{{ ((item.count / totalCount) * 100).toFixed(0) }}%</span>
</div>
</template>
</div>
</div>
</template>
@@ -56,19 +58,6 @@ const maxItems = computed(() => props.maxItems ?? props.items.length)
gap: 8px;
}
.item-cell {
display: flex;
flex-direction: column;
align-items: center;
}
.item-img {
width: 48px;
height: 48px;
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.item-placeholder {
width: 48px;
height: 48px;
@@ -76,24 +65,4 @@ const maxItems = computed(() => props.maxItems ?? props.items.length)
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.item-pickrate {
font-size: 0.65rem;
color: var(--color-on-surface);
opacity: 0.6;
margin-top: 1px;
}
/* Responsive: Mobile */
@media only screen and (max-width: 900px) {
.item-img {
width: 40px;
height: 40px;
}
.item-placeholder {
width: 40px;
height: 40px;
}
}
</style>

View File

@@ -1,256 +1,90 @@
<script setup lang="ts">
import { isEmpty, deepClone } from '~/utils/helpers'
import { getCoreItems, getLateGameItems } from '~/utils/buildHelpers'
import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue'
import SummonerSpells from '~/components/build/SummonerSpells.vue'
import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue'
import ItemRow from '~/components/build/ItemRow.vue'
import FirstBack from '~/components/build/FirstBack.vue'
import type { Build } from 'match_collector'
const props = defineProps<{
runes: Array<{
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate: number
}>
builds: Builds
summonerSpells?: Array<{ id: number; count: number; pickrate: number }> // API data when available
builds: Array<Build>
}>()
// State
const currentlySelectedBuild = ref(0)
// Fetch items from cached API
const { data: items } = useFetch<Array<Item>>('/api/cdragon/items', {
lazy: true,
server: false
})
const itemMap = ref<Map<number, Item>>(new Map())
// Use composables for data fetching
const { itemMap } = useItemMap()
const { perks, perkStyles } = useRuneStyles()
watch(
items,
newItems => {
if (Array.isArray(newItems)) {
const map = new Map<number, Item>()
for (const item of newItems) {
if (item?.id) {
map.set(item.id, item)
}
}
itemMap.value = map
}
},
{ immediate: true }
)
// Use composable for builds management
const { builds } = useBuilds(toRef(props, 'builds'))
const currentBuild = computed(() => builds.value[currentlySelectedBuild.value])
// Fetch summoner spells from cached API
const { data: summonerSpellsData } = useFetch<Array<SummonerSpell>>(
'/api/cdragon/summoner-spells',
{
lazy: true,
server: false
}
)
const summonerSpellMap = ref<Map<number, SummonerSpell>>(new Map())
watch(
summonerSpellsData,
newData => {
if (Array.isArray(newData)) {
const map = new Map<number, SummonerSpell>()
for (const spell of newData) {
if (spell?.id) {
map.set(spell.id, spell)
}
}
summonerSpellMap.value = map
}
},
{ immediate: true }
)
// Mock summoner spells data if not provided by API
const mockSummonerSpells = computed(() => {
if (props.summonerSpells && props.summonerSpells.length > 0) {
return props.summonerSpells
}
// Default mock data based on common summoner spells
return [
{ id: 4, count: 1000, pickrate: 0.45 }, // Flash
{ id: 7, count: 800, pickrate: 0.35 }, // Heal
{ id: 14, count: 600, pickrate: 0.15 }, // Ignite
{ id: 3, count: 200, pickrate: 0.05 } // Exhaust
]
// Late game items for current build
const lateGameItems = computed(() => {
if (!currentBuild.value) return []
return getLateGameItems(currentBuild.value).slice(0, 6)
})
// Builds management
const builds = ref<Builds>(deepClone(props.builds))
const currentlySelectedRunes = ref(0)
// Reset selected build when variant changes
watch(
() => props.builds,
newBuilds => {
builds.value = deepClone(newBuilds)
trimBuilds(builds.value)
trimLateGameItems(builds.value)
},
{ deep: true }
)
onMounted(() => {
trimBuilds(builds.value)
trimLateGameItems(builds.value)
})
function trimBuilds(builds: Builds): void {
if (!builds?.tree?.children) return
builds.tree.children.splice(1, builds.tree.children.length - 1)
if (builds.tree.children[0]?.children) {
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1)
}
}
function trimLateGameItems(builds: Builds): void {
if (!builds?.tree || isEmpty(builds.lateGame)) return
function trimLateGameItemsFromTree(tree: ItemTree): void {
const foundIndex = builds.lateGame.findIndex(x => x.data === tree.data)
if (foundIndex !== -1) {
builds.lateGame.splice(foundIndex, 1)
}
for (const child of tree.children || []) {
trimLateGameItemsFromTree(child)
}
}
trimLateGameItemsFromTree(builds.tree)
}
// Get first core item for build variant display
const firstCoreItems = computed(() => {
const result: number[] = []
for (let i = 0; i < props.runes.length; i++) {
const tree = builds.value?.tree
if (tree?.children?.[0]?.data) {
result.push(tree.children[0].data)
} else if (tree?.data) {
result.push(tree.data)
} else {
result.push(0)
}
}
return result
})
// Get the highest pickrate rune build
const highestPickrateBuildIndex = computed(() => {
if (props.runes.length === 0) return 0
let maxIndex = 0
let maxPickrate = props.runes[0].pickrate
for (let i = 1; i < props.runes.length; i++) {
if (props.runes[i].pickrate > maxPickrate) {
maxPickrate = props.runes[i].pickrate
maxIndex = i
}
}
return maxIndex
})
// State for compact rune selector
const selectedRuneIndex = ref(0)
watch(
() => currentlySelectedBuild,
() => {
selectedRuneIndex.value = 0
}
)
function selectRune(index: number) {
selectedRuneIndex.value = index
currentlySelectedBuild.value = index
}
// Rune styles
const primaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
const secondaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
const keystoneIds: Ref<Array<number>> = ref(Array(props.runes.length))
const { data: perks_data }: PerksResponse = await useFetch('/api/cdragon/perks')
const perks = reactive(new Map())
for (const perk of perks_data.value) {
perks.set(perk.id, perk)
}
const { data: stylesData }: PerkStylesResponse = await useFetch('/api/cdragon/perkstyles')
function refreshStylesKeystones() {
for (const style of stylesData.value.styles) {
for (const rune of props.runes) {
if (style.id == rune.primaryStyle) {
primaryStyles.value[props.runes.indexOf(rune)] = style
for (const perk of style.slots[0].perks) {
if (rune.selections.includes(perk)) {
keystoneIds.value[props.runes.indexOf(rune)] = perk
}
}
}
if (style.id == rune.secondaryStyle) {
secondaryStyles.value[props.runes.indexOf(rune)] = style
}
}
}
}
watch(
() => props.runes,
() => currentBuild,
() => {
currentlySelectedBuild.value = 0
primaryStyles.value = Array(props.runes.length)
secondaryStyles.value = Array(props.runes.length)
keystoneIds.value = Array(props.runes.length)
refreshStylesKeystones()
currentlySelectedRunes.value = 0
}
)
refreshStylesKeystones()
function selectRune(index: number): void {
currentlySelectedRunes.value = index
}
function selectBuild(index: number): void {
currentlySelectedBuild.value = index
}
</script>
<template>
<div class="build-viewer">
<!-- Global Build Variant Selector - Single variant with highest pickrate -->
<div v-if="currentBuild" class="build-viewer">
<div style="display: flex">
<BuildVariantSelector
:keystone-id="keystoneIds[highestPickrateBuildIndex]"
:item-id="firstCoreItems[highestPickrateBuildIndex]"
v-for="(build, i) in builds"
:key="i"
:keystone-id="build.runeKeystone"
:item-id="build.items.children[0].data!"
:keystore="perks"
:item-map="itemMap"
:pickrate="1"
:pickrate="build.pickrate"
:selected="currentBuild == build"
:index="i"
@select="selectBuild"
/>
</div>
<!-- Main Build Content -->
<div class="build-content">
<!-- Left Column: Summoner Spells + Runes -->
<div class="build-left-column">
<!-- Summoner Spells -->
<SummonerSpells :spells="mockSummonerSpells" :summoner-spell-map="summonerSpellMap" />
<!-- Rune Page -->
<div class="rune-section">
<h3 class="section-title">Runes</h3>
<div class="rune-page-wrapper">
<RunePage
v-if="runes[currentlySelectedBuild]"
:primary-style-id="runes[currentlySelectedBuild].primaryStyle"
:secondary-style-id="runes[currentlySelectedBuild].secondaryStyle"
:selection-ids="runes[currentlySelectedBuild].selections"
v-if="currentBuild.runes"
:primary-style-id="currentBuild.runes[currentlySelectedRunes].primaryStyle"
:secondary-style-id="currentBuild.runes[currentlySelectedRunes].secondaryStyle"
:selection-ids="currentBuild.runes[currentlySelectedRunes].selections"
/>
</div>
<!-- Compact Rune Selector -->
<CompactRuneSelector
:runes="runes"
:primary-styles="primaryStyles"
:secondary-styles="secondaryStyles"
:keystone-ids="keystoneIds"
:runes="currentBuild.runes"
:perks="perks"
:perk-styles="perkStyles"
:selected-index="currentlySelectedBuild"
@select="selectRune"
/>
@@ -263,46 +97,54 @@ refreshStylesKeystones()
<!-- Start/Support + Boots Container -->
<div class="item-row-group">
<!-- Start Items -->
<!-- Start Item (root of the tree) -->
<ItemRow
v-if="!builds.suppItems"
v-if="currentBuild.startItems && currentBuild.startItems.length > 0"
label="Start"
:items="builds.start"
:items="currentBuild.startItems"
:item-map="itemMap"
:total-count="builds.tree.count"
:total-count="currentBuild.count"
/>
<!-- Support Items -->
<ItemRow
v-if="builds.suppItems"
v-if="currentBuild.suppItems && currentBuild.suppItems.length > 0"
label="Support"
:items="builds.suppItems"
:items="currentBuild.suppItems"
:item-map="itemMap"
:total-count="builds.tree.count"
:total-count="currentBuild.count"
/>
<!-- Boots (regular or rush) -->
<ItemRow
:label="builds.bootsFirst > 0.5 ? 'Boots Rush' : 'Boots'"
:items="builds.boots.slice(0, 2)"
:label="currentBuild.bootsFirst > 0.5 ? 'Boots Rush' : 'Boots'"
:items="currentBuild.boots.slice(0, 2)"
:item-map="itemMap"
:total-count="builds.tree.count"
:total-count="currentBuild.count"
:max-items="2"
/>
</div>
<!-- Core Items Tree -->
<div class="item-row">
<!-- 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) -->
<div v-if="currentBuild.items?.children?.length" class="item-row">
<span class="item-row-label">Core</span>
<ItemTree :tree="builds.tree" />
<ItemTree :tree="getCoreItems(currentBuild)" />
</div>
<!-- Late Game -->
<ItemRow
v-if="lateGameItems.length > 0"
label="Late Game"
:items="builds.lateGame.slice(0, 6)"
:items="lateGameItems"
:item-map="itemMap"
:total-count="builds.tree.count"
:total-count="currentBuild.count"
:max-items="6"
/>
</div>
@@ -422,6 +264,7 @@ refreshStylesKeystones()
.rune-page-wrapper {
transform: scale(1);
transform-origin: center center;
margin-top: 0px;
}
}
</style>

View File

@@ -1,49 +0,0 @@
<script lang="ts" setup>
defineProps<{
title: string
bootsFirst?: number
sizePerc?: number
}>()
</script>
<template>
<div
:style="
sizePerc != undefined && sizePerc != null ? 'max-height: ' + sizePerc * 600 + 'px;' : ''
"
class="item-box"
>
<div
style="display: flex; flex-direction: column; justify-content: center; align-items: center"
>
<h2 class="item-box-title">{{ title }}</h2>
<h5 v-if="bootsFirst != undefined && bootsFirst != null" style="margin: auto">
({{ (bootsFirst * 100).toFixed(2) }}%)
</h5>
</div>
<slot />
</div>
</template>
<style scoped>
.item-box {
border: 1px solid var(--color-on-surface);
border-radius: 8px;
margin: 10px;
width: fit-content;
height: 600px;
}
.item-box-title {
font-variant: small-caps;
text-align: center;
margin: 10px;
}
@media only screen and (max-width: 1000px) {
.item-box {
width: 95%;
height: fit-content;
}
}
</style>

View File

@@ -0,0 +1,142 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import type { Item } from '~/types/cdragon'
import type { ItemTag } from 'match_collector'
interface Props {
item: Item
size?: number
showPickrate?: boolean
pickrate?: number
class?: string
tags?: ItemTag[]
}
// Expose the icon element for external use (e.g., arrow drawing)
const iconElement = ref<HTMLElement | null>(null)
defineExpose({
iconElement
})
const props = withDefaults(defineProps<Props>(), {
size: 48,
showPickrate: false,
pickrate: 0,
class: '',
tags: () => []
})
// Tooltip state - encapsulated in this component
const tooltipState = reactive({
show: false,
item: null as Item | null,
x: 0,
y: 0
})
const handleMouseEnter = (event: MouseEvent) => {
tooltipState.item = props.item
// Calculate optimal position to keep tooltip within viewport
// Don't estimate height - position based on cursor location
const tooltipWidth = 300 // Maximum width from CSS
const padding = 10 // Minimum padding from edges
const offset = 15 // Distance from cursor
// Get viewport dimensions
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Right edge detection: if we're in the right half, position to the left
let x = event.clientX + offset
if (event.clientX + tooltipWidth + offset > viewportWidth - padding) {
x = event.clientX - tooltipWidth - offset
// Clamp if still off-screen
if (x < padding) {
x = padding
}
}
// Bottom edge detection: if we're in the bottom half, position above
// Use a smaller offset for vertical to keep it close
let y = event.clientY + offset
if (event.clientY > viewportHeight * 0.7) {
y = event.clientY - offset - 200 // Position ~200px above
// Clamp if too high
if (y < padding) {
y = padding
}
}
// Ensure Y is within reasonable bounds
y = Math.min(y, viewportHeight - padding)
tooltipState.x = x
tooltipState.y = y
tooltipState.show = true
}
const handleMouseLeave = () => {
tooltipState.show = false
tooltipState.item = null
}
const itemIconPath = computed(() => CDRAGON_BASE + mapPath(props.item.iconPath))
</script>
<template>
<div class="item-icon-wrapper" @mouseleave="handleMouseLeave">
<div
ref="iconElement"
class="item-icon"
:class="props.class"
:style="{ width: size + 'px', height: size + 'px' }"
@mouseenter="handleMouseEnter"
>
<NuxtImg :src="itemIconPath" :alt="item.name || 'Item'" class="item-img" />
</div>
<span v-if="showPickrate" class="item-pickrate"> {{ (pickrate * 100).toFixed(0) }}% </span>
<ItemTooltip
:show="tooltipState.show"
:item="tooltipState.item"
:x="tooltipState.x"
:y="tooltipState.y"
:tags="tags"
/>
</div>
</template>
<style scoped>
.item-icon-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.item-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
border: 1px solid var(--color-on-surface);
overflow: hidden;
position: relative;
}
.item-img {
width: 100%;
height: 100%;
}
.item-pickrate {
font-size: 0.65rem;
color: var(--color-on-surface);
opacity: 0.6;
margin-top: 2px;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,693 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import {
parseItemDescription,
type TextSegment,
type ItemEffect,
type ParsedDescription
} from 'dragon-item-parser'
import type { Item } from '~/types/cdragon'
import type { ItemTag } from 'match_collector'
interface Props {
item: Item | null
show: boolean
x: number
y: number
tags?: ItemTag[]
}
const props = withDefaults(defineProps<Props>(), {
tags: () => []
})
// Tag display helpers
function getTagLabel(tag: ItemTag): string {
const labels: Record<ItemTag, string> = {
ahead: 'Ahead',
behind: 'Behind',
region_euw: 'EUW',
region_eun: 'EUN',
region_na: 'NA',
region_kr: 'KR'
}
return labels[tag] || tag
}
function getTagTooltip(tag: ItemTag): string {
const tooltips: Record<ItemTag, string> = {
ahead: 'This item is typically bought when ahead in gold',
behind: 'This item is typically bought when behind in gold',
region_euw: 'Popular in EU West region',
region_eun: 'Popular in EU Nordic & East region',
region_na: 'Popular in North America region',
region_kr: 'Popular in Korea region'
}
return tooltips[tag] || tag
}
function getTagClass(tag: ItemTag): string {
return `tag-${tag}`
}
// Parse the item description once
const parsedDescription = computed<ParsedDescription | null>(() => {
if (!props.item?.description) return null
return parseItemDescription(props.item.description)
})
// Format stats for display
const formattedStats = computed(() => {
const stats = parsedDescription.value?.stats
if (!stats) return []
const statLabels: Record<string, string> = {
attackDamage: 'Attack Damage',
abilityPower: 'Ability Power',
attackSpeed: 'Attack Speed',
criticalStrikeChance: 'Critical Strike Chance',
criticalStrikeDamage: 'Critical Strike Damage',
lifeSteal: 'Life Steal',
omnivamp: 'Omnivamp',
physicalVamp: 'Physical Vamp',
spellVamp: 'Spell Vamp',
health: 'Health',
armor: 'Armor',
magicResist: 'Magic Resist',
mana: 'Mana',
baseManaRegen: 'Base Mana Regen',
baseHealthRegen: 'Base Health Regen',
moveSpeed: 'Move Speed',
abilityHaste: 'Ability Haste',
armorPenetration: 'Armor Penetration',
magicPenetration: 'Magic Penetration',
lethality: 'Lethality',
healAndShieldPower: 'Heal and Shield Power',
tenacity: 'Tenacity',
slowResist: 'Slow Resist'
}
const percentageStats = [
'attackSpeed',
'criticalStrikeChance',
'criticalStrikeDamage',
'lifeSteal',
'omnivamp',
'physicalVamp',
'spellVamp',
'baseManaRegen',
'baseHealthRegen',
'armorPenetration',
'magicPenetration',
'healAndShieldPower',
'tenacity',
'slowResist'
]
return Object.entries(stats)
.filter(([_, value]) => value !== undefined)
.map(([key, value]) => ({
key,
label: statLabels[key] || key,
value: percentageStats.includes(key) ? `${value}%` : value
}))
})
// Get CSS class for text segment type
function getSegmentClass(segment: TextSegment): string {
const classMap: Record<TextSegment['type'], string> = {
text: '',
highlight: 'stat-highlight',
passive: 'tag-passive',
active: 'tag-active',
keyword: 'tag-keyword',
keywordMajor: 'tag-keyword-major',
keywordStealth: 'tag-keyword-stealth',
status: 'tag-status',
speed: 'tag-speed',
scaleMana: 'tag-scaling',
scaleHealth: 'tag-scaling',
scaleAP: 'tag-scaling',
scaleAD: 'tag-scaling',
scaleArmor: 'tag-scaling',
scaleMR: 'tag-scaling',
scaleLevel: 'tag-scaling',
scaleBonusHealth: 'tag-scaling',
scaleBonusMana: 'tag-scaling',
scaleMaxHealth: 'tag-scaling',
spellName: 'tag-spellname',
unique: 'tag-unique',
rarityMythic: 'tag-rarity-mythic',
rarityLegendary: 'tag-rarity-legendary',
rarityGeneric: 'tag-rarity-generic',
magicDamage: 'tag-magic-damage',
physicalDamage: 'tag-physical-damage',
trueDamage: 'tag-true-damage',
healing: 'tag-healing',
shield: 'tag-shield',
attention: 'stat-highlight',
onHit: 'tag-onhit',
color: ''
}
return classMap[segment.type] || ''
}
// Get effect type label
function getEffectTypeLabel(type: ItemEffect['type']): string {
const labels: Record<ItemEffect['type'], string> = {
passive: 'Passive',
active: 'Active',
unique: 'Unique',
mythic: 'Mythic',
legendary: 'Legendary',
epic: 'Epic'
}
return labels[type] || ''
}
// Get effect type class
function getEffectTypeClass(type: ItemEffect['type']): string {
const classMap: Record<ItemEffect['type'], string> = {
passive: 'effect-passive',
active: 'effect-active',
unique: 'effect-unique',
mythic: 'effect-mythic',
legendary: 'effect-legendary',
epic: 'effect-epic'
}
return classMap[type] || ''
}
// Render text segments to HTML
function renderSegments(segments: TextSegment[]): string {
return segments
.map(segment => {
const cssClass = getSegmentClass(segment)
if (segment.type === 'color' && segment.color) {
return `<span style="color: ${segment.color}">${segment.content}</span>`
}
if (cssClass) {
return `<span class="${cssClass}">${segment.content}</span>`
}
return segment.content
})
.join('')
}
// Render effect description
function renderEffect(effect: ItemEffect): string {
return renderSegments(effect.description)
}
// Render rules
function renderRules(segments: TextSegment[] | undefined): string {
if (!segments) return ''
return renderSegments(segments)
}
// Render flavor text
function renderFlavorText(segments: TextSegment[] | undefined): string {
if (!segments) return ''
return renderSegments(segments)
}
// Check if we should show the effects section
const hasEffects = computed(() => {
return parsedDescription.value?.effects?.length
})
// Check if we should show rules
const hasRules = computed(() => {
return parsedDescription.value?.rules?.length
})
// Check if we should show flavor text
const hasFlavorText = computed(() => {
return parsedDescription.value?.flavorText?.length
})
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="show && item"
class="item-tooltip"
:style="{
left: x + 'px',
top: y + 'px'
}"
@mouseenter.stop
>
<!-- Header -->
<div class="tooltip-header">
<NuxtImg class="tooltip-icon" :src="CDRAGON_BASE + mapPath(item.iconPath)" />
<div class="tooltip-title">
<h3>{{ item.name || 'Unknown Item' }}</h3>
<span v-if="item.priceTotal" class="tooltip-gold">{{ item.priceTotal }} Gold</span>
</div>
</div>
<!-- Plaintext (brief description) -->
<div v-if="item.plaintext" class="tooltip-plaintext">
{{ item.plaintext }}
</div>
<!-- Item Tags -->
<div v-if="tags && tags.length > 0" class="tooltip-tags-section">
<div class="tooltip-tags">
<span
v-for="tag in tags"
:key="tag"
:class="['item-tag', getTagClass(tag)]"
:title="getTagTooltip(tag)"
>
{{ getTagLabel(tag) }}
</span>
</div>
</div>
<!-- Stats Section -->
<div v-if="formattedStats.length > 0" class="tooltip-stats">
<div v-for="stat in formattedStats" :key="stat.key" class="stat-row">
<span class="stat-value">{{ stat.value }}</span>
<span class="stat-label">{{ stat.label }}</span>
</div>
</div>
<!-- Effects Section -->
<div v-if="hasEffects" class="tooltip-effects">
<div
v-for="(effect, index) in parsedDescription?.effects"
:key="index"
:class="['effect-item', getEffectTypeClass(effect.type)]"
>
<div class="effect-header">
<span class="effect-type">{{ getEffectTypeLabel(effect.type) }}</span>
<span v-if="effect.name" class="effect-name">{{ effect.name }}</span>
</div>
<!-- eslint-disable vue/no-v-html -->
<div class="effect-description" v-html="renderEffect(effect)"></div>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
<!-- Rules Section -->
<div v-if="hasRules" class="tooltip-rules">
<!-- eslint-disable vue/no-v-html -->
<div v-html="renderRules(parsedDescription?.rules)"></div>
<!-- eslint-enable vue/no-v-html -->
</div>
<!-- Flavor Text -->
<div v-if="hasFlavorText" class="tooltip-flavor">
<!-- eslint-disable vue/no-v-html -->
<div v-html="renderFlavorText(parsedDescription?.flavorText)"></div>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.item-tooltip {
position: fixed;
z-index: 1000;
background: var(--tooltip-bg);
border: 1px solid var(--tooltip-border);
border-radius: 8px;
padding: 12px;
max-width: 320px;
min-width: 250px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
pointer-events: none;
font-family: 'Inter', sans-serif;
}
/* Header */
.tooltip-header {
display: flex;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--tooltip-header-border);
margin-bottom: 10px;
}
.tooltip-icon {
width: 48px;
height: 48px;
border-radius: 4px;
border: 2px solid var(--tooltip-border);
flex-shrink: 0;
background: rgba(0, 0, 0, 0.3);
}
.tooltip-title {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.tooltip-title h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--tooltip-text);
line-height: 1.2;
}
.tooltip-gold {
font-size: 0.85rem;
color: var(--color-gold);
font-weight: 500;
}
/* Plaintext */
.tooltip-plaintext {
font-size: 0.8rem;
color: var(--tooltip-text-dim);
margin-bottom: 8px;
font-style: italic;
line-height: 1.4;
}
/* Item Tags Section */
.tooltip-tags-section {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--tooltip-header-border);
}
.tooltip-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.tooltip-tags .item-tag {
font-size: 0.65rem;
padding: 2px 6px;
border-radius: 3px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.3px;
white-space: nowrap;
}
.tooltip-tags .tag-ahead {
background-color: #22c55e;
color: white;
}
.tooltip-tags .tag-behind {
background-color: #ef4444;
color: white;
}
.tooltip-tags .tag-region_euw {
background-color: #3b82f6;
color: white;
}
.tooltip-tags .tag-region_eun {
background-color: #8b5cf6;
color: white;
}
.tooltip-tags .tag-region_na {
background-color: #f59e0b;
color: white;
}
.tooltip-tags .tag-region_kr {
background-color: #ec4899;
color: white;
}
/* Stats Section */
.tooltip-stats {
margin-bottom: 10px;
}
.stat-row {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 3px;
}
.stat-value {
color: var(--tooltip-stat-value);
font-weight: 600;
font-size: 0.85rem;
min-width: 45px;
text-align: right;
}
.stat-label {
color: var(--tooltip-stat-label);
font-size: 0.85rem;
}
/* Effects Section */
.tooltip-effects {
padding-top: 8px;
border-top: 1px solid var(--tooltip-header-border);
}
.effect-item {
margin-bottom: 8px;
padding-left: 10px;
border-left: 3px solid var(--tooltip-border);
}
.effect-item:last-child {
margin-bottom: 0;
}
.effect-item.effect-passive {
border-left-color: var(--tooltip-effect-passive);
}
.effect-item.effect-active {
border-left-color: var(--tooltip-effect-active);
}
.effect-item.effect-unique {
border-left-color: var(--tooltip-effect-unique);
}
.effect-item.effect-mythic {
border-left-color: var(--tooltip-effect-mythic);
}
.effect-item.effect-legendary {
border-left-color: var(--tooltip-effect-legendary);
}
.effect-item.effect-epic {
border-left-color: var(--tooltip-effect-epic);
}
.effect-header {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 3px;
}
.effect-type {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 5px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.1);
}
.effect-passive .effect-type {
color: var(--tooltip-effect-passive);
}
.effect-active .effect-type {
color: var(--tooltip-effect-active);
}
.effect-unique .effect-type {
color: var(--tooltip-effect-unique);
}
.effect-mythic .effect-type {
color: var(--tooltip-effect-mythic);
}
.effect-legendary .effect-type {
color: var(--tooltip-effect-legendary);
}
.effect-epic .effect-type {
color: var(--tooltip-effect-epic);
}
.effect-name {
font-weight: 600;
color: var(--tooltip-text);
font-size: 0.85rem;
}
.effect-description {
font-size: 0.8rem;
color: var(--tooltip-text-dim);
line-height: 1.5;
}
/* Rules Section */
.tooltip-rules {
margin-top: 8px;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.2);
border-left: 2px solid var(--tooltip-border);
font-size: 0.75rem;
font-style: italic;
color: var(--tooltip-text-dim);
}
/* Flavor Text */
.tooltip-flavor {
margin-top: 8px;
padding: 6px 8px;
background: rgba(74, 222, 255, 0.05);
border-left: 2px solid var(--tooltip-flavor);
font-size: 0.75rem;
font-style: italic;
color: var(--tooltip-flavor);
}
/* Text segment styles inside effects */
.effect-description :deep(.stat-highlight) {
color: var(--tooltip-highlight);
font-weight: 600;
}
.effect-description :deep(.tag-passive) {
color: var(--tooltip-effect-passive);
font-weight: 600;
}
.effect-description :deep(.tag-active) {
color: var(--tooltip-effect-active);
font-weight: 600;
}
.effect-description :deep(.tag-keyword) {
color: var(--tooltip-keyword);
font-weight: 600;
}
.effect-description :deep(.tag-keyword-major) {
color: var(--tooltip-keyword-major);
font-weight: 700;
font-style: italic;
}
.effect-description :deep(.tag-keyword-stealth) {
color: var(--tooltip-keyword-stealth);
font-weight: 600;
}
.effect-description :deep(.tag-status) {
color: var(--tooltip-status);
font-weight: 500;
font-style: italic;
}
.effect-description :deep(.tag-speed),
.effect-description :deep(.tag-scaling) {
color: var(--tooltip-scaling);
font-style: italic;
font-weight: 500;
}
.effect-description :deep(.tag-healing) {
color: var(--tooltip-healing);
font-weight: 500;
}
.effect-description :deep(.tag-shield) {
color: var(--tooltip-shield);
font-weight: 500;
}
.effect-description :deep(.tag-magic-damage) {
color: var(--tooltip-magic-damage);
font-weight: 500;
}
.effect-description :deep(.tag-physical-damage) {
color: var(--tooltip-physical-damage);
font-weight: 500;
}
.effect-description :deep(.tag-true-damage) {
color: var(--tooltip-true-damage);
font-weight: 600;
}
.effect-description :deep(.tag-onhit) {
background: rgba(52, 152, 219, 0.2);
color: var(--tooltip-onhit);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.effect-description :deep(.tag-spellname) {
color: var(--tooltip-spellname);
font-weight: 600;
font-style: italic;
}
.effect-description :deep(.tag-unique) {
color: var(--tooltip-effect-unique);
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.5px;
}
.effect-description :deep(.tag-rarity-mythic) {
color: var(--tooltip-effect-mythic);
font-weight: 700;
font-size: 0.85rem;
}
.effect-description :deep(.tag-rarity-legendary) {
color: var(--tooltip-effect-legendary);
font-weight: 600;
font-size: 0.85rem;
}
.effect-description :deep(.tag-rarity-generic) {
color: var(--tooltip-effect-epic);
font-weight: 500;
font-size: 0.85rem;
}
/* Transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import { LinePath as svgdomarrowsLinePath } from 'svg-dom-arrows'
import type { ItemTree } from 'match_collector'
import type { Item } from '~/types/cdragon'
defineProps<{
tree: ItemTree
parentCount?: number
@@ -12,20 +15,16 @@ const emit = defineEmits<{
parentReady: []
}>()
const { data: items, pending: itemsLoading } = useFetch<Array<{ id: number; iconPath: string }>>(
'/api/cdragon/items',
{
const { data: items, pending: itemsLoading } = useFetch<Array<Item>>('/api/cdragon/items', {
lazy: true, // Don't block rendering
server: false // Client-side only
}
)
})
// Track image loading state
const imagesLoaded = ref(false)
const imageElement: Ref<HTMLImageElement | null> = ref(null)
// Create item map reactively
const itemMap = reactive(new Map<number, { id: number; iconPath: string }>())
const itemMap = reactive(new Map<number, Item>())
watch(
items,
newItems => {
@@ -39,15 +38,13 @@ watch(
{ immediate: true }
)
function getItemIconPath(itemId: number): string {
const item = itemMap.get(itemId)
return item ? CDRAGON_BASE + mapPath(item.iconPath) : ''
}
const start: Ref<Element | null> = useTemplateRef('start')
const startTreeItem = useTemplateRef('start')
const arrows: Array<svgdomarrowsLinePath> = []
const pendingChildMounts: Array<Element> = []
// Get the actual icon element for arrow drawing
const startElement = computed(() => startTreeItem.value?.iconElement ?? null)
// Function to wait for an image to load
function waitForImageLoad(imgElement: HTMLImageElement): Promise<void> {
return new Promise(resolve => {
@@ -91,12 +88,12 @@ onMounted(async () => {
}
})
if (start.value) {
const imgElement = start.value as HTMLImageElement
imageElement.value = imgElement
// Wait for own image to load
await waitForImageLoad(imgElement)
if (startElement.value) {
// Wait for the ItemIcon to load its image
const imgElement = startElement.value.querySelector('img')
if (imgElement) {
await waitForImageLoad(imgElement as HTMLImageElement)
}
// Now that image is loaded and DOM is ready, draw arrows
imagesLoaded.value = true
@@ -108,7 +105,7 @@ onMounted(async () => {
if (pendingChildMounts.length > 0) {
await nextTick()
for (const childEnd of pendingChildMounts) {
drawArrow(start.value!, childEnd)
drawArrow(startElement.value!, childEnd)
}
pendingChildMounts.length = 0
}
@@ -117,7 +114,7 @@ onMounted(async () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
refreshArrows()
emit('mount', start.value!)
emit('mount', startElement.value!)
})
})
}
@@ -133,12 +130,12 @@ onBeforeUpdate(() => {
onUpdated(async () => {
await nextTick()
if (start.value && imagesLoaded.value) {
if (startElement.value && imagesLoaded.value) {
// Redraw arrows after DOM update
requestAnimationFrame(() => {
requestAnimationFrame(() => {
refreshArrows()
emit('mount', start.value!)
emit('mount', startElement.value!)
})
})
}
@@ -168,7 +165,7 @@ function drawArrow(start: Element, end: Element) {
left: 0
}
},
style: 'stroke:var(--color-on-surface);stroke-width:2;fill:transparent;',
style: 'stroke:var(--color-on-surface);stroke-width:2;fill:transparent;pointer-events:none;',
appendTo: document.body
})
arrows.push(arrow)
@@ -194,12 +191,12 @@ addEventListener('scroll', _ => {
})
function handleSubtreeMount(end: Element) {
if (start.value) {
if (startElement.value) {
if (imagesLoaded.value) {
// Parent is ready, draw arrow immediately
requestAnimationFrame(() => {
requestAnimationFrame(() => {
drawArrow(start.value!, end)
drawArrow(startElement.value!, end)
refreshArrows()
})
})
@@ -213,7 +210,7 @@ function handleSubtreeMount(end: Element) {
function handleParentReady() {
// Parent became ready, redraw all our arrows
if (start.value && imagesLoaded.value) {
if (startElement.value && imagesLoaded.value) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
refreshArrows()
@@ -233,13 +230,16 @@ function handleRefresh() {
<template>
<div class="item-tree-container">
<div v-if="tree.data != undefined && tree.data != null" class="item-tree-node">
<img
<ItemIcon
v-if="itemMap.get(tree.data)"
ref="start"
:item="itemMap.get(tree.data)!"
:show-pickrate="true"
:pickrate="parentCount ? tree.count / parentCount : 0"
:size="48"
:tags="tree.tags"
class="item-tree-img"
:alt="tree.data.toString()"
:src="getItemIconPath(tree.data)"
/>
<span class="item-tree-pickrate">{{ ((tree.count / parentCount!!) * 100).toFixed(0) }}%</span>
</div>
<div class="item-tree-children">
@@ -268,36 +268,23 @@ function handleRefresh() {
flex-direction: column;
align-items: center;
width: fit-content;
}
.item-tree-img {
width: 48px;
height: 48px;
border-radius: 4px;
border: 1px solid var(--color-on-surface);
}
.item-tree-pickrate {
font-size: 0.65rem;
color: var(--color-on-surface);
opacity: 0.6;
margin-top: 2px;
position: relative;
z-index: 10;
}
.item-tree-children {
margin-left: 32px;
position: relative;
z-index: 1;
}
.item-tree-child {
width: fit-content;
position: relative;
}
/* Mobile responsive */
@media only screen and (max-width: 900px) {
.item-tree-img {
width: 40px;
height: 40px;
}
.item-tree-children {
margin-left: 20px;
}

View File

@@ -1,346 +0,0 @@
<script setup lang="ts">
import { isEmpty, deepClone } from '~/utils/helpers'
const props = defineProps<{
builds: Builds
loading?: boolean
error?: boolean
}>()
// State - use cached API endpoint instead of direct CDragon fetch
const {
data: items,
pending: loadingItems,
error: itemsError
} = useFetch('/api/cdragon/items', {
lazy: true, // Don't block rendering
server: false // Client-side only
})
const itemMap = ref<Map<number, unknown>>(new Map())
// Initialize item map
watch(
items,
newItems => {
try {
const itemsData = newItems || []
if (Array.isArray(itemsData)) {
const map = new Map<number, unknown>()
for (const item of itemsData) {
if (item?.id) {
map.set(item.id, item)
}
}
itemMap.value = map
}
} catch (error) {
console.error('Error initializing item map:', error)
}
},
{ immediate: true }
)
// Builds management
const builds = ref<Builds>(deepClone(props.builds))
watch(
() => props.builds,
newBuilds => {
builds.value = deepClone(newBuilds)
trimBuilds(builds.value)
trimLateGameItems(builds.value)
},
{ deep: true }
)
// Initialize with trimmed builds
onMounted(() => {
trimBuilds(builds.value)
trimLateGameItems(builds.value)
})
/**
* Trim builds tree to show only primary build paths
*/
function trimBuilds(builds: Builds): void {
if (!builds?.tree?.children) return
// Keep only the first child (primary build path)
builds.tree.children.splice(1, builds.tree.children.length - 1)
// For the primary path, keep only the first child of the first child
if (builds.tree.children[0]?.children) {
builds.tree.children[0].children.splice(1, builds.tree.children[0].children.length - 1)
}
}
/**
* Remove items from lateGame that are already in the build tree
*/
function trimLateGameItems(builds: Builds): void {
if (!builds?.tree || isEmpty(builds.lateGame)) return
function trimLateGameItemsFromTree(tree: ItemTree): void {
const foundIndex = builds.lateGame.findIndex(x => x.data === tree.data)
if (foundIndex !== -1) {
builds.lateGame.splice(foundIndex, 1)
}
for (const child of tree.children || []) {
trimLateGameItemsFromTree(child)
}
}
trimLateGameItemsFromTree(builds.tree)
}
// Error and loading states
const _hasError = computed(() => itemsError.value || props.error)
const _isLoading = computed(() => loadingItems.value || props.loading)
</script>
<template>
<div id="iv-container">
<div>
<!-- Start items -->
<ItemBox v-if="builds.suppItems == undefined || builds.suppItems == null" title="start">
<div class="iv-items-container">
<div
v-for="item in builds.start"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
<!-- Supp items -->
<ItemBox v-if="builds.suppItems != undefined && builds.suppItems != null" title="supp">
<div class="iv-items-container">
<div
v-for="item in builds.suppItems"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
</div>
<!-- Boots first : when champion rush boots -->
<ItemBox v-if="builds.bootsFirst > 0.5" title="boots rush" :boots-first="builds.bootsFirst">
<div class="iv-items-container">
<div
v-for="item in builds.boots"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
<!-- Core items -->
<ItemBox title="core">
<ItemTree style="margin: auto; width: fit-content" :tree="builds.tree" />
</ItemBox>
<!-- Boots -->
<ItemBox v-if="builds.bootsFirst <= 0.5" title="boots">
<div class="iv-items-container">
<div
v-for="item in builds.boots.slice(0, 4)"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</ItemBox>
<!-- Late game items -->
<ItemBox title="late game">
<div id="iv-late-game-container">
<div class="iv-items-container">
<div
v-for="item in builds.lateGame.slice(0, 4)"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
<div v-if="builds.lateGame.length > 4" class="iv-items-container">
<div
v-for="item in builds.lateGame.slice(4, 8)"
:key="item.data"
style="margin-left: 5px; margin-right: 5px"
>
<NuxtImg
v-if="item.data != null && item.data != undefined && itemMap.get(item.data)"
class="item-img"
width="64"
height="64"
:alt="item.data.toString()"
:src="CDRAGON_BASE + mapPath((itemMap.get(item.data) as any).iconPath)"
/>
<div
v-else
style="
width: 64px;
height: 64px;
margin: 10px;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
"
></div>
<h3 style="width: fit-content; margin: auto; margin-bottom: 10px">
{{ ((item.count / builds.tree.count) * 100).toFixed(0) }}%
</h3>
</div>
</div>
</div>
</ItemBox>
</div>
</template>
<style>
#iv-container {
display: flex;
width: fit-content;
height: fit-content;
}
.iv-items-container {
display: flex;
flex-direction: column;
width: fit-content;
height: fit-content;
margin: auto;
}
.item-img {
border: 1px solid var(--color-on-surface);
margin: 10px;
}
#iv-late-game-container {
display: flex;
}
@media only screen and (max-width: 1000px) {
#iv-container {
flex-direction: column;
width: 100%;
}
.iv-items-container {
flex-direction: row;
}
.item-img {
width: 48px;
height: 48px;
}
#iv-late-game-container {
flex-direction: column;
}
}
</style>

View File

@@ -2,6 +2,8 @@
import { CDRAGON_BASE } from '~/utils/cdragon'
import MatchupSpectrum from './Spectrum.vue'
import type { MatchupData } from 'match_collector'
defineProps<{
matchups?: Array<MatchupData>
championId: number

View File

@@ -2,6 +2,8 @@
import { ref } from 'vue'
import { CDRAGON_BASE } from '~/utils/cdragon'
import type { MatchupData } from 'match_collector'
defineProps<{
matchups?: Array<MatchupData>
championId: number

View File

@@ -1,4 +1,6 @@
<script lang="ts" setup>
import type { LaneData } from 'match_collector'
defineProps<{
championName?: string
championLanes?: Array<LaneData>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
import type { LaneData } from 'match_collector'
defineProps<{
championName?: string
championLanes?: Array<LaneData>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
import type { LaneData } from 'match_collector'
defineProps<{
championName?: string
championLanes?: Array<LaneData>
@@ -39,9 +41,9 @@ if (route.path.startsWith('/tierlist/')) {
<div class="sidebar-container">
<Logo
font-size="2.6rem"
img-width="60"
style="padding-left: 15px; padding-right: 15px; margin-top: 30px"
font-size="2rem"
img-width="45"
style="padding-left: 10px; padding-right: 10px; margin-top: 20px"
/>
<div v-for="(lane, i) in championLanes" :key="i">
@@ -49,22 +51,22 @@ if (route.path.startsWith('/tierlist/')) {
style="
display: flex;
align-items: center;
margin-top: 30px;
margin-top: 20px;
padding-right: 10px;
overflow: hidden;
"
>
<h1 style="font-size: 2.4rem; padding-left: 20px">{{ championName }}</h1>
<h1 style="font-size: 1.8rem; padding-left: 15px">{{ championName }}</h1>
<NuxtImg
format="webp"
style="margin-left: 10px"
width="40"
height="40"
style="margin-left: 8px"
width="30"
height="30"
:src="LANE_IMAGES[lanePositionToIndex(lane.data)]"
/>
<h2
v-if="championName != null && championName != undefined && championName.length < 8"
style="margin-left: 5px; font-size: 1.8rem; font-weight: 200"
style="margin-left: 5px; font-size: 1.4rem; font-weight: 200"
>
{{ POSITIONS_STR[lanePositionToIndex(lane.data.toLowerCase())] }}
</h2>
@@ -74,7 +76,7 @@ if (route.path.startsWith('/tierlist/')) {
:class="
'sidebar-link ' + (state == 'build' && laneState == i ? 'sidebar-link-selected' : '')
"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
style="margin-top: 8px; font-size: 1.5rem; padding-left: 25px"
@click="handleStateChange('build', i)"
>
Build
@@ -85,7 +87,7 @@ if (route.path.startsWith('/tierlist/')) {
'sidebar-link ' +
(state == 'alternatives' && laneState == i ? 'sidebar-link-selected' : '')
"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
style="margin-top: 8px; font-size: 1.5rem; padding-left: 25px"
@click="handleStateChange('alternatives', i)"
>
Alternatives
@@ -94,15 +96,15 @@ if (route.path.startsWith('/tierlist/')) {
:class="
'sidebar-link ' + (state == 'matchups' && laneState == i ? 'sidebar-link-selected' : '')
"
style="margin-top: 10px; font-size: 1.9rem; padding-left: 35px"
style="margin-top: 8px; font-size: 1.5rem; padding-left: 25px"
@click="handleStateChange('matchups', i)"
>
Matchups
</h2>
</div>
<div v-if="tierlistList == true" style="margin-top: 30px">
<h2 style="padding-left: 20px; font-size: 2.4rem; margin-bottom: 10px">Tierlist</h2>
<div v-if="tierlistList == true" style="margin-top: 20px">
<h2 style="padding-left: 15px; font-size: 1.8rem; margin-bottom: 8px">Tierlist</h2>
<NuxtLink
v-for="(pos, i) in POSITIONS"
:key="i"
@@ -112,16 +114,16 @@ if (route.path.startsWith('/tierlist/')) {
<div
:class="selected == pos ? 'sidebar-link-selected' : ''"
class="sidebar-link"
style="padding-left: 35px; display: flex; align-items: center"
style="padding-left: 25px; display: flex; align-items: center"
>
<NuxtImg
format="webp"
width="40"
height="40"
width="30"
height="30"
:src="LANE_IMAGES[i]"
:alt="POSITIONS_STR[i]"
/>
<h3 style="font-size: 2.1rem; font-weight: 200; margin-left: 10px">
<h3 style="font-size: 1.6rem; font-weight: 200; margin-left: 8px">
{{ POSITIONS_STR[i] }}
</h3>
</div>
@@ -130,14 +132,20 @@ if (route.path.startsWith('/tierlist/')) {
<div style="position: absolute; bottom: 0; margin-bottom: 10px; padding-left: 10px">
<template v-if="stats">
<h3 style="font-size: 23px; font-weight: 200">Patch {{ stats.patch }}</h3>
<h3 style="font-size: 23px; font-weight: 200">{{ stats.count }} games</h3>
<h3 style="font-size: 18px; font-weight: 200">Patch {{ stats.patch }}</h3>
<h3 style="font-size: 18px; font-weight: 200">{{ stats.count }} games</h3>
</template>
<template v-else>
<h3 style="font-size: 23px; font-weight: 200; opacity: 0.5">Loading stats...</h3>
<h3 style="font-size: 18px; font-weight: 200; opacity: 0.5">Loading stats...</h3>
</template>
<h3 style="font-size: 18px; font-weight: 200; margin-top: 10px; margin-bottom: 10px">
Challenger only
</h3>
<h3 style="font-size: 18px; font-weight: 200; margin-top: 10px; margin-bottom: 10px">
EUW/EUNE/NA/KR
</h3>
<NuxtLink to="/about"><h3>About</h3></NuxtLink>
<h2 style="font-size: 12px; font-weight: 200; margin-top: 5px">
<h2 style="font-size: 10px; font-weight: 200; margin-top: 3px; margin-right: 10px">
BuildPath isn't endorsed by Riot Games and doesn't reflect the views or opinions of Riot
Games or anyone officially involved in producing or managing Riot Games properties. Riot
Games, and all associated properties are trademarks or registered trademarks of Riot Games,

View File

@@ -1,4 +1,7 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import type { PerkStyle, Perk } from '~/types/cdragon'
const props = defineProps<{
primaryStyleId: number
secondaryStyleId: number
@@ -8,13 +11,14 @@ const props = defineProps<{
const primaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
const secondaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
const { data: perks_data }: PerksResponse = await useFetch('/api/cdragon/perks')
const { data: perks_data }: { data: Ref<Array<Perk>> } = await useFetch('/api/cdragon/perks')
const perks = reactive(new Map())
for (const perk of perks_data.value) {
perks.set(perk.id, perk)
}
const { data: stylesData }: PerkStylesResponse = await useFetch('/api/cdragon/perkstyles')
const { data: stylesData }: { data: Ref<{ styles: Array<PerkStyle> }> } =
await useFetch('/api/cdragon/perkstyles')
watch(
() => props.primaryStyleId,
async (_newP, _oldP) => {
@@ -56,14 +60,13 @@ refreshStyles()
:key="slotIndex"
class="rune-slot"
>
<NuxtImg
v-for="perk in slot.perks"
:key="perk"
width="48"
:class="
'rune-img rune-keystone ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')
"
:src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"
<RuneIcon
v-for="perkId in slot.perks"
:key="perkId"
:perk="perks.get(perkId)"
:size="48"
:is-active="props.selectionIds.includes(perkId)"
:is-keystone="true"
/>
</div>
<div
@@ -71,36 +74,37 @@ refreshStyles()
:key="slotIndex"
class="rune-slot"
>
<NuxtImg
v-for="perk in slot.perks"
:key="perk"
width="48"
:class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')"
:src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"
<RuneIcon
v-for="perkId in slot.perks"
:key="perkId"
:perk="perks.get(perkId)"
:size="48"
:is-active="props.selectionIds.includes(perkId)"
/>
</div>
</div>
<div class="rune-spacer-bar" />
<div class="rune-holder" style="align-content: end">
<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
v-for="(slot, slotIndex) in secondaryStyle.slots.slice(1, 4)"
:key="slotIndex"
class="rune-slot"
>
<NuxtImg
v-for="perk in slot.perks"
:key="perk"
width="48"
:class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')"
:src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"
<RuneIcon
v-for="perkId in slot.perks"
:key="perkId"
:perk="perks.get(perkId)"
:size="48"
:is-active="props.selectionIds.includes(perkId)"
/>
</div>
<!-- <div class="rune-slot mini" v-for="slot in primaryStyle.slots.slice(4, 7)">
<img width="32" v-for="perk in slot.perks" :class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')" :src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"/>
</div> -->
</div>
</div>
</template>
@@ -123,24 +127,15 @@ refreshStyles()
margin-top: 10px;
margin-bottom: 10px;
}
.rune-img {
max-width: 100%;
overflow: hidden;
filter: grayscale(1);
border: 1px var(--color-on-surface) solid;
border-radius: 50%;
}
.rune-keystone {
border: none;
}
.rune-activated {
filter: none;
}
.rune-spacer-bar {
margin-left: 20px;
margin-right: 20px;
border: 1px var(--color-on-surface) solid;
}
.rune-style-img {
width: 48px;
height: 48px;
}
@media only screen and (max-width: 650px) {
.rune-slot {
@@ -148,10 +143,6 @@ refreshStyles()
margin-top: 20px;
margin-bottom: 20px;
}
.rune-img {
width: 24px;
height: 24px;
}
.rune-style-img {
width: 24px;
height: 24px;
@@ -160,5 +151,9 @@ refreshStyles()
margin-left: 10px;
margin-right: 10px;
}
.rune-icon {
width: 24px !important;
height: 24px !important;
}
}
</style>

View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import type { Perk } from '~/types/cdragon'
interface Props {
perk: Perk
size?: number
isActive?: boolean
isKeystone?: boolean
class?: string
}
const props = withDefaults(defineProps<Props>(), {
size: 48,
isActive: false,
isKeystone: false,
class: ''
})
// Tooltip state - encapsulated in this component
const tooltipState = reactive({
show: false,
perk: null as Perk | null,
x: 0,
y: 0
})
const handleMouseEnter = (event: MouseEvent) => {
tooltipState.perk = props.perk
// Calculate optimal position to keep tooltip within viewport
const tooltipWidth = 300 // Maximum width from CSS
const padding = 10 // Minimum padding from edges
const offset = 15 // Distance from cursor
// Get viewport dimensions
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
// Right edge detection: if we're in the right half, position to the left
let x = event.clientX + offset
if (event.clientX + tooltipWidth + offset > viewportWidth - padding) {
x = event.clientX - tooltipWidth - offset
// Clamp if still off-screen
if (x < padding) {
x = padding
}
}
// Bottom edge detection: if we're in the bottom half, position above
let y = event.clientY + offset
if (event.clientY > viewportHeight * 0.7) {
y = event.clientY - offset - 200 // Position ~200px above
// Clamp if too high
if (y < padding) {
y = padding
}
}
// Ensure Y is within reasonable bounds
y = Math.min(y, viewportHeight - padding)
tooltipState.x = x
tooltipState.y = y
tooltipState.show = true
}
const handleMouseLeave = () => {
tooltipState.show = false
tooltipState.perk = null
}
const perkIconPath = computed(() => CDRAGON_BASE + mapPath(props.perk.iconPath))
</script>
<template>
<div class="rune-icon-wrapper" @mouseleave="handleMouseLeave">
<div
class="rune-icon"
:class="[
props.class,
{
'rune-activated': isActive,
'rune-keystone': isKeystone
}
]"
:style="{ width: size + 'px', height: size + 'px' }"
@mouseenter="handleMouseEnter"
>
<NuxtImg :src="perkIconPath" :alt="perk.name || 'Rune'" class="rune-img" />
</div>
<RuneTooltip
:show="tooltipState.show"
:perk="tooltipState.perk"
:x="tooltipState.x"
:y="tooltipState.y"
/>
</div>
</template>
<style scoped>
.rune-icon-wrapper {
display: flex;
flex-direction: column;
align-items: center;
}
.rune-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 1px solid var(--color-on-surface);
overflow: hidden;
position: relative;
}
.rune-icon.rune-keystone {
border: none;
}
.rune-img {
width: 100%;
height: 100%;
filter: grayscale(1);
}
.rune-icon.rune-activated .rune-img {
filter: none;
}
</style>

View File

@@ -0,0 +1,366 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import type { Perk } from '~/types/cdragon'
interface Props {
perk: Perk | null
show: boolean
x: number
y: number
}
const props = defineProps<Props>()
// Parse the long description to extract styled content
// Rune descriptions use HTML-like tags including special lol-uikit tags
interface TextSegment {
type: string
content: string
color?: string
}
function parseRuneDescription(description: string | undefined): TextSegment[] {
if (!description) return []
const segments: TextSegment[] = []
// Pattern to match various tag types:
// - Simple tags: <status>, <keyword>, </status>
// - Tags with attributes: <font color='#FF8000'>, <lol-uikit-tooltipped-keyword ...>
// - Self-closing or complex tags
const tagPattern = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g
let lastIndex = 0
const tagStack: Array<{ type: string; color?: string }> = []
let match
while ((match = tagPattern.exec(description)) !== null) {
// Add any text before this tag
if (match.index > lastIndex) {
const text = description.slice(lastIndex, match.index)
if (text) {
const currentStyle = tagStack.length > 0 ? tagStack[tagStack.length - 1] : { type: 'text' }
segments.push({
type: currentStyle.type,
content: text,
color: currentStyle.color
})
}
}
const [fullMatch, isClosing, tagName, attributes] = match
// Normalize tag name (handle lol-uikit-tooltipped-keyword -> keyword)
let normalizedTag = tagName
if (tagName === 'lol-uikit-tooltipped-keyword') {
normalizedTag = 'keyword'
} else if (tagName === 'lol-uikit-tooltipped-link') {
normalizedTag = 'keyword'
}
if (!isClosing) {
// Opening tag - extract color if present
const colorMatch = attributes.match(/color=['"]([^'"]+)['"]/i)
const color = colorMatch ? colorMatch[1] : undefined
tagStack.push({ type: normalizedTag, color })
} else {
// Closing tag
tagStack.pop()
}
lastIndex = match.index + fullMatch.length
}
// Add any remaining text
if (lastIndex < description.length) {
const text = description.slice(lastIndex)
if (text) {
const currentStyle = tagStack.length > 0 ? tagStack[tagStack.length - 1] : { type: 'text' }
segments.push({
type: currentStyle.type,
content: text,
color: currentStyle.color
})
}
}
return segments
}
// Get CSS class for text segment type
function getSegmentClass(segment: TextSegment): string {
const classMap: Record<string, string> = {
text: '',
highlight: 'stat-highlight',
passive: 'tag-passive',
active: 'tag-active',
keyword: 'tag-keyword',
keywordMajor: 'tag-keyword-major',
keywordStealth: 'tag-keyword-stealth',
status: 'tag-status',
speed: 'tag-speed',
scaleMana: 'tag-scaling',
scaleHealth: 'tag-scaling',
scaleAP: 'tag-scaling',
scaleAD: 'tag-scaling',
scaleArmor: 'tag-scaling',
scaleMR: 'tag-scaling',
scaleLevel: 'tag-scaling',
scaleBonusHealth: 'tag-scaling',
scaleBonusMana: 'tag-scaling',
scaleMaxHealth: 'tag-scaling',
spellName: 'tag-spellname',
unique: 'tag-unique',
rarityMythic: 'tag-rarity-mythic',
rarityLegendary: 'tag-rarity-legendary',
rarityGeneric: 'tag-rarity-generic',
magicDamage: 'tag-magic-damage',
physicalDamage: 'tag-physical-damage',
trueDamage: 'tag-true-damage',
healing: 'tag-healing',
shield: 'tag-shield',
attention: 'stat-highlight',
onHit: 'tag-onhit',
color: ''
}
return classMap[segment.type] || ''
}
// Render text segments to HTML
function renderSegments(segments: TextSegment[]): string {
return segments
.map(segment => {
const cssClass = getSegmentClass(segment)
// If segment has a color, use it directly (handles <font color='...'> tags)
if (segment.color) {
return `<span style="color: ${segment.color}">${segment.content}</span>`
}
if (cssClass) {
return `<span class="${cssClass}">${segment.content}</span>`
}
return segment.content
})
.join('')
}
// Parsed description
const parsedLongDesc = computed<TextSegment[]>(() => {
return parseRuneDescription(props.perk?.longDesc)
})
const hasLongDesc = computed(() => {
return parsedLongDesc.value.length > 0
})
</script>
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="show && perk"
class="rune-tooltip"
:style="{
left: x + 'px',
top: y + 'px'
}"
@mouseenter.stop
>
<!-- Header -->
<div class="tooltip-header">
<NuxtImg class="tooltip-icon" :src="CDRAGON_BASE + mapPath(perk.iconPath)" />
<div class="tooltip-title">
<h3>{{ perk.name || 'Unknown Rune' }}</h3>
</div>
</div>
<!-- Long Description (detailed) -->
<div v-if="hasLongDesc" class="tooltip-long-desc">
<!-- eslint-disable vue/no-v-html -->
<div v-html="renderSegments(parsedLongDesc)"></div>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.rune-tooltip {
position: fixed;
z-index: 1000;
background: var(--tooltip-bg);
border: 1px solid var(--tooltip-border);
border-radius: 8px;
padding: 12px;
max-width: 320px;
min-width: 250px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
pointer-events: none;
font-family: 'Inter', sans-serif;
}
/* Header */
.tooltip-header {
display: flex;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--tooltip-header-border);
margin-bottom: 10px;
}
.tooltip-icon {
width: 48px;
height: 48px;
border-radius: 50%;
flex-shrink: 0;
background: rgba(0, 0, 0, 0.3);
}
.tooltip-title {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.tooltip-title h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--tooltip-text);
line-height: 1.2;
}
/* Long Description */
.tooltip-long-desc {
font-size: 0.8rem;
color: var(--tooltip-text-dim);
line-height: 1.5;
}
/* Text segment styles */
.tooltip-long-desc :deep(.stat-highlight) {
color: var(--tooltip-highlight);
font-weight: 600;
}
.tooltip-long-desc :deep(.tag-passive) {
color: var(--tooltip-effect-passive);
font-weight: 600;
}
.tooltip-long-desc :deep(.tag-active) {
color: var(--tooltip-effect-active);
font-weight: 600;
}
.tooltip-long-desc :deep(.tag-keyword) {
color: var(--tooltip-keyword);
font-weight: 600;
}
.tooltip-long-desc :deep(.tag-keyword-major) {
color: var(--tooltip-keyword-major);
font-weight: 700;
font-style: italic;
}
.tooltip-long-desc :deep(.tag-keyword-stealth) {
color: var(--tooltip-keyword-stealth);
font-weight: 600;
}
.tooltip-long-desc :deep(.tag-status) {
color: var(--tooltip-status);
font-weight: 500;
font-style: italic;
}
.tooltip-long-desc :deep(.tag-speed),
.tooltip-long-desc :deep(.tag-scaling) {
color: var(--tooltip-scaling);
font-style: italic;
font-weight: 500;
}
.tooltip-long-desc :deep(.tag-healing) {
color: var(--tooltip-healing);
font-weight: 500;
}
.tooltip-long-desc :deep(.tag-shield) {
color: var(--tooltip-shield);
font-weight: 500;
}
.tooltip-long-desc :deep(.tag-magic-damage) {
color: var(--tooltip-magic-damage);
font-weight: 500;
}
.tooltip-long-desc :deep(.tag-physical-damage) {
color: var(--tooltip-physical-damage);
font-weight: 500;
}
.tooltip-long-desc :deep(.tag-true-damage) {
color: var(--tooltip-true-damage);
font-weight: 600;
}
.tooltip-long-desc :deep(.tag-onhit) {
background: rgba(52, 152, 219, 0.2);
color: var(--tooltip-onhit);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.tooltip-long-desc :deep(.tag-spellname) {
color: var(--tooltip-spellname);
font-weight: 600;
font-style: italic;
}
.tooltip-long-desc :deep(.tag-unique) {
color: var(--tooltip-effect-unique);
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.5px;
}
.tooltip-long-desc :deep(.tag-rarity-mythic) {
color: var(--tooltip-effect-mythic);
font-weight: 700;
font-size: 0.85rem;
}
.tooltip-long-desc :deep(.tag-rarity-legendary) {
color: var(--tooltip-effect-legendary);
font-weight: 600;
font-size: 0.85rem;
}
.tooltip-long-desc :deep(.tag-rarity-generic) {
color: var(--tooltip-effect-epic);
font-weight: 500;
font-size: 0.85rem;
}
/* Transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,152 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
runes: Array<{
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate: number
}>
}>()
const currentlySelectedPage = ref(0)
const primaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
const secondaryStyles: Ref<Array<PerkStyle>> = ref(Array(props.runes.length))
const keystoneIds: Ref<Array<number>> = ref(Array(props.runes.length))
// Use cached API endpoints instead of direct CDragon fetch
const { data: perks_data }: PerksResponse = await useFetch('/api/cdragon/perks')
const perks = reactive(new Map())
for (const perk of perks_data.value) {
perks.set(perk.id, perk)
}
const { data: stylesData }: PerkStylesResponse = await useFetch('/api/cdragon/perkstyles')
watch(
() => props.runes,
(_newRunes, _oldRunes) => {
currentlySelectedPage.value = 0
primaryStyles.value = Array(props.runes.length)
secondaryStyles.value = Array(props.runes.length)
keystoneIds.value = Array(props.runes.length)
refreshStylesKeystones()
}
)
function refreshStylesKeystones() {
for (const style of stylesData.value.styles) {
for (const rune of props.runes) {
if (style.id == rune.primaryStyle) {
primaryStyles.value[props.runes.indexOf(rune)] = style
for (const perk of style.slots[0].perks) {
if (rune.selections.includes(perk)) {
keystoneIds.value[props.runes.indexOf(rune)] = perk
}
}
}
if (style.id == rune.secondaryStyle) {
secondaryStyles.value[props.runes.indexOf(rune)] = style
}
}
}
}
refreshStylesKeystones()
function runeSelect(index: number) {
currentlySelectedPage.value = index
}
</script>
<template>
<div style="width: fit-content">
<RunePage
v-if="runes[currentlySelectedPage] != undefined && runes[currentlySelectedPage] != null"
style="margin: auto; width: fit-content"
:primary-style-id="runes[currentlySelectedPage].primaryStyle"
:secondary-style-id="runes[currentlySelectedPage].secondaryStyle"
:selection-ids="runes[currentlySelectedPage].selections"
/>
<div style="display: flex; margin-top: 20px; justify-content: center">
<div v-for="(_, i) in runes" :key="i" @click="runeSelect(i)">
<div
:class="
'rune-selector-entry ' +
(i == currentlySelectedPage ? 'rune-selector-entry-selected' : '')
"
>
<div class="rs-styles-container">
<NuxtImg
v-if="primaryStyles[i] != null && primaryStyles[i] != undefined"
class="rs-style-img"
style="margin: auto"
:src="CDRAGON_BASE + mapPath(primaryStyles[i].iconPath)"
/>
<NuxtImg
v-if="keystoneIds[i] != null && keystoneIds[i] != undefined"
class="rs-style-img"
width="34"
:src="CDRAGON_BASE + mapPath(perks.get(keystoneIds[i]).iconPath)"
/>
<NuxtImg
v-if="secondaryStyles[i] != null && secondaryStyles[i] != undefined"
class="rs-style-img"
style="margin: auto"
:src="CDRAGON_BASE + mapPath(secondaryStyles[i].iconPath)"
/>
</div>
</div>
<h3 class="rs-pickrate">{{ (runes[i].pickrate * 100).toFixed(2) }}% pick.</h3>
</div>
</div>
</div>
</template>
<style>
.rune-selector-entry {
width: 200px;
height: 120px;
margin-left: 10px;
margin-right: 10px;
border-radius: 8%;
border: 1px solid var(--color-on-surface);
}
.rune-selector-entry:hover {
cursor: pointer;
}
.rune-selector-entry-selected {
background-color: var(--color-surface-darker);
}
.rs-styles-container {
display: flex;
margin-top: 20px;
}
.rs-pickrate {
text-align: center;
margin-top: -40px;
padding-bottom: 40px;
}
@media only screen and (max-width: 650px) {
.rune-selector-entry {
width: 100px;
height: 60px;
margin-left: 5px;
margin-right: 5px;
}
.rs-styles-container {
margin-top: 17px;
}
.rs-pickrate {
margin-top: 5px;
padding-bottom: 0px;
}
.rs-style-img {
width: 24px;
height: 24px;
}
}
</style>

View File

@@ -10,6 +10,9 @@ import {
} from 'chart.js'
import { Bar } from 'vue-chartjs'
import type { Champion } from '~/types/cdragon'
import type { LaneData } from 'match_collector'
// Register
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)

View File

@@ -1,4 +1,7 @@
<script setup lang="ts">
import type { LaneData } from 'match_collector'
import type { Champion } from '~/types/cdragon'
defineProps<{
title: string
tier: Array<{ champion: Champion; lane: LaneData }>

View File

@@ -0,0 +1,21 @@
import type { Builds } from 'match_collector'
/**
* Composable for managing build data
*/
import { deepClone } from '~/utils/helpers'
export const useBuilds = (buildsProp: Ref<Builds>) => {
const builds = ref<Builds>(deepClone(buildsProp.value))
// Watch for changes and rebuild
watch(
() => buildsProp.value,
newBuilds => {
builds.value = deepClone(newBuilds)
},
{ deep: true }
)
return { builds }
}

View File

@@ -0,0 +1,32 @@
import type { Item } from '~/types/cdragon'
/**
* Composable for fetching and managing item data from CDragon API
* Returns a reactive Map of item ID to item data
*/
export const useItemMap = () => {
const { data: items } = useFetch<Array<Item>>('/api/cdragon/items', {
lazy: true,
server: false
})
const itemMap = ref<Map<number, Item>>(new Map())
watch(
items,
newItems => {
if (Array.isArray(newItems)) {
const map = new Map<number, Item>()
for (const item of newItems) {
if (item?.id) {
map.set(item.id, item)
}
}
itemMap.value = map
}
},
{ immediate: true }
)
return { itemMap }
}

View File

@@ -0,0 +1,47 @@
import type { Perk, PerkStyle } from '~/types/cdragon'
/**
* Composable for fetching and managing rune styles and keystones
* Transforms rune data into format needed for display components
*/
export const useRuneStyles = () => {
const { data: perksData } = useFetch('/api/cdragon/perks')
const { data: stylesData } = useFetch('/api/cdragon/perkstyles')
const perks = reactive(new Map<number, Perk>())
watch(
perksData,
newPerks => {
if (Array.isArray(newPerks)) {
perks.clear()
for (const perk of newPerks) {
if (perk?.id) {
perks.set(perk.id, perk)
}
}
}
},
{ immediate: true }
)
const perkStyles = reactive(new Map<number, PerkStyle>())
watch(
stylesData,
newPerkStyles => {
if (Array.isArray(newPerkStyles?.styles)) {
perkStyles.clear()
for (const perkStyle of newPerkStyles.styles) {
if (perkStyle?.id) {
perkStyles.set(perkStyle.id, perkStyle)
}
}
}
},
{ immediate: true }
)
return {
perks,
perkStyles
}
}

View File

@@ -0,0 +1,35 @@
import type { SummonerSpell } from '~/types/cdragon'
/**
* Composable for fetching and managing summoner spell data from CDragon API
* Returns a reactive Map of spell ID to spell data
*/
export const useSummonerSpellMap = () => {
const { data: summonerSpellsData } = useFetch<Array<SummonerSpell>>(
'/api/cdragon/summoner-spells',
{
lazy: true,
server: false
}
)
const summonerSpellMap = ref<Map<number, SummonerSpell>>(new Map())
watch(
summonerSpellsData,
newData => {
if (Array.isArray(newData)) {
const map = new Map<number, SummonerSpell>()
for (const spell of newData) {
if (spell?.id) {
map.set(spell.id, spell)
}
}
summonerSpellMap.value = map
}
},
{ immediate: true }
)
return { summonerSpellMap }
}

View File

@@ -0,0 +1,13 @@
// Polyfill for Object.groupBy (requires Node.js 21+, we're on 20)
// This must be imported before any code that uses Object.groupBy
if (typeof Object.groupBy === 'undefined') {
Object.groupBy = (items, keyFn) => {
const result = {}
let index = 0
for (const item of items) {
const key = keyFn(item, index++)
;(result[key] ??= []).push(item)
}
return result
}
}

View File

@@ -1,4 +1,5 @@
// @ts-check
import './eslint-polyfill.mjs'
import withNuxt from './.nuxt/eslint.config.mjs'
import js from '@eslint/js'
@@ -16,24 +17,7 @@ export default withNuxt([
languageOptions: {
globals: {
...globals.browser,
...globals.node,
// Add global types from our API definitions
ChampionSummary: 'readonly',
LaneData: 'readonly',
ChampionData: 'readonly',
ItemTree: 'readonly',
Builds: 'readonly',
PerkStyle: 'readonly',
PerksResponse: 'readonly',
PerkStylesResponse: 'readonly',
Champion: 'readonly',
ChampionsResponse: 'readonly',
ChampionResponse: 'readonly',
ItemResponse: 'readonly',
MatchupData: 'readonly',
Item: 'readonly',
SummonerSpell: 'readonly',
Perk: 'readonly'
...globals.node
}
},
rules: {

File diff suppressed because it is too large Load Diff

View File

@@ -14,6 +14,8 @@
"format:check": "prettier --check ."
},
"dependencies": {
"match_collector": "file:../match_collector",
"dragon-item-parser": "file:../dragon-item-parser",
"@nuxt/eslint": "^1.12.1",
"@nuxt/fonts": "^0.11.3",
"@nuxt/image": "^1.10.0",

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ChampionData } from 'match_collector'
const route = useRoute()
const championAlias = route.params.alias as string
@@ -10,6 +12,13 @@ const error = ref<string | null>(null)
const laneState = ref(0)
const state = ref('build')
// Data fetching
const { itemMap } = useItemMap()
const { perks } = useRuneStyles()
// State for selected variant in alternatives tab
const selectedAltVariant = ref(0)
// Use useAsyncData with client-side fetching for faster initial page load
const {
data: championData,
@@ -69,7 +78,6 @@ watch(
// Add timeout to prevent infinite loading
const loadingTimeout = setTimeout(() => {
if (isLoading.value && !championData.value && !error.value) {
console.warn('Champion data loading timed out')
error.value = 'Loading took too long. Please try again.'
isLoading.value = false
}
@@ -156,6 +164,7 @@ function fetchChampionData() {
/>
<div id="champion-content">
<div class="champion-header">
<ChampionTitle
v-if="championData.gameCount > 0 && lane"
id="champion-title"
@@ -164,20 +173,48 @@ function fetchChampionData() {
:pickrate="lane.pickrate || 0"
:game-count="lane.count || 0"
/>
<SummonerSpells v-if="lane" :summoner-spells="lane.summonerSpells" />
</div>
<ClientOnly>
<LazyBuildViewer
v-if="state == 'build' && championData.gameCount > 0 && lane?.runes && lane?.builds"
v-if="state == 'build' && championData.gameCount > 0 && lane?.builds"
style="margin: auto; margin-top: 40px"
:runes="lane.runes"
:builds="lane.builds"
/>
</ClientOnly>
<ClientOnly>
<LazyItemTree
v-if="state == 'alternatives' && championData.gameCount > 0 && lane?.builds?.tree"
style="margin: auto; margin-top: 40px; width: fit-content"
:tree="lane.builds.tree"
<div
v-if="state == 'alternatives' && championData.gameCount > 0 && lane && lane.builds"
style="
margin: auto;
margin-top: 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
"
>
<div style="display: flex">
<LazyBuildVariantSelector
v-for="(build, i) in lane.builds"
:key="i"
:keystone-id="build.runeKeystone"
:item-id="build.items.children[0].data"
:keystore="perks"
:item-map="itemMap"
:pickrate="build.pickrate"
:selected="selectedAltVariant == i"
:index="i"
@select="selectedAltVariant = i"
/>
</div>
<LazyItemTree
v-if="lane.builds[selectedAltVariant]?.items"
style="width: fit-content"
:tree="lane.builds[selectedAltVariant].items"
/>
</div>
</ClientOnly>
<ClientOnly>
<LazyMatchupSection
@@ -292,6 +329,15 @@ function fetchChampionData() {
background-color: #45a049;
}
/* Champion header layout */
.champion-header {
display: flex;
align-items: center;
margin: auto;
width: fit-content;
gap: 40px;
}
@media only screen and (max-width: 650px) {
#champion-content {
margin: auto;
@@ -300,7 +346,18 @@ function fetchChampionData() {
#champion-title {
margin: auto;
}
.champion-header {
flex-direction: column;
gap: 20px;
}
.layout-selector {
flex-wrap: wrap;
justify-content: center;
}
}
@media only screen and (max-width: 1200px) {
#alias-content-wrapper {
flex-direction: column;

View File

@@ -1,10 +1,14 @@
<script setup lang="ts">
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
import type { LaneData, ChampionData, Champion } from 'match_collector'
const route = useRoute()
const lane = route.params.lane as string
const { data: championsData }: ChampionsResponse = await useFetch('/api/cdragon/champion-summary')
const { data: championsData }: { data: Ref<Array<Champion>> } = await useFetch(
'/api/cdragon/champion-summary'
)
const { data: championsLanes }: { data: Ref<Array<ChampionData>> } =
await useFetch('/api/champions')

View File

@@ -1,4 +1,5 @@
import type { MongoClient } from 'mongodb'
import type { ChampionData } from 'match_collector'
import { connectToDatabase, fetchLatestPatch } from '../../utils/mongo'
async function championInfos(client: MongoClient, patch: string, championAlias: string) {

View File

@@ -1,4 +1,5 @@
import type { MongoClient } from 'mongodb'
import type { ChampionData } from 'match_collector'
import { connectToDatabase, fetchLatestPatch } from '../utils/mongo'
async function champions(client: MongoClient, patch: string) {
@@ -11,7 +12,6 @@ async function champions(client: MongoClient, patch: string) {
if (x.lanes != undefined && x.lanes != null) {
for (const lane of x.lanes) {
delete lane.builds
delete lane.runes
}
}
})

View File

@@ -1,19 +1,43 @@
import type { MongoClient } from 'mongodb'
import { connectToDatabase, fetchLatestPatch } from '../utils/mongo'
import { connectToDatabase, fetchLatestPatch, getAvailablePlatforms } from '../utils/mongo'
async function fetchGameCount(client: MongoClient, patch: string) {
const database = client.db('matches')
// Check for platform-specific collections
const platforms = await getAvailablePlatforms(client, patch)
if (platforms.length > 0) {
// Sum counts from all platform-specific collections
let totalCount = 0
const platformCounts: Record<string, number> = {}
for (const platform of platforms) {
const collection = database.collection(`${patch}_${platform}`)
const count = await collection.countDocuments()
platformCounts[platform] = count
totalCount += count
}
return { total: totalCount, platforms: platformCounts }
}
// Fall back to old format (single collection)
const matches = database.collection(patch)
const count = await matches.countDocuments()
return count
return { total: count, platforms: {} }
}
export default defineEventHandler(async _ => {
const client = await connectToDatabase()
const latestPatch = await fetchLatestPatch(client)
const gameCount = await fetchGameCount(client, latestPatch)
const gameCountData = await fetchGameCount(client, latestPatch)
await client.close()
return { patch: latestPatch, count: gameCount }
return {
patch: latestPatch,
count: gameCountData.total,
platformCounts: gameCountData.platforms
}
})

View File

@@ -1,6 +1,7 @@
import { readFile, existsSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { parseItemStats, type ItemStats } from 'dragon-item-parser'
const readFileAsync = promisify(readFile)
@@ -8,13 +9,18 @@ const readFileAsync = promisify(readFile)
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
// 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 = () => {
if (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')
}
// Default to /cdragon for production (Docker)
return '/cdragon'
}
/**
@@ -78,14 +84,20 @@ async function fetchFromCache<T>(
}
/**
* Get items data from cache
* Get items data from cache with parsed stats
*/
async function getItems(patch?: string): Promise<CDragonItem[]> {
return fetchFromCache<CDragonItem[]>(
async function getItems(patch?: string): Promise<CDragonItemWithStats[]> {
const items = await fetchFromCache<CDragonItem[]>(
'items.json',
'plugins/rcp-be-lol-game-data/global/default/v1/items.json',
{ patch }
)
// Parse stats from description for each item
return items.map(item => ({
...item,
stats: parseItemStats(item.description || '')
}))
}
/**
@@ -148,6 +160,10 @@ interface CDragonItem {
}
}
interface CDragonItemWithStats extends CDragonItem {
stats: ItemStats
}
interface CDragonPerk {
id: number
name: string
@@ -197,6 +213,7 @@ export {
export type {
CDragonItem,
CDragonItemWithStats,
CDragonPerk,
CDragonPerkStyle,
CDragonPerkStyles,

View File

@@ -1,5 +1,9 @@
import { MongoClient } from 'mongodb'
// Available platforms for region-specific match data
const PLATFORMS = ['EUW1', 'EUN1', 'NA1', 'KR'] as const
type Platform = (typeof PLATFORMS)[number]
async function connectToDatabase() {
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
@@ -15,11 +19,59 @@ async function connectToDatabase() {
return client
}
async function fetchLatestPatch(client: MongoClient) {
const database = client.db('patches')
const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
return latestPatch!.patch as string
/**
* Get the latest patch from existing match collections in the database.
* Collections are named like "15.1_EUW1", "15.2_NA1", etc.
*/
async function fetchLatestPatch(client: MongoClient): Promise<string> {
const matchesDb = client.db('matches')
const collections = await matchesDb.listCollections().toArray()
const collectionNames = collections.map(c => c.name)
// Extract unique patch versions from collection names
const patches = new Set<string>()
for (const name of collectionNames) {
// Collection names are either "patch_platform" or just "patch"
const patch = name.split('_')[0]
if (patch && /^\d+\.\d+$/.test(patch)) {
patches.add(patch)
}
}
if (patches.size === 0) {
throw new Error('No patch collections found in database')
}
// Sort patches and return the latest (highest version number)
const sortedPatches = Array.from(patches).sort((a, b) => {
const [aMajor, aMinor] = a.split('.').map(Number)
const [bMajor, bMinor] = b.split('.').map(Number)
if (aMajor !== bMajor) return bMajor - aMajor
return bMinor - aMinor
})
return sortedPatches[0]
}
export { connectToDatabase, fetchLatestPatch }
/**
* Get available platforms for a given patch by checking which match collections exist
* Note: Match collections are platform-specific (e.g., "15.1_EUW1")
* Champion collections are aggregated across all platforms (e.g., "15.1")
*/
async function getAvailablePlatforms(client: MongoClient, patch: string): Promise<Platform[]> {
const matchesDb = client.db('matches')
const collections = await matchesDb.listCollections().toArray()
const collectionNames = collections.map(c => c.name)
const availablePlatforms: Platform[] = []
for (const platform of PLATFORMS) {
if (collectionNames.includes(`${patch}_${platform}`)) {
availablePlatforms.push(platform)
}
}
return availablePlatforms
}
export { connectToDatabase, fetchLatestPatch, getAvailablePlatforms, PLATFORMS }
export type { Platform }

View File

@@ -1,85 +0,0 @@
declare global {
/**
* Represents an item in the build tree
*/
interface ItemTree {
count: number
data: number
children: ItemTree[]
}
/**
* Represents champion build information
*/
interface Builds {
start: Array<{ count: number; data: number }>
tree: ItemTree
bootsFirst: number
boots: Array<{ count: number; data: number }>
lateGame: Array<{ count: number; data: number }>
suppItems?: Array<{ count: number; data: number }>
}
/**
* Represents a rune configuration
*/
interface Rune {
count: number
primaryStyle: number
secondaryStyle: number
selections: number[]
pickrate: number
}
/**
* Represents counter data for a champion
*/
interface MatchupData {
championId: number
winrate: number
games: number
championName: string
championAlias: string
}
/**
* Represents lane-specific champion data
*/
interface LaneData {
data: string
count: number
winningMatches: number
losingMatches: number
winrate: number
pickrate: number
runes?: Rune[]
builds?: Builds
matchups?: MatchupData[]
}
/**
* Represents complete champion data
*/
interface ChampionData {
id: number
name: string
alias: string
gameCount: number
winrate: number
pickrate: number
lanes: LaneData[]
}
/**
* Champion summary from CDragon
*/
interface ChampionSummary {
id: number
name: string
alias: string
squarePortraitPath: string
// Add other relevant fields as needed
}
}
export {}

View File

@@ -1,51 +1,45 @@
declare global {
type ChampionsResponse = {
data: Ref<Array<Champion>>
}
type ChampionResponse = {
data: Ref<ChampionFull>
}
type Champion = {
import type { ItemStats } from 'dragon-item-parser'
type Champion = {
name: string
alias: string
squarePortraitPath: string
}
type ChampionFull = {
}
type ChampionFull = {
name: string
alias: string
squarePortraitPath: string
title: string
}
type ItemResponse = {
data: Ref<Array<Item>>
}
type Item = {
}
type Item = {
id: number
iconPath: string
name?: string
}
type SummonerSpell = {
description?: string
plaintext?: string
into?: number[]
from?: number[]
price?: number
priceTotal?: number
stats?: ItemStats
}
type SummonerSpell = {
id: number
iconPath: string
name: string
}
type PerksResponse = {
data: Ref<Array<Perk>>
}
type Perk = {
}
type Perk = {
id: number
name: string
iconPath: string
}
type PerkStylesResponse = {
data: Ref<{ styles: Array<PerkStyle> }>
}
type PerkStyle = {
shortDesc?: string
longDesc?: string
}
type PerkStyle = {
id: number
name: string
iconPath: string
slots: Array<{ perks: Array<number> }>
}
}
export {}
export type { Champion, ChampionFull, Item, SummonerSpell, Perk, PerkStyle }

View File

@@ -0,0 +1,92 @@
import type { Build, ItemTree } from 'match_collector'
/**
* Gets all late game items from the item tree (items beyond first level)
* Returns a flat array of unique items with their counts
*/
export function getLateGameItems(build: Build): Array<{ data: number; count: number }> {
const lateGameItems: Array<{ data: number; count: number }> = []
const itemCounts = new Map<number, number>()
// Collect late items
function collectLateItems(tree: ItemTree, depth: number = 0): void {
if (depth >= 3 && tree.data !== undefined && tree.count > 0) {
const existing = itemCounts.get(tree.data) || 0
itemCounts.set(tree.data, existing + tree.count)
}
for (const child of tree.children) {
collectLateItems(child, depth + 1)
}
}
collectLateItems(build.items)
// Convert map to array
for (const [data, count] of itemCounts.entries()) {
lateGameItems.push({ data, count })
}
lateGameItems.sort((a, b) => b.count - a.count)
// Sort by count descending
return lateGameItems.filter(item => !treeToArray(getCoreItems(build)).includes(item.data))
}
function treeToArray(tree: ItemTree): Array<number> {
const arr: Array<number> = []
if (tree.data != null) arr.push(tree.data)
for (const child of tree.children) arr.push(...treeToArray(child))
return arr
}
/**
* Creates a deep copy of an ItemTree trimmed to a maximum depth
* @param tree - The item tree to copy and trim
* @param maxDepth - The maximum depth to keep (inclusive)
* @param currentDepth - The current depth during recursion
* @returns A new ItemTree with children trimmed beyond maxDepth
*/
function trimTreeDepth(tree: ItemTree, maxDepth: number, currentDepth: number = 0): ItemTree {
const trimmedTree: ItemTree = {
count: tree.count,
data: tree.data,
children: [],
tags: tree.tags,
boughtWhen: tree.boughtWhen,
platformCount: tree.platformCount
}
// If we haven't reached maxDepth, include children
if (currentDepth < maxDepth) {
for (const child of tree.children || []) {
trimmedTree.children.push(trimTreeDepth(child, maxDepth, currentDepth + 1))
}
}
return trimmedTree
}
function trimTreeChildrensAtDepth(tree: ItemTree, maxChildren: number, depth: number) {
if (depth == 0) {
if (tree.children.length > maxChildren) {
tree.children.splice(maxChildren, tree.children.length - maxChildren)
}
return
}
for (const c of tree.children) {
trimTreeChildrensAtDepth(c, maxChildren, depth - 1)
}
}
export function getCoreItems(build: Build): ItemTree {
const tree = trimTreeDepth(build.items, 3)
trimTreeChildrensAtDepth(tree, 1, 0)
trimTreeChildrensAtDepth(tree, 1, 1)
trimTreeChildrensAtDepth(tree, 3, 2)
return tree
}

View File

@@ -1 +1,2 @@
node_modules
dist

View File

@@ -1,8 +1,33 @@
FROM node:lts-alpine
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
# This Dockerfile should be built from the project root directory:
# docker build -f match_collector/Dockerfile -t buildpath-match_collector .
FROM node:current-alpine AS build
RUN mkdir -p /home/node/app && chown -R node:node /home/node/app
WORKDIR /home/node/app
USER node
COPY --chown=node:node package*.json ./
# Copy and build dragon-item-parser first
COPY --chown=node:node dragon-item-parser/package*.json ./dragon-item-parser/
COPY --chown=node:node dragon-item-parser/tsconfig.json ./dragon-item-parser/
COPY --chown=node:node dragon-item-parser/src ./dragon-item-parser/src
WORKDIR /home/node/app/dragon-item-parser
RUN npm install && npm run build
# Build match_collector
WORKDIR /home/node/app
COPY --chown=node:node match_collector/package*.json ./match_collector/
WORKDIR /home/node/app/match_collector
RUN npm install
COPY --chown=node:node . .
CMD /bin/sh -c "node --import=tsx index.ts; sleep 20h"
COPY --chown=node:node match_collector/. .
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
WORKDIR /home/node/app
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 --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

@@ -1,462 +0,0 @@
function sameArrays(array1: Array<number>, array2: Array<number>) {
if (array1.length != array2.length) return false
for (const e of array1) {
if (!array2.includes(e)) return false
}
return true
}
import { MongoClient } from 'mongodb'
import { ItemTree, treeInit, treeMerge, treeCutBranches, treeSort } from './item_tree'
const itemDict = new Map()
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
}
function arrayRemovePercentage(
array: Array<{ count: number }>,
totalGames: number,
percentage: number
) {
const toRemove: Array<{ count: number }> = []
for (const item of array) {
if (item.count / totalGames < percentage) {
toRemove.push(item)
}
}
for (const tr of toRemove) {
array.splice(array.indexOf(tr), 1)
}
}
type Rune = {
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate?: number
}
type Builds = {
tree: ItemTree
start: Array<{ data: number; count: number }>
bootsFirst: number
boots: Array<{ data: number; count: number }>
lateGame: Array<{ data: number; count: number }>
suppItems?: Array<{ data: number; count: number }>
}
type Champion = {
id: number
name: string
alias: string
}
type MatchupData = {
championId: number
winrate: number
games: number
championName: string
championAlias: string
}
type LaneData = {
data: string
count: number
winningMatches: number
losingMatches: number
winrate: number
pickrate: number
runes: Array<Rune>
builds: Builds
matchups?: Array<MatchupData>
}
type ChampionData = {
champion: Champion
winningMatches: number
losingMatches: number
lanes: Array<LaneData>
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleParticipantRunes(participant: any, runes: Array<Rune>) {
const primaryStyle = participant.perks.styles[0].style
const secondaryStyle = participant.perks.styles[1].style
const selections: Array<number> = []
for (const style of participant.perks.styles) {
for (const perk of style.selections) {
selections.push(perk.perk)
}
}
const gameRunes: Rune = {
count: 1,
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(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
timeline: any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
participant: any,
participantIndex: number,
builds: Builds
) {
const items: Array<number> = []
for (const frame of timeline.info.frames) {
for (const event of frame.events) {
if (event.participantId != participantIndex) continue
if (event.type == 'ITEM_UNDO') {
if (items.length > 0 && items[items.length - 1] == event.beforeId) {
items.pop()
}
continue
}
const itemInfo = itemDict.get(event.itemId)
// Handle bounty of worlds destroy as upgrade
if (event.type == 'ITEM_DESTROYED') {
if (event.itemId == 3867) {
const suppItem: number = itemInfo.to.find(
(x: number) =>
x == participant.item0 ||
x == participant.item1 ||
x == participant.item2 ||
x == participant.item3 ||
x == participant.item4 ||
x == participant.item5 ||
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 })
else already.count += 1
}
}
}
if (event.type != 'ITEM_PURCHASED') continue
// Handle boots upgrades
if (
itemInfo.requiredBuffCurrencyName == 'Feats_NoxianBootPurchaseBuff' ||
itemInfo.requiredBuffCurrencyName == 'Feats_SpecialQuestBootBuff'
) {
continue
}
// Handle boots differently
if (itemInfo.categories.includes('Boots')) {
if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) {
// Check for bootsFirst
if (items.length < 2) {
builds.bootsFirst += 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 })
else already.count += 1
}
continue
}
// Check if item should be included
if (itemInfo.categories.includes('Consumable')) continue
if (itemInfo.categories.includes('Trinket')) continue
// Ignore zephyr
if (event.itemId == 3172) continue
// 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
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
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function handleMatch(match: any, champions: Map<number, ChampionData>) {
let participantIndex = 0
for (const participant of match.info.participants) {
participantIndex += 1
const championId = participant.championId
const champion = champions.get(championId)
// 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,
winningMatches: 0,
losingMatches: 0,
winrate: 0,
pickrate: 0,
matchups: []
}
champion.lanes.push(lane)
} else lane.count += 1
// Initialize matchups if not present
if (!lane.matchups) {
lane.matchups = []
}
// Winrate
if (participant.win) {
champion.winningMatches++
lane.winningMatches++
} else {
champion.losingMatches++
lane.losingMatches++
}
// Track counter matchups - find opponent in same lane
const opponentTeam = participant.teamId === 100 ? 200 : 100
const opponent = match.info.participants.find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(p: any) => p.teamId === opponentTeam && p.teamPosition === participant.teamPosition
)
if (opponent) {
const opponentChampionId = opponent.championId
// Track this matchup for current champion
const matchup = lane.matchups.find(c => c.championId === opponentChampionId)
if (matchup) {
matchup.games += 1
if (participant.win) {
matchup.winrate = (matchup.winrate * (matchup.games - 1) + 1) / matchup.games
} else {
matchup.winrate = (matchup.winrate * (matchup.games - 1)) / matchup.games
}
} else {
const opponentChampion = champions.get(opponentChampionId)
lane.matchups.push({
championId: opponentChampionId,
winrate: participant.win ? 1 : 0,
games: 1,
championName: opponentChampion.champion.name,
championAlias: opponentChampion.champion.alias
})
}
}
// Runes
handleParticipantRunes(participant, lane.runes)
// Items
handleMatchItems(match.timeline, participant, participantIndex, lane.builds)
}
}
async function handleMatchList(
client: MongoClient,
patch: string,
champions: Map<number, ChampionData>
) {
const database = client.db('matches')
const matches = database.collection(patch)
const allMatches = matches.find()
const totalMatches: number = await matches.countDocuments()
let currentMatch = 0
for await (const match of allMatches) {
process.stdout.write(
'\rComputing champion stats, game entry ' + currentMatch + '/' + totalMatches + ' ... '
)
currentMatch += 1
handleMatch(match, champions)
}
return totalMatches
}
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
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
}
for (const lane of champion.lanes) {
const builds = lane.builds
// 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)
}
for (const lane of champion.lanes) {
lane.winrate = lane.winningMatches / lane.count
lane.pickrate = lane.count / totalMatches
}
// Sort matchups by score (games * winrate) in descending order
for (const lane of champion.lanes) {
if (lane.matchups && lane.matchups.length > 0) {
// Filter out matchups with insufficient games (minimum 5 games)
const filteredMatchups = lane.matchups.filter(m => m.games >= 5)
// Sort by score (games * (winrate - 0.5)^2) descending
filteredMatchups.sort((a, b) => {
// Handle special case of exactly 50% winrate
if (a.winrate === 0.5 && b.winrate === 0.5) {
// Both have 50% winrate, sort by games (more games first)
return b.games - a.games
}
if (a.winrate === 0.5 || b.winrate === 0.5) {
// a has 50% winrate, b doesn't - b comes first
return b.winrate - a.winrate
}
if (a.winrate > 0.5 && b.winrate < 0.5) return -1
if (a.winrate < 0.5 && b.winrate > 0.5) return 1
if (a.winrate > 0.5) {
return b.games * (b.winrate - 0.5) ** 2 - a.games * (a.winrate - 0.5) ** 2
} else {
return -1 * b.games * (0.5 - b.winrate) ** 2 - -1 * a.games * (0.5 - a.winrate) ** 2
}
})
// Limit to top matchups (or keep all if we want comprehensive data)
lane.matchups = filteredMatchups
}
}
return {
name: champion.champion.name,
alias: champion.champion.alias.toLowerCase(),
id: champion.champion.id,
lanes: champion.lanes,
winrate: champion.winningMatches / totalChampionMatches,
gameCount: totalChampionMatches,
pickrate: totalChampionMatches / totalMatches
}
}
async function championList() {
const response = await fetch(
'https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
)
const list = await response.json()
return list.slice(1)
}
async function makeChampionsStats(client: MongoClient, patch: string) {
const globalItems = await itemList()
for (const item of globalItems) {
itemDict.set(item.id, item)
}
const list = await championList()
console.log('Generating stats for ' + list.length + ' champions')
// Pre-generate list of champions
const champions: Map<number, ChampionData> = new Map()
for (const champion of list) {
champions.set(champion.id, {
champion: { id: champion.id, name: champion.name, alias: champion.alias },
winningMatches: 0,
losingMatches: 0,
lanes: []
})
}
// Loop through all matches to generate stats
const totalMatches = await handleMatchList(client, patch, champions)
// Finalize and save stats for every champion
const database = client.db('champions')
const collection = database.collection(patch)
for (const champion of list) {
const championInfo = await finalizeChampionStats(champions.get(champion.id), totalMatches)
await collection.updateOne({ id: champion.id }, { $set: championInfo }, { upsert: true })
}
// Create alias-index for better key-find
await collection.createIndex({ alias: 1 })
}
export default { makeChampionsStats }

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

@@ -1,218 +0,0 @@
const base = 'https://euw1.api.riotgames.com'
const api_key = process.env.RIOT_API_KEY
const sleep_minutes = 12
import { MongoClient } from 'mongodb'
import champion_stat from './champion_stat'
main()
async function main() {
// Check if we're in development mode with pre-loaded data
if (process.env.NODE_ENV === 'development' && process.env.USE_IMPORTED_DATA === 'true') {
console.log('MatchCollector - Development mode with pre-loaded data')
await runWithPreloadedData()
return
}
// Original production mode
console.log('MatchCollector - Hello !')
const client = await connectToDatabase()
const [latestPatch, latestPatchTime] = await fetchLatestPatchDate(client)
console.log(
'Connected to database, latest patch ' + latestPatch + ' was epoch: ' + latestPatchTime
)
const alreadySeenGameList = await alreadySeenGames(client, latestPatch)
console.log('We already have ' + alreadySeenGameList.length + ' matches for this patch !')
console.log('Using RIOT_API_KEY: ' + api_key)
if (api_key != null && api_key != undefined && api_key != '') {
const challengerLeague = await fetchChallengerLeague()
console.log('ChallengerLeague: got ' + challengerLeague.entries.length + ' entries')
const gameList = []
let i = 0
for (const challenger of challengerLeague.entries) {
console.log('Entry ' + i + '/' + challengerLeague.entries.length + ' ...')
const puuid = challenger.puuid
const challengerGameList = await summonerGameList(puuid, latestPatchTime)
for (const game of challengerGameList) {
if (!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
gameList.push(game)
}
}
i++
}
console.log('Games: got ' + gameList.length + ' entries')
i = 0
for (const game of gameList) {
console.log('Entry ' + i + '/' + gameList.length + ' ...')
const gameMatch = await match(game)
const gameTimeline = await matchTimeline(game)
gameMatch.timeline = gameTimeline
await saveMatch(client, gameMatch, latestPatch)
i++
}
}
console.log('Generating stats...')
await champion_stat.makeChampionsStats(client, latestPatch)
console.log('All done. Closing client.')
await client.close()
}
async function handleRateLimit(url: URL): Promise<Response> {
let response = await fetch(url)
if (response.status == 429) {
await new Promise(resolve => setTimeout(resolve, sleep_minutes * 60 * 1000))
response = await handleRateLimit(url)
}
return response
}
function handleError(response: Response) {
if (!response.ok) {
console.log(
'Error during fetch(' +
response.url +
'): STATUS ' +
response.status +
' (' +
response.statusText +
')'
)
process.exit(1)
}
}
async function connectToDatabase() {
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
if (
process.env.MONGO_URI != undefined &&
process.env.MONGO_URI != null &&
process.env.MONGO_URI != ''
) {
uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
await client.connect()
return client
}
async function fetchLatestPatchDate(client) {
const database = client.db('patches')
const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
return [latestPatch.patch, Math.floor(latestPatch.date.valueOf() / 1000)]
}
async function fetchChallengerLeague() {
const queue = 'RANKED_SOLO_5x5'
const endpoint = `/lol/league/v4/challengerleagues/by-queue/${queue}`
const url = `${base}${endpoint}?api_key=${api_key}`
const challengerLeagueResponse = await handleRateLimit(new URL(url))
handleError(challengerLeagueResponse)
const challengerLeague = await challengerLeagueResponse.json()
return challengerLeague
}
async function summonerGameList(puuid, startTime) {
const base = 'https://europe.api.riotgames.com'
const endpoint = `/lol/match/v5/matches/by-puuid/${puuid}/ids`
const url = `${base}${endpoint}?queue=420&type=ranked&startTime=${startTime}&api_key=${api_key}`
const gameListResponse = await handleRateLimit(new URL(url))
handleError(gameListResponse)
const gameList = await gameListResponse.json()
return gameList
}
async function match(matchId) {
const base = 'https://europe.api.riotgames.com'
const endpoint = `/lol/match/v5/matches/${matchId}`
const url = `${base}${endpoint}?api_key=${api_key}`
const matchResponse = await handleRateLimit(new URL(url))
handleError(matchResponse)
const match = await matchResponse.json()
return match
}
async function matchTimeline(matchId) {
const base = 'https://europe.api.riotgames.com'
const endpoint = `/lol/match/v5/matches/${matchId}/timeline`
const url = `${base}${endpoint}?api_key=${api_key}`
const timelineResponse = await handleRateLimit(new URL(url))
handleError(timelineResponse)
const timeline = await timelineResponse.json()
return timeline
}
async function alreadySeenGames(client, latestPatch) {
const database = client.db('matches')
const matches = database.collection(latestPatch)
const alreadySeen = await matches.distinct('metadata.matchId')
return alreadySeen
}
async function saveMatch(client, match, latestPatch) {
const database = client.db('matches')
const matches = database.collection(latestPatch)
await matches.insertOne(match)
}
/**
* Development mode function that generates stats from pre-loaded data
*/
async function runWithPreloadedData() {
console.log('Using pre-loaded match data for development')
const client = await connectToDatabase()
try {
const [latestPatch] = await fetchLatestPatchDate(client)
console.log(`Latest patch: ${latestPatch}`)
// Check if we have matches for this patch
const matchesDb = client.db('matches')
const collections = await matchesDb.listCollections().toArray()
const patchCollections = collections.map(c => c.name).filter(name => name === latestPatch)
if (patchCollections.length === 0) {
console.error(`❌ No match data found for patch ${latestPatch}`)
console.log('💡 Please run the data import script first:')
console.log(' node dev/scripts/setup-db.js')
return
}
console.log(`Found ${patchCollections.length} match collection(s)`)
// Generate stats for each patch with data
for (const patch of patchCollections) {
console.log(`Generating stats for patch ${patch}...`)
await champion_stat.makeChampionsStats(client, patch)
console.log(`Stats generated for patch ${patch}`)
}
console.log('🎉 All stats generated successfully!')
console.log('🚀 Your development database is ready for frontend testing!')
} catch (error) {
console.error('❌ Error in development mode:', error)
throw error
} finally {
await client.close()
}
}

View File

@@ -1,84 +0,0 @@
type ItemTree = {
data: number | undefined
count: number
children: Array<ItemTree>
}
function treeInit(): ItemTree {
return { data: undefined, count: 0, children: [] }
}
function treeNode(data: number, count: number): ItemTree {
return { data: data, count: count, children: [] }
}
/*
* Merge a node with an item tree
*/
function nodeMerge(itemtree: ItemTree, node: ItemTree) {
const item = node.data
const count = node.count
let next: ItemTree | null = null
// Try to find an existing node in this tree level with same item
for (const node of itemtree.children) {
if (node.data == item) {
node.count += 1
next = node
break
}
}
// If not found, add item node at this level
if (next == null) {
next = treeNode(item, count)
itemtree.children.push(next)
}
return next
}
/*
* Merge a full build path with an existing item tree
*/
function treeMerge(itemtree: ItemTree, items: Array<number>) {
let current = itemtree
for (const item of items) {
current = nodeMerge(current, { data: item, count: 1, children: [] })
}
}
function treeCutBranches(itemtree: ItemTree, thresholdCount: number, thresholdPerc: number) {
// Remove branches that are above threshold count
while (itemtree.children.length > thresholdCount) {
const leastUsedBranch = itemtree.children.reduce(
(a, b) => (Math.min(a.count, b.count) == a.count ? a : b),
{ data: undefined, count: +Infinity, children: [] }
)
itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1)
}
// Remove branches that are of too low usage
const toRemove: Array<ItemTree> = []
for (const child of itemtree.children) {
if (child.count / itemtree.count < thresholdPerc) {
toRemove.push(child)
}
}
for (const tr of toRemove) {
itemtree.children.splice(itemtree.children.indexOf(tr), 1)
}
itemtree.children.map(x => treeCutBranches(x, thresholdCount, thresholdPerc))
}
function treeSort(itemtree: ItemTree) {
itemtree.children.sort((a, b) => b.count - a.count)
for (const item of itemtree.children) {
treeSort(item)
}
}
export { ItemTree, treeMerge, treeInit, treeCutBranches, treeSort }

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,41 @@
{
"name": "match_collector",
"version": "1.0.0",
"main": "index.ts",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
"type": "module",
"exports": {
".": {
"types": "./dist/lib.d.ts",
"import": "./dist/lib.js"
}
},
"scripts": {
"build": "tsc",
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --write .",
"format:check": "prettier --check ."
"lint": "eslint ./src",
"lint:fix": "eslint --fix ./src",
"format": "prettier --write ./src",
"format:check": "prettier --check ./src",
"dev": "node --import=tsx src/index.ts"
},
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"mongodb": "^6.10.0"
"dragon-item-parser": "file:../dragon-item-parser",
"mongodb": "^7.2.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^22.9.1",
"@types/node": "^25.6.0",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.0",
"tsx": "^4.19.2",
"typescript": "^5.9.3",
"prettier": "^3.8.3",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.53.1"
}
}

136
match_collector/src/api.ts Normal file
View File

@@ -0,0 +1,136 @@
type Match = {
metadata: {
dataVersion: string
matchId: string
participants: string[]
}
info: {
endOfGameResult: string
gameCreation: number
gameDuration: number
gameEndTimestamp: number
gameId: number
gameMode: string
gameName: string
gameStartTimestamp: number
gameType: string
gameVersion: string
mapId: number
participants: Participant[]
platformId: string
queueId: number
teams: Team[]
tournamentCode: string
}
timeline: Timeline
}
type Timeline = {
metadata: {
dataVersion: string
matchId: string
participants: string[]
}
info: {
endOfGameResult: string
frameInterval: number
gameId: number
participants: {
participantId: number
puuid: string
}[]
frames: Frame[]
}
}
type Team = {
bans: Ban[]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
objectives: any
teamId: number
win: boolean
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Ban = any
type Participant = {
allInPing: number
assistMePings: number
assists: number
baronKills: number
bountyLevel: number
champExperience: number
champLevel: number
championId: number
championName: string
commandPings: number
championTransform: number
consumablesPurchased: number
// eslint-disable-next-line @typescript-eslint/no-explicit-any
challenges: any
damageDealtToBuildings: number
deaths: number
item0: number
item1: number
item2: number
item3: number
item4: number
item5: number
item6: number
itemsPurchased: number
kills: number
lane: string
participantId: number
// eslint-disable-next-line @typescript-eslint/no-explicit-any
perks: any
puuid: string
summoner1Id: number
summoner2Id: number
summonerId: string
teamId: number
teamPosition: string
win: boolean
}
type Frame = {
events: Event[]
participantFrames: {
'1': ParticipantFrame
'2': ParticipantFrame
'3': ParticipantFrame
'4': ParticipantFrame
'5': ParticipantFrame
'6': ParticipantFrame
'7': ParticipantFrame
'8': ParticipantFrame
'9': ParticipantFrame
'10': ParticipantFrame
}
timestamp: number
}
type ParticipantFrame = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
championStats: any
currentGold: number
// eslint-disable-next-line @typescript-eslint/no-explicit-any
damageStats: any
goldPerSecond: number
jungleMinionsKilled: number
level: number
minionsKilled: number
participantId: number
position: {
x: number
y: number
}
timeEnemySpentControlled: number
totalGold: number
xp: number
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Event = any
export { Match, Timeline, Team, Ban, Participant, Frame, Event }

View File

@@ -0,0 +1,100 @@
import { writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import { join, resolve } from 'path'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
// Get current directory for relative path resolution
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// CDragon base URL for specific patch
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
// Assets to cache for each patch
const CDRAGON_ASSETS = [
{
name: 'items.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
},
{
name: 'perks.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/perks.json'
},
{
name: 'perkstyles.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
},
{
name: 'summoner-spells.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/summoner-spells.json'
},
{
name: 'champion-summary.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
}
]
/**
* Download CDragon assets for a specific patch.
* This caches game data locally for faster access.
*/
async function downloadCDragonAssets(patch: string) {
// Convert patch format for CDragon: "16.4" -> "16.4" (already in correct format)
// CDragon uses patch format without the last minor version
const cdragonPatch = patch
console.log(`\n=== Downloading CDragon assets for patch ${cdragonPatch} ===`)
// Get cache directory from environment or use default
// In development, use a local directory relative to project root
// In production (Docker), use /cdragon (shared volume with frontend)
const defaultCacheDir =
process.env.NODE_ENV === 'development'
? resolve(__dirname, '../../dev/data/cdragon')
: '/cdragon'
const cacheDir = process.env.CDRAGON_CACHE_DIR || defaultCacheDir
const patchDir = join(cacheDir, cdragonPatch)
// Create patch directory if it doesn't exist
if (!existsSync(patchDir)) {
await mkdir(patchDir, { recursive: true })
console.log(`Created directory: ${patchDir}`)
}
// Download each asset
for (const asset of CDRAGON_ASSETS) {
const url = `${CDRAGON_BASE}${cdragonPatch}/${asset.path}`
const filePath = join(patchDir, asset.name)
try {
console.log(`Downloading ${asset.name}...`)
const response = await fetch(url)
if (!response.ok) {
console.error(`Failed to download ${asset.name}: ${response.status} ${response.statusText}`)
continue
}
const data = await response.json()
await writeFile(filePath, JSON.stringify(data, null, 2))
console.log(`Saved ${asset.name} to ${filePath}`)
} catch (error) {
console.error(`Error downloading ${asset.name}:`, error)
}
}
// Create a symlink or copy to 'latest' directory for easy access
const latestDir = join(cacheDir, 'latest')
const latestFile = join(latestDir, 'patch.txt')
if (!existsSync(latestDir)) {
await mkdir(latestDir, { recursive: true })
}
await writeFile(latestFile, patch)
console.log(`Updated latest patch reference to ${patch}`)
console.log('CDragon assets download complete!')
}
export { downloadCDragonAssets }

View File

@@ -0,0 +1,786 @@
import { MongoClient } from 'mongodb'
import {
GoldAdvantageTag,
treeInit,
treeMerge,
treeCutBranches,
treeSort,
treeMergeTree,
areTreeSimilars,
treeDeriveTags
} from './item_tree'
import { PLATFORM_KEYS } from './platform'
import {
initItemDict as initFirstBackItemDict,
extractFirstBackFromMatch,
groupFirstBacksByItemSet
} from './first_back'
import { Match, Timeline, Participant, Frame } from './api'
import type {
Rune,
InternalBuild,
InternalBuildWithStartItems,
InternalLaneData,
InternalChampionData,
FirstBackData
} from './types'
// Type aliases for internal use
type Builds = InternalBuild[]
type Build = InternalBuild
type BuildWithStartItems = InternalBuildWithStartItems
type LaneData = InternalLaneData
type ChampionData = InternalChampionData
function sameArrays(array1: number[], array2: number[]) {
if (array1.length != array2.length) return false
for (const e of array1) {
if (!array2.includes(e)) return false
}
return true
}
const itemDict = new Map()
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
}
function arrayRemovePercentage(
array: Array<{ count: number }>,
totalGames: number,
percentage: number
) {
const toRemove: Array<{ count: number }> = []
for (const item of array) {
if (item.count / totalGames < percentage) {
toRemove.push(item)
}
}
for (const tr of toRemove) {
array.splice(array.indexOf(tr), 1)
}
}
// Helper function to create rune configuration from participant
function createRuneConfiguration(participant: Participant): Rune {
const primaryStyle = participant.perks.styles[0].style
const secondaryStyle = participant.perks.styles[1].style
const selections: Array<number> = []
for (const style of participant.perks.styles) {
for (const perk of style.selections) {
selections.push(perk.perk)
}
}
return {
count: 0, // Will be incremented when added to build
primaryStyle: primaryStyle,
secondaryStyle: secondaryStyle,
selections: selections
}
}
// Find or create a build for the given rune keystone
function findOrCreateBuild(builds: Builds, participant: Participant): 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
}
// Calculate gold advantage at the time of item purchase
// Returns 'ahead', 'behind', or 'even' based on gold difference
function calculateGoldAdvantage(
match: Match,
frame: Frame,
participantIndex: number
): GoldAdvantageTag {
const GOLD_THRESHOLD = 1000 // 1000 gold difference threshold
const participantFrames = [
frame.participantFrames[1],
frame.participantFrames[2],
frame.participantFrames[3],
frame.participantFrames[4],
frame.participantFrames[5],
frame.participantFrames[6],
frame.participantFrames[7],
frame.participantFrames[8],
frame.participantFrames[9],
frame.participantFrames[10]
]
// Find the participant's team
const participantFrame = participantFrames[participantIndex - 1]
const participantGold = participantFrame.totalGold
if (!participantFrame) return 'even'
const participant = match.info.participants.find(
x => x.participantId == participantFrame.participantId
)!
const opponent = match.info.participants.find(
x => x.teamPosition === participant.teamPosition && x.teamId != participant.teamId
)
if (opponent == undefined) return 'even'
const opponentGold = participantFrames.find(
x => x.participantId == opponent.participantId
)!.totalGold
const goldDiff = participantGold - opponentGold
if (goldDiff >= GOLD_THRESHOLD) return 'ahead'
if (goldDiff <= -GOLD_THRESHOLD) return 'behind'
return 'even'
}
function handleMatchBuilds(
match: Match,
participant: Participant,
participantIndex: number,
builds: Builds,
platform?: string
): { build: Build; startItemId: number | undefined } {
const timeline: Timeline = match.timeline
// Find or create the build for this participant's rune configuration
const build = findOrCreateBuild(builds, participant)
build.count += 1
const items: Array<{ itemId: number; goldAdvantage: GoldAdvantageTag; platform?: string }> = []
let startItemId: number | undefined = undefined
for (const frame of timeline.info.frames) {
for (const event of frame.events) {
if (event.participantId != participantIndex) continue
if (event.type == 'ITEM_UNDO') {
if (items.length > 0 && items[items.length - 1].itemId == event.beforeId) {
items.pop()
}
continue
}
const itemInfo = itemDict.get(event.itemId)
// Handle bounty of worlds destroy as upgrade
if (event.type == 'ITEM_DESTROYED') {
if (event.itemId == 3867) {
const suppItem: number = itemInfo.to.find(
(x: number) =>
x == participant.item0 ||
x == participant.item1 ||
x == participant.item2 ||
x == participant.item3 ||
x == participant.item4 ||
x == participant.item5 ||
x == participant.item6
)
if (suppItem != undefined) {
const already = build.suppItems.find(x => x.data == suppItem)
if (already == undefined) build.suppItems.push({ count: 1, data: suppItem })
else already.count += 1
}
}
}
if (event.type != 'ITEM_PURCHASED') continue
// Handle boots differently
if (itemInfo.categories.includes('Boots')) {
// Ignore basic boots, only count Tier 2 boots
if (event.itemId != 1001) {
// Check for bootsFirst
if (items.length < 2) {
build.bootsFirstCount += 1
}
// 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
}
continue
}
// Check if item should be included
if (itemInfo.categories.includes('Consumable')) continue
if (itemInfo.categories.includes('Trinket')) continue
// Ignore zephyr
if (event.itemId == 3172) continue
// Ignore Cull as not-first item
if (event.itemId == 1083 && 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
// Calculate gold advantage at time of purchase
const goldAdvantage = calculateGoldAdvantage(match, frame, participantIndex)
items.push({ itemId: event.itemId, goldAdvantage, platform })
}
}
// 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)
// The first item is the starter item
startItemId = items[0].itemId
}
return { build, startItemId }
}
function handleMatch(match: Match, champions: Map<number, ChampionData>, platform?: string) {
let participantIndex = 0
for (const participant of match.info.participants) {
participantIndex += 1
const championId = participant.championId
const champion = champions.get(championId)!
// Lanes
let lane = champion.lanes.find(x => x.data == participant.teamPosition)
if (lane == undefined) {
lane = {
count: 1,
data: participant.teamPosition,
builds: [],
winningMatches: 0,
losingMatches: 0,
winrate: 0,
pickrate: 0,
summonerSpells: [],
matchups: [],
regionDistribution: { euw: 0, eun: 0, na: 0, kr: 0 }
}
champion.lanes.push(lane)
} else lane.count += 1
// Track region distribution for this lane
if (lane.regionDistribution && platform) {
const platformKey = platform.toLowerCase()
const regionKey = PLATFORM_KEYS[platformKey]
if (regionKey) {
lane.regionDistribution[regionKey]!++
}
}
// Initialize matchups if not present
if (!lane.matchups) {
lane.matchups = []
}
// Winrate
if (participant.win) {
champion.winningMatches++
lane.winningMatches++
} else {
champion.losingMatches++
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(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(p: any) => p.teamId === opponentTeam && p.teamPosition === participant.teamPosition
)
if (opponent) {
const opponentChampionId = opponent.championId
// Track this matchup for current champion
const matchup = lane.matchups.find(c => c.championId === opponentChampionId)
if (matchup) {
matchup.games += 1
if (participant.win) {
matchup.winrate = (matchup.winrate * (matchup.games - 1) + 1) / matchup.games
} else {
matchup.winrate = (matchup.winrate * (matchup.games - 1)) / matchup.games
}
} else {
const opponentChampion = champions.get(opponentChampionId)!
lane.matchups.push({
championId: opponentChampionId,
winrate: participant.win ? 1 : 0,
games: 1,
championName: opponentChampion.champion.name,
championAlias: opponentChampion.champion.alias
})
}
}
// Items and runes (builds)
const { build, startItemId } = handleMatchBuilds(
match,
participant,
participantIndex,
lane.builds,
platform
)
// First back data - store at build level with start item tracking
const firstBackData = extractFirstBackFromMatch(match, participantIndex)
if (firstBackData) {
if (!build.firstBacksRaw) {
build.firstBacksRaw = []
}
// Include the starter item ID for proper filtering when splitting builds
firstBackData.startItemId = startItemId
build.firstBacksRaw.push(firstBackData)
}
}
}
async function handleMatchList(
client: MongoClient,
patch: string,
champions: Map<number, ChampionData>,
platform?: string
) {
const database = client.db('matches')
const collectionName = platform ? `${patch}_${platform}` : patch
const matches = database.collection(collectionName)
const allMatches = matches.find()
const totalMatches: number = await matches.countDocuments()
let currentMatch = 0
for await (const match of allMatches) {
process.stdout.write(
'\rComputing champion stats, game entry ' + currentMatch + '/' + totalMatches + ' ... '
)
currentMatch += 1
handleMatch(match as unknown as Match, champions, platform)
}
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,
firstBacksRaw: build.firstBacksRaw
}
]
} 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) {
// 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({
runeKeystone: build.runeKeystone,
runes: build.runes,
items: c,
bootsFirstCount: scaledBootsFirstCount,
count: c.count,
startItems: [{ data: c.data!, count: c.count }],
suppItems: scaledSuppItems,
boots: scaledBoots,
firstBacksRaw: filteredFirstBacksRaw
})
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)
// 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,
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),
firstBacksRaw: firstBacksRaw.length > 0 ? firstBacksRaw : undefined
})
}
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)
// Derive tags from purchase patterns (gold advantage, region)
treeDeriveTags(build.items, lane.regionDistribution)
// 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)
for (const lane of champion.lanes) {
// 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)
// Cleaning up builds
cleanupLaneBuilds(lane)
// 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)
}
// 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)
// 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) {
lane.winrate = lane.winningMatches / lane.count
lane.pickrate = lane.count / totalMatches
}
// Sort matchups by score (games * winrate) in descending order
for (const lane of champion.lanes) {
if (lane.matchups && lane.matchups.length > 0) {
// Filter out matchups with insufficient games (minimum 5 games)
const filteredMatchups = lane.matchups.filter(m => m.games >= 5)
// Sort by score (games * (winrate - 0.5)^2) descending
filteredMatchups.sort((a, b) => {
// Handle special case of exactly 50% winrate
if (a.winrate === 0.5 && b.winrate === 0.5) {
// Both have 50% winrate, sort by games (more games first)
return b.games - a.games
}
if (a.winrate === 0.5 || b.winrate === 0.5) {
// a has 50% winrate, b doesn't - b comes first
return b.winrate - a.winrate
}
if (a.winrate > 0.5 && b.winrate < 0.5) return -1
if (a.winrate < 0.5 && b.winrate > 0.5) return 1
if (a.winrate > 0.5) {
return b.games * (b.winrate - 0.5) ** 2 - a.games * (a.winrate - 0.5) ** 2
} else {
return -1 * b.games * (0.5 - b.winrate) ** 2 - -1 * a.games * (0.5 - a.winrate) ** 2
}
})
// Limit to top matchups (or keep all if we want comprehensive data)
lane.matchups = filteredMatchups
}
}
return {
name: champion.champion.name,
alias: champion.champion.alias.toLowerCase(),
id: champion.champion.id,
lanes: champion.lanes,
winrate: champion.winningMatches / totalChampionMatches,
gameCount: totalChampionMatches,
pickrate: totalChampionMatches / totalMatches
}
}
async function championList() {
const response = await fetch(
'https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
)
const list = await response.json()
return list.slice(1)
}
async function makeChampionsStats(client: MongoClient, patch: string, platforms: string[] = []) {
const globalItems = await itemList()
for (const item of globalItems) {
itemDict.set(item.id, item)
}
// Initialize first back item dictionary
await initFirstBackItemDict()
const list = await championList()
console.log('Generating stats for ' + list.length + ' champions')
// Pre-generate list of champions (shared across all platforms)
const champions: Map<number, ChampionData> = new Map()
for (const champion of list) {
champions.set(champion.id, {
champion: { id: champion.id, name: champion.name, alias: champion.alias },
winningMatches: 0,
losingMatches: 0,
lanes: []
})
}
// Process matches from all platforms, merging into the same champions map
let totalMatches = 0
for (const platform of platforms) {
console.log(`\n=== Processing matches from platform: ${platform} ===`)
const platformMatches = await handleMatchList(client, patch, champions, platform)
totalMatches += platformMatches
console.log(`Processed ${platformMatches} matches from ${platform}`)
}
console.log(`\n=== Total matches processed: ${totalMatches} ===`)
// Finalize and save stats to a single champions collection
const database = client.db('champions')
const collection = database.collection(patch)
for (const champion of list) {
const championInfo = await finalizeChampionStats(champions.get(champion.id)!, totalMatches)
await collection.updateOne({ id: champion.id }, { $set: championInfo }, { upsert: true })
}
// Create alias-index for better key-find
await collection.createIndex({ alias: 1 })
console.log(`Stats saved to collection: ${patch}`)
}
export default { makeChampionsStats }

View File

@@ -0,0 +1,225 @@
import { Match, Timeline } from './api'
import type { BackEvent, ItemSet, FirstBackData, FirstBackGroup } from './types'
// Re-export types for backward compatibility
export type { BackEvent, ItemSet, FirstBackData, FirstBackGroup }
// 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
}
// 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
}

View File

@@ -0,0 +1,321 @@
const api_key = process.env.RIOT_API_KEY
const sleep_minutes = 12
import { MongoClient } from 'mongodb'
import champion_stat from './champion_stat'
import { Match } from './api'
import { PLATFORMS, getPlatformBaseUrl, getRegionalBaseUrl, getRegionForPlatform } from './platform'
import { downloadCDragonAssets } from './cdragon_cache'
main()
/**
* Extract patch version from gameVersion string.
* gameVersion format is like "15.1.123.4567" -> we want "15.1"
*/
function extractPatchFromGameVersion(gameVersion: string): string {
const parts = gameVersion.split('.')
if (parts.length >= 2) {
return `${parts[0]}.${parts[1]}`
}
return gameVersion
}
/**
* Get the latest patch from existing match collections in the database.
* Collections are named like "15.1_EUW1", "15.2_NA1", etc.
*/
async function getLatestPatchFromCollections(client: MongoClient): Promise<string | null> {
const matchesDb = client.db('matches')
const collections = await matchesDb.listCollections().toArray()
const collectionNames = collections.map(c => c.name)
// Extract unique patch versions from collection names
const patches = new Set<string>()
for (const name of collectionNames) {
// Collection names are either "patch_platform" or just "patch"
const patch = name.split('_')[0]
if (patch && /^\d+\.\d+$/.test(patch)) {
patches.add(patch)
}
}
if (patches.size === 0) {
return null
}
// Sort patches and return the latest (highest version number)
const sortedPatches = Array.from(patches).sort((a, b) => {
const [aMajor, aMinor] = a.split('.').map(Number)
const [bMajor, bMinor] = b.split('.').map(Number)
if (aMajor !== bMajor) return bMajor - aMajor
return bMinor - aMinor
})
return sortedPatches[0]
}
async function main() {
// Check if we're in development mode with pre-loaded data
if (process.env.NODE_ENV === 'development' && process.env.USE_IMPORTED_DATA === 'true') {
console.log('MatchCollector - Development mode with pre-loaded data')
await runWithPreloadedData()
return
}
// Production mode: collect matches and organize by their gameVersion
console.log('MatchCollector - Hello !')
const client = await connectToDatabase()
console.log('Connected to database')
console.log('Using RIOT_API_KEY: ' + api_key)
if (api_key != null && api_key != undefined && api_key != '') {
// Iterate through all platforms
for (const [platform, region] of Object.entries(PLATFORMS)) {
console.log(`\n=== Processing platform: ${platform} (region: ${region}) ===`)
// Get already seen games for all patches (we'll check by gameVersion when saving)
const alreadySeenGameList = await alreadySeenGamesAllPatches(client, platform)
console.log('We already have ' + alreadySeenGameList.length + ' matches for this platform !')
const challengerLeague = await fetchChallengerLeague(platform)
console.log('ChallengerLeague: got ' + challengerLeague.entries.length + ' entries')
// Use 30 days ago as start time for collecting matches
const startTime = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60
const gameList: string[] = []
let i = 0
for (const challenger of challengerLeague.entries) {
console.log('Entry ' + i + '/' + challengerLeague.entries.length + ' ...')
const puuid = challenger.puuid
const challengerGameList = await summonerGameList(puuid, startTime, region)
for (const game of challengerGameList) {
if (!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
gameList.push(game)
}
}
i++
}
console.log('Games: got ' + gameList.length + ' entries for ' + platform)
i = 0
for (const game of gameList) {
console.log('Entry ' + i + '/' + gameList.length + ' ...')
// Determine region from matchId (format: PLATFORM_matchId)
// Map platform prefix to regional routing value for match API
const matchPlatformPrefix = game.split('_')[0]
const matchRegion = getRegionForPlatform(matchPlatformPrefix) || region
const gameMatch = await match(game, matchRegion)
const gameTimeline = await matchTimeline(game, matchRegion)
gameMatch.timeline = gameTimeline
// Extract patch from gameVersion and save to appropriate collection
const patch = extractPatchFromGameVersion(gameMatch.info.gameVersion)
await saveMatch(client, gameMatch, patch, platform)
i++
}
}
}
// Get the latest patch from collections and generate stats for it
const latestPatch = await getLatestPatchFromCollections(client)
if (latestPatch) {
console.log(`Generating stats for latest patch: ${latestPatch}...`)
await champion_stat.makeChampionsStats(client, latestPatch, Object.keys(PLATFORMS))
// Download CDragon assets for the latest patch
await downloadCDragonAssets(latestPatch)
} else {
console.log('No matches found in database, skipping stat generation')
}
console.log('All done. Closing client.')
await client.close()
}
async function handleRateLimit(url: URL): Promise<Response> {
let response = await fetch(url)
if (response.status == 429) {
await new Promise(resolve => setTimeout(resolve, sleep_minutes * 60 * 1000))
response = await handleRateLimit(url)
}
return response
}
function handleError(response: Response) {
if (!response.ok) {
console.log(
'Error during fetch(' +
response.url +
'): STATUS ' +
response.status +
' (' +
response.statusText +
')'
)
process.exit(1)
}
}
async function connectToDatabase() {
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
if (
process.env.MONGO_URI != undefined &&
process.env.MONGO_URI != null &&
process.env.MONGO_URI != ''
) {
uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
await client.connect()
return client
}
async function fetchChallengerLeague(platform: string) {
const queue = 'RANKED_SOLO_5x5'
const endpoint = `/lol/league/v4/challengerleagues/by-queue/${queue}`
const baseUrl = getPlatformBaseUrl(platform)
const url = `${baseUrl}${endpoint}?api_key=${api_key}`
const challengerLeagueResponse = await handleRateLimit(new URL(url))
handleError(challengerLeagueResponse)
const challengerLeague = await challengerLeagueResponse.json()
return challengerLeague
}
async function summonerGameList(puuid: string, startTime: number, region: string) {
const baseUrl = getRegionalBaseUrl(region)
const endpoint = `/lol/match/v5/matches/by-puuid/${puuid}/ids`
const url = `${baseUrl}${endpoint}?queue=420&type=ranked&startTime=${startTime}&api_key=${api_key}`
const gameListResponse = await handleRateLimit(new URL(url))
handleError(gameListResponse)
const gameList = await gameListResponse.json()
return gameList
}
async function match(matchId: string, region: string) {
const baseUrl = getRegionalBaseUrl(region)
const endpoint = `/lol/match/v5/matches/${matchId}`
const url = `${baseUrl}${endpoint}?api_key=${api_key}`
const matchResponse = await handleRateLimit(new URL(url))
handleError(matchResponse)
const match = await matchResponse.json()
return match
}
async function matchTimeline(matchId: string, region: string) {
const baseUrl = getRegionalBaseUrl(region)
const endpoint = `/lol/match/v5/matches/${matchId}/timeline`
const url = `${baseUrl}${endpoint}?api_key=${api_key}`
const timelineResponse = await handleRateLimit(new URL(url))
handleError(timelineResponse)
const timeline = await timelineResponse.json()
return timeline
}
/**
* Get already seen games across all patches for a specific platform.
* This is used when we don't know the patch beforehand (we get it from gameVersion).
*/
async function alreadySeenGamesAllPatches(client: MongoClient, platform: string) {
const matchesDb = client.db('matches')
const collections = await matchesDb.listCollections().toArray()
const collectionNames = collections.map(c => c.name)
// Find all collections for this platform (format: "patch_platform")
const platformCollections = collectionNames.filter(name => name.endsWith(`_${platform}`))
const allSeen: string[] = []
for (const collectionName of platformCollections) {
const matches = matchesDb.collection(collectionName)
const seen = await matches.distinct('metadata.matchId')
allSeen.push(...seen)
}
return allSeen
}
async function saveMatch(client: MongoClient, match: Match, patch: string, platform: string) {
const database = client.db('matches')
const collectionName = `${patch}_${platform}`
const matches = database.collection(collectionName)
await matches.insertOne(match)
}
/**
* Development mode function that generates stats from pre-loaded data
*/
async function runWithPreloadedData() {
console.log('Using pre-loaded match data for development')
const client = await connectToDatabase()
try {
// Get the latest patch from collections instead of patches database
const latestPatch = await getLatestPatchFromCollections(client)
if (!latestPatch) {
console.error('❌ No match data found in database')
console.log('💡 Please run the data import script first:')
console.log(' node dev/scripts/setup-db.js')
return
}
console.log(`Latest patch: ${latestPatch}`)
// Check if we have matches for this patch (including platform-specific collections)
const matchesDb = client.db('matches')
const collections = await matchesDb.listCollections().toArray()
const collectionNames = collections.map(c => c.name)
// Find collections for this patch (both global and platform-specific)
const patchCollections = collectionNames.filter(
name => name === latestPatch || name.startsWith(`${latestPatch}_`)
)
if (patchCollections.length === 0) {
console.error(`❌ No match data found for patch ${latestPatch}`)
console.log('💡 Please run the data import script first:')
console.log(' node dev/scripts/setup-db.js')
return
}
console.log(
`Found ${patchCollections.length} match collection(s): ${patchCollections.join(', ')}`
)
// Extract platforms from collection names (e.g., "15.1_EUW1" -> "EUW1")
const platforms = patchCollections
.filter(name => name.startsWith(`${latestPatch}_`))
.map(name => name.replace(`${latestPatch}_`, ''))
// Generate stats for each platform
if (platforms.length > 0) {
await champion_stat.makeChampionsStats(client, latestPatch, platforms)
} else {
// Fallback for old-style collections without platform suffix
await champion_stat.makeChampionsStats(client, latestPatch)
}
// Download CDragon assets for the latest patch
await downloadCDragonAssets(latestPatch)
console.log('🎉 All stats generated successfully!')
console.log('🚀 Your development database is ready for frontend testing!')
} catch (error) {
console.error('❌ Error in development mode:', error)
throw error
} finally {
await client.close()
}
}

View File

@@ -0,0 +1,326 @@
import {
REGION_KEYS,
initPlatformCounts,
mergePlatformCounts,
singlePlatformCount
} from './platform'
import type { PlatformCounts } from './platform'
import type { GoldAdvantageTag, ItemTag, ItemTree } from './types'
function treeInit(): ItemTree {
return {
data: undefined,
count: 0,
children: [],
boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 },
platformCount: initPlatformCounts(),
tags: []
}
}
/*
* Merge a node with an item tree
*/
function nodeMerge(itemtree: ItemTree, node: ItemTree) {
const item = node.data
const count = node.count
let next: ItemTree | null = null
// Try to find an existing node in this tree level with same item
for (const child of itemtree.children) {
if (child.data == item) {
child.count += 1
child.boughtWhen.aheadCount += node.boughtWhen.aheadCount
child.boughtWhen.evenCount += node.boughtWhen.evenCount
child.boughtWhen.behindCount += node.boughtWhen.behindCount
// Merge platform counts
mergePlatformCounts(child.platformCount, node.platformCount)
next = child
break
}
}
// If not found, add item node at this level
if (next == null && item !== undefined) {
next = {
data: item,
count: count,
children: [],
boughtWhen: { ...node.boughtWhen },
platformCount: { ...node.platformCount },
tags: []
}
itemtree.children.push(next)
}
return next!
}
/*
* Merge a full build path with an existing item tree
*/
function treeMerge(
itemtree: ItemTree,
items: Array<{ itemId: number; goldAdvantage: GoldAdvantageTag; platform?: string }>
) {
let current = itemtree
for (const item of items) {
current = nodeMerge(current, {
data: item.itemId,
count: 1,
boughtWhen: {
aheadCount: item.goldAdvantage == 'ahead' ? 1 : 0,
evenCount: item.goldAdvantage == 'even' ? 1 : 0,
behindCount: item.goldAdvantage == 'behind' ? 1 : 0,
meanGold: 0
},
children: [],
platformCount: item.platform ? singlePlatformCount(item.platform) : initPlatformCounts(),
tags: []
})
}
}
function treeCutBranches(itemtree: ItemTree, thresholdCount: number, thresholdPerc: number) {
// Remove branches that are above threshold count
while (itemtree.children.length > thresholdCount) {
const leastUsedBranch = itemtree.children.reduce(
(a, b) => (Math.min(a.count, b.count) == a.count ? a : b),
{
data: undefined,
count: +Infinity,
children: [],
boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 },
platformCount: initPlatformCounts(),
tags: []
}
)
itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1)
}
// Remove branches that are of too low usage
const toRemove: Array<ItemTree> = []
for (const child of itemtree.children) {
if (child.count / itemtree.count < thresholdPerc) {
toRemove.push(child)
}
}
for (const tr of toRemove) {
itemtree.children.splice(itemtree.children.indexOf(tr), 1)
}
itemtree.children.map(x => treeCutBranches(x, thresholdCount, thresholdPerc))
}
function treeSort(itemtree: ItemTree) {
itemtree.children.sort((a, b) => b.count - a.count)
for (const item of itemtree.children) {
treeSort(item)
}
}
/*
* Deep clone an ItemTree
*/
function treeClone(tree: ItemTree): ItemTree {
return {
data: tree.data,
count: tree.count,
children: tree.children.map(child => treeClone(child)),
boughtWhen: {
aheadCount: tree.boughtWhen.aheadCount,
behindCount: tree.boughtWhen.behindCount,
evenCount: tree.boughtWhen.evenCount,
meanGold: tree.boughtWhen.meanGold
},
platformCount: { ...tree.platformCount },
tags: [...tree.tags]
}
}
/*
* Merge two ItemTrees into one
*/
function treeMergeTree(t1: ItemTree, t2: ItemTree): ItemTree {
// Merge counts for the root
t1.count += t2.count
// Merge platform counts
mergePlatformCounts(t1.platformCount, t2.platformCount)
// Merge boughtWhen
t1.boughtWhen.aheadCount += t2.boughtWhen.aheadCount
t1.boughtWhen.evenCount += t2.boughtWhen.evenCount
t1.boughtWhen.behindCount += t2.boughtWhen.behindCount
// 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))
}
/*
* Derive tags for an item based on purchase patterns
* Tags are derived when a specific condition is dominant (>= 60% threshold)
* For region tags, we compare against expected distribution to find items that are
* significantly more popular in a region than expected
*/
function deriveTags(node: ItemTree, expectedRegionDistribution?: PlatformCounts): void {
const tags: Array<ItemTag> = []
// Derive gold situation tags
const totalGoldSituations =
node.boughtWhen.aheadCount + node.boughtWhen.behindCount + node.boughtWhen.evenCount
if (totalGoldSituations > 0) {
const aheadPct = node.boughtWhen.aheadCount / totalGoldSituations
const behindPct = node.boughtWhen.behindCount / totalGoldSituations
// Only tag if there's a dominant pattern (>= 60%)
if (aheadPct >= 0.6) {
tags.push('ahead')
} else if (behindPct >= 0.6) {
tags.push('behind')
}
}
// Derive region tags by comparing against expected distribution
const totalRegionCount = REGION_KEYS.reduce((sum, key) => sum + node.platformCount[key], 0)
if (totalRegionCount > 0 && expectedRegionDistribution) {
const totalExpected = REGION_KEYS.reduce((sum, key) => sum + expectedRegionDistribution[key], 0)
if (totalExpected > 0) {
// Tag if one region accounts for >= 60% of the normalized distribution
// Normalized value = actual percentage / expected percentage ratio
const regionTags: Array<{ key: keyof PlatformCounts; tag: ItemTag }> = [
{ key: 'euw', tag: 'region_euw' },
{ key: 'eun', tag: 'region_eun' },
{ key: 'na', tag: 'region_na' },
{ key: 'kr', tag: 'region_kr' }
]
// Calculate normalized values (actual/expected ratio) for each region
const normalizedValues = regionTags.map(({ key, tag }) => {
const expectedPct = expectedRegionDistribution[key] / totalExpected
const actualPct = node.platformCount[key] / totalRegionCount
const normalizedValue = expectedPct > 0 ? actualPct / expectedPct : 0
return { tag, value: normalizedValue }
})
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)
break // Only tag the most dominant region
}
}
}
}
}
node.tags = tags
// Recursively derive tags for children
for (const child of node.children) {
deriveTags(child, expectedRegionDistribution)
}
}
/*
* Apply tag derivation to an entire tree
* expectedRegionDistribution: the total region distribution for the champion/lane,
* used to detect items that are region-specific
*/
function treeDeriveTags(itemtree: ItemTree, expectedRegionDistribution?: PlatformCounts): void {
deriveTags(itemtree, expectedRegionDistribution)
}
export {
PlatformCounts,
GoldAdvantageTag,
ItemTag,
treeMerge,
treeInit,
treeCutBranches,
treeSort,
treeMergeTree,
areTreeSimilars,
treeDeriveTags
}

View File

@@ -0,0 +1,27 @@
/**
* Match Collector Library
* Exports all shared types for use by other projects (e.g., frontend)
*/
// Export all types
export type {
ItemTag,
GoldAdvantageTag,
PlatformCounts,
ItemTree,
Rune,
ItemCountEntry,
FirstBackItemSetEntry,
ItemSet,
FirstBackGroup,
Build,
Builds,
MatchupData,
SummonerSpellData,
LaneData,
ChampionData,
ChampionSummary,
Champion,
BackEvent,
FirstBackData
} from './types'

View File

@@ -0,0 +1,104 @@
/**
* Platform and region configuration for Riot Games API
*
* Platforms are the server clusters (EUW1, EUN1, NA1, KR)
* Regions are the routing values for match API (EUROPE, AMERICAS, ASIA)
*/
// Platform to regional routing value mapping
const PLATFORMS: Record<string, string> = {
EUW1: 'EUROPE',
EUN1: 'EUROPE',
NA1: 'AMERICAS',
KR: 'ASIA'
}
// Platform counts for tracking item purchases per region
interface PlatformCounts {
euw: number
eun: number
na: number
kr: number
}
// Platform key mapping for converting platform strings to PlatformCounts keys
const PLATFORM_KEYS: Record<string, keyof PlatformCounts> = {
euw1: 'euw',
eun1: 'eun',
na1: 'na',
kr: 'kr'
}
// List of all region keys for iteration
const REGION_KEYS: Array<keyof PlatformCounts> = ['euw', 'eun', 'na', 'kr']
/**
* Get the base URL for platform-specific API calls (e.g., league-v4)
*/
function getPlatformBaseUrl(platform: string): string {
return `https://${platform.toLowerCase()}.api.riotgames.com`
}
/**
* Get the base URL for regional API calls (e.g., match-v5)
*/
function getRegionalBaseUrl(region: string): string {
return `https://${region.toLowerCase()}.api.riotgames.com`
}
/**
* Get the regional routing value for a platform
* Falls back to the provided default region if platform not found
*/
function getRegionForPlatform(platform: string): string | undefined {
return PLATFORMS[platform]
}
/**
* Initialize an empty PlatformCounts object
*/
function initPlatformCounts(): PlatformCounts {
return { euw: 0, eun: 0, na: 0, kr: 0 }
}
/**
* Merge platform counts from source into target
*/
function mergePlatformCounts(target: PlatformCounts, source: PlatformCounts): void {
for (const key of REGION_KEYS) {
target[key] += source[key]
}
}
/**
* Create a platform count with a single platform set to 1
*/
function singlePlatformCount(platform: string): PlatformCounts {
const counts = initPlatformCounts()
const key = PLATFORM_KEYS[platform.toLowerCase()]
if (key) {
counts[key] = 1
}
return counts
}
/**
* Get the PlatformCounts key for a platform string
*/
function getPlatformKey(platform: string): keyof PlatformCounts | undefined {
return PLATFORM_KEYS[platform.toLowerCase()]
}
export {
PLATFORMS,
PLATFORM_KEYS,
REGION_KEYS,
PlatformCounts,
getPlatformBaseUrl,
getRegionalBaseUrl,
getRegionForPlatform,
initPlatformCounts,
mergePlatformCounts,
singlePlatformCount,
getPlatformKey
}

View File

@@ -0,0 +1,257 @@
/**
* Shared types between match_collector and frontend
*/
/**
* Item tags derived from purchase patterns
*/
export type ItemTag = 'ahead' | 'behind' | 'region_euw' | 'region_eun' | 'region_na' | 'region_kr'
/**
* Gold advantage tag for item purchases
*/
export type GoldAdvantageTag = 'ahead' | 'behind' | 'even'
/**
* Platform counts for region tracking
*/
export interface PlatformCounts {
euw: number
eun: number
na: number
kr: number
}
/**
* Represents an item in the build tree
*/
export interface ItemTree {
data: number | undefined
count: number
children: ItemTree[]
tags: ItemTag[]
// Gold advantage tracking (used during processing)
boughtWhen: {
aheadCount: number
behindCount: number
evenCount: number
meanGold: number
}
// Platform tracking (used during processing)
platformCount: PlatformCounts
}
/**
* Represents a rune configuration
*/
export interface Rune {
count: number
primaryStyle: number
secondaryStyle: number
selections: number[]
pickrate?: number
}
/**
* Represents an item entry with count
*/
export interface ItemCountEntry {
count: number
data: number
}
/**
* Represents an item in a first back item set
*/
export interface FirstBackItemSetEntry {
itemId: number
count: number
}
/**
* Represents an item set (combination of items)
*/
export interface ItemSet {
items: FirstBackItemSetEntry[]
totalGold: number
}
/**
* Internal type for first back data during processing
*/
export interface FirstBackData {
timestamp: number
itemSet: ItemSet
startItemId?: number
}
/**
* Represents a grouped first back by item set
*/
export interface FirstBackGroup {
itemSet: ItemSet
count: number
pickrate: number
avgTimestamp: number
}
/**
* Internal build type with processing fields
*/
export interface InternalBuild {
runeKeystone: number
runes: Rune[]
items: ItemTree
bootsFirstCount: number
bootsFirst?: number
count: number
suppItems: ItemCountEntry[]
boots: ItemCountEntry[]
pickrate?: number
firstBacksRaw?: FirstBackData[]
firstBacks?: FirstBackGroup[]
}
/**
* Internal build type with start items
*/
export interface InternalBuildWithStartItems {
runeKeystone: number
runes: Rune[]
items: ItemTree
bootsFirst?: number
bootsFirstCount: number
count: number
startItems: ItemCountEntry[]
suppItems: ItemCountEntry[]
boots: ItemCountEntry[]
pickrate?: number
firstBacksRaw?: FirstBackData[]
firstBacks?: FirstBackGroup[]
}
/**
* Represents a complete build with runes and items (final output format)
*/
export interface Build {
runeKeystone: number
runes: Rune[]
items: ItemTree
bootsFirst: number
count: number
boots: ItemCountEntry[]
suppItems: ItemCountEntry[]
startItems: ItemCountEntry[]
pickrate: number
firstBacks?: FirstBackGroup[]
}
/**
* Represents champion build information (array of builds)
*/
export type Builds = Build[]
/**
* Represents counter data for a champion
*/
export interface MatchupData {
championId: number
winrate: number
games: number
championName: string
championAlias: string
}
/**
* Represents summoner spell data
*/
export interface SummonerSpellData {
id: number
count: number
pickrate?: number
}
/**
* Internal lane data with processing fields
*/
export interface InternalLaneData {
data: string
count: number
winningMatches: number
losingMatches: number
winrate: number
pickrate: number
builds: InternalBuild[]
matchups?: MatchupData[]
summonerSpells: SummonerSpellData[]
regionDistribution?: PlatformCounts
}
/**
* Represents lane-specific champion data (final output format)
*/
export interface LaneData {
data: string
count: number
winningMatches: number
losingMatches: number
winrate: number
pickrate: number
builds?: Builds
summonerSpells: SummonerSpellData[]
matchups?: MatchupData[]
}
/**
* Internal champion data with processing fields
*/
export interface InternalChampionData {
champion: Champion
winningMatches: number
losingMatches: number
lanes: InternalLaneData[]
}
/**
* Represents complete champion data (final output format)
*/
export interface ChampionData {
id: number
name: string
alias: string
gameCount: number
winrate: number
pickrate: number
lanes: LaneData[]
}
/**
* Champion summary from CDragon
*/
export interface ChampionSummary {
id: number
name: string
alias: string
squarePortraitPath: string
}
/**
* Internal type for champion info
*/
export interface Champion {
id: number
name: string
alias: string
}
/**
* Internal type for back event
*/
export interface BackEvent {
timestamp: number
items: Array<{
itemId: number
gold: number
}>
totalGold: number
}

View File

@@ -1,5 +1,15 @@
{
"compilerOptions": {
"types": ["node"]
}
"types": ["node"],
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1 +0,0 @@
node_modules

View File

@@ -1,8 +0,0 @@
FROM node:lts-alpine
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
WORKDIR /home/node/app
USER node
COPY --chown=node:node package*.json ./
RUN npm install
COPY --chown=node:node . .
CMD /bin/sh -c "node --import=tsx index.ts; sleep 1h"

View File

@@ -1,169 +0,0 @@
import { MongoClient } from 'mongodb'
import { writeFile, mkdir } from 'fs/promises'
import { existsSync } from 'fs'
import { join } from 'path'
// CDragon base URL for specific patch
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
// Assets to cache for each patch
const CDRAGON_ASSETS = [
{
name: 'items.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
},
{
name: 'perks.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/perks.json'
},
{
name: 'perkstyles.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
},
{
name: 'summoner-spells.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/summoner-spells.json'
},
{
name: 'champion-summary.json',
path: 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
}
]
main()
async function main() {
const client = await connectToDatabase()
const dbPatch = await getLatestPatchFromDatabase(client)
// In dev mode, get patch from database (the one we have match data for)
if (process.env.NODE_ENV === 'development') {
console.log('Development mode: downloading cache for database patch')
await client.close()
if (dbPatch) {
console.log('Latest patch in database: ' + dbPatch)
await downloadAssets(dbPatch)
} else {
console.log('No patch found in database!')
}
return
}
// Production mode: check database and update if new patch
const newPatch = await fetchLatestPatch()
console.log('Latest patch is: ' + newPatch)
const newDate = new Date()
if (!(await compareLatestSavedPatch(client, newPatch, newDate))) {
await downloadAssets(newPatch)
}
await client.close()
}
async function fetchLatestPatch() {
const url = 'https://ddragon.leagueoflegends.com/api/versions.json'
const patchDataResponse = await fetch(url)
const patchData = await patchDataResponse.json()
const patch: string = patchData[0]
return patch
}
async function connectToDatabase() {
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
if (
process.env.MONGO_URI != undefined &&
process.env.MONGO_URI != null &&
process.env.MONGO_URI != ''
) {
uri = process.env.MONGO_URI
}
const client = new MongoClient(uri)
await client.connect()
return client
}
async function compareLatestSavedPatch(client: MongoClient, newPatch: string, newDate: Date) {
const database = client.db('patches')
const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
if (latestPatch == null) {
console.log('No previous patch recorded in database.')
} else {
console.log('Latest patch in database is: ' + latestPatch.patch)
}
if (latestPatch == null || latestPatch.patch != newPatch) {
await patches.insertOne({ patch: newPatch, date: newDate })
return false
}
return true
}
async function getLatestPatchFromDatabase(client: MongoClient): Promise<string | null> {
const database = client.db('patches')
const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
if (latestPatch == null) {
return null
}
return latestPatch.patch
}
async function downloadAssets(patch: string) {
// Convert patch format for CDragon: "16.4.1" -> "16.4"
// CDragon uses patch format without the last minor version
const cdragonPatch = patch.split('.').slice(0, 2).join('.')
console.log(`Downloading CDragon assets for patch ${cdragonPatch} (from ${patch})...`)
// Get cache directory from environment or use default
const cacheDir = process.env.CDRAGON_CACHE_DIR || '/cdragon'
const patchDir = join(cacheDir, cdragonPatch)
// Create patch directory if it doesn't exist
if (!existsSync(patchDir)) {
await mkdir(patchDir, { recursive: true })
console.log(`Created directory: ${patchDir}`)
}
// Download each asset
for (const asset of CDRAGON_ASSETS) {
const url = `${CDRAGON_BASE}${cdragonPatch}/${asset.path}`
const filePath = join(patchDir, asset.name)
try {
console.log(`Downloading ${asset.name}...`)
const response = await fetch(url)
if (!response.ok) {
console.error(`Failed to download ${asset.name}: ${response.status} ${response.statusText}`)
continue
}
const data = await response.json()
await writeFile(filePath, JSON.stringify(data, null, 2))
console.log(`Saved ${asset.name} to ${filePath}`)
} catch (error) {
console.error(`Error downloading ${asset.name}:`, error)
}
}
// Create a symlink or copy to 'latest' directory for easy access
const latestDir = join(cacheDir, 'latest')
const latestFile = join(latestDir, 'patch.txt')
if (!existsSync(latestDir)) {
await mkdir(latestDir, { recursive: true })
}
await writeFile(latestFile, patch)
console.log(`Updated latest patch reference to ${patch}`)
console.log('CDragon assets download complete!')
}

View File

@@ -1,31 +0,0 @@
{
"name": "patch_detector",
"version": "1.0.0",
"main": "index.ts",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"mongodb": "^6.10.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^22.10.1",
"@typescript-eslint/eslint-plugin": "^8.53.1",
"@typescript-eslint/parser": "^8.53.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.0",
"tsx": "^4.19.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.53.1"
}
}

View File

@@ -1,5 +0,0 @@
{
"compilerOptions": {
"types": ["node"]
}
}