Compare commits

..
355 Commits
Author SHA1 Message Date
tresabhi 006b2fef78 correct flag and tank media paths 2026-05-25 14:20:46 -05:00
tresabhi 26a0c1f176 temporarily disable build steps to see caching speed 2026-05-25 14:09:09 -05:00
tresabhi 403d1e7849 update wotb vars 2026-05-25 14:04:25 -05:00
tresabhi c9d8f6b445 oops, forgot the script 2026-05-25 13:53:42 -05:00
tresabhi 73c53d6ef5 download entire game on miss 2026-05-25 13:50:37 -05:00
tresabhi f8e8fd4016 game cache fingerprinting 2026-05-25 13:43:21 -05:00
tresabhi 15b29f439a steam username and password 2026-05-25 13:31:44 -05:00
tresabhi fd96cd5975 fingerprinting 2026-05-25 13:24:11 -05:00
tresabhi 66b9a9076f set up depot downloader 2026-05-25 12:58:19 -05:00
tresabhi 746c7963de add website build 2026-05-24 12:59:42 -05:00
tresabhi ae96ae34c0 move media to /media 2026-05-24 12:55:30 -05:00
tresabhi 1f690d11c7 equipment icons 2026-05-24 12:42:08 -05:00
tresabhi 8cf3da12fa well its working now at least i guess 2026-05-23 14:30:52 -05:00
tresabhi a8ae806f8b what 2026-05-23 14:25:32 -05:00
tresabhi 8a6841adea remove 2026-05-23 13:18:52 -05:00
tresabhi 5e88f5a73d update module 2026-05-23 13:18:09 -05:00
tresabhi aa1f357538 equipment icon parsing on c# end 2026-05-22 19:42:32 -05:00
tresabhi ca270e2010 beginnings of equipment icon 2026-05-22 13:51:30 -05:00
tresabhi 17264c1adc simplify compatibility logic 2026-05-20 13:57:05 -05:00
tresabhi 2627aba938 some consumable compatibility code 2026-05-20 13:48:20 -05:00
tresabhi a2574efb9c some equipment groundwork 2026-05-20 13:09:19 -05:00
tresabhi d3ab4bac51 error wrapper hoc 2026-05-20 12:22:03 -05:00
tresabhi ad78e45f1b hocs 2026-05-20 12:17:09 -05:00
tresabhi 5ea7bbd6a1 react boundary 2026-05-19 19:02:22 -05:00
tresabhi 2b955a281b basic error screen 2026-05-19 14:38:28 -05:00
tresabhi 676601b06e use prefixed ids 2026-05-19 14:03:07 -05:00
tresabhi b81d9985ee alts and batch alignemnt 2026-05-19 13:43:43 -05:00
tresabhi d66d25a261 module wrapping 2026-05-19 13:20:54 -05:00
tresabhi 402d054b30 stuff icon 2026-05-18 17:08:12 -05:00
tresabhi 61d6d42742 hide modules for no-up 2026-05-18 16:57:21 -05:00
tresabhi 03e06b050e separate game interface import better 2026-05-18 16:45:36 -05:00
tresabhi cca46957bc apply correct media prefixes 2026-05-18 16:32:46 -05:00
tresabhi 3c92ab3a19 force latest node 2026-05-18 16:25:10 -05:00
tresabhi 6c31f663f6 better ssr checking 2026-05-18 16:21:23 -05:00
tresabhi bd140f0eda remove duplicate setup script in package.json 2026-05-18 16:00:50 -05:00
tresabhi a3d8fcb960 i feel like these are going to fail, so here they are, fixed 2026-05-18 15:58:46 -05:00
tresabhi 06304cf075 update workflow to work with build chunks 2026-05-18 15:54:07 -05:00
tresabhi 416d061df1 all paths 2026-05-18 15:33:38 -05:00
tresabhi 47c87970cd add build chiunking 2026-05-18 13:54:03 -05:00
tresabhi ff3ed9d656 remove log 2026-05-17 17:14:03 -05:00
tresabhi 4e9c3df5eb equipment stage parameters 2026-05-17 14:03:02 -05:00
tresabhi c100a1e655 cleaner equipment and modules ui 2026-05-17 13:46:53 -05:00
tresabhi 26cc0cc723 fix currency icon paths 2026-05-17 13:31:19 -05:00
tresabhi f697f94bd5 equipment selection 2026-05-17 13:29:03 -05:00
tresabhi 30fa18f0fd start of equipment ui 2026-05-17 13:19:28 -05:00
tresabhi c45af8e156 equipment data 2026-05-17 12:56:23 -05:00
tresabhi f95a6e3495 handle nested messages correctly 2026-05-17 12:16:24 -05:00
tresabhi 0e637d772e fix hac 2026-05-15 22:53:43 -05:00
tresabhi a834d48a38 update imports 2026-05-15 22:52:25 -05:00
tresabhi f550386fd9 simplify imports 2026-05-15 22:46:36 -05:00
tresabhi 0a4b81cde4 fix paths 2026-05-15 22:30:52 -05:00
tresabhi e1cc60ea5e some work on equipment 2026-05-15 21:58:30 -05:00
tresabhi a40860901b stock/upgrade buttons 2026-05-15 21:41:40 -05:00
tresabhi f3e96718eb exclusive alternate guns 2026-05-15 21:18:01 -05:00
tresabhi a00411919d patch da code 2026-05-15 18:43:08 -05:00
tresabhi b298874f0d reorganize some code 2026-05-15 18:31:17 -05:00
tresabhi 7011d4d20f bigger icons 2026-05-15 18:25:25 -05:00
tresabhi d9ce16b41a currency icons 2026-05-15 18:16:06 -05:00
tresabhi f0e0d849d5 module price rendering 2026-05-15 10:54:36 -05:00
tresabhi 6d2573ee95 softer buttons 2026-05-11 17:18:44 -05:00
tresabhi 4c77cc2282 feat: dynamic color scales 2026-05-11 17:08:44 -05:00
tresabhi 32f6916266 combined alternative modules 2026-05-11 17:00:30 -05:00
tresabhi 3a91e082d5 feat: compact icons 2026-05-11 16:49:14 -05:00
tresabhi 2184c1c703 feat: pitch limits and modules 2026-05-11 16:17:15 -05:00
tresabhi 95ad6bade7 feat: update shit 2026-05-07 22:22:29 -05:00
tresabhi 8bda4a5331 fix: keys 2026-05-05 18:11:49 -05:00
tresabhi 16c77f587a fix: separate server and client for cu mode 2026-05-05 17:43:12 -05:00
tresabhi a218c791a9 fix: tank card min width on the home page 2026-05-05 17:30:00 -05:00
tresabhi dccdf55bfc feat: simpler warning 2026-05-05 17:25:32 -05:00
tresabhi 1ba6e70c56 feat: warning 2026-05-05 17:23:22 -05:00
tresabhi 0e76184813 feat: cu flags 2026-05-05 17:11:57 -05:00
tresabhi c674da29d5 feat: cu tank icons 2026-05-05 17:08:43 -05:00
tresabhi ba4e6dfd68 feat: some implementation for client unmounted mode 2026-05-05 16:48:16 -05:00
TrèsAbhi 980fd6e50e what 2026-04-30 22:39:38 -05:00
TrèsAbhiandCopilot 44e7cd7505 feat: module icons
Co-authored-by: Copilot <[email protected]>
2026-04-29 16:27:13 -05:00
TrèsAbhiandCopilot 3a60535564 fix: null rendering
Co-authored-by: Copilot <[email protected]>
2026-04-29 15:22:12 -05:00
TrèsAbhi 9abfeb749c feat: subtler shadows in light mode 2026-04-29 15:20:19 -05:00
TrèsAbhiandCopilot 0442c789f1 feat: class, type and nation
Co-authored-by: Copilot <[email protected]>
2026-04-29 15:17:59 -05:00
TrèsAbhiandCopilot 76dc9f0742 feat: node rendering
Co-authored-by: Copilot <[email protected]>
2026-04-29 10:46:37 -05:00
TrèsAbhi ff56f1441e feat: tier rendering 2026-04-29 10:31:36 -05:00
TrèsAbhiandCopilot e3db886923 feat: game strings in characteristics
Co-authored-by: Copilot <[email protected]>
2026-04-29 10:17:53 -05:00
TrèsAbhiandCopilot 249f62759e feat: characteristics
Co-authored-by: Copilot <[email protected]>
2026-04-28 22:31:28 -05:00
TrèsAbhi 7767481633 armor groups 2026-04-28 22:19:38 -05:00
TrèsAbhi a97b1383d8 fix: patch stage 0 2026-04-28 22:15:18 -05:00
TrèsAbhi 1b224b3949 fix: upgrade requirements 2026-04-28 22:11:57 -05:00
TrèsAbhiandCopilot 018aa33a7f feat: some aggregation
Co-authored-by: Copilot <[email protected]>
2026-04-28 21:22:50 -05:00
TrèsAbhiandCopilot 4b7b8b3392 feat: module selection
Co-authored-by: Copilot <[email protected]>
2026-04-28 16:56:38 -05:00
TrèsAbhi 272824a72f fix: tool banner padding 2026-04-28 16:12:06 -05:00
TrèsAbhi ac02d65db0 fix: name width 2026-04-28 15:52:46 -05:00
TrèsAbhi 9bcaff9fd3 fix: light mode skeleton style 2026-04-28 15:50:32 -05:00
TrèsAbhiandCopilot 7b650bf39b feat: incremental loader
Co-authored-by: Copilot <[email protected]>
2026-04-28 15:31:21 -05:00
TrèsAbhi ee1ecb83b7 feat: class and type sorting 2026-04-28 15:11:20 -05:00
TrèsAbhi d6e37119a8 feat: search 2026-04-27 19:57:44 -05:00
TrèsAbhi 385cc3b6d2 feat: tier sorting 2026-04-27 19:34:06 -05:00
TrèsAbhi b4936bf13b feat: smaller tank icons 2026-04-27 14:43:49 -05:00
TrèsAbhiandCopilot e1e9403c60 fix: home pag credit
Co-authored-by: Copilot <[email protected]>
2026-04-27 14:41:00 -05:00
TrèsAbhiandCopilot 0828d50830 feat: less spamming api
Co-authored-by: Copilot <[email protected]>
2026-04-27 14:39:47 -05:00
TrèsAbhiandCopilot 0817ba18bd feat: homescreen light mode
Co-authored-by: Copilot <[email protected]>
2026-04-26 20:06:59 -05:00
TrèsAbhi 121a80d079 feat: light theme 2026-04-26 19:59:56 -05:00
TrèsAbhiandCopilot 932c414b0c fix: implement tank list on client
Co-authored-by: Copilot <[email protected]>
2026-04-26 19:44:27 -05:00
TrèsAbhiandCopilot 3a38441d42 feat: theme switching
Co-authored-by: Copilot <[email protected]>
2026-04-26 19:43:10 -05:00
TrèsAbhi 3e039d34c2 feat: grid sizing 2026-04-26 19:31:16 -05:00
TrèsAbhiandCopilot 2bae132d18 feat: tankopedia list
Co-authored-by: Copilot <[email protected]>
2026-04-26 18:19:09 -05:00
TrèsAbhi bc0004574a feat: nation flags 2026-04-26 18:02:45 -05:00
TrèsAbhi 2e208507c3 feat: simplify scrollbar 2026-04-20 21:01:54 -05:00
TrèsAbhi 33209a6d31 fix: tank names 2026-04-20 20:55:24 -05:00
TrèsAbhi 13311245a5 feat: dlcs 2026-04-20 20:51:57 -05:00
TrèsAbhi 4067357b54 fix: home-page popular tank overflow 2026-04-20 20:44:45 -05:00
TrèsAbhi fd6b69edc1 fix: missing tank icon handling 2026-04-18 00:30:21 -05:00
TrèsAbhi 2504833a29 fix: optimize async cache 2026-04-17 23:52:33 -05:00
TrèsAbhi 6d31dcb74b fix: styles of home page 2026-04-17 23:35:56 -05:00
TrèsAbhi a149af50fd fix: some more imports 2026-04-14 20:34:28 -05:00
TrèsAbhi 13927927b3 feat: update remote storage and parsing 2026-04-10 14:56:30 -05:00
TrèsAbhi 68edf520c5 fix: update imports 2026-04-09 22:07:26 -05:00
TrèsAbhi f7933a1295 fix: update builders 2026-04-09 22:05:21 -05:00
TrèsAbhi 9a1c09490f feat: import google 2026-04-09 21:59:58 -05:00
TrèsAbhi c8b5c216e3 feat: format 2026-04-09 21:53:05 -05:00
TrèsAbhi 344954513a feat: reorganize protos 2026-04-09 21:47:12 -05:00
TrèsAbhi f8029bdf67 feat: update protos 2026-04-09 21:25:02 -05:00
TrèsAbhi d9c7d31017 Merge branch 'main' into ue 2026-04-09 21:21:20 -05:00
TrèsAbhi 79dba0fe2c feat: update package 2026-04-04 22:48:41 -05:00
TrèsAbhi c0376182f8 Merge branch 'main' into ue 2026-04-04 20:25:51 -05:00
TrèsAbhi 8e3b5de249 feat: use decorators 2026-03-23 18:06:26 -05:00
TrèsAbhi 44711a58c7 feat: discrimination 2026-03-20 20:40:45 -05:00
TrèsAbhi e7337145d2 feat: some caching 2026-03-20 20:38:04 -05:00
TrèsAbhi c116b40dfc feat: some more docs 2026-03-20 19:58:56 -05:00
TrèsAbhi d88f7ad539 fix: arrays 2026-03-20 19:51:27 -05:00
TrèsAbhi b5d61cebb3 fix: omit non-source files 2026-03-20 19:33:43 -05:00
TrèsAbhi 551108e047 feat: some more docs 2026-03-20 19:13:37 -05:00
TrèsAbhi 5feec9bf1e fix: types 2026-03-20 14:57:00 -05:00
TrèsAbhi 313691d3e9 feat: better gutter and headings 2026-03-20 14:52:06 -05:00
TrèsAbhi 950c99d3d7 feat: clean 2026-03-20 14:48:38 -05:00
TrèsAbhi 80cc74a945 feat: typescript types 2026-03-20 14:43:12 -05:00
TrèsAbhi 4e586253c0 feat: smaller heaings 2026-03-19 18:48:50 -05:00
TrèsAbhi 08980158e4 fix: tool banner alignment 2026-03-19 18:47:58 -05:00
TrèsAbhi 1e917c7b89 feat: big 2026-03-19 18:45:30 -05:00
TrèsAbhi 8d3678ddcb feat: macro names 2026-03-19 18:44:00 -05:00
TrèsAbhi 6a58c5ea1d feat: form fields 2026-03-19 18:31:00 -05:00
TrèsAbhi b9bbf22f2c feat: more decoration 2026-03-19 18:26:08 -05:00
TrèsAbhi c99ed0cb53 feat: types 2026-03-19 18:17:36 -05:00
TrèsAbhi cef18d4f64 feat: see macro 2026-03-19 17:17:23 -05:00
TrèsAbhi 824169fc25 feat: zooweemama 2026-03-19 13:26:34 -05:00
TrèsAbhi 841b5708ae feat: in-heading testing 2026-03-18 20:43:41 -05:00
TrèsAbhi 8ad110f73b test 2026-03-18 18:30:06 -05:00
TrèsAbhi 0869ea09c8 fix: gap 2026-03-18 18:03:47 -05:00
TrèsAbhi 0e57943d33 feat: cleaner api rendering 2026-03-15 23:29:33 -05:00
TrèsAbhi 8b01b7c192 feat: cleaner api rendering 2026-03-15 23:23:29 -05:00
TrèsAbhi a7bd671309 feat: tank card links 2026-03-15 22:42:13 -05:00
TrèsAbhi 406ad52c8d feat: flesh out popular tanks 2026-03-15 22:37:46 -05:00
tresabhi b6c4c4e852 Merge branch 'main' into ue 2026-03-11 16:53:56 -05:00
tresabhi f193d5c503 feat: port to linux 2026-03-11 16:43:58 -05:00
tresabhi 6a06cb17a3 Merge branch 'main' into ue 2026-03-06 14:11:24 -06:00
TrèsAbhi bf218b0975 Merge branch 'main' into ue 2026-02-21 22:36:19 -06:00
TrèsAbhi 5412c479c8 Merge branch 'main' into ue 2026-02-18 19:19:21 -06:00
TrèsAbhi ccb86ae824 feat: start migration to proto for popular tanks 2026-02-11 15:13:23 -06:00
TrèsAbhi 001709c25c Merge branch 'main' into ue 2026-02-10 08:58:21 -06:00
TrèsAbhi e50b8d11bf feat: comment for future 2026-02-03 15:20:10 -06:00
TrèsAbhi ea782af079 feat: popular tanks 2026-02-03 09:04:36 -06:00
TrèsAbhi 0ebf9f6c3d feat: tank icon api 2026-02-02 22:00:46 -06:00
TrèsAbhi 6fec467ec8 feat: blitzkit cue4parse branch 2026-02-02 21:53:48 -06:00
TrèsAbhi 8b0f1cdfed update skiasharp version to latest 2026-02-02 21:42:19 -06:00
TrèsAbhi f6008bd288 feat: image 2026-02-02 20:09:46 -06:00
TrèsAbhi 1776c8fc15 feat: capture some error 2026-02-02 19:57:15 -06:00
TrèsAbhi 99f41b9805 feat: exporting 2026-02-02 19:04:54 -06:00
TrèsAbhi d4f11fc52a make mount sync 2026-02-02 18:17:25 -06:00
TrèsAbhi 750aeb39c9 reinit cue4parse 2026-02-02 18:14:02 -06:00
TrèsAbhi 0f355bc2fc make mount truely syncronous 2026-02-02 18:07:20 -06:00
TrèsAbhi 6ebd3063ed fix: remove oodle sync 2026-02-02 17:37:36 -06:00
TrèsAbhi b070cad92a SynchronousOodleHelper 2026-02-02 17:35:02 -06:00
TrèsAbhi 204fbbff59 feat: some cleanup 2026-02-02 17:28:24 -06:00
TrèsAbhi 98e7760142 feat: some syncronouse stuff 2026-02-02 17:24:20 -06:00
TrèsAbhi 6c1c088d44 feat: custom provider 2026-02-02 17:08:37 -06:00
TrèsAbhi 0b7f717057 feat: cue4parse submodule 2026-02-01 21:34:03 -06:00
TrèsAbhi 728cb43f05 feat: reading some files 2026-02-01 21:05:38 -06:00
TrèsAbhi ff02f06cbc feat: tanksDirectoryNations 2026-02-01 19:34:36 -06:00
TrèsAbhi 7a817cbe8c feat: smooth curtains 2026-02-01 12:42:52 -06:00
TrèsAbhi 58e0876aa0 feat: curtains 2026-02-01 12:41:29 -06:00
TrèsAbhi f84f846e76 feat: scroll functionality 2026-02-01 12:31:51 -06:00
TrèsAbhi ddb869e767 feat: start of scroller 2026-02-01 12:24:55 -06:00
TrèsAbhi 015d457d23 feat: proper api 2026-02-01 00:14:58 -06:00
TrèsAbhi ba6a83a9cb fix: docs 2026-02-01 00:09:06 -06:00
TrèsAbhi 689985ed93 feat: delete workflows 2026-02-01 00:06:39 -06:00
TrèsAbhi bf9262f1f1 feat: some work on footer 2026-01-31 23:21:43 -06:00
TrèsAbhi 821bd363ad feat: more groups 2026-01-31 23:06:46 -06:00
TrèsAbhi 4a9fc65e71 feat: tanks section 2026-01-31 22:54:00 -06:00
TrèsAbhi 81877f5abd feat: tank cards 2026-01-31 22:32:59 -06:00
TrèsAbhi 01eed3e045 fix: remove unused class test 2026-01-31 21:46:16 -06:00
TrèsAbhi 7bc423b570 fix: stack overflow on closing wss 2026-01-31 21:46:05 -06:00
TrèsAbhi dba56f8946 feat: blitz provider 2026-01-27 08:59:34 -06:00
TrèsAbhi c1f765d5f7 feat: setup interop 2026-01-25 21:27:24 -06:00
TrèsAbhi 7f5b5b614f feat: setup game interface 2026-01-25 19:32:30 -06:00
TrèsAbhi 5cd149b2b9 feat: higher constrast low contrast 2026-01-25 19:21:07 -06:00
TrèsAbhi 0bc2d0aaf6 feat: home page tools 2026-01-25 16:13:36 -06:00
TrèsAbhi 1dc9a341d1 fix: inconsistentcies 2026-01-25 15:57:44 -06:00
TrèsAbhi 2ad1e71176 feat: revert navbar 2026-01-25 15:55:30 -06:00
TrèsAbhi ca6973f529 feat: update protos to pre-ut4 2026-01-23 10:25:45 -06:00
TrèsAbhi 323d624ec7 feat: idk bro 2026-01-18 18:38:26 -06:00
TrèsAbhi 49f84db473 feat: recently viewed 2026-01-18 17:55:38 -06:00
TrèsAbhi 7399c402af feat: ahhhhhhhhhh 2026-01-18 15:14:26 -06:00
TrèsAbhi cbfcb29691 feat: new navbar 2026-01-18 14:51:55 -06:00
TrèsAbhi ae616fbaf3 feat: cleaner popup 2026-01-18 14:12:20 -06:00
TrèsAbhi 35275d4aa8 fix: use animation 2026-01-17 13:54:19 -06:00
TrèsAbhi dda63830ec feat: glow 2026-01-17 13:50:53 -06:00
TrèsAbhi 79b4f05809 feat: start of popup 2026-01-17 13:36:17 -06:00
TrèsAbhi b50c47b744 feat: skeleton 2026-01-16 20:37:11 -06:00
TrèsAbhi f7c6fdd861 fix: onChange 2026-01-16 20:14:47 -06:00
TrèsAbhi 47b583821e fix: hydration 2026-01-16 20:13:56 -06:00
TrèsAbhi 369ac113fa feat: custom loader 2026-01-16 19:28:10 -06:00
TrèsAbhi fc4d8b5ad0 fix: some cleanup 2026-01-16 13:17:17 -06:00
TrèsAbhi 72e1cf7d6a feat: postcss 2026-01-16 13:11:23 -06:00
TrèsAbhi dd96556e2a Change Section import to named import 2026-01-15 14:43:34 -06:00
TrèsAbhi a7ee51526a fix: reset count on change 2026-01-15 14:36:52 -06:00
TrèsAbhi 610b59f4c6 feat: search functionality 2026-01-15 14:30:40 -06:00
TrèsAbhi 63ce327458 feat: avatars search bar ui 2026-01-15 14:13:34 -06:00
TrèsAbhi 6ec7a50320 feat: lint fixes 2026-01-15 13:58:34 -06:00
TrèsAbhi 60a29ef2e7 feat: fancier navbar 2026-01-15 13:48:37 -06:00
TrèsAbhi 52c3a0abe0 feat: number indexed 2026-01-15 12:58:05 -06:00
TrèsAbhi 5bbe5c2720 feat: count badge 2026-01-15 12:55:18 -06:00
TrèsAbhi c0c6a21e43 feat: groups 2026-01-14 13:58:53 -06:00
TrèsAbhi a7f93698a6 feat: tool banners 2026-01-14 13:30:12 -06:00
TrèsAbhi 1a18058228 feat: split hooks 2026-01-14 12:41:51 -06:00
TrèsAbhi 67024daa25 fix: some cleanup 2026-01-14 12:37:42 -06:00
TrèsAbhi de6137d1a5 feat: incremtal loading 2026-01-14 12:34:28 -06:00
TrèsAbhi 11e87bd519 feat: sort 2026-01-14 11:56:44 -06:00
TrèsAbhi 3b4e089756 fix: store trimmed only in cache 2026-01-14 11:48:55 -06:00
TrèsAbhi 4a6f5845f9 feat: shadows for outlines 2026-01-13 20:49:40 -06:00
TrèsAbhi e9684ce033 feat: navbar rewrite 2026-01-13 20:33:14 -06:00
TrèsAbhi 27abf1b0de feat: string hooks 2026-01-13 20:15:55 -06:00
TrèsAbhi f5f3f70e35 feat: client side game strings 2026-01-13 13:30:09 -06:00
TrèsAbhi 2d282a3261 feat: game strings api 2026-01-13 13:18:55 -06:00
TrèsAbhi a2d0d2d8c0 feat: card styles 2026-01-13 12:46:34 -06:00
TrèsAbhi ea412086d4 feat: start of avatars 2026-01-12 14:13:21 -06:00
TrèsAbhi 455679a981 feat: some footer 2026-01-12 14:00:13 -06:00
TrèsAbhi a21bc6174d feat: some navbar 2026-01-12 13:46:58 -06:00
TrèsAbhi fae1a89402 feat: some more apis 2026-01-12 13:20:08 -06:00
TrèsAbhi 5a1a0c59f1 feat: profile and backgroud apis 2026-01-12 13:12:11 -06:00
TrèsAbhi c98271f783 feat: darker input 2026-01-12 12:48:47 -06:00
TrèsAbhi 033660c2d9 fix: webp 2026-01-11 13:48:27 -06:00
TrèsAbhi 1a639c51e1 feat: update backgrounds 2026-01-11 13:46:54 -06:00
TrèsAbhi 1366579dd1 feat: some unification 2026-01-11 13:40:01 -06:00
TrèsAbhi f5ed2c2c40 feat: concurrency 2026-01-11 11:52:28 -06:00
TrèsAbhi 1598de9f5f feat: imageProxy 2026-01-11 11:51:02 -06:00
TrèsAbhi 6f47f5cc99 feat: bumps 2026-01-10 13:49:11 -06:00
TrèsAbhi 1117dcb935 feat: links 2026-01-10 13:19:59 -06:00
TrèsAbhi 01a58f9aa8 feat: finalize tool cards 2026-01-10 13:07:59 -06:00
TrèsAbhi 9096644d51 feat: cards 2026-01-10 12:43:39 -06:00
TrèsAbhi d42fc0e3f6 Merge branch 'main' into ue 2026-01-09 14:06:57 -06:00
TrèsAbhi 17d1526b6f Merge branch 'main' into ue 2026-01-09 14:05:43 -06:00
TrèsAbhi db8ac195d4 feat: start of cards 2026-01-09 14:04:59 -06:00
TrèsAbhi c5f5f69fd6 feat: more home page 2026-01-09 13:57:28 -06:00
TrèsAbhi 44203223fd feat: server side strings 2026-01-09 13:38:00 -06:00
TrèsAbhi bdabadcd49 feat: some more styles 2026-01-09 13:02:58 -06:00
TrèsAbhi 220e4388d4 feat: start on header 2026-01-08 20:39:44 -06:00
TrèsAbhi 51b2d1ce86 feat: cleaner output 2026-01-08 20:17:11 -06:00
TrèsAbhi f3c1825c57 fix: complete protos implementation 2026-01-08 20:16:01 -06:00
TrèsAbhi 359dfcd102 feat: move some 2026-01-08 20:06:14 -06:00
TrèsAbhi 649cdd893f feat: styles 2026-01-08 20:02:24 -06:00
TrèsAbhi 0cb974acf5 feat: scales 2026-01-07 20:36:55 -06:00
TrèsAbhi 9bca5dccc7 feat: start of new protos 2026-01-04 15:35:58 -06:00
TrèsAbhi d707f1cc5d Merge branch 'main' into ue 2026-01-04 15:23:43 -06:00
TrèsAbhi 4ed4395535 feat: start of strings 2026-01-03 13:29:15 -06:00
TrèsAbhi 0209d48475 feat: backgrounds 2026-01-02 20:17:24 -06:00
TrèsAbhi 437bee4a85 feat: proxy avatars 2026-01-02 20:04:19 -06:00
TrèsAbhi 4b8e73fa57 feat: cache 2026-01-02 14:26:05 -06:00
TrèsAbhi b96f925d9a feat: avatars 2026-01-02 14:20:29 -06:00
TrèsAbhi 1551dc50fd Merge branch 'main' into ue 2026-01-02 12:51:20 -06:00
TrèsAbhi daf4cef61c feat: mode apis 2025-12-28 12:20:45 -06:00
TrèsAbhi 496e127f64 Merge branch 'main' into ue 2025-12-28 12:11:40 -06:00
TrèsAbhi da1af66c77 feat: start of mechanics 2025-12-26 20:07:22 -06:00
TrèsAbhi 01409985a2 feat: list 2025-12-26 19:43:44 -06:00
TrèsAbhi a86ac0619a feat: stop working on ranking since a lot of tanks are broken 2025-12-26 19:37:52 -06:00
TrèsAbhi 385d165512 feat: optimize tankList 2025-12-26 19:21:14 -06:00
TrèsAbhi fcfc97b964 feat: start of all tanks 2025-12-26 14:59:09 -06:00
TrèsAbhi 53d05e73a6 feat: some docs 2025-12-26 14:21:04 -06:00
TrèsAbhi 40a1eb861d feat: meta section 2025-12-26 14:14:29 -06:00
TrèsAbhi 1d3cb95b89 fix: assert wotb server 2025-12-26 11:33:04 -06:00
TrèsAbhi 010303a818 feat: camo 2025-12-24 20:30:19 -06:00
TrèsAbhi 7281b9fed0 feat: power_to_weight 2025-12-24 20:10:06 -06:00
TrèsAbhi c5785c7ae6 feat: more controllers 2025-12-24 19:57:26 -06:00
TrèsAbhi 698731cb9a feat: generalize render chars 2025-12-24 19:48:01 -06:00
TrèsAbhi 99bae8f2c5 fix: array rendering 2025-12-24 19:39:26 -06:00
TrèsAbhi 5034ec1aa0 feat: terrain hardness 2025-12-24 19:37:21 -06:00
TrèsAbhi be821c1f8b Merge branch 'ue' of https://github.com/blitzkit/blitzkit into ue 2025-12-24 19:33:04 -06:00
TrèsAbhi a364b79a9b feat: autoreloader dom 2025-12-24 19:33:01 -06:00
TrèsAbhi 0aa6e1e79a Update deployment condition for GitHub Pages 2025-12-20 20:03:27 -06:00
TrèsAbhi d2a6379131 feat: abstracted meta 2025-12-20 19:10:11 -06:00
TrèsAbhi 07ce61d19f fix: depression 2025-12-20 19:05:42 -06:00
TrèsAbhi ad9f2d8609 feat: start of pitch yaw limits 2025-12-20 19:03:57 -06:00
TrèsAbhi 57702d8a43 fix: repo 2025-12-20 18:10:24 -06:00
TrèsAbhi 9a30af3818 fix: proper pitch limits 2025-12-20 10:26:25 -06:00
TrèsAbhi 23751fbdf3 feat: dynamic dispersion 2025-12-20 10:15:23 -06:00
TrèsAbhi db6d181f78 feat: use max stage 2025-12-20 09:59:26 -06:00
TrèsAbhi 56ca4dcc61 feat: more accents 2025-12-19 22:23:03 -06:00
TrèsAbhi d08b537721 feat: some styling 2025-12-19 21:25:55 -06:00
TrèsAbhi c1a2edb489 feat: baselayout 2025-12-19 17:55:26 -06:00
TrèsAbhi 164b7fa454 feat: api renderer 2025-12-19 17:50:59 -06:00
TrèsAbhi 428483cf4c fix: disable minifySyntax 2025-12-19 16:14:36 -06:00
TrèsAbhi 89f02eb399 minifySyntax 2025-12-19 16:11:26 -06:00
TrèsAbhi 4ce127516c minifyWhitespace 2025-12-19 16:09:27 -06:00
TrèsAbhi 6f4f487dfe minifyIdentifiers 2025-12-19 16:07:34 -06:00
TrèsAbhi 57dd60e804 feat: no minify 2025-12-19 16:01:58 -06:00
TrèsAbhi d24fd2250c feat: deploy on ue 2025-12-19 15:53:30 -06:00
TrèsAbhi e00bcdfc06 feat: latest node 2025-12-19 15:51:47 -06:00
TrèsAbhi 67dd204ee6 fix: typo 2025-12-19 15:48:07 -06:00
TrèsAbhi e130583fa4 fix: build:catalogitemaccesssor 2025-12-19 15:46:40 -06:00
TrèsAbhi 6ad20a0fcf feat: cron 2025-12-19 15:44:32 -06:00
TrèsAbhi 9fad644d7a feat: workflow 2025-12-19 15:42:38 -06:00
TrèsAbhi 8cb523dd06 fix: remove pwa 2025-12-19 15:35:50 -06:00
TrèsAbhi b50ee23a26 feat: re-install 2025-12-19 15:34:16 -06:00
TrèsAbhi a973cf699b fix: cleaner stage0 hack 2025-12-19 15:26:47 -06:00
TrèsAbhi 102abf49c4 fix: default isPump 2025-12-19 15:20:58 -06:00
TrèsAbhi 8929e503ea fix: pump times 2025-12-19 15:17:45 -06:00
TrèsAbhi a2a794eb74 fix: use order instead of group 2025-12-19 15:11:33 -06:00
TrèsAbhi 2e95f5dd95 fix: unit conversion 2025-12-19 15:09:46 -06:00
TrèsAbhi 23a42230cb feat: units 2025-12-19 15:04:35 -06:00
TrèsAbhi e66daed7ae feat: dpm 2025-12-19 14:51:18 -06:00
TrèsAbhi f2d0f68f07 feat: primary_armor 2025-12-19 14:34:17 -06:00
TrèsAbhi be4af0bf84 feat: implement some 2025-12-18 21:29:10 -06:00
TrèsAbhi 15172e8b33 fix: fetch correct stages 2025-12-18 17:00:06 -06:00
TrèsAbhi c6b7918525 feat: shells 2025-12-18 16:45:02 -06:00
TrèsAbhi 4f1c4421e5 feat: some more 2025-12-18 16:29:13 -06:00
TrèsAbhi 7b830f5a71 feat: start of more attrs 2025-12-18 16:12:01 -06:00
TrèsAbhi 6ce1135835 feat: rename a bit 2025-12-18 15:30:18 -06:00
TrèsAbhi 66eaaef6de feat: implement shell attr 2025-12-18 15:07:04 -06:00
TrèsAbhi 48fbf59036 feat: override common data 2025-12-18 13:20:35 -06:00
TrèsAbhi 0e0f4fbe9f fix: find att0 correctly 2025-12-18 13:16:20 -06:00
TrèsAbhi 6ba6e10108 feat: rename 2025-12-18 13:08:42 -06:00
TrèsAbhi cf8fa4ffb4 feat: remaining stagers 2025-12-18 12:59:12 -06:00
TrèsAbhi 2b0180dce2 Merge branch 'main' into ue 2025-12-18 12:45:10 -06:00
TrèsAbhi caee848a47 Merge branch 'main' into ue 2025-12-15 22:29:11 -06:00
TrèsAbhi d01b7cf707 feat: shells 2025-12-14 22:00:45 -06:00
TrèsAbhi 8921f84c38 feat: verbose 2025-12-14 21:41:26 -06:00
TrèsAbhi 6830df01fa penetration_groups_upgrades 2025-12-14 21:38:18 -06:00
TrèsAbhi 67da59ef41 feat: start of stages 2025-12-13 23:35:38 -06:00
TrèsAbhi 036cc9915d feat: start of atts 2025-12-13 23:10:51 -06:00
TrèsAbhi 57b6ba19f0 feat: rename 2025-12-13 22:15:48 -06:00
TrèsAbhi 32b5593d07 feat: lenient strings 2025-12-13 13:16:42 -06:00
TrèsAbhi 3b757562d2 Merge branch 'main' into ue 2025-12-10 22:52:49 -06:00
TrèsAbhi 4c8c2d0868 feat: auto pick localization entity 2025-12-05 21:35:58 -06:00
TrèsAbhi b2f266401f Merge branch 'main' into ue 2025-12-05 21:23:39 -06:00
TrèsAbhi 5541eca23d feat: start of derived statistics 2025-12-01 16:51:11 -06:00
TrèsAbhi 122d65840e feat: aggregateAttributes 2025-12-01 13:09:55 -06:00
TrèsAbhi ed4b72d85c feat: upgrade stages 2025-11-30 22:26:13 -06:00
TrèsAbhi 4033e8618f feat: useAwait 2025-11-30 22:12:46 -06:00
TrèsAbhi 42cada1811 feat: move and api 2025-11-30 21:06:17 -06:00
TrèsAbhi ff7ed213c8 feat: complete 2025-11-30 21:01:53 -06:00
TrèsAbhi a0367af06b feat: start of stats 2025-11-30 20:54:36 -06:00
TrèsAbhi 58c2965c53 Merge branch 'main' into ue 2025-11-30 20:27:42 -06:00
TrèsAbhi edae41409a feat: tank data 2025-11-29 14:22:32 -06:00
TrèsAbhi 383d3a36ef feat: load tank data 2025-11-28 14:21:26 -06:00
TrèsAbhi 6a958da390 feat: implement strings 2025-11-28 14:13:03 -06:00
TrèsAbhi ef154f99d5 feat: start of scraping 2025-11-28 13:51:17 -06:00
TrèsAbhi 4a20102de3 feat: base work 2025-11-28 13:19:15 -06:00
TrèsAbhi 4e170f2917 feat: port minimal files 2025-11-28 13:03:33 -06:00
TrèsAbhi a70d788f6b feat: init ue website 2025-11-28 13:00:41 -06:00
781 changed files with 34398 additions and 8201 deletions

No files matched your search

-45
View File
@@ -1,45 +0,0 @@
name: Discover IDs
on:
push:
branches: production
schedule:
- cron: 0 */6 * * *
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
discover:
name: Discover IDs
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: production
submodules: recursive
token: ${{ secrets.GH_TOKEN }}
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Setup Git
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Discover IDs
run: bun run discover-ids
env:
PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }}
GH_TOKEN: ${{ secrets.IDS_TOKEN }}
+63 -121
View File
@@ -2,98 +2,45 @@ name: Website
on:
push:
schedule:
- cron: "0 4 * * *"
workflow_dispatch:
inputs:
branch:
description: Branch
type: choice
required: true
default: production
options:
- production
- main
environment:
description: Environment
type: choice
required: true
default: production
options:
- development
- beta
- production
- preview
- opentest
client_provider:
description: Client provider
type: choice
required: true
default: steam
options:
- steam
- zip
- wgpkg
client_source:
description: Client source
type: string
required: true
default: 444200/windows
# schedule:
# - cron: "0 4 * * *"
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: ${{ github.workflow }}-${{ inputs.environment || github.ref }}
cancel-in-progress: true
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
env:
# client
STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }}
STEAM_PASSWORD: ${{ secrets.STEAM_PASSWORD }}
CLIENT_PROVIDER: ${{ inputs.client_provider || 'steam' }}
CLIENT_SOURCE: ${{ inputs.client_source || '444200/windows' }}
CLIENT_DIR: ../../temp/game
# blitzkit
PUBLIC_ENVIRONMENT: ${{ inputs.environment || 'production' }}
PUBLIC_PROMOTE_OPENTEST: false
PUBLIC_STATIC_ASSET_BASE: https://assets.blitzkit.app
PUBLIC_GUIDE_WEBSITE: https://guidesblitz.com
# blitz
WOTB_GLOSSARY: ${{ secrets.WOTB_GLOSSARY }}
WOTB_GLOSSARY_DOMAIN: ${{ secrets.WOTB_GLOSSARY_DOMAIN }}
WOTB_GLOSSARY_PATH: ${{ secrets.WOTB_GLOSSARY_PATH }}
WOTB_SERVER: ${{ secrets.WOTB_SERVER }}
WOTB_VERSION: ${{ secrets.WOTB_VERSION }}
PUBLIC_WOTB_CLIENT: ../../temp/game
WOTB_USMAP: ../closed/blitz.usmap
# google
PUBLIC_GOOGLE_TAG_MANAGER_ID: ${{ secrets.PUBLIC_GOOGLE_TAG_MANAGER_ID }}
GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }}
PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID: ${{ secrets.PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID }}
# wargaming
PUBLIC_WARGAMING_APPLICATION_ID: ${{ secrets.PUBLIC_WARGAMING_APPLICATION_ID }}
# steam
STEAM_USERNAME: ${{ secrets.STEAM_USERNAME }}
STEAM_PASSWORD: ${{ secrets.STEAM_PASSWORD }}
STEAM_APP: 3341250
STEAM_OS: windows
STEAM_FINGERPRINT: Manifest_UFSFiles_Win64.txt
jobs:
build:
name: Build website
name: Build chunk ${{ matrix.chunk }}
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
chunk: [default, api]
chunk: [pages, api, media]
steps:
- name: Free disk space
@@ -102,68 +49,63 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.branch || 'production' }}
submodules: recursive
token: ${{ secrets.GH_TOKEN }}
- name: Setup bun
- name: Set up Bun
uses: oven-sh/setup-bun@v2
- name: Setup protoc
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: latest
- name: Set up Dotnet
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
- name: Set up Protoc
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GH_TOKEN }}
- name: Setup CLIs
- name: Set up project
run: |
sudo apt-get update
sudo apt-get install -y aria2 libarchive-tools
bun run setup
./scripts/setup_depot_downloader.sh
- name: Install dependencies
run: bun install
- name: Download fingerprint
run: |
./scripts/download_fingerprint.sh
- name: Download Client
run: bun run download-client
- name: Build
run: bun run --bun build
env:
CHUNK: ${{ matrix.chunk }}
- name: Upload artifact
id: upload
uses: actions/upload-artifact@v4
- name: Download cached game
uses: actions/cache@v5
id: game-cache
with:
name: chunk-${{ matrix.chunk }}
path: dist/website
- name: Trigger deployment
if: ${{ github.ref == 'refs/heads/production' }}
env:
TOKEN: ${{ secrets.GH_NODE_DEPLOY_TOKEN }}
CHUNK: ${{ matrix.chunk }}
path: temp/game
key: ${{ runner.os }}-${{ hashFiles(format('temp/game/{0}', env.FINGERPRINT)) }}
- name: Download Steam game
if: steps.game-cache.outputs.cache-hit != 'true'
run: |
ENV_FILE="environments/${PUBLIC_ENVIRONMENT}.json"
TARGET_REPO=$(jq -r ".nodes.\"$CHUNK\"" "$ENV_FILE")
./scripts/download_game.sh
if [ "$TARGET_REPO" == "null" ] || [ -z "$TARGET_REPO" ]; then
exit 1
fi
# - name: Build
# run: bun run build-website
# env:
# BUILD_CHUNK: ${{ matrix.chunk }}
curl \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $TOKEN" \
"https://api.github.com/repos/$TARGET_REPO/dispatches" \
-d "{
\"event_type\": \"deploy-pages\",
\"client_payload\": {
\"source_repo\": \"${{ github.repository }}\",
\"run_id\": \"${{ github.run_id }}\",
\"artifact_id\": \"${{ steps.upload.outputs.artifact-id }}\",
\"chunk\": \"$CHUNK\"
}
}"
# - name: Upload artifact
# id: upload
# uses: actions/upload-pages-artifact@v4
# with:
# path: dist/website
# - name: Trigger target repository
# run: |
# curl -X POST \
# -H "Authorization: token ${{ secrets.GH_TOKEN }}" \
# -H "Accept: application/vnd.github.v3+json" \
# https://github.com \
# -d '{ "event_type": "deploy-pages", "client_payload": { "artifact_url": "${{ steps.upload.outputs.artifact_url }}" } }'
+4 -2
View File
@@ -1,7 +1,7 @@
node_modules
_node_modules
package-lock.json
.env
*.env
/test-results/
/playwright-report/
/playwright/.cache/
@@ -22,11 +22,12 @@ depots
public/sitemap*
/firebase.json
target
# packages/core/src/protos/*.ts
packages/website-ue/src/protos/*.ts
/.vercel
.astro
.vercel
/src
/packages/bot/src/prisma
dev-dist
# Logs
logs
@@ -52,3 +53,4 @@ dist-ssr
*.njsproj
*.sln
*.sw?
*.lscache
+3
View File
@@ -1,3 +1,6 @@
[submodule "packages/closed"]
path = packages/closed
url = https://github.com/blitzkit/closed.git
[submodule "packages/cue4parse"]
path = packages/cue4parse
url = https://github.com/tresabhi/CUE4Parse.git
+15 -3
View File
@@ -1,4 +1,6 @@
{
// "js/ts.experimental.useTsgo": true,
"protobuf.includes": ["${workspaceFolder}/path/to/protos"],
"cSpell.words": [
"AABBOX",
"Abhi",
@@ -41,6 +43,7 @@
"Callouts",
"Camo",
"Carro",
"catalogitemaccessor",
"CCPAOPTOUT",
"CDON",
"clanid",
@@ -62,15 +65,16 @@
"Ddpf",
"Ddsd",
"deadrail",
"DEBUFF",
"debugflags",
"deburr",
"deburred",
"decalmask",
"decompressor",
"décor",
"dedup",
"defence",
"degressive",
"depotdownloader",
"devkrieg",
"diacriticless",
"DIDS",
@@ -87,6 +91,7 @@
"Everytime",
"FASTNAME",
"favicons",
"filelist",
"flexdirection",
"fontsource",
"FOURCC",
@@ -147,6 +152,7 @@
"noout",
"normalmap",
"oneof",
"Oodle",
"oopsies",
"Oopsy",
"opentest",
@@ -162,6 +168,7 @@
"playerachievements",
"playerinfo",
"playform",
"Playtest",
"pngjs",
"POSITIVEX",
"POSITIVEY",
@@ -178,6 +185,7 @@
"ratingleaderboard",
"raycaster",
"Redirector",
"REGEN",
"Relogin",
"researchable",
"Resvg",
@@ -191,12 +199,14 @@
"searchplayers",
"searchtanks",
"SHAREDEXP",
"Skia",
"SKLLD",
"sluggify",
"smoothstep",
"Smri",
"Specularity",
"SSAO",
"stackable",
"startcase",
"statkit",
"statsfull",
@@ -220,6 +230,7 @@
"tungstentip",
"Turretless",
"TYPELESS",
"uasset",
"ubjson",
"Unicum",
"UNLOCALIZED",
@@ -229,6 +240,7 @@
"unrotate",
"unwrapper",
"userns",
"usmap",
"vercel",
"vickers",
"vitepress",
@@ -241,6 +253,7 @@
"winrate",
"wireframe",
"Woah",
"wooorm",
"WOTB",
"wotblitz",
"wotbreplay",
@@ -253,6 +266,5 @@
"youtuber",
"youtubers",
"zoomable"
],
"typescript.tsdk": "node_modules\\typescript\\lib"
]
}
+28 -22
View File
@@ -8,13 +8,15 @@ If you intend to contribute to the code, note that you may need to check out sen
## Packages
| Package | Title | Source | Memo |
| ------------------------------------ | -------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| [packages/core](packages/core) | | | Mutual internal code shared between all other packages. |
| [packages/i18n](packages/i18n) | | [Crowdin](https://crowdin.com/project/blitzkrieg) | Translations served as JSON shared across all other packages, including some utility code. |
| [packages/scripts](packages/scripts) | | | CLI utilities for the development and deployment of BlitzKit services. |
| [packages/varuna](packages/varuna) | Varuna | [NPM](https://www.npmjs.com/package/varuna) | React and Astro-oriented state management library, awaiting separation from BlitzKit. Unavailable from NPM at this time. |
| [packages/website](packages/website) | BlitzKit | https://blitzkit.app | The BlitzKit website provides a wide range of tools, APIs, and interconnects all other services. |
| Package | Title | Source | Memo |
| ------------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ |
| [packages/app](packages/app) | BlitzKit | [Play Store](https://play.google.com/store/apps/details?id=com.tresabhi.blitzkit), [App Store](https://apps.apple.com/app/4321), [Microsoft Store](https://apps.microsoft.com/detail/zyxw) | The native app of BlitzKit for all platforms. Unavailable from stores at this time. |
| [packages/bot](packages/bot) | BlitzKit Discord Bot | [Discord Application Directory](https://discord.com/discovery/applications/1097673957865443370) | The one-in-all Discord bot for statistics. |
| [packages/core](packages/core) | | | Mutual internal code shared between all other packages. |
| [packages/i18n](packages/i18n) | | [Crowdin](https://crowdin.com/project/blitzkrieg) | Translations served as JSON shared across all other packages, including some utility code. |
| [packages/scripts](packages/scripts) | | | CLI utilities for the development and deployment of BlitzKit services. |
| [packages/varuna](packages/varuna) | Varuna | [NPM](https://www.npmjs.com/package/varuna) | React and Astro-oriented state management library, awaiting separation from BlitzKit. Unavailable from NPM at this time. |
| [packages/website](packages/website) | BlitzKit | https://blitzkit.app | The BlitzKit website provides a wide range of tools, APIs, and interconnects all other services. |
## Submodules
@@ -24,18 +26,22 @@ If you intend to contribute to the code, note that you may need to check out sen
## Environment Variables
| Name | Source | BlitzKit Default | Memo |
| ------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `PUBLIC_WARGAMING_APPLICATION_ID` | [Wargaming Developer's Room](https://developers.wargaming.net/) | | Application ID needed to communicate with Wargaming APIs. |
| `PUBLIC_PROMOTE_OPENTEST` | | `false` | Optional boolean dictating whether the website advertises [the opentest subdomain of BlitzKit](https://opentest.blitzkit.app/). |
| `WOTB_GLOSSARY`, `WOTB_GLOSSARY_DOMAIN`, `WOTB_GLOSSARY_PATH` | | | |
| `PUBLIC_GOOGLE_TAG_MANAGER_ID` | [Google Tag Manager](https://tagmanager.google.com/) | `G-GL2JVHCGPQ` | Google Tag Manager's unique identifier for analytical purposes. |
| `PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID` | [Google Analytics](https://analytics.google.com/) | `430711767` | Google Analytics' unique property ID for [BlitzKit's apex domain](https://blitzkit.app/) and subdomains. |
| `GOOGLE_APPLICATION_CREDENTIALS` | [Google Cloud](https://cloud.google.com/) | | Google Cloud's API key, primarily used for ranking popular tanks and finding relevant tank review videos in Tankopedia. |
| `PUBLIC_DISCORD_SERVER_ID` | [BlitzKit Discord Server](https://discord.gg/nDt7AjGJQH) | `734786591205359697` | BlitzKit Discord Server's id. |
| `PUBLIC_ASSET_BASE` | [blitzkit/assets](https://github.com/blitzkit/assets) | `https://blitzkit.github.io/assets` | The root domain and path that serves the external assets like tank models and icons. |
| `PUBLIC_GUIDE_WEBSITE` | [GuidesBlitz](https://guidesblitz.com/) | `https://guidesblitz.com` | The website that serves the encyclopedia-like content in BlitzKit. |
| `SERVICING` | | `false` | Wether the website is down for servicing. |
| `GH_TOKEN` | [GitHub Fine-Grained Tokens](https://github.com/settings/personal-access-tokens) | | The GitHub API key used for checking out private submodules and pushing content to the asset repository. |
| `DATABASE_URL` | [CockroachDB](https://www.cockroachlabs.com/) | | The CockroachDB-SQL database URL used to link Wargaming and Discord users. |
| `WOTB_DLC_CDN` | | | |
| Name | Source | BlitzKit Default | Memo |
| ------------------------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `PUBLIC_WARGAMING_APPLICATION_ID` | [Wargaming Developer's Room](https://developers.wargaming.net/) | | Application ID needed to communicate with Wargaming APIs. |
| `PUBLIC_PROMOTE_OPENTEST` | | `false` | Optional boolean dictating whether the website advertises [the opentest subdomain of BlitzKit](https://opentest.blitzkit.app/). |
| `ALLOW_ROBOTS` | | `true` | Toggles between `Allow: /` and `Disallow: /` in the website's `/robots.txt`. This is usually `false` for all BlitzKit's subdomains. |
| `WOTB_GLOSSARY`, `WOTB_GLOSSARY_DOMAIN`, `WOTB_GLOSSARY_PATH` | | | |
| `PUBLIC_GOOGLE_TAG_MANAGER_ID` | [Google Tag Manager](https://tagmanager.google.com/) | `G-GL2JVHCGPQ` | Google Tag Manager's unique identifier for analytical purposes. |
| `PUBLIC_GOOGLE_ANALYTICS_PROPERTY_ID` | [Google Analytics](https://analytics.google.com/) | `430711767` | Google Analytics' unique property ID for [BlitzKit's apex domain](https://blitzkit.app/) and subdomains. |
| `GOOGLE_APPLICATION_CREDENTIALS` | [Google Cloud](https://cloud.google.com/) | | Google Cloud's API key, primarily used for ranking popular tanks and finding relevant tank review videos in Tankopedia. |
| `PUBLIC_DISCORD_SERVER_ID` | [BlitzKit Discord Server](https://discord.gg/nDt7AjGJQH) | `734786591205359697` | BlitzKit Discord Server's id. |
| `PUBLIC_ASSET_BASE` | [blitzkit/assets](https://github.com/blitzkit/assets) | `https://blitzkit.github.io/assets` | The root domain and path that serves the external assets like tank models and icons. |
| `PUBLIC_BRANCH` | | `main` | The branch that the website is operating on. This is completely nominal. |
| `PUBLIC_GUIDE_WEBSITE` | [GuidesBlitz](https://guidesblitz.com/) | `https://guidesblitz.com` | The website that serves the encyclopedia-like content in BlitzKit. |
| `SERVICING` | | `false` | Wether the website is down for servicing. |
| `DISCORD_CLIENT_ID` | [Discord Developer Portal](https://discord.com/developers/applications) | `1097673957865443370` | The BlitzKit Discord Bot's user ID. |
| `GH_TOKEN` | [GitHub Fine-Grained Tokens](https://github.com/settings/personal-access-tokens) | | The GitHub API key used for checking out private submodules and pushing content to the asset repository. |
| `DISCORD_TOKEN` | [Discord Developer Portal](https://discord.com/developers/applications) | | The Discord API Key for the BlitzKit Discord Bot. |
| `DATABASE_URL` | [CockroachDB](https://www.cockroachlabs.com/) | | The CockroachDB-SQL database URL used to link Wargaming and Discord users. |
| `WOTB_DLC_CDN` | | | |
-10
View File
@@ -1,10 +0,0 @@
{
"folders": [
{
"path": ".",
},
{
"path": "C:/Program Files/WindowsApps/7458BE2C.WorldofTanksBlitz_11.1.256.0_x64__x4tje2y229k00/Data",
},
],
}
+1118 -538
View File
File diff suppressed because it is too large. Load diff
-6
View File
@@ -1,6 +0,0 @@
{
"words": [
"gamemodes",
"Tankopedia"
]
}
-18
View File
@@ -1,18 +0,0 @@
# BlitzKit 1.30.3
Thank you to [GonnaHetzMe](https://github.com/karelpak1) for your contributions this update.
## Features
- [GonnaHetzMe] Added tooltips to the module selector in Tankopedia
## Fixes
- Added aggressive re-rendering for the Guess mini-game's score readout to make inspect-element harder
- Removed tank visual and collision model caching
- Guesser once again breaks streaks if not all tiers are selected
- BlitzKit now properly unloads tank models from memory when visually hidden in Guesser
- [GonnaHetzMe] Tanks with complex transparency like the Herdbreaker once again handle opacity correctly
- [GonnaHetzMe] Pieces of tank models that have transparency now handle culling properly (they're visible from both sides)
- [GonnaHetzMe] Tanks with "cardboard" textures have been fixed, thanks to better PVR file parsing
- [GonnaHetzMe] Tank icons are now scaled correctly so small tanks are now properly zoomed out in contrast to bigger ones
-5
View File
@@ -1,5 +0,0 @@
# BlitzKit 1.30.4
## Fixes
- BlitzKit now correctly implements minor WoTB updates
-8
View File
@@ -1,8 +0,0 @@
# BlitzKit 1.30.5
## Features
- Reduced the amount of code that executes for every rendered frame, hopefully improving performance
- Reduced the number of lights by 20% to decrease the amount of shadow calculations
- Decreased the ground size by 80% for an equal reduction in shadow texture sizes
- Added the ability for BlitzKit to dismount the components that handled the intro lighting animation when not animating, reducing checks running every frame
-7
View File
@@ -1,7 +0,0 @@
# BlitzKit 1.30.6
## Fixes
- Tanks no longer "randomly" appear dark in Tankopedia and Mixer
- The ground is now as dark as other modes when Static Armor is selected in Tankopedia
- Fixed some naming issues with the "equal" icon, improving performance a tiny tiny bit
-20
View File
@@ -1,20 +0,0 @@
# BlitzKit 1.30.7
Thank you to [Mark Greene](https://github.com/Hitjack007) for your contributions this update.
## Features
- Dragging items in the Tier List now automatically scrolls the screen if you reach the top/bottom edge of the screen
## Fixes
- [Mark Greene] Clicking on the armor multiple times with HE no longer crashes BlitzKit
- [Mark Greene] Ricochets that later intersect with external modules no longer falsely claim a successful damaging penetration
- The lighting animation no longer stops prematurely when BlitzKit is unfocused
- The spot light angle has been increased to more evenly light up larger tanks
- BlitzKit once again uses soft shadows, reducing the pixelated shadows
- Fixed the weird patterns that formed on the tracks of tanks under self-shadows
- Increased the floor size and light decay to create the illusion that the floor is infinite
- [Mark Greene] Safari no longer crashes when unloading textures for unloaded tanks
- The gun depression transition between two constraints (like often behind tanks) now uses a gentler zoned and can never be 0
- [Mark Greene] Armor plate names in Static Armor are now properly capitalized in English and translated to other languages
-9
View File
@@ -1,9 +0,0 @@
# BlitzKit 1.30.8
Support for tanks with Chinese names.
## Fixes
- The URL generator for BlitzKit now handles names that have only Chinese characters
- The Mixed Virtual File System now handles non-existent directories correctly
- Updated equipments to match the game again
-40
View File
@@ -1,40 +0,0 @@
# BlitzKit 1.31.0
This update massively upgrades BlitzKit's graphical fidelity (while making it faster) and updates the way BlitzKit deploys updates, speeding up future versions.
## Features
- Improved the performance of the Tank Sandbox in Tankopedia
- Reduced the number of lights by 25%
- Reduced the resolution of shadows by 50%
- Improved the graphical fidelity of tanks in the Tankopedia
- Added soft contact shadows below tanks
- Added sky and ground reflected lighting to the top and bottom of tanks
- Removed the fog that used to darken tanks when zoomed far away
- Made BlitzKitLand™ fade out circularly to hide the sharp edges
- Pointed the lights 1.5m above the ground
- This genuinely makes BlitzKit almost photo-realistic in my opinion
- Non-PBR tanks are now lit up brighter to accommodate for their dark textures
## Fixes
- Fixed a bug where the ambient occlusion map (a measure of how dark a part of a tank gets due to low light bounces) was applied to the color of the tank directly, making many tanks appear dark even in direct light
- Tanks with chinese characters now generate urls through transliteration
## Known Issues
- The flags found behind the selected tank in the tech tree section of the Tankopedia is offset incorrectly
## Technical Features
- BlitzKit can now calculate and store performance statistics and account details for up 10,000,000,000 players
- This will help make the BlitzKit Performance tool more accurate, and play well with an upcoming, unannounced major feature
## Technical Changes
- The `production` branch now deploys updates to `blitzkit.app` instead of the `main` branch
- `robots.txt` now disables indexing of BlitzKit variants based on the environment
- The website is now compiled in "chunks" of which the `api` chunk now takes on the task of mangling what was previously stored in `assets3`
- `assets3` will be removed in `1.31.1`, please migrate to the new `api` if you use any BlitzKit assets
- Removed the `bot` and `app` packages
- Removed Prisma
-3
View File
@@ -1,3 +0,0 @@
# BlitzKit 1.31.1
This update adds capabilities to release BlitzKit OpenTest and Preview.
+11
View File
@@ -2,6 +2,17 @@
🎉 Services for everything and anything World of Tanks Blitz
## Bot
BlitzKit is available as a Discord bot and can be [installed very easily](/guide/invite).
- Track statistics
- Find inactive players in a clan
- Lookup owned tanks
- Create embeds for streams
- Track a player/tanks statistical evolution
- ...and a variety of other similar things
## Website
Provides a bunch of very helpful tools; https://blitzkit.app/
+3
View File
@@ -0,0 +1,3 @@
# Inviting the Bot
Go to [application directory for BlitzKit](https://discord.com/application-directory/1097673957865443370) and click "Add to Server." Voila! You're ready to go.
-17
View File
@@ -1,17 +0,0 @@
{
"robots": false,
"canonical": "https://blitzkit.app",
"paths": {
"default": null,
"api": null
},
"nodes": {
"default": "blitzkit/deploy-node-beta.blitzkit.app",
"api": "blitzkit/deploy-node-api.beta.blitzkit.app"
},
"aliases": {
"api": "https://api.beta.blitzkit.app",
"static": "https://static.beta.blitzkit.app"
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"robots": false,
"canonical": "https://blitzkit.app",
"paths": {
"default": null,
"api": "api"
},
"nodes": {},
"aliases": {
"api": "/api",
"static": "https://static.blitzkit.app"
}
}
-17
View File
@@ -1,17 +0,0 @@
{
"robots": false,
"canonical": "https://blitzkit.app",
"paths": {
"default": null,
"api": null
},
"nodes": {
"default": "blitzkit/deploy-node-opentest.blitzkit.app",
"api": "blitzkit/deploy-node-api.opentest.blitzkit.app"
},
"aliases": {
"api": "https://api.opentest.blitzkit.app",
"static": "https://static.opentest.blitzkit.app"
}
}
-17
View File
@@ -1,17 +0,0 @@
{
"robots": false,
"canonical": "https://blitzkit.app",
"paths": {
"default": null,
"api": null
},
"nodes": {
"default": "blitzkit/deploy-node-preview.blitzkit.app",
"api": "blitzkit/deploy-node-api.preview.blitzkit.app"
},
"aliases": {
"api": "https://api.preview.blitzkit.app",
"static": "https://static.preview.blitzkit.app"
}
}
-17
View File
@@ -1,17 +0,0 @@
{
"robots": true,
"canonical": "https://blitzkit.app",
"paths": {
"default": null,
"api": null
},
"nodes": {
"default": "blitzkit/deploy-node-blitzkit.app",
"api": "blitzkit/deploy-node-api.blitzkit.app"
},
"aliases": {
"api": "https://api.blitzkit.app",
"static": "https://static.blitzkit.app"
}
}
+5 -10
View File
@@ -1,24 +1,19 @@
{
"name": "blitzkit",
"version": "1.31.1",
"version": "2.0.0-beta.3",
"license": "BlitzKit License",
"private": true,
"scripts": {
"dev": "cd packages/website && bun run dev",
"build": "cd packages/website && bun run build",
"discover-ids": "cd packages/scripts && bun run discover-ids",
"build-protos": "cd packages/scripts && bun run build-protos",
"record-performance": "cd packages/scripts && bun run record-performance",
"download-client": "cd packages/scripts && bun run download-client"
"setup": "chmod a+x scripts/*.sh && ./scripts/setup.sh",
"dev": "./scripts/dev.sh",
"build-game": "./scripts/build_game.sh",
"build-website": "./scripts/build_website.sh"
},
"type": "module",
"workspaces": [
"packages/*"
],
"dependencies": {
"@gltf-transform/extensions": "^4.4.2",
"lz4js": "^0.2.0",
"sharp": "^0.34.5",
"typescript": "^5.9.3"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@blitzkit/app",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/cli": "^2",
"vite": "^6.0.3",
"typescript": "~5.6.2"
}
}
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+5286
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "blitzkit"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "blitzkit_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
+14
View File
@@ -0,0 +1,14 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+27
View File
@@ -0,0 +1,27 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::process::{Command, Stdio};
use tauri::Manager;
fn main() {
tauri::Builder::default()
.setup(|app| {
let app_handle = app.handle();
let entry = app_handle
.path_resolver()
.resolve_resource("../../dist/website/server/entry.mjs")
.expect("failed to resolve server.ts");
Command::new("bun")
.arg("run")
.arg(entry)
.stderr(Stdio::null())
.spawn()
.expect("failed to start Bun server");
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running Tauri app");
}
+38
View File
@@ -0,0 +1,38 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "blitzkit",
"version": "0.1.0",
"identifier": "com.tresabhi.blitzkit",
"build": {
"beforeDevCommand": "cd ../website && bun run dev",
"_beforeBuildCommand": "cd ../website && bun run build",
"frontendDist": "../../../dist/website/client",
"devUrl": "http://localhost:4321"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"url": "http://localhost:4321",
"title": "BlitzKit",
"fullscreen": false,
"resizable": true,
"maximized": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/[email protected]",
"icons/icon.icns",
"icons/icon.ico"
]
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": ["../../tsconfig.json"],
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
}
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@blitzkit/bot",
"dependencies": {
"@blitzkit/core": "workspace:*",
"@blitzkit/i18n": "workspace:*",
"@radix-ui/colors": "^3.0.0",
"@radix-ui/themes": "^3.3.0",
"@resvg/resvg-js": "^2.6.2",
"@types/adm-zip": "^0.5.7",
"@types/bun": "^1.3.9",
"@types/lodash-es": "^4.17.12",
"@types/markdown-escape": "^1.1.3",
"@types/react": "^19.2.14",
"adm-zip": "^0.5.16",
"bepaint": "^0.1.0-beta.3",
"discord.js": "^14.25.1",
"dotenv": "^17.3.1",
"fuzzysort": "^3.1.0",
"lodash-es": "^4.17.23",
"markdown-escape": "^2.0.0",
"pickleparser": "^0.2.1",
"prisma": "^6.19.2",
"protobufjs": "^7.5.4",
"react": "^19.2.4",
"satori": "^0.18.4",
"sharp": "^0.34.5"
},
"private": true,
"scripts": {
"dev": "bun --watch src/main.ts",
"start": "bun src/main.ts"
},
"type": "module",
"types": "src/types.d.ts"
}
+68
View File
@@ -0,0 +1,68 @@
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { translator } from "../core/localization/translator";
import {
commandsArray,
type CommandRegistry,
} from "../events/interactionCreate";
const subcommands = [
"embeds",
"introduction",
"invite",
"timezones",
"credits",
] as const;
type Subcommand = (typeof subcommands)[number];
const DOCS: Record<Subcommand, string> = {
embeds: "guide/embeds",
introduction: "guide/introduction",
invite: "guide/invite",
timezones: "guide/timezones",
credits: "about/credits",
};
export const aboutCommand = new Promise<CommandRegistry>(async (resolve) => {
resolve({
command: createLocalizedCommand("about", [
{ subcommand: "commands" },
...subcommands.map((subcommand) => ({ subcommand })),
]),
async handler(interaction) {
const subcommand = interaction.options.getSubcommand();
const { strings } = translator(interaction.locale);
if (subcommand === "commands") {
return `${strings.bot.commands.about.subcommands.commands.body}${(
await Promise.all(commandsArray)
)
.sort((a, b) =>
(a.command.name_localizations?.[interaction.locale] ??
a.command.name) <
(b.command.name_localizations?.[interaction.locale] ??
b.command.name)
? -1
: 1,
)
.map(
(registry) =>
`- \`/${
registry.command.name_localizations?.[interaction.locale] ??
registry.command.name
}\`: ${
registry.command.description_localizations?.[
interaction.locale
] ?? registry.command.description
}`,
)
.join("\n")}`;
}
const url = `https://raw.githubusercontent.com/tresabhi/blitzkit/main/docs/${DOCS[subcommand as Subcommand]}.md`;
const content = fetch(url).then((response) => response.text());
return content;
},
});
});
@@ -0,0 +1,65 @@
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 { createLocalizedCommand } from "../../core/discord/createLocalizedCommand";
import { getFiltersFromButton } from "../../core/discord/getFiltersFromButton";
import { resolvePeriodFromButton } from "../../core/discord/resolvePeriodFromButton";
import { resolvePlayerFromButton } from "../../core/discord/resolvePlayerFromButton";
import { translator } from "../../core/localization/translator";
import type { CommandRegistry } from "../../events/interactionCreate";
import { renderBreakdown } from "./render";
export const breakdownCommand = new Promise<CommandRegistry>(
async (resolve) => {
resolve({
command: await addPeriodicFilterOptions(
createLocalizedCommand("breakdown"),
(option) => option.addStringOption(addUsernameChoices),
),
async handler(interaction) {
const { strings } = translator(interaction.locale);
return strings.bot.commands.dead;
// 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,
);
},
});
},
);
@@ -0,0 +1,295 @@
import {
type BlitzStats,
calculateWN8,
getAccountInfo,
getClanAccountInfo,
getTankStats,
getWN8Percentile,
TankDefinition,
} from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import { Locale } from "discord.js";
import { chunk } from "lodash-es";
import * as Breakdown from "../../components/Breakdown";
import { CommandWrapper } from "../../components/CommandWrapper";
import { NoData } from "../../components/NoData";
import { TitleBar } from "../../components/TitleBar";
import { filtersToDescription } from "../../core/blitzkit/filtersToDescription";
import {
averageDefinitions,
tankDefinitions,
} from "../../core/blitzkit/nonBlockingPromises";
import { UserError } from "../../core/blitzkit/userError";
import {
filterStats,
type StatFilters,
} from "../../core/blitzstars/filterStats";
import { getStatsInPeriod } from "../../core/blitzstars/getStatsInPeriod";
import type { ResolvedPeriod } from "../../core/discord/resolvePeriodFromCommand";
import type { ResolvedPlayer } from "../../core/discord/resolvePlayerFromCommand";
import { translator } from "../../core/localization/translator";
const ROWS_PER_PAGE = 8;
const MAX_PAGES = 9;
export async function renderBreakdown(
{ region, id }: ResolvedPlayer,
{ start, end, name }: ResolvedPeriod,
filters: StatFilters,
locale: Locale,
) {
const { strings, unwrap } = translator(locale);
const awaitedTankDefinitions = await tankDefinitions;
const awaitedTankAverages = await averageDefinitions;
const statsInPeriod = await getStatsInPeriod(region, id, start, end, locale);
const { filteredOrder } = await filterStats(statsInPeriod, filters);
const accountInfo = await getAccountInfo(region, id);
const clanData = await getClanAccountInfo(region, id, ["clan"]);
const tankStats = await getTankStats(region, id);
const filterDescriptions = await filtersToDescription(filters, locale);
const orderedCurrentStats: BlitzStats[] = [];
const orderedCareerStats: BlitzStats[] = [];
const orderedCurrentWN8: (number | undefined)[] = [];
const orderedCareerWN8: (number | undefined)[] = [];
const orderedCareerWN8Full: (number | undefined)[] = [];
if (tankStats === null) {
throw new UserError(strings.bot.common.errors.no_tank_stats);
}
filteredOrder.forEach((id) => {
const tankAveragesEntry = awaitedTankAverages.averages[id];
const current = statsInPeriod.diff[id];
const career = tankStats.find((tank) => tank.tank_id === id)!.all;
const currentWN8 = tankAveragesEntry
? calculateWN8(tankAveragesEntry.mu, current)
: undefined;
const careerWN8 = tankAveragesEntry
? calculateWN8(tankAveragesEntry.mu, career)
: undefined;
orderedCurrentStats.push(current);
orderedCareerStats.push(career);
orderedCurrentWN8.push(currentWN8);
orderedCareerWN8.push(careerWN8);
});
tankStats.forEach((tank) => {
const tankAveragesEntry = awaitedTankAverages.averages[tank.tank_id];
orderedCareerWN8Full.push(
tankAveragesEntry
? calculateWN8(tankAveragesEntry.mu, tank.all)
: undefined,
);
});
const currentBattles = orderedCurrentStats.reduce(
(accumulator, current) => accumulator + current.battles,
0,
);
const careerBattles = tankStats.reduce(
(accumulator, current) => accumulator + current.all.battles,
0,
);
const currentWinrate =
orderedCurrentStats.reduce(
(accumulator, current) => accumulator + current.wins,
0,
) / currentBattles;
const careerWinrate =
tankStats.reduce(
(accumulator, current) => accumulator + current.all.wins,
0,
) / careerBattles;
const currentWN8 =
orderedCurrentWN8.reduce<number>(
(accumulator, current, index) =>
accumulator + (current ?? 0) * orderedCurrentStats[index].battles,
0,
) /
orderedCurrentStats.reduce(
(accumulator, current, index) =>
accumulator +
(orderedCurrentWN8[index] === undefined ? 0 : current.battles),
0,
);
const careerWN8 =
orderedCareerWN8Full.reduce<number>(
(accumulator, current) => accumulator + (current ?? 0),
0,
) /
tankStats.reduce(
(accumulator, current, index) =>
accumulator +
(orderedCareerWN8Full[index] === undefined ? 0 : current.all.battles),
0,
);
const currentDamage =
orderedCurrentStats.reduce(
(accumulator, current) => accumulator + current.damage_dealt,
0,
) / currentBattles;
const careerDamage =
tankStats.reduce(
(accumulator, current) => accumulator + current.all.damage_dealt,
0,
) / careerBattles;
const children: React.JSX.Element[] = [];
if (filteredOrder.length > 0) {
children.push(
<Breakdown.Row
key={id}
displayType="summary"
title={strings.bot.commands.breakdown.body.total}
stats={[
{
title: strings.bot.commands.breakdown.body.battles,
current: currentBattles,
career: careerBattles,
},
{
title: strings.bot.commands.breakdown.body.winrate,
current: `${(100 * currentWinrate).toFixed(2)}%`,
career: `${(100 * careerWinrate).toFixed(2)}%`,
delta: currentWinrate - careerWinrate,
},
{
title: strings.bot.commands.breakdown.body.wn8,
current:
currentWN8 === undefined
? undefined
: Math.round(currentWN8).toLocaleString(),
career:
careerWN8 === undefined
? undefined
: Math.round(careerWN8).toLocaleString(),
percentile:
currentWN8 === undefined
? undefined
: getWN8Percentile(currentWN8),
},
{
title: strings.bot.commands.breakdown.body.damage,
current: Math.round(currentDamage).toLocaleString(),
career: Math.round(careerDamage).toLocaleString(),
delta: currentDamage - careerDamage,
},
]}
/>,
);
filteredOrder.forEach((id, index) => {
const tankDefinition = awaitedTankDefinitions.tanks[id] as
| TankDefinition
| undefined;
const current = orderedCurrentStats[index];
const career = orderedCareerStats[index];
const currentWN8 = orderedCurrentWN8[index];
const careerWN8 = orderedCareerWN8[index];
children.push(
<Breakdown.Row
key={id}
displayType="tank"
class={tankDefinition?.class}
type={tankDefinition?.type}
title={
tankDefinition
? unwrap(tankDefinition.name)
: literals(strings.bot.commands.breakdown.body.unknown_tank, {
id,
})
}
stats={[
{
title: strings.bot.commands.breakdown.body.battles,
current: current.battles.toLocaleString(),
career: career.battles.toLocaleString(),
},
{
title: strings.bot.commands.breakdown.body.winrate,
current: `${(100 * (current.wins / current.battles)).toFixed(
2,
)}%`,
career: `${(100 * (career.wins / career.battles)).toFixed(2)}%`,
delta:
current.wins / current.battles - career.wins / career.battles,
},
{
title: strings.bot.commands.breakdown.body.wn8,
current:
currentWN8 === undefined
? undefined
: Math.round(currentWN8).toLocaleString(),
career:
careerWN8 === undefined
? undefined
: Math.round(careerWN8).toLocaleString(),
percentile:
currentWN8 === undefined
? undefined
: getWN8Percentile(currentWN8),
},
{
title: strings.bot.commands.breakdown.body.damage,
current: Math.round(
current.damage_dealt / current.battles,
).toLocaleString(),
career: Math.round(
career.damage_dealt / career.battles,
).toLocaleString(),
delta:
current.damage_dealt / current.battles -
career.damage_dealt / career.battles,
},
]}
/>,
);
});
}
const pages = chunk(
children,
Math.max(Math.ceil(children.length / MAX_PAGES), ROWS_PER_PAGE),
);
if (filteredOrder.length > 0) {
return pages
.map((page, index) => (
<CommandWrapper>
{index === 0 && (
<TitleBar
title={accountInfo.nickname}
image={
clanData?.clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData?.clan?.emblem_set_id}.png`
: undefined
}
description={`${name} • ${filterDescriptions}`}
/>
)}
<Breakdown.Root>{page}</Breakdown.Root>
</CommandWrapper>
))
.reverse();
} else {
return [
<CommandWrapper>
<TitleBar
title={accountInfo.nickname}
image={
clanData?.clan
? `https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanData?.clan?.emblem_set_id}.png`
: undefined
}
description={`${name} • ${filterDescriptions}`}
/>
<NoData type="battles_in_period" locale={locale} />
</CommandWrapper>,
];
}
}
+37
View File
@@ -0,0 +1,37 @@
import packageJSON from "../../../../package.json";
import { client } from "../core/discord/client";
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { translator } from "../core/localization/translator";
import type { CommandRegistry } from "../events/interactionCreate";
const executionStart = new Date().getTime();
export const debugCommand = new Promise<CommandRegistry>((resolve) => {
resolve({
command: createLocalizedCommand("debug"),
async handler(interaction) {
const { strings } = translator(interaction.locale);
const currentTime = new Date().getTime();
const uptime = currentTime - executionStart;
const list = [
[strings.bot.commands.debug.body.version, packageJSON.version],
[
strings.bot.commands.debug.body.shard.label,
client.shard?.ids[0] ?? strings.bot.commands.debug.body.shard.default,
],
[
strings.bot.commands.debug.body.uptime,
`${Math.floor((uptime / 1000 / 60 / 60) % 24)}h ${Math.floor(
(uptime / 1000 / 60) % 60
)}m ${Math.floor((uptime / 1000) % 60)}s ${Math.floor(
uptime % 1000
)}ms`,
],
];
return `${strings.bot.commands.debug.body.title}${list
.map(([key, value]) => `- ${key}: ${value}`)
.join("\n")}`;
},
});
});
+182
View File
@@ -0,0 +1,182 @@
import { emblemURL, getAccountInfo, getClanAccountInfo } from "@blitzkit/core";
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 { getPlayerHistories } from "../core/blitzstars/getPlayerHistories";
import { getTankHistories } from "../core/blitzstars/getTankHistories";
import { addPeriodSubCommands } from "../core/discord/addPeriodSubCommands";
import { addTankChoices } from "../core/discord/addTankChoices";
import { addUsernameChoices } from "../core/discord/addUsernameChoices";
import { autocompleteTanks } from "../core/discord/autocompleteTanks";
import { autocompleteUsername } from "../core/discord/autocompleteUsername";
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { resolvePeriodFromButton } from "../core/discord/resolvePeriodFromButton";
import { type ResolvedPeriod } from "../core/discord/resolvePeriodFromCommand";
import { resolvePlayerFromButton } from "../core/discord/resolvePlayerFromButton";
import { type ResolvedPlayer } from "../core/discord/resolvePlayerFromCommand";
import { translator } from "../core/localization/translator";
import type { CommandRegistry } from "../events/interactionCreate";
type EvolutionStatType = "player" | "tank";
async function render(
{ region, id }: ResolvedPlayer,
{ start, end, name }: ResolvedPeriod,
type: "player" | "tank",
tankId: number | null,
locale: Locale,
) {
const accountInfo = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ["clan"]))?.clan;
const logo = clan ? emblemURL(clan.emblem_set_id) : undefined;
const histories = await (type === "player"
? getPlayerHistories(region, id, {
start,
end,
includeLatestHistories: true,
includePreviousHistories: true,
})
: getTankHistories(region, id, locale, {
tankId: tankId!,
start,
end,
includeLatestHistories: true,
includePreviousHistories: true,
}));
const plot = histories.map(
(history) =>
[
history.all.battles,
(history.all.wins / history.all.battles) * 100,
] as Graph.PlotItem,
);
const times = histories.map((history) => history.last_battle_time);
const minTime = Math.min(...times);
const maxTime = Math.max(...times);
const ys = plot.map(([, y]) => y);
const xs = plot.map(([x]) => x);
const maxY = Math.max(...ys);
const minY = Math.min(...ys);
const maxX = Math.max(...xs);
const minX = Math.min(...xs);
return (
<CommandWrapper>
<TitleBar title={accountInfo.nickname} image={logo} description={name} />
{plot.length > 0 && (
<Graph.Root
verticalMargin={{
min: minY,
max: maxY,
suffix: "%",
precision: 2,
}}
horizontalMargin={{
min: minX,
max: maxX,
precision: 0,
}}
xMinLabel={new Date(minTime * 1000).toLocaleDateString(locale)}
xMaxLabel={new Date(maxTime * 1000).toLocaleDateString(locale)}
>
{Graph.Line({
color: LineColor.Red,
plot: plot,
minY,
maxY,
})}
</Graph.Root>
)}
{plot.length === 0 && <NoData type="battles_in_period" locale={locale} />}
</CommandWrapper>
);
}
export const evolutionCommand = new Promise<CommandRegistry>(
async (resolve) => {
resolve({
command: createLocalizedCommand("evolution", [
{
group: "player",
modify(option: SlashCommandSubcommandGroupBuilder) {
addPeriodSubCommands(option, (option) =>
option.addStringOption(addUsernameChoices),
);
},
},
{
group: "tank",
modify(option: SlashCommandSubcommandGroupBuilder) {
addPeriodSubCommands(option, (option) =>
option
.addStringOption(addTankChoices)
.addStringOption(addUsernameChoices),
);
},
},
]),
async handler(interaction) {
const { strings } = translator(interaction.locale);
return strings.bot.commands.dead;
// const commandGroup =
// interaction.options.getSubcommandGroup() as EvolutionStatType;
// const player = await resolvePlayerFromCommand(interaction);
// const period = resolvePeriodFromCommand(player.region, interaction);
// const tankIdRaw = interaction.options.getString("tank")!;
// const tankId =
// commandGroup === "tank"
// ? await resolveTankId(tankIdRaw, interaction.locale)
// : null;
// const path = commandToURL(interaction, {
// ...player,
// ...getCustomPeriodParams(interaction),
// tankId,
// });
// return [
// await render(
// player,
// period,
// commandGroup,
// tankId,
// interaction.locale
// ),
// buttonRefresh(interaction, path),
// await getBlitzStarsLinkButton(
// player.region,
// player.id,
// tankId ?? undefined
// ),
// ];
},
autocomplete: (interaction) => {
autocompleteUsername(interaction);
autocompleteTanks(interaction);
},
async button(interaction) {
const url = new URL(`https://example.com/${interaction.customId}`);
const path = url.pathname.split("/");
const commandGroup = path[2] as EvolutionStatType;
const player = await resolvePlayerFromButton(interaction);
const period = resolvePeriodFromButton(player.region, interaction);
return await render(
player,
period,
commandGroup,
parseInt(url.searchParams.get("tankId") ?? "0") || null,
interaction.locale,
);
},
});
},
);
+117
View File
@@ -0,0 +1,117 @@
import {
emblemURL,
getAccountInfo,
getClanAccountInfo,
getTierWeights,
} from "@blitzkit/core";
import { Locale } from "discord.js";
import { CommandWrapper } from "../components/CommandWrapper";
import { GenericAllStats } from "../components/GenericAllStats";
import { NoData } from "../components/NoData";
import { TierWeights } from "../components/TierWeights";
import { TitleBar } from "../components/TitleBar";
import { filtersToDescription } from "../core/blitzkit/filtersToDescription";
import { filterStats, type StatFilters } from "../core/blitzstars/filterStats";
import { getStatsInPeriod } from "../core/blitzstars/getStatsInPeriod";
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 { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { getFiltersFromButton } from "../core/discord/getFiltersFromButton";
import { resolvePeriodFromButton } from "../core/discord/resolvePeriodFromButton";
import { type ResolvedPeriod } from "../core/discord/resolvePeriodFromCommand";
import { resolvePlayerFromButton } from "../core/discord/resolvePlayerFromButton";
import { type ResolvedPlayer } from "../core/discord/resolvePlayerFromCommand";
import { translator } from "../core/localization/translator";
import type { CommandRegistry } from "../events/interactionCreate";
async function render(
{ region, id }: ResolvedPlayer,
{ start, end, name }: ResolvedPeriod,
filters: StatFilters,
locale: Locale,
) {
const { nickname } = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ["clan"]))?.clan;
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
const diffedTankStats = await getStatsInPeriod(
region,
id,
start,
end,
locale,
);
const { stats, supplementary, filteredOrder } = await filterStats(
diffedTankStats,
filters,
);
const filterDescriptions = await filtersToDescription(filters, locale);
const tierWeights = await getTierWeights(diffedTankStats.diff, filteredOrder);
return (
<CommandWrapper>
<TitleBar
title={nickname}
image={clanImage}
description={`${name} • ${filterDescriptions}`}
/>
{stats.battles === 0 && (
<NoData type="battles_in_period" locale={locale} />
)}
{stats.battles > 0 && <TierWeights weights={tierWeights!} />}
{stats.battles > 0 && (
<GenericAllStats
stats={stats}
supplementaryStats={supplementary}
locale={locale}
/>
)}
</CommandWrapper>
);
}
export const fullStatsCommand = new Promise<CommandRegistry>(
async (resolve) => {
resolve({
command: await addPeriodicFilterOptions(
createLocalizedCommand("full-stats"),
(option) => option.addStringOption(addUsernameChoices),
),
async handler(interaction) {
const { strings } = translator(interaction.locale);
return strings.bot.commands.dead;
// 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 Promise.all([
// render(player, period, filters, interaction.locale),
// buttonRefresh(interaction, path),
// 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 render(player, period, filters, interaction.locale);
},
});
},
);
+96
View File
@@ -0,0 +1,96 @@
import { getAccountInfo, getClanInfo } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import { Locale } from "discord.js";
import { CommandWrapper } from "../components/CommandWrapper";
import { GenericStats } from "../components/GenericStats";
import { NoData } from "../components/NoData";
import { TitleBar } from "../components/TitleBar";
import { addClanChoices } from "../core/discord/addClanChoices";
import { autocompleteClan } from "../core/discord/autocompleteClan";
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { localizationObject } from "../core/discord/localizationObject";
import { resolveClanFromCommand } from "../core/discord/resolveClanFromCommand";
import { translator } from "../core/localization/translator";
import type { CommandRegistry } from "../events/interactionCreate";
const DEFAULT_THRESHOLD = 7;
export const inactiveCommand = new Promise<CommandRegistry>((resolve) => {
resolve({
command: createLocalizedCommand("inactive")
.addStringOption(addClanChoices)
.addNumberOption((option) => {
const { strings } = translator(Locale.EnglishUS);
return option
.setName("threshold")
.setNameLocalizations(
localizationObject(
(strings) => strings.bot.commands.inactive.options.threshold.name,
undefined,
true
)
)
.setDescription(
strings.bot.commands.inactive.options.threshold.description
)
.setDescriptionLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.inactive.options.threshold.description
)
)
.setMinValue(0);
}),
async handler(interaction) {
const { strings } = translator(interaction.locale);
const { region, id } = await resolveClanFromCommand(interaction);
const threshold =
interaction.options.getNumber("threshold")! ?? DEFAULT_THRESHOLD;
const time = new Date().getTime() / 1000;
const clanInfo = await getClanInfo(region, id);
const accountInfo = await getAccountInfo(region, clanInfo.members_ids);
const inactive = accountInfo
.map(
(account) =>
[
account.nickname,
(time - account.last_battle_time) / 60 / 60 / 24,
] as [string, number]
)
.filter(([, inactiveDays]) => inactiveDays >= threshold)
.sort((a, b) => b[1] - a[1])
.map(
([name, days]) =>
[
name,
literals(strings.bot.commands.inactive.body.listing, {
days: days.toFixed(0),
}),
] as [string, string]
);
const hasInactiveMembers = inactive.length > 0;
return (
<CommandWrapper>
<TitleBar
title={clanInfo.name}
image={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanInfo.emblem_set_id}.png`}
description={`${literals(
strings.bot.commands.inactive.body.subtitle,
{ days: threshold }
)} • ${new Date().toLocaleDateString(interaction.locale)}`}
/>
{!hasInactiveMembers && (
<NoData type="players_in_period" locale={interaction.locale} />
)}
{hasInactiveMembers && <GenericStats stats={inactive} />}
</CommandWrapper>
);
},
autocomplete: autocompleteClan,
});
});
+193
View File
@@ -0,0 +1,193 @@
import { getAccountInfo, getClanAccountInfo } from '@blitzkit/core';
import { teal } from '@radix-ui/colors';
import { Glow } from '../components/AllStatsOverview/components/HeroStat/components/Glow';
import { CommandWrapper } from '../components/CommandWrapper';
import { linkBlitzAndDiscord } from '../core/db/linkBlitzAndDiscord';
import { addUsernameChoices } from '../core/discord/addUsernameChoices';
import { autocompleteUsername } from '../core/discord/autocompleteUsername';
import { createLocalizedCommand } from '../core/discord/createLocalizedCommand';
import { embedInfo } from '../core/discord/embedInfo';
import { resolvePlayerFromCommand } from '../core/discord/resolvePlayerFromCommand';
import { translator } from '../core/localization/translator';
import type { CommandRegistry } from '../events/interactionCreate';
import { theme } from '../stitches.config';
export const verifyCommand = new Promise<CommandRegistry>((resolve) => {
resolve({
command: createLocalizedCommand('link').addStringOption((option) =>
addUsernameChoices(option).setRequired(true),
),
async handler(interaction) {
const { strings } = translator(interaction.locale);
const { id, region } = await resolvePlayerFromCommand(interaction);
const discordId = BigInt(interaction.user.id);
const accountInfo = await getAccountInfo(region, id);
const clanAccountInfo = await getClanAccountInfo(region, id, ['clan']);
await linkBlitzAndDiscord(discordId, id);
return [
<CommandWrapper fat>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Glow color={teal.teal9} rotation={90} />
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 16,
}}
>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
justifyContent: 'center',
}}
>
<img
alt="avatar"
src={
interaction.user.avatarURL({ extension: 'png' }) ??
'https://i.stack.imgur.com/l60Hf.png'
}
style={{
width: 64,
height: 64,
borderRadius: 32,
objectFit: 'cover',
}}
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<span
style={{
fontSize: 32,
color: theme.colors.textHighContrast,
fontWeight: 900,
maxWidth: 240,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{interaction.user.displayName}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
@{interaction.user.username}
</span>
</div>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
color: theme.colors.textLowContrast_teal,
}}
>
<img
alt="accounts linked"
src="https://i.imgur.com/jIcRgog.png"
style={{
width: 16,
height: 16,
}}
/>
<span style={{ fontSize: 16 }}>
{strings.bot.commands.link.body.accounts_linked}
</span>
</div>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
justifyContent: 'center',
}}
>
{clanAccountInfo?.clan ? (
<img
alt="clan emblem"
style={{
width: 64,
height: 64,
borderRadius: 32,
objectFit: 'cover',
}}
src={`https://wotblitz-gc.gcdn.co/icons/clanEmblems1x/clan-icon-v2-${clanAccountInfo?.clan?.emblem_set_id}.png`}
/>
) : null}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<span
style={{
fontSize: 32,
color: theme.colors.textHighContrast,
fontWeight: 900,
maxWidth: 240,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{accountInfo.nickname}
</span>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
{clanAccountInfo?.clan
? `[${clanAccountInfo?.clan?.tag}] • `
: ''}
{strings.common.regions.normal[region]}
</span>
</div>
</div>
</div>
<Glow color={teal.teal9} rotation={-90} />
</div>
</CommandWrapper>,
embedInfo(
strings.bot.commands.link.embed.title,
strings.bot.commands.link.embed.description,
),
];
},
autocomplete: autocompleteUsername,
});
});
+90
View File
@@ -0,0 +1,90 @@
import {
TANK_CLASSES,
TIER_ROMAN_NUMERALS,
TankDefinition,
flags,
getAccountInfo,
getTankStats,
} from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import markdownEscape from "markdown-escape";
import {
gameDefinitions,
tankDefinitions,
} from "../core/blitzkit/nonBlockingPromises";
import { addTierChoices } from "../core/discord/addTierChoices";
import { addUsernameChoices } from "../core/discord/addUsernameChoices";
import { autocompleteUsername } from "../core/discord/autocompleteUsername";
import { chunkLines } from "../core/discord/chunkLines";
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { resolvePlayerFromCommand } from "../core/discord/resolvePlayerFromCommand";
import { translator } from "../core/localization/translator";
import { type CommandRegistry } from "../events/interactionCreate";
export const ownedTanksCommand = new Promise<CommandRegistry>((resolve) => {
resolve({
command: createLocalizedCommand("owned-tanks")
.addStringOption(addTierChoices)
.addStringOption(addUsernameChoices),
async handler(interaction) {
const { strings, unwrap } = translator(interaction.locale);
const tier = Number(interaction.options.getString("tier"));
const { id, region } = await resolvePlayerFromCommand(interaction);
const accountInfo = await getAccountInfo(region, id);
const tankStats = await getTankStats(region, id);
if (tankStats === null) {
return strings.bot.common.errors.no_tank_stats;
}
const filteredTanks = (
await Promise.all(
tankStats.map(async (tankData) => ({
tankDefinitions: (await tankDefinitions).tanks[tankData.tank_id]!,
id: tankData.tank_id,
})),
)
).filter((tank) => tank.tankDefinitions?.tier === tier);
const groupedTanks: Record<string, TankDefinition[]> = {};
const nations: string[] = [];
filteredTanks.forEach((tank) => {
if (groupedTanks[tank.tankDefinitions.nation] === undefined) {
groupedTanks[tank.tankDefinitions.nation] = [tank.tankDefinitions];
nations.push(tank.tankDefinitions.nation);
} else {
groupedTanks[tank.tankDefinitions.nation].push(tank.tankDefinitions);
}
});
const awaitedTankDefinitions = await tankDefinitions;
const awaitedGameDefinitions = await gameDefinitions;
const title = literals(strings.bot.commands.owned_tanks.body.title, {
name: markdownEscape(accountInfo.nickname),
tier: TIER_ROMAN_NUMERALS[tier],
});
const tankList = tankStats
.map(({ tank_id }) => awaitedTankDefinitions.tanks[tank_id])
.filter((tank) => tank.tier === tier)
.sort(
(a, b) =>
TANK_CLASSES.indexOf(a.class) - TANK_CLASSES.indexOf(b.class),
)
.sort(
(a, b) =>
awaitedGameDefinitions.nations.indexOf(a.nation) -
awaitedGameDefinitions.nations.indexOf(b.nation),
)
.map(
(tank) =>
`[${flags[tank.nation]} ${markdownEscape(unwrap(tank.name!))}](<https://blitzkit.app/tanks/${tank.slug}>)`,
);
const lines = [title, ...tankList];
return chunkLines(lines);
},
autocomplete: autocompleteUsername,
});
});
+46
View File
@@ -0,0 +1,46 @@
import { literals } from "@blitzkit/i18n";
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { translator } from "../core/localization/translator";
import { CommandRegistry } from "../events/interactionCreate";
export const permissionsCommand = new Promise<CommandRegistry>((resolve) => {
resolve({
command: createLocalizedCommand("permissions"),
async handler(interaction) {
const { strings } = translator(interaction.locale);
// friday the 13th permissions easter egg
const isFridayThe13th =
new Date().getDay() === 5 && new Date().getDate() === 13;
const permissions = [
[
interaction.appPermissions?.has("ViewChannel"),
strings.bot.commands.permissions.body.view_channels,
],
[
interaction.appPermissions?.has("ReadMessageHistory"),
strings.bot.commands.permissions.body.read_message_history,
],
[
interaction.appPermissions?.has("AttachFiles"),
strings.bot.commands.permissions.body.attach_files,
],
[
interaction.appPermissions?.has("UseExternalEmojis"),
strings.bot.commands.permissions.body.external_emojis,
],
];
const body = permissions
.map(([hasPermission, description]) => {
if (hasPermission) {
return `${isFridayThe13th ? "😇" : "✅"} ${description}`;
} else {
return `${isFridayThe13th ? "💀" : "❌"} ${description}`;
}
})
.join("\n");
return literals(strings.bot.commands.permissions.body.title, { body });
},
});
});
+34
View File
@@ -0,0 +1,34 @@
import { literals } from "@blitzkit/i18n";
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { translator } from "../core/localization/translator";
import type { CommandRegistry } from "../events/interactionCreate";
export const pingCommand = new Promise<CommandRegistry>((resolve) => {
resolve({
handlesInteraction: true,
command: createLocalizedCommand("ping", [
{ subcommand: "blitzkit" },
{ subcommand: "wotb" },
]),
async handler(interaction) {
const { strings } = translator(interaction.locale);
const subcommand = interaction.options.getSubcommand();
const executionStart = Date.now();
if (subcommand === "blitzkit") {
await interaction.editReply(strings.bot.commands.ping.body.pong);
} else {
await fetch("https://api.wotblitz.com/");
}
const executionTime = Date.now() - executionStart;
interaction.editReply(
literals(strings.bot.commands.ping.body.pong_with_time, {
time: executionTime.toFixed(0),
})
);
},
});
});
+69
View File
@@ -0,0 +1,69 @@
import { getAccountInfo } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import markdownEscape from "markdown-escape";
import { addUsernameChoices } from "../core/discord/addUsernameChoices";
import { autocompleteUsername } from "../core/discord/autocompleteUsername";
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { embedInfo } from "../core/discord/embedInfo";
import { markdownTable } from "../core/discord/markdownTable";
import { resolvePlayerFromCommand } from "../core/discord/resolvePlayerFromCommand";
import { translator } from "../core/localization/translator";
import { CommandRegistry } from "../events/interactionCreate";
export const playerInfoCommand = new Promise<CommandRegistry>((resolve) => {
resolve({
command:
createLocalizedCommand("player-info").addStringOption(addUsernameChoices),
async handler(interaction) {
const { strings } = translator(interaction.locale);
const account = await resolvePlayerFromCommand(interaction);
const { id, region: region } = account;
const accountInfo = await getAccountInfo(region, id);
return embedInfo(
literals(strings.bot.commands.player_info.body.title, {
name: markdownEscape(accountInfo.nickname),
}),
markdownTable([
[
strings.bot.commands.player_info.body.nickname,
`${accountInfo.nickname}`,
],
[
strings.bot.commands.player_info.body.battles,
`${accountInfo.statistics.all.battles}`,
],
[
strings.bot.commands.player_info.body.winrate,
`${(
100 *
(accountInfo.statistics.all.wins /
accountInfo.statistics.all.battles)
).toFixed(2)}%`,
],
[],
[
strings.bot.commands.player_info.body.account_id,
`${accountInfo.account_id}`,
],
[
strings.bot.commands.player_info.body.created,
new Date(accountInfo.created_at * 1000).toLocaleDateString(
interaction.locale
),
],
[
strings.bot.commands.player_info.body.last_battle,
new Date(accountInfo.last_battle_time * 1000).toLocaleDateString(
interaction.locale
),
],
])
);
},
autocomplete: autocompleteUsername,
});
});
+535
View File
@@ -0,0 +1,535 @@
import {
deltaBkrlBlitzStats,
emblemURL,
getAccountInfo,
getArchivedRatingLeaderboard,
getClanAccountInfo,
getLeagueFromScore,
getRatingInfo,
getRatingNeighbors,
imgur,
} from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import { SlashCommandSubcommandBuilder } from "discord.js";
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 { normalizeLeagueIcon } from "../core/blitz/normalizeLeagueIcon";
import { iconPng } from "../core/blitzkit/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 type { 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", [
{
subcommand: "today",
modify(option: SlashCommandSubcommandBuilder) {
option.addStringOption(addUsernameChoices);
},
},
{
subcommand: "season",
modify(option: SlashCommandSubcommandBuilder) {
option.addStringOption(addUsernameChoices);
},
},
]),
async handler(interaction) {
const subcommand = interaction.options.getSubcommand(true) as
| "season"
| "today";
const { strings } = 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 ratingInfo = await getRatingInfo(region);
if (ratingInfo.detail) {
throw new Error(`Rating info not available for ${region}`);
}
const leaderboard = await getArchivedRatingLeaderboard(
region,
ratingInfo.current_season - (subcommand === "today" ? 0 : 1)
);
if (leaderboard.version!.$case === "v1") {
throw new Error(
"Encountered bkrl minimal format in latest season. Wait till next cron?"
);
}
const accountInfo = await getAccountInfo(region, id, [
"statistics.rating",
]);
const statsIndex = leaderboard.version!.value.entries.findIndex(
(entry) => entry.id === id
);
const statsA = leaderboard
? leaderboard.version!.value.entries[statsIndex!]
: null;
const positionA = leaderboard ? statsIndex! + 1 : undefined;
const statsB1 = accountInfo.statistics.rating!;
const statsB2Array = (await getRatingNeighbors(region, id, 0)).neighbors;
if (statsB2Array === undefined) {
return literals(strings.bot.commands.rating.errors.no_participation, {
name: markdownEscape(accountInfo.nickname),
});
}
const [statsB2] = statsB2Array;
const league = getLeagueFromScore(statsB2.score);
if (!statsA) {
return strings.bot.commands.rating.errors.unrecorded_stats;
}
if (!statsB1) {
return literals(strings.bot.commands.rating.errors.no_participation, {
name: 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];
let rewardImage = reward
? reward.type === "vehicle"
? reward.vehicle.preview_image_url
: reward.stuff.image_url
: undefined;
if (rewardImage) {
const { ok } = await fetch(rewardImage);
if (!ok) {
rewardImage = imgur("kP88vqr");
}
}
return (
<CommandWrapper>
<TitleBar
title={accountInfo.nickname}
image={clanImage}
description={`${
strings.bot.commands.rating.body.subtitle[subcommand]
} • ${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
alt={`League ${league.name}`}
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_red
: theme.colors.textLowContrast_green,
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 iconPng(rewardImage!)}
/>
</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>
);
},
});
});
@@ -0,0 +1,468 @@
import {
FIRST_MINIMAL_ARCHIVED_RATING_SEASON,
getAccountInfo,
getArchivedLatestSeasonNumber,
getArchivedRatingLeaderboard,
getClanAccountInfo,
getRatingInfo,
getRatingLeague,
getRatingNeighbors,
imgur,
isOnGoingRatingSeason,
LEAGUES,
type RatingInfo,
RatingLeaderboard,
type Region,
} from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import {
type APIApplicationCommandOptionChoice,
Locale,
SlashCommandSubcommandBuilder,
} from "discord.js";
import { range } from "lodash-es";
import markdownEscape from "markdown-escape";
import { CommandWrapper } from "../components/CommandWrapper";
import * as Leaderboard from "../components/Leaderboard";
import { TitleBar } from "../components/TitleBar";
import { UserError } from "../core/blitzkit/userError";
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 type { CommandRegistry } from "../events/interactionCreate";
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
).slice(-25);
function addOptions(option: SlashCommandSubcommandBuilder) {
const { strings } = translator(Locale.EnglishUS);
return option
.addIntegerOption((option) =>
option
.setName(strings.bot.commands.rating_leaderboard.options.limit.name)
.setNameLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.rating_leaderboard.options.limit.name,
undefined,
true
)
)
.setDescription(
literals(
strings.bot.commands.rating_leaderboard.options.limit
.description,
{ count: DEFAULT_LIMIT }
)
)
.setDescriptionLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.rating_leaderboard.options.limit
.description,
{ count: DEFAULT_LIMIT }
)
)
.setRequired(false)
.setMinValue(5)
.setMaxValue(30)
)
.addStringOption((option) =>
option
.setName(
strings.bot.commands.rating_leaderboard.options.season.name
)
.setNameLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.rating_leaderboard.options.season.name,
undefined,
true
)
)
.setDescription(
strings.bot.commands.rating_leaderboard.options.season.description
)
.setDescriptionLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.rating_leaderboard.options.season
.description
)
)
.setRequired(false)
.addChoices(
...seasonNumbers
.map((number, index) => {
const current =
index === seasonNumbers.length - 1 && onGoingSeason;
return {
name: current
? literals(
strings.bot.commands.rating_leaderboard.options.season
.choices.current,
{ season: number }
)
: `${number}`,
value: `${number}`,
name_localizations: current
? localizationObject(
(strings) =>
strings.bot.commands.rating_leaderboard.options
.season.choices.current,
{ season: number }
)
: undefined,
} satisfies APIApplicationCommandOptionChoice<string>;
})
.reverse()
)
);
}
resolve({
command: createLocalizedCommand("rating-leaderboard", [
{
subcommand: "neighbors",
modify(option: SlashCommandSubcommandBuilder) {
option.addStringOption(addUsernameChoices);
addOptions(option);
},
},
{
subcommand: "league",
modify(option: SlashCommandSubcommandBuilder) {
option
.addStringOption((option) => {
const { strings } = translator(Locale.EnglishUS);
return option
.setName(
strings.bot.commands.rating_leaderboard.options.league.name
)
.setNameLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.rating_leaderboard.options.league
.name,
undefined,
true
)
)
.setDescription(
strings.bot.commands.rating_leaderboard.options.league
.description
)
.setDescriptionLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.rating_leaderboard.options.league
.description
)
)
.addChoices(
...LEAGUES.map(
({ name }, value) =>
({
name: (
strings.common.leagues as Record<string, string>
)[name],
value: `${value}`,
name_localizations: localizationObject(
(strings) =>
(
strings.common.leagues as Record<string, string>
)[name]
),
} satisfies APIApplicationCommandOptionChoice<string>)
)
)
.setRequired(true);
})
.addStringOption(addRegionChoices);
addOptions(option);
},
},
]),
// BIG TODO: title seems to be in english
async handler(interaction) {
const { strings } = 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 archivedLeaderboard: RatingLeaderboard | null;
let playerId: number | undefined;
let regionRatingInfo: RatingInfo | null;
if (subcommand === "league") {
const leagueIndex = parseInt(
interaction.options.getString("league")!
);
const region = interaction.options.getString("region") as Region;
regionRatingInfo = await (isCurrentSeason
? getRatingInfo(region)
: null);
if (
regionRatingInfo !== null &&
regionRatingInfo.detail !== undefined
) {
return strings.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.version!.value.entries.findIndex(
(player) =>
player.score < LEAGUES[leagueIndex - 1].minScore
);
const lastPlayerIndex = firstPlayerIndex + limit - 1;
const trimmed = players.version!.value.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 ??
literals(
strings.bot.commands.rating_leaderboard.body
.deleted_player,
{ id: player.id }
),
} satisfies SimplifiedPlayer)
);
}
);
const leagueInfo = LEAGUES[leagueIndex];
archivedLeaderboard = await getArchivedRatingLeaderboard(
region,
season
);
players = result;
playersAfter =
(regionRatingInfo === null
? archivedLeaderboard.version!.value.entries.length
: regionRatingInfo.count) - result[result.length - 1].position;
titleName = `${
leagueInfo
? (strings.common.leagues as Record<string, string>)[
LEAGUES[leagueInfo.index].name
]
: ""
} - ${strings.common.regions.short[region]}`;
titleImage = imgur(leagueInfo.icon);
titleDescription = literals(
strings.bot.commands.rating_leaderboard.body.top_players,
{ count: limit }
);
} else {
const { region, id } = await resolvePlayerFromCommand(interaction);
regionRatingInfo = isCurrentSeason
? await getRatingInfo(region)
: null;
if (
regionRatingInfo !== null &&
regionRatingInfo.detail !== undefined
) {
return strings.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.version!.value.entries.findIndex(
(player) => player.id === id
);
if (playerIndex === -1) {
throw new UserError(
literals(
strings.bot.commands.rating_leaderboard.body
.no_participation,
{ player: markdownEscape(accountInfo.nickname), season }
)
);
}
const halfRange = Math.round(limit / 2);
const trimmed = players.version!.value.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}`,
}));
}
);
archivedLeaderboard = await getArchivedRatingLeaderboard(
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 =
strings.bot.commands.rating_leaderboard.body.neighbors;
playersAfter =
(regionRatingInfo === null
? archivedLeaderboard.version!.value.entries.length
: regionRatingInfo.count) -
neighbors[neighbors.length - 1].position;
playerId = id;
}
const items = players.map((player) => {
const midnightIndex =
archivedLeaderboard?.version!.value.entries.findIndex(
(item) => item.id === player.id
);
const midnightScore =
midnightIndex === undefined
? player.score
: archivedLeaderboard!.version!.value.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={literals(
strings.bot.commands.rating_leaderboard.body.more,
{ count: playersAfter.toLocaleString(interaction.locale) }
)}
/>
)}
</Leaderboard.Root>
)}
</CommandWrapper>
);
},
autocomplete: autocompleteUsername,
});
}
);
+592
View File
@@ -0,0 +1,592 @@
import {
calculateWN8,
emblemURL,
getClanAccountInfo,
idToRegion,
tankIcon,
TIER_ROMAN_NUMERALS,
} from "@blitzkit/core";
import { Locale } from "discord.js";
import { clamp } from "lodash-es";
import { CommandWrapper } from "../components/CommandWrapper";
import { TitleBar } from "../components/TitleBar";
import { iconPng } from "../core/blitzkit/iconPng";
import {
averageDefinitions,
mapDefinitions,
tankDefinitions,
} from "../core/blitzkit/nonBlockingPromises";
import { RenderConfiguration } from "../core/blitzkit/renderConfiguration";
import { buttonLink } from "../core/discord/buttonLink";
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { localizationObject } from "../core/discord/localizationObject";
import { translator } from "../core/localization/translator";
import { type CommandRegistry } from "../events/interactionCreate";
import { theme } from "../stitches.config";
type WotInspectorReplaySubmission =
| { status: "error"; data: unknown; error: { message: string } }
| {
status: "ok";
data: {
download_url: string;
view_url: string;
summary: {
battle_start_time: string;
map_name: string;
battle_start_timestamp: number;
battle_duration: number;
title: string;
arena_unique_id: string;
allies: number[];
enemies: number[];
protagonist: number;
player_name: string;
vehicle: string;
exp_base: number;
exp_total: number;
credits_base: number;
credits_total: number;
room_type: number;
battle_type: number;
mastery_badge: number;
battle_result: number;
vehicle_tier: number;
vehicle_type: number;
protagonist_team: number;
winner_team: number;
details: {
achievements: unknown[];
base_capture_points: number;
base_defend_points: number;
chassis_id: unknown | null;
clan_tag: string;
clanid: number;
credits: number;
damage_assisted: number;
damage_assisted_track: number;
damage_blocked: unknown | null;
damage_made: number;
damage_received: number;
dbid: number;
death_reason: number;
distance_travelled: number;
enemies_damaged: number;
enemies_destroyed: number;
enemies_spotted: number;
entity_id: number;
exp: number;
exp_for_assist: number;
exp_for_damage: number;
exp_team_bonus: number;
gun_id: unknown | null;
hero_bonus_credits: number;
hero_bonus_exp: number;
hitpoints_left: number;
hits_bounced: number;
hits_pen: number;
hits_received: number;
hits_splash: number;
killed_by: number;
name: string;
shots_hit: number;
shots_made: number;
shots_pen: number;
shots_splash: number;
squad_index: number;
team: number;
time_alive: number;
turret_id: unknown | null;
vehicle_descr: number;
wp_points_earned: number;
wp_points_stolen: number;
};
};
};
error: {};
};
interface WotInspectorReplay {
id: string;
map_id: number;
battle_duration: number;
title: unknown | null;
player_name: string;
protagonist: number;
vehicle_descr: number;
mastery_badge: number;
exp_base: number;
enemies_spotted: number;
enemies_destroyed: number;
damage_assisted: number;
damage_made: number;
details_url: string;
download_url: string;
game_version: {
name: string;
package: string;
};
arena_unique_id: string;
download_count: number;
data_version: number;
private: boolean;
private_clan: boolean;
battle_start_time: Date;
upload_time: Date;
allies: number[];
enemies: number[];
protagonist_clan: number;
protagonist_team: number;
battle_result: number;
credits_base: number;
tags: number[];
battle_type: number;
room_type: number;
last_accessed_time: Date;
winner_team: number;
finish_reason: number;
players_data: WotInspectorReplayPlayerData[];
exp_total: number;
credits_total: number;
repair_cost: number;
exp_free: number;
exp_free_base: number;
exp_penalty: number;
credits_penalty: number;
credits_contribution_in: number;
credits_contribution_out: number;
camouflage_id: number;
}
export interface WotInspectorReplayPlayerData {
team: number;
name: string;
entity_id: number;
dbid: number;
clanid: number | null;
clan_tag: null | string;
hitpoints_left: number;
credits: number;
exp: number;
shots_made: number;
shots_hit: number;
shots_splash: number;
shots_pen: number;
damage_made: number;
damage_received: number;
damage_assisted: number;
damage_assisted_track: number;
hits_received: number;
hits_bounced: number;
hits_splash: number;
hits_pen: number;
enemies_spotted: number;
enemies_damaged: number;
enemies_destroyed: number;
time_alive: number;
distance_travelled: number;
killed_by: number;
base_capture_points: number;
base_defend_points: number;
exp_for_damage: number;
exp_for_assist: number;
exp_team_bonus: number;
wp_points_earned: number;
wp_points_stolen: number;
hero_bonus_credits: number;
hero_bonus_exp: number;
death_reason: number;
achievements: any[];
vehicle_descr: number;
turret_id: number;
gun_id: number;
chassis_id: number;
squad_index: number;
damage_blocked: number;
}
function stat(value: string | number, flex = 2) {
return (
<div
style={{
flex,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ fontSize: 16 }}>{value}</span>
</div>
);
}
async function playerListing(
locale: Locale,
player: WotInspectorReplayPlayerData,
protagonistTeam: boolean,
winningTeam: number,
recordingPlayer = false,
) {
const { unwrap } = translator(locale);
const awaitedTankAverages = await averageDefinitions;
const awaitedTankDefinitions = await tankDefinitions;
const tank = awaitedTankDefinitions.tanks[player.vehicle_descr];
const blockAccent = recordingPlayer ? "_amber" : "";
const healthAccent = protagonistTeam
? recordingPlayer
? "_amber"
: "_green"
: "_red";
// our turret ids are always off by 1 or 2 lol so imma just grab the closest
// TODO: 💀
const turret = tank.turrets.sort(
(a, b) =>
Math.abs(a.id - player.turret_id) - Math.abs(b.id - player.turret_id),
)[0];
const totalHealth = turret.health + tank.health;
const healthLeft = clamp(player.hitpoints_left / totalHealth, 0, 1);
const tankAveragesEntry = awaitedTankAverages.averages[player.vehicle_descr];
const wn8 =
tankAveragesEntry === undefined
? -1
: calculateWN8(tankAveragesEntry.mu, {
battles: 1,
damage_dealt: player.damage_made,
dropped_capture_points: player.base_defend_points,
frags: player.enemies_destroyed,
spotted: player.enemies_spotted,
wins: winningTeam === player.team ? 1 : 0,
});
return (
<div
key={player.entity_id}
style={{
display: "flex",
alignItems: "center",
gap: 8,
backgroundColor: theme.colors[`appBackground2${blockAccent}`],
padding: 8,
}}
>
<div
style={{
display: "flex",
gap: 8,
flex: 1,
overflow: "hidden",
alignItems: "center",
}}
>
<img
alt={unwrap(tank.name!)}
src={await iconPng(tankIcon(tank.id))}
style={{
width: 32,
height: 32,
objectFit: "contain",
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
// align
}}
>
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
}}
>
<span
style={{
color: theme.colors[`textLowContrast${blockAccent}`],
fontSize: 12,
}}
>
{TIER_ROMAN_NUMERALS[tank.tier]}
</span>
<span
style={{
color: theme.colors[`textHighContrast${blockAccent}`],
fontSize: 16,
}}
>
{unwrap(tank.name!)}
</span>
</div>
<span
style={{
fontSize: 16,
color: theme.colors[`textLowContrast${blockAccent}`],
whiteSpace: "nowrap",
}}
>
{player.name}
{player.clan_tag ? ` [${player.clan_tag}]` : ""}
</span>
</div>
</div>
<div
style={{
display: "flex",
flex: 1,
}}
>
{stat(player.damage_made.toLocaleString())}
{stat(wn8 === -1 ? "--" : Math.round(wn8).toLocaleString())}
{stat(player.exp.toLocaleString())}
{stat(player.enemies_destroyed, 1)}
</div>
<div
style={{
borderRadius: 2,
backgroundColor: theme.colors[`componentInteractive${healthAccent}`],
display: "flex",
width: 4,
height: "100%",
transform: "scaleY(-1)",
}}
>
<div
style={{
width: "100%",
height: `${healthLeft * 100}%`,
borderRadius: 2,
backgroundColor: theme.colors[`solidBackground${healthAccent}`],
}}
/>
</div>
</div>
);
}
function Header() {
return (
<div
style={{
padding: 8,
display: "flex",
alignItems: "center",
gap: 8,
}}
>
<div style={{ flex: 1 }} />
<div
style={{
display: "flex",
flex: 1,
}}
>
<div
style={{
flex: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Damage
</span>
</div>
<div
style={{
flex: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
WN8
</span>
</div>
<div
style={{
flex: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
XP
</span>
</div>
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span
style={{
fontSize: 16,
color: theme.colors.textLowContrast,
}}
>
Kills
</span>
</div>
</div>
</div>
);
}
export const replayCommand = new Promise<CommandRegistry>((resolve) => {
const { strings } = translator(Locale.EnglishUS);
resolve({
command: createLocalizedCommand("replay").addAttachmentOption((option) =>
option
.setName(strings.bot.commands.replay.options.file.name)
.setNameLocalizations(
localizationObject(
(strings) => strings.bot.commands.replay.options.file.name,
undefined,
true,
),
)
.setDescription(strings.bot.commands.replay.options.file.description)
.setDescriptionLocalizations(
localizationObject(
(strings) => strings.bot.commands.replay.options.file.description,
),
)
.setRequired(true),
),
async handler(interaction) {
const awaitedMapDefinitions = await mapDefinitions;
const file = interaction.options.getAttachment("file")!;
const submissionData = (await fetch(
`https://wotinspector.com/api/replay/upload/?url=${encodeURIComponent(
file.url,
)}`,
).then((response) => response.json())) as WotInspectorReplaySubmission;
if (submissionData.status === "error") {
return `# Uh oh! WoT Inspector didn't like that.\n\nThere was an error processing your replay. Get help on [the official Discord server](https://discord.gg/nDt7AjGJQH).`;
}
const replayId = submissionData.data.download_url.split("/").at(-1)!;
const replayData = (await fetch(
`https://api.wotinspector.com/v2/blitz/replays/${replayId}/`,
).then((response) => response.json())) as WotInspectorReplay;
const antagonistTeamId = 3 - replayData.protagonist_team;
const players = replayData.players_data.sort(
(a, b) => b.damage_made - a.damage_made,
);
const clan = (
await getClanAccountInfo(
idToRegion(replayData.protagonist),
replayData.protagonist,
["clan"],
)
)?.clan;
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
return [
new RenderConfiguration({ width: 960 }),
<CommandWrapper>
<TitleBar
title={replayData.player_name}
image={clanImage}
description={`${
replayData.protagonist_team === replayData.winner_team
? "Victory"
: "Defeat"
} • ${awaitedMapDefinitions.maps[replayData.map_id].name}`}
/>
<div
style={{
display: "flex",
gap: 8,
}}
>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<Header />
{await Promise.all(
players
.filter(
(player) => player.team === replayData.protagonist_team,
)
.map((player) =>
playerListing(
interaction.locale,
player,
true,
replayData.winner_team,
player.dbid === replayData.protagonist,
),
),
)}
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<Header />
{await Promise.all(
players
.filter((player) => player.team === antagonistTeamId)
.map((player) =>
playerListing(
interaction.locale,
player,
false,
replayData.winner_team,
),
),
)}
</div>
</div>
</CommandWrapper>,
buttonLink(submissionData.data.view_url, "WoT Inspector"),
buttonLink(submissionData.data.download_url, "Download"),
];
},
});
});
+626
View File
@@ -0,0 +1,626 @@
import {
asset,
emblemURL,
equipmentPriceMatrix,
getAccountInfo,
getClanAccountInfo,
getTankStats,
TANK_ICONS,
TankType,
TIER_ROMAN_NUMERALS,
} from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import { escapeMarkdown, Locale } from "discord.js";
import { CommandWrapper } from "../components/CommandWrapper";
import { TitleBar } from "../components/TitleBar";
import { resolveTankId } from "../core/blitz/resolveTankId";
import { buildTechTreeLine } from "../core/blitzkit/buildTechTreeLine";
import { iconPng } from "../core/blitzkit/iconPng";
import { tankDefinitions } from "../core/blitzkit/nonBlockingPromises";
import { resolveAncestry } from "../core/blitzkit/resolveAncestry";
import { tankIconPng } from "../core/blitzkit/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 { type CommandRegistry } from "../events/interactionCreate";
import { theme } from "../stitches.config";
export const researchCommand = new Promise<CommandRegistry>((resolve) => {
const { strings } = translator(Locale.EnglishUS);
resolve({
command: createLocalizedCommand("research")
.addStringOption((option) =>
option
.setName(strings.bot.commands.research.options.target_tank.name!)
.setNameLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.research.options.target_tank.name,
undefined,
true,
),
)
.setDescription(
strings.bot.commands.research.options.target_tank.description,
)
.setDescriptionLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.research.options.target_tank.description,
),
)
.setAutocomplete(true)
.setRequired(true),
)
.addStringOption((option) =>
option
.setName(strings.bot.commands.research.options.starting_tank.name!)
.setNameLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.research.options.starting_tank.name,
undefined,
true,
),
)
.setDescription(
strings.bot.commands.research.options.starting_tank.description,
)
.setDescriptionLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.research.options.starting_tank.description,
),
)
.setAutocomplete(true)
.setRequired(false),
)
.addStringOption(addUsernameChoices),
async handler(interaction) {
const { strings, unwrap } = 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.tanks[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 literals(
strings.bot.commands.research.errors.start_end_equal,
{ tank: escapeMarkdown(unwrap(targettank.name!)) },
);
}
const startingTank = awaitedTankDefinitions.tanks[startingTankId];
if (startingTank.tier > targetTank.tier) {
return literals(strings.bot.commands.research.errors.unordered, {
tank_1: escapeMarkdown(unwrap(startingtank.name!)),
tank_2: escapeMarkdown(unwrap(targettank.name!)),
});
}
if (!targetTankAncestry.includes(startingTankId)) {
return literals(
strings.bot.commands.research.errors.tanks_not_on_line,
{
tank_1: escapeMarkdown(unwrap(targettank.name!)),
tank_2: escapeMarkdown(unwrap(startingtank.name!)),
},
);
}
} else {
if (targetTank.type !== TankType.TANK_TYPE_RESEARCHABLE) {
return literals(strings.bot.commands.research.errors.non_tech_tree, {
tank: escapeMarkdown(unwrap(targettank.name!)),
});
}
if (targetTank.tier === 1) {
return literals(strings.bot.commands.research.errors.tier_1, {
tank: escapeMarkdown(unwrap(targettank.name!)),
});
}
const tankStats = await getTankStats(region, id);
if (tankStats === null) {
return strings.bot.common.errors.no_tank_stats;
}
if (tankStats.some(({ tank_id }) => tank_id === targetTankId)) {
return literals(
strings.bot.commands.research.errors.already_researched,
{ tank: escapeMarkdown(unwrap(targettank.name!)) },
);
}
const foundAncestor = targetTankAncestry.find(
(id) =>
awaitedTankDefinitions.tanks[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.tanks[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.research_cost !== undefined &&
typeof turret.research_cost.research_cost_type!.value === "number"
) {
turretXps.set(
turret.id,
turret.research_cost.research_cost_type!.value,
);
}
turret.guns.forEach((gun) => {
if (
gun.research_cost !== undefined &&
typeof gun.research_cost.research_cost_type!.value === "number"
) {
gunXps.set(gun.id, gun.research_cost.research_cost_type!.value);
}
});
});
tank.engines.forEach((engine) => {
if (
engine.research_cost !== undefined &&
typeof engine.research_cost.research_cost_type!.value === "number"
) {
engineXps.set(
engine.id,
engine.research_cost.research_cost_type!.value,
);
}
});
tank.tracks.forEach((track) => {
if (
track.research_cost !== undefined &&
typeof track.research_cost.research_cost_type!.value === "number"
) {
trackXps.set(
track.id,
track.research_cost.research_cost_type!.value,
);
}
});
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.research_cost!.research_cost_type!.value as number,
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={`${strings.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,
}}
>
{strings.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
alt="XP icon"
src={await iconPng(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
alt="Silver icon"
src={await iconPng(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
alt="Silver icon"
src={await iconPng(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.tanks[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%",
}}
>
<img
alt="Tank icon"
src={await tankIconPng(id)}
style={{
height: 100,
width: 100,
objectFit: "contain",
position: "absolute",
left: "25%",
top: 0,
}}
/>
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
justifyContent: "center",
position: "absolute",
bottom: 8,
left: "50%",
transform: "translateX(-50%)",
}}
>
<img
alt={unwrap(tank.name!)}
src={TANK_ICONS[tank.class]}
width={16}
height={16}
/>
<span
style={{
color: theme.colors.textHighContrast,
}}
>
{unwrap(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
alt="XP icon"
src={await iconPng(asset("icons/currencies/xp.webp"))}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{literals(
strings.bot.commands.research.body.research,
{
cost: costs[index].research?.toLocaleString(
interaction.locale,
),
},
)}
</span>
</div>
{costs[index].upgrades > 0 && (
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
}}
>
<img
alt="Free XP icon"
src={await iconPng(
asset("icons/currencies/free-xp.webp"),
)}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{literals(
strings.bot.commands.research.body.upgrades,
{ cost: costs[index].upgrades.toLocaleString() },
)}
</span>
</div>
)}
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
}}
>
<img
alt="Silver icon"
src={await iconPng(
asset("icons/currencies/silver.webp"),
)}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{literals(
strings.bot.commands.research.body.purchase,
{
cost: costs[index].purchase.toLocaleString(
interaction.locale,
),
},
)}
</span>
</div>
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
}}
>
<img
alt="Silver icon"
src={await iconPng(
asset("icons/currencies/silver.webp"),
)}
width={16}
height={16}
/>
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{literals(
strings.bot.commands.research.body.equipment,
{
cost: costs[index].equipment.toLocaleString(
interaction.locale,
),
},
)}
</span>
</div>
</div>
</div>
);
}),
)}
</div>
</CommandWrapper>
);
return startingTankRaw
? image
: [strings.bot.commands.research.body.estimation, image];
},
autocomplete(interaction) {
autocompleteTanks(interaction, true, ["target-tank", "starting-tank"]);
},
});
});
+74
View File
@@ -0,0 +1,74 @@
import { type ClanList, fetchBlitz, type Region } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import { Locale } from "discord.js";
import markdownEscape from "markdown-escape";
import { addClanChoices } from "../core/discord/addClanChoices";
import { addRegionChoices } from "../core/discord/addRegionChoices";
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { embedInfo } from "../core/discord/embedInfo";
import { localizationObject } from "../core/discord/localizationObject";
import { translator } from "../core/localization/translator";
import type { CommandRegistry } from "../events/interactionCreate";
const DEFAULT_LIMIT = 25;
export const searchClansCommand = new Promise<CommandRegistry>((resolve) => {
resolve({
command: createLocalizedCommand("search-clans")
.addStringOption(addRegionChoices)
.addStringOption((option) =>
addClanChoices(option).setAutocomplete(false)
)
.addIntegerOption((option) => {
const { strings } = translator(Locale.EnglishUS);
return option
.setName(strings.bot.commands.search_clans.options.limit.name)
.setNameLocalizations(
localizationObject(
(strings) => strings.bot.commands.search_clans.options.limit.name,
undefined,
true
)
)
.setDescription(
literals(
strings.bot.commands.search_clans.options.limit.description,
{ limit: DEFAULT_LIMIT }
)
)
.setDescriptionLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.search_clans.options.limit.description,
{ limit: DEFAULT_LIMIT }
)
)
.setMinValue(1)
.setMaxValue(100);
}),
async handler(interaction) {
const { strings } = translator(interaction.locale);
const region = interaction.options.getString("region") as Region;
const clan = interaction.options.getString("clan")!;
const limit = interaction.options.getInteger("limit") ?? DEFAULT_LIMIT;
const clanList = await fetchBlitz<ClanList>(region, "clans/list", {
search: clan,
limit,
});
return embedInfo(
literals(strings.bot.commands.search_clans.body.title, {
search: markdownEscape(clan),
region: strings.common.regions.normal[region],
}),
clanList.length === 0
? strings.bot.commands.search_clans.body.no_results
: `\`\`\`\n${clanList
.map((clan) => `${clan.name} [${clan.tag}]`)
.join("\n")}\n\`\`\``
);
},
});
});
@@ -0,0 +1,83 @@
import {
type AccountList,
fetchBlitz,
type Region,
usernamePattern,
} from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import { Locale } from "discord.js";
import markdownEscape from "markdown-escape";
import { addRegionChoices } from "../core/discord/addRegionChoices";
import { addUsernameChoices } from "../core/discord/addUsernameChoices";
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { embedInfo } from "../core/discord/embedInfo";
import { localizationObject } from "../core/discord/localizationObject";
import { translator } from "../core/localization/translator";
import type { CommandRegistry } from "../events/interactionCreate";
const DEFAULT_LIMIT = 25;
export const searchPlayersCommand = new Promise<CommandRegistry>((resolve) => {
const { strings } = translator(Locale.EnglishUS);
resolve({
command: createLocalizedCommand("search-players")
.addStringOption(addRegionChoices)
.addStringOption((option) =>
addUsernameChoices(option).setAutocomplete(false).setRequired(true)
)
.addIntegerOption((option) =>
option
.setName(strings.bot.commands.search_players.options.limit.name)
.setNameLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.search_players.options.limit.name,
undefined,
true
)
)
.setDescription(
literals(
strings.bot.commands.search_players.options.limit.description,
{ limit: DEFAULT_LIMIT }
)
)
.setDescriptionLocalizations(
localizationObject(
(strings) =>
strings.bot.commands.search_players.options.limit.description,
{ limit: DEFAULT_LIMIT }
)
)
.setMinValue(1)
.setMaxValue(100)
),
async handler(interaction) {
const { strings } = translator(interaction.locale);
const region = interaction.options.getString("region") as Region;
const name = interaction.options.getString("username")!;
const limit = interaction.options.getInteger("limit") ?? 25;
const trimmedSearch = name.trim();
const players = usernamePattern.test(trimmedSearch)
? await fetchBlitz<AccountList>(region, "account/list", {
search: trimmedSearch,
limit,
})
: [];
return embedInfo(
literals(strings.bot.commands.search_players.body.title, {
search: markdownEscape(trimmedSearch),
region: strings.common.regions.normal[region],
}),
`\`\`\`${
players.length === 0
? strings.bot.commands.search_players.body.no_results
: players.map((player) => player.nickname).join("\n")
}\`\`\``
);
},
});
});
+102
View File
@@ -0,0 +1,102 @@
import { emblemURL, getAccountInfo, getClanAccountInfo } from "@blitzkit/core";
import { Locale } from "discord.js";
import { AllStatsOverview } from "../components/AllStatsOverview";
import { CommandWrapper } from "../components/CommandWrapper";
import { NoData } from "../components/NoData";
import { TitleBar } from "../components/TitleBar";
import { filtersToDescription } from "../core/blitzkit/filtersToDescription";
import { filterStats, type StatFilters } from "../core/blitzstars/filterStats";
import { getStatsInPeriod } from "../core/blitzstars/getStatsInPeriod";
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 { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { getFiltersFromButton } from "../core/discord/getFiltersFromButton";
import { resolvePeriodFromButton } from "../core/discord/resolvePeriodFromButton";
import { type ResolvedPeriod } from "../core/discord/resolvePeriodFromCommand";
import { resolvePlayerFromButton } from "../core/discord/resolvePlayerFromButton";
import { type ResolvedPlayer } from "../core/discord/resolvePlayerFromCommand";
import { translator } from "../core/localization/translator";
import type { CommandRegistry } from "../events/interactionCreate";
async function render(
{ region, id }: ResolvedPlayer,
{ start, end, name }: ResolvedPeriod,
filters: StatFilters,
locale: Locale,
) {
const { nickname } = await getAccountInfo(region, id);
const clan = (await getClanAccountInfo(region, id, ["clan"]))?.clan;
const clanImage = clan ? emblemURL(clan.emblem_set_id) : undefined;
const diffedTankStats = await getStatsInPeriod(
region,
id,
start,
end,
locale,
);
const { stats, supplementary } = await filterStats(diffedTankStats, filters);
const filterDescriptions = await filtersToDescription(filters, locale);
return (
<CommandWrapper>
<TitleBar
title={nickname}
image={clanImage}
description={`${name} • ${filterDescriptions}`}
/>
{!stats.battles && <NoData type="battles_in_period" locale={locale} />}
{stats.battles > 0 && (
<AllStatsOverview
locale={locale}
stats={stats}
supplementaryStats={supplementary}
/>
)}
</CommandWrapper>
);
}
export const statsCommand = new Promise<CommandRegistry>(async (resolve) => {
resolve({
command: await addPeriodicFilterOptions(
createLocalizedCommand("stats"),
(option) => option.addStringOption(addUsernameChoices),
),
async handler(interaction) {
const { strings } = translator(interaction.locale);
return strings.bot.commands.dead;
// 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 Promise.all([
// render(player, period, filters, interaction.locale),
// buttonRefresh(interaction, path),
// 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 render(player, period, filters, interaction.locale);
},
});
});
+60
View File
@@ -0,0 +1,60 @@
import { gameDefinitions } from "../core/blitzkit/nonBlockingPromises";
import { addFilterOptions } from "../core/discord/addFilterOptions";
import { addUsernameChoices } from "../core/discord/addUsernameChoices";
import { autocompleteTanks } from "../core/discord/autocompleteTanks";
import { autocompleteUsername } from "../core/discord/autocompleteUsername";
import { createLocalizedCommand } from "../core/discord/createLocalizedCommand";
import { getFiltersFromButton } from "../core/discord/getFiltersFromButton";
import { resolvePeriodFromButton } from "../core/discord/resolvePeriodFromButton";
import { resolvePlayerFromButton } from "../core/discord/resolvePlayerFromButton";
import { translator } from "../core/localization/translator";
import { type CommandRegistry } from "../events/interactionCreate";
import { renderBreakdown } from "./breakdown/render";
export const todayCommand = new Promise<CommandRegistry>(async (resolve) => {
const awaitedGameDefinitions = await gameDefinitions;
resolve({
command: addFilterOptions(
createLocalizedCommand("today"),
awaitedGameDefinitions.nations,
).addStringOption(addUsernameChoices),
async handler(interaction) {
const { strings } = translator(interaction.locale);
return strings.bot.commands.dead;
// const player = await resolvePlayerFromCommand(interaction);
// const period = resolvePeriodFromCommand(
// player.region,
// interaction,
// 'today',
// );
// const filters = await getFiltersFromCommand(interaction);
// const path = commandToURL(interaction, {
// ...player,
// ...getCustomPeriodParams(interaction, true),
// ...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);
},
});
});
@@ -0,0 +1,50 @@
import { type 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>
);
}
@@ -0,0 +1,72 @@
import { theme } from '../../../../stitches.config';
import { Glow } from './components/Glow';
export interface WN8DisplayProps {
subtitle: string;
color: string;
value: string | number;
}
export function HeroStat({ color, subtitle, value }: WN8DisplayProps) {
return (
<div
style={{
width: 128 * 2, // idk why twice the width turns into the correct width
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Glow color={color} style={{ marginBottom: -16 }} />
<div
style={{
width: 128,
height: 80,
backgroundColor: color,
borderRadius: 40,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
}}
>
<span
style={{
display: 'block',
fontWeight: 900,
fontSize: 32,
color: theme.colors.textHighContrast,
lineHeight: '26px',
letterSpacing: '-0.03em',
}}
>
{value}
</span>
<span
style={{
display: 'block',
fontSize: 16,
lineHeight: '16px',
color: theme.colors.textHighContrast,
}}
>
{subtitle}
</span>
</div>
</div>
<Glow color={color} direction="reverse" style={{ marginTop: -16 }} />
</div>
);
}
@@ -0,0 +1,47 @@
import { theme } from '../../../stitches.config';
export enum WN8SurroundingStatAlign {
Left,
Right,
}
export interface WN8SurroundingStatProps {
label: string;
value: string | number;
align?: WN8SurroundingStatAlign;
padded?: boolean;
}
export function WN8SurroundingStat({
label,
value,
align = WN8SurroundingStatAlign.Left,
padded = false,
}: WN8SurroundingStatProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems:
align === WN8SurroundingStatAlign.Left ? 'flex-start' : 'flex-end',
paddingLeft: padded && align === WN8SurroundingStatAlign.Left ? 32 : 0,
paddingRight:
padded && align === WN8SurroundingStatAlign.Right ? 32 : 0,
}}
>
<span
style={{
color: theme.colors.textHighContrast,
fontSize: 24,
fontWeight: 700,
}}
>
{value}
</span>
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
{label}
</span>
</div>
);
}
@@ -0,0 +1,130 @@
import {
getWN8Percentile,
Percentile,
type SupplementaryStats,
} from '@blitzkit/core';
import { Locale } from 'discord.js';
import { translator } from '../../core/localization/translator';
import { PERCENTILE_COLORS } from '../PercentileIndicator/constants';
import { HeroStat } from './components/HeroStat';
import {
WN8SurroundingStat,
WN8SurroundingStatAlign,
} from './components/WN8SurroundingStat';
export interface AllStatsOverviewProps {
stats: {
battles: number;
wins: number;
damage_dealt: number;
damage_received: number;
survived_battles: number;
hits: number;
shots: number;
frags: number;
};
supplementaryStats: SupplementaryStats;
locale: Locale;
}
export function AllStatsOverview({
stats,
locale,
supplementaryStats,
}: AllStatsOverviewProps) {
const { strings } = translator(locale);
const percentile =
typeof supplementaryStats.WN8 === 'number'
? getWN8Percentile(supplementaryStats.WN8)
: Percentile.VeryBad;
const color = PERCENTILE_COLORS[percentile];
const heroStat =
typeof supplementaryStats.WN8 === 'number'
? supplementaryStats.WN8.toFixed(0)
: '--';
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: -64,
width: '100%',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
alignItems: 'flex-end',
flex: 1,
}}
>
<WN8SurroundingStat
label={strings.bot.common.hero_stats.battles}
value={stats.battles.toLocaleString()}
align={WN8SurroundingStatAlign.Right}
/>
<WN8SurroundingStat
label={strings.bot.common.hero_stats.winrate}
value={`${(100 * (stats.wins / stats.battles)).toFixed(2)}%`}
align={WN8SurroundingStatAlign.Right}
padded
/>
<WN8SurroundingStat
label={strings.bot.common.hero_stats.damage}
value={Math.round(
stats.damage_dealt / stats.battles,
).toLocaleString()}
align={WN8SurroundingStatAlign.Right}
padded
/>
<WN8SurroundingStat
label={strings.bot.common.hero_stats.damage_ratio}
value={(stats.damage_dealt / stats.damage_received).toFixed(2)}
align={WN8SurroundingStatAlign.Right}
/>
</div>
<HeroStat
value={heroStat}
subtitle={strings.common.wn8_percentile[percentile]}
color={color}
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
flex: 1,
}}
>
<WN8SurroundingStat
label={strings.bot.common.hero_stats.tier}
value={
supplementaryStats.tier ? supplementaryStats.tier.toFixed(2) : '--'
}
/>
<WN8SurroundingStat
label={strings.bot.common.hero_stats.survival}
value={`${(100 * (stats.survived_battles / stats.battles)).toFixed(
2,
)}%`}
padded
/>
<WN8SurroundingStat
label={strings.bot.common.hero_stats.accuracy}
value={`${(100 * (stats.hits / stats.shots)).toFixed(2)}%`}
padded
/>
<WN8SurroundingStat
label={strings.bot.common.hero_stats.kills}
value={(stats.frags / stats.battles).toFixed(2)}
/>
</div>
</div>
);
}
@@ -0,0 +1,16 @@
import { type ReactNode } from "react";
interface RootProps {
children: ReactNode;
}
export function Root({ children }: RootProps) {
return (
<div
className="session-tracker-root"
style={{ display: "flex", flexDirection: "column", gap: 8 }}
>
{children}
</div>
);
}
@@ -0,0 +1,126 @@
import {
Percentile,
TankClass,
TankType,
TREE_TYPE_ICONS,
} from "@blitzkit/core";
import { createColors } from "bepaint";
import {
type AccentColor,
type GrayColor,
PALETTES,
} from "../../../core/radix/radixColors";
import { RowStat } from "./RowStat";
export interface RowStatItem {
title: string;
current?: number | string;
career?: number | string;
delta?: number;
percentile?: Percentile;
}
interface RowProps {
displayType?: "tank" | "summary";
naked?: boolean;
title: string;
minimized?: boolean;
type?: TankType;
class?: TankClass;
color?: AccentColor | GrayColor;
stats: (RowStatItem | undefined)[];
}
export function Row({
color = "slate",
stats,
title,
minimized,
class: tankClass,
type,
naked = false,
displayType,
}: RowProps) {
const palette = PALETTES[`${color}Dark`];
const theme = createColors(palette);
return (
<div
className="session-tracker-row"
style={{
maxWidth: naked ? 600 : Infinity,
display: "flex",
flexDirection: "column",
borderRadius: 4,
backgroundColor: naked
? `${theme.appBackground2}c0`
: theme.appBackground2,
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 8,
gap: 4,
backgroundColor: naked
? `${theme.componentInteractive}c0`
: theme.componentInteractive,
}}
>
{displayType === "tank" && tankClass !== undefined && (
<img
alt={TankClass[tankClass]}
src={
TREE_TYPE_ICONS[type ?? TankType.TANK_TYPE_RESEARCHABLE][
tankClass
]
}
style={{ width: 16, height: 16 }}
/>
)}
<span
style={{
color:
displayType === "tank"
? type === TankType.TANK_TYPE_COLLECTOR
? theme.textLowContrast_blue
: type === TankType.TANK_TYPE_PREMIUM
? theme.textLowContrast_amber
: theme.textHighContrast
: theme.textLowContrast,
fontWeight: 900,
fontSize: 16,
}}
>
{title}
</span>
</div>
<div
style={{
display: "flex",
paddingTop: 8,
paddingBottom: 8,
}}
>
{stats.map(
(row, index) =>
row && (
<RowStat
minimized={minimized}
key={index}
name={`${row.title} • ${row.career ?? "--"}`}
value={row.current ?? "--"}
delta={row.delta}
percentile={row.percentile}
/>
),
)}
</div>
</div>
);
}
@@ -0,0 +1,89 @@
import { Percentile } from "@blitzkit/core";
import { createColors } from "bepaint";
import {
type AccentColor,
type GrayColor,
PALETTES,
} from "../../../core/radix/radixColors";
import { PercentileIndicator } from "../../PercentileIndicator";
interface RowStatProps {
name: string;
value?: string | number;
delta?: number;
percentile?: Percentile;
minimized?: boolean;
color?: AccentColor | GrayColor;
}
export function RowStat({
name,
value,
delta,
percentile,
minimized = false,
color = "slate",
}: RowStatProps) {
const theme = createColors(PALETTES[`${color}Dark`]);
return (
<div
className="session-tracker-row-stat"
style={{
display: "flex",
flexDirection: "column",
gap: 4,
alignItems: "center",
justifyContent: "center",
flex: 1,
}}
>
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
justifyContent: "center",
}}
>
{percentile === undefined && delta !== undefined && delta !== 0 && (
<img
alt={delta > 0 ? "Increase" : "Decrease"}
src={
(delta ?? 0) > 0
? "https://i.imgur.com/qbjiXa1.png"
: "https://i.imgur.com/3uyNhun.png"
}
style={{ width: 12, height: 12 }}
/>
)}
{percentile !== undefined && (
<PercentileIndicator percentile={percentile} />
)}
<span
style={{
color: theme.textHighContrast,
fontWeight: 700,
fontSize: 16,
textAlign: "center",
}}
>
{value}
</span>
</div>
{!minimized && (
<span
style={{
color: theme.textLowContrast,
textAlign: "center",
fontSize: 12,
}}
>
{name}
</span>
)}
</div>
);
}
@@ -0,0 +1,3 @@
export * from './components/Root';
export * from './components/Row';
export * from './components/RowStat';
@@ -0,0 +1,26 @@
import { type ReactNode } from "react";
import { theme } from "../stitches.config";
interface CommandWrapperProps {
children: ReactNode;
fat?: boolean;
}
export function CommandWrapper({ children, fat }: CommandWrapperProps) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
padding: `16px ${fat ? 0 : 16}px`,
gap: 16,
width: "100%",
color: theme.colors.textHighContrast,
backgroundColor: theme.colors.appBackground1,
position: "relative",
}}
>
{children}
</div>
);
}
@@ -0,0 +1,21 @@
interface DeltaCaretProps {
delta: number;
}
export function DeltaCaret({ delta }: DeltaCaretProps) {
return (
<img
alt={delta > 0 ? 'Increase' : 'Decrease'}
style={{
width: 16,
height: 16,
objectFit: 'contain',
}}
src={
delta > 0
? 'https://i.imgur.com/gjI4a5f.png'
: 'https://i.imgur.com/lLGZygN.png'
}
/>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { asset } from '@blitzkit/core';
interface FlagProps {
nation: string;
}
export function Flag({ nation }: FlagProps) {
return (
<div
style={{
width: 22,
height: 22,
overflow: 'hidden',
}}
>
<img alt={nation} src={asset(`flags/circle/${nation}.webp`)} />
</div>
);
}
@@ -0,0 +1,95 @@
import { Percentile } from '@blitzkit/core';
import { theme } from '../../../stitches.config';
import { PercentileIndicator } from '../../PercentileIndicator';
interface CardItem {
label: string;
value: string | number | undefined;
percentile?: Percentile;
}
interface CardProps {
items: CardItem[];
title: string;
}
export function Card({ items, title }: CardProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 16,
flex: 1,
borderRadius: 4,
backgroundColor: theme.colors.componentInteractive,
padding: 8,
}}
>
<span
style={{
color: theme.colors.textHighContrast,
fontSize: 24,
}}
>
{title}
</span>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{items.map((item) => (
<div
key={item.label}
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
{item.label}
</span>
<div
style={{
flex: 1,
height: 1,
borderRadius: 0.5,
backgroundColor: theme.colors.borderNonInteractive,
}}
/>
<div
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
justifyContent: 'center',
}}
>
{item.percentile !== undefined && (
<PercentileIndicator percentile={item.percentile} />
)}
<span
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{item.value === undefined || Number.isNaN(item.value)
? '--'
: item.value}
</span>
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,17 @@
interface RootProps {
children: React.ReactNode;
}
export function Root({ children }: RootProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
{children}
</div>
);
}
@@ -0,0 +1,7 @@
interface RowProps {
children: React.ReactNode;
}
export function Row({ children }: RowProps) {
return <div style={{ display: 'flex', gap: 8 }}>{children}</div>;
}
@@ -0,0 +1,149 @@
import {
type BlitzStats,
getWN8Percentile,
isNumber,
type SupplementaryStats,
} from '@blitzkit/core';
import { Locale } from 'discord.js';
import { translator } from '../../core/localization/translator';
import { Card } from './components/Card';
import { Root } from './components/Root';
import { Row } from './components/Row';
interface GenericAllStatsProps {
stats: BlitzStats;
supplementaryStats?: SupplementaryStats;
locale: Locale;
}
export function GenericAllStats({
stats,
supplementaryStats,
locale,
}: GenericAllStatsProps) {
const { strings } = translator(locale);
const damageRatio = stats.damage_dealt / stats.damage_received;
const killsToDeathRatio =
stats.frags / (stats.battles - stats.survived_battles);
return (
<Root>
<Row>
<Card
title={strings.bot.commands.full_stats.body.cards.games.title}
items={[
{
label: strings.bot.commands.full_stats.body.cards.games.winrate,
value: `${(100 * (stats.wins / stats.battles)).toFixed(2)}%`,
},
{
label: strings.bot.commands.full_stats.body.cards.games.battles,
value: stats.battles,
},
{
label:
strings.bot.commands.full_stats.body.cards.games.wins_losses,
value: `${stats.wins} / ${stats.losses}`,
},
]}
/>
<Card
title={strings.bot.commands.full_stats.body.cards.efficiency.title}
items={[
{
label: strings.bot.commands.full_stats.body.cards.efficiency.wn8,
value:
supplementaryStats && isNumber(supplementaryStats.WN8)
? supplementaryStats.WN8!.toFixed(0)
: undefined,
percentile: isNumber(supplementaryStats?.WN8)
? getWN8Percentile(supplementaryStats!.WN8!)
: undefined,
},
{
label:
strings.bot.commands.full_stats.body.cards.efficiency.survival,
value: `${(
100 *
(stats.survived_battles / stats.battles)
).toFixed(2)}%`,
},
{
label:
strings.bot.commands.full_stats.body.cards.efficiency.accuracy,
value: `${((stats.hits / stats.shots) * 100).toFixed(2)}%`,
},
]}
/>
</Row>
<Row>
<Card
title={strings.bot.commands.full_stats.body.cards.lethality.title}
items={[
{
label:
strings.bot.commands.full_stats.body.cards.lethality
.average_damage,
value: (stats.damage_dealt / stats.battles).toFixed(0),
},
{
label:
strings.bot.commands.full_stats.body.cards.lethality
.damage_per_tier,
value: supplementaryStats?.tier
? (
stats.damage_dealt /
stats.battles /
supplementaryStats.tier
).toFixed(0)
: undefined,
},
{
label:
strings.bot.commands.full_stats.body.cards.lethality
.damage_ratio,
value: isFinite(damageRatio) ? damageRatio.toFixed(2) : undefined,
},
{
label:
strings.bot.commands.full_stats.body.cards.lethality
.average_kills,
value: (stats.frags / stats.battles).toFixed(0),
},
]}
/>
<Card
title={strings.bot.commands.full_stats.body.cards.miscellaneous.title}
items={[
{
label:
strings.bot.commands.full_stats.body.cards.miscellaneous
.average_tier,
value: supplementaryStats?.tier?.toFixed(2),
},
{
label:
strings.bot.commands.full_stats.body.cards.miscellaneous
.average_spots,
value: (stats.spotted / stats.battles).toFixed(2),
},
{
label:
strings.bot.commands.full_stats.body.cards.miscellaneous
.average_xp,
value: (stats.xp / stats.battles).toFixed(0),
},
{
label:
strings.bot.commands.full_stats.body.cards.miscellaneous
.kills_to_death_ratio,
value: isFinite(killsToDeathRatio)
? killsToDeathRatio.toFixed(2)
: undefined,
},
]}
/>
</Row>
</Root>
);
}
@@ -0,0 +1,16 @@
import { type GenericStat } from "..";
import { GenericStatsRow } from "./GenericStatsRow";
interface GenericStatsColumnProps {
stats: GenericStat[];
}
export function GenericStatsColumn({ stats }: GenericStatsColumnProps) {
return (
<div style={{ flex: 1, display: "flex", flexDirection: "column", gap: 8 }}>
{stats.map((stat) => (
<GenericStatsRow key={stat[0]} stat={stat} />
))}
</div>
);
}
@@ -0,0 +1,46 @@
import { type GenericStat } from "..";
import { theme } from "../../../stitches.config";
import { PercentileIndicator } from "../../PercentileIndicator";
interface GenericStatsRowProps {
stat: GenericStat;
}
export function GenericStatsRow({ stat }: GenericStatsRowProps) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span
style={{
color: theme.colors.textLowContrast,
fontSize: 16,
whiteSpace: "nowrap",
maxWidth: 128,
overflow: "hidden",
}}
>
{stat[0]}
</span>
<span
style={{
color: theme.colors.textHighContrast,
fontWeight: 700,
fontSize: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 4,
}}
>
{stat[2] !== undefined && <PercentileIndicator percentile={stat[2]} />}
{stat[1]}
</span>
</div>
);
}
@@ -0,0 +1,34 @@
import { Percentile } from '@blitzkit/core';
import { GenericStatsColumn } from './components/GenericStatsColumn';
export type GenericStat =
| [string, string | number | undefined]
| [string, string | number | undefined, Percentile | undefined];
export interface GenericStatsProps {
stats: GenericStat[];
columnCount?: number;
}
export function GenericStats({ stats, columnCount = 2 }: GenericStatsProps) {
const filteredStats = stats.filter(([, value]) => value !== undefined);
const itemsPerRow = Math.ceil(filteredStats.length / columnCount);
const columns: GenericStat[][] = [];
filteredStats.forEach((stat, index) => {
const column = Math.floor(index / itemsPerRow);
if (!columns[column]) columns[column] = [];
columns[column].push(stat);
});
return (
<div style={{ display: 'flex', gap: 32 }}>
{columns.map((stats) => (
<GenericStatsColumn
key={stats.map((stat) => stat[0]).join('-')}
stats={stats}
/>
))}
</div>
);
}
@@ -0,0 +1,11 @@
export enum LineColor {
Red,
Green,
Blue,
White,
}
export enum LineStyle {
Solid,
Dashes,
DashesAndDots,
}
@@ -0,0 +1,65 @@
import { blue, green, red, slateDark } from '@radix-ui/colors';
import { GRAPH_HEIGHT, GRAPH_WIDTH } from '../Root/constants';
import { LineColor, LineStyle } from './constants';
const LINE_COLORS = {
[LineColor.Red]: red.red9,
[LineColor.Green]: green.green9,
[LineColor.Blue]: blue.blue9,
[LineColor.White]: slateDark.slate11,
};
const LINE_STYLES = {
[LineStyle.Solid]: undefined,
[LineStyle.Dashes]: '8, 8',
[LineStyle.DashesAndDots]: '1, 4, 8, 4',
};
export type PlotItem = [number, number];
type Plot = PlotItem[];
interface LineProps {
plot: Plot;
minY?: number;
maxY?: number;
color?: LineColor;
style?: LineStyle;
lineWidth?: number;
}
export function Line({
plot,
minY: providedMinY,
maxY: providedMaxY,
color = LineColor.Red,
style = LineStyle.Solid,
lineWidth = 2,
}: LineProps) {
const xs = plot.map(([x]) => x);
const minX = Math.min(...xs);
const maxX = Math.max(...xs);
const ys = plot.map(([, y]) => y);
const minY = providedMinY ?? Math.min(...ys);
const maxY = providedMaxY ?? Math.max(...ys);
const rangeX = maxX - minX;
const rangeY = maxY - minY;
const points = plot
.map(
([x, y]) =>
`${(x - minX) * (GRAPH_WIDTH / rangeX)},${
GRAPH_HEIGHT - (y - minY) * (GRAPH_HEIGHT / rangeY)
}`,
)
.join(' ');
return (
<polyline
vector-effect="non-scaling-stroke"
fill="none"
stroke={LINE_COLORS[color]}
strokeWidth={lineWidth}
stroke-dasharray={LINE_STYLES[style]}
points={points}
/>
);
}
@@ -0,0 +1,4 @@
export enum MarginOrientation {
Vertical,
Horizontal,
}
@@ -0,0 +1,49 @@
import { times } from 'lodash-es';
import { theme } from '../../../../stitches.config';
import { type MarginInputProps } from '../Root';
import { MarginOrientation } from './constants';
interface MarginProps extends MarginInputProps {
verticalSeparations: number;
orientation?: MarginOrientation;
}
export function Margin({
verticalSeparations,
max,
min,
suffix,
precision = 0,
orientation = MarginOrientation.Vertical,
}: MarginProps) {
const isVertical = orientation === MarginOrientation.Vertical;
return (
<div
style={{
paddingLeft: isVertical ? 0 : 64,
width: isVertical ? 48 : '100%',
display: 'flex',
flexDirection: isVertical ? 'column' : 'row-reverse',
justifyContent: 'space-between',
}}
>
{times(verticalSeparations, (index) => (
<span
key={index}
style={{
fontSize: 16,
color: theme.colors.textHighContrast,
}}
>
{`${(
(max - min) *
((verticalSeparations - (index + 1)) /
(verticalSeparations - 1)) +
min
).toFixed(precision)}${suffix ?? ''}`}
</span>
))}
</div>
);
}
@@ -0,0 +1,2 @@
export const GRAPH_WIDTH = 384;
export const GRAPH_HEIGHT = 320;
@@ -0,0 +1,109 @@
import { times } from "lodash-es";
import { type ReactNode } from "react";
import { theme } from "../../../../stitches.config";
import { Margin } from "../Margin";
import { MarginOrientation } from "../Margin/constants";
import { GRAPH_HEIGHT, GRAPH_WIDTH } from "./constants";
export interface MarginInputProps {
min: number;
max: number;
suffix?: string;
precision?: number;
}
interface RootProps {
children: ReactNode;
verticalMargin?: MarginInputProps;
horizontalMargin?: MarginInputProps;
separations?: number;
xMinLabel: string;
xMaxLabel: string;
xTitle?: string;
}
export function Root({
children,
verticalMargin,
horizontalMargin,
separations = 5,
xMinLabel,
xMaxLabel,
xTitle,
}: RootProps) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div
style={{
paddingLeft: 64,
display: "flex",
justifyContent: "space-between",
}}
>
<span style={{ fontSize: 16, color: theme.colors.textHighContrast }}>
{xMinLabel}
</span>
<span style={{ fontSize: 16, color: theme.colors.textHighContrast }}>
{xMaxLabel}
</span>
</div>
<div style={{ display: "flex", gap: 16 }}>
{verticalMargin && (
<Margin {...verticalMargin} verticalSeparations={separations} />
)}
<svg
viewBox={`0 0 ${GRAPH_WIDTH} ${GRAPH_HEIGHT}`}
preserveAspectRatio="none"
style={{
flex: 1,
backgroundColor: theme.colors.appBackground2,
borderRadius: 4,
border: theme.borderStyles.nonInteractive,
}}
>
{times(separations - 2, (index) => {
const y = GRAPH_HEIGHT * ((index + 1) / (separations - 1));
return (
<polyline
key={index}
vector-effect="non-scaling-stroke"
fill="none"
stroke={theme.colors.borderNonInteractive}
strokeWidth="2"
points={`0,${y}, ${GRAPH_WIDTH},${y}`}
/>
);
})}
{children}
</svg>
</div>
{horizontalMargin && (
<Margin
{...horizontalMargin}
verticalSeparations={separations}
orientation={MarginOrientation.Horizontal}
/>
)}
{xTitle !== undefined && (
<div
style={{
paddingLeft: 64,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
{xTitle}
</span>
</div>
)}
</div>
);
}
@@ -0,0 +1,3 @@
export * from './components/Line';
export * from './components/Margin';
export * from './components/Root';
@@ -0,0 +1,29 @@
import { theme } from '../../../stitches.config';
interface GapProps {
message: string;
}
export function Gap({ message: number }: GapProps) {
return (
<div
style={{
display: 'flex',
padding: 8,
backgroundColor: theme.colors.componentNonInteractive,
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
}}
>
<span
style={{
color: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{number.toLocaleString()}
</span>
</div>
);
}
@@ -0,0 +1,171 @@
import { type ComponentProps } from "react";
import { theme } from "../../../stitches.config";
interface ItemProps extends ComponentProps<"div"> {
position: number;
deltaPosition?: number;
score?: number;
deltaScore?: number;
nickname: string;
clan?: string;
highlight?: boolean;
}
export function Item({
position,
deltaPosition,
score,
deltaScore,
nickname,
clan,
highlight,
...props
}: ItemProps) {
return (
<div
{...props}
style={{
cursor: "pointer",
display: "flex",
padding: 8,
backgroundColor: highlight
? theme.colors.componentInteractive_blue
: theme.colors.componentInteractive,
borderRadius: 4,
alignItems: "center",
justifyContent: "center",
gap: 8,
}}
>
<span
style={{
color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{position.toLocaleString()}.
</span>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 4,
}}
>
<span
style={{
color: highlight
? theme.colors.textHighContrast_blue
: theme.colors.textHighContrast,
fontSize: 16,
}}
>
{nickname}
</span>
{clan && (
<span
style={{
color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16,
}}
>
[{clan}]
</span>
)}
</div>
{deltaPosition !== undefined && deltaPosition !== 0 && (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 4,
}}
>
<img
alt={deltaPosition > 0 ? "Increase" : "Decrease"}
style={{
width: 8,
height: 8,
}}
src={
deltaPosition > 0
? "https://i.imgur.com/qbjiXa1.png"
: "https://i.imgur.com/3uyNhun.png"
}
/>
<span
style={{
color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{Math.abs(deltaPosition).toLocaleString()}
</span>
</div>
)}
{/* TODO: lol remove this spacer div for a better solution */}
<div style={{ flex: 1 }} />
<div
style={{
display: "flex",
gap: 4,
alignItems: "center",
width: 96,
justifyContent: "flex-end",
}}
>
{deltaScore !== 0 && deltaScore !== undefined && (
<span
style={{
color: highlight
? theme.colors.textLowContrast_blue
: theme.colors.textLowContrast,
fontSize: 16,
}}
>
{Math.abs(deltaScore).toLocaleString()}
</span>
)}
{deltaScore !== 0 && deltaScore !== undefined && (
<img
alt={deltaScore > 0 ? "Increase" : "Decrease"}
style={{
width: 8,
height: 8,
}}
src={
deltaScore > 0
? "https://i.imgur.com/qbjiXa1.png"
: "https://i.imgur.com/3uyNhun.png"
}
/>
)}
{score !== undefined && (
<span
style={{
color: highlight
? theme.colors.textHighContrast_blue
: theme.colors.textHighContrast,
fontSize: 16,
}}
>
{score.toLocaleString()}
</span>
)}
</div>
</div>
);
}
@@ -0,0 +1,13 @@
import { type ReactNode } from 'react';
interface RootProps {
children: ReactNode;
}
export function Root({ children }: RootProps) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{children}
</div>
);
}
@@ -0,0 +1,3 @@
export * from './components/Gap';
export * from './components/Item';
export * from './components/Root';
@@ -0,0 +1,5 @@
export enum NoDataType {
BattlesInPeriod,
PlayersInPeriod,
TanksFound,
}
@@ -0,0 +1,30 @@
import { Locale } from 'discord.js';
import { translator } from '../../core/localization/translator';
import { theme } from '../../stitches.config';
interface NoDataProps {
type:
| 'battles_in_period'
| 'players_in_period'
| 'tanks_found'
| 'battles_in_season';
locale: Locale;
}
export function NoData({ type, locale }: NoDataProps) {
const { strings } = translator(locale);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<span style={{ fontSize: 16, color: theme.colors.textLowContrast }}>
{strings.bot.common.no_data[type]}
</span>
</div>
);
}
@@ -0,0 +1,26 @@
import { Percentile } from '@blitzkit/core';
import {
amber,
blue,
green,
lime,
orange,
pink,
purple,
red,
slate,
teal,
} from '@radix-ui/colors';
export const PERCENTILE_COLORS = {
[Percentile.VeryBad]: slate.slate9,
[Percentile.Bad]: red.red9,
[Percentile.BelowAverage]: orange.orange9,
[Percentile.Average]: amber.amber10,
[Percentile.AboveAverage]: lime.lime8,
[Percentile.Good]: green.green9,
[Percentile.VeryGood]: teal.teal9,
[Percentile.Great]: blue.blue9,
[Percentile.Unicum]: pink.pink9,
[Percentile.SuperUnicum]: purple.purple9,
};
@@ -0,0 +1,19 @@
import { Percentile } from '@blitzkit/core';
import { PERCENTILE_COLORS } from './constants';
interface PercentileIndicatorProps {
percentile: Percentile;
}
export function PercentileIndicator({ percentile }: PercentileIndicatorProps) {
return (
<div
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: PERCENTILE_COLORS[percentile],
}}
/>
);
}
@@ -0,0 +1,20 @@
import { type ReactNode } from 'react';
interface ColumnProps {
children: ReactNode;
}
export function Column({ children }: ColumnProps) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
flex: 1,
}}
>
{children}
</div>
);
}
@@ -0,0 +1,75 @@
import { TankClass, TankType, TREE_TYPE_ICONS } from "@blitzkit/core";
import { theme } from "../../../../stitches.config";
export interface ItemProps {
tankClass?: TankClass;
name: string;
tankType: TankType;
}
const TREE_TYPE_COLOR = {
[TankType.TANK_TYPE_RESEARCHABLE]: "",
[TankType.TANK_TYPE_PREMIUM]: "_amber",
[TankType.TANK_TYPE_COLLECTOR]: "_blue",
} as const;
export function Item({
tankClass: tankType,
name,
tankType: treeType,
}: ItemProps) {
return (
<div
style={{
display: "flex",
height: 32,
paddingLeft: 8,
paddingRight: 8,
overflow: "hidden",
alignItems: "center",
borderRadius: 4,
backgroundColor:
theme.colors[`componentInteractive${TREE_TYPE_COLOR[treeType]}`],
}}
>
<div
style={{
display: "flex",
gap: 4,
flex: 1,
alignItems: "center",
justifyContent: "flex-start",
overflow: "hidden",
}}
>
{tankType && (
<img
alt={TankType[tankType]}
src={TREE_TYPE_ICONS[treeType][tankType]}
style={{ width: 14, height: 14 }}
/>
)}
<span
style={{
fontSize: 16,
whiteSpace: "nowrap",
color: theme.colors[`textLowContrast${TREE_TYPE_COLOR[treeType]}`],
}}
>
{name}
</span>
</div>
{/* {image && (
// TODO: remove hardcoded dimensions when satori fixes non-width unloadable images
<img
alt={name}
src={image}
width={106}
height={32}
style={{ objectFit: 'cover' }}
/>
)} */}
</div>
);
}
@@ -0,0 +1,19 @@
import { type ReactNode } from "react";
interface RootProps {
children: ReactNode;
}
export function Root({ children }: RootProps) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 16,
}}
>
{children}
</div>
);
}
@@ -0,0 +1,18 @@
import { type ReactNode } from "react";
interface RowProps {
children: ReactNode;
}
export function Row({ children }: RowProps) {
return (
<div
style={{
display: "flex",
gap: 8,
}}
>
{children}
</div>
);
}
@@ -0,0 +1,19 @@
import { theme } from '../../../stitches.config';
interface TitleProps {
children: string;
}
export function Title({ children }: TitleProps) {
return (
<span
style={{
fontWeight: 900,
color: theme.colors.textHighContrast,
fontSize: 24,
}}
>
{children}
</span>
);
}
@@ -0,0 +1,5 @@
export * from './components/Column';
export * from './components/Item';
export * from './components/Root';
export * from './components/Row';
export * from './components/Title';
@@ -0,0 +1,32 @@
import type { TierWeightsRecord } from "@blitzkit/core";
import { theme } from "../stitches.config";
interface TierWeightsProps {
weights: TierWeightsRecord;
}
export function TierWeights({ weights }: TierWeightsProps) {
return (
<div style={{ display: "flex", gap: 8 }}>
{Object.entries(weights).map(([tier, weight]) => (
<div
key={tier}
style={{
backgroundColor: theme.colors.appBackground1_light,
flex: weight,
color: theme.colors.textHighContrast_light,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 16,
overflow: "hidden",
borderRadius: 8,
minWidth: 16,
}}
>
{tier}
</div>
))}
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { theme } from '../stitches.config';
interface TitleBarProps {
title: string;
image?: string;
description?: string;
}
export function TitleBar({ title, image, description }: TitleBarProps) {
return (
<div
style={{
gap: 4,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
padding: '8px 0px',
}}
>
<div
style={{
gap: 4,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
{image && (
<img
alt={title}
style={{ width: 32, height: 32, objectFit: 'contain' }}
src={image}
/>
)}
<span
style={{
fontSize: 32,
fontWeight: 900,
background: `linear-gradient(180deg, ${theme.colors.textHighContrast} 0%, ${theme.colors.textLowContrast} 100%)`,
backgroundClip: 'text',
}}
>
{title}
</span>
</div>
<span style={{ color: theme.colors.textLowContrast, fontSize: 16 }}>
{description}
</span>
</div>
);
}
@@ -0,0 +1,3 @@
export function normalizeLeagueIcon(url: string) {
return url.startsWith('http') ? url : `https:${url}`;
}
@@ -0,0 +1,48 @@
import { SEARCH_KEYS } from "@blitzkit/core";
import { literals } from "@blitzkit/i18n";
import { Locale } from "discord.js";
import { go } from "fuzzysort";
import markdownEscape from "markdown-escape";
import { tankDefinitions, tankNames } from "../blitzkit/nonBlockingPromises";
import { UserError } from "../blitzkit/userError";
import { tankNamesTechTreeOnly } from "../discord/autocompleteTanks";
import { translator } from "../localization/translator";
export async function resolveTankId(
tank: string | number,
locale: Locale,
techTreeOnly = false
) {
const { strings } = translator(locale);
const number = typeof tank === "string" ? Number(tank) : tank;
if (Number.isNaN(number)) {
const searchResult = go(
`${tank}`,
await (techTreeOnly ? tankNamesTechTreeOnly : tankNames),
{ keys: SEARCH_KEYS, limit: 1 }
);
if (searchResult.length === 0) {
throw new UserError(
literals(strings.bot.common.errors.tank_name_not_found, {
search: markdownEscape(`${tank}`),
})
);
} else {
return searchResult[0].obj.id;
}
} else {
const awaitedTankDefinitions = await tankDefinitions;
if (awaitedTankDefinitions.tanks[number]) {
return number;
} else {
throw new UserError(
literals(strings.bot.common.errors.tank_id_not_found, {
search: number,
})
);
}
}
}
@@ -0,0 +1,23 @@
import { searchClansAcrossRegions } from '@blitzkit/core';
import { Locale } from 'discord.js';
import { UserError } from '../blitzkit/userError';
import { translator } from '../localization/translator';
export async function searchClansAcrossRegionsBotWrapper(
search: string,
locale: Locale,
limit?: number,
) {
const { strings } = translator(locale);
const trimmed = search.trim();
if (trimmed.length < 3) {
throw new UserError(strings.bot.common.errors.query_too_small);
}
if (trimmed.length > 100) {
throw new UserError(strings.bot.common.errors.query_too_long);
}
return await searchClansAcrossRegions(trimmed, limit);
}
Loaded 100 of 781 files, more files were not shown because too many files have changed in this diff. Show more