Compare commits

...

23 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
73 changed files with 9769 additions and 6939 deletions

32
.dockerignore Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -20,40 +20,70 @@ async function setupDatabase() {
// Check if data directory exists and has files
const dataDir = path.join(__dirname, '../data');
const patchFile = path.join(dataDir, "patches.json");
if(!fs.existsSync(dataDir) || !fs.existsSync(patchFile)) {
// Try to get latest patch version from existing data files or database
let latestPatch = await getLatestPatchVersion();
// If no patch found, download snapshot
if (!latestPatch) {
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
console.log('🚫 No data files found. Downloading latest snapshot...');
}
console.log('🚫 No match data found. Downloading latest snapshot...');
await downloadAndExtractSnapshot();
// Try again after download
latestPatch = await getLatestPatchVersion();
}
// Get latest patch version
const latestPatch = await getLatestPatchVersion();
if (!latestPatch) {
console.error('❌ Could not determine latest patch version');
console.log('💡 Make sure you have match data files in the data directory');
process.exit(1);
}
console.log(`🎯 Latest patch version: ${latestPatch}`);
// Check if data directory exists and has files
// Support both old format (patch_matches.json) and new platform-specific format (patch_PLATFORM_matches.json)
// Also support both "XX.Y" and "XX.Y.Z" patch formats in filenames
console.log('🔍 Checking for data files...');
const platforms = ['EUW1', 'EUN1', 'NA1', 'KR'];
const dataFiles = [
{ path: 'patches.json', required: true, description: 'Patches data' }
];
const dataFiles = [];
// Check for platform-specific match files
// Files may be named with either "16.8" or "16.8.1" format
let foundPlatformFiles = [];
for (const platform of platforms) {
const platformFile = `${latestPatch}_${platform}.json`;
const fullPath = path.join(dataDir, platformFile);
if (fs.existsSync(fullPath)) {
// 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: platformFile, required: false, description: `Match data for ${platform}` });
dataFiles.push({ path: matchFile, required: false, description: `Match data for ${platform}` });
}
}
// If no platform-specific files found, look for old format
if (foundPlatformFiles.length === 0) {
// Try to find any match file for this patch
const files = fs.readdirSync(dataDir);
const matchFile = files.find(f => {
const match = f.match(/^(\d+\.\d+(?:\.\d+)?)(?:_matches)?\.json$/);
const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
return match && patchFromName === latestPatch;
});
if (matchFile) {
dataFiles.push({ path: matchFile, required: true, description: 'Match data' });
} else {
dataFiles.push({ path: `${latestPatch}_matches.json`, required: true, description: 'Match data' });
}
}
let filesExist = true;
for (const file of dataFiles) {
@@ -91,11 +121,7 @@ async function setupDatabase() {
// 4. Wait for MongoDB to be ready
await waitForMongoDB();
// 5. Import patches data
console.log('📦 Importing patches data...');
await importPatchesData();
// 6. Check existing matches count and import if needed
// 5. Check existing matches count and import if needed
console.log('Checking existing matches count...');
// Check for platform-specific collections or fall back to old format
@@ -129,11 +155,7 @@ async function setupDatabase() {
}
}
// 7. Fetch CDragon data for the current patch
console.log('🎮 Fetching CDragon data...');
await fetchCDragonData();
// 8. Run match collector to generate stats
// 7. Run match collector to generate stats (this also handles CDragon caching)
console.log('📊 Generating champion stats...');
await generateChampionStats();
@@ -148,48 +170,70 @@ async function setupDatabase() {
}
async function getLatestPatchVersion() {
const dataDir = path.join(__dirname, '../data');
// First, try to get patch from match data files (format: PATCH_PLATFORM.json or PATCH_matches.json)
if (fs.existsSync(dataDir)) {
const files = fs.readdirSync(dataDir);
const patches = new Set();
for (const file of files) {
// Match patterns like "16.8.1_EUW1.json" or "15.1_EUW1.json" or "15.1_matches.json" or "15.1.json"
// Patch version can be either "XX.Y" or "XX.Y.Z" format
const match = file.match(/^(\d+\.\d+(?:\.\d+)?)(?:_[A-Z0-9]+)?(?:_matches)?\.json$/);
if (match) {
// Normalize to "XX.Y" format (strip the third part if present)
const patch = match[1].split('.').slice(0, 2).join('.');
patches.add(patch);
}
}
if (patches.size > 0) {
// Sort patches and return the latest (highest version number)
const sortedPatches = Array.from(patches).sort((a, b) => {
const [aMajor, aMinor] = a.split('.').map(Number);
const [bMajor, bMinor] = b.split('.').map(Number);
if (aMajor !== bMajor) return bMajor - aMajor;
return bMinor - aMinor;
});
return sortedPatches[0];
}
}
// Fallback: try to get from database collections
try {
const filePath = path.join(__dirname, '../data/patches.json');
if(!fs.existsSync(filePath)) {
return null;
const client = new MongoClient(getMongoUri());
await client.connect();
const db = client.db('matches');
const collections = await db.listCollections().toArray();
const collectionNames = collections.map(c => c.name);
const patches = new Set();
for (const name of collectionNames) {
// Collection names are either "patch_platform" or just "patch"
const patch = name.split('_')[0];
if (patch && /^\d+\.\d+$/.test(patch)) {
patches.add(patch);
}
}
const fileContent = fs.readFileSync(filePath, 'utf8');
await client.close();
// Check if it's line-delimited JSON or array format
let patchesData;
if (fileContent.trim().startsWith('[')) {
// Array format
patchesData = JSON.parse(fileContent);
if (!Array.isArray(patchesData)) {
throw new Error('Patches data should be an array');
if (patches.size > 0) {
const sortedPatches = Array.from(patches).sort((a, b) => {
const [aMajor, aMinor] = a.split('.').map(Number);
const [bMajor, bMinor] = b.split('.').map(Number);
if (aMajor !== bMajor) return bMajor - aMajor;
return bMinor - aMinor;
});
return sortedPatches[0];
}
} else {
// Line-delimited JSON format
patchesData = fileContent.split('\n')
.filter(line => line.trim() !== '')
.map(line => JSON.parse(line));
}
// Convert dates to Date objects for proper sorting
patchesData = patchesData.map(patch => ({
...patch,
date: new Date(patch.date.$date || patch.date)
}));
// Sort patches by date (newest first) and get the latest
const sortedPatches = patchesData.sort((a, b) => b.date - a.date);
const latestPatch = sortedPatches[0];
if (!latestPatch || !latestPatch.patch) {
throw new Error('Could not find patch version in patches data');
}
return latestPatch.patch;
} catch (error) {
console.error('❌ Failed to get latest patch version:', error);
throw error;
// Database not available, continue with other methods
}
return null;
}
async function downloadAndExtractSnapshot() {
@@ -256,83 +300,25 @@ async function waitForMongoDB() {
}
}
async function importPatchesData() {
const client = new MongoClient(getMongoUri());
await client.connect();
try {
const filePath = path.join(__dirname, '../data/patches.json');
const fileContent = fs.readFileSync(filePath, 'utf8');
// Check if it's line-delimited JSON or array format
let patchesData;
if (fileContent.trim().startsWith('[')) {
// Array format
patchesData = JSON.parse(fileContent);
if (!Array.isArray(patchesData)) {
throw new Error('Patches data should be an array');
}
} else {
// Line-delimited JSON format
patchesData = fileContent.split('\n')
.filter(line => line.trim() !== '')
.map(line => {
const doc = JSON.parse(line);
return convertMongoExtendedJson(doc);
});
}
// Convert any extended JSON in array format too
if (Array.isArray(patchesData)) {
patchesData = patchesData.map(doc => convertMongoExtendedJson(doc));
}
// Sort patches by date (newest first)
patchesData.sort((a, b) => {
const dateA = new Date(a.date || a.date.$date || 0);
const dateB = new Date(b.date || b.date.$date || 0);
return dateB - dateA; // Descending order (newest first)
});
const db = client.db('patches');
const collection = db.collection('patches');
// Clear existing data
await collection.deleteMany({});
// Insert sorted data
const result = await collection.insertMany(patchesData);
console.log(`✅ Imported ${result.insertedCount} patches (sorted by date)`);
// Create index
await collection.createIndex({ date: -1 });
console.log('✅ Created patches index');
} catch (error) {
console.error('❌ Failed to import patches:', error);
throw error;
} finally {
await client.close();
}
}
async function importMatchesData(patchVersion, foundPlatformFiles = []) {
const dataDir = path.join(__dirname, '../data');
const files = fs.readdirSync(dataDir);
try {
// If platform-specific files were found, import each one
if (foundPlatformFiles.length > 0) {
for (const platform of foundPlatformFiles) {
// Try both formats: patch_PLATFORM.json and patch_PLATFORM_matches.json
let matchesFile = path.join(dataDir, `${patchVersion}_${platform}.json`);
// 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;
});
if (matchFile) {
const matchesFile = path.join(dataDir, matchFile);
const collectionName = `${patchVersion}_${platform}`;
// Fallback to _matches.json suffix if the direct file doesn't exist
if (!fs.existsSync(matchesFile)) {
matchesFile = path.join(dataDir, `${patchVersion}_${platform}_matches.json`);
}
if (fs.existsSync(matchesFile)) {
console.log(`📥 Importing matches for ${platform}...`);
execSync(
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
@@ -348,15 +334,16 @@ async function importMatchesData(patchVersion, foundPlatformFiles = []) {
}
} else {
// Fall back to old format (single file without platform suffix)
// Try both formats: patch_matches.json and patch.json
let matchesFile = path.join(dataDir, `${patchVersion}_matches.json`);
// Find any match file for this patch
const matchFile = files.find(f => {
const match = f.match(/^(\d+\.\d+(?:\.\d+)?)(?:_matches)?\.json$/);
const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
return match && patchFromName === patchVersion;
});
if (matchFile) {
const matchesFile = path.join(dataDir, matchFile);
const collectionName = patchVersion;
if (!fs.existsSync(matchesFile)) {
matchesFile = path.join(dataDir, `${patchVersion}.json`);
}
if (fs.existsSync(matchesFile)) {
execSync(
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
{
@@ -404,24 +391,6 @@ async function generateChampionStats() {
}
}
async function fetchCDragonData() {
try {
console.log('🔄 Running CDragon fetcher...');
// Run the fetch-cdragon script
const fetchCDragonPath = path.join(__dirname, 'fetch-cdragon.js');
execSync(`node ${fetchCDragonPath}`, {
stdio: 'inherit',
cwd: path.join(__dirname, '..')
});
console.log('✅ CDragon data fetched');
} catch (error) {
console.error('❌ Failed to fetch CDragon data:', error);
throw error;
}
}
async function getMatchCount(patchVersion, platform = null) {
const client = new MongoClient(getMongoUri());
await client.connect();
@@ -582,9 +551,6 @@ if (import.meta.url === `file://${process.argv[1]}`) {
case 'generate-stats':
generateChampionStats().catch(console.error);
break;
case 'import-patches':
importPatchesData().catch(console.error);
break;
case 'match-count':
if (args[1]) {
getMatchCount(args[1]).then(count => console.log(`Match count: ${count}`)).catch(console.error);
@@ -605,7 +571,6 @@ if (import.meta.url === `file://${process.argv[1]}`) {
export {
setupDatabase,
importPatchesData,
importMatchesData,
generateChampionStats,
checkDatabaseStatus,

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

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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">
import type { Item } from '~/types/cdragon'
interface ItemData {
data: number
count: number

View File

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

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import type { Item } from '~/types/cdragon'
import type { ItemTag } from 'match_collector'
interface Props {
item: Item
size?: number
@@ -121,7 +124,6 @@ const itemIconPath = computed(() => CDRAGON_BASE + mapPath(props.item.iconPath))
border-radius: 4px;
border: 1px solid var(--color-on-surface);
overflow: hidden;
cursor: help;
position: relative;
}

View File

@@ -1,5 +1,14 @@
<script setup lang="ts">
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
import {
parseItemDescription,
type TextSegment,
type ItemEffect,
type ParsedDescription
} from 'dragon-item-parser'
import type { Item } from '~/types/cdragon'
import type { ItemTag } from 'match_collector'
interface Props {
item: Item | null
@@ -42,78 +51,184 @@ function getTagClass(tag: ItemTag): string {
return `tag-${tag}`
}
// Parse description and convert to styled HTML
const formatDescription = (description?: string) => {
if (!description) return ''
// Parse the item description once
const parsedDescription = computed<ParsedDescription | null>(() => {
if (!props.item?.description) return null
return parseItemDescription(props.item.description)
})
// Replace <br> and other structural tags
const html = description
.replace(/<br\s*\/?>/gi, '<br>')
.replace(/<br><br><br>/gi, '') // Remove triple breaks
.replace(/<br><br>/gi, '') // Remove double breaks
.replace(/<mainText>/gi, '')
.replace(/<\/mainText>/gi, '')
.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()
// Format stats for display
const formattedStats = computed(() => {
const stats = parsedDescription.value?.stats
if (!stats) return []
return html
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 formattedDescription = computed(() =>
props.item ? formatDescription(props.item.description) : ''
)
const percentageStats = [
'attackSpeed',
'criticalStrikeChance',
'criticalStrikeDamage',
'lifeSteal',
'omnivamp',
'physicalVamp',
'spellVamp',
'baseManaRegen',
'baseHealthRegen',
'armorPenetration',
'magicPenetration',
'healAndShieldPower',
'tenacity',
'slowResist'
]
return Object.entries(stats)
.filter(([_, value]) => value !== undefined)
.map(([key, value]) => ({
key,
label: statLabels[key] || key,
value: percentageStats.includes(key) ? `${value}%` : value
}))
})
// Get CSS class for text segment type
function getSegmentClass(segment: TextSegment): string {
const classMap: Record<TextSegment['type'], string> = {
text: '',
highlight: 'stat-highlight',
passive: 'tag-passive',
active: 'tag-active',
keyword: 'tag-keyword',
keywordMajor: 'tag-keyword-major',
keywordStealth: 'tag-keyword-stealth',
status: 'tag-status',
speed: 'tag-speed',
scaleMana: 'tag-scaling',
scaleHealth: 'tag-scaling',
scaleAP: 'tag-scaling',
scaleAD: 'tag-scaling',
scaleArmor: 'tag-scaling',
scaleMR: 'tag-scaling',
scaleLevel: 'tag-scaling',
scaleBonusHealth: 'tag-scaling',
scaleBonusMana: 'tag-scaling',
scaleMaxHealth: 'tag-scaling',
spellName: 'tag-spellname',
unique: 'tag-unique',
rarityMythic: 'tag-rarity-mythic',
rarityLegendary: 'tag-rarity-legendary',
rarityGeneric: 'tag-rarity-generic',
magicDamage: 'tag-magic-damage',
physicalDamage: 'tag-physical-damage',
trueDamage: 'tag-true-damage',
healing: 'tag-healing',
shield: 'tag-shield',
attention: 'stat-highlight',
onHit: 'tag-onhit',
color: ''
}
return classMap[segment.type] || ''
}
// Get effect type label
function getEffectTypeLabel(type: ItemEffect['type']): string {
const labels: Record<ItemEffect['type'], string> = {
passive: 'Passive',
active: 'Active',
unique: 'Unique',
mythic: 'Mythic',
legendary: 'Legendary',
epic: 'Epic'
}
return labels[type] || ''
}
// Get effect type class
function getEffectTypeClass(type: ItemEffect['type']): string {
const classMap: Record<ItemEffect['type'], string> = {
passive: 'effect-passive',
active: 'effect-active',
unique: 'effect-unique',
mythic: 'effect-mythic',
legendary: 'effect-legendary',
epic: 'effect-epic'
}
return classMap[type] || ''
}
// Render text segments to HTML
function renderSegments(segments: TextSegment[]): string {
return segments
.map(segment => {
const cssClass = getSegmentClass(segment)
if (segment.type === 'color' && segment.color) {
return `<span style="color: ${segment.color}">${segment.content}</span>`
}
if (cssClass) {
return `<span class="${cssClass}">${segment.content}</span>`
}
return segment.content
})
.join('')
}
// Render effect description
function renderEffect(effect: ItemEffect): string {
return renderSegments(effect.description)
}
// Render rules
function renderRules(segments: TextSegment[] | undefined): string {
if (!segments) return ''
return renderSegments(segments)
}
// Render flavor text
function renderFlavorText(segments: TextSegment[] | undefined): string {
if (!segments) return ''
return renderSegments(segments)
}
// Check if we should show the effects section
const hasEffects = computed(() => {
return parsedDescription.value?.effects?.length
})
// Check if we should show rules
const hasRules = computed(() => {
return parsedDescription.value?.rules?.length
})
// Check if we should show flavor text
const hasFlavorText = computed(() => {
return parsedDescription.value?.flavorText?.length
})
</script>
<template>
@@ -128,6 +243,7 @@ const formattedDescription = computed(() =>
}"
@mouseenter.stop
>
<!-- Header -->
<div class="tooltip-header">
<NuxtImg class="tooltip-icon" :src="CDRAGON_BASE + mapPath(item.iconPath)" />
<div class="tooltip-title">
@@ -136,12 +252,14 @@ const formattedDescription = computed(() =>
</div>
</div>
<!-- Plaintext (brief description) -->
<div v-if="item.plaintext" class="tooltip-plaintext">
{{ item.plaintext }}
</div>
<!-- Item Tags -->
<div v-if="tags && tags.length > 0" class="tooltip-tags">
<div v-if="tags && tags.length > 0" class="tooltip-tags-section">
<div class="tooltip-tags">
<span
v-for="tag in tags"
:key="tag"
@@ -151,15 +269,47 @@ const formattedDescription = computed(() =>
{{ getTagLabel(tag) }}
</span>
</div>
</div>
<!-- eslint-disable vue/no-v-html -->
<!-- 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-if="formattedDescription"
class="tooltip-description"
v-html="formattedDescription"
></div>
v-for="(effect, index) in parsedDescription?.effects"
:key="index"
:class="['effect-item', getEffectTypeClass(effect.type)]"
>
<div class="effect-header">
<span class="effect-type">{{ getEffectTypeLabel(effect.type) }}</span>
<span v-if="effect.name" class="effect-name">{{ effect.name }}</span>
</div>
<!-- eslint-disable vue/no-v-html -->
<div class="effect-description" v-html="renderEffect(effect)"></div>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
<!-- Rules Section -->
<div v-if="hasRules" class="tooltip-rules">
<!-- eslint-disable vue/no-v-html -->
<div v-html="renderRules(parsedDescription?.rules)"></div>
<!-- eslint-enable vue/no-v-html -->
</div>
<!-- Flavor Text -->
<div v-if="hasFlavorText" class="tooltip-flavor">
<!-- eslint-disable vue/no-v-html -->
<div v-html="renderFlavorText(parsedDescription?.flavorText)"></div>
<!-- eslint-enable vue/no-v-html -->
</div>
</div>
</Transition>
</Teleport>
</template>
@@ -168,18 +318,23 @@ const formattedDescription = computed(() =>
.item-tooltip {
position: fixed;
z-index: 1000;
background: var(--color-surface);
border: 1px solid var(--color-on-surface);
background: var(--tooltip-bg);
border: 1px solid var(--tooltip-border);
border-radius: 8px;
padding: 12px;
max-width: 300px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
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;
}
@@ -187,212 +342,56 @@ const formattedDescription = computed(() =>
width: 48px;
height: 48px;
border-radius: 4px;
border: 1px solid var(--color-on-surface);
border: 2px solid var(--tooltip-border);
flex-shrink: 0;
background: rgba(0, 0, 0, 0.3);
}
.tooltip-title {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
}
.tooltip-title h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-on-surface);
color: var(--tooltip-text);
line-height: 1.2;
}
.tooltip-gold {
font-size: 0.85rem;
color: var(--color-gold);
margin-top: 4px;
font-weight: 500;
}
/* Plaintext */
.tooltip-plaintext {
font-size: 0.85rem;
color: var(--color-on-surface);
opacity: 0.8;
font-size: 0.8rem;
color: var(--tooltip-text-dim);
margin-bottom: 8px;
font-style: italic;
line-height: 1.4;
}
.tooltip-description {
font-size: 0.85rem;
color: var(--color-on-surface);
line-height: 1.5;
white-space: pre-wrap;
/* Item Tags Section */
.tooltip-tags-section {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--tooltip-header-border);
}
/* Stats section */
.tooltip-stats {
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-on-surface-dim);
}
.tooltip-stats :deep(.stat-highlight) {
color: #ffcc00;
font-weight: 600;
}
/* Tag styles */
.tooltip-description :deep(.tag-passive) {
color: #4a9eff;
font-weight: 600;
}
.tooltip-description :deep(.tag-active) {
color: #ff6b6b;
font-weight: 600;
}
.tooltip-description :deep(.tag-keyword) {
color: #ffd700;
font-weight: 600;
}
.tooltip-description :deep(.tag-keyword-major) {
color: #ff8c00;
font-weight: 700;
font-style: italic;
}
.tooltip-description :deep(.tag-keyword-stealth) {
color: #9b59b6;
font-weight: 600;
}
.tooltip-description :deep(.tag-status) {
color: #e74c3c;
font-weight: 500;
font-style: italic;
}
.tooltip-description :deep(.tag-speed),
.tooltip-description :deep(.tag-scale-mana),
.tooltip-description :deep(.tag-scale-health),
.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-weight: 500;
}
.tooltip-description :deep(.tag-healing),
.tooltip-description :deep(.tag-health) {
color: #2ecc71;
font-weight: 500;
}
.tooltip-description :deep(.tag-shield) {
color: #3498db;
font-weight: 500;
}
.tooltip-description :deep(.tag-magic-damage) {
color: #9b59b6;
font-weight: 500;
}
.tooltip-description :deep(.tag-physical-damage) {
color: #e67e22;
font-weight: 500;
}
.tooltip-description :deep(.tag-true-damage) {
color: #c0392b;
font-weight: 600;
}
.tooltip-description :deep(.tag-onhit) {
background: rgba(52, 152, 219, 0.1);
color: #3498db;
padding: 1px 4px;
border-radius: 3px;
font-size: 0.8rem;
font-weight: 500;
}
.tooltip-description :deep(.tag-spellname) {
color: #1abc9c;
font-weight: 600;
font-style: italic;
}
.tooltip-description :deep(.tag-unique) {
color: #f39c12;
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.5px;
}
.tooltip-description :deep(.tag-rarity-mythic) {
color: #ff5252;
font-weight: 700;
font-size: 0.85rem;
}
.tooltip-description :deep(.tag-rarity-legendary) {
color: #ff9800;
font-weight: 600;
font-size: 0.85rem;
}
.tooltip-description :deep(.tag-rarity-generic) {
color: #ffd54f;
font-weight: 500;
font-size: 0.85rem;
}
.tooltip-description :deep(.tag-rules) {
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);
}
/* Item tags in tooltip */
.tooltip-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--color-on-surface-dim);
}
.tooltip-tags .item-tag {
font-size: 0.7rem;
font-size: 0.65rem;
padding: 2px 6px;
border-radius: 3px;
font-weight: 600;
@@ -401,7 +400,6 @@ const formattedDescription = computed(() =>
white-space: nowrap;
}
/* Gold situation tags */
.tooltip-tags .tag-ahead {
background-color: #22c55e;
color: white;
@@ -412,7 +410,6 @@ const formattedDescription = computed(() =>
color: white;
}
/* Region tags */
.tooltip-tags .tag-region_euw {
background-color: #3b82f6;
color: white;
@@ -433,6 +430,257 @@ const formattedDescription = computed(() =>
color: white;
}
/* Stats Section */
.tooltip-stats {
margin-bottom: 10px;
}
.stat-row {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 3px;
}
.stat-value {
color: var(--tooltip-stat-value);
font-weight: 600;
font-size: 0.85rem;
min-width: 45px;
text-align: right;
}
.stat-label {
color: var(--tooltip-stat-label);
font-size: 0.85rem;
}
/* Effects Section */
.tooltip-effects {
padding-top: 8px;
border-top: 1px solid var(--tooltip-header-border);
}
.effect-item {
margin-bottom: 8px;
padding-left: 10px;
border-left: 3px solid var(--tooltip-border);
}
.effect-item:last-child {
margin-bottom: 0;
}
.effect-item.effect-passive {
border-left-color: var(--tooltip-effect-passive);
}
.effect-item.effect-active {
border-left-color: var(--tooltip-effect-active);
}
.effect-item.effect-unique {
border-left-color: var(--tooltip-effect-unique);
}
.effect-item.effect-mythic {
border-left-color: var(--tooltip-effect-mythic);
}
.effect-item.effect-legendary {
border-left-color: var(--tooltip-effect-legendary);
}
.effect-item.effect-epic {
border-left-color: var(--tooltip-effect-epic);
}
.effect-header {
display: flex;
align-items: baseline;
gap: 6px;
margin-bottom: 3px;
}
.effect-type {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 5px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.1);
}
.effect-passive .effect-type {
color: var(--tooltip-effect-passive);
}
.effect-active .effect-type {
color: var(--tooltip-effect-active);
}
.effect-unique .effect-type {
color: var(--tooltip-effect-unique);
}
.effect-mythic .effect-type {
color: var(--tooltip-effect-mythic);
}
.effect-legendary .effect-type {
color: var(--tooltip-effect-legendary);
}
.effect-epic .effect-type {
color: var(--tooltip-effect-epic);
}
.effect-name {
font-weight: 600;
color: var(--tooltip-text);
font-size: 0.85rem;
}
.effect-description {
font-size: 0.8rem;
color: var(--tooltip-text-dim);
line-height: 1.5;
}
/* Rules Section */
.tooltip-rules {
margin-top: 8px;
padding: 6px 8px;
background: rgba(0, 0, 0, 0.2);
border-left: 2px solid var(--tooltip-border);
font-size: 0.75rem;
font-style: italic;
color: var(--tooltip-text-dim);
}
/* Flavor Text */
.tooltip-flavor {
margin-top: 8px;
padding: 6px 8px;
background: rgba(74, 222, 255, 0.05);
border-left: 2px solid var(--tooltip-flavor);
font-size: 0.75rem;
font-style: italic;
color: var(--tooltip-flavor);
}
/* Text segment styles inside effects */
.effect-description :deep(.stat-highlight) {
color: var(--tooltip-highlight);
font-weight: 600;
}
.effect-description :deep(.tag-passive) {
color: var(--tooltip-effect-passive);
font-weight: 600;
}
.effect-description :deep(.tag-active) {
color: var(--tooltip-effect-active);
font-weight: 600;
}
.effect-description :deep(.tag-keyword) {
color: var(--tooltip-keyword);
font-weight: 600;
}
.effect-description :deep(.tag-keyword-major) {
color: var(--tooltip-keyword-major);
font-weight: 700;
font-style: italic;
}
.effect-description :deep(.tag-keyword-stealth) {
color: var(--tooltip-keyword-stealth);
font-weight: 600;
}
.effect-description :deep(.tag-status) {
color: var(--tooltip-status);
font-weight: 500;
font-style: italic;
}
.effect-description :deep(.tag-speed),
.effect-description :deep(.tag-scaling) {
color: var(--tooltip-scaling);
font-style: italic;
font-weight: 500;
}
.effect-description :deep(.tag-healing) {
color: var(--tooltip-healing);
font-weight: 500;
}
.effect-description :deep(.tag-shield) {
color: var(--tooltip-shield);
font-weight: 500;
}
.effect-description :deep(.tag-magic-damage) {
color: var(--tooltip-magic-damage);
font-weight: 500;
}
.effect-description :deep(.tag-physical-damage) {
color: var(--tooltip-physical-damage);
font-weight: 500;
}
.effect-description :deep(.tag-true-damage) {
color: var(--tooltip-true-damage);
font-weight: 600;
}
.effect-description :deep(.tag-onhit) {
background: rgba(52, 152, 219, 0.2);
color: var(--tooltip-onhit);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.75rem;
font-weight: 500;
}
.effect-description :deep(.tag-spellname) {
color: var(--tooltip-spellname);
font-weight: 600;
font-style: italic;
}
.effect-description :deep(.tag-unique) {
color: var(--tooltip-effect-unique);
font-weight: 700;
font-size: 0.75rem;
letter-spacing: 0.5px;
}
.effect-description :deep(.tag-rarity-mythic) {
color: var(--tooltip-effect-mythic);
font-weight: 700;
font-size: 0.85rem;
}
.effect-description :deep(.tag-rarity-legendary) {
color: var(--tooltip-effect-legendary);
font-weight: 600;
font-size: 0.85rem;
}
.effect-description :deep(.tag-rarity-generic) {
color: var(--tooltip-effect-epic);
font-weight: 500;
font-size: 0.85rem;
}
/* Transition */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease;

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
import { LinePath as svgdomarrowsLinePath } from 'svg-dom-arrows'
import type { ItemTree } from 'match_collector'
import type { Item } from '~/types/cdragon'
defineProps<{
tree: ItemTree
parentCount?: number

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
import type { Builds } from 'match_collector'
/**
* 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
* 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
* Transforms rune data into format needed for display components
@@ -5,7 +7,6 @@
export const useRuneStyles = () => {
const { data: perksData } = useFetch('/api/cdragon/perks')
const { data: stylesData } = useFetch('/api/cdragon/perkstyles')
console.log(stylesData.value)
const perks = reactive(new Map<number, Perk>())
watch(

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { readFile, existsSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { parseItemStats, type ItemStats } from 'dragon-item-parser'
const readFileAsync = promisify(readFile)
@@ -8,14 +9,19 @@ const readFileAsync = promisify(readFile)
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
// Cache directory - can be configured via environment variable
// Default to dev/cdragon for development
// In development, use dev/data/cdragon relative to project root
// In production, use /cdragon (shared volume)
const getCacheDir = () => {
if (process.env.CDRAGON_CACHE_DIR) {
return process.env.CDRAGON_CACHE_DIR
}
// Default to dev/cdragon relative to project root
// Check if we're in development mode (explicitly set)
if (process.env.NODE_ENV === 'development') {
return join(process.cwd(), '..', 'dev', 'data', 'cdragon')
}
// Default to /cdragon for production (Docker)
return '/cdragon'
}
/**
* Get the current patch from the patch.txt file or fallback to 'latest'
@@ -78,14 +84,20 @@ async function fetchFromCache<T>(
}
/**
* Get items data from cache
* Get items data from cache with parsed stats
*/
async function getItems(patch?: string): Promise<CDragonItem[]> {
return fetchFromCache<CDragonItem[]>(
async function getItems(patch?: string): Promise<CDragonItemWithStats[]> {
const items = await fetchFromCache<CDragonItem[]>(
'items.json',
'plugins/rcp-be-lol-game-data/global/default/v1/items.json',
{ patch }
)
// Parse stats from description for each item
return items.map(item => ({
...item,
stats: parseItemStats(item.description || '')
}))
}
/**
@@ -148,6 +160,10 @@ interface CDragonItem {
}
}
interface CDragonItemWithStats extends CDragonItem {
stats: ItemStats
}
interface CDragonPerk {
id: number
name: string
@@ -197,6 +213,7 @@ export {
export type {
CDragonItem,
CDragonItemWithStats,
CDragonPerk,
CDragonPerkStyle,
CDragonPerkStyles,

View File

@@ -19,11 +19,38 @@ async function connectToDatabase() {
return client
}
async function fetchLatestPatch(client: MongoClient) {
const database = client.db('patches')
const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
return latestPatch!.patch as string
/**
* Get the latest patch from existing match collections in the database.
* Collections are named like "15.1_EUW1", "15.2_NA1", etc.
*/
async function fetchLatestPatch(client: MongoClient): Promise<string> {
const matchesDb = client.db('matches')
const collections = await matchesDb.listCollections().toArray()
const collectionNames = collections.map(c => c.name)
// Extract unique patch versions from collection names
const patches = new Set<string>()
for (const name of collectionNames) {
// Collection names are either "patch_platform" or just "patch"
const patch = name.split('_')[0]
if (patch && /^\d+\.\d+$/.test(patch)) {
patches.add(patch)
}
}
if (patches.size === 0) {
throw new Error('No patch collections found in database')
}
// Sort patches and return the latest (highest version number)
const sortedPatches = Array.from(patches).sort((a, b) => {
const [aMajor, aMinor] = a.split('.').map(Number)
const [bMajor, bMinor] = b.split('.').map(Number)
if (aMajor !== bMajor) return bMajor - aMajor
return bMinor - aMinor
})
return sortedPatches[0]
}
/**

View File

@@ -1,99 +0,0 @@
declare global {
/**
* Item tags derived from purchase patterns
*/
type ItemTag = 'ahead' | 'behind' | 'region_euw' | 'region_eun' | 'region_na' | 'region_kr'
/**
* Represents an item in the build tree
*/
interface ItemTree {
count: number
data: number
children: ItemTree[]
tags: ItemTag[]
}
/**
* 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,10 +1,5 @@
declare global {
type ChampionsResponse = {
data: Ref<Array<Champion>>
}
type ChampionResponse = {
data: Ref<ChampionFull>
}
import type { ItemStats } from 'dragon-item-parser'
type Champion = {
name: string
alias: string
@@ -16,9 +11,6 @@ declare global {
squarePortraitPath: string
title: string
}
type ItemResponse = {
data: Ref<Array<Item>>
}
type Item = {
id: number
iconPath: string
@@ -29,22 +21,19 @@ declare global {
from?: number[]
price?: number
priceTotal?: number
stats?: ItemStats
}
type SummonerSpell = {
id: number
iconPath: string
name: string
}
type PerksResponse = {
data: Ref<Array<Perk>>
}
type Perk = {
id: number
name: string
iconPath: string
}
type PerkStylesResponse = {
data: Ref<{ styles: Array<PerkStyle> }>
shortDesc?: string
longDesc?: string
}
type PerkStyle = {
id: number
@@ -52,6 +41,5 @@ declare global {
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)
* 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)
console.log(lateGameItems)
// Sort by count descending
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 = {
count: tree.count,
data: tree.data,
children: []
children: [],
tags: tree.tags,
boughtWhen: tree.boughtWhen,
platformCount: tree.platformCount
}
// If we haven't reached maxDepth, include children

View File

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

View File

@@ -1,8 +1,33 @@
FROM node:lts-alpine
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
# This Dockerfile should be built from the project root directory:
# docker build -f match_collector/Dockerfile -t buildpath-match_collector .
FROM node:current-alpine AS build
RUN mkdir -p /home/node/app && chown -R node:node /home/node/app
WORKDIR /home/node/app
USER node
COPY --chown=node:node package*.json ./
# Copy and build dragon-item-parser first
COPY --chown=node:node dragon-item-parser/package*.json ./dragon-item-parser/
COPY --chown=node:node dragon-item-parser/tsconfig.json ./dragon-item-parser/
COPY --chown=node:node dragon-item-parser/src ./dragon-item-parser/src
WORKDIR /home/node/app/dragon-item-parser
RUN npm install && npm run build
# Build match_collector
WORKDIR /home/node/app
COPY --chown=node:node match_collector/package*.json ./match_collector/
WORKDIR /home/node/app/match_collector
RUN npm install
COPY --chown=node:node . .
CMD ["/bin/sh", "-c", "node --import=tsx src/index.ts; sleep 20h"]
COPY --chown=node:node match_collector/. .
FROM node:current-alpine
# Install su-exec for dropping privileges
RUN apk add --no-cache su-exec
RUN mkdir -p /home/node/app && chown -R node:node /home/node/app
WORKDIR /home/node/app
COPY --from=build --chown=node:node /home/node/app/match_collector/node_modules ./node_modules
COPY --from=build --chown=node:node /home/node/app/match_collector/. .
COPY --chown=node:node match_collector/docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Run entrypoint as root to fix permissions, then drop to node user
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["/bin/sh", "-c", "node --import=tsx src/index.ts; sleep 12h"]

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -6,10 +6,21 @@ type Match = {
}
info: {
endOfGameResult: string
frameInterval: number
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
}

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,8 +1,6 @@
import { MongoClient } from 'mongodb'
import {
ItemTree,
GoldAdvantageTag,
PlatformCounts,
treeInit,
treeMerge,
treeCutBranches,
@@ -12,10 +10,30 @@ import {
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'
function sameArrays(array1: Array<number>, array2: Array<number>) {
// Type aliases for internal use
type Builds = InternalBuild[]
type Build = InternalBuild
type BuildWithStartItems = InternalBuildWithStartItems
type LaneData = InternalLaneData
type ChampionData = InternalChampionData
function sameArrays(array1: number[], array2: number[]) {
if (array1.length != array2.length) return false
for (const e of array1) {
if (!array2.includes(e)) return false
@@ -48,72 +66,6 @@ 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 }>
// Region distribution for this lane (used for tag derivation)
regionDistribution?: PlatformCounts
}
type ChampionData = {
champion: Champion
winningMatches: number
losingMatches: number
lanes: Array<LaneData>
}
// Helper function to create rune configuration from participant
function createRuneConfiguration(participant: Participant): Rune {
const primaryStyle = participant.perks.styles[0].style
@@ -225,7 +177,7 @@ function handleMatchBuilds(
participantIndex: number,
builds: Builds,
platform?: string
) {
): { build: Build; startItemId: number | undefined } {
const timeline: Timeline = match.timeline
// Find or create the build for this participant's rune configuration
@@ -233,6 +185,7 @@ function handleMatchBuilds(
build.count += 1
const items: Array<{ itemId: number; goldAdvantage: GoldAdvantageTag; platform?: string }> = []
let startItemId: number | undefined = undefined
for (const frame of timeline.info.frames) {
for (const event of frame.events) {
if (event.participantId != participantIndex) continue
@@ -266,17 +219,10 @@ function handleMatchBuilds(
}
if (event.type != 'ITEM_PURCHASED') continue
// Handle boots upgrades
if (
itemInfo.requiredBuffCurrencyName == 'Feats_NoxianBootPurchaseBuff' ||
itemInfo.requiredBuffCurrencyName == 'Feats_SpecialQuestBootBuff'
) {
continue
}
// Handle boots differently
if (itemInfo.categories.includes('Boots')) {
if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) {
// Ignore basic boots, only count Tier 2 boots
if (event.itemId != 1001) {
// Check for bootsFirst
if (items.length < 2) {
build.bootsFirstCount += 1
@@ -315,7 +261,11 @@ function handleMatchBuilds(
// This tree includes start item as the root, then branching paths
if (items.length > 0) {
treeMerge(build.items, items)
// The first item is the starter item
startItemId = items[0].itemId
}
return { build, startItemId }
}
function handleMatch(match: Match, champions: Map<number, ChampionData>, platform?: string) {
@@ -411,7 +361,24 @@ function handleMatch(match: Match, champions: Map<number, ChampionData>, platfor
}
// Items and runes (builds)
handleMatchBuilds(match, participant, participantIndex, lane.builds, platform)
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)
}
}
}
@@ -473,7 +440,8 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS
startItems,
suppItems: build.suppItems,
boots: build.boots,
pickrate: build.pickrate
pickrate: build.pickrate,
firstBacksRaw: build.firstBacksRaw
}
]
} else {
@@ -481,15 +449,44 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS
console.log(`Warning: for champion ${championName}, start item splits build variant.`)
const builds = []
for (const c of build.items.children) {
// Calculate the ratio for proportional distribution
const ratio = c.count / build.count
// Proportionally distribute boots counts
const scaledBoots = build.boots.map(b => ({
data: b.data,
count: Math.round(b.count * ratio)
}))
// Proportionally distribute suppItems counts
const scaledSuppItems = build.suppItems.map(s => ({
data: s.data,
count: Math.round(s.count * ratio)
}))
// Proportionally distribute bootsFirstCount
const scaledBootsFirstCount = Math.round(build.bootsFirstCount * ratio)
// Filter firstBacksRaw by starter item
let filteredFirstBacksRaw: FirstBackData[] | undefined
if (build.firstBacksRaw && build.firstBacksRaw.length > 0) {
// Filter by the starter item ID that was tracked when storing firstBacksRaw
filteredFirstBacksRaw = build.firstBacksRaw.filter(fb => fb.startItemId === c.data)
if (filteredFirstBacksRaw.length === 0) {
filteredFirstBacksRaw = undefined
}
}
builds.push({
runeKeystone: build.runeKeystone,
runes: build.runes,
items: c,
bootsFirstCount: build.bootsFirstCount,
bootsFirstCount: scaledBootsFirstCount,
count: c.count,
startItems: [{ data: c.data!, count: c.count }],
suppItems: build.suppItems,
boots: build.boots
suppItems: scaledSuppItems,
boots: scaledBoots,
firstBacksRaw: filteredFirstBacksRaw
})
c.data = undefined
}
@@ -573,6 +570,14 @@ function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems
const runes = Array.from(runesMap.values())
runes.sort((a, b) => b.count - a.count)
// Merge first backs raw data
const firstBacksRaw: FirstBackData[] = []
for (const build of allSimilarBuilds) {
if (build.firstBacksRaw) {
firstBacksRaw.push(...build.firstBacksRaw)
}
}
merged.push({
runeKeystone: runes[0].selections[0],
runes: runes,
@@ -581,7 +586,8 @@ function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems
count: totalCount,
startItems: mergeItemCounts(allSimilarBuilds, b => b.startItems),
suppItems: mergeItemCounts(allSimilarBuilds, b => b.suppItems),
boots: mergeItemCounts(allSimilarBuilds, b => b.boots)
boots: mergeItemCounts(allSimilarBuilds, b => b.boots),
firstBacksRaw: firstBacksRaw.length > 0 ? firstBacksRaw : undefined
})
}
@@ -659,6 +665,19 @@ async function finalizeChampionStats(champion: ChampionData, totalMatches: numbe
// all along.
lane.builds = mergeBuildsForRunes(lane.builds as BuildWithStartItems[])
cleanupLaneBuilds(lane)
// Process first backs at build level - group by item set
for (const build of lane.builds) {
if (build.firstBacksRaw && build.firstBacksRaw.length > 0) {
build.firstBacks = groupFirstBacksByItemSet(build.firstBacksRaw)
// Keep only top 7 groups
if (build.firstBacks!.length > 7) {
build.firstBacks = build.firstBacks!.slice(0, 7)
}
// Clean up raw data to save space
delete build.firstBacksRaw
}
}
}
for (const lane of champion.lanes) {
@@ -723,6 +742,9 @@ async function makeChampionsStats(client: MongoClient, patch: string, platforms:
itemDict.set(item.id, item)
}
// Initialize first back item dictionary
await initFirstBackItemDict()
const list = await championList()
console.log('Generating stats for ' + list.length + ' champions')

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

@@ -6,9 +6,56 @@ 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') {
@@ -17,13 +64,10 @@ async function main() {
return
}
// Original production mode
// Production mode: collect matches and organize by their gameVersion
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
)
console.log('Connected to database')
console.log('Using RIOT_API_KEY: ' + api_key)
if (api_key != null && api_key != undefined && api_key != '') {
@@ -31,20 +75,22 @@ async function main() {
for (const [platform, region] of Object.entries(PLATFORMS)) {
console.log(`\n=== Processing platform: ${platform} (region: ${region}) ===`)
const alreadySeenGameList = await alreadySeenGames(client, latestPatch, platform)
console.log(
'We already have ' + alreadySeenGameList.length + ' matches for this patch/platform !'
)
// 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, latestPatchTime, region)
const challengerGameList = await summonerGameList(puuid, startTime, region)
for (const game of challengerGameList) {
if (!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
gameList.push(game)
@@ -64,15 +110,26 @@ async function main() {
const gameMatch = await match(game, matchRegion)
const gameTimeline = await matchTimeline(game, matchRegion)
gameMatch.timeline = gameTimeline
await saveMatch(client, gameMatch, latestPatch, platform)
// Extract patch from gameVersion and save to appropriate collection
const patch = extractPatchFromGameVersion(gameMatch.info.gameVersion)
await saveMatch(client, gameMatch, patch, platform)
i++
}
}
}
console.log('Generating stats...')
// 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()
}
@@ -117,13 +174,6 @@ async function connectToDatabase() {
return client
}
async function fetchLatestPatchDate(client: MongoClient) {
const database = client.db('patches')
const patches = database.collection('patches')
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
return [latestPatch!.patch, Math.floor(latestPatch!.date.valueOf() / 1000)]
}
async function fetchChallengerLeague(platform: string) {
const queue = 'RANKED_SOLO_5x5'
const endpoint = `/lol/league/v4/challengerleagues/by-queue/${queue}`
@@ -138,7 +188,7 @@ async function fetchChallengerLeague(platform: string) {
return challengerLeague
}
async function summonerGameList(puuid: string, startTime: string, region: string) {
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}`
@@ -174,18 +224,31 @@ async function matchTimeline(matchId: string, region: string) {
return timeline
}
async function alreadySeenGames(client: MongoClient, latestPatch: string, platform: string) {
const database = client.db('matches')
const collectionName = `${latestPatch}_${platform}`
const matches = database.collection(collectionName)
/**
* 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)
const alreadySeen = await matches.distinct('metadata.matchId')
return alreadySeen
// 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)
}
async function saveMatch(client: MongoClient, match: Match, latestPatch: string, platform: string) {
return allSeen
}
async function saveMatch(client: MongoClient, match: Match, patch: string, platform: string) {
const database = client.db('matches')
const collectionName = `${latestPatch}_${platform}`
const collectionName = `${patch}_${platform}`
const matches = database.collection(collectionName)
await matches.insertOne(match)
}
@@ -198,7 +261,16 @@ async function runWithPreloadedData() {
const client = await connectToDatabase()
try {
const [latestPatch] = await fetchLatestPatchDate(client)
// 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)
@@ -235,6 +307,9 @@ async function runWithPreloadedData() {
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) {

View File

@@ -6,31 +6,7 @@ import {
} from './platform'
import type { PlatformCounts } from './platform'
type GoldAdvantageTag = 'ahead' | 'behind' | 'even'
// Item tags that can be derived from purchase patterns
type ItemTag = 'ahead' | 'behind' | 'region_euw' | 'region_eun' | 'region_na' | 'region_kr'
type ItemTree = {
data: number | undefined
count: number
children: Array<ItemTree>
// Gold advantage tracking
boughtWhen: {
aheadCount: number
behindCount: number
evenCount: number
meanGold: number
}
// Platform tracking
platformCount: PlatformCounts
// Derived tags for display
tags: Array<ItemTag>
}
import type { GoldAdvantageTag, ItemTag, ItemTree } from './types'
function treeInit(): ItemTree {
return {
@@ -288,12 +264,8 @@ function deriveTags(node: ItemTree, expectedRegionDistribution?: PlatformCounts)
const totalExpected = REGION_KEYS.reduce((sum, key) => sum + expectedRegionDistribution[key], 0)
if (totalExpected > 0) {
// Tag if the item is significantly more popular in a region (>= 1.5x expected rate)
// and has a minimum absolute percentage (>= 10%)
const SIGNIFICANCE_THRESHOLD = 1.5
const MINIMUM_PCT = 0.1
// Loop through all regions to derive tags
// 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' },
@@ -301,12 +273,23 @@ function deriveTags(node: ItemTree, expectedRegionDistribution?: PlatformCounts)
{ key: 'kr', tag: 'region_kr' }
]
for (const { key, tag } of regionTags) {
// Calculate normalized values (actual/expected ratio) for each region
const normalizedValues = regionTags.map(({ key, tag }) => {
const expectedPct = expectedRegionDistribution[key] / totalExpected
const actualPct = node.platformCount[key] / totalRegionCount
const normalizedValue = expectedPct > 0 ? actualPct / expectedPct : 0
return { tag, value: normalizedValue }
})
if (actualPct >= expectedPct * SIGNIFICANCE_THRESHOLD && actualPct >= MINIMUM_PCT) {
const totalNormalized = normalizedValues.reduce((sum, { value }) => sum + value, 0)
// Tag the region if it accounts for >= 60% of the normalized distribution
if (totalNormalized > 0) {
for (const { tag, value } of normalizedValues) {
if (value / totalNormalized >= 0.6) {
tags.push(tag)
break // Only tag the most dominant region
}
}
}
}
@@ -330,7 +313,6 @@ function treeDeriveTags(itemtree: ItemTree, expectedRegionDistribution?: Platfor
}
export {
ItemTree,
PlatformCounts,
GoldAdvantageTag,
ItemTag,

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,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,6 +1,15 @@
{
"compilerOptions": {
"types": ["node"],
"declaration": true
}
"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"]
}
}