Compare commits
23 Commits
c976f340e6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
b2178fec85
|
|||
|
ee32060a7f
|
|||
|
d231ae7c38
|
|||
|
5c83e45d2a
|
|||
|
50c0646a93
|
|||
|
8263dc1c93
|
|||
|
c506bad739
|
|||
|
a0e2915c3d
|
|||
|
c7d0d929be
|
|||
|
8ee981b949
|
|||
|
686962b678
|
|||
|
a467046c55
|
|||
|
a0b3e49759
|
|||
| 7051ace13f | |||
|
e1ab81854a
|
|||
|
db2ca353c5
|
|||
|
7712abe3f0
|
|||
|
af51d61e0c
|
|||
|
4a80540243
|
|||
|
0b2d00ad0b
|
|||
| 0e0a12513e | |||
| e82ad73de1 | |||
|
a98e3c6589
|
32
.dockerignore
Normal file
32
.dockerignore
Normal 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
|
||||
49
.gitea/workflows/dragon-item-parser.yml
Normal file
49
.gitea/workflows/dragon-item-parser.yml
Normal 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
|
||||
@@ -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
|
||||
|
||||
12
README.md
12
README.md
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
3
dragon-item-parser/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
@@ -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
53
dragon-item-parser/package.json
Normal file
53
dragon-item-parser/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
19
dragon-item-parser/src/index.ts
Normal file
19
dragon-item-parser/src/index.ts
Normal 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'
|
||||
787
dragon-item-parser/src/item.ts
Normal file
787
dragon-item-parser/src/item.ts
Normal 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)
|
||||
}
|
||||
492
dragon-item-parser/test/item.test.ts
Normal file
492
dragon-item-parser/test/item.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
17
dragon-item-parser/tsconfig.json
Normal file
17
dragon-item-parser/tsconfig.json
Normal 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"]
|
||||
}
|
||||
7
dragon-item-parser/vitest.config.ts
Normal file
7
dragon-item-parser/vitest.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['test/**/*.test.ts']
|
||||
}
|
||||
})
|
||||
@@ -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"]
|
||||
@@ -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 */
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
121
frontend/components/build/FirstBack.vue
Normal file
121
frontend/components/build/FirstBack.vue
Normal 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>
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Item } from '~/types/cdragon'
|
||||
|
||||
interface ItemData {
|
||||
data: number
|
||||
count: number
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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] || ''
|
||||
}
|
||||
|
||||
const formattedDescription = computed(() =>
|
||||
props.item ? formatDescription(props.item.description) : ''
|
||||
)
|
||||
// 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,20 +243,23 @@ const formattedDescription = computed(() =>
|
||||
}"
|
||||
@mouseenter.stop
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="tooltip-header">
|
||||
<NuxtImg class="tooltip-icon" :src="CDRAGON_BASE + mapPath(item.iconPath)" />
|
||||
<div class="tooltip-title">
|
||||
<h3>{{ item.name || 'Unknown Item' }}</h3>
|
||||
<span v-if="item.priceTotal" class="tooltip-gold"> {{ item.priceTotal }} Gold </span>
|
||||
<span v-if="item.priceTotal" class="tooltip-gold">{{ item.priceTotal }} Gold</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plaintext (brief description) -->
|
||||
<div v-if="item.plaintext" class="tooltip-plaintext">
|
||||
{{ item.plaintext }}
|
||||
</div>
|
||||
|
||||
<!-- Item Tags -->
|
||||
<div v-if="tags && tags.length > 0" class="tooltip-tags">
|
||||
<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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import type { LaneData } from 'match_collector'
|
||||
|
||||
defineProps<{
|
||||
championName?: string
|
||||
championLanes?: Array<LaneData>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
133
frontend/components/rune/RuneIcon.vue
Normal file
133
frontend/components/rune/RuneIcon.vue
Normal 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>
|
||||
366
frontend/components/rune/RuneTooltip.vue
Normal file
366
frontend/components/rune/RuneTooltip.vue
Normal 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>
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 }>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Builds } from 'match_collector'
|
||||
|
||||
/**
|
||||
* Composable for managing build data
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
13
frontend/eslint-polyfill.mjs
Normal file
13
frontend/eslint-polyfill.mjs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
9161
frontend/package-lock.json
generated
9161
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFile, existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { promisify } from 'util'
|
||||
import { parseItemStats, type ItemStats } from 'dragon-item-parser'
|
||||
|
||||
const readFileAsync = promisify(readFile)
|
||||
|
||||
@@ -8,13 +9,18 @@ const readFileAsync = promisify(readFile)
|
||||
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
|
||||
|
||||
// Cache directory - can be configured via environment variable
|
||||
// Default to dev/cdragon for development
|
||||
// In development, use dev/data/cdragon relative to project root
|
||||
// In production, use /cdragon (shared volume)
|
||||
const getCacheDir = () => {
|
||||
if (process.env.CDRAGON_CACHE_DIR) {
|
||||
return process.env.CDRAGON_CACHE_DIR
|
||||
}
|
||||
// Default to dev/cdragon relative to project root
|
||||
// Check if we're in development mode (explicitly set)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return join(process.cwd(), '..', 'dev', 'data', 'cdragon')
|
||||
}
|
||||
// Default to /cdragon for production (Docker)
|
||||
return '/cdragon'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,14 +84,20 @@ async function fetchFromCache<T>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Get items data from cache
|
||||
* Get items data from cache with parsed stats
|
||||
*/
|
||||
async function getItems(patch?: string): Promise<CDragonItem[]> {
|
||||
return fetchFromCache<CDragonItem[]>(
|
||||
async function getItems(patch?: string): Promise<CDragonItemWithStats[]> {
|
||||
const items = await fetchFromCache<CDragonItem[]>(
|
||||
'items.json',
|
||||
'plugins/rcp-be-lol-game-data/global/default/v1/items.json',
|
||||
{ patch }
|
||||
)
|
||||
|
||||
// Parse stats from description for each item
|
||||
return items.map(item => ({
|
||||
...item,
|
||||
stats: parseItemStats(item.description || '')
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,6 +160,10 @@ interface CDragonItem {
|
||||
}
|
||||
}
|
||||
|
||||
interface CDragonItemWithStats extends CDragonItem {
|
||||
stats: ItemStats
|
||||
}
|
||||
|
||||
interface CDragonPerk {
|
||||
id: number
|
||||
name: string
|
||||
@@ -197,6 +213,7 @@ export {
|
||||
|
||||
export type {
|
||||
CDragonItem,
|
||||
CDragonItemWithStats,
|
||||
CDragonPerk,
|
||||
CDragonPerkStyle,
|
||||
CDragonPerkStyles,
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {}
|
||||
@@ -1,25 +1,17 @@
|
||||
declare global {
|
||||
type ChampionsResponse = {
|
||||
data: Ref<Array<Champion>>
|
||||
}
|
||||
type ChampionResponse = {
|
||||
data: Ref<ChampionFull>
|
||||
}
|
||||
type Champion = {
|
||||
import type { ItemStats } from 'dragon-item-parser'
|
||||
|
||||
type Champion = {
|
||||
name: string
|
||||
alias: string
|
||||
squarePortraitPath: string
|
||||
}
|
||||
type ChampionFull = {
|
||||
}
|
||||
type ChampionFull = {
|
||||
name: string
|
||||
alias: string
|
||||
squarePortraitPath: string
|
||||
title: string
|
||||
}
|
||||
type ItemResponse = {
|
||||
data: Ref<Array<Item>>
|
||||
}
|
||||
type Item = {
|
||||
}
|
||||
type Item = {
|
||||
id: number
|
||||
iconPath: string
|
||||
name?: string
|
||||
@@ -29,29 +21,25 @@ declare global {
|
||||
from?: number[]
|
||||
price?: number
|
||||
priceTotal?: number
|
||||
}
|
||||
type SummonerSpell = {
|
||||
stats?: ItemStats
|
||||
}
|
||||
type SummonerSpell = {
|
||||
id: number
|
||||
iconPath: string
|
||||
name: string
|
||||
}
|
||||
type PerksResponse = {
|
||||
data: Ref<Array<Perk>>
|
||||
}
|
||||
type Perk = {
|
||||
}
|
||||
type Perk = {
|
||||
id: number
|
||||
name: string
|
||||
iconPath: string
|
||||
}
|
||||
type PerkStylesResponse = {
|
||||
data: Ref<{ styles: Array<PerkStyle> }>
|
||||
}
|
||||
type PerkStyle = {
|
||||
shortDesc?: string
|
||||
longDesc?: string
|
||||
}
|
||||
type PerkStyle = {
|
||||
id: number
|
||||
name: string
|
||||
iconPath: string
|
||||
slots: Array<{ perks: Array<number> }>
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
export type { Champion, ChampionFull, Item, SummonerSpell, Perk, PerkStyle }
|
||||
|
||||
@@ -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
|
||||
|
||||
1
match_collector/.gitignore
vendored
1
match_collector/.gitignore
vendored
@@ -1 +1,2 @@
|
||||
node_modules
|
||||
dist
|
||||
|
||||
@@ -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"]
|
||||
|
||||
9
match_collector/docker-entrypoint.sh
Normal file
9
match_collector/docker-entrypoint.sh
Normal 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 "$@"
|
||||
781
match_collector/package-lock.json
generated
781
match_collector/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
100
match_collector/src/cdragon_cache.ts
Normal file
100
match_collector/src/cdragon_cache.ts
Normal 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 }
|
||||
@@ -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')
|
||||
|
||||
|
||||
225
match_collector/src/first_back.ts
Normal file
225
match_collector/src/first_back.ts
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
return allSeen
|
||||
}
|
||||
|
||||
async function saveMatch(client: MongoClient, match: Match, latestPatch: string, platform: string) {
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
27
match_collector/src/lib.ts
Normal file
27
match_collector/src/lib.ts
Normal 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'
|
||||
257
match_collector/src/types.ts
Normal file
257
match_collector/src/types.ts
Normal 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
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
1
patch_detector/.gitignore
vendored
1
patch_detector/.gitignore
vendored
@@ -1 +0,0 @@
|
||||
node_modules
|
||||
@@ -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"
|
||||
@@ -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!')
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user