Compare commits

...
101 Commits
Author SHA1 Message Date
TrèsAbhi 4062e56b4c feat: migrated to new db 2024-04-03 15:19:48 -05:00
TrèsAbhi 0885e7e68a feat: logs 2024-04-02 10:37:39 -05:00
TrèsAbhi 3cfa837682 Merge branch 'main' of https://github.com/tresabhi/blitzkrieg into dev 2024-04-02 10:26:31 -05:00
TrèsAbhi 1982604878 fix: some more floating component removals 2024-04-02 10:21:51 -05:00
TrèsAbhi 9ef6eea4a9 fix: remove useless tooltips 2024-04-02 10:10:15 -05:00
TrèsAbhi 78e6b2696c fix: word tolerant pitch and yaw 2024-04-02 10:04:54 -05:00
TrèsAbhi 40b6f30092 fix: synced armor states 2 2024-04-02 10:02:21 -05:00
TrèsAbhi de2d9a1a3e fix: synced armor states 2024-04-02 09:52:46 -05:00
TrèsAbhi b7ad12c698 Create ads.txt 2024-04-02 08:24:37 -05:00
TrèsAbhi 886c2aa1d1 fix: access document on client 2 2024-04-01 22:02:15 -05:00
TrèsAbhi 9818c9868a fix: access document on client 2024-04-01 22:00:38 -05:00
TrèsAbhi 31055f1d66 fix: use link for big tank search 2024-04-01 21:57:54 -05:00
TrèsAbhi 6cef753053 feat: donation icons 2024-04-01 21:46:17 -05:00
TrèsAbhi 5b889f8483 feat: more accessible full screen mode 2024-04-01 21:33:47 -05:00
TrèsAbhi d0d905ccfb feat: better antagonist bar 2024-04-01 19:30:06 -05:00
TrèsAbhi 9a91a8bb13 fix: excessive antagonist bar gap 2024-04-01 19:17:59 -05:00
TrèsAbhi c12cc9b8c5 feat: length sorting 2024-04-01 19:12:59 -05:00
TrèsAbhi fdbfd0bc10 feat: rename size to volume 2024-04-01 19:10:42 -05:00
TrèsAbhi 286b178218 feat: some cleanup 2024-04-01 19:09:21 -05:00
TrèsAbhi 54e012f736 feat: dropdown modules 2024-04-01 19:05:55 -05:00
TrèsAbhi cc6de50de6 feat: upgrade to radix 3.0 2024-04-01 18:36:14 -05:00
TrèsAbhi 194e5fceca feat: crew training info 2024-04-01 09:31:02 -05:00
TrèsAbhi 879a5d534c feat: consumable info 2024-04-01 09:27:54 -05:00
TrèsAbhi 29aaf60da0 feat: cleaner info 2024-04-01 09:24:12 -05:00
TrèsAbhi 3c8f8b49e4 feat: condensed 2 2024-03-31 22:32:43 -05:00
TrèsAbhi 0b88f71f26 feat: condensed 2024-03-31 22:27:11 -05:00
TrèsAbhi 65e239a22b feat: hide module actions on no upgrades 2024-03-31 22:16:29 -05:00
TrèsAbhi 7727e64931 feat: quick actions 2024-03-31 22:14:14 -05:00
TrèsAbhi 7b0465322d feat: special skills 2024-03-31 19:27:37 -05:00
TrèsAbhi 099e5eb228 feat: skill stats 2024-03-31 19:17:30 -05:00
TrèsAbhi eaff26c8d9 feat: crew skills ui 2024-03-31 18:49:17 -05:00
TrèsAbhi d79129df91 feat: skill definitions 2024-03-31 18:24:19 -05:00
TrèsAbhi b363c76639 feat: crew skill icons 2024-03-31 18:17:48 -05:00
TrèsAbhi 034429cd47 Merge branch 'main' of https://github.com/tresabhi/blitzkrieg 2024-03-31 17:48:30 -05:00
TrèsAbhi 57867317cc fix: missing info 2024-03-31 17:48:15 -05:00
TrèsAbhi c2e314b193 feat: log 2024-03-31 17:48:01 -05:00
TrèsAbhi 9d2af1de13 fix: quick equipments 2024-03-31 17:45:22 -05:00
TrèsAbhi 232823566b fix: missing info 2024-03-30 12:48:17 -05:00
TrèsAbhi 958f0f2a52 fix: log version 2024-03-30 12:44:46 -05:00
TrèsAbhi b34fef2147 feat: logs 2024-03-30 12:44:34 -05:00
TrèsAbhi fe661124fa Merge branch 'dev' of https://github.com/tresabhi/blitzkrieg into dev 2024-03-30 12:39:19 -05:00
TrèsAbhi 457dcbcad5 fix: 0 battle handling 2024-03-30 12:38:53 -05:00
TrèsAbhi c18ee70eba fix: /rating use midnight leaderboard 2024-03-30 12:38:53 -05:00
TrèsAbhi 095ffc3d6a fix: sync on click inspector 2024-03-30 12:38:53 -05:00
TrèsAbhi e8b91648b1 fix: click through post inputs 2024-03-30 12:38:53 -05:00
TrèsAbhi 1b43f6ba99 fix: use final thickness 2024-03-30 12:38:53 -05:00
TrèsAbhi fc5360363c fix: uniform inv proj mat 2024-03-30 12:38:53 -05:00
TrèsAbhi 47b3c3029e fix: more accurate HE 2024-03-30 12:38:53 -05:00
TrèsAbhi dea7f34ca3 fix: use normal proj mat 2024-03-30 12:38:53 -05:00
TrèsAbhi 1e5026ea4a fix: persistent equipment 2024-03-30 12:38:53 -05:00
TrèsAbhi 98f568ff6a fix: use three js inverse view proj 2024-03-30 12:38:53 -05:00
TrèsAbhi 10a6f8371d fix: 0 battle handling 2024-03-30 12:33:17 -05:00
TrèsAbhi 0933aa6615 fix: /rating use midnight leaderboard 2024-03-30 12:32:48 -05:00
TrèsAbhi 746592418d Update cd-rating-latest.yml 2024-03-30 12:22:20 -05:00
TrèsAbhi e2404b063c fix: sync on click inspector 2024-03-30 12:14:49 -05:00
TrèsAbhi bae1301556 fix: click through post inputs 2024-03-30 12:13:47 -05:00
TrèsAbhi c2de4952ad fix: use final thickness 2024-03-30 11:34:39 -05:00
TrèsAbhi cce61d8075 fix: uniform inv proj mat 2024-03-30 11:31:44 -05:00
TrèsAbhi 34a0388f45 fix: more accurate HE 2024-03-30 11:28:34 -05:00
TrèsAbhi 9d62965fc3 fix: use normal proj mat 2024-03-30 10:39:58 -05:00
TrèsAbhi 892434e28f fix: persistent equipment 2024-03-30 10:30:30 -05:00
TrèsAbhi 2597fe4bcd fix: use three js inverse view proj 2024-03-30 10:23:23 -05:00
TrèsAbhi f85b4b36bd fix: async exec 2024-03-29 16:45:17 -05:00
TrèsAbhi f777dafefc feat: generated list 2024-03-29 16:42:56 -05:00
TrèsAbhi 6ef65ccea3 fix: don't install fixed sharp twice 2024-03-29 16:40:07 -05:00
TrèsAbhi 974b27a313 fix: production flag 2024-03-29 16:38:51 -05:00
TrèsAbhi 5124d8abcd Merge branch 'dev' of https://github.com/tresabhi/blitzkrieg into dev 2024-03-29 16:33:15 -05:00
TrèsAbhi 849bd356c0 feat: logs 2024-03-29 16:33:13 -05:00
TrèsAbhi 6406486441 New Crowdin updates (#104) 2024-03-28 10:33:46 -05:00
TrèsAbhi 2993ada2e5 fix: always absolute deltas 2024-03-28 10:33:05 -05:00
TrèsAbhi f083c18dd0 feat: more optimal fetching 2024-03-28 10:31:36 -05:00
TrèsAbhi c7021c5689 feat: finalized 2024-03-28 10:27:33 -05:00
TrèsAbhi 815369805a fix: null rating type safety 2024-03-25 21:11:04 -05:00
TrèsAbhi d8feb5c3a8 fix: null rating type safety 2024-03-25 19:22:21 -05:00
TrèsAbhi cb101da8a8 fix: types 2024-03-25 18:43:23 -05:00
TrèsAbhi 71481f5971 feat: renaming 2024-03-25 18:22:37 -05:00
TrèsAbhi 817796ed3c feat: full command 2024-03-25 18:12:33 -05:00
TrèsAbhi ebb7618f2d feat: hero stat 2024-03-25 18:00:07 -05:00
TrèsAbhi 9717f153cd New Crowdin updates (#103) 2024-03-25 10:20:01 -05:00
TrèsAbhi afa0cfaeef feat: start on final design 2024-03-24 22:06:48 -05:00
TrèsAbhi 298b89b227 feat: new rating stat style 2024-03-21 22:32:01 -05:00
TrèsAbhi 3b94034767 feat: change ratings path to rating 2024-03-21 16:44:47 -05:00
TrèsAbhi 444b4c21af feat: sitemao 2024-03-21 16:29:59 -05:00
TrèsAbhi 3e9c828781 New Crowdin updates (#102) 2024-03-21 14:19:41 -05:00
TrèsAbhi ca3a1fbe3f feat: ratings stats 2024-03-20 20:40:01 -05:00
TrèsAbhi 5f28b541e3 feat: started work on rating stats 2024-03-20 18:03:00 -05:00
TrèsAbhi 18edd349f2 fix: only exclude root test files 2024-03-20 17:37:56 -05:00
TrèsAbhi f7c6a065c1 feat: renamed /ratings to /ratings-leaderboard 2024-03-20 17:33:21 -05:00
TrèsAbhi 91d48eeadd feat: remove reward section 2024-03-20 17:04:59 -05:00
TrèsAbhi 21bafa1b7b feat: added more stats 2024-03-20 16:58:43 -05:00
TrèsAbhi 38af2f2817 feat: extended ratings stats 2024-03-19 18:21:56 -05:00
TrèsAbhi 8faa1143e9 fix: only build assets on push to main 2024-03-18 18:13:34 -05:00
TrèsAbhi 8f34ebbca4 feat: grouped ratings workflows 2024-03-18 18:12:55 -05:00
TrèsAbhi 1c03701e17 feat: ratings leaderboard disk format 2024-03-18 18:06:45 -05:00
TrèsAbhi c8edd16eb3 feat: logs 2024-03-18 16:47:53 -05:00
TrèsAbhi b96ff2e239 New Crowdin updates (#101) 2024-03-18 16:36:55 -05:00
TrèsAbhi a575da3005 fix: move script to head 2024-03-18 10:09:46 -05:00
TrèsAbhi 3ca2dece3e feat: google analytics 2024-03-18 10:02:09 -05:00
TrèsAbhi 191d6133b2 feat: togglable spaced armor 2024-03-17 22:11:56 -05:00
TrèsAbhi b15e73e3a8 Update build.ts 2024-03-17 21:53:09 -05:00
TrèsAbhi 9a6b8ab979 Update cd-host.yml 2024-03-17 21:51:48 -05:00
140 changed files with 3794 additions and 2116 deletions

No files matched your search

+3 -2
View File
@@ -2,6 +2,7 @@ name: CD Build Assets
on: on:
push: push:
branches: main
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: '0 2 * * *' - cron: '0 2 * * *'
@@ -52,7 +53,7 @@ jobs:
run: | run: |
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}" $env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}" $env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DISCORD_BLITZ_DB = "${{ secrets.DISCORD_BLITZ_DB }}" $env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
$env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}" $env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}"
yarn setup:patches --depot yarn setup:patches --depot
@@ -60,5 +61,5 @@ jobs:
run: | run: |
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}" $env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}" $env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DISCORD_BLITZ_DB = "${{ secrets.DISCORD_BLITZ_DB }}" $env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }} yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }}
+1 -1
View File
@@ -33,4 +33,4 @@ jobs:
unzip bot.zip unzip bot.zip
- name: Run bot - name: Run bot
run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} node --enable-source-maps index.cjs run: NODE_ENV=production GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} node --enable-source-maps index.cjs
@@ -1,11 +1,7 @@
name: CD Ratings Asia name: CD Rating Asia
# latest: '0 0 * * *'
# asia midnight: '0 17 * * *'
on: on:
schedule: schedule:
- cron: '0 0 * * *'
- cron: '0 17 * * *' - cron: '0 17 * * *'
jobs: jobs:
@@ -35,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingsleaderboard --region=asia --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=asia --target=midnight --production
@@ -1,11 +1,7 @@
name: CD Ratings Europe name: CD Rating Europe
# latest: '0 0 * * *'
# eu midnight: '0 23 * * *'
on: on:
schedule: schedule:
- cron: '0 0 * * *'
- cron: '0 23 * * *' - cron: '0 23 * * *'
jobs: jobs:
@@ -35,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingsleaderboard --region=eu --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=eu --target=midnight --production
+34
View File
@@ -0,0 +1,34 @@
name: CD Rating Latest
on:
schedule:
- cron: '0 0 * * *'
jobs:
host:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
- name: Install Yarn
run: npm install --location=global yarn
- name: Use node_modules cache
uses: actions/cache@v3
with:
path: '**/node_modules'
key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn
- name: Generate all leaderboards
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=na,asia,eu --target=latest --production
@@ -1,11 +1,7 @@
name: CD Ratings North America name: CD Rating North America
# latest: '0 0 * * *'
# na midnight: '0 5 * * *'
on: on:
schedule: schedule:
- cron: '0 0 * * *'
- cron: '0 5 * * *' - cron: '0 5 * * *'
jobs: jobs:
@@ -35,4 +31,4 @@ jobs:
run: yarn run: yarn
- name: Generate leaderboard - name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DISCORD_BLITZ_DB=${{ secrets.DISCORD_BLITZ_DB }} yarn publish:ratingsleaderboard --region=na --target=${{ github.event.schedule == '0 0 * * *' && 'latest' || 'midnight' }} run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} DATABASE_URL=${{ secrets.DATABASE_URL }} yarn publish:ratingleaderboard --region=na --target=midnight --production
+1 -4
View File
@@ -33,11 +33,8 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: yarn run: yarn
- name: Add fixed sharp
run: yarn add [email protected] --ignore-engines
- name: Build - name: Build
run: yarn build:bot run: yarn build:bot --production
- name: Archive build - name: Archive build
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
+3 -2
View File
@@ -8,7 +8,6 @@ dist/
dist.* dist.*
yarn-error.log yarn-error.log
/test.* /test.*
test.*
/docs/.vitepress/dist /docs/.vitepress/dist
/docs/.vitepress/cache /docs/.vitepress/cache
servers.csv servers.csv
@@ -18,4 +17,6 @@ temp/
/.yarn /.yarn
/.VSCodeCounter /.VSCodeCounter
/.yarn /.yarn
depots depots
public/sitemap*.xml
public/robots.txt
+4 -2
View File
@@ -16,6 +16,7 @@
"betakrieg", "betakrieg",
"BINORMAL", "BINORMAL",
"Binormals", "Binormals",
"BKRL",
"bkwd", "bkwd",
"blitzstars", "blitzstars",
"Camo", "Camo",
@@ -99,8 +100,7 @@
"POSITIVEZ", "POSITIVEZ",
"Promisable", "Promisable",
"Pyogenics", "Pyogenics",
"ratingsleaderboard", "ratingleaderboard",
"ratingsplot",
"raycaster", "raycaster",
"researchable", "researchable",
"Resvg", "Resvg",
@@ -125,6 +125,7 @@
"Synx", "Synx",
"tankaverages", "tankaverages",
"Tankerch", "Tankerch",
"tankmen",
"tankopedia", "tankopedia",
"tankopedias", "tankopedias",
"tankstats", "tankstats",
@@ -143,6 +144,7 @@
"Vovko", "Vovko",
"wargaming", "wargaming",
"webp", "webp",
"whitespaces",
"winrate", "winrate",
"wireframe", "wireframe",
"WOTB", "WOTB",
+13
View File
@@ -0,0 +1,13 @@
# Blitzkrieg 1.17.2
Introducing the new `/research` command!
## Features
- Added the `/research` command
- Type in the name of a tech tree tank you desire to research and this command will list all the expenses in researching all tanks in that line
- The starting tank is inferred automatically by the tanks you own
- This may be a bit inaccurate because of the lack of accurate information Wargaming provides about owned tanks
- You are also manually provide a starting tank and a user to see the progress on a line for
- Replaced Vercel Analytics (which collapsed under the insane amount of traffic on the website) with Google Analytics
- Spaced armor can now be toggled in the Tankopedia armor sandbox
+19
View File
@@ -0,0 +1,19 @@
# Blitzkrieg 1.18.0
Introducing the new `/rating` command!
## Features
- `/rating` command
- Features statistics on today's performance in ratings
- The included stats are battles, winrate, damage, survival, and kills
- This command also features the score, rank, and league you are at plus the reward you'll get if you are high enough on the leaderboard
- `/ratings`, the old leaderboard command, has been renamed `/rating-leaderboard`
- Reward icons have been removed from the leaderboard
## Technical Changes
- Rating leaderboards are now built in a more action-efficient manner.
- This version also introduces the BKRL (Blitzkrieg rating leaderboard) file format which is an efficient way to store entire leaderboards of regions to disk (1 MB per 40,000 users).
- Latest archival actions now build all regions at once in a single action.
- The sharp module is now included in the build.
+11
View File
@@ -0,0 +1,11 @@
# Blitzkrieg 1.18.1
HE gets more accurate and say goodbye to NaNaNaNaNaN.
## Fixes
- Armor highlighting now works on Apple MacBooks
- Applied equipments now persist after switching shells
- The bottom of the tank sandbox panel can now be interacted with as it was previously blocked by an invisible layer
- `/rating` now uses the midnight leaderboard instead of the absolute latest one
- 0-battle stats are now hidden
+7
View File
@@ -0,0 +1,7 @@
# Blitzkrieg 1.18.2
Redefining how micro a micro-path is.
## Fixes
- The pitch and yaw controls, along with the quick equipment buttons, can once again be interacted with
+29
View File
@@ -0,0 +1,29 @@
# Blitzkrieg 1.18.3
Making Tankopedia more pleasing to use. This update only affects the website.
## Features
- Added crew skills
- Added quick actions
- For example, crew skills have the "clear" and "maximize" actions and equipment simply has a "clear" action
- Added info buttons
- Beside the titles of a few sections, a new info buttons have been added for extended descriptions
- Tank configurations have been condensed thanks to a new layout that works well on most screen sizes
- The Tankopedia now takes advantage of wider screens than before; hence, getting rid of the useless whitespaces on the sides
- Tank modules are now neatly wrapped into dropdown menus
- Sorting tanks by "size" has been renamed to "volume" and length has been added as a new option
- The antagonist (the shooting tank) bar has been shrunk to only fit the its contents
- Fullscreen mode now features all the missing dropdowns and options that were previously hidden
- Added icons for donations on Patreon and Ko-fi in the navigation bar
- Tank search now uses regular links allowing for things like opening in new tabs, copying links, etc.
## Fixes
- Armor highlighting once again works properly even after switching around tank modules
- Inputting invalid numbers into the pitch and yaw fields is now handled correctly
- Removed unnecessary tooltips that didn't show up anyway due to Radix UI 3
## Technical Changes
- Upgraded to Radix UI 3
+45
View File
@@ -0,0 +1,45 @@
# Blitzkrieg Rating Leaderboard
A straightforward way of storing the entire World of Tanks Blitz rating leaderboard to disk.
```cpp
primary BlitzkriegRatingLeaderboard {
utf8"BKRL" magic;
Header header;
primary Body<header> body;
}
Header {
uint16 version; // always 1
Format format;
uint32 count;
}
Format enum<uint8> {
Base,
Superset1,
}
Body<Header header> match<header.format> {
Base {
primary {
uint32 id;
uint16 score;
}[header.count] entries;
};
Superset1 {
primary {
uint32 id;
uint16 score;
uint32 battles;
uint32 wins;
uint32 survived;
uint32 damage;
uint32 kills;
}[header.count] entries;
};
}
```
+1 -3
View File
@@ -1,7 +1,5 @@
# Cedilla Object Notation # Cedilla Object Notation
This documentation uses an experimental binary notation syntax called "Buffer Up".
1. JSON compressed as CDON, written directly to disk (a direct 50% reduction). 1. JSON compressed as CDON, written directly to disk (a direct 50% reduction).
2. CDON further compressed with LZ4 (a clean 90% reduction). 2. CDON further compressed with LZ4 (a clean 90% reduction).
3. The raw JSON file as fetched from a random data source. 3. The raw JSON file as fetched from a random data source.
@@ -19,7 +17,7 @@ primary CedillaObjectNotation {
} }
Header { Header {
uint16 version; uint16 version; // always 1
FSFormat fsFormat; FSFormat fsFormat;
StringTable<fsFormat> stringTable; StringTable<fsFormat> stringTable;
} }
+1 -1
View File
@@ -18,5 +18,5 @@ Blitzkrieg is available as a Discord bot and can be [installed very easily](/gui
Provides a bunch of very helpful tools; https://blitz-krieg.vercel.app/ Provides a bunch of very helpful tools; https://blitz-krieg.vercel.app/
- Session tracker - Session tracker
- Ratings leaderboard - Rating leaderboard
- Way more to come - Way more to come
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: process.env.SITE_URL || 'https://blitz-krieg.vercel.app',
generateRobotsTxt: true,
sourceDir: 'dist/website',
};
+9 -10
View File
@@ -1,24 +1,23 @@
{ {
"name": "blitzkrieg", "name": "blitzkrieg",
"version": "1.17.1", "version": "1.18.3",
"description": "🎉 Tools for everything World of Tanks Blitz", "description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts", "main": "src/server.ts",
"type": "module", "type": "module",
"scripts": { "scripts": {
"build:bot": "vite-node --script scripts/build", "build:bot": "vite-node --script scripts/build --production",
"build:website": "next build", "build:website": "next build && next-sitemap --config next-sitemap.config.cjs",
"build:docs": "vitepress build docs", "build:docs": "vitepress build docs",
"build:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard", "build:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard",
"build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index", "build:assets": "vite-node --max_old_space_size=16384 --script scripts/buildAssets/index",
"build:test": "vite-node --script scripts/buildTest", "build:test": "vite-node --script scripts/buildTest",
"build:ratingsplot": "vite-node --script scripts/buildRatingsPlot", "dev:bot": "vite-node --script scripts/build && yarn start:bot",
"dev:bot": "yarn build:bot --dev && yarn start:bot",
"dev:docs": "vitepress dev docs", "dev:docs": "vitepress dev docs",
"dev:website": "next dev", "dev:website": "next dev",
"start:bot": "node --enable-source-maps dist/bot/index.cjs", "start:bot": "node --enable-source-maps dist/bot/index.cjs",
"start:website": "next start", "start:website": "next start",
"host:docs": "vitepress preview docs", "host:docs": "vitepress preview docs",
"publish:ratingsleaderboard": "vite-node --script scripts/buildRatingsLeaderboard --publish", "publish:ratingleaderboard": "vite-node --script scripts/buildRatingLeaderboard --publish",
"setup:patches": "vite-node --script scripts/applyPatches", "setup:patches": "vite-node --script scripts/applyPatches",
"lint": "npx tsc", "lint": "npx tsc",
"lint:website": "next lint", "lint:website": "next lint",
@@ -37,13 +36,12 @@
"@prisma/client": "^5.4.1", "@prisma/client": "^5.4.1",
"@radix-ui/colors": "^3.0.0", "@radix-ui/colors": "^3.0.0",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/themes": "^2.0.1", "@radix-ui/themes": "^3.0.1",
"@react-three/drei": "^9.93.0", "@react-three/drei": "^9.93.0",
"@react-three/fiber": "^8.15.14", "@react-three/fiber": "^8.15.14",
"@resvg/resvg-js": "^2.4.1", "@resvg/resvg-js": "^2.4.1",
"@types/three": "^0.160.0", "@types/three": "^0.160.0",
"@vanilla-extract/css": "^1.13.0", "@vanilla-extract/css": "^1.13.0",
"@vercel/analytics": "^1.2.2",
"bepaint": "^0.1.0-beta.3", "bepaint": "^0.1.0-beta.3",
"decompress": "^4.2.1", "decompress": "^4.2.1",
"discord.js": "^14.13.0", "discord.js": "^14.13.0",
@@ -58,6 +56,7 @@
"lz4js": "^0.2.0", "lz4js": "^0.2.0",
"markdown-escape": "^2.0.0", "markdown-escape": "^2.0.0",
"next": "^14.1.0", "next": "^14.1.0",
"next-sitemap": "^4.2.3",
"parse-dds": "^1.2.1", "parse-dds": "^1.2.1",
"postprocessing": "^6.34.1", "postprocessing": "^6.34.1",
"react": "^18.2.0", "react": "^18.2.0",
@@ -66,7 +65,7 @@
"react-fuzzysort": "^1.0.1", "react-fuzzysort": "^1.0.1",
"react-promise-suspense": "^0.3.4", "react-promise-suspense": "^0.3.4",
"react-spinners": "^0.13.8", "react-spinners": "^0.13.8",
"satori": "^0.10.8", "satori": "^0.10.13",
"sharp": "0.33.1", "sharp": "0.33.1",
"three": "0.159.0", "three": "0.159.0",
"three-stdlib": "^2.29.4", "three-stdlib": "^2.29.4",
@@ -1,8 +0,0 @@
-- CreateTable
CREATE TABLE "discord_blitz" (
"discord" INT8 NOT NULL,
"region" STRING NOT NULL,
"blitz" INT4 NOT NULL,
CONSTRAINT "discord_blitz_pkey" PRIMARY KEY ("discord")
);
-3
View File
@@ -1,3 +0,0 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "cockroachdb"
+7 -3
View File
@@ -4,11 +4,15 @@ generator client {
datasource db { datasource db {
provider = "cockroachdb" provider = "cockroachdb"
url = env("DISCORD_BLITZ_DB") url = env("DATABASE_URL")
} }
model discord_blitz { model Users {
blitz Int @id
last_used DateTime? @default(now())
}
model DiscordUsers {
discord BigInt @id discord BigInt @id
region String
blitz Int blitz Int
} }
+1
View File
@@ -0,0 +1 @@
google.com, pub-1289406790225052, DIRECT, f08c47fec0942fa0
File renamed without changes.
File renamed without changes.
+15 -7
View File
@@ -1,10 +1,13 @@
import { exec } from 'child_process';
import { build } from 'esbuild'; import { build } from 'esbuild';
import { cp, readdir } from 'fs/promises'; import { cp, readdir } from 'fs/promises';
import { argv } from 'process'; import { argv } from 'process';
import { promisify } from 'util';
const isDev = argv.includes('--dev'); const execPromise = promisify(exec);
const isProduction = argv.includes('--production');
readdir('src/workers').then((workers) => { await readdir('src/workers').then((workers) => {
const files = [ const files = [
'bot', 'bot',
'index', 'index',
@@ -40,14 +43,19 @@ readdir('src/workers').then((workers) => {
}); });
// copy schema.prisma // copy schema.prisma
cp('prisma/schema.prisma', 'dist/bot/schema.prisma'); await cp('prisma/schema.prisma', 'dist/bot/schema.prisma');
readdir('node_modules/prisma').then((files) => await readdir('node_modules/prisma').then((files) =>
files files
.filter((file) => file.endsWith('.node')) .filter((file) => file.endsWith('.node'))
.map((file) => cp(`node_modules/prisma/${file}`, `dist/bot/${file}`)), .map((file) => cp(`node_modules/prisma/${file}`, `dist/bot/${file}`)),
); );
// copy sharp as is if (isProduction) {
if (!isDev) { // install fixed sharp
cp('node_modules/sharp', 'dist/bot/node_modules/sharp', { recursive: true }); console.log('Installing fixed sharp...');
await execPromise('yarn init -y && yarn add [email protected] --ignore-engines', {
cwd: 'dist/bot',
});
} }
console.log('Generated:', await readdir('dist/bot'));
+20 -5
View File
@@ -1,4 +1,4 @@
import { readdir, writeFile } from 'fs/promises'; import { readdir } from 'fs/promises';
import { parse as parsePath } from 'path'; import { parse as parsePath } from 'path';
import { Vector3Tuple } from 'three'; import { Vector3Tuple } from 'three';
import { parse as parseYaml } from 'yaml'; import { parse as parseYaml } from 'yaml';
@@ -21,6 +21,7 @@ import {
ModelDefinitions, ModelDefinitions,
} from '../../src/core/blitzkrieg/modelDefinitions'; } from '../../src/core/blitzkrieg/modelDefinitions';
import { ProvisionDefinitions } from '../../src/core/blitzkrieg/provisionDefinitions'; import { ProvisionDefinitions } from '../../src/core/blitzkrieg/provisionDefinitions';
import { SkillDefinitions } from '../../src/core/blitzkrieg/skillDefinitions';
import { superCompress } from '../../src/core/blitzkrieg/superCompress'; import { superCompress } from '../../src/core/blitzkrieg/superCompress';
import { import {
Crew, Crew,
@@ -32,6 +33,7 @@ import {
Tier, Tier,
} from '../../src/core/blitzkrieg/tankDefinitions'; } from '../../src/core/blitzkrieg/tankDefinitions';
import { DATA, POI } from './constants'; import { DATA, POI } from './constants';
import { Avatar } from './skillIcons';
import { TankParameters } from './tankIcons'; import { TankParameters } from './tankIcons';
interface Strings { interface Strings {
@@ -116,6 +118,7 @@ interface VehicleDefinitions {
weight: number; weight: number;
terrainResistance: string; terrainResistance: string;
rotationSpeed: number; rotationSpeed: number;
userString: string;
shotDispersionFactors: { shotDispersionFactors: {
vehicleMovement: number; vehicleMovement: number;
vehicleRotation: number; vehicleRotation: number;
@@ -341,6 +344,9 @@ export async function definitions(production: boolean) {
}; };
const consumableDefinitions: ConsumableDefinitions = {}; const consumableDefinitions: ConsumableDefinitions = {};
const provisionDefinitions: ProvisionDefinitions = {}; const provisionDefinitions: ProvisionDefinitions = {};
const skillDefinitions: SkillDefinitions = {
classes: { lightTank: [], mediumTank: [], heavyTank: [], 'AT-SPG': [] },
};
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then( const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then(
(nations) => nations.filter((nation) => nation !== 'common'), (nations) => nations.filter((nation) => nation !== 'common'),
); );
@@ -367,6 +373,9 @@ export async function definitions(production: boolean) {
const provisions = await readXMLDVPL<{ root: ProvisionsCommon }>( const provisions = await readXMLDVPL<{ root: ProvisionsCommon }>(
`${DATA}/${POI.provisionsCommon}.dvpl`, `${DATA}/${POI.provisionsCommon}.dvpl`,
); );
const avatar = await readXMLDVPL<{ root: Avatar }>(
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
);
const tankXps = new Map<number, number>(); const tankXps = new Map<number, number>();
@@ -558,6 +567,7 @@ export async function definitions(production: boolean) {
tankDefinitions[tankId].tracks.push({ tankDefinitions[tankId].tracks.push({
id: trackId, id: trackId,
weight: track.weight, weight: track.weight,
name: strings[track.userString],
traverseSpeed: track.rotationSpeed, traverseSpeed: track.rotationSpeed,
dispersion: { dispersion: {
move: track.shotDispersionFactors.vehicleMovement, move: track.shotDispersionFactors.vehicleMovement,
@@ -1084,10 +1094,10 @@ export async function definitions(production: boolean) {
} }
}); });
writeFile( Object.entries(avatar.root.skillsByClasses).forEach(([tankClass, skills]) => {
'test.tanks.cdon.lz4', skillDefinitions.classes[tankClass as TankClass] = skills.split(' ');
Buffer.from(superCompress(tankDefinitions), 'base64'), });
);
await commitAssets( await commitAssets(
'definitions', 'definitions',
[ [
@@ -1116,6 +1126,11 @@ export async function definitions(production: boolean) {
encoding: 'base64', encoding: 'base64',
path: 'definitions/provisions.cdon.lz4', path: 'definitions/provisions.cdon.lz4',
}, },
{
content: superCompress(skillDefinitions),
encoding: 'base64',
path: 'definitions/skills.cdon.lz4',
},
], ],
production, production,
); );
+2
View File
@@ -20,6 +20,7 @@ import { equipmentIcons } from './equipmentIcons';
import { moduleIcons } from './moduleIcons'; import { moduleIcons } from './moduleIcons';
import { scratchedFlags } from './scratchedFlags'; import { scratchedFlags } from './scratchedFlags';
import { shellIcons } from './shellIcons'; import { shellIcons } from './shellIcons';
import { skillIcons } from './skillIcons';
import { tankArmors } from './tankArmors'; import { tankArmors } from './tankArmors';
import { tankIcons } from './tankIcons'; import { tankIcons } from './tankIcons';
import { tankModels } from './tankModels'; import { tankModels } from './tankModels';
@@ -40,6 +41,7 @@ const methods = [
tankArmors, tankArmors,
tankModels, tankModels,
currencies, currencies,
skillIcons,
]; ];
for (const method of methods) { for (const method of methods) {
+51
View File
@@ -0,0 +1,51 @@
import sharp from 'sharp';
import { TankClass } from '../../src/components/Tanks';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
import { DATA } from './constants';
interface SkillIcon {
name: string;
state: number;
}
export interface Avatar {
roles: unknown;
skillsByClasses: Record<TankClass, string>;
skills: {
[name: string]: {
userString: string;
effectDescription: string;
tipDescription: string;
icon: SkillIcon | SkillIcon[];
type: 'continuous' | 'trigger';
};
};
}
export async function skillIcons(production: boolean) {
console.log('Building skill icons...');
const avatar = await readXMLDVPL<{ root: Avatar }>(
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
);
const changes = await Promise.all(
Object.values(avatar.root.skills).map(async (skill) => {
const icon = Array.isArray(skill.icon) ? skill.icon[0] : skill.icon;
const name = icon.name.split('/').at(-1)!.replace(/_\d$/, '');
const path = `${DATA}${icon.name.replace('~res:', '')}.packed.webp.dvpl`;
const image = sharp(await readDVPLFile(path)).trim();
const content = (await image.toBuffer()).toString('base64');
return {
content,
encoding: 'base64',
path: `icons/skills/${name}.webp`,
} satisfies FileChange;
}),
);
commitAssets('skill icons', changes, production);
}
+199
View File
@@ -0,0 +1,199 @@
import { chunk } from 'lodash';
import { argv } from 'process';
import {
RatingInfo,
RatingNeighbors,
RatingPlayer,
} from '../src/commands/ratingLeaderboard';
import { RegionSubdomain } from '../src/constants/regions';
import { getAccountInfo } from '../src/core/blitz/getAccountInfo';
import regionSubdomainToRegion from '../src/core/blitz/regionSubdomainToRegion';
import { commitAssets } from '../src/core/blitzkrieg/commitAssets';
import { FileChange } from '../src/core/blitzkrieg/commitMultipleFiles.js';
import { patientFetchJSON } from '../src/core/blitzkrieg/patientFetchJSON';
import { superCompress } from '../src/core/blitzkrieg/superCompress';
import {
BkrlBaseEntry,
BkrlFormat,
BkrlSuperset1Entry,
BkrlWriteStream,
} from '../src/core/streams/bkrl';
import { CdonValue, CdonWriteStream } from '../src/core/streams/cdon';
/*
* Central North American Time (UTC-5): use 0 5 * * *.
* Central European Time (UTC+1): use 0 23 * * *.
* Central Asia Standard Time (UTC+7): use 0 17 * * *.
*/
const production = argv.includes('--production');
const target = argv
.find((arg) => arg.startsWith('--target='))
?.split('=')[1] as 'midnight' | 'latest' | undefined;
const regionSubdomains = argv
.find((arg) => arg.startsWith('--region='))
?.split('=')[1];
if (!target) throw new Error('Target parameter not specified');
if (!regionSubdomains) throw new Error('Region parameter not specified');
regionSubdomains.split(',').forEach(async (regionSubdomain) => {
const region = regionSubdomainToRegion(regionSubdomain as RegionSubdomain);
console.log(`Building ${target} rating leaderboard for ${region}`);
const NEIGHBORS = 2 ** 8;
const PLAYERS = await patientFetchJSON<RatingInfo>(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/season/`,
).then((data) => (data.detail === undefined ? data.count : undefined));
if (PLAYERS === undefined) {
throw new Error('No current rating season running');
}
const players: Record<number, RatingPlayer> = {};
const leaderboard: BkrlBaseEntry[] = [];
let registered = 0;
async function branchFromPlayer(id: number) {
const { neighbors } = await patientFetchJSON<RatingNeighbors>(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${NEIGHBORS}`,
);
const firstPlayer = neighbors[0];
const lastPlayer = neighbors[neighbors.length - 1];
const branches: Promise<void>[] = [];
if (!players[firstPlayer.number])
branches.push(branchFromPlayer(firstPlayer.spa_id));
if (!players[lastPlayer.number])
branches.push(branchFromPlayer(lastPlayer.spa_id));
neighbors.forEach((neighbor) => {
if (players[neighbor.number]) return;
players[neighbor.number] = neighbor;
registered++;
});
console.log(
`Registered ${registered} / ${PLAYERS} (${(
(registered / PLAYERS!) *
100
).toFixed(2)}%)`,
);
await Promise.all(branches);
}
async function branchFromLeague(id: number) {
const { result } = await patientFetchJSON<{ result: RatingPlayer[] }>(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/league/${id}/top/`,
);
const firstPlayer = result[0];
const lastPlayer = result.at(-1)!;
await Promise.all([
branchFromPlayer(firstPlayer.spa_id),
branchFromPlayer(lastPlayer.spa_id),
]);
}
console.log('Branching from leagues...');
await Promise.all([
branchFromLeague(0),
branchFromLeague(1),
branchFromLeague(2),
branchFromLeague(3),
branchFromLeague(4),
]);
console.log('Converting to an array...');
for (let index = 0; index < PLAYERS; index++) {
const listing = players[index + 1];
if (listing) {
leaderboard[index] = { id: listing.spa_id, score: listing.score };
} else {
console.warn(`Position ${index + 1} had no player`);
leaderboard[index] = { id: 0, score: 0 };
}
}
console.log('Requesting further stats...');
const comprehensiveLeaderboard: BkrlSuperset1Entry[] = [];
let chunkIndex = 0;
for (const leaderboardChunk of chunk(leaderboard, 100)) {
console.log(
`Chunk ${chunkIndex + 1} of ${Math.ceil(leaderboard.length / 100)}`,
);
const stats = await getAccountInfo(
region,
leaderboardChunk.map(({ id }) => id),
['statistics.rating'],
);
stats.forEach((stat, index) => {
const trueIndex = chunkIndex * 100 + index;
comprehensiveLeaderboard[trueIndex] =
leaderboardChunk[index].id === 0 ||
stat?.statistics.rating === undefined
? {
battles: 0,
damage: 0,
id: 0,
kills: 0,
score: 0,
survived: 0,
wins: 0,
}
: {
id: leaderboardChunk[index].id,
score: leaderboardChunk[index].score,
battles: stat.statistics.rating.battles,
damage: stat.statistics.rating.damage_dealt,
kills: stat.statistics.rating.frags,
survived: stat.statistics.rating.survived_battles,
wins: stat.statistics.rating.wins,
};
});
chunkIndex++;
}
const info = await patientFetchJSON<RatingInfo & { detail: undefined }>(
`https://${regionSubdomain}.wotblitz.com/en/api/rating-leaderboards/season/`,
);
const normalizedServer = regionSubdomain === 'na' ? 'com' : regionSubdomain;
const infoContent = new CdonWriteStream().cdon(
info as unknown as CdonValue,
).uint8Array;
const leaderboardWriteStream = new BkrlWriteStream();
leaderboardWriteStream.bkrl({
format: BkrlFormat.Superset1,
entries: comprehensiveLeaderboard,
});
const changes: FileChange[] = [
{
path: `regions/${normalizedServer}/rating/${info.current_season}/info.cdon.lz4`,
content: superCompress(infoContent),
encoding: 'base64',
},
{
path: `regions/${normalizedServer}/rating/${info.current_season}/${target}.bkrl`,
content: Buffer.from(leaderboardWriteStream.uint8Array).toString(
'base64',
),
encoding: 'base64',
},
];
commitAssets(
`rating leaderboard ${regionSubdomain} ${target}`,
changes,
production,
);
});
-140
View File
@@ -1,140 +0,0 @@
import { rm, writeFile } from 'fs/promises';
import { argv } from 'process';
import {
RatingsInfo,
RatingsNeighbors,
RatingsPlayer,
} from '../src/commands/ratings';
import commitMultipleFiles from '../src/core/blitzkrieg/commitMultipleFiles.js';
import { patientFetchJSON } from '../src/core/blitzkrieg/patientFetchJSON';
/*
* Central North American Time (UTC-5): use 0 5 * * *.
* Central European Time (UTC+1): use 0 23 * * *.
* Central Asia Standard Time (UTC+7): use 0 17 * * *.
*
* [region]/ratings/[season]/info.json: season info like rewards, start & end time, etc.
* [region]/ratings/[season]/latest.json: the absolute latest leaderboard recorded at UTC+0 midnight
* [region]/ratings/[season]/midnight.json: last midnight's leaderboard relative to the region
*/
export type BlitzkriegRatingsLeaderboardEntry = { id: number; score: number };
export type BlitzkriegRatingsLeaderboard = BlitzkriegRatingsLeaderboardEntry[];
const publish = argv.includes('--publish');
const target = argv
.find((arg) => arg.startsWith('--target='))
?.split('=')[1] as 'midnight' | 'latest' | undefined;
const server = argv.find((arg) => arg.startsWith('--region='))?.split('=')[1];
if (!target) throw new Error('Target parameter not specified');
if (!server) throw new Error('Region parameter not specified');
const NEIGHBORS = 2 ** 8;
const PLAYERS = await patientFetchJSON<RatingsInfo>(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
).then((data) => (data.detail === undefined ? data.count : undefined));
if (PLAYERS === undefined) {
throw new Error('No current ratings season running');
}
const players: Record<number, RatingsPlayer> = {};
const leaderboard: BlitzkriegRatingsLeaderboard = [];
let registered = 0;
async function branchFromPlayer(id: number) {
const { neighbors } = await patientFetchJSON<RatingsNeighbors>(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${NEIGHBORS}`,
);
const firstPlayer = neighbors[0];
const lastPlayer = neighbors[neighbors.length - 1];
const branches: Promise<void>[] = [];
if (!players[firstPlayer.number])
branches.push(branchFromPlayer(firstPlayer.spa_id));
if (!players[lastPlayer.number])
branches.push(branchFromPlayer(lastPlayer.spa_id));
neighbors.forEach((neighbor) => {
if (players[neighbor.number]) return;
players[neighbor.number] = neighbor;
registered++;
});
console.log(
`Registered ${registered} / ${PLAYERS} (${(
(registered / PLAYERS!) *
100
).toFixed(2)}%)`,
);
await Promise.all(branches);
}
async function branchFromLeague(id: number) {
const { result } = await patientFetchJSON<{ result: RatingsPlayer[] }>(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/league/${id}/top/`,
);
const firstPlayer = result[0];
const lastPlayer = result[result.length - 1];
await Promise.all([
branchFromPlayer(firstPlayer.spa_id),
branchFromPlayer(lastPlayer.spa_id),
]);
}
console.log('Branching from leagues...');
await Promise.all([
branchFromLeague(0),
branchFromLeague(1),
branchFromLeague(2),
branchFromLeague(3),
branchFromLeague(4),
]);
console.log('Converting to an array...');
for (let index = 0; index < PLAYERS; index++) {
const listing = players[index + 1];
if (!listing) {
console.warn(`Position ${index + 1} had no player`);
continue;
}
leaderboard[index] = {
id: listing.spa_id,
score: listing.score,
};
}
const leaderboardJSON = JSON.stringify(leaderboard);
if (publish) {
const info = await patientFetchJSON<RatingsInfo & { detail: undefined }>(
`https://${server}.wotblitz.com/en/api/rating-leaderboards/season/`,
);
const normalizedServer = server === 'na' ? 'com' : server;
const infoPath = `regions/${normalizedServer}/ratings/${info.current_season}/info.json`;
const leaderboardPath = `regions/${normalizedServer}/ratings/${info.current_season}/${target}.json`;
const infoJSON = JSON.stringify(info);
console.log(`Publishing to season ${info.current_season}...`);
commitMultipleFiles(
'tresabhi',
'blitzkrieg-assets',
'main',
new Date().toString(),
[
{ path: infoPath, content: infoJSON, encoding: 'utf-8' },
{ path: leaderboardPath, content: leaderboardJSON, encoding: 'utf-8' },
],
);
} else {
console.log('Writing to disk...');
await rm('dist.json', { force: true });
await writeFile('dist.json', leaderboardJSON);
}
-46
View File
@@ -1,46 +0,0 @@
import { writeFile } from 'fs/promises';
import { Region } from '../src/constants/regions';
import { getRatingsNeighbors } from '../src/core/blitz/getRatingsNeighbors';
const REGION: Region = 'com';
const PLAYER = 1041988373;
const ITERATIONS = 2 ** 4;
const MAX_NEIGHBORS = 2 ** 7;
const data: [number, number][] = [];
let finished = 0;
for (let index = 0; index < ITERATIONS; index++) {
const neighbors = Math.round((Math.random() * MAX_NEIGHBORS) / 2) * 2;
const start = Date.now();
console.log(`🟡 Iteration ${index} with ${neighbors} neighbors`);
try {
await getRatingsNeighbors(REGION, PLAYER, neighbors);
finished++;
console.log(
`🟢 (${Math.round(
100 * (finished / ITERATIONS),
)}%) Iteration ${index} with ${neighbors} neighbors`,
);
const end = Date.now();
const time = end - start;
data.push([neighbors, time]);
} catch (error) {
console.log(
`🔴 Iteration ${index} with ${neighbors} neighbors\n\n${error}`,
);
}
}
console.log('🟢 Done, sorting');
data.sort(([neighborsA], [neighborsB]) => neighborsA - neighborsB);
console.log('🟢 Writing');
writeFile(
'dist/ratingsPlot.csv',
data.map(([neighbors, time]) => `${neighbors},${time}`).join('\n'),
);
@@ -1,10 +1,10 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { Region } from '../../../../../../constants/regions'; import { Region } from '../../../../../../constants/regions';
import getRatingsInfo from '../../../../../../core/blitz/getRatingsInfo'; import getRatingInfo from '../../../../../../core/blitz/getRatingInfo';
export async function GET( export async function GET(
request: Request, request: Request,
{ params }: { params: { region: Region } }, { params }: { params: { region: Region } },
) { ) {
return NextResponse.json(await getRatingsInfo(params.region)); return NextResponse.json(await getRatingInfo(params.region));
} }
@@ -1,12 +1,12 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { Region } from '../../../../../../../constants/regions'; import { Region } from '../../../../../../../constants/regions';
import { getRatingsLeague } from '../../../../../../../core/blitz/getRatingsLeague'; import { getRatingLeague } from '../../../../../../../core/blitz/getRatingLeague';
export async function GET( export async function GET(
request: Request, request: Request,
{ params }: { params: { region: Region; league: number } }, { params }: { params: { region: Region; league: number } },
) { ) {
return NextResponse.json( return NextResponse.json(
await getRatingsLeague(params.region, params.league, true), await getRatingLeague(params.region, params.league, true),
); );
} }
@@ -1,12 +1,12 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { Region } from '../../../../../../../constants/regions'; import { Region } from '../../../../../../../constants/regions';
import { searchCurrentRatingsPlayers } from '../../../../../../../core/blitz/searchCurrentRatingsPlayers'; import { searchCurrentRatingPlayers } from '../../../../../../../core/blitz/searchCurrentRatingPlayers';
export async function GET( export async function GET(
request: Request, request: Request,
{ params }: { params: { region: Region; username: string } }, { params }: { params: { region: Region; username: string } },
) { ) {
return NextResponse.json( return NextResponse.json(
await searchCurrentRatingsPlayers(params.region, params.username), await searchCurrentRatingPlayers(params.region, params.username),
); );
} }
+6 -5
View File
@@ -3,16 +3,17 @@
import { CaretRightIcon } from '@radix-ui/react-icons'; import { CaretRightIcon } from '@radix-ui/react-icons';
import { AlertDialog, Button, Flex, Text, Theme } from '@radix-ui/themes'; import { AlertDialog, Button, Flex, Text, Theme } from '@radix-ui/themes';
import '@radix-ui/themes/styles.css'; import '@radix-ui/themes/styles.css';
import { Analytics } from '@vercel/analytics/react';
import { config } from 'dotenv'; import { config } from 'dotenv';
import { Roboto_Flex } from 'next/font/google'; import { Roboto_Flex } from 'next/font/google';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { ReactNode, useEffect, useState } from 'react'; import { ReactNode, useEffect, useState } from 'react';
import { SkyLine } from '../assets/art/SkyLine'; import { SkyLine } from '../assets/art/SkyLine';
import { Analytics } from '../components/Analytics';
import Navbar from '../components/Navbar'; import Navbar from '../components/Navbar';
import PageWrapper from '../components/PageWrapper'; import PageWrapper from '../components/PageWrapper';
import isDev from '../core/blitzkrieg/isDev'; import isDev from '../core/blitzkrieg/isDev';
import { isLocalhost } from '../core/blitzkrieg/isLocalhost'; import { isLocalhost } from '../core/blitzkrieg/isLocalhost';
import { useFullScreen } from '../hooks/useFullScreen';
import { useApp } from '../stores/app'; import { useApp } from '../stores/app';
import { mintTheme } from '../themes/mint'; import { mintTheme } from '../themes/mint';
@@ -33,6 +34,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
const pathname = usePathname(); const pathname = usePathname();
const isEmbed = pathname.split('/')[1] === 'embeds'; const isEmbed = pathname.split('/')[1] === 'embeds';
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false); const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
const isFullScreen = useFullScreen();
useEffect(() => { useEffect(() => {
setShowDevBuildAlert( setShowDevBuildAlert(
@@ -51,6 +53,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
name="description" name="description"
content="🎉 Tools for everything World of Tanks Blitz" content="🎉 Tools for everything World of Tanks Blitz"
/> />
<Analytics />
</head> </head>
<body <body
@@ -60,8 +63,6 @@ export default function RootLayout({ children }: RootLayoutProps) {
backgroundColor: isEmbed ? 'transparent' : undefined, backgroundColor: isEmbed ? 'transparent' : undefined,
}} }}
> >
<Analytics />
<Theme <Theme
appearance="dark" appearance="dark"
panelBackground="translucent" panelBackground="translucent"
@@ -101,11 +102,11 @@ export default function RootLayout({ children }: RootLayoutProps) {
<Flex direction="column">{children}</Flex> <Flex direction="column">{children}</Flex>
{!isEmbed && ( {!isEmbed && !isFullScreen && (
<PageWrapper> <PageWrapper>
<a <a
target="_blank" target="_blank"
href="https://ko-fi.com/tresabhi" href="https://www.patreon.com/tresabhi"
style={{ textDecoration: 'none' }} style={{ textDecoration: 'none' }}
> >
<Flex <Flex
+1 -2
View File
@@ -15,11 +15,10 @@ export default function Page() {
return ( return (
<PageWrapper> <PageWrapper>
<TextField.Root> <TextField.Root placeholder="Search tools...">
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot> </TextField.Slot>
<TextField.Input ref={input} placeholder="Search tools..." />
</TextField.Root> </TextField.Root>
<Flex gap="3" wrap="wrap" align="center" justify="center"> <Flex gap="3" wrap="wrap" align="center" justify="center">
@@ -53,29 +53,26 @@ export function PageTurner({
<CaretLeftIcon /> <CaretLeftIcon />
)} )}
</Button> </Button>
<TextField.Root>
<TextField.Root
type="number"
ref={pageInput}
style={{ textAlign: 'center' }}
min={1}
max={pages}
onBlur={(event) => {
onPageChange(
Math.max(0, Math.min(pages - 1, event.target.valueAsNumber - 1)),
false,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
>
<TextField.Slot>Page</TextField.Slot> <TextField.Slot>Page</TextField.Slot>
<TextField.Input
type="number"
ref={pageInput}
style={{ width: 64, textAlign: 'center' }}
min={1}
max={pages}
onBlur={(event) => {
onPageChange(
Math.max(
0,
Math.min(pages - 1, event.target.valueAsNumber - 1),
),
false,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
/>
<TextField.Slot>out of {pages}</TextField.Slot> <TextField.Slot>out of {pages}</TextField.Slot>
</TextField.Root> </TextField.Root>
<Button <Button
@@ -15,11 +15,11 @@ import { produce } from 'immer';
import { debounce, range } from 'lodash'; import { debounce, range } from 'lodash';
import { ChangeEvent, useEffect, useRef, useState } from 'react'; import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { create } from 'zustand'; import { create } from 'zustand';
import { RatingsInfo, RatingsPlayer } from '../../../commands/ratings'; import { RatingInfo, RatingPlayer } from '../../../commands/ratingLeaderboard';
import PageWrapper from '../../../components/PageWrapper'; import PageWrapper from '../../../components/PageWrapper';
import { Skeleton } from '../../../components/Skeleton'; import { Skeleton } from '../../../components/Skeleton';
import { LEAGUES } from '../../../constants/leagues'; import { LEAGUES } from '../../../constants/leagues';
import { FIRST_ARCHIVED_RATINGS_SEASON } from '../../../constants/ratings'; import { FIRST_MINIMAL_ARCHIVED_RATING_SEASON } from '../../../constants/rating';
import { import {
REGIONS, REGIONS,
Region, Region,
@@ -28,17 +28,17 @@ import {
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID'; import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
import fetchBlitz from '../../../core/blitz/fetchBlitz'; import fetchBlitz from '../../../core/blitz/fetchBlitz';
import { getClanAccountInfo } from '../../../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../../../core/blitz/getClanAccountInfo';
import getRatingsInfo from '../../../core/blitz/getRatingsInfo'; import getRatingInfo from '../../../core/blitz/getRatingInfo';
import { getRatingsLeague } from '../../../core/blitz/getRatingsLeague'; import { getRatingLeague } from '../../../core/blitz/getRatingLeague';
import { getRatingsNeighbors } from '../../../core/blitz/getRatingsNeighbors'; import { getRatingNeighbors } from '../../../core/blitz/getRatingNeighbors';
import { searchCurrentRatingsPlayers } from '../../../core/blitz/searchCurrentRatingsPlayers'; import { searchCurrentRatingPlayers } from '../../../core/blitz/searchCurrentRatingPlayers';
import { import {
AccountList, AccountList,
AccountListItem, AccountListItem,
} from '../../../core/blitz/searchPlayersAcrossRegions'; } from '../../../core/blitz/searchPlayersAcrossRegions';
import { getArchivedLatestSeasonNumber } from '../../../core/blitzkrieg/getArchivedLatestSeasonNumber'; import { getArchivedLatestSeasonNumber } from '../../../core/blitzkrieg/getArchivedLatestSeasonNumber';
import getArchivedRatingsInfo from '../../../core/blitzkrieg/getArchivedRatingsInfo'; import getArchivedRatingInfo from '../../../core/blitzkrieg/getArchivedRatingInfo';
import { getArchivedRatingsLeaderboard } from '../../../core/blitzkrieg/getArchivedRatingsLeaderboard'; import { getArchivedRatingLeaderboard } from '../../../core/blitzkrieg/getArchivedRatingLeaderboard';
import { theme } from '../../../stitches.config'; import { theme } from '../../../stitches.config';
import { PageTurner } from './components/PageTurner'; import { PageTurner } from './components/PageTurner';
@@ -51,7 +51,7 @@ interface NameCacheEntry {
} }
type NameCache = Record<Region, Record<number, NameCacheEntry | null>>; type NameCache = Record<Region, Record<number, NameCacheEntry | null>>;
type LeaderboardCache = Record<Region, Record<number, Record<number, number>>>; type LeaderboardCache = Record<Region, Record<number, Record<number, number>>>;
type InfoCache = Record<Region, Record<number, RatingsInfo | undefined>>; type InfoCache = Record<Region, Record<number, RatingInfo | undefined>>;
type ScoreCache = Record<Region, Record<number, Record<number, number>>>; type ScoreCache = Record<Region, Record<number, Record<number, number>>>;
const useNameCache = create<NameCache>(() => ({ const useNameCache = create<NameCache>(() => ({
@@ -93,7 +93,7 @@ export default function Page() {
{ type: 'id'; id: number } | { type: 'position'; position: number } | null { type: 'id'; id: number } | { type: 'position'; position: number } | null
>(null); >(null);
const ratingsInfo = useInfoCache()[region][season]; const ratingInfo = useInfoCache()[region][season];
const [latestArchivedSeasonNumber, setLatestArchivedSeasonNumber] = useState< const [latestArchivedSeasonNumber, setLatestArchivedSeasonNumber] = useState<
number | null number | null
>(null); >(null);
@@ -111,7 +111,7 @@ export default function Page() {
}); });
const pages = Math.ceil( const pages = Math.ceil(
(ratingsInfo && ratingsInfo.detail === undefined ? ratingsInfo?.count : 1) / (ratingInfo && ratingInfo.detail === undefined ? ratingInfo?.count : 1) /
ROWS_PER_PAGE, ROWS_PER_PAGE,
); );
@@ -122,8 +122,8 @@ export default function Page() {
(async () => { (async () => {
const newInfo = const newInfo =
season === 0 season === 0
? await getRatingsInfo(region) ? await getRatingInfo(region)
: await getArchivedRatingsInfo(region, season); : await getArchivedRatingInfo(region, season);
useInfoCache.setState( useInfoCache.setState(
produce((draft: InfoCache) => { produce((draft: InfoCache) => {
draft[region][season] = newInfo; draft[region][season] = newInfo;
@@ -140,7 +140,7 @@ export default function Page() {
const results = ( const results = (
await Promise.all( await Promise.all(
range(0, 5).map(async (league: number) => { range(0, 5).map(async (league: number) => {
const data = await getRatingsLeague(region, league); const data = await getRatingLeague(region, league);
leaguePositionCache.current[region][league] = data[0].number - 1; leaguePositionCache.current[region][league] = data[0].number - 1;
return data; return data;
@@ -178,12 +178,12 @@ export default function Page() {
); );
})(); })();
} else { } else {
getArchivedRatingsLeaderboard(region, season).then((results) => { getArchivedRatingLeaderboard(region, season).then((results) => {
useLeaderboardCache.setState( useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => { produce((draft: LeaderboardCache) => {
if (!(season in draft[region])) draft[region][season] = {}; if (!(season in draft[region])) draft[region][season] = {};
results.forEach((result, index) => { results.entries.forEach((result, index) => {
draft[region][season][index] = result.id; draft[region][season][index] = result.id;
}); });
}), }),
@@ -192,7 +192,7 @@ export default function Page() {
produce((draft: ScoreCache) => { produce((draft: ScoreCache) => {
if (!(season in draft[region])) draft[region][season] = {}; if (!(season in draft[region])) draft[region][season] = {};
results.forEach((result) => { results.entries.forEach((result) => {
draft[region][season][result.id] = result.score; draft[region][season][result.id] = result.score;
}); });
}), }),
@@ -213,7 +213,7 @@ export default function Page() {
}, [leaderboard[region]?.[season] && 0 in leaderboard[region][season]]); }, [leaderboard[region]?.[season] && 0 in leaderboard[region][season]]);
// neighboring page caching for all seasons // neighboring page caching for all seasons
useEffect(() => { useEffect(() => {
if (!ratingsInfo || ratingsInfo?.detail) return; if (!ratingInfo || ratingInfo?.detail) return;
if (season === 0) { if (season === 0) {
const middleIndex = page * ROWS_PER_PAGE + ROWS_PER_PAGE / 2; const middleIndex = page * ROWS_PER_PAGE + ROWS_PER_PAGE / 2;
@@ -236,7 +236,7 @@ export default function Page() {
const ids = range( const ids = range(
Math.max(0, (page - 1) * ROWS_PER_PAGE), Math.max(0, (page - 1) * ROWS_PER_PAGE),
Math.min( Math.min(
ratingsInfo.count - 1, ratingInfo.count - 1,
(page + 1) * ROWS_PER_PAGE + ROWS_PER_PAGE, (page + 1) * ROWS_PER_PAGE + ROWS_PER_PAGE,
), ),
) )
@@ -265,7 +265,7 @@ export default function Page() {
page, page,
region, region,
season, season,
ratingsInfo?.detail, ratingInfo?.detail,
leaderboard[region][season] !== undefined && leaderboard[region][season] !== undefined &&
0 in leaderboard[region][season], 0 in leaderboard[region][season],
]); ]);
@@ -279,7 +279,7 @@ export default function Page() {
async function cacheNeighbors( async function cacheNeighbors(
id: number, id: number,
size: number, size: number,
): Promise<RatingsPlayer[] | null> { ): Promise<RatingPlayer[] | null> {
const radius = Math.round(size / 2); const radius = Math.round(size / 2);
const targetPosition = Object.entries(leaderboard[region][season]).find( const targetPosition = Object.entries(leaderboard[region][season]).find(
([, player]) => id === player, ([, player]) => id === player,
@@ -303,7 +303,7 @@ export default function Page() {
} }
} }
const { neighbors } = await getRatingsNeighbors(region, id, size, true); const { neighbors } = await getRatingNeighbors(region, id, size, true);
useLeaderboardCache.setState( useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => { produce((draft: LeaderboardCache) => {
@@ -341,11 +341,11 @@ export default function Page() {
return neighbors; return neighbors;
} }
async function jumpToPosition(position: number) { async function jumpToPosition(position: number) {
if (!ratingsInfo || ratingsInfo?.detail) return; if (!ratingInfo || ratingInfo?.detail) return;
const targetPosition = Math.min( const targetPosition = Math.min(
ratingsInfo.count - 1, ratingInfo.count - 1,
Math.max(0, Math.min(position, ratingsInfo.count - 1)), Math.max(0, Math.min(position, ratingInfo.count - 1)),
); );
if (season === 0) { if (season === 0) {
@@ -378,7 +378,7 @@ export default function Page() {
const loadingDirection = Math.sign(targetPosition - closestPosition); const loadingDirection = Math.sign(targetPosition - closestPosition);
let seedingPlayer = closestPlayerId as number; let seedingPlayer = closestPlayerId as number;
let targetPositionAcquired = false; let targetPositionAcquired = false;
let neighbors: RatingsPlayer[] | null; let neighbors: RatingPlayer[] | null;
setLoadingProgress([0, closestPositionDistance]); setLoadingProgress([0, closestPositionDistance]);
@@ -423,7 +423,7 @@ export default function Page() {
} }
} }
async function jumpToScore() { async function jumpToScore() {
if (!ratingsInfo || ratingsInfo?.detail) return; if (!ratingInfo || ratingInfo?.detail) return;
const targetScore = scoreInput.current!.valueAsNumber; const targetScore = scoreInput.current!.valueAsNumber;
@@ -522,7 +522,7 @@ export default function Page() {
} else { } else {
let playerIndex = -1; let playerIndex = -1;
for (let index = ratingsInfo.count; index >= 0; index--) { for (let index = ratingInfo.count; index >= 0; index--) {
if (!(index in leaderboard[region][season])) continue; if (!(index in leaderboard[region][season])) continue;
if (scores[leaderboard[region][season][index]] >= targetScore) { if (scores[leaderboard[region][season][index]] >= targetScore) {
@@ -545,7 +545,7 @@ export default function Page() {
} }
const totalPlayers = const totalPlayers =
!ratingsInfo?.detail && ratingsInfo?.count ? ratingsInfo.count : undefined; !ratingInfo?.detail && ratingInfo?.count ? ratingInfo.count : undefined;
return ( return (
<PageWrapper color="orange"> <PageWrapper color="orange">
@@ -573,7 +573,7 @@ export default function Page() {
<Select.Trigger style={{ flex: 1 }} /> <Select.Trigger style={{ flex: 1 }} />
<Select.Content> <Select.Content>
{ratingsInfo?.detail === undefined && ( {ratingInfo?.detail === undefined && (
<Select.Item value="0">Current season</Select.Item> <Select.Item value="0">Current season</Select.Item>
)} )}
@@ -582,7 +582,7 @@ export default function Page() {
{latestArchivedSeasonNumber && {latestArchivedSeasonNumber &&
range( range(
FIRST_ARCHIVED_RATINGS_SEASON, FIRST_MINIMAL_ARCHIVED_RATING_SEASON,
latestArchivedSeasonNumber + 1, latestArchivedSeasonNumber + 1,
) )
.map((season) => ( .map((season) => (
@@ -605,7 +605,7 @@ export default function Page() {
<Dialog.Content> <Dialog.Content>
<Flex gap="4" justify="center"> <Flex gap="4" justify="center">
<TextField.Input <TextField.Root
ref={scoreInput} ref={scoreInput}
type="number" type="number"
placeholder="Type a score..." placeholder="Type a score..."
@@ -638,7 +638,7 @@ export default function Page() {
<Dialog.Content> <Dialog.Content>
<Flex gap="4" justify="center"> <Flex gap="4" justify="center">
<TextField.Input <TextField.Root
ref={positionInput} ref={positionInput}
type="number" type="number"
placeholder="Type a position..." placeholder="Type a position..."
@@ -681,8 +681,8 @@ export default function Page() {
<Select.Trigger /> <Select.Trigger />
<Select.Content> <Select.Content>
{ratingsInfo?.detail === undefined && {ratingInfo?.detail === undefined &&
ratingsInfo?.leagues.map(({ index, title }) => ( ratingInfo?.leagues.map(({ index, title }) => (
<Select.Item key={index} value={`${index}`}> <Select.Item key={index} value={`${index}`}>
{title} {title}
</Select.Item> </Select.Item>
@@ -714,11 +714,7 @@ export default function Page() {
); );
} }
} else { } else {
if ( if (!ratingInfo || ratingInfo.detail || !leaderboard)
!ratingsInfo ||
ratingsInfo.detail ||
!leaderboard
)
return; return;
const minScore = const minScore =
@@ -727,7 +723,7 @@ export default function Page() {
for ( for (
let index = 0; let index = 0;
index < ratingsInfo.count; index < ratingInfo.count;
index++ index++
) { ) {
if ( if (
@@ -763,105 +759,100 @@ export default function Page() {
<Dialog.Content> <Dialog.Content>
<Flex gap="4" direction="column"> <Flex gap="4" direction="column">
<TextField.Root> <TextField.Root
onChange={debounce(
async (event: ChangeEvent<HTMLInputElement>) => {
const trimmedSearch = event.target.value.trim();
const encodedSearch = encodeURIComponent(trimmedSearch);
if (trimmedSearch) {
if (season === 0) {
setSearchLoading(true);
const accountList = await searchCurrentRatingPlayers(
region,
trimmedSearch,
);
const values = Object.values(accountList).filter(
(searchedPlayer) => !searchedPlayer.skip,
);
const valuesWithTypes =
values as ((typeof values)[number] & {
skip: false;
})[];
useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => {
if (draft) {
values.forEach((player) => {
const playerWithTypes =
player as typeof player & { skip: false };
draft[region][season][
playerWithTypes.number - 1
] = playerWithTypes.spa_id;
});
}
}),
);
useNameCache.setState(
produce((draft: typeof names) => {
valuesWithTypes.forEach((searchedPlayer) => {
if (searchedPlayer.nickname) {
draft[region][searchedPlayer.spa_id] = {
nickname: searchedPlayer.nickname,
clan: searchedPlayer.clan_tag,
};
}
});
}),
);
setSearchResults(
valuesWithTypes.map((searchedPlayer) => ({
account_id: searchedPlayer.spa_id,
nickname: searchedPlayer.nickname,
number: searchedPlayer.number,
})),
);
setSearchLoading(false);
} else {
setSearchLoading(true);
const accountList = await fetchBlitz<AccountList>(
`https://api.wotblitz.${region}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}`,
);
const playerKeys = Object.keys(
leaderboard[region][season],
);
setSearchResults(
accountList
.map((searchedPlayer) => ({
...searchedPlayer,
number: parseInt(
playerKeys.find(
(indexAsString) =>
leaderboard[region][season][
parseInt(indexAsString)
] === searchedPlayer.account_id,
)!,
),
}))
.filter((player) => !Number.isNaN(player.number)),
);
setSearchLoading(false);
}
} else {
setSearchResults(undefined);
}
},
500,
)}
placeholder="Search for player..."
>
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot> </TextField.Slot>
<TextField.Input
onChange={debounce(
async (event: ChangeEvent<HTMLInputElement>) => {
const trimmedSearch = event.target.value.trim();
const encodedSearch = encodeURIComponent(trimmedSearch);
if (trimmedSearch) {
if (season === 0) {
setSearchLoading(true);
const accountList =
await searchCurrentRatingsPlayers(
region,
trimmedSearch,
);
const values = Object.values(accountList).filter(
(searchedPlayer) => !searchedPlayer.skip,
);
const valuesWithTypes =
values as ((typeof values)[number] & {
skip: false;
})[];
useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => {
if (draft) {
values.forEach((player) => {
const playerWithTypes =
player as typeof player & { skip: false };
draft[region][season][
playerWithTypes.number - 1
] = playerWithTypes.spa_id;
});
}
}),
);
useNameCache.setState(
produce((draft: typeof names) => {
valuesWithTypes.forEach((searchedPlayer) => {
if (searchedPlayer.nickname) {
draft[region][searchedPlayer.spa_id] = {
nickname: searchedPlayer.nickname,
clan: searchedPlayer.clan_tag,
};
}
});
}),
);
setSearchResults(
valuesWithTypes.map((searchedPlayer) => ({
account_id: searchedPlayer.spa_id,
nickname: searchedPlayer.nickname,
number: searchedPlayer.number,
})),
);
setSearchLoading(false);
} else {
setSearchLoading(true);
const accountList = await fetchBlitz<AccountList>(
`https://api.wotblitz.${region}/wotb/account/list/?application_id=${WARGAMING_APPLICATION_ID}&search=${encodedSearch}`,
);
const playerKeys = Object.keys(
leaderboard[region][season],
);
setSearchResults(
accountList
.map((searchedPlayer) => ({
...searchedPlayer,
number: parseInt(
playerKeys.find(
(indexAsString) =>
leaderboard[region][season][
parseInt(indexAsString)
] === searchedPlayer.account_id,
)!,
),
}))
.filter(
(player) => !Number.isNaN(player.number),
),
);
setSearchLoading(false);
}
} else {
setSearchResults(undefined);
}
},
500,
)}
placeholder="Search for player..."
/>
</TextField.Root> </TextField.Root>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
@@ -885,12 +876,12 @@ export default function Page() {
id: player.account_id, id: player.account_id,
}); });
} else { } else {
if (!ratingsInfo || ratingsInfo.detail) return; if (!ratingInfo || ratingInfo.detail) return;
let playerIndex = -1; let playerIndex = -1;
for ( for (
let index = 0; let index = 0;
index < ratingsInfo.count; index < ratingInfo.count;
index++ index++
) { ) {
if ( if (
@@ -951,9 +942,7 @@ export default function Page() {
</Flex> </Flex>
<PageTurner <PageTurner
totalPlayers={ totalPlayers={(ratingInfo?.detail ? undefined : ratingInfo?.count) ?? 0}
(ratingsInfo?.detail ? undefined : ratingsInfo?.count) ?? 0
}
leaderboard={leaderboard[region][season] ?? {}} leaderboard={leaderboard[region][season] ?? {}}
rowsPerPage={ROWS_PER_PAGE} rowsPerPage={ROWS_PER_PAGE}
page={page} page={page}
@@ -982,7 +971,7 @@ export default function Page() {
page * ROWS_PER_PAGE, page * ROWS_PER_PAGE,
Math.min( Math.min(
page * ROWS_PER_PAGE + ROWS_PER_PAGE, page * ROWS_PER_PAGE + ROWS_PER_PAGE,
ratingsInfo?.detail ? 0 : (ratingsInfo?.count ?? 1) - 1, ratingInfo?.detail ? 0 : (ratingInfo?.count ?? 1) - 1,
), ),
).map((index) => { ).map((index) => {
const id = leaderboard[region][season]?.[index]; const id = leaderboard[region][season]?.[index];
@@ -994,8 +983,8 @@ export default function Page() {
: highlightedPlayer.position === index : highlightedPlayer.position === index
: false; : false;
const reward = const reward =
!ratingsInfo?.detail && ratingsInfo?.rewards !ratingInfo?.detail && ratingInfo?.rewards
? ratingsInfo.rewards.find( ? ratingInfo.rewards.find(
(reward) => (reward) =>
reward.from_position <= position && reward.from_position <= position &&
reward.to_position >= position, reward.to_position >= position,
@@ -1090,9 +1079,7 @@ export default function Page() {
</Table.Root> </Table.Root>
<PageTurner <PageTurner
totalPlayers={ totalPlayers={(ratingInfo?.detail ? undefined : ratingInfo?.count) ?? 0}
(ratingsInfo?.detail ? undefined : ratingsInfo?.count) ?? 0
}
rowsPerPage={ROWS_PER_PAGE} rowsPerPage={ROWS_PER_PAGE}
leaderboard={leaderboard[region][season] ?? {}} leaderboard={leaderboard[region][season] ?? {}}
page={page} page={page}
+12 -13
View File
@@ -37,19 +37,18 @@ export function Menu({ Builder, reset }: ContextMenuProps) {
<Heading size="4">Edit title</Heading> <Heading size="4">Edit title</Heading>
<Flex gap="2" style={{ width: '100%' }}> <Flex gap="2" style={{ width: '100%' }}>
<TextField.Root style={{ flex: 1 }}> <TextField.Root
<TextField.Input style={{ flex: 1 }}
ref={titleInput} ref={titleInput}
defaultValue={session.title} defaultValue={session.title}
placeholder="Enter title..." placeholder="Enter title..."
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && titleInput.current) { if (event.key === 'Enter' && titleInput.current) {
setShowTitleEditor(false); setShowTitleEditor(false);
useSession.setState({ title: titleInput.current!.value }); useSession.setState({ title: titleInput.current!.value });
} }
}} }}
/> />
</TextField.Root>
<Dialog.Close> <Dialog.Close>
<Button <Button
+27 -29
View File
@@ -56,38 +56,36 @@ export default function Page() {
<PageWrapper color="blue"> <PageWrapper color="blue">
<div className={styles.toolBar}> <div className={styles.toolBar}>
<div style={{ flex: 1, boxSizing: 'border-box', position: 'relative' }}> <div style={{ flex: 1, boxSizing: 'border-box', position: 'relative' }}>
<TextField.Root> <TextField.Root
defaultValue={session.isTracking ? session.nickname : undefined}
ref={input}
onChange={(event) => {
event.stopPropagation();
if (event.target.value) {
setShowSearchResults(true);
setSearchResults(undefined);
} else {
setShowSearchResults(false);
}
handleChange(event);
}}
onBlur={(event) => {
// TODO: remove this hack when https://github.com/radix-ui/primitives/issues/2193 (https://github.com/radix-ui/primitives/issues/1969) is fixed
if (showSearchResults) event.target.focus();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setShowSearchResults(false);
input.current?.blur();
}
}}
placeholder="Search for a player..."
>
<TextField.Slot> <TextField.Slot>
<PersonIcon height="16" width="16" /> <PersonIcon height="16" width="16" />
</TextField.Slot> </TextField.Slot>
<TextField.Input
defaultValue={session.isTracking ? session.nickname : undefined}
ref={input}
onChange={(event) => {
event.stopPropagation();
if (event.target.value) {
setShowSearchResults(true);
setSearchResults(undefined);
} else {
setShowSearchResults(false);
}
handleChange(event);
}}
onBlur={(event) => {
// TODO: remove this hack when https://github.com/radix-ui/primitives/issues/2193 is fixed
if (showSearchResults) event.target.focus();
}}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setShowSearchResults(false);
input.current?.blur();
}
}}
placeholder="Search for a player..."
/>
</TextField.Root> </TextField.Root>
<DropdownMenu.Root open={showSearchResults} modal={false}> <DropdownMenu.Root open={showSearchResults} modal={false}>
@@ -1,15 +1,5 @@
import { CaretRightIcon, Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons'; import { Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons';
import { import { Button, Card, Dialog, Flex, Heading, Tabs } from '@radix-ui/themes';
Button,
Card,
Dialog,
Flex,
Heading,
Tabs,
Text,
Tooltip,
} from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { ModuleButton } from '../../../../../components/ModuleButtons/ModuleButton'; import { ModuleButton } from '../../../../../components/ModuleButtons/ModuleButton';
import { ShellButton } from '../../../../../components/ModuleButtons/ShellButton'; import { ShellButton } from '../../../../../components/ModuleButtons/ShellButton';
@@ -37,58 +27,70 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
const [tab, setTab] = useState('search'); const [tab, setTab] = useState('search');
const [antagonistSelectorOpen, setAntagonistSelectorVisible] = const [antagonistSelectorOpen, setAntagonistSelectorVisible] =
useState(false); useState(false);
const router = useRouter();
if (mode !== 'armor') return null; if (mode !== 'armor') return null;
return ( return (
<Card <Flex justify="center">
style={ <Card
floating style={
? { floating
position: 'absolute', ? {
bottom: 8, position: 'absolute',
left: '50%', bottom: 8,
transform: 'translateX(-50%)', left: '50%',
} transform: 'translateX(-50%)',
: undefined }
} : undefined
> }
<Flex align="center" justify="between" gap="6"> >
<Flex> <Flex
{antagonist.gun.shells.map((shell, index) => { align="center"
return ( justify="center"
<ShellButton wrap="wrap"
selected={antagonist.shell.id === shell.id} style={{
shell={shell.icon} gap: 'min(5vw, 32px)',
rowChild }}
first={index === 0} >
key={shell.id} <Flex>
last={index === antagonist.gun.shells.length - 1} {antagonist.gun.shells.map((shell, index) => {
onClick={() => { return (
mutateDuel((draft) => { <ShellButton
draft.antagonist!.shell = shell; selected={antagonist.shell.id === shell.id}
mutateTankopediaTemporary((draft) => { shell={shell.icon}
draft.shot = undefined; rowChild
first={index === 0}
key={shell.id}
last={index === antagonist.gun.shells.length - 1}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.shell = shell;
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}); });
}); }}
}} />
/> );
); })}
})} </Flex>
</Flex>
<Flex align="center" gap="4"> <Button
{!floating && <Text>Versus</Text>} variant="ghost"
onClick={() => {
mutateDuel((draft) => {
[draft.protagonist, draft.antagonist] = [
draft.antagonist,
draft.protagonist,
];
pushTankopediaPath(draft.antagonist!.tank.id);
});
}}
>
<ShuffleIcon /> Swap
</Button>
{floating && ( <Flex align="center" gap="4">
<Flex gap="2" align="center">
Versus {antagonist.tank.name}
<SmallTankIcon id={antagonist.tank.id} size={16} />
</Flex>
)}
{!floating && (
<Dialog.Root <Dialog.Root
open={antagonistSelectorOpen} open={antagonistSelectorOpen}
onOpenChange={setAntagonistSelectorVisible} onOpenChange={setAntagonistSelectorVisible}
@@ -96,9 +98,8 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
<Dialog.Trigger> <Dialog.Trigger>
<Button variant="ghost"> <Button variant="ghost">
<Flex gap="2" align="center"> <Flex gap="2" align="center">
{antagonist.tank.name} Versus {antagonist.tank.name}
<SmallTankIcon id={antagonist.tank.id} size={16} /> <SmallTankIcon id={antagonist.tank.id} size={16} />
<CaretRightIcon />
</Flex> </Flex>
</Button> </Button>
</Dialog.Trigger> </Dialog.Trigger>
@@ -160,61 +161,51 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
<Flex gap="2" wrap="wrap"> <Flex gap="2" wrap="wrap">
<Flex> <Flex>
{antagonist.tank.turrets.map((turret, index) => ( {antagonist.tank.turrets.map((turret, index) => (
<Tooltip content={turret.name} key={turret.id}> <ModuleButton
<ModuleButton rowChild
rowChild first={index === 0}
first={index === 0} last={
last={ index === antagonist.tank.turrets.length - 1
index === }
antagonist.tank.turrets.length - 1 key={turret.id}
} onClick={() => {
key={turret.id} mutateDuel((draft) => {
onClick={() => { draft.antagonist!.turret = turret;
mutateDuel((draft) => { draft.antagonist!.gun =
draft.antagonist!.turret = turret; turret.guns.at(-1)!;
draft.antagonist!.gun = draft.antagonist!.shell =
turret.guns.at(-1)!; draft.antagonist!.gun.shells[0];
draft.antagonist!.shell = });
draft.antagonist!.gun.shells[0]; mutateTankopediaTemporary((draft) => {
}); draft.shot = undefined;
mutateTankopediaTemporary((draft) => { });
draft.shot = undefined; }}
}); selected={antagonist.turret.id === turret.id}
}} discriminator={
selected={ TIER_ROMAN_NUMERALS[turret.tier]
antagonist.turret.id === turret.id }
} module="turret"
discriminator={ />
TIER_ROMAN_NUMERALS[turret.tier]
}
module="turret"
/>
</Tooltip>
))} ))}
</Flex> </Flex>
<Flex> <Flex>
{antagonist.turret.guns.map((gun, index) => ( {antagonist.turret.guns.map((gun, index) => (
<Tooltip content={gun.name} key={gun.id}> <ModuleButton
<ModuleButton rowChild
rowChild first={index === 0}
first={index === 0} last={
last={ index === antagonist.turret.guns.length - 1
index === }
antagonist.turret.guns.length - 1 onClick={() => {
} mutateDuel((draft) => {
onClick={() => { draft.antagonist!.gun = gun;
mutateDuel((draft) => { draft.antagonist!.shell = gun.shells[0];
draft.antagonist!.gun = gun; });
draft.antagonist!.shell = gun.shells[0]; }}
}); selected={antagonist.gun.id === gun.id}
}} discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
selected={antagonist.gun.id === gun.id} module="gun"
discriminator={ />
TIER_ROMAN_NUMERALS[gun.tier]
}
module="gun"
/>
</Tooltip>
))} ))}
</Flex> </Flex>
</Flex> </Flex>
@@ -251,24 +242,9 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
</Tabs.Root> </Tabs.Root>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
)} </Flex>
</Flex> </Flex>
</Card>
<Button </Flex>
variant="ghost"
onClick={() => {
mutateDuel((draft) => {
[draft.protagonist, draft.antagonist] = [
draft.antagonist,
draft.protagonist,
];
pushTankopediaPath(draft.antagonist!.tank.id);
});
}}
>
<ShuffleIcon /> Swap
</Button>
</Flex>
</Card>
); );
} }
@@ -1,5 +1,5 @@
import { Flex } from '@radix-ui/themes';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import * as styles from '../../../../page.css';
interface ConfigurationChildWrapperProps { interface ConfigurationChildWrapperProps {
children: ReactNode; children: ReactNode;
@@ -8,5 +8,9 @@ interface ConfigurationChildWrapperProps {
export function ConfigurationChildWrapper({ export function ConfigurationChildWrapper({
children, children,
}: ConfigurationChildWrapperProps) { }: ConfigurationChildWrapperProps) {
return <div className={styles.configurationChild}>{children}</div>; return (
<Flex gap="2" direction="column">
{children}
</Flex>
);
} }
@@ -1,4 +1,12 @@
import { Flex, Heading } from '@radix-ui/themes'; import { InfoCircledIcon } from '@radix-ui/react-icons';
import {
Button,
Flex,
Heading,
IconButton,
Popover,
Text,
} from '@radix-ui/themes';
import { use } from 'react'; import { use } from 'react';
import { ConsumableButton } from '../../../../../../../components/ModuleButtons/ConsumableButton'; import { ConsumableButton } from '../../../../../../../components/ModuleButtons/ConsumableButton';
import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkrieg/checkConsumableProvisionInclusivity'; import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkrieg/checkConsumableProvisionInclusivity';
@@ -27,7 +35,38 @@ export function Consumables() {
return ( return (
<ConfigurationChildWrapper> <ConfigurationChildWrapper>
<Heading size="4">Consumables</Heading> <Flex gap="4" align="center">
<Flex gap="2" align="center">
<Heading size="4">Consumables</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Text>
<Text color="amber">Yellow consumables</Text> do not affect the
statistics.
</Text>
</Popover.Content>
</Popover.Root>
</Flex>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.consumables = [];
});
}}
>
Clear
</Button>
</Flex>
<Flex wrap="wrap"> <Flex wrap="wrap">
{consumablesList.map((consumable, index) => { {consumablesList.map((consumable, index) => {
@@ -35,10 +74,11 @@ export function Consumables() {
return ( return (
<ConsumableButton <ConsumableButton
special={consumable.duration === undefined}
duration={ duration={
typeof consumable.duration === 'number' consumable.duration === undefined
? consumable.duration * (hasHighEndConsumables ? 0.7 : 1) ? undefined
: undefined : consumable.duration * (hasHighEndConsumables ? 0.7 : 1)
} }
cooldown={ cooldown={
consumable.cooldown * consumable.cooldown *
@@ -1,11 +1,11 @@
import { Flex, Heading } from '@radix-ui/themes'; import { Button, Flex, Heading } from '@radix-ui/themes';
import { use } from 'react'; import { use } from 'react';
import { EquipmentButton } from '../../../../../../../components/ModuleButtons/EquipmentButton'; import { EquipmentButton } from '../../../../../../../components/ModuleButtons/EquipmentButton';
import { equipmentDefinitions } from '../../../../../../../core/blitzkrieg/equipmentDefinitions'; import { equipmentDefinitions } from '../../../../../../../core/blitzkrieg/equipmentDefinitions';
import { mutateDuel, useDuel } from '../../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper'; import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Equipments() { export function Equipment() {
const protagonist = useDuel((state) => state.protagonist!); const protagonist = useDuel((state) => state.protagonist!);
const awaitedEquipmentDefinitions = use(equipmentDefinitions); const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const equipmentRows = const equipmentRows =
@@ -14,7 +14,24 @@ export function Equipments() {
return ( return (
<ConfigurationChildWrapper> <ConfigurationChildWrapper>
<Heading size="4">Equipments</Heading> <Flex gap="4" align="center">
<Heading size="4">Equipment</Heading>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.equipment.forEach((row) => {
row.forEach((_, index) => {
row[index] = 0;
});
});
});
}}
>
Clear
</Button>
</Flex>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
{equipmentRows.map((equipmentRow, rowIndex) => ( {equipmentRows.map((equipmentRow, rowIndex) => (
@@ -87,36 +87,35 @@ export function Miscellaneous() {
direction="column" direction="column"
gap="1" gap="1"
justify="end" justify="end"
style={{ width: 180 }} style={{ width: 120 }}
> >
<TextField.Root> <TextField.Root
ref={crewInput}
defaultValue={Math.round(crewMastery * 100)}
style={{ textAlign: 'right' }}
onBlur={() => {
const newValue = Number(crewInput.current!.value) / 100;
if (isNaN(newValue)) {
crewInput.current!.value = `${Math.round(crewMastery * 100)}`;
return;
}
mutateDuel((draft) => {
draft.protagonist!.crewMastery = clamp(
newValue / 100,
0.5,
1,
);
});
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
crewInput.current?.blur();
}
}}
>
<TextField.Slot>Crew</TextField.Slot> <TextField.Slot>Crew</TextField.Slot>
<TextField.Input
ref={crewInput}
defaultValue={Math.round(crewMastery * 100)}
style={{ textAlign: 'right' }}
onBlur={() => {
const newValue = Number(crewInput.current!.value) / 100;
if (isNaN(newValue)) {
crewInput.current!.value = `${Math.round(crewMastery * 100)}`;
return;
}
mutateDuel((draft) => {
draft.protagonist!.crewMastery = clamp(
newValue / 100,
0.5,
1,
);
});
}}
onKeyUp={(event) => {
if (event.key === 'Enter') {
crewInput.current?.blur();
}
}}
/>
<TextField.Slot>%</TextField.Slot> <TextField.Slot>%</TextField.Slot>
</TextField.Root> </TextField.Root>
@@ -1,4 +1,5 @@
import { Flex, Heading } from '@radix-ui/themes'; import { Button, DropdownMenu, Flex, Heading } from '@radix-ui/themes';
import { useState } from 'react';
import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton'; import { ModuleButton } from '../../../../../../../components/ModuleButtons/ModuleButton';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkrieg/tankDefinitions/constants'; import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkrieg/tankDefinitions/constants';
import { mutateDuel, useDuel } from '../../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
@@ -7,101 +8,236 @@ import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Modules() { export function Modules() {
const protagonist = useDuel((state) => state.protagonist!); const protagonist = useDuel((state) => state.protagonist!);
const hasUpgrades =
protagonist.tank.turrets.length > 1 ||
protagonist.tank.turrets[0].guns.length > 1 ||
protagonist.tank.engines.length > 1 ||
protagonist.tank.tracks.length > 1;
const [turretMenuOpen, setTurretMenuOpen] = useState(false);
const [gunMenuOpen, setGunMenuOpen] = useState(false);
const [engineMenuOpen, setEngineMenuOpen] = useState(false);
const [trackMenuOpen, setTrackMenuOpen] = useState(false);
return ( return (
<ConfigurationChildWrapper> <ConfigurationChildWrapper>
<Heading size="4">Modules</Heading> <Flex gap="4" align="center">
<Heading size="4">Modules</Heading>
{hasUpgrades && (
<>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.turret =
draft.protagonist!.tank.turrets[0];
draft.protagonist!.gun = draft.protagonist!.turret.guns[0];
draft.protagonist!.shell = draft.protagonist!.gun.shells[0];
draft.protagonist!.engine =
draft.protagonist!.tank.engines[0];
draft.protagonist!.track = draft.protagonist!.tank.tracks[0];
});
}}
>
Stock
</Button>
<Button
variant="ghost"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.turret =
draft.protagonist!.tank.turrets.at(-1)!;
draft.protagonist!.gun =
draft.protagonist!.turret.guns.at(-1)!;
draft.protagonist!.shell =
draft.protagonist!.gun.shells.at(-1)!;
draft.protagonist!.engine =
draft.protagonist!.tank.engines.at(-1)!;
draft.protagonist!.track =
draft.protagonist!.tank.tracks.at(-1)!;
});
}}
>
Upgrade
</Button>
</>
)}
</Flex>
<Flex gap="2" wrap="wrap"> <Flex gap="1" wrap="wrap">
<Flex> <ModuleButton
{protagonist.tank.turrets.map((turret, index) => { module="turret"
return ( discriminator={TIER_ROMAN_NUMERALS[protagonist.turret.tier]}
<ModuleButton first
key={turret.id} last
selected={protagonist.turret.id === turret.id} rowChild
module="turret" onClick={() => setTurretMenuOpen(true)}
discriminator={TIER_ROMAN_NUMERALS[turret.tier]} />
first={index === 0}
last={index === protagonist.tank.turrets.length - 1}
rowChild
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.turret = turret;
draft.protagonist!.gun = turret.guns.at(-1)!;
draft.protagonist!.shell = turret.guns.at(-1)!.shells[0];
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
/>
);
})}
</Flex>
<Flex> <DropdownMenu.Root
{protagonist.turret.guns.map((gun, index) => { open={turretMenuOpen}
return ( onOpenChange={setTurretMenuOpen}
<ModuleButton >
key={gun.id} <DropdownMenu.Trigger>
module="gun" <div />
discriminator={TIER_ROMAN_NUMERALS[gun.tier]} </DropdownMenu.Trigger>
selected={protagonist.gun.id === gun.id}
first={index === 0}
last={index === protagonist.turret.guns.length - 1}
rowChild
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.gun = gun;
draft.protagonist!.shell = gun.shells[0];
});
}}
/>
);
})}
</Flex>
<Flex> <DropdownMenu.Content>
{protagonist.tank.engines.map((engine, index) => { <DropdownMenu.RadioGroup value={`${protagonist.turret.id}`}>
return ( {[...protagonist.tank.turrets].reverse().map((turret) => {
<ModuleButton return (
key={engine.id} <DropdownMenu.RadioItem
module="engine" key={turret.id}
discriminator={TIER_ROMAN_NUMERALS[engine.tier]} value={`${turret.id}`}
selected={protagonist.engine.id === engine.id} onClick={() => {
first={index === 0} mutateDuel((draft) => {
last={index === protagonist.tank.engines.length - 1} draft.protagonist!.turret = turret;
rowChild draft.protagonist!.gun = turret.guns.at(-1)!;
onClick={() => { draft.protagonist!.shell =
mutateDuel((draft) => { turret.guns.at(-1)!.shells[0];
draft.protagonist!.engine = engine; });
}); mutateTankopediaTemporary((draft) => {
}} draft.shot = undefined;
/> });
); setTurretMenuOpen(false);
})} }}
</Flex> >
<ModuleButton
variant="ghost"
module="turret"
discriminator={TIER_ROMAN_NUMERALS[turret.tier]}
/>{' '}
{turret.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Flex> <ModuleButton
{protagonist.tank.tracks.map((track, index) => { module="gun"
return ( discriminator={TIER_ROMAN_NUMERALS[protagonist.gun.tier]}
<ModuleButton first
key={track.id} last
module="chassis" rowChild
discriminator={TIER_ROMAN_NUMERALS[track.tier]} onClick={() => setGunMenuOpen(true)}
selected={protagonist.track.id === track.id} />
first={index === 0}
last={index === protagonist.tank.tracks.length - 1} <DropdownMenu.Root open={gunMenuOpen} onOpenChange={setGunMenuOpen}>
rowChild <DropdownMenu.Trigger>
onClick={() => { <div />
mutateDuel((draft) => { </DropdownMenu.Trigger>
draft.protagonist!.track = track;
}); <DropdownMenu.Content>
}} <DropdownMenu.RadioGroup value={`${protagonist.gun.id}`}>
/> {[...protagonist.turret.guns].reverse().map((gun) => {
); return (
})} <DropdownMenu.RadioItem
</Flex> key={gun.id}
value={`${gun.id}`}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.gun = gun;
draft.protagonist!.shell = gun.shells[0];
});
}}
>
<ModuleButton
variant="ghost"
module="gun"
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
/>{' '}
{gun.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<ModuleButton
module="engine"
discriminator={TIER_ROMAN_NUMERALS[protagonist.engine.tier]}
first
last
rowChild
onClick={() => setEngineMenuOpen(true)}
/>
<DropdownMenu.Root
open={engineMenuOpen}
onOpenChange={setEngineMenuOpen}
>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${protagonist.engine.id}`}>
{[...protagonist.tank.engines].reverse().map((engine) => {
return (
<DropdownMenu.RadioItem
key={engine.id}
value={`${engine.id}`}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.engine = engine;
});
}}
>
<ModuleButton
variant="ghost"
module="engine"
discriminator={TIER_ROMAN_NUMERALS[engine.tier]}
/>{' '}
{engine.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<ModuleButton
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[protagonist.track.tier]}
first
last
rowChild
onClick={() => setTrackMenuOpen(true)}
/>
<DropdownMenu.Root open={trackMenuOpen} onOpenChange={setTrackMenuOpen}>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${protagonist.track.id}`}>
{[...protagonist.tank.tracks].reverse().map((track) => {
return (
<DropdownMenu.RadioItem
key={track.id}
value={`${track.id}`}
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.track = track;
});
}}
>
<ModuleButton
variant="ghost"
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[track.tier]}
/>{' '}
{track.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Flex> </Flex>
</ConfigurationChildWrapper> </ConfigurationChildWrapper>
); );
@@ -1,4 +1,4 @@
import { Flex, Heading } from '@radix-ui/themes'; import { Button, Flex, Heading } from '@radix-ui/themes';
import { use } from 'react'; import { use } from 'react';
import { ProvisionButton } from '../../../../../../../components/ModuleButtons/ProvisionButton'; import { ProvisionButton } from '../../../../../../../components/ModuleButtons/ProvisionButton';
import { availableProvisions } from '../../../../../../../core/blitzkrieg/availableProvisions'; import { availableProvisions } from '../../../../../../../core/blitzkrieg/availableProvisions';
@@ -18,7 +18,20 @@ export function Provisions() {
return ( return (
<ConfigurationChildWrapper> <ConfigurationChildWrapper>
<Heading size="4">Provisions</Heading> <Flex gap="4" align="center">
<Heading size="4">Provisions</Heading>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.provisions = [];
});
}}
>
Clear
</Button>
</Flex>
<Flex wrap="wrap"> <Flex wrap="wrap">
{provisionsList.map((provision, index) => { {provisionsList.map((provision, index) => {
@@ -0,0 +1,8 @@
export const permanentSkills = [
'smooth_turn',
'soft_recoil',
'smooth_driving',
'smooth_turret',
'virtuoso',
'camouflage',
];
@@ -0,0 +1,134 @@
import { InfoCircledIcon } from '@radix-ui/react-icons';
import {
Button,
Flex,
Heading,
IconButton,
Kbd,
Popover,
Text,
} from '@radix-ui/themes';
import { use } from 'react';
import { GenericTankComponentButton } from '../../../../../../../../components/ModuleButtons/GenericTankComponentButton';
import { asset } from '../../../../../../../../core/blitzkrieg/asset';
import { skillDefinitions } from '../../../../../../../../core/blitzkrieg/skillDefinitions';
import { Tier } from '../../../../../../../../core/blitzkrieg/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../../core/blitzkrieg/tankDefinitions/constants';
import {
mutateTankopediaTemporary,
useTankopediaTemporary,
} from '../../../../../../../../stores/tankopedia';
import { ConfigurationChildWrapper } from '../ConfigurationChildWrapper';
import { permanentSkills } from './constants';
export function Skills() {
const awaitedSkillDefinitions = use(skillDefinitions);
const skillLevels = useTankopediaTemporary((state) => state.skills);
if (Object.keys(skillLevels).length === 0) {
mutateTankopediaTemporary((draft) => {
Object.values(awaitedSkillDefinitions.classes).forEach((skills) => {
skills.forEach((skill) => {
draft.skills[skill] = permanentSkills.includes(skill) ? 7 : 0;
});
});
});
return null;
}
return (
<ConfigurationChildWrapper>
<Flex gap="4" align="center">
<Flex gap="2" align="center">
<Heading size="4">Crew skills</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex direction="column" gap="2">
<Text>
<Text color="amber">Yellow skills</Text> only apply under
special circumstances.
</Text>
<Text>
Hold <Kbd>Shift</Kbd> to quickly toggle between level 0 and 7.
</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
<Button
variant="ghost"
color="red"
onClick={() => {
mutateTankopediaTemporary((draft) => {
Object.keys(draft.skills).forEach((skill) => {
draft.skills[skill] = 0;
});
});
}}
>
Clear
</Button>
<Button
variant="ghost"
onClick={() => {
mutateTankopediaTemporary((draft) => {
Object.keys(draft.skills).forEach((skill) => {
draft.skills[skill] = 7;
});
});
}}
>
Maximize
</Button>
</Flex>
<Flex direction="column" gap="2">
{Object.entries(awaitedSkillDefinitions.classes).map(
([tankClass, skills]) => (
<Flex gap="2" key={tankClass}>
{skills.map((skill) => {
const level = skillLevels[skill];
return (
<GenericTankComponentButton
special={!permanentSkills.includes(skill)}
first
last
selected={level > 0}
discriminator={
level === 0
? undefined
: TIER_ROMAN_NUMERALS[level as Tier]
}
icon={asset(`icons/skills/${skill}.webp`)}
onClick={(event) => {
mutateTankopediaTemporary((draft) => {
if (event.shiftKey) {
draft.skills[skill] =
draft.skills[skill] === 0 ? 7 : 0;
} else {
draft.skills[skill] = level - 1;
if (draft.skills[skill] < 0) draft.skills[skill] = 7;
}
});
}}
/>
);
})}
</Flex>
),
)}
</Flex>
</ConfigurationChildWrapper>
);
}
@@ -1,5 +1,13 @@
import { AccessibilityIcon } from '@radix-ui/react-icons'; import { AccessibilityIcon, InfoCircledIcon } from '@radix-ui/react-icons';
import { Flex, Heading, Slider, Text, TextField } from '@radix-ui/themes'; import {
Flex,
Heading,
IconButton,
Popover,
Slider,
Text,
TextField,
} from '@radix-ui/themes';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { use, useEffect, useRef, useState } from 'react'; import { use, useEffect, useRef, useState } from 'react';
import { lerp } from 'three/src/math/MathUtils'; import { lerp } from 'three/src/math/MathUtils';
@@ -22,17 +30,21 @@ import {
import { unionBoundingBox } from '../../../../../../core/blitzkrieg/unionBoundingBox'; import { unionBoundingBox } from '../../../../../../core/blitzkrieg/unionBoundingBox';
import { useConsumable } from '../../../../../../hooks/useConsumable'; import { useConsumable } from '../../../../../../hooks/useConsumable';
import { useEquipment } from '../../../../../../hooks/useEquipment'; import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { useProvision } from '../../../../../../hooks/useProvision'; import { useProvision } from '../../../../../../hooks/useProvision';
import { useProvisions } from '../../../../../../hooks/useProvisions'; import { useProvisions } from '../../../../../../hooks/useProvisions';
import { mutateDuel, useDuel } from '../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../stores/duel';
import { useTankopediaTemporary } from '../../../../../../stores/tankopedia';
import { Info } from './components/Info'; import { Info } from './components/Info';
import { InfoWithDelta } from './components/InfoWithDelta'; import { InfoWithDelta } from './components/InfoWithDelta';
export function Characteristics() { export function Characteristics() {
const awaitedModelDefinitions = use(modelDefinitions); const awaitedModelDefinitions = use(modelDefinitions);
const crewSkills = useTankopediaTemporary((state) => state.skills);
const { tank, turret, gun, engine, track, shell } = useDuel( const { tank, turret, gun, engine, track, shell } = useDuel(
(state) => state.protagonist!, (state) => state.protagonist!,
); );
const isFullScreen = useFullScreen();
const hasImprovedVentilation = useEquipment(102); const hasImprovedVentilation = useEquipment(102);
const provisions = useProvisions(); const provisions = useProvisions();
const crewMastery = useDuel((state) => state.protagonist!.crewMastery); const crewMastery = useDuel((state) => state.protagonist!.crewMastery);
@@ -132,10 +144,26 @@ export function Characteristics() {
const dispersionStillCoefficient = const dispersionStillCoefficient =
coefficient([hasRefinedGun, -0.1], [hasReticleCalibration, -0.4]) * coefficient([hasRefinedGun, -0.1], [hasReticleCalibration, -0.4]) *
coefficient([true, degressiveStat(crewMemberMastery)]); coefficient([true, degressiveStat(crewMemberMastery)]);
const dispersionMovingCoefficient = coefficient([ const dispersionDamagedCoefficient = coefficient([
hasVerticalStabilizer, hasVerticalStabilizer,
-0.15, -0.15,
]); ]);
const dispersionHullTraverseCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.smooth_turn / 100],
);
const dispersionAfterShotCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.soft_recoil / 100],
);
const dispersionMovingCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.smooth_driving / 100],
);
const dispersionTurretTraverseCoefficient = coefficient(
[hasVerticalStabilizer, -0.15],
[true, -crewSkills.smooth_turret * (2 / 100)],
);
const enginePowerCoefficient = coefficient( const enginePowerCoefficient = coefficient(
[ [
hasEngineAccelerator && hasEngineAccelerator &&
@@ -161,6 +189,7 @@ export function Characteristics() {
coefficient( coefficient(
[hasImprovedControl, 0.1], [hasImprovedControl, 0.1],
[hasImprovedEnginePowerBoost, 0.05], [hasImprovedEnginePowerBoost, 0.05],
[true, crewSkills.virtuoso / 100],
) * coefficient([true, progressiveStat(crewMemberMastery)]); ) * coefficient([true, progressiveStat(crewMemberMastery)]);
const resistanceCoefficient = coefficient([hasImprovedSuspension, -0.25]); const resistanceCoefficient = coefficient([hasImprovedSuspension, -0.25]);
const viewRangeCoefficient = const viewRangeCoefficient =
@@ -204,6 +233,7 @@ export function Characteristics() {
camouflage, camouflage,
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02, tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
], ],
[true, crewSkills.camouflage * (3 / 100)],
); );
const resolvedEnginePower = engine.power * enginePowerCoefficient; const resolvedEnginePower = engine.power * enginePowerCoefficient;
@@ -232,6 +262,8 @@ export function Characteristics() {
}); });
}, [hasImprovedVerticalStabilizer]); }, [hasImprovedVerticalStabilizer]);
if (isFullScreen) return null;
return ( return (
<Flex direction="column" gap="8" style={{ width: '100%' }}> <Flex direction="column" gap="8" style={{ width: '100%' }}>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
@@ -390,25 +422,25 @@ export function Characteristics() {
setPenetrationDistance(value); setPenetrationDistance(value);
}, 500)} }, 500)}
/> />
<TextField.Root style={{ width: 64 }}> <TextField.Root
<TextField.Input style={{ width: 64 }}
ref={penetrationDistanceInput} ref={penetrationDistanceInput}
defaultValue={penetrationDistance} defaultValue={penetrationDistance}
onBlur={() => { onBlur={() => {
setPenetrationDistance( setPenetrationDistance(
Math.min( Math.min(
parseInt(penetrationDistanceInput.current!.value), parseInt(penetrationDistanceInput.current!.value),
500, 500,
), ),
); );
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {
event.currentTarget.blur(); event.currentTarget.blur();
} }
}} }}
/> >
<TextField.Slot>m</TextField.Slot> <TextField.Slot side="right">m</TextField.Slot>
</TextField.Root> </TextField.Root>
</Flex> </Flex>
</> </>
@@ -419,6 +451,11 @@ export function Characteristics() {
<InfoWithDelta name="Module damage" unit="hp" decimals={0}> <InfoWithDelta name="Module damage" unit="hp" decimals={0}>
{shell.damage.module * damageCoefficient} {shell.damage.module * damageCoefficient}
</InfoWithDelta> </InfoWithDelta>
{isExplosive(shell.type) && (
<InfoWithDelta name="Splash radius" unit="m" decimals={0}>
{shell.explosionRadius!}
</InfoWithDelta>
)}
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}> <InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
{shell.speed * shellVelocityCoefficient} {shell.speed * shellVelocityCoefficient}
</InfoWithDelta> </InfoWithDelta>
@@ -456,7 +493,7 @@ export function Characteristics() {
name="Hull traversing" name="Hull traversing"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{track.dispersion.traverse * dispersionMovingCoefficient} {track.dispersion.traverse * dispersionHullTraverseCoefficient}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={3} decimals={3}
@@ -465,7 +502,7 @@ export function Characteristics() {
name="Turret traversing" name="Turret traversing"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.traverse * dispersionMovingCoefficient} {gun.dispersion.traverse * dispersionTurretTraverseCoefficient}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={3} decimals={3}
@@ -474,7 +511,7 @@ export function Characteristics() {
name="After shooting" name="After shooting"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.shot * dispersionMovingCoefficient} {gun.dispersion.shot * dispersionAfterShotCoefficient}
</InfoWithDelta> </InfoWithDelta>
<InfoWithDelta <InfoWithDelta
decimals={1} decimals={1}
@@ -484,7 +521,7 @@ export function Characteristics() {
unit="scalar" unit="scalar"
deltaType="lowerIsBetter" deltaType="lowerIsBetter"
> >
{gun.dispersion.damaged * dispersionMovingCoefficient} {gun.dispersion.damaged * dispersionDamagedCoefficient}
</InfoWithDelta> </InfoWithDelta>
<Info name="Gun flexibility" unit="°" /> <Info name="Gun flexibility" unit="°" />
<InfoWithDelta decimals={1} indent name="Depression"> <InfoWithDelta decimals={1} indent name="Depression">
@@ -632,7 +669,24 @@ export function Characteristics() {
</Flex> </Flex>
<Flex direction="column" gap="2"> <Flex direction="column" gap="2">
<Heading size="5">Crew training</Heading> <Flex gap="2" align="center">
<Heading size="5">Crew training</Heading>
<Popover.Root>
<Popover.Trigger>
<IconButton variant="ghost">
<InfoCircledIcon />
</IconButton>
</Popover.Trigger>
<Popover.Content>
<Flex gap="1" align="center">
<AccessibilityIcon />
<Text>represents the other roles a crew member can take.</Text>
</Flex>
</Popover.Content>
</Popover.Root>
</Flex>
{tank.crew.map((member) => { {tank.crew.map((member) => {
const count = member.count ?? 1; const count = member.count ?? 1;
@@ -12,6 +12,7 @@ import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTran
import { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose'; import { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose';
import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon'; import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon';
import { useEquipment } from '../../../../../../hooks/useEquipment'; import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { useWideFormat } from '../../../../../../hooks/useWideFormat'; import { useWideFormat } from '../../../../../../hooks/useWideFormat';
import { mutateDuel, useDuel } from '../../../../../../stores/duel'; import { mutateDuel, useDuel } from '../../../../../../stores/duel';
import mutateTankopediaPersistent, { import mutateTankopediaPersistent, {
@@ -33,7 +34,6 @@ export function TankSandbox() {
const hasImprovedVerticalStabilizer = useEquipment(122); const hasImprovedVerticalStabilizer = useEquipment(122);
const awaitedModelDefinitions = use(modelDefinitions); const awaitedModelDefinitions = use(modelDefinitions);
const canvasWrapper = useRef<HTMLDivElement>(null); const canvasWrapper = useRef<HTMLDivElement>(null);
const [isFullScreen, setIsFullScreen] = useState(false);
const mode = useTankopediaPersistent((state) => state.mode); const mode = useTankopediaPersistent((state) => state.mode);
const protagonist = useDuel((state) => state.protagonist!); const protagonist = useDuel((state) => state.protagonist!);
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id]; const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
@@ -43,6 +43,7 @@ export function TankSandbox() {
const wideFormat = useWideFormat(); const wideFormat = useWideFormat();
const [loadModel, setLoadModel] = useState(wideFormat || !isMobile); const [loadModel, setLoadModel] = useState(wideFormat || !isMobile);
const duel = useDuel(); const duel = useDuel();
const isFullScreen = useFullScreen();
function handlePointerDown() { function handlePointerDown() {
window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointermove', handlePointerMove);
@@ -59,10 +60,6 @@ export function TankSandbox() {
} }
useEffect(() => { useEffect(() => {
function handleFullScreenChange() {
setIsFullScreen(document.fullscreenElement !== null);
}
function handlePoseEvent(pose: Pose) { function handlePoseEvent(pose: Pose) {
switch (pose) { switch (pose) {
case Pose.HullDown: { case Pose.HullDown: {
@@ -121,10 +118,8 @@ export function TankSandbox() {
} }
poseEvent.on(handlePoseEvent); poseEvent.on(handlePoseEvent);
document.addEventListener('fullscreenchange', handleFullScreenChange);
return () => { return () => {
document.removeEventListener('fullscreenchange', handleFullScreenChange);
poseEvent.off(handlePoseEvent); poseEvent.off(handlePoseEvent);
}; };
}); });
@@ -145,8 +140,13 @@ export function TankSandbox() {
<Theme radius={isFullScreen ? 'none' : undefined}> <Theme radius={isFullScreen ? 'none' : undefined}>
<Card <Card
style={{ style={{
position: 'relative', position: isFullScreen ? 'fixed' : 'relative',
border: isFullScreen ? 'none' : 'unset', border: isFullScreen ? 'none' : 'unset',
width: isFullScreen ? '100vw' : 'unset',
height: isFullScreen ? 'calc(100vh)' : 'unset',
top: isFullScreen ? 0 : 'unset',
left: isFullScreen ? 0 : 'unset',
zIndex: isFullScreen ? 2 : 'unset',
}} }}
ref={canvasWrapper} ref={canvasWrapper}
> >
@@ -8,6 +8,7 @@ import { Button, DropdownMenu, Flex } from '@radix-ui/themes';
import { RefObject } from 'react'; import { RefObject } from 'react';
import { Pose, poseEvent } from '../../../../../../../core/blitzkrieg/pose'; import { Pose, poseEvent } from '../../../../../../../core/blitzkrieg/pose';
import mutateTankopediaPersistent, { import mutateTankopediaPersistent, {
mutateTankopediaTemporary,
useTankopediaPersistent, useTankopediaPersistent,
} from '../../../../../../../stores/tankopedia'; } from '../../../../../../../stores/tankopedia';
import { ENVIRONMENTS } from '../../Lighting'; import { ENVIRONMENTS } from '../../Lighting';
@@ -17,7 +18,7 @@ interface OptionsProps {
canvasWrapper: RefObject<HTMLElement>; canvasWrapper: RefObject<HTMLElement>;
} }
export function Options({ isFullScreen, canvasWrapper }: OptionsProps) { export function Options({ isFullScreen }: OptionsProps) {
const showGrid = useTankopediaPersistent( const showGrid = useTankopediaPersistent(
(state) => state.model.visual.showGrid, (state) => state.model.visual.showGrid,
); );
@@ -30,6 +31,9 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
const wireframe = useTankopediaPersistent( const wireframe = useTankopediaPersistent(
(state) => state.model.visual.wireframe, (state) => state.model.visual.wireframe,
); );
const showSpacedArmor = useTankopediaPersistent(
(state) => state.model.visual.showSpacedArmor,
);
const opaque = useTankopediaPersistent((state) => state.model.visual.opaque); const opaque = useTankopediaPersistent((state) => state.model.visual.opaque);
const fullScreenAvailable = const fullScreenAvailable =
typeof document !== 'undefined' && document.fullscreenEnabled; typeof document !== 'undefined' && document.fullscreenEnabled;
@@ -46,122 +50,132 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
right: 18, right: 18,
}} }}
> >
{!isFullScreen && ( <DropdownMenu.Root>
<DropdownMenu.Root> <DropdownMenu.Trigger>
<DropdownMenu.Trigger> <Button variant="ghost">
<Button variant="ghost"> <EyeOpenIcon />
<EyeOpenIcon /> </Button>
</Button> </DropdownMenu.Trigger>
</DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}> <DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
Hull down Hull down
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}> <DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
Face hug Face hug
</DropdownMenu.Item> </DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}> <DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
Default Default
</DropdownMenu.Item> </DropdownMenu.Item>
</DropdownMenu.Content> </DropdownMenu.Content>
</DropdownMenu.Root> </DropdownMenu.Root>
)}
{!isFullScreen && ( <DropdownMenu.Root>
<DropdownMenu.Root> <DropdownMenu.Trigger>
<DropdownMenu.Trigger> <Button variant="ghost">
<Button variant="ghost"> <GearIcon />
<GearIcon /> </Button>
</Button> </DropdownMenu.Trigger>
</DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
<DropdownMenu.Label>Armor</DropdownMenu.Label> <DropdownMenu.Label>Armor</DropdownMenu.Label>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={greenPenetration} checked={showSpacedArmor}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.greenPenetration = checked; draft.model.visual.showSpacedArmor = checked;
}); });
}} mutateTankopediaTemporary((draft) => {
> draft.shot = undefined;
Green penetration });
</DropdownMenu.CheckboxItem> }}
>
Spaced armor
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={wireframe} checked={greenPenetration}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked; draft.model.visual.greenPenetration = checked;
}); });
}} }}
> >
Wireframe Green penetration
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={opaque} checked={wireframe}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.opaque = checked; draft.model.visual.wireframe = checked;
}); });
}} }}
> >
Opaque Wireframe
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.Label>Environment</DropdownMenu.Label> <DropdownMenu.CheckboxItem
checked={opaque}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.opaque = checked;
});
}}
>
Opaque
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.Label>Environment</DropdownMenu.Label>
checked={showGrid}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showGrid = checked;
});
}}
>
Show grid
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={showEnvironment} checked={showGrid}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.model.visual.showEnvironment = checked; draft.model.visual.showGrid = checked;
}); });
}} }}
> >
View environment Show grid
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.Sub> <DropdownMenu.CheckboxItem
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger> checked={showEnvironment}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showEnvironment = checked;
});
}}
>
View environment
</DropdownMenu.CheckboxItem>
<DropdownMenu.SubContent> <DropdownMenu.Sub>
<DropdownMenu.RadioGroup value={environment}> <DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
{ENVIRONMENTS.map((environment) => (
<DropdownMenu.RadioItem <DropdownMenu.SubContent>
key={environment} <DropdownMenu.RadioGroup value={environment}>
value={environment} {ENVIRONMENTS.map((environment) => (
onClick={() => { <DropdownMenu.RadioItem
mutateTankopediaPersistent((draft) => { key={environment}
draft.model.visual.environment = environment; value={environment}
}); onClick={() => {
}} mutateTankopediaPersistent((draft) => {
> draft.model.visual.environment = environment;
{environment[0].toUpperCase() + environment.slice(1)} });
</DropdownMenu.RadioItem> }}
))} >
</DropdownMenu.RadioGroup> {environment[0].toUpperCase() + environment.slice(1)}
</DropdownMenu.SubContent> </DropdownMenu.RadioItem>
</DropdownMenu.Sub> ))}
</DropdownMenu.Content> </DropdownMenu.RadioGroup>
</DropdownMenu.Root> </DropdownMenu.SubContent>
)} </DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
{fullScreenAvailable && ( {fullScreenAvailable && (
<Button <Button
@@ -169,7 +183,7 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
onClick={() => { onClick={() => {
if (isFullScreen) { if (isFullScreen) {
document.exitFullscreen(); document.exitFullscreen();
} else canvasWrapper.current?.requestFullscreen(); } else document.body.requestFullscreen();
}} }}
> >
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />} {isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
@@ -46,19 +46,47 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
position: 'absolute', position: 'absolute',
left: 8, left: 8,
bottom: isFullScreen ? 80 : 8, bottom: isFullScreen ? 80 : 8,
pointerEvents: 'none',
}} }}
gap="4" gap="4"
> >
<Flex <Flex gap="2" style={{ width: 256, pointerEvents: 'auto' }}>
gap="2"
style={{
width: 256,
}}
>
<TextField.Root <TextField.Root
style={{ style={{
flex: 1, flex: 1,
textAlign: 'right',
}} }}
onBlur={() => {
const value = Number(turretYawInput.current!.value);
if (isNaN(value)) {
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
degToRad(value),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
turretYawInput.current?.blur();
}
}}
onFocus={() => turretYawInput.current?.focus()}
ref={turretYawInput}
> >
<TextField.Slot <TextField.Slot
style={{ style={{
@@ -67,46 +95,45 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
> >
Yaw Yaw
</TextField.Slot> </TextField.Slot>
<TextField.Input <TextField.Slot style={{ userSelect: 'none' }}>°</TextField.Slot>
onBlur={() => {
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
degToRad(Number(turretYawInput.current!.value)),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(
1,
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
turretYawInput.current?.blur();
}
}}
onFocus={() => turretYawInput.current?.focus()}
ref={turretYawInput}
style={{ textAlign: 'right' }}
/>
<TextField.Slot
style={{
userSelect: 'none',
}}
>
°
</TextField.Slot>
</TextField.Root> </TextField.Root>
<TextField.Root <TextField.Root
style={{ style={{
flex: 1, flex: 1,
textAlign: 'right',
}} }}
onBlur={() => {
const value = Number(gunPitchInput.current!.value);
if (isNaN(value)) {
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
return;
}
const [pitch, yaw] = applyPitchYawLimits(
degToRad(-value + initialGunPitch),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
gunPitchInput.current?.blur();
}
}}
onFocus={() => gunPitchInput.current?.focus()}
ref={gunPitchInput}
> >
<TextField.Slot <TextField.Slot
style={{ style={{
@@ -115,35 +142,6 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
> >
Pitch Pitch
</TextField.Slot> </TextField.Slot>
<TextField.Input
onBlur={() => {
const [pitch, yaw] = applyPitchYawLimits(
degToRad(
-Number(gunPitchInput.current!.value) + initialGunPitch,
),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
gunPitchInput.current?.blur();
}
}}
onFocus={() => gunPitchInput.current?.focus()}
ref={gunPitchInput}
style={{ textAlign: 'right' }}
/>
<TextField.Slot <TextField.Slot
style={{ style={{
userSelect: 'none', userSelect: 'none',
@@ -155,7 +153,7 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
</Flex> </Flex>
{mode === 'armor' && ( {mode === 'armor' && (
<Flex gap="4"> <Flex gap="4" style={{ pointerEvents: 'auto' }}>
<QuickEquipmentButton <QuickEquipmentButton
equipment={103} equipment={103}
active={hasCalibratedShells} active={hasCalibratedShells}
+16 -10
View File
@@ -10,10 +10,11 @@ import { mutateTankopediaTemporary } from '../../../../stores/tankopedia';
import { AntagonistBar } from './components/AntagonistBar'; import { AntagonistBar } from './components/AntagonistBar';
import { Characteristics } from './components/Characteristics'; import { Characteristics } from './components/Characteristics';
import { Consumables } from './components/Characteristics/components/Consumables'; import { Consumables } from './components/Characteristics/components/Consumables';
import { Equipments } from './components/Characteristics/components/Equipments'; import { Equipment } from './components/Characteristics/components/Equipment';
import { Miscellaneous } from './components/Characteristics/components/Miscellaneous'; import { Miscellaneous } from './components/Characteristics/components/Miscellaneous';
import { Modules } from './components/Characteristics/components/Modules'; import { Modules } from './components/Characteristics/components/Modules';
import { Provisions } from './components/Characteristics/components/Provisions'; import { Provisions } from './components/Characteristics/components/Provisions';
import { Skills } from './components/Characteristics/components/Skills';
import { TankSandbox } from './components/Model/TankSandbox'; import { TankSandbox } from './components/Model/TankSandbox';
import { Title } from './components/Title'; import { Title } from './components/Title';
@@ -63,10 +64,8 @@ export default function Page({ params }: { params: { id: string } }) {
}; };
}, [initialId, assigned]); }, [initialId, assigned]);
return ( return (
<PageWrapper color="purple" size="double"> <PageWrapper color="purple" size={1600}>
{assigned && ( {assigned && (
<> <>
<Title /> <Title />
@@ -79,7 +78,7 @@ export default function Page({ params }: { params: { id: string } }) {
justify="center" justify="center"
> >
<Flex <Flex
gap="4" gap="8"
direction="column" direction="column"
style={{ style={{
flex: 1, flex: 1,
@@ -88,15 +87,22 @@ export default function Page({ params }: { params: { id: string } }) {
position: wideFormat ? 'sticky' : undefined, position: wideFormat ? 'sticky' : undefined,
}} }}
> >
<TankSandbox /> <Flex gap="4" direction="column">
<AntagonistBar /> <TankSandbox />
<AntagonistBar />
</Flex>
<Flex direction="column" gap="4"> <Flex gap="5" wrap="wrap">
<Modules /> <Modules />
<Provisions /> <Provisions />
<Equipments />
<Miscellaneous />
<Consumables /> <Consumables />
<Flex gap="5" wrap="wrap">
<Skills />
<Equipment />
</Flex>
<Miscellaneous />
</Flex> </Flex>
</Flex> </Flex>
@@ -32,32 +32,31 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
> >
<CaretLeftIcon /> <CaretLeftIcon />
</Button> </Button>
<TextField.Root> <TextField.Root
defaultValue={1}
type="number"
ref={pageInput}
min={1}
max={Math.floor(searchedList.length / tanksPerPage) + 1}
style={{ textAlign: 'center' }}
onBlur={(event) => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.max(
0,
Math.min(
Math.floor(searchedList.length / tanksPerPage),
event.target.valueAsNumber - 1,
),
);
});
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
>
<TextField.Slot>Page</TextField.Slot> <TextField.Slot>Page</TextField.Slot>
<TextField.Input
defaultValue={1}
type="number"
ref={pageInput}
min={1}
max={Math.floor(searchedList.length / tanksPerPage) + 1}
style={{ width: 64, textAlign: 'center' }}
onBlur={(event) => {
mutateTankopediaPersistent((draft) => {
draft.filters.page = Math.max(
0,
Math.min(
Math.floor(searchedList.length / tanksPerPage),
event.target.valueAsNumber - 1,
),
);
});
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
(event.target as HTMLInputElement).blur();
}
}}
/>
<TextField.Slot> <TextField.Slot>
out of {Math.floor(searchedList.length / tanksPerPage) + 1} out of {Math.floor(searchedList.length / tanksPerPage) + 1}
</TextField.Slot> </TextField.Slot>
@@ -14,6 +14,7 @@ import {
TextField, TextField,
} from '@radix-ui/themes'; } from '@radix-ui/themes';
import { go } from 'fuzzysort'; import { go } from 'fuzzysort';
import Link from 'next/link';
import { use, useEffect, useMemo, useRef, useState } from 'react'; import { use, useEffect, useMemo, useRef, useState } from 'react';
import { TreeType } from '../../../../../components/Tanks'; import { TreeType } from '../../../../../components/Tanks';
import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants'; import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants';
@@ -151,7 +152,7 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
); );
break; break;
case 'survivability.size': case 'survivability.volume':
sorted = filtered.sort((a, b) => { sorted = filtered.sort((a, b) => {
const aTankModelDefinition = awaitedModelDefinitions[a.id]; const aTankModelDefinition = awaitedModelDefinitions[a.id];
const bTankModelDefinition = awaitedModelDefinitions[b.id]; const bTankModelDefinition = awaitedModelDefinitions[b.id];
@@ -178,6 +179,33 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
}); });
break; break;
case 'survivability.length':
sorted = filtered.sort((a, b) => {
const aTankModelDefinition = awaitedModelDefinitions[a.id];
const bTankModelDefinition = awaitedModelDefinitions[b.id];
const aTurretModelDefinition =
aTankModelDefinition.turrets[a.turrets.at(-1)!.id];
const bTurretModelDefinition =
bTankModelDefinition.turrets[b.turrets.at(-1)!.id];
const aSize = normalizeBoundingBox(
unionBoundingBox(
aTankModelDefinition.boundingBox,
aTurretModelDefinition.boundingBox,
),
);
const bSize = normalizeBoundingBox(
unionBoundingBox(
bTankModelDefinition.boundingBox,
bTurretModelDefinition.boundingBox,
),
);
const aLength = Math.max(aSize[0], aSize[1], aSize[2]);
const bLength = Math.max(bSize[0], bSize[1], bSize[2]);
return aLength - bLength;
});
break;
case 'fire.dpm': case 'fire.dpm':
sorted = filtered.sort( sorted = filtered.sort(
(a, b) => (a, b) =>
@@ -392,27 +420,26 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
<> <>
<Flex direction="column" gap="4"> <Flex direction="column" gap="4">
<Flex gap="2"> <Flex gap="2">
<TextField.Root style={{ flex: 1 }}> <TextField.Root
style={{ flex: 1 }}
ref={input}
placeholder="Search tanks..."
onChange={(event) => {
const results = go(event.target.value, searchableTanks, {
keys: [
'name',
'nameFull',
'id',
] satisfies (keyof TankDefinition)[],
all: true,
}).map(({ obj }) => obj);
setSearchedList(results);
}}
>
<TextField.Slot> <TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" /> <MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot> </TextField.Slot>
<TextField.Input
ref={input}
placeholder="Search tanks..."
onChange={(event) => {
const results = go(event.target.value, searchableTanks, {
keys: [
'name',
'nameFull',
'id',
] satisfies (keyof TankDefinition)[],
all: true,
}).map(({ obj }) => obj);
setSearchedList(results);
}}
/>
</TextField.Root> </TextField.Root>
<DropdownMenu.Root> <DropdownMenu.Root>
@@ -699,14 +726,24 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
Camouflage shooting Camouflage shooting
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem <DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.size'} checked={sort.by === 'survivability.volume'}
onClick={() => { onClick={() => {
mutateTankopediaPersistent((draft) => { mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.size'; draft.sort.by = 'survivability.volume';
}); });
}} }}
> >
Size Volume
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.length'}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.sort.by = 'survivability.length';
});
}}
>
Length
</DropdownMenu.CheckboxItem> </DropdownMenu.CheckboxItem>
</DropdownMenu.SubContent> </DropdownMenu.SubContent>
</DropdownMenu.Sub> </DropdownMenu.Sub>
@@ -771,117 +808,123 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
<Flex wrap="wrap" gap="2" justify={compact ? 'between' : 'center'}> <Flex wrap="wrap" gap="2" justify={compact ? 'between' : 'center'}>
{!compact && {!compact &&
searchResultsPageSlice.map((tank) => ( searchResultsPageSlice.map((tank) => (
<Card <Link
key={tank.id} href={`/tools/tankopedia/${tank.id}`}
onClick={() => onSelect(tank)}
className={styles.listing}
style={{ style={{
flex: 1, flex: 1,
cursor: 'pointer', minWidth: 256,
height: 64,
}} }}
> >
<Inset <Card
key={tank.id} key={tank.id}
className={styles.listing}
style={{ style={{
minWidth: 256, cursor: 'pointer',
minHeight: 64, height: 64,
position: 'relative', width: '100%',
display: 'flex',
}} }}
> >
<img <Inset
className={styles.flag} key={tank.id}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{ style={{
width: '100%', minHeight: 64,
height: '100%', position: 'relative',
objectFit: 'contain', display: 'flex',
position: 'absolute',
}} }}
/>
<div
className={styles.listingShadow}
style={{
width: '100%',
height: '100%',
position: 'absolute',
transition: `box-shadow ${theme.durations.regular}`,
}}
/>
<div
style={{
width: 256,
height: 128,
}}
className={styles.listingImage}
> >
<img <img
src={tankIcon(tank.id)} className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{ style={{
width: '100%',
height: '100%',
objectFit: 'contain', objectFit: 'contain',
objectPosition: 'left center', position: 'absolute',
}} }}
/> />
</div>
<Flex <div
align="center" className={styles.listingShadow}
justify="between" style={{
className={styles.listingLabel} width: '100%',
style={{ height: '100%',
position: 'absolute', position: 'absolute',
bottom: 8, transition: `box-shadow ${theme.durations.regular}`,
padding: '0 12px', }}
width: '100%', />
boxSizing: 'border-box',
}} <div
> style={{
<Flex align="center" justify="center" gap="1"> width: 256,
height: 128,
}}
className={styles.listingImage}
>
<img <img
src={ src={tankIcon(tank.id)}
(tank.treeType === 'collector'
? TANK_ICONS_COLLECTOR
: tank.treeType === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.class]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{ style={{
color: objectFit: 'contain',
tank.treeType === 'researchable' objectPosition: 'left center',
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}} }}
> />
{tank.name} </div>
<Flex
align="center"
justify="between"
className={styles.listingLabel}
style={{
position: 'absolute',
bottom: 8,
padding: '0 12px',
width: '100%',
boxSizing: 'border-box',
}}
>
<Flex align="center" justify="center" gap="1">
<img
src={
(tank.treeType === 'collector'
? TANK_ICONS_COLLECTOR
: tank.treeType === 'premium'
? TANK_ICONS_PREMIUM
: TANK_ICONS)[tank.class]
}
style={{ width: '1em', height: '1em' }}
/>
<Text
size="4"
color={
tank.treeType === 'collector'
? 'blue'
: tank.treeType === 'premium'
? 'amber'
: undefined
}
weight="medium"
style={{
color:
tank.treeType === 'researchable'
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{tank.name}
</Text>
</Flex>
<Text size="4" color="gray" highContrast={false}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text> </Text>
</Flex> </Flex>
</Inset>
<Text size="4" color="gray" highContrast={false}> </Card>
{TIER_ROMAN_NUMERALS[tank.tier]} </Link>
</Text>
</Flex>
</Inset>
</Card>
))} ))}
{compact && ( {compact && (
-6
View File
@@ -74,12 +74,6 @@ export const characteristics = style({
}, },
}); });
export const configurationChild = style({
display: 'flex',
gap: 8,
flexDirection: 'column',
});
export const configuration = style({ export const configuration = style({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
+1 -1
View File
@@ -8,7 +8,7 @@ export default function Page() {
const router = useRouter(); const router = useRouter();
return ( return (
<PageWrapper size="double" color="purple"> <PageWrapper size={1200} color="purple">
<TankSearch <TankSearch
onSelect={(tank) => { onSelect={(tank) => {
router.push(`./tankopedia/${tank.id}`); router.push(`./tankopedia/${tank.id}`);
+2 -2
View File
@@ -7,7 +7,7 @@ import TitleBar from '../components/TitleBar';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import resolveTankId from '../core/blitz/resolveTankId'; import resolveTankId from '../core/blitz/resolveTankId';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { emblemURL } from '../core/blitzkrieg/emblemURL';
import getTankHistories from '../core/blitzkrieg/getTankHistories'; import getTankHistories from '../core/blitzkrieg/getTankHistories';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getPlayerHistories from '../core/blitzstars/getPlayerHistories'; import getPlayerHistories from '../core/blitzstars/getPlayerHistories';
@@ -41,7 +41,7 @@ async function render(
) { ) {
const accountInfo = await getAccountInfo(region, id); const accountInfo = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const logo = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; const logo = clan ? emblemURL(clan.emblem_set_id) : undefined;
const histories = await (type === 'player' const histories = await (type === 'player'
? getPlayerHistories(region, id, { ? getPlayerHistories(region, id, {
start, start,
+2 -2
View File
@@ -6,7 +6,7 @@ import TierWeights from '../components/TierWeights';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { emblemURL } from '../core/blitzkrieg/emblemURL';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
@@ -40,7 +40,7 @@ async function render(
) { ) {
const { nickname } = await getAccountInfo(region, id); const { nickname } = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
const diffedTankStats = await getStatsInPeriod( const diffedTankStats = await getStatsInPeriod(
region, region,
id, id,
+4 -4
View File
@@ -2,11 +2,11 @@ import { teal } from '@radix-ui/colors';
import { GuildMemberRoleManager } from 'discord.js'; import { GuildMemberRoleManager } from 'discord.js';
import markdownEscape from 'markdown-escape'; import markdownEscape from 'markdown-escape';
import discord from '../../discord.json' assert { type: 'json' }; import discord from '../../discord.json' assert { type: 'json' };
import { Glow } from '../components/AllStatsOverview/components/WN8Display/components/Glow'; import { Glow } from '../components/AllStatsOverview/components/HeroStat/components/Glow';
import CommandWrapper from '../components/CommandWrapper'; import CommandWrapper from '../components/CommandWrapper';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitz'; import { linkBlitzAndDiscord } from '../core/blitzkrieg/discordBlitzLink';
import addUsernameChoices from '../core/discord/addUsernameChoices'; import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername'; import autocompleteUsername from '../core/discord/autocompleteUsername';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand'; import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
@@ -29,11 +29,11 @@ export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
async handler(interaction) { async handler(interaction) {
const { t, translate } = translator(interaction.locale); const { t, translate } = translator(interaction.locale);
const { id, region } = await resolvePlayerFromCommand(interaction); const { id, region } = await resolvePlayerFromCommand(interaction);
const discordId = parseInt(interaction.user.id); const discordId = BigInt(interaction.user.id);
const accountInfo = await getAccountInfo(region, id); const accountInfo = await getAccountInfo(region, id);
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']); const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
await linkBlitzAndDiscord(discordId, region, id); await linkBlitzAndDiscord(discordId, id);
if (interaction.guildId === discord.sklld_guild_id) { if (interaction.guildId === discord.sklld_guild_id) {
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) { if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
+494
View File
@@ -0,0 +1,494 @@
import markdownEscape from 'markdown-escape';
import { Glow } from '../components/AllStatsOverview/components/HeroStat/components/Glow';
import CommandWrapper from '../components/CommandWrapper';
import { DeltaCaret } from '../components/DeltaCaret';
import TitleBar from '../components/TitleBar';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { getLeagueFromScore } from '../core/blitz/getLeagueFromScore';
import getRatingInfo from '../core/blitz/getRatingInfo';
import { getRatingNeighbors } from '../core/blitz/getRatingNeighbors';
import { normalizeLeagueIcon } from '../core/blitz/normalizeLeagueIcon';
import { emblemURL } from '../core/blitzkrieg/emblemURL';
import getArchivedRatingMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingMidnightLeaderboard';
import { webpToPng } from '../core/blitzkrieg/iconPng';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { translator } from '../core/localization/translator';
import { deltaBkrlBlitzStats } from '../core/statistics/deltaBkrlBlitzStats';
import { BkrlFormat } from '../core/streams/bkrl';
import { CommandRegistry } from '../events/interactionCreate';
import { theme } from '../stitches.config';
const LEAGUE_ACCENT = [
'_purple',
'_sand',
'_amber',
'_mauve',
'_brown',
] as const;
export const ratingCommand = new Promise<CommandRegistry>((resolve) => {
resolve({
command:
createLocalizedCommand('rating').addStringOption(addUsernameChoices),
inProduction: true,
inPublic: true,
async handler(interaction) {
const { t, translate } = translator(interaction.locale);
const { id, region } = await resolvePlayerFromCommand(interaction);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
const leaderboard = await getArchivedRatingMidnightLeaderboard(
region,
53,
);
if (leaderboard?.format === BkrlFormat.Base) {
throw new Error(
'Encountered bkrl minimal format in latest season. Wait till next cron?',
);
}
const ratingInfo = await getRatingInfo(region);
const accountInfo = await getAccountInfo(region, id, [
'statistics.rating',
]);
const statsIndex = leaderboard?.entries.findIndex(
(entry) => entry.id === id,
);
const statsA = leaderboard ? leaderboard.entries[statsIndex!] : null;
const positionA = leaderboard ? statsIndex! + 1 : undefined;
const statsB1 = accountInfo.statistics.rating!;
const [statsB2] = (await getRatingNeighbors(region, id, 0)).neighbors;
const league = getLeagueFromScore(statsB2.score);
if (ratingInfo.detail) {
throw new Error(`Rating info not available for ${region}`);
}
if (!statsA) {
return t`bot.commands.rating.errors.unrecorded_stats`;
}
if (!statsB1) {
return translate('bot.commands.rating.errors.no_participation', [
markdownEscape(accountInfo.nickname),
]);
}
const reward = ratingInfo.rewards.find(
(reward) =>
statsB2.number >= reward.from_position &&
statsB2.number <= reward.to_position,
);
const positionDelta = leaderboard
? statsB2.number - positionA!
: undefined;
const delta = deltaBkrlBlitzStats(
statsA,
accountInfo.statistics.rating!,
statsB2,
);
const leagueTheme = LEAGUE_ACCENT[league.index];
return (
<CommandWrapper>
<TitleBar
title={accountInfo.nickname}
image={clanImage}
description={`${t`bot.commands.rating.body.subtitle`} • ${new Date().toLocaleDateString(
interaction.locale,
)}`}
/>
<div
style={{
position: 'relative',
display: 'flex',
overflow: 'hidden',
}}
>
<div
style={{
position: 'relative',
padding: 16,
backgroundColor: theme.colors[`appBackground2${leagueTheme}`],
borderRadius: 4,
border: `1px solid ${theme.colors[`componentInteractive${leagueTheme}`]}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
}}
>
{reward === undefined && (
<Glow
endOpacity={3 / 4}
color={theme.colors[`solidBackground${leagueTheme}`]}
style={{
transform: 'translateX(-16px) rotate(90deg)',
}}
/>
)}
<div
style={{
display: 'flex',
gap: 8,
justifyContent: 'center',
alignItems: 'center',
}}
>
<img
style={{
width: 64,
height: 64,
objectFit: 'contain',
}}
src={normalizeLeagueIcon(
ratingInfo.leagues[league.index].big_icon,
)}
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<span
style={{
color: theme.colors[`textHighContrast${leagueTheme}`],
fontSize: 24,
fontWeight: 900,
}}
>
{statsB2.score.toLocaleString(interaction.locale)}
</span>
{delta.score !== 0 && (
<>
<DeltaCaret delta={delta.score} />
<span
style={{
color:
delta.score > 0
? theme.colors.textLowContrast_green
: theme.colors.textLowContrast_red,
fontSize: 16,
}}
>
{Math.abs(delta.score).toLocaleString(
interaction.locale,
)}
</span>
</>
)}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
}}
>
<span
style={{
color: theme.colors[`textLowContrast${leagueTheme}`],
fontSize: 16,
}}
>
#{statsB2.number.toLocaleString(interaction.locale)}
</span>
{positionDelta !== undefined && positionDelta !== 0 && (
<>
<DeltaCaret delta={positionDelta} />
<span
style={{
color:
positionDelta > 0
? theme.colors.textLowContrast_green
: theme.colors.textLowContrast_red,
fontSize: 16,
}}
>
{Math.abs(positionDelta).toLocaleString(
interaction.locale,
)}
</span>
</>
)}
</div>
</div>
</div>
{reward && (
<div
style={{
display: 'flex',
gap: 8,
justifyContent: 'center',
alignItems: 'center',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
}}
>
<span
style={{
color: theme.colors[`textHighContrast${leagueTheme}`],
fontSize: 16,
maxWidth: 136,
textAlign: 'right',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
overflow: 'hidden',
}}
>
{reward.type === 'vehicle'
? reward.vehicle.user_string
: reward.stuff.title}
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
}}
>
<span
style={{
color: theme.colors[`textLowContrast${leagueTheme}`],
fontSize: 16,
}}
>
Reward
{reward.type === 'stuff' ? ` x${reward.count}` : ''}
</span>
</div>
</div>
<img
style={{
width: 64,
height: 64,
objectFit: 'cover',
}}
src={await webpToPng(
reward.type === 'vehicle'
? reward.vehicle.preview_image_url
: reward.stuff.image_url,
)}
/>
</div>
)}
{reward === undefined && (
<Glow
endOpacity={3 / 4}
color={theme.colors[`solidBackground${leagueTheme}`]}
style={{
transform: 'translateX(16px) rotate(-90deg)',
}}
/>
)}
</div>
{/* separated because fragment middle man invalidates relative parent: https://github.com/vercel/satori/issues/605 */}
{reward && (
<Glow
direction="reverse"
color={theme.colors[`solidBackground${leagueTheme}`]}
style={{
position: 'absolute',
top: -40,
left: '50%',
transform: 'translateX(-50%)',
}}
/>
)}
{reward && (
<Glow
color={theme.colors[`solidBackground${leagueTheme}`]}
style={{
position: 'absolute',
bottom: -40,
left: '50%',
transform: 'translateX(-50%)',
}}
/>
)}
</div>
{delta.battles > 0 && (
<div style={{ padding: '8px 0', display: 'flex' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{delta.battles.toLocaleString(interaction.locale)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Battles
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{(100 * (delta.wins / delta.battles)).toFixed(0)}%
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Winrate
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{Math.round(delta.damage / delta.battles).toLocaleString(
interaction.locale,
)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Damage
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{(100 * (delta.survived / delta.battles)).toFixed(0)}%
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Survival
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
flex: 1,
flexDirection: 'column',
}}
>
<span
style={{
fontSize: 24,
color: theme.colors.textHighContrast,
fontWeight: 'bold',
}}
>
{(delta.kills / delta.battles).toFixed(2)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Kills
</span>
</div>
</div>
)}
</CommandWrapper>
);
},
});
});
+513
View File
@@ -0,0 +1,513 @@
import {
APIApplicationCommandOptionChoice,
Locale,
SlashCommandSubcommandBuilder,
} from 'discord.js';
import { range } from 'lodash';
import markdownEscape from 'markdown-escape';
import CommandWrapper from '../components/CommandWrapper';
import * as Leaderboard from '../components/Leaderboard';
import TitleBar from '../components/TitleBar';
import { LEAGUES } from '../constants/leagues';
import { FIRST_MINIMAL_ARCHIVED_RATING_SEASON } from '../constants/rating';
import { Region } from '../constants/regions';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getRatingInfo from '../core/blitz/getRatingInfo';
import { getRatingLeague } from '../core/blitz/getRatingLeague';
import { getRatingNeighbors } from '../core/blitz/getRatingNeighbors';
import { isOnGoingRatingSeason } from '../core/blitz/isOnGoingRatingSeason';
import { getArchivedLatestSeasonNumber } from '../core/blitzkrieg/getArchivedLatestSeasonNumber';
import getArchivedRatingInfo from '../core/blitzkrieg/getArchivedRatingInfo';
import { getArchivedRatingLeaderboard } from '../core/blitzkrieg/getArchivedRatingLeaderboard';
import getArchivedRatingMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingMidnightLeaderboard';
import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
import { localizationObject } from '../core/discord/localizationObject';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { translator } from '../core/localization/translator';
import { BkrlDiscriminatedEntries } from '../core/streams/bkrl';
import { CommandRegistry } from '../events/interactionCreate';
import { UserError } from '../hooks/userError';
export interface RatingPlayer {
spa_id: number;
mmr: number;
season_number: number;
calibrationBattlesLeft: number;
number: number;
percentile: number;
skip: boolean;
updated_at: string;
score: number;
nickname: string;
clan_tag: string;
}
export interface LeagueTop {
result: RatingPlayer[];
}
export type RatingReward = {
from_position: number;
to_position: number;
count: number;
} & (
| {
type: 'vehicle';
vehicle: {
id: number;
name: string;
nation: string;
subnation: string;
use_subnation_flag: boolean;
type_slug: string;
level: number;
roman_level: string;
user_string: string;
image_url: string;
preview_image_url: string;
is_premium: boolean;
is_collectible: boolean;
};
}
| {
type: 'stuff';
stuff: {
name: string;
count: number;
title: string;
image_url: string;
type: string;
sizes: {};
};
}
);
export interface RatingLeague {
title: string;
small_icon: string;
big_icon: string;
background: string;
index: number;
percentile: number;
}
export type RatingInfo =
| {
detail: undefined;
title: string;
icon: string | null;
start_at: string;
finish_at: string;
current_season: number;
updated_at: string;
count: number;
rewards: RatingReward[];
leagues: RatingLeague[];
}
| { detail: { error: string } };
export interface RatingNeighbors {
neighbors: RatingPlayer[];
}
interface SimplifiedPlayer {
id: number;
score: number;
position: number;
clan: string | undefined;
nickname: string;
}
const DEFAULT_LIMIT = 10;
export const ratingLeaderboardCommand = new Promise<CommandRegistry>(
async (resolve) => {
const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
const onGoingSeason = await isOnGoingRatingSeason();
const seasonNumbers = range(
FIRST_MINIMAL_ARCHIVED_RATING_SEASON,
latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1,
);
function addOptions(option: SlashCommandSubcommandBuilder) {
const { t, translate } = translator(Locale.EnglishUS);
return option
.addIntegerOption((option) =>
option
.setName(t`bot.commands.rating_leaderboard.options.limit`)
.setNameLocalizations(
localizationObject(
'bot.commands.rating_leaderboard.options.limit',
),
)
.setDescription(
translate(
'bot.commands.rating_leaderboard.options.limit.description',
[`${DEFAULT_LIMIT}`],
),
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.rating_leaderboard.options.limit.description',
[`${DEFAULT_LIMIT}`],
),
)
.setRequired(false)
.setMinValue(5)
.setMaxValue(30),
)
.addStringOption((option) =>
option
.setName(t`bot.commands.rating_leaderboard.options.season`)
.setNameLocalizations(
localizationObject(
'bot.commands.rating_leaderboard.options.season',
),
)
.setDescription(
translate(
'bot.commands.rating_leaderboard.options.season.description',
),
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.rating_leaderboard.options.season.description',
),
)
.setRequired(false)
.addChoices(
...seasonNumbers
.map((number, index) => {
const current =
index === seasonNumbers.length - 1 && onGoingSeason;
return {
name: current
? translate(
'bot.commands.rating_leaderboard.options.season.choices.current',
[`${number}`],
)
: `${number}`,
value: `${number}`,
name_localizations: current
? localizationObject(
'bot.commands.rating_leaderboard.options.season.choices.current',
[`${number}`],
)
: undefined,
} satisfies APIApplicationCommandOptionChoice<string>;
})
.reverse(),
),
);
}
resolve({
inProduction: true,
inPublic: true,
command: createLocalizedCommand('rating-leaderboard', [
{
subcommand: 'neighbors',
modify(option: SlashCommandSubcommandBuilder) {
option.addStringOption(addUsernameChoices);
addOptions(option);
},
},
{
subcommand: 'league',
modify(option: SlashCommandSubcommandBuilder) {
option
.addStringOption((option) => {
const { t, translate } = translator(Locale.EnglishUS);
return option
.setName(t`bot.commands.rating_leaderboard.options.league`)
.setNameLocalizations(
localizationObject(
'bot.commands.rating_leaderboard.options.league',
),
)
.setDescription(
translate(
'bot.commands.rating_leaderboard.options.league.description',
),
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.rating_leaderboard.options.league.description',
),
)
.addChoices(
...LEAGUES.map(
({ name }, value) =>
({
name: translate(`common.leagues.${name}`),
value: `${value}`,
name_localizations: localizationObject(
`common.leagues.${name}`,
),
}) satisfies APIApplicationCommandOptionChoice<string>,
),
)
.setRequired(true);
})
.addStringOption(addRegionChoices);
addOptions(option);
},
},
]),
// BIG TODO: title seems to be in english
async handler(interaction) {
const { t, translate } = translator(interaction.locale);
const subcommand = interaction.options.getSubcommand(true) as
| 'league'
| 'neighbors';
const limit = interaction.options.getInteger('limit') ?? DEFAULT_LIMIT;
const seasonOption = interaction.options.getString('season');
const season = seasonOption
? parseInt(seasonOption)
: onGoingSeason
? latestArchivedSeasonNumber + 1
: latestArchivedSeasonNumber;
const isCurrentSeason = season === latestArchivedSeasonNumber + 1;
let titleName: string;
let titleImage: string | undefined;
let titleDescription: string;
let playersAfter: number;
let players: SimplifiedPlayer[] | undefined;
let midnightLeaderboard: BkrlDiscriminatedEntries | null;
let playerId: number | undefined;
let regionRatingInfo: RatingInfo;
if (subcommand === 'league') {
const leagueIndex = parseInt(
interaction.options.getString('league')!,
);
const region = interaction.options.getString('region') as Region;
regionRatingInfo = await (isCurrentSeason
? getRatingInfo(region)
: getArchivedRatingInfo(region, season!));
if (regionRatingInfo.detail !== undefined)
return t`bot.commands.rating_leaderboard.body.no_ongoing_season`;
const result = isCurrentSeason
? await getRatingLeague(region, leagueIndex).then(async (result) =>
result.slice(0, limit).map(
(player) =>
({
id: player.spa_id,
score: player.score,
position: player.number,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
: await getArchivedRatingLeaderboard(region, season!).then(
async (players) => {
const firstPlayerIndex =
leagueIndex === 0
? 0
: players.entries.findIndex(
(player) =>
player.score < LEAGUES[leagueIndex - 1].minScore,
);
const lastPlayerIndex = firstPlayerIndex + limit - 1;
const trimmed = players.entries.slice(
firstPlayerIndex,
lastPlayerIndex + 1,
);
const clanData = await getClanAccountInfo(
region,
trimmed.map(({ id }) => id),
['clan'],
);
return trimmed.map(
(player, index) =>
({
id: player.id,
score: player.score,
position: firstPlayerIndex + index + 1,
clan: clanData[player.id]?.clan?.tag,
nickname:
clanData[index]?.account_name ??
translate(
'bot.commands.rating_leaderboard.body.deleted_player',
[`${player.id}`],
),
}) satisfies SimplifiedPlayer,
);
},
);
const leagueInfo = regionRatingInfo.leagues[leagueIndex];
midnightLeaderboard = isCurrentSeason
? await getArchivedRatingMidnightLeaderboard(
region,
regionRatingInfo.current_season,
)
: await getArchivedRatingMidnightLeaderboard(region, season!);
players = result;
playersAfter =
regionRatingInfo.count - result[result.length - 1].position;
titleName = `${
leagueInfo
? translate(`common.leagues.${LEAGUES[leagueInfo.index].name}`)
: ''
} - ${translate(`common.regions.short.${region}`)}`;
titleImage = leagueInfo.big_icon.startsWith('http')
? leagueInfo.big_icon
: `https:${leagueInfo.big_icon}`;
titleDescription = translate(
'bot.commands.rating_leaderboard.body.top_players',
[`${limit}`],
);
} else {
const { region, id } = await resolvePlayerFromCommand(interaction);
regionRatingInfo = isCurrentSeason
? await getRatingInfo(region)
: await getArchivedRatingInfo(region, season!);
if (regionRatingInfo.detail !== undefined)
return t`bot.commands.rating_leaderboard.body.no_ongoing_season`;
const accountInfo = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const neighbors = isCurrentSeason
? await getRatingNeighbors(region, id, Math.round(limit / 2)).then(
(players) =>
players.neighbors.map(
(player) =>
({
id: player.spa_id,
position: player.number,
score: player.score,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
: await getArchivedRatingLeaderboard(region, season!).then(
async (players) => {
const playerIndex = players.entries.findIndex(
(player) => player.id === id,
);
if (playerIndex === -1) {
throw new UserError(
translate(
'bot.commands.rating_leaderboard.body.no_participation',
[markdownEscape(accountInfo.nickname), `${season}`],
),
);
}
const halfRange = Math.round(limit / 2);
const trimmed = players.entries.slice(
playerIndex - halfRange,
playerIndex + halfRange + 1,
);
const clanData = await getClanAccountInfo(
region,
trimmed.map((player) => player.id),
['clan'],
);
return trimmed.map((player, index) => ({
id: player.id,
score: player.score,
position: playerIndex + index + 1,
clan: clanData[index]?.clan?.tag,
nickname: clanData[index]
? clanData[index]!.account_name
: `Deleted ${player.id}`,
}));
},
);
midnightLeaderboard = isCurrentSeason
? await getArchivedRatingMidnightLeaderboard(
region,
regionRatingInfo.current_season,
)
: await getArchivedRatingMidnightLeaderboard(region, season!);
players = neighbors;
titleImage = clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
: undefined;
titleName = neighbors ? accountInfo.nickname : '';
titleDescription = t`bot.commands.rating_leaderboard.body.neighbors`;
playersAfter =
regionRatingInfo.count - neighbors[neighbors.length - 1].position;
playerId = id;
}
const items = players.map((player) => {
const midnightIndex = midnightLeaderboard?.entries.findIndex(
(item) => item.id === player.id,
);
const midnightScore =
midnightIndex === undefined
? player.score
: midnightLeaderboard!.entries[midnightIndex].score;
const midnightPosition =
midnightIndex === undefined ? player.position : midnightIndex + 1;
return (
<Leaderboard.Item
nickname={player.nickname}
score={player.score}
position={player.position}
clan={player.clan}
deltaScore={player.score - midnightScore}
deltaPosition={midnightPosition - player.position}
key={player.id}
highlight={player.id === playerId}
/>
);
});
return (
<CommandWrapper>
<TitleBar
title={titleName}
image={titleImage}
description={titleDescription}
/>
{items && (
<Leaderboard.Root>
{items}
{playersAfter > 0 && (
<Leaderboard.Gap
message={translate(
'bot.commands.rating_leaderboard.body.more',
[`${playersAfter.toLocaleString(interaction.locale)}`],
)}
/>
)}
</Leaderboard.Root>
)}
</CommandWrapper>
);
},
autocomplete: autocompleteUsername,
});
},
);
-505
View File
@@ -1,505 +0,0 @@
import {
APIApplicationCommandOptionChoice,
Locale,
SlashCommandSubcommandBuilder,
} from 'discord.js';
import { range } from 'lodash';
import markdownEscape from 'markdown-escape';
import { BlitzkriegRatingsLeaderboard } from '../../scripts/buildRatingsLeaderboard';
import CommandWrapper from '../components/CommandWrapper';
import * as Leaderboard from '../components/Leaderboard';
import TitleBar from '../components/TitleBar';
import { LEAGUES } from '../constants/leagues';
import { FIRST_ARCHIVED_RATINGS_SEASON } from '../constants/ratings';
import { Region } from '../constants/regions';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getRatingsInfo from '../core/blitz/getRatingsInfo';
import { getRatingsLeague } from '../core/blitz/getRatingsLeague';
import { getRatingsNeighbors } from '../core/blitz/getRatingsNeighbors';
import { isOnGoingRatingsSeason } from '../core/blitz/isOnGoingRatingsSeason';
import { getArchivedLatestSeasonNumber } from '../core/blitzkrieg/getArchivedLatestSeasonNumber';
import getArchivedRatingsInfo from '../core/blitzkrieg/getArchivedRatingsInfo';
import { getArchivedRatingsLeaderboard } from '../core/blitzkrieg/getArchivedRatingsLeaderboard';
import getArchivedRatingsMidnightLeaderboard from '../core/blitzkrieg/getArchivedRatingsMidnightLeaderboard';
import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
import { localizationObject } from '../core/discord/localizationObject';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { translator } from '../core/localization/translator';
import { CommandRegistry } from '../events/interactionCreate';
import { UserError } from '../hooks/userError';
export interface RatingsPlayer {
spa_id: number;
mmr: number;
season_number: number;
calibrationBattlesLeft: number;
number: number;
percentile: number;
skip: boolean;
updated_at: string;
score: number;
nickname: string;
clan_tag: string;
}
export interface LeagueTop {
result: RatingsPlayer[];
}
export type RatingsReward = {
from_position: number;
to_position: number;
count: number;
} & (
| {
type: 'vehicle';
vehicle: {
id: number;
name: string;
nation: string;
subnation: string;
use_subnation_flag: boolean;
type_slug: string;
level: number;
roman_level: string;
user_string: string;
image_url: string;
preview_url: string;
is_premium: boolean;
is_collectible: boolean;
};
}
| {
type: 'stuff';
stuff: {
name: string;
count: number;
title: string;
image_url: string;
type: string;
sizes: {};
};
}
);
export interface RatingsLeague {
title: string;
small_icon: string;
big_icon: string;
background: string;
index: number;
percentile: number;
}
export type RatingsInfo =
| {
detail: undefined;
title: string;
icon: string | null;
start_at: string;
finish_at: string;
current_season: number;
updated_at: string;
count: number;
rewards: RatingsReward[];
leagues: RatingsLeague[];
}
| { detail: { error: string } };
export interface RatingsNeighbors {
neighbors: RatingsPlayer[];
}
interface SimplifiedPlayer {
id: number;
score: number;
position: number;
clan: string | undefined;
nickname: string;
}
const DEFAULT_LIMIT = 10;
export const ratingsCommand = new Promise<CommandRegistry>(async (resolve) => {
const latestArchivedSeasonNumber = await getArchivedLatestSeasonNumber();
const onGoingSeason = await isOnGoingRatingsSeason();
const seasonNumbers = range(
FIRST_ARCHIVED_RATINGS_SEASON,
latestArchivedSeasonNumber + (onGoingSeason ? 1 : 0) + 1,
);
function addOptions(option: SlashCommandSubcommandBuilder) {
const { t, translate } = translator(Locale.EnglishUS);
return option
.addIntegerOption((option) =>
option
.setName(t`bot.commands.ratings.options.limit`)
.setNameLocalizations(
localizationObject('bot.commands.ratings.options.limit'),
)
.setDescription(
translate('bot.commands.ratings.options.limit.description', [
`${DEFAULT_LIMIT}`,
]),
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.ratings.options.limit.description',
[`${DEFAULT_LIMIT}`],
),
)
.setRequired(false)
.setMinValue(5)
.setMaxValue(30),
)
.addStringOption((option) =>
option
.setName(t`bot.commands.ratings.options.season`)
.setNameLocalizations(
localizationObject('bot.commands.ratings.options.season'),
)
.setDescription(
translate('bot.commands.ratings.options.season.description'),
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.ratings.options.season.description',
),
)
.setRequired(false)
.addChoices(
...seasonNumbers
.map((number, index) => {
const current =
index === seasonNumbers.length - 1 && onGoingSeason;
return {
name: current
? translate(
'bot.commands.ratings.options.season.choices.current',
[`${number}`],
)
: `${number}`,
value: `${number}`,
name_localizations: current
? localizationObject(
'bot.commands.ratings.options.season.choices.current',
[`${number}`],
)
: undefined,
} satisfies APIApplicationCommandOptionChoice<string>;
})
.reverse(),
),
);
}
resolve({
inProduction: true,
inPublic: true,
command: createLocalizedCommand('ratings', [
{
subcommand: 'neighbors',
modify(option: SlashCommandSubcommandBuilder) {
option.addStringOption(addUsernameChoices);
addOptions(option);
},
},
{
subcommand: 'league',
modify(option: SlashCommandSubcommandBuilder) {
option
.addStringOption((option) => {
const { t, translate } = translator(Locale.EnglishUS);
return option
.setName(t`bot.commands.ratings.options.league`)
.setNameLocalizations(
localizationObject('bot.commands.ratings.options.league'),
)
.setDescription(
translate('bot.commands.ratings.options.league.description'),
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.ratings.options.league.description',
),
)
.addChoices(
...LEAGUES.map(
({ name }, value) =>
({
name: translate(`common.leagues.${name}`),
value: `${value}`,
name_localizations: localizationObject(
`common.leagues.${name}`,
),
}) satisfies APIApplicationCommandOptionChoice<string>,
),
)
.setRequired(true);
})
.addStringOption(addRegionChoices);
addOptions(option);
},
},
]),
// BIG TODO: title seems to be in english
async handler(interaction) {
const { t, translate } = translator(interaction.locale);
const subcommand = interaction.options.getSubcommand(true) as
| 'league'
| 'neighbors';
const limit = interaction.options.getInteger('limit') ?? DEFAULT_LIMIT;
const seasonOption = interaction.options.getString('season');
const season = seasonOption
? parseInt(seasonOption)
: onGoingSeason
? latestArchivedSeasonNumber + 1
: latestArchivedSeasonNumber;
const isCurrentSeason = season === latestArchivedSeasonNumber + 1;
let titleName: string;
let titleImage: string | undefined;
let titleDescription: string;
let playersAfter: number;
let players: SimplifiedPlayer[] | undefined;
let midnightLeaderboard: BlitzkriegRatingsLeaderboard | undefined;
let playerId: number | undefined;
let regionRatingsInfo: RatingsInfo;
if (subcommand === 'league') {
const leagueIndex = parseInt(interaction.options.getString('league')!);
const region = interaction.options.getString('region') as Region;
regionRatingsInfo = await (isCurrentSeason
? getRatingsInfo(region)
: getArchivedRatingsInfo(region, season!));
if (regionRatingsInfo.detail !== undefined)
return t`bot.commands.ratings.body.no_ongoing_season`;
const result = isCurrentSeason
? await getRatingsLeague(region, leagueIndex).then(async (result) =>
result.slice(0, limit).map(
(player) =>
({
id: player.spa_id,
score: player.score,
position: player.number,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
: await getArchivedRatingsLeaderboard(region, season!).then(
async (players) => {
const firstPlayerIndex =
leagueIndex === 0
? 0
: players.findIndex(
(player) =>
player.score < LEAGUES[leagueIndex - 1].minScore,
);
const lastPlayerIndex = firstPlayerIndex + limit - 1;
const trimmed = players.slice(
firstPlayerIndex,
lastPlayerIndex + 1,
);
const clanData = await getClanAccountInfo(
region,
trimmed.map(({ id }) => id),
['clan'],
);
return trimmed.map(
(player, index) =>
({
id: player.id,
score: player.score,
position: firstPlayerIndex + index + 1,
clan: clanData[player.id]?.clan?.tag,
nickname:
clanData[index]?.account_name ??
translate('bot.commands.ratings.body.deleted_player', [
`${player.id}`,
]),
}) satisfies SimplifiedPlayer,
);
},
);
const leagueInfo = regionRatingsInfo.leagues[leagueIndex];
midnightLeaderboard = isCurrentSeason
? await getArchivedRatingsMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: await getArchivedRatingsMidnightLeaderboard(region, season!);
players = result;
playersAfter =
regionRatingsInfo.count - result[result.length - 1].position;
titleName = `${
leagueInfo
? translate(`common.leagues.${LEAGUES[leagueInfo.index].name}`)
: ''
} - ${translate(`common.regions.short.${region}`)}`;
titleImage = leagueInfo.big_icon.startsWith('http')
? leagueInfo.big_icon
: `https:${leagueInfo.big_icon}`;
titleDescription = translate('bot.commands.ratings.body.top_players', [
`${limit}`,
]);
} else {
const { region, id } = await resolvePlayerFromCommand(interaction);
regionRatingsInfo = isCurrentSeason
? await getRatingsInfo(region)
: await getArchivedRatingsInfo(region, season!);
if (regionRatingsInfo.detail !== undefined)
return t`bot.commands.ratings.body.no_ongoing_season`;
const accountInfo = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const neighbors = isCurrentSeason
? await getRatingsNeighbors(region, id, Math.round(limit / 2)).then(
(players) =>
players.neighbors.map(
(player) =>
({
id: player.spa_id,
position: player.number,
score: player.score,
clan: player.clan_tag,
nickname: player.nickname,
}) satisfies SimplifiedPlayer,
),
)
: await getArchivedRatingsLeaderboard(region, season!).then(
async (players) => {
const playerIndex = players.findIndex(
(player) => player.id === id,
);
if (playerIndex === -1) {
throw new UserError(
translate('bot.commands.ratings.body.no_participation', [
markdownEscape(accountInfo.nickname),
`${season}`,
]),
);
}
const halfRange = Math.round(limit / 2);
const trimmed = players.slice(
playerIndex - halfRange,
playerIndex + halfRange + 1,
);
const clanData = await getClanAccountInfo(
region,
trimmed.map((player) => player.id),
['clan'],
);
return trimmed.map((player, index) => ({
id: player.id,
score: player.score,
position: playerIndex + index + 1,
clan: clanData[index]?.clan?.tag,
nickname: clanData[index]
? clanData[index]!.account_name
: `Deleted ${player.id}`,
}));
},
);
midnightLeaderboard = isCurrentSeason
? await getArchivedRatingsMidnightLeaderboard(
region,
regionRatingsInfo.current_season,
)
: await getArchivedRatingsMidnightLeaderboard(region, season!);
players = neighbors;
titleImage = clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clan.emblem_set_id}.png`
: undefined;
titleName = neighbors ? accountInfo.nickname : '';
titleDescription = t`bot.commands.ratings.body.neighbors`;
playersAfter =
regionRatingsInfo.count - neighbors[neighbors.length - 1].position;
playerId = id;
}
const resolvedRegionRatingsInfo: typeof regionRatingsInfo & {
detail: undefined;
} = regionRatingsInfo;
const items = players.map((player) => {
const reward = resolvedRegionRatingsInfo.rewards.find(
(reward) =>
player.position >= reward.from_position &&
player.position <= reward.to_position,
);
const midnightIndex = midnightLeaderboard?.findIndex(
(item) => item.id === player.id,
);
const midnightScore =
midnightIndex === undefined
? player.score
: midnightLeaderboard![midnightIndex].score;
const midnightPosition =
midnightIndex === undefined ? player.position : midnightIndex + 1;
return (
<Leaderboard.Item
nickname={player.nickname}
score={player.score}
position={player.position}
clan={player.clan}
reward={reward}
deltaScore={player.score - midnightScore}
deltaPosition={midnightPosition - player.position}
key={player.id}
highlight={player.id === playerId}
/>
);
});
return (
<CommandWrapper>
<TitleBar
title={titleName}
image={titleImage}
description={titleDescription}
/>
{items && (
<Leaderboard.Root>
{items}
{playersAfter > 0 && (
<Leaderboard.Gap
message={translate('bot.commands.ratings.body.more', [
`${playersAfter.toLocaleString(interaction.locale)}`,
])}
/>
)}
</Leaderboard.Root>
)}
</CommandWrapper>
);
},
autocomplete: autocompleteUsername,
});
});
+15 -12
View File
@@ -8,8 +8,8 @@ import getTankStats from '../core/blitz/getTankStats';
import resolveTankId from '../core/blitz/resolveTankId'; import resolveTankId from '../core/blitz/resolveTankId';
import { asset } from '../core/blitzkrieg/asset'; import { asset } from '../core/blitzkrieg/asset';
import { buildTechTreeLine } from '../core/blitzkrieg/buildTechTreeLine'; import { buildTechTreeLine } from '../core/blitzkrieg/buildTechTreeLine';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { emblemURL } from '../core/blitzkrieg/emblemURL';
import { iconPng } from '../core/blitzkrieg/iconPng'; import { webpToPng } from '../core/blitzkrieg/iconPng';
import { resolveAncestry } from '../core/blitzkrieg/resolveAncestry'; import { resolveAncestry } from '../core/blitzkrieg/resolveAncestry';
import { tankDefinitions } from '../core/blitzkrieg/tankDefinitions'; import { tankDefinitions } from '../core/blitzkrieg/tankDefinitions';
import { import {
@@ -17,6 +17,7 @@ import {
TIER_ROMAN_NUMERALS, TIER_ROMAN_NUMERALS,
} from '../core/blitzkrieg/tankDefinitions/constants'; } from '../core/blitzkrieg/tankDefinitions/constants';
import { tankIconPng } from '../core/blitzkrieg/tankIconPng'; import { tankIconPng } from '../core/blitzkrieg/tankIconPng';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks'; import autocompleteTanks from '../core/discord/autocompleteTanks';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand'; import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
import { localizationObject } from '../core/discord/localizationObject'; import { localizationObject } from '../core/discord/localizationObject';
@@ -31,7 +32,6 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
resolve({ resolve({
inProduction: true, inProduction: true,
inPublic: true, inPublic: true,
inPreview: true,
command: createLocalizedCommand('research') command: createLocalizedCommand('research')
.addStringOption((option) => .addStringOption((option) =>
@@ -67,7 +67,8 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
) )
.setAutocomplete(true) .setAutocomplete(true)
.setRequired(false), .setRequired(false),
), )
.addStringOption(addUsernameChoices),
async handler(interaction) { async handler(interaction) {
const { t, translate } = translator(interaction.locale); const { t, translate } = translator(interaction.locale);
@@ -154,7 +155,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
const line = [...techTreeLine, startingTankId].reverse(); const line = [...techTreeLine, startingTankId].reverse();
const { nickname } = await getAccountInfo(region, id); const { nickname } = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
const costs = await Promise.all( const costs = await Promise.all(
line.map(async (id) => { line.map(async (id) => {
const tank = awaitedTankDefinitions[id]; const tank = awaitedTankDefinitions[id];
@@ -275,7 +276,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng(asset('icons/currencies/xp.webp'))} src={await webpToPng(asset('icons/currencies/xp.webp'))}
width={16} width={16}
height={16} height={16}
/> />
@@ -300,7 +301,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng(asset('icons/currencies/silver.webp'))} src={await webpToPng(asset('icons/currencies/silver.webp'))}
width={16} width={16}
height={16} height={16}
/> />
@@ -325,7 +326,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng(asset('icons/currencies/silver.webp'))} src={await webpToPng(asset('icons/currencies/silver.webp'))}
width={16} width={16}
height={16} height={16}
/> />
@@ -451,7 +452,9 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng(asset('icons/currencies/xp.webp'))} src={await webpToPng(
asset('icons/currencies/xp.webp'),
)}
width={16} width={16}
height={16} height={16}
/> />
@@ -478,7 +481,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng( src={await webpToPng(
asset('icons/currencies/free-xp.webp'), asset('icons/currencies/free-xp.webp'),
)} )}
width={16} width={16}
@@ -505,7 +508,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng( src={await webpToPng(
asset('icons/currencies/silver.webp'), asset('icons/currencies/silver.webp'),
)} )}
width={16} width={16}
@@ -533,7 +536,7 @@ export const researchCommand = new Promise<CommandRegistry>((resolve) => {
}} }}
> >
<img <img
src={await iconPng( src={await webpToPng(
asset('icons/currencies/silver.webp'), asset('icons/currencies/silver.webp'),
)} )}
width={16} width={16}
+2 -2
View File
@@ -5,7 +5,7 @@ import NoData from '../components/NoData';
import TitleBar from '../components/TitleBar'; import TitleBar from '../components/TitleBar';
import { getAccountInfo } from '../core/blitz/getAccountInfo'; import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo'; import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL'; import { emblemURL } from '../core/blitzkrieg/emblemURL';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription'; import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton'; import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod'; import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
@@ -38,7 +38,7 @@ async function render(
) { ) {
const { nickname } = await getAccountInfo(region, id); const { nickname } = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan; const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemIdToURL(clan.emblem_set_id) : undefined; const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
const diffedTankStats = await getStatsInPeriod( const diffedTankStats = await getStatsInPeriod(
region, region,
id, id,
@@ -0,0 +1,50 @@
import { ComponentProps } from 'react';
export interface GlowProps extends ComponentProps<'div'> {
color: string;
direction?: 'default' | 'reverse';
rotation?: number;
startOpacity?: number;
endOpacity?: number;
}
const width = 16;
export function Glow({
color,
direction = 'default',
rotation = 0,
style,
startOpacity = 0,
endOpacity = 1,
...props
}: GlowProps) {
const color1 = `${color}${Math.round(endOpacity * 255)
.toString(16)
.padStart(2, '0')}`;
const color2 = `${color}${Math.round(startOpacity * 255)
.toString(16)
.padStart(2, '0')}`;
const background = `linear-gradient(${
direction === 'default' ? 0 : 180
}deg, ${color1} 0%, ${color2} 100%)`;
return (
<div
style={{
display: 'flex',
alignItems: direction === 'default' ? 'flex-end' : 'flex-start',
justifyContent: 'center',
transform: `rotate(${rotation}deg)`,
...style,
}}
{...props}
>
<div style={{ height: 54, width, background }} />
<div style={{ height: 72, width, background }} />
<div style={{ height: 80, width, background }} />
<div style={{ height: 72, width, background }} />
<div style={{ height: 54, width, background }} />
</div>
);
}
@@ -1,5 +1,6 @@
import { Locale } from 'discord.js'; import { Locale } from 'discord.js';
import { Percentile } from '../../../../constants/percentiles'; import { Percentile } from '../../../../constants/percentiles';
import { SupplementaryStats } from '../../../../core/blitz/getAccountInfo';
import { translator } from '../../../../core/localization/translator'; import { translator } from '../../../../core/localization/translator';
import getWN8Percentile from '../../../../core/statistics/getWN8Percentile'; import getWN8Percentile from '../../../../core/statistics/getWN8Percentile';
import { theme } from '../../../../stitches.config'; import { theme } from '../../../../stitches.config';
@@ -7,16 +8,19 @@ import { PERCENTILE_COLORS } from '../../../PercentileIndicator/constants';
import { Glow } from './components/Glow'; import { Glow } from './components/Glow';
export interface WN8DisplayProps { export interface WN8DisplayProps {
wn8?: number; stats: SupplementaryStats;
locale: Locale; locale: Locale;
} }
export function WN8Display({ wn8, locale }: WN8DisplayProps) { export function HeroStat({ stats, locale }: WN8DisplayProps) {
const percentile =
wn8 === undefined ? Percentile.VeryBad : getWN8Percentile(wn8);
const color = PERCENTILE_COLORS[percentile];
const { translate } = translator(locale); const { translate } = translator(locale);
const percentile =
stats.WN8 === undefined ? Percentile.VeryBad : getWN8Percentile(stats.WN8);
const color = PERCENTILE_COLORS[percentile];
const heroStat = stats.WN8 === undefined ? '--' : stats.WN8.toFixed(0);
const subtitle = translate(`common.wn8_percentile.${percentile}`);
return ( return (
<div <div
style={{ style={{
@@ -43,23 +47,32 @@ export function WN8Display({ wn8, locale }: WN8DisplayProps) {
gap: -4, gap: -4,
}} }}
> >
<span <div
style={{ style={{
fontWeight: 900, display: 'flex',
fontSize: 32, flexDirection: 'column',
color: theme.colors.textHighContrast, alignItems: 'center',
justifyContent: 'center',
}} }}
> >
{wn8 === undefined ? '--' : wn8.toFixed(0)} <span
</span> style={{
<span fontWeight: 900,
style={{ fontSize: 32,
fontSize: 16, color: theme.colors.textHighContrast,
color: theme.colors.textHighContrast, }}
}} >
> {heroStat}
{translate(`common.wn8_percentile.${percentile}`)} </span>
</span> <span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{subtitle}
</span>
</div>
</div> </div>
<Glow color={color} direction="reverse" /> <Glow color={color} direction="reverse" />
@@ -1,35 +0,0 @@
export interface GlowProps {
color: string;
direction?: 'default' | 'reverse';
rotation?: number;
}
export function Glow({
color,
direction = 'default',
rotation = 0,
}: GlowProps) {
const color1 = color;
const color2 = `${color}00`;
const background = `linear-gradient(${
direction === 'default' ? 0 : 180
}deg, ${color1} 0%, ${color2} 100%)`;
return (
<div
style={{
display: 'flex',
alignItems: direction === 'default' ? 'flex-end' : 'flex-start',
justifyContent: 'center',
transform: `rotate(${rotation}deg)`,
}}
>
<div style={{ height: 54, width: 16, background }} />
<div style={{ height: 72, width: 16, background }} />
<div style={{ height: 80, width: 16, background }} />
<div style={{ height: 72, width: 16, background }} />
<div style={{ height: 54, width: 16, background }} />
</div>
);
}
+23 -16
View File
@@ -1,14 +1,23 @@
import { Locale } from 'discord.js'; import { Locale } from 'discord.js';
import { AllStats, SupplementaryStats } from '../../core/blitz/getAccountInfo'; import { SupplementaryStats } from '../../core/blitz/getAccountInfo';
import { translator } from '../../core/localization/translator'; import { translator } from '../../core/localization/translator';
import { WN8Display } from './components/WN8Display'; import { HeroStat } from './components/HeroStat';
import { import {
WN8SurroundingStat, WN8SurroundingStat,
WN8SurroundingStatAlign, WN8SurroundingStatAlign,
} from './components/WN8SurroundingStat'; } from './components/WN8SurroundingStat';
export interface AllStatsOverviewProps { export interface AllStatsOverviewProps {
stats: AllStats; stats: {
battles: number;
wins: number;
damage_dealt: number;
damage_received: number;
survived_battles: number;
hits: number;
shots: number;
frags: number;
};
supplementaryStats: SupplementaryStats; supplementaryStats: SupplementaryStats;
locale: Locale; locale: Locale;
} }
@@ -24,6 +33,7 @@ export default function AllStatsOverview({
<div <div
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center',
gap: -64, gap: -64,
width: '100%', width: '100%',
}} }}
@@ -38,18 +48,18 @@ export default function AllStatsOverview({
}} }}
> >
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.battles`} label={t`bot.common.hero_stats.battles`}
value={stats.battles.toLocaleString()} value={stats.battles.toLocaleString()}
align={WN8SurroundingStatAlign.Right} align={WN8SurroundingStatAlign.Right}
/> />
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.winrate`} label={t`bot.common.hero_stats.winrate`}
value={`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`} value={`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`}
align={WN8SurroundingStatAlign.Right} align={WN8SurroundingStatAlign.Right}
padded padded
/> />
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.damage`} label={t`bot.common.hero_stats.damage`}
value={Math.round( value={Math.round(
stats.damage_dealt / stats.battles, stats.damage_dealt / stats.battles,
).toLocaleString()} ).toLocaleString()}
@@ -57,13 +67,13 @@ export default function AllStatsOverview({
padded padded
/> />
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.damage_ratio`} label={t`bot.common.hero_stats.damage_ratio`}
value={(stats.damage_dealt / stats.damage_received).toFixed(2)} value={(stats.damage_dealt / stats.damage_received).toFixed(2)}
align={WN8SurroundingStatAlign.Right} align={WN8SurroundingStatAlign.Right}
/> />
</div> </div>
<WN8Display locale={locale} wn8={supplementaryStats.WN8} /> <HeroStat locale={locale} stats={supplementaryStats} />
<div <div
style={{ style={{
@@ -74,29 +84,26 @@ export default function AllStatsOverview({
}} }}
> >
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.tier`} label={t`bot.common.hero_stats.tier`}
value={ value={
supplementaryStats.tier ? supplementaryStats.tier.toFixed(2) : '--' supplementaryStats.tier ? supplementaryStats.tier.toFixed(2) : '--'
} }
/> />
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.survival`} label={t`bot.common.hero_stats.survival`}
value={`${(100 * (stats.survived_battles / stats.battles)).toFixed( value={`${(100 * (stats.survived_battles / stats.battles)).toFixed(
2, 2,
)}%`} )}%`}
padded padded
/> />
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.accuracy`} label={t`bot.common.hero_stats.accuracy`}
value={`${(100 * (stats.hits / stats.shots)).toFixed(2)}%`} value={`${(100 * (stats.hits / stats.shots)).toFixed(2)}%`}
padded padded
/> />
<WN8SurroundingStat <WN8SurroundingStat
label={t`bot.commands.stats.body.kill_ratio`} label={t`bot.common.hero_stats.kills`}
value={( value={(stats.frags / stats.battles).toFixed(2)}
stats.frags /
(stats.battles - stats.survived_battles)
).toFixed(2)}
/> />
</div> </div>
</div> </div>
+21
View File
@@ -0,0 +1,21 @@
export function Analytics() {
return (
<>
<script
async
src="https://www.googletagmanager.com/gtag/js?id=G-98NTMRHRP9"
/>
<script
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-98NTMRHRP9');
`,
}}
/>
</>
);
}
@@ -20,6 +20,7 @@ export const CoreArmorScene = memo(() => {
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
const initialTankopediaState = useTankopediaPersistent.getState(); const initialTankopediaState = useTankopediaPersistent.getState();
const protagonist = useDuel((draft) => draft.protagonist!);
useEffect(() => { useEffect(() => {
if (!modelDefinitions) return; if (!modelDefinitions) return;
@@ -88,7 +89,7 @@ export const CoreArmorScene = memo(() => {
turretContainer.current?.rotation.copy(turretRotation); turretContainer.current?.rotation.copy(turretRotation);
} }
handleModelTransform(useDuel.getState().protagonist!); handleModelTransform(protagonist);
modelTransformEvent.on(handleModelTransform); modelTransformEvent.on(handleModelTransform);
return () => { return () => {
@@ -48,7 +48,9 @@ export function CoreArmorSceneComponent({
explosionRadius: { value: null }, explosionRadius: { value: null },
greenPenetration: { value: null }, greenPenetration: { value: null },
opaque: { value: null }, opaque: { value: null },
useSpacedArmor: { value: null },
inverseProjectionMatrix: { value: null },
resolution: { value: new Vector2() }, resolution: { value: new Vector2() },
spacedArmorBuffer: { value: null }, spacedArmorBuffer: { value: null },
spacedArmorDepth: { value: null }, spacedArmorDepth: { value: null },
@@ -56,10 +58,7 @@ export function CoreArmorSceneComponent({
}); });
useEffect(() => { useEffect(() => {
function handleShellChange(shell: ShellDefinition) { async function handleShellChange(shell: ShellDefinition) {
material.uniforms.penetration.value = resolveNearPenetration(
shell.penetration,
);
material.uniforms.caliber.value = shell.caliber; material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90); material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
material.uniforms.normalization.value = degToRad( material.uniforms.normalization.value = degToRad(
@@ -69,12 +68,17 @@ export function CoreArmorSceneComponent({
material.uniforms.canSplash.value = canSplash(shell.type); material.uniforms.canSplash.value = canSplash(shell.type);
material.uniforms.damage.value = shell.damage.armor; material.uniforms.damage.value = shell.damage.armor;
material.uniforms.explosionRadius.value = shell.explosionRadius; material.uniforms.explosionRadius.value = shell.explosionRadius;
const duel = useDuel.getState();
await handleProtagonistEquipmentChange(duel.protagonist!.equipment);
await handleAntagonistEquipmentChange(duel.antagonist!.equipment);
} }
function handleVisualChange( function handleVisualChange(
visual: TankopediaPersistent['model']['visual'], visual: TankopediaPersistent['model']['visual'],
) { ) {
material.uniforms.greenPenetration.value = visual.greenPenetration; material.uniforms.greenPenetration.value = visual.greenPenetration;
material.uniforms.opaque.value = visual.opaque || visual.wireframe; material.uniforms.opaque.value = visual.opaque || visual.wireframe;
material.uniforms.useSpacedArmor.value = visual.showSpacedArmor;
material.wireframe = visual.wireframe; material.wireframe = visual.wireframe;
} }
async function handleProtagonistEquipmentChange( async function handleProtagonistEquipmentChange(
@@ -89,6 +93,7 @@ export function CoreArmorSceneComponent({
const shell = useDuel.getState().antagonist!.shell; const shell = useDuel.getState().antagonist!.shell;
const penetration = resolveNearPenetration(shell.penetration); const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(103, true, equipment); const hasCalibratedShells = await hasEquipment(103, true, equipment);
material.uniforms.penetration.value = hasCalibratedShells material.uniforms.penetration.value = hasCalibratedShells
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05) ? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
: penetration; : penetration;
@@ -118,15 +123,17 @@ export function CoreArmorSceneComponent({
return () => { return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe()); unsubscribes.forEach((unsubscribe) => unsubscribe());
}; };
}, []); });
useFrame(({ gl }) => { useFrame(({ gl, camera }) => {
gl.getSize(material.uniforms.resolution.value).multiplyScalar( gl.getSize(material.uniforms.resolution.value).multiplyScalar(
gl.getPixelRatio(), gl.getPixelRatio(),
); );
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture; material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
material.uniforms.spacedArmorDepth.value = material.uniforms.spacedArmorDepth.value =
spacedArmorRenderTarget.depthTexture; spacedArmorRenderTarget.depthTexture;
material.uniforms.inverseProjectionMatrix.value =
camera.projectionMatrixInverse;
}); });
return ( return (
@@ -1,6 +1,5 @@
varying vec3 vNormal; varying vec3 vNormal;
varying vec3 vViewPosition; varying vec3 vViewPosition;
varying mat4 vProjectionMatrix;
uniform float thickness; uniform float thickness;
uniform float penetration; uniform float penetration;
@@ -14,13 +13,15 @@ uniform float damage;
uniform float explosionRadius; uniform float explosionRadius;
uniform bool greenPenetration; uniform bool greenPenetration;
uniform bool opaque; uniform bool opaque;
uniform bool useSpacedArmor;
uniform mat4 inverseProjectionMatrix;
uniform highp sampler2D spacedArmorBuffer; uniform highp sampler2D spacedArmorBuffer;
uniform highp sampler2D spacedArmorDepth; uniform highp sampler2D spacedArmorDepth;
float depthToDistance(float depth) { float depthToDistance(float depth) {
vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); vec4 clipPosition = vec4(gl_FragCoord.xy / resolution * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0);
vec4 eyePosition = inverse(vProjectionMatrix) * clipPosition; vec4 eyePosition = inverseProjectionMatrix * clipPosition;
return length(eyePosition.xyz / eyePosition.w); return length(eyePosition.xyz / eyePosition.w);
} }
@@ -41,36 +42,42 @@ void main() {
bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0; bool twoCalibersRule = caliber > thickness * 2.0 && thickness > 0.0;
float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization; float finalNormalization = twoCalibersRule ? ((1.4 * normalization * caliber) / (2.0 * thickness)) : normalization;
float finalThickness = thickness / cos(max(0.0, angle - finalNormalization)); float finalThickness = thickness / cos(max(0.0, angle - finalNormalization));
float remainingPenetration = penetration;
// world space is in meters // world space in meters
vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates); vec4 spacedArmorDepthFragment = texture2D(spacedArmorDepth, screenCoordinates);
float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r); float spacedArmorDistance = depthToDistance(spacedArmorDepthFragment.r);
float coreArmorDistance = depthToDistance(gl_FragCoord.z); float coreArmorDistance = depthToDistance(gl_FragCoord.z);
float distanceFromSpacedArmor = coreArmorDistance - spacedArmorDistance; float distanceFromSpacedArmor = coreArmorDistance - spacedArmorDistance;
if (isUnderSpacedArmor) { if (canSplash && useSpacedArmor && isUnderSpacedArmor) {
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration; float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
remainingPenetration -= spacedArmorThickness; float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * (finalThickness + spacedArmorThickness);
if (isExplosive && remainingPenetration > 0.0) { penetrationChance = 0.0;
// there is a 50% penetration loss per meter for HEAT shells splashChance = finalDamage > 0.0 ? 1.0 : 0.0;
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
}
}
remainingPenetration = max(0.0, remainingPenetration);
float delta = finalThickness - remainingPenetration;
float randomization = remainingPenetration * 0.05;
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
if (canSplash) {
// only allow splashing if the damage equation deals more than 0 damage
float nominalArmorThickness = finalThickness - remainingPenetration;
float finalDamage = 0.5 * damage * (1.0 - distanceFromSpacedArmor / explosionRadius) - 1.1 * nominalArmorThickness;
splashChance = finalDamage > 0.0 ? 1.0 : 0.0; // TODO: add randomization here?
} else { } else {
splashChance = 0.0; float remainingPenetration = penetration;
if (useSpacedArmor && isUnderSpacedArmor) {
float spacedArmorThickness = spacedArmorBufferFragment.r * penetration;
remainingPenetration -= spacedArmorThickness;
if (isExplosive && remainingPenetration > 0.0) {
remainingPenetration -= 0.5 * remainingPenetration * distanceFromSpacedArmor;
}
}
remainingPenetration = max(0.0, remainingPenetration);
float delta = finalThickness - remainingPenetration;
float randomization = remainingPenetration * 0.05;
penetrationChance = clamp(1.0 - (delta + randomization) / (2.0 * randomization), 0.0, 1.0);
if (canSplash) {
float splashDamage = 0.5 * damage - 1.1 * finalThickness;
splashChance = splashDamage > 0.0 ? 1.0 : 0.0;
} else {
splashChance = 0.0;
}
} }
} }
@@ -1,12 +1,10 @@
varying vec3 vNormal; varying vec3 vNormal;
varying vec3 vViewPosition; varying vec3 vViewPosition;
varying mat4 vProjectionMatrix;
void main() { void main() {
vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0); vec4 vViewPosition4 = modelViewMatrix * vec4(position, 1.0);
vViewPosition = vViewPosition4.xyz; vViewPosition = vViewPosition4.xyz;
vProjectionMatrix = projectionMatrix;
vNormal = normalMatrix * normal; vNormal = normalMatrix * normal;
gl_Position = projectionMatrix * vViewPosition4; gl_Position = projectionMatrix * vViewPosition4;
@@ -4,6 +4,7 @@ import { useFrame } from '@react-three/fiber';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Mesh } from 'three'; import { Mesh } from 'three';
import { radToDeg } from 'three/src/math/MathUtils'; import { radToDeg } from 'three/src/math/MathUtils';
import { canSplash } from '../../../core/blitz/canSplash';
import { isExplosive } from '../../../core/blitz/isExplosive'; import { isExplosive } from '../../../core/blitz/isExplosive';
import { NaNFallback } from '../../../core/math/NaNFallback'; import { NaNFallback } from '../../../core/math/NaNFallback';
import { normalToEuler } from '../../../core/math/normalToEuler'; import { normalToEuler } from '../../../core/math/normalToEuler';
@@ -42,6 +43,7 @@ export function ShotDisplay() {
); );
const { shell } = useDuel.getState().antagonist!; const { shell } = useDuel.getState().antagonist!;
const explosive = isExplosive(shell.type); const explosive = isExplosive(shell.type);
const splashing = canSplash(shell.type);
const midPointLayer = (shot?.layers.findLast( const midPointLayer = (shot?.layers.findLast(
(layer) => layer.type !== null && layer.status === 'ricochet', (layer) => layer.type !== null && layer.status === 'ricochet',
) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase; ) ?? shot?.layers.findLast((layer) => layer.type !== null)) as ShotLayerBase;
@@ -64,6 +66,18 @@ export function ShotDisplay() {
return accumulator; return accumulator;
}, 0) + LENGTH_INFINITY }, 0) + LENGTH_INFINITY
: undefined; : undefined;
const preMidPointThickness = shot
? preMidPointLayers!.reduce(
(accumulator, layer) =>
accumulator +
(layer.type === null
? 0
: layer.type === ArmorType.External
? layer.thickness
: layer.thicknessAngled),
0,
)
: undefined;
const postMidPointGap = shot const postMidPointGap = shot
? postMidPointLayers!.reduce((accumulator, layer) => { ? postMidPointLayers!.reduce((accumulator, layer) => {
if (layer.type === null) return accumulator + layer.distance; if (layer.type === null) return accumulator + layer.distance;
@@ -246,8 +260,9 @@ export function ShotDisplay() {
(1 - (1 -
distanceFromSpacedArmor! / shell.explosionRadius!) - distanceFromSpacedArmor! / shell.explosionRadius!) -
1.1 * 1.1 *
(midPointLayer as ShotLayerNonExternal) ((midPointLayer as ShotLayerNonExternal)
.thicknessAngled, .thicknessAngled +
preMidPointThickness!),
), ),
), ),
0, 0,
@@ -255,7 +270,7 @@ export function ShotDisplay() {
</Text> </Text>
{shot.layers.map((layer, index) => { {shot.layers.map((layer, index) => {
if (layer.type === null && !explosive) return null; if (layer.type === null && (!explosive || splashing)) return null;
return ( return (
<Flex key={index} align="center" gap="2"> <Flex key={index} align="center" gap="2">
@@ -31,6 +31,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
const turretContainer = useRef<Group>(null); const turretContainer = useRef<Group>(null);
const gunContainer = useRef<Group>(null); const gunContainer = useRef<Group>(null);
const initialTankopediaState = useTankopediaPersistent.getState(); const initialTankopediaState = useTankopediaPersistent.getState();
const protagonist = useDuel((draft) => draft.protagonist!);
useEffect(() => { useEffect(() => {
if (!modelDefinitions) return; if (!modelDefinitions) return;
@@ -99,7 +100,7 @@ export const SpacedArmorScene = memo<SpacedArmorSceneProps>(({ scene }) => {
turretContainer.current?.rotation.copy(turretRotation); turretContainer.current?.rotation.copy(turretRotation);
} }
handleModelTransform(useDuel.getState().protagonist!); handleModelTransform(protagonist);
modelTransformEvent.on(handleModelTransform); modelTransformEvent.on(handleModelTransform);
return () => { return () => {
@@ -83,7 +83,7 @@ export function SpacedArmorSubExternal({
return () => { return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe()); unsubscribes.forEach((unsubscribe) => unsubscribe());
}; };
}, []); });
return ( return (
<> <>
@@ -95,7 +95,7 @@ export function SpacedArmorSubSpaced({
return () => { return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe()); unsubscribes.forEach((unsubscribe) => unsubscribe());
}; };
}, []); });
return ( return (
<> <>
@@ -18,6 +18,7 @@ import { useDuel } from '../../../../stores/duel';
import { import {
Shot, Shot,
ShotLayerBase, ShotLayerBase,
useTankopediaPersistent,
useTankopediaTemporary, useTankopediaTemporary,
} from '../../../../stores/tankopedia'; } from '../../../../stores/tankopedia';
import { ArmorType } from '../SpacedArmorScene'; import { ArmorType } from '../SpacedArmorScene';
@@ -115,12 +116,20 @@ export function SpacedArmorSceneComponent({
pushLogs(shellNormal, filterIntersections(event.intersections), 0); pushLogs(shellNormal, filterIntersections(event.intersections), 0);
function filterIntersections(intersectionsRaw: Intersection[]) { function filterIntersections(intersectionsRaw: Intersection[]) {
const intersectionsAll = intersectionsRaw.filter( const showSpacedArmor =
useTankopediaPersistent.getState().model.visual.showSpacedArmor;
const intersectionsTyped = intersectionsRaw.filter(
({ object }) => object.userData.type !== undefined, ({ object }) => object.userData.type !== undefined,
) as ArmorMeshIntersection[]; ) as ArmorMeshIntersection[];
const intersectionsWithDuplicates = intersectionsAll.slice( const intersectionsFiltered = intersectionsTyped.filter(
({ object }) =>
showSpacedArmor
? true
: object.userData.type === ArmorType.Core,
);
const intersectionsWithDuplicates = intersectionsFiltered.slice(
0, 0,
intersectionsAll.findIndex( intersectionsFiltered.findIndex(
({ object }) => object.userData.type === ArmorType.Core, ({ object }) => object.userData.type === ArmorType.Core,
) + 1, ) + 1,
); );
@@ -170,7 +179,7 @@ export function SpacedArmorSceneComponent({
const distance = ( const distance = (
shot.layers.at(-1) as ShotLayerBase shot.layers.at(-1) as ShotLayerBase
).point.distanceTo(intersections[0].point); ).point.distanceTo(intersections[0].point);
if (explosive) if (explosive && !splashing)
remainingPenetration -= 0.5 * remainingPenetration * distance; remainingPenetration -= 0.5 * remainingPenetration * distance;
const blocked = remainingPenetration < 0; const blocked = remainingPenetration < 0;
@@ -196,7 +205,7 @@ export function SpacedArmorSceneComponent({
const previousIntersection = intersections[layerIndex - 1]; const previousIntersection = intersections[layerIndex - 1];
const distance = const distance =
intersection.distance - previousIntersection.distance; intersection.distance - previousIntersection.distance;
if (explosive) if (explosive && !splashing)
remainingPenetration -= remainingPenetration -=
0.5 * remainingPenetration * distance; 0.5 * remainingPenetration * distance;
const wasted = remainingPenetration < 0; const wasted = remainingPenetration < 0;
@@ -207,7 +216,7 @@ export function SpacedArmorSceneComponent({
distance, distance,
}); });
if (wasted) return; if (wasted && !splashing) return;
} }
if (intersection.object.userData.type === ArmorType.External) { if (intersection.object.userData.type === ArmorType.External) {
@@ -224,7 +233,11 @@ export function SpacedArmorSceneComponent({
status: blocked ? 'blocked' : 'penetration', status: blocked ? 'blocked' : 'penetration',
}); });
if (blocked) break; if (splashing) {
remainingPenetration = Math.max(0, remainingPenetration);
} else if (blocked) {
break;
}
} else { } else {
const type = intersection.object.userData.type; const type = intersection.object.userData.type;
const angle = surfaceNormal.angleTo(shellNormal); const angle = surfaceNormal.angleTo(shellNormal);
+1
View File
@@ -16,6 +16,7 @@ export default function CommandWrapper({ children, fat }: CommandWrapperProps) {
gap: 16, gap: 16,
width: 480, width: 480,
color: theme.colors.textHighContrast, color: theme.colors.textHighContrast,
position: 'relative',
background: theme.colors.appBackground1, background: theme.colors.appBackground1,
}} }}
> >
+20
View File
@@ -0,0 +1,20 @@
interface DeltaCaretProps {
delta: number;
}
export function DeltaCaret({ delta }: DeltaCaretProps) {
return (
<img
style={{
width: 16,
height: 16,
objectFit: 'contain',
}}
src={
delta > 0
? 'https://i.imgur.com/gjI4a5f.png'
: 'https://i.imgur.com/lLGZygN.png'
}
/>
);
}
+3 -41
View File
@@ -1,5 +1,4 @@
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
import { RatingsReward } from '../../../commands/ratings';
import { theme } from '../../../stitches.config'; import { theme } from '../../../stitches.config';
interface ItemProps extends ComponentProps<'div'> { interface ItemProps extends ComponentProps<'div'> {
@@ -7,22 +6,16 @@ interface ItemProps extends ComponentProps<'div'> {
deltaPosition?: number; deltaPosition?: number;
score?: number; score?: number;
deltaScore?: number; deltaScore?: number;
reward?: RatingsReward;
nickname: string; nickname: string;
clan?: string; clan?: string;
highlight?: boolean; highlight?: boolean;
} }
function normalizeImage(url: string) {
return url.endsWith('.webp') ? 'https://i.imgur.com/ZniCzbO.png' : url;
}
export function Item({ export function Item({
position, position,
deltaPosition, deltaPosition,
score, score,
deltaScore, deltaScore,
reward,
nickname, nickname,
clan, clan,
highlight, highlight,
@@ -120,40 +113,8 @@ export function Item({
</div> </div>
)} )}
<div {/* TODO: lol remove this spacer div for a better solution */}
style={{ <div style={{ flex: 1 }} />
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 4,
}}
>
{reward && (
<img
style={{ width: 16, height: 16, objectFit: 'cover' }}
src={normalizeImage(
reward.type === 'vehicle'
? reward.vehicle.image_url
: reward.stuff.image_url,
)}
width={16}
height={16}
/>
)}
{reward && reward.count > 1 && (
<span
style={{
color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16,
}}
>
x {reward.count.toLocaleString()}
</span>
)}
</div>
<div <div
style={{ style={{
@@ -189,6 +150,7 @@ export function Item({
} }
/> />
)} )}
{score !== undefined && ( {score !== undefined && (
<span <span
style={{ style={{
@@ -11,23 +11,28 @@ export interface TankComponentButtonProps
discriminator?: ReactNode; discriminator?: ReactNode;
disabled?: boolean; disabled?: boolean;
banner?: string; banner?: string;
special?: boolean;
} }
export function TankComponentButton({ export function TankComponentButton({
variant,
selected, selected,
first = false, first = false,
last = false, last = false,
rowChild, rowChild,
discriminator, discriminator,
special,
children, children,
banner, banner,
disabled,
style,
...props ...props
}: TankComponentButtonProps) { }: TankComponentButtonProps) {
return ( return (
<Button <Button
radius="medium" radius="medium"
color={selected ? undefined : 'gray'} color={selected ? (special ? 'amber' : undefined) : 'gray'}
variant={selected ? 'surface' : 'soft'} variant={variant ?? (selected ? 'surface' : 'soft')}
style={{ style={{
padding: 0, padding: 0,
minWidth: 48, minWidth: 48,
@@ -39,7 +44,11 @@ export function TankComponentButton({
borderBottomRightRadius: last ? undefined : 0, borderBottomRightRadius: last ? undefined : 0,
margin: rowChild ? -0.5 : 'unset', margin: rowChild ? -0.5 : 'unset',
overflow: 'hidden', overflow: 'hidden',
cursor: 'unset',
pointerEvents: variant === 'ghost' ? 'none' : undefined,
...style,
}} }}
disabled={variant === 'ghost' || disabled}
{...props} {...props}
> >
{banner !== undefined && ( {banner !== undefined && (
@@ -56,23 +65,23 @@ export function TankComponentButton({
/> />
)} )}
{children}
{discriminator !== undefined && ( {discriminator !== undefined && (
<Text <Text
size="1" size="1"
style={{ style={{
textShadow: '0 0 4px black', textShadow: '0 0 4px black',
zIndex: 1,
position: 'absolute', position: 'absolute',
top: '50%', top: '50%',
right: 8, right: 8,
textAlign: 'right', textAlign: 'right',
}} }}
color={variant === 'ghost' ? 'gray' : undefined}
> >
{discriminator} {discriminator}
</Text> </Text>
)} )}
{children}
</Button> </Button>
); );
} }
+42 -2
View File
@@ -1,10 +1,16 @@
import { DiscordLogoIcon } from '@radix-ui/react-icons'; import { DiscordLogoIcon } from '@radix-ui/react-icons';
import { Flex, Text, Theme } from '@radix-ui/themes'; import { Flex, Text, Theme } from '@radix-ui/themes';
import Link from 'next/link'; import Link from 'next/link';
import { useFullScreen } from '../../hooks/useFullScreen';
import { BlitzkriegWormWide } from '../../icons/BlitzkriegWormWide'; import { BlitzkriegWormWide } from '../../icons/BlitzkriegWormWide';
import { PatreonIcon } from '../../icons/Patreon';
import { theme } from '../../stitches.config'; import { theme } from '../../stitches.config';
export default function Navbar() { export default function Navbar() {
const isFullScreen = useFullScreen();
if (isFullScreen) return null;
return ( return (
<Theme radius="none"> <Theme radius="none">
<div <div
@@ -51,9 +57,10 @@ export default function Navbar() {
</Text> </Text>
</Flex> </Flex>
<Flex justify="center" align="center" gap="2"> <Flex justify="center" align="center" gap="4">
<Link <Link
href="https://discord.gg/nDt7AjGJQH" href="https://discord.gg/nDt7AjGJQH"
target="_blank"
style={{ style={{
color: 'inherit', color: 'inherit',
display: 'flex', display: 'flex',
@@ -61,7 +68,40 @@ export default function Navbar() {
justifyContent: 'center', justifyContent: 'center',
}} }}
> >
<DiscordLogoIcon /> <DiscordLogoIcon width={16} height={16} />
</Link>
<Link
href="https://ko-fi.com/tresabhi"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
src="https://i.imgur.com/QdqgdP2.png"
style={{
width: 20,
height: 20,
objectFit: 'contain',
}}
/>
</Link>
<Link
href="https://www.patreon.com/tresabhi"
target="_blank"
style={{
color: 'inherit',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<PatreonIcon width={14} height={14} />
</Link> </Link>
</Flex> </Flex>
</Flex> </Flex>
+5 -1
View File
@@ -3,7 +3,11 @@ import { translator } from '../../core/localization/translator';
import { theme } from '../../stitches.config'; import { theme } from '../../stitches.config';
interface NoDataProps { interface NoDataProps {
type: 'battles_in_period' | 'players_in_period' | 'tanks_found'; type:
| 'battles_in_period'
| 'players_in_period'
| 'tanks_found'
| 'battles_in_season';
locale: Locale; locale: Locale;
} }
+6 -6
View File
@@ -1,15 +1,15 @@
import { Flex, Theme } from '@radix-ui/themes'; import { Flex, FlexProps, Theme } from '@radix-ui/themes';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
interface PageWrapperProps extends ComponentProps<typeof Flex> { type PageWrapperProps = FlexProps & {
color?: ComponentProps<typeof Theme>['accentColor']; color?: ComponentProps<typeof Theme>['accentColor'];
size?: 'regular' | 'wide' | 'double'; size?: number;
} };
export default function PageWrapper({ export default function PageWrapper({
style, style,
color, color,
size = 'regular', size = 800,
...props ...props
}: PageWrapperProps) { }: PageWrapperProps) {
return ( return (
@@ -19,7 +19,7 @@ export default function PageWrapper({
gap="4" gap="4"
style={{ style={{
width: '100%', width: '100%',
maxWidth: size === 'regular' ? 800 : size === 'double' ? 1200 : 960, maxWidth: size,
margin: 'auto', margin: 'auto',
padding: 16, padding: 16,
boxSizing: 'border-box', boxSizing: 'border-box',
+1
View File
@@ -15,6 +15,7 @@ export default function TitleBar({ title, image, description }: TitleBarProps) {
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
padding: '8px 0px',
}} }}
> >
<div <div
+5 -5
View File
@@ -1,7 +1,7 @@
export const LEAGUES = [ export const LEAGUES = [
{ name: 'diamond', minScore: 5000 }, { name: 'diamond', minScore: 5000, index: 0 },
{ name: 'platinum', minScore: 4000 }, { name: 'platinum', minScore: 4000, index: 1 },
{ name: 'gold', minScore: 3000 }, { name: 'gold', minScore: 3000, index: 2 },
{ name: 'silver', minScore: 2000 }, { name: 'silver', minScore: 2000, index: 3 },
{ name: 'bronze', minScore: 0 }, { name: 'bronze', minScore: -Infinity, index: 4 },
]; ];
+2
View File
@@ -0,0 +1,2 @@
export const FIRST_MINIMAL_ARCHIVED_RATING_SEASON = 49;
export const FIRST_COMPREHENSIVE1_ARCHIVED_RATING_SEASON = 53;
-1
View File
@@ -1 +0,0 @@
export const FIRST_ARCHIVED_RATINGS_SEASON = 49;
+3 -3
View File
@@ -18,9 +18,9 @@ export const TOOLS: Tool[] = [
description: 'Blitz tank encyclopedia', description: 'Blitz tank encyclopedia',
}, },
{ {
id: 'ratings', id: 'rating',
title: 'Ratings', title: 'Rating leaderboard',
description: 'Ratings gamemode leaderboards', description: 'Full rating gamemode leaderboard',
}, },
{ {
id: 'discord', id: 'discord',
+3 -1
View File
@@ -1,3 +1,5 @@
import { patientFetch } from '../blitzkrieg/patientFetch';
type BlitzResponse<Data extends object> = type BlitzResponse<Data extends object> =
| { | {
status: 'error'; status: 'error';
@@ -23,7 +25,7 @@ async function manageQueue() {
}, 1000); }, 1000);
const { url, resolve } = queue.shift()!; const { url, resolve } = queue.shift()!;
const data = (await fetch(url) const data = (await patientFetch(url)
.then((response) => response.json()) .then((response) => response.json())
.catch((error) => ({ status: 'error', error }))) as BlitzResponse< .catch((error) => ({ status: 'error', error }))) as BlitzResponse<
typeof resolve typeof resolve
+25 -3
View File
@@ -24,7 +24,6 @@ export interface AllStats {
} }
export interface SupplementaryStats { export interface SupplementaryStats {
// if undefined, WN8 is not available for tanks without averages
WN8?: number; WN8?: number;
tier?: number; tier?: number;
} }
@@ -51,6 +50,28 @@ export interface ClanStats {
dropped_capture_points: number; dropped_capture_points: number;
} }
export interface RatingStats {
spotted: number;
calibration_battles_left: number;
hits: number;
frags: number;
recalibration_start_time: number;
mm_rating: number;
wins: number;
losses: number;
is_recalibration: boolean;
capture_points: number;
battles: number;
damage_dealt: number;
damage_received: number;
shots: number;
frags8p: number;
xp: number;
win_and_survived: number;
survived_battles: number;
dropped_capture_points: number;
}
interface IndividualAccountInfo { interface IndividualAccountInfo {
account_id: number; account_id: number;
created_at: number; created_at: number;
@@ -61,6 +82,7 @@ interface IndividualAccountInfo {
statistics: { statistics: {
all: AllStats; all: AllStats;
clan: ClanStats; clan: ClanStats;
rating?: RatingStats;
frags: null | number; frags: null | number;
}; };
} }
@@ -74,11 +96,11 @@ export async function getAccountInfo<
ReturnType = Ids extends number ReturnType = Ids extends number
? IndividualAccountInfo ? IndividualAccountInfo
: IndividualAccountInfo[], : IndividualAccountInfo[],
>(region: Region, ids: Ids) { >(region: Region, ids: Ids, extra: string[] = []) {
const object = await fetchBlitz<AccountInfo>( const object = await fetchBlitz<AccountInfo>(
`https://api.wotblitz.${region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds( `https://api.wotblitz.${region}/wotb/account/info/?application_id=${WARGAMING_APPLICATION_ID}&account_id=${normalizeIds(
ids, ids,
)}`, )}${extra.length === 0 ? '' : `&extra=${extra.join(',')}`}`,
); );
if (typeof ids === 'number') { if (typeof ids === 'number') {
+5
View File
@@ -0,0 +1,5 @@
import { LEAGUES } from '../../constants/leagues';
export function getLeagueFromScore(score: number) {
return LEAGUES.find(({ minScore }) => score >= minScore)!;
}
@@ -1,26 +1,26 @@
import { RatingsInfo } from '../../commands/ratings'; import { RatingInfo } from '../../commands/ratingLeaderboard';
import { Region } from '../../constants/regions'; import { Region } from '../../constants/regions';
import { context } from '../blitzkrieg/context'; import { context } from '../blitzkrieg/context';
import { patientFetch } from '../blitzkrieg/patientFetch'; import { patientFetch } from '../blitzkrieg/patientFetch';
import regionToRegionSubdomain from './regionToRegionSubdomain'; import regionToRegionSubdomain from './regionToRegionSubdomain';
const ratingsInfoCache: Partial<Record<Region, RatingsInfo>> = {}; const cache: Partial<Record<Region, RatingInfo>> = {};
export default async function getRatingsInfo(region: Region) { export default async function getRatingInfo(region: Region) {
if (ratingsInfoCache[region]) return ratingsInfoCache[region]!; if (cache[region]) return cache[region]!;
const response = await patientFetch( const response = await patientFetch(
context === 'website' context === 'website'
? `/api/${region}/ratings/current/info` ? `/api/${region}/rating/current/info`
: `https://${regionToRegionSubdomain( : `https://${regionToRegionSubdomain(
region, region,
)}.wotblitz.com/en/api/rating-leaderboards/season/`, )}.wotblitz.com/en/api/rating-leaderboards/season/`,
undefined, undefined,
{ cache: 'no-store' }, { cache: 'no-store' },
); );
const data = (await response.json()) as RatingsInfo; const data = (await response.json()) as RatingInfo;
ratingsInfoCache[region] = data; cache[region] = data;
return data; return data;
} }
@@ -1,10 +1,10 @@
import { LeagueTop, RatingsPlayer } from '../../commands/ratings'; import { LeagueTop, RatingPlayer } from '../../commands/ratingLeaderboard';
import { Region } from '../../constants/regions'; import { Region } from '../../constants/regions';
import { context } from '../blitzkrieg/context'; import { context } from '../blitzkrieg/context';
import { patientFetch } from '../blitzkrieg/patientFetch'; import { patientFetch } from '../blitzkrieg/patientFetch';
import regionToRegionSubdomain from './regionToRegionSubdomain'; import regionToRegionSubdomain from './regionToRegionSubdomain';
export async function getRatingsLeague( export async function getRatingLeague(
region: Region, region: Region,
league: number, league: number,
usePatientFetcher = false, usePatientFetcher = false,
@@ -15,7 +15,7 @@ export async function getRatingsLeague(
: fetch(url, { cache: 'no-store' }); : fetch(url, { cache: 'no-store' });
const response = await fetcher( const response = await fetcher(
context === 'website' context === 'website'
? `/api/${region}/ratings/current/league/${league}` ? `/api/${region}/rating/current/league/${league}`
: `https://${regionToRegionSubdomain( : `https://${regionToRegionSubdomain(
region, region,
)}.wotblitz.com/en/api/rating-leaderboards/league/${league}/top/`, )}.wotblitz.com/en/api/rating-leaderboards/league/${league}/top/`,
@@ -23,6 +23,6 @@ export async function getRatingsLeague(
const json = await response.json(); const json = await response.json();
return context === 'website' return context === 'website'
? (json as RatingsPlayer[]) ? (json as RatingPlayer[])
: (json as LeagueTop).result; : (json as LeagueTop).result;
} }
@@ -1,10 +1,10 @@
import { RatingsNeighbors } from '../../commands/ratings'; import { RatingNeighbors } from '../../commands/ratingLeaderboard';
import { Region } from '../../constants/regions'; import { Region } from '../../constants/regions';
import { patientFetch } from '../blitzkrieg/patientFetch'; import { patientFetch } from '../blitzkrieg/patientFetch';
import { withCORSProxy } from '../blitzkrieg/withCORSProxy'; import { withCORSProxy } from '../blitzkrieg/withCORSProxy';
import regionToRegionSubdomain from './regionToRegionSubdomain'; import regionToRegionSubdomain from './regionToRegionSubdomain';
export async function getRatingsNeighbors( export async function getRatingNeighbors(
region: Region, region: Region,
id: number, id: number,
count: number, count: number,
@@ -21,7 +21,7 @@ export async function getRatingsNeighbors(
)}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${count}`, )}.wotblitz.com/en/api/rating-leaderboards/user/${id}/?neighbors=${count}`,
), ),
); );
const json = (await response.json()) as RatingsNeighbors; const json = (await response.json()) as RatingNeighbors;
return json; return json;
} }
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more