194 Commits

Author SHA1 Message Date
a1b0889f49 Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloadedv3
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m59s
2024-10-29 21:51:15 +00:00
a5604335c1 fix a few pages not being the full screen height
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 3m4s
2024-10-29 17:47:20 -04:00
acec87bb17 add device set on fallback
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Has been cancelled
2024-10-29 21:47:02 +00:00
24f4910364 Merge remote-tracking branch 'origin/master'
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 5s
2024-10-29 21:43:45 +00:00
b4095e3bf6 update score feed page 2024-10-29 21:43:10 +00:00
9979732cc6 small responsiveness fix
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Has been cancelled
2024-10-29 17:42:56 -04:00
cd8bbeff5d new footer
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 3m0s
2024-10-29 17:39:32 -04:00
803edb4fd5 update icons
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 3m1s
2024-10-29 16:34:16 -04:00
5774f51b06 make score difficulty random 2024-10-29 16:25:57 -04:00
8814b9881e make realtime scores mobile responsive 2024-10-29 16:16:04 -04:00
e1c665193b redeploy site
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 3m17s
2024-10-29 16:01:08 -04:00
3491057f04 update bun lock 2024-10-29 15:59:58 -04:00
8f6c556662 more landing page stuff (:
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 8s
2024-10-29 15:50:58 -04:00
a47cc3c7d8 disable all time until i can make fetching it faster
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m53s
2024-10-29 19:50:40 +00:00
be5f0ab780 testing
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
2024-10-29 19:36:11 +00:00
de441b698c smh my head
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 58s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m55s
2024-10-29 18:52:14 +00:00
448155a3c3 smh my head
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 34s
Deploy Website / docker (ubuntu-latest) (push) Failing after 34s
2024-10-29 18:49:20 +00:00
78c8c1ba98 add some optimization
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 32s
2024-10-29 18:48:09 +00:00
069a566d40 add additional data and previous score in top scores response
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 32s
2024-10-29 18:45:34 +00:00
8011ed7b5a add additional data in top scores response
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 31s
2024-10-29 18:44:21 +00:00
f232468fc1 fix grr
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 33s
Deploy Website / docker (ubuntu-latest) (push) Failing after 31s
2024-10-29 18:39:27 +00:00
b68de0552f cleanup top scores and add timeframes to them
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 45s
Deploy Website / docker (ubuntu-latest) (push) Failing after 32s
2024-10-29 18:34:58 +00:00
9e96d2f0ba replace discord log with console log 2024-10-29 14:42:34 +00:00
f4192b5030 remove debug 2024-10-29 14:17:49 +00:00
b610c5d97f fix cron 2024-10-29 14:17:42 +00:00
67c4865697 fix player name 2024-10-29 14:17:33 +00:00
3a2a876f74 fix player scores background refreshing
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 52s
2024-10-29 14:11:30 +00:00
a26bf53996 this is useless
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m52s
2024-10-29 12:39:57 +00:00
57e74e30e2 Merge remote-tracking branch 'origin/master'
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 3m14s
2024-10-28 22:53:13 -04:00
f197d0b3c3 test 2024-10-29 02:50:34 +00:00
a80213aa51 Begin on the new landing page 2024-10-28 22:47:45 -04:00
e9c03a662e add is prod check to Sentry
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m46s
2024-10-28 22:14:00 +00:00
18aaba86be testing sentry stuff
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m49s
2024-10-28 21:53:37 +00:00
72a9cee7af testing sentry stuff
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m3s
2024-10-28 21:44:15 +00:00
314ade1457 testing sentry stuff
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m19s
2024-10-28 21:38:23 +00:00
01cc5d8c48 testing sentry stuff 2024-10-28 21:36:39 +00:00
e5f0bd0595 bundle analyzer
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m37s
2024-10-28 21:31:04 +00:00
3c7cedc529 fix tge fix
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m19s
2024-10-28 21:08:02 +00:00
e88fb50b14 change song author and mapper name sizing
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m32s
2024-10-28 21:05:29 +00:00
6b30c6efed cleanup
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m33s
2024-10-28 21:01:12 +00:00
ebb91344bc fix previous score timestamp hover
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-28 19:10:30 +00:00
02015525e3 oops
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 40s
2024-10-28 19:01:47 +00:00
c1f33578d7 fix vs for mobile
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m21s
2024-10-28 16:33:21 +00:00
0ec1fc9d41 oops
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m19s
2024-10-28 16:30:12 +00:00
1c2214a659 made the player page look much nicer
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-28 16:22:33 +00:00
f156b7f582 fix previous score
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
2024-10-28 15:47:41 +00:00
ad568ddf5d fix player data tracking
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 40s
2024-10-28 14:00:12 +00:00
df297d0c99 maybe fix player data tracking idk
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
2024-10-28 13:35:14 +00:00
c8fb08b192 add embeds to top and live scores pages
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-28 13:28:54 +00:00
981bc13a1f update player name
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
2024-10-28 13:27:46 +00:00
8314cbcf2d bob the builder
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m21s
2024-10-28 13:22:23 +00:00
f52b62ba83 add top scores page
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m31s
2024-10-28 13:18:40 +00:00
0a5d42f6ac make player score fetching much faster
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
2024-10-28 12:12:44 +00:00
ce65116db4 log this to see if its why some don't get tracked
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 1m12s
2024-10-28 11:46:03 +00:00
6c81316364 auto reload site stats
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-27 19:30:58 +00:00
b83fb6f3a8 cache statistics
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m20s
2024-10-27 15:17:54 +00:00
c58f24103f update lgo
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
2024-10-27 15:13:43 +00:00
e146d20f4f log ss score tracking
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 40s
2024-10-27 15:11:00 +00:00
ffa4ab2b6c add PP to the acc chart for a score
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-27 15:08:20 +00:00
b889eee7ff change connection idle timeout
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 40s
2024-10-27 14:03:41 +00:00
28e8561020 cleanup and track player history when creating the player instead of waiting for the cron job
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
2024-10-27 14:01:12 +00:00
de3768559f include today in seeding player history
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 42s
2024-10-27 13:35:45 +00:00
4be0b072b2 fix player embed image not updating (maybe?)
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-27 13:30:51 +00:00
d086e922c4 fix leaderboard id
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 48s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m22s
2024-10-27 11:20:08 +00:00
96ab9be79a maybe this will help the memory usage idk
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-27 10:59:41 +00:00
3357939071 maybe fix backend crash?
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 1m13s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-27 10:45:20 +00:00
f3737ce7a5 format previous score
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m20s
2024-10-26 18:44:57 +01:00
9626931b91 migrate some values to ssr data tracking so we don't need to rely on BL as much
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 49s
Deploy Website / docker (ubuntu-latest) (push) Has been cancelled
2024-10-26 18:41:51 +01:00
5ff0d11f5a show pause count for scores
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m21s
2024-10-26 18:02:04 +01:00
5f4d3829e2 no need to log this
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m19s
2024-10-26 15:45:40 +01:00
b3cd770724 move score feed button to the footer
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m18s
2024-10-26 15:38:24 +01:00
c3a75b139a minimize api calls to scoresaber
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-26 15:33:05 +01:00
ba80b9623b revert react
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m40s
2024-10-26 15:19:03 +01:00
e57e725639 optimize requests to scoresaber's api
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m57s
2024-10-26 15:16:50 +01:00
f8b0f7c6cd cleanup + bump next & react
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 2m12s
2024-10-26 14:58:44 +01:00
0d39a905f6 never refresh ranked leaderboards
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 40s
2024-10-26 13:27:53 +01:00
7be8c37779 move this to the cached leaderboard
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
2024-10-26 13:22:43 +01:00
6bc2e09f43 meow
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 45s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m25s
2024-10-26 13:16:55 +01:00
da7f5f1c62 fix imports
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 8s
2024-10-26 13:14:25 +01:00
fe888d9fb6 cache scoresaber leaderboards
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 32s
Deploy Website / docker (ubuntu-latest) (push) Has been cancelled
2024-10-26 13:13:32 +01:00
a8eb2372cb fix imports
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-26 11:46:24 +01:00
e0c719eaba highlight correct player on leaderboard scores and make player name clickable on a leaderboard score
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Has been cancelled
2024-10-26 11:45:34 +01:00
413d72182d add per hand real accuracy (eg: 95%)
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m27s
2024-10-26 11:38:16 +01:00
d7929cc36a fix player daily score set
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 1m47s
2024-10-26 01:05:51 +01:00
dd162bf77c fix mobile score
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m25s
2024-10-25 21:56:52 +01:00
5d7bdc17b1 fix score history icon
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 6s
2024-10-25 21:42:21 +01:00
ff287222f7 reset dropdown mode on dropdown close
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Has been cancelled
2024-10-25 21:40:07 +01:00
3abffec9cb oops
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m25s
2024-10-25 21:34:26 +01:00
f20d83a436 fix error
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 34s
Deploy Website / docker (ubuntu-latest) (push) Failing after 32s
2024-10-25 21:32:59 +01:00
97fba47fd8 add score history viewing
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 34s
Deploy Website / docker (ubuntu-latest) (push) Failing after 33s
2024-10-25 21:29:57 +01:00
9fb5317bc8 maybe it just needs time to work idk
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 43s
2024-10-25 18:29:57 +01:00
7e1d172b43 fix perms
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
2024-10-25 18:01:44 +01:00
da950e08f2 bot stuff
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
2024-10-25 18:00:15 +01:00
Lee
2b9a777506 Update projects/backend/src/bot/bot.ts
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
2024-10-25 16:49:38 +00:00
90b0994524 add bot cmd
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 30s
2024-10-25 17:44:19 +01:00
53e0ce007d rename cron 2024-10-25 17:39:25 +01:00
a421243973 ensure scores are always up-to-date for players
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-25 17:37:56 +01:00
b911072a47 change wording
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-25 14:25:04 +01:00
59d5cdb2ae only scroll to on leaderboard page
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-25 13:38:11 +01:00
a9338393f5 add default values for rankedScores and unrankedScores stat
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 1m12s
2024-10-25 13:37:04 +01:00
6d0c6aa47f add more statistics
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 48s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m26s
2024-10-24 14:36:24 +01:00
aaee96ad7b cleanup imports
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 48s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-24 14:32:37 +01:00
cd1f010698 fix crying
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-24 14:28:18 +01:00
1d9433ef02 fix score acc slider not updating the acc and add a loader to the leaderboard dropdown
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m33s
2024-10-24 13:30:37 +01:00
9f4e3afffd Merge remote-tracking branch 'origin/master' 2024-10-24 09:11:26 +01:00
4232add9c7 bump lock file 2024-10-24 09:11:21 +01:00
Lee
3b2a42a995 Merge pull request 'Update oven/bun Docker tag to v1.1.33' (#87) from renovate/oven-bun-1.x into master
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 18s
Deploy Website / docker (ubuntu-latest) (push) Failing after 9s
Reviewed-on: #87
2024-10-24 08:08:24 +00:00
02083204f7 Update oven/bun Docker tag to v1.1.33 2024-10-24 08:03:37 +00:00
fc38aba6f4 hide total ranked scores side text on mobile
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m31s
2024-10-23 23:26:40 +01:00
781a3e8cdc fix previous score miss counts
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m32s
2024-10-23 23:18:37 +01:00
20376070c3 show role color on role badge
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m56s
2024-10-23 23:05:20 +01:00
42264ece64 oopsie
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m35s
2024-10-23 20:54:20 +01:00
2852e0c0ed track difficulty and characteristic for scoresaber scores
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 32s
Deploy Website / docker (ubuntu-latest) (push) Failing after 30s
2024-10-23 20:52:57 +01:00
0a87877373 ignore score if it already exists
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
2024-10-23 20:47:12 +01:00
b8f6829f71 literally nothing uses this
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m31s
2024-10-23 20:30:33 +01:00
44bc812ad8 fix
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m35s
2024-10-23 20:26:48 +01:00
d1d12b4193 fix import
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m39s
2024-10-23 20:24:16 +01:00
d42c888e82 implement scoresaber score tracking (for previous scores)
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 1m6s
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m40s
2024-10-23 20:20:57 +01:00
3c4406c4b7 format obstacles
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m25s
2024-10-23 17:59:56 +01:00
2a681e6b32 oops
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 45s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m25s
2024-10-23 17:47:54 +01:00
90c57ad086 add score acc chart
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 32s
Deploy Website / docker (ubuntu-latest) (push) Failing after 31s
2024-10-23 17:44:55 +01:00
0731d20edc tooltipsssssssssss
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m25s
2024-10-23 16:44:08 +01:00
0d12e7c024 fix score feed acc
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-23 16:41:31 +01:00
e403d1f241 this might help a little bit
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-23 16:30:53 +01:00
b4bcf32a43 maybe fix tooltip on mobile?
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 2m22s
2024-10-23 16:23:03 +01:00
56b2f272b9 maybe fix tooltip on mobile?
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Has been cancelled
2024-10-23 16:22:41 +01:00
55b9f0e4ef fix swagger
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 40s
2024-10-23 16:19:29 +01:00
1bc2b35ec0 lookup beat saver map hash inside the versions not the _id
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 39s
2024-10-23 16:08:26 +01:00
ed4bcc93e1 typescript makes me want to kms sometimes.
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 40s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-23 15:52:25 +01:00
de3dec22de it's really not that hard, just build
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m35s
2024-10-23 15:45:56 +01:00
4b5c2acad5 oh?
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m38s
2024-10-23 15:42:38 +01:00
6e38f36945 oops
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 29s
2024-10-23 15:40:47 +01:00
584af8c5a4 upsert the beatsaver map
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Has been cancelled
2024-10-23 15:40:28 +01:00
0f68b2b69e maybe fix?
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 32s
Deploy Website / docker (ubuntu-latest) (push) Failing after 32s
2024-10-23 15:37:21 +01:00
33b931b5f1 add map stats from beat saver
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Failing after 1m34s
2024-10-23 15:33:25 +01:00
62090b8054 i am smart
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m21s
2024-10-23 09:24:48 +01:00
f8e0326dec oops
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 45s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m21s
2024-10-23 09:06:44 +01:00
c09a50b8a2 add previous days query to player history
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 39s
2024-10-23 09:02:11 +01:00
55cbcb3d66 bump lock file 2024-10-23 08:46:15 +01:00
Lee
fd95f9414f Merge pull request 'Update dependency @sentry/nextjs to v8.35.0' (#79) from renovate/sentry-javascript-monorepo into master
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 11s
Reviewed-on: #79
2024-10-23 07:45:31 +00:00
Lee
05e10424ef Merge pull request 'Update dependency lucide-react to ^0.453.0' (#80) from renovate/lucide-monorepo into master
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Has been cancelled
Reviewed-on: #80
2024-10-23 07:45:26 +00:00
Lee
d3ba6eedc4 Merge pull request 'Update oven/bun Docker tag to v1.1.32' (#84) from renovate/oven-bun-1.x into master
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 1m9s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m22s
Reviewed-on: #84
2024-10-23 07:41:36 +00:00
6bbf628ab5 fix
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m22s
2024-10-23 08:36:55 +01:00
56ae9b717c fix mini ranking for top players
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m21s
2024-10-23 08:24:31 +01:00
08295d7b04 make the score improvement text smaller and show a previous pp inaccuracy warning
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m21s
2024-10-23 08:13:07 +01:00
8090361615 cleanup score badges
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-23 07:52:23 +01:00
299cf20cb9 fix hand acc
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-23 07:40:11 +01:00
ff9ff5b96b cleanup hand acc
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m44s
2024-10-23 07:31:43 +01:00
c3cf48e731 maybe fix tooltips on mobile?
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m21s
2024-10-22 22:52:56 +01:00
1befe6cc57 add fc pp to pp hover
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m21s
2024-10-22 22:48:01 +01:00
7b008d8e55 fix ranking page links
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m26s
2024-10-22 21:20:13 +01:00
68e343083b fix timestamp
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 39s
2024-10-22 19:10:18 +01:00
989d66780d add timestamp to additional data
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 45s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-22 19:08:22 +01:00
ca8fb41fab fix error
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 40s
2024-10-22 18:55:47 +01:00
6a1b18581f add score
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 44s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m22s
2024-10-22 18:54:21 +01:00
a33c1b81b7 fix scores set today
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-22 18:46:57 +01:00
6495db7588 meh
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 1m6s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m22s
2024-10-22 18:36:48 +01:00
c3ab9851ab fix total ranked score stat
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 9s
Deploy Website / docker (ubuntu-latest) (push) Failing after 12s
2024-10-22 18:34:32 +01:00
ef287d6c3c fix whatever the fuck this bug was
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Failing after 9s
2024-10-22 18:33:00 +01:00
cf84ebe456 smh my head
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 8s
Deploy Website / docker (ubuntu-latest) (push) Failing after 7s
2024-10-22 18:29:11 +01:00
220cf31511 smh my head
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 12s
Deploy Website / docker (ubuntu-latest) (push) Failing after 9s
2024-10-22 18:25:26 +01:00
3f63225f16 smh my head 2024-10-22 18:24:46 +01:00
50bc341c38 fix package caching?
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 8s
Deploy Website / docker (ubuntu-latest) (push) Failing after 8s
2024-10-22 18:23:25 +01:00
7b87188e98 add bl replay button
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m25s
2024-10-22 18:20:34 +01:00
75f79e34b7 store bl score and leaderboard id
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 47s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m22s
2024-10-22 18:10:33 +01:00
2fc8b265d2 fix previous score fc tooltip
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m23s
2024-10-22 17:55:05 +01:00
f090c0dcbb bl score fixes
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-22 17:48:32 +01:00
9c20aff89d add bombs to miss count
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 41s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m30s
2024-10-22 17:35:14 +01:00
36ab7eb4cf oopsie
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 54s
Deploy Website / docker (ubuntu-latest) (push) Has been cancelled
2024-10-22 17:32:09 +01:00
f3dee6a7d2 rework beatleader data tracking
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 34s
Deploy Website / docker (ubuntu-latest) (push) Failing after 32s
2024-10-22 17:30:14 +01:00
fa2ba83c7a add beatleader data tracking!!!!!!!!!!!!!
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 44s
Deploy Website / docker (ubuntu-latest) (push) Failing after 38s
2024-10-22 15:59:41 +01:00
074d4de123 fix imports
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m24s
2024-10-22 13:55:58 +01:00
854f88c43a track total score, total ranked score, replay watched count and add a score chart
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Failing after 31s
Deploy Website / docker (ubuntu-latest) (push) Failing after 30s
2024-10-22 13:54:54 +01:00
15e6cb85c4 Update dependency lucide-react to ^0.453.0 2024-10-22 12:02:52 +00:00
696da236d5 bump nextjs
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 1m7s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m8s
2024-10-22 12:37:52 +01:00
f89207f306 fix total score change
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 48s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m7s
2024-10-22 12:34:17 +01:00
be25896c5e change some color stuff
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m34s
2024-10-22 12:26:58 +01:00
fbf5603866 Update oven/bun Docker tag to v1.1.32 2024-10-21 21:03:02 +00:00
d62b6524f7 cleanup
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 46s
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m10s
2024-10-21 17:39:47 +01:00
de47905e28 make leaderboard info column slightly bigger
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m10s
2024-10-21 15:35:26 +01:00
9a621eea82 oh?
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m12s
2024-10-21 14:21:11 +01:00
42e0c3a7b2 still include today
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 44s
Deploy Website / docker (ubuntu-latest) (push) Failing after 59s
2024-10-21 14:18:26 +01:00
0b92cec911 fix graph only showing 49 days of data
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 45s
Deploy Website / docker (ubuntu-latest) (push) Failing after 57s
2024-10-21 14:15:59 +01:00
5933074569 fix qualified status for leaderboards
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 45s
Deploy Website / docker (ubuntu-latest) (push) Failing after 59s
2024-10-21 13:47:53 +01:00
af8c87f5af add player role badge
Some checks failed
Deploy Backend / docker (ubuntu-latest) (push) Successful in 45s
Deploy Website / docker (ubuntu-latest) (push) Failing after 57s
2024-10-21 13:39:02 +01:00
173703664d oops
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m5s
2024-10-21 13:29:23 +01:00
077bb6d73b add acc slider to the pp graph
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Has been cancelled
2024-10-21 13:27:47 +01:00
78e3ec43d7 Update dependency @sentry/nextjs to v8.35.0 2024-10-21 12:02:38 +00:00
b7349f0226 fix scores history being reset for that day
All checks were successful
Deploy Backend / docker (ubuntu-latest) (push) Successful in 45s
2024-10-21 07:45:23 +01:00
ad826d7a3f make all queries re fetch every 5 mins
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m12s
2024-10-21 07:43:18 +01:00
6baeab930d add more info to leaderboard score misses
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m7s
2024-10-21 07:33:46 +01:00
87b2c7c48a fix player header pp color
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m8s
2024-10-21 07:06:42 +01:00
b9587feb9e re-add days ago to the chart
Some checks failed
Deploy Website / docker (ubuntu-latest) (push) Has been cancelled
2024-10-21 07:04:39 +01:00
fad22274fd make song name clickable on leaderboard page (goes to beatsaver map)
All checks were successful
Deploy Website / docker (ubuntu-latest) (push) Successful in 2m7s
2024-10-21 06:56:20 +01:00
218 changed files with 6384 additions and 1968 deletions

View File

@ -9,6 +9,7 @@ on:
- projects/website/**
- projects/common/**
- .gitea/workflows/deploy-website.yml
- bun.lockb
jobs:
docker:

View File

@ -4,4 +4,4 @@ This is the 3rd re-code of this project. The first one was a mess, the second on
## meow
meow
meow

BIN
bun.lockb Executable file → Normal file

Binary file not shown.

View File

@ -13,6 +13,7 @@
"author": "fascinated7",
"license": "MIT",
"dependencies": {
"concurrently": "^9.0.1"
"concurrently": "^9.0.1",
"cross-env": "^7.0.3"
}
}

View File

@ -1,4 +1,4 @@
FROM oven/bun:1.1.31-alpine AS base
FROM oven/bun:1.1.33-alpine AS base
# Install dependencies
FROM base AS depends

View File

@ -14,6 +14,7 @@
"@elysiajs/swagger": "^1.1.3",
"@ssr/common": "workspace:common",
"@tqman/nice-logger": "^1.0.1",
"@typegoose/auto-increment": "^4.7.0",
"@typegoose/typegoose": "^12.8.0",
"@typescript-eslint/eslint-plugin": "^8.9.0",
"@typescript-eslint/parser": "^8.9.0",

View File

@ -1,17 +1,19 @@
import { Client, MetadataStorage } from "discordx";
import { Config } from "@ssr/common/config";
import { ActivityType, EmbedBuilder } from "discord.js";
import { Config } from "@ssr/common/config";
export const guildId = "1295984874942894100";
export enum DiscordChannels {
trackedPlayerLogs = "1295985197262569512",
numberOneFeed = "1295988063817830430",
backendLogs = "1296524935237468250",
}
const DiscordBot = new Client({
intents: [],
const client = new Client({
intents: ["Guilds", "GuildMessages"],
presence: {
status: "online",
activities: [
{
name: "scores...",
@ -22,15 +24,26 @@ const DiscordBot = new Client({
},
});
DiscordBot.once("ready", () => {
client.once("ready", () => {
console.log("Discord bot ready!");
});
export function initDiscordBot() {
export async function initDiscordBot() {
console.log("Initializing discord bot...");
// We will now build our application to load all the commands/events for both bots.
MetadataStorage.instance.build().then(async () => {
await DiscordBot.login(Config.discordBotToken!).then();
// Setup slash commands
client.once("ready", async () => {
await client.initApplicationCommands();
console.log(client.applicationCommands);
});
client.on("interactionCreate", interaction => {
client.executeInteraction(interaction);
});
// Login
await client.login(Config.discordBotToken!);
});
}
@ -41,13 +54,12 @@ export function initDiscordBot() {
* @param message the message to log
*/
export async function logToChannel(channelId: DiscordChannels, message: EmbedBuilder) {
const channel = await DiscordBot.channels.fetch(channelId);
if (channel == undefined) {
throw new Error(`Channel "${channelId}" not found`);
try {
const channel = await client.channels.fetch(channelId);
if (channel != undefined && channel.isSendable()) {
channel.send({ embeds: [message] });
}
} catch {
/* empty */
}
if (!channel.isSendable()) {
throw new Error(`Channel "${channelId}" is not sendable`);
}
channel.send({ embeds: [message] });
}

View File

@ -0,0 +1,20 @@
import { Discord, Guild, Slash } from "discordx";
import { CommandInteraction } from "discord.js";
import { PlayerService } from "../../service/player.service";
import { guildId } from "../bot";
@Discord()
export class RefreshPlayerScoresCommand {
@Guild(guildId)
@Slash({
description: "Refreshes scores for all tracked players",
name: "refresh-player-scores",
defaultMemberPermissions: ["Administrator"],
})
hello(interaction: CommandInteraction) {
interaction.reply("Updating player scores...").then(async response => {
await PlayerService.refreshPlayerScores();
await response.edit("Done!");
});
}
}

View File

@ -1,5 +1,6 @@
import { SSRCache } from "@ssr/common/cache";
import { InternalServerError } from "../error/internal-server-error";
import { InternalServerError } from "@ssr/common/error/internal-server-error";
import { isProduction } from "@ssr/common/utils/utils";
/**
* Fetches data with caching.
@ -13,6 +14,10 @@ export async function fetchWithCache<T>(
cacheKey: string,
fetchFn: () => Promise<T | undefined>
): Promise<T | undefined> {
if (!isProduction()) {
return await fetchFn();
}
if (cache == undefined) {
throw new InternalServerError(`Cache is not defined`);
}

View File

@ -0,0 +1,37 @@
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
import { DiscordChannels, logToChannel } from "../bot/bot";
import { EmbedBuilder } from "discord.js";
import { formatPp } from "@ssr/common/utils/number-utils";
/**
* Logs that a new player is being tracked
*
* @param player the player being tracked
*/
export async function logNewTrackedPlayer(player: ScoreSaberPlayerToken) {
await logToChannel(
DiscordChannels.trackedPlayerLogs,
new EmbedBuilder()
.setTitle("New Player Tracked")
.setDescription(`https://ssr.fascinated.cc/player/${player.id}`)
.addFields([
{
name: "Username",
value: player.name,
inline: true,
},
{
name: "ID",
value: player.id,
inline: true,
},
{
name: "PP",
value: formatPp(player.pp) + "pp",
inline: true,
},
])
.setThumbnail(player.profilePicture)
.setColor("#00ff00")
);
}

View File

@ -7,24 +7,32 @@ import { AroundPlayerResponse } from "@ssr/common/response/around-player-respons
@Controller("/player")
export default class PlayerController {
@Get("/history/:id", {
@Get("/history/:id/:days", {
config: {},
params: t.Object({
id: t.String({ required: true }),
days: t.Number({ default: 50, required: false }),
}),
query: t.Object({
createIfMissing: t.Boolean({ default: false, required: false }),
}),
})
public async getPlayer({
params: { id },
params: { id, days },
query: { createIfMissing },
}: {
params: { id: string };
params: { id: string; days: number };
query: { createIfMissing: boolean };
}): Promise<{ statistics: Record<string, PlayerHistory> }> {
if (days < 1) {
days = 1;
}
// Limit to 10 years
if (days > 365 * 10) {
days = 365 * 10;
}
const player = await PlayerService.getPlayer(id, createIfMissing);
return { statistics: player.getHistoryPreviousDays(50) };
return { statistics: player.getHistoryPreviousDays(days) };
}
@Get("/tracked/:id", {

View File

@ -1,7 +1,9 @@
import { Controller, Get } from "elysia-decorators";
import { t } from "elysia";
import { Leaderboards } from "@ssr/common/leaderboard";
import { TopScoresResponse } from "@ssr/common/response/top-scores-response";
import { ScoreService } from "../service/score.service";
import { Timeframe } from "@ssr/common/timeframe";
@Controller("/scores")
export default class ScoresController {
@ -52,4 +54,51 @@ export default class ScoresController {
}): Promise<unknown> {
return await ScoreService.getLeaderboardScores(leaderboard, id, page);
}
@Get("/history/:playerId/:leaderboardId/:page", {
config: {},
params: t.Object({
playerId: t.String({ required: true }),
leaderboardId: t.String({ required: true }),
page: t.Number({ required: true }),
}),
})
public async getScoreHistory({
params: { playerId, leaderboardId, page },
}: {
params: {
playerId: string;
leaderboardId: string;
page: number;
};
query: { search?: string };
}): Promise<unknown> {
return (await ScoreService.getScoreHistory(playerId, leaderboardId, page)).toJSON();
}
@Get("/top", {
config: {},
query: t.Object({
limit: t.Number({ required: true }),
timeframe: t.String({ required: true }),
}),
})
public async getTopScores({
query: { limit, timeframe },
}: {
query: { limit: number; timeframe: Timeframe };
}): Promise<TopScoresResponse> {
if (limit <= 0) {
limit = 1;
} else if (limit > 100) {
limit = 100;
}
const scores = await ScoreService.getTopScores(limit, timeframe);
return {
scores,
timeframe,
limit,
};
}
}

View File

@ -8,22 +8,20 @@ import { etag } from "@bogeychan/elysia-etag";
import AppController from "./controller/app.controller";
import * as dotenv from "@dotenvx/dotenvx";
import mongoose from "mongoose";
import { setLogLevel } from "@typegoose/typegoose";
import PlayerController from "./controller/player.controller";
import { PlayerService } from "./service/player.service";
import { cron } from "@elysiajs/cron";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { delay, isProduction } from "@ssr/common/utils/utils";
import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket";
import { isProduction } from "@ssr/common/utils/utils";
import ImageController from "./controller/image.controller";
import { ScoreService } from "./service/score.service";
import { Config } from "@ssr/common/config";
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
import ScoresController from "./controller/scores.controller";
import LeaderboardController from "./controller/leaderboard.controller";
import { getAppVersion } from "./common/app.util";
import { connectScoresaberWebsocket } from "@ssr/common/websocket/scoresaber-websocket";
import { connectBeatLeaderWebsocket } from "@ssr/common/websocket/beatleader-websocket";
import { DiscordChannels, initDiscordBot, logToChannel } from "./bot/bot";
import { EmbedBuilder } from "discord.js";
import { getAppVersion } from "./common/app.util";
// Load .env file
dotenv.config({
@ -32,18 +30,32 @@ dotenv.config({
override: true,
});
// Connect to Mongo
await mongoose.connect(Config.mongoUri!); // Connect to MongoDB
setLogLevel("DEBUG");
connectScoreSaberWebSocket({
onScore: async playerScore => {
await PlayerService.trackScore(playerScore);
await ScoreService.notifyNumberOne(playerScore);
// Connect to websockets
connectScoresaberWebsocket({
onScore: async score => {
await ScoreService.trackScoreSaberScore(score.score, score.leaderboard);
await ScoreService.updatePlayerScoresSet(score);
await ScoreService.notifyNumberOne(score);
},
onDisconnect: async error => {
await logToChannel(
DiscordChannels.backendLogs,
new EmbedBuilder().setDescription(`ScoreSaber websocket disconnected: ${error}`)
new EmbedBuilder().setDescription(`ScoreSaber websocket disconnected: ${JSON.stringify(error)}`)
);
},
});
connectBeatLeaderWebsocket({
onScore: async score => {
await ScoreService.trackBeatLeaderScore(score);
},
onDisconnect: async error => {
await logToChannel(
DiscordChannels.backendLogs,
new EmbedBuilder().setDescription(`BeatLeader websocket disconnected: ${JSON.stringify(error)}`)
);
},
});
@ -52,44 +64,22 @@ export const app = new Elysia();
app.use(
cron({
name: "player-statistics-tracker-cron",
pattern: "1 0 * * *", // Every day at 00:01
pattern: "0 1 * * *", // Every day at 00:01
timezone: "Europe/London", // UTC time
protect: true,
run: async () => {
const pages = 20; // top 1000 players
const cooldown = 60_000 / 250; // 250 requests per minute
let toTrack: PlayerDocument[] = await PlayerModel.find({});
const toRemoveIds: string[] = [];
// loop through pages to fetch the top players
console.log(`Fetching ${pages} pages of players from ScoreSaber...`);
for (let i = 0; i < pages; i++) {
const pageNumber = i + 1;
console.log(`Fetching page ${pageNumber}...`);
const page = await scoresaberService.lookupPlayers(pageNumber);
if (page === undefined) {
console.log(`Failed to fetch players on page ${pageNumber}, skipping page...`);
await delay(cooldown);
continue;
}
for (const player of page.players) {
const foundPlayer = await PlayerService.getPlayer(player.id, true, player);
await PlayerService.trackScoreSaberPlayer(foundPlayer, player);
toRemoveIds.push(foundPlayer.id);
}
await delay(cooldown);
}
console.log(`Finished tracking player statistics for ${pages} pages, found ${toRemoveIds.length} players.`);
// remove all players that have been tracked
toTrack = toTrack.filter(player => !toRemoveIds.includes(player.id));
console.log(`Tracking ${toTrack.length} player statistics...`);
for (const player of toTrack) {
await PlayerService.trackScoreSaberPlayer(player);
await delay(cooldown);
}
console.log("Finished tracking player statistics.");
await PlayerService.updatePlayerStatistics();
},
})
);
app.use(
cron({
name: "player-scores-tracker-cron",
pattern: "0 4 * * *", // Every day at 04:00
timezone: "Europe/London", // UTC time
protect: true,
run: async () => {
await PlayerService.refreshPlayerScores();
},
})
);
@ -162,14 +152,25 @@ app.use(
version: await getAppVersion(),
},
},
scalarConfig: {
servers: [
{
url: "https://ssr.fascinated.cc/api",
description: "Production server",
},
],
},
})
);
app.onStart(() => {
app.onStart(async () => {
console.log("Listening on port http://localhost:8080");
if (isProduction()) {
initDiscordBot();
await initDiscordBot();
}
});
app.listen(8080);
app.listen({
port: 8080,
idleTimeout: 120, // 2 minutes
});

View File

@ -1,15 +1,38 @@
import { PlayerModel } from "@ssr/common/model/player";
import { AppStatistics } from "@ssr/common/types/backend/app-statistics";
import { ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score";
import { AdditionalScoreDataModel } from "@ssr/common/model/additional-score-data/additional-score-data";
import { BeatSaverMapModel } from "@ssr/common/model/beatsaver/map";
import { ScoreSaberLeaderboardModel } from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
import { SSRCache } from "@ssr/common/cache";
const statisticsCache = new SSRCache({
ttl: 120 * 1000, // 2 minutes
});
export class AppService {
/**
* Gets the app statistics.
*/
public static async getAppStatistics(): Promise<AppStatistics> {
const trackedPlayers = await PlayerModel.countDocuments();
if (statisticsCache.has("app-statistics")) {
return statisticsCache.get<AppStatistics>("app-statistics")!;
}
return {
const trackedPlayers = await PlayerModel.countDocuments();
const trackedScores = await ScoreSaberScoreModel.countDocuments();
const additionalScoresData = await AdditionalScoreDataModel.countDocuments();
const cachedBeatSaverMaps = await BeatSaverMapModel.countDocuments();
const cachedScoreSaberLeaderboards = await ScoreSaberLeaderboardModel.countDocuments();
const response = {
trackedPlayers,
trackedScores,
additionalScoresData,
cachedBeatSaverMaps,
cachedScoreSaberLeaderboards,
};
statisticsCache.set("app-statistics", response);
return response;
}
}

View File

@ -1,39 +1,96 @@
import { beatsaverService } from "@ssr/common/service/impl/beatsaver";
import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/beatsaver-map";
import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/map";
export default class BeatSaverService {
/**
* Gets a map by its hash.
* Gets a map by its hash, updates if necessary, or inserts if not found.
*
* @param hash the hash of the map
* @returns the beatsaver map
* @returns the beatsaver map, or undefined if not found
*/
public static async getMap(hash: string): Promise<BeatSaverMap | undefined> {
let map = await BeatSaverMapModel.findById(hash);
if (map != undefined) {
let map = await BeatSaverMapModel.findOne({
"versions.hash": hash.toUpperCase(),
});
if (map) {
const toObject = map.toObject() as BeatSaverMap;
if (toObject.unknownMap) {
// If the map is not found, return undefined
if (toObject.notFound) {
return undefined;
}
return toObject;
// If the map does not need to be refreshed, return it
if (!(map as unknown as BeatSaverMap).shouldRefresh()) {
return toObject;
}
}
// Map needs to be fetched or refreshed
const token = await beatsaverService.lookupMap(hash);
map = await BeatSaverMapModel.create(
token
const uploader = token?.uploader;
const metadata = token?.metadata;
// Create the new map object based on fetched data
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
const newMapData: BeatSaverMap =
token && uploader && metadata
? {
_id: hash,
_id: hash, // todo: change this to an incrementing id
bsr: token.id,
name: token.name,
description: token.description,
author: {
id: token.uploader.id,
id: uploader.id,
name: uploader.name,
avatar: uploader.avatar,
},
metadata: {
bpm: metadata.bpm,
duration: metadata.duration,
levelAuthorName: metadata.levelAuthorName,
songAuthorName: metadata.songAuthorName,
songName: metadata.songName,
songSubName: metadata.songSubName,
},
versions: token.versions.map(version => ({
hash: version.hash.toUpperCase(),
difficulties: version.diffs.map(diff => ({
njs: diff.njs,
offset: diff.offset,
notes: diff.notes,
bombs: diff.bombs,
obstacles: diff.obstacles,
nps: diff.nps,
characteristic: diff.characteristic,
difficulty: diff.difficulty,
events: diff.events,
chroma: diff.chroma,
mappingExtensions: diff.me,
noodleExtensions: diff.ne,
cinema: diff.cinema,
maxScore: diff.maxScore,
label: diff.label,
})),
createdAt: new Date(version.createdAt),
})),
lastRefreshed: new Date(),
}
: {
_id: hash,
unknownMap: true,
}
);
if (map.unknownMap) {
notFound: true,
};
// Upsert the map: if it exists, update it; if not, create a new one
map = await BeatSaverMapModel.findOneAndUpdate({ _id: hash }, newMapData, {
upsert: true,
new: true,
setDefaultsOnInsert: true,
});
if (map == null || map.notFound) {
return undefined;
}
return map.toObject() as BeatSaverMap;

View File

@ -2,16 +2,17 @@ import { ImageResponse } from "@vercel/og";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import React from "react";
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
import { getDifficultyFromScoreSaberDifficulty } from "@ssr/common/utils/scoresaber-utils";
import { StarIcon } from "../../components/star-icon";
import { GlobeIcon } from "../../components/globe-icon";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player";
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
import { Jimp } from "jimp";
import { extractColors } from "extract-colors";
import { Config } from "@ssr/common/config";
import { fetchWithCache } from "../common/cache.util";
import { SSRCache } from "@ssr/common/cache";
import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators";
import LeaderboardService from "./leaderboard.service";
import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
const cache = new SSRCache({
ttl: 1000 * 60 * 60, // 1 hour
@ -175,12 +176,11 @@ export class ImageService {
* @param id the leaderboard's id
*/
public static async generateLeaderboardImage(id: string) {
const leaderboard = await fetchWithCache<ScoreSaberLeaderboardToken>(cache, `leaderboard-${id}`, () =>
scoresaberService.lookupLeaderboard(id)
);
if (!leaderboard) {
const response = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>("scoresaber", id);
if (!response) {
return undefined;
}
const { leaderboard } = response;
const ranked = leaderboard.stars > 0;
@ -188,7 +188,7 @@ export class ImageService {
(
<ImageService.BaseImage>
{/* Leaderboard Cover Image */}
<img src={leaderboard.coverImage} width={256} height={256} alt="Leaderboard Cover" tw="rounded-full mb-3" />
<img src={leaderboard.songArt} width={256} height={256} alt="Leaderboard Cover" tw="rounded-full mb-3" />
{/* Leaderboard Name */}
<p tw="font-bold text-6xl m-0">
@ -205,9 +205,7 @@ export class ImageService {
)}
{/* Leaderboard Difficulty */}
<p tw={"font-bold m-0 text-4xl" + (ranked ? " pl-3" : "")}>
{getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty)}
</p>
<p tw={"font-bold m-0 text-4xl" + (ranked ? " pl-3" : "")}>{leaderboard.difficulty.difficulty}</p>
</div>
{/* Leaderboard Author */}

View File

@ -1,17 +1,16 @@
import { Leaderboards } from "@ssr/common/leaderboard";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { SSRCache } from "@ssr/common/cache";
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
import Leaderboard from "@ssr/common/leaderboard/leaderboard";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import { NotFoundError } from "elysia";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import BeatSaverService from "./beatsaver.service";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
const leaderboardCache = new SSRCache({
ttl: 1000 * 60 * 60 * 24,
});
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators";
import {
ScoreSaberLeaderboard,
ScoreSaberLeaderboardModel,
} from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
import Leaderboard from "@ssr/common/model/leaderboard/leaderboard";
export default class LeaderboardService {
/**
@ -21,16 +20,9 @@ export default class LeaderboardService {
* @param id the id
*/
private static async getLeaderboardToken<T>(leaderboard: Leaderboards, id: string): Promise<T | undefined> {
const cacheKey = `${leaderboard}-${id}`;
if (leaderboardCache.has(cacheKey)) {
return leaderboardCache.get(cacheKey) as T;
}
switch (leaderboard) {
case "scoresaber": {
const leaderboard = (await scoresaberService.lookupLeaderboard(id)) as T;
leaderboardCache.set(cacheKey, leaderboard);
return leaderboard;
return (await scoresaberService.lookupLeaderboard(id)) as T;
}
default: {
return undefined;
@ -49,16 +41,45 @@ export default class LeaderboardService {
let leaderboard: Leaderboard | undefined;
let beatSaverMap: BeatSaverMap | undefined;
const now = new Date();
switch (leaderboardName) {
case "scoresaber": {
const leaderboardToken = await LeaderboardService.getLeaderboardToken<ScoreSaberLeaderboardToken>(
leaderboardName,
id
);
if (leaderboardToken == undefined) {
let foundLeaderboard = false;
const cachedLeaderboard = await ScoreSaberLeaderboardModel.findById(id);
if (cachedLeaderboard != null) {
leaderboard = cachedLeaderboard.toObject() as unknown as ScoreSaberLeaderboard;
if (
leaderboard &&
(leaderboard.ranked || // Never refresh ranked leaderboards (it will get refreshed every night)
leaderboard.lastRefreshed == undefined || // Refresh if it has never been refreshed
now.getTime() - leaderboard.lastRefreshed.getTime() > 1000 * 60 * 60 * 24) // Refresh every day
) {
foundLeaderboard = true;
}
}
if (!foundLeaderboard) {
const leaderboardToken = await LeaderboardService.getLeaderboardToken<ScoreSaberLeaderboardToken>(
leaderboardName,
id
);
if (leaderboardToken == undefined) {
throw new NotFoundError(`Leaderboard not found for "${id}"`);
}
leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
leaderboard.lastRefreshed = new Date();
await ScoreSaberLeaderboardModel.findOneAndUpdate({ _id: id }, leaderboard, {
upsert: true,
new: true,
setDefaultsOnInsert: true,
});
}
if (leaderboard == undefined) {
throw new NotFoundError(`Leaderboard not found for "${id}"`);
}
leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
beatSaverMap = await BeatSaverService.getMap(leaderboard.songHash);
break;
}

View File

@ -1,80 +1,82 @@
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
import { NotFoundError } from "../error/not-found-error";
import { NotFoundError } from "@ssr/common/error/not-found-error";
import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
import { InternalServerError } from "../error/internal-server-error";
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
import { formatPp } from "@ssr/common/utils/number-utils";
import { getPageFromRank, isProduction } from "@ssr/common/utils/utils";
import { DiscordChannels, logToChannel } from "../bot/bot";
import { EmbedBuilder } from "discord.js";
import { InternalServerError } from "@ssr/common/error/internal-server-error";
import { delay, getPageFromRank, isProduction } from "@ssr/common/utils/utils";
import { AroundPlayer } from "@ssr/common/types/around-player";
import { ScoreSort } from "@ssr/common/score/score-sort";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators";
import { ScoreService } from "./score.service";
import { logNewTrackedPlayer } from "../common/embds";
const SCORESABER_REQUEST_COOLDOWN = 60_000 / 250; // 250 requests per minute
const accountCreationLock: { [id: string]: Promise<PlayerDocument> } = {};
export class PlayerService {
/**
* Get a player from the database.
*
* @param id the player to fetch
* @param create if true, create the player if it doesn't exist
* @param playerToken an optional player token for the player
* @returns the player
* @throws NotFoundError if the player is not found
*/
public static async getPlayer(
id: string,
create: boolean = false,
playerToken?: ScoreSaberPlayerToken
): Promise<PlayerDocument> {
// Wait for the existing lock if it's in progress
if (accountCreationLock[id] !== undefined) {
await accountCreationLock[id];
}
let player: PlayerDocument | null = await PlayerModel.findById(id);
if (player === null) {
// If create is on, create the player, otherwise return unknown player
playerToken = create ? (playerToken ? playerToken : await scoresaberService.lookupPlayer(id)) : undefined;
if (playerToken === undefined) {
if (!create) {
throw new NotFoundError(`Player "${id}" not found`);
}
console.log(`Creating player "${id}"...`);
try {
player = (await PlayerModel.create({ _id: id })) as PlayerDocument;
player.trackedSince = new Date();
await this.seedPlayerHistory(player, playerToken);
playerToken = playerToken || (await scoresaberService.lookupPlayer(id));
// Only notify in production
if (isProduction()) {
await logToChannel(
DiscordChannels.trackedPlayerLogs,
new EmbedBuilder()
.setTitle("New Player Tracked")
.setDescription(`https://ssr.fascinated.cc/player/${playerToken.id}`)
.addFields([
{
name: "Username",
value: playerToken.name,
inline: true,
},
{
name: "ID",
value: playerToken.id,
inline: true,
},
{
name: "PP",
value: formatPp(playerToken.pp) + "pp",
inline: true,
},
])
.setThumbnail(playerToken.profilePicture)
.setColor("#00ff00")
);
if (!playerToken) {
throw new NotFoundError(`Player "${id}" not found`);
}
// Create a new lock promise and assign it
accountCreationLock[id] = (async () => {
let newPlayer: PlayerDocument;
try {
console.log(`Creating player "${id}"...`);
newPlayer = (await PlayerModel.create({ _id: id })) as PlayerDocument;
newPlayer.trackedSince = new Date();
await newPlayer.save();
await this.seedPlayerHistory(newPlayer, playerToken);
await this.refreshAllPlayerScores(newPlayer);
// Notify in production
if (isProduction()) {
await logNewTrackedPlayer(playerToken);
}
} catch (err) {
console.log(`Failed to create player document for "${id}"`, err);
throw new InternalServerError(`Failed to create player document for "${id}"`);
} finally {
// Ensure the lock is always removed
delete accountCreationLock[id];
}
} catch (err) {
const message = `Failed to create player document for "${id}"`;
console.log(message, err);
throw new InternalServerError(message);
return newPlayer;
})();
// Wait for the player creation to complete
player = await accountCreationLock[id];
// Update player name
if (player.name !== playerToken.name) {
player.name = playerToken.name;
await player.save();
}
}
return player;
// Ensure that the player is now of type PlayerDocument
return player as PlayerDocument;
}
/**
@ -91,7 +93,7 @@ export class PlayerService {
});
playerRankHistory.push(playerToken.rank);
let daysAgo = 1; // Start from yesterday
let daysAgo = 0; // Start from today
for (let i = playerRankHistory.length - daysAgo - 1; i >= 0; i--) {
const rank = playerRankHistory[i];
// Skip inactive days
@ -132,7 +134,7 @@ export class PlayerService {
// Seed the history with ScoreSaber data if no history exists
if (foundPlayer.getDaysTracked() === 0) {
await this.seedPlayerHistory(foundPlayer, player);
await this.seedPlayerHistory(foundPlayer.id, player);
}
// Update current day's statistics
@ -148,12 +150,21 @@ export class PlayerService {
history.countryRank = player.countryRank;
history.rank = player.rank;
history.accuracy = {
...history.accuracy,
averageRankedAccuracy: scoreStats.averageRankedAccuracy,
};
history.scores = {
rankedScores: 0,
unrankedScores: 0,
...history.scores,
totalScores: scoreStats.totalPlayCount,
totalRankedScores: scoreStats.rankedPlayCount,
};
history.score = {
...history.score,
totalScore: scoreStats.totalScore,
totalRankedScore: scoreStats.totalRankedScore,
};
foundPlayer.setStatisticHistory(dateToday, history);
foundPlayer.sortStatisticHistory();
@ -164,47 +175,6 @@ export class PlayerService {
console.log(`Tracked player "${foundPlayer.id}"!`);
}
/**
* Track player score.
*
* @param score the score to track
* @param leaderboard the leaderboard to track
*/
public static async trackScore({ score, leaderboard }: ScoreSaberPlayerScoreToken) {
const playerId = score.leaderboardPlayerInfo.id;
const playerName = score.leaderboardPlayerInfo.name;
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
// Player is not tracked, so ignore the score.
if (player == undefined) {
return;
}
const today = new Date();
let history = player.getHistoryByDate(today);
if (history == undefined || Object.keys(history).length === 0) {
history = { scores: { rankedScores: 0, unrankedScores: 0 } }; // Ensure initialization
}
const scores = history.scores || {};
if (leaderboard.stars > 0) {
scores.rankedScores!++;
} else {
scores.unrankedScores!++;
}
history.scores = scores;
player.setStatisticHistory(today, history);
player.sortStatisticHistory();
// Save the changes
player.markModified("statisticHistory");
await player.save();
console.log(
`Updated scores set statistic for "${playerName}"(${playerId}), scores today: ${scores.rankedScores} ranked, ${scores.unrankedScores} unranked`
);
}
/**
* Gets the players around a player.
*
@ -276,4 +246,126 @@ export class PlayerService {
return players.slice(start, end);
}
/**
* Refreshes all the players scores.
*
* @param player the player to refresh
*/
public static async refreshAllPlayerScores(player: PlayerDocument) {
await this.refreshPlayerScoreSaberScores(player);
}
/**
* Ensures that all the players scores from the
* ScoreSaber API are up-to-date.
*
* @param player the player to refresh
* @private
*/
private static async refreshPlayerScoreSaberScores(player: PlayerDocument) {
console.log(`Refreshing scores for ${player.id}...`);
let page = 1;
let hasMorePages = true;
while (hasMorePages) {
const scoresPage = await scoresaberService.lookupPlayerScores({
playerId: player.id,
page: page,
limit: 100,
sort: ScoreSort.recent,
});
if (!scoresPage) {
console.warn(`Failed to fetch scores for ${player.id} on page ${page}.`);
break;
}
let missingScores = 0;
for (const score of scoresPage.playerScores) {
const leaderboard = getScoreSaberLeaderboardFromToken(score.leaderboard);
const scoreSaberScore = await ScoreService.getScoreSaberScore(
player.id,
leaderboard.id + "",
leaderboard.difficulty.difficulty,
leaderboard.difficulty.characteristic,
score.score.baseScore
);
if (scoreSaberScore == null) {
missingScores++;
}
await ScoreService.trackScoreSaberScore(score.score, score.leaderboard, player.id);
}
// Stop paginating if no scores are missing OR if player has seededScores marked true
if ((missingScores === 0 && player.seededScores) || page >= Math.ceil(scoresPage.metadata.total / 100)) {
hasMorePages = false;
}
page++;
await delay(SCORESABER_REQUEST_COOLDOWN); // Cooldown between page requests
}
// Mark player as seeded
player.seededScores = true;
await player.save();
console.log(`Finished refreshing scores for ${player.id}, total pages refreshed: ${page - 1}.`);
}
/**
* Ensures all player scores are up-to-date.
*/
public static async refreshPlayerScores() {
console.log(`Refreshing player score data...`);
const players = await PlayerModel.find({});
console.log(`Found ${players.length} players to refresh.`);
for (const player of players) {
await this.refreshAllPlayerScores(player);
await delay(SCORESABER_REQUEST_COOLDOWN); // Cooldown between players
}
}
/**
* Updates the player statistics for all players.
*/
public static async updatePlayerStatistics() {
const pages = 20; // top 1000 players
let toTrack: PlayerDocument[] = await PlayerModel.find({});
const toRemoveIds: string[] = [];
// loop through pages to fetch the top players
console.log(`Fetching ${pages} pages of players from ScoreSaber...`);
for (let i = 0; i < pages; i++) {
const pageNumber = i + 1;
console.log(`Fetching page ${pageNumber}...`);
const page = await scoresaberService.lookupPlayers(pageNumber);
if (page === undefined) {
console.log(`Failed to fetch players on page ${pageNumber}, skipping page...`);
await delay(SCORESABER_REQUEST_COOLDOWN);
continue;
}
for (const player of page.players) {
const foundPlayer = await PlayerService.getPlayer(player.id, true, player);
await PlayerService.trackScoreSaberPlayer(foundPlayer, player);
toRemoveIds.push(foundPlayer.id);
}
await delay(SCORESABER_REQUEST_COOLDOWN);
}
console.log(`Finished tracking player statistics for ${pages} pages, found ${toRemoveIds.length} players.`);
// remove all players that have been tracked
toTrack = toTrack.filter(player => !toRemoveIds.includes(player.id));
console.log(`Tracking ${toTrack.length} player statistics...`);
for (const player of toTrack) {
await PlayerService.trackScoreSaberPlayer(player);
await delay(SCORESABER_REQUEST_COOLDOWN);
}
console.log("Finished tracking player statistics.");
}
}

View File

@ -4,25 +4,43 @@ import { isProduction } from "@ssr/common/utils/utils";
import { Metadata } from "@ssr/common/types/metadata";
import { NotFoundError } from "elysia";
import BeatSaverService from "./beatsaver.service";
import ScoreSaberLeaderboard, {
getScoreSaberLeaderboardFromToken,
} from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { getScoreSaberScoreFromToken } from "@ssr/common/score/impl/scoresaber-score";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
import { ScoreSort } from "@ssr/common/score/score-sort";
import { Leaderboards } from "@ssr/common/leaderboard";
import Leaderboard from "@ssr/common/leaderboard/leaderboard";
import LeaderboardService from "./leaderboard.service";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import { PlayerScore } from "@ssr/common/score/player-score";
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
import Score from "@ssr/common/score/score";
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
import { DiscordChannels, logToChannel } from "../bot/bot";
import { EmbedBuilder } from "discord.js";
import { Config } from "@ssr/common/config";
import { SSRCache } from "@ssr/common/cache";
import { fetchWithCache } from "../common/cache.util";
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
import { BeatLeaderScoreToken } from "@ssr/common/types/token/beatleader/score/score";
import {
AdditionalScoreData,
AdditionalScoreDataModel,
} from "@ssr/common/model/additional-score-data/additional-score-data";
import { BeatLeaderScoreImprovementToken } from "@ssr/common/types/token/beatleader/score/score-improvement";
import { ScoreType } from "@ssr/common/model/score/score";
import { getScoreSaberLeaderboardFromToken, getScoreSaberScoreFromToken } from "@ssr/common/token-creators";
import {
ScoreSaberPreviousScore,
ScoreSaberScore,
ScoreSaberScoreModel,
} from "@ssr/common/model/score/impl/scoresaber-score";
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
import { MapDifficulty } from "@ssr/common/score/map-difficulty";
import { MapCharacteristic } from "@ssr/common/types/map-characteristic";
import { Page, Pagination } from "@ssr/common/pagination";
import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
import Leaderboard from "@ssr/common/model/leaderboard/leaderboard";
import { Timeframe } from "@ssr/common/timeframe";
import { getDaysAgoDate } from "@ssr/common/utils/time-utils";
import { PlayerService } from "./player.service";
const playerScoresCache = new SSRCache({
ttl: 1000 * 60, // 1 minute
@ -45,8 +63,8 @@ export class ScoreService {
}
const { score: scoreToken, leaderboard: leaderboardToken } = playerScore;
const score = getScoreSaberScoreFromToken(scoreToken, leaderboardToken);
const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
const score = getScoreSaberScoreFromToken(scoreToken, leaderboard, scoreToken.leaderboardPlayerInfo.id);
const playerInfo = score.playerInfo;
// Not ranked
@ -112,37 +130,337 @@ export class ScoreService {
}
/**
* Gets scores for a player.
* Updates the players set scores count for today.
*
* @param leaderboardName the leaderboard to get the scores from
* @param id the players id
* @param page the page to get
* @param sort the sort to use
* @param search the search to use
* @returns the scores
* @param score the score
*/
public static async updatePlayerScoresSet({
score: scoreToken,
leaderboard: leaderboardToken,
}: ScoreSaberPlayerScoreToken) {
const playerId = scoreToken.leaderboardPlayerInfo.id;
const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
// Player is not tracked, so ignore the score.
if (player == undefined) {
return;
}
const today = new Date();
const history = player.getHistoryByDate(today);
const scores = history.scores || {
rankedScores: 0,
unrankedScores: 0,
};
if (leaderboard.stars > 0) {
scores.rankedScores!++;
} else {
scores.unrankedScores!++;
}
history.scores = scores;
player.setStatisticHistory(today, history);
player.markModified("statisticHistory");
await player.save();
}
/**
* Tracks ScoreSaber score.
*
* @param scoreToken the score to track
* @param leaderboardToken the leaderboard for the score
* @param playerId the id of the player
*/
public static async trackScoreSaberScore(
scoreToken: ScoreSaberScoreToken,
leaderboardToken: ScoreSaberLeaderboardToken,
playerId?: string
) {
playerId = (scoreToken.leaderboardPlayerInfo && scoreToken.leaderboardPlayerInfo.id) || playerId;
if (!playerId) {
console.error(`Player ID is undefined, unable to track score: ${scoreToken.id}`);
return;
}
const playerName = (scoreToken.leaderboardPlayerInfo && scoreToken.leaderboardPlayerInfo.name) || "Unknown";
const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
const score = getScoreSaberScoreFromToken(scoreToken, leaderboard, playerId);
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
// Player is not tracked, so ignore the score.
if (player == undefined) {
return;
}
// Update player name
if (playerName !== "Unknown") {
player.name = playerName;
await player.save();
}
// The score has already been tracked, so ignore it.
if (
(await this.getScoreSaberScore(
playerId,
leaderboard.id + "",
leaderboard.difficulty.difficulty,
leaderboard.difficulty.characteristic,
score.score
)) !== null
) {
console.log(
`ScoreSaber score already tracked for "${playerName}"(${playerId}), difficulty: ${score.difficulty}, score: ${score.score}, leaderboard: ${leaderboard.id}, ignoring...`
);
return;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
delete score.playerInfo;
await ScoreSaberScoreModel.create(score);
console.log(
`Tracked ScoreSaber score for "${playerName}"(${playerId}), difficulty: ${score.difficulty}, score: ${score.score}, pp: ${score.pp.toFixed(2)}pp, leaderboard: ${leaderboard.id}`
);
}
/**
* Tracks BeatLeader score.
*
* @param score the score to track
*/
public static async trackBeatLeaderScore(score: BeatLeaderScoreToken) {
const { playerId, player: scorePlayer, leaderboard } = score;
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
// Player is not tracked, so ignore the score.
if (player == undefined) {
return;
}
// The score has already been tracked, so ignore it.
if (
(await this.getAdditionalScoreData(
playerId,
leaderboard.song.hash,
leaderboard.difficulty.difficultyName,
score.baseScore
)) !== undefined
) {
return;
}
const getMisses = (score: BeatLeaderScoreToken | BeatLeaderScoreImprovementToken) => {
return score.missedNotes + score.badCuts + score.bombCuts;
};
const difficulty = leaderboard.difficulty;
const difficultyKey = `${difficulty.difficultyName}-${difficulty.modeName}`;
const rawScoreImprovement = score.scoreImprovement;
const data = {
playerId: playerId,
songHash: leaderboard.song.hash.toUpperCase(),
songDifficulty: difficultyKey,
songScore: score.baseScore,
scoreId: score.id,
leaderboardId: leaderboard.id,
misses: {
misses: getMisses(score),
missedNotes: score.missedNotes,
bombCuts: score.bombCuts,
badCuts: score.badCuts,
wallsHit: score.wallsHit,
},
pauses: score.pauses,
fcAccuracy: score.fcAccuracy * 100,
fullCombo: score.fullCombo,
handAccuracy: {
left: score.accLeft,
right: score.accRight,
},
timestamp: new Date(Number(score.timeset) * 1000),
} as AdditionalScoreData;
if (rawScoreImprovement && rawScoreImprovement.score > 0) {
data.scoreImprovement = {
score: rawScoreImprovement.score,
misses: {
misses: getMisses(rawScoreImprovement),
missedNotes: rawScoreImprovement.missedNotes,
bombCuts: rawScoreImprovement.bombCuts,
badCuts: rawScoreImprovement.badCuts,
wallsHit: rawScoreImprovement.wallsHit,
},
accuracy: rawScoreImprovement.accuracy * 100,
handAccuracy: {
left: rawScoreImprovement.accLeft,
right: rawScoreImprovement.accRight,
},
};
}
await AdditionalScoreDataModel.create(data);
console.log(
`Tracked additional score data for "${scorePlayer.name}"(${playerId}), difficulty: ${difficultyKey}, score: ${score.baseScore}`
);
}
/**
* Gets the top tracked scores.
*
* @param amount the amount of scores to get
* @param timeframe the timeframe to filter by
* @returns the top scores
*/
public static async getTopScores(amount: number = 100, timeframe: Timeframe) {
console.log(`Getting top scores for timeframe: ${timeframe}, limit: ${amount}...`);
const before = Date.now();
let daysAgo = -1;
if (timeframe === "daily") {
daysAgo = 1;
} else if (timeframe === "weekly") {
daysAgo = 8;
} else if (timeframe === "monthly") {
daysAgo = 31;
}
const date: Date = daysAgo == -1 ? new Date(0) : getDaysAgoDate(daysAgo);
const foundScores = await ScoreSaberScoreModel.aggregate([
{ $match: { timestamp: { $gte: date } } },
{
$group: {
_id: { leaderboardId: "$leaderboardId", playerId: "$playerId" },
score: { $first: "$$ROOT" },
},
},
{ $sort: { "score.pp": -1 } },
{ $limit: amount },
]);
const scores: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[] = [];
for (const { score: scoreData } of foundScores) {
const score = new ScoreSaberScoreModel(scoreData).toObject() as ScoreSaberScore;
const leaderboardResponse = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
"scoresaber",
score.leaderboardId + ""
);
if (!leaderboardResponse) {
continue;
}
const { leaderboard, beatsaver } = leaderboardResponse;
try {
const player = await PlayerService.getPlayer(score.playerId);
if (player !== undefined) {
score.playerInfo = {
id: player.id,
name: player.name,
};
}
} catch {
score.playerInfo = {
id: score.playerId,
};
}
const [additionalData, previousScore] = await Promise.all([
this.getAdditionalScoreData(
score.playerId,
leaderboard.songHash,
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
score.score
),
this.getPreviousScore(score.playerId, leaderboard.id + "", score.timestamp),
]);
if (additionalData) {
score.additionalData = additionalData;
}
if (previousScore) {
score.previousScore = previousScore;
}
scores.push({
score: score,
leaderboard: leaderboard,
beatSaver: beatsaver,
});
}
console.log(`Got ${scores.length} scores in ${Date.now() - before}ms (timeframe: ${timeframe}, limit: ${amount})`);
return scores;
}
/**
* Gets the additional score data for a player's score.
*
* @param playerId the id of the player
* @param songHash the hash of the map
* @param songDifficulty the difficulty of the map
* @param songScore the score of the play
* @private
*/
private static async getAdditionalScoreData(
playerId: string,
songHash: string,
songDifficulty: string,
songScore: number
): Promise<AdditionalScoreData | undefined> {
const additionalData = await AdditionalScoreDataModel.findOne({
playerId: playerId,
songHash: songHash.toUpperCase(),
songDifficulty: songDifficulty,
songScore: songScore,
});
if (!additionalData) {
return undefined;
}
return additionalData.toObject();
}
/**
* Gets a ScoreSaber score.
*
* @param playerId the player who set the score
* @param leaderboardId the leaderboard id the score was set on
* @param difficulty the difficulty played
* @param characteristic the characteristic played
* @param score the score of the score set
*/
public static async getScoreSaberScore(
playerId: string,
leaderboardId: string,
difficulty: MapDifficulty,
characteristic: MapCharacteristic,
score: number
) {
return ScoreSaberScoreModel.findOne({
playerId: playerId,
leaderboardId: leaderboardId,
difficulty: difficulty,
characteristic: characteristic,
score: score,
});
}
public static async getPlayerScores(
leaderboardName: Leaderboards,
id: string,
playerId: string,
page: number,
sort: string,
search?: string
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
return fetchWithCache(
playerScoresCache,
`player-scores-${leaderboardName}-${id}-${page}-${sort}-${search}`,
`player-scores-${leaderboardName}-${playerId}-${page}-${sort}-${search}`,
async () => {
const scores: PlayerScore<unknown, unknown>[] | undefined = [];
let beatSaverMap: BeatSaverMap | undefined;
const scores: PlayerScore<unknown, unknown>[] = [];
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
switch (leaderboardName) {
case "scoresaber": {
const leaderboardScores = await scoresaberService.lookupPlayerScores({
playerId: id,
page: page,
playerId,
page,
sort: sort as ScoreSort,
search: search,
search,
});
if (leaderboardScores == undefined) {
break;
@ -155,23 +473,43 @@ export class ScoreService {
leaderboardScores.metadata.itemsPerPage
);
for (const token of leaderboardScores.playerScores) {
const score = getScoreSaberScoreFromToken(token.score, token.leaderboard);
if (score == undefined) {
continue;
}
const tokenLeaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
if (tokenLeaderboard == undefined) {
continue;
}
beatSaverMap = await BeatSaverService.getMap(tokenLeaderboard.songHash);
const scorePromises = leaderboardScores.playerScores.map(async token => {
const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
if (!leaderboard) return undefined;
scores.push({
const score = getScoreSaberScoreFromToken(token.score, leaderboard, playerId);
if (!score) return undefined;
// Fetch additional data, previous score, and BeatSaver map concurrently
const [additionalData, previousScore, beatSaverMap] = await Promise.all([
this.getAdditionalScoreData(
playerId,
leaderboard.songHash,
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
score.score
),
this.getPreviousScore(playerId, leaderboard.id + "", score.timestamp),
BeatSaverService.getMap(leaderboard.songHash),
]);
if (additionalData) {
score.additionalData = additionalData;
}
if (previousScore) {
score.previousScore = previousScore;
}
return {
score: score,
leaderboard: tokenLeaderboard,
leaderboard: leaderboard,
beatSaver: beatSaverMap,
});
}
} as PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>;
});
const resolvedScores = (await Promise.all(scorePromises)).filter(
(s): s is PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard> => s !== undefined
);
scores.push(...resolvedScores);
break;
}
default: {
@ -191,65 +529,186 @@ export class ScoreService {
* Gets scores for a leaderboard.
*
* @param leaderboardName the leaderboard to get the scores from
* @param id the leaderboard id
* @param leaderboardId the leaderboard id
* @param page the page to get
* @returns the scores
*/
public static async getLeaderboardScores(
leaderboardName: Leaderboards,
id: string,
leaderboardId: string,
page: number
): Promise<LeaderboardScoresResponse<unknown, unknown> | undefined> {
return fetchWithCache(leaderboardScoresCache, `leaderboard-scores-${leaderboardName}-${id}-${page}`, async () => {
const scores: Score[] = [];
let leaderboard: Leaderboard | undefined;
let beatSaverMap: BeatSaverMap | undefined;
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
return fetchWithCache(
leaderboardScoresCache,
`leaderboard-scores-${leaderboardName}-${leaderboardId}-${page}`,
async () => {
const scores: ScoreType[] = [];
let leaderboard: Leaderboard | undefined;
let beatSaverMap: BeatSaverMap | undefined;
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
switch (leaderboardName) {
case "scoresaber": {
const leaderboardResponse = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
leaderboardName,
id
);
if (leaderboardResponse == undefined) {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
leaderboard = leaderboardResponse.leaderboard;
beatSaverMap = leaderboardResponse.beatsaver;
switch (leaderboardName) {
case "scoresaber": {
const leaderboardResponse = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
leaderboardName,
leaderboardId
);
if (leaderboardResponse == undefined) {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
leaderboard = leaderboardResponse.leaderboard;
beatSaverMap = leaderboardResponse.beatsaver;
const leaderboardScores = await scoresaberService.lookupLeaderboardScores(id, page);
if (leaderboardScores == undefined) {
const leaderboardScores = await scoresaberService.lookupLeaderboardScores(leaderboardId, page);
if (leaderboardScores == undefined) {
break;
}
for (const token of leaderboardScores.scores) {
const score = getScoreSaberScoreFromToken(
token,
leaderboardResponse.leaderboard,
token.leaderboardPlayerInfo.id
);
if (score == undefined) {
continue;
}
scores.push(score);
}
metadata = new Metadata(
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
leaderboardScores.metadata.total,
leaderboardScores.metadata.page,
leaderboardScores.metadata.itemsPerPage
);
break;
}
default: {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
}
for (const token of leaderboardScores.scores) {
const score = getScoreSaberScoreFromToken(token, leaderboardResponse.leaderboard);
if (score == undefined) {
continue;
}
scores.push(score);
return {
scores: scores,
leaderboard: leaderboard,
beatSaver: beatSaverMap,
metadata: metadata,
};
}
);
}
/**
* Gets the player's score history for a map.
*
* @param playerId the player's id to get the previous scores for
* @param leaderboardId the leaderboard to get the previous scores on
* @param page the page to get
*/
public static async getScoreHistory(
playerId: string,
leaderboardId: string,
page: number
): Promise<Page<PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>>> {
const scores = await ScoreSaberScoreModel.find({ playerId: playerId, leaderboardId: leaderboardId })
.sort({ timestamp: -1 })
.skip(1);
if (scores == null || scores.length == 0) {
throw new NotFoundError(`No previous scores found for ${playerId} in ${leaderboardId}`);
}
return new Pagination<PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>>()
.setItemsPerPage(8)
.setTotalItems(scores.length)
.getPage(page, async () => {
const toReturn: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[] = [];
for (const score of scores) {
const leaderboardResponse = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
"scoresaber",
leaderboardId
);
if (leaderboardResponse == undefined) {
throw new NotFoundError(`Leaderboard "${leaderboardId}" not found`);
}
const { leaderboard, beatsaver } = leaderboardResponse;
const additionalData = await this.getAdditionalScoreData(
playerId,
leaderboard.songHash,
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
score.score
);
if (additionalData !== undefined) {
score.additionalData = additionalData;
}
const previousScore = await this.getPreviousScore(playerId, leaderboardId, score.timestamp);
if (previousScore !== undefined) {
score.previousScore = previousScore;
}
metadata = new Metadata(
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
leaderboardScores.metadata.total,
leaderboardScores.metadata.page,
leaderboardScores.metadata.itemsPerPage
);
break;
toReturn.push({
score: score as unknown as ScoreSaberScore,
leaderboard: leaderboard,
beatSaver: beatsaver,
});
}
default: {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
}
return {
scores: scores,
leaderboard: leaderboard,
beatSaver: beatSaverMap,
metadata: metadata,
};
return toReturn;
});
}
/**
* Gets the player's previous score for a map.
*
* @param playerId the player's id to get the previous score for
* @param leaderboardId the leaderboard to get the previous score on
* @param timestamp the score's timestamp to get the previous score for
* @returns the score, or undefined if none
*/
public static async getPreviousScore(
playerId: string,
leaderboardId: string,
timestamp: Date
): Promise<ScoreSaberPreviousScore | undefined> {
const scores = await ScoreSaberScoreModel.find({ playerId: playerId, leaderboardId: leaderboardId }).sort({
timestamp: -1,
});
if (scores == null || scores.length == 0) {
return undefined;
}
const scoreIndex = scores.findIndex(score => score.timestamp.getTime() == timestamp.getTime());
const score = scores.find(score => score.timestamp.getTime() == timestamp.getTime());
if (scoreIndex == -1 || score == undefined) {
return undefined;
}
const previousScore = scores[scoreIndex + 1];
if (previousScore == undefined) {
return undefined;
}
return {
score: previousScore.score,
accuracy: previousScore.accuracy,
modifiers: previousScore.modifiers,
misses: previousScore.misses,
missedNotes: previousScore.missedNotes,
badCuts: previousScore.badCuts,
fullCombo: previousScore.fullCombo,
pp: previousScore.pp,
weight: previousScore.weight,
maxCombo: previousScore.maxCombo,
timestamp: previousScore.timestamp,
change: {
score: score.score - previousScore.score,
accuracy: score.accuracy - previousScore.accuracy,
misses: score.misses - previousScore.misses,
missedNotes: score.missedNotes - previousScore.missedNotes,
badCuts: score.badCuts - previousScore.badCuts,
pp: score.pp - previousScore.pp,
weight: score.weight && previousScore.weight && score.weight - previousScore.weight,
maxCombo: score.maxCombo - previousScore.maxCombo,
},
} as ScoreSaberPreviousScore;
}
}

View File

@ -11,5 +11,6 @@
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"jsx": "react",
},
"incremental": true
}
}

View File

@ -19,6 +19,7 @@
"typescript": "^5"
},
"dependencies": {
"@typegoose/auto-increment": "^4.7.0",
"@typegoose/typegoose": "^12.8.0",
"ky": "^1.7.2",
"ws": "^8.18.0"

View File

@ -1,6 +1,6 @@
type CacheOptions = {
/**
* The time the cached object will be valid for
* The time (in ms) the cached object will be valid for
*/
ttl?: number;

View File

@ -1,4 +1,4 @@
import { HttpCode } from "../common/http-codes";
import { HttpCode } from "../http-codes";
export class InternalServerError extends Error {
constructor(

View File

@ -1,4 +1,4 @@
import { HttpCode } from "../common/http-codes";
import { HttpCode } from "../http-codes";
export class NotFoundError extends Error {
constructor(

View File

@ -1,4 +1,4 @@
import { HttpCode } from "../common/http-codes";
import { HttpCode } from "../http-codes";
export class RateLimitError extends Error {
constructor(

View File

@ -1,63 +0,0 @@
import Leaderboard from "../leaderboard";
import LeaderboardDifficulty from "../leaderboard-difficulty";
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
import { getDifficultyFromScoreSaberDifficulty } from "../../utils/scoresaber-utils";
import { parseDate } from "../../utils/time-utils";
export default interface ScoreSaberLeaderboard extends Leaderboard {
/**
* The star count for the leaderboard.
*/
readonly stars: number;
/**
* The total amount of plays.
*/
readonly plays: number;
/**
* The amount of plays today.
*/
readonly dailyPlays: number;
}
/**
* Parses a {@link ScoreSaberLeaderboardToken} into a {@link ScoreSaberLeaderboard}.
*
* @param token the token to parse
*/
export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardToken): ScoreSaberLeaderboard {
const difficulty: LeaderboardDifficulty = {
leaderboardId: token.difficulty.leaderboardId,
difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty),
gameMode: token.difficulty.gameMode,
difficultyRaw: token.difficulty.difficultyRaw,
};
return {
id: token.id,
songHash: token.songHash,
songName: token.songName,
songSubName: token.songSubName,
songAuthorName: token.songAuthorName,
levelAuthorName: token.levelAuthorName,
difficulty: difficulty,
difficulties:
token.difficulties != undefined && token.difficulties.length > 0
? token.difficulties.map(difficulty => {
return {
leaderboardId: difficulty.leaderboardId,
difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty),
gameMode: difficulty.gameMode,
difficultyRaw: difficulty.difficultyRaw,
};
})
: [difficulty],
maxScore: token.maxScore,
ranked: token.ranked,
songArt: token.coverImage,
timestamp: parseDate(token.createdDate),
stars: token.stars,
plays: token.plays,
dailyPlays: token.dailyPlays,
};
}

View File

@ -1,23 +0,0 @@
import { Difficulty } from "../score/difficulty";
export default interface LeaderboardDifficulty {
/**
* The id of the leaderboard.
*/
leaderboardId: number;
/**
* The difficulty of the leaderboard.
*/
difficulty: Difficulty;
/**
* The game mode of the leaderboard.
*/
gameMode: string;
/**
* The raw difficulty of the leaderboard.
*/
difficultyRaw: string;
}

View File

@ -1,75 +0,0 @@
import LeaderboardDifficulty from "./leaderboard-difficulty";
export default interface Leaderboard {
/**
* The id of the leaderboard.
* @private
*/
readonly id: number;
/**
* The hash of the song this leaderboard is for.
* @private
*/
readonly songHash: string;
/**
* The name of the song this leaderboard is for.
* @private
*/
readonly songName: string;
/**
* The sub name of the leaderboard.
* @private
*/
readonly songSubName: string;
/**
* The author of the song this leaderboard is for.
* @private
*/
readonly songAuthorName: string;
/**
* The author of the level this leaderboard is for.
* @private
*/
readonly levelAuthorName: string;
/**
* The difficulty of the leaderboard.
* @private
*/
readonly difficulty: LeaderboardDifficulty;
/**
* The difficulties of the leaderboard.
* @private
*/
readonly difficulties: LeaderboardDifficulty[];
/**
* The maximum score of the leaderboard.
* @private
*/
readonly maxScore: number;
/**
* Whether the leaderboard is ranked.
* @private
*/
readonly ranked: boolean;
/**
* The link to the song art.
* @private
*/
readonly songArt: string;
/**
* The date the leaderboard was created.
* @private
*/
readonly timestamp: Date;
}

View File

@ -0,0 +1,133 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose";
import { HandAccuracy } from "./hand-accuracy";
import { Misses } from "./misses";
/**
* The model for additional score data.
*/
@modelOptions({
options: { allowMixed: Severity.ALLOW },
schemaOptions: {
collection: "additional-score-data",
toObject: {
virtuals: true,
transform: function (_, ret) {
delete ret._id;
delete ret.playerId;
delete ret.songHash;
delete ret.songDifficulty;
delete ret.songScore;
delete ret.__v;
return ret;
},
},
},
})
export class AdditionalScoreData {
/**
* The of the player who set the score.
*/
@prop({ required: true, index: true })
public playerId!: string;
/**
* The hash of the song.
*/
@prop({ required: true, index: true })
public songHash!: string;
/**
* The difficulty the score was set on.
*/
@prop({ required: true, index: true })
public songDifficulty!: string;
/**
* The score of the play.
*/
@prop({ required: true, index: true })
public songScore!: number;
// Above data is only so we can fetch it
// --------------------------------
/**
* The BeatLeader score id for this score.
*/
@prop({ required: false })
public scoreId!: number;
/**
* The BeatLeader leaderboard id for this score.
*/
@prop({ required: false })
public leaderboardId!: string;
/**
* The amount of pauses in the play.
*/
@prop({ required: false })
public pauses!: number;
/**
* The miss data for the play.
*/
@prop({ required: false, _id: false })
public misses!: Misses;
/**
* The hand accuracy for each hand.
* @private
*/
@prop({ required: false, _id: false })
public handAccuracy!: HandAccuracy;
/**
* The full combo accuracy of the play.
*/
@prop({ required: true })
public fcAccuracy!: number;
/**
* Whether the play was a full combo.
*/
@prop({ required: true })
public fullCombo!: boolean;
/**
* The score improvement.
*/
@prop({ required: false, _id: false })
public scoreImprovement?: {
/**
* The change in the score.
*/
score: number;
/**
* The change in the accuracy.
*/
accuracy: number;
/**
* The change in the misses.
*/
misses: Misses;
/**
* The change in the hand accuracy.
*/
handAccuracy: HandAccuracy;
};
/**
* The date the score was set on.
*/
@prop({ required: true, index: true })
public timestamp!: Date;
}
export type AdditionalScoreDataDocument = AdditionalScoreData & Document;
export const AdditionalScoreDataModel: ReturnModelType<typeof AdditionalScoreData> =
getModelForClass(AdditionalScoreData);

View File

@ -0,0 +1,15 @@
import { prop } from "@typegoose/typegoose";
export class HandAccuracy {
/**
* The left hand accuracy.
*/
@prop({ required: true })
left!: number;
/**
* The right hand accuracy.
*/
@prop({ required: true })
right!: number;
}

View File

@ -0,0 +1,33 @@
import { prop } from "@typegoose/typegoose";
export class Misses {
/**
* The amount of misses notes + bad cuts.
*/
@prop({ required: true })
misses!: number;
/**
* The total amount of notes that were missed.
*/
@prop({ required: true })
missedNotes!: number;
/**
* The amount of times a bomb was hit.
*/
@prop({ required: true })
bombCuts!: number;
/**
* The amount of walls hit in the play.
*/
@prop({ required: true })
wallsHit!: number;
/**
* The number of bad cuts.
*/
@prop({ required: true })
badCuts!: number;
}

View File

@ -0,0 +1,27 @@
import { prop } from "@typegoose/typegoose";
export default class BeatSaverAuthor {
/**
* The id of the author.
*/
@prop({ required: true })
id: number;
/**
* The name of the mapper.
*/
@prop({ required: true })
name: string;
/**
* The avatar URL for the mapper.
*/
@prop({ required: true })
avatar: string;
constructor(id: number, name: string, avatar: string) {
this.id = id;
this.name = name;
this.avatar = avatar;
}
}

View File

@ -1,13 +0,0 @@
import { prop } from "@typegoose/typegoose";
export default class BeatsaverAuthor {
/**
* The id of the author.
*/
@prop({ required: true })
id: number;
constructor(id: number) {
this.id = id;
}
}

View File

@ -1,57 +0,0 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose";
import BeatsaverAuthor from "./beatsaver-author";
/**
* The model for a BeatSaver map.
*/
@modelOptions({
options: { allowMixed: Severity.ALLOW },
schemaOptions: {
toObject: {
virtuals: true,
transform: function (_, ret) {
ret.id = ret._id;
delete ret._id;
delete ret.__v;
return ret;
},
},
},
})
export class BeatSaverMap {
/**
* The internal MongoDB ID (_id).
*/
@prop({ required: true })
private _id!: string;
/**
* The bsr code for the map.
* @private
*/
@prop({ required: false })
public bsr!: string;
/**
* The author of the map.
*/
@prop({ required: false, _id: false, type: () => BeatsaverAuthor })
public author!: BeatsaverAuthor;
/**
* True if the map is unknown on beatsaver.
*/
@prop({ required: false })
public unknownMap?: boolean;
/**
* Exposes `id` as a virtual field mapped from `_id`.
*/
public get id(): string {
return this._id;
}
}
export type BeatSaverMapDocument = BeatSaverMap & Document;
export const BeatSaverMapModel: ReturnModelType<typeof BeatSaverMap> = getModelForClass(BeatSaverMap);

View File

@ -0,0 +1,128 @@
import { prop } from "@typegoose/typegoose";
import { type MapDifficulty } from "../../score/map-difficulty";
export default class BeatSaverMapDifficulty {
/**
* The NJS of this difficulty.
*/
@prop({ required: true })
njs: number;
/**
* The NJS offset of this difficulty.
*/
@prop({ required: true })
offset: number;
/**
* The amount of notes in this difficulty.
*/
@prop({ required: true })
notes: number;
/**
* The amount of bombs in this difficulty.
*/
@prop({ required: true })
bombs: number;
/**
* The amount of obstacles in this difficulty.
*/
@prop({ required: true })
obstacles: number;
/**
* The notes per second in this difficulty.
*/
@prop({ required: true })
nps: number;
/**
* The characteristic of this difficulty.
*/
@prop({ required: true, enum: ["Standard", "Lawless"] })
characteristic: "Standard" | "Lawless";
/**
* The difficulty level.
*/
@prop({ required: true })
difficulty: MapDifficulty;
/**
* The amount of lighting events in this difficulty.
*/
@prop({ required: true })
events: number;
/**
* Whether this difficulty uses Chroma.
*/
@prop({ required: true, default: false })
chroma: boolean;
/**
* Does this difficulty use Mapping Extensions.
*/
@prop({ required: true, default: false })
mappingExtensions: boolean;
/**
* Does this difficulty use Noodle Extensions.
*/
@prop({ required: true, default: false })
noodleExtensions: boolean;
/**
* Whether this difficulty uses cinema mode.
*/
@prop({ required: true, default: false })
cinema: boolean;
/**
* The maximum score achievable in this difficulty.
*/
@prop({ required: true })
maxScore: number;
/**
* The custom label for this difficulty.
*/
@prop()
label: string;
constructor(
njs: number,
offset: number,
notes: number,
bombs: number,
obstacles: number,
nps: number,
characteristic: "Standard" | "Lawless",
difficulty: MapDifficulty,
events: number,
chroma: boolean,
mappingExtensions: boolean,
noodleExtensions: boolean,
cinema: boolean,
maxScore: number,
label: string
) {
this.njs = njs;
this.offset = offset;
this.notes = notes;
this.bombs = bombs;
this.obstacles = obstacles;
this.nps = nps;
this.characteristic = characteristic;
this.difficulty = difficulty;
this.events = events;
this.chroma = chroma;
this.mappingExtensions = mappingExtensions;
this.noodleExtensions = noodleExtensions;
this.cinema = cinema;
this.maxScore = maxScore;
this.label = label;
}
}

View File

@ -0,0 +1,55 @@
import { prop } from "@typegoose/typegoose";
export default class BeatSaverMapMetadata {
/**
* The bpm of the song.
*/
@prop({ required: true })
bpm: number;
/**
* The song's length in seconds.
*/
@prop({ required: true })
duration: number;
/**
* The song's name.
*/
@prop({ required: true })
songName: string;
/**
* The song's sub name.
*/
@prop({ required: false })
songSubName: string;
/**
* The artist(s) name.
*/
@prop({ required: true })
songAuthorName: string;
/**
* The level mapper(s) name.
*/
@prop({ required: true })
levelAuthorName: string;
constructor(
bpm: number,
duration: number,
songName: string,
songSubName: string,
songAuthorName: string,
levelAuthorName: string
) {
this.bpm = bpm;
this.duration = duration;
this.songName = songName;
this.songSubName = songSubName;
this.songAuthorName = songAuthorName;
this.levelAuthorName = levelAuthorName;
}
}

View File

@ -0,0 +1,31 @@
import { modelOptions, prop, Severity } from "@typegoose/typegoose";
import BeatSaverMapDifficulty from "./map-difficulty";
@modelOptions({
options: { allowMixed: Severity.ALLOW },
})
export default class BeatSaverMapVersion {
/**
* The hash of this map.
*/
@prop({ required: true })
hash: string;
/**
* The date the map was created.
*/
@prop({ required: true })
createdAt: Date;
/**
* The difficulties of this map.
*/
@prop({ required: true })
difficulties: BeatSaverMapDifficulty[];
constructor(hash: string, createdAt: Date, difficulties: BeatSaverMapDifficulty[]) {
this.hash = hash;
this.createdAt = createdAt;
this.difficulties = difficulties;
}
}

View File

@ -0,0 +1,99 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose";
import BeatSaverAuthor from "./author";
import BeatSaverMapVersion from "./map-version";
import BeatSaverMapMetadata from "./map-metadata";
/**
* The model for a BeatSaver map.
*/
@modelOptions({
options: { allowMixed: Severity.ALLOW },
schemaOptions: {
collection: "beatsaver-maps",
toObject: {
virtuals: true,
transform: function (_, ret) {
ret.id = ret._id;
delete ret._id;
delete ret.__v;
return ret;
},
},
},
})
export class BeatSaverMap {
/**
* The internal MongoDB ID (_id).
*/
@prop({ required: true })
private _id!: string;
/**
* The name of the map.
*/
@prop({ required: false })
public name!: string;
/**
* The description of the map.
*/
@prop({ required: false })
public description!: string;
/**
* The bsr code for the map.
*/
@prop({ required: false })
public bsr!: string;
/**
* The author of the map.
*/
@prop({ required: false, _id: false, type: () => BeatSaverAuthor })
public author!: BeatSaverAuthor;
/**
* The versions of the map.
*/
@prop({ required: false, _id: false, type: () => [BeatSaverMapVersion] })
public versions!: BeatSaverMapVersion[];
/**
* The metadata of the map.
*/
@prop({ required: false, _id: false, type: () => BeatSaverMapMetadata })
public metadata!: BeatSaverMapMetadata;
/**
* True if the map is not found on beatsaver.
*/
@prop({ required: false })
public notFound?: boolean;
/**
* The last time the map data was refreshed.
*/
@prop({ required: true })
public lastRefreshed!: Date;
/**
* Exposes `id` as a virtual field mapped from `_id`.
*/
public get id(): string {
return this._id;
}
/**
* Should the map data be refreshed?
*
* @returns true if the map data should be refreshed
*/
public shouldRefresh(): boolean {
const now = new Date();
return now.getTime() - this.lastRefreshed.getTime() > 1000 * 60 * 60 * 24 * 3; // 3 days
}
}
export type BeatSaverMapDocument = BeatSaverMap & Document;
export const BeatSaverMapModel: ReturnModelType<typeof BeatSaverMap> = getModelForClass(BeatSaverMap);

View File

@ -0,0 +1,56 @@
import Leaderboard from "../leaderboard";
import { type LeaderboardStatus } from "../leaderboard-status";
import { getModelForClass, modelOptions, Prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose";
@modelOptions({
options: { allowMixed: Severity.ALLOW },
schemaOptions: {
collection: "scoresaber-leaderboards",
toObject: {
virtuals: true,
transform: function (_, ret) {
ret.id = ret._id;
delete ret._id;
delete ret.__v;
return ret;
},
},
},
})
export default class ScoreSaberLeaderboardInternal extends Leaderboard {
/**
* The star count for the leaderboard.
*/
@Prop({ required: true })
readonly stars!: number;
/**
* The total amount of plays.
*/
@Prop({ required: true })
readonly plays!: number;
/**
* The amount of plays today.
*/
@Prop({ required: true })
readonly dailyPlays!: number;
/**
* Whether this leaderboard is qualified to be ranked.
*/
@Prop({ required: true })
readonly qualified!: boolean;
/**
* The status of the map.
*/
@Prop({ required: true })
readonly status!: LeaderboardStatus;
}
export type ScoreSaberLeaderboard = InstanceType<typeof ScoreSaberLeaderboardInternal>;
export type ScoreSaberLeaderboardDocument = ScoreSaberLeaderboard & Document;
export const ScoreSaberLeaderboardModel: ReturnModelType<typeof ScoreSaberLeaderboardInternal> =
getModelForClass(ScoreSaberLeaderboardInternal);

View File

@ -0,0 +1,29 @@
import { type MapDifficulty } from "../../score/map-difficulty";
import { type MapCharacteristic } from "../../types/map-characteristic";
import { Prop } from "@typegoose/typegoose";
export default class LeaderboardDifficulty {
/**
* The id of the leaderboard.
*/
@Prop({ required: true })
leaderboardId!: number;
/**
* The difficulty of the leaderboard.
*/
@Prop({ required: true })
difficulty!: MapDifficulty;
/**
* The characteristic of the leaderboard.
*/
@Prop({ required: true })
characteristic!: MapCharacteristic;
/**
* The raw difficulty of the leaderboard.
*/
@Prop({ required: true })
difficultyRaw!: string;
}

View File

@ -0,0 +1,4 @@
/**
* The status of the leaderboard.
*/
export type LeaderboardStatus = "Unranked" | "Ranked" | "Qualified";

View File

@ -0,0 +1,99 @@
import LeaderboardDifficulty from "./leaderboard-difficulty";
import { Prop } from "@typegoose/typegoose";
export default class Leaderboard {
/**
* The id of the leaderboard.
* @private
*/
@Prop({ required: true })
private readonly _id?: number;
/**
* The hash of the song this leaderboard is for.
* @private
*/
@Prop({ required: true })
readonly songHash!: string;
/**
* The name of the song this leaderboard is for.
* @private
*/
@Prop({ required: true })
readonly songName!: string;
/**
* The sub name of the leaderboard.
* @private
*/
@Prop({ required: true })
readonly songSubName!: string;
/**
* The author of the song this leaderboard is for.
* @private
*/
@Prop({ required: true })
readonly songAuthorName!: string;
/**
* The author of the level this leaderboard is for.
* @private
*/
@Prop({ required: true })
readonly levelAuthorName!: string;
/**
* The difficulty of the leaderboard.
* @private
*/
@Prop({ required: true, _id: false, type: () => LeaderboardDifficulty })
readonly difficulty!: LeaderboardDifficulty;
/**
* The difficulties of the leaderboard.
* @private
*/
@Prop({ required: true, _id: false, type: () => [LeaderboardDifficulty] })
readonly difficulties!: LeaderboardDifficulty[];
/**
* The maximum score of the leaderboard.
* @private
*/
@Prop({ required: true })
readonly maxScore!: number;
/**
* Whether the leaderboard is ranked.
* @private
*/
@Prop({ required: true })
readonly ranked!: boolean;
/**
* The link to the song art.
* @private
*/
@Prop({ required: true })
readonly songArt!: string;
/**
* The date the leaderboard was created.
* @private
*/
@Prop({ required: true })
readonly timestamp!: Date;
/**
* The date the leaderboard was last refreshed.
* @private
*/
@Prop({ required: true })
lastRefreshed?: Date;
get id(): number {
return this._id ?? 0;
}
}

View File

@ -14,12 +14,24 @@ export class Player {
@prop()
public _id!: string;
/**
* The player's name.
*/
@prop()
public name?: string;
/**
* The player's statistic history.
*/
@prop()
private statisticHistory?: Record<string, PlayerHistory>;
/**
* Whether the player has their scores seeded.
*/
@prop()
public seededScores?: boolean;
/**
* The date the player was last tracked.
*/
@ -63,7 +75,7 @@ export class Player {
const statisticHistory = this.getStatisticHistory();
const history: Record<string, PlayerHistory> = {};
for (let i = 0; i < days; i++) {
for (let i = 0; i <= days; i++) {
const date = formatDateMinimal(getMidnightAlignedDate(getDaysAgoDate(i)));
const playerHistory = statisticHistory[date];
if (playerHistory !== undefined && Object.keys(playerHistory).length > 0) {

View File

@ -0,0 +1,101 @@
import { getModelForClass, modelOptions, plugin, Prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import Score from "../score";
import { type ScoreSaberLeaderboardPlayerInfoToken } from "../../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
import { Document } from "mongoose";
import { AutoIncrementID } from "@typegoose/auto-increment";
import { PreviousScore } from "../previous-score";
@modelOptions({
options: { allowMixed: Severity.ALLOW },
schemaOptions: {
collection: "scoresaber-scores",
toObject: {
virtuals: true,
transform: function (_, ret) {
ret.id = ret._id;
delete ret._id;
delete ret.__v;
return ret;
},
},
},
})
@plugin(AutoIncrementID, {
field: "_id",
startAt: 1,
trackerModelName: "scores",
trackerCollection: "increments",
overwriteModelName: "scoresaber-scores",
})
export class ScoreSaberScoreInternal extends Score {
/**
* The score's id.
*/
@Prop({ required: true, index: true })
public readonly scoreId!: string;
/**
* The leaderboard the score was set on.
*/
@Prop({ required: true, index: true })
public readonly leaderboardId!: number;
/**
* The amount of pp for the score.
* @private
*/
@Prop({ required: true, index: true })
public pp!: number;
/**
* The weight of the score, or undefined if not ranked.
* @private
*/
@Prop()
public readonly weight?: number;
/**
* The max combo of the score.
*/
@Prop({ required: true })
public readonly maxCombo!: number;
/**
* The previous score, if any.
*/
public previousScore?: ScoreSaberPreviousScore;
}
class ScoreSaberScorePublic extends ScoreSaberScoreInternal {
/**
* The player who set the score.
*/
public playerInfo!: ScoreSaberLeaderboardPlayerInfoToken;
}
export type ScoreSaberPreviousScore = PreviousScore & {
/**
* The pp of the previous score.
*/
pp: number;
/**
* The weight of the previous score.
*/
weight: number;
/**
* The max combo of the previous score.
*/
maxCombo: number;
/**
* The change between the previous score and the current score.
*/
change?: ScoreSaberPreviousScore;
};
export type ScoreSaberScore = InstanceType<typeof ScoreSaberScorePublic>;
export type ScoreSaberScoreDocument = ScoreSaberScore & Document;
export const ScoreSaberScoreModel: ReturnModelType<typeof ScoreSaberScoreInternal> =
getModelForClass(ScoreSaberScoreInternal);

View File

@ -0,0 +1,43 @@
import { Modifier } from "../../score/modifier";
export type PreviousScore = {
/**
* The score of the previous score.
*/
score: number;
/**
* The accuracy of the previous score.
*/
accuracy: number;
/**
* The modifiers of the previous score.
*/
modifiers?: Modifier[];
/**
* The misses of the previous score.
*/
misses: number;
/**
* The missed notes of the previous score.
*/
missedNotes: number;
/**
* The bad cuts of the previous score.
*/
badCuts: number;
/**
* The full combo of the previous score.
*/
fullCombo?: boolean;
/**
* When the previous score was set.
*/
timestamp: Date;
};

View File

@ -0,0 +1,102 @@
import { Modifier } from "../../score/modifier";
import { AdditionalScoreData } from "../additional-score-data/additional-score-data";
import { prop } from "@typegoose/typegoose";
import { type MapDifficulty } from "../../score/map-difficulty";
import { type MapCharacteristic } from "../../types/map-characteristic";
/**
* The model for a score.
*/
export default class Score {
/**
* The internal score id.
*/
@prop()
public _id?: number;
/**
* The id of the player who set the score.
*/
@prop({ required: true, index: true })
public readonly playerId!: string;
/**
* The map difficulty played in the score.
*/
@prop({ required: true })
public readonly difficulty!: MapDifficulty;
/**
* The characteristic of the map.
*/
@prop({ required: true })
public readonly characteristic!: MapCharacteristic;
/**
* The base score for the score.
* @private
*/
@prop({ required: true })
public readonly score!: number;
/**
* The accuracy of the score.
*/
@prop({ required: true })
public readonly accuracy!: number;
/**
* The rank for the score.
* @private
*/
@prop({ required: true })
public readonly rank!: number;
/**
* The modifiers used on the score.
* @private
*/
@prop({ enum: () => Modifier, type: String, required: true })
public readonly modifiers!: Modifier[];
/**
* The total amount of misses.
* @private
*/
@prop({ required: true })
public readonly misses!: number;
/**
* The amount of missed notes.
*/
@prop({ required: true })
public readonly missedNotes!: number;
/**
* The amount of bad cuts.
* @private
*/
@prop({ required: true })
public readonly badCuts!: number;
/**
* Whether every note was hit.
* @private
*/
@prop({ required: true })
public readonly fullCombo!: boolean;
/**
* The additional data for the score.
*/
public additionalData?: AdditionalScoreData;
/**
* The time the score was set.
* @private
*/
@prop({ required: true, index: true })
public readonly timestamp!: Date;
}
export type ScoreType = InstanceType<typeof Score>;

View File

@ -0,0 +1,103 @@
import { Metadata } from "./types/metadata";
import { NotFoundError } from "./error/not-found-error";
type FetchItemsFunction<T> = (fetchItems: FetchItems) => Promise<T[]>;
export class Pagination<T> {
private itemsPerPage: number = 0;
private totalItems: number = 0;
private items: T[] | null = null; // Optional array to hold set items
/**
* Sets the number of items per page.
* @param itemsPerPage - The number of items per page.
* @returns the pagination
*/
setItemsPerPage(itemsPerPage: number): Pagination<T> {
this.itemsPerPage = itemsPerPage;
return this;
}
/**
* Sets the items to paginate.
* @param items - The items to paginate.
* @returns the pagination
*/
setItems(items: T[]): Pagination<T> {
this.items = items;
this.totalItems = items.length;
return this;
}
/**
* Sets the total number of items.
* @param totalItems - Total number of items.
* @returns the pagination
*/
setTotalItems(totalItems: number): Pagination<T> {
this.totalItems = totalItems;
return this;
}
/**
* Gets a page of items, using either static items or a dynamic fetchItems callback.
* @param page - The page number to retrieve.
* @param fetchItems - The async function to fetch items if setItems was not used.
* @returns A promise resolving to the page of items.
* @throws throws an error if the page number is invalid.
*/
async getPage(page: number, fetchItems?: FetchItemsFunction<T>): Promise<Page<T>> {
const totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
if (page < 1 || page > totalPages) {
throw new NotFoundError("Invalid page number");
}
// Calculate the range of items to fetch for the current page
const start = (page - 1) * this.itemsPerPage;
const end = start + this.itemsPerPage;
let pageItems: T[];
// Use set items if they are present, otherwise use fetchItems callback
if (this.items) {
pageItems = this.items.slice(start, end);
} else if (fetchItems) {
pageItems = await fetchItems(new FetchItems(start, end));
} else {
throw new Error("Items function is not set and no fetchItems callback provided");
}
return new Page<T>(pageItems, new Metadata(totalPages, this.totalItems, page, this.itemsPerPage));
}
}
class FetchItems {
readonly start: number;
readonly end: number;
constructor(start: number, end: number) {
this.start = start;
this.end = end;
}
}
export class Page<T> {
readonly items: T[];
readonly metadata: Metadata;
constructor(items: T[], metadata: Metadata) {
this.items = items;
this.metadata = metadata;
}
/**
* Converts the page to a JSON object.
*/
toJSON() {
return {
items: this.items,
metadata: this.metadata,
};
}
}

View File

@ -1,11 +1,5 @@
import Player, { StatisticChange } from "../player";
import ky from "ky";
import { PlayerHistory } from "../player-history";
import ScoreSaberPlayerToken from "../../types/token/scoresaber/score-saber-player-token";
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../../utils/time-utils";
import { getPageFromRank } from "../../utils/utils";
import { Config } from "../../config";
import { getValueFromHistory } from "../../utils/player-utils";
/**
* A ScoreSaber player.
@ -29,7 +23,7 @@ export default interface ScoreSaberPlayer extends Player {
/**
* The role the player has.
*/
role: ScoreSaberRole | undefined;
role: string | undefined;
/**
* The badges the player has.
@ -73,217 +67,6 @@ export default interface ScoreSaberPlayer extends Player {
isBeingTracked?: boolean;
}
/**
* Gets the ScoreSaber Player from an {@link ScoreSaberPlayerToken}.
*
* @param token the player token
* @param playerIdCookie the id of the claimed player
*/
export async function getScoreSaberPlayerFromToken(
token: ScoreSaberPlayerToken,
playerIdCookie?: string
): Promise<ScoreSaberPlayer> {
const bio: ScoreSaberBio = {
lines: token.bio?.split("\n") || [],
linesStripped: token.bio?.replace(/<[^>]+>/g, "")?.split("\n") || [],
};
const role = token.role == null ? undefined : (token.role as ScoreSaberRole);
const badges: ScoreSaberBadge[] =
token.badges?.map(badge => {
return {
url: badge.image,
description: badge.description,
};
}) || [];
let isBeingTracked = false;
const todayDate = formatDateMinimal(getMidnightAlignedDate(new Date()));
let statisticHistory: { [key: string]: PlayerHistory } = {};
try {
const { statistics: history } = await ky
.get<{
statistics: { [key: string]: PlayerHistory };
}>(
`${Config.apiUrl}/player/history/${token.id}${playerIdCookie && playerIdCookie == token.id ? "?createIfMissing=true" : ""}`
)
.json();
if (history) {
// Use the latest data for today
history[todayDate] = {
...{
scores: {
rankedScores: 0,
unrankedScores: 0,
totalScores: 0,
totalRankedScores: 0,
},
},
...history[todayDate],
rank: token.rank,
countryRank: token.countryRank,
pp: token.pp,
accuracy: {
averageRankedAccuracy: token.scoreStats.averageRankedAccuracy,
},
};
isBeingTracked = true;
}
statisticHistory = history;
} catch (e) {}
const playerRankHistory = token.histories.split(",").map(value => {
return parseInt(value);
});
playerRankHistory.push(token.rank);
let missingDays = 0;
let daysAgo = 0; // Start from current day
for (let i = playerRankHistory.length - 1; i >= 0; i--) {
const rank = playerRankHistory[i];
if (rank == 999_999) {
continue;
}
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
daysAgo += 1;
const dateKey = formatDateMinimal(date);
if (!statisticHistory[dateKey] || statisticHistory[dateKey].rank == undefined) {
missingDays += 1;
statisticHistory[dateKey] = {
...statisticHistory[dateKey],
rank: rank,
scores: {
totalScores: token.scoreStats.totalPlayCount,
totalRankedScores: token.scoreStats.rankedPlayCount,
},
};
}
}
if (missingDays > 0 && missingDays != playerRankHistory.length) {
console.log(
`Player has ${missingDays} missing day${missingDays > 1 ? "s" : ""}, filling in with fallback history...`
);
}
// Sort the fallback history
statisticHistory = Object.entries(statisticHistory)
.sort((a, b) => Date.parse(b[0]) - Date.parse(a[0]))
.reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {});
/**
* Gets the change in the given stat
*
* @param statType the stat to check
* @param daysAgo the amount of days ago to get the stat for
* @return the change
*/
const getStatisticChange = (statType: string, daysAgo: number = 1): number | undefined => {
const todayStats = statisticHistory[todayDate];
let otherDate: Date | undefined;
// Use the same logic as the first version to get the date exactly 'daysAgo' days earlier
if (daysAgo === 1) {
otherDate = getMidnightAlignedDate(getDaysAgoDate(1)); // Yesterday
} else {
const targetDate = getDaysAgoDate(daysAgo);
// Filter available dates to find the closest one to the target
const availableDates = Object.keys(statisticHistory)
.map(dateKey => new Date(dateKey))
.filter(date => {
// Convert date back to the correct format for statisticHistory lookup
const formattedDate = formatDateMinimal(date);
const statsForDate = statisticHistory[formattedDate];
const hasStat = statsForDate && statType in statsForDate;
// Only consider past dates with the required statType
const isPast = date.getTime() < new Date().getTime();
return hasStat && isPast;
});
// If no valid dates are found, return undefined
if (availableDates.length === 0) {
return undefined;
}
// Find the closest date from the filtered available dates
otherDate = availableDates.reduce((closestDate, currentDate) => {
const currentDiff = Math.abs(currentDate.getTime() - targetDate.getTime());
const closestDiff = Math.abs(closestDate.getTime() - targetDate.getTime());
return currentDiff < closestDiff ? currentDate : closestDate;
}, availableDates[0]); // Start with the first available date
}
// Ensure todayStats exists and contains the statType
if (!todayStats || !(statType in todayStats)) {
return undefined;
}
const otherStats = statisticHistory[formatDateMinimal(otherDate)]; // This is now validated
// Ensure otherStats exists and contains the statType
if (!otherStats || !(statType in otherStats)) {
return undefined;
}
const statToday = getValueFromHistory(todayStats, statType);
const statOther = getValueFromHistory(otherStats, statType);
if (statToday === undefined || statOther === undefined) {
return undefined;
}
// Return the difference, accounting for negative changes in ranks
return (statToday - statOther) * (statType === "pp" ? 1 : -1);
};
const getStatisticChanges = (daysAgo: number): PlayerHistory => {
return {
rank: getStatisticChange("rank", daysAgo),
countryRank: getStatisticChange("countryRank", daysAgo),
pp: getStatisticChange("pp", daysAgo),
scores: {
totalScores: getStatisticChange("scores.totalScores", daysAgo),
totalRankedScores: getStatisticChange("scores.totalRankedScores", daysAgo),
},
};
};
return {
id: token.id,
name: token.name,
avatar: token.profilePicture,
country: token.country,
rank: token.rank,
countryRank: token.countryRank,
joinedDate: new Date(token.firstSeen),
bio: bio,
pp: token.pp,
statisticChange: {
daily: getStatisticChanges(1),
weekly: getStatisticChanges(7),
monthly: getStatisticChanges(30),
yearly: getStatisticChanges(365),
},
role: role,
badges: badges,
statisticHistory: statisticHistory,
statistics: token.scoreStats,
rankPages: {
global: getPageFromRank(token.rank, 50),
country: getPageFromRank(token.countryRank, 50),
},
permissions: token.permissions,
banned: token.banned,
inactive: token.inactive,
isBeingTracked: isBeingTracked,
};
}
/**
* A bio of a player.
*/
@ -299,11 +82,6 @@ export type ScoreSaberBio = {
linesStripped: string[];
};
/**
* The ScoreSaber account roles.
*/
export type ScoreSaberRole = "Admin";
/**
* A badge for a player.
*/

View File

@ -15,7 +15,27 @@ export interface PlayerHistory {
pp?: number;
/**
* The amount of scores set for this day.
* How many times replays of the player scores have been watched
*/
replaysWatched?: number;
/**
* The player's score stats.
*/
score?: {
/**
* The total amount of unranked and ranked score.
*/
totalScore?: number;
/**
* The total amount of ranked score.
*/
totalRankedScore?: number;
};
/**
* The player's scores stats.
*/
scores?: {
/**
@ -40,7 +60,7 @@ export interface PlayerHistory {
};
/**
* The player's accuracy.
* The player's accuracy stats.
*/
accuracy?: {
/**

View File

@ -0,0 +1,64 @@
import ScoreSaberPlayer from "./impl/scoresaber-player";
import { StatisticRange } from "./player";
export type PlayerStatValue = {
/**
* The type of the stat.
*/
type: string;
/**
* The value of the stat.
*/
value: (player: ScoreSaberPlayer, range: StatisticRange) => number | undefined;
};
export type PlayerStatChangeType =
| "Rank"
| "CountryRank"
| "PerformancePoints"
| "TotalPlayCount"
| "RankedPlayCount"
| "TotalScore"
| "TotalRankedScore"
| "AverageRankedAccuracy"
| "TotalReplaysWatched";
export const PlayerStatChange: Record<PlayerStatChangeType, PlayerStatValue> = {
Rank: {
type: "Rank",
value: (player, range) => player.statisticChange?.[range].rank,
},
CountryRank: {
type: "Country Rank",
value: (player, range) => player.statisticChange?.[range].countryRank,
},
PerformancePoints: {
type: "Performance Points",
value: (player, range) => player.statisticChange?.[range].pp,
},
TotalPlayCount: {
type: "Total Play Count",
value: (player, range) => player.statisticChange?.[range].scores?.totalScores,
},
RankedPlayCount: {
type: "Ranked Play Count",
value: (player, range) => player.statisticChange?.[range].scores?.totalRankedScores,
},
TotalScore: {
type: "Total Score",
value: (player, range) => player.statisticChange?.[range].score?.totalScore,
},
TotalRankedScore: {
type: "Total Ranked Score",
value: (player, range) => player.statisticChange?.[range].score?.totalRankedScore,
},
AverageRankedAccuracy: {
type: "Average Ranked Accuracy",
value: (player, range) => player.statisticChange?.[range].accuracy?.averageRankedAccuracy,
},
TotalReplaysWatched: {
type: "Total Replays Watched",
value: (player, range) => player.statisticChange?.[range].replaysWatched,
},
};

View File

@ -1,32 +0,0 @@
export type PlayerStatValue = {
/**
* The display name of the stat.
*/
displayName: string;
/**
* The value of the stat.
*/
value?: "rank" | "countryRank" | "pp";
};
export const PlayerStat: Record<string, PlayerStatValue> = {
Rank: {
displayName: "Rank",
value: "rank",
},
CountryRank: {
displayName: "Country Rank",
value: "countryRank",
},
PerformancePoints: {
displayName: "Performance Points",
value: "pp",
},
TotalPlayCount: {
displayName: "Total Play Count",
},
RankedPlayCount: {
displayName: "Ranked Play Count",
},
};

View File

@ -55,9 +55,7 @@ export default class Player {
}
}
export type StatisticRange = "daily" | "weekly" | "monthly";
export type StatisticChange = {
daily: PlayerHistory;
weekly: PlayerHistory;
monthly: PlayerHistory;
yearly: PlayerHistory;
[key in StatisticRange]: PlayerHistory;
};

View File

@ -1 +0,0 @@
export type StatTimeframe = "daily" | "weekly" | "monthly";

View File

@ -1,4 +1,4 @@
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "../model/beatsaver/map";
export type LeaderboardResponse<L> = {
/**

View File

@ -1,5 +1,5 @@
import { Metadata } from "../types/metadata";
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "../model/beatsaver/map";
export default interface LeaderboardScoresResponse<S, L> {
/**

View File

@ -0,0 +1,21 @@
import { ScoreSaberLeaderboard } from "src/model/leaderboard/impl/scoresaber-leaderboard";
import { ScoreSaberScore } from "../model/score/impl/scoresaber-score";
import { PlayerScore } from "../score/player-score";
import { Timeframe } from "../timeframe";
export type TopScoresResponse = {
/**
* The top scores.
*/
scores: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[];
/**
* The timeframe returned.
*/
timeframe: Timeframe;
/**
* The amount of scores to fetch.
*/
limit: number;
};

View File

@ -1 +0,0 @@
export type Difficulty = "Easy" | "Normal" | "Hard" | "Expert" | "Expert+" | "Unknown";

View File

@ -1,76 +0,0 @@
import Score from "../score";
import { Modifier } from "../modifier";
import ScoreSaberScoreToken from "../../types/token/scoresaber/score-saber-score-token";
import ScoreSaberLeaderboardPlayerInfoToken from "../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
import ScoreSaberLeaderboard from "../../leaderboard/impl/scoresaber-leaderboard";
export default interface ScoreSaberScore extends Score {
/**
* The score's id.
*/
readonly id: string;
/**
* The amount of pp for the score.
* @private
*/
readonly pp: number;
/**
* The weight of the score, or undefined if not ranked.s
* @private
*/
readonly weight?: number;
/**
* The max combo of the score.
*/
readonly maxCombo: number;
/**
* The player who set the score
*/
readonly playerInfo: ScoreSaberLeaderboardPlayerInfoToken;
}
/**
* Gets a {@link ScoreSaberScore} from a {@link ScoreSaberScoreToken}.
*
* @param token the token to convert
* @param leaderboard the leaderboard the score was set on
*/
export function getScoreSaberScoreFromToken(
token: ScoreSaberScoreToken,
leaderboard?: ScoreSaberLeaderboardToken | ScoreSaberLeaderboard
): ScoreSaberScore {
const modifiers: Modifier[] =
token.modifiers == undefined || token.modifiers === ""
? []
: token.modifiers.split(",").map(mod => {
mod = mod.toUpperCase();
const modifier = Modifier[mod as keyof typeof Modifier];
if (modifier === undefined) {
throw new Error(`Unknown modifier: ${mod}`);
}
return modifier;
});
return {
leaderboard: "scoresaber",
score: token.baseScore,
accuracy: leaderboard ? (token.baseScore / leaderboard.maxScore) * 100 : Infinity,
rank: token.rank,
modifiers: modifiers,
misses: token.missedNotes + token.badCuts,
missedNotes: token.missedNotes,
badCuts: token.badCuts,
fullCombo: token.fullCombo,
timestamp: new Date(token.timeSet),
id: token.id,
pp: token.pp,
weight: token.weight,
maxCombo: token.maxCombo,
playerInfo: token.leaderboardPlayerInfo,
};
}

View File

@ -0,0 +1 @@
export type MapDifficulty = "Easy" | "Normal" | "Hard" | "Expert" | "ExpertPlus" | "Unknown";

View File

@ -1,4 +1,4 @@
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "../model/beatsaver/map";
export interface PlayerScore<S, L> {
/**

View File

@ -1,61 +0,0 @@
import { Modifier } from "./modifier";
import { Leaderboards } from "../leaderboard";
export default interface Score {
/**
* The leaderboard the score is from.
*/
readonly leaderboard: Leaderboards;
/**
* The base score for the score.
* @private
*/
readonly score: number;
/**
* The accuracy of the score.
*/
readonly accuracy: number;
/**
* The rank for the score.
* @private
*/
readonly rank: number;
/**
* The modifiers used on the score.
* @private
*/
readonly modifiers: Modifier[];
/**
* The amount total amount of misses.
* @private
*/
readonly misses: number;
/**
* The amount of missed notes.
*/
readonly missedNotes: number;
/**
* The amount of bad cuts.
* @private
*/
readonly badCuts: number;
/**
* Whether every note was hit.
* @private
*/
readonly fullCombo: boolean;
/**
* The time the score was set.
* @private
*/
readonly timestamp: Date;
}

View File

@ -0,0 +1,34 @@
import Service from "../service";
import { ScoreStatsToken } from "../../types/token/beatleader/score-stats/score-stats";
const LOOKUP_MAP_STATS_BY_SCORE_ID_ENDPOINT = `https://cdn.scorestats.beatleader.xyz/:scoreId.json`;
class BeatLeaderService extends Service {
constructor() {
super("BeatLeader");
}
/**
* Looks up the score stats for a score
*
* @param scoreId the score id to get
* @returns the score stats, or undefined if nothing was found
*/
async lookupScoreStats(scoreId: number): Promise<ScoreStatsToken | undefined> {
const before = performance.now();
this.log(`Looking score stats for "${scoreId}"...`);
const response = await this.fetch<ScoreStatsToken>(
LOOKUP_MAP_STATS_BY_SCORE_ID_ENDPOINT.replace(":scoreId", scoreId + "")
);
// Score stats not found
if (response == undefined) {
return undefined;
}
this.log(`Found score stats for score "${scoreId}" in ${(performance.now() - before).toFixed(0)}ms`);
return response;
}
}
export const beatLeaderService = new BeatLeaderService();

View File

@ -1,5 +1,5 @@
import Service from "../service";
import { BeatSaverMapToken } from "../../types/token/beatsaver/beat-saver-map-token";
import { BeatSaverMapToken } from "../../types/token/beatsaver/map";
const API_BASE = "https://api.beatsaver.com";
const LOOKUP_MAP_BY_HASH_ENDPOINT = `${API_BASE}/maps/hash/:query`;

View File

@ -7,7 +7,7 @@ import ScoreSaberPlayerScoresPageToken from "../../types/token/scoresaber/score-
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token";
import { clamp, lerp } from "../../utils/math-utils";
import { CurvePoint } from "../../utils/curve-point";
import { CurvePoint } from "../../curve-point";
import { SSRCache } from "../../cache";
const API_BASE = "https://scoresaber.com/api";
@ -30,7 +30,7 @@ const LOOKUP_LEADERBOARD_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/sc
const STAR_MULTIPLIER = 42.117208413;
const playerCache = new SSRCache({
ttl: 60 * 30, // 30 minutes
ttl: 60, // 1 minute
});
class ScoreSaberService extends Service {
@ -167,18 +167,21 @@ class ScoreSaberService extends Service {
*
* @param playerId the ID of the player to look up
* @param sort the sort to use
* @param limit the amount of sores to fetch
* @param page the page to get scores for
* @param search
* @param search the query to search for
* @returns the scores of the player, or undefined
*/
public async lookupPlayerScores({
playerId,
sort,
limit = 8,
page,
search,
}: {
playerId: string;
sort: ScoreSort;
limit?: number;
page: number;
search?: string;
useProxy?: boolean;
@ -189,7 +192,7 @@ class ScoreSaberService extends Service {
);
const response = await this.fetch<ScoreSaberPlayerScoresPageToken>(
LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId)
.replace(":limit", 8 + "")
.replace(":limit", limit + "")
.replace(":sort", sort)
.replace(":page", page + "") + (search ? `&search=${search}` : "")
);

View File

@ -0,0 +1 @@
export type Timeframe = "daily" | "weekly" | "monthly";

View File

@ -0,0 +1,331 @@
import ScoreSaberLeaderboard from "./model/leaderboard/impl/scoresaber-leaderboard";
import ScoreSaberLeaderboardToken from "./types/token/scoresaber/score-saber-leaderboard-token";
import LeaderboardDifficulty from "./model/leaderboard/leaderboard-difficulty";
import { getDifficultyFromScoreSaberDifficulty } from "./utils/scoresaber-utils";
import { MapCharacteristic } from "./types/map-characteristic";
import { LeaderboardStatus } from "./model/leaderboard/leaderboard-status";
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate, parseDate } from "./utils/time-utils";
import ScoreSaberPlayerToken from "./types/token/scoresaber/score-saber-player-token";
import ScoreSaberPlayer, { ScoreSaberBadge, ScoreSaberBio } from "./player/impl/scoresaber-player";
import { PlayerHistory } from "./player/player-history";
import ky from "ky";
import { Config } from "./config";
import { getValueFromHistory } from "./utils/player-utils";
import { getPageFromRank } from "./utils/utils";
import ScoreSaberScoreToken from "./types/token/scoresaber/score-saber-score-token";
import { ScoreSaberScore } from "./model/score/impl/scoresaber-score";
import { Modifier } from "./score/modifier";
/**
* Parses a {@link ScoreSaberLeaderboardToken} into a {@link ScoreSaberLeaderboard}.
*
* @param token the token to parse
*/
export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardToken): ScoreSaberLeaderboard {
const difficulty: LeaderboardDifficulty = {
leaderboardId: token.difficulty.leaderboardId,
difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty),
characteristic: token.difficulty.gameMode.replace("Solo", "") as MapCharacteristic,
difficultyRaw: token.difficulty.difficultyRaw,
};
let status: LeaderboardStatus = "Unranked";
if (token.qualified) {
status = "Qualified";
} else if (token.ranked) {
status = "Ranked";
}
return {
id: token.id,
songHash: token.songHash.toUpperCase(),
songName: token.songName,
songSubName: token.songSubName,
songAuthorName: token.songAuthorName,
levelAuthorName: token.levelAuthorName,
difficulty: difficulty,
difficulties:
token.difficulties != undefined && token.difficulties.length > 0
? token.difficulties.map(difficulty => {
return {
leaderboardId: difficulty.leaderboardId,
difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty),
characteristic: difficulty.gameMode.replace("Solo", "") as MapCharacteristic,
difficultyRaw: difficulty.difficultyRaw,
};
})
: [difficulty],
maxScore: token.maxScore,
ranked: token.ranked,
songArt: token.coverImage,
timestamp: parseDate(token.createdDate),
stars: token.stars,
plays: token.plays,
dailyPlays: token.dailyPlays,
qualified: token.qualified,
status: status,
};
}
/**
* Gets a {@link ScoreSaberScore} from a {@link ScoreSaberScoreToken}.
*
* @param token the token to convert
* @param playerId the id of the player who set the score
* @param leaderboard the leaderboard the score was set on
*/
export function getScoreSaberScoreFromToken(
token: ScoreSaberScoreToken,
leaderboard: ScoreSaberLeaderboard,
playerId?: string
): ScoreSaberScore {
const modifiers: Modifier[] =
token.modifiers == undefined || token.modifiers === ""
? []
: token.modifiers.split(",").map(mod => {
mod = mod.toUpperCase();
const modifier = Modifier[mod as keyof typeof Modifier];
if (modifier === undefined) {
throw new Error(`Unknown modifier: ${mod}`);
}
return modifier;
});
return {
playerId: playerId || token.leaderboardPlayerInfo.id,
leaderboardId: leaderboard.id,
difficulty: leaderboard.difficulty.difficulty,
characteristic: leaderboard.difficulty.characteristic,
score: token.baseScore,
accuracy: leaderboard.maxScore ? (token.baseScore / leaderboard.maxScore) * 100 : Infinity,
rank: token.rank,
modifiers: modifiers,
misses: token.missedNotes + token.badCuts,
missedNotes: token.missedNotes,
badCuts: token.badCuts,
fullCombo: token.fullCombo,
timestamp: new Date(token.timeSet),
scoreId: token.id,
pp: token.pp,
weight: token.weight,
maxCombo: token.maxCombo,
playerInfo: token.leaderboardPlayerInfo,
};
}
/**
* Gets the ScoreSaber Player from an {@link ScoreSaberPlayerToken}.
*
* @param token the player token
* @param playerIdCookie the id of the claimed player
*/
export async function getScoreSaberPlayerFromToken(
token: ScoreSaberPlayerToken,
playerIdCookie?: string
): Promise<ScoreSaberPlayer> {
const bio: ScoreSaberBio = {
lines: token.bio?.split("\n") || [],
linesStripped: token.bio?.replace(/<[^>]+>/g, "")?.split("\n") || [], // strips html tags
};
const badges: ScoreSaberBadge[] =
token.badges?.map(badge => {
return {
url: badge.image,
description: badge.description,
};
}) || [];
let isBeingTracked = false;
const todayDate = formatDateMinimal(getMidnightAlignedDate(new Date()));
let statisticHistory: { [key: string]: PlayerHistory } = {};
try {
const { statistics: history } = await ky
.get<{
statistics: { [key: string]: PlayerHistory };
}>(
`${Config.apiUrl}/player/history/${token.id}/50/${playerIdCookie && playerIdCookie == token.id ? "?createIfMissing=true" : ""}`
)
.json();
if (history) {
// Use the latest data for today
history[todayDate] = {
...history[todayDate],
rank: token.rank,
countryRank: token.countryRank,
pp: token.pp,
replaysWatched: token.scoreStats.replaysWatched,
accuracy: {
...history[todayDate]?.accuracy,
averageRankedAccuracy: token.scoreStats.averageRankedAccuracy,
},
scores: {
...history[todayDate]?.scores,
totalScores: token.scoreStats.totalPlayCount,
totalRankedScores: token.scoreStats.rankedPlayCount,
},
score: {
...history[todayDate]?.score,
totalScore: token.scoreStats.totalScore,
totalRankedScore: token.scoreStats.totalRankedScore,
},
};
isBeingTracked = true;
}
statisticHistory = history;
} catch (e) {}
const playerRankHistory = token.histories.split(",").map(value => {
return parseInt(value);
});
playerRankHistory.push(token.rank);
let missingDays = 0;
let daysAgo = 0; // Start from current day
for (let i = playerRankHistory.length - 1; i >= 0; i--) {
const rank = playerRankHistory[i];
if (rank == 999_999) {
continue;
}
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
daysAgo += 1;
const dateKey = formatDateMinimal(date);
if (!statisticHistory[dateKey] || statisticHistory[dateKey].rank == undefined) {
missingDays += 1;
statisticHistory[dateKey] = {
...statisticHistory[dateKey],
rank: rank,
};
}
}
if (missingDays > 0 && missingDays != playerRankHistory.length) {
console.log(
`Player has ${missingDays} missing day${missingDays > 1 ? "s" : ""}, filling in with fallback history...`
);
}
// Sort the fallback history
statisticHistory = Object.entries(statisticHistory)
.sort((a, b) => Date.parse(b[0]) - Date.parse(a[0]))
.reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {});
/**
* Gets the change in the given stat
*
* @param statType the stat to check
* @param isNegativeChange whether to multiply the change by 1 or -1
* @param daysAgo the amount of days ago to get the stat for
* @return the change
*/
const getStatisticChange = (statType: string, isNegativeChange: boolean, daysAgo: number = 1): number | undefined => {
const todayStats = statisticHistory[todayDate];
let otherDate: Date | undefined;
if (daysAgo === 1) {
otherDate = getMidnightAlignedDate(getDaysAgoDate(1)); // Yesterday
} else {
const targetDate = getDaysAgoDate(daysAgo);
// Filter available dates to find the closest one to the target
const availableDates = Object.keys(statisticHistory)
.map(dateKey => new Date(dateKey))
.filter(date => {
// Convert date back to the correct format for statisticHistory lookup
const formattedDate = formatDateMinimal(date);
const statsForDate = statisticHistory[formattedDate];
const hasStat = statsForDate && statType in statsForDate;
// Only consider past dates with the required statType
const isPast = date.getTime() < new Date().getTime();
return hasStat && isPast;
});
// If no valid dates are found, return undefined
if (availableDates.length === 0) {
return undefined;
}
// Find the closest date from the filtered available dates
otherDate = availableDates.reduce((closestDate, currentDate) => {
const currentDiff = Math.abs(currentDate.getTime() - targetDate.getTime());
const closestDiff = Math.abs(closestDate.getTime() - targetDate.getTime());
return currentDiff < closestDiff ? currentDate : closestDate;
}, availableDates[0]); // Start with the first available date
}
// Ensure todayStats exists and contains the statType
if (!todayStats || !getValueFromHistory(todayStats, statType)) {
return undefined;
}
const otherStats = statisticHistory[formatDateMinimal(otherDate)]; // This is now validated
// Ensure otherStats exists and contains the statType
if (!otherStats || !getValueFromHistory(otherStats, statType)) {
return undefined;
}
const statToday = getValueFromHistory(todayStats, statType);
const statOther = getValueFromHistory(otherStats, statType);
if (statToday === undefined || statOther === undefined) {
return undefined;
}
return (statToday - statOther) * (!isNegativeChange ? 1 : -1);
};
const getStatisticChanges = (daysAgo: number): PlayerHistory => {
return {
rank: getStatisticChange("rank", true, daysAgo),
countryRank: getStatisticChange("countryRank", true, daysAgo),
pp: getStatisticChange("pp", false, daysAgo),
replaysWatched: getStatisticChange("replaysWatched", false, daysAgo),
accuracy: {
averageRankedAccuracy: getStatisticChange("accuracy.averageRankedAccuracy", false, daysAgo),
},
score: {
totalScore: getStatisticChange("score.totalScore", false, daysAgo),
totalRankedScore: getStatisticChange("score.totalRankedScore", false, daysAgo),
},
scores: {
totalScores: getStatisticChange("scores.totalScores", false, daysAgo),
totalRankedScores: getStatisticChange("scores.totalRankedScores", false, daysAgo),
rankedScores: getStatisticChange("scores.rankedScores", false, daysAgo),
unrankedScores: getStatisticChange("scores.unrankedScores", false, daysAgo),
},
};
};
return {
id: token.id,
name: token.name,
avatar: token.profilePicture,
country: token.country,
rank: token.rank,
countryRank: token.countryRank,
joinedDate: new Date(token.firstSeen),
bio: bio,
pp: token.pp,
statisticChange: {
daily: getStatisticChanges(1),
weekly: getStatisticChanges(7),
monthly: getStatisticChanges(30),
},
role: token.role == null ? undefined : token.role,
badges: badges,
statisticHistory: statisticHistory,
statistics: token.scoreStats,
rankPages: {
global: getPageFromRank(token.rank, 50),
country: getPageFromRank(token.countryRank, 50),
},
permissions: token.permissions,
banned: token.banned,
inactive: token.inactive,
isBeingTracked: isBeingTracked,
};
}

View File

@ -3,4 +3,24 @@ export type AppStatistics = {
* The total amount of players being tracked.
*/
trackedPlayers: number;
/**
* The total amount of ScoreSaber scores tracked.
*/
trackedScores: number;
/**
* The total amount of additional data for scores being tracked.
*/
additionalScoresData: number;
/**
* The amount of cached BeatSaver maps.
*/
cachedBeatSaverMaps: number;
/**
* The amount of cached ScoreSaber leaderboards.
*/
cachedScoreSaberLeaderboards: number;
};

View File

@ -0,0 +1 @@
export type MapCharacteristic = "Standard" | "Lawless";

View File

@ -1,13 +0,0 @@
import { Metadata } from "./metadata";
export type Page<T> = {
/**
* The data to return.
*/
data: T[];
/**
* The metadata of the page.
*/
metadata: Metadata;
};

View File

@ -0,0 +1,30 @@
import { BeatLeaderModifierToken } from "./modifier/modifiers";
import { BeatLeaderModifierRatingToken } from "./modifier/modifier-rating";
export type BeatLeaderDifficultyToken = {
id: number;
value: number;
mode: number;
difficultyName: string;
modeName: string;
status: number;
modifierValues: BeatLeaderModifierToken;
modifiersRating: BeatLeaderModifierRatingToken;
nominatedTime: number;
qualifiedTime: number;
rankedTime: number;
stars: number;
predictedAcc: number;
passRating: number;
accRating: number;
techRating: number;
type: number;
njs: number;
nps: number;
notes: number;
bombs: number;
walls: number;
maxScore: number;
duration: number;
requirements: number;
};

View File

@ -0,0 +1,16 @@
import { BeatLeaderSongToken } from "./score/song";
import { BeatLeaderDifficultyToken } from "./difficulty";
export type BeatLeaderLeaderboardToken = {
id: string;
song: BeatLeaderSongToken;
difficulty: BeatLeaderDifficultyToken;
scores: null; // ??
changes: null; // ??
qualification: null; // ??
reweight: null; // ??
leaderboardGroup: null; // ??
plays: number;
clan: null; // ??
clanRankingContested: boolean;
};

View File

@ -0,0 +1,18 @@
export type BeatLeaderModifierRatingToken = {
id: number;
fsPredictedAcc: number;
fsPassRating: number;
fsAccRating: number;
fsTechRating: number;
fsStars: number;
ssPredictedAcc: number;
ssPassRating: number;
ssAccRating: number;
ssTechRating: number;
ssStars: number;
sfPredictedAcc: number;
sfPassRating: number;
sfAccRating: number;
sfTechRating: number;
sfStars: number;
};

View File

@ -0,0 +1,16 @@
export type BeatLeaderModifierToken = {
modifierId: number;
da: number;
fs: number;
sf: number;
ss: number;
gn: number;
na: number;
nb: number;
nf: number;
no: number;
pm: number;
sc: number;
sa: number;
op: number;
};

View File

@ -0,0 +1,10 @@
export type BeatLeaderPlayerToken = {
id: string;
country: string;
avatar: string;
pp: number;
rank: number;
countryRank: number;
name: string;
// todo: finish this
};

View File

@ -0,0 +1,66 @@
export type ScoreStatsAccuracyTrackerToken = {
/**
* The accuracy of the right hand.
*/
accRight: number;
/**
* The accuracy of the left hand.
*/
accLeft: number;
/**
* The left hand pre-swing.
*/
leftPreswing: number;
/**
* The right hand pre-swing.
*/
rightPreswing: number;
/**
* The average pre-swing.
*/
averagePreswing: number;
/**
* The left hand post-swing.
*/
leftPostswing: number;
/**
* The right hand post-swing.
*/
rightPostswing: number;
/**
* The left hand time dependence.
*/
leftTimeDependence: number;
/**
* The right hand time dependence.
*/
rightTimeDependence: number;
/**
* The left hand average cut.
*/
leftAverageCut: number[];
/**
* The right hand average cut.
*/
rightAverageCut: number[];
/**
* The grid accuracy.
*/
gridAcc: number[];
/**
* The full combo accuracy.
*/
fcAcc: number;
};

View File

@ -0,0 +1,16 @@
export type ScoreStatsHeadPositionToken = {
/**
* The X position of the head
*/
x: number;
/**
* The Y position of the head
*/
y: number;
/**
* The Z position of the head
*/
z: number;
};

View File

@ -0,0 +1,51 @@
export type ScoreStatsHitTrackerToken = {
/**
* The maximum combo achieved.
*/
maxCombo: number;
/**
* The highest amount of 115 notes hit in a row.
*/
maxStreak: number;
/**
* The left hand timing.
*/
leftTiming: number;
/**
* The right hand timing.
*/
rightTiming: number;
/**
* The left hand misses.
*/
leftMiss: number;
/**
* The right hand misses.
*/
rightMiss: number;
/**
* The left hand bad cuts.
*/
leftBadCuts: number;
/**
* The right hand bad cuts.
*/
rightBadCuts: number;
/**
* The left hand bombs.
*/
leftBombs: number;
/**
* The right hand bombs.
*/
rightBombs: number;
};

View File

@ -0,0 +1,6 @@
export type ScoreStatsGraphTrackerToken = {
/**
* The accuracy graph data.
*/
graph: number[];
};

View File

@ -0,0 +1,26 @@
import { ScoreStatsHitTrackerToken } from "./hit-tracker";
import { ScoreStatsAccuracyTrackerToken } from "./accuracy-tracker";
import { ScoreStatsWinTrackerToken } from "./win-tracker";
import { ScoreStatsGraphTrackerToken } from "./score-graph-tracker";
export type ScoreStatsToken = {
/**
* The hit tracker stats.
*/
hitTracker: ScoreStatsHitTrackerToken;
/**
* The accuracy tracker stats.
*/
accuracyTracker: ScoreStatsAccuracyTrackerToken;
/**
* The win tracker stats.
*/
winTracker: ScoreStatsWinTrackerToken;
/**
* The score graph tracker stats.
*/
scoreGraphTracker: ScoreStatsGraphTrackerToken;
};

View File

@ -0,0 +1,48 @@
import { ScoreStatsHeadPositionToken } from "./head-position";
export type ScoreStatsWinTrackerToken = {
/**
* Whether the score was won. (not failed)
*/
won: boolean;
/**
* The time the score ended.
*/
endTime: number;
/**
* The total amount of pauses.
*/
nbOfPause: number;
/**
* The total amount of pause time.
*/
totalPauseDuration: number;
/**
* The jump distance the score was played on.
*/
jumpDistance: number;
/**
* The average height of the player.
*/
averageHeight: number;
/**
* The average head position of the player.
*/
averageHeadPosition: ScoreStatsHeadPositionToken;
/**
* The total score.
*/
totalScore: number;
/**
* The maximum score for this song.
*/
maxScore: number;
};

View File

@ -0,0 +1,19 @@
export type BeatLeaderScoreImprovementToken = {
id: number;
timeset: number;
score: number;
accuracy: number;
pp: number;
bonusPp: number;
rank: number;
accRight: number;
accLeft: number;
averageRankedAccuracy: number;
totalPp: number;
totalRank: number;
badCuts: number;
missedNotes: number;
bombCuts: number;
wallsHit: number;
pauses: number;
};

View File

@ -0,0 +1,8 @@
export type BeatLeaderScoreOffsetsToken = {
id: number;
frames: number;
notes: number;
walls: number;
heights: number;
pauses: number;
};

View File

@ -0,0 +1,52 @@
import { BeatLeaderLeaderboardToken } from "../leaderboard";
import { BeatLeaderScoreImprovementToken } from "./score-improvement";
import { BeatLeaderScoreOffsetsToken } from "./score-offsets";
import { BeatLeaderPlayerToken } from "../player";
export type BeatLeaderScoreToken = {
myScore: null; // ??
validContexts: number;
leaderboard: BeatLeaderLeaderboardToken;
contextExtensions: null; // ??
accLeft: number;
accRight: number;
id: number;
baseScore: number;
modifiedScore: number;
accuracy: number;
playerId: string;
pp: number;
bonusPp: number;
passPP: number;
accPP: number;
techPP: number;
rank: number;
country: string;
fcAccuracy: number;
fcPp: number;
weight: number;
replay: string;
modifiers: string;
badCuts: number;
missedNotes: number;
bombCuts: number;
wallsHit: number;
pauses: number;
fullCombo: boolean;
platform: string;
maxCombo: number;
maxStreak: number;
hmd: number;
controller: number;
leaderboardId: string;
timeset: string;
timepost: number;
replaysWatched: number;
playCount: number;
priority: number;
player: BeatLeaderPlayerToken; // ??
scoreImprovement: BeatLeaderScoreImprovementToken;
rankVoting: null; // ??
metadata: null; // ??
offsets: BeatLeaderScoreOffsetsToken;
};

View File

@ -0,0 +1,16 @@
export type BeatLeaderSongToken = {
id: string;
hash: string;
name: string;
subName: string;
author: string;
mapperId: string;
coverImage: string;
fullCoverImage: string;
downloadUrl: string;
bpm: number;
duration: number;
tags: string;
uploadTime: number;
difficulties: null; // ??
};

View File

@ -1,24 +0,0 @@
import BeatSaverAccountToken from "./beat-saver-account-token";
import BeatSaverMapMetadataToken from "./beat-saver-map-metadata-token";
import BeatSaverMapStatsToken from "./beat-saver-map-stats-token";
export interface BeatSaverMapToken {
id: string;
name: string;
description: string;
uploader: BeatSaverAccountToken;
metadata: BeatSaverMapMetadataToken;
stats: BeatSaverMapStatsToken;
uploaded: string;
automapper: boolean;
ranked: boolean;
qualified: boolean;
// todo: versions
createdAt: string;
updatedAt: string;
lastPublishedAt: string;
tags: string[];
declaredAi: string;
blRanked: boolean;
blQualified: boolean;
}

View File

@ -0,0 +1,16 @@
export type MapDifficultyParitySummaryToken = {
/**
* The amount of parity errors.
*/
errors: number;
/**
* The amount of parity warnings.
*/
warns: number;
/**
* The amount of resets in the difficulty.
*/
resets: number;
};

View File

@ -0,0 +1,90 @@
import { MapDifficulty } from "../../../score/map-difficulty";
import { MapDifficultyParitySummaryToken } from "./difficulty-parity-summary";
export type BeatSaverMapDifficultyToken = {
/**
* The NJS of this difficulty.
*/
njs: number;
/**
* The NJS offset of this difficulty.
*/
offset: number;
/**
* The amount of notes in this difficulty.
*/
notes: number;
/**
* The amount of bombs in this difficulty.
*/
bombs: number;
/**
* The amount of obstacles in this difficulty.
*/
obstacles: number;
/**
* The notes per second in this difficulty.
*/
nps: number;
/**
* The length of this difficulty in seconds.
*/
length: number;
/**
* The characteristic of this difficulty.
*/
characteristic: "Standard" | "Lawless";
/**
* The difficulty of this difficulty.
*/
difficulty: MapDifficulty;
/**
* The amount of lighting events in this difficulty.
*/
events: number;
/**
* Whether this difficulty uses Chroma.
*/
chroma: boolean;
/**
* Quite frankly I have no fucking idea what these are.
*/
me: boolean;
ne: boolean;
/**
* Does this difficulty use cinema?
*/
cinema: boolean;
/**
* The length of this difficulty in seconds.
*/
seconds: number;
/**
* The parity summary of this difficulty.
*/
paritySummary: MapDifficultyParitySummaryToken;
/**
* The maximum score of this difficulty.
*/
maxScore: number;
/**
* The custom difficulty label.
*/
label: string;
};

View File

@ -0,0 +1,43 @@
import { BeatSaverMapDifficultyToken } from "./map-difficulty";
export type BeatSaverMapVersionToken = {
/**
* The hash of the map.
*/
hash: string;
/**
* The stage of the map.
*/
stage: "Published"; // todo: find the rest of these
/**
* The date the map was created.
*/
createdAt: string;
/**
* The sage score of the map. (no idea what this is x.x)
*/
sageScore: number;
/**
* The difficulties in the map.
*/
diffs: BeatSaverMapDifficultyToken[];
/**
* The URL to the download of the map.
*/
downloadURL: string;
/**
* The URL to the cover image.
*/
coverURL: string;
/**
* The URL to the preview of the map.
*/
previewURL: string;
};

View File

@ -0,0 +1,96 @@
import BeatSaverAccountToken from "./account";
import BeatSaverMapMetadataToken from "./map-metadata";
import BeatSaverMapStatsToken from "./map-stats";
import { BeatSaverMapVersionToken } from "./map-version";
export interface BeatSaverMapToken {
/**
* The id of the map.
*/
id: string;
/**
* The name of the map.
*/
name: string;
/**
* The description of the map.
*/
description: string;
/**
* The uploader of the map.
*/
uploader: BeatSaverAccountToken;
/**
* The metadata of the map.
*/
metadata: BeatSaverMapMetadataToken;
/**
* The stats of the map.
*/
stats: BeatSaverMapStatsToken;
/**
* The date the map was uploaded.
*/
uploaded: string;
/**
* Whether the map was mapped by an automapper.
*/
automapper: boolean;
/**
* Whether the map is ranked on ScoreSaber.
*/
ranked: boolean;
/**
* Whether the map is qualified on ScoreSaber.
*/
qualified: boolean;
/**
* The versions of the map.
*/
versions: BeatSaverMapVersionToken[];
/**
* The date the map was created.
*/
createdAt: string;
/**
* The date the map was last updated.
*/
updatedAt: string;
/**
* The date the map was last published.
*/
lastPublishedAt: string;
/**
* The tags of the map.
*/
tags: string[];
/**
* Whether the map is declared to be mapped by an AI.
*/
declaredAi: string;
/**
* Whether the map is ranked on BeatLeader.
*/
blRanked: boolean;
/**
* Whether the map is qualified on BeatLeader.
*/
blQualified: boolean;
}

View File

@ -1,8 +1,8 @@
export default interface ScoreSaberLeaderboardPlayerInfoToken {
export type ScoreSaberLeaderboardPlayerInfoToken = {
id: string;
name: string;
profilePicture: string;
country: string;
permissions: number;
role: string;
}
name?: string;
profilePicture?: string;
country?: string;
permissions?: number;
role?: string;
};

View File

@ -1,5 +1,5 @@
import ScoreSaberLeaderboardToken from "./score-saber-leaderboard-token";
import ScoreSaberLeaderboardPlayerInfoToken from "./score-saber-leaderboard-player-info-token";
import { ScoreSaberLeaderboardPlayerInfoToken } from "./score-saber-leaderboard-player-info-token";
export default interface ScoreSaberScoreToken {
id: string;

View File

@ -1,7 +1,8 @@
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
import { BeatSaverMap } from "../model/beatsaver/map";
import { MapDifficulty } from "../score/map-difficulty";
/**
* Gets the beatSaver mapper profile url.
* Gets the BeatSaver mapper profile url.
*
* @param map the beatsaver map
* @returns the beatsaver mapper profile url
@ -9,3 +10,18 @@ import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
export function getBeatSaverMapperProfileUrl(map?: BeatSaverMap) {
return map != undefined ? `https://beatsaver.com/profile/${map?.author.id}` : undefined;
}
/**
* Gets a BeatSaver difficulty from a map.
*
* @param map the map to get the difficulty from
* @param hash the hash of the map
* @param difficulty the difficulty to get
*/
export function getBeatSaverDifficulty(map: BeatSaverMap, hash: string, difficulty: MapDifficulty) {
const version = map.versions.find(v => v.hash === hash);
if (version == undefined) {
return undefined;
}
return version.difficulties.find(d => d.difficulty === difficulty);
}

View File

@ -41,3 +41,16 @@ export function formatNumber(num: number, type: "number" | "pp" = "number") {
}
return formatNumberWithCommas(num);
}
/**
* Ensures a number is always positive
*
* @param num the number to ensure
* @returns the positive number
*/
export function ensurePositiveNumber(num: number) {
if (num == -0) {
return 0;
}
return num < 0 ? num * -1 : num;
}

View File

@ -60,7 +60,7 @@ export function sortPlayerHistory(history: Map<string, PlayerHistory>) {
* @param id the player id
*/
export async function trackPlayer(id: string) {
await kyFetch(`${Config.apiUrl}/player/history/${id}?createIfMissing=true`);
await kyFetch(`${Config.apiUrl}/player/history/${id}/1?createIfMissing=true`);
}
/**

View File

@ -4,6 +4,23 @@ import PlayerScoresResponse from "../response/player-scores-response";
import { Config } from "../config";
import { ScoreSort } from "../score/score-sort";
import LeaderboardScoresResponse from "../response/leaderboard-scores-response";
import { Page } from "../pagination";
import { ScoreSaberScore } from "src/model/score/impl/scoresaber-score";
import { PlayerScore } from "../score/player-score";
import ScoreSaberLeaderboard from "../model/leaderboard/impl/scoresaber-leaderboard";
/**
* Fetches the player's scores
*
* @param playerId the id of the player
* @param leaderboardId the id of the leaderboard
* @param page the page
*/
export async function fetchPlayerScoresHistory(playerId: string, leaderboardId: string, page: number) {
return kyFetch<Page<PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>>>(
`${Config.apiUrl}/scores/history/${playerId}/${leaderboardId}/${page}`
);
}
/**
* Fetches the player's scores

Some files were not shown because too many files have changed in this diff Show More