Compare commits
31 Commits
19a9226dac
...
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
|
|||
|
c976f340e6
|
|||
|
360be86c10
|
|||
| a5728a147f | |||
| 17024f91a8 | |||
|
2c774caf5f
|
|||
|
dae65c8fa2
|
|||
|
0f84b9a707
|
|||
|
b7435f0884
|
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
|
working-directory: ./match_collector
|
||||||
run: npm run format:check
|
run: npm run format:check
|
||||||
|
|
||||||
- name: Install dependencies for patch_detector
|
|
||||||
working-directory: ./patch_detector
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Lint patch_detector
|
|
||||||
working-directory: ./patch_detector
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
- name: Check formatting for patch_detector
|
|
||||||
working-directory: ./patch_detector
|
|
||||||
run: npm run format:check
|
|
||||||
|
|
||||||
build-and-push-images:
|
build-and-push-images:
|
||||||
needs: lint-and-format
|
needs: lint-and-format
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -72,25 +60,18 @@ jobs:
|
|||||||
- name: Build and push frontend docker image
|
- name: Build and push frontend docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./frontend
|
context: .
|
||||||
|
file: ./frontend/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.vhaudiquet.fr/vhaudiquet/lolstats-frontend:latest
|
git.vhaudiquet.fr/vhaudiquet/lolstats-frontend:latest
|
||||||
git.vhaudiquet.fr/vhaudiquet/lolstats-frontend:${{ github.sha }}
|
git.vhaudiquet.fr/vhaudiquet/lolstats-frontend:${{ github.sha }}
|
||||||
|
|
||||||
- name: Build and push patch_detector docker image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: ./patch_detector
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
git.vhaudiquet.fr/vhaudiquet/lolstats-patch_detector:latest
|
|
||||||
git.vhaudiquet.fr/vhaudiquet/lolstats-patch_detector:${{ github.sha }}
|
|
||||||
|
|
||||||
- name: Build and push match_collector docker image
|
- name: Build and push match_collector docker image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./match_collector
|
context: .
|
||||||
|
file: ./match_collector/Dockerfile
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
git.vhaudiquet.fr/vhaudiquet/lolstats-match_collector:latest
|
git.vhaudiquet.fr/vhaudiquet/lolstats-match_collector:latest
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -5,10 +5,9 @@ https://buildpath.win
|
|||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
BuildPath is made of four components:
|
BuildPath is made of three components:
|
||||||
- a MongoDB document database
|
- a MongoDB document database
|
||||||
- `patch_detector`, a daemon which runs periodically to detect new League of Legends patch from official APIs
|
- `match_collector`, a daemon which runs periodically to collect matches from League of Legends challenger players, automatically organizing them by patch version (extracted from match data), and generating statistics
|
||||||
- `match_collector`, a daemon which runs periodically to collect matches from League of Legends challenger players on current patch, and generate statistics from those matches, saving them in the database
|
|
||||||
- `frontend`, which is a Nuxt.JS project hosting an API serving the statistics but also the full web frontend (Vue.JS)
|
- `frontend`, which is a Nuxt.JS project hosting an API serving the statistics but also the full web frontend (Vue.JS)
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
@@ -19,9 +18,9 @@ Developing BuildPath requires Docker for a local MongoDB instance, and a NodeJS/
|
|||||||
Then, for the first-time setup:
|
Then, for the first-time setup:
|
||||||
```bash
|
```bash
|
||||||
# Install npm and node
|
# Install npm and node
|
||||||
sudo apt install npm nodejs # Ubuntu
|
sudo apt install npm nodejs node-typescript # Ubuntu/Debian
|
||||||
sudo pacman -S npm nodejs # Arch
|
sudo pacman -S npm nodejs typescript # Arch
|
||||||
sudo dnf install nodejs # Fedora
|
sudo dnf install nodejs typescript # Fedora
|
||||||
|
|
||||||
# Install docker. Follow instructions on
|
# Install docker. Follow instructions on
|
||||||
# https://docs.docker.com/engine/install/
|
# https://docs.docker.com/engine/install/
|
||||||
@@ -30,7 +29,6 @@ sudo dnf install nodejs # Fedora
|
|||||||
cd dev && npm i && cd .. # Install dependencies for the dev environment
|
cd dev && npm i && cd .. # Install dependencies for the dev environment
|
||||||
cd frontend && npm i && cd .. # Install dependencies for frontend
|
cd frontend && npm i && cd .. # Install dependencies for frontend
|
||||||
cd match_collector && npm i && cd .. # Install dependencies for match_collector
|
cd match_collector && npm i && cd .. # Install dependencies for match_collector
|
||||||
cd patch_detector && npm i && cd .. # Install dependencies for patch_detector
|
|
||||||
```
|
```
|
||||||
|
|
||||||
BuildPath needs data to work, either for generating statistics in the `match_collector` or for the frontend.
|
BuildPath needs data to work, either for generating statistics in the `match_collector` or for the frontend.
|
||||||
|
|||||||
@@ -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,23 +20,70 @@ async function setupDatabase() {
|
|||||||
|
|
||||||
// Check if data directory exists and has files
|
// Check if data directory exists and has files
|
||||||
const dataDir = path.join(__dirname, '../data');
|
const dataDir = path.join(__dirname, '../data');
|
||||||
const patchFile = path.join(dataDir, "patches.json");
|
|
||||||
if(!fs.existsSync(dataDir) || !fs.existsSync(patchFile)) {
|
// Try to get latest patch version from existing data files or database
|
||||||
|
let latestPatch = await getLatestPatchVersion();
|
||||||
|
|
||||||
|
// If no patch found, download snapshot
|
||||||
|
if (!latestPatch) {
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
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();
|
await downloadAndExtractSnapshot();
|
||||||
|
|
||||||
|
// Try again after download
|
||||||
|
latestPatch = await getLatestPatchVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get latest patch version
|
if (!latestPatch) {
|
||||||
const latestPatch = await getLatestPatchVersion();
|
console.error('❌ Could not determine latest patch version');
|
||||||
|
console.log('💡 Make sure you have match data files in the data directory');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
console.log(`🎯 Latest patch version: ${latestPatch}`);
|
console.log(`🎯 Latest patch version: ${latestPatch}`);
|
||||||
|
|
||||||
// Check if data directory exists and has files
|
// Check if data directory exists and has files
|
||||||
|
// Support both old format (patch_matches.json) and new platform-specific format (patch_PLATFORM_matches.json)
|
||||||
|
// Also support both "XX.Y" and "XX.Y.Z" patch formats in filenames
|
||||||
console.log('🔍 Checking for data files...');
|
console.log('🔍 Checking for data files...');
|
||||||
const dataFiles = [
|
const platforms = ['EUW1', 'EUN1', 'NA1', 'KR'];
|
||||||
{ path: 'patches.json', required: true, description: 'Patches data' },
|
const dataFiles = [];
|
||||||
{ path: `${latestPatch}_matches.json`, required: true, description: 'Match data' }
|
|
||||||
];
|
// Check for platform-specific match files
|
||||||
|
// Files may be named with either "16.8" or "16.8.1" format
|
||||||
|
let foundPlatformFiles = [];
|
||||||
|
for (const platform of platforms) {
|
||||||
|
// Try both formats: "16.8_PLATFORM.json" and "16.8.1_PLATFORM.json"
|
||||||
|
const files = fs.readdirSync(dataDir);
|
||||||
|
const matchFile = files.find(f => {
|
||||||
|
const match = f.match(/^(\d+\.\d+(?:\.\d+)?)_([A-Z0-9]+)\.json$/);
|
||||||
|
const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
|
||||||
|
return match && patchFromName === latestPatch && match[2] === platform;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchFile) {
|
||||||
|
foundPlatformFiles.push(platform);
|
||||||
|
dataFiles.push({ path: matchFile, required: false, description: `Match data for ${platform}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no platform-specific files found, look for old format
|
||||||
|
if (foundPlatformFiles.length === 0) {
|
||||||
|
// Try to find any match file for this patch
|
||||||
|
const files = fs.readdirSync(dataDir);
|
||||||
|
const matchFile = files.find(f => {
|
||||||
|
const match = f.match(/^(\d+\.\d+(?:\.\d+)?)(?:_matches)?\.json$/);
|
||||||
|
const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
|
||||||
|
return match && patchFromName === latestPatch;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchFile) {
|
||||||
|
dataFiles.push({ path: matchFile, required: true, description: 'Match data' });
|
||||||
|
} else {
|
||||||
|
dataFiles.push({ path: `${latestPatch}_matches.json`, required: true, description: 'Match data' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let filesExist = true;
|
let filesExist = true;
|
||||||
for (const file of dataFiles) {
|
for (const file of dataFiles) {
|
||||||
@@ -74,27 +121,41 @@ async function setupDatabase() {
|
|||||||
// 4. Wait for MongoDB to be ready
|
// 4. Wait for MongoDB to be ready
|
||||||
await waitForMongoDB();
|
await waitForMongoDB();
|
||||||
|
|
||||||
// 5. Import patches data
|
// 5. Check existing matches count and import if needed
|
||||||
console.log('📦 Importing patches data...');
|
|
||||||
await importPatchesData();
|
|
||||||
|
|
||||||
// 6. Check existing matches count and import if needed
|
|
||||||
console.log('Checking existing matches count...');
|
console.log('Checking existing matches count...');
|
||||||
|
|
||||||
|
// Check for platform-specific collections or fall back to old format
|
||||||
|
const existingPlatforms = await getExistingPlatforms(latestPatch);
|
||||||
|
|
||||||
|
if (existingPlatforms.length > 0) {
|
||||||
|
console.log(`📊 Found platform-specific collections: ${existingPlatforms.join(', ')}`);
|
||||||
|
let totalMatches = 0;
|
||||||
|
for (const platform of existingPlatforms) {
|
||||||
|
const count = await getMatchCount(latestPatch, platform);
|
||||||
|
console.log(` ${platform}: ${count} matches`);
|
||||||
|
totalMatches += count;
|
||||||
|
}
|
||||||
|
console.log(`📊 Total matches in database: ${totalMatches}`);
|
||||||
|
|
||||||
|
if (totalMatches < 100) {
|
||||||
|
console.log('📥 Importing matches (this may take a while)...');
|
||||||
|
await importMatchesData(latestPatch, foundPlatformFiles);
|
||||||
|
} else {
|
||||||
|
console.log('✅ Skipping matches import - sufficient data already present');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const matchCount = await getMatchCount(latestPatch);
|
const matchCount = await getMatchCount(latestPatch);
|
||||||
console.log(`📊 Current matches in database: ${matchCount}`);
|
console.log(`📊 Current matches in database: ${matchCount}`);
|
||||||
|
|
||||||
if (matchCount < 100) {
|
if (matchCount < 100) {
|
||||||
console.log('📥 Importing matches (this may take a while)...');
|
console.log('📥 Importing matches (this may take a while)...');
|
||||||
await importMatchesData(latestPatch);
|
await importMatchesData(latestPatch, foundPlatformFiles);
|
||||||
} else {
|
} else {
|
||||||
console.log('✅ Skipping matches import - sufficient data already present');
|
console.log('✅ Skipping matches import - sufficient data already present');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 7. Fetch CDragon data for the current patch
|
// 7. Run match collector to generate stats (this also handles CDragon caching)
|
||||||
console.log('🎮 Fetching CDragon data...');
|
|
||||||
await fetchCDragonData();
|
|
||||||
|
|
||||||
// 8. Run match collector to generate stats
|
|
||||||
console.log('📊 Generating champion stats...');
|
console.log('📊 Generating champion stats...');
|
||||||
await generateChampionStats();
|
await generateChampionStats();
|
||||||
|
|
||||||
@@ -109,48 +170,70 @@ async function setupDatabase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getLatestPatchVersion() {
|
async function getLatestPatchVersion() {
|
||||||
|
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 {
|
try {
|
||||||
const filePath = path.join(__dirname, '../data/patches.json');
|
const client = new MongoClient(getMongoUri());
|
||||||
if(!fs.existsSync(filePath)) {
|
await client.connect();
|
||||||
return null;
|
|
||||||
|
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
|
if (patches.size > 0) {
|
||||||
let patchesData;
|
const sortedPatches = Array.from(patches).sort((a, b) => {
|
||||||
if (fileContent.trim().startsWith('[')) {
|
const [aMajor, aMinor] = a.split('.').map(Number);
|
||||||
// Array format
|
const [bMajor, bMinor] = b.split('.').map(Number);
|
||||||
patchesData = JSON.parse(fileContent);
|
if (aMajor !== bMajor) return bMajor - aMajor;
|
||||||
if (!Array.isArray(patchesData)) {
|
return bMinor - aMinor;
|
||||||
throw new Error('Patches data should be an array');
|
});
|
||||||
|
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) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to get latest patch version:', error);
|
// Database not available, continue with other methods
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function downloadAndExtractSnapshot() {
|
async function downloadAndExtractSnapshot() {
|
||||||
@@ -217,72 +300,51 @@ async function waitForMongoDB() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importPatchesData() {
|
|
||||||
const client = new MongoClient(getMongoUri());
|
async function importMatchesData(patchVersion, foundPlatformFiles = []) {
|
||||||
await client.connect();
|
const dataDir = path.join(__dirname, '../data');
|
||||||
|
const files = fs.readdirSync(dataDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(__dirname, '../data/patches.json');
|
// If platform-specific files were found, import each one
|
||||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
if (foundPlatformFiles.length > 0) {
|
||||||
|
for (const platform of foundPlatformFiles) {
|
||||||
|
// Find the actual file for this platform (could be "16.8_PLATFORM.json" or "16.8.1_PLATFORM.json")
|
||||||
|
const matchFile = files.find(f => {
|
||||||
|
const match = f.match(/^(\d+\.\d+(?:\.\d+)?)_([A-Z0-9]+)\.json$/);
|
||||||
|
const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
|
||||||
|
return match && patchFromName === patchVersion && match[2] === platform;
|
||||||
|
});
|
||||||
|
|
||||||
// Check if it's line-delimited JSON or array format
|
if (matchFile) {
|
||||||
let patchesData;
|
const matchesFile = path.join(dataDir, matchFile);
|
||||||
if (fileContent.trim().startsWith('[')) {
|
const collectionName = `${patchVersion}_${platform}`;
|
||||||
// Array format
|
console.log(`📥 Importing matches for ${platform}...`);
|
||||||
patchesData = JSON.parse(fileContent);
|
execSync(
|
||||||
if (!Array.isArray(patchesData)) {
|
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
|
||||||
throw new Error('Patches data should be an array');
|
{
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: { ...process.env, MONGO_URI: getMongoUri() }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
console.log(`✅ Matches import completed for ${platform}`);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ No match file found for ${platform}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Line-delimited JSON format
|
// Fall back to old format (single file without platform suffix)
|
||||||
patchesData = fileContent.split('\n')
|
// Find any match file for this patch
|
||||||
.filter(line => line.trim() !== '')
|
const matchFile = files.find(f => {
|
||||||
.map(line => {
|
const match = f.match(/^(\d+\.\d+(?:\.\d+)?)(?:_matches)?\.json$/);
|
||||||
const doc = JSON.parse(line);
|
const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
|
||||||
return convertMongoExtendedJson(doc);
|
return match && patchFromName === patchVersion;
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert any extended JSON in array format too
|
|
||||||
if (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');
|
if (matchFile) {
|
||||||
const collection = db.collection('patches');
|
const matchesFile = path.join(dataDir, matchFile);
|
||||||
|
|
||||||
// Clear existing data
|
|
||||||
await collection.deleteMany({});
|
|
||||||
|
|
||||||
// Insert sorted data
|
|
||||||
const result = await collection.insertMany(patchesData);
|
|
||||||
console.log(`✅ Imported ${result.insertedCount} patches (sorted by date)`);
|
|
||||||
|
|
||||||
// Create index
|
|
||||||
await collection.createIndex({ date: -1 });
|
|
||||||
console.log('✅ Created patches index');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to import patches:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importMatchesData(patchVersion) {
|
|
||||||
const matchesFile = path.join(__dirname, '../data', `${patchVersion}_matches.json`);
|
|
||||||
const collectionName = patchVersion;
|
const collectionName = patchVersion;
|
||||||
|
execSync(
|
||||||
try {
|
|
||||||
const result = execSync(
|
|
||||||
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
|
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
|
||||||
{
|
{
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
@@ -290,6 +352,10 @@ async function importMatchesData(patchVersion) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
console.log('✅ Matches import completed');
|
console.log('✅ Matches import completed');
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ No match file found for patch ${patchVersion}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to import matches:', error);
|
console.error('❌ Failed to import matches:', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -311,12 +377,11 @@ async function generateChampionStats() {
|
|||||||
MONGO_HOST: 'localhost'
|
MONGO_HOST: 'localhost'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Run the match collector directly with tsx (TypeScript executor) instead of docker compose
|
// Run the match collector
|
||||||
const matchCollectorPath = path.join(__dirname, '../../match_collector/index.ts');
|
execSync(`npm run dev`, {
|
||||||
execSync(`npx tsx ${matchCollectorPath}`, {
|
|
||||||
stdio: 'inherit',
|
stdio: 'inherit',
|
||||||
env: env,
|
env: env,
|
||||||
cwd: path.join(__dirname, '../..')
|
cwd: path.join(__dirname, '../../match_collector')
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('✅ Champion stats generated');
|
console.log('✅ Champion stats generated');
|
||||||
@@ -326,31 +391,14 @@ async function generateChampionStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCDragonData() {
|
async function getMatchCount(patchVersion, platform = null) {
|
||||||
try {
|
|
||||||
console.log('🔄 Running CDragon fetcher...');
|
|
||||||
|
|
||||||
// Run the fetch-cdragon script
|
|
||||||
const fetchCDragonPath = path.join(__dirname, 'fetch-cdragon.js');
|
|
||||||
execSync(`node ${fetchCDragonPath}`, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
cwd: path.join(__dirname, '..')
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ CDragon data fetched');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to fetch CDragon data:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMatchCount(patchVersion) {
|
|
||||||
const client = new MongoClient(getMongoUri());
|
const client = new MongoClient(getMongoUri());
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const db = client.db('matches');
|
const db = client.db('matches');
|
||||||
const collection = db.collection(patchVersion);
|
const collectionName = platform ? `${patchVersion}_${platform}` : patchVersion;
|
||||||
|
const collection = db.collection(collectionName);
|
||||||
const count = await collection.countDocuments();
|
const count = await collection.countDocuments();
|
||||||
return count;
|
return count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -361,6 +409,33 @@ async function getMatchCount(patchVersion) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getExistingPlatforms(patchVersion) {
|
||||||
|
const client = new MongoClient(getMongoUri());
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = client.db('matches');
|
||||||
|
const collections = await db.listCollections().toArray();
|
||||||
|
const collectionNames = collections.map(c => c.name);
|
||||||
|
|
||||||
|
const platforms = ['EUW1', 'EUN1', 'NA1', 'KR'];
|
||||||
|
const existingPlatforms = [];
|
||||||
|
|
||||||
|
for (const platform of platforms) {
|
||||||
|
if (collectionNames.includes(`${patchVersion}_${platform}`)) {
|
||||||
|
existingPlatforms.push(platform);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return existingPlatforms;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to get existing platforms:', error);
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getMongoUri() {
|
function getMongoUri() {
|
||||||
return process.env.MONGO_URI || 'mongodb://root:password@localhost:27017/buildpath?authSource=admin';
|
return process.env.MONGO_URI || 'mongodb://root:password@localhost:27017/buildpath?authSource=admin';
|
||||||
}
|
}
|
||||||
@@ -476,9 +551,6 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|||||||
case 'generate-stats':
|
case 'generate-stats':
|
||||||
generateChampionStats().catch(console.error);
|
generateChampionStats().catch(console.error);
|
||||||
break;
|
break;
|
||||||
case 'import-patches':
|
|
||||||
importPatchesData().catch(console.error);
|
|
||||||
break;
|
|
||||||
case 'match-count':
|
case 'match-count':
|
||||||
if (args[1]) {
|
if (args[1]) {
|
||||||
getMatchCount(args[1]).then(count => console.log(`Match count: ${count}`)).catch(console.error);
|
getMatchCount(args[1]).then(count => console.log(`Match count: ${count}`)).catch(console.error);
|
||||||
@@ -499,7 +571,6 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
setupDatabase,
|
setupDatabase,
|
||||||
importPatchesData,
|
|
||||||
importMatchesData,
|
importMatchesData,
|
||||||
generateChampionStats,
|
generateChampionStats,
|
||||||
checkDatabaseStatus,
|
checkDatabaseStatus,
|
||||||
|
|||||||
3
dragon-item-parser/.gitignore
vendored
Normal file
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,
|
js.configs.recommended,
|
||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
prettier,
|
prettier,
|
||||||
|
{
|
||||||
|
ignores: ['dist/**', 'node_modules/**']
|
||||||
|
},
|
||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
semi: 'off',
|
semi: 'off',
|
||||||
File diff suppressed because it is too large
Load Diff
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
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
COPY package*.json ./
|
|
||||||
|
# Copy and build dragon-item-parser first
|
||||||
|
COPY dragon-item-parser/package*.json ./dragon-item-parser/
|
||||||
|
COPY dragon-item-parser/tsconfig.json ./dragon-item-parser/
|
||||||
|
COPY dragon-item-parser/src ./dragon-item-parser/src
|
||||||
|
WORKDIR /app/dragon-item-parser
|
||||||
|
RUN npm install && npm run build
|
||||||
|
|
||||||
|
# Build the frontend
|
||||||
|
WORKDIR /app
|
||||||
|
COPY frontend/package*.json ./frontend/
|
||||||
|
WORKDIR /app/frontend
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY . .
|
COPY frontend/. .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
FROM base
|
FROM base
|
||||||
COPY --from=build /app/.output /app/.output
|
COPY --from=build /app/frontend/.output /app/.output
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", ".output/server/index.mjs"]
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
@@ -5,6 +5,40 @@
|
|||||||
--color-on-surface: #b7b8e1;
|
--color-on-surface: #b7b8e1;
|
||||||
--color-surface-darker: #1f1d1c;
|
--color-surface-darker: #1f1d1c;
|
||||||
--color-gold: #ffd700;
|
--color-gold: #ffd700;
|
||||||
|
|
||||||
|
/* Tooltip colors */
|
||||||
|
--tooltip-bg: #312e2c;
|
||||||
|
--tooltip-border: #4a4543;
|
||||||
|
--tooltip-header-border: rgba(183, 184, 225, 0.2);
|
||||||
|
--tooltip-text: #b7b8e1;
|
||||||
|
--tooltip-text-dim: #8a8b9e;
|
||||||
|
--tooltip-stat-value: #ffd700;
|
||||||
|
--tooltip-stat-label: #b7b8e1;
|
||||||
|
|
||||||
|
/* Effect type colors */
|
||||||
|
--tooltip-effect-passive: #4a9eff;
|
||||||
|
--tooltip-effect-active: #ff6b6b;
|
||||||
|
--tooltip-effect-unique: #f39c12;
|
||||||
|
--tooltip-effect-mythic: #ff5252;
|
||||||
|
--tooltip-effect-legendary: #ff9800;
|
||||||
|
--tooltip-effect-epic: #ffd54f;
|
||||||
|
|
||||||
|
/* Text segment colors */
|
||||||
|
--tooltip-highlight: #ffd700;
|
||||||
|
--tooltip-keyword: #ffd700;
|
||||||
|
--tooltip-keyword-major: #ff8c00;
|
||||||
|
--tooltip-keyword-stealth: #9b59b6;
|
||||||
|
--tooltip-status: #e74c3c;
|
||||||
|
--tooltip-speed: #5dade2;
|
||||||
|
--tooltip-scaling: #5dade2;
|
||||||
|
--tooltip-magic-damage: #9b59b6;
|
||||||
|
--tooltip-physical-damage: #e67e22;
|
||||||
|
--tooltip-true-damage: #ffffff;
|
||||||
|
--tooltip-healing: #2ecc71;
|
||||||
|
--tooltip-shield: #3498db;
|
||||||
|
--tooltip-onhit: #5dade2;
|
||||||
|
--tooltip-spellname: #1abc9c;
|
||||||
|
--tooltip-flavor: #6a9fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Font setting */
|
/* Font setting */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { debounce, isEmpty } from '~/utils/helpers'
|
import { debounce, isEmpty } from '~/utils/helpers'
|
||||||
|
import type { ChampionSummary, LaneData, ChampionData } from 'match_collector'
|
||||||
// Constants
|
// Constants
|
||||||
const CHAMPIONS_API_URL = '/api/champions'
|
const CHAMPIONS_API_URL = '/api/champions'
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
||||||
|
|
||||||
|
import type { Perk, Item } from '~/types/cdragon'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
keystoneId: number
|
keystoneId: number
|
||||||
itemId: number
|
itemId: number
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
||||||
|
|
||||||
|
import type { PerkStyle, Perk } from '~/types/cdragon'
|
||||||
|
|
||||||
interface RuneBuild {
|
interface RuneBuild {
|
||||||
count: number
|
count: number
|
||||||
primaryStyle: number
|
primaryStyle: number
|
||||||
|
|||||||
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">
|
<script setup lang="ts">
|
||||||
|
import type { Item } from '~/types/cdragon'
|
||||||
|
|
||||||
interface ItemData {
|
interface ItemData {
|
||||||
data: number
|
data: number
|
||||||
count: number
|
count: number
|
||||||
|
|||||||
@@ -3,9 +3,12 @@ import { getCoreItems, getLateGameItems } from '~/utils/buildHelpers'
|
|||||||
import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue'
|
import BuildVariantSelector from '~/components/build/BuildVariantSelector.vue'
|
||||||
import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue'
|
import CompactRuneSelector from '~/components/build/CompactRuneSelector.vue'
|
||||||
import ItemRow from '~/components/build/ItemRow.vue'
|
import ItemRow from '~/components/build/ItemRow.vue'
|
||||||
|
import FirstBack from '~/components/build/FirstBack.vue'
|
||||||
|
|
||||||
|
import type { Build } from 'match_collector'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
builds: Builds
|
builds: Array<Build>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -51,7 +54,7 @@ function selectBuild(index: number): void {
|
|||||||
v-for="(build, i) in builds"
|
v-for="(build, i) in builds"
|
||||||
:key="i"
|
:key="i"
|
||||||
:keystone-id="build.runeKeystone"
|
:keystone-id="build.runeKeystone"
|
||||||
:item-id="build.items.children[0].data"
|
:item-id="build.items.children[0].data!"
|
||||||
:keystore="perks"
|
:keystore="perks"
|
||||||
:item-map="itemMap"
|
:item-map="itemMap"
|
||||||
:pickrate="build.pickrate"
|
:pickrate="build.pickrate"
|
||||||
@@ -122,6 +125,13 @@ function selectBuild(index: number): void {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- First Back Section -->
|
||||||
|
<FirstBack
|
||||||
|
v-if="currentBuild.firstBacks && currentBuild.firstBacks.length > 0"
|
||||||
|
:first-backs="currentBuild.firstBacks"
|
||||||
|
:item-map="itemMap"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Core Items Tree (children of start item) -->
|
<!-- Core Items Tree (children of start item) -->
|
||||||
<div v-if="currentBuild.items?.children?.length" class="item-row">
|
<div v-if="currentBuild.items?.children?.length" class="item-row">
|
||||||
<span class="item-row-label">Core</span>
|
<span class="item-row-label">Core</span>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
||||||
|
|
||||||
|
import type { Item } from '~/types/cdragon'
|
||||||
|
import type { ItemTag } from 'match_collector'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: Item
|
item: Item
|
||||||
size?: number
|
size?: number
|
||||||
showPickrate?: boolean
|
showPickrate?: boolean
|
||||||
pickrate?: number
|
pickrate?: number
|
||||||
class?: string
|
class?: string
|
||||||
|
tags?: ItemTag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expose the icon element for external use (e.g., arrow drawing)
|
// Expose the icon element for external use (e.g., arrow drawing)
|
||||||
@@ -20,7 +24,8 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
size: 48,
|
size: 48,
|
||||||
showPickrate: false,
|
showPickrate: false,
|
||||||
pickrate: 0,
|
pickrate: 0,
|
||||||
class: ''
|
class: '',
|
||||||
|
tags: () => []
|
||||||
})
|
})
|
||||||
|
|
||||||
// Tooltip state - encapsulated in this component
|
// Tooltip state - encapsulated in this component
|
||||||
@@ -100,6 +105,7 @@ const itemIconPath = computed(() => CDRAGON_BASE + mapPath(props.item.iconPath))
|
|||||||
:item="tooltipState.item"
|
:item="tooltipState.item"
|
||||||
:x="tooltipState.x"
|
:x="tooltipState.x"
|
||||||
:y="tooltipState.y"
|
:y="tooltipState.y"
|
||||||
|
:tags="tags"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -118,7 +124,6 @@ const itemIconPath = computed(() => CDRAGON_BASE + mapPath(props.item.iconPath))
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--color-on-surface);
|
border: 1px solid var(--color-on-surface);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: help;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +1,234 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
||||||
|
import {
|
||||||
|
parseItemDescription,
|
||||||
|
type TextSegment,
|
||||||
|
type ItemEffect,
|
||||||
|
type ParsedDescription
|
||||||
|
} from 'dragon-item-parser'
|
||||||
|
|
||||||
|
import type { Item } from '~/types/cdragon'
|
||||||
|
import type { ItemTag } from 'match_collector'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
item: Item | null
|
item: Item | null
|
||||||
show: boolean
|
show: boolean
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
|
tags?: ItemTag[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
tags: () => []
|
||||||
|
})
|
||||||
|
|
||||||
// Parse description and convert to styled HTML
|
// Tag display helpers
|
||||||
const formatDescription = (description?: string) => {
|
function getTagLabel(tag: ItemTag): string {
|
||||||
if (!description) return ''
|
const labels: Record<ItemTag, string> = {
|
||||||
|
ahead: 'Ahead',
|
||||||
// Replace <br> and other structural tags
|
behind: 'Behind',
|
||||||
const html = description
|
region_euw: 'EUW',
|
||||||
.replace(/<br\s*\/?>/gi, '<br>')
|
region_eun: 'EUN',
|
||||||
.replace(/<br><br><br>/gi, '') // Remove triple breaks
|
region_na: 'NA',
|
||||||
.replace(/<br><br>/gi, '') // Remove double breaks
|
region_kr: 'KR'
|
||||||
.replace(/<mainText>/gi, '')
|
}
|
||||||
.replace(/<\/mainText>/gi, '')
|
return labels[tag] || tag
|
||||||
.replace(/<stats>/gi, '<div class="tooltip-stats">')
|
|
||||||
.replace(/<\/stats>/gi, '</div>')
|
|
||||||
.replace(/<passive>/gi, '<span class="tag-passive">')
|
|
||||||
.replace(/<\/passive>/gi, '</span>:')
|
|
||||||
.replace(/<active>/gi, '<span class="tag-active">')
|
|
||||||
.replace(/<\/active>/gi, '</span>')
|
|
||||||
.replace(/<keyword>/gi, '<span class="tag-keyword">')
|
|
||||||
.replace(/<\/keyword>/gi, '</span>')
|
|
||||||
.replace(/<attention>/gi, '<span class="stat-highlight">')
|
|
||||||
.replace(/<\/attention>/gi, '</span>')
|
|
||||||
.replace(/<keywordMajor>/gi, '<span class="tag-keyword-major">')
|
|
||||||
.replace(/<\/keywordMajor>/gi, '</span>')
|
|
||||||
.replace(/<keywordStealth>/gi, '<span class="tag-keyword-stealth">')
|
|
||||||
.replace(/<\/keywordStealth>/gi, '</span>')
|
|
||||||
.replace(/<status>/gi, '<span class="tag-status">')
|
|
||||||
.replace(/<\/status>/gi, '</span>')
|
|
||||||
.replace(/<speed>/gi, '<span class="tag-speed">')
|
|
||||||
.replace(/<\/speed>/gi, '</span>')
|
|
||||||
.replace(/<scaleMana>/gi, '<span class="tag-scale-mana">')
|
|
||||||
.replace(/<\/scaleMana>/gi, '</span>')
|
|
||||||
.replace(/<scaleHealth>/gi, '<span class="tag-scale-health">')
|
|
||||||
.replace(/<\/scaleHealth>/gi, '</span>')
|
|
||||||
.replace(/<scaleAP>/gi, '<span class="tag-scale-ap">')
|
|
||||||
.replace(/<\/scaleAP>/gi, '</span>')
|
|
||||||
.replace(/<scaleAD>/gi, '<span class="tag-scale-ad">')
|
|
||||||
.replace(/<\/scaleAD>/gi, '</span>')
|
|
||||||
.replace(/<scaleArmor>/gi, '<span class="tag-scale-armor">')
|
|
||||||
.replace(/<\/scaleArmor>/gi, '</span>')
|
|
||||||
.replace(/<scaleMR>/gi, '<span class="tag-scale-mr">')
|
|
||||||
.replace(/<\/scaleMR>/gi, '</span>')
|
|
||||||
.replace(/<scaleLevel>/gi, '<span class="tag-scale-level">')
|
|
||||||
.replace(/<\/scaleLevel>/gi, '</span>')
|
|
||||||
.replace(/<spellName>/gi, '<span class="tag-spellname">')
|
|
||||||
.replace(/<\/spellName>/gi, '</span>')
|
|
||||||
.replace(/<unique>/gi, '<span class="tag-unique">UNIQUE</span>')
|
|
||||||
.replace(/<\/unique>/gi, '')
|
|
||||||
.replace(/<rarityMythic>/gi, '<span class="tag-rarity-mythic">Mythic</span>')
|
|
||||||
.replace(/<\/rarityMythic>/gi, '')
|
|
||||||
.replace(/<rarityLegendary>/gi, '<span class="tag-rarity-legendary">Legendary</span>')
|
|
||||||
.replace(/<\/rarityLegendary>/gi, '')
|
|
||||||
.replace(/<rarityGeneric>/gi, '<span class="tag-rarity-generic">Epic</span>')
|
|
||||||
.replace(/<\/rarityGeneric>/gi, '')
|
|
||||||
.replace(/<rules>/gi, '<div class="tag-rules">')
|
|
||||||
.replace(/<\/rules>/gi, '</div>')
|
|
||||||
.replace(/<flavorText>/gi, '<div class="tag-flavor">')
|
|
||||||
.replace(/<\/flavorText>/gi, '</div>')
|
|
||||||
.replace(/<li>/gi, '<div class="tag-list">')
|
|
||||||
.replace(/<\/li>/gi, '</div>')
|
|
||||||
.replace(/<font color='([^']+)'>/gi, '<span style="color: $1">')
|
|
||||||
.replace(/<\/font>/gi, '</span>')
|
|
||||||
.replace(/<b>/gi, '<strong>')
|
|
||||||
.replace(/<\/b>/gi, '</strong>')
|
|
||||||
.replace(/\[@@[^@]*@@\]/g, ' ') // Remove stat placeholders
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
return html
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedDescription = computed(() =>
|
function getTagTooltip(tag: ItemTag): string {
|
||||||
props.item ? formatDescription(props.item.description) : ''
|
const tooltips: Record<ItemTag, string> = {
|
||||||
)
|
ahead: 'This item is typically bought when ahead in gold',
|
||||||
|
behind: 'This item is typically bought when behind in gold',
|
||||||
|
region_euw: 'Popular in EU West region',
|
||||||
|
region_eun: 'Popular in EU Nordic & East region',
|
||||||
|
region_na: 'Popular in North America region',
|
||||||
|
region_kr: 'Popular in Korea region'
|
||||||
|
}
|
||||||
|
return tooltips[tag] || tag
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTagClass(tag: ItemTag): string {
|
||||||
|
return `tag-${tag}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the item description once
|
||||||
|
const parsedDescription = computed<ParsedDescription | null>(() => {
|
||||||
|
if (!props.item?.description) return null
|
||||||
|
return parseItemDescription(props.item.description)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Format stats for display
|
||||||
|
const formattedStats = computed(() => {
|
||||||
|
const stats = parsedDescription.value?.stats
|
||||||
|
if (!stats) return []
|
||||||
|
|
||||||
|
const statLabels: Record<string, string> = {
|
||||||
|
attackDamage: 'Attack Damage',
|
||||||
|
abilityPower: 'Ability Power',
|
||||||
|
attackSpeed: 'Attack Speed',
|
||||||
|
criticalStrikeChance: 'Critical Strike Chance',
|
||||||
|
criticalStrikeDamage: 'Critical Strike Damage',
|
||||||
|
lifeSteal: 'Life Steal',
|
||||||
|
omnivamp: 'Omnivamp',
|
||||||
|
physicalVamp: 'Physical Vamp',
|
||||||
|
spellVamp: 'Spell Vamp',
|
||||||
|
health: 'Health',
|
||||||
|
armor: 'Armor',
|
||||||
|
magicResist: 'Magic Resist',
|
||||||
|
mana: 'Mana',
|
||||||
|
baseManaRegen: 'Base Mana Regen',
|
||||||
|
baseHealthRegen: 'Base Health Regen',
|
||||||
|
moveSpeed: 'Move Speed',
|
||||||
|
abilityHaste: 'Ability Haste',
|
||||||
|
armorPenetration: 'Armor Penetration',
|
||||||
|
magicPenetration: 'Magic Penetration',
|
||||||
|
lethality: 'Lethality',
|
||||||
|
healAndShieldPower: 'Heal and Shield Power',
|
||||||
|
tenacity: 'Tenacity',
|
||||||
|
slowResist: 'Slow Resist'
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentageStats = [
|
||||||
|
'attackSpeed',
|
||||||
|
'criticalStrikeChance',
|
||||||
|
'criticalStrikeDamage',
|
||||||
|
'lifeSteal',
|
||||||
|
'omnivamp',
|
||||||
|
'physicalVamp',
|
||||||
|
'spellVamp',
|
||||||
|
'baseManaRegen',
|
||||||
|
'baseHealthRegen',
|
||||||
|
'armorPenetration',
|
||||||
|
'magicPenetration',
|
||||||
|
'healAndShieldPower',
|
||||||
|
'tenacity',
|
||||||
|
'slowResist'
|
||||||
|
]
|
||||||
|
|
||||||
|
return Object.entries(stats)
|
||||||
|
.filter(([_, value]) => value !== undefined)
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
label: statLabels[key] || key,
|
||||||
|
value: percentageStats.includes(key) ? `${value}%` : value
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get CSS class for text segment type
|
||||||
|
function getSegmentClass(segment: TextSegment): string {
|
||||||
|
const classMap: Record<TextSegment['type'], string> = {
|
||||||
|
text: '',
|
||||||
|
highlight: 'stat-highlight',
|
||||||
|
passive: 'tag-passive',
|
||||||
|
active: 'tag-active',
|
||||||
|
keyword: 'tag-keyword',
|
||||||
|
keywordMajor: 'tag-keyword-major',
|
||||||
|
keywordStealth: 'tag-keyword-stealth',
|
||||||
|
status: 'tag-status',
|
||||||
|
speed: 'tag-speed',
|
||||||
|
scaleMana: 'tag-scaling',
|
||||||
|
scaleHealth: 'tag-scaling',
|
||||||
|
scaleAP: 'tag-scaling',
|
||||||
|
scaleAD: 'tag-scaling',
|
||||||
|
scaleArmor: 'tag-scaling',
|
||||||
|
scaleMR: 'tag-scaling',
|
||||||
|
scaleLevel: 'tag-scaling',
|
||||||
|
scaleBonusHealth: 'tag-scaling',
|
||||||
|
scaleBonusMana: 'tag-scaling',
|
||||||
|
scaleMaxHealth: 'tag-scaling',
|
||||||
|
spellName: 'tag-spellname',
|
||||||
|
unique: 'tag-unique',
|
||||||
|
rarityMythic: 'tag-rarity-mythic',
|
||||||
|
rarityLegendary: 'tag-rarity-legendary',
|
||||||
|
rarityGeneric: 'tag-rarity-generic',
|
||||||
|
magicDamage: 'tag-magic-damage',
|
||||||
|
physicalDamage: 'tag-physical-damage',
|
||||||
|
trueDamage: 'tag-true-damage',
|
||||||
|
healing: 'tag-healing',
|
||||||
|
shield: 'tag-shield',
|
||||||
|
attention: 'stat-highlight',
|
||||||
|
onHit: 'tag-onhit',
|
||||||
|
color: ''
|
||||||
|
}
|
||||||
|
return classMap[segment.type] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get effect type label
|
||||||
|
function getEffectTypeLabel(type: ItemEffect['type']): string {
|
||||||
|
const labels: Record<ItemEffect['type'], string> = {
|
||||||
|
passive: 'Passive',
|
||||||
|
active: 'Active',
|
||||||
|
unique: 'Unique',
|
||||||
|
mythic: 'Mythic',
|
||||||
|
legendary: 'Legendary',
|
||||||
|
epic: 'Epic'
|
||||||
|
}
|
||||||
|
return labels[type] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get effect type class
|
||||||
|
function getEffectTypeClass(type: ItemEffect['type']): string {
|
||||||
|
const classMap: Record<ItemEffect['type'], string> = {
|
||||||
|
passive: 'effect-passive',
|
||||||
|
active: 'effect-active',
|
||||||
|
unique: 'effect-unique',
|
||||||
|
mythic: 'effect-mythic',
|
||||||
|
legendary: 'effect-legendary',
|
||||||
|
epic: 'effect-epic'
|
||||||
|
}
|
||||||
|
return classMap[type] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render text segments to HTML
|
||||||
|
function renderSegments(segments: TextSegment[]): string {
|
||||||
|
return segments
|
||||||
|
.map(segment => {
|
||||||
|
const cssClass = getSegmentClass(segment)
|
||||||
|
|
||||||
|
if (segment.type === 'color' && segment.color) {
|
||||||
|
return `<span style="color: ${segment.color}">${segment.content}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cssClass) {
|
||||||
|
return `<span class="${cssClass}">${segment.content}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
return segment.content
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render effect description
|
||||||
|
function renderEffect(effect: ItemEffect): string {
|
||||||
|
return renderSegments(effect.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render rules
|
||||||
|
function renderRules(segments: TextSegment[] | undefined): string {
|
||||||
|
if (!segments) return ''
|
||||||
|
return renderSegments(segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render flavor text
|
||||||
|
function renderFlavorText(segments: TextSegment[] | undefined): string {
|
||||||
|
if (!segments) return ''
|
||||||
|
return renderSegments(segments)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should show the effects section
|
||||||
|
const hasEffects = computed(() => {
|
||||||
|
return parsedDescription.value?.effects?.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if we should show rules
|
||||||
|
const hasRules = computed(() => {
|
||||||
|
return parsedDescription.value?.rules?.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check if we should show flavor text
|
||||||
|
const hasFlavorText = computed(() => {
|
||||||
|
return parsedDescription.value?.flavorText?.length
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -96,26 +243,73 @@ const formattedDescription = computed(() =>
|
|||||||
}"
|
}"
|
||||||
@mouseenter.stop
|
@mouseenter.stop
|
||||||
>
|
>
|
||||||
|
<!-- Header -->
|
||||||
<div class="tooltip-header">
|
<div class="tooltip-header">
|
||||||
<NuxtImg class="tooltip-icon" :src="CDRAGON_BASE + mapPath(item.iconPath)" />
|
<NuxtImg class="tooltip-icon" :src="CDRAGON_BASE + mapPath(item.iconPath)" />
|
||||||
<div class="tooltip-title">
|
<div class="tooltip-title">
|
||||||
<h3>{{ item.name || 'Unknown Item' }}</h3>
|
<h3>{{ item.name || 'Unknown Item' }}</h3>
|
||||||
<span v-if="item.priceTotal" class="tooltip-gold"> {{ item.priceTotal }} Gold </span>
|
<span v-if="item.priceTotal" class="tooltip-gold">{{ item.priceTotal }} Gold</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Plaintext (brief description) -->
|
||||||
<div v-if="item.plaintext" class="tooltip-plaintext">
|
<div v-if="item.plaintext" class="tooltip-plaintext">
|
||||||
{{ item.plaintext }}
|
{{ item.plaintext }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- eslint-disable vue/no-v-html -->
|
<!-- Item Tags -->
|
||||||
|
<div v-if="tags && tags.length > 0" class="tooltip-tags-section">
|
||||||
|
<div class="tooltip-tags">
|
||||||
|
<span
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag"
|
||||||
|
:class="['item-tag', getTagClass(tag)]"
|
||||||
|
:title="getTagTooltip(tag)"
|
||||||
|
>
|
||||||
|
{{ getTagLabel(tag) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
<div v-if="formattedStats.length > 0" class="tooltip-stats">
|
||||||
|
<div v-for="stat in formattedStats" :key="stat.key" class="stat-row">
|
||||||
|
<span class="stat-value">{{ stat.value }}</span>
|
||||||
|
<span class="stat-label">{{ stat.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Effects Section -->
|
||||||
|
<div v-if="hasEffects" class="tooltip-effects">
|
||||||
<div
|
<div
|
||||||
v-if="formattedDescription"
|
v-for="(effect, index) in parsedDescription?.effects"
|
||||||
class="tooltip-description"
|
:key="index"
|
||||||
v-html="formattedDescription"
|
:class="['effect-item', getEffectTypeClass(effect.type)]"
|
||||||
></div>
|
>
|
||||||
|
<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 -->
|
<!-- eslint-enable vue/no-v-html -->
|
||||||
</div>
|
</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>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
@@ -124,18 +318,23 @@ const formattedDescription = computed(() =>
|
|||||||
.item-tooltip {
|
.item-tooltip {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
background: var(--color-surface);
|
background: var(--tooltip-bg);
|
||||||
border: 1px solid var(--color-on-surface);
|
border: 1px solid var(--tooltip-border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
max-width: 300px;
|
max-width: 320px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
min-width: 250px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
.tooltip-header {
|
.tooltip-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid var(--tooltip-header-border);
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,200 +342,345 @@ const formattedDescription = computed(() =>
|
|||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--color-on-surface);
|
border: 2px solid var(--tooltip-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-title {
|
.tooltip-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-title h3 {
|
.tooltip-title h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-on-surface);
|
color: var(--tooltip-text);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-gold {
|
.tooltip-gold {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-gold);
|
color: var(--color-gold);
|
||||||
margin-top: 4px;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Plaintext */
|
||||||
.tooltip-plaintext {
|
.tooltip-plaintext {
|
||||||
font-size: 0.85rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-on-surface);
|
color: var(--tooltip-text-dim);
|
||||||
opacity: 0.8;
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description {
|
/* Item Tags Section */
|
||||||
font-size: 0.85rem;
|
.tooltip-tags-section {
|
||||||
color: var(--color-on-surface);
|
margin-bottom: 10px;
|
||||||
line-height: 1.5;
|
padding-bottom: 10px;
|
||||||
white-space: pre-wrap;
|
border-bottom: 1px solid var(--tooltip-header-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stats section */
|
.tooltip-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-tags .item-tag {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-tags .tag-ahead {
|
||||||
|
background-color: #22c55e;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-tags .tag-behind {
|
||||||
|
background-color: #ef4444;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-tags .tag-region_euw {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-tags .tag-region_eun {
|
||||||
|
background-color: #8b5cf6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-tags .tag-region_na {
|
||||||
|
background-color: #f59e0b;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-tags .tag-region_kr {
|
||||||
|
background-color: #ec4899;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Section */
|
||||||
.tooltip-stats {
|
.tooltip-stats {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
color: var(--tooltip-stat-value);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 45px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--tooltip-stat-label);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Effects Section */
|
||||||
|
.tooltip-effects {
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--tooltip-header-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item {
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
padding-bottom: 8px;
|
padding-left: 10px;
|
||||||
border-bottom: 1px solid var(--color-on-surface-dim);
|
border-left: 3px solid var(--tooltip-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-stats :deep(.stat-highlight) {
|
.effect-item:last-child {
|
||||||
color: #ffcc00;
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item.effect-passive {
|
||||||
|
border-left-color: var(--tooltip-effect-passive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item.effect-active {
|
||||||
|
border-left-color: var(--tooltip-effect-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item.effect-unique {
|
||||||
|
border-left-color: var(--tooltip-effect-unique);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item.effect-mythic {
|
||||||
|
border-left-color: var(--tooltip-effect-mythic);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item.effect-legendary {
|
||||||
|
border-left-color: var(--tooltip-effect-legendary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item.effect-epic {
|
||||||
|
border-left-color: var(--tooltip-effect-epic);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-type {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-passive .effect-type {
|
||||||
|
color: var(--tooltip-effect-passive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-active .effect-type {
|
||||||
|
color: var(--tooltip-effect-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-unique .effect-type {
|
||||||
|
color: var(--tooltip-effect-unique);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-mythic .effect-type {
|
||||||
|
color: var(--tooltip-effect-mythic);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-legendary .effect-type {
|
||||||
|
color: var(--tooltip-effect-legendary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-epic .effect-type {
|
||||||
|
color: var(--tooltip-effect-epic);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tooltip-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--tooltip-text-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rules Section */
|
||||||
|
.tooltip-rules {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-left: 2px solid var(--tooltip-border);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--tooltip-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flavor Text */
|
||||||
|
.tooltip-flavor {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(74, 222, 255, 0.05);
|
||||||
|
border-left: 2px solid var(--tooltip-flavor);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--tooltip-flavor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text segment styles inside effects */
|
||||||
|
.effect-description :deep(.stat-highlight) {
|
||||||
|
color: var(--tooltip-highlight);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tag styles */
|
.effect-description :deep(.tag-passive) {
|
||||||
.tooltip-description :deep(.tag-passive) {
|
color: var(--tooltip-effect-passive);
|
||||||
color: #4a9eff;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-active) {
|
.effect-description :deep(.tag-active) {
|
||||||
color: #ff6b6b;
|
color: var(--tooltip-effect-active);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-keyword) {
|
.effect-description :deep(.tag-keyword) {
|
||||||
color: #ffd700;
|
color: var(--tooltip-keyword);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-keyword-major) {
|
.effect-description :deep(.tag-keyword-major) {
|
||||||
color: #ff8c00;
|
color: var(--tooltip-keyword-major);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-keyword-stealth) {
|
.effect-description :deep(.tag-keyword-stealth) {
|
||||||
color: #9b59b6;
|
color: var(--tooltip-keyword-stealth);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-status) {
|
.effect-description :deep(.tag-status) {
|
||||||
color: #e74c3c;
|
color: var(--tooltip-status);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-speed),
|
.effect-description :deep(.tag-speed),
|
||||||
.tooltip-description :deep(.tag-scale-mana),
|
.effect-description :deep(.tag-scaling) {
|
||||||
.tooltip-description :deep(.tag-scale-health),
|
color: var(--tooltip-scaling);
|
||||||
.tooltip-description :deep(.tag-scale-ap),
|
|
||||||
.tooltip-description :deep(.tag-scale-ad),
|
|
||||||
.tooltip-description :deep(.tag-scale-armor),
|
|
||||||
.tooltip-description :deep(.tag-scale-mr),
|
|
||||||
.tooltip-description :deep(.tag-scale-level) {
|
|
||||||
color: #3498db;
|
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-healing),
|
.effect-description :deep(.tag-healing) {
|
||||||
.tooltip-description :deep(.tag-health) {
|
color: var(--tooltip-healing);
|
||||||
color: #2ecc71;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-shield) {
|
.effect-description :deep(.tag-shield) {
|
||||||
color: #3498db;
|
color: var(--tooltip-shield);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-magic-damage) {
|
.effect-description :deep(.tag-magic-damage) {
|
||||||
color: #9b59b6;
|
color: var(--tooltip-magic-damage);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-physical-damage) {
|
.effect-description :deep(.tag-physical-damage) {
|
||||||
color: #e67e22;
|
color: var(--tooltip-physical-damage);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-true-damage) {
|
.effect-description :deep(.tag-true-damage) {
|
||||||
color: #c0392b;
|
color: var(--tooltip-true-damage);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-onhit) {
|
.effect-description :deep(.tag-onhit) {
|
||||||
background: rgba(52, 152, 219, 0.1);
|
background: rgba(52, 152, 219, 0.2);
|
||||||
color: #3498db;
|
color: var(--tooltip-onhit);
|
||||||
padding: 1px 4px;
|
padding: 1px 4px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-spellname) {
|
.effect-description :deep(.tag-spellname) {
|
||||||
color: #1abc9c;
|
color: var(--tooltip-spellname);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-unique) {
|
.effect-description :deep(.tag-unique) {
|
||||||
color: #f39c12;
|
color: var(--tooltip-effect-unique);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-rarity-mythic) {
|
.effect-description :deep(.tag-rarity-mythic) {
|
||||||
color: #ff5252;
|
color: var(--tooltip-effect-mythic);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-rarity-legendary) {
|
.effect-description :deep(.tag-rarity-legendary) {
|
||||||
color: #ff9800;
|
color: var(--tooltip-effect-legendary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-rarity-generic) {
|
.effect-description :deep(.tag-rarity-generic) {
|
||||||
color: #ffd54f;
|
color: var(--tooltip-effect-epic);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-rules) {
|
/* Transition */
|
||||||
margin-top: 8px;
|
|
||||||
padding: 6px;
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
border-left: 2px solid var(--color-on-surface-dim);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-style: italic;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-flavor) {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 6px;
|
|
||||||
background: rgba(74, 222, 255, 0.1);
|
|
||||||
border-left: 2px solid #4a9eff;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-list) {
|
|
||||||
margin: 4px 0;
|
|
||||||
padding-left: 12px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description strong {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-on-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-enter-active,
|
.fade-enter-active,
|
||||||
.fade-leave-active {
|
.fade-leave-active {
|
||||||
transition: opacity 0.15s ease;
|
transition: opacity 0.15s ease;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LinePath as svgdomarrowsLinePath } from 'svg-dom-arrows'
|
import { LinePath as svgdomarrowsLinePath } from 'svg-dom-arrows'
|
||||||
|
|
||||||
|
import type { ItemTree } from 'match_collector'
|
||||||
|
import type { Item } from '~/types/cdragon'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
tree: ItemTree
|
tree: ItemTree
|
||||||
parentCount?: number
|
parentCount?: number
|
||||||
@@ -234,6 +237,7 @@ function handleRefresh() {
|
|||||||
:show-pickrate="true"
|
:show-pickrate="true"
|
||||||
:pickrate="parentCount ? tree.count / parentCount : 0"
|
:pickrate="parentCount ? tree.count / parentCount : 0"
|
||||||
:size="48"
|
:size="48"
|
||||||
|
:tags="tree.tags"
|
||||||
class="item-tree-img"
|
class="item-tree-img"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { CDRAGON_BASE } from '~/utils/cdragon'
|
import { CDRAGON_BASE } from '~/utils/cdragon'
|
||||||
import MatchupSpectrum from './Spectrum.vue'
|
import MatchupSpectrum from './Spectrum.vue'
|
||||||
|
|
||||||
|
import type { MatchupData } from 'match_collector'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
matchups?: Array<MatchupData>
|
matchups?: Array<MatchupData>
|
||||||
championId: number
|
championId: number
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { CDRAGON_BASE } from '~/utils/cdragon'
|
import { CDRAGON_BASE } from '~/utils/cdragon'
|
||||||
|
|
||||||
|
import type { MatchupData } from 'match_collector'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
matchups?: Array<MatchupData>
|
matchups?: Array<MatchupData>
|
||||||
championId: number
|
championId: number
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { LaneData } from 'match_collector'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
championName?: string
|
championName?: string
|
||||||
championLanes?: Array<LaneData>
|
championLanes?: Array<LaneData>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
|
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
|
||||||
|
|
||||||
|
import type { LaneData } from 'match_collector'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
championName?: string
|
championName?: string
|
||||||
championLanes?: Array<LaneData>
|
championLanes?: Array<LaneData>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
|
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
|
||||||
|
|
||||||
|
import type { LaneData } from 'match_collector'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
championName?: string
|
championName?: string
|
||||||
championLanes?: Array<LaneData>
|
championLanes?: Array<LaneData>
|
||||||
@@ -137,7 +139,10 @@ if (route.path.startsWith('/tierlist/')) {
|
|||||||
<h3 style="font-size: 18px; font-weight: 200; opacity: 0.5">Loading stats...</h3>
|
<h3 style="font-size: 18px; font-weight: 200; opacity: 0.5">Loading stats...</h3>
|
||||||
</template>
|
</template>
|
||||||
<h3 style="font-size: 18px; font-weight: 200; margin-top: 10px; margin-bottom: 10px">
|
<h3 style="font-size: 18px; font-weight: 200; margin-top: 10px; margin-bottom: 10px">
|
||||||
EUW Challenger only
|
Challenger only
|
||||||
|
</h3>
|
||||||
|
<h3 style="font-size: 18px; font-weight: 200; margin-top: 10px; margin-bottom: 10px">
|
||||||
|
EUW/EUNE/NA/KR
|
||||||
</h3>
|
</h3>
|
||||||
<NuxtLink to="/about"><h3>About</h3></NuxtLink>
|
<NuxtLink to="/about"><h3>About</h3></NuxtLink>
|
||||||
<h2 style="font-size: 10px; font-weight: 200; margin-top: 3px; margin-right: 10px">
|
<h2 style="font-size: 10px; font-weight: 200; margin-top: 3px; margin-right: 10px">
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { CDRAGON_BASE, mapPath } from '~/utils/cdragon'
|
||||||
|
import type { PerkStyle, Perk } from '~/types/cdragon'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
primaryStyleId: number
|
primaryStyleId: number
|
||||||
secondaryStyleId: number
|
secondaryStyleId: number
|
||||||
@@ -8,13 +11,14 @@ const props = defineProps<{
|
|||||||
const primaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
|
const primaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
|
||||||
const secondaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
|
const secondaryStyle: Ref<PerkStyle> = ref({ id: 0, name: '', iconPath: '', slots: [] })
|
||||||
|
|
||||||
const { data: perks_data }: PerksResponse = await useFetch('/api/cdragon/perks')
|
const { data: perks_data }: { data: Ref<Array<Perk>> } = await useFetch('/api/cdragon/perks')
|
||||||
const perks = reactive(new Map())
|
const perks = reactive(new Map())
|
||||||
for (const perk of perks_data.value) {
|
for (const perk of perks_data.value) {
|
||||||
perks.set(perk.id, perk)
|
perks.set(perk.id, perk)
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: stylesData }: PerkStylesResponse = await useFetch('/api/cdragon/perkstyles')
|
const { data: stylesData }: { data: Ref<{ styles: Array<PerkStyle> }> } =
|
||||||
|
await useFetch('/api/cdragon/perkstyles')
|
||||||
watch(
|
watch(
|
||||||
() => props.primaryStyleId,
|
() => props.primaryStyleId,
|
||||||
async (_newP, _oldP) => {
|
async (_newP, _oldP) => {
|
||||||
@@ -56,14 +60,13 @@ refreshStyles()
|
|||||||
:key="slotIndex"
|
:key="slotIndex"
|
||||||
class="rune-slot"
|
class="rune-slot"
|
||||||
>
|
>
|
||||||
<NuxtImg
|
<RuneIcon
|
||||||
v-for="perk in slot.perks"
|
v-for="perkId in slot.perks"
|
||||||
:key="perk"
|
:key="perkId"
|
||||||
width="48"
|
:perk="perks.get(perkId)"
|
||||||
:class="
|
:size="48"
|
||||||
'rune-img rune-keystone ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')
|
:is-active="props.selectionIds.includes(perkId)"
|
||||||
"
|
:is-keystone="true"
|
||||||
:src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -71,36 +74,37 @@ refreshStyles()
|
|||||||
:key="slotIndex"
|
:key="slotIndex"
|
||||||
class="rune-slot"
|
class="rune-slot"
|
||||||
>
|
>
|
||||||
<NuxtImg
|
<RuneIcon
|
||||||
v-for="perk in slot.perks"
|
v-for="perkId in slot.perks"
|
||||||
:key="perk"
|
:key="perkId"
|
||||||
width="48"
|
:perk="perks.get(perkId)"
|
||||||
:class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')"
|
:size="48"
|
||||||
:src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"
|
:is-active="props.selectionIds.includes(perkId)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rune-spacer-bar" />
|
<div class="rune-spacer-bar" />
|
||||||
<div class="rune-holder" style="align-content: end">
|
<div class="rune-holder" style="align-content: end">
|
||||||
<div class="rune-slot">
|
<div class="rune-slot">
|
||||||
<img style="margin: auto" :src="CDRAGON_BASE + mapPath(secondaryStyle.iconPath)" />
|
<img
|
||||||
|
class="rune-style-img"
|
||||||
|
style="margin: auto"
|
||||||
|
:src="CDRAGON_BASE + mapPath(secondaryStyle.iconPath)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="(slot, slotIndex) in secondaryStyle.slots.slice(1, 4)"
|
v-for="(slot, slotIndex) in secondaryStyle.slots.slice(1, 4)"
|
||||||
:key="slotIndex"
|
:key="slotIndex"
|
||||||
class="rune-slot"
|
class="rune-slot"
|
||||||
>
|
>
|
||||||
<NuxtImg
|
<RuneIcon
|
||||||
v-for="perk in slot.perks"
|
v-for="perkId in slot.perks"
|
||||||
:key="perk"
|
:key="perkId"
|
||||||
width="48"
|
:perk="perks.get(perkId)"
|
||||||
:class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')"
|
:size="48"
|
||||||
:src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"
|
:is-active="props.selectionIds.includes(perkId)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- <div class="rune-slot mini" v-for="slot in primaryStyle.slots.slice(4, 7)">
|
|
||||||
<img width="32" v-for="perk in slot.perks" :class="'rune-img ' + (props.selectionIds.includes(perk) ? 'rune-activated' : '')" :src="'https://raw.communitydragon.org/latest/' + mapPath(perks.get(perk).iconPath)"/>
|
|
||||||
</div> -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -123,24 +127,15 @@ refreshStyles()
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.rune-img {
|
|
||||||
max-width: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
filter: grayscale(1);
|
|
||||||
border: 1px var(--color-on-surface) solid;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
.rune-keystone {
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
.rune-activated {
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
.rune-spacer-bar {
|
.rune-spacer-bar {
|
||||||
margin-left: 20px;
|
margin-left: 20px;
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
border: 1px var(--color-on-surface) solid;
|
border: 1px var(--color-on-surface) solid;
|
||||||
}
|
}
|
||||||
|
.rune-style-img {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
@media only screen and (max-width: 650px) {
|
@media only screen and (max-width: 650px) {
|
||||||
.rune-slot {
|
.rune-slot {
|
||||||
@@ -148,10 +143,6 @@ refreshStyles()
|
|||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
.rune-img {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
.rune-style-img {
|
.rune-style-img {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@@ -160,5 +151,9 @@ refreshStyles()
|
|||||||
margin-left: 10px;
|
margin-left: 10px;
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
|
.rune-icon {
|
||||||
|
width: 24px !important;
|
||||||
|
height: 24px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
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'
|
} from 'chart.js'
|
||||||
import { Bar } from 'vue-chartjs'
|
import { Bar } from 'vue-chartjs'
|
||||||
|
|
||||||
|
import type { Champion } from '~/types/cdragon'
|
||||||
|
import type { LaneData } from 'match_collector'
|
||||||
|
|
||||||
// Register
|
// Register
|
||||||
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)
|
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { LaneData } from 'match_collector'
|
||||||
|
import type { Champion } from '~/types/cdragon'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string
|
title: string
|
||||||
tier: Array<{ champion: Champion; lane: LaneData }>
|
tier: Array<{ champion: Champion; lane: LaneData }>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Builds } from 'match_collector'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for managing build data
|
* 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
|
* Composable for fetching and managing item data from CDragon API
|
||||||
* Returns a reactive Map of item ID to item data
|
* Returns a reactive Map of item ID to item data
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { Perk, PerkStyle } from '~/types/cdragon'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for fetching and managing rune styles and keystones
|
* Composable for fetching and managing rune styles and keystones
|
||||||
* Transforms rune data into format needed for display components
|
* Transforms rune data into format needed for display components
|
||||||
@@ -5,7 +7,6 @@
|
|||||||
export const useRuneStyles = () => {
|
export const useRuneStyles = () => {
|
||||||
const { data: perksData } = useFetch('/api/cdragon/perks')
|
const { data: perksData } = useFetch('/api/cdragon/perks')
|
||||||
const { data: stylesData } = useFetch('/api/cdragon/perkstyles')
|
const { data: stylesData } = useFetch('/api/cdragon/perkstyles')
|
||||||
console.log(stylesData.value)
|
|
||||||
|
|
||||||
const perks = reactive(new Map<number, Perk>())
|
const perks = reactive(new Map<number, Perk>())
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { SummonerSpell } from '~/types/cdragon'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for fetching and managing summoner spell data from CDragon API
|
* Composable for fetching and managing summoner spell data from CDragon API
|
||||||
* Returns a reactive Map of spell ID to spell data
|
* Returns a reactive Map of spell ID to spell data
|
||||||
|
|||||||
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
|
// @ts-check
|
||||||
|
import './eslint-polyfill.mjs'
|
||||||
import withNuxt from './.nuxt/eslint.config.mjs'
|
import withNuxt from './.nuxt/eslint.config.mjs'
|
||||||
|
|
||||||
import js from '@eslint/js'
|
import js from '@eslint/js'
|
||||||
@@ -16,24 +17,7 @@ export default withNuxt([
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
globals: {
|
globals: {
|
||||||
...globals.browser,
|
...globals.browser,
|
||||||
...globals.node,
|
...globals.node
|
||||||
// Add global types from our API definitions
|
|
||||||
ChampionSummary: 'readonly',
|
|
||||||
LaneData: 'readonly',
|
|
||||||
ChampionData: 'readonly',
|
|
||||||
ItemTree: 'readonly',
|
|
||||||
Builds: 'readonly',
|
|
||||||
PerkStyle: 'readonly',
|
|
||||||
PerksResponse: 'readonly',
|
|
||||||
PerkStylesResponse: 'readonly',
|
|
||||||
Champion: 'readonly',
|
|
||||||
ChampionsResponse: 'readonly',
|
|
||||||
ChampionResponse: 'readonly',
|
|
||||||
ItemResponse: 'readonly',
|
|
||||||
MatchupData: 'readonly',
|
|
||||||
Item: 'readonly',
|
|
||||||
SummonerSpell: 'readonly',
|
|
||||||
Perk: 'readonly'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
|||||||
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 ."
|
"format:check": "prettier --check ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"match_collector": "file:../match_collector",
|
||||||
|
"dragon-item-parser": "file:../dragon-item-parser",
|
||||||
"@nuxt/eslint": "^1.12.1",
|
"@nuxt/eslint": "^1.12.1",
|
||||||
"@nuxt/fonts": "^0.11.3",
|
"@nuxt/fonts": "^0.11.3",
|
||||||
"@nuxt/image": "^1.10.0",
|
"@nuxt/image": "^1.10.0",
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
import type { ChampionData } from 'match_collector'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const championAlias = route.params.alias as string
|
const championAlias = route.params.alias as string
|
||||||
|
|
||||||
@@ -76,7 +78,6 @@ watch(
|
|||||||
// Add timeout to prevent infinite loading
|
// Add timeout to prevent infinite loading
|
||||||
const loadingTimeout = setTimeout(() => {
|
const loadingTimeout = setTimeout(() => {
|
||||||
if (isLoading.value && !championData.value && !error.value) {
|
if (isLoading.value && !championData.value && !error.value) {
|
||||||
console.warn('Champion data loading timed out')
|
|
||||||
error.value = 'Loading took too long. Please try again.'
|
error.value = 'Loading took too long. Please try again.'
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
|
import { LANE_IMAGES, lanePositionToIndex, POSITIONS_STR } from '~/utils/cdragon'
|
||||||
|
|
||||||
|
import type { LaneData, ChampionData, Champion } from 'match_collector'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const lane = route.params.lane as string
|
const lane = route.params.lane as string
|
||||||
|
|
||||||
const { data: championsData }: ChampionsResponse = await useFetch('/api/cdragon/champion-summary')
|
const { data: championsData }: { data: Ref<Array<Champion>> } = await useFetch(
|
||||||
|
'/api/cdragon/champion-summary'
|
||||||
|
)
|
||||||
|
|
||||||
const { data: championsLanes }: { data: Ref<Array<ChampionData>> } =
|
const { data: championsLanes }: { data: Ref<Array<ChampionData>> } =
|
||||||
await useFetch('/api/champions')
|
await useFetch('/api/champions')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { MongoClient } from 'mongodb'
|
import type { MongoClient } from 'mongodb'
|
||||||
|
import type { ChampionData } from 'match_collector'
|
||||||
import { connectToDatabase, fetchLatestPatch } from '../../utils/mongo'
|
import { connectToDatabase, fetchLatestPatch } from '../../utils/mongo'
|
||||||
|
|
||||||
async function championInfos(client: MongoClient, patch: string, championAlias: string) {
|
async function championInfos(client: MongoClient, patch: string, championAlias: string) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { MongoClient } from 'mongodb'
|
import type { MongoClient } from 'mongodb'
|
||||||
|
import type { ChampionData } from 'match_collector'
|
||||||
import { connectToDatabase, fetchLatestPatch } from '../utils/mongo'
|
import { connectToDatabase, fetchLatestPatch } from '../utils/mongo'
|
||||||
|
|
||||||
async function champions(client: MongoClient, patch: string) {
|
async function champions(client: MongoClient, patch: string) {
|
||||||
@@ -11,7 +12,6 @@ async function champions(client: MongoClient, patch: string) {
|
|||||||
if (x.lanes != undefined && x.lanes != null) {
|
if (x.lanes != undefined && x.lanes != null) {
|
||||||
for (const lane of x.lanes) {
|
for (const lane of x.lanes) {
|
||||||
delete lane.builds
|
delete lane.builds
|
||||||
delete lane.runes
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,19 +1,43 @@
|
|||||||
import type { MongoClient } from 'mongodb'
|
import type { MongoClient } from 'mongodb'
|
||||||
import { connectToDatabase, fetchLatestPatch } from '../utils/mongo'
|
import { connectToDatabase, fetchLatestPatch, getAvailablePlatforms } from '../utils/mongo'
|
||||||
|
|
||||||
async function fetchGameCount(client: MongoClient, patch: string) {
|
async function fetchGameCount(client: MongoClient, patch: string) {
|
||||||
const database = client.db('matches')
|
const database = client.db('matches')
|
||||||
|
|
||||||
|
// Check for platform-specific collections
|
||||||
|
const platforms = await getAvailablePlatforms(client, patch)
|
||||||
|
|
||||||
|
if (platforms.length > 0) {
|
||||||
|
// Sum counts from all platform-specific collections
|
||||||
|
let totalCount = 0
|
||||||
|
const platformCounts: Record<string, number> = {}
|
||||||
|
|
||||||
|
for (const platform of platforms) {
|
||||||
|
const collection = database.collection(`${patch}_${platform}`)
|
||||||
|
const count = await collection.countDocuments()
|
||||||
|
platformCounts[platform] = count
|
||||||
|
totalCount += count
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total: totalCount, platforms: platformCounts }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to old format (single collection)
|
||||||
const matches = database.collection(patch)
|
const matches = database.collection(patch)
|
||||||
const count = await matches.countDocuments()
|
const count = await matches.countDocuments()
|
||||||
return count
|
return { total: count, platforms: {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async _ => {
|
export default defineEventHandler(async _ => {
|
||||||
const client = await connectToDatabase()
|
const client = await connectToDatabase()
|
||||||
const latestPatch = await fetchLatestPatch(client)
|
const latestPatch = await fetchLatestPatch(client)
|
||||||
const gameCount = await fetchGameCount(client, latestPatch)
|
const gameCountData = await fetchGameCount(client, latestPatch)
|
||||||
|
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
return { patch: latestPatch, count: gameCount }
|
return {
|
||||||
|
patch: latestPatch,
|
||||||
|
count: gameCountData.total,
|
||||||
|
platformCounts: gameCountData.platforms
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { readFile, existsSync } from 'fs'
|
import { readFile, existsSync } from 'fs'
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
|
import { parseItemStats, type ItemStats } from 'dragon-item-parser'
|
||||||
|
|
||||||
const readFileAsync = promisify(readFile)
|
const readFileAsync = promisify(readFile)
|
||||||
|
|
||||||
@@ -8,13 +9,18 @@ const readFileAsync = promisify(readFile)
|
|||||||
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
|
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
|
||||||
|
|
||||||
// Cache directory - can be configured via environment variable
|
// Cache directory - can be configured via environment variable
|
||||||
// Default to dev/cdragon for development
|
// In development, use dev/data/cdragon relative to project root
|
||||||
|
// In production, use /cdragon (shared volume)
|
||||||
const getCacheDir = () => {
|
const getCacheDir = () => {
|
||||||
if (process.env.CDRAGON_CACHE_DIR) {
|
if (process.env.CDRAGON_CACHE_DIR) {
|
||||||
return process.env.CDRAGON_CACHE_DIR
|
return process.env.CDRAGON_CACHE_DIR
|
||||||
}
|
}
|
||||||
// Default to dev/cdragon relative to project root
|
// Check if we're in development mode (explicitly set)
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
return join(process.cwd(), '..', 'dev', 'data', 'cdragon')
|
return join(process.cwd(), '..', 'dev', 'data', 'cdragon')
|
||||||
|
}
|
||||||
|
// Default to /cdragon for production (Docker)
|
||||||
|
return '/cdragon'
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -78,14 +84,20 @@ async function fetchFromCache<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get items data from cache
|
* Get items data from cache with parsed stats
|
||||||
*/
|
*/
|
||||||
async function getItems(patch?: string): Promise<CDragonItem[]> {
|
async function getItems(patch?: string): Promise<CDragonItemWithStats[]> {
|
||||||
return fetchFromCache<CDragonItem[]>(
|
const items = await fetchFromCache<CDragonItem[]>(
|
||||||
'items.json',
|
'items.json',
|
||||||
'plugins/rcp-be-lol-game-data/global/default/v1/items.json',
|
'plugins/rcp-be-lol-game-data/global/default/v1/items.json',
|
||||||
{ patch }
|
{ patch }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Parse stats from description for each item
|
||||||
|
return items.map(item => ({
|
||||||
|
...item,
|
||||||
|
stats: parseItemStats(item.description || '')
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -148,6 +160,10 @@ interface CDragonItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CDragonItemWithStats extends CDragonItem {
|
||||||
|
stats: ItemStats
|
||||||
|
}
|
||||||
|
|
||||||
interface CDragonPerk {
|
interface CDragonPerk {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
@@ -197,6 +213,7 @@ export {
|
|||||||
|
|
||||||
export type {
|
export type {
|
||||||
CDragonItem,
|
CDragonItem,
|
||||||
|
CDragonItemWithStats,
|
||||||
CDragonPerk,
|
CDragonPerk,
|
||||||
CDragonPerkStyle,
|
CDragonPerkStyle,
|
||||||
CDragonPerkStyles,
|
CDragonPerkStyles,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { MongoClient } from 'mongodb'
|
import { MongoClient } from 'mongodb'
|
||||||
|
|
||||||
|
// Available platforms for region-specific match data
|
||||||
|
const PLATFORMS = ['EUW1', 'EUN1', 'NA1', 'KR'] as const
|
||||||
|
type Platform = (typeof PLATFORMS)[number]
|
||||||
|
|
||||||
async function connectToDatabase() {
|
async function connectToDatabase() {
|
||||||
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
|
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
|
||||||
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
|
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
|
||||||
@@ -15,11 +19,59 @@ async function connectToDatabase() {
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLatestPatch(client: MongoClient) {
|
/**
|
||||||
const database = client.db('patches')
|
* Get the latest patch from existing match collections in the database.
|
||||||
const patches = database.collection('patches')
|
* Collections are named like "15.1_EUW1", "15.2_NA1", etc.
|
||||||
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
|
*/
|
||||||
return latestPatch!.patch as string
|
async function fetchLatestPatch(client: MongoClient): Promise<string> {
|
||||||
|
const matchesDb = client.db('matches')
|
||||||
|
const collections = await matchesDb.listCollections().toArray()
|
||||||
|
const collectionNames = collections.map(c => c.name)
|
||||||
|
|
||||||
|
// Extract unique patch versions from collection names
|
||||||
|
const patches = new Set<string>()
|
||||||
|
for (const name of collectionNames) {
|
||||||
|
// Collection names are either "patch_platform" or just "patch"
|
||||||
|
const patch = name.split('_')[0]
|
||||||
|
if (patch && /^\d+\.\d+$/.test(patch)) {
|
||||||
|
patches.add(patch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patches.size === 0) {
|
||||||
|
throw new Error('No patch collections found in database')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort patches and return the latest (highest version number)
|
||||||
|
const sortedPatches = Array.from(patches).sort((a, b) => {
|
||||||
|
const [aMajor, aMinor] = a.split('.').map(Number)
|
||||||
|
const [bMajor, bMinor] = b.split('.').map(Number)
|
||||||
|
if (aMajor !== bMajor) return bMajor - aMajor
|
||||||
|
return bMinor - aMinor
|
||||||
|
})
|
||||||
|
|
||||||
|
return sortedPatches[0]
|
||||||
}
|
}
|
||||||
|
|
||||||
export { connectToDatabase, fetchLatestPatch }
|
/**
|
||||||
|
* Get available platforms for a given patch by checking which match collections exist
|
||||||
|
* Note: Match collections are platform-specific (e.g., "15.1_EUW1")
|
||||||
|
* Champion collections are aggregated across all platforms (e.g., "15.1")
|
||||||
|
*/
|
||||||
|
async function getAvailablePlatforms(client: MongoClient, patch: string): Promise<Platform[]> {
|
||||||
|
const matchesDb = client.db('matches')
|
||||||
|
const collections = await matchesDb.listCollections().toArray()
|
||||||
|
const collectionNames = collections.map(c => c.name)
|
||||||
|
|
||||||
|
const availablePlatforms: Platform[] = []
|
||||||
|
for (const platform of PLATFORMS) {
|
||||||
|
if (collectionNames.includes(`${patch}_${platform}`)) {
|
||||||
|
availablePlatforms.push(platform)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return availablePlatforms
|
||||||
|
}
|
||||||
|
|
||||||
|
export { connectToDatabase, fetchLatestPatch, getAvailablePlatforms, PLATFORMS }
|
||||||
|
export type { Platform }
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
declare global {
|
|
||||||
/**
|
|
||||||
* Represents an item in the build tree
|
|
||||||
*/
|
|
||||||
interface ItemTree {
|
|
||||||
count: number
|
|
||||||
data: number
|
|
||||||
children: ItemTree[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a complete build with runes and items
|
|
||||||
*/
|
|
||||||
interface Build {
|
|
||||||
runeKeystone: number
|
|
||||||
runes: Rune[]
|
|
||||||
items: ItemTree
|
|
||||||
bootsFirst: number
|
|
||||||
count: number
|
|
||||||
boots: Array<{ count: number; data: number }>
|
|
||||||
suppItems: Array<{ count: number; data: number }>
|
|
||||||
startItems: Array<{ count: number; data: number }>
|
|
||||||
pickrate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents champion build information (array of builds)
|
|
||||||
*/
|
|
||||||
type Builds = Array<Build>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a rune configuration
|
|
||||||
*/
|
|
||||||
interface Rune {
|
|
||||||
count: number
|
|
||||||
primaryStyle: number
|
|
||||||
secondaryStyle: number
|
|
||||||
selections: number[]
|
|
||||||
pickrate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents counter data for a champion
|
|
||||||
*/
|
|
||||||
interface MatchupData {
|
|
||||||
championId: number
|
|
||||||
winrate: number
|
|
||||||
games: number
|
|
||||||
championName: string
|
|
||||||
championAlias: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents lane-specific champion data
|
|
||||||
*/
|
|
||||||
interface LaneData {
|
|
||||||
data: string
|
|
||||||
count: number
|
|
||||||
winningMatches: number
|
|
||||||
losingMatches: number
|
|
||||||
winrate: number
|
|
||||||
pickrate: number
|
|
||||||
builds?: Builds
|
|
||||||
summonerSpells: Array<{ id: number; count: number; pickrate: number }>
|
|
||||||
matchups?: MatchupData[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents complete champion data
|
|
||||||
*/
|
|
||||||
interface ChampionData {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
alias: string
|
|
||||||
gameCount: number
|
|
||||||
winrate: number
|
|
||||||
pickrate: number
|
|
||||||
lanes: LaneData[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Champion summary from CDragon
|
|
||||||
*/
|
|
||||||
interface ChampionSummary {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
alias: string
|
|
||||||
squarePortraitPath: string
|
|
||||||
// Add other relevant fields as needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {}
|
|
||||||
@@ -1,25 +1,17 @@
|
|||||||
declare global {
|
import type { ItemStats } from 'dragon-item-parser'
|
||||||
type ChampionsResponse = {
|
|
||||||
data: Ref<Array<Champion>>
|
type Champion = {
|
||||||
}
|
|
||||||
type ChampionResponse = {
|
|
||||||
data: Ref<ChampionFull>
|
|
||||||
}
|
|
||||||
type Champion = {
|
|
||||||
name: string
|
name: string
|
||||||
alias: string
|
alias: string
|
||||||
squarePortraitPath: string
|
squarePortraitPath: string
|
||||||
}
|
}
|
||||||
type ChampionFull = {
|
type ChampionFull = {
|
||||||
name: string
|
name: string
|
||||||
alias: string
|
alias: string
|
||||||
squarePortraitPath: string
|
squarePortraitPath: string
|
||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
type ItemResponse = {
|
type Item = {
|
||||||
data: Ref<Array<Item>>
|
|
||||||
}
|
|
||||||
type Item = {
|
|
||||||
id: number
|
id: number
|
||||||
iconPath: string
|
iconPath: string
|
||||||
name?: string
|
name?: string
|
||||||
@@ -29,29 +21,25 @@ declare global {
|
|||||||
from?: number[]
|
from?: number[]
|
||||||
price?: number
|
price?: number
|
||||||
priceTotal?: number
|
priceTotal?: number
|
||||||
}
|
stats?: ItemStats
|
||||||
type SummonerSpell = {
|
}
|
||||||
|
type SummonerSpell = {
|
||||||
id: number
|
id: number
|
||||||
iconPath: string
|
iconPath: string
|
||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
type PerksResponse = {
|
type Perk = {
|
||||||
data: Ref<Array<Perk>>
|
|
||||||
}
|
|
||||||
type Perk = {
|
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
iconPath: string
|
iconPath: string
|
||||||
}
|
shortDesc?: string
|
||||||
type PerkStylesResponse = {
|
longDesc?: string
|
||||||
data: Ref<{ styles: Array<PerkStyle> }>
|
}
|
||||||
}
|
type PerkStyle = {
|
||||||
type PerkStyle = {
|
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
iconPath: string
|
iconPath: string
|
||||||
slots: Array<{ perks: Array<number> }>
|
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)
|
* Gets all late game items from the item tree (items beyond first level)
|
||||||
* Returns a flat array of unique items with their counts
|
* Returns a flat array of unique items with their counts
|
||||||
@@ -27,8 +29,6 @@ export function getLateGameItems(build: Build): Array<{ data: number; count: num
|
|||||||
|
|
||||||
lateGameItems.sort((a, b) => b.count - a.count)
|
lateGameItems.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
console.log(lateGameItems)
|
|
||||||
|
|
||||||
// Sort by count descending
|
// Sort by count descending
|
||||||
return lateGameItems.filter(item => !treeToArray(getCoreItems(build)).includes(item.data))
|
return lateGameItems.filter(item => !treeToArray(getCoreItems(build)).includes(item.data))
|
||||||
}
|
}
|
||||||
@@ -54,7 +54,10 @@ function trimTreeDepth(tree: ItemTree, maxDepth: number, currentDepth: number =
|
|||||||
const trimmedTree: ItemTree = {
|
const trimmedTree: ItemTree = {
|
||||||
count: tree.count,
|
count: tree.count,
|
||||||
data: tree.data,
|
data: tree.data,
|
||||||
children: []
|
children: [],
|
||||||
|
tags: tree.tags,
|
||||||
|
boughtWhen: tree.boughtWhen,
|
||||||
|
platformCount: tree.platformCount
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we haven't reached maxDepth, include children
|
// If we haven't reached maxDepth, include children
|
||||||
|
|||||||
1
match_collector/.gitignore
vendored
1
match_collector/.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
dist
|
||||||
|
|||||||
@@ -1,8 +1,33 @@
|
|||||||
FROM node:lts-alpine
|
# This Dockerfile should be built from the project root directory:
|
||||||
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
|
# docker build -f match_collector/Dockerfile -t buildpath-match_collector .
|
||||||
|
|
||||||
|
FROM node:current-alpine AS build
|
||||||
|
RUN mkdir -p /home/node/app && chown -R node:node /home/node/app
|
||||||
WORKDIR /home/node/app
|
WORKDIR /home/node/app
|
||||||
USER node
|
|
||||||
COPY --chown=node:node package*.json ./
|
# Copy and build dragon-item-parser first
|
||||||
|
COPY --chown=node:node dragon-item-parser/package*.json ./dragon-item-parser/
|
||||||
|
COPY --chown=node:node dragon-item-parser/tsconfig.json ./dragon-item-parser/
|
||||||
|
COPY --chown=node:node dragon-item-parser/src ./dragon-item-parser/src
|
||||||
|
WORKDIR /home/node/app/dragon-item-parser
|
||||||
|
RUN npm install && npm run build
|
||||||
|
|
||||||
|
# Build match_collector
|
||||||
|
WORKDIR /home/node/app
|
||||||
|
COPY --chown=node:node match_collector/package*.json ./match_collector/
|
||||||
|
WORKDIR /home/node/app/match_collector
|
||||||
RUN npm install
|
RUN npm install
|
||||||
COPY --chown=node:node . .
|
COPY --chown=node:node match_collector/. .
|
||||||
CMD /bin/sh -c "node --import=tsx index.ts; sleep 20h"
|
|
||||||
|
FROM node:current-alpine
|
||||||
|
# Install su-exec for dropping privileges
|
||||||
|
RUN apk add --no-cache su-exec
|
||||||
|
RUN mkdir -p /home/node/app && chown -R node:node /home/node/app
|
||||||
|
WORKDIR /home/node/app
|
||||||
|
COPY --from=build --chown=node:node /home/node/app/match_collector/node_modules ./node_modules
|
||||||
|
COPY --from=build --chown=node:node /home/node/app/match_collector/. .
|
||||||
|
COPY --chown=node:node match_collector/docker-entrypoint.sh /usr/local/bin/
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
# Run entrypoint as root to fix permissions, then drop to node user
|
||||||
|
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||||
|
CMD ["/bin/sh", "-c", "node --import=tsx src/index.ts; sleep 12h"]
|
||||||
|
|||||||
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 "$@"
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
const base = 'https://euw1.api.riotgames.com'
|
|
||||||
const api_key = process.env.RIOT_API_KEY
|
|
||||||
const sleep_minutes = 12
|
|
||||||
|
|
||||||
import { MongoClient } from 'mongodb'
|
|
||||||
|
|
||||||
import champion_stat from './champion_stat'
|
|
||||||
|
|
||||||
main()
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
// Check if we're in development mode with pre-loaded data
|
|
||||||
if (process.env.NODE_ENV === 'development' && process.env.USE_IMPORTED_DATA === 'true') {
|
|
||||||
console.log('MatchCollector - Development mode with pre-loaded data')
|
|
||||||
await runWithPreloadedData()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Original production mode
|
|
||||||
console.log('MatchCollector - Hello !')
|
|
||||||
const client = await connectToDatabase()
|
|
||||||
const [latestPatch, latestPatchTime] = await fetchLatestPatchDate(client)
|
|
||||||
console.log(
|
|
||||||
'Connected to database, latest patch ' + latestPatch + ' was epoch: ' + latestPatchTime
|
|
||||||
)
|
|
||||||
|
|
||||||
const alreadySeenGameList = await alreadySeenGames(client, latestPatch)
|
|
||||||
console.log('We already have ' + alreadySeenGameList.length + ' matches for this patch !')
|
|
||||||
|
|
||||||
console.log('Using RIOT_API_KEY: ' + api_key)
|
|
||||||
if (api_key != null && api_key != undefined && api_key != '') {
|
|
||||||
const challengerLeague = await fetchChallengerLeague()
|
|
||||||
console.log('ChallengerLeague: got ' + challengerLeague.entries.length + ' entries')
|
|
||||||
|
|
||||||
const gameList = []
|
|
||||||
let i = 0
|
|
||||||
for (const challenger of challengerLeague.entries) {
|
|
||||||
console.log('Entry ' + i + '/' + challengerLeague.entries.length + ' ...')
|
|
||||||
const puuid = challenger.puuid
|
|
||||||
const challengerGameList = await summonerGameList(puuid, latestPatchTime)
|
|
||||||
for (const game of challengerGameList) {
|
|
||||||
if (!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
|
|
||||||
gameList.push(game)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Games: got ' + gameList.length + ' entries')
|
|
||||||
i = 0
|
|
||||||
for (const game of gameList) {
|
|
||||||
console.log('Entry ' + i + '/' + gameList.length + ' ...')
|
|
||||||
const gameMatch = await match(game)
|
|
||||||
const gameTimeline = await matchTimeline(game)
|
|
||||||
gameMatch.timeline = gameTimeline
|
|
||||||
await saveMatch(client, gameMatch, latestPatch)
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Generating stats...')
|
|
||||||
await champion_stat.makeChampionsStats(client, latestPatch)
|
|
||||||
|
|
||||||
console.log('All done. Closing client.')
|
|
||||||
await client.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRateLimit(url: URL): Promise<Response> {
|
|
||||||
let response = await fetch(url)
|
|
||||||
if (response.status == 429) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, sleep_minutes * 60 * 1000))
|
|
||||||
response = await handleRateLimit(url)
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleError(response: Response) {
|
|
||||||
if (!response.ok) {
|
|
||||||
console.log(
|
|
||||||
'Error during fetch(' +
|
|
||||||
response.url +
|
|
||||||
'): STATUS ' +
|
|
||||||
response.status +
|
|
||||||
' (' +
|
|
||||||
response.statusText +
|
|
||||||
')'
|
|
||||||
)
|
|
||||||
process.exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectToDatabase() {
|
|
||||||
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
|
|
||||||
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
|
|
||||||
if (
|
|
||||||
process.env.MONGO_URI != undefined &&
|
|
||||||
process.env.MONGO_URI != null &&
|
|
||||||
process.env.MONGO_URI != ''
|
|
||||||
) {
|
|
||||||
uri = process.env.MONGO_URI
|
|
||||||
}
|
|
||||||
const client = new MongoClient(uri)
|
|
||||||
await client.connect()
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLatestPatchDate(client) {
|
|
||||||
const database = client.db('patches')
|
|
||||||
const patches = database.collection('patches')
|
|
||||||
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
|
|
||||||
return [latestPatch.patch, Math.floor(latestPatch.date.valueOf() / 1000)]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchChallengerLeague() {
|
|
||||||
const queue = 'RANKED_SOLO_5x5'
|
|
||||||
const endpoint = `/lol/league/v4/challengerleagues/by-queue/${queue}`
|
|
||||||
const url = `${base}${endpoint}?api_key=${api_key}`
|
|
||||||
|
|
||||||
const challengerLeagueResponse = await handleRateLimit(new URL(url))
|
|
||||||
|
|
||||||
handleError(challengerLeagueResponse)
|
|
||||||
|
|
||||||
const challengerLeague = await challengerLeagueResponse.json()
|
|
||||||
return challengerLeague
|
|
||||||
}
|
|
||||||
|
|
||||||
async function summonerGameList(puuid, startTime) {
|
|
||||||
const base = 'https://europe.api.riotgames.com'
|
|
||||||
const endpoint = `/lol/match/v5/matches/by-puuid/${puuid}/ids`
|
|
||||||
const url = `${base}${endpoint}?queue=420&type=ranked&startTime=${startTime}&api_key=${api_key}`
|
|
||||||
|
|
||||||
const gameListResponse = await handleRateLimit(new URL(url))
|
|
||||||
handleError(gameListResponse)
|
|
||||||
const gameList = await gameListResponse.json()
|
|
||||||
|
|
||||||
return gameList
|
|
||||||
}
|
|
||||||
|
|
||||||
async function match(matchId) {
|
|
||||||
const base = 'https://europe.api.riotgames.com'
|
|
||||||
const endpoint = `/lol/match/v5/matches/${matchId}`
|
|
||||||
const url = `${base}${endpoint}?api_key=${api_key}`
|
|
||||||
|
|
||||||
const matchResponse = await handleRateLimit(new URL(url))
|
|
||||||
handleError(matchResponse)
|
|
||||||
const match = await matchResponse.json()
|
|
||||||
|
|
||||||
return match
|
|
||||||
}
|
|
||||||
|
|
||||||
async function matchTimeline(matchId) {
|
|
||||||
const base = 'https://europe.api.riotgames.com'
|
|
||||||
const endpoint = `/lol/match/v5/matches/${matchId}/timeline`
|
|
||||||
const url = `${base}${endpoint}?api_key=${api_key}`
|
|
||||||
|
|
||||||
const timelineResponse = await handleRateLimit(new URL(url))
|
|
||||||
handleError(timelineResponse)
|
|
||||||
const timeline = await timelineResponse.json()
|
|
||||||
|
|
||||||
return timeline
|
|
||||||
}
|
|
||||||
|
|
||||||
async function alreadySeenGames(client, latestPatch) {
|
|
||||||
const database = client.db('matches')
|
|
||||||
const matches = database.collection(latestPatch)
|
|
||||||
|
|
||||||
const alreadySeen = await matches.distinct('metadata.matchId')
|
|
||||||
return alreadySeen
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveMatch(client, match, latestPatch) {
|
|
||||||
const database = client.db('matches')
|
|
||||||
const matches = database.collection(latestPatch)
|
|
||||||
await matches.insertOne(match)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Development mode function that generates stats from pre-loaded data
|
|
||||||
*/
|
|
||||||
async function runWithPreloadedData() {
|
|
||||||
console.log('Using pre-loaded match data for development')
|
|
||||||
|
|
||||||
const client = await connectToDatabase()
|
|
||||||
try {
|
|
||||||
const [latestPatch] = await fetchLatestPatchDate(client)
|
|
||||||
console.log(`Latest patch: ${latestPatch}`)
|
|
||||||
|
|
||||||
// Check if we have matches for this patch
|
|
||||||
const matchesDb = client.db('matches')
|
|
||||||
const collections = await matchesDb.listCollections().toArray()
|
|
||||||
const patchCollections = collections.map(c => c.name).filter(name => name === latestPatch)
|
|
||||||
|
|
||||||
if (patchCollections.length === 0) {
|
|
||||||
console.error(`❌ No match data found for patch ${latestPatch}`)
|
|
||||||
console.log('💡 Please run the data import script first:')
|
|
||||||
console.log(' node dev/scripts/setup-db.js')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Found ${patchCollections.length} match collection(s)`)
|
|
||||||
|
|
||||||
// Generate stats for each patch with data
|
|
||||||
for (const patch of patchCollections) {
|
|
||||||
console.log(`Generating stats for patch ${patch}...`)
|
|
||||||
await champion_stat.makeChampionsStats(client, patch)
|
|
||||||
console.log(`Stats generated for patch ${patch}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('🎉 All stats generated successfully!')
|
|
||||||
console.log('🚀 Your development database is ready for frontend testing!')
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Error in development mode:', error)
|
|
||||||
throw error
|
|
||||||
} finally {
|
|
||||||
await client.close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
type ItemTree = {
|
|
||||||
data: number | undefined
|
|
||||||
count: number
|
|
||||||
children: Array<ItemTree>
|
|
||||||
}
|
|
||||||
|
|
||||||
function treeInit(): ItemTree {
|
|
||||||
return { data: undefined, count: 0, children: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
function treeNode(data: number, count: number): ItemTree {
|
|
||||||
return { data: data, count: count, children: [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Merge a node with an item tree
|
|
||||||
*/
|
|
||||||
function nodeMerge(itemtree: ItemTree, node: ItemTree) {
|
|
||||||
const item = node.data
|
|
||||||
const count = node.count
|
|
||||||
let next: ItemTree | null = null
|
|
||||||
|
|
||||||
// Try to find an existing node in this tree level with same item
|
|
||||||
for (const node of itemtree.children) {
|
|
||||||
if (node.data == item) {
|
|
||||||
node.count += 1
|
|
||||||
next = node
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not found, add item node at this level
|
|
||||||
if (next == null) {
|
|
||||||
next = treeNode(item, count)
|
|
||||||
itemtree.children.push(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Merge a full build path with an existing item tree
|
|
||||||
*/
|
|
||||||
function treeMerge(itemtree: ItemTree, items: Array<number>) {
|
|
||||||
let current = itemtree
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
current = nodeMerge(current, { data: item, count: 1, children: [] })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function treeCutBranches(itemtree: ItemTree, thresholdCount: number, thresholdPerc: number) {
|
|
||||||
// Remove branches that are above threshold count
|
|
||||||
while (itemtree.children.length > thresholdCount) {
|
|
||||||
const leastUsedBranch = itemtree.children.reduce(
|
|
||||||
(a, b) => (Math.min(a.count, b.count) == a.count ? a : b),
|
|
||||||
{ data: undefined, count: +Infinity, children: [] }
|
|
||||||
)
|
|
||||||
itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove branches that are of too low usage
|
|
||||||
const toRemove: Array<ItemTree> = []
|
|
||||||
for (const child of itemtree.children) {
|
|
||||||
if (child.count / itemtree.count < thresholdPerc) {
|
|
||||||
toRemove.push(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const tr of toRemove) {
|
|
||||||
itemtree.children.splice(itemtree.children.indexOf(tr), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
itemtree.children.map(x => treeCutBranches(x, thresholdCount, thresholdPerc))
|
|
||||||
}
|
|
||||||
|
|
||||||
function treeSort(itemtree: ItemTree) {
|
|
||||||
itemtree.children.sort((a, b) => b.count - a.count)
|
|
||||||
|
|
||||||
for (const item of itemtree.children) {
|
|
||||||
treeSort(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Deep clone an ItemTree
|
|
||||||
*/
|
|
||||||
function treeClone(tree: ItemTree): ItemTree {
|
|
||||||
return {
|
|
||||||
data: tree.data,
|
|
||||||
count: tree.count,
|
|
||||||
children: tree.children.map(child => treeClone(child))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Merge two ItemTrees into one
|
|
||||||
*/
|
|
||||||
function treeMergeTree(t1: ItemTree, t2: ItemTree): ItemTree {
|
|
||||||
// Merge counts for the root
|
|
||||||
t1.count += t2.count
|
|
||||||
|
|
||||||
// Merge children from t2 into t1
|
|
||||||
for (const child2 of t2.children) {
|
|
||||||
// Find matching child in t1 (same data value)
|
|
||||||
const matchingChild = t1.children.find(child1 => child1.data === child2.data)
|
|
||||||
|
|
||||||
if (matchingChild) {
|
|
||||||
// Recursively merge matching children
|
|
||||||
treeMergeTree(matchingChild, child2)
|
|
||||||
} else {
|
|
||||||
// Add a deep copy of child2 to t1
|
|
||||||
t1.children.push(treeClone(child2))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return t1
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Flatten an ItemTree into a Set of item numbers
|
|
||||||
*/
|
|
||||||
function treeToSet(itemtree: ItemTree): Set<number> {
|
|
||||||
const items: Set<number> = new Set()
|
|
||||||
|
|
||||||
function traverse(node: ItemTree) {
|
|
||||||
if (node.data !== undefined) {
|
|
||||||
items.add(node.data)
|
|
||||||
}
|
|
||||||
for (const child of node.children) {
|
|
||||||
traverse(child)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
traverse(itemtree)
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Calculate similarity between two trees as item sets.
|
|
||||||
* Returns a number between 0 and 1, where 1 means identical and 0 means completely different.
|
|
||||||
* Uses Jaccard similarity: |A ∩ B| / |A ∪ B|
|
|
||||||
* Sets included in one another will have similarity close to 1.
|
|
||||||
*/
|
|
||||||
function areTreeSimilars(t1: ItemTree, t2: ItemTree): number {
|
|
||||||
const set1 = treeToSet(t1)
|
|
||||||
const set2 = treeToSet(t2)
|
|
||||||
|
|
||||||
// Handle empty sets
|
|
||||||
if (set1.size === 0 && set2.size === 0) {
|
|
||||||
return 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate intersection
|
|
||||||
const intersection = new Set<number>()
|
|
||||||
for (const item of Array.from(set1)) {
|
|
||||||
if (set2.has(item)) {
|
|
||||||
intersection.add(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate union
|
|
||||||
const union = new Set<number>()
|
|
||||||
for (const item of Array.from(set1)) {
|
|
||||||
union.add(item)
|
|
||||||
}
|
|
||||||
for (const item of Array.from(set2)) {
|
|
||||||
union.add(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Jaccard similarity: |intersection| / |union|
|
|
||||||
const similarity = intersection.size / Math.min(set1.size, set2.size)
|
|
||||||
|
|
||||||
// Ensure result is between 0 and 1
|
|
||||||
return Math.max(0, Math.min(1, similarity))
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ItemTree, treeMerge, treeInit, treeCutBranches, treeSort, treeMergeTree, areTreeSimilars }
|
|
||||||
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,31 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "match_collector",
|
"name": "match_collector",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.ts",
|
"main": "dist/lib.js",
|
||||||
|
"types": "dist/lib.d.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/lib.d.ts",
|
||||||
|
"import": "./dist/lib.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
"lint": "eslint .",
|
"lint": "eslint ./src",
|
||||||
"lint:fix": "eslint --fix .",
|
"lint:fix": "eslint --fix ./src",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write ./src",
|
||||||
"format:check": "prettier --check ."
|
"format:check": "prettier --check ./src",
|
||||||
|
"dev": "node --import=tsx src/index.ts"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mongodb": "^6.10.0"
|
"dragon-item-parser": "file:../dragon-item-parser",
|
||||||
|
"mongodb": "^7.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@types/node": "^22.9.1",
|
"@types/node": "^25.6.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
||||||
"@typescript-eslint/parser": "^8.53.1",
|
"@typescript-eslint/parser": "^8.53.1",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.8.0",
|
"prettier": "^3.8.3",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.53.1"
|
"typescript-eslint": "^8.53.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
match_collector/src/api.ts
Normal file
136
match_collector/src/api.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
type Match = {
|
||||||
|
metadata: {
|
||||||
|
dataVersion: string
|
||||||
|
matchId: string
|
||||||
|
participants: string[]
|
||||||
|
}
|
||||||
|
info: {
|
||||||
|
endOfGameResult: string
|
||||||
|
gameCreation: number
|
||||||
|
gameDuration: number
|
||||||
|
gameEndTimestamp: number
|
||||||
|
gameId: number
|
||||||
|
gameMode: string
|
||||||
|
gameName: string
|
||||||
|
gameStartTimestamp: number
|
||||||
|
gameType: string
|
||||||
|
gameVersion: string
|
||||||
|
mapId: number
|
||||||
|
participants: Participant[]
|
||||||
|
platformId: string
|
||||||
|
queueId: number
|
||||||
|
teams: Team[]
|
||||||
|
tournamentCode: string
|
||||||
|
}
|
||||||
|
timeline: Timeline
|
||||||
|
}
|
||||||
|
|
||||||
|
type Timeline = {
|
||||||
|
metadata: {
|
||||||
|
dataVersion: string
|
||||||
|
matchId: string
|
||||||
|
participants: string[]
|
||||||
|
}
|
||||||
|
info: {
|
||||||
|
endOfGameResult: string
|
||||||
|
frameInterval: number
|
||||||
|
gameId: number
|
||||||
|
participants: {
|
||||||
|
participantId: number
|
||||||
|
puuid: string
|
||||||
|
}[]
|
||||||
|
frames: Frame[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Team = {
|
||||||
|
bans: Ban[]
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
objectives: any
|
||||||
|
teamId: number
|
||||||
|
win: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type Ban = any
|
||||||
|
|
||||||
|
type Participant = {
|
||||||
|
allInPing: number
|
||||||
|
assistMePings: number
|
||||||
|
assists: number
|
||||||
|
baronKills: number
|
||||||
|
bountyLevel: number
|
||||||
|
champExperience: number
|
||||||
|
champLevel: number
|
||||||
|
championId: number
|
||||||
|
championName: string
|
||||||
|
commandPings: number
|
||||||
|
championTransform: number
|
||||||
|
consumablesPurchased: number
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
challenges: any
|
||||||
|
damageDealtToBuildings: number
|
||||||
|
deaths: number
|
||||||
|
item0: number
|
||||||
|
item1: number
|
||||||
|
item2: number
|
||||||
|
item3: number
|
||||||
|
item4: number
|
||||||
|
item5: number
|
||||||
|
item6: number
|
||||||
|
itemsPurchased: number
|
||||||
|
kills: number
|
||||||
|
lane: string
|
||||||
|
participantId: number
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
perks: any
|
||||||
|
puuid: string
|
||||||
|
summoner1Id: number
|
||||||
|
summoner2Id: number
|
||||||
|
summonerId: string
|
||||||
|
teamId: number
|
||||||
|
teamPosition: string
|
||||||
|
win: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Frame = {
|
||||||
|
events: Event[]
|
||||||
|
participantFrames: {
|
||||||
|
'1': ParticipantFrame
|
||||||
|
'2': ParticipantFrame
|
||||||
|
'3': ParticipantFrame
|
||||||
|
'4': ParticipantFrame
|
||||||
|
'5': ParticipantFrame
|
||||||
|
'6': ParticipantFrame
|
||||||
|
'7': ParticipantFrame
|
||||||
|
'8': ParticipantFrame
|
||||||
|
'9': ParticipantFrame
|
||||||
|
'10': ParticipantFrame
|
||||||
|
}
|
||||||
|
timestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParticipantFrame = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
championStats: any
|
||||||
|
currentGold: number
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
damageStats: any
|
||||||
|
goldPerSecond: number
|
||||||
|
jungleMinionsKilled: number
|
||||||
|
level: number
|
||||||
|
minionsKilled: number
|
||||||
|
participantId: number
|
||||||
|
position: {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
timeEnemySpentControlled: number
|
||||||
|
totalGold: number
|
||||||
|
xp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type Event = any
|
||||||
|
|
||||||
|
export { Match, Timeline, Team, Ban, Participant, Frame, Event }
|
||||||
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,21 +1,45 @@
|
|||||||
function sameArrays(array1: Array<number>, array2: Array<number>) {
|
import { MongoClient } from 'mongodb'
|
||||||
|
import {
|
||||||
|
GoldAdvantageTag,
|
||||||
|
treeInit,
|
||||||
|
treeMerge,
|
||||||
|
treeCutBranches,
|
||||||
|
treeSort,
|
||||||
|
treeMergeTree,
|
||||||
|
areTreeSimilars,
|
||||||
|
treeDeriveTags
|
||||||
|
} from './item_tree'
|
||||||
|
import { PLATFORM_KEYS } from './platform'
|
||||||
|
import {
|
||||||
|
initItemDict as initFirstBackItemDict,
|
||||||
|
extractFirstBackFromMatch,
|
||||||
|
groupFirstBacksByItemSet
|
||||||
|
} from './first_back'
|
||||||
|
|
||||||
|
import { Match, Timeline, Participant, Frame } from './api'
|
||||||
|
import type {
|
||||||
|
Rune,
|
||||||
|
InternalBuild,
|
||||||
|
InternalBuildWithStartItems,
|
||||||
|
InternalLaneData,
|
||||||
|
InternalChampionData,
|
||||||
|
FirstBackData
|
||||||
|
} from './types'
|
||||||
|
|
||||||
|
// Type aliases for internal use
|
||||||
|
type Builds = InternalBuild[]
|
||||||
|
type Build = InternalBuild
|
||||||
|
type BuildWithStartItems = InternalBuildWithStartItems
|
||||||
|
type LaneData = InternalLaneData
|
||||||
|
type ChampionData = InternalChampionData
|
||||||
|
|
||||||
|
function sameArrays(array1: number[], array2: number[]) {
|
||||||
if (array1.length != array2.length) return false
|
if (array1.length != array2.length) return false
|
||||||
for (const e of array1) {
|
for (const e of array1) {
|
||||||
if (!array2.includes(e)) return false
|
if (!array2.includes(e)) return false
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
import { MongoClient } from 'mongodb'
|
|
||||||
import {
|
|
||||||
ItemTree,
|
|
||||||
treeInit,
|
|
||||||
treeMerge,
|
|
||||||
treeCutBranches,
|
|
||||||
treeSort,
|
|
||||||
treeMergeTree,
|
|
||||||
areTreeSimilars
|
|
||||||
} from './item_tree'
|
|
||||||
const itemDict = new Map()
|
const itemDict = new Map()
|
||||||
|
|
||||||
async function itemList() {
|
async function itemList() {
|
||||||
@@ -42,73 +66,8 @@ function arrayRemovePercentage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Rune = {
|
|
||||||
count: number
|
|
||||||
primaryStyle: number
|
|
||||||
secondaryStyle: number
|
|
||||||
selections: Array<number>
|
|
||||||
pickrate?: number
|
|
||||||
}
|
|
||||||
type Build = {
|
|
||||||
runeKeystone: number
|
|
||||||
runes: Array<Rune>
|
|
||||||
items: ItemTree
|
|
||||||
bootsFirstCount: number
|
|
||||||
bootsFirst?: number
|
|
||||||
count: number
|
|
||||||
suppItems: Array<{ data: number; count: number }>
|
|
||||||
boots: Array<{ data: number; count: number }>
|
|
||||||
pickrate?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type BuildWithStartItems = {
|
|
||||||
runeKeystone: number
|
|
||||||
runes: Array<Rune>
|
|
||||||
items: ItemTree
|
|
||||||
bootsFirst?: number
|
|
||||||
bootsFirstCount: number
|
|
||||||
count: number
|
|
||||||
startItems: Array<{ data: number; count: number }>
|
|
||||||
suppItems: Array<{ data: number; count: number }>
|
|
||||||
boots: Array<{ data: number; count: number }>
|
|
||||||
pickrate?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type Builds = Build[]
|
|
||||||
type Champion = {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
alias: string
|
|
||||||
}
|
|
||||||
type MatchupData = {
|
|
||||||
championId: number
|
|
||||||
winrate: number
|
|
||||||
games: number
|
|
||||||
championName: string
|
|
||||||
championAlias: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type LaneData = {
|
|
||||||
data: string
|
|
||||||
count: number
|
|
||||||
winningMatches: number
|
|
||||||
losingMatches: number
|
|
||||||
winrate: number
|
|
||||||
pickrate: number
|
|
||||||
builds: Builds
|
|
||||||
matchups?: Array<MatchupData>
|
|
||||||
summonerSpells: Array<{ id: number; count: number; pickrate?: number }>
|
|
||||||
}
|
|
||||||
type ChampionData = {
|
|
||||||
champion: Champion
|
|
||||||
winningMatches: number
|
|
||||||
losingMatches: number
|
|
||||||
lanes: Array<LaneData>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to create rune configuration from participant
|
// Helper function to create rune configuration from participant
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
function createRuneConfiguration(participant: Participant): Rune {
|
||||||
function createRuneConfiguration(participant: any): Rune {
|
|
||||||
const primaryStyle = participant.perks.styles[0].style
|
const primaryStyle = participant.perks.styles[0].style
|
||||||
const secondaryStyle = participant.perks.styles[1].style
|
const secondaryStyle = participant.perks.styles[1].style
|
||||||
const selections: Array<number> = []
|
const selections: Array<number> = []
|
||||||
@@ -126,8 +85,7 @@ function createRuneConfiguration(participant: any): Rune {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find or create a build for the given rune keystone
|
// Find or create a build for the given rune keystone
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
function findOrCreateBuild(builds: Builds, participant: Participant): Build {
|
||||||
function findOrCreateBuild(builds: Builds, participant: any): Build {
|
|
||||||
const keystone = participant.perks.styles[0].selections[0].perk
|
const keystone = participant.perks.styles[0].selections[0].perk
|
||||||
const runeConfig = createRuneConfiguration(participant)
|
const runeConfig = createRuneConfiguration(participant)
|
||||||
|
|
||||||
@@ -166,24 +124,73 @@ function findOrCreateBuild(builds: Builds, participant: any): Build {
|
|||||||
return newBuild
|
return newBuild
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate gold advantage at the time of item purchase
|
||||||
|
// Returns 'ahead', 'behind', or 'even' based on gold difference
|
||||||
|
function calculateGoldAdvantage(
|
||||||
|
match: Match,
|
||||||
|
frame: Frame,
|
||||||
|
participantIndex: number
|
||||||
|
): GoldAdvantageTag {
|
||||||
|
const GOLD_THRESHOLD = 1000 // 1000 gold difference threshold
|
||||||
|
|
||||||
|
const participantFrames = [
|
||||||
|
frame.participantFrames[1],
|
||||||
|
frame.participantFrames[2],
|
||||||
|
frame.participantFrames[3],
|
||||||
|
frame.participantFrames[4],
|
||||||
|
frame.participantFrames[5],
|
||||||
|
frame.participantFrames[6],
|
||||||
|
frame.participantFrames[7],
|
||||||
|
frame.participantFrames[8],
|
||||||
|
frame.participantFrames[9],
|
||||||
|
frame.participantFrames[10]
|
||||||
|
]
|
||||||
|
|
||||||
|
// Find the participant's team
|
||||||
|
const participantFrame = participantFrames[participantIndex - 1]
|
||||||
|
const participantGold = participantFrame.totalGold
|
||||||
|
if (!participantFrame) return 'even'
|
||||||
|
|
||||||
|
const participant = match.info.participants.find(
|
||||||
|
x => x.participantId == participantFrame.participantId
|
||||||
|
)!
|
||||||
|
|
||||||
|
const opponent = match.info.participants.find(
|
||||||
|
x => x.teamPosition === participant.teamPosition && x.teamId != participant.teamId
|
||||||
|
)
|
||||||
|
if (opponent == undefined) return 'even'
|
||||||
|
|
||||||
|
const opponentGold = participantFrames.find(
|
||||||
|
x => x.participantId == opponent.participantId
|
||||||
|
)!.totalGold
|
||||||
|
|
||||||
|
const goldDiff = participantGold - opponentGold
|
||||||
|
|
||||||
|
if (goldDiff >= GOLD_THRESHOLD) return 'ahead'
|
||||||
|
if (goldDiff <= -GOLD_THRESHOLD) return 'behind'
|
||||||
|
return 'even'
|
||||||
|
}
|
||||||
|
|
||||||
function handleMatchBuilds(
|
function handleMatchBuilds(
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
match: Match,
|
||||||
timeline: any,
|
participant: Participant,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
participant: any,
|
|
||||||
participantIndex: number,
|
participantIndex: number,
|
||||||
builds: Builds
|
builds: Builds,
|
||||||
) {
|
platform?: string
|
||||||
|
): { build: Build; startItemId: number | undefined } {
|
||||||
|
const timeline: Timeline = match.timeline
|
||||||
|
|
||||||
// Find or create the build for this participant's rune configuration
|
// Find or create the build for this participant's rune configuration
|
||||||
const build = findOrCreateBuild(builds, participant)
|
const build = findOrCreateBuild(builds, participant)
|
||||||
build.count += 1
|
build.count += 1
|
||||||
|
|
||||||
const items: Array<number> = []
|
const items: Array<{ itemId: number; goldAdvantage: GoldAdvantageTag; platform?: string }> = []
|
||||||
|
let startItemId: number | undefined = undefined
|
||||||
for (const frame of timeline.info.frames) {
|
for (const frame of timeline.info.frames) {
|
||||||
for (const event of frame.events) {
|
for (const event of frame.events) {
|
||||||
if (event.participantId != participantIndex) continue
|
if (event.participantId != participantIndex) continue
|
||||||
if (event.type == 'ITEM_UNDO') {
|
if (event.type == 'ITEM_UNDO') {
|
||||||
if (items.length > 0 && items[items.length - 1] == event.beforeId) {
|
if (items.length > 0 && items[items.length - 1].itemId == event.beforeId) {
|
||||||
items.pop()
|
items.pop()
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -212,17 +219,10 @@ function handleMatchBuilds(
|
|||||||
}
|
}
|
||||||
if (event.type != 'ITEM_PURCHASED') continue
|
if (event.type != 'ITEM_PURCHASED') continue
|
||||||
|
|
||||||
// Handle boots upgrades
|
|
||||||
if (
|
|
||||||
itemInfo.requiredBuffCurrencyName == 'Feats_NoxianBootPurchaseBuff' ||
|
|
||||||
itemInfo.requiredBuffCurrencyName == 'Feats_SpecialQuestBootBuff'
|
|
||||||
) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle boots differently
|
// Handle boots differently
|
||||||
if (itemInfo.categories.includes('Boots')) {
|
if (itemInfo.categories.includes('Boots')) {
|
||||||
if (itemInfo.to.length == 0 || (itemInfo.to[0] >= 3171 && itemInfo.to[0] <= 3176)) {
|
// Ignore basic boots, only count Tier 2 boots
|
||||||
|
if (event.itemId != 1001) {
|
||||||
// Check for bootsFirst
|
// Check for bootsFirst
|
||||||
if (items.length < 2) {
|
if (items.length < 2) {
|
||||||
build.bootsFirstCount += 1
|
build.bootsFirstCount += 1
|
||||||
@@ -251,7 +251,9 @@ function handleMatchBuilds(
|
|||||||
if (itemInfo.to.length != 0 && (items.length >= 1 || participant.teamPosition === 'UTILITY'))
|
if (itemInfo.to.length != 0 && (items.length >= 1 || participant.teamPosition === 'UTILITY'))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
items.push(event.itemId)
|
// Calculate gold advantage at time of purchase
|
||||||
|
const goldAdvantage = calculateGoldAdvantage(match, frame, participantIndex)
|
||||||
|
items.push({ itemId: event.itemId, goldAdvantage, platform })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,16 +261,19 @@ function handleMatchBuilds(
|
|||||||
// This tree includes start item as the root, then branching paths
|
// This tree includes start item as the root, then branching paths
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
treeMerge(build.items, items)
|
treeMerge(build.items, items)
|
||||||
|
// The first item is the starter item
|
||||||
|
startItemId = items[0].itemId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { build, startItemId }
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
function handleMatch(match: Match, champions: Map<number, ChampionData>, platform?: string) {
|
||||||
function handleMatch(match: any, champions: Map<number, ChampionData>) {
|
|
||||||
let participantIndex = 0
|
let participantIndex = 0
|
||||||
for (const participant of match.info.participants) {
|
for (const participant of match.info.participants) {
|
||||||
participantIndex += 1
|
participantIndex += 1
|
||||||
const championId = participant.championId
|
const championId = participant.championId
|
||||||
const champion = champions.get(championId)
|
const champion = champions.get(championId)!
|
||||||
|
|
||||||
// Lanes
|
// Lanes
|
||||||
let lane = champion.lanes.find(x => x.data == participant.teamPosition)
|
let lane = champion.lanes.find(x => x.data == participant.teamPosition)
|
||||||
@@ -282,11 +287,21 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
|
|||||||
winrate: 0,
|
winrate: 0,
|
||||||
pickrate: 0,
|
pickrate: 0,
|
||||||
summonerSpells: [],
|
summonerSpells: [],
|
||||||
matchups: []
|
matchups: [],
|
||||||
|
regionDistribution: { euw: 0, eun: 0, na: 0, kr: 0 }
|
||||||
}
|
}
|
||||||
champion.lanes.push(lane)
|
champion.lanes.push(lane)
|
||||||
} else lane.count += 1
|
} else lane.count += 1
|
||||||
|
|
||||||
|
// Track region distribution for this lane
|
||||||
|
if (lane.regionDistribution && platform) {
|
||||||
|
const platformKey = platform.toLowerCase()
|
||||||
|
const regionKey = PLATFORM_KEYS[platformKey]
|
||||||
|
if (regionKey) {
|
||||||
|
lane.regionDistribution[regionKey]!++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize matchups if not present
|
// Initialize matchups if not present
|
||||||
if (!lane.matchups) {
|
if (!lane.matchups) {
|
||||||
lane.matchups = []
|
lane.matchups = []
|
||||||
@@ -333,7 +348,7 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
|
|||||||
matchup.winrate = (matchup.winrate * (matchup.games - 1)) / matchup.games
|
matchup.winrate = (matchup.winrate * (matchup.games - 1)) / matchup.games
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const opponentChampion = champions.get(opponentChampionId)
|
const opponentChampion = champions.get(opponentChampionId)!
|
||||||
|
|
||||||
lane.matchups.push({
|
lane.matchups.push({
|
||||||
championId: opponentChampionId,
|
championId: opponentChampionId,
|
||||||
@@ -346,17 +361,36 @@ function handleMatch(match: any, champions: Map<number, ChampionData>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Items and runes (builds)
|
// Items and runes (builds)
|
||||||
handleMatchBuilds(match.timeline, participant, participantIndex, lane.builds)
|
const { build, startItemId } = handleMatchBuilds(
|
||||||
|
match,
|
||||||
|
participant,
|
||||||
|
participantIndex,
|
||||||
|
lane.builds,
|
||||||
|
platform
|
||||||
|
)
|
||||||
|
|
||||||
|
// First back data - store at build level with start item tracking
|
||||||
|
const firstBackData = extractFirstBackFromMatch(match, participantIndex)
|
||||||
|
if (firstBackData) {
|
||||||
|
if (!build.firstBacksRaw) {
|
||||||
|
build.firstBacksRaw = []
|
||||||
|
}
|
||||||
|
// Include the starter item ID for proper filtering when splitting builds
|
||||||
|
firstBackData.startItemId = startItemId
|
||||||
|
build.firstBacksRaw.push(firstBackData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMatchList(
|
async function handleMatchList(
|
||||||
client: MongoClient,
|
client: MongoClient,
|
||||||
patch: string,
|
patch: string,
|
||||||
champions: Map<number, ChampionData>
|
champions: Map<number, ChampionData>,
|
||||||
|
platform?: string
|
||||||
) {
|
) {
|
||||||
const database = client.db('matches')
|
const database = client.db('matches')
|
||||||
const matches = database.collection(patch)
|
const collectionName = platform ? `${patch}_${platform}` : patch
|
||||||
|
const matches = database.collection(collectionName)
|
||||||
const allMatches = matches.find()
|
const allMatches = matches.find()
|
||||||
const totalMatches: number = await matches.countDocuments()
|
const totalMatches: number = await matches.countDocuments()
|
||||||
|
|
||||||
@@ -366,7 +400,7 @@ async function handleMatchList(
|
|||||||
'\rComputing champion stats, game entry ' + currentMatch + '/' + totalMatches + ' ... '
|
'\rComputing champion stats, game entry ' + currentMatch + '/' + totalMatches + ' ... '
|
||||||
)
|
)
|
||||||
currentMatch += 1
|
currentMatch += 1
|
||||||
handleMatch(match, champions)
|
handleMatch(match as unknown as Match, champions, platform)
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalMatches
|
return totalMatches
|
||||||
@@ -389,10 +423,10 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS
|
|||||||
) {
|
) {
|
||||||
const startItems = []
|
const startItems = []
|
||||||
let items = build.items.children[0]
|
let items = build.items.children[0]
|
||||||
startItems.push({ data: build.items.children[0].data, count: build.items.children[0].count })
|
startItems.push({ data: build.items.children[0].data!, count: build.items.children[0].count })
|
||||||
build.items.children[0].data = undefined
|
build.items.children[0].data = undefined
|
||||||
if (build.items.children.length > 1) {
|
if (build.items.children.length > 1) {
|
||||||
startItems.push({ data: build.items.children[1].data, count: build.items.children[1].count })
|
startItems.push({ data: build.items.children[1].data!, count: build.items.children[1].count })
|
||||||
build.items.children[1].data = undefined
|
build.items.children[1].data = undefined
|
||||||
items = treeMergeTree(build.items.children[0], build.items.children[1])
|
items = treeMergeTree(build.items.children[0], build.items.children[1])
|
||||||
}
|
}
|
||||||
@@ -406,7 +440,8 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS
|
|||||||
startItems,
|
startItems,
|
||||||
suppItems: build.suppItems,
|
suppItems: build.suppItems,
|
||||||
boots: build.boots,
|
boots: build.boots,
|
||||||
pickrate: build.pickrate
|
pickrate: build.pickrate,
|
||||||
|
firstBacksRaw: build.firstBacksRaw
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
@@ -414,15 +449,44 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS
|
|||||||
console.log(`Warning: for champion ${championName}, start item splits build variant.`)
|
console.log(`Warning: for champion ${championName}, start item splits build variant.`)
|
||||||
const builds = []
|
const builds = []
|
||||||
for (const c of build.items.children) {
|
for (const c of build.items.children) {
|
||||||
|
// Calculate the ratio for proportional distribution
|
||||||
|
const ratio = c.count / build.count
|
||||||
|
|
||||||
|
// Proportionally distribute boots counts
|
||||||
|
const scaledBoots = build.boots.map(b => ({
|
||||||
|
data: b.data,
|
||||||
|
count: Math.round(b.count * ratio)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Proportionally distribute suppItems counts
|
||||||
|
const scaledSuppItems = build.suppItems.map(s => ({
|
||||||
|
data: s.data,
|
||||||
|
count: Math.round(s.count * ratio)
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Proportionally distribute bootsFirstCount
|
||||||
|
const scaledBootsFirstCount = Math.round(build.bootsFirstCount * ratio)
|
||||||
|
|
||||||
|
// Filter firstBacksRaw by starter item
|
||||||
|
let filteredFirstBacksRaw: FirstBackData[] | undefined
|
||||||
|
if (build.firstBacksRaw && build.firstBacksRaw.length > 0) {
|
||||||
|
// Filter by the starter item ID that was tracked when storing firstBacksRaw
|
||||||
|
filteredFirstBacksRaw = build.firstBacksRaw.filter(fb => fb.startItemId === c.data)
|
||||||
|
if (filteredFirstBacksRaw.length === 0) {
|
||||||
|
filteredFirstBacksRaw = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
builds.push({
|
builds.push({
|
||||||
runeKeystone: build.runeKeystone,
|
runeKeystone: build.runeKeystone,
|
||||||
runes: build.runes,
|
runes: build.runes,
|
||||||
items: c,
|
items: c,
|
||||||
bootsFirstCount: build.bootsFirstCount,
|
bootsFirstCount: scaledBootsFirstCount,
|
||||||
count: c.count,
|
count: c.count,
|
||||||
startItems: [{ data: c.data, count: c.count }],
|
startItems: [{ data: c.data!, count: c.count }],
|
||||||
suppItems: build.suppItems,
|
suppItems: scaledSuppItems,
|
||||||
boots: build.boots
|
boots: scaledBoots,
|
||||||
|
firstBacksRaw: filteredFirstBacksRaw
|
||||||
})
|
})
|
||||||
c.data = undefined
|
c.data = undefined
|
||||||
}
|
}
|
||||||
@@ -506,6 +570,14 @@ function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems
|
|||||||
const runes = Array.from(runesMap.values())
|
const runes = Array.from(runesMap.values())
|
||||||
runes.sort((a, b) => b.count - a.count)
|
runes.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
// Merge first backs raw data
|
||||||
|
const firstBacksRaw: FirstBackData[] = []
|
||||||
|
for (const build of allSimilarBuilds) {
|
||||||
|
if (build.firstBacksRaw) {
|
||||||
|
firstBacksRaw.push(...build.firstBacksRaw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
merged.push({
|
merged.push({
|
||||||
runeKeystone: runes[0].selections[0],
|
runeKeystone: runes[0].selections[0],
|
||||||
runes: runes,
|
runes: runes,
|
||||||
@@ -514,7 +586,8 @@ function mergeBuildsForRunes(builds: BuildWithStartItems[]): BuildWithStartItems
|
|||||||
count: totalCount,
|
count: totalCount,
|
||||||
startItems: mergeItemCounts(allSimilarBuilds, b => b.startItems),
|
startItems: mergeItemCounts(allSimilarBuilds, b => b.startItems),
|
||||||
suppItems: mergeItemCounts(allSimilarBuilds, b => b.suppItems),
|
suppItems: mergeItemCounts(allSimilarBuilds, b => b.suppItems),
|
||||||
boots: mergeItemCounts(allSimilarBuilds, b => b.boots)
|
boots: mergeItemCounts(allSimilarBuilds, b => b.boots),
|
||||||
|
firstBacksRaw: firstBacksRaw.length > 0 ? firstBacksRaw : undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,6 +610,9 @@ function cleanupLaneBuilds(lane: LaneData) {
|
|||||||
treeCutBranches(build.items, 4, 0.05)
|
treeCutBranches(build.items, 4, 0.05)
|
||||||
treeSort(build.items)
|
treeSort(build.items)
|
||||||
|
|
||||||
|
// Derive tags from purchase patterns (gold advantage, region)
|
||||||
|
treeDeriveTags(build.items, lane.regionDistribution)
|
||||||
|
|
||||||
// Remove boots that are not within percentage threshold
|
// Remove boots that are not within percentage threshold
|
||||||
arrayRemovePercentage(build.boots, build.count, 0.05)
|
arrayRemovePercentage(build.boots, build.count, 0.05)
|
||||||
build.boots.sort((a, b) => b.count - a.count)
|
build.boots.sort((a, b) => b.count - a.count)
|
||||||
@@ -567,7 +643,7 @@ async function finalizeChampionStats(champion: ChampionData, totalMatches: numbe
|
|||||||
// Summoner spells
|
// Summoner spells
|
||||||
lane.summonerSpells.forEach(x => (x.pickrate = x.count / lane.count))
|
lane.summonerSpells.forEach(x => (x.pickrate = x.count / lane.count))
|
||||||
lane.summonerSpells.sort((a, b) => b.count - a.count)
|
lane.summonerSpells.sort((a, b) => b.count - a.count)
|
||||||
lane.summonerSpells = lane.summonerSpells.filter(x => x.pickrate >= 0.05)
|
lane.summonerSpells = lane.summonerSpells.filter(x => x.pickrate! >= 0.05)
|
||||||
|
|
||||||
// Cleaning up builds
|
// Cleaning up builds
|
||||||
cleanupLaneBuilds(lane)
|
cleanupLaneBuilds(lane)
|
||||||
@@ -589,6 +665,19 @@ async function finalizeChampionStats(champion: ChampionData, totalMatches: numbe
|
|||||||
// all along.
|
// all along.
|
||||||
lane.builds = mergeBuildsForRunes(lane.builds as BuildWithStartItems[])
|
lane.builds = mergeBuildsForRunes(lane.builds as BuildWithStartItems[])
|
||||||
cleanupLaneBuilds(lane)
|
cleanupLaneBuilds(lane)
|
||||||
|
|
||||||
|
// Process first backs at build level - group by item set
|
||||||
|
for (const build of lane.builds) {
|
||||||
|
if (build.firstBacksRaw && build.firstBacksRaw.length > 0) {
|
||||||
|
build.firstBacks = groupFirstBacksByItemSet(build.firstBacksRaw)
|
||||||
|
// Keep only top 7 groups
|
||||||
|
if (build.firstBacks!.length > 7) {
|
||||||
|
build.firstBacks = build.firstBacks!.slice(0, 7)
|
||||||
|
}
|
||||||
|
// Clean up raw data to save space
|
||||||
|
delete build.firstBacksRaw
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const lane of champion.lanes) {
|
for (const lane of champion.lanes) {
|
||||||
@@ -647,16 +736,19 @@ async function championList() {
|
|||||||
return list.slice(1)
|
return list.slice(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function makeChampionsStats(client: MongoClient, patch: string) {
|
async function makeChampionsStats(client: MongoClient, patch: string, platforms: string[] = []) {
|
||||||
const globalItems = await itemList()
|
const globalItems = await itemList()
|
||||||
for (const item of globalItems) {
|
for (const item of globalItems) {
|
||||||
itemDict.set(item.id, item)
|
itemDict.set(item.id, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize first back item dictionary
|
||||||
|
await initFirstBackItemDict()
|
||||||
|
|
||||||
const list = await championList()
|
const list = await championList()
|
||||||
console.log('Generating stats for ' + list.length + ' champions')
|
console.log('Generating stats for ' + list.length + ' champions')
|
||||||
|
|
||||||
// Pre-generate list of champions
|
// Pre-generate list of champions (shared across all platforms)
|
||||||
const champions: Map<number, ChampionData> = new Map()
|
const champions: Map<number, ChampionData> = new Map()
|
||||||
for (const champion of list) {
|
for (const champion of list) {
|
||||||
champions.set(champion.id, {
|
champions.set(champion.id, {
|
||||||
@@ -667,19 +759,28 @@ async function makeChampionsStats(client: MongoClient, patch: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loop through all matches to generate stats
|
// Process matches from all platforms, merging into the same champions map
|
||||||
const totalMatches = await handleMatchList(client, patch, champions)
|
let totalMatches = 0
|
||||||
|
for (const platform of platforms) {
|
||||||
|
console.log(`\n=== Processing matches from platform: ${platform} ===`)
|
||||||
|
const platformMatches = await handleMatchList(client, patch, champions, platform)
|
||||||
|
totalMatches += platformMatches
|
||||||
|
console.log(`Processed ${platformMatches} matches from ${platform}`)
|
||||||
|
}
|
||||||
|
|
||||||
// Finalize and save stats for every champion
|
console.log(`\n=== Total matches processed: ${totalMatches} ===`)
|
||||||
|
|
||||||
|
// Finalize and save stats to a single champions collection
|
||||||
const database = client.db('champions')
|
const database = client.db('champions')
|
||||||
const collection = database.collection(patch)
|
const collection = database.collection(patch)
|
||||||
for (const champion of list) {
|
for (const champion of list) {
|
||||||
const championInfo = await finalizeChampionStats(champions.get(champion.id), totalMatches)
|
const championInfo = await finalizeChampionStats(champions.get(champion.id)!, totalMatches)
|
||||||
await collection.updateOne({ id: champion.id }, { $set: championInfo }, { upsert: true })
|
await collection.updateOne({ id: champion.id }, { $set: championInfo }, { upsert: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create alias-index for better key-find
|
// Create alias-index for better key-find
|
||||||
await collection.createIndex({ alias: 1 })
|
await collection.createIndex({ alias: 1 })
|
||||||
|
console.log(`Stats saved to collection: ${patch}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default { makeChampionsStats }
|
export default { makeChampionsStats }
|
||||||
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
|
||||||
|
}
|
||||||
321
match_collector/src/index.ts
Normal file
321
match_collector/src/index.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
const api_key = process.env.RIOT_API_KEY
|
||||||
|
const sleep_minutes = 12
|
||||||
|
|
||||||
|
import { MongoClient } from 'mongodb'
|
||||||
|
|
||||||
|
import champion_stat from './champion_stat'
|
||||||
|
import { Match } from './api'
|
||||||
|
import { PLATFORMS, getPlatformBaseUrl, getRegionalBaseUrl, getRegionForPlatform } from './platform'
|
||||||
|
import { downloadCDragonAssets } from './cdragon_cache'
|
||||||
|
|
||||||
|
main()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract patch version from gameVersion string.
|
||||||
|
* gameVersion format is like "15.1.123.4567" -> we want "15.1"
|
||||||
|
*/
|
||||||
|
function extractPatchFromGameVersion(gameVersion: string): string {
|
||||||
|
const parts = gameVersion.split('.')
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0]}.${parts[1]}`
|
||||||
|
}
|
||||||
|
return gameVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the latest patch from existing match collections in the database.
|
||||||
|
* Collections are named like "15.1_EUW1", "15.2_NA1", etc.
|
||||||
|
*/
|
||||||
|
async function getLatestPatchFromCollections(client: MongoClient): Promise<string | null> {
|
||||||
|
const matchesDb = client.db('matches')
|
||||||
|
const collections = await matchesDb.listCollections().toArray()
|
||||||
|
const collectionNames = collections.map(c => c.name)
|
||||||
|
|
||||||
|
// Extract unique patch versions from collection names
|
||||||
|
const patches = new Set<string>()
|
||||||
|
for (const name of collectionNames) {
|
||||||
|
// Collection names are either "patch_platform" or just "patch"
|
||||||
|
const patch = name.split('_')[0]
|
||||||
|
if (patch && /^\d+\.\d+$/.test(patch)) {
|
||||||
|
patches.add(patch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patches.size === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort patches and return the latest (highest version number)
|
||||||
|
const sortedPatches = Array.from(patches).sort((a, b) => {
|
||||||
|
const [aMajor, aMinor] = a.split('.').map(Number)
|
||||||
|
const [bMajor, bMinor] = b.split('.').map(Number)
|
||||||
|
if (aMajor !== bMajor) return bMajor - aMajor
|
||||||
|
return bMinor - aMinor
|
||||||
|
})
|
||||||
|
|
||||||
|
return sortedPatches[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Check if we're in development mode with pre-loaded data
|
||||||
|
if (process.env.NODE_ENV === 'development' && process.env.USE_IMPORTED_DATA === 'true') {
|
||||||
|
console.log('MatchCollector - Development mode with pre-loaded data')
|
||||||
|
await runWithPreloadedData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Production mode: collect matches and organize by their gameVersion
|
||||||
|
console.log('MatchCollector - Hello !')
|
||||||
|
const client = await connectToDatabase()
|
||||||
|
console.log('Connected to database')
|
||||||
|
|
||||||
|
console.log('Using RIOT_API_KEY: ' + api_key)
|
||||||
|
if (api_key != null && api_key != undefined && api_key != '') {
|
||||||
|
// Iterate through all platforms
|
||||||
|
for (const [platform, region] of Object.entries(PLATFORMS)) {
|
||||||
|
console.log(`\n=== Processing platform: ${platform} (region: ${region}) ===`)
|
||||||
|
|
||||||
|
// Get already seen games for all patches (we'll check by gameVersion when saving)
|
||||||
|
const alreadySeenGameList = await alreadySeenGamesAllPatches(client, platform)
|
||||||
|
console.log('We already have ' + alreadySeenGameList.length + ' matches for this platform !')
|
||||||
|
|
||||||
|
const challengerLeague = await fetchChallengerLeague(platform)
|
||||||
|
console.log('ChallengerLeague: got ' + challengerLeague.entries.length + ' entries')
|
||||||
|
|
||||||
|
// Use 30 days ago as start time for collecting matches
|
||||||
|
const startTime = Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60
|
||||||
|
|
||||||
|
const gameList: string[] = []
|
||||||
|
let i = 0
|
||||||
|
for (const challenger of challengerLeague.entries) {
|
||||||
|
console.log('Entry ' + i + '/' + challengerLeague.entries.length + ' ...')
|
||||||
|
const puuid = challenger.puuid
|
||||||
|
const challengerGameList = await summonerGameList(puuid, startTime, region)
|
||||||
|
for (const game of challengerGameList) {
|
||||||
|
if (!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
|
||||||
|
gameList.push(game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Games: got ' + gameList.length + ' entries for ' + platform)
|
||||||
|
i = 0
|
||||||
|
for (const game of gameList) {
|
||||||
|
console.log('Entry ' + i + '/' + gameList.length + ' ...')
|
||||||
|
// Determine region from matchId (format: PLATFORM_matchId)
|
||||||
|
// Map platform prefix to regional routing value for match API
|
||||||
|
const matchPlatformPrefix = game.split('_')[0]
|
||||||
|
const matchRegion = getRegionForPlatform(matchPlatformPrefix) || region
|
||||||
|
const gameMatch = await match(game, matchRegion)
|
||||||
|
const gameTimeline = await matchTimeline(game, matchRegion)
|
||||||
|
gameMatch.timeline = gameTimeline
|
||||||
|
// Extract patch from gameVersion and save to appropriate collection
|
||||||
|
const patch = extractPatchFromGameVersion(gameMatch.info.gameVersion)
|
||||||
|
await saveMatch(client, gameMatch, patch, platform)
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the latest patch from collections and generate stats for it
|
||||||
|
const latestPatch = await getLatestPatchFromCollections(client)
|
||||||
|
if (latestPatch) {
|
||||||
|
console.log(`Generating stats for latest patch: ${latestPatch}...`)
|
||||||
|
await champion_stat.makeChampionsStats(client, latestPatch, Object.keys(PLATFORMS))
|
||||||
|
|
||||||
|
// Download CDragon assets for the latest patch
|
||||||
|
await downloadCDragonAssets(latestPatch)
|
||||||
|
} else {
|
||||||
|
console.log('No matches found in database, skipping stat generation')
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('All done. Closing client.')
|
||||||
|
await client.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRateLimit(url: URL): Promise<Response> {
|
||||||
|
let response = await fetch(url)
|
||||||
|
if (response.status == 429) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, sleep_minutes * 60 * 1000))
|
||||||
|
response = await handleRateLimit(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleError(response: Response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log(
|
||||||
|
'Error during fetch(' +
|
||||||
|
response.url +
|
||||||
|
'): STATUS ' +
|
||||||
|
response.status +
|
||||||
|
' (' +
|
||||||
|
response.statusText +
|
||||||
|
')'
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectToDatabase() {
|
||||||
|
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
|
||||||
|
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
|
||||||
|
if (
|
||||||
|
process.env.MONGO_URI != undefined &&
|
||||||
|
process.env.MONGO_URI != null &&
|
||||||
|
process.env.MONGO_URI != ''
|
||||||
|
) {
|
||||||
|
uri = process.env.MONGO_URI
|
||||||
|
}
|
||||||
|
const client = new MongoClient(uri)
|
||||||
|
await client.connect()
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchChallengerLeague(platform: string) {
|
||||||
|
const queue = 'RANKED_SOLO_5x5'
|
||||||
|
const endpoint = `/lol/league/v4/challengerleagues/by-queue/${queue}`
|
||||||
|
const baseUrl = getPlatformBaseUrl(platform)
|
||||||
|
const url = `${baseUrl}${endpoint}?api_key=${api_key}`
|
||||||
|
|
||||||
|
const challengerLeagueResponse = await handleRateLimit(new URL(url))
|
||||||
|
|
||||||
|
handleError(challengerLeagueResponse)
|
||||||
|
|
||||||
|
const challengerLeague = await challengerLeagueResponse.json()
|
||||||
|
return challengerLeague
|
||||||
|
}
|
||||||
|
|
||||||
|
async function summonerGameList(puuid: string, startTime: number, region: string) {
|
||||||
|
const baseUrl = getRegionalBaseUrl(region)
|
||||||
|
const endpoint = `/lol/match/v5/matches/by-puuid/${puuid}/ids`
|
||||||
|
const url = `${baseUrl}${endpoint}?queue=420&type=ranked&startTime=${startTime}&api_key=${api_key}`
|
||||||
|
|
||||||
|
const gameListResponse = await handleRateLimit(new URL(url))
|
||||||
|
handleError(gameListResponse)
|
||||||
|
const gameList = await gameListResponse.json()
|
||||||
|
|
||||||
|
return gameList
|
||||||
|
}
|
||||||
|
|
||||||
|
async function match(matchId: string, region: string) {
|
||||||
|
const baseUrl = getRegionalBaseUrl(region)
|
||||||
|
const endpoint = `/lol/match/v5/matches/${matchId}`
|
||||||
|
const url = `${baseUrl}${endpoint}?api_key=${api_key}`
|
||||||
|
|
||||||
|
const matchResponse = await handleRateLimit(new URL(url))
|
||||||
|
handleError(matchResponse)
|
||||||
|
const match = await matchResponse.json()
|
||||||
|
|
||||||
|
return match
|
||||||
|
}
|
||||||
|
|
||||||
|
async function matchTimeline(matchId: string, region: string) {
|
||||||
|
const baseUrl = getRegionalBaseUrl(region)
|
||||||
|
const endpoint = `/lol/match/v5/matches/${matchId}/timeline`
|
||||||
|
const url = `${baseUrl}${endpoint}?api_key=${api_key}`
|
||||||
|
|
||||||
|
const timelineResponse = await handleRateLimit(new URL(url))
|
||||||
|
handleError(timelineResponse)
|
||||||
|
const timeline = await timelineResponse.json()
|
||||||
|
|
||||||
|
return timeline
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get already seen games across all patches for a specific platform.
|
||||||
|
* This is used when we don't know the patch beforehand (we get it from gameVersion).
|
||||||
|
*/
|
||||||
|
async function alreadySeenGamesAllPatches(client: MongoClient, platform: string) {
|
||||||
|
const matchesDb = client.db('matches')
|
||||||
|
const collections = await matchesDb.listCollections().toArray()
|
||||||
|
const collectionNames = collections.map(c => c.name)
|
||||||
|
|
||||||
|
// Find all collections for this platform (format: "patch_platform")
|
||||||
|
const platformCollections = collectionNames.filter(name => name.endsWith(`_${platform}`))
|
||||||
|
|
||||||
|
const allSeen: string[] = []
|
||||||
|
for (const collectionName of platformCollections) {
|
||||||
|
const matches = matchesDb.collection(collectionName)
|
||||||
|
const seen = await matches.distinct('metadata.matchId')
|
||||||
|
allSeen.push(...seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allSeen
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMatch(client: MongoClient, match: Match, patch: string, platform: string) {
|
||||||
|
const database = client.db('matches')
|
||||||
|
const collectionName = `${patch}_${platform}`
|
||||||
|
const matches = database.collection(collectionName)
|
||||||
|
await matches.insertOne(match)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Development mode function that generates stats from pre-loaded data
|
||||||
|
*/
|
||||||
|
async function runWithPreloadedData() {
|
||||||
|
console.log('Using pre-loaded match data for development')
|
||||||
|
|
||||||
|
const client = await connectToDatabase()
|
||||||
|
try {
|
||||||
|
// Get the latest patch from collections instead of patches database
|
||||||
|
const latestPatch = await getLatestPatchFromCollections(client)
|
||||||
|
|
||||||
|
if (!latestPatch) {
|
||||||
|
console.error('❌ No match data found in database')
|
||||||
|
console.log('💡 Please run the data import script first:')
|
||||||
|
console.log(' node dev/scripts/setup-db.js')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Latest patch: ${latestPatch}`)
|
||||||
|
|
||||||
|
// Check if we have matches for this patch (including platform-specific collections)
|
||||||
|
const matchesDb = client.db('matches')
|
||||||
|
const collections = await matchesDb.listCollections().toArray()
|
||||||
|
const collectionNames = collections.map(c => c.name)
|
||||||
|
|
||||||
|
// Find collections for this patch (both global and platform-specific)
|
||||||
|
const patchCollections = collectionNames.filter(
|
||||||
|
name => name === latestPatch || name.startsWith(`${latestPatch}_`)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (patchCollections.length === 0) {
|
||||||
|
console.error(`❌ No match data found for patch ${latestPatch}`)
|
||||||
|
console.log('💡 Please run the data import script first:')
|
||||||
|
console.log(' node dev/scripts/setup-db.js')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Found ${patchCollections.length} match collection(s): ${patchCollections.join(', ')}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extract platforms from collection names (e.g., "15.1_EUW1" -> "EUW1")
|
||||||
|
const platforms = patchCollections
|
||||||
|
.filter(name => name.startsWith(`${latestPatch}_`))
|
||||||
|
.map(name => name.replace(`${latestPatch}_`, ''))
|
||||||
|
|
||||||
|
// Generate stats for each platform
|
||||||
|
if (platforms.length > 0) {
|
||||||
|
await champion_stat.makeChampionsStats(client, latestPatch, platforms)
|
||||||
|
} else {
|
||||||
|
// Fallback for old-style collections without platform suffix
|
||||||
|
await champion_stat.makeChampionsStats(client, latestPatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download CDragon assets for the latest patch
|
||||||
|
await downloadCDragonAssets(latestPatch)
|
||||||
|
|
||||||
|
console.log('🎉 All stats generated successfully!')
|
||||||
|
console.log('🚀 Your development database is ready for frontend testing!')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error in development mode:', error)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
await client.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
326
match_collector/src/item_tree.ts
Normal file
326
match_collector/src/item_tree.ts
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import {
|
||||||
|
REGION_KEYS,
|
||||||
|
initPlatformCounts,
|
||||||
|
mergePlatformCounts,
|
||||||
|
singlePlatformCount
|
||||||
|
} from './platform'
|
||||||
|
|
||||||
|
import type { PlatformCounts } from './platform'
|
||||||
|
import type { GoldAdvantageTag, ItemTag, ItemTree } from './types'
|
||||||
|
|
||||||
|
function treeInit(): ItemTree {
|
||||||
|
return {
|
||||||
|
data: undefined,
|
||||||
|
count: 0,
|
||||||
|
children: [],
|
||||||
|
boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 },
|
||||||
|
platformCount: initPlatformCounts(),
|
||||||
|
tags: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Merge a node with an item tree
|
||||||
|
*/
|
||||||
|
function nodeMerge(itemtree: ItemTree, node: ItemTree) {
|
||||||
|
const item = node.data
|
||||||
|
const count = node.count
|
||||||
|
let next: ItemTree | null = null
|
||||||
|
|
||||||
|
// Try to find an existing node in this tree level with same item
|
||||||
|
for (const child of itemtree.children) {
|
||||||
|
if (child.data == item) {
|
||||||
|
child.count += 1
|
||||||
|
|
||||||
|
child.boughtWhen.aheadCount += node.boughtWhen.aheadCount
|
||||||
|
child.boughtWhen.evenCount += node.boughtWhen.evenCount
|
||||||
|
child.boughtWhen.behindCount += node.boughtWhen.behindCount
|
||||||
|
|
||||||
|
// Merge platform counts
|
||||||
|
mergePlatformCounts(child.platformCount, node.platformCount)
|
||||||
|
|
||||||
|
next = child
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found, add item node at this level
|
||||||
|
if (next == null && item !== undefined) {
|
||||||
|
next = {
|
||||||
|
data: item,
|
||||||
|
count: count,
|
||||||
|
children: [],
|
||||||
|
boughtWhen: { ...node.boughtWhen },
|
||||||
|
platformCount: { ...node.platformCount },
|
||||||
|
tags: []
|
||||||
|
}
|
||||||
|
itemtree.children.push(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
return next!
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Merge a full build path with an existing item tree
|
||||||
|
*/
|
||||||
|
function treeMerge(
|
||||||
|
itemtree: ItemTree,
|
||||||
|
items: Array<{ itemId: number; goldAdvantage: GoldAdvantageTag; platform?: string }>
|
||||||
|
) {
|
||||||
|
let current = itemtree
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
current = nodeMerge(current, {
|
||||||
|
data: item.itemId,
|
||||||
|
count: 1,
|
||||||
|
boughtWhen: {
|
||||||
|
aheadCount: item.goldAdvantage == 'ahead' ? 1 : 0,
|
||||||
|
evenCount: item.goldAdvantage == 'even' ? 1 : 0,
|
||||||
|
behindCount: item.goldAdvantage == 'behind' ? 1 : 0,
|
||||||
|
meanGold: 0
|
||||||
|
},
|
||||||
|
children: [],
|
||||||
|
platformCount: item.platform ? singlePlatformCount(item.platform) : initPlatformCounts(),
|
||||||
|
tags: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeCutBranches(itemtree: ItemTree, thresholdCount: number, thresholdPerc: number) {
|
||||||
|
// Remove branches that are above threshold count
|
||||||
|
while (itemtree.children.length > thresholdCount) {
|
||||||
|
const leastUsedBranch = itemtree.children.reduce(
|
||||||
|
(a, b) => (Math.min(a.count, b.count) == a.count ? a : b),
|
||||||
|
{
|
||||||
|
data: undefined,
|
||||||
|
count: +Infinity,
|
||||||
|
children: [],
|
||||||
|
boughtWhen: { aheadCount: 0, behindCount: 0, evenCount: 0, meanGold: 0 },
|
||||||
|
platformCount: initPlatformCounts(),
|
||||||
|
tags: []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
itemtree.children.splice(itemtree.children.indexOf(leastUsedBranch), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove branches that are of too low usage
|
||||||
|
const toRemove: Array<ItemTree> = []
|
||||||
|
for (const child of itemtree.children) {
|
||||||
|
if (child.count / itemtree.count < thresholdPerc) {
|
||||||
|
toRemove.push(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const tr of toRemove) {
|
||||||
|
itemtree.children.splice(itemtree.children.indexOf(tr), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemtree.children.map(x => treeCutBranches(x, thresholdCount, thresholdPerc))
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeSort(itemtree: ItemTree) {
|
||||||
|
itemtree.children.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
for (const item of itemtree.children) {
|
||||||
|
treeSort(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Deep clone an ItemTree
|
||||||
|
*/
|
||||||
|
function treeClone(tree: ItemTree): ItemTree {
|
||||||
|
return {
|
||||||
|
data: tree.data,
|
||||||
|
count: tree.count,
|
||||||
|
children: tree.children.map(child => treeClone(child)),
|
||||||
|
boughtWhen: {
|
||||||
|
aheadCount: tree.boughtWhen.aheadCount,
|
||||||
|
behindCount: tree.boughtWhen.behindCount,
|
||||||
|
evenCount: tree.boughtWhen.evenCount,
|
||||||
|
meanGold: tree.boughtWhen.meanGold
|
||||||
|
},
|
||||||
|
platformCount: { ...tree.platformCount },
|
||||||
|
tags: [...tree.tags]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Merge two ItemTrees into one
|
||||||
|
*/
|
||||||
|
function treeMergeTree(t1: ItemTree, t2: ItemTree): ItemTree {
|
||||||
|
// Merge counts for the root
|
||||||
|
t1.count += t2.count
|
||||||
|
|
||||||
|
// Merge platform counts
|
||||||
|
mergePlatformCounts(t1.platformCount, t2.platformCount)
|
||||||
|
|
||||||
|
// Merge boughtWhen
|
||||||
|
t1.boughtWhen.aheadCount += t2.boughtWhen.aheadCount
|
||||||
|
t1.boughtWhen.evenCount += t2.boughtWhen.evenCount
|
||||||
|
t1.boughtWhen.behindCount += t2.boughtWhen.behindCount
|
||||||
|
|
||||||
|
// Merge children from t2 into t1
|
||||||
|
for (const child2 of t2.children) {
|
||||||
|
// Find matching child in t1 (same data value)
|
||||||
|
const matchingChild = t1.children.find(child1 => child1.data === child2.data)
|
||||||
|
|
||||||
|
if (matchingChild) {
|
||||||
|
// Recursively merge matching children
|
||||||
|
treeMergeTree(matchingChild, child2)
|
||||||
|
} else {
|
||||||
|
// Add a deep copy of child2 to t1
|
||||||
|
t1.children.push(treeClone(child2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return t1
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Flatten an ItemTree into a Set of item numbers
|
||||||
|
*/
|
||||||
|
function treeToSet(itemtree: ItemTree): Set<number> {
|
||||||
|
const items: Set<number> = new Set()
|
||||||
|
|
||||||
|
function traverse(node: ItemTree) {
|
||||||
|
if (node.data !== undefined) {
|
||||||
|
items.add(node.data)
|
||||||
|
}
|
||||||
|
for (const child of node.children) {
|
||||||
|
traverse(child)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traverse(itemtree)
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Calculate similarity between two trees as item sets.
|
||||||
|
* Returns a number between 0 and 1, where 1 means identical and 0 means completely different.
|
||||||
|
* Uses Jaccard similarity: |A ∩ B| / |A ∪ B|
|
||||||
|
* Sets included in one another will have similarity close to 1.
|
||||||
|
*/
|
||||||
|
function areTreeSimilars(t1: ItemTree, t2: ItemTree): number {
|
||||||
|
const set1 = treeToSet(t1)
|
||||||
|
const set2 = treeToSet(t2)
|
||||||
|
|
||||||
|
// Handle empty sets
|
||||||
|
if (set1.size === 0 && set2.size === 0) {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate intersection
|
||||||
|
const intersection = new Set<number>()
|
||||||
|
for (const item of Array.from(set1)) {
|
||||||
|
if (set2.has(item)) {
|
||||||
|
intersection.add(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate union
|
||||||
|
const union = new Set<number>()
|
||||||
|
for (const item of Array.from(set1)) {
|
||||||
|
union.add(item)
|
||||||
|
}
|
||||||
|
for (const item of Array.from(set2)) {
|
||||||
|
union.add(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jaccard similarity: |intersection| / |union|
|
||||||
|
const similarity = intersection.size / Math.min(set1.size, set2.size)
|
||||||
|
|
||||||
|
// Ensure result is between 0 and 1
|
||||||
|
return Math.max(0, Math.min(1, similarity))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Derive tags for an item based on purchase patterns
|
||||||
|
* Tags are derived when a specific condition is dominant (>= 60% threshold)
|
||||||
|
* For region tags, we compare against expected distribution to find items that are
|
||||||
|
* significantly more popular in a region than expected
|
||||||
|
*/
|
||||||
|
function deriveTags(node: ItemTree, expectedRegionDistribution?: PlatformCounts): void {
|
||||||
|
const tags: Array<ItemTag> = []
|
||||||
|
|
||||||
|
// Derive gold situation tags
|
||||||
|
const totalGoldSituations =
|
||||||
|
node.boughtWhen.aheadCount + node.boughtWhen.behindCount + node.boughtWhen.evenCount
|
||||||
|
if (totalGoldSituations > 0) {
|
||||||
|
const aheadPct = node.boughtWhen.aheadCount / totalGoldSituations
|
||||||
|
const behindPct = node.boughtWhen.behindCount / totalGoldSituations
|
||||||
|
|
||||||
|
// Only tag if there's a dominant pattern (>= 60%)
|
||||||
|
if (aheadPct >= 0.6) {
|
||||||
|
tags.push('ahead')
|
||||||
|
} else if (behindPct >= 0.6) {
|
||||||
|
tags.push('behind')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive region tags by comparing against expected distribution
|
||||||
|
const totalRegionCount = REGION_KEYS.reduce((sum, key) => sum + node.platformCount[key], 0)
|
||||||
|
if (totalRegionCount > 0 && expectedRegionDistribution) {
|
||||||
|
const totalExpected = REGION_KEYS.reduce((sum, key) => sum + expectedRegionDistribution[key], 0)
|
||||||
|
|
||||||
|
if (totalExpected > 0) {
|
||||||
|
// Tag if one region accounts for >= 60% of the normalized distribution
|
||||||
|
// Normalized value = actual percentage / expected percentage ratio
|
||||||
|
const regionTags: Array<{ key: keyof PlatformCounts; tag: ItemTag }> = [
|
||||||
|
{ key: 'euw', tag: 'region_euw' },
|
||||||
|
{ key: 'eun', tag: 'region_eun' },
|
||||||
|
{ key: 'na', tag: 'region_na' },
|
||||||
|
{ key: 'kr', tag: 'region_kr' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Calculate normalized values (actual/expected ratio) for each region
|
||||||
|
const normalizedValues = regionTags.map(({ key, tag }) => {
|
||||||
|
const expectedPct = expectedRegionDistribution[key] / totalExpected
|
||||||
|
const actualPct = node.platformCount[key] / totalRegionCount
|
||||||
|
const normalizedValue = expectedPct > 0 ? actualPct / expectedPct : 0
|
||||||
|
return { tag, value: normalizedValue }
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalNormalized = normalizedValues.reduce((sum, { value }) => sum + value, 0)
|
||||||
|
|
||||||
|
// Tag the region if it accounts for >= 60% of the normalized distribution
|
||||||
|
if (totalNormalized > 0) {
|
||||||
|
for (const { tag, value } of normalizedValues) {
|
||||||
|
if (value / totalNormalized >= 0.6) {
|
||||||
|
tags.push(tag)
|
||||||
|
break // Only tag the most dominant region
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
node.tags = tags
|
||||||
|
|
||||||
|
// Recursively derive tags for children
|
||||||
|
for (const child of node.children) {
|
||||||
|
deriveTags(child, expectedRegionDistribution)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Apply tag derivation to an entire tree
|
||||||
|
* expectedRegionDistribution: the total region distribution for the champion/lane,
|
||||||
|
* used to detect items that are region-specific
|
||||||
|
*/
|
||||||
|
function treeDeriveTags(itemtree: ItemTree, expectedRegionDistribution?: PlatformCounts): void {
|
||||||
|
deriveTags(itemtree, expectedRegionDistribution)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
PlatformCounts,
|
||||||
|
GoldAdvantageTag,
|
||||||
|
ItemTag,
|
||||||
|
treeMerge,
|
||||||
|
treeInit,
|
||||||
|
treeCutBranches,
|
||||||
|
treeSort,
|
||||||
|
treeMergeTree,
|
||||||
|
areTreeSimilars,
|
||||||
|
treeDeriveTags
|
||||||
|
}
|
||||||
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'
|
||||||
104
match_collector/src/platform.ts
Normal file
104
match_collector/src/platform.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Platform and region configuration for Riot Games API
|
||||||
|
*
|
||||||
|
* Platforms are the server clusters (EUW1, EUN1, NA1, KR)
|
||||||
|
* Regions are the routing values for match API (EUROPE, AMERICAS, ASIA)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Platform to regional routing value mapping
|
||||||
|
const PLATFORMS: Record<string, string> = {
|
||||||
|
EUW1: 'EUROPE',
|
||||||
|
EUN1: 'EUROPE',
|
||||||
|
NA1: 'AMERICAS',
|
||||||
|
KR: 'ASIA'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform counts for tracking item purchases per region
|
||||||
|
interface PlatformCounts {
|
||||||
|
euw: number
|
||||||
|
eun: number
|
||||||
|
na: number
|
||||||
|
kr: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform key mapping for converting platform strings to PlatformCounts keys
|
||||||
|
const PLATFORM_KEYS: Record<string, keyof PlatformCounts> = {
|
||||||
|
euw1: 'euw',
|
||||||
|
eun1: 'eun',
|
||||||
|
na1: 'na',
|
||||||
|
kr: 'kr'
|
||||||
|
}
|
||||||
|
|
||||||
|
// List of all region keys for iteration
|
||||||
|
const REGION_KEYS: Array<keyof PlatformCounts> = ['euw', 'eun', 'na', 'kr']
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base URL for platform-specific API calls (e.g., league-v4)
|
||||||
|
*/
|
||||||
|
function getPlatformBaseUrl(platform: string): string {
|
||||||
|
return `https://${platform.toLowerCase()}.api.riotgames.com`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base URL for regional API calls (e.g., match-v5)
|
||||||
|
*/
|
||||||
|
function getRegionalBaseUrl(region: string): string {
|
||||||
|
return `https://${region.toLowerCase()}.api.riotgames.com`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the regional routing value for a platform
|
||||||
|
* Falls back to the provided default region if platform not found
|
||||||
|
*/
|
||||||
|
function getRegionForPlatform(platform: string): string | undefined {
|
||||||
|
return PLATFORMS[platform]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize an empty PlatformCounts object
|
||||||
|
*/
|
||||||
|
function initPlatformCounts(): PlatformCounts {
|
||||||
|
return { euw: 0, eun: 0, na: 0, kr: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge platform counts from source into target
|
||||||
|
*/
|
||||||
|
function mergePlatformCounts(target: PlatformCounts, source: PlatformCounts): void {
|
||||||
|
for (const key of REGION_KEYS) {
|
||||||
|
target[key] += source[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a platform count with a single platform set to 1
|
||||||
|
*/
|
||||||
|
function singlePlatformCount(platform: string): PlatformCounts {
|
||||||
|
const counts = initPlatformCounts()
|
||||||
|
const key = PLATFORM_KEYS[platform.toLowerCase()]
|
||||||
|
if (key) {
|
||||||
|
counts[key] = 1
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the PlatformCounts key for a platform string
|
||||||
|
*/
|
||||||
|
function getPlatformKey(platform: string): keyof PlatformCounts | undefined {
|
||||||
|
return PLATFORM_KEYS[platform.toLowerCase()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
PLATFORMS,
|
||||||
|
PLATFORM_KEYS,
|
||||||
|
REGION_KEYS,
|
||||||
|
PlatformCounts,
|
||||||
|
getPlatformBaseUrl,
|
||||||
|
getRegionalBaseUrl,
|
||||||
|
getRegionForPlatform,
|
||||||
|
initPlatformCounts,
|
||||||
|
mergePlatformCounts,
|
||||||
|
singlePlatformCount,
|
||||||
|
getPlatformKey
|
||||||
|
}
|
||||||
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,5 +1,15 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["node"]
|
"types": ["node"],
|
||||||
}
|
"declaration": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
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