Compare commits

...
178 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
TrèsAbhi 87786d41a7 feat: logs 2024-03-17 21:48:23 -05:00
TrèsAbhi ee7ffe7bc8 feat: include sharp in build 2024-03-17 21:45:55 -05:00
TrèsAbhi 379a33375d feat: better totals 2024-03-17 21:40:21 -05:00
TrèsAbhi bd3f5dea05 feat: optimized png cache 2024-03-17 21:23:57 -05:00
TrèsAbhi 146d36234e New Crowdin updates (#99) 2024-03-17 21:19:24 -05:00
TrèsAbhi f3edcb8077 Update cd-assets.yml 2024-03-17 20:17:11 -05:00
TrèsAbhi 9e492015ff feat: jpg texture files 2024-03-16 15:34:48 -05:00
TrèsAbhi d2977182d9 fix: grammer 2024-03-16 15:24:25 -05:00
TrèsAbhi 7d2d65bab8 fix: install sharp 2024-03-16 15:18:23 -05:00
TrèsAbhi 415c3bd066 fix: fixed sharp 2024-03-16 15:15:34 -05:00
TrèsAbhi 990ab1520c Merge pull request #100 from tresabhi/dev 2024-03-16 15:12:54 -05:00
TrèsAbhi 81cb1afeff fix: types 2024-03-16 15:12:47 -05:00
TrèsAbhi dba5a0a2cd feat: logs 2024-03-16 15:10:55 -05:00
TrèsAbhi 04cd205fee feat: preview servers 2024-03-16 15:07:05 -05:00
TrèsAbhi ec93d8e9ab feat: better words 2024-03-16 10:41:25 -05:00
TrèsAbhi 56321d9e33 feat: simplified owned tanks command 2024-03-16 00:06:44 -05:00
TrèsAbhi d39ab43e74 feat: error handling for /research 2024-03-15 21:50:19 -05:00
TrèsAbhi 787d3ae6ea feat: functionality and icons 2024-03-15 12:28:29 -05:00
TrèsAbhi 035f751bf1 feat: remove protobuf 2024-03-15 10:47:05 -05:00
TrèsAbhi 43442ac829 feat: start of protobuf 2024-03-15 00:21:58 -05:00
TrèsAbhi da02a089b9 feat: xp definitions 2024-03-14 23:31:50 -05:00
TrèsAbhi 476d8e804a feat: some ui 2024-03-14 17:31:11 -05:00
TrèsAbhi 43e1d53d39 feat: resolving lines 2024-03-14 16:43:28 -05:00
TrèsAbhi 5372068830 feat: added trees 2024-03-13 23:23:06 -05:00
TrèsAbhi 025cec3687 Merge pull request #98 from tresabhi/main 2024-03-13 20:30:50 -05:00
TrèsAbhi c47c629a83 Merge pull request #95 from tresabhi/l10n 2024-03-13 20:30:09 -05:00
TrèsAbhi 2b7907f8f7 fix: use main 2024-03-13 20:29:28 -05:00
TrèsAbhi 903d4e4d95 fix: multiple equipments 2024-03-13 18:50:08 -05:00
TrèsAbhi 8857b2baae feat: production ready 2024-03-13 18:36:48 -05:00
TrèsAbhi f7bbb17f66 Merge pull request #96 from tresabhi/1.16.4 2024-03-13 18:28:59 -05:00
TrèsAbhi f407ffe4bb feat: ignore script types 2024-03-13 18:25:13 -05:00
TrèsAbhi 791a09daae feat: simplified imports 2024-03-13 18:15:13 -05:00
TrèsAbhi 9592c0b55f feat: logs 2024-03-13 18:00:25 -05:00
TrèsAbhi d9a34dc60e fix: use no-store 2024-03-13 17:52:06 -05:00
TrèsAbhi 30e4e4e9b3 fix: autoreloader dpm by crew stats 2024-03-13 17:46:48 -05:00
TrèsAbhi bd3ea9a8ec fix: crew stats for auto reloader dpm 2024-03-13 17:42:19 -05:00
TrèsAbhi 8131b69422 fix: use WOTB_DLC_CDN 2024-03-13 17:35:40 -05:00
TrèsAbhi 340d9c2666 feat: nicer layer penetration indices 2024-03-13 17:34:29 -05:00
TrèsAbhi 2a169fac34 fix: more accurate splash mechanics 2024-03-13 17:15:22 -05:00
TrèsAbhi 4819100ec6 feat: sharp-based texture manipulation 2024-03-13 17:00:40 -05:00
TrèsAbhi f39a94cbe0 fix: us depot 2024-03-13 16:52:52 -05:00
TrèsAbhi e61598f768 Update cd-assets.yml 2024-03-13 14:14:14 -05:00
TrèsAbhi 04ee3a0a44 fix: use correct gh token 2024-03-13 13:30:08 -05:00
TrèsAbhi 8fa613f1e2 fix: use github token 2024-03-13 13:29:14 -05:00
TrèsAbhi bdc240df08 feat: push on any 2024-03-13 13:27:15 -05:00
TrèsAbhi 7169e0d91a New translations en-us.json (Spanish) 2024-03-13 05:37:55 -05:00
TrèsAbhi 889ed7e191 feat: verbose 2024-03-13 00:22:54 -05:00
TrèsAbhi 1fb8dac805 feat: smarter host 2024-03-13 00:19:33 -05:00
TrèsAbhi 9f71ac68b9 feat: patches 2024-03-13 00:17:30 -05:00
TrèsAbhi c1f3517a79 Merge pull request #97 from tresabhi/main 2024-03-12 23:39:24 -05:00
TrèsAbhi adb0af0f18 Delete docs/changelogs/1.16.2 copy.md 2024-03-12 23:39:06 -05:00
TrèsAbhi f79f1ffdc3 feat: organize files 2024-03-12 23:36:56 -05:00
TrèsAbhi 1c9b7463fc New translations en-us.json (Croatian (disambiguation)) 2024-03-12 23:04:08 -05:00
TrèsAbhi 80741d66fc New translations en-us.json (Spanish) 2024-03-12 23:04:07 -05:00
TrèsAbhi f02fb00e8c fix: space after plus 2024-03-12 22:50:08 -05:00
TrèsAbhi 3d353cc3cf feat: updated workflow action versions 2024-03-12 22:48:41 -05:00
TrèsAbhi c54ceb944e Merge pull request #94 from tresabhi/1.16.3 2024-03-12 22:43:51 -05:00
TrèsAbhi 37a2ba3566 feat: docs 2024-03-12 18:07:32 -05:00
TrèsAbhi caff7bb6d0 feat: improved vertical stablizer model compat 2024-03-12 18:00:00 -05:00
TrèsAbhi 01c491aee0 feat: 1 more decimal in pitch and yaw 2024-03-12 17:40:58 -05:00
TrèsAbhi fd408d6777 fix: use initial gun pitches correctly 2024-03-12 17:39:46 -05:00
TrèsAbhi 6d1ed1e56e fix: reveals a few hidden values 2024-03-12 17:09:21 -05:00
TrèsAbhi 46f9d605f7 fix: intra clip spelling 2024-03-12 16:53:42 -05:00
TrèsAbhi a65ea42b81 Update cd-ratings-na.yml 2024-03-12 13:22:03 -05:00
TrèsAbhi 40f8c6a915 Update cd-ratings-eu.yml 2024-03-12 13:21:19 -05:00
TrèsAbhi 4678359f36 Update cd-ratings-asia.yml 2024-03-12 13:20:58 -05:00
TrèsAbhi f91bd63646 whooped 2024-03-10 12:50:25 -05:00
TrèsAbhi c1872262f5 huh 2024-03-10 12:29:04 -05:00
TrèsAbhi 082e112758 Update constants.ts 2024-03-10 10:54:27 -05:00
TrèsAbhi 4cb7d50eb5 Update cd-assets.yml 2024-03-10 10:53:34 -05:00
TrèsAbhi 8c050add23 my butt 2024-03-09 23:33:14 -06:00
TrèsAbhi 2610697d86 wee ooo wee oo 2024-03-09 23:04:09 -06:00
TrèsAbhi 64f1de37ad help me 2024-03-09 22:50:55 -06:00
TrèsAbhi 58f6862279 aaaaaaaaaaaaaaaaaaaaaa 2024-03-09 22:46:21 -06:00
TrèsAbhi b47f6fdb32 i dunno xd 2024-03-09 22:42:03 -06:00
TrèsAbhi ce5a53d0ff i dunno 2024-03-09 22:39:04 -06:00
TrèsAbhi 6ee1fa13d3 fix: use require 2024-03-09 22:15:22 -06:00
254 changed files with 5917 additions and 3099 deletions

No files matched your search

+16 -4
View File
@@ -2,10 +2,10 @@ name: CD Build Assets
on:
push:
branches: dev
branches: main
workflow_dispatch:
# schedule:
# - cron: '0 0 * * *'
schedule:
- cron: '0 2 * * *'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -18,6 +18,9 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ secrets.GH_TOKEN }}
- name: Use node
uses: actions/setup-node@v4
@@ -44,10 +47,19 @@ jobs:
run: yarn setup:depotdownloader
- name: Run DepotDownloader
run: ./temp/depotDownloader/DepotDownloader.exe -app 444202 -username ${{ secrets.DEPOT_DOWNLOADER_USERNAME }} -password "${{ secrets.DEPOT_DOWNLOADER_PASSWORD }}"
run: ./temp/depotDownloader/DepotDownloader.exe -app 444200 -username ${{ secrets.DEPOT_DOWNLOADER_USERNAME }} -password "${{ secrets.DEPOT_DOWNLOADER_PASSWORD }}"
- name: Apply patches
run: |
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
$env:WOTB_DLC_CDN = "${{ secrets.WOTB_DLC_CDN }}"
yarn setup:patches --depot
- name: Build assets
run: |
$env:GH_TOKEN = "${{ secrets.GH_TOKEN }}"
$env:DISCORD_TOKEN = "${{ secrets.DISCORD_TOKEN }}"
$env:DATABASE_URL = "${{ secrets.DATABASE_URL }}"
yarn build:assets --all-targets --depot ${{ github.ref == 'refs/heads/main' && '--production' || '' }}
+3 -3
View File
@@ -18,10 +18,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
@@ -54,7 +54,7 @@ jobs:
uses: actions/deploy-pages@v1
- name: Archive build
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: dist
path: docs/.vitepress/dist
+4 -2
View File
@@ -6,6 +6,8 @@ on:
schedule:
- cron: '0 */5 * * *'
workflow_dispatch:
release:
types: [created, published, released, edited]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -17,7 +19,7 @@ jobs:
steps:
- name: Use node
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: 18
@@ -31,4 +33,4 @@ jobs:
unzip bot.zip
- 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
# latest: '0 0 * * *'
# asia midnight: '0 17 * * *'
name: CD Rating Asia
on:
schedule:
- cron: '0 0 * * *'
- cron: '0 17 * * *'
jobs:
@@ -14,10 +10,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
@@ -35,4 +31,4 @@ jobs:
run: yarn
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} 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
# latest: '0 0 * * *'
# eu midnight: '0 23 * * *'
name: CD Rating Europe
on:
schedule:
- cron: '0 0 * * *'
- cron: '0 23 * * *'
jobs:
@@ -14,10 +10,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
@@ -35,4 +31,4 @@ jobs:
run: yarn
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} 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
# latest: '0 0 * * *'
# na midnight: '0 5 * * *'
name: CD Rating North America
on:
schedule:
- cron: '0 0 * * *'
- cron: '0 5 * * *'
jobs:
@@ -14,10 +10,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
@@ -35,4 +31,4 @@ jobs:
run: yarn
- name: Generate leaderboard
run: GH_TOKEN=${{ secrets.GH_TOKEN }} DISCORD_TOKEN=${{ secrets.DISCORD_TOKEN }} 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
+4 -4
View File
@@ -13,10 +13,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
@@ -34,10 +34,10 @@ jobs:
run: yarn
- name: Build
run: yarn build:bot
run: yarn build:bot --production
- name: Archive build
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: bot
path: dist/bot
+3 -3
View File
@@ -13,10 +13,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
@@ -37,7 +37,7 @@ jobs:
run: yarn build:docs
- name: Archive build
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: docs
path: dist/docs
+3 -3
View File
@@ -13,10 +13,10 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Use node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'npm'
@@ -37,7 +37,7 @@ jobs:
run: yarn build:website
- name: Archive build
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: website
path: dist/website
+4 -2
View File
@@ -8,7 +8,6 @@ dist/
dist.*
yarn-error.log
/test.*
test.*
/docs/.vitepress/dist
/docs/.vitepress/cache
servers.csv
@@ -17,4 +16,7 @@ tsconfig.tsbuildinfo
temp/
/.yarn
/.VSCodeCounter
/.yarn
/.yarn
depots
public/sitemap*.xml
public/robots.txt
+7 -2
View File
@@ -7,6 +7,7 @@
"alphatest",
"APAC",
"apcr",
"applypatches",
"artefacts",
"atgm",
"AYUV",
@@ -15,12 +16,14 @@
"betakrieg",
"BINORMAL",
"Binormals",
"BKRL",
"bkwd",
"blitzstars",
"Camo",
"CDON",
"classname",
"cleandist",
"closedkrieg",
"cockroackdb",
"Coef",
"cooldown",
@@ -73,6 +76,7 @@
"lods",
"metallicness",
"minitelrose",
"Minotauro",
"MIPMAPCOUNT",
"modeltransform",
"MURICA",
@@ -96,8 +100,7 @@
"POSITIVEZ",
"Promisable",
"Pyogenics",
"ratingsleaderboard",
"ratingsplot",
"ratingleaderboard",
"raycaster",
"researchable",
"Resvg",
@@ -122,6 +125,7 @@
"Synx",
"tankaverages",
"Tankerch",
"tankmen",
"tankopedia",
"tankopedias",
"tankstats",
@@ -140,6 +144,7 @@
"Vovko",
"wargaming",
"webp",
"whitespaces",
"winrate",
"wireframe",
"WOTB",
+21
View File
@@ -0,0 +1,21 @@
# Blitzkrieg 1.16.3
Further polishing and corrections for the Tankopedia.
## Features
- Added a few more stats to view for the Tankopedia
- Added the count of shells in the magazine for auto-reloaders
- The optimal shell index was moved below this stats
- Shell reloads for auto-reloaders now abide crew stats
- Intra-clip is now also shown for auto-reloaders
- Added 1 more decimal place worth of precision when viewing and changing gun pitch and yaw
## Fixes
- "Inter-clip" is now correctly spelled as "intra-clip"
- Tanks with initial gun pitches (like the Minotauro) are now handled correctly
- The stats now reflect the fact that the gun is naturally pitched downwards
- The view model now compensates for the natural pitch
- Tanks with the improved vertical stabilizer equipment are now handled correctly
- The view model now allows you to pitch the gun further as intended when the equipment is on
+19
View File
@@ -0,0 +1,19 @@
# Blitzkrieg 1.16.4
A few issues here and there addressed for good measure.
## Changes
- Armor layer penetration indices are now much more compact.
## Fixes
- Reverted splash mechanics changes from `1.16.1` as it made them less accurate.
- Auto-reloaders now report their DPM stats correctly with crew training.
## Technical Changes
- All files in the project were reviewed and constant exports were isolated.
- The downloaded game in the workflow now also includes the latest patches.
- Asset building now uses sharp under the hood which should in theory be faster.
- Fetching CDON LZ4 files now uses `no-store` instead of `no-cache`
+13
View File
@@ -0,0 +1,13 @@
# Blitzkrieg 1.17.0
A preview command and a touchup.
## Features
- Added `/research` in preview only accessible in the official Discord server.
- More details will be posted when the command is made public.
## Changes
- `/owned-tanks` now returns a simple text message listing all the tanks instead of an image.
- This is meant to improve the readability and ease of copying.
+8
View File
@@ -0,0 +1,8 @@
# Blitzkrieg 1.17.1
Some stability fixes.
## Technical Changes
- `sharp` is now included in the build instead of having being installed before runtime.
- Png converter cache is now more memory efficient.
+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
This documentation uses an experimental binary notation syntax called "Buffer Up".
1. JSON compressed as CDON, written directly to disk (a direct 50% reduction).
2. CDON further compressed with LZ4 (a clean 90% reduction).
3. The raw JSON file as fetched from a random data source.
@@ -19,7 +17,7 @@ primary CedillaObjectNotation {
}
Header {
uint16 version;
uint16 version; // always 1
FSFormat fsFormat;
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/
- Session tracker
- Ratings leaderboard
- Rating leaderboard
- 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',
};
+11 -11
View File
@@ -1,24 +1,24 @@
{
"name": "blitzkrieg",
"version": "1.16.2",
"version": "1.18.3",
"description": "🎉 Tools for everything World of Tanks Blitz",
"main": "src/server.ts",
"type": "module",
"scripts": {
"build:bot": "vite-node --script scripts/build",
"build:website": "next build",
"build:bot": "vite-node --script scripts/build --production",
"build:website": "next build && next-sitemap --config next-sitemap.config.cjs",
"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:test": "vite-node --script scripts/buildTest",
"build:ratingsplot": "vite-node --script scripts/buildRatingsPlot",
"dev:bot": "yarn build:bot && yarn start:bot",
"dev:bot": "vite-node --script scripts/build && yarn start:bot",
"dev:docs": "vitepress dev docs",
"dev:website": "next dev",
"start:bot": "node --enable-source-maps dist/bot/index.cjs",
"start:website": "next start",
"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",
"lint": "npx tsc",
"lint:website": "next lint",
"format": "prettier --write \"**/*.@(json|js|jsx|css|md|ts|tsx|html|xml|yml|gltf)\"",
@@ -34,15 +34,14 @@
"@octokit/plugin-throttling": "^8.1.3",
"@octokit/rest": "^20.0.1",
"@prisma/client": "^5.4.1",
"@radix-ui/colors": "^3.0.0-rc.4",
"@radix-ui/colors": "^3.0.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/fiber": "^8.15.14",
"@resvg/resvg-js": "^2.4.1",
"@types/three": "^0.160.0",
"@vanilla-extract/css": "^1.13.0",
"@vercel/analytics": "^1.2.2",
"bepaint": "^0.1.0-beta.3",
"decompress": "^4.2.1",
"discord.js": "^14.13.0",
@@ -57,6 +56,7 @@
"lz4js": "^0.2.0",
"markdown-escape": "^2.0.0",
"next": "^14.1.0",
"next-sitemap": "^4.2.3",
"parse-dds": "^1.2.1",
"postprocessing": "^6.34.1",
"react": "^18.2.0",
@@ -65,7 +65,7 @@
"react-fuzzysort": "^1.0.1",
"react-promise-suspense": "^0.3.4",
"react-spinners": "^0.13.8",
"satori": "^0.10.8",
"satori": "^0.10.13",
"sharp": "0.33.1",
"three": "0.159.0",
"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 {
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
region String
blitz Int
}
+1
View File
@@ -0,0 +1 @@
google.com, pub-1289406790225052, DIRECT, f08c47fec0942fa0
File renamed without changes.
File renamed without changes.
+48
View File
@@ -0,0 +1,48 @@
import { mkdir, writeFile } from 'fs/promises';
import { parse as parsePath } from 'path';
import { parse as parseYaml } from 'yaml';
import { readStringDVPL } from '../src/core/blitz/readStringDVPL';
import { writeDVPL } from '../src/core/blitz/writeDVPL';
import { secrets } from '../src/core/blitzkrieg/secrets';
import { dvp } from '../submodules/closedkrieg/src/dvp';
import { DATA } from './buildAssets/constants';
const versionTextFile = await readStringDVPL(`${DATA}/version.txt.dvpl`);
const currentVersion = versionTextFile
.split(' ')[0]
.split('.')
.slice(0, 3)
.join('.');
console.log(`Installing patches for ${currentVersion}...`);
let patchIndex = 1;
while (true) {
const response = await fetch(
`${secrets.WOTB_DLC_CDN}/dlc/s${currentVersion}_${patchIndex}.yaml`,
);
if (response.status === 200) {
console.log(`Applying patch ${patchIndex}...`);
const data = parseYaml(await response.text());
const dvpm = await fetch(`${secrets.WOTB_DLC_CDN}/dlc/${data.dx11}`).then(
(response) => response.arrayBuffer(),
);
const dvpd = await fetch(
`${secrets.WOTB_DLC_CDN}/dlc/${data.dx11.replace('.dvpm', '.dvpd')}`,
).then((response) => response.arrayBuffer());
const files = await dvp(dvpm, dvpd);
await Promise.all(
files.map(async ({ path, data }) => {
const { dir } = parsePath(path);
await mkdir(`${DATA}/${dir}`, { recursive: true });
await writeFile(`${DATA}/${path}.dvpl`, writeDVPL(Buffer.from(data)));
}),
);
patchIndex++;
} else break;
}
+20 -3
View File
@@ -1,7 +1,13 @@
import { exec } from 'child_process';
import { build } from 'esbuild';
import { cp, readdir } from 'fs/promises';
import { argv } from 'process';
import { promisify } from 'util';
readdir('src/workers').then((workers) => {
const execPromise = promisify(exec);
const isProduction = argv.includes('--production');
await readdir('src/workers').then((workers) => {
const files = [
'bot',
'index',
@@ -25,6 +31,7 @@ readdir('src/workers').then((workers) => {
'.js': '.cjs',
},
external: ['sharp'],
bundle: true,
sourcemap: true,
minifyIdentifiers: false, // causes goofy errors
@@ -36,9 +43,19 @@ readdir('src/workers').then((workers) => {
});
// copy schema.prisma
cp('prisma/schema.prisma', 'dist/bot/schema.prisma');
readdir('node_modules/prisma').then((files) =>
await cp('prisma/schema.prisma', 'dist/bot/schema.prisma');
await readdir('node_modules/prisma').then((files) =>
files
.filter((file) => file.endsWith('.node'))
.map((file) => cp(`node_modules/prisma/${file}`, `dist/bot/${file}`)),
);
if (isProduction) {
// install fixed sharp
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'));
+6 -6
View File
@@ -1,13 +1,13 @@
import { readdir } from 'fs/promises';
import { argv } from 'process';
const isDepot = argv.includes('--depot');
export const WOTB_WIN32_DEPOT = 444202;
export const DATA = isDepot
? // ? await (async () => {
// const [installationVersion] = await readdir(`depots/${WOTB_WIN32_DEPOT}`);
// return `depots/${WOTB_WIN32_DEPOT}/${installationVersion}/Data`;
// })()
`depots/${WOTB_WIN32_DEPOT}/Data`
? await (async () => {
const [installationVersion] = await readdir(`depots/${WOTB_WIN32_DEPOT}`);
return `depots/${WOTB_WIN32_DEPOT}/${installationVersion}/Data`;
})()
: 'C:/Program Files (x86)/Steam/steamapps/common/World of Tanks Blitz/Data';
export const POI = {
@@ -24,7 +24,7 @@ export const POI = {
collisionMeshes: '3d/Tanks/CollisionMeshes',
optionalDevices: 'XML/item_defs/vehicles/common/optional_devices.xml',
optionalDeviceSlots:
'XML/item_defs/vehicles/common/optional_device_slots.xml',
'XML/item_defs/vehicles/common/optional_device_slots.xml',
optionalDeviceImageMappings:
'UI/Screens3/Lobby/Inventory/OptionalDevices/OptionalDevicesItemImage.style.yaml',
optionalDevicesImage:
+39
View File
@@ -0,0 +1,39 @@
import sharp from 'sharp';
import { readDVPLFile } from '../../src/core/blitz/readDVPLFile';
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
import { DATA } from './constants';
const ICONS = [
['currency_silver_m.packed.webp', 'silver'],
['currency_premium_xl.packed.webp', 'premium'],
['currency_gold_m.packed.webp', 'gold'],
['currency_free-xp_xl.packed.webp', 'free-xp'],
['currency_elite-xp_xl.packed.webp', 'elite-xp'],
['currency_crew-xp_xl.packed.webp', 'crew-xp'],
['currency_battle-xp_xl.packed.webp', 'xp'],
];
export async function currencies(production: boolean) {
console.log('Building currency icons...');
const changes = await Promise.all(
ICONS.map(async ([file, name]) => {
const content = (
await sharp(
await readDVPLFile(`${DATA}/Gfx/Lobby/currency/${file}.dvpl`),
)
.trim()
.toBuffer()
).toString('base64');
return {
content,
encoding: 'base64',
path: `icons/currencies/${name}.webp`,
} satisfies FileChange;
}),
);
await commitAssets('currency icons', changes, production);
}
+177 -43
View File
@@ -21,6 +21,7 @@ import {
ModelDefinitions,
} from '../../src/core/blitzkrieg/modelDefinitions';
import { ProvisionDefinitions } from '../../src/core/blitzkrieg/provisionDefinitions';
import { SkillDefinitions } from '../../src/core/blitzkrieg/skillDefinitions';
import { superCompress } from '../../src/core/blitzkrieg/superCompress';
import {
Crew,
@@ -32,6 +33,7 @@ import {
Tier,
} from '../../src/core/blitzkrieg/tankDefinitions';
import { DATA, POI } from './constants';
import { Avatar } from './skillIcons';
import { TankParameters } from './tankIcons';
interface Strings {
@@ -79,6 +81,16 @@ type VehicleDefinitionArmor = Record<
string,
number | { vehicleDamageFactor: 0; '#text': number }
>;
interface UnlocksInner {
cost: number;
'#text': number;
}
type Unlocks = {
[key in 'vehicle' | 'engine' | 'chassis' | 'turret' | 'gun']:
| UnlocksInner
| UnlocksInner[];
};
type UnlocksListing = Unlocks | undefined;
interface VehicleDefinitions {
invisibility: {
moving: number;
@@ -92,7 +104,7 @@ interface VehicleDefinitions {
};
consumableSlots: number;
provisionSlots: number;
optDevicePreset: string;
optDevicePreset: string | string[];
hull: {
armor: VehicleDefinitionArmor;
turretPositions: { turret: string };
@@ -102,9 +114,11 @@ interface VehicleDefinitions {
};
chassis: {
[key: string]: {
unlocks: UnlocksListing;
weight: number;
terrainResistance: string;
rotationSpeed: number;
userString: string;
shotDispersionFactors: {
vehicleMovement: number;
vehicleRotation: number;
@@ -130,10 +144,13 @@ interface VehicleDefinitions {
};
};
engines: {
[key: string]: unknown;
[key: string]: {
unlocks: UnlocksListing;
};
};
turrets0: {
[key: string]: {
unlocks: UnlocksListing;
rotationSpeed: number;
weight: number;
circularVisionRadius: number;
@@ -149,6 +166,7 @@ interface VehicleDefinitions {
};
guns: {
[key: string]: {
unlocks: UnlocksListing;
armor: VehicleDefinitionArmor & {
gun?:
| number
@@ -326,6 +344,9 @@ export async function definitions(production: boolean) {
};
const consumableDefinitions: ConsumableDefinitions = {};
const provisionDefinitions: ProvisionDefinitions = {};
const skillDefinitions: SkillDefinitions = {
classes: { lightTank: [], mediumTank: [], heavyTank: [], 'AT-SPG': [] },
};
const nations = await readdir(`${DATA}/${POI.vehicleDefinitions}`).then(
(nations) => nations.filter((nation) => nation !== 'common'),
);
@@ -352,6 +373,11 @@ export async function definitions(production: boolean) {
const provisions = await readXMLDVPL<{ root: ProvisionsCommon }>(
`${DATA}/${POI.provisionsCommon}.dvpl`,
);
const avatar = await readXMLDVPL<{ root: Avatar }>(
`${DATA}/XML/item_defs/tankmen/avatar.xml.dvpl`,
);
const tankXps = new Map<number, number>();
await Promise.all(
nations.map(async (nation) => {
@@ -385,6 +411,10 @@ export async function definitions(production: boolean) {
);
for (const tankKey in tankList.root) {
const gunXps = new Map<number, number>();
const turretXps = new Map<number, number>();
const engineXps = new Map<number, number>();
const trackXps = new Map<number, number>();
const tank = tankList.root[tankKey];
const tankPrice: TankDefinitionPrice =
typeof tank.price === 'number'
@@ -427,6 +457,7 @@ export async function definitions(production: boolean) {
});
const crew: Crew[] = [];
const fixedCamouflage = tankTags.includes('eventCamouflage_user');
const totalUnlocks: UnlocksListing[] = [];
Object.entries(tankDefinition.root.crew).forEach(([key, value]) => {
let entry: Crew;
@@ -473,7 +504,8 @@ export async function definitions(production: boolean) {
forwards: tankDefinition.root.speedLimits.forward,
backwards: tankDefinition.root.speedLimits.backward,
},
equipment,
equipment:
typeof equipment === 'string' ? equipment : equipment.at(-1)!,
consumables: tankDefinition.root.consumableSlots,
provisions: tankDefinition.root.provisionSlots,
name:
@@ -531,9 +563,11 @@ export async function definitions(production: boolean) {
.split(' ')
.map(Number) as Vector3Tuple;
totalUnlocks.push(track.unlocks);
tankDefinitions[tankId].tracks.push({
id: trackId,
weight: track.weight,
name: strings[track.userString],
traverseSpeed: track.rotationSpeed,
dispersion: {
move: track.shotDispersionFactors.vehicleMovement,
@@ -557,16 +591,18 @@ export async function definitions(production: boolean) {
});
Object.keys(tankDefinition.root.engines).forEach((engineKey) => {
const engine = enginesList.root.shared[engineKey];
const engine = tankDefinition.root.engines[engineKey];
const engineListEntry = enginesList.root.shared[engineKey];
const engineId = toUniqueId(nation, enginesList.root.ids[engineKey]);
totalUnlocks.push(engine.unlocks);
tankDefinitions[tankId].engines.push({
id: engineId,
name: strings[engine.userString],
fireChance: engine.fireStartingChance,
tier: engine.level as Tier,
weight: engine.weight,
power: engine.power,
name: strings[engineListEntry.userString],
fireChance: engineListEntry.fireStartingChance,
tier: engineListEntry.level as Tier,
weight: engineListEntry.weight,
power: engineListEntry.power,
});
});
@@ -593,6 +629,7 @@ export async function definitions(production: boolean) {
.map(Number) as Vector3Tuple;
const turretArmor: ModelArmor = { thickness: {} };
totalUnlocks.push(turret.unlocks);
Object.keys(turret.armor)
.filter((name) => name.startsWith('armor_'))
.forEach((name) => {
@@ -645,11 +682,11 @@ export async function definitions(production: boolean) {
};
Object.keys(turret.guns).forEach((gunKey, gunIndex) => {
const turretGunEntry = turret.guns[gunKey];
const gun = turret.guns[gunKey];
const gunId = toUniqueId(nation, gunList.root.ids[gunKey]);
const gunListEntry = gunList.root.shared[gunKey];
const pitchLimitsRaw =
turretGunEntry.pitchLimits ?? gunListEntry.pitchLimits;
gun.pitchLimits ?? gunListEntry.pitchLimits;
const gunPitch = (
typeof pitchLimitsRaw === 'string'
? pitchLimitsRaw
@@ -658,43 +695,41 @@ export async function definitions(production: boolean) {
.split(' ')
.map(Number) as [number, number];
const gunModel = Number(
parsePath(turretGunEntry.models.undamaged).name.split('_')[1],
parsePath(gun.models.undamaged).name.split('_')[1],
);
const gunName =
strings[gunListEntry.userString] ?? gunKey.replaceAll('_', ' ');
const gunType =
'clip' in turretGunEntry
? turretGunEntry.pumpGunMode
'clip' in gun
? gun.pumpGunMode
? 'autoReloader'
: 'autoLoader'
: 'regular';
const gunReload =
gunType === 'autoReloader'
? turretGunEntry.pumpGunReloadTimes!.split(' ').map(Number)
: turretGunEntry.reloadTime;
? gun.pumpGunReloadTimes!.split(' ').map(Number)
: gun.reloadTime;
const gunClipCount =
gunType === 'regular' ? undefined : turretGunEntry.clip!.count;
const gunInterClip =
gunType === 'regular'
? undefined
: 60 / turretGunEntry.clip!.rate;
const front = turretGunEntry.extraPitchLimits?.front
? turretGunEntry.extraPitchLimits.front.split(' ').map(Number)
gunType === 'regular' ? undefined : gun.clip!.count;
const gunIntraClip =
gunType === 'regular' ? undefined : 60 / gun.clip!.rate;
const front = gun.extraPitchLimits?.front
? gun.extraPitchLimits.front.split(' ').map(Number)
: undefined;
const back = turretGunEntry.extraPitchLimits?.back
? turretGunEntry.extraPitchLimits.back.split(' ').map(Number)
const back = gun.extraPitchLimits?.back
? gun.extraPitchLimits.back.split(' ').map(Number)
: undefined;
const transition = turretGunEntry.extraPitchLimits?.transition
? typeof turretGunEntry.extraPitchLimits.transition === 'number'
? turretGunEntry.extraPitchLimits.transition
: turretGunEntry.extraPitchLimits.transition.at(-1)!
const transition = gun.extraPitchLimits?.transition
? typeof gun.extraPitchLimits.transition === 'number'
? gun.extraPitchLimits.transition
: gun.extraPitchLimits.transition.at(-1)!
: undefined;
const gunArmor: ModelArmor = { thickness: {} };
const shotDispersionFactors =
turretGunEntry.shotDispersionFactors ??
gunListEntry.shotDispersionFactors;
gun.shotDispersionFactors ?? gunListEntry.shotDispersionFactors;
Object.keys(turretGunEntry.armor)
totalUnlocks.push(gun.unlocks);
Object.keys(gun.armor)
.filter((name) => name.startsWith('armor_'))
.forEach((name) => {
const armorIdString = name.match(/armor_(\d+)/)?.[1];
@@ -702,7 +737,7 @@ export async function definitions(production: boolean) {
throw new SyntaxError(`Invalid armor id: ${name}`);
}
const armorId = parseInt(armorIdString);
const armorRaw = turretGunEntry.armor[name];
const armorRaw = gun.armor[name];
if (typeof armorRaw === 'number') {
gunArmor.thickness[armorId] = armorRaw;
} else {
@@ -722,15 +757,15 @@ export async function definitions(production: boolean) {
type: gunType,
reload: gunReload,
count: gunClipCount,
interClip: gunInterClip,
intraClip: gunIntraClip,
camouflageLoss:
typeof turretGunEntry.invisibilityFactorAtShot === 'number'
? turretGunEntry.invisibilityFactorAtShot
: turretGunEntry.invisibilityFactorAtShot.at(-1)!,
aimTime: turretGunEntry.aimingTime ?? gunListEntry.aimingTime,
typeof gun.invisibilityFactorAtShot === 'number'
? gun.invisibilityFactorAtShot
: gun.invisibilityFactorAtShot.at(-1)!,
aimTime: gun.aimingTime ?? gunListEntry.aimingTime,
dispersion: {
base:
turretGunEntry.shotDispersionRadius ??
gun.shotDispersionRadius ??
gunListEntry.shotDispersionRadius,
damaged: shotDispersionFactors.whileGunDamaged,
shot: shotDispersionFactors.afterShot,
@@ -742,11 +777,11 @@ export async function definitions(production: boolean) {
armor: gunArmor,
model: gunModel,
thickness:
turretGunEntry.armor.gun === undefined
gun.armor.gun === undefined
? 0
: typeof turretGunEntry.armor.gun === 'number'
? turretGunEntry.armor.gun
: turretGunEntry.armor.gun['#text'],
: typeof gun.armor.gun === 'number'
? gun.armor.gun
: gun.armor.gun['#text'],
pitch: {
min: gunPitch[0],
max: gunPitch[1],
@@ -808,10 +843,100 @@ export async function definitions(production: boolean) {
});
},
);
totalUnlocks.forEach((unlocks) => {
if (unlocks === undefined) return;
Object.entries(unlocks).forEach(([key, value]) => {
(Array.isArray(value) ? value : [value]).forEach((vehicle) => {
switch (key as keyof Unlocks) {
case 'vehicle': {
const tankListEntry = tankList.root[vehicle['#text']];
const currentTank = tankDefinitions[tankId];
const successorId = toUniqueId(nation, tankListEntry.id);
tankXps.set(successorId, vehicle.cost);
if (currentTank.successors === undefined) {
currentTank.successors = [];
}
if (!currentTank.successors!.includes(successorId)) {
currentTank.successors!.push(successorId);
}
break;
}
case 'gun': {
gunXps.set(
toUniqueId(nation, gunList.root.ids[vehicle['#text']]),
vehicle.cost,
);
break;
}
case 'turret': {
turretXps.set(
toUniqueId(nation, turretList.root.ids[vehicle['#text']]),
vehicle.cost,
);
break;
}
case 'engine': {
engineXps.set(
toUniqueId(nation, enginesList.root.ids[vehicle['#text']]),
vehicle.cost,
);
break;
}
case 'chassis': {
trackXps.set(
toUniqueId(nation, chassisList.root.ids[vehicle['#text']]),
vehicle.cost,
);
break;
}
}
});
});
});
Object.values(tankDefinitions[tankId].turrets).forEach((turret) => {
turret.xp = turretXps.get(turret.id);
Object.values(turret.guns).forEach((gun) => {
gun.xp = gunXps.get(gun.id);
});
});
Object.values(tankDefinitions[tankId].engines).forEach((engine) => {
engine.xp = engineXps.get(engine.id);
});
Object.values(tankDefinitions[tankId].tracks).forEach((track) => {
track.xp = trackXps.get(track.id);
});
}
}),
);
Object.values(tankDefinitions).forEach((tank) => {
tank.xp = tankXps.get(tank.id);
});
Object.values(tankDefinitions).forEach((tank) => {
tank.successors?.forEach((predecessorId) => {
if (tankDefinitions[predecessorId].ancestors === undefined) {
tankDefinitions[predecessorId].ancestors = [];
}
if (!tankDefinitions[predecessorId].ancestors?.includes(tank.id)) {
tankDefinitions[predecessorId].ancestors?.push(tank.id);
}
});
});
Object.entries(optionalDevices.root).forEach(
([optionalDeviceKey, optionalDeviceEntry]) => {
if (optionalDeviceKey === 'nextAvailableId') return;
@@ -969,6 +1094,10 @@ export async function definitions(production: boolean) {
}
});
Object.entries(avatar.root.skillsByClasses).forEach(([tankClass, skills]) => {
skillDefinitions.classes[tankClass as TankClass] = skills.split(' ');
});
await commitAssets(
'definitions',
[
@@ -997,6 +1126,11 @@ export async function definitions(production: boolean) {
encoding: 'base64',
path: 'definitions/provisions.cdon.lz4',
},
{
content: superCompress(skillDefinitions),
encoding: 'base64',
path: 'definitions/skills.cdon.lz4',
},
],
production,
);
+4
View File
@@ -14,11 +14,13 @@ import { boosterIcons } from './boosterIcons';
import { camouflageIcons } from './camouflageIcons';
import { circleFlags } from './circleFlags';
import { consumableProvisionIcons } from './consumableProvisionIcons';
import { currencies } from './currencies';
import { definitions } from './definitions';
import { equipmentIcons } from './equipmentIcons';
import { moduleIcons } from './moduleIcons';
import { scratchedFlags } from './scratchedFlags';
import { shellIcons } from './shellIcons';
import { skillIcons } from './skillIcons';
import { tankArmors } from './tankArmors';
import { tankIcons } from './tankIcons';
import { tankModels } from './tankModels';
@@ -38,6 +40,8 @@ const methods = [
boosterIcons,
tankArmors,
tankModels,
currencies,
skillIcons,
];
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);
}
-19
View File
@@ -1,21 +1,15 @@
import { NodeIO } from '@gltf-transform/core';
import { readdir } from 'fs/promises';
import { Vector3Tuple } from 'three';
import { extractModel } from '../../src/core/blitz/extractModel';
import { readXMLDVPL } from '../../src/core/blitz/readXMLDVPL';
import { readYAMLDVPL } from '../../src/core/blitz/readYAMLDVPL';
import { toUniqueId } from '../../src/core/blitz/toUniqueId';
// import { AssetCommit } from '../../src/core/github/assetCommit';
import { commitAssets } from '../../src/core/blitzkrieg/commitAssets';
import { FileChange } from '../../src/core/blitzkrieg/commitMultipleFiles';
import { DATA, POI } from './constants';
import { VehicleDefinitionList } from './definitions';
import { TankParameters } from './tankIcons';
interface VehicleCustomization {
armorColor: string;
}
export async function tankModels(production: boolean) {
console.log('Building tank models...');
@@ -27,18 +21,9 @@ export async function tankModels(production: boolean) {
for (const nationIndex in nations) {
const nation = nations[nationIndex];
const changes: FileChange[] = [];
// const assetCommit = new AssetCommit(`tank models ${nation}`, production);
const tanks = await readXMLDVPL<{ root: VehicleDefinitionList }>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/list.xml.dvpl`,
);
const customization = await readXMLDVPL<{ root: VehicleCustomization }>(
`${DATA}/${POI.vehicleDefinitions}/${nation}/customization.xml.dvpl`,
);
const baseColor = customization.root.armorColor
.split(' ')
.slice(0, 3)
.map(Number)
.map((channel) => channel / 255) as Vector3Tuple;
console.log(`Building models for ${nation}`);
@@ -56,11 +41,8 @@ export async function tankModels(production: boolean) {
const model = await extractModel(
DATA,
parameters.resourcesPath.blitzModelPath.replace(/\.sc2$/, ''),
baseColor,
);
// writeFile(`test.${tankKey}.glb`, await nodeIO.writeBinary(model));
changes.push({
path: `3d/tanks/models/${id}.glb`,
content: Buffer.from(await nodeIO.writeBinary(model)).toString(
@@ -71,7 +53,6 @@ export async function tankModels(production: boolean) {
}),
);
// assetCommit.push();
await commitAssets(`tank models ${nation}`, 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'),
);
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Node",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"resolveJsonModule": true,
"jsx": "preserve",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"incremental": true,
"isolatedModules": true
}
}
@@ -1,10 +1,10 @@
import { NextResponse } from 'next/server';
import { Region } from '../../../../../../constants/regions';
import getRatingsInfo from '../../../../../../core/blitz/getRatingsInfo';
import getRatingInfo from '../../../../../../core/blitz/getRatingInfo';
export async function GET(
request: Request,
{ 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 { Region } from '../../../../../../../constants/regions';
import { getRatingsLeague } from '../../../../../../../core/blitz/getRatingsLeague';
import { getRatingLeague } from '../../../../../../../core/blitz/getRatingLeague';
export async function GET(
request: Request,
{ params }: { params: { region: Region; league: number } },
) {
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 { Region } from '../../../../../../../constants/regions';
import { searchCurrentRatingsPlayers } from '../../../../../../../core/blitz/searchCurrentRatingsPlayers';
import { searchCurrentRatingPlayers } from '../../../../../../../core/blitz/searchCurrentRatingPlayers';
export async function GET(
request: Request,
{ params }: { params: { region: Region; username: string } },
) {
return NextResponse.json(
await searchCurrentRatingsPlayers(params.region, params.username),
await searchCurrentRatingPlayers(params.region, params.username),
);
}
+2 -4
View File
@@ -5,6 +5,7 @@ import { useEffect, useState } from 'react';
import * as Breakdown from '../../../components/Breakdown';
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
import fetchBlitz from '../../../core/blitz/fetchBlitz';
import { resetSession } from '../../../core/blitzkrieg/resetSession';
import {
TankDefinition,
tankDefinitions,
@@ -13,10 +14,7 @@ import { tankAverages } from '../../../core/blitzstars/tankAverages';
import calculateWN8 from '../../../core/statistics/calculateWN8';
import { deltaTankStats } from '../../../core/statistics/deltaTankStats';
import getWN8Percentile from '../../../core/statistics/getWN8Percentile';
import mutateSession, {
resetSession,
useSession,
} from '../../../stores/session';
import mutateSession, { useSession } from '../../../stores/session';
import { IndividualTankStats, TanksStats } from '../../../types/tanksStats';
import { CustomColumnDisplay } from '../../tools/session/components/CustomColumn';
import { Menu } from '../../tools/session/components/Menu';
+6 -5
View File
@@ -3,16 +3,17 @@
import { CaretRightIcon } from '@radix-ui/react-icons';
import { AlertDialog, Button, Flex, Text, Theme } from '@radix-ui/themes';
import '@radix-ui/themes/styles.css';
import { Analytics } from '@vercel/analytics/react';
import { config } from 'dotenv';
import { Roboto_Flex } from 'next/font/google';
import { usePathname } from 'next/navigation';
import { ReactNode, useEffect, useState } from 'react';
import { SkyLine } from '../assets/art/SkyLine';
import { Analytics } from '../components/Analytics';
import Navbar from '../components/Navbar';
import PageWrapper from '../components/PageWrapper';
import isDev from '../core/blitzkrieg/isDev';
import { isLocalhost } from '../core/blitzkrieg/isLocalhost';
import { useFullScreen } from '../hooks/useFullScreen';
import { useApp } from '../stores/app';
import { mintTheme } from '../themes/mint';
@@ -33,6 +34,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
const pathname = usePathname();
const isEmbed = pathname.split('/')[1] === 'embeds';
const [showDevBuildAlert, setShowDevBuildAlert] = useState(false);
const isFullScreen = useFullScreen();
useEffect(() => {
setShowDevBuildAlert(
@@ -51,6 +53,7 @@ export default function RootLayout({ children }: RootLayoutProps) {
name="description"
content="🎉 Tools for everything World of Tanks Blitz"
/>
<Analytics />
</head>
<body
@@ -60,8 +63,6 @@ export default function RootLayout({ children }: RootLayoutProps) {
backgroundColor: isEmbed ? 'transparent' : undefined,
}}
>
<Analytics />
<Theme
appearance="dark"
panelBackground="translucent"
@@ -101,11 +102,11 @@ export default function RootLayout({ children }: RootLayoutProps) {
<Flex direction="column">{children}</Flex>
{!isEmbed && (
{!isEmbed && !isFullScreen && (
<PageWrapper>
<a
target="_blank"
href="https://ko-fi.com/tresabhi"
href="https://www.patreon.com/tresabhi"
style={{ textDecoration: 'none' }}
>
<Flex
+1 -2
View File
@@ -15,11 +15,10 @@ export default function Page() {
return (
<PageWrapper>
<TextField.Root>
<TextField.Root placeholder="Search tools...">
<TextField.Slot>
<MagnifyingGlassIcon height="16" width="16" />
</TextField.Slot>
<TextField.Input ref={input} placeholder="Search tools..." />
</TextField.Root>
<Flex gap="3" wrap="wrap" align="center" justify="center">
@@ -53,29 +53,26 @@ export function PageTurner({
<CaretLeftIcon />
)}
</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.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.Root>
<Button
@@ -15,11 +15,11 @@ import { produce } from 'immer';
import { debounce, range } from 'lodash';
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import { create } from 'zustand';
import { RatingsInfo, RatingsPlayer } from '../../../commands/ratings';
import { RatingInfo, RatingPlayer } from '../../../commands/ratingLeaderboard';
import PageWrapper from '../../../components/PageWrapper';
import { Skeleton } from '../../../components/Skeleton';
import { LEAGUES } from '../../../constants/leagues';
import { FIRST_ARCHIVED_RATINGS_SEASON } from '../../../constants/ratings';
import { FIRST_MINIMAL_ARCHIVED_RATING_SEASON } from '../../../constants/rating';
import {
REGIONS,
Region,
@@ -28,17 +28,17 @@ import {
import { WARGAMING_APPLICATION_ID } from '../../../constants/wargamingApplicationID';
import fetchBlitz from '../../../core/blitz/fetchBlitz';
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 { searchCurrentRatingsPlayers } from '../../../core/blitz/searchCurrentRatingsPlayers';
import getRatingInfo from '../../../core/blitz/getRatingInfo';
import { getRatingLeague } from '../../../core/blitz/getRatingLeague';
import { getRatingNeighbors } from '../../../core/blitz/getRatingNeighbors';
import { searchCurrentRatingPlayers } from '../../../core/blitz/searchCurrentRatingPlayers';
import {
AccountList,
AccountListItem,
} from '../../../core/blitz/searchPlayersAcrossRegions';
import { getArchivedLatestSeasonNumber } from '../../../core/blitzkrieg/getArchivedLatestSeasonNumber';
import getArchivedRatingsInfo from '../../../core/blitzkrieg/getArchivedRatingsInfo';
import { getArchivedRatingsLeaderboard } from '../../../core/blitzkrieg/getArchivedRatingsLeaderboard';
import getArchivedRatingInfo from '../../../core/blitzkrieg/getArchivedRatingInfo';
import { getArchivedRatingLeaderboard } from '../../../core/blitzkrieg/getArchivedRatingLeaderboard';
import { theme } from '../../../stitches.config';
import { PageTurner } from './components/PageTurner';
@@ -51,7 +51,7 @@ interface NameCacheEntry {
}
type NameCache = Record<Region, Record<number, NameCacheEntry | null>>;
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>>>;
const useNameCache = create<NameCache>(() => ({
@@ -93,7 +93,7 @@ export default function Page() {
{ type: 'id'; id: number } | { type: 'position'; position: number } | null
>(null);
const ratingsInfo = useInfoCache()[region][season];
const ratingInfo = useInfoCache()[region][season];
const [latestArchivedSeasonNumber, setLatestArchivedSeasonNumber] = useState<
number | null
>(null);
@@ -111,7 +111,7 @@ export default function Page() {
});
const pages = Math.ceil(
(ratingsInfo && ratingsInfo.detail === undefined ? ratingsInfo?.count : 1) /
(ratingInfo && ratingInfo.detail === undefined ? ratingInfo?.count : 1) /
ROWS_PER_PAGE,
);
@@ -122,8 +122,8 @@ export default function Page() {
(async () => {
const newInfo =
season === 0
? await getRatingsInfo(region)
: await getArchivedRatingsInfo(region, season);
? await getRatingInfo(region)
: await getArchivedRatingInfo(region, season);
useInfoCache.setState(
produce((draft: InfoCache) => {
draft[region][season] = newInfo;
@@ -140,7 +140,7 @@ export default function Page() {
const results = (
await Promise.all(
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;
return data;
@@ -178,12 +178,12 @@ export default function Page() {
);
})();
} else {
getArchivedRatingsLeaderboard(region, season).then((results) => {
getArchivedRatingLeaderboard(region, season).then((results) => {
useLeaderboardCache.setState(
produce((draft: LeaderboardCache) => {
if (!(season in draft[region])) draft[region][season] = {};
results.forEach((result, index) => {
results.entries.forEach((result, index) => {
draft[region][season][index] = result.id;
});
}),
@@ -192,7 +192,7 @@ export default function Page() {
produce((draft: ScoreCache) => {
if (!(season in draft[region])) draft[region][season] = {};
results.forEach((result) => {
results.entries.forEach((result) => {
draft[region][season][result.id] = result.score;
});
}),
@@ -213,7 +213,7 @@ export default function Page() {
}, [leaderboard[region]?.[season] && 0 in leaderboard[region][season]]);
// neighboring page caching for all seasons
useEffect(() => {
if (!ratingsInfo || ratingsInfo?.detail) return;
if (!ratingInfo || ratingInfo?.detail) return;
if (season === 0) {
const middleIndex = page * ROWS_PER_PAGE + ROWS_PER_PAGE / 2;
@@ -236,7 +236,7 @@ export default function Page() {
const ids = range(
Math.max(0, (page - 1) * ROWS_PER_PAGE),
Math.min(
ratingsInfo.count - 1,
ratingInfo.count - 1,
(page + 1) * ROWS_PER_PAGE + ROWS_PER_PAGE,
),
)
@@ -265,7 +265,7 @@ export default function Page() {
page,
region,
season,
ratingsInfo?.detail,
ratingInfo?.detail,
leaderboard[region][season] !== undefined &&
0 in leaderboard[region][season],
]);
@@ -279,7 +279,7 @@ export default function Page() {
async function cacheNeighbors(
id: number,
size: number,
): Promise<RatingsPlayer[] | null> {
): Promise<RatingPlayer[] | null> {
const radius = Math.round(size / 2);
const targetPosition = Object.entries(leaderboard[region][season]).find(
([, 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(
produce((draft: LeaderboardCache) => {
@@ -341,11 +341,11 @@ export default function Page() {
return neighbors;
}
async function jumpToPosition(position: number) {
if (!ratingsInfo || ratingsInfo?.detail) return;
if (!ratingInfo || ratingInfo?.detail) return;
const targetPosition = Math.min(
ratingsInfo.count - 1,
Math.max(0, Math.min(position, ratingsInfo.count - 1)),
ratingInfo.count - 1,
Math.max(0, Math.min(position, ratingInfo.count - 1)),
);
if (season === 0) {
@@ -378,7 +378,7 @@ export default function Page() {
const loadingDirection = Math.sign(targetPosition - closestPosition);
let seedingPlayer = closestPlayerId as number;
let targetPositionAcquired = false;
let neighbors: RatingsPlayer[] | null;
let neighbors: RatingPlayer[] | null;
setLoadingProgress([0, closestPositionDistance]);
@@ -423,7 +423,7 @@ export default function Page() {
}
}
async function jumpToScore() {
if (!ratingsInfo || ratingsInfo?.detail) return;
if (!ratingInfo || ratingInfo?.detail) return;
const targetScore = scoreInput.current!.valueAsNumber;
@@ -522,7 +522,7 @@ export default function Page() {
} else {
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 (scores[leaderboard[region][season][index]] >= targetScore) {
@@ -545,7 +545,7 @@ export default function Page() {
}
const totalPlayers =
!ratingsInfo?.detail && ratingsInfo?.count ? ratingsInfo.count : undefined;
!ratingInfo?.detail && ratingInfo?.count ? ratingInfo.count : undefined;
return (
<PageWrapper color="orange">
@@ -573,7 +573,7 @@ export default function Page() {
<Select.Trigger style={{ flex: 1 }} />
<Select.Content>
{ratingsInfo?.detail === undefined && (
{ratingInfo?.detail === undefined && (
<Select.Item value="0">Current season</Select.Item>
)}
@@ -582,7 +582,7 @@ export default function Page() {
{latestArchivedSeasonNumber &&
range(
FIRST_ARCHIVED_RATINGS_SEASON,
FIRST_MINIMAL_ARCHIVED_RATING_SEASON,
latestArchivedSeasonNumber + 1,
)
.map((season) => (
@@ -605,7 +605,7 @@ export default function Page() {
<Dialog.Content>
<Flex gap="4" justify="center">
<TextField.Input
<TextField.Root
ref={scoreInput}
type="number"
placeholder="Type a score..."
@@ -638,7 +638,7 @@ export default function Page() {
<Dialog.Content>
<Flex gap="4" justify="center">
<TextField.Input
<TextField.Root
ref={positionInput}
type="number"
placeholder="Type a position..."
@@ -681,8 +681,8 @@ export default function Page() {
<Select.Trigger />
<Select.Content>
{ratingsInfo?.detail === undefined &&
ratingsInfo?.leagues.map(({ index, title }) => (
{ratingInfo?.detail === undefined &&
ratingInfo?.leagues.map(({ index, title }) => (
<Select.Item key={index} value={`${index}`}>
{title}
</Select.Item>
@@ -714,11 +714,7 @@ export default function Page() {
);
}
} else {
if (
!ratingsInfo ||
ratingsInfo.detail ||
!leaderboard
)
if (!ratingInfo || ratingInfo.detail || !leaderboard)
return;
const minScore =
@@ -727,7 +723,7 @@ export default function Page() {
for (
let index = 0;
index < ratingsInfo.count;
index < ratingInfo.count;
index++
) {
if (
@@ -763,105 +759,100 @@ export default function Page() {
<Dialog.Content>
<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>
<MagnifyingGlassIcon height="16" width="16" />
</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>
<Flex direction="column" gap="2">
@@ -885,12 +876,12 @@ export default function Page() {
id: player.account_id,
});
} else {
if (!ratingsInfo || ratingsInfo.detail) return;
if (!ratingInfo || ratingInfo.detail) return;
let playerIndex = -1;
for (
let index = 0;
index < ratingsInfo.count;
index < ratingInfo.count;
index++
) {
if (
@@ -951,9 +942,7 @@ export default function Page() {
</Flex>
<PageTurner
totalPlayers={
(ratingsInfo?.detail ? undefined : ratingsInfo?.count) ?? 0
}
totalPlayers={(ratingInfo?.detail ? undefined : ratingInfo?.count) ?? 0}
leaderboard={leaderboard[region][season] ?? {}}
rowsPerPage={ROWS_PER_PAGE}
page={page}
@@ -982,7 +971,7 @@ export default function Page() {
page * ROWS_PER_PAGE,
Math.min(
page * ROWS_PER_PAGE + ROWS_PER_PAGE,
ratingsInfo?.detail ? 0 : (ratingsInfo?.count ?? 1) - 1,
ratingInfo?.detail ? 0 : (ratingInfo?.count ?? 1) - 1,
),
).map((index) => {
const id = leaderboard[region][season]?.[index];
@@ -994,8 +983,8 @@ export default function Page() {
: highlightedPlayer.position === index
: false;
const reward =
!ratingsInfo?.detail && ratingsInfo?.rewards
? ratingsInfo.rewards.find(
!ratingInfo?.detail && ratingInfo?.rewards
? ratingInfo.rewards.find(
(reward) =>
reward.from_position <= position &&
reward.to_position >= position,
@@ -1090,9 +1079,7 @@ export default function Page() {
</Table.Root>
<PageTurner
totalPlayers={
(ratingsInfo?.detail ? undefined : ratingsInfo?.count) ?? 0
}
totalPlayers={(ratingInfo?.detail ? undefined : ratingInfo?.count) ?? 0}
rowsPerPage={ROWS_PER_PAGE}
leaderboard={leaderboard[region][season] ?? {}}
page={page}
+12 -13
View File
@@ -37,19 +37,18 @@ export function Menu({ Builder, reset }: ContextMenuProps) {
<Heading size="4">Edit title</Heading>
<Flex gap="2" style={{ width: '100%' }}>
<TextField.Root style={{ flex: 1 }}>
<TextField.Input
ref={titleInput}
defaultValue={session.title}
placeholder="Enter title..."
onKeyDown={(event) => {
if (event.key === 'Enter' && titleInput.current) {
setShowTitleEditor(false);
useSession.setState({ title: titleInput.current!.value });
}
}}
/>
</TextField.Root>
<TextField.Root
style={{ flex: 1 }}
ref={titleInput}
defaultValue={session.title}
placeholder="Enter title..."
onKeyDown={(event) => {
if (event.key === 'Enter' && titleInput.current) {
setShowTitleEditor(false);
useSession.setState({ title: titleInput.current!.value });
}
}}
/>
<Dialog.Close>
<Button
+30 -30
View File
@@ -22,7 +22,9 @@ import { UNLOCALIZED_REGION_NAMES } from '../../../constants/regions';
import searchPlayersAcrossRegions, {
AccountListWithServer,
} from '../../../core/blitz/searchPlayersAcrossRegions';
import { resetSession, setSession, useSession } from '../../../stores/session';
import { resetSession } from '../../../core/blitzkrieg/resetSession';
import { setSession } from '../../../core/blitzkrieg/setSession';
import { useSession } from '../../../stores/session';
import SessionPage from '../../embeds/session/page';
import { Menu } from './components/Menu';
import * as styles from './page.css';
@@ -54,38 +56,36 @@ export default function Page() {
<PageWrapper color="blue">
<div className={styles.toolBar}>
<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>
<PersonIcon height="16" width="16" />
</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>
<DropdownMenu.Root open={showSearchResults} modal={false}>
@@ -1,15 +1,5 @@
import { CaretRightIcon, Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons';
import {
Button,
Card,
Dialog,
Flex,
Heading,
Tabs,
Text,
Tooltip,
} from '@radix-ui/themes';
import { useRouter } from 'next/navigation';
import { Cross1Icon, ShuffleIcon } from '@radix-ui/react-icons';
import { Button, Card, Dialog, Flex, Heading, Tabs } from '@radix-ui/themes';
import { useState } from 'react';
import { ModuleButton } from '../../../../../components/ModuleButtons/ModuleButton';
import { ShellButton } from '../../../../../components/ModuleButtons/ShellButton';
@@ -19,7 +9,7 @@ import { pushTankopediaPath } from '../../../../../core/blitzkrieg/pushTankopedi
import {
SHELL_NAMES,
TIER_ROMAN_NUMERALS,
} from '../../../../../core/blitzkrieg/tankDefinitions';
} from '../../../../../core/blitzkrieg/tankDefinitions/constants';
import { mutateDuel, useDuel } from '../../../../../stores/duel';
import {
mutateTankopediaTemporary,
@@ -37,58 +27,70 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
const [tab, setTab] = useState('search');
const [antagonistSelectorOpen, setAntagonistSelectorVisible] =
useState(false);
const router = useRouter();
if (mode !== 'armor') return null;
return (
<Card
style={
floating
? {
position: 'absolute',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
}
: undefined
}
>
<Flex align="center" justify="between" gap="6">
<Flex>
{antagonist.gun.shells.map((shell, index) => {
return (
<ShellButton
selected={antagonist.shell.id === shell.id}
shell={shell.icon}
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 justify="center">
<Card
style={
floating
? {
position: 'absolute',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
}
: undefined
}
>
<Flex
align="center"
justify="center"
wrap="wrap"
style={{
gap: 'min(5vw, 32px)',
}}
>
<Flex>
{antagonist.gun.shells.map((shell, index) => {
return (
<ShellButton
selected={antagonist.shell.id === shell.id}
shell={shell.icon}
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">
{!floating && <Text>Versus</Text>}
<Button
variant="ghost"
onClick={() => {
mutateDuel((draft) => {
[draft.protagonist, draft.antagonist] = [
draft.antagonist,
draft.protagonist,
];
pushTankopediaPath(draft.antagonist!.tank.id);
});
}}
>
<ShuffleIcon /> Swap
</Button>
{floating && (
<Flex gap="2" align="center">
Versus {antagonist.tank.name}
<SmallTankIcon id={antagonist.tank.id} size={16} />
</Flex>
)}
{!floating && (
<Flex align="center" gap="4">
<Dialog.Root
open={antagonistSelectorOpen}
onOpenChange={setAntagonistSelectorVisible}
@@ -96,9 +98,8 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
<Dialog.Trigger>
<Button variant="ghost">
<Flex gap="2" align="center">
{antagonist.tank.name}
Versus {antagonist.tank.name}
<SmallTankIcon id={antagonist.tank.id} size={16} />
<CaretRightIcon />
</Flex>
</Button>
</Dialog.Trigger>
@@ -160,61 +161,51 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
<Flex gap="2" wrap="wrap">
<Flex>
{antagonist.tank.turrets.map((turret, index) => (
<Tooltip content={turret.name} key={turret.id}>
<ModuleButton
rowChild
first={index === 0}
last={
index ===
antagonist.tank.turrets.length - 1
}
key={turret.id}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.turret = turret;
draft.antagonist!.gun =
turret.guns.at(-1)!;
draft.antagonist!.shell =
draft.antagonist!.gun.shells[0];
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
selected={
antagonist.turret.id === turret.id
}
discriminator={
TIER_ROMAN_NUMERALS[turret.tier]
}
module="turret"
/>
</Tooltip>
<ModuleButton
rowChild
first={index === 0}
last={
index === antagonist.tank.turrets.length - 1
}
key={turret.id}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.turret = turret;
draft.antagonist!.gun =
turret.guns.at(-1)!;
draft.antagonist!.shell =
draft.antagonist!.gun.shells[0];
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
selected={antagonist.turret.id === turret.id}
discriminator={
TIER_ROMAN_NUMERALS[turret.tier]
}
module="turret"
/>
))}
</Flex>
<Flex>
{antagonist.turret.guns.map((gun, index) => (
<Tooltip content={gun.name} key={gun.id}>
<ModuleButton
rowChild
first={index === 0}
last={
index ===
antagonist.turret.guns.length - 1
}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.gun = gun;
draft.antagonist!.shell = gun.shells[0];
});
}}
selected={antagonist.gun.id === gun.id}
discriminator={
TIER_ROMAN_NUMERALS[gun.tier]
}
module="gun"
/>
</Tooltip>
<ModuleButton
rowChild
first={index === 0}
last={
index === antagonist.turret.guns.length - 1
}
onClick={() => {
mutateDuel((draft) => {
draft.antagonist!.gun = gun;
draft.antagonist!.shell = gun.shells[0];
});
}}
selected={antagonist.gun.id === gun.id}
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
module="gun"
/>
))}
</Flex>
</Flex>
@@ -251,24 +242,9 @@ export function AntagonistBar({ floating }: AntagonistBarProps) {
</Tabs.Root>
</Dialog.Content>
</Dialog.Root>
)}
</Flex>
</Flex>
<Button
variant="ghost"
onClick={() => {
mutateDuel((draft) => {
[draft.protagonist, draft.antagonist] = [
draft.antagonist,
draft.protagonist,
];
pushTankopediaPath(draft.antagonist!.tank.id);
});
}}
>
<ShuffleIcon /> Swap
</Button>
</Flex>
</Card>
</Card>
</Flex>
);
}
@@ -1,5 +1,5 @@
import { Flex } from '@radix-ui/themes';
import { ReactNode } from 'react';
import * as styles from '../../../../page.css';
interface ConfigurationChildWrapperProps {
children: ReactNode;
@@ -8,5 +8,9 @@ interface ConfigurationChildWrapperProps {
export function ConfigurationChildWrapper({
children,
}: 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 { ConsumableButton } from '../../../../../../../components/ModuleButtons/ConsumableButton';
import { checkConsumableProvisionInclusivity } from '../../../../../../../core/blitzkrieg/checkConsumableProvisionInclusivity';
@@ -27,7 +35,38 @@ export function Consumables() {
return (
<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">
{consumablesList.map((consumable, index) => {
@@ -35,10 +74,11 @@ export function Consumables() {
return (
<ConsumableButton
special={consumable.duration === undefined}
duration={
typeof consumable.duration === 'number'
? consumable.duration * (hasHighEndConsumables ? 0.7 : 1)
: undefined
consumable.duration === undefined
? undefined
: consumable.duration * (hasHighEndConsumables ? 0.7 : 1)
}
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 { EquipmentButton } from '../../../../../../../components/ModuleButtons/EquipmentButton';
import { equipmentDefinitions } from '../../../../../../../core/blitzkrieg/equipmentDefinitions';
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Equipments() {
export function Equipment() {
const protagonist = useDuel((state) => state.protagonist!);
const awaitedEquipmentDefinitions = use(equipmentDefinitions);
const equipmentRows =
@@ -14,7 +14,24 @@ export function Equipments() {
return (
<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">
{equipmentRows.map((equipmentRow, rowIndex) => (
@@ -87,36 +87,35 @@ export function Miscellaneous() {
direction="column"
gap="1"
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.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.Root>
@@ -1,107 +1,243 @@
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 { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkrieg/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../../core/blitzkrieg/tankDefinitions/constants';
import { mutateDuel, useDuel } from '../../../../../../../stores/duel';
import { mutateTankopediaTemporary } from '../../../../../../../stores/tankopedia';
import { ConfigurationChildWrapper } from './ConfigurationChildWrapper';
export function Modules() {
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 (
<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>
{protagonist.tank.turrets.map((turret, index) => {
return (
<ModuleButton
key={turret.id}
selected={protagonist.turret.id === turret.id}
module="turret"
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 gap="1" wrap="wrap">
<ModuleButton
module="turret"
discriminator={TIER_ROMAN_NUMERALS[protagonist.turret.tier]}
first
last
rowChild
onClick={() => setTurretMenuOpen(true)}
/>
<Flex>
{protagonist.turret.guns.map((gun, index) => {
return (
<ModuleButton
key={gun.id}
module="gun"
discriminator={TIER_ROMAN_NUMERALS[gun.tier]}
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>
<DropdownMenu.Root
open={turretMenuOpen}
onOpenChange={setTurretMenuOpen}
>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<Flex>
{protagonist.tank.engines.map((engine, index) => {
return (
<ModuleButton
key={engine.id}
module="engine"
discriminator={TIER_ROMAN_NUMERALS[engine.tier]}
selected={protagonist.engine.id === engine.id}
first={index === 0}
last={index === protagonist.tank.engines.length - 1}
rowChild
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.engine = engine;
});
}}
/>
);
})}
</Flex>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${protagonist.turret.id}`}>
{[...protagonist.tank.turrets].reverse().map((turret) => {
return (
<DropdownMenu.RadioItem
key={turret.id}
value={`${turret.id}`}
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;
});
setTurretMenuOpen(false);
}}
>
<ModuleButton
variant="ghost"
module="turret"
discriminator={TIER_ROMAN_NUMERALS[turret.tier]}
/>{' '}
{turret.name}
</DropdownMenu.RadioItem>
);
})}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
<Flex>
{protagonist.tank.tracks.map((track, index) => {
return (
<ModuleButton
key={track.id}
module="chassis"
discriminator={TIER_ROMAN_NUMERALS[track.tier]}
selected={protagonist.track.id === track.id}
first={index === 0}
last={index === protagonist.tank.tracks.length - 1}
rowChild
onClick={() => {
mutateDuel((draft) => {
draft.protagonist!.track = track;
});
}}
/>
);
})}
</Flex>
<ModuleButton
module="gun"
discriminator={TIER_ROMAN_NUMERALS[protagonist.gun.tier]}
first
last
rowChild
onClick={() => setGunMenuOpen(true)}
/>
<DropdownMenu.Root open={gunMenuOpen} onOpenChange={setGunMenuOpen}>
<DropdownMenu.Trigger>
<div />
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.RadioGroup value={`${protagonist.gun.id}`}>
{[...protagonist.turret.guns].reverse().map((gun) => {
return (
<DropdownMenu.RadioItem
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>
</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 { ProvisionButton } from '../../../../../../../components/ModuleButtons/ProvisionButton';
import { availableProvisions } from '../../../../../../../core/blitzkrieg/availableProvisions';
@@ -18,7 +18,20 @@ export function Provisions() {
return (
<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">
{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,9 +1,18 @@
import { AccessibilityIcon } from '@radix-ui/react-icons';
import { Flex, Heading, Slider, Text, TextField } from '@radix-ui/themes';
import { AccessibilityIcon, InfoCircledIcon } from '@radix-ui/react-icons';
import {
Flex,
Heading,
IconButton,
Popover,
Slider,
Text,
TextField,
} from '@radix-ui/themes';
import { debounce } from 'lodash';
import { use, useEffect, useRef, useState } from 'react';
import { lerp } from 'three/src/math/MathUtils';
import { ShellButton } from '../../../../../../components/ModuleButtons/ShellButton';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { isExplosive } from '../../../../../../core/blitz/isExplosive';
import { resolveNearPenetration } from '../../../../../../core/blitz/resolveNearPenetration';
import { coefficient } from '../../../../../../core/blitzkrieg/coefficient';
@@ -17,21 +26,25 @@ import {
CREW_MEMBER_NAMES,
GUN_TYPE_NAMES,
SHELL_NAMES,
} from '../../../../../../core/blitzkrieg/tankDefinitions';
} from '../../../../../../core/blitzkrieg/tankDefinitions/constants';
import { unionBoundingBox } from '../../../../../../core/blitzkrieg/unionBoundingBox';
import { useConsumable } from '../../../../../../hooks/useConsumable';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { useProvision } from '../../../../../../hooks/useProvision';
import { useProvisions } from '../../../../../../hooks/useProvisions';
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
import { useTankopediaTemporary } from '../../../../../../stores/tankopedia';
import { Info } from './components/Info';
import { InfoWithDelta } from './components/InfoWithDelta';
export function Characteristics() {
const awaitedModelDefinitions = use(modelDefinitions);
const crewSkills = useTankopediaTemporary((state) => state.skills);
const { tank, turret, gun, engine, track, shell } = useDuel(
(state) => state.protagonist!,
);
const isFullScreen = useFullScreen();
const hasImprovedVentilation = useEquipment(102);
const provisions = useProvisions();
const crewMastery = useDuel((state) => state.protagonist!.crewMastery);
@@ -56,6 +69,7 @@ export function Characteristics() {
turretModelDefinition.boundingBox,
),
);
const gunDefaultPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
const weight =
tank.weight + engine.weight + track.weight + turret.weight + gun.weight;
const weightTons = weight / 1000;
@@ -113,7 +127,7 @@ export function Characteristics() {
// TODO: add max rolls
[hasTungsten, 0.15],
);
const interClipCoefficient = coefficient([hasShellReloadBoost, -0.3]);
const intraClipCoefficient = coefficient([hasShellReloadBoost, -0.3]);
const shellVelocityCoefficient = coefficient(
[hasSupercharger, 0.3],
[hasImprovedGunPowder, 0.3],
@@ -130,10 +144,26 @@ export function Characteristics() {
const dispersionStillCoefficient =
coefficient([hasRefinedGun, -0.1], [hasReticleCalibration, -0.4]) *
coefficient([true, degressiveStat(crewMemberMastery)]);
const dispersionMovingCoefficient = coefficient([
const dispersionDamagedCoefficient = coefficient([
hasVerticalStabilizer,
-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(
[
hasEngineAccelerator &&
@@ -159,6 +189,7 @@ export function Characteristics() {
coefficient(
[hasImprovedControl, 0.1],
[hasImprovedEnginePowerBoost, 0.05],
[true, crewSkills.virtuoso / 100],
) * coefficient([true, progressiveStat(crewMemberMastery)]);
const resistanceCoefficient = coefficient([hasImprovedSuspension, -0.25]);
const viewRangeCoefficient =
@@ -202,6 +233,7 @@ export function Characteristics() {
camouflage,
tank.class === 'AT-SPG' ? 0.04 : tank.class === 'heavyTank' ? 0.03 : 0.02,
],
[true, crewSkills.camouflage * (3 / 100)],
);
const resolvedEnginePower = engine.power * enginePowerCoefficient;
@@ -210,7 +242,7 @@ export function Characteristics() {
shell,
damageCoefficient,
reloadCoefficient,
interClipCoefficient,
intraClipCoefficient,
);
useEffect(() => {
@@ -218,6 +250,19 @@ export function Characteristics() {
penetrationDistanceInput.current.value = `${penetrationDistance}`;
}
}, [penetrationDistance]);
useEffect(() => {
mutateDuel((draft) => {
[draft.protagonist!.pitch, draft.protagonist!.yaw] = applyPitchYawLimits(
draft.protagonist!.pitch,
draft.protagonist!.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
});
}, [hasImprovedVerticalStabilizer]);
if (isFullScreen) return null;
return (
<Flex direction="column" gap="8" style={{ width: '100%' }}>
@@ -255,9 +300,13 @@ export function Characteristics() {
<>
<InfoWithDelta decimals={0} indent name="Maximum" unit="hp / min">
{gun.reload.at(-1)! < gun.reload.at(-2)!
? (shell.damage.armor / (gun.reload.at(-1)! + gun.interClip)) *
? ((damageCoefficient * shell.damage.armor) /
(gun.reload.at(-1)! * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
60
: (shell.damage.armor / gun.reload[0]) * 60}
: ((damageCoefficient * shell.damage.armor) /
(gun.reload[0] * reloadCoefficient)) *
60}
</InfoWithDelta>
<InfoWithDelta
decimals={0}
@@ -266,13 +315,26 @@ export function Characteristics() {
unit="hp / min"
>
{gun.reload.at(-1)! < gun.reload.at(-2)!
? (shell.damage.armor / (gun.reload.at(-1)! + gun.interClip)) *
? ((damageCoefficient * shell.damage.armor) /
(gun.reload.at(-1)! * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
(60 -
(gun.reload.slice(0, -1).length - 1) * gun.interClip) +
shell.damage.armor * gun.reload.slice(0, -1).length
: (shell.damage.armor / (gun.reload[0] + gun.interClip)) *
(60 - (gun.reload.slice(1).length - 1) * gun.interClip) +
shell.damage.armor * gun.reload.slice(1).length}
(gun.reload.slice(0, -1).length - 1) *
gun.intraClip *
intraClipCoefficient) +
damageCoefficient *
shell.damage.armor *
gun.reload.slice(0, -1).length
: ((damageCoefficient * shell.damage.armor) /
(gun.reload[0] * reloadCoefficient +
gun.intraClip * intraClipCoefficient)) *
(60 -
(gun.reload.slice(1).length - 1) *
gun.intraClip *
intraClipCoefficient) +
damageCoefficient *
shell.damage.armor *
gun.reload.slice(1).length}
</InfoWithDelta>
<Info
indent
@@ -285,14 +347,15 @@ export function Characteristics() {
</a>
}
/>
<Info indent name="Optimal shell index">
{gun.reload.at(-1)! < gun.reload.at(-2)! ? gun.reload.length : 1}
</Info>
</>
)}
{gun.type === 'autoReloader' ? (
<>
<Info name="Reload" unit="s" />
<InfoWithDelta name="Shells">{gun.reload.length}</InfoWithDelta>
<Info indent name="Most optimal">
{gun.reload.at(-1)! < gun.reload.at(-2)! ? gun.reload.length : 1}
</Info>
<Info name="Shell reloads" unit="s" />
{gun.reload.map((reload, index) => (
<InfoWithDelta
key={index}
@@ -301,7 +364,7 @@ export function Characteristics() {
decimals={2}
deltaType="lowerIsBetter"
>
{reload}
{reload * reloadCoefficient}
</InfoWithDelta>
))}
</>
@@ -315,17 +378,18 @@ export function Characteristics() {
{gun.reload * reloadCoefficient}
</InfoWithDelta>
)}
{gun.type === 'autoLoader' && (
<InfoWithDelta
indent
decimals={2}
name="Inter-clip reload"
unit="s"
deltaType="lowerIsBetter"
>
{gun.interClip * interClipCoefficient}
</InfoWithDelta>
)}
{gun.type === 'autoLoader' ||
(gun.type === 'autoReloader' && (
<InfoWithDelta
indent
decimals={2}
name="Intra-clip"
unit="s"
deltaType="lowerIsBetter"
>
{gun.intraClip * intraClipCoefficient}
</InfoWithDelta>
))}
<InfoWithDelta name="Caliber" decimals={0} unit="mm">
{shell.caliber}
</InfoWithDelta>
@@ -358,25 +422,25 @@ export function Characteristics() {
setPenetrationDistance(value);
}, 500)}
/>
<TextField.Root style={{ width: 64 }}>
<TextField.Input
ref={penetrationDistanceInput}
defaultValue={penetrationDistance}
onBlur={() => {
setPenetrationDistance(
Math.min(
parseInt(penetrationDistanceInput.current!.value),
500,
),
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
/>
<TextField.Slot>m</TextField.Slot>
<TextField.Root
style={{ width: 64 }}
ref={penetrationDistanceInput}
defaultValue={penetrationDistance}
onBlur={() => {
setPenetrationDistance(
Math.min(
parseInt(penetrationDistanceInput.current!.value),
500,
),
);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
}}
>
<TextField.Slot side="right">m</TextField.Slot>
</TextField.Root>
</Flex>
</>
@@ -387,6 +451,11 @@ export function Characteristics() {
<InfoWithDelta name="Module damage" unit="hp" decimals={0}>
{shell.damage.module * damageCoefficient}
</InfoWithDelta>
{isExplosive(shell.type) && (
<InfoWithDelta name="Splash radius" unit="m" decimals={0}>
{shell.explosionRadius!}
</InfoWithDelta>
)}
<InfoWithDelta name="Shell velocity" unit="m/s" decimals={0}>
{shell.speed * shellVelocityCoefficient}
</InfoWithDelta>
@@ -424,7 +493,7 @@ export function Characteristics() {
name="Hull traversing"
deltaType="lowerIsBetter"
>
{track.dispersion.traverse * dispersionMovingCoefficient}
{track.dispersion.traverse * dispersionHullTraverseCoefficient}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
@@ -433,7 +502,7 @@ export function Characteristics() {
name="Turret traversing"
deltaType="lowerIsBetter"
>
{gun.dispersion.traverse * dispersionMovingCoefficient}
{gun.dispersion.traverse * dispersionTurretTraverseCoefficient}
</InfoWithDelta>
<InfoWithDelta
decimals={3}
@@ -442,7 +511,7 @@ export function Characteristics() {
name="After shooting"
deltaType="lowerIsBetter"
>
{gun.dispersion.shot * dispersionMovingCoefficient}
{gun.dispersion.shot * dispersionAfterShotCoefficient}
</InfoWithDelta>
<InfoWithDelta
decimals={1}
@@ -452,15 +521,17 @@ export function Characteristics() {
unit="scalar"
deltaType="lowerIsBetter"
>
{gun.dispersion.damaged * dispersionMovingCoefficient}
{gun.dispersion.damaged * dispersionDamagedCoefficient}
</InfoWithDelta>
<Info name="Gun flexibility" unit="°" />
<InfoWithDelta decimals={1} indent name="Depression">
{gunModelDefinition.pitch.max +
gunDefaultPitch +
(hasImprovedVerticalStabilizer ? 3 : 0)}
</InfoWithDelta>
<InfoWithDelta decimals={1} indent name="Elevation">
{-gunModelDefinition.pitch.min +
{-gunModelDefinition.pitch.min -
gunDefaultPitch +
(hasImprovedVerticalStabilizer ? 3 : 0)}
</InfoWithDelta>
{gunModelDefinition.pitch.front && (
@@ -598,7 +669,24 @@ export function Characteristics() {
</Flex>
<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) => {
const count = member.count ?? 1;
@@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react';
import { Vector3 } from 'three';
import { OrbitControls as OrbitControlsClass } from 'three-stdlib';
import { applyPitchYawLimits } from '../../../../../core/blitz/applyPitchYawLimits';
import { hasEquipment } from '../../../../../core/blitzkrieg/hasEquipment';
import { modelDefinitions } from '../../../../../core/blitzkrieg/modelDefinitions';
import { Pose, poseEvent } from '../../../../../core/blitzkrieg/pose';
import { useAwait } from '../../../../../hooks/useAwait';
@@ -67,7 +68,9 @@ export function Controls() {
},
);
function handlePoseEvent(event: Pose) {
async function handlePoseEvent(event: Pose) {
const hasImprovedVerticalStabilizer = await hasEquipment(122);
switch (event) {
case Pose.HullDown: {
const [pitch] = applyPitchYawLimits(
@@ -75,6 +78,7 @@ export function Controls() {
0,
protagonistGunModelDefinition.pitch,
protagonistTurretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
camera.position
@@ -106,6 +110,7 @@ export function Controls() {
0,
protagonistGunModelDefinition.pitch,
protagonistTurretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
camera.position
@@ -11,6 +11,8 @@ import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinit
import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTransform';
import { Pose, poseEvent } from '../../../../../../core/blitzkrieg/pose';
import { tankIcon } from '../../../../../../core/blitzkrieg/tankIcon';
import { useEquipment } from '../../../../../../hooks/useEquipment';
import { useFullScreen } from '../../../../../../hooks/useFullScreen';
import { useWideFormat } from '../../../../../../hooks/useWideFormat';
import { mutateDuel, useDuel } from '../../../../../../stores/duel';
import mutateTankopediaPersistent, {
@@ -29,9 +31,9 @@ import { TankModel } from './components/TankModel';
export function TankSandbox() {
const canvas = useRef<HTMLCanvasElement>(null);
const hasImprovedVerticalStabilizer = useEquipment(122);
const awaitedModelDefinitions = use(modelDefinitions);
const canvasWrapper = useRef<HTMLDivElement>(null);
const [isFullScreen, setIsFullScreen] = useState(false);
const mode = useTankopediaPersistent((state) => state.mode);
const protagonist = useDuel((state) => state.protagonist!);
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
@@ -41,6 +43,7 @@ export function TankSandbox() {
const wideFormat = useWideFormat();
const [loadModel, setLoadModel] = useState(wideFormat || !isMobile);
const duel = useDuel();
const isFullScreen = useFullScreen();
function handlePointerDown() {
window.addEventListener('pointermove', handlePointerMove);
@@ -57,10 +60,6 @@ export function TankSandbox() {
}
useEffect(() => {
function handleFullScreenChange() {
setIsFullScreen(document.fullscreenElement !== null);
}
function handlePoseEvent(pose: Pose) {
switch (pose) {
case Pose.HullDown: {
@@ -70,6 +69,7 @@ export function TankSandbox() {
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
@@ -87,6 +87,7 @@ export function TankSandbox() {
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
@@ -104,6 +105,7 @@ export function TankSandbox() {
0,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
@@ -116,10 +118,8 @@ export function TankSandbox() {
}
poseEvent.on(handlePoseEvent);
document.addEventListener('fullscreenchange', handleFullScreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullScreenChange);
poseEvent.off(handlePoseEvent);
};
});
@@ -131,6 +131,7 @@ export function TankSandbox() {
draft.protagonist!.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
});
}, [protagonist.gun, protagonist.turret]);
@@ -139,8 +140,13 @@ export function TankSandbox() {
<Theme radius={isFullScreen ? 'none' : undefined}>
<Card
style={{
position: 'relative',
position: isFullScreen ? 'fixed' : 'relative',
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}
>
@@ -8,6 +8,7 @@ import { Button, DropdownMenu, Flex } from '@radix-ui/themes';
import { RefObject } from 'react';
import { Pose, poseEvent } from '../../../../../../../core/blitzkrieg/pose';
import mutateTankopediaPersistent, {
mutateTankopediaTemporary,
useTankopediaPersistent,
} from '../../../../../../../stores/tankopedia';
import { ENVIRONMENTS } from '../../Lighting';
@@ -17,7 +18,7 @@ interface OptionsProps {
canvasWrapper: RefObject<HTMLElement>;
}
export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
export function Options({ isFullScreen }: OptionsProps) {
const showGrid = useTankopediaPersistent(
(state) => state.model.visual.showGrid,
);
@@ -30,6 +31,9 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
const wireframe = useTankopediaPersistent(
(state) => state.model.visual.wireframe,
);
const showSpacedArmor = useTankopediaPersistent(
(state) => state.model.visual.showSpacedArmor,
);
const opaque = useTankopediaPersistent((state) => state.model.visual.opaque);
const fullScreenAvailable =
typeof document !== 'undefined' && document.fullscreenEnabled;
@@ -46,122 +50,132 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
right: 18,
}}
>
{!isFullScreen && (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost">
<EyeOpenIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost">
<EyeOpenIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
Hull down
</DropdownMenu.Item>
<DropdownMenu.Content>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.HullDown)}>
Hull down
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
Face hug
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.FaceHug)}>
Face hug
</DropdownMenu.Item>
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
Default
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
)}
<DropdownMenu.Item onClick={() => poseEvent.emit(Pose.Default)}>
Default
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{!isFullScreen && (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost">
<GearIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button variant="ghost">
<GearIcon />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>Armor</DropdownMenu.Label>
<DropdownMenu.Content>
<DropdownMenu.Label>Armor</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={greenPenetration}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.greenPenetration = checked;
});
}}
>
Green penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={showSpacedArmor}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showSpacedArmor = checked;
});
mutateTankopediaTemporary((draft) => {
draft.shot = undefined;
});
}}
>
Spaced armor
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={wireframe}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked;
});
}}
>
Wireframe
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={greenPenetration}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.greenPenetration = checked;
});
}}
>
Green penetration
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={opaque}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.opaque = checked;
});
}}
>
Opaque
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={wireframe}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.wireframe = checked;
});
}}
>
Wireframe
</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
checked={showGrid}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showGrid = checked;
});
}}
>
Show grid
</DropdownMenu.CheckboxItem>
<DropdownMenu.Label>Environment</DropdownMenu.Label>
<DropdownMenu.CheckboxItem
checked={showEnvironment}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showEnvironment = checked;
});
}}
>
View environment
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={showGrid}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showGrid = checked;
});
}}
>
Show grid
</DropdownMenu.CheckboxItem>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
<DropdownMenu.CheckboxItem
checked={showEnvironment}
onCheckedChange={(checked) => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.showEnvironment = checked;
});
}}
>
View environment
</DropdownMenu.CheckboxItem>
<DropdownMenu.SubContent>
<DropdownMenu.RadioGroup value={environment}>
{ENVIRONMENTS.map((environment) => (
<DropdownMenu.RadioItem
key={environment}
value={environment}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.environment = environment;
});
}}
>
{environment[0].toUpperCase() + environment.slice(1)}
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
)}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Lighting</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.RadioGroup value={environment}>
{ENVIRONMENTS.map((environment) => (
<DropdownMenu.RadioItem
key={environment}
value={environment}
onClick={() => {
mutateTankopediaPersistent((draft) => {
draft.model.visual.environment = environment;
});
}}
>
{environment[0].toUpperCase() + environment.slice(1)}
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
</DropdownMenu.Content>
</DropdownMenu.Root>
{fullScreenAvailable && (
<Button
@@ -169,7 +183,7 @@ export function Options({ isFullScreen, canvasWrapper }: OptionsProps) {
onClick={() => {
if (isFullScreen) {
document.exitFullscreen();
} else canvasWrapper.current?.requestFullscreen();
} else document.body.requestFullscreen();
}}
>
{isFullScreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
@@ -4,6 +4,7 @@ import { Euler, Group, Vector3 } from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { I_HAT, J_HAT, K_HAT } from '../../../../../../../constants/axis';
import { applyPitchYawLimits } from '../../../../../../../core/blitz/applyPitchYawLimits';
import { hasEquipment } from '../../../../../../../core/blitzkrieg/hasEquipment';
import { jsxTree } from '../../../../../../../core/blitzkrieg/jsxTree';
import { modelDefinitions } from '../../../../../../../core/blitzkrieg/modelDefinitions';
import {
@@ -106,7 +107,6 @@ export const TankModel = memo(() => {
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const { gltf } = useModel(protagonist.tank.id);
const nodes = Object.values(gltf.nodes);
@@ -161,12 +161,14 @@ export const TankModel = memo(() => {
window.addEventListener('pointerup', handlePointerUp);
}
}
function handlePointerMove(event: PointerEvent) {
async function handlePointerMove(event: PointerEvent) {
const hasImprovedVerticalStabilizer = await hasEquipment(122);
[pitch, yaw] = applyPitchYawLimits(
pitch,
yaw + event.movementX * (Math.PI / canvas.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
}
@@ -220,12 +222,14 @@ export const TankModel = memo(() => {
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
}
function handlePointerMove(event: PointerEvent) {
async function handlePointerMove(event: PointerEvent) {
const hasImprovedVerticalStabilizer = await hasEquipment(122);
[pitch, yaw] = applyPitchYawLimits(
pitch - event.movementY * (Math.PI / canvas.height),
yaw + event.movementX * (Math.PI / canvas.width),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
hasImprovedVerticalStabilizer,
);
modelTransformEvent.emit({ pitch, yaw });
}
@@ -1,5 +1,6 @@
import { Flex, TextField } from '@radix-ui/themes';
import { use, useEffect, useRef } from 'react';
import { degToRad, radToDeg } from 'three/src/math/MathUtils';
import { applyPitchYawLimits } from '../../../../../../core/blitz/applyPitchYawLimits';
import { modelDefinitions } from '../../../../../../core/blitzkrieg/modelDefinitions';
import { modelTransformEvent } from '../../../../../../core/blitzkrieg/modelTransform';
@@ -20,22 +21,21 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
const mode = useTankopediaPersistent((state) => state.mode);
const hasCalibratedShells = useEquipment(103, true);
const hasEnhancedArmor = useEquipment(110);
useEffect(() => {
turretYawInput.current!.value = `${-Math.round(
protagonist.yaw * (180 / Math.PI),
)}`;
}, [protagonist.yaw]);
useEffect(() => {
gunPitchInput.current!.value = `${-Math.round(
protagonist.pitch * (180 / Math.PI),
)}`;
}, [protagonist.pitch]);
const tankModelDefinition = awaitedModelDefinitions[protagonist.tank.id];
const turretModelDefinition =
tankModelDefinition.turrets[protagonist.turret.id];
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const initialGunPitch = tankModelDefinition.turretRotation?.pitch ?? 0;
const hasImprovedVerticalStabilizer = useEquipment(122);
useEffect(() => {
turretYawInput.current!.value = radToDeg(protagonist.yaw).toFixed(1);
}, [protagonist.yaw]);
useEffect(() => {
gunPitchInput.current!.value = (
-radToDeg(protagonist.pitch) + initialGunPitch
).toFixed(1);
}, [protagonist.pitch]);
return (
<Flex
@@ -46,19 +46,47 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
position: 'absolute',
left: 8,
bottom: isFullScreen ? 80 : 8,
pointerEvents: 'none',
}}
gap="4"
>
<Flex
gap="2"
style={{
width: 256,
}}
>
<Flex gap="2" style={{ width: 256, pointerEvents: 'auto' }}>
<TextField.Root
style={{
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
style={{
@@ -67,46 +95,45 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
>
Yaw
</TextField.Slot>
<TextField.Input
defaultValue={Math.round(protagonist.yaw * (180 / Math.PI))}
onBlur={() => {
const [pitch, yaw] = applyPitchYawLimits(
protagonist.pitch,
-Number(turretYawInput.current?.value) * (Math.PI / 180),
gunModelDefinition.pitch,
turretModelDefinition.yaw,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
turretYawInput.current!.value = `${Math.round(
yaw * (180 / Math.PI),
)}`;
}}
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.Slot style={{ userSelect: 'none' }}>°</TextField.Slot>
</TextField.Root>
<TextField.Root
style={{
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
style={{
@@ -115,33 +142,6 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
>
Pitch
</TextField.Slot>
<TextField.Input
defaultValue={-Math.round(protagonist.pitch * (180 / Math.PI))}
onBlur={() => {
const [pitch, yaw] = applyPitchYawLimits(
-Number(gunPitchInput.current?.value) * (Math.PI / 180),
protagonist.yaw,
gunModelDefinition.pitch,
turretModelDefinition.yaw,
);
modelTransformEvent.emit({ pitch, yaw });
mutateDuel((state) => {
state.protagonist!.pitch = pitch;
state.protagonist!.yaw = yaw;
});
gunPitchInput.current!.value = `${Math.round(
pitch * (180 / Math.PI),
)}`;
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
gunPitchInput.current?.blur();
}
}}
onFocus={() => gunPitchInput.current?.focus()}
ref={gunPitchInput}
style={{ textAlign: 'right' }}
/>
<TextField.Slot
style={{
userSelect: 'none',
@@ -153,7 +153,7 @@ export function RotationInputs({ isFullScreen }: RotationInputsProps) {
</Flex>
{mode === 'armor' && (
<Flex gap="4">
<Flex gap="4" style={{ pointerEvents: 'auto' }}>
<QuickEquipmentButton
equipment={103}
active={hasCalibratedShells}
@@ -1,7 +1,7 @@
import { UpdateIcon } from '@radix-ui/react-icons';
import { Button, Dialog, Flex, Heading } from '@radix-ui/themes';
import { useState } from 'react';
import { TREE_TYPE_ICONS } from '../../../../../components/Tanks';
import { TREE_TYPE_ICONS } from '../../../../../components/Tanks/components/Item/constants';
import { assignDuelMember } from '../../../../../core/blitzkrieg/assignDuelMember';
import { pushTankopediaPath } from '../../../../../core/blitzkrieg/pushTankopediaPath';
import { useDuel } from '../../../../../stores/duel';
+16 -10
View File
@@ -10,10 +10,11 @@ import { mutateTankopediaTemporary } from '../../../../stores/tankopedia';
import { AntagonistBar } from './components/AntagonistBar';
import { Characteristics } from './components/Characteristics';
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 { Modules } from './components/Characteristics/components/Modules';
import { Provisions } from './components/Characteristics/components/Provisions';
import { Skills } from './components/Characteristics/components/Skills';
import { TankSandbox } from './components/Model/TankSandbox';
import { Title } from './components/Title';
@@ -63,10 +64,8 @@ export default function Page({ params }: { params: { id: string } }) {
};
}, [initialId, assigned]);
return (
<PageWrapper color="purple" size="double">
<PageWrapper color="purple" size={1600}>
{assigned && (
<>
<Title />
@@ -79,7 +78,7 @@ export default function Page({ params }: { params: { id: string } }) {
justify="center"
>
<Flex
gap="4"
gap="8"
direction="column"
style={{
flex: 1,
@@ -88,15 +87,22 @@ export default function Page({ params }: { params: { id: string } }) {
position: wideFormat ? 'sticky' : undefined,
}}
>
<TankSandbox />
<AntagonistBar />
<Flex gap="4" direction="column">
<TankSandbox />
<AntagonistBar />
</Flex>
<Flex direction="column" gap="4">
<Flex gap="5" wrap="wrap">
<Modules />
<Provisions />
<Equipments />
<Miscellaneous />
<Consumables />
<Flex gap="5" wrap="wrap">
<Skills />
<Equipment />
</Flex>
<Miscellaneous />
</Flex>
</Flex>
@@ -32,32 +32,31 @@ export function PageTurner({ searchedList, tanksPerPage }: PageTurnerProps) {
>
<CaretLeftIcon />
</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.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>
out of {Math.floor(searchedList.length / tanksPerPage) + 1}
</TextField.Slot>
@@ -1,9 +1,7 @@
import { Button, Flex, Text } from '@radix-ui/themes';
import { SmallTankIcon } from '../../../../../../components/SmallTankIcon';
import {
TIER_ROMAN_NUMERALS,
TankDefinition,
} from '../../../../../../core/blitzkrieg/tankDefinitions';
import { TankDefinition } from '../../../../../../core/blitzkrieg/tankDefinitions';
import { TIER_ROMAN_NUMERALS } from '../../../../../../core/blitzkrieg/tankDefinitions/constants';
interface CompactSearchResultRowProps {
tanks: TankDefinition[];
@@ -4,17 +4,18 @@ import { CaretDownIcon } from '@radix-ui/react-icons';
import { Button, DropdownMenu, Flex } from '@radix-ui/themes';
import { use } from 'react';
import { Flag } from '../../../../../../../components/Flag';
import {} from '../../../../../../../components/Tanks';
import {
TANK_CLASSES,
TREE_TYPES,
TREE_TYPE_ICONS,
TREE_TYPE_IMAGES,
} from '../../../../../../../components/Tanks';
} from '../../../../../../../components/Tanks/components/Item/constants';
import { NATIONS } from '../../../../../../../core/blitzkrieg/tankDefinitions';
import {
NATIONS,
TIERS,
TIER_ROMAN_NUMERALS,
} from '../../../../../../../core/blitzkrieg/tankDefinitions';
} from '../../../../../../../core/blitzkrieg/tankDefinitions/constants';
import mutateTankopediaPersistent, {
TankopediaTestTankDisplay,
useTankopediaPersistent,
@@ -14,8 +14,10 @@ import {
TextField,
} from '@radix-ui/themes';
import { go } from 'fuzzysort';
import Link from 'next/link';
import { use, useEffect, useMemo, useRef, useState } from 'react';
import { TANK_CLASSES, TreeType } from '../../../../../components/Tanks';
import { TreeType } from '../../../../../components/Tanks';
import { TANK_CLASSES } from '../../../../../components/Tanks/components/Item/constants';
import { resolveNearPenetration } from '../../../../../core/blitz/resolveNearPenetration';
import { asset } from '../../../../../core/blitzkrieg/asset';
import { modelDefinitions } from '../../../../../core/blitzkrieg/modelDefinitions';
@@ -24,13 +26,15 @@ import { resolveDpm } from '../../../../../core/blitzkrieg/resolveDpm';
import { resolveReload } from '../../../../../core/blitzkrieg/resolveReload';
import {
NATIONS,
TankDefinition,
tanksDefinitionsArray,
} from '../../../../../core/blitzkrieg/tankDefinitions';
import {
TANK_ICONS,
TANK_ICONS_COLLECTOR,
TANK_ICONS_PREMIUM,
TIER_ROMAN_NUMERALS,
TankDefinition,
tanksDefinitionsArray,
} from '../../../../../core/blitzkrieg/tankDefinitions';
} from '../../../../../core/blitzkrieg/tankDefinitions/constants';
import { tankIcon } from '../../../../../core/blitzkrieg/tankIcon';
import { unionBoundingBox } from '../../../../../core/blitzkrieg/unionBoundingBox';
import { theme } from '../../../../../stitches.config';
@@ -148,7 +152,7 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
);
break;
case 'survivability.size':
case 'survivability.volume':
sorted = filtered.sort((a, b) => {
const aTankModelDefinition = awaitedModelDefinitions[a.id];
const bTankModelDefinition = awaitedModelDefinitions[b.id];
@@ -175,6 +179,33 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
});
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':
sorted = filtered.sort(
(a, b) =>
@@ -389,27 +420,26 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
<>
<Flex direction="column" gap="4">
<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>
<MagnifyingGlassIcon height="16" width="16" />
</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>
<DropdownMenu.Root>
@@ -696,14 +726,24 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
Camouflage shooting
</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem
checked={sort.by === 'survivability.size'}
checked={sort.by === 'survivability.volume'}
onClick={() => {
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.SubContent>
</DropdownMenu.Sub>
@@ -768,117 +808,123 @@ export function TankSearch({ compact, onSelect = () => {} }: TankSearchProps) {
<Flex wrap="wrap" gap="2" justify={compact ? 'between' : 'center'}>
{!compact &&
searchResultsPageSlice.map((tank) => (
<Card
key={tank.id}
onClick={() => onSelect(tank)}
className={styles.listing}
<Link
href={`/tools/tankopedia/${tank.id}`}
style={{
flex: 1,
cursor: 'pointer',
height: 64,
minWidth: 256,
}}
>
<Inset
<Card
key={tank.id}
className={styles.listing}
style={{
minWidth: 256,
minHeight: 64,
position: 'relative',
display: 'flex',
cursor: 'pointer',
height: 64,
width: '100%',
}}
>
<img
className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
<Inset
key={tank.id}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
position: 'absolute',
minHeight: 64,
position: 'relative',
display: 'flex',
}}
/>
<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
src={tankIcon(tank.id)}
className={styles.flag}
src={asset(`flags/scratched/${tank.nation}.webp`)}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
objectPosition: 'left center',
position: 'absolute',
}}
/>
</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">
<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
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"
src={tankIcon(tank.id)}
style={{
color:
tank.treeType === 'researchable'
? slateDark.slate12
: undefined,
whiteSpace: 'nowrap',
maxWidth: 160,
display: 'block',
textOverflow: 'ellipsis',
overflow: 'hidden',
objectFit: 'contain',
objectPosition: 'left center',
}}
>
{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>
</Flex>
<Text size="4" color="gray" highContrast={false}>
{TIER_ROMAN_NUMERALS[tank.tier]}
</Text>
</Flex>
</Inset>
</Card>
</Inset>
</Card>
</Link>
))}
{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({
display: 'flex',
flexDirection: 'column',
+1 -1
View File
@@ -8,7 +8,7 @@ export default function Page() {
const router = useRouter();
return (
<PageWrapper size="double" color="purple">
<PageWrapper size={1200} color="purple">
<TankSearch
onSelect={(tank) => {
router.push(`./tankopedia/${tank.id}`);
+71
View File
@@ -0,0 +1,71 @@
import { getBlitzStarsLinkButton } from '../../core/blitzstars/getBlitzStarsLinkButton';
import addPeriodicFilterOptions from '../../core/discord/addPeriodicFilterOptions';
import addUsernameChoices from '../../core/discord/addUsernameChoices';
import autocompleteTanks from '../../core/discord/autocompleteTanks';
import autocompleteUsername from '../../core/discord/autocompleteUsername';
import { buttonRefresh } from '../../core/discord/buttonRefresh';
import commandToURL from '../../core/discord/commandToURL';
import { createLocalizedCommand } from '../../core/discord/createLocalizedCommand';
import { getCustomPeriodParams } from '../../core/discord/getCustomPeriodParams';
import { getFiltersFromButton } from '../../core/discord/getFiltersFromButton';
import { getFiltersFromCommand } from '../../core/discord/getFiltersFromCommand';
import resolvePeriodFromButton from '../../core/discord/resolvePeriodFromButton';
import resolvePeriodFromCommand from '../../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../../core/discord/resolvePlayerFromCommand';
import { CommandRegistry } from '../../events/interactionCreate';
import { renderBreakdown } from './render';
export const breakdownCommand = new Promise<CommandRegistry>(
async (resolve) => {
resolve({
inProduction: true,
inPublic: true,
command: await addPeriodicFilterOptions(
createLocalizedCommand('breakdown'),
(option) => option.addStringOption(addUsernameChoices),
),
async handler(interaction) {
const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(player.region, interaction);
const filters = await getFiltersFromCommand(interaction);
const path = commandToURL(interaction, {
...player,
...getCustomPeriodParams(interaction),
...filters,
});
return [
...(await renderBreakdown(
player,
period,
filters,
interaction.locale,
)),
buttonRefresh(interaction, path),
await getBlitzStarsLinkButton(player.region, player.id),
];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction);
const filters = getFiltersFromButton(interaction);
return await renderBreakdown(
player,
period,
filters,
interaction.locale,
);
},
});
},
);
@@ -1,43 +1,25 @@
import { Locale } from 'discord.js';
import { chunk } from 'lodash';
import * as Breakdown from '../components/Breakdown';
import CommandWrapper from '../components/CommandWrapper';
import NoData from '../components/NoData';
import TitleBar from '../components/TitleBar';
import { AllStats, getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getTankStats from '../core/blitz/getTankStats';
import { filtersToDescription } from '../core/blitzkrieg/filtersToDescription';
import * as Breakdown from '../../components/Breakdown';
import CommandWrapper from '../../components/CommandWrapper';
import NoData from '../../components/NoData';
import TitleBar from '../../components/TitleBar';
import { AllStats, getAccountInfo } from '../../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../../core/blitz/getClanAccountInfo';
import getTankStats from '../../core/blitz/getTankStats';
import { filtersToDescription } from '../../core/blitzkrieg/filtersToDescription';
import {
TankDefinition,
tankDefinitions,
} from '../core/blitzkrieg/tankDefinitions';
import { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
import { tankAverages } from '../core/blitzstars/tankAverages';
import addPeriodicFilterOptions from '../core/discord/addPeriodicFilterOptions';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks';
import autocompleteUsername from '../core/discord/autocompleteUsername';
import { buttonRefresh } from '../core/discord/buttonRefresh';
import commandToURL from '../core/discord/commandToURL';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
import { getCustomPeriodParams } from '../core/discord/getCustomPeriodParams';
import { getFiltersFromButton } from '../core/discord/getFiltersFromButton';
import { getFiltersFromCommand } from '../core/discord/getFiltersFromCommand';
import resolvePeriodFromButton from '../core/discord/resolvePeriodFromButton';
import resolvePeriodFromCommand, {
ResolvedPeriod,
} from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand, {
ResolvedPlayer,
} from '../core/discord/resolvePlayerFromCommand';
import { translator } from '../core/localization/translator';
import calculateWN8 from '../core/statistics/calculateWN8';
import { StatFilters, filterStats } from '../core/statistics/filterStats';
import getWN8Percentile from '../core/statistics/getWN8Percentile';
import { CommandRegistry } from '../events/interactionCreate';
} from '../../core/blitzkrieg/tankDefinitions';
import getStatsInPeriod from '../../core/blitzstars/getStatsInPeriod';
import { tankAverages } from '../../core/blitzstars/tankAverages';
import { ResolvedPeriod } from '../../core/discord/resolvePeriodFromCommand';
import { ResolvedPlayer } from '../../core/discord/resolvePlayerFromCommand';
import { translator } from '../../core/localization/translator';
import calculateWN8 from '../../core/statistics/calculateWN8';
import { StatFilters, filterStats } from '../../core/statistics/filterStats';
import getWN8Percentile from '../../core/statistics/getWN8Percentile';
const ROWS_PER_PAGE = 8;
const MAX_PAGES = 9;
@@ -296,57 +278,3 @@ export async function renderBreakdown(
];
}
}
export const breakdownCommand = new Promise<CommandRegistry>(
async (resolve) => {
resolve({
inProduction: true,
inPublic: true,
command: await addPeriodicFilterOptions(
createLocalizedCommand('breakdown'),
(option) => option.addStringOption(addUsernameChoices),
),
async handler(interaction) {
const player = await resolvePlayerFromCommand(interaction);
const period = resolvePeriodFromCommand(player.region, interaction);
const filters = await getFiltersFromCommand(interaction);
const path = commandToURL(interaction, {
...player,
...getCustomPeriodParams(interaction),
...filters,
});
return [
...(await renderBreakdown(
player,
period,
filters,
interaction.locale,
)),
buttonRefresh(interaction, path),
await getBlitzStarsLinkButton(player.region, player.id),
];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction);
const filters = getFiltersFromButton(interaction);
return await renderBreakdown(
player,
period,
filters,
interaction.locale,
);
},
});
},
);
+4 -3
View File
@@ -1,12 +1,13 @@
import { Locale, SlashCommandSubcommandGroupBuilder } from 'discord.js';
import CommandWrapper from '../components/CommandWrapper';
import * as Graph from '../components/Graph';
import { LineColor } from '../components/Graph/components/Line/constants';
import NoData from '../components/NoData';
import TitleBar from '../components/TitleBar';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
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 { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getPlayerHistories from '../core/blitzstars/getPlayerHistories';
@@ -40,7 +41,7 @@ async function render(
) {
const accountInfo = await getAccountInfo(region, id);
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'
? getPlayerHistories(region, id, {
start,
@@ -93,7 +94,7 @@ async function render(
xMaxLabel={new Date(maxTime * 1000).toLocaleDateString(locale)}
>
{Graph.Line({
color: Graph.LineColor.Red,
color: LineColor.Red,
plot: plot,
minY,
maxY,
+2 -2
View File
@@ -6,7 +6,7 @@ import TierWeights from '../components/TierWeights';
import TitleBar from '../components/TitleBar';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
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 { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
@@ -40,7 +40,7 @@ async function render(
) {
const { nickname } = await getAccountInfo(region, id);
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(
region,
id,
+4 -4
View File
@@ -2,11 +2,11 @@ import { teal } from '@radix-ui/colors';
import { GuildMemberRoleManager } from 'discord.js';
import markdownEscape from 'markdown-escape';
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 { getAccountInfo } from '../core/blitz/getAccountInfo';
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 autocompleteUsername from '../core/discord/autocompleteUsername';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
@@ -29,11 +29,11 @@ export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
async handler(interaction) {
const { t, translate } = translator(interaction.locale);
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 clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
await linkBlitzAndDiscord(discordId, region, id);
await linkBlitzAndDiscord(discordId, id);
if (interaction.guildId === discord.sklld_guild_id) {
if (!interaction.guild?.members.me?.permissions.has('ManageRoles')) {
+31 -78
View File
@@ -1,18 +1,16 @@
import CommandWrapper from '../components/CommandWrapper';
import NoData from '../components/NoData';
import * as Tanks from '../components/Tanks';
import TitleBar from '../components/TitleBar';
import { chunk } from 'lodash';
import markdownEscape from 'markdown-escape';
import { TANK_CLASSES } from '../components/Tanks/components/Item/constants';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getTankStats from '../core/blitz/getTankStats';
import { emblemIdToURL } from '../core/blitzkrieg/emblemIdToURL';
import {
TIER_ROMAN_NUMERALS,
NATIONS,
TankDefinition,
Tier,
flags,
tankDefinitions,
} from '../core/blitzkrieg/tankDefinitions';
import { tankIcon } from '../core/blitzkrieg/tankIcon';
import { TIER_ROMAN_NUMERALS } from '../core/blitzkrieg/tankDefinitions/constants';
import addTierChoices from '../core/discord/addTierChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteUsername from '../core/discord/autocompleteUsername';
@@ -21,6 +19,8 @@ import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { translator } from '../core/localization/translator';
import { CommandRegistry } from '../events/interactionCreate';
const TANKS_PER_MESSAGE = 64;
export const ownedTanksCommand = new Promise<CommandRegistry>((resolve) => {
resolve({
inProduction: true,
@@ -33,10 +33,9 @@ export const ownedTanksCommand = new Promise<CommandRegistry>((resolve) => {
async handler(interaction) {
const { translate } = translator(interaction.locale);
const tier = Number(interaction.options.getString('tier'));
const account = await resolvePlayerFromCommand(interaction);
const { id, region: server } = account;
const accountInfo = await getAccountInfo(server, id);
const tankStats = await getTankStats(server, id, interaction.locale);
const { id, region } = await resolvePlayerFromCommand(interaction);
const accountInfo = await getAccountInfo(region, id);
const tankStats = await getTankStats(region, id, interaction.locale);
const filteredTanks = (
await Promise.all(
tankStats.map(async (tankData) => ({
@@ -45,7 +44,6 @@ export const ownedTanksCommand = new Promise<CommandRegistry>((resolve) => {
})),
)
).filter((tank) => tank.tankDefinitions?.tier === tier);
const clanAccountInfo = await getClanAccountInfo(server, id, ['clan']);
const groupedTanks: Record<string, TankDefinition[]> = {};
const nations: string[] = [];
@@ -58,73 +56,28 @@ export const ownedTanksCommand = new Promise<CommandRegistry>((resolve) => {
}
});
nations.sort();
const awaitedTankDefinitions = await tankDefinitions;
const awaitedNations = await NATIONS;
const lines = tankStats
.map(({ tank_id }) => awaitedTankDefinitions[tank_id])
.filter((tank) => tank.tier === tier)
.sort(
(a, b) =>
TANK_CLASSES.indexOf(a.class) - TANK_CLASSES.indexOf(b.class),
)
.sort(
(a, b) =>
awaitedNations.indexOf(a.nation) - awaitedNations.indexOf(b.nation),
)
.map((tank) => `${flags[tank.nation]} ${markdownEscape(tank.name)}`);
return (
<CommandWrapper>
<TitleBar
title={accountInfo.nickname}
image={
clanAccountInfo?.clan
? emblemIdToURL(clanAccountInfo.clan.emblem_set_id)
: undefined
}
description={translate('bot.commands.owned_tanks.body.subtitle', [
return chunk(lines, TANKS_PER_MESSAGE).map((lines, index) =>
index === 0
? `${translate('bot.commands.owned_tanks.body.title', [
markdownEscape(accountInfo.nickname),
TIER_ROMAN_NUMERALS[tier as Tier],
])}
/>
{filteredTanks.length === 0 && (
<NoData type="tanks_found" locale={interaction.locale} />
)}
{filteredTanks.length > 0 &&
(await Promise.all(
nations.map(async (nation) => {
const tanks = groupedTanks[nation];
const leftColumnSize = Math.ceil(tanks.length / 2);
const leftColumn = tanks.slice(0, leftColumnSize);
const rightColumn = tanks.slice(leftColumnSize);
return (
<Tanks.Root key={nation}>
<Tanks.Title>
{translate(`common.nations.${nation}`)}
</Tanks.Title>
<Tanks.Row>
<Tanks.Column>
{await Promise.all(
leftColumn.map(async (tank) => (
<Tanks.Item
key={tank.id}
name={tank.name}
tankType={tank.class}
image={tankIcon(tank.id)}
treeType={tank.treeType}
/>
)),
)}
</Tanks.Column>
<Tanks.Column>
{await Promise.all(
rightColumn.map(async (tank) => (
<Tanks.Item
key={tank.id}
name={tank.name}
tankType={tank.class}
image={tankIcon(tank.id)}
treeType={tank.treeType}
/>
)),
)}
</Tanks.Column>
</Tanks.Row>
</Tanks.Root>
);
}),
))}
</CommandWrapper>
])}${lines.join('\n')}`
: lines.join('\n'),
);
},
+4
View File
@@ -27,6 +27,10 @@ export const permissionsCommand = new Promise<CommandRegistry>((resolve) => {
interaction.appPermissions?.has('AttachFiles'),
t`bot.commands.permissions.body.attach_files`,
],
[
interaction.appPermissions?.has('UseExternalEmojis'),
t`bot.commands.permissions.body.external_emojis`,
],
];
const body = permissions
.map(([hasPermission, description]) => {
+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,
});
});
+576
View File
@@ -0,0 +1,576 @@
import { Locale, escapeMarkdown } from 'discord.js';
import CommandWrapper from '../components/CommandWrapper';
import TitleBar from '../components/TitleBar';
import { equipmentPriceMatrix } from '../constants/equipmentPrice';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
import { getClanAccountInfo } from '../core/blitz/getClanAccountInfo';
import getTankStats from '../core/blitz/getTankStats';
import resolveTankId from '../core/blitz/resolveTankId';
import { asset } from '../core/blitzkrieg/asset';
import { buildTechTreeLine } from '../core/blitzkrieg/buildTechTreeLine';
import { emblemURL } from '../core/blitzkrieg/emblemURL';
import { webpToPng } from '../core/blitzkrieg/iconPng';
import { resolveAncestry } from '../core/blitzkrieg/resolveAncestry';
import { tankDefinitions } from '../core/blitzkrieg/tankDefinitions';
import {
TANK_ICONS,
TIER_ROMAN_NUMERALS,
} from '../core/blitzkrieg/tankDefinitions/constants';
import { tankIconPng } from '../core/blitzkrieg/tankIconPng';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import autocompleteTanks from '../core/discord/autocompleteTanks';
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 { theme } from '../stitches.config';
export const researchCommand = new Promise<CommandRegistry>((resolve) => {
const { t } = translator(Locale.EnglishUS);
resolve({
inProduction: true,
inPublic: true,
command: createLocalizedCommand('research')
.addStringOption((option) =>
option
.setName(t`bot.commands.research.options.target_tank`)
.setNameLocalizations(
localizationObject('bot.commands.research.options.target_tank'),
)
.setDescription(
t`bot.commands.research.options.target_tank.description`,
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.research.options.target_tank.description',
),
)
.setAutocomplete(true)
.setRequired(true),
)
.addStringOption((option) =>
option
.setName(t`bot.commands.research.options.starting_tank`)
.setNameLocalizations(
localizationObject('bot.commands.research.options.starting_tank'),
)
.setDescription(
t`bot.commands.research.options.starting_tank.description`,
)
.setDescriptionLocalizations(
localizationObject(
'bot.commands.research.options.starting_tank.description',
),
)
.setAutocomplete(true)
.setRequired(false),
)
.addStringOption(addUsernameChoices),
async handler(interaction) {
const { t, translate } = translator(interaction.locale);
const awaitedTankDefinitions = await tankDefinitions;
const targetTankId = await resolveTankId(
interaction.options.getString('target-tank', true),
interaction.locale,
true,
);
const startingTankRaw = interaction.options.getString('starting-tank');
const targetTank = awaitedTankDefinitions[targetTankId];
const { id, region } = await resolvePlayerFromCommand(interaction);
let startingTankId: number;
const targetTankAncestry = await resolveAncestry(targetTankId);
if (startingTankRaw) {
startingTankId = await resolveTankId(
startingTankRaw,
interaction.locale,
true,
);
if (targetTankId === startingTankId) {
return translate('bot.commands.research.errors.start_end_equal', [
escapeMarkdown(targetTank.name),
]);
}
const startingTank = awaitedTankDefinitions[startingTankId];
if (startingTank.tier > targetTank.tier) {
return translate('bot.commands.research.errors.unordered', [
escapeMarkdown(startingTank.name),
escapeMarkdown(targetTank.name),
escapeMarkdown(targetTank.name),
]);
}
if (!targetTankAncestry.includes(startingTankId)) {
return translate('bot.commands.research.errors.tanks_not_on_line', [
escapeMarkdown(targetTank.name),
escapeMarkdown(startingTank.name),
]);
}
} else {
if (targetTank.treeType !== 'researchable') {
return translate('bot.commands.research.errors.non_tech_tree', [
escapeMarkdown(targetTank.name),
]);
}
if (targetTank.tier === 1) {
return translate('bot.commands.research.errors.tier_1', [
escapeMarkdown(targetTank.name),
]);
}
const tankStats = await getTankStats(region, id, interaction.locale);
if (tankStats.some(({ tank_id }) => tank_id === targetTankId)) {
return translate('bot.commands.research.errors.already_researched', [
escapeMarkdown(targetTank.name),
]);
}
const foundAncestor = targetTankAncestry.find(
(id) =>
awaitedTankDefinitions[id].tier === 1 ||
tankStats.some(({ tank_id }) => tank_id === id),
);
if (foundAncestor === undefined) {
throw new Error('No first owned tech tree found.');
}
startingTankId = foundAncestor;
}
const techTreeLine = await buildTechTreeLine(
startingTankId,
targetTankId,
);
const line = [...techTreeLine, startingTankId].reverse();
const { nickname } = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ['clan']))?.clan;
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
const costs = await Promise.all(
line.map(async (id) => {
const tank = awaitedTankDefinitions[id];
const turretXps = new Map<number, number>();
const gunXps = new Map<number, number>();
const engineXps = new Map<number, number>();
const trackXps = new Map<number, number>();
tank.turrets.forEach((turret) => {
if (turret.xp !== undefined) {
turretXps.set(turret.id, turret.xp);
}
turret.guns.forEach((gun) => {
if (gun.xp !== undefined) {
gunXps.set(gun.id, gun.xp);
}
});
});
tank.engines.forEach((engine) => {
if (engine.xp !== undefined) {
engineXps.set(engine.id, engine.xp);
}
});
tank.tracks.forEach((track) => {
if (track.xp !== undefined) {
trackXps.set(track.id, track.xp);
}
});
let moduleXp = 0;
turretXps.forEach((value) => {
moduleXp += value;
});
gunXps.forEach((value) => {
moduleXp += value;
});
engineXps.forEach((value) => {
moduleXp += value;
});
trackXps.forEach((value) => {
moduleXp += value;
});
return {
research: tank.xp,
upgrades: moduleXp,
purchase: tank.price.value,
equipment: equipmentPriceMatrix[tank.tier].reduce(
(a, b) => a + 3 * b,
0,
),
};
}),
);
const image = (
<CommandWrapper>
<TitleBar
title={nickname}
image={clanImage}
description={`${t`bot.commands.research.body.subtitle`} • ${targetTank.name}`}
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<div
style={{
display: 'flex',
borderRadius: 4,
backgroundColor: theme.colors.appBackground2,
border: theme.borderStyles.subtle,
}}
>
<div style={{ width: 48 }}></div>
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
right: 24,
}}
>
<span
style={{
color: theme.colors.textHighContrast,
}}
>
{t`bot.commands.research.body.total`}
</span>
</div>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 8,
padding: '16px 0',
}}
>
<div
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
}}
>
<img
src={await webpToPng(asset('icons/currencies/xp.webp'))}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
Experience:{' '}
{costs
.reduce((a, b) => a + (b.research ?? 0) + b.upgrades, 0)
.toLocaleString(interaction.locale)}
</span>
</div>
<div
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
}}
>
<img
src={await webpToPng(asset('icons/currencies/silver.webp'))}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
Purchase:{' '}
{costs
.reduce((a, b) => a + b.purchase, 0)
.toLocaleString(interaction.locale)}
</span>
</div>
<div
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
}}
>
<img
src={await webpToPng(asset('icons/currencies/silver.webp'))}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
Equipment:{' '}
{costs
.reduce((a, b) => a + b.equipment, 0)
.toLocaleString(interaction.locale)}
</span>
</div>
</div>
</div>
{await Promise.all(
line.map(async (id, index) => {
const tank = awaitedTankDefinitions[id];
return (
<div
key={id}
style={{
display: 'flex',
borderRadius: 4,
backgroundColor: theme.colors.appBackground2,
border: theme.borderStyles.subtle,
}}
>
<div
style={{
width: 48,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span
style={{
color: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{TIER_ROMAN_NUMERALS[tank.tier]}
</span>
</div>
<div
style={{
flex: 1,
display: 'flex',
position: 'relative',
right: '5%',
}}
>
<div
style={{
height: 120,
width: 240,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
left: '25%',
top: '5%',
}}
>
<img
src={await tankIconPng(id)}
style={{
height: '100%',
position: 'absolute',
objectFit: 'contain',
}}
/>
</div>
<div
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
justifyContent: 'center',
position: 'absolute',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
}}
>
<img
src={TANK_ICONS[tank.class]}
width={16}
height={16}
/>
<span
style={{
color: theme.colors.textHighContrast,
}}
>
{tank.name}
</span>
</div>
</div>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 8,
padding: '16px 0',
}}
>
<div
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
}}
>
<img
src={await webpToPng(
asset('icons/currencies/xp.webp'),
)}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{translate('bot.commands.research.body.research', [
`${costs[index].research?.toLocaleString(
interaction.locale,
)}`,
])}
</span>
</div>
{costs[index].upgrades > 0 && (
<div
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
}}
>
<img
src={await webpToPng(
asset('icons/currencies/free-xp.webp'),
)}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{translate('bot.commands.research.body.upgrades', [
costs[index].upgrades.toLocaleString(),
])}
</span>
</div>
)}
<div
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
}}
>
<img
src={await webpToPng(
asset('icons/currencies/silver.webp'),
)}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{translate('bot.commands.research.body.purchase', [
costs[index].purchase.toLocaleString(
interaction.locale,
),
])}
</span>
</div>
<div
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
}}
>
<img
src={await webpToPng(
asset('icons/currencies/silver.webp'),
)}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{translate('bot.commands.research.body.equipment', [
costs[index].equipment.toLocaleString(
interaction.locale,
),
])}
</span>
</div>
</div>
</div>
);
}),
)}
</div>
</CommandWrapper>
);
return startingTankRaw
? image
: [t`bot.commands.research.body.estimation`, image];
},
autocomplete(interaction) {
autocompleteTanks(interaction, true, ['target-tank', 'starting-tank']);
},
});
});
+2 -4
View File
@@ -3,10 +3,8 @@ import markdownEscape from 'markdown-escape';
import { Region } from '../constants/regions';
import { WARGAMING_APPLICATION_ID } from '../constants/wargamingApplicationID';
import fetchBlitz from '../core/blitz/fetchBlitz';
import {
AccountList,
usernamePattern,
} from '../core/blitz/searchPlayersAcrossRegions';
import { AccountList } from '../core/blitz/searchPlayersAcrossRegions';
import { usernamePattern } from '../core/blitz/searchPlayersAcrossRegions/constants';
import addRegionChoices from '../core/discord/addRegionChoices';
import addUsernameChoices from '../core/discord/addUsernameChoices';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
+2 -2
View File
@@ -5,7 +5,7 @@ import NoData from '../components/NoData';
import TitleBar from '../components/TitleBar';
import { getAccountInfo } from '../core/blitz/getAccountInfo';
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 { getBlitzStarsLinkButton } from '../core/blitzstars/getBlitzStarsLinkButton';
import getStatsInPeriod from '../core/blitzstars/getStatsInPeriod';
@@ -38,7 +38,7 @@ async function render(
) {
const { nickname } = await getAccountInfo(region, id);
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(
region,
id,
+1 -1
View File
@@ -15,7 +15,7 @@ import resolvePeriodFromCommand from '../core/discord/resolvePeriodFromCommand';
import resolvePlayerFromButton from '../core/discord/resolvePlayerFromButton';
import resolvePlayerFromCommand from '../core/discord/resolvePlayerFromCommand';
import { CommandRegistry } from '../events/interactionCreate';
import { renderBreakdown } from './breakdown';
import { renderBreakdown } from './breakdown/render';
export const todayCommand = new Promise<CommandRegistry>(async (resolve) => {
const nations = await NATIONS;
@@ -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,22 +1,26 @@
import { Locale } from 'discord.js';
import { Percentile } from '../../../../constants/percentiles';
import { SupplementaryStats } from '../../../../core/blitz/getAccountInfo';
import { translator } from '../../../../core/localization/translator';
import getWN8Percentile from '../../../../core/statistics/getWN8Percentile';
import { theme } from '../../../../stitches.config';
import { PERCENTILE_COLORS } from '../../../PercentileIndicator';
import { PERCENTILE_COLORS } from '../../../PercentileIndicator/constants';
import { Glow } from './components/Glow';
export interface WN8DisplayProps {
wn8?: number;
stats: SupplementaryStats;
locale: Locale;
}
export function WN8Display({ wn8, locale }: WN8DisplayProps) {
const percentile =
wn8 === undefined ? Percentile.VeryBad : getWN8Percentile(wn8);
const color = PERCENTILE_COLORS[percentile];
export function HeroStat({ stats, locale }: WN8DisplayProps) {
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 (
<div
style={{
@@ -43,23 +47,32 @@ export function WN8Display({ wn8, locale }: WN8DisplayProps) {
gap: -4,
}}
>
<span
<div
style={{
fontWeight: 900,
fontSize: 32,
color: theme.colors.textHighContrast,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
{wn8 === undefined ? '--' : wn8.toFixed(0)}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{translate(`common.wn8_percentile.${percentile}`)}
</span>
<span
style={{
fontWeight: 900,
fontSize: 32,
color: theme.colors.textHighContrast,
}}
>
{heroStat}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{subtitle}
</span>
</div>
</div>
<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>
);
}
@@ -34,7 +34,7 @@ export function WN8SurroundingStat({
style={{
color: theme.colors.textHighContrast,
fontSize: 24,
fontWeight: 'bold',
fontWeight: 700,
}}
>
{value}
+23 -16
View File
@@ -1,14 +1,23 @@
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 { WN8Display } from './components/WN8Display';
import { HeroStat } from './components/HeroStat';
import {
WN8SurroundingStat,
WN8SurroundingStatAlign,
} from './components/WN8SurroundingStat';
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;
locale: Locale;
}
@@ -24,6 +33,7 @@ export default function AllStatsOverview({
<div
style={{
display: 'flex',
alignItems: 'center',
gap: -64,
width: '100%',
}}
@@ -38,18 +48,18 @@ export default function AllStatsOverview({
}}
>
<WN8SurroundingStat
label={t`bot.commands.stats.body.battles`}
label={t`bot.common.hero_stats.battles`}
value={stats.battles.toLocaleString()}
align={WN8SurroundingStatAlign.Right}
/>
<WN8SurroundingStat
label={t`bot.commands.stats.body.winrate`}
label={t`bot.common.hero_stats.winrate`}
value={`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`}
align={WN8SurroundingStatAlign.Right}
padded
/>
<WN8SurroundingStat
label={t`bot.commands.stats.body.damage`}
label={t`bot.common.hero_stats.damage`}
value={Math.round(
stats.damage_dealt / stats.battles,
).toLocaleString()}
@@ -57,13 +67,13 @@ export default function AllStatsOverview({
padded
/>
<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)}
align={WN8SurroundingStatAlign.Right}
/>
</div>
<WN8Display locale={locale} wn8={supplementaryStats.WN8} />
<HeroStat locale={locale} stats={supplementaryStats} />
<div
style={{
@@ -74,29 +84,26 @@ export default function AllStatsOverview({
}}
>
<WN8SurroundingStat
label={t`bot.commands.stats.body.tier`}
label={t`bot.common.hero_stats.tier`}
value={
supplementaryStats.tier ? supplementaryStats.tier.toFixed(2) : '--'
}
/>
<WN8SurroundingStat
label={t`bot.commands.stats.body.survival`}
label={t`bot.common.hero_stats.survival`}
value={`${(100 * (stats.survived_battles / stats.battles)).toFixed(
2,
)}%`}
padded
/>
<WN8SurroundingStat
label={t`bot.commands.stats.body.accuracy`}
label={t`bot.common.hero_stats.accuracy`}
value={`${(100 * (stats.hits / stats.shots)).toFixed(2)}%`}
padded
/>
<WN8SurroundingStat
label={t`bot.commands.stats.body.kill_ratio`}
value={(
stats.frags /
(stats.battles - stats.survived_battles)
).toFixed(2)}
label={t`bot.common.hero_stats.kills`}
value={(stats.frags / stats.battles).toFixed(2)}
/>
</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 gunContainer = useRef<Group>(null);
const initialTankopediaState = useTankopediaPersistent.getState();
const protagonist = useDuel((draft) => draft.protagonist!);
useEffect(() => {
if (!modelDefinitions) return;
@@ -88,7 +89,7 @@ export const CoreArmorScene = memo(() => {
turretContainer.current?.rotation.copy(turretRotation);
}
handleModelTransform(useDuel.getState().protagonist!);
handleModelTransform(protagonist);
modelTransformEvent.on(handleModelTransform);
return () => {
@@ -1,12 +1,6 @@
import { useFrame } from '@react-three/fiber';
import { useEffect } from 'react';
import {
MeshBasicMaterial,
Object3D,
ShaderMaterial,
Vector2,
WebGLRenderTarget,
} from 'three';
import { MeshBasicMaterial, Object3D, ShaderMaterial, Vector2 } from 'three';
import { degToRad } from 'three/src/math/MathUtils';
import { canSplash } from '../../../../core/blitz/canSplash';
import { isExplosive } from '../../../../core/blitz/isExplosive';
@@ -21,8 +15,7 @@ import {
} from '../../../../stores/tankopedia';
import fragmentShader from './shaders/fragment.glsl';
import vertexShader from './shaders/vertex.glsl';
export const spacedArmorRenderTarget = new WebGLRenderTarget();
import { spacedArmorRenderTarget } from './target';
interface CoreArmorSceneComponentProps {
thickness: number;
@@ -55,7 +48,9 @@ export function CoreArmorSceneComponent({
explosionRadius: { value: null },
greenPenetration: { value: null },
opaque: { value: null },
useSpacedArmor: { value: null },
inverseProjectionMatrix: { value: null },
resolution: { value: new Vector2() },
spacedArmorBuffer: { value: null },
spacedArmorDepth: { value: null },
@@ -63,10 +58,7 @@ export function CoreArmorSceneComponent({
});
useEffect(() => {
function handleShellChange(shell: ShellDefinition) {
material.uniforms.penetration.value = resolveNearPenetration(
shell.penetration,
);
async function handleShellChange(shell: ShellDefinition) {
material.uniforms.caliber.value = shell.caliber;
material.uniforms.ricochet.value = degToRad(shell.ricochet ?? 90);
material.uniforms.normalization.value = degToRad(
@@ -76,12 +68,17 @@ export function CoreArmorSceneComponent({
material.uniforms.canSplash.value = canSplash(shell.type);
material.uniforms.damage.value = shell.damage.armor;
material.uniforms.explosionRadius.value = shell.explosionRadius;
const duel = useDuel.getState();
await handleProtagonistEquipmentChange(duel.protagonist!.equipment);
await handleAntagonistEquipmentChange(duel.antagonist!.equipment);
}
function handleVisualChange(
visual: TankopediaPersistent['model']['visual'],
) {
material.uniforms.greenPenetration.value = visual.greenPenetration;
material.uniforms.opaque.value = visual.opaque || visual.wireframe;
material.uniforms.useSpacedArmor.value = visual.showSpacedArmor;
material.wireframe = visual.wireframe;
}
async function handleProtagonistEquipmentChange(
@@ -96,6 +93,7 @@ export function CoreArmorSceneComponent({
const shell = useDuel.getState().antagonist!.shell;
const penetration = resolveNearPenetration(shell.penetration);
const hasCalibratedShells = await hasEquipment(103, true, equipment);
material.uniforms.penetration.value = hasCalibratedShells
? penetration * (isExplosive(shell.type) ? 1.1 : 1.05)
: penetration;
@@ -125,15 +123,17 @@ export function CoreArmorSceneComponent({
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe());
};
}, []);
});
useFrame(({ gl }) => {
useFrame(({ gl, camera }) => {
gl.getSize(material.uniforms.resolution.value).multiplyScalar(
gl.getPixelRatio(),
);
material.uniforms.spacedArmorBuffer.value = spacedArmorRenderTarget.texture;
material.uniforms.spacedArmorDepth.value =
spacedArmorRenderTarget.depthTexture;
material.uniforms.inverseProjectionMatrix.value =
camera.projectionMatrixInverse;
});
return (
Loaded 100 of 254 files, more files were not shown because too many files have changed in this diff. Show more