Compare commits

...

31 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
77 changed files with 10820 additions and 7220 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 working-directory: ./match_collector
run: npm run format:check 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: build-and-push-images:
needs: lint-and-format needs: lint-and-format
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -72,25 +60,18 @@ jobs:
- name: Build and push frontend docker image - name: Build and push frontend docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: ./frontend context: .
file: ./frontend/Dockerfile
push: true push: true
tags: | tags: |
git.vhaudiquet.fr/vhaudiquet/lolstats-frontend:latest git.vhaudiquet.fr/vhaudiquet/lolstats-frontend:latest
git.vhaudiquet.fr/vhaudiquet/lolstats-frontend:${{ github.sha }} 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 - name: Build and push match_collector docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: ./match_collector context: .
file: ./match_collector/Dockerfile
push: true push: true
tags: | tags: |
git.vhaudiquet.fr/vhaudiquet/lolstats-match_collector:latest git.vhaudiquet.fr/vhaudiquet/lolstats-match_collector:latest

View File

@@ -5,10 +5,9 @@ https://buildpath.win
## Architecture ## Architecture
BuildPath is made of four components: BuildPath is made of three components:
- a MongoDB document database - 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, automatically organizing them by patch version (extracted from match data), and generating statistics
- `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
- `frontend`, which is a Nuxt.JS project hosting an API serving the statistics but also the full web frontend (Vue.JS) - `frontend`, which is a Nuxt.JS project hosting an API serving the statistics but also the full web frontend (Vue.JS)
## Development ## Development
@@ -19,9 +18,9 @@ Developing BuildPath requires Docker for a local MongoDB instance, and a NodeJS/
Then, for the first-time setup: Then, for the first-time setup:
```bash ```bash
# Install npm and node # Install npm and node
sudo apt install npm nodejs # Ubuntu sudo apt install npm nodejs node-typescript # Ubuntu/Debian
sudo pacman -S npm nodejs # Arch sudo pacman -S npm nodejs typescript # Arch
sudo dnf install nodejs # Fedora sudo dnf install nodejs typescript # Fedora
# Install docker. Follow instructions on # Install docker. Follow instructions on
# https://docs.docker.com/engine/install/ # 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 dev && npm i && cd .. # Install dependencies for the dev environment
cd frontend && npm i && cd .. # Install dependencies for frontend cd frontend && npm i && cd .. # Install dependencies for frontend
cd match_collector && npm i && cd .. # Install dependencies for match_collector 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. 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 // Check if data directory exists and has files
const dataDir = path.join(__dirname, '../data'); 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
fs.mkdirSync(dataDir, { recursive: true }); let latestPatch = await getLatestPatchVersion();
console.log('🚫 No data files found. Downloading latest snapshot...');
// If no patch found, download snapshot
if (!latestPatch) {
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
console.log('🚫 No match data found. Downloading latest snapshot...');
await downloadAndExtractSnapshot(); await downloadAndExtractSnapshot();
// Try again after download
latestPatch = await getLatestPatchVersion();
} }
// Get latest patch version if (!latestPatch) {
const latestPatch = await getLatestPatchVersion(); 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}`); console.log(`🎯 Latest patch version: ${latestPatch}`);
// Check if data directory exists and has files // 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...'); console.log('🔍 Checking for data files...');
const dataFiles = [ const platforms = ['EUW1', 'EUN1', 'NA1', 'KR'];
{ path: 'patches.json', required: true, description: 'Patches data' }, const dataFiles = [];
{ path: `${latestPatch}_matches.json`, required: true, description: 'Match data' }
]; // 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; let filesExist = true;
for (const file of dataFiles) { for (const file of dataFiles) {
@@ -74,27 +121,41 @@ async function setupDatabase() {
// 4. Wait for MongoDB to be ready // 4. Wait for MongoDB to be ready
await waitForMongoDB(); await waitForMongoDB();
// 5. Import patches data // 5. Check existing matches count and import if needed
console.log('📦 Importing patches data...');
await importPatchesData();
// 6. Check existing matches count and import if needed
console.log('Checking existing matches count...'); console.log('Checking existing matches count...');
const matchCount = await getMatchCount(latestPatch);
console.log(`📊 Current matches in database: ${matchCount}`);
if (matchCount < 100) { // Check for platform-specific collections or fall back to old format
console.log('📥 Importing matches (this may take a while)...'); const existingPlatforms = await getExistingPlatforms(latestPatch);
await importMatchesData(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 { } else {
console.log('✅ Skipping matches import - sufficient data already present'); 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, foundPlatformFiles);
} else {
console.log('✅ Skipping matches import - sufficient data already present');
}
} }
// 7. Fetch CDragon data for the current patch // 7. Run match collector to generate stats (this also handles CDragon caching)
console.log('🎮 Fetching CDragon data...');
await fetchCDragonData();
// 8. Run match collector to generate stats
console.log('📊 Generating champion stats...'); console.log('📊 Generating champion stats...');
await generateChampionStats(); await generateChampionStats();
@@ -109,48 +170,70 @@ async function setupDatabase() {
} }
async function getLatestPatchVersion() { async function getLatestPatchVersion() {
try { const dataDir = path.join(__dirname, '../data');
const filePath = path.join(__dirname, '../data/patches.json');
if(!fs.existsSync(filePath)) {
return null;
}
const fileContent = fs.readFileSync(filePath, 'utf8'); // 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();
// Check if it's line-delimited JSON or array format for (const file of files) {
let patchesData; // Match patterns like "16.8.1_EUW1.json" or "15.1_EUW1.json" or "15.1_matches.json" or "15.1.json"
if (fileContent.trim().startsWith('[')) { // Patch version can be either "XX.Y" or "XX.Y.Z" format
// Array format const match = file.match(/^(\d+\.\d+(?:\.\d+)?)(?:_[A-Z0-9]+)?(?:_matches)?\.json$/);
patchesData = JSON.parse(fileContent); if (match) {
if (!Array.isArray(patchesData)) { // Normalize to "XX.Y" format (strip the third part if present)
throw new Error('Patches data should be an array'); const patch = match[1].split('.').slice(0, 2).join('.');
patches.add(patch);
} }
} 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 if (patches.size > 0) {
patchesData = patchesData.map(patch => ({ // Sort patches and return the latest (highest version number)
...patch, const sortedPatches = Array.from(patches).sort((a, b) => {
date: new Date(patch.date.$date || patch.date) const [aMajor, aMinor] = a.split('.').map(Number);
})); const [bMajor, bMinor] = b.split('.').map(Number);
if (aMajor !== bMajor) return bMajor - aMajor;
// Sort patches by date (newest first) and get the latest return bMinor - aMinor;
const sortedPatches = patchesData.sort((a, b) => b.date - a.date); });
const latestPatch = sortedPatches[0]; return 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;
} }
// Fallback: try to get from database collections
try {
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);
}
}
await client.close();
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];
}
} catch (error) {
// Database not available, continue with other methods
}
return null;
} }
async function downloadAndExtractSnapshot() { async function downloadAndExtractSnapshot() {
@@ -217,79 +300,62 @@ async function waitForMongoDB() {
} }
} }
async function importPatchesData() {
const client = new MongoClient(getMongoUri()); async function importMatchesData(patchVersion, foundPlatformFiles = []) {
await client.connect(); const dataDir = path.join(__dirname, '../data');
const files = fs.readdirSync(dataDir);
try { try {
const filePath = path.join(__dirname, '../data/patches.json'); // If platform-specific files were found, import each one
const fileContent = fs.readFileSync(filePath, 'utf8'); 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 if (matchFile) {
let patchesData; const matchesFile = path.join(dataDir, matchFile);
if (fileContent.trim().startsWith('[')) { const collectionName = `${patchVersion}_${platform}`;
// Array format console.log(`📥 Importing matches for ${platform}...`);
patchesData = JSON.parse(fileContent); execSync(
if (!Array.isArray(patchesData)) { `node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
throw new Error('Patches data should be an array'); {
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 { } else {
// Line-delimited JSON format // Fall back to old format (single file without platform suffix)
patchesData = fileContent.split('\n') // Find any match file for this patch
.filter(line => line.trim() !== '') const matchFile = files.find(f => {
.map(line => { const match = f.match(/^(\d+\.\d+(?:\.\d+)?)(?:_matches)?\.json$/);
const doc = JSON.parse(line); const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
return convertMongoExtendedJson(doc); return match && patchFromName === patchVersion;
}); });
}
// Convert any extended JSON in array format too if (matchFile) {
if (Array.isArray(patchesData)) { const matchesFile = path.join(dataDir, matchFile);
patchesData = patchesData.map(doc => convertMongoExtendedJson(doc)); const collectionName = patchVersion;
} execSync(
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
// Sort patches by date (newest first) {
patchesData.sort((a, b) => { stdio: 'inherit',
const dateA = new Date(a.date || a.date.$date || 0); env: { ...process.env, MONGO_URI: getMongoUri() }
const dateB = new Date(b.date || b.date.$date || 0); }
return dateB - dateA; // Descending order (newest first) );
}); console.log('✅ Matches import completed');
} else {
const db = client.db('patches'); console.log(`⚠️ No match file found for patch ${patchVersion}`);
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`);
const collectionName = patchVersion;
try {
const result = execSync(
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
{
stdio: 'inherit',
env: { ...process.env, MONGO_URI: getMongoUri() }
} }
); }
console.log('✅ Matches import completed');
} catch (error) { } catch (error) {
console.error('❌ Failed to import matches:', error); console.error('❌ Failed to import matches:', error);
throw error; throw error;
@@ -311,12 +377,11 @@ async function generateChampionStats() {
MONGO_HOST: 'localhost' MONGO_HOST: 'localhost'
}; };
// Run the match collector directly with tsx (TypeScript executor) instead of docker compose // Run the match collector
const matchCollectorPath = path.join(__dirname, '../../match_collector/index.ts'); execSync(`npm run dev`, {
execSync(`npx tsx ${matchCollectorPath}`, {
stdio: 'inherit', stdio: 'inherit',
env: env, env: env,
cwd: path.join(__dirname, '../..') cwd: path.join(__dirname, '../../match_collector')
}); });
console.log('✅ Champion stats generated'); console.log('✅ Champion stats generated');
@@ -326,31 +391,14 @@ async function generateChampionStats() {
} }
} }
async function fetchCDragonData() { async function getMatchCount(patchVersion, platform = null) {
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) {
const client = new MongoClient(getMongoUri()); const client = new MongoClient(getMongoUri());
await client.connect(); await client.connect();
try { try {
const db = client.db('matches'); 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(); const count = await collection.countDocuments();
return count; return count;
} catch (error) { } 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() { function getMongoUri() {
return process.env.MONGO_URI || 'mongodb://root:password@localhost:27017/buildpath?authSource=admin'; 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': case 'generate-stats':
generateChampionStats().catch(console.error); generateChampionStats().catch(console.error);
break; break;
case 'import-patches':
importPatchesData().catch(console.error);
break;
case 'match-count': case 'match-count':
if (args[1]) { if (args[1]) {
getMatchCount(args[1]).then(count => console.log(`Match count: ${count}`)).catch(console.error); 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 { export {
setupDatabase, setupDatabase,
importPatchesData,
importMatchesData, importMatchesData,
generateChampionStats, generateChampionStats,
checkDatabaseStatus, 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, js.configs.recommended,
tseslint.configs.recommended, tseslint.configs.recommended,
prettier, prettier,
{
ignores: ['dist/**', 'node_modules/**']
},
{ {
rules: { rules: {
semi: 'off', 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 WORKDIR /app
FROM base AS build 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 RUN npm install
COPY . . COPY frontend/. .
RUN npm run build RUN npm run build
FROM base FROM base
COPY --from=build /app/.output /app/.output COPY --from=build /app/frontend/.output /app/.output
EXPOSE 3000 EXPOSE 3000
CMD ["node", ".output/server/index.mjs"] CMD ["node", ".output/server/index.mjs"]

View File

@@ -5,6 +5,40 @@
--color-on-surface: #b7b8e1; --color-on-surface: #b7b8e1;
--color-surface-darker: #1f1d1c; --color-surface-darker: #1f1d1c;
--color-gold: #ffd700; --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 */ /* Font setting */

View File

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

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon' import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import type { Perk, Item } from '~/types/cdragon'
const props = defineProps<{ const props = defineProps<{
keystoneId: number keystoneId: number
itemId: number itemId: number

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon' import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import type { PerkStyle, Perk } from '~/types/cdragon'
interface RuneBuild { interface RuneBuild {
count: number count: number
primaryStyle: number primaryStyle: number

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,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Item } from '~/types/cdragon'
interface ItemData { interface ItemData {
data: number data: number
count: number count: number

View File

@@ -3,9 +3,12 @@ import { getCoreItems, getLateGameItems } from '~/utils/buildHelpers'
import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue' import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue'
import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue' import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue'
import ItemRow from '~/components/build/ItemRow.vue' import ItemRow from '~/components/build/ItemRow.vue'
import FirstBack from '~/components/build/FirstBack.vue'
import type { Build } from 'match_collector'
const props = defineProps<{ const props = defineProps<{
builds: Builds builds: Array<Build>
}>() }>()
// State // State
@@ -51,7 +54,7 @@ function selectBuild(index: number): void {
v-for="(build, i) in builds" v-for="(build, i) in builds"
:key="i" :key="i"
:keystone-id="build.runeKeystone" :keystone-id="build.runeKeystone"
:item-id="build.items.children[0].data" :item-id="build.items.children[0].data!"
:keystore="perks" :keystore="perks"
:item-map="itemMap" :item-map="itemMap"
:pickrate="build.pickrate" :pickrate="build.pickrate"
@@ -122,6 +125,13 @@ function selectBuild(index: number): void {
/> />
</div> </div>
<!-- First Back Section -->
<FirstBack
v-if="currentBuild.firstBacks && currentBuild.firstBacks.length > 0"
:first-backs="currentBuild.firstBacks"
:item-map="itemMap"
/>
<!-- Core Items Tree (children of start item) --> <!-- Core Items Tree (children of start item) -->
<div v-if="currentBuild.items?.children?.length" class="item-row"> <div v-if="currentBuild.items?.children?.length" class="item-row">
<span class="item-row-label">Core</span> <span class="item-row-label">Core</span>

View File

@@ -1,12 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon' import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import type { Item } from '~/types/cdragon'
import type { ItemTag } from 'match_collector'
interface Props { interface Props {
item: Item item: Item
size?: number size?: number
showPickrate?: boolean showPickrate?: boolean
pickrate?: number pickrate?: number
class?: string class?: string
tags?: ItemTag[]
} }
// Expose the icon element for external use (e.g., arrow drawing) // Expose the icon element for external use (e.g., arrow drawing)
@@ -20,7 +24,8 @@ const props = withDefaults(defineProps<Props>(), {
size: 48, size: 48,
showPickrate: false, showPickrate: false,
pickrate: 0, pickrate: 0,
class: '' class: '',
tags: () => []
}) })
// Tooltip state - encapsulated in this component // Tooltip state - encapsulated in this component
@@ -100,6 +105,7 @@ const itemIconPath = computed(() => CDRAGON_BASE + mapPath(props.item.iconPath))
:item="tooltipState.item" :item="tooltipState.item"
:x="tooltipState.x" :x="tooltipState.x"
:y="tooltipState.y" :y="tooltipState.y"
:tags="tags"
/> />
</div> </div>
</template> </template>
@@ -118,7 +124,6 @@ const itemIconPath = computed(() => CDRAGON_BASE + mapPath(props.item.iconPath))
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--color-on-surface); border: 1px solid var(--color-on-surface);
overflow: hidden; overflow: hidden;
cursor: help;
position: relative; position: relative;
} }

View File

@@ -1,87 +1,234 @@
<script setup lang="ts"> <script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon' 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 { interface Props {
item: Item | null item: Item | null
show: boolean show: boolean
x: number x: number
y: number y: number
tags?: ItemTag[]
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), {
tags: () => []
})
// Parse description and convert to styled HTML // Tag display helpers
const formatDescription = (description?: string) => { function getTagLabel(tag: ItemTag): string {
if (!description) return '' const labels: Record<ItemTag, string> = {
ahead: 'Ahead',
// Replace <br> and other structural tags behind: 'Behind',
const html = description region_euw: 'EUW',
.replace(/<br\s*\/?>/gi, '<br>') region_eun: 'EUN',
.replace(/<br><br><br>/gi, '') // Remove triple breaks region_na: 'NA',
.replace(/<br><br>/gi, '') // Remove double breaks region_kr: 'KR'
.replace(/<mainText>/gi, '') }
.replace(/<\/mainText>/gi, '') return labels[tag] || tag
.replace(/<stats>/gi, '<div class="tooltip-stats">')
.replace(/<\/stats>/gi, '</div>')
.replace(/<passive>/gi, '<span class="tag-passive">')
.replace(/<\/passive>/gi, '</span>:')
.replace(/<active>/gi, '<span class="tag-active">')
.replace(/<\/active>/gi, '</span>')
.replace(/<keyword>/gi, '<span class="tag-keyword">')
.replace(/<\/keyword>/gi, '</span>')
.replace(/<attention>/gi, '<span class="stat-highlight">')
.replace(/<\/attention>/gi, '</span>')
.replace(/<keywordMajor>/gi, '<span class="tag-keyword-major">')
.replace(/<\/keywordMajor>/gi, '</span>')
.replace(/<keywordStealth>/gi, '<span class="tag-keyword-stealth">')
.replace(/<\/keywordStealth>/gi, '</span>')
.replace(/<status>/gi, '<span class="tag-status">')
.replace(/<\/status>/gi, '</span>')
.replace(/<speed>/gi, '<span class="tag-speed">')
.replace(/<\/speed>/gi, '</span>')
.replace(/<scaleMana>/gi, '<span class="tag-scale-mana">')
.replace(/<\/scaleMana>/gi, '</span>')
.replace(/<scaleHealth>/gi, '<span class="tag-scale-health">')
.replace(/<\/scaleHealth>/gi, '</span>')
.replace(/<scaleAP>/gi, '<span class="tag-scale-ap">')
.replace(/<\/scaleAP>/gi, '</span>')
.replace(/<scaleAD>/gi, '<span class="tag-scale-ad">')
.replace(/<\/scaleAD>/gi, '</span>')
.replace(/<scaleArmor>/gi, '<span class="tag-scale-armor">')
.replace(/<\/scaleArmor>/gi, '</span>')
.replace(/<scaleMR>/gi, '<span class="tag-scale-mr">')
.replace(/<\/scaleMR>/gi, '</span>')
.replace(/<scaleLevel>/gi, '<span class="tag-scale-level">')
.replace(/<\/scaleLevel>/gi, '</span>')
.replace(/<spellName>/gi, '<span class="tag-spellname">')
.replace(/<\/spellName>/gi, '</span>')
.replace(/<unique>/gi, '<span class="tag-unique">UNIQUE</span>')
.replace(/<\/unique>/gi, '')
.replace(/<rarityMythic>/gi, '<span class="tag-rarity-mythic">Mythic</span>')
.replace(/<\/rarityMythic>/gi, '')
.replace(/<rarityLegendary>/gi, '<span class="tag-rarity-legendary">Legendary</span>')
.replace(/<\/rarityLegendary>/gi, '')
.replace(/<rarityGeneric>/gi, '<span class="tag-rarity-generic">Epic</span>')
.replace(/<\/rarityGeneric>/gi, '')
.replace(/<rules>/gi, '<div class="tag-rules">')
.replace(/<\/rules>/gi, '</div>')
.replace(/<flavorText>/gi, '<div class="tag-flavor">')
.replace(/<\/flavorText>/gi, '</div>')
.replace(/<li>/gi, '<div class="tag-list">')
.replace(/<\/li>/gi, '</div>')
.replace(/<font color='([^']+)'>/gi, '<span style="color: $1">')
.replace(/<\/font>/gi, '</span>')
.replace(/<b>/gi, '<strong>')
.replace(/<\/b>/gi, '</strong>')
.replace(/\[@@[^@]*@@\]/g, ' ') // Remove stat placeholders
.trim()
return html
} }
const formattedDescription = computed(() => function getTagTooltip(tag: ItemTag): string {
props.item ? formatDescription(props.item.description) : '' 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> </script>
<template> <template>
@@ -96,25 +243,72 @@ const formattedDescription = computed(() =>
}" }"
@mouseenter.stop @mouseenter.stop
> >
<!-- Header -->
<div class="tooltip-header"> <div class="tooltip-header">
<NuxtImg class="tooltip-icon" :src="CDRAGON_BASE + mapPath(item.iconPath)" /> <NuxtImg class="tooltip-icon" :src="CDRAGON_BASE + mapPath(item.iconPath)" />
<div class="tooltip-title"> <div class="tooltip-title">
<h3>{{ item.name || 'Unknown Item' }}</h3> <h3>{{ item.name || 'Unknown Item' }}</h3>
<span v-if="item.priceTotal" class="tooltip-gold"> {{ item.priceTotal }} Gold </span> <span v-if="item.priceTotal" class="tooltip-gold">{{ item.priceTotal }} Gold</span>
</div> </div>
</div> </div>
<!-- Plaintext (brief description) -->
<div v-if="item.plaintext" class="tooltip-plaintext"> <div v-if="item.plaintext" class="tooltip-plaintext">
{{ item.plaintext }} {{ item.plaintext }}
</div> </div>
<!-- eslint-disable vue/no-v-html --> <!-- Item Tags -->
<div <div v-if="tags && tags.length > 0" class="tooltip-tags-section">
v-if="formattedDescription" <div class="tooltip-tags">
class="tooltip-description" <span
v-html="formattedDescription" v-for="tag in tags"
></div> :key="tag"
<!-- eslint-enable vue/no-v-html --> :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> </div>
</Transition> </Transition>
</Teleport> </Teleport>
@@ -124,18 +318,23 @@ const formattedDescription = computed(() =>
.item-tooltip { .item-tooltip {
position: fixed; position: fixed;
z-index: 1000; z-index: 1000;
background: var(--color-surface); background: var(--tooltip-bg);
border: 1px solid var(--color-on-surface); border: 1px solid var(--tooltip-border);
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
max-width: 300px; max-width: 320px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); min-width: 250px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
pointer-events: none; pointer-events: none;
font-family: 'Inter', sans-serif;
} }
/* Header */
.tooltip-header { .tooltip-header {
display: flex; display: flex;
gap: 12px; gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--tooltip-header-border);
margin-bottom: 10px; margin-bottom: 10px;
} }
@@ -143,200 +342,345 @@ const formattedDescription = computed(() =>
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--color-on-surface); border: 2px solid var(--tooltip-border);
flex-shrink: 0; flex-shrink: 0;
background: rgba(0, 0, 0, 0.3);
} }
.tooltip-title { .tooltip-title {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
gap: 4px;
} }
.tooltip-title h3 { .tooltip-title h3 {
margin: 0; margin: 0;
font-size: 1rem; font-size: 1rem;
font-weight: 600; font-weight: 600;
color: var(--color-on-surface); color: var(--tooltip-text);
line-height: 1.2; line-height: 1.2;
} }
.tooltip-gold { .tooltip-gold {
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-gold); color: var(--color-gold);
margin-top: 4px; font-weight: 500;
} }
/* Plaintext */
.tooltip-plaintext { .tooltip-plaintext {
font-size: 0.85rem; font-size: 0.8rem;
color: var(--color-on-surface); color: var(--tooltip-text-dim);
opacity: 0.8;
margin-bottom: 8px; margin-bottom: 8px;
font-style: italic; font-style: italic;
line-height: 1.4;
} }
.tooltip-description { /* Item Tags Section */
font-size: 0.85rem; .tooltip-tags-section {
color: var(--color-on-surface); margin-bottom: 10px;
line-height: 1.5; padding-bottom: 10px;
white-space: pre-wrap; border-bottom: 1px solid var(--tooltip-header-border);
} }
/* Stats section */ .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 { .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; margin-bottom: 8px;
padding-bottom: 8px; padding-left: 10px;
border-bottom: 1px solid var(--color-on-surface-dim); border-left: 3px solid var(--tooltip-border);
} }
.tooltip-stats :deep(.stat-highlight) { .effect-item:last-child {
color: #ffcc00; 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; font-weight: 600;
} }
/* Tag styles */ .effect-description :deep(.tag-passive) {
.tooltip-description :deep(.tag-passive) { color: var(--tooltip-effect-passive);
color: #4a9eff;
font-weight: 600; font-weight: 600;
} }
.tooltip-description :deep(.tag-active) { .effect-description :deep(.tag-active) {
color: #ff6b6b; color: var(--tooltip-effect-active);
font-weight: 600; font-weight: 600;
} }
.tooltip-description :deep(.tag-keyword) { .effect-description :deep(.tag-keyword) {
color: #ffd700; color: var(--tooltip-keyword);
font-weight: 600; font-weight: 600;
} }
.tooltip-description :deep(.tag-keyword-major) { .effect-description :deep(.tag-keyword-major) {
color: #ff8c00; color: var(--tooltip-keyword-major);
font-weight: 700; font-weight: 700;
font-style: italic; font-style: italic;
} }
.tooltip-description :deep(.tag-keyword-stealth) { .effect-description :deep(.tag-keyword-stealth) {
color: #9b59b6; color: var(--tooltip-keyword-stealth);
font-weight: 600; font-weight: 600;
} }
.tooltip-description :deep(.tag-status) { .effect-description :deep(.tag-status) {
color: #e74c3c; color: var(--tooltip-status);
font-weight: 500; font-weight: 500;
font-style: italic; font-style: italic;
} }
.tooltip-description :deep(.tag-speed), .effect-description :deep(.tag-speed),
.tooltip-description :deep(.tag-scale-mana), .effect-description :deep(.tag-scaling) {
.tooltip-description :deep(.tag-scale-health), color: var(--tooltip-scaling);
.tooltip-description :deep(.tag-scale-ap),
.tooltip-description :deep(.tag-scale-ad),
.tooltip-description :deep(.tag-scale-armor),
.tooltip-description :deep(.tag-scale-mr),
.tooltip-description :deep(.tag-scale-level) {
color: #3498db;
font-style: italic; font-style: italic;
font-weight: 500; font-weight: 500;
} }
.tooltip-description :deep(.tag-healing), .effect-description :deep(.tag-healing) {
.tooltip-description :deep(.tag-health) { color: var(--tooltip-healing);
color: #2ecc71;
font-weight: 500; font-weight: 500;
} }
.tooltip-description :deep(.tag-shield) { .effect-description :deep(.tag-shield) {
color: #3498db; color: var(--tooltip-shield);
font-weight: 500; font-weight: 500;
} }
.tooltip-description :deep(.tag-magic-damage) { .effect-description :deep(.tag-magic-damage) {
color: #9b59b6; color: var(--tooltip-magic-damage);
font-weight: 500; font-weight: 500;
} }
.tooltip-description :deep(.tag-physical-damage) { .effect-description :deep(.tag-physical-damage) {
color: #e67e22; color: var(--tooltip-physical-damage);
font-weight: 500; font-weight: 500;
} }
.tooltip-description :deep(.tag-true-damage) { .effect-description :deep(.tag-true-damage) {
color: #c0392b; color: var(--tooltip-true-damage);
font-weight: 600; font-weight: 600;
} }
.tooltip-description :deep(.tag-onhit) { .effect-description :deep(.tag-onhit) {
background: rgba(52, 152, 219, 0.1); background: rgba(52, 152, 219, 0.2);
color: #3498db; color: var(--tooltip-onhit);
padding: 1px 4px; padding: 1px 4px;
border-radius: 3px; border-radius: 3px;
font-size: 0.8rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
} }
.tooltip-description :deep(.tag-spellname) { .effect-description :deep(.tag-spellname) {
color: #1abc9c; color: var(--tooltip-spellname);
font-weight: 600; font-weight: 600;
font-style: italic; font-style: italic;
} }
.tooltip-description :deep(.tag-unique) { .effect-description :deep(.tag-unique) {
color: #f39c12; color: var(--tooltip-effect-unique);
font-weight: 700; font-weight: 700;
font-size: 0.75rem; font-size: 0.75rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.tooltip-description :deep(.tag-rarity-mythic) { .effect-description :deep(.tag-rarity-mythic) {
color: #ff5252; color: var(--tooltip-effect-mythic);
font-weight: 700; font-weight: 700;
font-size: 0.85rem; font-size: 0.85rem;
} }
.tooltip-description :deep(.tag-rarity-legendary) { .effect-description :deep(.tag-rarity-legendary) {
color: #ff9800; color: var(--tooltip-effect-legendary);
font-weight: 600; font-weight: 600;
font-size: 0.85rem; font-size: 0.85rem;
} }
.tooltip-description :deep(.tag-rarity-generic) { .effect-description :deep(.tag-rarity-generic) {
color: #ffd54f; color: var(--tooltip-effect-epic);
font-weight: 500; font-weight: 500;
font-size: 0.85rem; font-size: 0.85rem;
} }
.tooltip-description :deep(.tag-rules) { /* Transition */
margin-top: 8px;
padding: 6px;
background: rgba(0, 0, 0, 0.1);
border-left: 2px solid var(--color-on-surface-dim);
font-size: 0.8rem;
font-style: italic;
opacity: 0.8;
}
.tooltip-description :deep(.tag-flavor) {
margin-top: 10px;
padding: 6px;
background: rgba(74, 222, 255, 0.1);
border-left: 2px solid #4a9eff;
font-size: 0.8rem;
font-style: italic;
color: rgba(255, 255, 255, 0.7);
}
.tooltip-description :deep(.tag-list) {
margin: 4px 0;
padding-left: 12px;
position: relative;
}
.tooltip-description strong {
font-weight: 600;
color: var(--color-on-surface);
}
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.15s ease; transition: opacity 0.15s ease;

View File

@@ -1,6 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { LinePath as svgdomarrowsLinePath } from 'svg-dom-arrows' import { LinePath as svgdomarrowsLinePath } from 'svg-dom-arrows'
import type { ItemTree } from 'match_collector'
import type { Item } from '~/types/cdragon'
defineProps<{ defineProps<{
tree: ItemTree tree: ItemTree
parentCount?: number parentCount?: number
@@ -234,6 +237,7 @@ function handleRefresh() {
:show-pickrate="true" :show-pickrate="true"
:pickrate="parentCount ? tree.count / parentCount : 0" :pickrate="parentCount ? tree.count / parentCount : 0"
:size="48" :size="48"
:tags="tree.tags"
class="item-tree-img" class="item-tree-img"
/> />
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon' import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
import type { LaneData } from 'match_collector'
defineProps<{ defineProps<{
championName?: string championName?: string
championLanes?: Array<LaneData> championLanes?: Array<LaneData>
@@ -137,7 +139,10 @@ if (route.path.startsWith('/tierlist/')) {
<h3 style="font-size: 18px; font-weight: 200; opacity: 0.5">Loading stats...</h3> <h3 style="font-size: 18px; font-weight: 200; opacity: 0.5">Loading stats...</h3>
</template> </template>
<h3 style="font-size: 18px; font-weight: 200; margin-top: 10px; margin-bottom: 10px"> <h3 style="font-size: 18px; font-weight: 200; margin-top: 10px; margin-bottom: 10px">
EUW Challenger only Challenger only
</h3>
<h3 style="font-size: 18px; font-weight: 200; margin-top: 10px; margin-bottom: 10px">
EUW/EUNE/NA/KR
</h3> </h3>
<NuxtLink to="/about"><h3>About</h3></NuxtLink> <NuxtLink to="/about"><h3>About</h3></NuxtLink>
<h2 style="font-size: 10px; font-weight: 200; margin-top: 3px; margin-right: 10px"> <h2 style="font-size: 10px; font-weight: 200; margin-top: 3px; margin-right: 10px">

View File

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

View File

@@ -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

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

View File

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

View File

@@ -1,3 +1,5 @@
import type { Builds } from 'match_collector'
/** /**
* Composable for managing build data * Composable for managing build data
*/ */

View File

@@ -1,3 +1,5 @@
import type { Item } from '~/types/cdragon'
/** /**
* Composable for fetching and managing item data from CDragon API * Composable for fetching and managing item data from CDragon API
* Returns a reactive Map of item ID to item data * Returns a reactive Map of item ID to item data

View File

@@ -1,3 +1,5 @@
import type { Perk, PerkStyle } from '~/types/cdragon'
/** /**
* Composable for fetching and managing rune styles and keystones * Composable for fetching and managing rune styles and keystones
* Transforms rune data into format needed for display components * Transforms rune data into format needed for display components
@@ -5,7 +7,6 @@
export const useRuneStyles = () => { export const useRuneStyles = () => {
const { data: perksData } = useFetch('/api/cdragon/perks') const { data: perksData } = useFetch('/api/cdragon/perks')
const { data: stylesData } = useFetch('/api/cdragon/perkstyles') const { data: stylesData } = useFetch('/api/cdragon/perkstyles')
console.log(stylesData.value)
const perks = reactive(new Map<number, Perk>()) const perks = reactive(new Map<number, Perk>())
watch( watch(

View File

@@ -1,3 +1,5 @@
import type { SummonerSpell } from '~/types/cdragon'
/** /**
* Composable for fetching and managing summoner spell data from CDragon API * Composable for fetching and managing summoner spell data from CDragon API
* Returns a reactive Map of spell ID to spell data * Returns a reactive Map of spell ID to spell data

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 // @ts-check
import './eslint-polyfill.mjs'
import withNuxt from './.nuxt/eslint.config.mjs' import withNuxt from './.nuxt/eslint.config.mjs'
import js from '@eslint/js' import js from '@eslint/js'
@@ -16,24 +17,7 @@ export default withNuxt([
languageOptions: { languageOptions: {
globals: { globals: {
...globals.browser, ...globals.browser,
...globals.node, ...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'
} }
}, },
rules: { rules: {

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import type { ChampionData } from 'match_collector'
const route = useRoute() const route = useRoute()
const championAlias = route.params.alias as string const championAlias = route.params.alias as string
@@ -76,7 +78,6 @@ watch(
// Add timeout to prevent infinite loading // Add timeout to prevent infinite loading
const loadingTimeout = setTimeout(() => { const loadingTimeout = setTimeout(() => {
if (isLoading.value && !championData.value && !error.value) { if (isLoading.value && !championData.value && !error.value) {
console.warn('Champion data loading timed out')
error.value = 'Loading took too long. Please try again.' error.value = 'Loading took too long. Please try again.'
isLoading.value = false isLoading.value = false
} }

View File

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

View File

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

View File

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

View File

@@ -1,19 +1,43 @@
import type { MongoClient } from 'mongodb' 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) { async function fetchGameCount(client: MongoClient, patch: string) {
const database = client.db('matches') 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 matches = database.collection(patch)
const count = await matches.countDocuments() const count = await matches.countDocuments()
return count return { total: count, platforms: {} }
} }
export default defineEventHandler(async _ => { export default defineEventHandler(async _ => {
const client = await connectToDatabase() const client = await connectToDatabase()
const latestPatch = await fetchLatestPatch(client) const latestPatch = await fetchLatestPatch(client)
const gameCount = await fetchGameCount(client, latestPatch) const gameCountData = await fetchGameCount(client, latestPatch)
await client.close() 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 { readFile, existsSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { promisify } from 'util' import { promisify } from 'util'
import { parseItemStats, type ItemStats } from 'dragon-item-parser'
const readFileAsync = promisify(readFile) const readFileAsync = promisify(readFile)
@@ -8,13 +9,18 @@ const readFileAsync = promisify(readFile)
const CDRAGON_BASE = 'https://raw.communitydragon.org/' const CDRAGON_BASE = 'https://raw.communitydragon.org/'
// Cache directory - can be configured via environment variable // Cache directory - can be configured via environment variable
// Default to dev/cdragon for development // In development, use dev/data/cdragon relative to project root
// In production, use /cdragon (shared volume)
const getCacheDir = () => { const getCacheDir = () => {
if (process.env.CDRAGON_CACHE_DIR) { if (process.env.CDRAGON_CACHE_DIR) {
return process.env.CDRAGON_CACHE_DIR return process.env.CDRAGON_CACHE_DIR
} }
// Default to dev/cdragon relative to project root // Check if we're in development mode (explicitly set)
return join(process.cwd(), '..', 'dev', 'data', 'cdragon') 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[]> { async function getItems(patch?: string): Promise<CDragonItemWithStats[]> {
return fetchFromCache<CDragonItem[]>( const items = await fetchFromCache<CDragonItem[]>(
'items.json', 'items.json',
'plugins/rcp-be-lol-game-data/global/default/v1/items.json', 'plugins/rcp-be-lol-game-data/global/default/v1/items.json',
{ patch } { 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 { interface CDragonPerk {
id: number id: number
name: string name: string
@@ -197,6 +213,7 @@ export {
export type { export type {
CDragonItem, CDragonItem,
CDragonItemWithStats,
CDragonPerk, CDragonPerk,
CDragonPerkStyle, CDragonPerkStyle,
CDragonPerkStyles, CDragonPerkStyles,

View File

@@ -1,5 +1,9 @@
import { MongoClient } from 'mongodb' 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() { async function connectToDatabase() {
// Create a MongoClient with a MongoClientOptions object to set the Stable API version // 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}` let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
@@ -15,11 +19,59 @@ async function connectToDatabase() {
return client return client
} }
async function fetchLatestPatch(client: MongoClient) { /**
const database = client.db('patches') * Get the latest patch from existing match collections in the database.
const patches = database.collection('patches') * Collections are named like "15.1_EUW1", "15.2_NA1", etc.
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next() */
return latestPatch!.patch as string 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,93 +0,0 @@
declare global {
/**
* Represents an item in the build tree
*/
interface ItemTree {
count: number
data: number
children: ItemTree[]
}
/**
* Represents a complete build with runes and items
*/
interface Build {
runeKeystone: number
runes: Rune[]
items: ItemTree
bootsFirst: number
count: number
boots: Array<{ count: number; data: number }>
suppItems: Array<{ count: number; data: number }>
startItems: Array<{ count: number; data: number }>
pickrate: number
}
/**
* Represents champion build information (array of builds)
*/
type Builds = Array<Build>
/**
* 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
builds?: Builds
summonerSpells: Array<{ id: number; count: number; pickrate: number }>
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,57 +1,45 @@
declare global { import type { ItemStats } from 'dragon-item-parser'
type ChampionsResponse = {
data: Ref<Array<Champion>> type Champion = {
} name: string
type ChampionResponse = { alias: string
data: Ref<ChampionFull> squarePortraitPath: string
} }
type Champion = { type ChampionFull = {
name: string name: string
alias: string alias: string
squarePortraitPath: string squarePortraitPath: string
} title: string
type ChampionFull = { }
name: string type Item = {
alias: string id: number
squarePortraitPath: string iconPath: string
title: string name?: string
} description?: string
type ItemResponse = { plaintext?: string
data: Ref<Array<Item>> into?: number[]
} from?: number[]
type Item = { price?: number
id: number priceTotal?: number
iconPath: string stats?: ItemStats
name?: string }
description?: string type SummonerSpell = {
plaintext?: string id: number
into?: number[] iconPath: string
from?: number[] name: string
price?: number }
priceTotal?: number type Perk = {
} id: number
type SummonerSpell = { name: string
id: number iconPath: string
iconPath: string shortDesc?: string
name: string longDesc?: string
} }
type PerksResponse = { type PerkStyle = {
data: Ref<Array<Perk>> id: number
} name: string
type Perk = { iconPath: string
id: number slots: Array<{ perks: Array<number> }>
name: string
iconPath: string
}
type PerkStylesResponse = {
data: Ref<{ styles: Array<PerkStyle> }>
}
type PerkStyle = {
id: number
name: string
iconPath: string
slots: Array<{ perks: Array<number> }>
}
} }
export {} export type { Champion, ChampionFull, Item, SummonerSpell, Perk, PerkStyle }

View File

@@ -1,3 +1,5 @@
import type { Build, ItemTree } from 'match_collector'
/** /**
* Gets all late game items from the item tree (items beyond first level) * Gets all late game items from the item tree (items beyond first level)
* Returns a flat array of unique items with their counts * Returns a flat array of unique items with their counts
@@ -27,8 +29,6 @@ export function getLateGameItems(build: Build): Array<{ data: number; count: num
lateGameItems.sort((a, b) => b.count - a.count) lateGameItems.sort((a, b) => b.count - a.count)
console.log(lateGameItems)
// Sort by count descending // Sort by count descending
return lateGameItems.filter(item => !treeToArray(getCoreItems(build)).includes(item.data)) return lateGameItems.filter(item => !treeToArray(getCoreItems(build)).includes(item.data))
} }
@@ -54,7 +54,10 @@ function trimTreeDepth(tree: ItemTree, maxDepth: number, currentDepth: number =
const trimmedTree: ItemTree = { const trimmedTree: ItemTree = {
count: tree.count, count: tree.count,
data: tree.data, data: tree.data,
children: [] children: [],
tags: tree.tags,
boughtWhen: tree.boughtWhen,
platformCount: tree.platformCount
} }
// If we haven't reached maxDepth, include children // If we haven't reached maxDepth, include children

View File

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

View File

@@ -1,8 +1,33 @@
FROM node:lts-alpine # This Dockerfile should be built from the project root directory:
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app # 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 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 RUN npm install
COPY --chown=node:node . . COPY --chown=node:node match_collector/. .
CMD /bin/sh -c "node --import=tsx index.ts; sleep 20h"
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

@@ -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,177 +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)
}
}
/*
* Deep clone an ItemTree
*/
function treeClone(tree: ItemTree): ItemTree {
return {
data: tree.data,
count: tree.count,
children: tree.children.map(child => treeClone(child))
}
}
/*
* Merge two ItemTrees into one
*/
function treeMergeTree(t1: ItemTree, t2: ItemTree): ItemTree {
// Merge counts for the root
t1.count += t2.count
// 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))
}
export { ItemTree, treeMerge, treeInit, treeCutBranches, treeSort, treeMergeTree, areTreeSimilars }

File diff suppressed because it is too large Load Diff

View File

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

@@ -1,21 +1,45 @@
function sameArrays(array1: Array<number>, array2: Array<number>) { 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 if (array1.length != array2.length) return false
for (const e of array1) { for (const e of array1) {
if (!array2.includes(e)) return false if (!array2.includes(e)) return false
} }
return true return true
} }
import { MongoClient } from 'mongodb'
import {
ItemTree,
treeInit,
treeMerge,
treeCutBranches,
treeSort,
treeMergeTree,
areTreeSimilars
} from './item_tree'
const itemDict = new Map() const itemDict = new Map()
async function itemList() { async function itemList() {
@@ -42,73 +66,8 @@ function arrayRemovePercentage(
} }
} }
type Rune = {
count: number
primaryStyle: number
secondaryStyle: number
selections: Array<number>
pickrate?: number
}
type Build = {
runeKeystone: number
runes: Array<Rune>
items: ItemTree
bootsFirstCount: number
bootsFirst?: number
count: number
suppItems: Array<{ data: number; count: number }>
boots: Array<{ data: number; count: number }>
pickrate?: number
}
type BuildWithStartItems = {
runeKeystone: number
runes: Array<Rune>
items: ItemTree
bootsFirst?: number
bootsFirstCount: number
count: number
startItems: Array<{ data: number; count: number }>
suppItems: Array<{ data: number; count: number }>
boots: Array<{ data: number; count: number }>
pickrate?: number
}
type Builds = Build[]
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
builds: Builds
matchups?: Array<MatchupData>
summonerSpells: Array<{ id: number; count: number; pickrate?: number }>
}
type ChampionData = {
champion: Champion
winningMatches: number
losingMatches: number
lanes: Array<LaneData>
}
// Helper function to create rune configuration from participant // Helper function to create rune configuration from participant
// eslint-disable-next-line @typescript-eslint/no-explicit-any function createRuneConfiguration(participant: Participant): Rune {
function createRuneConfiguration(participant: any): Rune {
const primaryStyle = participant.perks.styles[0].style const primaryStyle = participant.perks.styles[0].style
const secondaryStyle = participant.perks.styles[1].style const secondaryStyle = participant.perks.styles[1].style
const selections: Array<number> = [] const selections: Array<number> = []
@@ -126,8 +85,7 @@ function createRuneConfiguration(participant: any): Rune {
} }
// Find or create a build for the given rune keystone // Find or create a build for the given rune keystone
// eslint-disable-next-line @typescript-eslint/no-explicit-any function findOrCreateBuild(builds: Builds, participant: Participant): Build {
function findOrCreateBuild(builds: Builds, participant: any): Build {
const keystone = participant.perks.styles[0].selections[0].perk const keystone = participant.perks.styles[0].selections[0].perk
const runeConfig = createRuneConfiguration(participant) const runeConfig = createRuneConfiguration(participant)
@@ -166,24 +124,73 @@ function findOrCreateBuild(builds: Builds, participant: any): Build {
return 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( function handleMatchBuilds(
// eslint-disable-next-line @typescript-eslint/no-explicit-any match: Match,
timeline: any, participant: Participant,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
participant: any,
participantIndex: number, participantIndex: number,
builds: Builds 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 // Find or create the build for this participant's rune configuration
const build = findOrCreateBuild(builds, participant) const build = findOrCreateBuild(builds, participant)
build.count += 1 build.count += 1
const items: Array<number> = [] const items: Array<{ itemId: number; goldAdvantage: GoldAdvantageTag; platform?: string }> = []
let startItemId: number | undefined = undefined
for (const frame of timeline.info.frames) { for (const frame of timeline.info.frames) {
for (const event of frame.events) { for (const event of frame.events) {
if (event.participantId != participantIndex) continue if (event.participantId != participantIndex) continue
if (event.type == 'ITEM_UNDO') { if (event.type == 'ITEM_UNDO') {
if (items.length > 0 && items[items.length - 1] == event.beforeId) { if (items.length > 0 && items[items.length - 1].itemId == event.beforeId) {
items.pop() items.pop()
} }
continue continue
@@ -212,17 +219,10 @@ function handleMatchBuilds(
} }
if (event.type != 'ITEM_PURCHASED') continue if (event.type != 'ITEM_PURCHASED') continue
// Handle boots upgrades
if (
itemInfo.requiredBuffCurrencyName == 'Feats_NoxianBootPurchaseBuff' ||
itemInfo.requiredBuffCurrencyName == 'Feats_SpecialQuestBootBuff'
) {
continue
}
// Handle boots differently // Handle boots differently
if (itemInfo.categories.includes('Boots')) { if (itemInfo.categories.includes('Boots')) {
if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) { // Ignore basic boots, only count Tier 2 boots
if (event.itemId != 1001) {
// Check for bootsFirst // Check for bootsFirst
if (items.length < 2) { if (items.length < 2) {
build.bootsFirstCount += 1 build.bootsFirstCount += 1
@@ -251,7 +251,9 @@ function handleMatchBuilds(
if (itemInfo.to.length != 0 && (items.length >= 1 || participant.teamPosition === 'UTILITY')) if (itemInfo.to.length != 0 && (items.length >= 1 || participant.teamPosition === 'UTILITY'))
continue continue
items.push(event.itemId) // Calculate gold advantage at time of purchase
const goldAdvantage = calculateGoldAdvantage(match, frame, participantIndex)
items.push({ itemId: event.itemId, goldAdvantage, platform })
} }
} }
@@ -259,16 +261,19 @@ function handleMatchBuilds(
// This tree includes start item as the root, then branching paths // This tree includes start item as the root, then branching paths
if (items.length > 0) { if (items.length > 0) {
treeMerge(build.items, items) treeMerge(build.items, items)
// The first item is the starter item
startItemId = items[0].itemId
} }
return { build, startItemId }
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any function handleMatch(match: Match, champions: Map<number, ChampionData>, platform?: string) {
function handleMatch(match: any, champions: Map<number, ChampionData>) {
let participantIndex = 0 let participantIndex = 0
for (const participant of match.info.participants) { for (const participant of match.info.participants) {
participantIndex += 1 participantIndex += 1
const championId = participant.championId const championId = participant.championId
const champion = champions.get(championId) const champion = champions.get(championId)!
// Lanes // Lanes
let lane = champion.lanes.find(x => x.data == participant.teamPosition) let lane = champion.lanes.find(x => x.data == participant.teamPosition)
@@ -282,11 +287,21 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
winrate: 0, winrate: 0,
pickrate: 0, pickrate: 0,
summonerSpells: [], summonerSpells: [],
matchups: [] matchups: [],
regionDistribution: { euw: 0, eun: 0, na: 0, kr: 0 }
} }
champion.lanes.push(lane) champion.lanes.push(lane)
} else lane.count += 1 } 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 // Initialize matchups if not present
if (!lane.matchups) { if (!lane.matchups) {
lane.matchups = [] lane.matchups = []
@@ -333,7 +348,7 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
matchup.winrate = (matchup.winrate * (matchup.games - 1)) / matchup.games matchup.winrate = (matchup.winrate * (matchup.games - 1)) / matchup.games
} }
} else { } else {
const opponentChampion = champions.get(opponentChampionId) const opponentChampion = champions.get(opponentChampionId)!
lane.matchups.push({ lane.matchups.push({
championId: opponentChampionId, championId: opponentChampionId,
@@ -346,17 +361,36 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
} }
// Items and runes (builds) // Items and runes (builds)
handleMatchBuilds(match.timeline, participant, participantIndex, lane.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( async function handleMatchList(
client: MongoClient, client: MongoClient,
patch: string, patch: string,
champions: Map<number, ChampionData> champions: Map<number, ChampionData>,
platform?: string
) { ) {
const database = client.db('matches') const database = client.db('matches')
const matches = database.collection(patch) const collectionName = platform ? `${patch}_${platform}` : patch
const matches = database.collection(collectionName)
const allMatches = matches.find() const allMatches = matches.find()
const totalMatches: number = await matches.countDocuments() const totalMatches: number = await matches.countDocuments()
@@ -366,7 +400,7 @@ async function handleMatchList(
'\rComputing champion stats, game entry ' + currentMatch + '/' + totalMatches + ' ... ' '\rComputing champion stats, game entry ' + currentMatch + '/' + totalMatches + ' ... '
) )
currentMatch += 1 currentMatch += 1
handleMatch(match, champions) handleMatch(match as unknown as Match, champions, platform)
} }
return totalMatches return totalMatches
@@ -389,10 +423,10 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS
) { ) {
const startItems = [] const startItems = []
let items = build.items.children[0] let items = build.items.children[0]
startItems.push({ data: build.items.children[0].data, count: build.items.children[0].count }) startItems.push({ data: build.items.children[0].data!, count: build.items.children[0].count })
build.items.children[0].data = undefined build.items.children[0].data = undefined
if (build.items.children.length > 1) { if (build.items.children.length > 1) {
startItems.push({ data: build.items.children[1].data, count: build.items.children[1].count }) startItems.push({ data: build.items.children[1].data!, count: build.items.children[1].count })
build.items.children[1].data = undefined build.items.children[1].data = undefined
items = treeMergeTree(build.items.children[0], build.items.children[1]) items = treeMergeTree(build.items.children[0], build.items.children[1])
} }
@@ -406,7 +440,8 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS
startItems, startItems,
suppItems: build.suppItems, suppItems: build.suppItems,
boots: build.boots, boots: build.boots,
pickrate: build.pickrate pickrate: build.pickrate,
firstBacksRaw: build.firstBacksRaw
} }
] ]
} else { } else {
@@ -414,15 +449,44 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS
console.log(`Warning: for champion ${championName}, start item splits build variant.`) console.log(`Warning: for champion ${championName}, start item splits build variant.`)
const builds = [] const builds = []
for (const c of build.items.children) { for (const c of build.items.children) {
// Calculate the ratio for proportional distribution
const ratio = c.count / build.count
// Proportionally distribute boots counts
const scaledBoots = build.boots.map(b => ({
data: b.data,
count: Math.round(b.count * ratio)
}))
// Proportionally distribute suppItems counts
const scaledSuppItems = build.suppItems.map(s => ({
data: s.data,
count: Math.round(s.count * ratio)
}))
// Proportionally distribute bootsFirstCount
const scaledBootsFirstCount = Math.round(build.bootsFirstCount * ratio)
// Filter firstBacksRaw by starter item
let filteredFirstBacksRaw: FirstBackData[] | undefined
if (build.firstBacksRaw && build.firstBacksRaw.length > 0) {
// Filter by the starter item ID that was tracked when storing firstBacksRaw
filteredFirstBacksRaw = build.firstBacksRaw.filter(fb => fb.startItemId === c.data)
if (filteredFirstBacksRaw.length === 0) {
filteredFirstBacksRaw = undefined
}
}
builds.push({ builds.push({
runeKeystone: build.runeKeystone, runeKeystone: build.runeKeystone,
runes: build.runes, runes: build.runes,
items: c, items: c,
bootsFirstCount: build.bootsFirstCount, bootsFirstCount: scaledBootsFirstCount,
count: c.count, count: c.count,
startItems: [{ data: c.data, count: c.count }], startItems: [{ data: c.data!, count: c.count }],
suppItems: build.suppItems, suppItems: scaledSuppItems,
boots: build.boots boots: scaledBoots,
firstBacksRaw: filteredFirstBacksRaw
}) })
c.data = undefined c.data = undefined
} }
@@ -506,6 +570,14 @@ function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems
const runes = Array.from(runesMap.values()) const runes = Array.from(runesMap.values())
runes.sort((a, b) => b.count - a.count) runes.sort((a, b) => b.count - a.count)
// Merge first backs raw data
const firstBacksRaw: FirstBackData[] = []
for (const build of allSimilarBuilds) {
if (build.firstBacksRaw) {
firstBacksRaw.push(...build.firstBacksRaw)
}
}
merged.push({ merged.push({
runeKeystone: runes[0].selections[0], runeKeystone: runes[0].selections[0],
runes: runes, runes: runes,
@@ -514,7 +586,8 @@ function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems
count: totalCount, count: totalCount,
startItems: mergeItemCounts(allSimilarBuilds, b => b.startItems), startItems: mergeItemCounts(allSimilarBuilds, b => b.startItems),
suppItems: mergeItemCounts(allSimilarBuilds, b => b.suppItems), suppItems: mergeItemCounts(allSimilarBuilds, b => b.suppItems),
boots: mergeItemCounts(allSimilarBuilds, b => b.boots) boots: mergeItemCounts(allSimilarBuilds, b => b.boots),
firstBacksRaw: firstBacksRaw.length > 0 ? firstBacksRaw : undefined
}) })
} }
@@ -537,6 +610,9 @@ function cleanupLaneBuilds(lane: LaneData) {
treeCutBranches(build.items, 4, 0.05) treeCutBranches(build.items, 4, 0.05)
treeSort(build.items) treeSort(build.items)
// Derive tags from purchase patterns (gold advantage, region)
treeDeriveTags(build.items, lane.regionDistribution)
// Remove boots that are not within percentage threshold // Remove boots that are not within percentage threshold
arrayRemovePercentage(build.boots, build.count, 0.05) arrayRemovePercentage(build.boots, build.count, 0.05)
build.boots.sort((a, b) => b.count - a.count) build.boots.sort((a, b) => b.count - a.count)
@@ -567,7 +643,7 @@ async function finalizeChampionStats(champion: ChampionData, totalMatches: numbe
// Summoner spells // Summoner spells
lane.summonerSpells.forEach(x => (x.pickrate = x.count / lane.count)) lane.summonerSpells.forEach(x => (x.pickrate = x.count / lane.count))
lane.summonerSpells.sort((a, b) => b.count - a.count) lane.summonerSpells.sort((a, b) => b.count - a.count)
lane.summonerSpells = lane.summonerSpells.filter(x => x.pickrate >= 0.05) lane.summonerSpells = lane.summonerSpells.filter(x => x.pickrate! >= 0.05)
// Cleaning up builds // Cleaning up builds
cleanupLaneBuilds(lane) cleanupLaneBuilds(lane)
@@ -589,6 +665,19 @@ async function finalizeChampionStats(champion: ChampionData, totalMatches: numbe
// all along. // all along.
lane.builds = mergeBuildsForRunes(lane.builds as BuildWithStartItems[]) lane.builds = mergeBuildsForRunes(lane.builds as BuildWithStartItems[])
cleanupLaneBuilds(lane) cleanupLaneBuilds(lane)
// Process first backs at build level - group by item set
for (const build of lane.builds) {
if (build.firstBacksRaw && build.firstBacksRaw.length > 0) {
build.firstBacks = groupFirstBacksByItemSet(build.firstBacksRaw)
// Keep only top 7 groups
if (build.firstBacks!.length > 7) {
build.firstBacks = build.firstBacks!.slice(0, 7)
}
// Clean up raw data to save space
delete build.firstBacksRaw
}
}
} }
for (const lane of champion.lanes) { for (const lane of champion.lanes) {
@@ -647,16 +736,19 @@ async function championList() {
return list.slice(1) return list.slice(1)
} }
async function makeChampionsStats(client: MongoClient, patch: string) { async function makeChampionsStats(client: MongoClient, patch: string, platforms: string[] = []) {
const globalItems = await itemList() const globalItems = await itemList()
for (const item of globalItems) { for (const item of globalItems) {
itemDict.set(item.id, item) itemDict.set(item.id, item)
} }
// Initialize first back item dictionary
await initFirstBackItemDict()
const list = await championList() const list = await championList()
console.log('Generating stats for ' + list.length + ' champions') console.log('Generating stats for ' + list.length + ' champions')
// Pre-generate list of champions // Pre-generate list of champions (shared across all platforms)
const champions: Map<number, ChampionData> = new Map() const champions: Map<number, ChampionData> = new Map()
for (const champion of list) { for (const champion of list) {
champions.set(champion.id, { champions.set(champion.id, {
@@ -667,19 +759,28 @@ async function makeChampionsStats(client: MongoClient, patch: string) {
}) })
} }
// Loop through all matches to generate stats // Process matches from all platforms, merging into the same champions map
const totalMatches = await handleMatchList(client, patch, champions) 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}`)
}
// Finalize and save stats for every champion console.log(`\n=== Total matches processed: ${totalMatches} ===`)
// Finalize and save stats to a single champions collection
const database = client.db('champions') const database = client.db('champions')
const collection = database.collection(patch) const collection = database.collection(patch)
for (const champion of list) { for (const champion of list) {
const championInfo = await finalizeChampionStats(champions.get(champion.id), totalMatches) const championInfo = await finalizeChampionStats(champions.get(champion.id)!, totalMatches)
await collection.updateOne({ id: champion.id }, { $set: championInfo }, { upsert: true }) await collection.updateOne({ id: champion.id }, { $set: championInfo }, { upsert: true })
} }
// Create alias-index for better key-find // Create alias-index for better key-find
await collection.createIndex({ alias: 1 }) await collection.createIndex({ alias: 1 })
console.log(`Stats saved to collection: ${patch}`)
} }
export default { makeChampionsStats } 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": { "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"]
}
}