diff --git a/.env.example b/.env.example index de54f5a..41206f4 100644 --- a/.env.example +++ b/.env.example @@ -14,5 +14,5 @@ API_VERSION=v1 CURRENT_SEASON=13 CORS_ORIGIN=http://localhost:4173 RATE_LIMIT_WINDOW=15 -RATE_LIMIT_MAX=100 +RATE_LIMIT_MAX=1000 LOG_LEVEL=info diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 912bbdf..dec9167 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -2,15 +2,15 @@ name: Backend Tests on: push: - branches: [ main, master ] + branches: [main, master] paths: - - 'api/**' - - '.github/workflows/backend-tests.yml' + - "api/**" + - ".github/workflows/backend-tests.yml" pull_request: - branches: [ main, master ] + branches: [main, master] paths: - - 'api/**' - - '.github/workflows/backend-tests.yml' + - "api/**" + - ".github/workflows/backend-tests.yml" jobs: test: @@ -46,7 +46,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: "20" - name: Cache npm dependencies uses: actions/cache@v3 diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 62c76fb..0163cd1 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -2,15 +2,15 @@ name: Frontend Tests on: push: - branches: [ main, master ] + branches: [main, master] paths: - - 'web/**' - - '.github/workflows/frontend-tests.yml' + - "web/**" + - ".github/workflows/frontend-tests.yml" pull_request: - branches: [ main, master ] + branches: [main, master] paths: - - 'web/**' - - '.github/workflows/frontend-tests.yml' + - "web/**" + - ".github/workflows/frontend-tests.yml" jobs: test: @@ -22,7 +22,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' + node-version: "20" - name: Cache npm dependencies uses: actions/cache@v3 diff --git a/.prettierrc b/.prettierrc index c0cde22..43d4ab4 100644 --- a/.prettierrc +++ b/.prettierrc @@ -16,4 +16,4 @@ "endOfLine": "lf", "embeddedLanguageFormatting": "auto", "singleAttributePerLine": false -} \ No newline at end of file +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..45fc4b0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# AGENTS.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +**Project Summary:** +This repository powers pd2.tools, a Project Diablo 2 toolkit with a React/Vite/Mantine frontend and a TypeScript Express API backed by Postgres and Redis; it ingests PD2 character data through scheduled jobs, stores season-aware characters, accounts, items, mercenaries, snapshots, economy listings, online-player history, and leaderboards, then exposes UI flows for build discovery, character/account inspection, economy price tracking, statistics, leaderboards, corrupted-zone tracking, character export, and damage/stat calculations using committed PD2 game data tables, with Docker Compose and GitHub Actions covering local/dev/prod deployment, linting, type checks, builds, and backend tests. + +**One very important principle to follow at all times is always try to solve the problem with the bare minimum number of changes. Always be very thorough in your research and analysis of the codebase, be certain that your answer is correct always. Don't make assumptions, instead put in the effort to verify things and actually read the code and trace logic.** + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: + +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: + +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: + +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: + +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: + +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. diff --git a/README.md b/README.md index f11c0f0..9e3bbac 100644 --- a/README.md +++ b/README.md @@ -38,21 +38,23 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile jobs up Open `http://localhost:4173` - ## 🤝 Contributing Contributions are welcome. For coordination or questions join the [pd2.tools discord](https://discord.com/invite/TVTExqWRhK). ### Getting Started + 1. Fork the repo. 2. Create a feature branch. 3. Make your changes. 4. Submit a PR. ## 👥 Contributors + ## ⭐ Star History + [![Star History Chart](https://api.star-history.com/svg?repos=coleestrin/pd2-tools&type=date&legend=top-left)](https://www.star-history.com/#coleestrin/pd2-tools&type=date&legend=top-left) diff --git a/api/.env.example b/api/.env.example index 6f02152..3ec56ae 100644 --- a/api/.env.example +++ b/api/.env.example @@ -23,7 +23,7 @@ CORS_ORIGIN=* # Rate Limiting RATE_LIMIT_WINDOW=15 -RATE_LIMIT_MAX=100 +RATE_LIMIT_MAX=1000 # Logging LOG_LEVEL=debug diff --git a/api/package-lock.json b/api/package-lock.json index 8066f53..e7d9e5c 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -91,6 +91,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1317,6 +1318,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz", "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.2", "generic-pool": "3.9.0", @@ -1654,6 +1656,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.3.tgz", "integrity": "sha512-F3u1fs/fce3FFk+DAxbxc78DF8x0cY09RRL8GnXLmkJ1jvx3TtPdWoTT5/NiYfI5ASqXBmfqJi9dZ3gxMx4lzw==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.19.8" } @@ -1823,6 +1826,7 @@ "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/types": "8.48.0", @@ -2187,6 +2191,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2627,6 +2632,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -3398,6 +3404,7 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3720,6 +3727,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4759,6 +4767,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6132,6 +6141,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -7069,6 +7079,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -7214,6 +7225,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7307,6 +7319,7 @@ "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7687,6 +7700,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/api/package.json b/api/package.json index 4cc155a..f074c50 100644 --- a/api/package.json +++ b/api/package.json @@ -59,4 +59,4 @@ "typescript": "5.7.2", "typescript-eslint": "^8.48.0" } -} \ No newline at end of file +} diff --git a/api/src/config/index.ts b/api/src/config/index.ts index 2a27480..fdc9749 100644 --- a/api/src/config/index.ts +++ b/api/src/config/index.ts @@ -52,7 +52,7 @@ export const config = { // Rate limiting rateLimit: { windowMs: parseInt(process.env.RATE_LIMIT_WINDOW || "15") * 60 * 1000, - max: parseInt(process.env.RATE_LIMIT_MAX || "100"), + max: parseInt(process.env.RATE_LIMIT_MAX || "1000"), }, // Logging diff --git a/api/src/jobs/character-scraper.ts b/api/src/jobs/character-scraper.ts index e47ba39..f4fb3da 100644 --- a/api/src/jobs/character-scraper.ts +++ b/api/src/jobs/character-scraper.ts @@ -127,7 +127,16 @@ class ApiClient { const request = this.requestQueue.shift()!; try { - const response = await fetch(request.url); + const response = await fetch(request.url, { + compress: false, + timeout: 30000, + headers: { + Accept: "application/json", + "Accept-Encoding": "identity", + Connection: "close", + "User-Agent": "pd2.tools/1.0 (+https://pd2.tools)", + }, + }); const data = await response.json(); request.resolve(data); } catch (error) { diff --git a/api/src/jobs/online-players-tracker.ts b/api/src/jobs/online-players-tracker.ts index eb5a3e3..a0e1bf5 100644 --- a/api/src/jobs/online-players-tracker.ts +++ b/api/src/jobs/online-players-tracker.ts @@ -7,7 +7,16 @@ const logger = mainLogger.createNamedLogger("Online Players Tracker"); async function recordOnlinePlayers() { try { - const resp = await fetch("https://api.projectdiablo2.com/game/online"); + const resp = await fetch("https://api.projectdiablo2.com/game/online", { + compress: false, + timeout: 30000, + headers: { + Accept: "application/json", + "Accept-Encoding": "identity", + Connection: "close", + "User-Agent": "pd2.tools/1.0 (+https://pd2.tools)", + }, + }); if (!resp.ok) { logger.error( `Failed to fetch online players: ${resp.status} ${resp.statusText}` diff --git a/api/src/routes/characters.ts b/api/src/routes/characters.ts index daa5569..b804cc5 100644 --- a/api/src/routes/characters.ts +++ b/api/src/routes/characters.ts @@ -26,10 +26,10 @@ function hasDamageCalculatorInput( return Boolean( character && - Array.isArray(data?.items) && - Array.isArray(character.skills) && - character.class?.name && - character.attributes + Array.isArray(data?.items) && + Array.isArray(character.skills) && + character.class?.name && + character.attributes ); } @@ -704,116 +704,135 @@ router.get( ); // POST /api/characters/:name/refresh - Manually refresh character data -router.post("/:name/refresh", characterRefreshLimiter, async (req: Request, res: Response) => { - try { - const { name } = req.params; - const now = Date.now(); - const cacheKey = `refresh:character:${name.toLowerCase()}`; +router.post( + "/:name/refresh", + characterRefreshLimiter, + async (req: Request, res: Response) => { + try { + const { name } = req.params; + const now = Date.now(); + const cacheKey = `refresh:character:${name.toLowerCase()}`; + + // Check 15-minute rate limit from Redis + const lastRefresh = await getCacheValue(cacheKey); + + // Check 15-minute rate limit + if (lastRefresh && now - lastRefresh < 15 * 60 * 1000) { + const retryAfter = Math.ceil( + (15 * 60 * 1000 - (now - lastRefresh)) / 1000 + ); + return res.status(429).json({ + error: "Character was refreshed recently. Please try again later.", + retryAfter, + }); + } + + // Fetch character data from PD2 API + logger.info(`Manual refresh requested for character: ${name}`); + const response = await fetch( + `https://api.projectdiablo2.com/game/character/${encodeURIComponent(name)}`, + { + compress: false, + timeout: 30000, + headers: { + Accept: "application/json", + "Accept-Encoding": "identity", + Connection: "close", + "User-Agent": "pd2.tools/1.0 (+https://pd2.tools)", + }, + } + ); + + if (!response.ok) { + return res.status(404).json({ + error: "Character not found or API unavailable", + }); + } + + const charData: any = await response.json(); - // Check 15-minute rate limit from Redis - const lastRefresh = await getCacheValue(cacheKey); + if (!charData?.character) { + return res.status(404).json({ + error: "Invalid character data received", + }); + } + + // Determine game mode + const gameMode = charData.character.status?.is_hardcore + ? "hardcore" + : "softcore"; + + // Get season from request or use current season + const season = charData.character.season || config.currentSeason; - // Check 15-minute rate limit - if (lastRefresh && now - lastRefresh < 15 * 60 * 1000) { - const retryAfter = Math.ceil( - (15 * 60 * 1000 - (now - lastRefresh)) / 1000 + // Get existing character to preserve accountName + const existingChar = await characterDB.getCharacterByName( + gameMode, + name, + season ); - return res.status(429).json({ - error: "Character was refreshed recently. Please try again later.", - retryAfter, - }); - } + const accountName = existingChar?.accountName; - // Fetch character data from PD2 API - logger.info(`Manual refresh requested for character: ${name}`); - const response = await fetch( - `https://api.projectdiablo2.com/game/character/${name}` - ); + // Set lastUpdated and calculate realSkills (same as scraper does) + charData.lastUpdated = now; + enrichArmoryPayload(charData as unknown as Partial); + charData.realSkills = calculateTotalSkills( + charData as unknown as CharacterResponse + ); - if (!response.ok) { - return res.status(404).json({ - error: "Character not found or API unavailable", - }); - } + // Ingest character data with preserved accountName + await characterDB.ingestCharacter( + charData, + gameMode, + season, + accountName + ); - const charData: any = await response.json(); + // Update rate limit cache in Redis (TTL: 15 minutes = 900 seconds) + await setCacheValue(cacheKey, now, 900); - if (!charData?.character) { - return res.status(404).json({ - error: "Invalid character data received", - }); - } + // Invalidate API cache for this character (cache keys are auto:/:name:) + const deletedKeys = await deleteCachePattern(`auto:/${name}:*`); + logger.debug( + `Invalidated ${deletedKeys} cache keys for character: ${name}` + ); - // Determine game mode - const gameMode = charData.character.status?.is_hardcore - ? "hardcore" - : "softcore"; - - // Get season from request or use current season - const season = charData.character.season || config.currentSeason; - - // Get existing character to preserve accountName - const existingChar = await characterDB.getCharacterByName( - gameMode, - name, - season - ); - const accountName = existingChar?.accountName; - - // Set lastUpdated and calculate realSkills (same as scraper does) - charData.lastUpdated = now; - enrichArmoryPayload(charData as unknown as Partial); - charData.realSkills = calculateTotalSkills( - charData as unknown as CharacterResponse - ); - - // Ingest character data with preserved accountName - await characterDB.ingestCharacter(charData, gameMode, season, accountName); - - // Update rate limit cache in Redis (TTL: 15 minutes = 900 seconds) - await setCacheValue(cacheKey, now, 900); - - // Invalidate API cache for this character (cache keys are auto:/:name:) - const deletedKeys = await deleteCachePattern(`auto:/${name}:*`); - logger.debug( - `Invalidated ${deletedKeys} cache keys for character: ${name}` - ); - - // Fetch and return updated character - const updatedChar = await characterDB.getCharacterByName( - gameMode, - name, - season - ); - - if (!updatedChar) { - return res.status(500).json({ - error: "Character refreshed but failed to retrieve updated data", - }); - } + // Fetch and return updated character + const updatedChar = await characterDB.getCharacterByName( + gameMode, + name, + season + ); - // Calculate realStats from items (same as GET endpoint does) - enrichArmoryPayload(updatedChar as unknown as Partial); - if (updatedChar.items && updatedChar.items.length > 0) { - // @ts-expect-error - character structure is validated by DB - const statParser = new CharacterStatParser(updatedChar); - updatedChar.realStats = statParser.parseAndGetCharStats(); - } + if (!updatedChar) { + return res.status(500).json({ + error: "Character refreshed but failed to retrieve updated data", + }); + } - attachDamageCalculation(updatedChar as unknown as Partial); + // Calculate realStats from items (same as GET endpoint does) + enrichArmoryPayload(updatedChar as unknown as Partial); + if (updatedChar.items && updatedChar.items.length > 0) { + // @ts-expect-error - character structure is validated by DB + const statParser = new CharacterStatParser(updatedChar); + updatedChar.realStats = statParser.parseAndGetCharStats(); + } - logger.info(`Character ${name} successfully refreshed`); + attachDamageCalculation(updatedChar as unknown as Partial); - // Return same format as GET endpoint (character directly, not wrapped) - return res.json(updatedChar); - } catch (error: unknown) { - logger.error("Error refreshing character", { - error: error instanceof Error ? error.message : String(error), - }); - return res.status(500).json({ - error: "Failed to refresh character data", - }); + logger.info(`Character ${name} successfully refreshed`); + + // Return same format as GET endpoint (character directly, not wrapped) + return res.json(updatedChar); + } catch (error: unknown) { + logger.error("Error refreshing character", { + error: error instanceof Error ? error.message : String(error), + }); + return res.status(500).json({ + error: "Failed to refresh character data", + }); + } } -}); +); export default router; diff --git a/api/src/routes/home.ts b/api/src/routes/home.ts index fdc281c..ecec2f8 100644 --- a/api/src/routes/home.ts +++ b/api/src/routes/home.ts @@ -128,22 +128,21 @@ router.get( softcoreMeta, economyItems, leaderboardData, - ] = - await Promise.all([ - characterDB.getRecentCharacters( - "softcore", - season, - RECENT_CHARACTER_SAMPLE_SIZE - ), - characterDB.getRecentCharacters( - "hardcore", - season, - RECENT_CHARACTER_SAMPLE_SIZE - ), - characterDB.getFilteredCharacters("softcore", { season }, 1, 0), - economyDB.getItemsSummary(season, 7), - characterDB.getLevel99Leaderboard("softcore", leaderboardSeason), - ]); + ] = await Promise.all([ + characterDB.getRecentCharacters( + "softcore", + season, + RECENT_CHARACTER_SAMPLE_SIZE + ), + characterDB.getRecentCharacters( + "hardcore", + season, + RECENT_CHARACTER_SAMPLE_SIZE + ), + characterDB.getFilteredCharacters("softcore", { season }, 1, 0), + economyDB.getItemsSummary(season, 7), + characterDB.getLevel99Leaderboard("softcore", leaderboardSeason), + ]); const recentCharacters = [ ...softcoreRecent.map((character) => @@ -154,7 +153,8 @@ router.get( ), ] .filter( - (character): character is RecentCharacterActivity => character !== null + (character): character is RecentCharacterActivity => + character !== null ) .sort((a, b) => b.lastUpdated - a.lastUpdated); @@ -180,29 +180,28 @@ router.get( const marketSnapshot = pickRandomItems( economyItems - .map((item) => { - const latest = item.price_data[item.price_data.length - 1]; - if (!latest || typeof latest.price !== "number") { - return null; - } - - if (!ECONOMY_ITEM_POOL.includes(item.item_name)) { - return null; - } - - return { - itemName: item.item_name, - price: latest.price, - listings: latest.numListings, - }; - }) - .filter( - ( - item - ): item is { itemName: string; price: number; listings: number } => - item !== null - ) - , + .map((item) => { + const latest = item.price_data[item.price_data.length - 1]; + if (!latest || typeof latest.price !== "number") { + return null; + } + + if (!ECONOMY_ITEM_POOL.includes(item.item_name)) { + return null; + } + + return { + itemName: item.item_name, + price: latest.price, + listings: latest.numListings, + }; + }) + .filter( + ( + item + ): item is { itemName: string; price: number; listings: number } => + item !== null + ), MARKET_ITEM_LIMIT ); diff --git a/api/src/routes/leaderboard.ts b/api/src/routes/leaderboard.ts index dcdafe0..9835825 100644 --- a/api/src/routes/leaderboard.ts +++ b/api/src/routes/leaderboard.ts @@ -48,33 +48,30 @@ router.get("/level99", validateSeason, async (req: Request, res: Response) => { * - gameMode: "softcore" | "hardcore" (default: "softcore") * - season: number (default: current season) */ -router.get( - "/mirrored", - validateSeason, - async (req: Request, res: Response) => { - try { - const gameMode = (req.query.gameMode as string) || "softcore"; - const season = req.query.season - ? parseInt(req.query.season as string, 10) - : config.currentSeason; +router.get("/mirrored", validateSeason, async (req: Request, res: Response) => { + try { + const gameMode = (req.query.gameMode as string) || "softcore"; + const season = req.query.season + ? parseInt(req.query.season as string, 10) + : config.currentSeason; - const leaderboard = await characterDB.getMirroredLeaderboard( - gameMode, - season - ); + const leaderboard = await characterDB.getMirroredLeaderboard( + gameMode, + season + ); - return res.json({ - leaderboard, - gameMode, - season, - total: leaderboard.length, - }); - } catch (error) { - logger.error("Error fetching mirrored leaderboard", { error }); - return res - .status(500) - .json({ error: "Failed to fetch mirrored leaderboard" }); - } + return res.json({ + leaderboard, + gameMode, + season, + total: leaderboard.length, + }); + } catch (error) { + logger.error("Error fetching mirrored leaderboard", { error }); + return res + .status(500) + .json({ error: "Failed to fetch mirrored leaderboard" }); + } }); export default router; diff --git a/api/src/types/damage.ts b/api/src/types/damage.ts index 6ee5018..1e242b9 100644 --- a/api/src/types/damage.ts +++ b/api/src/types/damage.ts @@ -111,7 +111,9 @@ export interface DamageAuraLevelBonus { level: number; skillLevelBonus: number; physicalBonusPercent: number; - elementalDamage: Partial, DamageRange>>; + elementalDamage: Partial< + Record, DamageRange> + >; poisonDamage?: PoisonDamagePayload; } @@ -160,7 +162,9 @@ export interface DamageProfileBreakdown { activeAuras: number; total: number; }; - elementalDamage: Partial, DamageRange>>; + elementalDamage: Partial< + Record, DamageRange> + >; poisonDamage?: PoisonDamage; } diff --git a/api/src/types/stats.ts b/api/src/types/stats.ts index 5b610ca..7e7a545 100644 --- a/api/src/types/stats.ts +++ b/api/src/types/stats.ts @@ -55,7 +55,7 @@ export interface CharStats { lightningSkillDamage: number; poisonSkillDamage: number; - //Elemental Pierce + //Elemental Pierce firePierce: number; coldPierce: number; lightningPierce: number; diff --git a/api/src/utils/armory-payload.ts b/api/src/utils/armory-payload.ts index 71817f6..3f508dd 100644 --- a/api/src/utils/armory-payload.ts +++ b/api/src/utils/armory-payload.ts @@ -56,16 +56,26 @@ function getArmorTable(): ArmorTable | null { const armorPath = path.join(PD2_GAME_DATA_DIRECTORY, "Armor.txt"); - cachedArmorTable = fs.existsSync(armorPath) ? parseArmorTable(armorPath) : null; + cachedArmorTable = fs.existsSync(armorPath) + ? parseArmorTable(armorPath) + : null; return cachedArmorTable; } -function getArmorCell(table: ArmorTable, row: string[], columnName: string): string { +function getArmorCell( + table: ArmorTable, + row: string[], + columnName: string +): string { const index = table.columns.indexOf(columnName); return index >= 0 ? row[index] || "" : ""; } -function getArmorNumber(table: ArmorTable, row: string[], columnName: string): number { +function getArmorNumber( + table: ArmorTable, + row: string[], + columnName: string +): number { const value = Number(getArmorCell(table, row, columnName)); return Number.isFinite(value) ? value : 0; } @@ -105,7 +115,10 @@ function getArmorRowForItem(item: IItem): [ArmorTable, string[]] | undefined { } function isEquippedBootItem(item: IItem): boolean { - if (item.location?.zone !== "Equipped" || item.location?.equipment !== "Boots") { + if ( + item.location?.zone !== "Equipped" || + item.location?.equipment !== "Boots" + ) { return false; } diff --git a/api/src/utils/character-stats.test.ts b/api/src/utils/character-stats.test.ts index 5768372..5606cc7 100644 --- a/api/src/utils/character-stats.test.ts +++ b/api/src/utils/character-stats.test.ts @@ -376,7 +376,9 @@ describe("StatParser", () => { }); it("should parse lightning pierce", () => { - const items = [createMockItem("Item1", ["-4% to Enemy Lightning Resistance"])]; + const items = [ + createMockItem("Item1", ["-4% to Enemy Lightning Resistance"]), + ]; const char = createMockCharacter(items); const parser = new CharacterStatParser(char); const stats = parser.parseAndGetCharStats(); @@ -385,7 +387,9 @@ describe("StatParser", () => { }); it("should parse poison pierce", () => { - const items = [createMockItem("Item1", ["-5% to Enemy Poison Resistance"])]; + const items = [ + createMockItem("Item1", ["-5% to Enemy Poison Resistance"]), + ]; const char = createMockCharacter(items); const parser = new CharacterStatParser(char); const stats = parser.parseAndGetCharStats(); @@ -509,9 +513,7 @@ describe("StatParser", () => { describe("Damage Procs", () => { it("should parse crushing blow", () => { - const items = [ - createMockItem("Item1", ["+25% Chance of Crushing Blow"]), - ]; + const items = [createMockItem("Item1", ["+25% Chance of Crushing Blow"])]; const char = createMockCharacter(items); const parser = new CharacterStatParser(char); const stats = parser.parseAndGetCharStats(); @@ -520,9 +522,7 @@ describe("StatParser", () => { }); it("should parse deadly strike", () => { - const items = [ - createMockItem("Item1", ["+33% Deadly Strike"]) - ]; + const items = [createMockItem("Item1", ["+33% Deadly Strike"])]; const char = createMockCharacter(items); const parser = new CharacterStatParser(char); const stats = parser.parseAndGetCharStats(); @@ -531,9 +531,7 @@ describe("StatParser", () => { }); it("should parse open wounds", () => { - const items = [ - createMockItem("Item1", ["+50% Chance of Open Wounds"]) - ]; + const items = [createMockItem("Item1", ["+50% Chance of Open Wounds"])]; const char = createMockCharacter(items); const parser = new CharacterStatParser(char); const stats = parser.parseAndGetCharStats(); @@ -543,7 +541,7 @@ describe("StatParser", () => { it("should parse open wounds additional dps", () => { const items = [ - createMockItem("Item1", ["+500 Open Wounds Damage Per Second"]) + createMockItem("Item1", ["+500 Open Wounds Damage Per Second"]), ]; const char = createMockCharacter(items); const parser = new CharacterStatParser(char); diff --git a/api/src/utils/character-stats.ts b/api/src/utils/character-stats.ts index fc34183..bdea305 100644 --- a/api/src/utils/character-stats.ts +++ b/api/src/utils/character-stats.ts @@ -249,7 +249,6 @@ export default class CharacterStatParser { if (lightAbsFlat) { this.characterStats.lAbsorbFlat += lightAbsFlat; continue; - } const magicAbsFlat = this.matchInt(/\+(\d+) Magic Absorb/, property); @@ -278,13 +277,19 @@ export default class CharacterStatParser { continue; } - const fasterHitRecovery = this.matchInt(/(\d+)% Faster Hit Recovery/, property); + const fasterHitRecovery = this.matchInt( + /(\d+)% Faster Hit Recovery/, + property + ); if (fasterHitRecovery) { this.characterStats.fasterHitRecovery += fasterHitRecovery; continue; } - const crushingBlow = this.matchInt(/(\d+)% Chance of Crushing Blow/, property); + const crushingBlow = this.matchInt( + /(\d+)% Chance of Crushing Blow/, + property + ); if (crushingBlow) { this.characterStats.crushingBlow += crushingBlow; continue; @@ -308,13 +313,19 @@ export default class CharacterStatParser { continue; } - const openWounds = this.matchInt(/(\d+)% Chance of Open Wounds/, property); + const openWounds = this.matchInt( + /(\d+)% Chance of Open Wounds/, + property + ); if (openWounds) { this.characterStats.openWounds += openWounds; continue; } - const openWoundsDPS = this.matchInt(/(\d+) Open Wounds Damage Per Second/, property); + const openWoundsDPS = this.matchInt( + /(\d+) Open Wounds Damage Per Second/, + property + ); if (openWoundsDPS) { this.characterStats.openWoundsDPS += openWoundsDPS; continue; @@ -326,56 +337,83 @@ export default class CharacterStatParser { continue; } - const mpPerKill = this.matchInt(/(\d+) to Mana after each Kill/, property); + const mpPerKill = this.matchInt( + /(\d+) to Mana after each Kill/, + property + ); if (mpPerKill) { this.characterStats.mpPerKill += mpPerKill; continue; } - const fireSkillDamage = this.matchInt(/(\d+)% to Fire Skill Damage/, property); + const fireSkillDamage = this.matchInt( + /(\d+)% to Fire Skill Damage/, + property + ); if (fireSkillDamage) { this.characterStats.fireSkillDamage += fireSkillDamage; continue; } - const coldSkillDamage = this.matchInt(/(\d+)% to Cold Skill Damage/, property); + const coldSkillDamage = this.matchInt( + /(\d+)% to Cold Skill Damage/, + property + ); if (coldSkillDamage) { this.characterStats.coldSkillDamage += coldSkillDamage; continue; } - const lightningSkillDamage = this.matchInt(/(\d+)% to Lightning Skill Damage/, property); + const lightningSkillDamage = this.matchInt( + /(\d+)% to Lightning Skill Damage/, + property + ); if (lightningSkillDamage) { this.characterStats.lightningSkillDamage += lightningSkillDamage; continue; } - const poisonSkillDamage = this.matchInt(/(\d+)% to Poison Skill Damage/, property); + const poisonSkillDamage = this.matchInt( + /(\d+)% to Poison Skill Damage/, + property + ); if (poisonSkillDamage) { this.characterStats.poisonSkillDamage += poisonSkillDamage; continue; } //Elemental Pierce currently does not take into account skills like Cold Mastery - const firePierce = this.matchInt(/(\d+)% to Enemy Fire Resistance/, property); + const firePierce = this.matchInt( + /(\d+)% to Enemy Fire Resistance/, + property + ); if (firePierce) { this.characterStats.firePierce -= firePierce; continue; } - const coldPierce = this.matchInt(/(\d+)% to Enemy Cold Resistance/, property); + const coldPierce = this.matchInt( + /(\d+)% to Enemy Cold Resistance/, + property + ); if (coldPierce) { this.characterStats.coldPierce -= coldPierce; continue; } - const lightningPierce = this.matchInt(/(\d+)% to Enemy Lightning Resistance/, property); + const lightningPierce = this.matchInt( + /(\d+)% to Enemy Lightning Resistance/, + property + ); if (lightningPierce) { this.characterStats.lightningPierce -= lightningPierce; continue; } - const poisonPierce = this.matchInt(/(\d+)% to Enemy Poison Resistance/, property); + const poisonPierce = this.matchInt( + /(\d+)% to Enemy Poison Resistance/, + property + ); if (poisonPierce) { this.characterStats.poisonPierce -= poisonPierce; continue; diff --git a/api/src/utils/damage-calculator.test.ts b/api/src/utils/damage-calculator.test.ts index 75571db..8d58eab 100644 --- a/api/src/utils/damage-calculator.test.ts +++ b/api/src/utils/damage-calculator.test.ts @@ -3,11 +3,7 @@ import fs from "fs"; import path from "path"; import { CharacterData, IItem } from "../types"; -const requiredGameFiles = [ - "Skills.txt", - "Missiles.txt", - "SkillDesc.txt", -]; +const requiredGameFiles = ["Skills.txt", "Missiles.txt", "SkillDesc.txt"]; const gameDataPath = path.resolve( process.cwd(), "src", @@ -90,16 +86,14 @@ function createWeapon(overrides: Partial = {}): IItem { requirements: { level: 0, strength: 0, dexterity: 0 }, }, quality: { id: 2, name: "Normal" }, - location: - overrides.location ?? - { - zone: "Equipped", - storage: "Equipped", - zone_id: 1, - storage_id: 0, - equipment: "Right Hand", - equipment_id: 4, - }, + location: overrides.location ?? { + zone: "Equipped", + storage: "Equipped", + zone_id: 1, + storage_id: 0, + equipment: "Right Hand", + equipment_id: 4, + }, position: { row: 0, column: 0 }, properties: [], damage: { @@ -154,16 +148,14 @@ function createBoot(overrides: Partial = {}): IItem { stat_bonus: { strength: 100 }, }, quality: { id: 2, name: "Normal" }, - location: - overrides.location ?? - { - zone: "Equipped", - storage: "Equipped", - zone_id: 1, - storage_id: 0, - equipment: "Boots", - equipment_id: 9, - }, + location: overrides.location ?? { + zone: "Equipped", + storage: "Equipped", + zone_id: 1, + storage_id: 0, + equipment: "Boots", + equipment_id: 9, + }, position: { row: 0, column: 0 }, properties: [], is_identified: true, @@ -222,13 +214,11 @@ function createCharacter(skillName: string, level: number): CharacterData { const describeWithGameData = hasRequiredGameData ? describe : describe.skip; const describeWithArmorData = - hasRequiredGameData && - fs.existsSync(path.join(gameDataPath, "Armor.txt")) + hasRequiredGameData && fs.existsSync(path.join(gameDataPath, "Armor.txt")) ? describe : describe.skip; const describeWithMonStatsData = - hasRequiredGameData && - fs.existsSync(path.join(gameDataPath, "MonStats.txt")) + hasRequiredGameData && fs.existsSync(path.join(gameDataPath, "MonStats.txt")) ? describe : describe.skip; @@ -281,7 +271,10 @@ function getSourceLevelScaledValue( return value / 2 ** (8 - hitShift); } -function getExpectedAuraPayloadsFromSkillsTxt(skillName: string, level: number) { +function getExpectedAuraPayloadsFromSkillsTxt( + skillName: string, + level: number +) { const skills = loadGameFile("Skills.txt", "skill"); const row = skills.rowsByKey.get(skillName)!; const min = getSourceLevelScaledValue(skills, row, level, "EMin", [ @@ -312,8 +305,16 @@ function getExpectedAuraPayloadsFromSkillsTxt(skillName: string, level: number) max: Math.floor(max), }, self: { - min: Math.floor((Math.floor(min * 256) * getGameFileNumber(skills, row, `Param${minParam}`)) / 256), - max: Math.floor((Math.floor(max * 256) * getGameFileNumber(skills, row, `Param${maxParam}`)) / 256), + min: Math.floor( + (Math.floor(min * 256) * + getGameFileNumber(skills, row, `Param${minParam}`)) / + 256 + ), + max: Math.floor( + (Math.floor(max * 256) * + getGameFileNumber(skills, row, `Param${maxParam}`)) / + 256 + ), }, }; } @@ -347,7 +348,10 @@ function getExpectedBattleCommandSkillLevelBonusFromSkillsTxt(level: number) { const skills = loadGameFile("Skills.txt", "skill"); const row = skills.rowsByKey.get("Battle Command")!; const stat = getGameFileCell(skills, row, "aurastat1"); - const calc = getGameFileCell(skills, row, "aurastatcalc1").replace(/\s+/g, ""); + const calc = getGameFileCell(skills, row, "aurastatcalc1").replace( + /\s+/g, + "" + ); expect(stat).toBe("item_allskills"); expect(calc).toBe("1+blvl/10"); @@ -359,7 +363,10 @@ function getExpectedBattleCommandPhysicalBonusFromSkillsTxt(level: number) { const skills = loadGameFile("Skills.txt", "skill"); const row = skills.rowsByKey.get("Battle Command")!; const stat = getGameFileCell(skills, row, "aurastat2"); - const calc = getGameFileCell(skills, row, "aurastatcalc2").replace(/\s+/g, ""); + const calc = getGameFileCell(skills, row, "aurastatcalc2").replace( + /\s+/g, + "" + ); expect(stat).toBe("damagepercent"); expect(calc).toBe("ln34"); @@ -395,12 +402,10 @@ function getExpectedWarCryPhysicalSynergyFromSkillsTxt({ return ( (howlBaseLevel + battleCryBaseLevel) * getGameFileNumber(skills, row, "Param8") + - ( - tauntBaseLevel + + (tauntBaseLevel + shoutBaseLevel + battleCommandBaseLevel + - battleOrdersBaseLevel - ) * + battleOrdersBaseLevel) * getGameFileNumber(skills, row, "Param7") ); } @@ -487,8 +492,12 @@ function getExpectedHydraFirePayloadFromSkillsTxt( getGameFileNumber(skills, fireMasteryRow, "Param2"); return { - min: Math.floor(Math.floor(min * (1 + synergyPercent / 100)) * (1 + masteryPercent / 100)), - max: Math.floor(Math.floor(max * (1 + synergyPercent / 100)) * (1 + masteryPercent / 100)), + min: Math.floor( + Math.floor(min * (1 + synergyPercent / 100)) * (1 + masteryPercent / 100) + ), + max: Math.floor( + Math.floor(max * (1 + synergyPercent / 100)) * (1 + masteryPercent / 100) + ), }; } @@ -502,20 +511,20 @@ function getExpectedSkeletalMagePayloadFromGameFiles( const mageRow = skills.rowsByKey.get("Raise Skeletal Mage")!; const masteryRow = skills.rowsByKey.get("Skeleton Mastery")!; const missileRow = missiles.rowsByKey.get(missileName)!; - const min = getSourceLevelScaledValue(missiles, missileRow, mageLevel, "EMin", [ - "MinELev1", - "MinELev2", - "MinELev3", - "MinELev4", - "MinELev5", - ]); - const max = getSourceLevelScaledValue(missiles, missileRow, mageLevel, "Emax", [ - "MaxELev1", - "MaxELev2", - "MaxELev3", - "MaxELev4", - "MaxELev5", - ]); + const min = getSourceLevelScaledValue( + missiles, + missileRow, + mageLevel, + "EMin", + ["MinELev1", "MinELev2", "MinELev3", "MinELev4", "MinELev5"] + ); + const max = getSourceLevelScaledValue( + missiles, + missileRow, + mageLevel, + "Emax", + ["MaxELev1", "MaxELev2", "MaxELev3", "MaxELev4", "MaxELev5"] + ); const dotMultiplier = getGameFileCell(missiles, missileRow, "EType") === "pois" ? getGameFileNumber(missiles, missileRow, "ELen") || 1 @@ -612,8 +621,7 @@ function getExpectedSkeletonDamagePercentFromGameFiles({ skillLevel < 4 ? 0 : (skillLevel - 3) * getGameFileNumber(skills, row, "Param3"); - total += - skeletonArcherBaseLevel * getGameFileNumber(skills, row, "Param7"); + total += skeletonArcherBaseLevel * getGameFileNumber(skills, row, "Param7"); } else { expect(getGameFileCell(skills, row, "DmgSymPerCalc")).toBe( getGameFileCell(skills, row, "passivecalc4") @@ -764,8 +772,7 @@ describeWithGameData("damage calculator component model", () => { ); const chargedBoltProfile = calculation.profiles.find( (profile) => - profile.skillName === "Charged Bolt" && - profile.playerAuraId === "none" + profile.skillName === "Charged Bolt" && profile.playerAuraId === "none" ); expect(chargedBoltOption).toMatchObject({ damageMode: "spell" }); @@ -887,8 +894,7 @@ describeWithGameData("damage calculator component model", () => { ); const baseProfile = calculation.profiles.find( (profile) => - profile.skillName === "Basic Attack" && - profile.playerAuraId === "none" + profile.skillName === "Basic Attack" && profile.playerAuraId === "none" ); const partyProfile = calculation.profiles.find( (profile) => @@ -928,12 +934,13 @@ describeWithGameData("damage calculator component model", () => { const battleCommand = calculation.playerAuraOptions.find( (aura) => aura.name === "Battle Command" ); - const skillLevelBonus = getExpectedBattleCommandSkillLevelBonusFromSkillsTxt(20); - const physicalBonus = getExpectedBattleCommandPhysicalBonusFromSkillsTxt(20); + const skillLevelBonus = + getExpectedBattleCommandSkillLevelBonusFromSkillsTxt(20); + const physicalBonus = + getExpectedBattleCommandPhysicalBonusFromSkillsTxt(20); const baseProfile = calculation.profiles.find( (profile) => - profile.skillName === "War Cry" && - profile.playerAuraId === "none" + profile.skillName === "War Cry" && profile.playerAuraId === "none" ); const battleCommandProfile = calculation.profiles.find( (profile) => @@ -955,10 +962,12 @@ describeWithGameData("damage calculator component model", () => { expect(battleCommandProfile!.skillLevel).toBe( baseProfile!.skillLevel + skillLevelBonus ); - expect(battleCommandProfile!.damageTotals.combinedDamage.min).toBeGreaterThan( - baseProfile!.damageTotals.combinedDamage.min - ); - expect(battleCommandProfile!.breakdown.physicalBonusPercent.activeAuras).toBe(0); + expect( + battleCommandProfile!.damageTotals.combinedDamage.min + ).toBeGreaterThan(baseProfile!.damageTotals.combinedDamage.min); + expect( + battleCommandProfile!.breakdown.physicalBonusPercent.activeAuras + ).toBe(0); expect(battleCommandProfile!.activeAuras).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -1004,8 +1013,7 @@ describeWithGameData("damage calculator component model", () => { }); const warCryProfile = calculation.profiles.find( (profile) => - profile.skillName === "War Cry" && - profile.playerAuraId === "none" + profile.skillName === "War Cry" && profile.playerAuraId === "none" ); expect(warCryProfile).toBeDefined(); @@ -1025,8 +1033,7 @@ describeWithGameData("damage calculator component model", () => { const calculation = calculateDamage(character); const arcticBlastProfile = calculation.profiles.find( (profile) => - profile.skillName === "Arctic Blast" && - profile.playerAuraId === "none" + profile.skillName === "Arctic Blast" && profile.playerAuraId === "none" ); expect(arcticBlastProfile).toBeDefined(); @@ -1067,14 +1074,12 @@ describeWithGameData("damage calculator component model", () => { const calculation = calculateDamage(character); const fistsOfFireProfile = calculation.profiles.find( (profile) => - profile.skillName === "Fists of Fire" && - profile.playerAuraId === "none" + profile.skillName === "Fists of Fire" && profile.playerAuraId === "none" ); const missilePhysicalComponents = fistsOfFireProfile?.damageComponents.filter( (component) => - component.source === "missile" && - component.damageType === "physical" + component.source === "missile" && component.damageType === "physical" ) || []; const meteorFireComponent = fistsOfFireProfile?.damageComponents.find( (component) => @@ -1127,8 +1132,7 @@ describeWithGameData("damage calculator component model", () => { ); const baseProfile = calculation.profiles.find( (profile) => - profile.skillName === "Basic Attack" && - profile.playerAuraId === "none" + profile.skillName === "Basic Attack" && profile.playerAuraId === "none" ); const venomProfile = calculation.profiles.find( (profile) => @@ -1152,10 +1156,11 @@ describeWithGameData("damage calculator component model", () => { ); expect(venom).toBeDefined(); - expect(venom!.selfLevelBonuses.find((bonus) => bonus.level === venomLevel)) - .toMatchObject({ - poisonDamage: expectedLevelBonus, - }); + expect( + venom!.selfLevelBonuses.find((bonus) => bonus.level === venomLevel) + ).toMatchObject({ + poisonDamage: expectedLevelBonus, + }); expect( venom!.partyLevelBonuses.find((bonus) => bonus.level === venomLevel)! .poisonDamage @@ -1268,16 +1273,14 @@ describeWithGameData("damage calculator component model", () => { expect(sequenceOption?.sequenceHits).toHaveLength(2); expect(sequenceProfile?.sequenceHits).toHaveLength(2); - expect(sequenceProfile?.damageTotals.combinedDamage).toEqual( - { - min: - rightProfile!.damageTotals.combinedDamage.min + - leftProfile!.damageTotals.combinedDamage.min, - max: - rightProfile!.damageTotals.combinedDamage.max + - leftProfile!.damageTotals.combinedDamage.max, - } - ); + expect(sequenceProfile?.damageTotals.combinedDamage).toEqual({ + min: + rightProfile!.damageTotals.combinedDamage.min + + leftProfile!.damageTotals.combinedDamage.min, + max: + rightProfile!.damageTotals.combinedDamage.max + + leftProfile!.damageTotals.combinedDamage.max, + }); expect(sequenceProfile?.notes.join(" ")).toContain("weapsel=2"); }); @@ -1317,16 +1320,17 @@ describeWithGameData("damage calculator component model", () => { const calculation = calculateDamage(character); const frenzyProfiles = calculation.profiles.filter( - (profile) => profile.skillId === "Frenzy" && profile.playerAuraId === "none" + (profile) => + profile.skillId === "Frenzy" && profile.playerAuraId === "none" ); const profileWeaponOptions = frenzyProfiles.map((profile) => calculation.weaponOptions.find((option) => option.id === profile.weaponId) ); expect(frenzyProfiles).toHaveLength(1); - expect(profileWeaponOptions.every((option) => option?.handMode === "dual_wield")).toBe( - true - ); + expect( + profileWeaponOptions.every((option) => option?.handMode === "dual_wield") + ).toBe(true); expect(frenzyProfiles[0].sequenceHits).toHaveLength(2); expect(frenzyProfiles[0].notes.join(" ")).toContain("required two-weapon"); }); @@ -1402,9 +1406,9 @@ describeWithGameData("damage calculator component model", () => { calculation.weaponOptions.some((option) => option.handMode === "missile") ).toBe(true); expect(doubleThrowProfiles).toHaveLength(1); - expect(profileWeaponOptions.every((option) => option?.handMode === "dual_throw")).toBe( - true - ); + expect( + profileWeaponOptions.every((option) => option?.handMode === "dual_throw") + ).toBe(true); expect(doubleThrowProfiles[0].sequenceHits).toEqual([ expect.objectContaining({ handMode: "missile", @@ -1415,7 +1419,9 @@ describeWithGameData("damage calculator component model", () => { itemName: "Left Throwing Knife", }), ]); - expect(doubleThrowProfiles[0].notes.join(" ")).toContain("required two-throw"); + expect(doubleThrowProfiles[0].notes.join(" ")).toContain( + "required two-throw" + ); }); it("uses bow two-handed armory damage as a missile weapon option", () => { @@ -1517,14 +1523,16 @@ describeWithGameData("damage calculator component model", () => { handMode: "missile", itemName: "Test Bow", }); - expect(primaryWeaponOptions.some((option) => option.handMode === "unarmed")).toBe( - false - ); + expect( + primaryWeaponOptions.some((option) => option.handMode === "unarmed") + ).toBe(false); expect(magicArrowProfile?.breakdown.flatPhysicalDamage).toEqual({ min: 12, max: 12, }); - expect(magicArrowProfile?.damageTotals.combinedDamage.max).toBeGreaterThan(0); + expect(magicArrowProfile?.damageTotals.combinedDamage.max).toBeGreaterThan( + 0 + ); }); it("uses equipped boots as the source item for kick skills", () => { @@ -1538,8 +1546,7 @@ describeWithGameData("damage calculator component model", () => { ); const dragonTalonProfiles = calculation.profiles.filter( (profile) => - profile.skillId === "Dragon Talon" && - profile.playerAuraId === "none" + profile.skillId === "Dragon Talon" && profile.playerAuraId === "none" ); const dragonTalonWeaponOptions = dragonTalonProfiles.map((profile) => calculation.weaponOptions.find((option) => option.id === profile.weaponId) @@ -1642,8 +1649,7 @@ describeWithMonStatsData("summon damage modeling", () => { ); const grizzlyProfile = calculation.profiles.find( (profile) => - profile.skillId === "Summon Grizzly" && - profile.playerAuraId === "none" + profile.skillId === "Summon Grizzly" && profile.playerAuraId === "none" ); const summonComponent = grizzlyProfile?.damageComponents.find( (component) => component.source === "summon" @@ -1667,7 +1673,9 @@ describeWithMonStatsData("summon damage modeling", () => { }), ]), }); - expect(grizzlyProfile?.notes.join(" ")).toContain("per-summon damage profile"); + expect(grizzlyProfile?.notes.join(" ")).toContain( + "per-summon damage profile" + ); }); it("uses MonStats attack damage and summon damagepercent for melee summons", () => { @@ -1706,8 +1714,7 @@ describeWithMonStatsData("summon damage modeling", () => { }); const skeletonProfile = calculation.profiles.find( (profile) => - profile.skillId === "Raise Skeleton" && - profile.playerAuraId === "none" + profile.skillId === "Raise Skeleton" && profile.playerAuraId === "none" ); const monsterComponent = skeletonProfile?.damageComponents.find( (component) => component.source === "monster" @@ -1749,10 +1756,12 @@ describeWithMonStatsData("summon damage modeling", () => { expect(monsterComponent?.damage.min).toBeGreaterThan( monsterComponent?.baseDamage?.min ?? 0 ); - expect( - skeletonProfile?.breakdown.physicalBonusPercent.selectedSkill - ).toBe(damagePercent); - expect(skeletonProfile?.damageTotals.combinedDamage.min).toBeGreaterThan(100); + expect(skeletonProfile?.breakdown.physicalBonusPercent.selectedSkill).toBe( + damagePercent + ); + expect(skeletonProfile?.damageTotals.combinedDamage.min).toBeGreaterThan( + 100 + ); }); it("applies Skeleton Mastery once to skeleton archer direct and flat physical damage", () => { @@ -1791,12 +1800,11 @@ describeWithMonStatsData("summon damage modeling", () => { raiseSkeletonBaseLevel, skeletonMasteryLevel, }); - const expectedDirectPhysical = getExpectedSkeletonArcherDirectPhysicalFromGameFiles( - { + const expectedDirectPhysical = + getExpectedSkeletonArcherDirectPhysicalFromGameFiles({ archerLevel, damagePercent, - } - ); + }); const expectedFlatPhysical = getExpectedSkeletonFlatPhysicalFromGameFiles({ skillName: "Raise Skeleton Archer", skillLevel: archerLevel, @@ -1861,10 +1869,12 @@ describeWithMonStatsData("summon damage modeling", () => { const calculation = calculateDamage(character); const fireGolemProfile = calculation.profiles.find( - (profile) => profile.skillId === "FireGolem" && profile.playerAuraId === "none" + (profile) => + profile.skillId === "FireGolem" && profile.playerAuraId === "none" ); const hydraProfile = calculation.profiles.find( - (profile) => profile.skillId === "Hydra" && profile.playerAuraId === "none" + (profile) => + profile.skillId === "Hydra" && profile.playerAuraId === "none" ); expect( @@ -1901,7 +1911,8 @@ describeWithMonStatsData("summon damage modeling", () => { const calculation = calculateDamage(character); const hydraProfile = calculation.profiles.find( - (profile) => profile.skillId === "Hydra" && profile.playerAuraId === "none" + (profile) => + profile.skillId === "Hydra" && profile.playerAuraId === "none" ); const hydraFireComponent = hydraProfile?.damageComponents.find( (component) => component.label === "Summon payload: Fire" @@ -1966,7 +1977,9 @@ describeWithMonStatsData("summon damage modeling", () => { "Raise Skeletal Mage (Poison Mage)", ]); expect( - calculation.skillOptions.some((option) => option.id === "Raise Skeletal Mage") + calculation.skillOptions.some( + (option) => option.id === "Raise Skeletal Mage" + ) ).toBe(false); expect(fireProfile?.weaponId).toBe("primary:summon:raise-skeletal-mage"); expect(fireProfile?.sourceSkillName).toBe("Raise Skeletal Mage"); @@ -2043,14 +2056,13 @@ describeWithMonStatsData("summon damage modeling", () => { component.label === "Summon payload: Poison" && component.damageType === "poison" ); - const expectedPoisonDamage = getExpectedPlaguePoppyPoisonPayloadFromGameFiles( - { + const expectedPoisonDamage = + getExpectedPlaguePoppyPoisonPayloadFromGameFiles({ plaguePoppyLevel, rabiesBaseLevel, cycleOfLifeBaseLevel, vinesBaseLevel, - } - ); + }); expect(poisonComponent).toBeDefined(); expect(poisonComponent?.damage).toEqual(expectedPoisonDamage); @@ -2071,7 +2083,8 @@ describeWithMonStatsData("summon damage modeling", () => { const calculation = calculateDamage(character); const clayProfile = calculation.profiles.find( - (profile) => profile.skillId === "Clay Golem" && profile.playerAuraId === "none" + (profile) => + profile.skillId === "Clay Golem" && profile.playerAuraId === "none" ); const monsterComponent = clayProfile?.damageComponents.find( (component) => component.source === "monster" @@ -2093,7 +2106,8 @@ describeWithMonStatsData("summon damage modeling", () => { const calculation = calculateDamage(character); const ravenProfile = calculation.profiles.find( - (profile) => profile.skillId === "Raven" && profile.playerAuraId === "none" + (profile) => + profile.skillId === "Raven" && profile.playerAuraId === "none" ); expect(ravenProfile?.damageComponents[0]).toMatchObject({ diff --git a/api/src/utils/damage-calculator.ts b/api/src/utils/damage-calculator.ts index 6b6d2b6..3ca783c 100644 --- a/api/src/utils/damage-calculator.ts +++ b/api/src/utils/damage-calculator.ts @@ -198,7 +198,9 @@ type DirectSkillDamage = { notes?: string[]; }>; physical: DamageRange; - elemental: Partial>; + elemental: Partial< + Record<"fire" | "cold" | "lightning" | "magic", DamageRange> + >; poisonRange?: DamageRange; poisonDamage?: PoisonDamage; }; @@ -279,7 +281,11 @@ const EMPTY_SKILL_MAP = new Map(); const GAME_TABLE_DEFINITIONS: Record = { Skills: { fileName: "Skills.txt", keyColumn: "skill", required: true }, Missiles: { fileName: "Missiles.txt", keyColumn: "Missile", required: true }, - SkillDesc: { fileName: "SkillDesc.txt", keyColumn: "skilldesc", required: true }, + SkillDesc: { + fileName: "SkillDesc.txt", + keyColumn: "skilldesc", + required: true, + }, MonStats: { fileName: "MonStats.txt", keyColumn: "Id" }, }; @@ -297,7 +303,9 @@ function parseGameTableFile(filePath: string, keyColumn: string): GameTable { const columns = (lines.shift() || "").split("\t"); const keyIndex = columns.indexOf(keyColumn); if (keyIndex < 0) { - throw new Error(`${path.basename(filePath)} is missing key column ${keyColumn}`); + throw new Error( + `${path.basename(filePath)} is missing key column ${keyColumn}` + ); } const rowsByKey: Record = {}; @@ -328,18 +336,17 @@ function loadPd2GameData(): GameData { } const tables: Partial> = {}; - (Object.entries(GAME_TABLE_DEFINITIONS) as Array< - [GameTableName, GameTableDefinition] - >).forEach(([tableName, definition]) => { + ( + Object.entries(GAME_TABLE_DEFINITIONS) as Array< + [GameTableName, GameTableDefinition] + > + ).forEach(([tableName, definition]) => { const tablePath = path.join(PD2_GAME_DATA_DIRECTORY, definition.fileName); if (!definition.required && !fs.existsSync(tablePath)) { return; } - tables[tableName] = parseGameTableFile( - tablePath, - definition.keyColumn - ); + tables[tableName] = parseGameTableFile(tablePath, definition.keyColumn); }); return { tables }; @@ -539,7 +546,9 @@ const gameColumnIndexes = new Map>(); function getGameTable(tableName: GameTableName): GameTable { const table = pd2GameData.tables[tableName]; if (!table) { - throw new Error(`PD2 game table ${tableName} is not available in the extract`); + throw new Error( + `PD2 game table ${tableName} is not available in the extract` + ); } return table; @@ -549,7 +558,10 @@ function getOptionalGameTable(tableName: GameTableName): GameTable | undefined { return pd2GameData.tables[tableName]; } -function getGameColumnIndex(tableName: GameTableName, columnName: string): number { +function getGameColumnIndex( + tableName: GameTableName, + columnName: string +): number { const cached = gameColumnIndexes.get(tableName); if (cached) { return cached.get(columnName) ?? -1; @@ -649,9 +661,7 @@ function getGameStatCalcColumn( prefix: "aura" | "passive", index: number ): string { - return prefix === "aura" - ? `aurastatcalc${index}` - : `passivecalc${index}`; + return prefix === "aura" ? `aurastatcalc${index}` : `passivecalc${index}`; } function getPassiveStatVisitKey(skillName: string, statName: string): string { @@ -659,10 +669,7 @@ function getPassiveStatVisitKey(skillName: string, statName: string): string { } function normalizeGameCalcFormula(expression: string): string { - return expression - .replace(/^"|"$/g, "") - .replace(/\s+/g, "") - .toLowerCase(); + return expression.replace(/^"|"$/g, "").replace(/\s+/g, "").toLowerCase(); } function isPureStatPassthrough(statName: string, expression: string): boolean { @@ -792,14 +799,9 @@ const SUPPORTED_AURA_DAMAGE_STATS = new Set([ "magicmaxdam", ]); -const SUPPORTED_AURA_POISON_STATS = new Set([ - "poisonmindam", - "poisonmaxdam", -]); +const SUPPORTED_AURA_POISON_STATS = new Set(["poisonmindam", "poisonmaxdam"]); -const SUPPORTED_AURA_SKILL_LEVEL_STATS = new Set([ - "item_allskills", -]); +const SUPPORTED_AURA_SKILL_LEVEL_STATS = new Set(["item_allskills"]); const SUPPORTED_AURA_EFFECT_STATS = new Set([ ...SUPPORTED_AURA_DAMAGE_STATS, @@ -812,11 +814,7 @@ const NON_SELECTABLE_BUFF_DAMAGE_STATS = new Set([ "poisonmaxdam", ]); -const NON_DAMAGE_SUMMON_PET_TYPES = new Set([ - "none", - "totem", - "revive", -]); +const NON_DAMAGE_SUMMON_PET_TYPES = new Set(["none", "totem", "revive"]); const NON_SELECTABLE_SUMMON_PET_TYPES = new Set([ ...NON_DAMAGE_SUMMON_PET_TYPES, @@ -850,7 +848,9 @@ const SUMMON_VARIANT_DEFINITIONS: Record = { ], }; -function getSummonVariantDefinitions(skillName: string): SummonVariantDefinition[] { +function getSummonVariantDefinitions( + skillName: string +): SummonVariantDefinition[] { return SUMMON_VARIANT_DEFINITIONS[skillName] || []; } @@ -901,26 +901,21 @@ function isGameSelfOnlyPoisonBuffSkill(skillRow: string[]): boolean { !getGameRowString("Skills", skillRow, "auratargetstate") && getGameRowString("Skills", skillRow, "EType") === "pois" && getGameRowNumber("Skills", skillRow, "ELen") > 0 && - hasAllGameStats( - skillRow, - [...SUPPORTED_AURA_POISON_STATS], - "aura", - 6 - ) + hasAllGameStats(skillRow, [...SUPPORTED_AURA_POISON_STATS], "aura", 6) ); } function isGameSummonSkill(skillName: string): boolean { const skillRow = getGameRow("Skills", skillName); - const petType = skillRow ? getGameRowString("Skills", skillRow, "pettype") : ""; + const petType = skillRow + ? getGameRowString("Skills", skillRow, "pettype") + : ""; if (NON_SELECTABLE_SUMMON_PET_TYPES.has(petType.toLowerCase())) { return false; } return Boolean( - skillRow && - (getGameRowString("Skills", skillRow, "summon") || - petType) + skillRow && (getGameRowString("Skills", skillRow, "summon") || petType) ); } @@ -940,11 +935,7 @@ function isSelectableSummonSkill(skillName: string): boolean { return false; } - return getSummonDamageComponents( - skillName, - 1, - EMPTY_SKILL_MAP - ).length > 0; + return getSummonDamageComponents(skillName, 1, EMPTY_SKILL_MAP).length > 0; } function isGameSelfOrPartyBuffSkill(skillName: string): boolean { @@ -1011,10 +1002,8 @@ function isGameWeaponAttackSkill(skillName: string): boolean { ); return ( - (getGameRowString("Skills", skillRow, "leftskill") === "1" && - (hasWeaponSourceDamage || - hasKickDamage || - hasAttackDescription)) + getGameRowString("Skills", skillRow, "leftskill") === "1" && + (hasWeaponSourceDamage || hasKickDamage || hasAttackDescription) ); } @@ -1026,17 +1015,20 @@ function getSkillAllowedTransformationIds(skillName: string): string[] { const stateColumns = ["State1", "State2", "State3"] as const; const transformationIds = stateColumns - .map((columnName) => - GAME_STATE_TO_TRANSFORMATION_ID[ - getGameRowString("Skills", skillRow, columnName).toLowerCase() - ] + .map( + (columnName) => + GAME_STATE_TO_TRANSFORMATION_ID[ + getGameRowString("Skills", skillRow, columnName).toLowerCase() + ] ) .filter((id): id is string => Boolean(id)); return Array.from(new Set(transformationIds)); } -function getGameAuraDefinitionNames(definition: PlayerAuraDefinition): string[] { +function getGameAuraDefinitionNames( + definition: PlayerAuraDefinition +): string[] { return [ definition.id, definition.name, @@ -1045,9 +1037,14 @@ function getGameAuraDefinitionNames(definition: PlayerAuraDefinition): string[] ]; } -function namesIncludeSkillName(names: readonly string[], skillName: string): boolean { +function namesIncludeSkillName( + names: readonly string[], + skillName: string +): boolean { const normalized = normalizeSkillName(skillName).toLowerCase(); - return names.some((name) => normalizeSkillName(name).toLowerCase() === normalized); + return names.some( + (name) => normalizeSkillName(name).toLowerCase() === normalized + ); } function getDamageScopeCount( @@ -1060,12 +1057,21 @@ function getDamageScopeCount( return undefined; } - const expression = getGameRowString("Skills", skillRow, definition.countColumn); + const expression = getGameRowString( + "Skills", + skillRow, + definition.countColumn + ); if (!expression) { return undefined; } - const count = evaluateGameCalcExpression(expression, skillRow, skillMap, level); + const count = evaluateGameCalcExpression( + expression, + skillRow, + skillMap, + level + ); return count > 0 ? count : undefined; } @@ -1202,8 +1208,14 @@ function getPlayerAuraDefinitionsFromGameData(): PlayerAuraDefinition[] { } for (let index = 1; index <= 5; index += 1) { - const auraSkillName = getGameRowString("Skills", sourceRow, `sumskill${index}`); - const auraRow = auraSkillName ? getGameRow("Skills", auraSkillName) : undefined; + const auraSkillName = getGameRowString( + "Skills", + sourceRow, + `sumskill${index}` + ); + const auraRow = auraSkillName + ? getGameRow("Skills", auraSkillName) + : undefined; if (auraRow && getGameRowString("Skills", auraRow, "aura") === "1") { addDefinition({ id: sourceSkillName, @@ -1246,7 +1258,9 @@ function getPlayerAuraDefinitionsFromGameData(): PlayerAuraDefinition[] { const PLAYER_AURA_DEFINITIONS = getPlayerAuraDefinitionsFromGameData(); -function getPlayerAuraDefinition(auraName: string): PlayerAuraDefinition | undefined { +function getPlayerAuraDefinition( + auraName: string +): PlayerAuraDefinition | undefined { return PLAYER_AURA_DEFINITIONS.find((definition) => namesIncludeSkillName(getGameAuraDefinitionNames(definition), auraName) ); @@ -1297,7 +1311,10 @@ function getGameLevelScaledValue( return value * getGameHitShiftMultiplier(tableName, row); } -function getGameHitShiftMultiplier(tableName: GameTableName, row: string[]): number { +function getGameHitShiftMultiplier( + tableName: GameTableName, + row: string[] +): number { const hitShift = getGameRowNumber(tableName, row, "HitShift"); return 1 / 2 ** (8 - hitShift); } @@ -1463,23 +1480,27 @@ function evaluateGameCalcExpression( ); const usesSynergizedElemental = /\bed(?:ns|xs)\b/.test(expression); const elementalSynergyPercent = usesSynergizedElemental - ? evaluateGameCalcExpression( - getGameRowString("Skills", skillRow, "EDmgSymPerCalc"), - skillRow, - skillMap, - level, - visitingPassiveStats - ) - : 0; + ? evaluateGameCalcExpression( + getGameRowString("Skills", skillRow, "EDmgSymPerCalc"), + skillRow, + skillMap, + level, + visitingPassiveStats + ) + : 0; const elementalAliasMultiplier = options.elementalAliasMode === "fixed" ? 256 : 1; const elementalMinAlias = Math.floor(elementalMin * elementalAliasMultiplier); const elementalMaxAlias = Math.floor(elementalMax * elementalAliasMultiplier); const synergizedElementalMinAlias = Math.floor( - elementalMin * (1 + elementalSynergyPercent / 100) * elementalAliasMultiplier + elementalMin * + (1 + elementalSynergyPercent / 100) * + elementalAliasMultiplier ); const synergizedElementalMaxAlias = Math.floor( - elementalMax * (1 + elementalSynergyPercent / 100) * elementalAliasMultiplier + elementalMax * + (1 + elementalSynergyPercent / 100) * + elementalAliasMultiplier ); const normalized = expression @@ -1492,17 +1513,21 @@ function evaluateGameCalcExpression( .replace(/skill\('([^']+)'\.lvl\)/g, (_, skillName: string) => String(getGameSkillEntry(skillMap, skillName).level) ) - .replace(/skill\('([^']+)'\.par([1-8])\)/g, (_, skillName: string, paramNumber: string) => { - const sourceRow = getGameRow("Skills", skillName); - return String( - sourceRow ? getGameSkillParam(sourceRow, Number(paramNumber)) : 0 - ); - }) + .replace( + /skill\('([^']+)'\.par([1-8])\)/g, + (_, skillName: string, paramNumber: string) => { + const sourceRow = getGameRow("Skills", skillName); + return String( + sourceRow ? getGameSkillParam(sourceRow, Number(paramNumber)) : 0 + ); + } + ) .replace(/\bln([1-8])([1-8])\b/g, linear) .replace(/\btoht\b/g, () => String( getGameRowNumber("Skills", skillRow, "ToHit") + - Math.max(0, level - 1) * getGameRowNumber("Skills", skillRow, "LevToHit") + Math.max(0, level - 1) * + getGameRowNumber("Skills", skillRow, "LevToHit") ) ) .replace(/\bedmn\b/g, String(elementalMinAlias)) @@ -1603,7 +1628,10 @@ function createGameComponent( }; } -function applyGameDotMultiplier(range: DamageRange, multiplier: number): DamageRange { +function applyGameDotMultiplier( + range: DamageRange, + multiplier: number +): DamageRange { return { min: range.min * multiplier, max: range.max * multiplier, @@ -1613,9 +1641,9 @@ function applyGameDotMultiplier(range: DamageRange, multiplier: number): DamageR function hasGameMissileDamage(missileRow: string[]): boolean { return Boolean( getGameRowString("Missiles", missileRow, "MinDamage") || - getGameRowString("Missiles", missileRow, "MaxDamage") || - getGameRowString("Missiles", missileRow, "EMin") || - getGameRowString("Missiles", missileRow, "Emax") + getGameRowString("Missiles", missileRow, "MaxDamage") || + getGameRowString("Missiles", missileRow, "EMin") || + getGameRowString("Missiles", missileRow, "Emax") ); } @@ -1697,7 +1725,10 @@ function getGamePoisonDurationSeconds(skillName: string): number | undefined { const missileDurations = getGameSkillMissileNames(skillRow) .map((missileName) => getGameRow("Missiles", missileName)) .filter((missileRow): missileRow is string[] => Boolean(missileRow)) - .filter((missileRow) => getGameRowString("Missiles", missileRow, "EType") === "pois") + .filter( + (missileRow) => + getGameRowString("Missiles", missileRow, "EType") === "pois" + ) .map((missileRow) => getGameRowNumber("Missiles", missileRow, "ELen") / 25) .filter((duration) => duration > 0); @@ -1842,7 +1873,8 @@ function shouldIncludeGameMissilePhysicalDamage( missileName: string ): boolean { if ( - normalizeSkillName(sourceSkillName) === normalizeSkillName("Fists of Fire") && + normalizeSkillName(sourceSkillName) === + normalizeSkillName("Fists of Fire") && missileName === "fofmeteor" ) { return false; @@ -2011,8 +2043,7 @@ function summarizeGameComponents( ); } - const poisonDuration = - getGamePoisonDurationSeconds(skillName) || 2; + const poisonDuration = getGamePoisonDurationSeconds(skillName) || 2; return { components, @@ -2034,7 +2065,12 @@ function getGameDirectSkillDamage( skillMap: Map, realStats?: CharacterData["realStats"] ): DirectSkillDamage | undefined { - const components = getGameSkillComponents(skillName, level, skillMap, realStats); + const components = getGameSkillComponents( + skillName, + level, + skillMap, + realStats + ); if (components.length === 0) { return undefined; } @@ -2067,7 +2103,10 @@ function getSkillMap(characterData: CharacterData): Map { return skillMap; } -function getSkillEntry(skillMap: Map, skillName: string): SkillEntry { +function getSkillEntry( + skillMap: Map, + skillName: string +): SkillEntry { const candidates = getEquivalentSkillNames(skillName); for (const candidate of candidates) { const entry = skillMap.get(candidate); @@ -2091,9 +2130,7 @@ function getSkillEntry(skillMap: Map, skillName: string): Sk }; } -function getLogicalHandSlot( - equipment?: string -): "right" | "left" | undefined { +function getLogicalHandSlot(equipment?: string): "right" | "left" | undefined { if (!equipment) { return undefined; } @@ -2180,8 +2217,7 @@ function buildWeaponSetContext( characterData: CharacterData, weaponSet: WeaponSet ): WeaponSetContext { - let realStats = - weaponSet === "primary" ? characterData.realStats : undefined; + let realStats = weaponSet === "primary" ? characterData.realStats : undefined; let realSkills = weaponSet === "primary" ? characterData.realSkills : undefined; @@ -2219,7 +2255,10 @@ function buildWeaponSetContext( ...characterData, realSkills, } as CharacterData; - const playerItems = getPlayerItemsForWeaponSet(characterData.items, weaponSet); + const playerItems = getPlayerItemsForWeaponSet( + characterData.items, + weaponSet + ); return { weaponSet, @@ -2241,9 +2280,10 @@ function buildWeaponSetContexts( }; } -function hasDamageRange( - damage?: { minimum?: number; maximum?: number } -): damage is { minimum: number; maximum: number } { +function hasDamageRange(damage?: { + minimum?: number; + maximum?: number; +}): damage is { minimum: number; maximum: number } { return ( typeof damage?.minimum === "number" && typeof damage?.maximum === "number" && @@ -2251,11 +2291,17 @@ function hasDamageRange( ); } -function getDamageFromArmoryItemData(item: IItem): DamageRangeWithSource | undefined { +function getDamageFromArmoryItemData( + item: IItem +): DamageRangeWithSource | undefined { const itemWithPossibleKickDamage = item as IItem & { - damage?: IItem["damage"] & { kick?: { minimum?: number; maximum?: number } }; + damage?: IItem["damage"] & { + kick?: { minimum?: number; maximum?: number }; + }; base?: IItem["base"] & { - damage?: IItem["damage"] & { kick?: { minimum?: number; maximum?: number } }; + damage?: IItem["damage"] & { + kick?: { minimum?: number; maximum?: number }; + }; }; }; const damageCandidates = [ @@ -2285,7 +2331,10 @@ function getBootKickDamage(item: IItem): DamageRangeWithSource | undefined { } function isEquippedBootItem(item: IItem): boolean { - if (item.location?.zone !== "Equipped" || item.location?.equipment !== "Boots") { + if ( + item.location?.zone !== "Equipped" || + item.location?.equipment !== "Boots" + ) { return false; } @@ -2500,7 +2549,8 @@ function addWeaponSequenceSelections( (["primary", "secondary"] as const).forEach((weaponSet) => { const setSelections = selections.filter( - (selection) => selection.weaponSet === weaponSet && !selection.sequenceHits + (selection) => + selection.weaponSet === weaponSet && !selection.sequenceHits ); const rightOneHanded = setSelections.find( (selection) => @@ -2661,7 +2711,10 @@ function getWeaponOptions(characterData: CharacterData): WeaponSelection[] { }); const summonSkillNames = Array.from(getSkillMap(characterData).entries()) - .filter(([skillName, entry]) => entry.level > 0 && isSelectableSummonSkill(skillName)) + .filter( + ([skillName, entry]) => + entry.level > 0 && isSelectableSummonSkill(skillName) + ) .map(([skillName]) => skillName); uniqueStrings(summonSkillNames).forEach((skillName) => { selections.push(createSummonSelection(skillName)); @@ -2733,7 +2786,10 @@ function getStatBonusPercent( return strength * 0.75 + dexterity * 0.75; } - if (weaponSelection.option.handMode === "missile" && !isBowOrCrossbow(weaponSelection.item)) { + if ( + weaponSelection.option.handMode === "missile" && + !isBowOrCrossbow(weaponSelection.item) + ) { return strength * 0.75 + dexterity * 0.75; } @@ -2751,7 +2807,9 @@ function getStatBonusPercent( return strength; } -function parseAuraProperty(property: string): { name: string; level: number } | null { +function parseAuraProperty( + property: string +): { name: string; level: number } | null { const match = property.match(/^Level (\d+) (.+?) Aura When Equipped$/i); if (!match) { return null; @@ -2771,8 +2829,8 @@ function isDamageAura(auraName: string): boolean { const skillRow = getGameRow("Skills", auraName); return Boolean( skillRow && - (hasAnyGameStat(skillRow, SUPPORTED_AURA_DAMAGE_STATS) || - GAME_ETYPES[getGameRowString("Skills", skillRow, "EType")]) + (hasAnyGameStat(skillRow, SUPPORTED_AURA_DAMAGE_STATS) || + GAME_ETYPES[getGameRowString("Skills", skillRow, "EType")]) ); } @@ -2896,13 +2954,15 @@ function collectPlayerAuraOptions( Object.values(contexts).forEach((context) => { Array.from(context.skillMap.entries()) .filter( - ([skillName, entry]) => - entry.level > 0 && isPlayerAuraSkill(skillName) + ([skillName, entry]) => entry.level > 0 && isPlayerAuraSkill(skillName) ) .forEach(([skillName, entry]) => { const definition = getPlayerAuraDefinition(skillName); const auraId = definition?.id || normalizeSkillName(skillName); - auraLevels.set(auraId, Math.max(auraLevels.get(auraId) || 0, entry.level)); + auraLevels.set( + auraId, + Math.max(auraLevels.get(auraId) || 0, entry.level) + ); }); }); @@ -2948,9 +3008,10 @@ function isSelectableAttackSkill(skillName: string): boolean { } function isSelectableSpellSkill(skillName: string): boolean { - const resolvedSkillName = getEquivalentSkillNames(skillName).find((candidate) => - Boolean(getGameRow("Skills", candidate)) - ) || skillName; + const resolvedSkillName = + getEquivalentSkillNames(skillName).find((candidate) => + Boolean(getGameRow("Skills", candidate)) + ) || skillName; if ( isPlayerAuraSkill(resolvedSkillName) || isGameSummonSkill(resolvedSkillName) || @@ -3009,7 +3070,8 @@ function collectDamageSkillOptions( ? "summon" : "spell"; const optionSources = - damageMode === "summon" && getSummonVariantDefinitions(skillName).length > 0 + damageMode === "summon" && + getSummonVariantDefinitions(skillName).length > 0 ? getSummonVariantDefinitions(skillName).map((variant) => ({ id: `${skillName}::${variant.id}`, name: `${skillName} (${variant.label})`, @@ -3053,7 +3115,8 @@ function collectDamageSkillOptions( Array.from(damageSkillLevels.entries()) .sort( (left, right) => - right[1].level - left[1].level || left[1].name.localeCompare(right[1].name) + right[1].level - left[1].level || + left[1].name.localeCompare(right[1].name) ) .forEach(([, metadata]) => { options.push({ @@ -3082,7 +3145,10 @@ function collectTransformationOptions( const getLevelBonuses = (gameSkillName: string, levels: number[]) => levels.map((level) => ({ level, - physicalBonusPercent: getTransformationDamagePercent(gameSkillName, level), + physicalBonusPercent: getTransformationDamagePercent( + gameSkillName, + level + ), })); const getTransformationLevel = ( definition: (typeof TRANSFORMATION_SKILL_DEFINITIONS)[number] @@ -3149,7 +3215,10 @@ function normalizeItemDamageRange(damage: { }); } -function addDamageRange(target: DamageRange, addition?: DamageRange): DamageRange { +function addDamageRange( + target: DamageRange, + addition?: DamageRange +): DamageRange { if (!addition) { return target; } @@ -3174,7 +3243,10 @@ function addPoisonDamage( return { total: current.total + addition.total, - durationSeconds: Math.max(current.durationSeconds, addition.durationSeconds), + durationSeconds: Math.max( + current.durationSeconds, + addition.durationSeconds + ), }; } @@ -3186,7 +3258,9 @@ function averageDamageRange(range: DamageRange): number { return Number(((range.min + range.max) / 2).toFixed(1)); } -function getCombinedDamageScore(profile: DamageProfile): [number, number, number] { +function getCombinedDamageScore( + profile: DamageProfile +): [number, number, number] { const combinedDamage = profile.damageTotals.combinedDamage; const averageCombinedDamage = Number.isFinite( profile.damageTotals.averageCombinedDamage @@ -3213,7 +3287,9 @@ function hasHigherCombinedDamage( return false; } -function getDefaultDamageProfile(profiles: DamageProfile[]): DamageProfile | null { +function getDefaultDamageProfile( + profiles: DamageProfile[] +): DamageProfile | null { const noManualAuraProfiles = profiles.filter( (profile) => profile.playerAuraId === "none" && @@ -3241,9 +3317,8 @@ function getDefaultTransformationSelection( .map((transformationId) => transformationOptions.find((option) => option.id === transformationId) ) - .find( - (option): option is DamageTransformationOption => - Boolean(option && option.id !== "none") + .find((option): option is DamageTransformationOption => + Boolean(option && option.id !== "none") ); if (!requiredTransformation) { @@ -3252,13 +3327,18 @@ function getDefaultTransformationSelection( const level = requiredTransformation.level || - requiredTransformation.levelOptions.find((optionLevel) => optionLevel > 0) || + requiredTransformation.levelOptions.find( + (optionLevel) => optionLevel > 0 + ) || 1; return `${requiredTransformation.id}:${level}`; } -function floorScaleDamageRange(range: DamageRange, multiplier: number): DamageRange { +function floorScaleDamageRange( + range: DamageRange, + multiplier: number +): DamageRange { return normalizeDamageRange({ min: Math.floor(range.min * multiplier), max: Math.floor(range.max * multiplier), @@ -3300,7 +3380,10 @@ function scalePhysicalDamageComponent( return { ...component, baseDamage: component.baseDamage || component.damage, - damage: floorScaleDamageRange(component.baseDamage || component.damage, multiplier), + damage: floorScaleDamageRange( + component.baseDamage || component.damage, + multiplier + ), }; } @@ -3316,7 +3399,9 @@ function sumDamageComponents( ); } -function buildDamageTotals(components: readonly DamageComponent[]): DamageTotals { +function buildDamageTotals( + components: readonly DamageComponent[] +): DamageTotals { const byElement: Partial> = {}; let poisonDamage: PoisonDamage | undefined; @@ -3365,7 +3450,9 @@ function buildDamageTotals(components: readonly DamageComponent[]): DamageTotals function elementalDamageFromTotals( totals: DamageTotals ): Partial> { - const elemental: Partial> = {}; + const elemental: Partial< + Record<"fire" | "cold" | "lightning" | "magic", DamageRange> + > = {}; (["fire", "cold", "lightning", "magic"] as const).forEach((element) => { const range = totals.byElement[element]; if (isNonZeroDamageRange(range)) { @@ -3507,9 +3594,16 @@ function getPreferredMonsterAttackMode( ); for (let index = 1; index <= 8; index += 1) { - const monsterSkillName = getGameRowString("MonStats", monsterRow, `Skill${index}`); - const mode = getGameRowString("MonStats", monsterRow, `Sk${index}mode`) - .toUpperCase() as MonsterAttackMode; + const monsterSkillName = getGameRowString( + "MonStats", + monsterRow, + `Skill${index}` + ); + const mode = getGameRowString( + "MonStats", + monsterRow, + `Sk${index}mode` + ).toUpperCase() as MonsterAttackMode; if ( summonedNames.has(monsterSkillName.toLowerCase()) && mode in MONSTER_ATTACK_MODE_COLUMNS && @@ -3597,7 +3691,11 @@ function getSummonedSkillComponents( ): DamageComponent[] { const components: DamageComponent[] = []; for (let index = 1; index <= 5; index += 1) { - const summonedSkillName = getGameRowString("Skills", skillRow, `sumskill${index}`); + const summonedSkillName = getGameRowString( + "Skills", + skillRow, + `sumskill${index}` + ); if (!summonedSkillName) { continue; } @@ -3607,8 +3705,8 @@ function getSummonedSkillComponents( ); const isSummonOwnedAuraSkill = Boolean( summonedSkillRow && - getGameRowString("Skills", summonedSkillRow, "aura") === "1" && - !getGameRowString("Skills", summonedSkillRow, "charclass") + getGameRowString("Skills", summonedSkillRow, "aura") === "1" && + !getGameRowString("Skills", summonedSkillRow, "charclass") ); if (!isMonsterListedSkill && !isSummonOwnedAuraSkill) { continue; @@ -3639,7 +3737,8 @@ function getSummonedSkillComponents( [ { table: "Skills.txt", - row: getGameRowString("Skills", skillRow, "skill") || ownerSkillName, + row: + getGameRowString("Skills", skillRow, "skill") || ownerSkillName, columns: [`sumskill${index}`, `sumsk${index}calc`], }, ] @@ -3661,13 +3760,21 @@ function getSummonFlatPhysicalComponents( (["aura", "passive"] as const).forEach((prefix) => { const maxIndex = prefix === "aura" ? 6 : 5; for (let index = 1; index <= maxIndex; index += 1) { - const stat = getGameRowString("Skills", skillRow, `${prefix}stat${index}`); + const stat = getGameRowString( + "Skills", + skillRow, + `${prefix}stat${index}` + ); if (stat !== "item_normaldamage") { continue; } const damage = evaluateGameCalcExpression( - getGameRowString("Skills", skillRow, getGameStatCalcColumn(prefix, index)), + getGameRowString( + "Skills", + skillRow, + getGameStatCalcColumn(prefix, index) + ), skillRow, skillMap, level @@ -3773,15 +3880,18 @@ function getSummonDamageComponents( getMonsterSkillNames(monsterRow) ) : []; - const monsterComponent = monsterRow && includeMonsterAttack - ? getSummonMonsterAttackComponent( - skillName, - skillRow, - monsterRow, - summonedSkillNames, - directComponents.some((component) => component.damageType === "physical") - ) - : undefined; + const monsterComponent = + monsterRow && includeMonsterAttack + ? getSummonMonsterAttackComponent( + skillName, + skillRow, + monsterRow, + summonedSkillNames, + directComponents.some( + (component) => component.damageType === "physical" + ) + ) + : undefined; return [ ...directComponents, @@ -3813,7 +3923,11 @@ function getSummonDamagePercent( } total += evaluateGameCalcExpression( - getGameRowString("Skills", skillRow, getGameStatCalcColumn(prefix, index)), + getGameRowString( + "Skills", + skillRow, + getGameStatCalcColumn(prefix, index) + ), skillRow, skillMap, level @@ -3883,7 +3997,12 @@ function getDuplicateDirectSummonDamagePercent( continue; } - total += evaluateGameCalcExpression(expression, skillRow, skillMap, level); + total += evaluateGameCalcExpression( + expression, + skillRow, + skillMap, + level + ); } }); @@ -3891,7 +4010,10 @@ function getDuplicateDirectSummonDamagePercent( } function getElementalSkillDamageBonusPercent( - element: Exclude, "">, + element: Exclude< + keyof NonNullable, + "" + >, realStats?: CharacterData["realStats"] ): number { if (!realStats) { @@ -4082,7 +4204,12 @@ function getSelectedSkillDamagePercent( getGameRowString("SkillDesc", skillDescRow, `desccalca${index}`), getGameRowString("SkillDesc", skillDescRow, `desccalcb${index}`), ].forEach((calc) => { - const value = evaluateGameCalcExpression(calc, skillRow, skillMap, level); + const value = evaluateGameCalcExpression( + calc, + skillRow, + skillMap, + level + ); if (value > 0) { gameDamageValues.push(value); } @@ -4109,7 +4236,10 @@ function getTransformationDamagePercent( } for (let index = 1; index <= 6; index += 1) { - if (getGameRowString("Skills", skillRow, `aurastat${index}`) !== "damagepercent") { + if ( + getGameRowString("Skills", skillRow, `aurastat${index}`) !== + "damagepercent" + ) { continue; } @@ -4132,19 +4262,21 @@ function getPassiveSkillDamagePercent( let total = 0; if (isJavelinOrSpear(weaponSelection.item)) { - total += getGameSkillPassiveStatValue( - "Javelin and Spear Mastery", - ["passive_mastery_melee_dmg", "passive_mastery_throw_dmg"], - skillMap - ) || 0; + total += + getGameSkillPassiveStatValue( + "Javelin and Spear Mastery", + ["passive_mastery_melee_dmg", "passive_mastery_throw_dmg"], + skillMap + ) || 0; } if (isClawWeapon(weaponSelection.item)) { - total += getGameSkillPassiveStatValue( - "Claw Mastery", - ["passive_mastery_melee_dmg"], - skillMap - ) || 0; + total += + getGameSkillPassiveStatValue( + "Claw Mastery", + ["passive_mastery_melee_dmg"], + skillMap + ) || 0; } if (characterClass.toLowerCase() === "barbarian") { @@ -4152,11 +4284,12 @@ function getPassiveSkillDamagePercent( weaponSelection.option.handMode === "two_handed" ? "Two Handed Mastery" : "One Handed Mastery"; - total += getGameSkillPassiveStatValue( - masterySkillName, - ["passive_mastery_melee_dmg", "passive_mastery_throw_dmg"], - skillMap - ) || 0; + total += + getGameSkillPassiveStatValue( + masterySkillName, + ["passive_mastery_melee_dmg", "passive_mastery_throw_dmg"], + skillMap + ) || 0; } return total; @@ -4168,7 +4301,9 @@ function parseItemDamageStats( ): { flatPhysicalDamage: DamageRange; nonWeaponEnhancedDamagePct: number; - elementalDamage: Partial>; + elementalDamage: Partial< + Record<"fire" | "cold" | "lightning" | "magic", DamageRange> + >; poisonDamage?: PoisonDamage; } { const flatPhysicalDamage = createEmptyDamageRange(); @@ -4178,7 +4313,8 @@ function parseItemDamageStats( let nonWeaponEnhancedDamagePct = 0; let poisonDamage: PoisonDamage | undefined; - const selectedWeaponKey = selectedWeapon.item.hash || String(selectedWeapon.item.id); + const selectedWeaponKey = + selectedWeapon.item.hash || String(selectedWeapon.item.id); const addElementalRange = ( element: "fire" | "cold" | "lightning" | "magic", @@ -4230,7 +4366,9 @@ function parseItemDamageStats( return; } - const addedPhysicalDamage = property.match(/^Adds (\d+)-(\d+) Damage$/i); + const addedPhysicalDamage = property.match( + /^Adds (\d+)-(\d+) Damage$/i + ); if (addedPhysicalDamage) { flatPhysicalDamage.min += Number(addedPhysicalDamage[1]); flatPhysicalDamage.max += Number(addedPhysicalDamage[2]); @@ -4241,23 +4379,37 @@ function parseItemDamageStats( if (!isWeapon || isSelectedWeapon) { const fireDamage = property.match(/^Adds (\d+)-(\d+) Fire Damage$/i); if (fireDamage) { - addElementalRange("fire", Number(fireDamage[1]), Number(fireDamage[2])); + addElementalRange( + "fire", + Number(fireDamage[1]), + Number(fireDamage[2]) + ); return; } const coldDamage = property.match(/^Adds (\d+)-(\d+) Cold Damage$/i); if (coldDamage) { - addElementalRange("cold", Number(coldDamage[1]), Number(coldDamage[2])); + addElementalRange( + "cold", + Number(coldDamage[1]), + Number(coldDamage[2]) + ); return; } const magicDamage = property.match(/^Adds (\d+)-(\d+) Magic Damage$/i); if (magicDamage) { - addElementalRange("magic", Number(magicDamage[1]), Number(magicDamage[2])); + addElementalRange( + "magic", + Number(magicDamage[1]), + Number(magicDamage[2]) + ); return; } - const lightningDamage = property.match(/^Adds (\d+)-(\d+) Lightning Damage$/i); + const lightningDamage = property.match( + /^Adds (\d+)-(\d+) Lightning Damage$/i + ); if (lightningDamage) { addElementalRange( "lightning", @@ -4276,7 +4428,9 @@ function parseItemDamageStats( | "cold" | "lightning" | "magic"; - standaloneElementalDamage[element].min += Number(minElementalDamage[2]); + standaloneElementalDamage[element].min += Number( + minElementalDamage[2] + ); return; } @@ -4289,7 +4443,9 @@ function parseItemDamageStats( | "cold" | "lightning" | "magic"; - standaloneElementalDamage[element].max += Number(maxElementalDamage[2]); + standaloneElementalDamage[element].max += Number( + maxElementalDamage[2] + ); return; } @@ -4371,7 +4527,9 @@ function usesFixedPointElementalAliases( statNames: readonly string[] ): boolean { return ( - statNames.some((statName) => AURA_ELEMENT_DAMAGE_STAT_NAMES.has(statName)) && + statNames.some((statName) => + AURA_ELEMENT_DAMAGE_STAT_NAMES.has(statName) + ) && /\bed(?:mn|mx|ns|xs)\b/.test(calc) && /\/\s*256\b/.test(calc) ); @@ -4423,9 +4581,10 @@ function getAuraPhysicalDamagePercent(aura: AuraSource): number { "party", skillMap ); - const gameValue = aura.carrier === "self" - ? gameSelfValue ?? gamePartyValue - : gamePartyValue; + const gameValue = + aura.carrier === "self" + ? (gameSelfValue ?? gamePartyValue) + : gamePartyValue; if (gameValue !== undefined) { return gameValue; } @@ -4445,9 +4604,10 @@ function getAuraSkillLevelBonus(aura: AuraSource): number { "party", skillMap ); - const gameValue = aura.carrier === "self" - ? gameSelfValue ?? gamePartyValue - : gamePartyValue; + const gameValue = + aura.carrier === "self" + ? (gameSelfValue ?? gamePartyValue) + : gamePartyValue; return gameValue ?? 0; } @@ -4506,7 +4666,7 @@ function hasSelfElementalAuraDamageStats( const [minStat, maxStat] = AURA_ELEMENT_STATS[element]; return Boolean( findGameAuraStatCalc(skillRow, [minStat], "self") && - findGameAuraStatCalc(skillRow, [maxStat], "self") + findGameAuraStatCalc(skillRow, [maxStat], "self") ); } @@ -4516,7 +4676,10 @@ function getGameBuffElementalRange( skillMap: Map ): DamageRange | undefined { const skillRow = getGameRow("Skills", getAuraFormulaSkillName(aura.name)); - if (!skillRow || GAME_ETYPES[getGameRowString("Skills", skillRow, "EType")] !== element) { + if ( + !skillRow || + GAME_ETYPES[getGameRowString("Skills", skillRow, "EType")] !== element + ) { return undefined; } @@ -4557,14 +4720,14 @@ function getAuraAttackDamage( skillMap: Map, realStats?: CharacterData["realStats"] ): Partial> { - const ranges: Partial> = {}; + const ranges: Partial< + Record<"fire" | "cold" | "lightning" | "magic", DamageRange> + > = {}; const skillDamageBonusApplies = aura.source === "player_skill"; const auraFormulaSkillMap = aura.source === "player_skill" ? skillMap : EMPTY_SKILL_MAP; - const getAuraRange = ( - element: "fire" | "cold" | "lightning" | "magic" - ) => + const getAuraRange = (element: "fire" | "cold" | "lightning" | "magic") => getGameAuraElementalRange(aura, element, auraFormulaSkillMap) || getGameBuffElementalRange(aura, element, auraFormulaSkillMap); @@ -4845,8 +5008,9 @@ function isWeaponSelectionCompatibleWithSkill( if (isSummonSkill) { return ( weaponSelection.option.handMode === "summon" && - normalizeSkillName(weaponSelection.summonSkillName || "").toLowerCase() === - normalizeSkillName(sourceSkillName).toLowerCase() + normalizeSkillName( + weaponSelection.summonSkillName || "" + ).toLowerCase() === normalizeSkillName(sourceSkillName).toLowerCase() ); } @@ -4874,7 +5038,10 @@ function isWeaponSelectionCompatibleWithSkill( } if (weaponSelection.sequenceHits?.length) { - return supportsWeaponSequence(sourceSkillName, weaponSelection.option.handMode); + return supportsWeaponSequence( + sourceSkillName, + weaponSelection.option.handMode + ); } return true; @@ -4945,7 +5112,9 @@ function buildSummonProfile( ? undefined : getSummonAuraSource({ name: playerAuraOption.name, - level: getSkillEntry(skillMap, playerAuraOption.name).level || playerAuraOption.level, + level: + getSkillEntry(skillMap, playerAuraOption.name).level || + playerAuraOption.level, source: playerAuraOption.source === "character_skill" ? ("player_skill" as const) @@ -4958,7 +5127,8 @@ function buildSummonProfile( ]); const effectiveSkillMap = applyAuraSkillLevelBonuses(skillMap, activeAuras); const selectedSkillLevel = - getSkillEntry(effectiveSkillMap, sourceSkillName).level || skillOption.level; + getSkillEntry(effectiveSkillMap, sourceSkillName).level || + skillOption.level; const selectedSkillRow = getGameRow("Skills", sourceSkillName); const baseComponents = getSummonDamageComponents( sourceSkillName, @@ -5138,7 +5308,9 @@ function buildSpellProfile( ? undefined : { name: playerAuraOption.name, - level: getSkillEntry(skillMap, playerAuraOption.name).level || playerAuraOption.level, + level: + getSkillEntry(skillMap, playerAuraOption.name).level || + playerAuraOption.level, source: playerAuraOption.source === "character_skill" && playerAuraCarrier === "self" @@ -5152,7 +5324,8 @@ function buildSpellProfile( ]); const effectiveSkillMap = applyAuraSkillLevelBonuses(skillMap, activeAuras); const selectedSkillLevel = - getSkillEntry(effectiveSkillMap, skillOption.name).level || skillOption.level; + getSkillEntry(effectiveSkillMap, skillOption.name).level || + skillOption.level; const directDamage = getDirectSkillDamage( skillOption.name, selectedSkillLevel, @@ -5187,7 +5360,9 @@ function buildSpellProfile( ); } - notes.push("Selected attack aura damage payloads are not applied to spell damage."); + notes.push( + "Selected attack aura damage payloads are not applied to spell damage." + ); } return { @@ -5313,7 +5488,8 @@ function buildSequenceProfile( averageHitDamage: damageTotals.averageCombinedDamage, breakdown: { weaponDamage: hitProfiles.reduce( - (total, { profile }) => addDamageRange(total, profile.breakdown.weaponDamage), + (total, { profile }) => + addDamageRange(total, profile.breakdown.weaponDamage), createEmptyDamageRange() ), flatPhysicalDamage: hitProfiles.reduce( @@ -5321,17 +5497,16 @@ function buildSequenceProfile( addDamageRange(total, profile.breakdown.flatPhysicalDamage), createEmptyDamageRange() ), - physicalBonusPercent: - firstProfile?.breakdown.physicalBonusPercent ?? { - stat: 0, - nonWeapon: 0, - passive: 0, - selectedSkill: 0, - selectedSkillSynergy: 0, - transformation: 0, - activeAuras: 0, - total: 0, - }, + physicalBonusPercent: firstProfile?.breakdown.physicalBonusPercent ?? { + stat: 0, + nonWeapon: 0, + passive: 0, + selectedSkill: 0, + selectedSkillSynergy: 0, + transformation: 0, + activeAuras: 0, + total: 0, + }, elementalDamage: totalElementalDamage, poisonDamage: damageTotals.poisonDamage, }, @@ -5385,7 +5560,9 @@ function buildProfile( ? undefined : { name: playerAuraOption.name, - level: getSkillEntry(skillMap, playerAuraOption.name).level || playerAuraOption.level, + level: + getSkillEntry(skillMap, playerAuraOption.name).level || + playerAuraOption.level, source: playerAuraOption.source === "character_skill" && playerAuraCarrier === "self" @@ -5399,9 +5576,11 @@ function buildProfile( ...(selectedPlayerAura ? [selectedPlayerAura] : []), ]); const effectiveSkillMap = applyAuraSkillLevelBonuses(skillMap, activeAuras); - const selectedSkillLevel = skillOption.name === "Basic Attack" - ? 1 - : getSkillEntry(effectiveSkillMap, skillOption.name).level || skillOption.level; + const selectedSkillLevel = + skillOption.name === "Basic Attack" + ? 1 + : getSkillEntry(effectiveSkillMap, skillOption.name).level || + skillOption.level; const parsedItemDamage = parseItemDamageStats(playerItems, weaponSelection); const statBonusPercent = getStatBonusPercent( @@ -5432,8 +5611,8 @@ function buildProfile( lightningPct: 0, magicPct: 0, poisonPct: 0, - } - : getSkillSynergyBonuses(selectedSkillName, effectiveSkillMap); + } + : getSkillSynergyBonuses(selectedSkillName, effectiveSkillMap); const directPhysicalSynergyPercent = selectedSkillName === "Basic Attack" ? 0 @@ -5488,7 +5667,8 @@ function buildProfile( ? "Weapon source" : `Weapon source (${selectedSkillName})`; const equipmentDamageSourceRefs = - weaponSelection.damageSourceRefs && weaponSelection.damageSourceRefs.length > 0 + weaponSelection.damageSourceRefs && + weaponSelection.damageSourceRefs.length > 0 ? weaponSelection.damageSourceRefs : [ { @@ -5694,7 +5874,9 @@ function buildProfile( const notes: string[] = []; if ( selectedSkillName !== "Basic Attack" && - directDamageComponents.some((component) => component.damageType !== "physical") + directDamageComponents.some( + (component) => component.damageType !== "physical" + ) ) { notes.push( `${selectedSkillName} includes independent skill or missile payload components from game-file damage fields in addition to weapon-source damage.` @@ -5770,7 +5952,8 @@ function getPlayerAuraSelectionsForSkill( } return playerAuraSelections.filter( - (selection) => selection.option.id === "none" || selection.carrier === "party" + (selection) => + selection.option.id === "none" || selection.carrier === "party" ); } @@ -5798,7 +5981,10 @@ export function calculateDamage( return []; } - return getPlayerAuraSelectionsForSkill(skillOption, playerAuraSelections).map((playerAuraSelection) => + return getPlayerAuraSelectionsForSkill( + skillOption, + playerAuraSelections + ).map((playerAuraSelection) => buildProfile( characterData, contexts[weaponSelection.weaponSet], @@ -5811,8 +5997,9 @@ export function calculateDamage( ); const defaultProfile = getDefaultDamageProfile(profiles); const defaultSkillSelection = - skillOptions.find((skillOption) => skillOption.id === defaultProfile?.skillId) || - defaultSkill; + skillOptions.find( + (skillOption) => skillOption.id === defaultProfile?.skillId + ) || defaultSkill; const defaultTransformationId = getDefaultTransformationSelection( defaultSkillSelection, transformationOptions, @@ -5835,7 +6022,11 @@ export function calculateDamage( ); } - if (weaponSelections.some((selection) => selection.option.handMode === "unarmed")) { + if ( + weaponSelections.some( + (selection) => selection.option.handMode === "unarmed" + ) + ) { notes.push( "If a weapon set does not expose readable equipped weapon damage, the calculator falls back to the unarmed 1-2 base damage value." ); @@ -5852,7 +6043,8 @@ export function calculateDamage( skillId: defaultProfile?.skillId ?? defaultSkill.id, playerAuraId: defaultProfile?.playerAuraId ?? defaultPlayerAura.id, playerAuraCarrier: defaultProfile?.playerAuraCarrier ?? "self", - playerAuraLevel: defaultProfile?.playerAuraLevel ?? defaultPlayerAura.level, + playerAuraLevel: + defaultProfile?.playerAuraLevel ?? defaultPlayerAura.level, transformationId: defaultTransformationId, }, profiles, diff --git a/api/src/utils/skill-calculator.test.ts b/api/src/utils/skill-calculator.test.ts index f32546e..f7a4660 100644 --- a/api/src/utils/skill-calculator.test.ts +++ b/api/src/utils/skill-calculator.test.ts @@ -518,7 +518,10 @@ describe("D2SkillParser", () => { ], }, { name: "Amulet", properties: ["+2 to Druid Skills"] }, - { name: "Gloves", properties: ["+2 to Shape Shifting Skills (Druid Only)"] }, + { + name: "Gloves", + properties: ["+2 to Shape Shifting Skills (Druid Only)"], + }, { name: "Hellfire Torch", properties: ["+2 to Druid Skills"] }, { name: "Arachnid Mesh", properties: ["+1 to All Skills"] }, { name: "Annihilus", properties: ["+1 to All Skills"] }, @@ -554,16 +557,36 @@ describe("D2SkillParser", () => { expect(result).toEqual( expect.arrayContaining([ - expect.objectContaining({ skill: "Rabies", level: 41, baseLevel: 20 }), - expect.objectContaining({ skill: "Feral Rage", level: 41, baseLevel: 20 }), - expect.objectContaining({ skill: "Lycanthropy", level: 41, baseLevel: 20 }), - expect.objectContaining({ skill: "Werewolf", level: 29, baseLevel: 8 }), + expect.objectContaining({ + skill: "Rabies", + level: 41, + baseLevel: 20, + }), + expect.objectContaining({ + skill: "Feral Rage", + level: 41, + baseLevel: 20, + }), + expect.objectContaining({ + skill: "Lycanthropy", + level: 41, + baseLevel: 20, + }), + expect.objectContaining({ + skill: "Werewolf", + level: 29, + baseLevel: 8, + }), expect.objectContaining({ skill: "Poison Creeper", level: 29, baseLevel: 20, }), - expect.objectContaining({ skill: "Oak Sage", level: 20, baseLevel: 11 }), + expect.objectContaining({ + skill: "Oak Sage", + level: 20, + baseLevel: 11, + }), ]) ); }); diff --git a/api/src/utils/skill-calculator.ts b/api/src/utils/skill-calculator.ts index e0db632..5ef5998 100644 --- a/api/src/utils/skill-calculator.ts +++ b/api/src/utils/skill-calculator.ts @@ -464,11 +464,7 @@ class D2SkillParser { }, { name: "Pole Arm Mastery", - categories: [ - "masteries skills", - "barbarian skills", - "pole arm mastery", - ], + categories: ["masteries skills", "barbarian skills", "pole arm mastery"], }, { name: "Spear Mastery", @@ -476,11 +472,7 @@ class D2SkillParser { }, { name: "Throwing Mastery", - categories: [ - "masteries skills", - "barbarian skills", - "throwing mastery", - ], + categories: ["masteries skills", "barbarian skills", "throwing mastery"], }, { name: "Taunt", @@ -540,11 +532,7 @@ class D2SkillParser { }, { name: "Combat Reflexes", - categories: [ - "masteries skills", - "barbarian skills", - "combat reflexes", - ], + categories: ["masteries skills", "barbarian skills", "combat reflexes"], }, { name: "Battle Orders", @@ -807,11 +795,7 @@ class D2SkillParser { }, { name: "Teeth", - categories: [ - "poison and bone skills", - "necromancer skills", - "teeth", - ], + categories: ["poison and bone skills", "necromancer skills", "teeth"], }, { name: "Poison Dagger", diff --git a/docker-compose.cloud.yml b/docker-compose.cloud.yml new file mode 100644 index 0000000..eb06e6c --- /dev/null +++ b/docker-compose.cloud.yml @@ -0,0 +1,206 @@ +x-cloud-logging: &cloud-logging + driver: json-file + options: + max-size: ${LOG_MAX_SIZE:-10m} + max-file: ${LOG_MAX_FILE:-5} + +x-api-environment: &api-environment + NODE_ENV: production + PORT: 3000 + API_VERSION: ${API_VERSION:-v1} + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_DB: ${POSTGRES_DB:-pd2_tools} + POSTGRES_USER: ${POSTGRES_USER:-pd2_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + REDIS_HOST: redis + REDIS_PORT: 6379 + CURRENT_SEASON: ${CURRENT_SEASON:-13} + CORS_ORIGIN: ${CORS_ORIGIN:-https://pd2.tools,https://www.pd2.tools} + RATE_LIMIT_WINDOW: ${RATE_LIMIT_WINDOW:-15} + RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-10000} + LOG_LEVEL: ${LOG_LEVEL:-info} + +x-api-service: &api-service + build: + context: ./api + dockerfile: Dockerfile + image: pd2-tools-api:local + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + environment: + <<: *api-environment + logging: *cloud-logging + +services: + postgres: + image: postgres:16 + restart: unless-stopped + shm_size: ${POSTGRES_SHM_SIZE:-36gb} + command: + - postgres + - -c + - "data_directory=/var/lib/postgresql/data" + - -c + - "hba_file=/var/lib/postgresql/data/pg_hba.conf" + - -c + - "ident_file=/var/lib/postgresql/data/pg_ident.conf" + - -c + - "external_pid_file=/var/run/postgresql/16-main.pid" + - -c + - "listen_addresses=*" + - -c + - "port=5432" + - -c + - "max_connections=100" + - -c + - "unix_socket_directories=/var/run/postgresql" + - -c + - "ssl=off" + - -c + - "shared_buffers=32GB" + - -c + - "work_mem=1GB" + - -c + - "maintenance_work_mem=4GB" + - -c + - "effective_cache_size=48GB" + - -c + - "dynamic_shared_memory_type=posix" + - -c + - "backend_flush_after=0" + - -c + - "effective_io_concurrency=256" + - -c + - "max_worker_processes=16" + - -c + - "max_parallel_workers=16" + - -c + - "max_parallel_workers_per_gather=13" + - -c + - "max_parallel_maintenance_workers=3" + - -c + - "wal_buffers=512MB" + - -c + - "max_wal_size=16GB" + - -c + - "min_wal_size=4GB" + - -c + - "random_page_cost=1.0" + - -c + - "cpu_tuple_cost=0.005" + - -c + - "cpu_operator_cost=0.001" + - -c + - "parallel_tuple_cost=0.01" + - -c + - "min_parallel_table_scan_size=128kB" + - -c + - "jit=on" + - -c + - "jit_above_cost=100" + - -c + - "jit_inline_above_cost=500" + - -c + - "jit_optimize_above_cost=500" + - -c + - "log_line_prefix=%m [%p] %q%u@%d " + - -c + - "log_timezone=Europe/Berlin" + - -c + - "cluster_name=16/main" + - -c + - "timezone=Europe/Berlin" + - -c + - "datestyle=iso, mdy" + - -c + - "lc_messages=en_US.UTF-8" + - -c + - "lc_monetary=en_US.UTF-8" + - -c + - "lc_numeric=en_US.UTF-8" + - -c + - "lc_time=en_US.UTF-8" + - -c + - "default_text_search_config=pg_catalog.english" + environment: + POSTGRES_DB: ${POSTGRES_DB:-pd2_tools} + POSTGRES_USER: ${POSTGRES_USER:-pd2_user} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + POSTGRES_INITDB_ARGS: "--locale=en_US.UTF-8" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: + [ + "CMD-SHELL", + "pg_isready -U ${POSTGRES_USER:-pd2_user} -d ${POSTGRES_DB:-pd2_tools}", + ] + interval: 10s + timeout: 5s + retries: 10 + start_period: 5s + logging: *cloud-logging + + redis: + image: redis:7-alpine + restart: unless-stopped + command: + [ + "redis-server", + "--appendonly", + "yes", + "--save", + "60", + "1", + "--loglevel", + "warning", + ] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 5s + logging: *cloud-logging + + api: + <<: *api-service + ports: + - "127.0.0.1:${API_PORT:-3000}:3000" + volumes: + - app_data:/app/data + + jobs: + <<: *api-service + command: ["npm", "run", "jobs"] + environment: + <<: *api-environment + volumes: + - app_data:/app/data + + # web: + # build: + # context: ./web + # dockerfile: Dockerfile + # args: + # VITE_API_URL: ${VITE_API_URL:-https://api.pd2.tools/api/v1} + # image: pd2-tools-web:local + # restart: unless-stopped + # depends_on: + # api: + # condition: service_started + # ports: + # - "127.0.0.1:${WEB_PORT:-4173}:4173" + # logging: *cloud-logging + +volumes: + postgres_data: + redis_data: + app_data: diff --git a/docker-compose.yml b/docker-compose.yml index 001c5fd..b922d95 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,7 @@ x-api-service: &api-service CURRENT_SEASON: ${CURRENT_SEASON:-13} CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:4173,http://127.0.0.1:4173,http://localhost:4174,http://127.0.0.1:4174,http://localhost:5173,http://127.0.0.1:5173} RATE_LIMIT_WINDOW: ${RATE_LIMIT_WINDOW:-15} - RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-100} + RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-1000} LOG_LEVEL: ${LOG_LEVEL:-info} services: @@ -51,7 +51,16 @@ services: image: redis:7-alpine restart: unless-stopped command: - ["redis-server", "--appendonly", "yes", "--save", "60", "1", "--loglevel", "warning"] + [ + "redis-server", + "--appendonly", + "yes", + "--save", + "60", + "1", + "--loglevel", + "warning", + ] volumes: - redis_data:/data healthcheck: diff --git a/web/package-lock.json b/web/package-lock.json index 18abbcf..5773c75 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -80,6 +80,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1069,6 +1070,7 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-7.14.2.tgz", "integrity": "sha512-lq5l8T6TRkaCbs3HeaRDvUG80JbrwCxQPF5pwNP4yRrdln99hWpGtyNm0DSfcN5u1eOQRudOtAc+R8TfU+2GXw==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.26.28", "clsx": "^2.1.1", @@ -1088,6 +1090,7 @@ "resolved": "https://registry.npmjs.org/@mantine/dates/-/dates-7.14.2.tgz", "integrity": "sha512-tyEA/HuG5yXJNV0PKRfJEwER5+uQoxd9bmM3O90F5yC652H4Kxlt//yN2bf67yt/WFx7SQGid1ESFbj4od7w3A==", "license": "MIT", + "peer": true, "dependencies": { "clsx": "^2.1.1" }, @@ -1104,6 +1107,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-7.14.2.tgz", "integrity": "sha512-WN0bEJHw2Lh/2PLqik8rqITJRZAu6A1FK6f+H0SNTrQuWYBPSHdNTqM8hntDnhpM/2mZ52t3VWYgvNVGczLxIw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -1511,6 +1515,7 @@ "resolved": "https://registry.npmjs.org/@tabler/icons-react/-/icons-react-3.22.0.tgz", "integrity": "sha512-pOnn+IqZpnkYsEKRvbXXLXwXhYwg4cy1fEVr5SRrgAYJXkobpDjFTdVHlab0HEBXY5AE1NjsMlVeK6H/8Vv2uQ==", "license": "MIT", + "peer": true, "dependencies": { "@tabler/icons": "3.22.0" }, @@ -1881,6 +1886,7 @@ "integrity": "sha512-lmt73NeHdy1Q/2ul295Qy3uninSqi6wQI18XwSpm8w0ZbQXUpjCAWP1Vlv/obudoBiIjJVjlztjQ+d/Md98Yxg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.11.0", "@typescript-eslint/types": "8.11.0", @@ -2118,6 +2124,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2236,6 +2243,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -2313,6 +2321,7 @@ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -2650,6 +2659,7 @@ "integrity": "sha512-EYZK6SX6zjFHST/HRytOdA/zE72Cq/bfw45LSyuwrdvcclb/gqV8RRQxywOBEWO2+WDpva6UZa4CcDeJKzUCFA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", @@ -3518,6 +3528,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.1.1", @@ -3704,6 +3715,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3728,6 +3740,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -3964,6 +3977,7 @@ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz", "integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==", "license": "MIT", + "peer": true, "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", @@ -4282,6 +4296,7 @@ "integrity": "sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4493,6 +4508,7 @@ "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/web/src/components/builds/MercItemCard/index.tsx b/web/src/components/builds/MercItemCard/index.tsx index 8a4a2ca..4c6d8e6 100644 --- a/web/src/components/builds/MercItemCard/index.tsx +++ b/web/src/components/builds/MercItemCard/index.tsx @@ -9,10 +9,7 @@ import { getBrightBorderColor, getDarkBackgroundColor, } from "../shared/item-colors"; -import { - type ItemData, - ItemTooltip, -} from "../shared/ItemHelpers"; +import { type ItemData, ItemTooltip } from "../shared/ItemHelpers"; interface Props { data: { diff --git a/web/src/components/builds/UniqueCard/index.tsx b/web/src/components/builds/UniqueCard/index.tsx index 00751d7..fa9d1e7 100644 --- a/web/src/components/builds/UniqueCard/index.tsx +++ b/web/src/components/builds/UniqueCard/index.tsx @@ -9,10 +9,7 @@ import { getBrightBorderColor, getDarkBackgroundColor, } from "../shared/item-colors"; -import { - type ItemData, - ItemTooltip, -} from "../shared/ItemHelpers"; +import { type ItemData, ItemTooltip } from "../shared/ItemHelpers"; interface Props { data: { diff --git a/web/src/components/character/DamageCalculatorSection.tsx b/web/src/components/character/DamageCalculatorSection.tsx index ca1f6c2..c1872c9 100644 --- a/web/src/components/character/DamageCalculatorSection.tsx +++ b/web/src/components/character/DamageCalculatorSection.tsx @@ -43,7 +43,9 @@ function getWeaponSetLabel(weaponSet: DamageWeaponOption["weaponSet"]) { return weaponSet === "primary" ? "primary set" : "swap set"; } -function isBowOrCrossbowWeapon(weapon: Pick) { +function isBowOrCrossbowWeapon( + weapon: Pick +) { return /bow|crossbow/i.test( [weapon.baseName, weapon.itemName, weapon.weaponType].join(" ") ); @@ -246,7 +248,9 @@ function normalizeAuraSelectionRows( } function getDamageTypeColor(damageType: string | null | undefined) { - return DAMAGE_TYPE_COLORS[damageType?.toLowerCase() ?? ""] ?? STAT_COLORS.zeroValue; + return ( + DAMAGE_TYPE_COLORS[damageType?.toLowerCase() ?? ""] ?? STAT_COLORS.zeroValue + ); } function getAuraLevelBonus( @@ -272,8 +276,8 @@ function getAuraLevelBonus( : auraOption.level || auraOption.levelOptions[0] || 1; const bonuses = isParty - ? auraOption.partyLevelBonuses ?? auraOption.levelBonuses - : auraOption.selfLevelBonuses ?? auraOption.levelBonuses; + ? (auraOption.partyLevelBonuses ?? auraOption.levelBonuses) + : (auraOption.selfLevelBonuses ?? auraOption.levelBonuses); return ( bonuses.find((bonus) => bonus.level === numericLevel) ?? { @@ -292,10 +296,12 @@ function getAuraBonusScore( }, 0); const poisonScore = bonus.poisonDamage?.total ?? 0; - return bonus.skillLevelBonus * 1000000 + + return ( + bonus.skillLevelBonus * 1000000 + bonus.physicalBonusPercent * 1000 + elementalScore + - poisonScore; + poisonScore + ); } function getResolvedAuraLevel( @@ -466,10 +472,16 @@ function buildDamageTotalsFromComponents( const instantDamage = components .filter((component) => component.timing === "instant") - .reduce((total, component) => addRange(total, component.damage), createEmptyRange()); + .reduce( + (total, component) => addRange(total, component.damage), + createEmptyRange() + ); const overTimeDamage = components .filter((component) => component.timing === "over_time") - .reduce((total, component) => addRange(total, component.damage), createEmptyRange()); + .reduce( + (total, component) => addRange(total, component.damage), + createEmptyRange() + ); const combinedDamage = addRange(instantDamage, overTimeDamage); return { @@ -498,8 +510,7 @@ function summaryFieldsFromComponents( return { damageTotals, - totalPhysicalDamage: - damageTotals.byElement.physical || createEmptyRange(), + totalPhysicalDamage: damageTotals.byElement.physical || createEmptyRange(), totalElementalDamage, totalPoisonDamage: damageTotals.poisonDamage, totalDamage: damageTotals.combinedDamage, @@ -661,7 +672,8 @@ function normalizeDamageTotals( const overTimeDamage = cloneRange(existingTotals?.overTimeDamage) ?? derivedTotals.overTimeDamage; const byElement = - existingTotals?.byElement && Object.keys(existingTotals.byElement).length > 0 + existingTotals?.byElement && + Object.keys(existingTotals.byElement).length > 0 ? existingTotals.byElement : derivedTotals.byElement; const poisonDamage = @@ -819,7 +831,11 @@ function applyAuraToProfile( } const auraAppliesAsParty = isParty || profile.skillDamageMode === "summon"; - const selectedBonus = getAuraLevelBonus(auraOption, level, auraAppliesAsParty); + const selectedBonus = getAuraLevelBonus( + auraOption, + level, + auraAppliesAsParty + ); const numericLevel = selectedBonus.level || auraOption.level || 1; const selectedAura = buildAuraSummary( auraOption, @@ -920,7 +936,12 @@ function applyAuraToProfile( { table: "Skills.txt", row: auraOption.name, - columns: ["aurastat*", "aurastatcalc*", "passivestat*", "passivecalc*"], + columns: [ + "aurastat*", + "aurastatcalc*", + "passivestat*", + "passivecalc*", + ], }, ], notes: [], @@ -1040,7 +1061,9 @@ function applyTransformationToProfile( name: transformationOption.name, level: numericLevel, }, - notes: profile.notes.includes(note) ? profile.notes : [...profile.notes, note], + notes: profile.notes.includes(note) + ? profile.notes + : [...profile.notes, note], }; } @@ -1096,7 +1119,9 @@ function StatLine({ wrap="nowrap" style={{ padding: "0.25rem 0", - borderBottom: isLast ? "none" : "0.0625rem solid rgba(255,255,255,0.08)", + borderBottom: isLast + ? "none" + : "0.0625rem solid rgba(255,255,255,0.08)", }} > @@ -1140,7 +1165,9 @@ export function DamageCalculatorSection({ createAuraSelectionRow(), ]); const [transformationId, setTransformationId] = useState(null); - const [transformationLevel, setTransformationLevel] = useState(null); + const [transformationLevel, setTransformationLevel] = useState( + null + ); const [notesExpanded, setNotesExpanded] = useState(false); useEffect(() => { @@ -1191,12 +1218,12 @@ export function DamageCalculatorSection({ setTransformationLevel( parsedTransformation.id === "none" ? "0" - : parsedTransformation.level ?? + : (parsedTransformation.level ?? String( transformationOption?.level ?? transformationOption?.levelOptions[0] ?? 1 - ) + )) ); }, [damageCalculation]); @@ -1269,7 +1296,10 @@ export function DamageCalculatorSection({ return; } - if (!weaponId || !availableWeaponOptions.some((weapon) => weapon.id === weaponId)) { + if ( + !weaponId || + !availableWeaponOptions.some((weapon) => weapon.id === weaponId) + ) { setWeaponId(availableWeaponOptions[0].id); } }, [availableWeaponOptions, weaponId]); @@ -1290,8 +1320,10 @@ export function DamageCalculatorSection({ availableWeaponOptions.find((weapon) => weapon.id === weaponId) ?? null, [availableWeaponOptions, weaponId] ); - const selectedSequenceWeaponOption = - selectedWeaponOption?.sequenceHits?.length ? selectedWeaponOption : null; + const selectedSequenceWeaponOption = selectedWeaponOption?.sequenceHits + ?.length + ? selectedWeaponOption + : null; const nonSequenceWeaponOptions = useMemo( () => availableWeaponOptions.filter( @@ -1301,8 +1333,8 @@ export function DamageCalculatorSection({ ); const primaryWeaponValue = hasSequenceWeaponControls && selectedSequenceWeaponOption - ? selectedSequenceWeaponOption.sequenceHits?.[0]?.weaponId ?? - selectedSequenceWeaponOption.id + ? (selectedSequenceWeaponOption.sequenceHits?.[0]?.weaponId ?? + selectedSequenceWeaponOption.id) : weaponId; const pairableSequenceWeaponOptions = useMemo(() => { if (!primaryWeaponValue) { @@ -1323,7 +1355,8 @@ export function DamageCalculatorSection({ ]); const secondaryWeaponValue = selectedSequenceWeaponOption?.id ?? "none"; const secondaryWeaponDisabled = - !requiresSequenceWeaponControls && pairableSequenceWeaponOptions.length === 0; + !requiresSequenceWeaponControls && + pairableSequenceWeaponOptions.length === 0; const selectedSkillAllowedTransformationIds = useMemo( () => selectedSkillOption?.allowedTransformationIds ?? [], @@ -1365,7 +1398,11 @@ export function DamageCalculatorSection({ ]); const compactRequiredTransformationOption = useMemo(() => { - if (!isCompact || !damageCalculation || !selectedSkillRequiresTransformation) { + if ( + !isCompact || + !damageCalculation || + !selectedSkillRequiresTransformation + ) { return null; } @@ -1624,7 +1661,10 @@ export function DamageCalculatorSection({ auraAppliesAsParty ); - return selectedBonus.skillLevelBonus > 0 || Boolean(selectedBonus.poisonDamage); + return ( + selectedBonus.skillLevelBonus > 0 || + Boolean(selectedBonus.poisonDamage) + ); } ); const precomputedAuraProfileRow = @@ -1803,7 +1843,9 @@ export function DamageCalculatorSection({
@@ -1888,7 +1930,8 @@ export function DamageCalculatorSection({ Selected Auras - Selecting an aura adds another row; choose No aura to remove one. + Selecting an aura adds another row; choose No aura to remove + one. @@ -1962,7 +2005,9 @@ export function DamageCalculatorSection({ {getCombinedDamageLabel(selectedProfile)} @@ -2041,7 +2086,8 @@ export function DamageCalculatorSection({ {formatRange(selectedProfile.totalPhysicalDamage)} - +{selectedProfile.breakdown.physicalBonusPercent.total}% total bonus + +{selectedProfile.breakdown.physicalBonusPercent.total}% + total bonus @@ -2099,12 +2145,15 @@ export function DamageCalculatorSection({ ), }} > - {formatRange(selectedProfile.damageTotals.overTimeDamage)} + {formatRange( + selectedProfile.damageTotals.overTimeDamage + )} {selectedProfile.totalPoisonDamage ? ( poison total{" "} - {selectedProfile.totalPoisonDamage.total.toLocaleString()} over{" "} + {selectedProfile.totalPoisonDamage.total.toLocaleString()}{" "} + over{" "} {selectedProfile.totalPoisonDamage.durationSeconds}s ) : null} @@ -2229,7 +2278,9 @@ export function DamageCalculatorSection({ ? "Summon base" : "Weapon damage" } - value={formatRange(selectedProfile.breakdown.weaponDamage)} + value={formatRange( + selectedProfile.breakdown.weaponDamage + )} color={getRangeColor( selectedProfile.breakdown.weaponDamage, STAT_COLORS.physicalDamageReduction @@ -2237,7 +2288,9 @@ export function DamageCalculatorSection({ /> @@ -2271,7 +2325,8 @@ export function DamageCalculatorSection({ label="Selected skill" value={`${selectedProfile.breakdown.physicalBonusPercent.selectedSkill}%`} color={getPercentColor( - selectedProfile.breakdown.physicalBonusPercent.selectedSkill, + selectedProfile.breakdown.physicalBonusPercent + .selectedSkill, STAT_COLORS.physicalDamageReduction )} /> @@ -2297,7 +2352,8 @@ export function DamageCalculatorSection({ label="Auras" value={`${selectedProfile.breakdown.physicalBonusPercent.activeAuras}%`} color={getPercentColor( - selectedProfile.breakdown.physicalBonusPercent.activeAuras, + selectedProfile.breakdown.physicalBonusPercent + .activeAuras, STAT_COLORS.physicalDamageReduction )} isLast @@ -2310,68 +2366,70 @@ export function DamageCalculatorSection({ (selectedProfile.notes.length > 0 || Boolean(selectedProfile.damageScope?.note) || damageCalculation.notes.length > 0) && ( - <> - - - - - Damage model notes - - - - - - - This calculator is intended to be a close model, not a - perfect guarantee. If you notice a significant - difference from the damage you expect,{" "} - - make a #bug-report - - . + <> + + + + + Damage model notes - {selectedProfile.damageScope?.note ? ( + + + + - {selectedProfile.damageScope.note} - - ) : null} - {selectedProfile.notes.map((note) => ( - - {note} - - ))} - {damageCalculation.notes.map((note) => ( - - {note} + This calculator is intended to be a close model, not + a perfect guarantee. If you notice a significant + difference from the damage you expect,{" "} + + make a #bug-report + + . - ))} - - - - - )} + {selectedProfile.damageScope?.note ? ( + + {selectedProfile.damageScope.note} + + ) : null} + {selectedProfile.notes.map((note) => ( + + {note} + + ))} + {damageCalculation.notes.map((note) => ( + + {note} + + ))} + + + + + )} ) : ( diff --git a/web/src/components/character/StatsSection.tsx b/web/src/components/character/StatsSection.tsx index cc542d8..5816a68 100644 --- a/web/src/components/character/StatsSection.tsx +++ b/web/src/components/character/StatsSection.tsx @@ -116,7 +116,7 @@ export function StatsSection({ firePierce: 0, coldPierce: 0, lightningPierce: 0, - poisonPierce: 0 + poisonPierce: 0, }; return ( @@ -162,7 +162,12 @@ export function StatsSection({ - + {/* Attributes */} @@ -256,48 +261,142 @@ export function StatsSection({ {/* Speed */} - - - - + + + + {/* Damage Procs */} - - - - + + + + {/* Leech */} - - - - + + + + {/* Elemental Skill Damage */} - - - - + + + + {/* Elemental Pierce */} - - - - + + + + {/* Rewards */} - - + +
diff --git a/web/src/components/economy/disclaimer.tsx b/web/src/components/economy/disclaimer.tsx index 5266c15..8250e7b 100644 --- a/web/src/components/economy/disclaimer.tsx +++ b/web/src/components/economy/disclaimer.tsx @@ -53,8 +53,8 @@ export default function EconomyDisclaimer() {
Prices may be inaccurate, especially for items with a low amount of - listings. Use your own discretion when determining item values. - Only available for softcore. + listings. Use your own discretion when determining item values. Only + available for softcore. diff --git a/web/src/components/layout/Footer/index.tsx b/web/src/components/layout/Footer/index.tsx index 006981a..ea1d2fc 100644 --- a/web/src/components/layout/Footer/index.tsx +++ b/web/src/components/layout/Footer/index.tsx @@ -145,7 +145,8 @@ export function Footer() {
- pd2.tools is not affiliated with or endorsed by the Project Diablo 2 team. + pd2.tools is not affiliated with or endorsed by the Project Diablo 2 + team.
diff --git a/web/src/pages/About.tsx b/web/src/pages/About.tsx index 8d11bc1..f00e2e3 100644 --- a/web/src/pages/About.tsx +++ b/web/src/pages/About.tsx @@ -150,8 +150,8 @@ export default function AboutPage() { fw={600} > Project Diablo 2 team - - {" "}for making the data that powers the site accessible. + {" "} + for making the data that powers the site accessible. Need to contact us? The best way is via our{" "} diff --git a/web/src/pages/CharacterExport.tsx b/web/src/pages/CharacterExport.tsx index 728b485..ff0137d 100644 --- a/web/src/pages/CharacterExport.tsx +++ b/web/src/pages/CharacterExport.tsx @@ -183,7 +183,12 @@ export default function CharacterExport() { }} >
- + } @@ -278,13 +283,7 @@ export default function CharacterExport() { padding: rem(24), }} > - + Character Exporter Archived @@ -294,8 +293,8 @@ export default function CharacterExport() { exporter. - Interested in the original exporter code? The archived - pd2.tools exporter source is{" "} + Interested in the original exporter code? The archived pd2.tools + exporter source is{" "} 0, staleTime: 0, retry: false, diff --git a/web/src/pages/Home.module.css b/web/src/pages/Home.module.css index de40452..e0dedb5 100644 --- a/web/src/pages/Home.module.css +++ b/web/src/pages/Home.module.css @@ -559,7 +559,9 @@ padding: 1.1rem; border-radius: 1rem; background: rgba(37, 38, 43, 0.82); - transition: transform 160ms ease, border-color 160ms ease; + transition: + transform 160ms ease, + border-color 160ms ease; } .featuredCard:hover { @@ -751,7 +753,6 @@ .sectionCopy { text-align: left; } - } @media (max-width: 48rem) { diff --git a/web/src/pages/Home.tsx b/web/src/pages/Home.tsx index e43acb1..23b4790 100644 --- a/web/src/pages/Home.tsx +++ b/web/src/pages/Home.tsx @@ -31,10 +31,7 @@ import { } from "../components/builds/shared/ItemHelpers"; import { API_ENDPOINTS } from "../config/api"; import { ECONOMY_ITEMS_DATA } from "../data/economy-items"; -import { - DEFAULT_VIEW_SEASON, - type HomeStats, -} from "../types"; +import { DEFAULT_VIEW_SEASON, type HomeStats } from "../types"; import classes from "./Home.module.css"; const CACHE_KEY = "pd2tools_home_stats"; @@ -119,17 +116,16 @@ interface HomeSliderResponse { const ECONOMY_ITEM_DETAILS_BY_INTERNAL_NAME = Object.entries( ECONOMY_ITEMS_DATA -).reduce>( - (acc, [slug, item]) => { - acc[item.itemNameInternal] = { - displayName: item.displayName, - iconUrl: item.iconUrl, - href: `/economy/item/${slug}`, - }; - return acc; - }, - {} -); +).reduce< + Record +>((acc, [slug, item]) => { + acc[item.itemNameInternal] = { + displayName: item.displayName, + iconUrl: item.iconUrl, + href: `/economy/item/${slug}`, + }; + return acc; +}, {}); function getEconomyIconPath(iconUrl?: string) { if (!iconUrl) { @@ -522,56 +518,60 @@ export default function Home() { >
{homeSliderQuery.isPending - ? Array.from({ length: RECENT_CHARACTER_LIMIT }).map( - (_, index) => ( - - ) - ) + ? Array.from({ + length: RECENT_CHARACTER_LIMIT, + }).map((_, index) => ( + + )) : null} {!homeSliderQuery.isPending && - homeSliderQuery.data?.recentCharacters.length ? ( - homeSliderQuery.data.recentCharacters.map( - (character) => ( - - + homeSliderQuery.data?.recentCharacters.length + ? homeSliderQuery.data.recentCharacters.map( + (character) => ( + + + +
+ + {character.name} + + + Level {character.level}{" "} + {character.className} · {character.mode} + +
-
- - {character.name} - - - Level {character.level}{" "} - {character.className} · {character.mode} + + {formatRelativeAge( + character.lastUpdated, + relativeTimeNow + )} -
- - - {formatRelativeAge( - character.lastUpdated, - relativeTimeNow - )} - -
+
+ ) ) - ) - ) : null} + : null} {!homeSliderQuery.isPending && !homeSliderQuery.data?.recentCharacters.length ? ( @@ -609,38 +609,40 @@ export default function Home() { : null} {!homeSliderQuery.isPending && - homeSliderQuery.data?.marketSnapshot.length ? ( - homeSliderQuery.data.marketSnapshot.map((item) => ( - - -
- - {item.itemName} - - - {item.listings >= 100 - ? "100+ listings" - : `${item.listings} listings`} - -
- - {formatPrice(item.price)} - -
- )) - ) : null} + homeSliderQuery.data?.marketSnapshot.length + ? homeSliderQuery.data.marketSnapshot.map( + (item) => ( + + +
+ + {item.itemName} + + + {item.listings >= 100 + ? "100+ listings" + : `${item.listings} listings`} + +
+ + {formatPrice(item.price)} + +
+ ) + ) + : null}
) : null} @@ -665,44 +667,46 @@ export default function Home() { : null} {!homeSliderQuery.isPending && - homeSliderQuery.data?.softcoreClasses.length ? ( - homeSliderQuery.data.softcoreClasses.map( - (item) => ( -
- -
-
- - {item.className} - - - {Math.round(item.share * 100)}% - -
-
-
+ homeSliderQuery.data?.softcoreClasses.length + ? homeSliderQuery.data.softcoreClasses.map( + (item) => ( +
+ +
+
+ + {item.className} + + + {Math.round(item.share * 100)}% + +
+
+
+
-
+ ) ) - ) - ) : null} + : null}
) : null} @@ -727,68 +731,72 @@ export default function Home() { : null} {!homeSliderQuery.isPending && - homeSliderQuery.data?.topItems.length ? ( - homeSliderQuery.data.topItems.map((item) => { - const itemData = item.itemData; - const imageUrl = itemData?.imageUrl; - const borderColor = getBrightBorderColor( - item.itemType - ); - const backgroundColor = getDarkBackgroundColor( - item.itemType - ); - - return ( - - { + const itemData = item.itemData; + const imageUrl = itemData?.imageUrl; + const borderColor = getBrightBorderColor( + item.itemType + ); + const backgroundColor = getDarkBackgroundColor( + item.itemType + ); + + return ( + -
- {imageUrl ? ( - - ) : null} -
-
- - {item.itemName} - - - {item.itemType} ·{" "} - {item.numOccurrences.toLocaleString()}{" "} - equips +
+ {imageUrl ? ( + + ) : null} +
+
+ + {item.itemName} + + + {item.itemType} ·{" "} + {item.numOccurrences.toLocaleString()}{" "} + equips + +
+ + {item.pct.toFixed(1)}% -
- - {item.pct.toFixed(1)}% - -
-
- ); - }) - ) : null} + + + ); + }) + : null}
) : null} @@ -813,38 +821,42 @@ export default function Home() { : null} {!homeSliderQuery.isPending && - homeSliderQuery.data?.leaderboard.length ? ( - homeSliderQuery.data.leaderboard.map((entry, index) => ( -
- - {index + 1} - - - - {entry.account_name} - - - - {entry.count}x - ( +
- 99 - - -
- )) - ) : null} + + {index + 1} + + + + {entry.account_name} + + + + {entry.count}x + + 99 + + +
+ ) + ) + : null} ) : null} @@ -903,7 +915,6 @@ export default function Home() { })} - diff --git a/web/src/pages/Leaderboard.tsx b/web/src/pages/Leaderboard.tsx index ba660af..dc2f82c 100644 --- a/web/src/pages/Leaderboard.tsx +++ b/web/src/pages/Leaderboard.tsx @@ -20,10 +20,7 @@ import { useState, useEffect } from "react"; import { Helmet } from "react-helmet"; import { useSearchParams } from "react-router-dom"; import { leaderboardAPI } from "../api"; -import type { - AccountLevel99Entry, - MirroredItemEntry, -} from "../api/leaderboard"; +import type { AccountLevel99Entry } from "../api/leaderboard"; import { DEFAULT_VIEW_SEASON, SHORT_SEASON_OPTIONS } from "../types"; const getRarityColor = (item: any): string | null => { @@ -459,88 +456,6 @@ function Level99LeaderboardTable({ gameMode, season }) { ); } -function MirroredItemLeaderboardTable({ gameMode, season }) { - return ( - leaderboardAPI.getMirroredLeaderboard(gameMode, season)} - emptyMessage="No mirrored items found" - renderPodium={(entry: MirroredItemEntry, rank) => ( - - - {entry.count} - - - Copies Found - - - Example:{" "} - - {entry.example_character_name} - - - - } - /> - )} - renderRow={(entry: MirroredItemEntry, index) => ( - - - - #{index + 4} - - - - } - multiline - position="right" - styles={{ - tooltip: { - backgroundColor: "rgba(0, 0, 0, 0.95)", - border: `1px solid ${getRarityBorderColor(entry.example_item_json)}`, - padding: 0, - maxWidth: "400px", - }, - }} - > -
- - {entry.item_name} - - - {entry.item_base_name} - -
-
-
- - - {entry.count} copies - - -
- )} - /> - ); -} - export default function LeaderboardPage() { const [searchParams, setSearchParams] = useSearchParams(); const theme = useMantineTheme(); @@ -551,36 +466,19 @@ export default function LeaderboardPage() { const [season, setSeason] = useState( parseInt(searchParams.get("season") || DEFAULT_VIEW_SEASON.toString(), 10) ); - const [activeLeaderboardTab, setActiveLeaderboardTab] = useState< - string | null - >(searchParams.get("tab") || "mirrored"); useEffect(() => { const params = new URLSearchParams(); if (gameMode !== "softcore") params.set("mode", gameMode); if (season !== DEFAULT_VIEW_SEASON) params.set("season", season.toString()); - if (activeLeaderboardTab !== "mirrored") - params.set("tab", activeLeaderboardTab || "mirrored"); setSearchParams(params, { replace: true }); - }, [gameMode, season, activeLeaderboardTab, setSearchParams]); + }, [gameMode, season, setSearchParams]); - // Pre-fetch all leaderboard data to track loading state const { isPending: level99Loading } = useQuery({ queryKey: ["level99Leaderboard", gameMode, season], queryFn: () => leaderboardAPI.getLevel99Leaderboard(gameMode, season), - enabled: activeLeaderboardTab === "level99", }); - const { isPending: mirroredLoading } = useQuery({ - queryKey: ["mirroredLeaderboard", gameMode, season], - queryFn: () => leaderboardAPI.getMirroredLeaderboard(gameMode, season), - enabled: activeLeaderboardTab === "mirrored", - }); - - const isAnyLoading = - (activeLeaderboardTab === "level99" && level99Loading) || - (activeLeaderboardTab === "mirrored" && mirroredLoading); - const cardWidthStyles = { width: "95%", maxWidth: "900px", @@ -595,7 +493,7 @@ export default function LeaderboardPage() { Leaderboard - pd2.tools @@ -641,25 +539,14 @@ export default function LeaderboardPage() { - {isAnyLoading ? ( + {level99Loading ? ( ) : ( - + - Most Mirrored Items Most Level 99 Accounts - - - - diff --git a/web/src/pages/Statistics.tsx b/web/src/pages/Statistics.tsx index 7988696..e990d7a 100644 --- a/web/src/pages/Statistics.tsx +++ b/web/src/pages/Statistics.tsx @@ -636,12 +636,10 @@ export default function StatisticsPage() { count, pct: total ? (count / total) * 100 : 0, accent: modeColor, - href: getBuildsHref( - gameMode, - metaSeason, - metaLevelRange, - { type: "class", value: className } - ), + href: getBuildsHref(gameMode, metaSeason, metaLevelRange, { + type: "class", + value: className, + }), }; }).sort((a, b) => b.count - a.count); }, [gameMode, metaLevelRange, metaSeason, metaSummaryQuery.data, modeColor]); @@ -995,7 +993,11 @@ export default function StatisticsPage() { "Characters", ]} /> - + @@ -1009,7 +1011,8 @@ export default function StatisticsPage() { Character Gamemode Distribution - Overall tracked characters split between softcore and hardcore. + Overall tracked characters split between softcore and + hardcore. @@ -1031,7 +1034,14 @@ export default function StatisticsPage() { innerRadius={70} outerRadius={100} labelLine={{ stroke: theme.colors.gray[5] }} - label={({ name, percent, x, y, textAnchor, fill }) => ( + label={({ + name, + percent, + x, + y, + textAnchor, + fill, + }) => ( @@ -1216,10 +1224,15 @@ export default function StatisticsPage() { {topSkills.map((skill, index) => ( @@ -1250,9 +1263,13 @@ export default function StatisticsPage() { {topItems.map((item, index) => { - const itemData = itemCatalogQuery.data?.get(item.item); + const itemData = itemCatalogQuery.data?.get( + item.item + ); const imageUrl = itemData?.imageUrl; - const borderColor = getBrightBorderColor(item.itemType); + const borderColor = getBrightBorderColor( + item.itemType + ); const backgroundColor = getDarkBackgroundColor( item.itemType ); @@ -1265,10 +1282,15 @@ export default function StatisticsPage() { itemName={item.item} > @@ -1330,10 +1352,15 @@ export default function StatisticsPage() { {topMercTypes.map((mercType, index) => ( @@ -1365,9 +1392,13 @@ export default function StatisticsPage() { {topMercItems.map((item, index) => { - const itemData = itemCatalogQuery.data?.get(item.item); + const itemData = itemCatalogQuery.data?.get( + item.item + ); const imageUrl = itemData?.imageUrl; - const borderColor = getBrightBorderColor(item.itemType); + const borderColor = getBrightBorderColor( + item.itemType + ); const backgroundColor = getDarkBackgroundColor( item.itemType ); @@ -1380,10 +1411,15 @@ export default function StatisticsPage() { itemName={item.item} > diff --git a/web/src/types/damage.ts b/web/src/types/damage.ts index 6c9b33d..716b3c1 100644 --- a/web/src/types/damage.ts +++ b/web/src/types/damage.ts @@ -111,7 +111,9 @@ export interface DamageAuraLevelBonus { level: number; skillLevelBonus: number; physicalBonusPercent: number; - elementalDamage: Partial, DamageRange>>; + elementalDamage: Partial< + Record, DamageRange> + >; poisonDamage?: PoisonDamagePayload; } @@ -160,7 +162,9 @@ export interface DamageProfileBreakdown { activeAuras: number; total: number; }; - elementalDamage: Partial, DamageRange>>; + elementalDamage: Partial< + Record, DamageRange> + >; poisonDamage?: PoisonDamage; }