Compare commits
23 Commits
c976f340e6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
b2178fec85
|
|||
|
ee32060a7f
|
|||
|
d231ae7c38
|
|||
|
5c83e45d2a
|
|||
|
50c0646a93
|
|||
|
8263dc1c93
|
|||
|
c506bad739
|
|||
|
a0e2915c3d
|
|||
|
c7d0d929be
|
|||
|
8ee981b949
|
|||
|
686962b678
|
|||
|
a467046c55
|
|||
|
a0b3e49759
|
|||
| 7051ace13f | |||
|
e1ab81854a
|
|||
|
db2ca353c5
|
|||
|
7712abe3f0
|
|||
|
af51d61e0c
|
|||
|
4a80540243
|
|||
|
0b2d00ad0b
|
|||
| 0e0a12513e | |||
| e82ad73de1 | |||
|
a98e3c6589
|
32
.dockerignore
Normal file
32
.dockerignore
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Dependencies
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
**/dist
|
||||||
|
**/.output
|
||||||
|
**/.nuxt
|
||||||
|
|
||||||
|
# Database data
|
||||||
|
dev/data/db
|
||||||
|
|
||||||
|
# Git
|
||||||
|
**/.git
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
**/.idea
|
||||||
|
**/.vscode
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
**/*.log
|
||||||
|
**/logs
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
**/coverage
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
49
.gitea/workflows/dragon-item-parser.yml
Normal file
49
.gitea/workflows/dragon-item-parser.yml
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: Dragon Item Parser CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- 'dragon-item-parser/**'
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'dragon-item-parser/**'
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: dragon-item-parser
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: npm run format:check
|
||||||
|
|
||||||
|
- name: Run linting
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Verify build output
|
||||||
|
run: |
|
||||||
|
test -f dist/index.js
|
||||||
|
test -f dist/index.d.ts
|
||||||
|
test -f dist/item.js
|
||||||
|
test -f dist/item.d.ts
|
||||||
@@ -43,18 +43,6 @@ jobs:
|
|||||||
working-directory: ./match_collector
|
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,40 +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)
|
// 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 platforms = ['EUW1', 'EUN1', 'NA1', 'KR'];
|
const platforms = ['EUW1', 'EUN1', 'NA1', 'KR'];
|
||||||
const dataFiles = [
|
const dataFiles = [];
|
||||||
{ path: 'patches.json', required: true, description: 'Patches data' }
|
|
||||||
];
|
|
||||||
|
|
||||||
// Check for platform-specific match files
|
// Check for platform-specific match files
|
||||||
|
// Files may be named with either "16.8" or "16.8.1" format
|
||||||
let foundPlatformFiles = [];
|
let foundPlatformFiles = [];
|
||||||
for (const platform of platforms) {
|
for (const platform of platforms) {
|
||||||
const platformFile = `${latestPatch}_${platform}.json`;
|
// Try both formats: "16.8_PLATFORM.json" and "16.8.1_PLATFORM.json"
|
||||||
const fullPath = path.join(dataDir, platformFile);
|
const files = fs.readdirSync(dataDir);
|
||||||
if (fs.existsSync(fullPath)) {
|
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);
|
foundPlatformFiles.push(platform);
|
||||||
dataFiles.push({ path: platformFile, required: false, description: `Match data for ${platform}` });
|
dataFiles.push({ path: matchFile, required: false, description: `Match data for ${platform}` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no platform-specific files found, look for old format
|
// If no platform-specific files found, look for old format
|
||||||
if (foundPlatformFiles.length === 0) {
|
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' });
|
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) {
|
||||||
@@ -91,11 +121,7 @@ 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
|
// Check for platform-specific collections or fall back to old format
|
||||||
@@ -129,11 +155,7 @@ async function setupDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Fetch CDragon data for the current patch
|
// 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();
|
||||||
|
|
||||||
@@ -148,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() {
|
||||||
@@ -256,83 +300,25 @@ async function waitForMongoDB() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importPatchesData() {
|
|
||||||
const client = new MongoClient(getMongoUri());
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filePath = path.join(__dirname, '../data/patches.json');
|
|
||||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
|
||||||
|
|
||||||
// Check if it's line-delimited JSON or array format
|
|
||||||
let patchesData;
|
|
||||||
if (fileContent.trim().startsWith('[')) {
|
|
||||||
// Array format
|
|
||||||
patchesData = JSON.parse(fileContent);
|
|
||||||
if (!Array.isArray(patchesData)) {
|
|
||||||
throw new Error('Patches data should be an array');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Line-delimited JSON format
|
|
||||||
patchesData = fileContent.split('\n')
|
|
||||||
.filter(line => line.trim() !== '')
|
|
||||||
.map(line => {
|
|
||||||
const doc = JSON.parse(line);
|
|
||||||
return convertMongoExtendedJson(doc);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert any extended JSON in array format too
|
|
||||||
if (Array.isArray(patchesData)) {
|
|
||||||
patchesData = patchesData.map(doc => convertMongoExtendedJson(doc));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort patches by date (newest first)
|
|
||||||
patchesData.sort((a, b) => {
|
|
||||||
const dateA = new Date(a.date || a.date.$date || 0);
|
|
||||||
const dateB = new Date(b.date || b.date.$date || 0);
|
|
||||||
return dateB - dateA; // Descending order (newest first)
|
|
||||||
});
|
|
||||||
|
|
||||||
const db = client.db('patches');
|
|
||||||
const collection = db.collection('patches');
|
|
||||||
|
|
||||||
// Clear existing data
|
|
||||||
await collection.deleteMany({});
|
|
||||||
|
|
||||||
// Insert sorted data
|
|
||||||
const result = await collection.insertMany(patchesData);
|
|
||||||
console.log(`✅ Imported ${result.insertedCount} patches (sorted by date)`);
|
|
||||||
|
|
||||||
// Create index
|
|
||||||
await collection.createIndex({ date: -1 });
|
|
||||||
console.log('✅ Created patches index');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to import patches:', error);
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
await client.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importMatchesData(patchVersion, foundPlatformFiles = []) {
|
async function importMatchesData(patchVersion, foundPlatformFiles = []) {
|
||||||
const dataDir = path.join(__dirname, '../data');
|
const dataDir = path.join(__dirname, '../data');
|
||||||
|
const files = fs.readdirSync(dataDir);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// If platform-specific files were found, import each one
|
// If platform-specific files were found, import each one
|
||||||
if (foundPlatformFiles.length > 0) {
|
if (foundPlatformFiles.length > 0) {
|
||||||
for (const platform of foundPlatformFiles) {
|
for (const platform of foundPlatformFiles) {
|
||||||
// Try both formats: patch_PLATFORM.json and patch_PLATFORM_matches.json
|
// Find the actual file for this platform (could be "16.8_PLATFORM.json" or "16.8.1_PLATFORM.json")
|
||||||
let matchesFile = path.join(dataDir, `${patchVersion}_${platform}.json`);
|
const matchFile = files.find(f => {
|
||||||
|
const match = f.match(/^(\d+\.\d+(?:\.\d+)?)_([A-Z0-9]+)\.json$/);
|
||||||
|
const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
|
||||||
|
return match && patchFromName === patchVersion && match[2] === platform;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchFile) {
|
||||||
|
const matchesFile = path.join(dataDir, matchFile);
|
||||||
const collectionName = `${patchVersion}_${platform}`;
|
const collectionName = `${patchVersion}_${platform}`;
|
||||||
|
|
||||||
// Fallback to _matches.json suffix if the direct file doesn't exist
|
|
||||||
if (!fs.existsSync(matchesFile)) {
|
|
||||||
matchesFile = path.join(dataDir, `${patchVersion}_${platform}_matches.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(matchesFile)) {
|
|
||||||
console.log(`📥 Importing matches for ${platform}...`);
|
console.log(`📥 Importing matches for ${platform}...`);
|
||||||
execSync(
|
execSync(
|
||||||
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
|
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
|
||||||
@@ -348,15 +334,16 @@ async function importMatchesData(patchVersion, foundPlatformFiles = []) {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fall back to old format (single file without platform suffix)
|
// Fall back to old format (single file without platform suffix)
|
||||||
// Try both formats: patch_matches.json and patch.json
|
// Find any match file for this patch
|
||||||
let matchesFile = path.join(dataDir, `${patchVersion}_matches.json`);
|
const matchFile = files.find(f => {
|
||||||
|
const match = f.match(/^(\d+\.\d+(?:\.\d+)?)(?:_matches)?\.json$/);
|
||||||
|
const patchFromName = match ? match[1].split('.').slice(0, 2).join('.') : null;
|
||||||
|
return match && patchFromName === patchVersion;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchFile) {
|
||||||
|
const matchesFile = path.join(dataDir, matchFile);
|
||||||
const collectionName = patchVersion;
|
const collectionName = patchVersion;
|
||||||
|
|
||||||
if (!fs.existsSync(matchesFile)) {
|
|
||||||
matchesFile = path.join(dataDir, `${patchVersion}.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(matchesFile)) {
|
|
||||||
execSync(
|
execSync(
|
||||||
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
|
`node ${path.join(__dirname, 'process-matches.js')} ${matchesFile} ${collectionName} 1000`,
|
||||||
{
|
{
|
||||||
@@ -404,24 +391,6 @@ async function generateChampionStats() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCDragonData() {
|
|
||||||
try {
|
|
||||||
console.log('🔄 Running CDragon fetcher...');
|
|
||||||
|
|
||||||
// Run the fetch-cdragon script
|
|
||||||
const fetchCDragonPath = path.join(__dirname, 'fetch-cdragon.js');
|
|
||||||
execSync(`node ${fetchCDragonPath}`, {
|
|
||||||
stdio: 'inherit',
|
|
||||||
cwd: path.join(__dirname, '..')
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('✅ CDragon data fetched');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to fetch CDragon data:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMatchCount(patchVersion, platform = null) {
|
async function getMatchCount(patchVersion, platform = null) {
|
||||||
const client = new MongoClient(getMongoUri());
|
const client = new MongoClient(getMongoUri());
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -582,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);
|
||||||
@@ -605,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,6 +1,9 @@
|
|||||||
<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
|
||||||
@@ -121,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,5 +1,14 @@
|
|||||||
<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
|
||||||
@@ -42,78 +51,184 @@ function getTagClass(tag: ItemTag): string {
|
|||||||
return `tag-${tag}`
|
return `tag-${tag}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse description and convert to styled HTML
|
// Parse the item description once
|
||||||
const formatDescription = (description?: string) => {
|
const parsedDescription = computed<ParsedDescription | null>(() => {
|
||||||
if (!description) return ''
|
if (!props.item?.description) return null
|
||||||
|
return parseItemDescription(props.item.description)
|
||||||
|
})
|
||||||
|
|
||||||
// Replace <br> and other structural tags
|
// Format stats for display
|
||||||
const html = description
|
const formattedStats = computed(() => {
|
||||||
.replace(/<br\s*\/?>/gi, '<br>')
|
const stats = parsedDescription.value?.stats
|
||||||
.replace(/<br><br><br>/gi, '') // Remove triple breaks
|
if (!stats) return []
|
||||||
.replace(/<br><br>/gi, '') // Remove double breaks
|
|
||||||
.replace(/<mainText>/gi, '')
|
|
||||||
.replace(/<\/mainText>/gi, '')
|
|
||||||
.replace(/<stats>/gi, '<div class="tooltip-stats">')
|
|
||||||
.replace(/<\/stats>/gi, '</div>')
|
|
||||||
.replace(/<passive>/gi, '<span class="tag-passive">')
|
|
||||||
.replace(/<\/passive>/gi, '</span>:')
|
|
||||||
.replace(/<active>/gi, '<span class="tag-active">')
|
|
||||||
.replace(/<\/active>/gi, '</span>')
|
|
||||||
.replace(/<keyword>/gi, '<span class="tag-keyword">')
|
|
||||||
.replace(/<\/keyword>/gi, '</span>')
|
|
||||||
.replace(/<attention>/gi, '<span class="stat-highlight">')
|
|
||||||
.replace(/<\/attention>/gi, '</span>')
|
|
||||||
.replace(/<keywordMajor>/gi, '<span class="tag-keyword-major">')
|
|
||||||
.replace(/<\/keywordMajor>/gi, '</span>')
|
|
||||||
.replace(/<keywordStealth>/gi, '<span class="tag-keyword-stealth">')
|
|
||||||
.replace(/<\/keywordStealth>/gi, '</span>')
|
|
||||||
.replace(/<status>/gi, '<span class="tag-status">')
|
|
||||||
.replace(/<\/status>/gi, '</span>')
|
|
||||||
.replace(/<speed>/gi, '<span class="tag-speed">')
|
|
||||||
.replace(/<\/speed>/gi, '</span>')
|
|
||||||
.replace(/<scaleMana>/gi, '<span class="tag-scale-mana">')
|
|
||||||
.replace(/<\/scaleMana>/gi, '</span>')
|
|
||||||
.replace(/<scaleHealth>/gi, '<span class="tag-scale-health">')
|
|
||||||
.replace(/<\/scaleHealth>/gi, '</span>')
|
|
||||||
.replace(/<scaleAP>/gi, '<span class="tag-scale-ap">')
|
|
||||||
.replace(/<\/scaleAP>/gi, '</span>')
|
|
||||||
.replace(/<scaleAD>/gi, '<span class="tag-scale-ad">')
|
|
||||||
.replace(/<\/scaleAD>/gi, '</span>')
|
|
||||||
.replace(/<scaleArmor>/gi, '<span class="tag-scale-armor">')
|
|
||||||
.replace(/<\/scaleArmor>/gi, '</span>')
|
|
||||||
.replace(/<scaleMR>/gi, '<span class="tag-scale-mr">')
|
|
||||||
.replace(/<\/scaleMR>/gi, '</span>')
|
|
||||||
.replace(/<scaleLevel>/gi, '<span class="tag-scale-level">')
|
|
||||||
.replace(/<\/scaleLevel>/gi, '</span>')
|
|
||||||
.replace(/<spellName>/gi, '<span class="tag-spellname">')
|
|
||||||
.replace(/<\/spellName>/gi, '</span>')
|
|
||||||
.replace(/<unique>/gi, '<span class="tag-unique">UNIQUE</span>')
|
|
||||||
.replace(/<\/unique>/gi, '')
|
|
||||||
.replace(/<rarityMythic>/gi, '<span class="tag-rarity-mythic">Mythic</span>')
|
|
||||||
.replace(/<\/rarityMythic>/gi, '')
|
|
||||||
.replace(/<rarityLegendary>/gi, '<span class="tag-rarity-legendary">Legendary</span>')
|
|
||||||
.replace(/<\/rarityLegendary>/gi, '')
|
|
||||||
.replace(/<rarityGeneric>/gi, '<span class="tag-rarity-generic">Epic</span>')
|
|
||||||
.replace(/<\/rarityGeneric>/gi, '')
|
|
||||||
.replace(/<rules>/gi, '<div class="tag-rules">')
|
|
||||||
.replace(/<\/rules>/gi, '</div>')
|
|
||||||
.replace(/<flavorText>/gi, '<div class="tag-flavor">')
|
|
||||||
.replace(/<\/flavorText>/gi, '</div>')
|
|
||||||
.replace(/<li>/gi, '<div class="tag-list">')
|
|
||||||
.replace(/<\/li>/gi, '</div>')
|
|
||||||
.replace(/<font color='([^']+)'>/gi, '<span style="color: $1">')
|
|
||||||
.replace(/<\/font>/gi, '</span>')
|
|
||||||
.replace(/<b>/gi, '<strong>')
|
|
||||||
.replace(/<\/b>/gi, '</strong>')
|
|
||||||
.replace(/\[@@[^@]*@@\]/g, ' ') // Remove stat placeholders
|
|
||||||
.trim()
|
|
||||||
|
|
||||||
return html
|
const statLabels: Record<string, string> = {
|
||||||
|
attackDamage: 'Attack Damage',
|
||||||
|
abilityPower: 'Ability Power',
|
||||||
|
attackSpeed: 'Attack Speed',
|
||||||
|
criticalStrikeChance: 'Critical Strike Chance',
|
||||||
|
criticalStrikeDamage: 'Critical Strike Damage',
|
||||||
|
lifeSteal: 'Life Steal',
|
||||||
|
omnivamp: 'Omnivamp',
|
||||||
|
physicalVamp: 'Physical Vamp',
|
||||||
|
spellVamp: 'Spell Vamp',
|
||||||
|
health: 'Health',
|
||||||
|
armor: 'Armor',
|
||||||
|
magicResist: 'Magic Resist',
|
||||||
|
mana: 'Mana',
|
||||||
|
baseManaRegen: 'Base Mana Regen',
|
||||||
|
baseHealthRegen: 'Base Health Regen',
|
||||||
|
moveSpeed: 'Move Speed',
|
||||||
|
abilityHaste: 'Ability Haste',
|
||||||
|
armorPenetration: 'Armor Penetration',
|
||||||
|
magicPenetration: 'Magic Penetration',
|
||||||
|
lethality: 'Lethality',
|
||||||
|
healAndShieldPower: 'Heal and Shield Power',
|
||||||
|
tenacity: 'Tenacity',
|
||||||
|
slowResist: 'Slow Resist'
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentageStats = [
|
||||||
|
'attackSpeed',
|
||||||
|
'criticalStrikeChance',
|
||||||
|
'criticalStrikeDamage',
|
||||||
|
'lifeSteal',
|
||||||
|
'omnivamp',
|
||||||
|
'physicalVamp',
|
||||||
|
'spellVamp',
|
||||||
|
'baseManaRegen',
|
||||||
|
'baseHealthRegen',
|
||||||
|
'armorPenetration',
|
||||||
|
'magicPenetration',
|
||||||
|
'healAndShieldPower',
|
||||||
|
'tenacity',
|
||||||
|
'slowResist'
|
||||||
|
]
|
||||||
|
|
||||||
|
return Object.entries(stats)
|
||||||
|
.filter(([_, value]) => value !== undefined)
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
label: statLabels[key] || key,
|
||||||
|
value: percentageStats.includes(key) ? `${value}%` : value
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get CSS class for text segment type
|
||||||
|
function getSegmentClass(segment: TextSegment): string {
|
||||||
|
const classMap: Record<TextSegment['type'], string> = {
|
||||||
|
text: '',
|
||||||
|
highlight: 'stat-highlight',
|
||||||
|
passive: 'tag-passive',
|
||||||
|
active: 'tag-active',
|
||||||
|
keyword: 'tag-keyword',
|
||||||
|
keywordMajor: 'tag-keyword-major',
|
||||||
|
keywordStealth: 'tag-keyword-stealth',
|
||||||
|
status: 'tag-status',
|
||||||
|
speed: 'tag-speed',
|
||||||
|
scaleMana: 'tag-scaling',
|
||||||
|
scaleHealth: 'tag-scaling',
|
||||||
|
scaleAP: 'tag-scaling',
|
||||||
|
scaleAD: 'tag-scaling',
|
||||||
|
scaleArmor: 'tag-scaling',
|
||||||
|
scaleMR: 'tag-scaling',
|
||||||
|
scaleLevel: 'tag-scaling',
|
||||||
|
scaleBonusHealth: 'tag-scaling',
|
||||||
|
scaleBonusMana: 'tag-scaling',
|
||||||
|
scaleMaxHealth: 'tag-scaling',
|
||||||
|
spellName: 'tag-spellname',
|
||||||
|
unique: 'tag-unique',
|
||||||
|
rarityMythic: 'tag-rarity-mythic',
|
||||||
|
rarityLegendary: 'tag-rarity-legendary',
|
||||||
|
rarityGeneric: 'tag-rarity-generic',
|
||||||
|
magicDamage: 'tag-magic-damage',
|
||||||
|
physicalDamage: 'tag-physical-damage',
|
||||||
|
trueDamage: 'tag-true-damage',
|
||||||
|
healing: 'tag-healing',
|
||||||
|
shield: 'tag-shield',
|
||||||
|
attention: 'stat-highlight',
|
||||||
|
onHit: 'tag-onhit',
|
||||||
|
color: ''
|
||||||
|
}
|
||||||
|
return classMap[segment.type] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const formattedDescription = computed(() =>
|
// Get effect type label
|
||||||
props.item ? formatDescription(props.item.description) : ''
|
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>
|
||||||
@@ -128,20 +243,23 @@ 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>
|
||||||
|
|
||||||
<!-- Item Tags -->
|
<!-- Item Tags -->
|
||||||
<div v-if="tags && tags.length > 0" class="tooltip-tags">
|
<div v-if="tags && tags.length > 0" class="tooltip-tags-section">
|
||||||
|
<div class="tooltip-tags">
|
||||||
<span
|
<span
|
||||||
v-for="tag in tags"
|
v-for="tag in tags"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
@@ -151,15 +269,47 @@ const formattedDescription = computed(() =>
|
|||||||
{{ getTagLabel(tag) }}
|
{{ getTagLabel(tag) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- eslint-disable vue/no-v-html -->
|
<!-- Stats Section -->
|
||||||
|
<div v-if="formattedStats.length > 0" class="tooltip-stats">
|
||||||
|
<div v-for="stat in formattedStats" :key="stat.key" class="stat-row">
|
||||||
|
<span class="stat-value">{{ stat.value }}</span>
|
||||||
|
<span class="stat-label">{{ stat.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Effects Section -->
|
||||||
|
<div v-if="hasEffects" class="tooltip-effects">
|
||||||
<div
|
<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>
|
||||||
@@ -168,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,212 +342,56 @@ 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-stats {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid var(--color-on-surface-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-stats :deep(.stat-highlight) {
|
|
||||||
color: #ffcc00;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tag styles */
|
|
||||||
.tooltip-description :deep(.tag-passive) {
|
|
||||||
color: #4a9eff;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-active) {
|
|
||||||
color: #ff6b6b;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-keyword) {
|
|
||||||
color: #ffd700;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-keyword-major) {
|
|
||||||
color: #ff8c00;
|
|
||||||
font-weight: 700;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-keyword-stealth) {
|
|
||||||
color: #9b59b6;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-status) {
|
|
||||||
color: #e74c3c;
|
|
||||||
font-weight: 500;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-speed),
|
|
||||||
.tooltip-description :deep(.tag-scale-mana),
|
|
||||||
.tooltip-description :deep(.tag-scale-health),
|
|
||||||
.tooltip-description :deep(.tag-scale-ap),
|
|
||||||
.tooltip-description :deep(.tag-scale-ad),
|
|
||||||
.tooltip-description :deep(.tag-scale-armor),
|
|
||||||
.tooltip-description :deep(.tag-scale-mr),
|
|
||||||
.tooltip-description :deep(.tag-scale-level) {
|
|
||||||
color: #3498db;
|
|
||||||
font-style: italic;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-healing),
|
|
||||||
.tooltip-description :deep(.tag-health) {
|
|
||||||
color: #2ecc71;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-shield) {
|
|
||||||
color: #3498db;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-magic-damage) {
|
|
||||||
color: #9b59b6;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-physical-damage) {
|
|
||||||
color: #e67e22;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-true-damage) {
|
|
||||||
color: #c0392b;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-onhit) {
|
|
||||||
background: rgba(52, 152, 219, 0.1);
|
|
||||||
color: #3498db;
|
|
||||||
padding: 1px 4px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-spellname) {
|
|
||||||
color: #1abc9c;
|
|
||||||
font-weight: 600;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-unique) {
|
|
||||||
color: #f39c12;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-rarity-mythic) {
|
|
||||||
color: #ff5252;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-rarity-legendary) {
|
|
||||||
color: #ff9800;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-rarity-generic) {
|
|
||||||
color: #ffd54f;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-rules) {
|
|
||||||
margin-top: 8px;
|
|
||||||
padding: 6px;
|
|
||||||
background: rgba(0, 0, 0, 0.1);
|
|
||||||
border-left: 2px solid var(--color-on-surface-dim);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-style: italic;
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-flavor) {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding: 6px;
|
|
||||||
background: rgba(74, 222, 255, 0.1);
|
|
||||||
border-left: 2px solid #4a9eff;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-style: italic;
|
|
||||||
color: rgba(255, 255, 255, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description :deep(.tag-list) {
|
|
||||||
margin: 4px 0;
|
|
||||||
padding-left: 12px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-description strong {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-on-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Item tags in tooltip */
|
|
||||||
.tooltip-tags {
|
.tooltip-tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-bottom: 8px;
|
|
||||||
padding-bottom: 8px;
|
|
||||||
border-bottom: 1px solid var(--color-on-surface-dim);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tooltip-tags .item-tag {
|
.tooltip-tags .item-tag {
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -401,7 +400,6 @@ const formattedDescription = computed(() =>
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Gold situation tags */
|
|
||||||
.tooltip-tags .tag-ahead {
|
.tooltip-tags .tag-ahead {
|
||||||
background-color: #22c55e;
|
background-color: #22c55e;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -412,7 +410,6 @@ const formattedDescription = computed(() =>
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Region tags */
|
|
||||||
.tooltip-tags .tag-region_euw {
|
.tooltip-tags .tag-region_euw {
|
||||||
background-color: #3b82f6;
|
background-color: #3b82f6;
|
||||||
color: white;
|
color: white;
|
||||||
@@ -433,6 +430,257 @@ const formattedDescription = computed(() =>
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Stats Section */
|
||||||
|
.tooltip-stats {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
color: var(--tooltip-stat-value);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
min-width: 45px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
color: var(--tooltip-stat-label);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Effects Section */
|
||||||
|
.tooltip-effects {
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--tooltip-header-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 3px solid var(--tooltip-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item.effect-passive {
|
||||||
|
border-left-color: var(--tooltip-effect-passive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item.effect-active {
|
||||||
|
border-left-color: var(--tooltip-effect-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item.effect-unique {
|
||||||
|
border-left-color: var(--tooltip-effect-unique);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item.effect-mythic {
|
||||||
|
border-left-color: var(--tooltip-effect-mythic);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item.effect-legendary {
|
||||||
|
border-left-color: var(--tooltip-effect-legendary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-item.effect-epic {
|
||||||
|
border-left-color: var(--tooltip-effect-epic);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-type {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-passive .effect-type {
|
||||||
|
color: var(--tooltip-effect-passive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-active .effect-type {
|
||||||
|
color: var(--tooltip-effect-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-unique .effect-type {
|
||||||
|
color: var(--tooltip-effect-unique);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-mythic .effect-type {
|
||||||
|
color: var(--tooltip-effect-mythic);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-legendary .effect-type {
|
||||||
|
color: var(--tooltip-effect-legendary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-epic .effect-type {
|
||||||
|
color: var(--tooltip-effect-epic);
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--tooltip-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--tooltip-text-dim);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rules Section */
|
||||||
|
.tooltip-rules {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-left: 2px solid var(--tooltip-border);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--tooltip-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flavor Text */
|
||||||
|
.tooltip-flavor {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(74, 222, 255, 0.05);
|
||||||
|
border-left: 2px solid var(--tooltip-flavor);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--tooltip-flavor);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text segment styles inside effects */
|
||||||
|
.effect-description :deep(.stat-highlight) {
|
||||||
|
color: var(--tooltip-highlight);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-passive) {
|
||||||
|
color: var(--tooltip-effect-passive);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-active) {
|
||||||
|
color: var(--tooltip-effect-active);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-keyword) {
|
||||||
|
color: var(--tooltip-keyword);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-keyword-major) {
|
||||||
|
color: var(--tooltip-keyword-major);
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-keyword-stealth) {
|
||||||
|
color: var(--tooltip-keyword-stealth);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-status) {
|
||||||
|
color: var(--tooltip-status);
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-speed),
|
||||||
|
.effect-description :deep(.tag-scaling) {
|
||||||
|
color: var(--tooltip-scaling);
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-healing) {
|
||||||
|
color: var(--tooltip-healing);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-shield) {
|
||||||
|
color: var(--tooltip-shield);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-magic-damage) {
|
||||||
|
color: var(--tooltip-magic-damage);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-physical-damage) {
|
||||||
|
color: var(--tooltip-physical-damage);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-true-damage) {
|
||||||
|
color: var(--tooltip-true-damage);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-onhit) {
|
||||||
|
background: rgba(52, 152, 219, 0.2);
|
||||||
|
color: var(--tooltip-onhit);
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-spellname) {
|
||||||
|
color: var(--tooltip-spellname);
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-unique) {
|
||||||
|
color: var(--tooltip-effect-unique);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-rarity-mythic) {
|
||||||
|
color: var(--tooltip-effect-mythic);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-rarity-legendary) {
|
||||||
|
color: var(--tooltip-effect-legendary);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.effect-description :deep(.tag-rarity-generic) {
|
||||||
|
color: var(--tooltip-effect-epic);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
.fade-enter-active,
|
.fade-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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,25 +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',
|
|
||||||
ItemTag: 'readonly',
|
|
||||||
Builds: 'readonly',
|
|
||||||
PerkStyle: 'readonly',
|
|
||||||
PerksResponse: 'readonly',
|
|
||||||
PerkStylesResponse: 'readonly',
|
|
||||||
Champion: 'readonly',
|
|
||||||
ChampionsResponse: 'readonly',
|
|
||||||
ChampionResponse: 'readonly',
|
|
||||||
ItemResponse: 'readonly',
|
|
||||||
MatchupData: 'readonly',
|
|
||||||
Item: 'readonly',
|
|
||||||
SummonerSpell: 'readonly',
|
|
||||||
Perk: 'readonly'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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,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,
|
||||||
|
|||||||
@@ -19,11 +19,38 @@ 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]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
declare global {
|
|
||||||
/**
|
|
||||||
* Item tags derived from purchase patterns
|
|
||||||
*/
|
|
||||||
type ItemTag = 'ahead' | 'behind' | 'region_euw' | 'region_eun' | 'region_na' | 'region_kr'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents an item in the build tree
|
|
||||||
*/
|
|
||||||
interface ItemTree {
|
|
||||||
count: number
|
|
||||||
data: number
|
|
||||||
children: ItemTree[]
|
|
||||||
tags: ItemTag[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a complete build with runes and items
|
|
||||||
*/
|
|
||||||
interface Build {
|
|
||||||
runeKeystone: number
|
|
||||||
runes: Rune[]
|
|
||||||
items: ItemTree
|
|
||||||
bootsFirst: number
|
|
||||||
count: number
|
|
||||||
boots: Array<{ count: number; data: number }>
|
|
||||||
suppItems: Array<{ count: number; data: number }>
|
|
||||||
startItems: Array<{ count: number; data: number }>
|
|
||||||
pickrate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents champion build information (array of builds)
|
|
||||||
*/
|
|
||||||
type Builds = Array<Build>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a rune configuration
|
|
||||||
*/
|
|
||||||
interface Rune {
|
|
||||||
count: number
|
|
||||||
primaryStyle: number
|
|
||||||
secondaryStyle: number
|
|
||||||
selections: number[]
|
|
||||||
pickrate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents counter data for a champion
|
|
||||||
*/
|
|
||||||
interface MatchupData {
|
|
||||||
championId: number
|
|
||||||
winrate: number
|
|
||||||
games: number
|
|
||||||
championName: string
|
|
||||||
championAlias: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents lane-specific champion data
|
|
||||||
*/
|
|
||||||
interface LaneData {
|
|
||||||
data: string
|
|
||||||
count: number
|
|
||||||
winningMatches: number
|
|
||||||
losingMatches: number
|
|
||||||
winrate: number
|
|
||||||
pickrate: number
|
|
||||||
builds?: Builds
|
|
||||||
summonerSpells: Array<{ id: number; count: number; pickrate: number }>
|
|
||||||
matchups?: MatchupData[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents complete champion data
|
|
||||||
*/
|
|
||||||
interface ChampionData {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
alias: string
|
|
||||||
gameCount: number
|
|
||||||
winrate: number
|
|
||||||
pickrate: number
|
|
||||||
lanes: LaneData[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Champion summary from CDragon
|
|
||||||
*/
|
|
||||||
interface ChampionSummary {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
alias: string
|
|
||||||
squarePortraitPath: string
|
|
||||||
// Add other relevant fields as needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {}
|
|
||||||
@@ -1,25 +1,17 @@
|
|||||||
declare global {
|
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 src/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 "$@"
|
||||||
781
match_collector/package-lock.json
generated
781
match_collector/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "match_collector",
|
"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"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,21 @@ type Match = {
|
|||||||
}
|
}
|
||||||
info: {
|
info: {
|
||||||
endOfGameResult: string
|
endOfGameResult: string
|
||||||
frameInterval: number
|
gameCreation: number
|
||||||
|
gameDuration: number
|
||||||
|
gameEndTimestamp: number
|
||||||
gameId: number
|
gameId: number
|
||||||
|
gameMode: string
|
||||||
|
gameName: string
|
||||||
|
gameStartTimestamp: number
|
||||||
|
gameType: string
|
||||||
|
gameVersion: string
|
||||||
|
mapId: number
|
||||||
participants: Participant[]
|
participants: Participant[]
|
||||||
|
platformId: string
|
||||||
|
queueId: number
|
||||||
teams: Team[]
|
teams: Team[]
|
||||||
|
tournamentCode: string
|
||||||
}
|
}
|
||||||
timeline: Timeline
|
timeline: Timeline
|
||||||
}
|
}
|
||||||
|
|||||||
100
match_collector/src/cdragon_cache.ts
Normal file
100
match_collector/src/cdragon_cache.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { writeFile, mkdir } from 'fs/promises'
|
||||||
|
import { existsSync } from 'fs'
|
||||||
|
import { join, resolve } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { dirname } from 'path'
|
||||||
|
|
||||||
|
// Get current directory for relative path resolution
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
|
// CDragon base URL for specific patch
|
||||||
|
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
|
||||||
|
|
||||||
|
// Assets to cache for each patch
|
||||||
|
const CDRAGON_ASSETS = [
|
||||||
|
{
|
||||||
|
name: 'items.json',
|
||||||
|
path: 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'perks.json',
|
||||||
|
path: 'plugins/rcp-be-lol-game-data/global/default/v1/perks.json'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'perkstyles.json',
|
||||||
|
path: 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'summoner-spells.json',
|
||||||
|
path: 'plugins/rcp-be-lol-game-data/global/default/v1/summoner-spells.json'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'champion-summary.json',
|
||||||
|
path: 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download CDragon assets for a specific patch.
|
||||||
|
* This caches game data locally for faster access.
|
||||||
|
*/
|
||||||
|
async function downloadCDragonAssets(patch: string) {
|
||||||
|
// Convert patch format for CDragon: "16.4" -> "16.4" (already in correct format)
|
||||||
|
// CDragon uses patch format without the last minor version
|
||||||
|
const cdragonPatch = patch
|
||||||
|
console.log(`\n=== Downloading CDragon assets for patch ${cdragonPatch} ===`)
|
||||||
|
|
||||||
|
// Get cache directory from environment or use default
|
||||||
|
// In development, use a local directory relative to project root
|
||||||
|
// In production (Docker), use /cdragon (shared volume with frontend)
|
||||||
|
const defaultCacheDir =
|
||||||
|
process.env.NODE_ENV === 'development'
|
||||||
|
? resolve(__dirname, '../../dev/data/cdragon')
|
||||||
|
: '/cdragon'
|
||||||
|
const cacheDir = process.env.CDRAGON_CACHE_DIR || defaultCacheDir
|
||||||
|
const patchDir = join(cacheDir, cdragonPatch)
|
||||||
|
|
||||||
|
// Create patch directory if it doesn't exist
|
||||||
|
if (!existsSync(patchDir)) {
|
||||||
|
await mkdir(patchDir, { recursive: true })
|
||||||
|
console.log(`Created directory: ${patchDir}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download each asset
|
||||||
|
for (const asset of CDRAGON_ASSETS) {
|
||||||
|
const url = `${CDRAGON_BASE}${cdragonPatch}/${asset.path}`
|
||||||
|
const filePath = join(patchDir, asset.name)
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Downloading ${asset.name}...`)
|
||||||
|
const response = await fetch(url)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`Failed to download ${asset.name}: ${response.status} ${response.statusText}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
await writeFile(filePath, JSON.stringify(data, null, 2))
|
||||||
|
console.log(`Saved ${asset.name} to ${filePath}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error downloading ${asset.name}:`, error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a symlink or copy to 'latest' directory for easy access
|
||||||
|
const latestDir = join(cacheDir, 'latest')
|
||||||
|
const latestFile = join(latestDir, 'patch.txt')
|
||||||
|
|
||||||
|
if (!existsSync(latestDir)) {
|
||||||
|
await mkdir(latestDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(latestFile, patch)
|
||||||
|
console.log(`Updated latest patch reference to ${patch}`)
|
||||||
|
|
||||||
|
console.log('CDragon assets download complete!')
|
||||||
|
}
|
||||||
|
|
||||||
|
export { downloadCDragonAssets }
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
import { MongoClient } from 'mongodb'
|
import { MongoClient } from 'mongodb'
|
||||||
import {
|
import {
|
||||||
ItemTree,
|
|
||||||
GoldAdvantageTag,
|
GoldAdvantageTag,
|
||||||
PlatformCounts,
|
|
||||||
treeInit,
|
treeInit,
|
||||||
treeMerge,
|
treeMerge,
|
||||||
treeCutBranches,
|
treeCutBranches,
|
||||||
@@ -12,10 +10,30 @@ import {
|
|||||||
treeDeriveTags
|
treeDeriveTags
|
||||||
} from './item_tree'
|
} from './item_tree'
|
||||||
import { PLATFORM_KEYS } from './platform'
|
import { PLATFORM_KEYS } from './platform'
|
||||||
|
import {
|
||||||
|
initItemDict as initFirstBackItemDict,
|
||||||
|
extractFirstBackFromMatch,
|
||||||
|
groupFirstBacksByItemSet
|
||||||
|
} from './first_back'
|
||||||
|
|
||||||
import { Match, Timeline, Participant, Frame } from './api'
|
import { Match, Timeline, Participant, Frame } from './api'
|
||||||
|
import type {
|
||||||
|
Rune,
|
||||||
|
InternalBuild,
|
||||||
|
InternalBuildWithStartItems,
|
||||||
|
InternalLaneData,
|
||||||
|
InternalChampionData,
|
||||||
|
FirstBackData
|
||||||
|
} from './types'
|
||||||
|
|
||||||
function sameArrays(array1: Array<number>, array2: Array<number>) {
|
// Type aliases for internal use
|
||||||
|
type Builds = InternalBuild[]
|
||||||
|
type Build = InternalBuild
|
||||||
|
type BuildWithStartItems = InternalBuildWithStartItems
|
||||||
|
type LaneData = InternalLaneData
|
||||||
|
type ChampionData = InternalChampionData
|
||||||
|
|
||||||
|
function sameArrays(array1: number[], array2: number[]) {
|
||||||
if (array1.length != array2.length) return false
|
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
|
||||||
@@ -48,72 +66,6 @@ function arrayRemovePercentage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Rune = {
|
|
||||||
count: number
|
|
||||||
primaryStyle: number
|
|
||||||
secondaryStyle: number
|
|
||||||
selections: Array<number>
|
|
||||||
pickrate?: number
|
|
||||||
}
|
|
||||||
type Build = {
|
|
||||||
runeKeystone: number
|
|
||||||
runes: Array<Rune>
|
|
||||||
items: ItemTree
|
|
||||||
bootsFirstCount: number
|
|
||||||
bootsFirst?: number
|
|
||||||
count: number
|
|
||||||
suppItems: Array<{ data: number; count: number }>
|
|
||||||
boots: Array<{ data: number; count: number }>
|
|
||||||
pickrate?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type BuildWithStartItems = {
|
|
||||||
runeKeystone: number
|
|
||||||
runes: Array<Rune>
|
|
||||||
items: ItemTree
|
|
||||||
bootsFirst?: number
|
|
||||||
bootsFirstCount: number
|
|
||||||
count: number
|
|
||||||
startItems: Array<{ data: number; count: number }>
|
|
||||||
suppItems: Array<{ data: number; count: number }>
|
|
||||||
boots: Array<{ data: number; count: number }>
|
|
||||||
pickrate?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type Builds = Build[]
|
|
||||||
type Champion = {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
alias: string
|
|
||||||
}
|
|
||||||
type MatchupData = {
|
|
||||||
championId: number
|
|
||||||
winrate: number
|
|
||||||
games: number
|
|
||||||
championName: string
|
|
||||||
championAlias: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type LaneData = {
|
|
||||||
data: string
|
|
||||||
count: number
|
|
||||||
winningMatches: number
|
|
||||||
losingMatches: number
|
|
||||||
winrate: number
|
|
||||||
pickrate: number
|
|
||||||
builds: Builds
|
|
||||||
matchups?: Array<MatchupData>
|
|
||||||
summonerSpells: Array<{ id: number; count: number; pickrate?: number }>
|
|
||||||
// Region distribution for this lane (used for tag derivation)
|
|
||||||
regionDistribution?: PlatformCounts
|
|
||||||
}
|
|
||||||
type ChampionData = {
|
|
||||||
champion: Champion
|
|
||||||
winningMatches: number
|
|
||||||
losingMatches: number
|
|
||||||
lanes: Array<LaneData>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to create rune configuration from participant
|
// Helper function to create rune configuration from participant
|
||||||
function createRuneConfiguration(participant: Participant): Rune {
|
function createRuneConfiguration(participant: Participant): Rune {
|
||||||
const primaryStyle = participant.perks.styles[0].style
|
const primaryStyle = participant.perks.styles[0].style
|
||||||
@@ -225,7 +177,7 @@ function handleMatchBuilds(
|
|||||||
participantIndex: number,
|
participantIndex: number,
|
||||||
builds: Builds,
|
builds: Builds,
|
||||||
platform?: string
|
platform?: string
|
||||||
) {
|
): { build: Build; startItemId: number | undefined } {
|
||||||
const timeline: Timeline = match.timeline
|
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
|
||||||
@@ -233,6 +185,7 @@ function handleMatchBuilds(
|
|||||||
build.count += 1
|
build.count += 1
|
||||||
|
|
||||||
const items: Array<{ itemId: number; goldAdvantage: GoldAdvantageTag; platform?: string }> = []
|
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
|
||||||
@@ -266,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
|
||||||
@@ -315,7 +261,11 @@ 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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMatch(match: Match, champions: Map<number, ChampionData>, platform?: string) {
|
function handleMatch(match: Match, champions: Map<number, ChampionData>, platform?: string) {
|
||||||
@@ -411,7 +361,24 @@ function handleMatch(match: Match, champions: Map<number, ChampionData>, platfor
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Items and runes (builds)
|
// Items and runes (builds)
|
||||||
handleMatchBuilds(match, participant, participantIndex, lane.builds, platform)
|
const { build, startItemId } = handleMatchBuilds(
|
||||||
|
match,
|
||||||
|
participant,
|
||||||
|
participantIndex,
|
||||||
|
lane.builds,
|
||||||
|
platform
|
||||||
|
)
|
||||||
|
|
||||||
|
// First back data - store at build level with start item tracking
|
||||||
|
const firstBackData = extractFirstBackFromMatch(match, participantIndex)
|
||||||
|
if (firstBackData) {
|
||||||
|
if (!build.firstBacksRaw) {
|
||||||
|
build.firstBacksRaw = []
|
||||||
|
}
|
||||||
|
// Include the starter item ID for proper filtering when splitting builds
|
||||||
|
firstBackData.startItemId = startItemId
|
||||||
|
build.firstBacksRaw.push(firstBackData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,7 +440,8 @@ function splitMergeOnStarterItem(build: Build, championName: string): BuildWithS
|
|||||||
startItems,
|
startItems,
|
||||||
suppItems: build.suppItems,
|
suppItems: build.suppItems,
|
||||||
boots: build.boots,
|
boots: build.boots,
|
||||||
pickrate: build.pickrate
|
pickrate: build.pickrate,
|
||||||
|
firstBacksRaw: build.firstBacksRaw
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
} else {
|
} else {
|
||||||
@@ -481,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
|
||||||
}
|
}
|
||||||
@@ -573,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,
|
||||||
@@ -581,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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,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) {
|
||||||
@@ -723,6 +742,9 @@ async function makeChampionsStats(client: MongoClient, patch: string, platforms:
|
|||||||
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')
|
||||||
|
|
||||||
|
|||||||
225
match_collector/src/first_back.ts
Normal file
225
match_collector/src/first_back.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { Match, Timeline } from './api'
|
||||||
|
import type { BackEvent, ItemSet, FirstBackData, FirstBackGroup } from './types'
|
||||||
|
|
||||||
|
// Re-export types for backward compatibility
|
||||||
|
export type { BackEvent, ItemSet, FirstBackData, FirstBackGroup }
|
||||||
|
|
||||||
|
// Item dictionary for gold information
|
||||||
|
const itemDict = new Map<
|
||||||
|
number,
|
||||||
|
{
|
||||||
|
price: number
|
||||||
|
priceTotal: number
|
||||||
|
to: number[]
|
||||||
|
categories: string[]
|
||||||
|
requiredBuffCurrencyName?: string
|
||||||
|
name?: string
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
|
||||||
|
async function itemList() {
|
||||||
|
const response = await fetch(
|
||||||
|
'https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/items.json'
|
||||||
|
)
|
||||||
|
const list = await response.json()
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initItemDict() {
|
||||||
|
if (itemDict.size > 0) return
|
||||||
|
const globalItems = await itemList()
|
||||||
|
for (const item of globalItems) {
|
||||||
|
itemDict.set(item.id, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get item gold value
|
||||||
|
function getItemGold(itemId: number): number {
|
||||||
|
const item = itemDict.get(itemId)
|
||||||
|
if (!item) return 0
|
||||||
|
return item.priceTotal || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if item should be tracked for first back
|
||||||
|
function shouldTrackItem(itemId: number): boolean {
|
||||||
|
const item = itemDict.get(itemId)
|
||||||
|
if (!item) return false
|
||||||
|
|
||||||
|
// Skip some consumables and trinkets
|
||||||
|
if (item.name == 'Health Potion') return false
|
||||||
|
if (item.name == 'Control Ward') return false
|
||||||
|
if (item.categories?.includes('Trinket')) return false
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a unique key for an item set (sorted by itemId for consistency)
|
||||||
|
function itemSetKey(items: Array<{ itemId: number; count: number }>): string {
|
||||||
|
const sorted = [...items].sort((a, b) => a.itemId - b.itemId)
|
||||||
|
return sorted.map(i => `${i.itemId}x${i.count}`).join(',')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse all backs from a match timeline for a specific participant
|
||||||
|
export function parseBacksFromTimeline(timeline: Timeline, participantIndex: number): BackEvent[] {
|
||||||
|
const backs: BackEvent[] = []
|
||||||
|
let currentBack: BackEvent | null = null
|
||||||
|
let lastPurchaseTimestamp = 0
|
||||||
|
const BACK_TIMEOUT = 30000 // 30 seconds - if no purchase for 30s, consider back ended
|
||||||
|
|
||||||
|
for (const frame of timeline.info.frames) {
|
||||||
|
for (const event of frame.events) {
|
||||||
|
if (event.participantId !== participantIndex) continue
|
||||||
|
|
||||||
|
if (event.type === 'ITEM_PURCHASED') {
|
||||||
|
if (!shouldTrackItem(event.itemId)) continue
|
||||||
|
|
||||||
|
const timestamp = event.timestamp
|
||||||
|
|
||||||
|
// Start new back if:
|
||||||
|
// 1. No current back, or
|
||||||
|
// 2. More than BACK_TIMEOUT since last purchase
|
||||||
|
if (!currentBack || timestamp - lastPurchaseTimestamp > BACK_TIMEOUT) {
|
||||||
|
// Save previous back if exists
|
||||||
|
if (currentBack && currentBack.items.length > 0) {
|
||||||
|
backs.push(currentBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new back
|
||||||
|
currentBack = {
|
||||||
|
timestamp,
|
||||||
|
items: [],
|
||||||
|
totalGold: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add item to current back
|
||||||
|
const itemGold = getItemGold(event.itemId)
|
||||||
|
currentBack.items.push({
|
||||||
|
itemId: event.itemId,
|
||||||
|
gold: itemGold
|
||||||
|
})
|
||||||
|
currentBack.totalGold += itemGold
|
||||||
|
lastPurchaseTimestamp = timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === 'ITEM_UNDO' && currentBack) {
|
||||||
|
// Handle undo - remove last item
|
||||||
|
if (currentBack.items.length > 0) {
|
||||||
|
const lastItem = currentBack.items.pop()
|
||||||
|
if (lastItem) {
|
||||||
|
currentBack.totalGold -= lastItem.gold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't forget the last back
|
||||||
|
if (currentBack && currentBack.items.length > 0) {
|
||||||
|
backs.push(currentBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
return backs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the first back (excluding starting items purchase at game start)
|
||||||
|
export function getFirstBack(backs: BackEvent[]): BackEvent | null {
|
||||||
|
// Filter out the initial purchase (usually within first minute)
|
||||||
|
// and get the first real back
|
||||||
|
const MIN_GAME_TIME = 60000 // 1 minute - ignore purchases before this
|
||||||
|
|
||||||
|
for (const back of backs) {
|
||||||
|
if (back.timestamp >= MIN_GAME_TIME) {
|
||||||
|
return back
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a back event to an item set
|
||||||
|
function backToItemSet(back: BackEvent): ItemSet {
|
||||||
|
const itemCounts = new Map<number, number>()
|
||||||
|
|
||||||
|
for (const item of back.items) {
|
||||||
|
const existing = itemCounts.get(item.itemId) || 0
|
||||||
|
itemCounts.set(item.itemId, existing + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = Array.from(itemCounts.entries()).map(([itemId, count]) => ({
|
||||||
|
itemId,
|
||||||
|
count
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
totalGold: back.totalGold
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group first backs by item set
|
||||||
|
export function groupFirstBacksByItemSet(firstBacks: FirstBackData[]): FirstBackGroup[] {
|
||||||
|
const totalBacks = firstBacks.length
|
||||||
|
|
||||||
|
// Group by item set
|
||||||
|
const itemSetGroups: Map<string, FirstBackData[]> = new Map()
|
||||||
|
|
||||||
|
for (const back of firstBacks) {
|
||||||
|
const key = itemSetKey(back.itemSet.items)
|
||||||
|
|
||||||
|
if (!itemSetGroups.has(key)) {
|
||||||
|
itemSetGroups.set(key, [])
|
||||||
|
}
|
||||||
|
itemSetGroups.get(key)!.push(back)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build result
|
||||||
|
const result: FirstBackGroup[] = []
|
||||||
|
|
||||||
|
for (const backs of itemSetGroups.values()) {
|
||||||
|
// Use the first back's item set (they're all the same)
|
||||||
|
const itemSet = backs[0].itemSet
|
||||||
|
const avgTimestamp = backs.reduce((sum, b) => sum + b.timestamp, 0) / backs.length
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
itemSet,
|
||||||
|
count: backs.length,
|
||||||
|
pickrate: backs.length / totalBacks,
|
||||||
|
avgTimestamp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by count (most common item sets first)
|
||||||
|
result.sort((a, b) => b.count - a.count)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract first back data from a match for a participant
|
||||||
|
export function extractFirstBackFromMatch(
|
||||||
|
match: Match,
|
||||||
|
participantIndex: number
|
||||||
|
): FirstBackData | null {
|
||||||
|
const timeline = match.timeline
|
||||||
|
if (!timeline) return null
|
||||||
|
|
||||||
|
const backs = parseBacksFromTimeline(timeline, participantIndex)
|
||||||
|
const firstBack = getFirstBack(backs)
|
||||||
|
|
||||||
|
if (!firstBack) return null
|
||||||
|
|
||||||
|
const itemSet = backToItemSet(firstBack)
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: firstBack.timestamp,
|
||||||
|
itemSet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
initItemDict,
|
||||||
|
parseBacksFromTimeline,
|
||||||
|
getFirstBack,
|
||||||
|
groupFirstBacksByItemSet,
|
||||||
|
extractFirstBackFromMatch
|
||||||
|
}
|
||||||
@@ -6,9 +6,56 @@ import { MongoClient } from 'mongodb'
|
|||||||
import champion_stat from './champion_stat'
|
import champion_stat from './champion_stat'
|
||||||
import { Match } from './api'
|
import { Match } from './api'
|
||||||
import { PLATFORMS, getPlatformBaseUrl, getRegionalBaseUrl, getRegionForPlatform } from './platform'
|
import { PLATFORMS, getPlatformBaseUrl, getRegionalBaseUrl, getRegionForPlatform } from './platform'
|
||||||
|
import { downloadCDragonAssets } from './cdragon_cache'
|
||||||
|
|
||||||
main()
|
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() {
|
async function main() {
|
||||||
// Check if we're in development mode with pre-loaded data
|
// Check if we're in development mode with pre-loaded data
|
||||||
if (process.env.NODE_ENV === 'development' && process.env.USE_IMPORTED_DATA === 'true') {
|
if (process.env.NODE_ENV === 'development' && process.env.USE_IMPORTED_DATA === 'true') {
|
||||||
@@ -17,13 +64,10 @@ async function main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Original production mode
|
// Production mode: collect matches and organize by their gameVersion
|
||||||
console.log('MatchCollector - Hello !')
|
console.log('MatchCollector - Hello !')
|
||||||
const client = await connectToDatabase()
|
const client = await connectToDatabase()
|
||||||
const [latestPatch, latestPatchTime] = await fetchLatestPatchDate(client)
|
console.log('Connected to database')
|
||||||
console.log(
|
|
||||||
'Connected to database, latest patch ' + latestPatch + ' was epoch: ' + latestPatchTime
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('Using RIOT_API_KEY: ' + api_key)
|
console.log('Using RIOT_API_KEY: ' + api_key)
|
||||||
if (api_key != null && api_key != undefined && api_key != '') {
|
if (api_key != null && api_key != undefined && api_key != '') {
|
||||||
@@ -31,20 +75,22 @@ async function main() {
|
|||||||
for (const [platform, region] of Object.entries(PLATFORMS)) {
|
for (const [platform, region] of Object.entries(PLATFORMS)) {
|
||||||
console.log(`\n=== Processing platform: ${platform} (region: ${region}) ===`)
|
console.log(`\n=== Processing platform: ${platform} (region: ${region}) ===`)
|
||||||
|
|
||||||
const alreadySeenGameList = await alreadySeenGames(client, latestPatch, platform)
|
// Get already seen games for all patches (we'll check by gameVersion when saving)
|
||||||
console.log(
|
const alreadySeenGameList = await alreadySeenGamesAllPatches(client, platform)
|
||||||
'We already have ' + alreadySeenGameList.length + ' matches for this patch/platform !'
|
console.log('We already have ' + alreadySeenGameList.length + ' matches for this platform !')
|
||||||
)
|
|
||||||
|
|
||||||
const challengerLeague = await fetchChallengerLeague(platform)
|
const challengerLeague = await fetchChallengerLeague(platform)
|
||||||
console.log('ChallengerLeague: got ' + challengerLeague.entries.length + ' entries')
|
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[] = []
|
const gameList: string[] = []
|
||||||
let i = 0
|
let i = 0
|
||||||
for (const challenger of challengerLeague.entries) {
|
for (const challenger of challengerLeague.entries) {
|
||||||
console.log('Entry ' + i + '/' + challengerLeague.entries.length + ' ...')
|
console.log('Entry ' + i + '/' + challengerLeague.entries.length + ' ...')
|
||||||
const puuid = challenger.puuid
|
const puuid = challenger.puuid
|
||||||
const challengerGameList = await summonerGameList(puuid, latestPatchTime, region)
|
const challengerGameList = await summonerGameList(puuid, startTime, region)
|
||||||
for (const game of challengerGameList) {
|
for (const game of challengerGameList) {
|
||||||
if (!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
|
if (!gameList.includes(game) && !alreadySeenGameList.includes(game)) {
|
||||||
gameList.push(game)
|
gameList.push(game)
|
||||||
@@ -64,15 +110,26 @@ async function main() {
|
|||||||
const gameMatch = await match(game, matchRegion)
|
const gameMatch = await match(game, matchRegion)
|
||||||
const gameTimeline = await matchTimeline(game, matchRegion)
|
const gameTimeline = await matchTimeline(game, matchRegion)
|
||||||
gameMatch.timeline = gameTimeline
|
gameMatch.timeline = gameTimeline
|
||||||
await saveMatch(client, gameMatch, latestPatch, platform)
|
// Extract patch from gameVersion and save to appropriate collection
|
||||||
|
const patch = extractPatchFromGameVersion(gameMatch.info.gameVersion)
|
||||||
|
await saveMatch(client, gameMatch, patch, platform)
|
||||||
i++
|
i++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Generating stats...')
|
// Get the latest patch from collections and generate stats for it
|
||||||
|
const latestPatch = await getLatestPatchFromCollections(client)
|
||||||
|
if (latestPatch) {
|
||||||
|
console.log(`Generating stats for latest patch: ${latestPatch}...`)
|
||||||
await champion_stat.makeChampionsStats(client, latestPatch, Object.keys(PLATFORMS))
|
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.')
|
console.log('All done. Closing client.')
|
||||||
await client.close()
|
await client.close()
|
||||||
}
|
}
|
||||||
@@ -117,13 +174,6 @@ async function connectToDatabase() {
|
|||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLatestPatchDate(client: MongoClient) {
|
|
||||||
const database = client.db('patches')
|
|
||||||
const patches = database.collection('patches')
|
|
||||||
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
|
|
||||||
return [latestPatch!.patch, Math.floor(latestPatch!.date.valueOf() / 1000)]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchChallengerLeague(platform: string) {
|
async function fetchChallengerLeague(platform: string) {
|
||||||
const queue = 'RANKED_SOLO_5x5'
|
const queue = 'RANKED_SOLO_5x5'
|
||||||
const endpoint = `/lol/league/v4/challengerleagues/by-queue/${queue}`
|
const endpoint = `/lol/league/v4/challengerleagues/by-queue/${queue}`
|
||||||
@@ -138,7 +188,7 @@ async function fetchChallengerLeague(platform: string) {
|
|||||||
return challengerLeague
|
return challengerLeague
|
||||||
}
|
}
|
||||||
|
|
||||||
async function summonerGameList(puuid: string, startTime: string, region: string) {
|
async function summonerGameList(puuid: string, startTime: number, region: string) {
|
||||||
const baseUrl = getRegionalBaseUrl(region)
|
const baseUrl = getRegionalBaseUrl(region)
|
||||||
const endpoint = `/lol/match/v5/matches/by-puuid/${puuid}/ids`
|
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 url = `${baseUrl}${endpoint}?queue=420&type=ranked&startTime=${startTime}&api_key=${api_key}`
|
||||||
@@ -174,18 +224,31 @@ async function matchTimeline(matchId: string, region: string) {
|
|||||||
return timeline
|
return timeline
|
||||||
}
|
}
|
||||||
|
|
||||||
async function alreadySeenGames(client: MongoClient, latestPatch: string, platform: string) {
|
/**
|
||||||
const database = client.db('matches')
|
* Get already seen games across all patches for a specific platform.
|
||||||
const collectionName = `${latestPatch}_${platform}`
|
* This is used when we don't know the patch beforehand (we get it from gameVersion).
|
||||||
const matches = database.collection(collectionName)
|
*/
|
||||||
|
async function alreadySeenGamesAllPatches(client: MongoClient, platform: string) {
|
||||||
|
const matchesDb = client.db('matches')
|
||||||
|
const collections = await matchesDb.listCollections().toArray()
|
||||||
|
const collectionNames = collections.map(c => c.name)
|
||||||
|
|
||||||
const alreadySeen = await matches.distinct('metadata.matchId')
|
// Find all collections for this platform (format: "patch_platform")
|
||||||
return alreadySeen
|
const platformCollections = collectionNames.filter(name => name.endsWith(`_${platform}`))
|
||||||
|
|
||||||
|
const allSeen: string[] = []
|
||||||
|
for (const collectionName of platformCollections) {
|
||||||
|
const matches = matchesDb.collection(collectionName)
|
||||||
|
const seen = await matches.distinct('metadata.matchId')
|
||||||
|
allSeen.push(...seen)
|
||||||
|
}
|
||||||
|
|
||||||
|
return allSeen
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveMatch(client: MongoClient, match: Match, latestPatch: string, platform: string) {
|
async function saveMatch(client: MongoClient, match: Match, patch: string, platform: string) {
|
||||||
const database = client.db('matches')
|
const database = client.db('matches')
|
||||||
const collectionName = `${latestPatch}_${platform}`
|
const collectionName = `${patch}_${platform}`
|
||||||
const matches = database.collection(collectionName)
|
const matches = database.collection(collectionName)
|
||||||
await matches.insertOne(match)
|
await matches.insertOne(match)
|
||||||
}
|
}
|
||||||
@@ -198,7 +261,16 @@ async function runWithPreloadedData() {
|
|||||||
|
|
||||||
const client = await connectToDatabase()
|
const client = await connectToDatabase()
|
||||||
try {
|
try {
|
||||||
const [latestPatch] = await fetchLatestPatchDate(client)
|
// Get the latest patch from collections instead of patches database
|
||||||
|
const latestPatch = await getLatestPatchFromCollections(client)
|
||||||
|
|
||||||
|
if (!latestPatch) {
|
||||||
|
console.error('❌ No match data found in database')
|
||||||
|
console.log('💡 Please run the data import script first:')
|
||||||
|
console.log(' node dev/scripts/setup-db.js')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Latest patch: ${latestPatch}`)
|
console.log(`Latest patch: ${latestPatch}`)
|
||||||
|
|
||||||
// Check if we have matches for this patch (including platform-specific collections)
|
// Check if we have matches for this patch (including platform-specific collections)
|
||||||
@@ -235,6 +307,9 @@ async function runWithPreloadedData() {
|
|||||||
await champion_stat.makeChampionsStats(client, latestPatch)
|
await champion_stat.makeChampionsStats(client, latestPatch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Download CDragon assets for the latest patch
|
||||||
|
await downloadCDragonAssets(latestPatch)
|
||||||
|
|
||||||
console.log('🎉 All stats generated successfully!')
|
console.log('🎉 All stats generated successfully!')
|
||||||
console.log('🚀 Your development database is ready for frontend testing!')
|
console.log('🚀 Your development database is ready for frontend testing!')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,31 +6,7 @@ import {
|
|||||||
} from './platform'
|
} from './platform'
|
||||||
|
|
||||||
import type { PlatformCounts } from './platform'
|
import type { PlatformCounts } from './platform'
|
||||||
|
import type { GoldAdvantageTag, ItemTag, ItemTree } from './types'
|
||||||
type GoldAdvantageTag = 'ahead' | 'behind' | 'even'
|
|
||||||
|
|
||||||
// Item tags that can be derived from purchase patterns
|
|
||||||
type ItemTag = 'ahead' | 'behind' | 'region_euw' | 'region_eun' | 'region_na' | 'region_kr'
|
|
||||||
|
|
||||||
type ItemTree = {
|
|
||||||
data: number | undefined
|
|
||||||
count: number
|
|
||||||
children: Array<ItemTree>
|
|
||||||
|
|
||||||
// Gold advantage tracking
|
|
||||||
boughtWhen: {
|
|
||||||
aheadCount: number
|
|
||||||
behindCount: number
|
|
||||||
evenCount: number
|
|
||||||
meanGold: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platform tracking
|
|
||||||
platformCount: PlatformCounts
|
|
||||||
|
|
||||||
// Derived tags for display
|
|
||||||
tags: Array<ItemTag>
|
|
||||||
}
|
|
||||||
|
|
||||||
function treeInit(): ItemTree {
|
function treeInit(): ItemTree {
|
||||||
return {
|
return {
|
||||||
@@ -288,12 +264,8 @@ function deriveTags(node: ItemTree, expectedRegionDistribution?: PlatformCounts)
|
|||||||
const totalExpected = REGION_KEYS.reduce((sum, key) => sum + expectedRegionDistribution[key], 0)
|
const totalExpected = REGION_KEYS.reduce((sum, key) => sum + expectedRegionDistribution[key], 0)
|
||||||
|
|
||||||
if (totalExpected > 0) {
|
if (totalExpected > 0) {
|
||||||
// Tag if the item is significantly more popular in a region (>= 1.5x expected rate)
|
// Tag if one region accounts for >= 60% of the normalized distribution
|
||||||
// and has a minimum absolute percentage (>= 10%)
|
// Normalized value = actual percentage / expected percentage ratio
|
||||||
const SIGNIFICANCE_THRESHOLD = 1.5
|
|
||||||
const MINIMUM_PCT = 0.1
|
|
||||||
|
|
||||||
// Loop through all regions to derive tags
|
|
||||||
const regionTags: Array<{ key: keyof PlatformCounts; tag: ItemTag }> = [
|
const regionTags: Array<{ key: keyof PlatformCounts; tag: ItemTag }> = [
|
||||||
{ key: 'euw', tag: 'region_euw' },
|
{ key: 'euw', tag: 'region_euw' },
|
||||||
{ key: 'eun', tag: 'region_eun' },
|
{ key: 'eun', tag: 'region_eun' },
|
||||||
@@ -301,12 +273,23 @@ function deriveTags(node: ItemTree, expectedRegionDistribution?: PlatformCounts)
|
|||||||
{ key: 'kr', tag: 'region_kr' }
|
{ key: 'kr', tag: 'region_kr' }
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const { key, tag } of regionTags) {
|
// Calculate normalized values (actual/expected ratio) for each region
|
||||||
|
const normalizedValues = regionTags.map(({ key, tag }) => {
|
||||||
const expectedPct = expectedRegionDistribution[key] / totalExpected
|
const expectedPct = expectedRegionDistribution[key] / totalExpected
|
||||||
const actualPct = node.platformCount[key] / totalRegionCount
|
const actualPct = node.platformCount[key] / totalRegionCount
|
||||||
|
const normalizedValue = expectedPct > 0 ? actualPct / expectedPct : 0
|
||||||
|
return { tag, value: normalizedValue }
|
||||||
|
})
|
||||||
|
|
||||||
if (actualPct >= expectedPct * SIGNIFICANCE_THRESHOLD && actualPct >= MINIMUM_PCT) {
|
const totalNormalized = normalizedValues.reduce((sum, { value }) => sum + value, 0)
|
||||||
|
|
||||||
|
// Tag the region if it accounts for >= 60% of the normalized distribution
|
||||||
|
if (totalNormalized > 0) {
|
||||||
|
for (const { tag, value } of normalizedValues) {
|
||||||
|
if (value / totalNormalized >= 0.6) {
|
||||||
tags.push(tag)
|
tags.push(tag)
|
||||||
|
break // Only tag the most dominant region
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,7 +313,6 @@ function treeDeriveTags(itemtree: ItemTree, expectedRegionDistribution?: Platfor
|
|||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
ItemTree,
|
|
||||||
PlatformCounts,
|
PlatformCounts,
|
||||||
GoldAdvantageTag,
|
GoldAdvantageTag,
|
||||||
ItemTag,
|
ItemTag,
|
||||||
|
|||||||
27
match_collector/src/lib.ts
Normal file
27
match_collector/src/lib.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Match Collector Library
|
||||||
|
* Exports all shared types for use by other projects (e.g., frontend)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Export all types
|
||||||
|
export type {
|
||||||
|
ItemTag,
|
||||||
|
GoldAdvantageTag,
|
||||||
|
PlatformCounts,
|
||||||
|
ItemTree,
|
||||||
|
Rune,
|
||||||
|
ItemCountEntry,
|
||||||
|
FirstBackItemSetEntry,
|
||||||
|
ItemSet,
|
||||||
|
FirstBackGroup,
|
||||||
|
Build,
|
||||||
|
Builds,
|
||||||
|
MatchupData,
|
||||||
|
SummonerSpellData,
|
||||||
|
LaneData,
|
||||||
|
ChampionData,
|
||||||
|
ChampionSummary,
|
||||||
|
Champion,
|
||||||
|
BackEvent,
|
||||||
|
FirstBackData
|
||||||
|
} from './types'
|
||||||
257
match_collector/src/types.ts
Normal file
257
match_collector/src/types.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
/**
|
||||||
|
* Shared types between match_collector and frontend
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item tags derived from purchase patterns
|
||||||
|
*/
|
||||||
|
export type ItemTag = 'ahead' | 'behind' | 'region_euw' | 'region_eun' | 'region_na' | 'region_kr'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gold advantage tag for item purchases
|
||||||
|
*/
|
||||||
|
export type GoldAdvantageTag = 'ahead' | 'behind' | 'even'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform counts for region tracking
|
||||||
|
*/
|
||||||
|
export interface PlatformCounts {
|
||||||
|
euw: number
|
||||||
|
eun: number
|
||||||
|
na: number
|
||||||
|
kr: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an item in the build tree
|
||||||
|
*/
|
||||||
|
export interface ItemTree {
|
||||||
|
data: number | undefined
|
||||||
|
count: number
|
||||||
|
children: ItemTree[]
|
||||||
|
tags: ItemTag[]
|
||||||
|
// Gold advantage tracking (used during processing)
|
||||||
|
boughtWhen: {
|
||||||
|
aheadCount: number
|
||||||
|
behindCount: number
|
||||||
|
evenCount: number
|
||||||
|
meanGold: number
|
||||||
|
}
|
||||||
|
// Platform tracking (used during processing)
|
||||||
|
platformCount: PlatformCounts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a rune configuration
|
||||||
|
*/
|
||||||
|
export interface Rune {
|
||||||
|
count: number
|
||||||
|
primaryStyle: number
|
||||||
|
secondaryStyle: number
|
||||||
|
selections: number[]
|
||||||
|
pickrate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an item entry with count
|
||||||
|
*/
|
||||||
|
export interface ItemCountEntry {
|
||||||
|
count: number
|
||||||
|
data: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an item in a first back item set
|
||||||
|
*/
|
||||||
|
export interface FirstBackItemSetEntry {
|
||||||
|
itemId: number
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an item set (combination of items)
|
||||||
|
*/
|
||||||
|
export interface ItemSet {
|
||||||
|
items: FirstBackItemSetEntry[]
|
||||||
|
totalGold: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal type for first back data during processing
|
||||||
|
*/
|
||||||
|
export interface FirstBackData {
|
||||||
|
timestamp: number
|
||||||
|
itemSet: ItemSet
|
||||||
|
startItemId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a grouped first back by item set
|
||||||
|
*/
|
||||||
|
export interface FirstBackGroup {
|
||||||
|
itemSet: ItemSet
|
||||||
|
count: number
|
||||||
|
pickrate: number
|
||||||
|
avgTimestamp: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal build type with processing fields
|
||||||
|
*/
|
||||||
|
export interface InternalBuild {
|
||||||
|
runeKeystone: number
|
||||||
|
runes: Rune[]
|
||||||
|
items: ItemTree
|
||||||
|
bootsFirstCount: number
|
||||||
|
bootsFirst?: number
|
||||||
|
count: number
|
||||||
|
suppItems: ItemCountEntry[]
|
||||||
|
boots: ItemCountEntry[]
|
||||||
|
pickrate?: number
|
||||||
|
firstBacksRaw?: FirstBackData[]
|
||||||
|
firstBacks?: FirstBackGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal build type with start items
|
||||||
|
*/
|
||||||
|
export interface InternalBuildWithStartItems {
|
||||||
|
runeKeystone: number
|
||||||
|
runes: Rune[]
|
||||||
|
items: ItemTree
|
||||||
|
bootsFirst?: number
|
||||||
|
bootsFirstCount: number
|
||||||
|
count: number
|
||||||
|
startItems: ItemCountEntry[]
|
||||||
|
suppItems: ItemCountEntry[]
|
||||||
|
boots: ItemCountEntry[]
|
||||||
|
pickrate?: number
|
||||||
|
firstBacksRaw?: FirstBackData[]
|
||||||
|
firstBacks?: FirstBackGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a complete build with runes and items (final output format)
|
||||||
|
*/
|
||||||
|
export interface Build {
|
||||||
|
runeKeystone: number
|
||||||
|
runes: Rune[]
|
||||||
|
items: ItemTree
|
||||||
|
bootsFirst: number
|
||||||
|
count: number
|
||||||
|
boots: ItemCountEntry[]
|
||||||
|
suppItems: ItemCountEntry[]
|
||||||
|
startItems: ItemCountEntry[]
|
||||||
|
pickrate: number
|
||||||
|
firstBacks?: FirstBackGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents champion build information (array of builds)
|
||||||
|
*/
|
||||||
|
export type Builds = Build[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents counter data for a champion
|
||||||
|
*/
|
||||||
|
export interface MatchupData {
|
||||||
|
championId: number
|
||||||
|
winrate: number
|
||||||
|
games: number
|
||||||
|
championName: string
|
||||||
|
championAlias: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents summoner spell data
|
||||||
|
*/
|
||||||
|
export interface SummonerSpellData {
|
||||||
|
id: number
|
||||||
|
count: number
|
||||||
|
pickrate?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal lane data with processing fields
|
||||||
|
*/
|
||||||
|
export interface InternalLaneData {
|
||||||
|
data: string
|
||||||
|
count: number
|
||||||
|
winningMatches: number
|
||||||
|
losingMatches: number
|
||||||
|
winrate: number
|
||||||
|
pickrate: number
|
||||||
|
builds: InternalBuild[]
|
||||||
|
matchups?: MatchupData[]
|
||||||
|
summonerSpells: SummonerSpellData[]
|
||||||
|
regionDistribution?: PlatformCounts
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents lane-specific champion data (final output format)
|
||||||
|
*/
|
||||||
|
export interface LaneData {
|
||||||
|
data: string
|
||||||
|
count: number
|
||||||
|
winningMatches: number
|
||||||
|
losingMatches: number
|
||||||
|
winrate: number
|
||||||
|
pickrate: number
|
||||||
|
builds?: Builds
|
||||||
|
summonerSpells: SummonerSpellData[]
|
||||||
|
matchups?: MatchupData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal champion data with processing fields
|
||||||
|
*/
|
||||||
|
export interface InternalChampionData {
|
||||||
|
champion: Champion
|
||||||
|
winningMatches: number
|
||||||
|
losingMatches: number
|
||||||
|
lanes: InternalLaneData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents complete champion data (final output format)
|
||||||
|
*/
|
||||||
|
export interface ChampionData {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
alias: string
|
||||||
|
gameCount: number
|
||||||
|
winrate: number
|
||||||
|
pickrate: number
|
||||||
|
lanes: LaneData[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Champion summary from CDragon
|
||||||
|
*/
|
||||||
|
export interface ChampionSummary {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
alias: string
|
||||||
|
squarePortraitPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal type for champion info
|
||||||
|
*/
|
||||||
|
export interface Champion {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
alias: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal type for back event
|
||||||
|
*/
|
||||||
|
export interface BackEvent {
|
||||||
|
timestamp: number
|
||||||
|
items: Array<{
|
||||||
|
itemId: number
|
||||||
|
gold: number
|
||||||
|
}>
|
||||||
|
totalGold: number
|
||||||
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"types": ["node"],
|
"types": ["node"],
|
||||||
"declaration": true
|
"declaration": true,
|
||||||
}
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
1
patch_detector/.gitignore
vendored
1
patch_detector/.gitignore
vendored
@@ -1 +0,0 @@
|
|||||||
node_modules
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
FROM node:lts-alpine
|
|
||||||
RUN mkdir -p /home/node/app/node_modules && chown -R node:node /home/node/app
|
|
||||||
WORKDIR /home/node/app
|
|
||||||
USER node
|
|
||||||
COPY --chown=node:node package*.json ./
|
|
||||||
RUN npm install
|
|
||||||
COPY --chown=node:node . .
|
|
||||||
CMD /bin/sh -c "node --import=tsx index.ts; sleep 1h"
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import { MongoClient } from 'mongodb'
|
|
||||||
import { writeFile, mkdir } from 'fs/promises'
|
|
||||||
import { existsSync } from 'fs'
|
|
||||||
import { join } from 'path'
|
|
||||||
|
|
||||||
// CDragon base URL for specific patch
|
|
||||||
const CDRAGON_BASE = 'https://raw.communitydragon.org/'
|
|
||||||
|
|
||||||
// Assets to cache for each patch
|
|
||||||
const CDRAGON_ASSETS = [
|
|
||||||
{
|
|
||||||
name: 'items.json',
|
|
||||||
path: 'plugins/rcp-be-lol-game-data/global/default/v1/items.json'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'perks.json',
|
|
||||||
path: 'plugins/rcp-be-lol-game-data/global/default/v1/perks.json'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'perkstyles.json',
|
|
||||||
path: 'plugins/rcp-be-lol-game-data/global/default/v1/perkstyles.json'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'summoner-spells.json',
|
|
||||||
path: 'plugins/rcp-be-lol-game-data/global/default/v1/summoner-spells.json'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'champion-summary.json',
|
|
||||||
path: 'plugins/rcp-be-lol-game-data/global/default/v1/champion-summary.json'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
main()
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const client = await connectToDatabase()
|
|
||||||
const dbPatch = await getLatestPatchFromDatabase(client)
|
|
||||||
|
|
||||||
// In dev mode, get patch from database (the one we have match data for)
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
|
||||||
console.log('Development mode: downloading cache for database patch')
|
|
||||||
await client.close()
|
|
||||||
|
|
||||||
if (dbPatch) {
|
|
||||||
console.log('Latest patch in database: ' + dbPatch)
|
|
||||||
await downloadAssets(dbPatch)
|
|
||||||
} else {
|
|
||||||
console.log('No patch found in database!')
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Production mode: check database and update if new patch
|
|
||||||
const newPatch = await fetchLatestPatch()
|
|
||||||
console.log('Latest patch is: ' + newPatch)
|
|
||||||
const newDate = new Date()
|
|
||||||
|
|
||||||
if (!(await compareLatestSavedPatch(client, newPatch, newDate))) {
|
|
||||||
await downloadAssets(newPatch)
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchLatestPatch() {
|
|
||||||
const url = 'https://ddragon.leagueoflegends.com/api/versions.json'
|
|
||||||
const patchDataResponse = await fetch(url)
|
|
||||||
const patchData = await patchDataResponse.json()
|
|
||||||
const patch: string = patchData[0]
|
|
||||||
return patch
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectToDatabase() {
|
|
||||||
// Create a MongoClient with a MongoClientOptions object to set the Stable API version
|
|
||||||
let uri = `mongodb://${process.env.MONGO_USER}:${process.env.MONGO_PASS}@${process.env.MONGO_HOST}`
|
|
||||||
if (
|
|
||||||
process.env.MONGO_URI != undefined &&
|
|
||||||
process.env.MONGO_URI != null &&
|
|
||||||
process.env.MONGO_URI != ''
|
|
||||||
) {
|
|
||||||
uri = process.env.MONGO_URI
|
|
||||||
}
|
|
||||||
const client = new MongoClient(uri)
|
|
||||||
await client.connect()
|
|
||||||
return client
|
|
||||||
}
|
|
||||||
|
|
||||||
async function compareLatestSavedPatch(client: MongoClient, newPatch: string, newDate: Date) {
|
|
||||||
const database = client.db('patches')
|
|
||||||
const patches = database.collection('patches')
|
|
||||||
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
|
|
||||||
|
|
||||||
if (latestPatch == null) {
|
|
||||||
console.log('No previous patch recorded in database.')
|
|
||||||
} else {
|
|
||||||
console.log('Latest patch in database is: ' + latestPatch.patch)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (latestPatch == null || latestPatch.patch != newPatch) {
|
|
||||||
await patches.insertOne({ patch: newPatch, date: newDate })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getLatestPatchFromDatabase(client: MongoClient): Promise<string | null> {
|
|
||||||
const database = client.db('patches')
|
|
||||||
const patches = database.collection('patches')
|
|
||||||
const latestPatch = await patches.find().limit(1).sort({ date: -1 }).next()
|
|
||||||
|
|
||||||
if (latestPatch == null) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return latestPatch.patch
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadAssets(patch: string) {
|
|
||||||
// Convert patch format for CDragon: "16.4.1" -> "16.4"
|
|
||||||
// CDragon uses patch format without the last minor version
|
|
||||||
const cdragonPatch = patch.split('.').slice(0, 2).join('.')
|
|
||||||
console.log(`Downloading CDragon assets for patch ${cdragonPatch} (from ${patch})...`)
|
|
||||||
|
|
||||||
// Get cache directory from environment or use default
|
|
||||||
const cacheDir = process.env.CDRAGON_CACHE_DIR || '/cdragon'
|
|
||||||
const patchDir = join(cacheDir, cdragonPatch)
|
|
||||||
|
|
||||||
// Create patch directory if it doesn't exist
|
|
||||||
if (!existsSync(patchDir)) {
|
|
||||||
await mkdir(patchDir, { recursive: true })
|
|
||||||
console.log(`Created directory: ${patchDir}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download each asset
|
|
||||||
for (const asset of CDRAGON_ASSETS) {
|
|
||||||
const url = `${CDRAGON_BASE}${cdragonPatch}/${asset.path}`
|
|
||||||
const filePath = join(patchDir, asset.name)
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log(`Downloading ${asset.name}...`)
|
|
||||||
const response = await fetch(url)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(`Failed to download ${asset.name}: ${response.status} ${response.statusText}`)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
await writeFile(filePath, JSON.stringify(data, null, 2))
|
|
||||||
console.log(`Saved ${asset.name} to ${filePath}`)
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Error downloading ${asset.name}:`, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a symlink or copy to 'latest' directory for easy access
|
|
||||||
const latestDir = join(cacheDir, 'latest')
|
|
||||||
const latestFile = join(latestDir, 'patch.txt')
|
|
||||||
|
|
||||||
if (!existsSync(latestDir)) {
|
|
||||||
await mkdir(latestDir, { recursive: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(latestFile, patch)
|
|
||||||
console.log(`Updated latest patch reference to ${patch}`)
|
|
||||||
|
|
||||||
console.log('CDragon assets download complete!')
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "patch_detector",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "index.ts",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"lint:fix": "eslint --fix .",
|
|
||||||
"format": "prettier --write .",
|
|
||||||
"format:check": "prettier --check ."
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"description": "",
|
|
||||||
"dependencies": {
|
|
||||||
"mongodb": "^6.10.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.39.2",
|
|
||||||
"@types/node": "^22.10.1",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^8.53.1",
|
|
||||||
"@typescript-eslint/parser": "^8.53.1",
|
|
||||||
"eslint": "^9.39.2",
|
|
||||||
"eslint-config-prettier": "^10.1.8",
|
|
||||||
"prettier": "^3.8.0",
|
|
||||||
"tsx": "^4.19.2",
|
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"typescript-eslint": "^8.53.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"types": ["node"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user