766 Commits

Author SHA1 Message Date
2edb5c04c9 testing 2024-10-25 16:56:37 +01:00
b911072a47 change wording 2024-10-25 14:25:04 +01:00
59d5cdb2ae only scroll to on leaderboard page 2024-10-25 13:38:11 +01:00
a9338393f5 add default values for rankedScores and unrankedScores stat 2024-10-25 13:37:04 +01:00
6d0c6aa47f add more statistics 2024-10-24 14:36:24 +01:00
aaee96ad7b cleanup imports 2024-10-24 14:32:37 +01:00
cd1f010698 fix crying 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 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' () from renovate/oven-bun-1.x into master
Reviewed-on: 
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 2024-10-23 23:26:40 +01:00
781a3e8cdc fix previous score miss counts 2024-10-23 23:18:37 +01:00
20376070c3 show role color on role badge 2024-10-23 23:05:20 +01:00
42264ece64 oopsie 2024-10-23 20:54:20 +01:00
2852e0c0ed track difficulty and characteristic for scoresaber scores 2024-10-23 20:52:57 +01:00
0a87877373 ignore score if it already exists 2024-10-23 20:47:12 +01:00
b8f6829f71 literally nothing uses this 2024-10-23 20:30:33 +01:00
44bc812ad8 fix 2024-10-23 20:26:48 +01:00
d1d12b4193 fix import 2024-10-23 20:24:16 +01:00
d42c888e82 implement scoresaber score tracking (for previous scores) 2024-10-23 20:20:57 +01:00
3c4406c4b7 format obstacles 2024-10-23 17:59:56 +01:00
2a681e6b32 oops 2024-10-23 17:47:54 +01:00
90c57ad086 add score acc chart 2024-10-23 17:44:55 +01:00
0731d20edc tooltipsssssssssss 2024-10-23 16:44:08 +01:00
0d12e7c024 fix score feed acc 2024-10-23 16:41:31 +01:00
e403d1f241 this might help a little bit 2024-10-23 16:30:53 +01:00
b4bcf32a43 maybe fix tooltip on mobile? 2024-10-23 16:23:03 +01:00
56b2f272b9 maybe fix tooltip on mobile? 2024-10-23 16:22:41 +01:00
55b9f0e4ef fix swagger 2024-10-23 16:19:29 +01:00
1bc2b35ec0 lookup beat saver map hash inside the versions not the _id 2024-10-23 16:08:26 +01:00
ed4bcc93e1 typescript makes me want to kms sometimes. 2024-10-23 15:52:25 +01:00
de3dec22de it's really not that hard, just build 2024-10-23 15:45:56 +01:00
4b5c2acad5 oh? 2024-10-23 15:42:38 +01:00
6e38f36945 oops 2024-10-23 15:40:47 +01:00
584af8c5a4 upsert the beatsaver map 2024-10-23 15:40:28 +01:00
0f68b2b69e maybe fix? 2024-10-23 15:37:21 +01:00
33b931b5f1 add map stats from beat saver 2024-10-23 15:33:25 +01:00
62090b8054 i am smart 2024-10-23 09:24:48 +01:00
f8e0326dec oops 2024-10-23 09:06:44 +01:00
c09a50b8a2 add previous days query to player history 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' () from renovate/sentry-javascript-monorepo into master
Reviewed-on: 
2024-10-23 07:45:31 +00:00
Lee
05e10424ef Merge pull request 'Update dependency lucide-react to ^0.453.0' () from renovate/lucide-monorepo into master
Reviewed-on: 
2024-10-23 07:45:26 +00:00
Lee
d3ba6eedc4 Merge pull request 'Update oven/bun Docker tag to v1.1.32' () from renovate/oven-bun-1.x into master
Reviewed-on: 
2024-10-23 07:41:36 +00:00
6bbf628ab5 fix 2024-10-23 08:36:55 +01:00
56ae9b717c fix mini ranking for top players 2024-10-23 08:24:31 +01:00
08295d7b04 make the score improvement text smaller and show a previous pp inaccuracy warning 2024-10-23 08:13:07 +01:00
8090361615 cleanup score badges 2024-10-23 07:52:23 +01:00
299cf20cb9 fix hand acc 2024-10-23 07:40:11 +01:00
ff9ff5b96b cleanup hand acc 2024-10-23 07:31:43 +01:00
c3cf48e731 maybe fix tooltips on mobile? 2024-10-22 22:52:56 +01:00
1befe6cc57 add fc pp to pp hover 2024-10-22 22:48:01 +01:00
7b008d8e55 fix ranking page links 2024-10-22 21:20:13 +01:00
68e343083b fix timestamp 2024-10-22 19:10:18 +01:00
989d66780d add timestamp to additional data 2024-10-22 19:08:22 +01:00
ca8fb41fab fix error 2024-10-22 18:55:47 +01:00
6a1b18581f add score 2024-10-22 18:54:21 +01:00
a33c1b81b7 fix scores set today 2024-10-22 18:46:57 +01:00
6495db7588 meh 2024-10-22 18:36:48 +01:00
c3ab9851ab fix total ranked score stat 2024-10-22 18:34:32 +01:00
ef287d6c3c fix whatever the fuck this bug was 2024-10-22 18:33:00 +01:00
cf84ebe456 smh my head 2024-10-22 18:29:11 +01:00
220cf31511 smh my head 2024-10-22 18:25:26 +01:00
3f63225f16 smh my head 2024-10-22 18:24:46 +01:00
50bc341c38 fix package caching? 2024-10-22 18:23:25 +01:00
7b87188e98 add bl replay button 2024-10-22 18:20:34 +01:00
75f79e34b7 store bl score and leaderboard id 2024-10-22 18:10:33 +01:00
2fc8b265d2 fix previous score fc tooltip 2024-10-22 17:55:05 +01:00
f090c0dcbb bl score fixes 2024-10-22 17:48:32 +01:00
9c20aff89d add bombs to miss count 2024-10-22 17:35:14 +01:00
36ab7eb4cf oopsie 2024-10-22 17:32:09 +01:00
f3dee6a7d2 rework beatleader data tracking 2024-10-22 17:30:14 +01:00
fa2ba83c7a add beatleader data tracking!!!!!!!!!!!!! 2024-10-22 15:59:41 +01:00
074d4de123 fix imports 2024-10-22 13:55:58 +01:00
854f88c43a track total score, total ranked score, replay watched count and add a score chart 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 2024-10-22 12:37:52 +01:00
f89207f306 fix total score change 2024-10-22 12:34:17 +01:00
be25896c5e change some color stuff 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 2024-10-21 17:39:47 +01:00
de47905e28 make leaderboard info column slightly bigger 2024-10-21 15:35:26 +01:00
9a621eea82 oh? 2024-10-21 14:21:11 +01:00
42e0c3a7b2 still include today 2024-10-21 14:18:26 +01:00
0b92cec911 fix graph only showing 49 days of data 2024-10-21 14:15:59 +01:00
5933074569 fix qualified status for leaderboards 2024-10-21 13:47:53 +01:00
af8c87f5af add player role badge 2024-10-21 13:39:02 +01:00
173703664d oops 2024-10-21 13:29:23 +01:00
077bb6d73b add acc slider to the pp graph 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 2024-10-21 07:45:23 +01:00
ad826d7a3f make all queries re fetch every 5 mins 2024-10-21 07:43:18 +01:00
6baeab930d add more info to leaderboard score misses 2024-10-21 07:33:46 +01:00
87b2c7c48a fix player header pp color 2024-10-21 07:06:42 +01:00
b9587feb9e re-add days ago to the chart 2024-10-21 07:04:39 +01:00
fad22274fd make song name clickable on leaderboard page (goes to beatsaver map) 2024-10-21 06:56:20 +01:00
577fcb0e0d Merge remote-tracking branch 'origin/master' 2024-10-21 06:47:58 +01:00
c3d4d1fe1f add leaderboard pp chart 2024-10-21 06:47:53 +01:00
Lee
a15893ea56 Update projects/website/src/components/player/player-scores.tsx 2024-10-20 22:10:02 +00:00
Lee
d0bcd29796 Delete projects/website/src/middleware.ts 2024-10-20 22:03:38 +00:00
81640c3c4e fix 2024-10-20 19:52:33 +01:00
06a13bedc8 use new stat for ranked and total play count change 2024-10-20 19:49:10 +01:00
bded9969fe track total scores and total ranked scores 2024-10-20 19:34:54 +01:00
cd2f8c0925 pass through the leaderboard 2024-10-20 19:25:32 +01:00
d1a9654e33 hide leaderboard pp on unranked maps 2024-10-20 19:24:31 +01:00
336518ff70 cleanup stat changes and add ranked and total scores change 2024-10-20 19:17:17 +01:00
a68e53734d add default stale time to queries 2024-10-20 14:04:55 +01:00
9d2a26fa07 skip 999_999 on fallback data 2024-10-20 14:01:47 +01:00
a0dd1b4601 fix country flag sizing 2024-10-20 13:59:48 +01:00
fcb84f820b fix imports 2024-10-20 13:53:30 +01:00
d806907604 update score badge tooltips 2024-10-20 13:49:14 +01:00
511f56af91 add a "-" for no modifiers 2024-10-20 13:44:36 +01:00
57a9780fe8 make mapper name clickable on the leaderboard 2024-10-20 13:43:25 +01:00
4d8debe333 fix easy color 2024-10-19 18:17:43 +01:00
899c3e11e6 remove replay stuff 2024-10-19 17:51:24 +01:00
8f617aca82 fix mini hover on player 2024-10-19 17:48:29 +01:00
9b549f8dc6 cleanup 2024-10-19 17:45:02 +01:00
1e8c38eb26 fix the url randomly switching to a leaderboard 2024-10-19 17:33:47 +01:00
2df95d140a fix tooltips 2024-10-19 17:29:17 +01:00
337331538a fix modifiers on score 2024-10-19 17:13:55 +01:00
d3ce922f00 cleanup 2024-10-19 17:08:00 +01:00
982202f813 make mini ranking text smaller 2024-10-19 15:37:25 +01:00
a1148d0f59 cleanup 2024-10-19 15:31:02 +01:00
0d182d3ff4 update mini ranking 2024-10-19 15:28:32 +01:00
7465f854e0 fix around me for top players 2024-10-19 15:21:57 +01:00
670f2047a0 show all modifiers now 2024-10-19 15:12:06 +01:00
d2be3d833b add role colors 2024-10-19 14:41:01 +01:00
16c34adc19 redesign leaderboard scores 2024-10-19 14:11:43 +01:00
caf5f01a09 make diff buttons look nicer 2024-10-19 13:13:36 +01:00
e0aeec5d5a log rate limit left 2024-10-19 12:48:57 +01:00
79bdb801ff add no fail to acc 2024-10-19 12:33:26 +01:00
cec3541345 refresh interval on scores 2024-10-19 11:31:53 +01:00
ac6aaee208 fix log 2024-10-19 11:17:08 +01:00
a773488e9b fetch spamming ss api 2024-10-19 09:58:30 +01:00
1f4be74c54 testing 2024-10-19 09:36:06 +01:00
31f57cbe6b testing 2024-10-19 09:19:43 +01:00
de05aceb9f fix these showing when they shouldn't 2024-10-19 09:07:01 +01:00
4eb96da1f1 add more info to offline network check 2024-10-19 07:52:19 +01:00
0931e52df5 fix score ui issue when on mobile and add swagger to the footer 2024-10-19 07:36:20 +01:00
37b491a0b5 fix swagger? 2024-10-19 07:15:28 +01:00
a8c40f50d6 fix swagger? 2024-10-19 06:56:07 +01:00
e1f5a13f57 don't cache this 2024-10-19 04:54:24 +01:00
c5bfdc8b9c migrate around me to the backend 2024-10-19 04:53:17 +01:00
c40b8b5d8e migrate around me to the backend 2024-10-19 04:53:06 +01:00
7421c47959 cleanup caching 2024-10-19 04:10:44 +01:00
238ec6e254 don't proxy requests that happen on the server 2024-10-19 03:50:29 +01:00
Lee
a6576e9730 Merge pull request 'Update oven/bun Docker tag to v1.1.31' () from renovate/oven-bun-1.x into master
Reviewed-on: 
2024-10-18 11:11:32 +00:00
60ac8d17c5 update log embed 2024-10-18 10:33:11 +01:00
8713ee3e02 update log embed 2024-10-18 10:32:13 +01:00
3a734075e0 add steam profile button and fix score acc 2024-10-18 10:27:38 +01:00
6c8ef89bb5 remove the rate limiter 2024-10-18 08:22:54 +01:00
0317eae926 fix beatsaver map causing page to not load if it's unknown 2024-10-18 08:13:26 +01:00
a636e7aa08 oops 2024-10-18 07:59:41 +01:00
9fb276ec4e show more data on the feed 2024-10-18 07:56:39 +01:00
4a966344f2 Update oven/bun Docker tag to v1.1.31 2024-10-18 05:02:48 +00:00
dd8befa9e0 fix logs 2024-10-17 19:02:32 +01:00
1350cdc0b1 fix imports 2024-10-17 18:37:47 +01:00
c43f27a6ac fix bot secret 2024-10-17 18:32:11 +01:00
a086bebc40 a super shitty discord bot but i needed logging 2024-10-17 18:29:30 +01:00
373a6355a6 make ws more reliable 2024-10-17 18:01:34 +01:00
0614b52745 send a heartbeat to the scoresaber ws 2024-10-17 17:46:57 +01:00
c72230a98d cache unknown beatsaver maps 2024-10-17 16:24:10 +01:00
73b7d17597 surely it works now 2024-10-17 15:57:51 +01:00
ba0a406eb4 7? 2024-10-17 15:53:12 +01:00
ccf229ade4 7? 2024-10-17 15:49:28 +01:00
d08f81b25d 7? 2024-10-17 15:47:20 +01:00
e37f0d5548 7 2024-10-17 15:37:13 +01:00
0231c6ccfe oops 2024-10-17 15:35:15 +01:00
b3c124631a move score page fetching to the backend 2024-10-17 15:30:14 +01:00
118dc9d9f1 Fix missing rank data on partial tracked days 2024-10-17 07:15:12 +01:00
7f5587546c cleanup and track friends data (if not being already tracked) 2024-10-17 07:12:03 +01:00
64f918c325 fix env vars 2024-10-17 06:53:31 +01:00
82b0a0ee71 don't show stat change if player is inactive or banned 2024-10-17 03:23:34 +01:00
b8553c3138 fix building 2024-10-17 03:17:42 +01:00
f83492ffdc show badges for inactive players 2024-10-17 03:13:37 +01:00
b5cfbf384a stupid 2024-10-17 03:11:28 +01:00
c64f046df3 fix score leaderboard staying open when switching sort/page 2024-10-17 03:08:27 +01:00
42d133bbbb fix player data not showing when claiming the profile 2024-10-17 02:40:18 +01:00
ae4e6912e5 add joined date stat 2024-10-17 02:21:59 +01:00
5263509bac add status page to the footer 2024-10-16 11:40:46 +01:00
24a15f97d1 fix issue with stat tracking 2024-10-16 11:36:06 +01:00
2367a03516 make the navbar move around less 2024-10-16 11:33:24 +01:00
b5ae8a8ae0 always enable logging 2024-10-16 11:27:04 +01:00
8ab81b1b27 impl friends system 2024-10-16 11:23:28 +01:00
2e2c03241e oops 2024-10-16 08:30:34 +01:00
1e8a9b9a59 fix theme color 2024-10-16 08:21:27 +01:00
cb7143ed3d should be all good now (and added api status notifications) 2024-10-16 08:15:11 +01:00
1eed0e1e99 im dumb 2024-10-16 08:03:36 +01:00
6d6e59ed13 oops 2024-10-16 07:50:25 +01:00
3dcf03ce53 imports 2024-10-16 07:48:20 +01:00
045f605cc6 switch lib 2024-10-16 07:47:52 +01:00
ff9408fb8c re-add per page/leaderboard embed colors 2024-10-16 07:31:52 +01:00
7f42a27d8f add new player tracking logging and add discord link to the footer 2024-10-16 06:53:30 +01:00
ed21d3d780 add twitter to the footer 2024-10-16 06:36:24 +01:00
7bacc30f33 cleanup images and add daily change and join date to the player image 2024-10-16 06:16:55 +01:00
74385252a4 how did this even get removed 2024-10-16 03:08:54 +01:00
6ee4c5b754 silly lock file 2024-10-16 03:04:15 +01:00
78c88acddf there 2024-10-16 03:03:15 +01:00
ee1c33bcc9 oops 2024-10-16 02:39:42 +01:00
013d866391 now! 2024-10-16 02:35:17 +01:00
9355f53ee5 add og image caching 2024-10-16 02:31:10 +01:00
a5e00e4850 add leaderboard embed image 2024-10-16 02:27:59 +01:00
3b691dae3c update player og image 2024-10-15 23:22:29 +01:00
5998eac6f7 add player page embed desc 2024-10-15 23:02:30 +01:00
22abdab10b it buildssssssssssssss 2024-10-15 20:32:58 +01:00
da7345b929 7? 2024-10-15 20:11:22 +01:00
eb1d2899b9 please just build 2024-10-15 20:06:18 +01:00
a8af4a9d45 maybe now 2024-10-15 20:02:10 +01:00
2af45b1508 build now pls 2024-10-15 19:56:44 +01:00
f2ef170f01 fix 2024-10-15 19:43:23 +01:00
970ab22e2f cleanup 2024-10-15 19:32:06 +01:00
d56a85c342 cleanup 2024-10-15 19:31:50 +01:00
f303794f5c cleanup 2024-10-15 19:26:55 +01:00
6f88ab8f30 move og image to backend 2024-10-15 19:26:04 +01:00
ef634194b8 add cool og image for player embed 2024-10-15 18:59:13 +01:00
005e05d8fb ples work now xoxo 2024-10-15 04:21:53 +01:00
b803362360 fix this 2024-10-15 04:16:24 +01:00
80c1c95014 add a ":" to the chart tooltip to split name and value 2024-10-15 04:11:48 +01:00
9d38e095fe add daily scores set tracking 2024-10-15 04:09:47 +01:00
5b3218c205 fix acc chart 2024-10-14 20:37:41 +01:00
8133d18ca2 format weekly rank change 2024-10-14 20:37:14 +01:00
383f41f9ca surely this works 2024-10-14 11:29:00 +01:00
5871b82f75 why no work 2024-10-14 11:19:55 +01:00
52e3ac9cec testing custom error page 2024-10-14 11:11:13 +01:00
055e0869b8 oops 2024-10-14 11:04:58 +01:00
04e0898b3c oops there 2024-10-14 10:58:30 +01:00
a6b99219e1 make the mobile pagination look nicer 2024-10-14 10:55:55 +01:00
eb06801026 make the mobile pagination more useful 2024-10-14 10:45:43 +01:00
ac4298c765 fix pp value on chart? 2024-10-14 03:06:22 +01:00
a15f8f46f9 make the charts nicer 2024-10-14 02:32:32 +01:00
cdf9942924 fix playerchart 2024-10-14 01:43:00 +01:00
67c1775edb remove this log 2024-10-13 05:27:27 +01:00
4d27fe9bae fix score feed getting stuck 2024-10-13 05:14:03 +01:00
aa4ef05b55 fix time formatter 2024-10-13 05:11:09 +01:00
0f282fd003 use a better hook 2024-10-13 04:58:57 +01:00
c4b5bace5d oops 2024-10-13 04:53:20 +01:00
0e3b2252a5 update "just now" time 2024-10-13 04:51:16 +01:00
684ac4660e cleanup 2024-10-13 04:41:11 +01:00
4cc5893757 add simple live score feed page 2024-10-13 04:40:04 +01:00
ee212150fd add default score sort (last selected) 2024-10-13 03:49:33 +01:00
2a61ed26a6 next 2024-10-13 01:54:11 +01:00
bc64e6ef3f next 2024-10-13 01:38:59 +01:00
0b8e693f80 next 2024-10-13 01:35:18 +01:00
17193fe18a remove this 2024-10-13 01:31:40 +01:00
97d7ab2d0a cookies are async now? 2024-10-13 01:28:49 +01:00
81fe9c3bb6 bump sentry 2024-10-13 01:24:28 +01:00
8c3ca26c9c bump react and nextjs 2024-10-13 01:14:51 +01:00
ae2f30a97a add hover to all country flags 2024-10-13 00:41:39 +01:00
b7783f5a4d fix mapper link for song to 2024-10-13 00:37:16 +01:00
783da27b1e make the song name link less fat 2024-10-13 00:34:00 +01:00
1998049509 fix broken chart 2024-10-13 00:20:46 +01:00
ba8579d60c fix ranking page 2024-10-12 22:56:29 +01:00
6d1c911c9f fix rank graph 2024-10-12 22:07:32 +01:00
e2d9a23974 cleanup 2024-10-12 20:29:22 +01:00
b86fb3a609 fixed height for these 2024-10-12 20:24:40 +01:00
cb9bc2143c fix leaderboard pp color 2024-10-12 20:22:08 +01:00
5cc3cca2d7 increase count up duration 2024-10-12 20:18:14 +01:00
3f2dd7ea90 fix player pp display and hide mini rankings on inactive/banned players 2024-10-12 15:38:34 +01:00
5e3ab8435b fix ranking page button on player profile 2024-10-12 15:21:48 +01:00
1917a55725 fix acc/pp being broken on new pages 2024-10-12 15:19:15 +01:00
20a0208e92 fix button gaps 2024-10-12 07:32:41 +01:00
988d8cb17e oops 2024-10-12 07:26:52 +01:00
c73f5c6373 fix mini ranking showing error msg when loading 2024-10-12 07:23:30 +01:00
98e8273c07 add score "edit" mode 2024-10-12 07:21:55 +01:00
f26b997fbb potential fix for this? 2024-10-12 05:26:30 +01:00
e67fcf328e update claim profile tooltip 2024-10-12 04:40:20 +01:00
eb89987614 fix pp color on ranking page 2024-10-12 04:25:00 +01:00
27c88cdb75 add a basic landing page 2024-10-12 04:12:35 +01:00
0ac70f4781 fix country flag size being inconsistent 2024-10-12 03:48:34 +01:00
fd03e3d6c2 oopsie doodle 2024-10-12 03:42:43 +01:00
f8b97e3471 cleanup 2024-10-12 03:37:54 +01:00
97a91d7249 fix leaderboard page info not updating 2024-10-12 03:17:39 +01:00
7327b8d169 show no data if data is missing instead of showing 0 2024-10-12 03:11:02 +01:00
1d6647b74e update wording of change tooltip 2024-10-12 03:06:37 +01:00
8f62c6c694 cleanup 2024-10-12 02:55:42 +01:00
afdbe0a3dc fix timeframes 2024-10-12 02:41:24 +01:00
2ab3d6b023 track top 1000 players 2024-10-12 02:37:08 +01:00
6947c30c23 fix double fetching of players 2024-10-12 02:36:44 +01:00
786bc69cf3 fix timframe changes showing 0 for some things 2024-10-11 21:45:50 +01:00
6ae69c2fec don't log unless needed 2024-10-11 21:38:30 +01:00
6cd141544c make acc chart 3 places 2024-10-11 20:20:06 +01:00
306269f1f9 cleanup 2024-10-11 20:16:38 +01:00
9f6a58e325 make the star count/diff label smaller 2024-10-11 20:01:49 +01:00
d99feecc8f show more decimal places on the ranked acc chart 2024-10-11 20:00:30 +01:00
ad87365a66 cleanup 2024-10-11 19:58:49 +01:00
d7a3b734ec fix pp 2024-10-11 19:46:03 +01:00
ccedfa2645 add daily, weekly and monthly change to rank, countryRank and pp as a hover 2024-10-11 19:35:39 +01:00
a0681d7b1c add tracking cooldown 2024-10-11 18:48:04 +01:00
d16cf9e4af add tracking cooldown 2024-10-11 18:47:33 +01:00
29f9b305e7 track top 10 pages, untested but it should work 2024-10-11 18:42:46 +01:00
dad8afe282 fill in missing data from the scoresaber api data 2024-10-11 04:02:21 +01:00
570f5e1eab fix hover on rank and country rank making the tooltip dim 2024-10-11 03:51:38 +01:00
b059ee3537 always ensure the frontpage of players is being tracked 2024-10-11 03:47:54 +01:00
0e4feb4181 fix clicking global rank taking you to the wrong page and update url when clicking a player on the ranking page 2024-10-11 03:42:50 +01:00
544e850540 fix clicking global rank taking you to the wrong page and update url when clicking a player on the ranking page 2024-10-11 03:42:42 +01:00
4894e0597c implement a janky fix for mobile until i can be bothered to make it mobile friendly 2024-10-11 03:35:12 +01:00
e4f0376af3 add weekly rank change and change the design a bit 2024-10-11 03:31:52 +01:00
e35c1c77d3 some bug fixes and add the ranking page 2024-10-11 02:43:28 +01:00
f649fb9c7f cleanup getting the player 2024-10-11 01:12:27 +01:00
26e34c32f1 fix missing acc tracking 2024-10-11 01:06:02 +01:00
e89ff73b76 fix cron time oops 2024-10-11 01:04:27 +01:00
6b2f9fa308 update log 2024-10-10 02:50:43 +01:00
130016957d oops 2024-10-10 02:34:48 +01:00
19ab2a2e3d log connecting ip 2024-10-10 02:25:54 +01:00
526167d4f1 fix mini rankings 2024-10-10 01:52:05 +01:00
f75897007c oops fix cron 2024-10-10 01:44:09 +01:00
fc287be481 fix player statistic tracking 2024-10-10 01:40:21 +01:00
0f1c101acc cronjob stuff 2024-10-10 01:22:09 +01:00
bca3732f1c remove the logs for this - it spams alot 2024-10-10 01:17:55 +01:00
d5cc35da05 force track players bc it never ran 2024-10-10 01:15:16 +01:00
82116a7405 stupid edge runtime 2024-10-10 00:43:51 +01:00
ee45e41d6d update the logger 2024-10-10 00:39:58 +01:00
886ed4b20c simple logger 2024-10-10 00:36:42 +01:00
ec40f1b564 oops 2024-10-10 00:03:03 +01:00
6443bac879 fix mobile issue on player header 2024-10-09 23:41:59 +01:00
6440001839 fix navbar 2024-10-09 18:15:48 +01:00
a69cf8a033 revert react bump 2024-10-09 18:08:46 +01:00
0293e50cee e 2024-10-09 18:05:51 +01:00
963f62d6a6 Merge remote-tracking branch 'origin/master' 2024-10-09 18:02:52 +01:00
392f7c0db8 disable cache for now 2024-10-09 18:01:51 +01:00
179dee0702 Fix bun lock file 2024-10-09 16:56:43 +00:00
2ebc04243c switch to a different cache with a ttl 2024-10-09 17:56:17 +01:00
ee77a8f626 bump react 2024-10-09 17:43:15 +01:00
be28191005 make navbar align with the page contents 2024-10-09 17:36:33 +01:00
5be22493fa remove db loaded toast 2024-10-09 17:32:58 +01:00
598b6881e8 Merge remote-tracking branch 'origin/master' 2024-10-09 17:28:41 +01:00
1faf896c08 this might help with the memory issues..?? 2024-10-09 17:27:37 +01:00
d0e4e1553b maybe this will run first with this? idk 2024-10-09 15:35:14 +01:00
10c41d820e Fix bun lock file 2024-10-09 14:34:11 +00:00
7eac8569f3 add manual 2024-10-09 15:33:08 +01:00
d5d5b2a36d Merge remote-tracking branch 'origin/master' 2024-10-09 15:32:33 +01:00
Lee
25ffd37d0b Merge pull request 'Update dependency lucide-react to ^0.451.0' () from renovate/lucide-monorepo into master
Reviewed-on: 
2024-10-09 14:32:10 +00:00
4f0a42472c fix bun fixer (ironic) 2024-10-09 15:32:00 +01:00
Lee
d58a6e0863 Merge pull request 'Update dependency eslint-config-next to v14.2.15' () from renovate/nextjs-monorepo into master
Reviewed-on: 
2024-10-09 14:30:52 +00:00
543429852b add bun fixer workflow 2024-10-09 15:30:24 +01:00
ee042fe91e add player tracked since date 2024-10-09 15:25:21 +01:00
6b8244fa48 fix imports and add a player data refresh interval 2024-10-09 15:20:25 +01:00
094e030f11 auto refresh the player data when claiming a profile 2024-10-09 15:18:42 +01:00
580665b2f6 fix mongo uri 2024-10-09 14:57:04 +01:00
b2368dd1d3 update chart labels 2024-10-09 02:44:48 +01:00
a72b098dea re-fix missed notes 2024-10-09 02:38:09 +01:00
c9e102d3d6 re-add beatsaver buttons 2024-10-09 02:35:34 +01:00
946b3c52dc oh it was getting OOM killed 2024-10-09 02:26:24 +01:00
104a08b0d9 idek 2024-10-09 02:20:34 +01:00
cd09148acb Update dependency lucide-react to ^0.451.0 2024-10-09 01:05:35 +00:00
8ec865d985 OOPSIE DOODLE (and fix it crying) 2024-10-09 01:58:42 +01:00
3b7b3b7e50 it builds locally now 2024-10-09 01:54:07 +01:00
516402863c 7??!?!? 2024-10-09 01:41:45 +01:00
3d2904c6f0 7??!?!? 2024-10-09 01:34:34 +01:00
046007af21 7 2024-10-09 01:32:52 +01:00
acd5dcd522 7 2024-10-09 01:27:39 +01:00
6ac3f485f3 oops 2024-10-09 01:23:56 +01:00
045e811dbb silly lock file 2024-10-09 01:20:57 +01:00
e6600a8b48 oops, build common 2024-10-09 01:19:41 +01:00
b6831b4a50 oops, build common 2024-10-09 01:19:31 +01:00
e87d73bbdf LETS GO BABY 2024-10-09 01:17:00 +01:00
935d4d5589 Update dependency eslint-config-next to v14.2.15 2024-10-08 21:03:20 +00:00
e0fca1168a smh 2024-10-08 16:40:55 +01:00
30bdb07510 fix workflows 2024-10-08 16:39:04 +01:00
debe0f13a2 base backend setup 2024-10-08 16:36:52 +01:00
a83c05aa01 Merge remote-tracking branch 'origin/master' 2024-10-08 15:39:17 +01:00
31aad41015 7 2024-10-08 15:39:02 +01:00
0d1cbf4c42 oops 2024-10-08 15:33:41 +01:00
3adb895a87 Merge remote-tracking branch 'origin/master'
# Conflicts:
#	backend/package.json
#	pnpm-lock.yaml
2024-10-08 15:32:16 +01:00
aa0a0c4c16 start backend work 2024-10-08 15:32:02 +01:00
Lee
f7e5ab8937 Merge pull request 'Update dependency nodemon to v3' () from renovate/nodemon-3.x into master
Reviewed-on: 
2024-10-04 23:26:24 +00:00
04ce91b459 7 2024-10-05 00:12:22 +01:00
fd8e832581 7 2024-10-05 00:09:22 +01:00
fa2091069a Update dependency nodemon to v3 2024-10-04 23:08:46 +00:00
dddd5c3aab 7 2024-10-05 00:07:48 +01:00
0fc54e699f 7 2024-10-05 00:06:38 +01:00
a46afff48a use prod mode 2024-10-05 00:01:39 +01:00
e202d72331 messing around with the backend 2024-10-04 23:59:29 +01:00
296179a10b switch backend to fastify 2024-10-04 23:26:05 +01:00
e5bfa8afa0 strip api prefix middleware 2024-10-04 23:14:26 +01:00
338f955c78 fix port 2024-10-04 23:09:34 +01:00
0203c9aa20 yes? 2024-10-04 23:05:45 +01:00
0480481f00 yes? 2024-10-04 23:04:02 +01:00
4d2f291e8f yes? 2024-10-04 23:02:10 +01:00
77819e873d yes? 2024-10-04 22:59:52 +01:00
522c09c32c yes? 2024-10-04 22:57:10 +01:00
5865d55392 yes? 2024-10-04 22:55:24 +01:00
1164b4358b add common to workflow paths 2024-10-04 22:27:53 +01:00
c04e47b10e add common to workflow paths 2024-10-04 22:26:09 +01:00
0ad7f1662e meow 2024-10-04 22:25:22 +01:00
e105a76bf2 impl simple backend 2024-10-04 22:21:37 +01:00
cd7cc29afd oops 2 2024-10-04 21:51:51 +01:00
779177d39b oops 2024-10-04 21:51:21 +01:00
498492bb91 deploy backend 2024-10-04 21:46:49 +01:00
c1fe5f2884 temp 2024-10-04 21:39:39 +01:00
88e0e95e9f 7 2024-10-04 21:37:00 +01:00
60ceb4f4ab 7 2024-10-04 21:23:47 +01:00
899909a316 7 2024-10-04 21:21:57 +01:00
400a230ded 7 2024-10-04 21:21:08 +01:00
97d917ae27 7 2024-10-04 21:18:19 +01:00
04156ae37e 7 2024-10-04 21:17:10 +01:00
8952ec63d7 7 2024-10-04 21:13:15 +01:00
e4d00c890a 7 2024-10-04 21:11:55 +01:00
78584c0ce8 add workflow dispatch 2024-10-04 21:10:55 +01:00
8938bd2924 start fixing website 2024-10-04 21:09:04 +01:00
287912bd81 start fixing website 2024-10-04 21:07:09 +01:00
7e6c706369 fix backend? 2024-10-04 21:05:21 +01:00
8850516693 smaller image 2024-10-04 20:59:47 +01:00
c09f27b16f smaller image 2024-10-04 20:57:05 +01:00
beeed6ed7c smaller image 2024-10-04 20:55:02 +01:00
8671f4e036 smaller image 2024-10-04 20:54:27 +01:00
9d83a95912 oopsie 2024-10-04 20:53:35 +01:00
58f1c651b4 oopsie 2024-10-04 20:49:45 +01:00
98c25afd9f oopsie 2024-10-04 20:48:35 +01:00
a53f15a491 7 2024-10-04 20:47:38 +01:00
81092225cf 7 2024-10-04 20:44:15 +01:00
5d439c3ad6 7 2024-10-04 20:42:29 +01:00
aff4ef4209 7 2024-10-04 20:39:58 +01:00
4a2c6a83e6 7 2024-10-04 20:38:36 +01:00
98c52e5525 let's try this again 2024-10-04 20:35:44 +01:00
00462d9ed6 bob 2024-10-04 18:25:37 +01:00
4d35ee4050 7 2024-10-04 18:14:59 +01:00
53607c791e 7 2024-10-04 18:12:32 +01:00
1493295654 7 2024-10-04 18:09:36 +01:00
29a05e72a2 7 2024-10-04 18:08:31 +01:00
7e4978a9e0 7 2024-10-04 18:06:08 +01:00
a6a92b64e9 7 2024-10-04 18:04:00 +01:00
7545ee2066 7 2024-10-04 18:00:46 +01:00
1f7652863b 7 2024-10-04 17:56:09 +01:00
dc76cf61d9 7 2024-10-04 17:51:05 +01:00
f8a6bccf14 7 2024-10-04 17:46:38 +01:00
b1656b2858 7 2024-10-04 17:42:32 +01:00
498e0cd9d6 7 2024-10-04 17:38:52 +01:00
0f9c1c85aa 7 2024-10-04 17:25:00 +01:00
cf550906a8 7 2024-10-04 17:24:52 +01:00
3a476167a4 7 2024-10-04 17:22:08 +01:00
6841494806 7 2024-10-04 17:21:44 +01:00
748ed6a34b bob 2024-10-04 17:15:17 +01:00
5cc9e2c1c2 bob 2024-10-04 17:11:11 +01:00
ac9ce6fc62 now? 2024-10-04 17:01:15 +01:00
0daa7b200a now? 2024-10-04 16:56:58 +01:00
d4d7a60d1b ehuqiweh 2024-10-04 16:51:17 +01:00
e0833d17f1 j 2024-10-04 16:43:12 +01:00
43cefc21aa remove inactive player cooldown check 2024-10-03 19:05:36 +01:00
8c12b6c521 add tooltip to missed notes 2024-10-03 18:42:22 +01:00
df30a4ecf0 fix missed notes count / fc 2024-10-03 18:36:30 +01:00
62ca4de74c i choose life 2024-10-02 17:39:12 +01:00
ec694e97fa now? 2024-10-02 14:08:57 +01:00
130b9bfdec move to mono repo (attempt 1) 2024-10-02 14:07:30 +01:00
49edd35a6c make leaderboard page not animate when loading it 2024-10-02 13:48:56 +01:00
bde65ea0b9 make the chart buttons look better 2024-10-02 13:27:55 +01:00
6434809c5c no one cares. 2024-10-02 11:02:39 +01:00
9f13c2f66a no one cares. 2024-10-02 10:59:00 +01:00
ba506a59db please build i am begging you 2024-10-02 10:56:28 +01:00
Lee
9d847449e8 Merge pull request 'Update radix-ui-primitives monorepo' () from renovate/radix-ui-primitives-monorepo into master
Reviewed-on: 
2024-10-02 09:48:43 +00:00
Lee
7cfbd52ccf Merge pull request 'Update dependency lucide-react to ^0.447.0' () from renovate/lucide-monorepo into master
Reviewed-on: 
2024-10-02 09:48:37 +00:00
37ea0f4244 only show extra charts if the player is being tracked 2024-10-02 10:48:28 +01:00
859df1dea3 fix exlint stuff 2024-10-02 10:40:10 +01:00
c949931621 fix days ago labels (made it statically generate them instead of being based on the data provided 2024-10-02 10:37:18 +01:00
31416e21a1 make the chart generic and add an accuracy chart with a toggle between the player charts 2024-10-02 10:22:10 +01:00
ca323287ba fix ranking for high pp differences 2024-10-02 09:37:43 +01:00
9a6583069b mini rankings breaking if the player is on page 1 2024-10-02 09:34:22 +01:00
0aa559f6d4 Update dependency lucide-react to ^0.447.0 2024-10-01 21:03:10 +00:00
906b5eb482 Update radix-ui-primitives monorepo 2024-10-01 21:03:04 +00:00
0042bbf214 add support for no background cover 2024-10-01 21:42:50 +01:00
16f3284110 add super basic settings page 2024-10-01 21:36:46 +01:00
5e84e553a1 add support for static background colors 2024-10-01 21:10:30 +01:00
8f0a7d2dd0 re-add analytics 2024-10-01 20:57:34 +01:00
903c49af7b add disabled cursor on first and last page of the pagination 2024-10-01 20:47:42 +01:00
dc7644876b generate page urls on the pagination (better SEO) 2024-10-01 20:33:38 +01:00
Lee
ec84a456af Merge pull request 'Update dependency eslint-config-next to v14.2.14' () from renovate/nextjs-monorepo into master
Reviewed-on: 
2024-10-01 19:21:33 +00:00
Lee
627a24dede Merge pull request 'Update dependency @tanstack/react-query to v5.59.0' () from renovate/tanstack-query-monorepo into master
Reviewed-on: 
2024-10-01 19:21:28 +00:00
35046cc0ae update page title template 2024-10-01 20:19:03 +01:00
50414d848a mobile leaderboard fixes and hide diffs that aren't standard (for now) 2024-10-01 20:18:00 +01:00
f60f34c0c2 update embed 2024-10-01 19:30:37 +01:00
21b33e1b40 update leaderboard page title 2024-10-01 19:22:34 +01:00
f09cf2def2 update leaderboard embed 2024-10-01 19:21:10 +01:00
df7166a246 oops, fix leaderboard button being hidden 2024-10-01 19:18:52 +01:00
162424b2d0 eeeeeeee essss linetttt 2024-10-01 19:17:22 +01:00
3a4bc7a83a add leaderboard page 2024-10-01 19:14:33 +01:00
a8ead62b79 Update dependency eslint-config-next to v14.2.14 2024-10-01 18:03:28 +00:00
8c87f6211b Update dependency @tanstack/react-query to v5.59.0 2024-10-01 16:03:25 +00:00
d4f7aec4a5 fix load time 2024-10-01 16:28:22 +01:00
cdaf16b157 add db load time in the toast 2024-10-01 15:49:38 +01:00
f30a373120 optimize mini ranking to only update when they are visible 2024-10-01 14:46:44 +01:00
50d5c0e5bc change up map buttons to be more mobile friendly and fix time ago formatter eslint stuff 2024-10-01 14:37:19 +01:00
bd4f49205b change time ago formatter 2024-10-01 11:52:28 +01:00
1faa97b332 add weighted pp tooltip 2024-10-01 11:44:53 +01:00
2884291063 fix cursor for score buttons 2024-10-01 10:17:34 +01:00
008d3298a6 fix cursor when hovering on score rank 2024-10-01 09:53:39 +01:00
fd57a97712 fix last tracked date not being updated 2024-10-01 09:52:36 +01:00
c79c5fa4f2 fix comments 2024-10-01 09:40:02 +01:00
73b039dca8 default to the page the players score is on when showing the leaderboard for a score 2024-10-01 09:37:51 +01:00
43d52e335e cache the beatsaver map 2024-10-01 00:00:28 +01:00
794de708b9 add skeleton for mini rankings 2024-09-30 23:55:18 +01:00
0757012f02 stupid imports 2024-09-30 23:32:58 +01:00
34636d9d9d add animation to the score leaderboard and clean it up a bit 2024-09-30 23:22:08 +01:00
b71b5d5ff2 fix imports 2024-09-30 23:06:33 +01:00
be89f6ba71 cleanup score badges 2024-09-30 23:06:06 +01:00
7ca22a07e0 add score colors to the scores on a score leaderboard (the button to show the leaderboard for that score) 2024-09-30 23:03:59 +01:00
381e4bcb86 use an optimized image url in getAverageColor to make it faster 2024-09-30 22:51:53 +01:00
918f4115b1 fix types and fix eslint err 2024-09-30 22:48:30 +01:00
4c654ca79a make the toast on the bottom on mobile 2024-09-30 22:44:29 +01:00
6a1af178fe make the toast on the bottom on mobile 2024-09-30 22:44:12 +01:00
e3b6528027 only scroll when needed 2024-09-30 22:41:02 +01:00
08ce0cf5aa scroll to the top of the score on when new scores are loaded 2024-09-30 22:39:51 +01:00
732599a721 fix depends on db loader 2024-09-30 22:20:48 +01:00
f38925c113 use the new prettier config and add a network offline loader 2024-09-30 22:16:55 +01:00
f32c329eb1 switch to jetbrains mono variable font and change up the mini ranking card a little bit 2024-09-30 21:17:42 +01:00
c8a07e7e32 use thumbnail for player embed 2024-09-30 18:40:32 +01:00
d3168f371c fix imports 2024-09-30 18:38:36 +01:00
d847f17f39 FIX THE PLAYER CHART RESIZING YESSSSSSSSSSSSSSSSSSSSSSSSSSSSS 2024-09-30 15:30:18 +01:00
3e91c5d98a add padding between the legend and the chart 2024-09-30 14:53:47 +01:00
7a8ece1587 improve mobile support 2024-09-30 14:27:50 +01:00
93893f0a27 fix player chart mobile support 2024-09-30 14:27:23 +01:00
639e7f16ec fix type 2024-09-30 14:15:23 +01:00
342dbefac7 fix mobile support for chart and make the chart more flexible (easier to add more stats) 2024-09-30 14:10:38 +01:00
9097d254f1 fix country rank in the player page embed 2024-09-30 13:37:20 +01:00
38702da204 add relative pp to the mini rankings and handle mobile support better for mini rankings 2024-09-30 13:21:31 +01:00
442e52d22d add acc ranges to the score (eg: A, S, SS, etc) 2024-09-30 12:51:22 +01:00
bb5506148c fix acc colors >= 95 not working 2024-09-30 12:33:26 +01:00
3217b26bce optimize the players avatar image 2024-09-30 11:50:27 +01:00
0943cedf78 proxy song cover art to optimize them 2024-09-30 11:43:46 +01:00
2abf754c13 add stale time to player data so it doesn't fetch it so often 2024-09-30 11:29:52 +01:00
5f1ee985fb add cache to the player data fetching on the player page to speedup initial page loads 2024-09-30 11:27:33 +01:00
b5071fd420 disable auto refetching of data 2024-09-30 11:23:05 +01:00
7ff29904d9 show visible gaps if there is missing data in the player chart 2024-09-30 09:49:09 +01:00
fb7928259a disable Sentry in dev 2024-09-30 09:44:28 +01:00
f3b227dbf0 fix sentry 2024-09-30 09:37:04 +01:00
f1eb855ba2 impl Sentry 2024-09-30 09:20:48 +01:00
843b2c81b6 yup 2024-09-30 09:11:12 +01:00
30beff3aa0 smh 2024-09-30 09:08:34 +01:00
3c81cba7b8 7 2024-09-30 09:03:14 +01:00
f0dede9368 surely now 2024-09-30 09:02:16 +01:00
56d013c61f now???????? 2024-09-30 08:56:48 +01:00
355899afad i hate depends 2024-09-30 08:53:47 +01:00
2448ab6e9d meow 2024-09-30 08:50:29 +01:00
dccda18b49 oopsie doodle 2024-09-30 08:48:58 +01:00
59ec24bbd9 add python 2024-09-30 08:46:25 +01:00
7498f2ee94 Merge remote-tracking branch 'origin/master' 2024-09-30 08:40:04 +01:00
7629b3aa1b fix player header for mobile 2024-09-30 08:39:25 +01:00
Lee
b954616fd0 Merge pull request 'Update dependency @trigger.dev/nextjs to v3.0.9' () from renovate/trigger.dev-nextjs-3.x-lockfile into master
Reviewed-on: 
2024-09-30 07:35:53 +00:00
Lee
38524b0587 Merge pull request 'Update dependency @trigger.dev/react to v3.0.9' () from renovate/trigger.dev-react-3.x-lockfile into master
Reviewed-on: 
2024-09-30 07:35:49 +00:00
773fe0740a Merge remote-tracking branch 'origin/master'
# Conflicts:
#	pnpm-lock.yaml
2024-09-30 08:35:11 +01:00
76f1422bd7 update player page embed to so the embed color is the avg color of their avatar and fixed history sorting 2024-09-30 08:35:00 +01:00
7577d770e8 Update dependency @trigger.dev/react to v3.0.9 2024-09-30 07:02:45 +00:00
e401255ee8 Update dependency @trigger.dev/nextjs to v3.0.9 2024-09-30 07:02:40 +00:00
Lee
d81dac3622 Merge pull request 'Update dependency @trigger.dev/sdk to v3.0.9' () from renovate/trigger.dev-sdk-3.x-lockfile into master
Reviewed-on: 
2024-09-30 06:53:50 +00:00
d1f5c4c1d6 always sort the history 2024-09-30 07:53:32 +01:00
487d0dc7b2 fix 2024-09-30 07:50:24 +01:00
b56229e58b eslint 2024-09-30 07:20:58 +01:00
ca6f43d443 fix player history 2024-09-30 07:18:13 +01:00
58cb483040 Update dependency @trigger.dev/sdk to v3.0.9 2024-09-30 04:02:31 +00:00
5bfd8baa3f why was this ked??! LOL 2024-09-28 16:18:49 +01:00
0b2e848383 fix job name 2024-09-28 15:26:33 +01:00
d9cdef5515 add tracked days count 2024-09-28 15:19:51 +01:00
05f98bf4a7 format days ago number 2024-09-28 15:07:47 +01:00
eeb4f56e79 add days ago to tracked status 2024-09-28 15:07:23 +01:00
e7b2594e2c fix tracked since 2024-09-28 14:59:30 +01:00
04dda00b7f use type for tracked status 2024-09-28 14:51:17 +01:00
6b02a0f139 oops - fix api 2024-09-28 14:48:34 +01:00
4acc4c4406 fix eslint 2024-09-28 14:44:32 +01:00
53b51582b2 add tracked since to the schema 2024-09-28 14:43:21 +01:00
a3392ca468 add tracked stats indicator 2024-09-28 14:38:02 +01:00
891b910686 don't animate score on initial page load 2024-09-28 14:14:29 +01:00
998484a682 only enable query when needed 2024-09-28 14:09:22 +01:00
fbb725dc93 add score searching 2024-09-28 14:02:33 +01:00
1733822bab fix search 2024-09-28 12:59:54 +01:00
11a8ee56cc add rank and country rank change stats 2024-09-28 12:48:14 +01:00
27d2a70198 add compression middleware 2024-09-28 08:28:46 +01:00
0c93cf9dea fix pp axis 2024-09-28 08:19:22 +01:00
737401f752 oops, fix pp change always showing 2024-09-28 08:05:25 +01:00
0b87f36ded meow 2024-09-28 08:00:41 +01:00
b271160db5 oops don't use the debug number 2024-09-28 08:00:22 +01:00
4b11a63f46 allow for negative pp change 2024-09-28 07:59:49 +01:00
8b935145f6 impl pp gain 2024-09-28 07:51:54 +01:00
dda873b2fe remove debug and fix days ago label 2024-09-28 06:46:36 +01:00
6079f8228c I THINK I FIXED IT 2024-09-28 06:43:27 +01:00
963faa23f3 surely now 2024-09-28 06:37:10 +01:00
0410f6b324 maybe now?!? 2024-09-28 06:31:25 +01:00
32e8468126 now?!?!? 2024-09-28 06:21:25 +01:00
1c9c76f721 now? 2024-09-28 06:20:42 +01:00
4cbe227827 maybe fixed? idk 2024-09-28 06:16:09 +01:00
be19ad1833 use the correct keys 2024-09-28 06:04:31 +01:00
5a48824553 eslint stuff 2024-09-28 05:59:28 +01:00
2f18f0f096 wish me luck 2024-09-28 05:57:35 +01:00
39526dde41 will it build now 2024-09-27 23:18:09 +01:00
5a074d68be fix claim profile tooltip position 2024-09-27 23:15:53 +01:00
4da95e344d upgrade to react 19 2024-09-27 23:10:54 +01:00
570b058e2d Merge remote-tracking branch 'origin/master' 2024-09-27 23:08:29 +01:00
2abfb0590a fix badges 2024-09-27 23:08:25 +01:00
Lee
256ff97b61 Merge pull request 'Update dependency next to v15.0.0-rc.0' () from renovate/nextjs-monorepo into master
Reviewed-on: 
2024-09-27 22:08:09 +00:00
1fa20b6e52 add player badges 2024-09-27 23:04:14 +01:00
d9b68f0c65 make the players name gray in the mini card to indicate its them 2024-09-27 22:17:10 +01:00
61b8fce571 make mini cards bigger on player page 2024-09-27 22:07:13 +01:00
ca3bfa8136 fix cursor on diff 2024-09-27 22:06:31 +01:00
0a2faf80e0 add time set hover to scores 2024-09-27 22:05:46 +01:00
3d3490ea47 Update dependency next to v15.0.0-rc.0 2024-09-27 21:02:27 +00:00
89389667f8 update to canary next and few small changes 2024-09-27 21:55:49 +01:00
52203bbb77 re-enable player rank chart and fix country flags 2024-09-27 21:23:44 +01:00
a7465a6dcd add global and country ranking cards on the player page 2024-09-27 21:19:44 +01:00
3709794d11 add open in new tab option to the footer 2024-09-27 19:50:27 +01:00
ed61d8c8e6 fix navbar buttons 2024-09-27 19:49:08 +01:00
Lee
ddfbe7e67b Merge pull request 'Update dependency @types/react to v18.3.10' () from renovate/react-monorepo into master
Reviewed-on: 
2024-09-27 18:36:56 +00:00
Lee
0b6644d436 Merge pull request 'Update dependency @types/node to v20.16.10' () from renovate/node-20.x-lockfile into master
Reviewed-on: 
2024-09-27 18:36:51 +00:00
Lee
0fb3faf43d Merge pull request 'Update dependency framer-motion to v11.9.0' () from renovate/framer-motion-11.x-lockfile into master
Reviewed-on: 
2024-09-27 18:36:44 +00:00
b0cb7de9b2 Update dependency @types/react to v18.3.10 2024-09-27 17:02:46 +00:00
78748a567a Update dependency @types/node to v20.16.10 2024-09-27 17:02:42 +00:00
cebfdbf7c6 Update dependency framer-motion to v11.9.0 2024-09-27 14:02:40 +00:00
Lee
432489f16b Merge pull request 'Update dependency framer-motion to v11.8.0' () from renovate/framer-motion-11.x-lockfile into master
Reviewed-on: 
2024-09-26 15:37:25 +00:00
881ec82c23 Update dependency framer-motion to v11.8.0 2024-09-26 14:02:51 +00:00
18cd1cce9d Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloadedv3 2024-09-26 12:46:47 +01:00
1f0ec157b6 fix text color? 2024-09-26 12:46:46 +01:00
Lee
dfdabd08a3 Merge pull request 'Update dependency @types/node to v20.16.9' () from renovate/node-20.x-lockfile into master
Reviewed-on: 
2024-09-26 00:43:54 +00:00
e650c89703 Update dependency @types/node to v20.16.9 2024-09-26 00:03:51 +00:00
Lee
48f188dda9 Merge pull request 'Update dependency @types/node to v20.16.9' () from renovate/node-20.x-lockfile into master
Reviewed-on: 
2024-09-25 23:49:44 +00:00
Lee
c2a3a8b6ef Merge pull request 'Update dependency framer-motion to v11.7.0' () from renovate/framer-motion-11.x-lockfile into master
Reviewed-on: 
2024-09-25 23:49:16 +00:00
6abe3a7dbd Update dependency framer-motion to v11.7.0 2024-09-25 18:02:54 +00:00
af7952831a Update dependency @types/node to v20.16.8 2024-09-25 18:02:50 +00:00
Lee
22e583df07 Merge pull request 'Update dependency lucide-react to ^0.446.0' () from renovate/lucide-monorepo into master
Reviewed-on: 
2024-09-25 17:12:43 +00:00
Lee
764e817572 Merge pull request 'Update dependency framer-motion to v11.7.0' () from renovate/framer-motion-11.x-lockfile into master
Reviewed-on: 
2024-09-25 17:12:36 +00:00
Lee
321f05f191 Merge pull request 'Update dependency @types/node to v20.16.7' () from renovate/node-20.x-lockfile into master
Reviewed-on: 
2024-09-25 17:12:31 +00:00
27865cd296 update title template 2024-09-25 15:08:06 +01:00
9c58950653 Update dependency framer-motion to v11.6.0 2024-09-25 09:02:21 +00:00
c1a1e43993 Update dependency lucide-react to ^0.446.0 2024-09-25 06:02:24 +00:00
097a2aa63f enable webpackMemoryOptimizations 2024-09-25 03:42:02 +01:00
99d69bf236 Update dependency @types/node to v20.16.7 2024-09-25 01:02:22 +00:00
Lee
b605b7f9aa Merge pull request 'Update dependency @types/react to v18.3.9' () from renovate/react-monorepo into master
Reviewed-on: 
2024-09-25 00:32:15 +00:00
Lee
f266fee5d8 Merge pull request 'Update dependency tailwindcss to v3.4.13' () from renovate/tailwindcss-3.x-lockfile into master
Reviewed-on: 
2024-09-25 00:32:09 +00:00
e8eea9cd91 Update dependency @types/react to v18.3.9 2024-09-24 15:02:33 +00:00
cd3b281c7f Update dependency tailwindcss to v3.4.13 2024-09-24 12:02:25 +00:00
Lee
f4e05928ef Merge pull request 'Update dependency @types/react to v18.3.8' () from renovate/react-monorepo into master
Reviewed-on: 
2024-09-24 12:01:20 +00:00
Lee
f8dae47a68 Merge pull request 'Update dependency framer-motion to v11.5.6' () from renovate/framer-motion-11.x-lockfile into master
Reviewed-on: 
2024-09-24 12:01:07 +00:00
Lee
740461b1a7 Merge pull request 'Update nextjs monorepo to v14.2.13' () from renovate/nextjs-monorepo into master
Reviewed-on: 
2024-09-24 12:00:56 +00:00
d0e6378bd5 Update nextjs monorepo to v14.2.13 2024-09-24 11:02:32 +00:00
9e00526ed9 Update dependency framer-motion to v11.5.6 2024-09-24 11:02:25 +00:00
ffc282af31 Update dependency @types/react to v18.3.8 2024-09-24 11:02:21 +00:00
d320a99b90 now?? 2024-09-24 11:37:08 +01:00
e2304a5db0 how did i even break it im so confused 2024-09-24 11:33:45 +01:00
e5031420ad pls work xoxo 2024-09-24 11:29:45 +01:00
Lee
b512b742a9 Merge pull request 'Update dependency tailwindcss to v3.4.13' () from renovate/tailwindcss-3.x-lockfile into master
Reviewed-on: 
2024-09-24 10:22:49 +00:00
Lee
195127429a Merge pull request 'Update dependency eslint to v8.57.1' () from renovate/eslint-monorepo into master
Reviewed-on: 
2024-09-24 10:22:44 +00:00
Lee
97c26ae1fd Merge pull request 'Update dependency @types/react to v18.3.8' () from renovate/react-monorepo into master
Reviewed-on: 
2024-09-24 10:22:38 +00:00
Lee
283edf8be1 Merge pull request 'Update dependency postcss to v8.4.47' () from renovate/postcss-8.x-lockfile into master
Reviewed-on: 
2024-09-24 10:22:32 +00:00
Lee
8d58e86442 Merge pull request 'Update dependency framer-motion to v11.5.6' () from renovate/framer-motion-11.x-lockfile into master
Reviewed-on: 
2024-09-24 10:22:21 +00:00
Lee
4886faee2a Merge pull request 'Update dependency lucide-react to ^0.445.0' () from renovate/lucide-monorepo into master
Reviewed-on: 
2024-09-24 10:22:15 +00:00
Lee
fa7d1ffb3c Merge pull request 'Update dependency @types/node to v20.16.6' () from renovate/node-20.x-lockfile into master
Reviewed-on: 
2024-09-24 10:22:10 +00:00
Lee
62bd1ae7e8 Merge pull request 'Update actions/checkout action to v4' () from renovate/actions-checkout-4.x into master
Reviewed-on: 
2024-09-24 10:22:03 +00:00
Lee
8cc0d2596c Merge pull request 'Update actions/cache action to v4' () from renovate/actions-cache-4.x into master
Reviewed-on: 
2024-09-24 10:21:56 +00:00
7c80e2405d fix tags 2024-09-24 11:21:41 +01:00
f522f853ed fix deployment 2024-09-24 11:21:15 +01:00
56ccdc2330 fix cache 2024-09-24 11:16:21 +01:00
b598ccc267 just use this as the main ssr i guess i cba fixing the old one 2024-09-24 11:15:16 +01:00
6a440d29db now? 2024-09-24 11:11:18 +01:00
a634ad560d 7 2024-09-24 11:05:05 +01:00
76e3eb7dde Update actions/checkout action to v4 2024-09-24 10:03:20 +00:00
00ad2efade Update actions/cache action to v4 2024-09-24 10:03:19 +00:00
acf32531b4 now? 2024-09-24 11:00:33 +01:00
c92aea1aca 7? 2024-09-24 10:53:03 +01:00
377a12dc80 now?!!??!! 2024-09-24 10:46:00 +01:00
4ee855ffc6 now? 2024-09-24 10:43:54 +01:00
427740b69e now? 2024-09-24 10:43:05 +01:00
66f0398e95 fix workflow? 2024-09-24 10:39:28 +01:00
3192d9f03f now? 2024-09-24 10:37:30 +01:00
d0206ff07d 7 2024-09-24 10:36:19 +01:00
45ccfd7c4c 7 2024-09-24 10:35:22 +01:00
c1134a1174 7 2024-09-24 10:35:10 +01:00
6822a2860a now ! 2024-09-24 10:34:09 +01:00
1686551ab3 temp disable 2024-09-24 10:33:16 +01:00
775ec2d988 maybe??? 2024-09-24 10:30:32 +01:00
23e8951f92 udpate 2024-09-24 10:23:53 +01:00
5f64e51ace ?!?! 2024-09-24 10:21:06 +01:00
2802797a64 fix log 2024-09-24 10:18:28 +01:00
15d35b5817 im too tired for this shit 2024-09-24 10:06:32 +01:00
b57e5dca70 oops 2024-09-24 10:04:26 +01:00
741c6617ca now? 2024-09-24 10:03:53 +01:00
a147ac9e5a e 2024-09-24 10:01:37 +01:00
60ed8181c8 add missing commit hash 2024-09-24 10:00:29 +01:00
d891d0be13 testingggggggg 2024-09-24 09:57:05 +01:00
d2e826c83f disable this for now 2024-09-24 03:45:31 +01:00
4b9e606646 attempt 2024-09-24 03:39:02 +01:00
cdbe1813cb fixed? 2024-09-24 03:26:14 +01:00
055c2cccce testing 2024-09-24 03:12:31 +01:00
34b989d99b j 2024-09-23 23:57:45 +01:00
0620f672db fix env 2024-09-23 23:53:07 +01:00
aa503373b8 testiing 2024-09-23 23:51:22 +01:00
0f0ddcf395 disable for now 2024-09-23 23:30:17 +01:00
69566fe5b8 this is required :c 2024-09-23 23:30:04 +01:00
b33f6cd815 test deployment 2024-09-23 23:28:05 +01:00
ff826d151c Update dependency @types/node to v20.16.6 2024-09-23 22:03:17 +00:00
a02d45e81c drone test 2024-09-23 22:56:49 +01:00
f7382ea68f Update dependency lucide-react to ^0.445.0 2024-09-20 22:02:52 +00:00
e01328cfb0 Update dependency framer-motion to v11.5.5 2024-09-19 09:02:15 +00:00
7a614659c1 Update dependency tailwindcss to v3.4.12 2024-09-17 16:02:15 +00:00
1aeac815b7 Update dependency eslint to v8.57.1 2024-09-16 16:02:27 +00:00
84bd9af6c1 Update dependency @types/react to v18.3.6 2024-09-16 10:02:08 +00:00
10fd094505 Update dependency postcss to v8.4.47 2024-09-14 18:02:09 +00:00
f705218bfd add hover transition to the footer 2024-09-14 18:40:39 +01:00
21e39be003 fix 2024-09-14 14:48:56 +01:00
87174eab5b fix diff color 2024-09-14 12:18:12 +01:00
fc7cf73977 fix build text 2024-09-14 00:21:06 +01:00
4b4e7f7f31 add short time back to the build info 2024-09-13 21:19:59 +01:00
c3e8b0869e hide build time on mobile 2024-09-13 21:07:00 +01:00
ae66a73abc add padding above the footer 2024-09-13 20:59:34 +01:00
287facc7f8 i hate and love eslint 2024-09-13 20:56:03 +01:00
74dfc2faa0 oopsie doodle 2024-09-13 20:54:34 +01:00
d46b717ead Merge remote-tracking branch 'origin/master' 2024-09-13 20:52:43 +01:00
e9b17327a8 add footer and build info 2024-09-13 20:52:27 +01:00
Lee
3b8025eece Merge pull request 'Update dependency @tanstack/react-query to v5.56.2' () from renovate/tanstack-query-monorepo into master
Reviewed-on: 
2024-09-13 19:15:39 +00:00
Lee
59f37224f2 Merge pull request 'Update dependency lucide-react to ^0.441.0' () from renovate/lucide-monorepo into master
Reviewed-on: 
2024-09-13 19:15:34 +00:00
37e2b305ff cleanup and remove web worker tests 2024-09-13 20:04:04 +01:00
bc2ddfa3a3 PLEASE JUST BUILD 2024-09-13 16:48:18 +01:00
709d51b00e happy now? 2024-09-13 16:46:29 +01:00
dd3b7256b0 stupid eslint 2024-09-13 16:40:47 +01:00
7112d3f53b web worker test 2024-09-13 16:38:45 +01:00
671f520635 change theme color (again) and add acc colors to the score stat badges 2024-09-13 14:22:51 +01:00
86252150b8 smh (my head) 2024-09-13 13:47:02 +01:00
281ee4a779 cleanup 2024-09-13 13:45:04 +01:00
983ccba37e make the players name clickable on the per score leaderboard 2024-09-13 00:13:42 +01:00
a3fa98b184 fix leaderboard arrow on mobile showing a backgrounf when pressed 2024-09-13 00:03:09 +01:00
aad351913f Update dependency lucide-react to ^0.441.0 2024-09-12 23:02:20 +00:00
c4be56edda Update dependency @tanstack/react-query to v5.56.2 2024-09-12 23:02:13 +00:00
33b270ab0b improve the per score leaderboard view and add a border to the card to make it more obvious 2024-09-13 00:00:14 +01:00
e852ac864d smaller gap in score card 2024-09-12 23:46:15 +01:00
Lee
33d29f5253 Merge pull request 'Update dependency @tanstack/react-query to v5.56.2' () from renovate/tanstack-query-monorepo into master
Reviewed-on: 
2024-09-12 22:39:14 +00:00
Lee
92c243fdfd Merge pull request 'Update nextjs monorepo to v14.2.11' () from renovate/nextjs-monorepo into master
Reviewed-on: 
2024-09-12 22:39:10 +00:00
cb8ce32a49 fix toast for bsr copy 2024-09-12 23:39:01 +01:00
d404f3b79f maybe a little better? placebo?? 2024-09-12 23:34:22 +01:00
b8311ad1ec make animation faster 2024-09-12 23:31:56 +01:00
e60a14e799 better score animations 2024-09-12 23:26:02 +01:00
39026d9a32 add dropdown leaderboard animation 2024-09-12 22:34:07 +01:00
14845c0377 add basic leaderboard dropdown on scores 2024-09-12 22:30:55 +01:00
bbc0882301 Update nextjs monorepo to v14.2.11 2024-09-12 20:02:42 +00:00
b1a889421c dns prefetch 2024-09-12 19:56:09 +01:00
f65407332c show score buttons on mobile 2024-09-12 19:39:27 +01:00
e26cd4b39c fix alt tags 2024-09-12 19:29:56 +01:00
2ecffe7c1f this makes lighthouse mad idk why 2024-09-12 19:29:06 +01:00
39a443ffff new font 2024-09-12 19:16:13 +01:00
e8d864e531 add player stat badges 2024-09-12 19:09:54 +01:00
17c40edc3a i hate grids. 2024-09-12 18:47:19 +01:00
8da9ec73d1 add a tooltip wrapper 2024-09-12 17:52:01 +01:00
b3c37afa0e oopsie doodle 2024-09-12 16:47:25 +01:00
9ec43b9a77 cleanup score stats 2024-09-12 16:41:38 +01:00
b411554f75 make score sort buttons prettier 2024-09-12 16:37:09 +01:00
9e3c670a9e cleanup score component and add finish score stats 2024-09-12 16:33:07 +01:00
c6497d7eab Update dependency @tanstack/react-query to v5.56.1 2024-09-12 15:02:06 +00:00
1f4b1d10af cleanup pagination 2024-09-12 15:08:25 +01:00
574b1a6532 move logging to debug and make mappers clickable on the score 2024-09-12 15:02:03 +01:00
7db722faea add missing song sub name to the score 2024-09-12 13:10:03 +01:00
6ee6b2b268 Merge branch 'master' of https://git.fascinated.cc/Fascinated/scoresaber-reloadedv3 2024-09-12 13:05:13 +01:00
c80240a362 use the newer image 2024-09-12 13:05:07 +01:00
Lee
f4b5be6f9a Merge pull request 'Update nextjs monorepo to v14.2.10' () from renovate/nextjs-monorepo into master
Reviewed-on: 
2024-09-12 12:03:34 +00:00
08970fddbf Update nextjs monorepo to v14.2.10 2024-09-12 12:02:40 +00:00
Lee
83c3b4c7bf Merge pull request 'Update dependency typescript to v5.6.2' () from renovate/typescript-5.x-lockfile into master
Reviewed-on: 
2024-09-12 12:00:18 +00:00
Lee
bb68abcea6 Merge pull request 'Update dependency lucide-react to ^0.440.0' () from renovate/lucide-monorepo into master
Reviewed-on: 
2024-09-12 12:00:13 +00:00
Lee
e44135b487 Merge pull request 'Update dependency @tanstack/react-query to v5.56.0' () from renovate/tanstack-query-monorepo into master
Reviewed-on: 
2024-09-12 12:00:00 +00:00
70c27c87ad fix scores flashing no scores found 2024-09-12 12:56:22 +01:00
aba0d4ba57 load initial scores server sided to prevent flashing 2024-09-12 12:47:21 +01:00
4d19fd3bfd add a simple page switch animation and show if the player is inactive or banned 2024-09-12 12:27:20 +01:00
763de454e7 add stale time to score data 2024-09-12 11:38:19 +01:00
f0dfbe78ea add loading indicator to the pagination 2024-09-12 11:35:37 +01:00
384ca306ed Update dependency @tanstack/react-query to v5.56.0 2024-09-12 10:02:08 +00:00
3338f87330 Update dependency lucide-react to ^0.440.0 2024-09-12 09:02:18 +00:00
99174e6299 fix text overflow breaking images 2024-09-12 01:06:49 +01:00
bb08a65701 Update dependency typescript to v5.6.2 2024-09-12 00:03:27 +00:00
Lee
e1a8e4ae72 Merge pull request 'Update nextjs monorepo to v14.2.9' () from renovate/nextjs-monorepo into master
Reviewed-on: 
2024-09-11 23:40:12 +00:00
Lee
758527ee1d Merge pull request 'Update dependency tailwindcss to v3.4.11' () from renovate/tailwindcss-3.x-lockfile into master
Reviewed-on: 
2024-09-11 23:37:39 +00:00
94bb7b12a6 Update nextjs monorepo to v14.2.9 2024-09-11 23:02:22 +00:00
1c1cd93d20 Update dependency tailwindcss to v3.4.11 2024-09-11 23:02:10 +00:00
837 changed files with 12781 additions and 5115 deletions
.dockerignore.eslintrc.json
.gitea
.gitignore.prettierrc.jsonDockerfilebun.lockbconfig.tsnext.config.mjspackage.jsonpnpm-lock.yaml
projects

2
.dockerignore Normal file

@ -0,0 +1,2 @@
node_modules
dist

@ -1,3 +0,0 @@
{
"extends": ["next/core-web-vitals", "next/typescript"]
}

@ -0,0 +1,39 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: scoresaber-reloaded-backend
namespace: public-services
spec:
replicas: 1
selector:
matchLabels:
app: scoresaber-reloaded-backend
template:
metadata:
labels:
app: scoresaber-reloaded-backend
spec:
containers:
- name: scoresaber-reloaded-backend-container
image: git.fascinated.cc/fascinated/scoresaber-reloaded-backend:latest
imagePullPolicy: Always
ports:
- containerPort: 8080
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 1000m # 1 vCPU
memory: 512Mi
env:
- name: MONGO_URI
valueFrom:
secretKeyRef:
name: ssr-backend-secret
key: MONGO_URI
- name: DISCORD_BOT_TOKEN
valueFrom:
secretKeyRef:
name: ssr-backend-secret
key: DISCORD_BOT_TOKEN

@ -0,0 +1,50 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: scoresaber-reloaded-backend-ingress
namespace: public-services
annotations:
kubernetes.io/ingress.class: traefik-external
spec:
entryPoints:
- websecure
routes:
- match: Host(`ssr.fascinated.cc`) && PathPrefix(`/api`)
kind: Rule
middlewares:
- name: default-headers
namespace: traefik
- name: compress
namespace: traefik
- name: scoresaber-reloaded-backend-strip-api-prefix
namespace: public-services
services:
- name: scoresaber-reloaded-backend-service
port: 8080
tls:
secretName: fascinated-cc
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: scoresaber-reloaded-backend-swagger-ingress
namespace: public-services
annotations:
kubernetes.io/ingress.class: traefik-external
spec:
entryPoints:
- websecure
routes:
- match: Host(`ssr.fascinated.cc`) && PathPrefix(`/swagger`)
kind: Rule
middlewares:
- name: default-headers
namespace: traefik
- name: compress
namespace: traefik
services:
- name: scoresaber-reloaded-backend-service
port: 8080
tls:
secretName: fascinated-cc

@ -0,0 +1,17 @@
---
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
creationTimestamp: null
name: ssr-backend-secret
namespace: public-services
spec:
encryptedData:
DISCORD_BOT_TOKEN: AgCkl523hUe1qei/mybm16Eht0UVEJtPXbcnZYJ6aeLYkB2Mihk8OK+rYGnxN/1MhjYh4VNW36KPZ8/9M/chfsIirECw8OLNhhxzXxbyUpNCmy9JZA4EB5H6UDzC0STOOZekncGtGBg2cU0sZeHaYUlERbFSaDADuLx5aP2RxgHX5xucUfmTtMVbCDg2pCsVT+wliS3gD4FBTVYr1ZxePNCDpPsjzTCd6v+AzlZidIuNwZUDVcXIuCpA+o3jpbzuriMJDyGHA9H7182nZycQ0Q8KQLB1JYgZ2em3Sec5VM4Gkxd6d38c41ldgAIdPjd8rurpxpAdptDgMupmXx8Rm66BdFr0A4ifBpvmLePYOe6cOrjhfGhcEjkzLhBqEWZ7cH4oOKXRX5f0FLWCcKI5G7/vCbRfpr6jx4KuFQt2Mq+BplApVjz/lhqXigd+JDiXx383fYEMaZq8FB2mozfatw1/OpZrhMlunFmQPd/eOcBzJArwYY28qbWaTrKroA1Mc1yWGwlwIEpZAyoNN5x6XuLBKF5tkd+R0h1usDsxQoToHmfYN3qYktZRKsbPb1UpwBHeUPPzzIbgFUgAvGtPhqau4GH3VoCP6qNRt0ATiC8O0k9iXXPrPz9ajp5dzHPmjDz35AmBeoxsrpWZeWbYslc/iIEzqKWBmmXa/NAerJ0fVmMsC9bb+6CTVg7ZOTk0JC9moT5BP3F6U9ZYMkT8x99whvoLdautqnVpnYz/N0SwMVkJzQQKtFNxZH8eTuow8Eqyni2Im1/owMRX00SDtqTt1+8M12GDsnk=
MONGO_URI: AgCDR9Dasy7vtqThemJxCcXEAW+tNXpsSyBQeeF/tpE6Q6L1itl+8f4EEz+WUQ5cZf5HswD105hNZDhEaoqJeDFllmsYyEL7cZMgrsK1UyhY8m6H4+vdAkX4pBiGf0ig5RMkISopEHCyvmdDkO7vvfjsh2fyjBv0BD9KvqMfzg/m3dEbm48JXbBKogdN9H8yj3L4Xi/xaLwgGx8jnG8A0VoR3VmxJfSJKFOhovb5prS0ZByGAT0hrdGb0TgERfFLlsgjrKAiBx1xHgA+L0RPrkJCHXYT7z1ll/LYKfedLqIc76PTYZpMywq8XDIQUu0JnyZb1OzrjhK3zolzb+HIK65Rp4/PoM8yGyUwEbyJ/e6LBjsIjqtVoylYWy3css3xYmABaWD9FVl/qwXekOPJqc1dcOdEItlg34j+H01HXR1p0/SHZOYRTKM1mWNmlvP5nBd6gGue91LUIumonAP/dCUP4KBwaUkvJx+NW5w0L2DXG7ZHn18MUUx/zKgjFGl1GigyD5v/r2gfXXYjTTcdtR0oNHTjB3nBtU0142irJQCuqWuB3NWs8NTPfNp7/kTlHP8eD9NJfiSKCgU+Ld7sg+MBjnB5mVU3dX4WAdebfGZHaa4RIwyJbcvgu88sdSP+bzukcWi5cFppwuVX/4s9Aml+GtvuojmLbzuPAOIGKz5MS8TpB1v3Hiy5VFFO1OiWn53sT093kOp4OxULLWYtlTey9f2LzTbWz+X9oUOdedaVlQW445+eGmyKj9FDWrKk+7+wPhgYNY5EonN6p3OKkWopN3tv/TK3qcvT93uYBbr/sP9VT7GHnkdH2us=
template:
metadata:
creationTimestamp: null
name: ssr-backend-secret
namespace: public-services
type: Opaque

@ -0,0 +1,13 @@
---
apiVersion: v1
kind: Service
metadata:
name: scoresaber-reloaded-backend-service
namespace: public-services
spec:
type: ClusterIP
ports:
- port: 8080
targetPort: 8080
selector:
app: scoresaber-reloaded-backend

@ -0,0 +1,10 @@
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: scoresaber-reloaded-backend-strip-api-prefix
namespace: public-services
spec:
stripPrefix:
prefixes:
- "/api"

@ -0,0 +1,28 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: scoresaber-reloaded-website
namespace: public-services
spec:
replicas: 1
selector:
matchLabels:
app: scoresaber-reloaded-website
template:
metadata:
labels:
app: scoresaber-reloaded-website
spec:
containers:
- name: scoresaber-reloaded-website-container
image: git.fascinated.cc/fascinated/scoresaber-reloaded-website:latest
imagePullPolicy: Always
ports:
- containerPort: 3000
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 1000m # 1 vCPU
memory: 1024Mi

@ -0,0 +1,24 @@
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: scoresaber-reloaded-website-ingress
namespace: public-services
annotations:
kubernetes.io/ingress.class: traefik-external
spec:
entryPoints:
- websecure
routes:
- match: Host(`ssr.fascinated.cc`)
kind: Rule
middlewares:
- name: default-headers
namespace: traefik
- name: compress
namespace: traefik
services:
- name: scoresaber-reloaded-website-service
port: 3000
tls:
secretName: fascinated-cc

@ -0,0 +1,13 @@
---
apiVersion: v1
kind: Service
metadata:
name: scoresaber-reloaded-website-service
namespace: public-services
spec:
type: ClusterIP
ports:
- port: 3000
targetPort: 3000
selector:
app: scoresaber-reloaded-website

@ -0,0 +1,98 @@
name: Deploy Backend
on:
workflow_dispatch:
push:
branches:
- master
paths:
- projects/backend/**
- projects/common/**
- .gitea/workflows/deploy-backend.yml
jobs:
docker:
strategy:
matrix:
arch: ["ubuntu-latest"]
runs-on: ${{ matrix.arch }}
# Steps to run
steps:
# Checkout the repo
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Deploy to Dokku
- name: Push to dokku
uses: dokku/github-action@master
with:
git_remote_url: "ssh://dokku@51.158.63.74:22/ssr-backend"
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
#name: "Deploy Backend"
#
#on:
# workflow_dispatch:
# push:
# branches:
# - master
# paths:
# - projects/backend/**
# - projects/common/**
# - .gitea/kubernetes/backend/**
# - .gitea/workflows/deploy-backend.yml
#
#jobs:
# deploy:
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
#
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v3
#
# - name: Login to Docker Hub
# uses: docker/login-action@v3
# with:
# username: ${{ secrets.REGISTRY_USERNAME }}
# password: ${{ secrets.REGISTRY_TOKEN }}
# registry: git.fascinated.cc
#
# - name: Build Image
# uses: docker/build-push-action@v6
# with:
# context: .
# file: ./projects/backend/Dockerfile
# push: true
# tags: |
# git.fascinated.cc/fascinated/scoresaber-reloaded-backend:${{ github.sha }}
# git.fascinated.cc/fascinated/scoresaber-reloaded-backend:latest
# build-args: |
# GIT_REV=${{ gitea.sha }}
#
# - name: Install kubectl
# uses: azure/setup-kubectl@v4
# id: install
#
# - name: Setup Kubernetes Context
# uses: azure/k8s-set-context@v4
# with:
# kubeconfig: ${{ secrets.KUBECONFIG }}
#
# - name: Deploy to Kubernetes
# uses: Azure/k8s-deploy@v5
# with:
# action: deploy
# namespace: public-services
# manifests: |
# .gitea/kubernetes/backend/sealed-secret.yaml
# .gitea/kubernetes/backend/deployment.yaml
# .gitea/kubernetes/backend/service.yaml
# .gitea/kubernetes/backend/strip-api-prefix-middleware.yaml
# .gitea/kubernetes/backend/ingress.yaml
# images: |
# git.fascinated.cc/fascinated/scoresaber-reloaded-backend:${{ github.sha }}

@ -0,0 +1,97 @@
name: Deploy Website
on:
workflow_dispatch:
push:
branches:
- master
paths:
- projects/website/**
- projects/common/**
- .gitea/workflows/deploy-website.yml
jobs:
docker:
strategy:
matrix:
arch: ["ubuntu-latest"]
runs-on: ${{ matrix.arch }}
# Steps to run
steps:
# Checkout the repo
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
# Deploy to Dokku
- name: Push to dokku
uses: dokku/github-action@master
with:
git_remote_url: "ssh://dokku@51.158.63.74:22/ssr-website"
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
#name: "Deploy Website"
#
#on:
# workflow_dispatch:
# push:
# branches:
# - master
# paths:
# - projects/website/**
# - projects/common/**
# - .gitea/kubernetes/website/**
# - .gitea/workflows/deploy-website.yml
#
#jobs:
# deploy:
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v4
#
# - name: Set up Docker Buildx
# uses: docker/setup-buildx-action@v3
#
# - name: Login to Docker Hub
# uses: docker/login-action@v3
# with:
# username: ${{ secrets.REGISTRY_USERNAME }}
# password: ${{ secrets.REGISTRY_TOKEN }}
# registry: git.fascinated.cc
#
# - name: Build Image
# uses: docker/build-push-action@v6
# with:
# context: .
# file: ./projects/website/Dockerfile
# push: true
# tags: |
# git.fascinated.cc/fascinated/scoresaber-reloaded-website:${{ github.sha }}
# git.fascinated.cc/fascinated/scoresaber-reloaded-website:latest
# build-args: |
# GIT_REV=${{ gitea.sha }}
# SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}
#
# - name: Install kubectl
# uses: azure/setup-kubectl@v4
# id: install
#
# - name: Setup Kubernetes Context
# uses: azure/k8s-set-context@v4
# with:
# kubeconfig: ${{ secrets.KUBECONFIG }}
#
# - name: Deploy to Kubernetes
# uses: Azure/k8s-deploy@v5
# with:
# action: deploy
# namespace: public-services
# manifests: |
# .gitea/kubernetes/website/deployment.yaml
# .gitea/kubernetes/website/service.yaml
# .gitea/kubernetes/website/ingress.yaml
# images: |
# git.fascinated.cc/fascinated/scoresaber-reloaded-website:${{ github.sha }}

@ -1,21 +0,0 @@
name: "Deploy SSR"
on:
push:
branches:
- master
jobs:
deploy:
runs-on: ubuntu-22.04
steps:
- name: Cloning repo
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push to dokku
uses: dokku/github-action@master
with:
git_remote_url: "ssh://dokku@10.0.50.65:22/ssr"
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}

107
.gitignore vendored

@ -1,36 +1,81 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
lerna-debug.log*
.pnpm-debug.log*
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
pids
*.pid
*.seed
*.pid.lock
lib-cov
coverage
*.lcov
.nyc_output
.grunt
bower_components
.lock-wscript
build/Release
node_modules/
jspm_packages/
web_modules/
*.tsbuildinfo
.npm
.eslintcache
.stylelintcache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
.node_repl_history
*.tgz
.yarn-integrity
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.cache
.parcel-cache
.next
out
.nuxt
dist
.cache/
.vuepress/dist
.temp
.docusaurus
.serverless/
.fusebox/
.dynamodb/
.tern-port
.vscode-test
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.DS_*
**/*.backup.*
**/*.back.*
node_modules
*.sublime*
psd
thumb
sketch
/node_modules
/.pnp
.pnp.js
/coverage
/.next/
/out/
/build
.DS_Store
*.pem
.env*.local
.vercel
next-env.d.ts
.idea
secret.yaml

12
.prettierrc.json Normal file

@ -0,0 +1,12 @@
{
"semi": true,
"tabWidth": 2,
"useTabs": false,
"singleQuote": false,
"trailingComma": "es5",
"printWidth": 120,
"bracketSpacing": true,
"arrowParens": "avoid",
"jsxSingleQuote": false,
"jsxBracketSameLine": false
}

@ -1,43 +0,0 @@
FROM fascinated/docker-images:node-pnpm-latest AS base
# Install depends
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json* pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile --quiet
# Build from source
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
# Build the app
RUN pnpm run build
# Run the app
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
COPY --from=builder --chown=nextjs:nodejs /app/next.config.mjs ./next.config.mjs
USER nextjs
EXPOSE 80
ENV HOSTNAME "0.0.0.0"
ENV PORT 80
CMD ["pnpm", "start"]

BIN
bun.lockb Executable file

Binary file not shown.

@ -1,3 +0,0 @@
export const config = {
siteUrl: "https://ssr-dev.fascinated.cc",
};

@ -1,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

@ -1,50 +1,19 @@
{
"name": "scoresaber-reloadedv3",
"version": "0.1.0",
"private": true,
"name": "scoresaber-reloaded",
"version": "1.0.0",
"workspaces": [
"projects/*"
],
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"dev:website": "bun --filter 'website' dev",
"dev:backend": "bun --filter 'backend' dev",
"dev:common": "bun --filter '@ssr/common' dev",
"dev": "concurrently \"bun run dev:common\" \"bun run dev:backend\""
},
"author": "fascinated7",
"license": "MIT",
"dependencies": {
"@formkit/tempo": "^0.1.2",
"@heroicons/react": "^2.1.5",
"@hookform/resolvers": "^3.9.0",
"@radix-ui/react-avatar": "^1.1.0",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-tooltip": "^1.1.2",
"@tanstack/react-query": "^5.55.4",
"chart.js": "^4.4.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"dexie": "^4.0.8",
"dexie-react-hooks": "^1.1.7",
"ky": "^1.7.2",
"lucide-react": "^0.439.0",
"next": "14.2.8",
"next-themes": "^0.3.0",
"react": "^18",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18",
"react-hook-form": "^7.53.0",
"tailwind-merge": "^2.5.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.8",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
"concurrently": "^9.0.1",
"cross-env": "^7.0.3"
}
}

3594
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,2 @@
node_modules
dist

@ -0,0 +1,2 @@
MONGO_URI=mongodb://localhost:27017
API_URL=http://localhost:8080

@ -0,0 +1,20 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
}
}

44
projects/backend/.gitignore vendored Normal file

@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
**/*.trace
**/*.zip
**/*.tar.gz
**/*.tgz
**/*.log
package-lock.json
**/*.bun
.env

@ -0,0 +1,34 @@
FROM oven/bun:1.1.33-alpine AS base
# Install dependencies
FROM base AS depends
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
# Run the app
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
# Copy the depends
COPY --from=depends /app/package.json* /app/bun.lockb* ./
COPY --from=depends /app/node_modules ./node_modules
# Build the common library
COPY --from=depends /app/projects/common ./projects/common
RUN bun i -g typescript
RUN bun --filter '@ssr/common' build
# Copy the backend project
COPY --from=depends /app/projects/backend ./projects/backend
# Lint before starting
RUN bun --filter 'backend' lint
ARG PORT=8080
ENV PORT $PORT
EXPOSE $PORT
CMD ["bun", "run", "--filter", "backend", "start"]

@ -0,0 +1,9 @@
# Backend
## Development
To start the development server run:
```bash
bun run dev
```
Open http://localhost:3000/ with your browser to see the result.

@ -0,0 +1,20 @@
import React from "react";
export const GlobeIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 26"
fill="currentColor"
style={{
width: "33px",
height: "33px",
paddingRight: "3px",
}}
>
<path
fillRule="evenodd"
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25ZM6.262 6.072a8.25 8.25 0 1 0 10.562-.766 4.5 4.5 0 0 1-1.318 1.357L14.25 7.5l.165.33a.809.809 0 0 1-1.086 1.085l-.604-.302a1.125 1.125 0 0 0-1.298.21l-.132.131c-.439.44-.439 1.152 0 1.591l.296.296c.256.257.622.374.98.314l1.17-.195c.323-.054.654.036.905.245l1.33 1.108c.32.267.46.694.358 1.1a8.7 8.7 0 0 1-2.288 4.04l-.723.724a1.125 1.125 0 0 1-1.298.21l-.153-.076a1.125 1.125 0 0 1-.622-1.006v-1.089c0-.298-.119-.585-.33-.796l-1.347-1.347a1.125 1.125 0 0 1-.21-1.298L9.75 12l-1.64-1.64a6 6 0 0 1-1.676-3.257l-.172-1.03Z"
clipRule="evenodd"
/>
</svg>
);

@ -0,0 +1,20 @@
import React from "react";
export const StarIcon = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
style={{
width: "40px",
height: "40px",
paddingRight: "3px",
}}
>
<path
fillRule="evenodd"
d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.006 5.404.434c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.434 2.082-5.005Z"
clipRule="evenodd"
/>
</svg>
);

@ -0,0 +1,39 @@
{
"name": "backend",
"version": "1.0.0",
"scripts": {
"dev": "bun run --watch src/index.ts",
"start": "bun run src/index.ts",
"lint": "eslint src/**/*.ts"
},
"dependencies": {
"@bogeychan/elysia-etag": "^0.0.6",
"@dotenvx/dotenvx": "^1.16.1",
"@elysiajs/cors": "^1.1.1",
"@elysiajs/cron": "^1.1.1",
"@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",
"@vercel/og": "^0.6.3",
"discordx": "^11.12.1",
"elysia": "latest",
"elysia-autoroutes": "^0.5.0",
"elysia-decorators": "^1.0.2",
"elysia-helmet": "^2.0.0",
"eslint": "^8.57.1",
"extract-colors": "^4.1.0",
"jimp": "^1.6.0",
"ky": "^1.7.2",
"mongoose": "^8.7.0",
"node-cache": "^5.1.2",
"react": "^18.3.1"
},
"devDependencies": {
"bun-types": "latest"
},
"module": "src/index.js"
}

@ -0,0 +1,54 @@
import { Client, MetadataStorage } from "discordx";
import { ActivityType, EmbedBuilder } from "discord.js";
import { Config } from "@ssr/common/config";
export enum DiscordChannels {
trackedPlayerLogs = "1295985197262569512",
numberOneFeed = "1295988063817830430",
backendLogs = "1296524935237468250",
}
const DiscordBot = new Client({
intents: [],
presence: {
status: "online",
activities: [
{
name: "scores...",
type: ActivityType.Watching,
url: "https://ssr.fascinated.cc",
},
],
},
});
DiscordBot.once("ready", () => {
console.log("Discord bot ready!");
});
export function initDiscordBot() {
console.log("Initializing discord bot...");
MetadataStorage.instance.build().then(async () => {
await DiscordBot.login(Config.discordBotToken!).then();
});
}
/**
* Logs the message to a discord channel.
*
* @param channelId the channel id to log to
* @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`);
}
if (!channel.isSendable()) {
throw new Error(`Channel "${channelId}" is not sendable`);
}
channel.send({ embeds: [message] });
}

@ -0,0 +1,10 @@
/**
* Gets the app version.
*/
export async function getAppVersion() {
if (!process.env.APP_VERSION) {
const packageJson = await import("../../package.json");
process.env.APP_VERSION = packageJson.version;
}
return process.env.APP_VERSION + "-" + (process.env.GIT_REV?.substring(0, 7) ?? "dev");
}

@ -0,0 +1,35 @@
import { SSRCache } from "@ssr/common/cache";
import { InternalServerError } from "../error/internal-server-error";
import { isProduction } from "@ssr/common/utils/utils";
/**
* Fetches data with caching.
*
* @param cache the cache to fetch from
* @param cacheKey The key used for caching.
* @param fetchFn The function to fetch data if it's not in cache.
*/
export async function fetchWithCache<T>(
cache: SSRCache,
cacheKey: string,
fetchFn: () => Promise<T | undefined>
): Promise<T | undefined> {
if (!isProduction()) {
return await fetchFn();
}
if (cache == undefined) {
throw new InternalServerError(`Cache is not defined`);
}
if (cache.has(cacheKey)) {
return cache.get<T>(cacheKey);
}
const data = await fetchFn();
if (data) {
cache.set(cacheKey, data);
}
return data;
}

@ -0,0 +1,75 @@
export const HttpCode = {
// 1xx Informational
CONTINUE: { code: 100, message: "Continue" },
SWITCHING_PROTOCOLS: { code: 101, message: "Switching Protocols" },
PROCESSING: { code: 102, message: "Processing" },
EARLY_HINTS: { code: 103, message: "Early Hints" },
// 2xx Success
OK: { code: 200, message: "OK" },
CREATED: { code: 201, message: "Created" },
ACCEPTED: { code: 202, message: "Accepted" },
NON_AUTHORITATIVE_INFORMATION: { code: 203, message: "Non-Authoritative Information" },
NO_CONTENT: { code: 204, message: "No Content" },
RESET_CONTENT: { code: 205, message: "Reset Content" },
PARTIAL_CONTENT: { code: 206, message: "Partial Content" },
MULTI_STATUS: { code: 207, message: "Multi-Status" },
ALREADY_REPORTED: { code: 208, message: "Already Reported" },
IM_USED: { code: 226, message: "IM Used" },
// 3xx Redirection
MULTIPLE_CHOICES: { code: 300, message: "Multiple Choices" },
MOVED_PERMANENTLY: { code: 301, message: "Moved Permanently" },
FOUND: { code: 302, message: "Found" },
SEE_OTHER: { code: 303, message: "See Other" },
NOT_MODIFIED: { code: 304, message: "Not Modified" },
USE_PROXY: { code: 305, message: "Use Proxy" },
TEMPORARY_REDIRECT: { code: 307, message: "Temporary Redirect" },
PERMANENT_REDIRECT: { code: 308, message: "Permanent Redirect" },
// 4xx Client Errors
BAD_REQUEST: { code: 400, message: "Bad Request" },
UNAUTHORIZED: { code: 401, message: "Unauthorized" },
PAYMENT_REQUIRED: { code: 402, message: "Payment Required" },
FORBIDDEN: { code: 403, message: "Forbidden" },
NOT_FOUND: { code: 404, message: "Not Found" },
METHOD_NOT_ALLOWED: { code: 405, message: "Method Not Allowed" },
NOT_ACCEPTABLE: { code: 406, message: "Not Acceptable" },
PROXY_AUTHENTICATION_REQUIRED: { code: 407, message: "Proxy Authentication Required" },
REQUEST_TIMEOUT: { code: 408, message: "Request Timeout" },
CONFLICT: { code: 409, message: "Conflict" },
GONE: { code: 410, message: "Gone" },
LENGTH_REQUIRED: { code: 411, message: "Length Required" },
PRECONDITION_FAILED: { code: 412, message: "Precondition Failed" },
PAYLOAD_TOO_LARGE: { code: 413, message: "Payload Too Large" },
URI_TOO_LONG: { code: 414, message: "URI Too Long" },
UNSUPPORTED_MEDIA_TYPE: { code: 415, message: "Unsupported Media Type" },
RANGE_NOT_SATISFIABLE: { code: 416, message: "Range Not Satisfiable" },
EXPECTATION_FAILED: { code: 417, message: "Expectation Failed" },
IM_A_TEAPOT: { code: 418, message: "I'm a teapot" },
MISDIRECTED_REQUEST: { code: 421, message: "Misdirected Request" },
UNPROCESSABLE_ENTITY: { code: 422, message: "Unprocessable Entity" },
LOCKED: { code: 423, message: "Locked" },
FAILED_DEPENDENCY: { code: 424, message: "Failed Dependency" },
TOO_EARLY: { code: 425, message: "Too Early" },
UPGRADE_REQUIRED: { code: 426, message: "Upgrade Required" },
PRECONDITION_REQUIRED: { code: 428, message: "Precondition Required" },
TOO_MANY_REQUESTS: { code: 429, message: "Too Many Requests" },
REQUEST_HEADER_FIELDS_TOO_LARGE: { code: 431, message: "Request Header Fields Too Large" },
UNAVAILABLE_FOR_LEGAL_REASONS: { code: 451, message: "Unavailable For Legal Reasons" },
// 5xx Server Errors
INTERNAL_SERVER_ERROR: { code: 500, message: "Internal Server Error" },
NOT_IMPLEMENTED: { code: 501, message: "Not Implemented" },
BAD_GATEWAY: { code: 502, message: "Bad Gateway" },
SERVICE_UNAVAILABLE: { code: 503, message: "Service Unavailable" },
GATEWAY_TIMEOUT: { code: 504, message: "Gateway Timeout" },
HTTP_VERSION_NOT_SUPPORTED: { code: 505, message: "HTTP Version Not Supported" },
VARIANT_ALSO_NEGOTIATES: { code: 506, message: "Variant Also Negotiates" },
INSUFFICIENT_STORAGE: { code: 507, message: "Insufficient Storage" },
LOOP_DETECTED: { code: 508, message: "Loop Detected" },
NOT_EXTENDED: { code: 510, message: "Not Extended" },
NETWORK_AUTHENTICATION_REQUIRED: { code: 511, message: "Network Authentication Required" },
} as const;
export type HttpCode = typeof HttpCode[keyof typeof HttpCode];

@ -0,0 +1,26 @@
import { Controller, Get } from "elysia-decorators";
import { getAppVersion } from "../common/app.util";
import { AppService } from "../service/app.service";
@Controller()
export default class AppController {
@Get("/")
public async index() {
return {
app: "backend",
version: await getAppVersion(),
};
}
@Get("/health")
public async getHealth() {
return {
status: "OK",
};
}
@Get("/statistics")
public async getStatistics() {
return await AppService.getAppStatistics();
}
}

@ -0,0 +1,36 @@
import { Controller, Get } from "elysia-decorators";
import { t } from "elysia";
import { ImageService } from "../service/image.service";
@Controller("/image")
export default class ImageController {
@Get("/averagecolor/:url", {
config: {},
params: t.Object({
url: t.String({ required: true }),
}),
})
public async getImageAverageColor({ params: { url } }: { params: { url: string } }) {
return await ImageService.getAverageImageColor(url);
}
@Get("/player/:id", {
config: {},
params: t.Object({
id: t.String({ required: true }),
}),
})
public async getPlayerImage({ params: { id } }: { params: { id: string } }) {
return await ImageService.generatePlayerImage(id);
}
@Get("/leaderboard/:id", {
config: {},
params: t.Object({
id: t.String({ required: true }),
}),
})
public async getLeaderboardImage({ params: { id } }: { params: { id: string } }) {
return await ImageService.generateLeaderboardImage(id);
}
}

@ -0,0 +1,26 @@
import { Controller, Get } from "elysia-decorators";
import { t } from "elysia";
import { Leaderboards } from "@ssr/common/leaderboard";
import LeaderboardService from "../service/leaderboard.service";
@Controller("/leaderboard")
export default class LeaderboardController {
@Get("/:leaderboard/:id", {
config: {},
params: t.Object({
id: t.String({ required: true }),
leaderboard: t.String({ required: true }),
}),
})
public async getLeaderboard({
params: { leaderboard, id },
}: {
params: {
leaderboard: Leaderboards;
id: string;
page: number;
};
}): Promise<unknown> {
return await LeaderboardService.getLeaderboard(leaderboard, id);
}
}

@ -0,0 +1,80 @@
import { Controller, Get } from "elysia-decorators";
import { PlayerService } from "../service/player.service";
import { t } from "elysia";
import { PlayerHistory } from "@ssr/common/player/player-history";
import { PlayerTrackedSince } from "@ssr/common/player/player-tracked-since";
import { AroundPlayerResponse } from "@ssr/common/response/around-player-response";
@Controller("/player")
export default class PlayerController {
@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, days },
query: { createIfMissing },
}: {
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(days) };
}
@Get("/tracked/:id", {
config: {},
params: t.Object({
id: t.String({ required: true }),
}),
})
public async getTrackedStatus({
params: { id },
query: { createIfMissing },
}: {
params: { id: string };
query: { createIfMissing: boolean };
}): Promise<PlayerTrackedSince> {
try {
const player = await PlayerService.getPlayer(id, createIfMissing);
return {
tracked: true,
daysTracked: player.getDaysTracked(),
};
} catch {
return {
tracked: false,
};
}
}
@Get("/around/:id/:type", {
config: {},
params: t.Object({
id: t.String({ required: true }),
type: t.String({ required: true }),
}),
})
public async getPlayersAround({
params: { id, type },
}: {
params: { id: string; type: "global" | "country" };
}): Promise<AroundPlayerResponse> {
return {
players: await PlayerService.getPlayersAroundPlayer(id, type),
};
}
}

@ -0,0 +1,59 @@
import { Controller, Get } from "elysia-decorators";
import { t } from "elysia";
import { Leaderboards } from "@ssr/common/leaderboard";
import { ScoreService } from "../service/score.service";
import { ScoreSortType } from "@ssr/common/sorter/sort-type";
import { SortDirection } from "@ssr/common/sorter/sort-direction";
@Controller("/scores")
export default class ScoresController {
@Get("/player/:leaderboard/:id/:page/:sort/:direction", {
config: {},
params: t.Object({
leaderboard: t.String({ required: true }),
id: t.String({ required: true }),
page: t.Number({ required: true }),
sort: t.String({ required: true }),
direction: t.String({ required: true }),
}),
query: t.Object({
search: t.Optional(t.String()),
}),
})
public async getScores({
params: { leaderboard, id, page, sort, direction },
query: { search },
}: {
params: {
leaderboard: Leaderboards;
id: string;
page: number;
sort: ScoreSortType;
direction: SortDirection;
};
query: { search?: string };
}): Promise<unknown> {
return await ScoreService.getPlayerScores(leaderboard, id, page, sort, direction, search);
}
@Get("/leaderboard/:leaderboard/:id/:page", {
config: {},
params: t.Object({
leaderboard: t.String({ required: true }),
id: t.String({ required: true }),
page: t.Number({ required: true }),
}),
})
public async getLeaderboardScores({
params: { leaderboard, id, page },
}: {
params: {
leaderboard: Leaderboards;
id: string;
page: number;
};
query: { search?: string };
}): Promise<unknown> {
return await ScoreService.getLeaderboardScores(leaderboard, id, page);
}
}

@ -0,0 +1,10 @@
import { HttpCode } from "../common/http-codes";
export class InternalServerError extends Error {
constructor(
public message: string = "internal-server-error",
public status: number = HttpCode.INTERNAL_SERVER_ERROR.code
) {
super(message);
}
}

@ -0,0 +1,10 @@
import { HttpCode } from "../common/http-codes";
export class NotFoundError extends Error {
constructor(
public message: string = "not-found",
public status: number = HttpCode.NOT_FOUND.code
) {
super(message);
}
}

@ -0,0 +1,10 @@
import { HttpCode } from "../common/http-codes";
export class RateLimitError extends Error {
constructor(
public message: string = "rate-limited",
public status: number = HttpCode.TOO_MANY_REQUESTS.code
) {
super(message);
}
}

@ -0,0 +1,238 @@
import { Elysia } from "elysia";
import cors from "@elysiajs/cors";
import { decorators } from "elysia-decorators";
import { logger } from "@tqman/nice-logger";
import { swagger } from "@elysiajs/swagger";
import { helmet } from "elysia-helmet";
import { etag } from "@bogeychan/elysia-etag";
import AppController from "./controller/app.controller";
import * as dotenv from "@dotenvx/dotenvx";
import mongoose from "mongoose";
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 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 { ScoreSort } from "@ssr/common/score/score-sort";
// Load .env file
dotenv.config({
logLevel: (await Bun.file(".env").exists()) ? "success" : "warn",
path: ".env",
override: true,
});
// Connect to Mongo
await mongoose.connect(Config.mongoUri!); // Connect to MongoDB
// 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: ${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)}`)
);
},
});
export const app = new Elysia();
app.use(
cron({
name: "player-statistics-tracker-cron",
pattern: "1 0 * * *", // Every day at 00:01
timezone: "Europe/London", // UTC time
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.");
},
})
);
app.use(
cron({
name: "scores-background-refresh",
pattern: "*/1 * * * *",
protect: true,
run: async () => {
console.log(`Refreshing player score data...`);
const players = await PlayerModel.find({});
console.log(`Found ${players.length} players to refresh.`);
for (const player of players) {
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) {
break;
}
if (scoresPage.metadata.total <= page * 100) {
hasMorePages = false;
}
page++;
for (const score of scoresPage.playerScores) {
await ScoreService.trackScoreSaberScore(score.score, score.leaderboard, player.id);
}
}
console.log(`Finished refreshing scores for ${player.id}, total pages refreshed: ${page - 1}.`);
}
},
})
);
/**
* Custom error handler
*/
app.onError({ as: "global" }, ({ code, error }) => {
// Return default error for type validation
if (code === "VALIDATION") {
return error.all;
}
const status = "status" in error ? error.status : undefined;
return {
...((status && { statusCode: status }) || { status: code }),
...(error.message != code && { message: error.message }),
timestamp: new Date().toISOString(),
};
});
/**
* Enable E-Tags
*/
app.use(etag());
/**
* Enable CORS
*/
app.use(cors());
/**
* Request logger
*/
app.use(
logger({
enabled: true,
mode: "combined",
})
);
/**
* Security settings
*/
app.use(
helmet({
hsts: false, // Disable HSTS
contentSecurityPolicy: false, // Disable CSP
dnsPrefetchControl: true, // Enable DNS prefetch
})
);
/**
* Controllers
*/
app.use(
decorators({
controllers: [AppController, PlayerController, ImageController, ScoresController, LeaderboardController],
})
);
/**
* Swagger Documentation
*/
app.use(
swagger({
documentation: {
info: {
title: "ScoreSaber Reloaded Documentation",
version: await getAppVersion(),
},
},
scalarConfig: {
servers: [
{
url: "https://ssr.fascinated.cc/api",
description: "Production server",
},
],
},
})
);
app.onStart(() => {
console.log("Listening on port http://localhost:8080");
if (isProduction()) {
initDiscordBot();
}
});
app.listen(8080);

@ -0,0 +1,24 @@
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";
export class AppService {
/**
* Gets the app statistics.
*/
public static async getAppStatistics(): Promise<AppStatistics> {
const trackedPlayers = await PlayerModel.countDocuments();
const trackedScores = await ScoreSaberScoreModel.countDocuments();
const additionalScoresData = await AdditionalScoreDataModel.countDocuments();
const cachedBeatSaverMaps = await BeatSaverMapModel.countDocuments();
return {
trackedPlayers,
trackedScores,
additionalScoresData,
cachedBeatSaverMaps,
};
}
}

@ -0,0 +1,98 @@
import { beatsaverService } from "@ssr/common/service/impl/beatsaver";
import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/map";
export default class BeatSaverService {
/**
* 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, or undefined if not found
*/
public static async getMap(hash: string): Promise<BeatSaverMap | undefined> {
let map = await BeatSaverMapModel.findOne({
"versions.hash": hash.toUpperCase(),
});
if (map) {
const toObject = map.toObject() as BeatSaverMap;
// If the map is not found, return undefined
if (toObject.notFound) {
return undefined;
}
// 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);
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, // todo: change this to an incrementing id
bsr: token.id,
name: token.name,
description: token.description,
author: {
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,
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;
}
}

@ -0,0 +1,221 @@
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 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";
const cache = new SSRCache({
ttl: 1000 * 60 * 60, // 1 hour
});
const imageOptions = { width: 1200, height: 630 };
export class ImageService {
/**
* Gets the average color of an image
*
* @param src the image url
* @returns the average color
* @private
*/
public static async getAverageImageColor(src: string): Promise<{ color: string } | undefined> {
src = decodeURIComponent(src);
return await fetchWithCache<{ color: string }>(cache, `average_color-${src}`, async () => {
try {
const image = await Jimp.read(src); // Load image using Jimp
const { width, height, data } = image.bitmap; // Access image dimensions and pixel data
// Convert the Buffer data to Uint8ClampedArray
const uint8ClampedArray = new Uint8ClampedArray(data);
// Extract the colors using extract-colors
const colors = await extractColors({ data: uint8ClampedArray, width, height });
// Return the most dominant color, or fallback if none found
if (colors && colors.length > 0) {
return { color: colors[2].hex }; // Returning the third most dominant color
}
return {
color: "#fff", // Fallback color in case no colors are found
};
} catch (error) {
console.error("Error fetching image or extracting colors:", error);
return {
color: "#fff", // Fallback color in case of an error
};
}
});
}
/**
* The base of the OpenGraph image
*
* @param children the content of the image
*/
public static BaseImage({ children }: { children: React.ReactNode }) {
return (
<div
tw="w-full h-full flex flex-col text-white text-3xl p-3 justify-center items-center relative"
style={{
backgroundColor: "#0a0a0a",
background: "radial-gradient(ellipse 60% 60% at 50% -20%, rgba(120,119,198,0.15), rgba(255,255,255,0))",
}}
>
{children}
</div>
);
}
/**
* Renders the change for a stat.
*
* @param change the amount of change
* @param format the function to format the value
*/
private static renderDailyChange(change: number, format: (value: number) => string = formatNumberWithCommas) {
if (change === 0) {
return null;
}
return (
<p tw={`text-[23px] pl-1 m-0 ${change > 0 ? "text-green-400" : "text-red-400"}`}>
{change > 0 ? "+" : ""}
{format(change)}
</p>
);
}
/**
* Generates the OpenGraph image for the player
*
* @param id the player's id
*/
public static async generatePlayerImage(id: string) {
const player = await fetchWithCache<ScoreSaberPlayer>(cache, `player-${id}`, async () => {
const token = await scoresaberService.lookupPlayer(id);
return token ? await getScoreSaberPlayerFromToken(token, Config.apiUrl) : undefined;
});
if (!player) {
return undefined;
}
const { statisticChange } = player;
const { daily } = statisticChange ?? {};
const rankChange = daily?.countryRank ?? 0;
const countryRankChange = daily?.rank ?? 0;
const ppChange = daily?.pp ?? 0;
return new ImageResponse(
(
<ImageService.BaseImage>
{/* Player Avatar */}
<img src={player.avatar} width={256} height={256} alt="Player's Avatar" tw="rounded-full mb-3" />
{/* Player Stats */}
<div tw="flex flex-col pl-3 items-center">
{/* Player Name */}
<p tw="font-bold text-6xl m-0">{player.name}</p>
{/* Player PP */}
<div tw="flex justify-center items-center text-[33px]">
<p tw="text-[#4858ff] m-0">{formatPp(player.pp)}pp</p>
{this.renderDailyChange(ppChange)}
</div>
{/* Player Stats */}
<div tw="flex">
{/* Player Rank */}
<div tw="flex px-2 justify-center items-center">
<GlobeIcon />
<p tw="m-0">#{formatNumberWithCommas(player.rank)}</p>
{this.renderDailyChange(rankChange)}
</div>
{/* Player Country Rank */}
<div tw="flex px-2 justify-center items-center">
<img
src={`https://ssr.fascinated.cc/assets/flags/${player.country.toLowerCase()}.png`}
height={20}
alt="Player's Country"
/>
<p tw="pl-1 m-0">#{formatNumberWithCommas(player.countryRank)}</p>
{this.renderDailyChange(countryRankChange)}
</div>
</div>
{/* Joined Date */}
<p tw="m-0 text-gray-400 mt-2">
Joined ScoreSaber in{" "}
{player.joinedDate.toLocaleString("en-US", {
timeZone: "Europe/London",
month: "long",
year: "numeric",
})}
</p>
</div>
</ImageService.BaseImage>
),
imageOptions
);
}
/**
* Generates the OpenGraph image for the leaderboard
*
* @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) {
return undefined;
}
const ranked = leaderboard.stars > 0;
return new ImageResponse(
(
<ImageService.BaseImage>
{/* Leaderboard Cover Image */}
<img src={leaderboard.coverImage} width={256} height={256} alt="Leaderboard Cover" tw="rounded-full mb-3" />
{/* Leaderboard Name */}
<p tw="font-bold text-6xl m-0">
{leaderboard.songName} {leaderboard.songSubName}
</p>
<div tw="flex justify-center items-center text-center">
{/* Leaderboard Stars */}
{ranked && (
<div tw="flex justify-center items-center text-4xl">
<p tw="font-bold m-0">{leaderboard.stars}</p>
<StarIcon />
</div>
)}
{/* Leaderboard Difficulty */}
<p tw={"font-bold m-0 text-4xl" + (ranked ? " pl-3" : "")}>
{getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty)}
</p>
</div>
{/* Leaderboard Author */}
<p tw="font-bold text-2xl text-gray-400 m-0 mt-2">Mapped by {leaderboard.levelAuthorName}</p>
</ImageService.BaseImage>
),
imageOptions
);
}
}

@ -0,0 +1,75 @@
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 BeatSaverService from "./beatsaver.service";
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators";
const leaderboardCache = new SSRCache({
ttl: 1000 * 60 * 60 * 24,
});
export default class LeaderboardService {
/**
* Gets the leaderboard.
*
* @param leaderboard the leaderboard
* @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;
}
default: {
return undefined;
}
}
}
/**
* Gets a leaderboard.
*
* @param leaderboardName the leaderboard to get
* @param id the players id
* @returns the scores
*/
public static async getLeaderboard<L>(leaderboardName: Leaderboards, id: string): Promise<LeaderboardResponse<L>> {
let leaderboard: Leaderboard | undefined;
let beatSaverMap: BeatSaverMap | undefined;
switch (leaderboardName) {
case "scoresaber": {
const leaderboardToken = await LeaderboardService.getLeaderboardToken<ScoreSaberLeaderboardToken>(
leaderboardName,
id
);
if (leaderboardToken == undefined) {
throw new NotFoundError(`Leaderboard not found for "${id}"`);
}
leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
beatSaverMap = await BeatSaverService.getMap(leaderboard.songHash);
break;
}
default: {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
}
return {
leaderboard: leaderboard as L,
beatsaver: beatSaverMap,
};
}
}

@ -0,0 +1,246 @@
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
import { NotFoundError } from "../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 { 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 { AroundPlayer } from "@ssr/common/types/around-player";
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> {
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) {
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);
// 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")
);
}
} catch (err) {
const message = `Failed to create player document for "${id}"`;
console.log(message, err);
throw new InternalServerError(message);
}
}
return player;
}
/**
* Seeds the player's history using data from
* the ScoreSaber API.
*
* @param player the player to seed
* @param playerToken the SoreSaber player token
*/
public static async seedPlayerHistory(player: PlayerDocument, playerToken: ScoreSaberPlayerToken): Promise<void> {
// Loop through rankHistory in reverse, from current day backwards
const playerRankHistory = playerToken.histories.split(",").map((value: string) => {
return parseInt(value);
});
playerRankHistory.push(playerToken.rank);
let daysAgo = 1; // Start from yesterday
for (let i = playerRankHistory.length - daysAgo - 1; i >= 0; i--) {
const rank = playerRankHistory[i];
// Skip inactive days
if (rank == 999_999) {
continue;
}
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
player.setStatisticHistory(date, {
rank: rank,
});
daysAgo += 1; // Increment daysAgo for each earlier rank
}
player.markModified("statisticHistory");
await player.save();
}
/**
* Tracks a players statistics
*
* @param foundPlayer the player to track
* @param playerToken an optional player token
*/
public static async trackScoreSaberPlayer(
foundPlayer: PlayerDocument,
playerToken?: ScoreSaberPlayerToken
): Promise<void> {
const dateToday = getMidnightAlignedDate(new Date());
const player = playerToken ? playerToken : await scoresaberService.lookupPlayer(foundPlayer.id);
if (player == undefined) {
console.log(`Player "${foundPlayer.id}" not found on ScoreSaber`);
return;
}
if (player.inactive) {
console.log(`Player "${foundPlayer.id}" is inactive on ScoreSaber`);
return;
}
// Seed the history with ScoreSaber data if no history exists
if (foundPlayer.getDaysTracked() === 0) {
await this.seedPlayerHistory(foundPlayer, player);
}
// Update current day's statistics
let history = foundPlayer.getHistoryByDate(dateToday);
if (history == undefined) {
history = {}; // Initialize if history is not found
}
const scoreStats = player.scoreStats;
// Set the history data
history.pp = player.pp;
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();
foundPlayer.lastTracked = new Date();
foundPlayer.markModified("statisticHistory");
await foundPlayer.save();
console.log(`Tracked player "${foundPlayer.id}"!`);
}
/**
* Gets the players around a player.
*
* @param id the player to get around
* @param type the type to get around
*/
public static async getPlayersAroundPlayer(id: string, type: AroundPlayer): Promise<ScoreSaberPlayerToken[]> {
const getRank = (player: ScoreSaberPlayerToken, type: AroundPlayer) => {
switch (type) {
case "global":
return player.rank;
case "country":
return player.countryRank;
}
};
const itemsPerPage = 50;
const player = await scoresaberService.lookupPlayer(id);
if (player == undefined) {
throw new NotFoundError(`Player "${id}" not found`);
}
const rank = getRank(player, type);
const rankWithinPage = rank % itemsPerPage;
const pagesToSearch = [getPageFromRank(rank, itemsPerPage)];
if (rankWithinPage > 0) {
pagesToSearch.push(getPageFromRank(rank - 1, itemsPerPage));
} else if (rankWithinPage < itemsPerPage - 1) {
pagesToSearch.push(getPageFromRank(rank + 1, itemsPerPage));
}
const rankings: Map<string, ScoreSaberPlayerToken> = new Map();
for (const page of pagesToSearch) {
const response =
type == "global"
? await scoresaberService.lookupPlayers(page)
: await scoresaberService.lookupPlayersByCountry(page, player.country);
if (response == undefined) {
continue;
}
for (const player of response.players) {
if (rankings.has(player.id)) {
continue;
}
rankings.set(player.id, player);
}
}
const players = rankings
.values()
.toArray()
.sort((a, b) => {
return getRank(a, type) - getRank(b, type);
});
// Show 3 players above and 1 below the requested player
const playerPosition = players.findIndex(p => p.id === player.id);
const start = Math.max(0, playerPosition - 3);
let end = Math.min(players.length, playerPosition + 2);
const playersLength = players.slice(start, end).length;
// If there is less than 5 players to return, add more players to the end
if (playersLength < 5) {
end = Math.min(end + 5 - playersLength, players.length);
}
return players.slice(start, end);
}
}

@ -0,0 +1,534 @@
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
import { isProduction } from "@ssr/common/utils/utils";
import { Metadata } from "@ssr/common/types/metadata";
import { NotFoundError } from "elysia";
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
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/map";
import { PlayerScore } from "@ssr/common/score/player-score";
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
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 Score, { ScoreType } from "@ssr/common/model/score/score";
import { getScoreSaberLeaderboardFromToken, getScoreSaberScoreFromToken } from "@ssr/common/token-creators";
import {
ScoreSaberScore,
ScoreSaberScoreInternal,
ScoreSaberScoreModel,
} from "@ssr/common/model/score/impl/scoresaber-score";
import ScoreSaberLeaderboard from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
import { ScoreSorters } from "@ssr/common/sorter/sorters";
import { ScoreSortType } from "@ssr/common/sorter/sort-type";
import { SortDirection } from "@ssr/common/sorter/sort-direction";
import { Pagination } from "../../../common/src/pagination";
import { PlayerService } from "./player.service";
import { ScoreSort } from "@ssr/common/score/score-sort";
import BeatSaverService from "./beatsaver.service";
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
const playerScoresCache = new SSRCache({
ttl: 1000 * 60, // 1 minute
});
const leaderboardScoresCache = new SSRCache({
ttl: 1000 * 60, // 1 minute
});
const ITEMS_PER_PAGE = 8;
export class ScoreService {
/**
* Notifies the number one score in Discord.
*
* @param playerScore the score to notify
*/
public static async notifyNumberOne(playerScore: ScoreSaberPlayerScoreToken) {
// Only notify in production
if (!isProduction()) {
return;
}
const { score: scoreToken, leaderboard: leaderboardToken } = playerScore;
const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
const score = getScoreSaberScoreFromToken(scoreToken, leaderboard, scoreToken.leaderboardPlayerInfo.id);
const playerInfo = score.playerInfo;
// Not ranked
if (leaderboard.stars <= 0) {
return;
}
// Not #1 rank
if (score.rank !== 1) {
return;
}
const player = await scoresaberService.lookupPlayer(playerInfo.id);
if (!player) {
return;
}
await logToChannel(
DiscordChannels.numberOneFeed,
new EmbedBuilder()
.setTitle(`${player.name} just set a #1!`)
.setDescription(
[
`${leaderboard.songName} ${leaderboard.songSubName} (${leaderboard.difficulty.difficulty} ${leaderboard.stars.toFixed(2)}★)`,
`[[Player]](${Config.websiteUrl}/player/${player.id}) [[Leaderboard]](${Config.websiteUrl}/leaderboard/${leaderboard.id})`,
].join("\n")
)
.addFields([
{
name: "Accuracy",
value: `${score.accuracy.toFixed(2)}%`,
inline: true,
},
{
name: "PP",
value: `${formatPp(score.pp)}pp`,
inline: true,
},
{
name: "Player Rank",
value: `#${formatNumberWithCommas(player.rank)}`,
inline: true,
},
{
name: "Misses",
value: formatNumberWithCommas(score.missedNotes),
inline: true,
},
{
name: "Bad Cuts",
value: formatNumberWithCommas(score.badCuts),
inline: true,
},
{
name: "Max Combo",
value: formatNumberWithCommas(score.maxCombo),
inline: true,
},
])
.setThumbnail(leaderboard.songArt)
.setTimestamp(score.timestamp)
.setColor("#00ff00")
);
}
/**
* Updates the players set scores count for today.
*
* @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);
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 = playerId || scoreToken.leaderboardPlayerInfo.id;
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;
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
delete score.playerInfo;
await ScoreSaberScoreModel.create(score);
}
/**
* 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 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 scores for a player.
*
* @param leaderboardName the leaderboard to get the scores from
* @param playerId the players id
* @param page the page to get
* @param sort the sort type to use
* @param direction the direction to sort the scores
* @param search the search to use
* @returns the scores
*/
public static async getPlayerScores(
leaderboardName: Leaderboards,
playerId: string,
page: number,
sort: ScoreSortType,
direction: SortDirection,
search?: string
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
console.log(
`Fetching scores for ${playerId} on ${leaderboardName}, page: ${page}, sort: ${sort}, direction: ${direction}, search: ${search}`
);
return fetchWithCache(
playerScoresCache,
`player-scores-${leaderboardName}-${playerId}-${page}-${sort}-${search}`,
async () => {
const toReturn: PlayerScore<unknown, unknown>[] | undefined = [];
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
switch (leaderboardName) {
case "scoresaber": {
let isPlayerTracked = false;
try {
isPlayerTracked = (await PlayerService.getPlayer(playerId, false)) != undefined;
} catch {
/* ignored */
}
if (isPlayerTracked) {
const rawScores = ScoreSorters.scoreSaber.sort(
sort,
direction,
(await ScoreSaberScoreModel.find({ playerId: playerId }).exec()) as unknown as ScoreSaberScore[]
);
if (!rawScores || rawScores.length === 0) {
break;
}
const pagination = new Pagination<ScoreSaberScore>().setItemsPerPage(ITEMS_PER_PAGE).setItems(rawScores);
const paginatedPage = pagination.getPage(page);
metadata = paginatedPage.metadata;
for (const score of paginatedPage.items) {
const { leaderboard, beatsaver } = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
"scoresaber",
String(score.leaderboardId)
);
if (leaderboard == undefined) {
continue;
}
const additionalData = await this.getAdditionalScoreData(
playerId,
leaderboard.songHash,
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
score.score
);
if (additionalData == undefined) {
continue;
}
toReturn.push({
score: score,
leaderboard: leaderboard,
beatSaver: beatsaver,
});
}
} else {
// Convert the sort type
let scoreSaberSort: ScoreSort;
switch (sort) {
case ScoreSortType.date: {
scoreSaberSort = ScoreSort.recent;
break;
}
case ScoreSortType.pp: {
scoreSaberSort = ScoreSort.top;
break;
}
default: {
scoreSaberSort = ScoreSort.recent;
break;
}
}
const rawScores = await scoresaberService.lookupPlayerScores({
playerId: playerId,
page: page,
sort: scoreSaberSort,
search: search,
});
if (!rawScores || rawScores.playerScores.length === 0) {
break;
}
metadata = new Metadata(
Math.ceil(rawScores.metadata.total / rawScores.metadata.itemsPerPage),
rawScores.metadata.total,
rawScores.metadata.page,
rawScores.metadata.itemsPerPage
);
for (const token of rawScores.playerScores) {
const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
if (leaderboard == undefined) {
continue;
}
const score = getScoreSaberScoreFromToken(token.score, leaderboard, playerId);
if (score == undefined) {
continue;
}
const additionalData = await this.getAdditionalScoreData(
playerId,
leaderboard.songHash,
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
score.score
);
if (additionalData !== undefined) {
score.additionalData = additionalData;
}
toReturn.push({
score: score,
leaderboard: leaderboard,
beatSaver: await BeatSaverService.getMap(leaderboard.songHash),
});
}
}
break;
}
default: {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
}
console.log(metadata);
return {
scores: toReturn,
metadata: metadata,
};
}
);
}
/**
* Gets scores for a leaderboard.
*
* @param leaderboardName the leaderboard to get the scores from
* @param leaderboardId the leaderboard id
* @param page the page to get
* @returns the scores
*/
public static async getLeaderboardScores(
leaderboardName: Leaderboards,
leaderboardId: string,
page: number
): Promise<LeaderboardScoresResponse<unknown, unknown> | undefined> {
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,
leaderboardId
);
if (leaderboardResponse == undefined) {
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
}
leaderboard = leaderboardResponse.leaderboard;
beatSaverMap = leaderboardResponse.beatsaver;
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`);
}
}
return {
scores: scores,
leaderboard: leaderboard,
beatSaver: beatSaverMap,
metadata: metadata,
};
}
);
}
}

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"types": ["bun-types"],
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"jsx": "react",
},
}

@ -0,0 +1,2 @@
node_modules
dist

@ -0,0 +1,27 @@
{
"name": "@ssr/common",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsc --watch --preserveWatchOutput",
"build": "tsc"
},
"exports": {
"./*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js",
"require": "./dist/*.js",
"default": "./dist/*.js"
}
},
"devDependencies": {
"@types/node": "^22.7.4",
"typescript": "^5"
},
"dependencies": {
"@typegoose/auto-increment": "^4.7.0",
"@typegoose/typegoose": "^12.8.0",
"ky": "^1.7.2",
"ws": "^8.18.0"
}
}

@ -0,0 +1,129 @@
type CacheOptions = {
/**
* The time the cached object will be valid for
*/
ttl?: number;
/**
* How often to check for expired objects
*/
checkInterval?: number;
/**
* Enable debug messages
*/
debug?: boolean;
};
type CachedObject = {
/**
* The cached object
*/
value: any;
/**
* The timestamp the object was cached
*/
timestamp: number;
};
export class SSRCache {
/**
* The time the cached object will be valid for
* @private
*/
private readonly ttl: number | undefined;
/**
* How often to check for expired objects
* @private
*/
private readonly checkInterval: number | undefined;
/**
* Enable debug messages
* @private
*/
private readonly debug: boolean;
/**
* The objects that have been cached
* @private
*/
private cache = new Map<string, CachedObject>();
constructor({ ttl, checkInterval, debug }: CacheOptions) {
this.ttl = ttl;
this.checkInterval = checkInterval || this.ttl ? 1000 * 60 : undefined; // 1 minute
this.debug = debug || false;
if (this.ttl !== undefined && this.checkInterval !== undefined) {
setInterval(() => {
for (const [key, value] of this.cache.entries()) {
if (value.timestamp + this.ttl! > Date.now()) {
continue;
}
this.remove(key);
}
}, this.checkInterval);
}
}
/**
* Gets an object from the cache
*
* @param key the cache key for the object
*/
public get<T>(key: string): T | undefined {
const cachedObject = this.cache.get(key);
if (cachedObject === undefined) {
if (this.debug) {
console.log(`Cache miss for key: ${key}`);
}
return undefined;
}
if (this.debug) {
console.log(`Retrieved ${key} from cache, value: ${JSON.stringify(cachedObject)}`);
}
return cachedObject.value as T;
}
/**
* Sets an object in the cache
*
* @param key the cache key
* @param value the object
*/
public set<T>(key: string, value: T): void {
this.cache.set(key, {
value,
timestamp: Date.now(),
});
if (this.debug) {
console.log(`Inserted ${key} into cache, value: ${JSON.stringify(value)}`);
}
}
/**
* Checks if an object is in the cache
*
* @param key the cache key
*/
public has(key: string): boolean {
return this.cache.has(key);
}
/**
* Removes an object from the cache
*
* @param key the cache key
*/
public remove(key: string): void {
this.cache.delete(key);
if (this.debug) {
console.log(`Removed ${key} from cache`);
}
}
}

@ -0,0 +1,13 @@
export const Config = {
/**
* All projects
*/
websiteUrl: process.env.NEXT_PUBLIC_SITE_URL || "https://ssr.fascinated.cc",
apiUrl: process.env.NEXT_PUBLIC_SITE_API || "https://ssr.fascinated.cc/api",
/**
* Backend
*/
mongoUri: process.env.MONGO_URI,
discordBotToken: process.env.DISCORD_BOT_TOKEN,
} as const;

@ -0,0 +1,5 @@
const Leaderboards = {
SCORESABER: "scoresaber",
} as const;
export type Leaderboards = (typeof Leaderboards)[keyof typeof Leaderboards];

@ -0,0 +1,29 @@
import Leaderboard from "../leaderboard";
import { LeaderboardStatus } from "../leaderboard-status";
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;
/**
* Whether this leaderboard is qualified to be ranked.
*/
readonly qualified: boolean;
/**
* The status of the map.
*/
readonly status: LeaderboardStatus;
}

@ -0,0 +1,24 @@
import { MapDifficulty } from "../score/map-difficulty";
import { MapCharacteristic } from "../types/map-characteristic";
export default interface LeaderboardDifficulty {
/**
* The id of the leaderboard.
*/
leaderboardId: number;
/**
* The difficulty of the leaderboard.
*/
difficulty: MapDifficulty;
/**
* The characteristic of the leaderboard.
*/
characteristic: MapCharacteristic;
/**
* The raw difficulty of the leaderboard.
*/
difficultyRaw: string;
}

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

@ -0,0 +1,75 @@
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;
}

@ -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);

@ -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;
}

@ -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;
}

@ -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;
}
}

@ -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;
}
}

@ -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;
}
}

@ -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;
}
}

@ -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);

@ -0,0 +1,113 @@
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
import { Document } from "mongoose";
import { PlayerHistory } from "../player/player-history";
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../utils/time-utils";
/**
* The model for a player.
*/
@modelOptions({ options: { allowMixed: Severity.ALLOW } })
export class Player {
/**
* The id of the player.
*/
@prop()
public _id!: string;
/**
* The player's statistic history.
*/
@prop()
private statisticHistory?: Record<string, PlayerHistory>;
/**
* The date the player was last tracked.
*/
@prop()
public lastTracked?: Date;
/**
* The date the player was first tracked.
*/
@prop()
public trackedSince?: Date;
/**
* Gets the player's statistic history.
*/
public getStatisticHistory(): Record<string, PlayerHistory> {
if (this.statisticHistory === undefined) {
this.statisticHistory = {};
}
return this.statisticHistory;
}
/**
* Gets the player's history for a specific date.
*
* @param date the date to get the history for.
*/
public getHistoryByDate(date: Date): PlayerHistory {
if (this.statisticHistory === undefined) {
this.statisticHistory = {};
}
return this.getStatisticHistory()[formatDateMinimal(getMidnightAlignedDate(date))] || {};
}
/**
* Gets the player's history for the previous X days.
*
* @param days the number of days to get the history for.
*/
public getHistoryPreviousDays(days: number): Record<string, PlayerHistory> {
const statisticHistory = this.getStatisticHistory();
const history: Record<string, PlayerHistory> = {};
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) {
history[date] = playerHistory;
}
}
return history;
}
/**
* Sets the player's statistic history.
*
* @param date the date to set it for.
* @param history the history to set.
*/
public setStatisticHistory(date: Date, history: PlayerHistory) {
if (this.statisticHistory === undefined) {
this.statisticHistory = {};
}
this.statisticHistory[formatDateMinimal(getMidnightAlignedDate(date))] = history;
}
/**
* Sorts the player's statistic history by
* date in descending order. (oldest to newest)
*/
public sortStatisticHistory() {
if (this.statisticHistory === undefined) {
this.statisticHistory = {};
}
this.statisticHistory = Object.fromEntries(
Object.entries(this.statisticHistory).sort((a, b) => new Date(b[0]).getTime() - new Date(a[0]).getTime())
);
}
/**
* Gets the number of days tracked.
*
* @returns the number of days tracked.
*/
public getDaysTracked(): number {
return Object.keys(this.getStatisticHistory()).length;
}
}
export type PlayerDocument = Player & Document;
export const PlayerModel: ReturnModelType<typeof Player> = getModelForClass(Player);

@ -0,0 +1,73 @@
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";
@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 })
public readonly 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;
}
class ScoreSaberScorePublic extends ScoreSaberScoreInternal {
/**
* The player who set the score.
*/
public playerInfo!: ScoreSaberLeaderboardPlayerInfoToken;
}
export type ScoreSaberScore = InstanceType<typeof ScoreSaberScorePublic>;
export type ScoreSaberScoreDocument = ScoreSaberScore & Document;
export const ScoreSaberScoreModel: ReturnModelType<typeof ScoreSaberScoreInternal> =
getModelForClass(ScoreSaberScoreInternal);

@ -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()
private _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 })
public readonly timestamp!: Date;
}
export type ScoreType = InstanceType<typeof Score>;

@ -0,0 +1,73 @@
import { NotFoundError } from "backend/src/error/not-found-error";
import { Metadata } from "./types/metadata";
export class Pagination<T> {
/**
* The amount of items per page.
* @private
*/
private itemsPerPage: number = 0;
/**
* The amount of items in total.
* @private
*/
private totalItems: number = 0;
/**
* The items to paginate.
* @private
*/
private items: T[] = [];
/**
* 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;
}
/**
* Gets a page of items.
*
* @param page the page number to retrieve.
* @returns the page of items.
* @throws throws an error if the page number is invalid.
*/
getPage(page: number): Page<T> {
const totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
if (page < 1 || page > totalPages) {
throw new NotFoundError("Invalid page number");
}
const items = this.items.slice((page - 1) * this.itemsPerPage, page * this.itemsPerPage);
return new Page<T>(items, new Metadata(totalPages, this.totalItems, page, this.itemsPerPage));
}
}
class Page<T> {
readonly items: T[];
readonly metadata: Metadata;
constructor(items: T[], metadata: Metadata) {
this.items = items;
this.metadata = metadata;
}
}

@ -0,0 +1,145 @@
import Player, { StatisticChange } from "../player";
import { PlayerHistory } from "../player-history";
/**
* A ScoreSaber player.
*/
export default interface ScoreSaberPlayer extends Player {
/**
* The bio of the player.
*/
bio: ScoreSaberBio;
/**
* The amount of pp the player has.
*/
pp: number;
/**
* The change in pp compared to yesterday.
*/
statisticChange: StatisticChange | undefined;
/**
* The role the player has.
*/
role: string | undefined;
/**
* The badges the player has.
*/
badges: ScoreSaberBadge[];
/**
* The rank history for this player.
*/
statisticHistory: { [key: string]: PlayerHistory };
/**
* The statistics for this player.
*/
statistics: ScoreSaberPlayerStatistics;
/**
* The permissions the player has.
*/
permissions: number;
/**
* The pages for the players positions.
*/
rankPages: ScoreSaberRankPages;
/**
* Whether the player is banned or not.
*/
banned: boolean;
/**
* Whether the player is inactive or not.
*/
inactive: boolean;
/**
* Whether the player is having their
* statistics being tracked or not.
*/
isBeingTracked?: boolean;
}
/**
* A bio of a player.
*/
export type ScoreSaberBio = {
/**
* The lines of the bio including any html tags.
*/
lines: string[];
/**
* The lines of the bio stripped of all html tags.
*/
linesStripped: string[];
};
/**
* A badge for a player.
*/
export type ScoreSaberBadge = {
/**
* The URL to the badge.
*/
url: string;
/**
* The description of the badge.
*/
description: string;
};
/**
* The statistics for a player.
*/
export type ScoreSaberPlayerStatistics = {
/**
* The total amount of score accumulated over all scores.
*/
totalScore: number;
/**
* The total amount of ranked score accumulated over all scores.
*/
totalRankedScore: number;
/**
* The average ranked accuracy for all ranked scores.
*/
averageRankedAccuracy: number;
/**
* The total amount of scores set.
*/
totalPlayCount: number;
/**
* The total amount of ranked score set.
*/
rankedPlayCount: number;
/**
* The amount of times their replays were watched.
*/
replaysWatched: number;
};
export type ScoreSaberRankPages = {
/**
* Their page for their global rank position.
*/
global: number;
/**
* Their page for their country rank position.
*/
country: number;
};

@ -0,0 +1,71 @@
export interface PlayerHistory {
/**
* The player's rank.
*/
rank?: number;
/**
* The player's country rank.
*/
countryRank?: number;
/**
* The pp of the player.
*/
pp?: number;
/**
* 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?: {
/**
* The amount of score set.
*/
rankedScores?: number;
/**
* The amount of unranked scores set.
*/
unrankedScores?: number;
/**
* The total amount of ranked scores
*/
totalRankedScores?: number;
/**
* The total amount of scores
*/
totalScores?: number;
};
/**
* The player's accuracy stats.
*/
accuracy?: {
/**
* The player's average ranked accuracy.
*/
averageRankedAccuracy?: number;
};
}

@ -0,0 +1,64 @@
import ScoreSaberPlayer from "./impl/scoresaber-player";
import { ChangeRange } from "./player";
export type PlayerStatValue = {
/**
* The type of the stat.
*/
type: string;
/**
* The value of the stat.
*/
value: (player: ScoreSaberPlayer, range: ChangeRange) => 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,
},
};

@ -0,0 +1,11 @@
export interface PlayerTrackedSince {
/**
* Whether the player statistics are being tracked
*/
tracked: boolean;
/**
* The amount of days the player has been tracked
*/
daysTracked?: number;
}

@ -0,0 +1,61 @@
import { PlayerHistory } from "./player-history";
export default class Player {
/**
* The ID of this player.
*/
id: string;
/**
* The name of this player.
*/
name: string;
/**
* The avatar url for this player.
*/
avatar: string;
/**
* The country of this player.
*/
country: string;
/**
* The rank of the player.
*/
rank: number;
/**
* The rank the player has in their country.
*/
countryRank: number;
/**
* The date the player joined the playform.
*/
joinedDate: Date;
constructor(
id: string,
name: string,
avatar: string,
country: string,
rank: number,
countryRank: number,
joinedDate: Date
) {
this.id = id;
this.name = name;
this.avatar = avatar;
this.country = country;
this.rank = rank;
this.countryRank = countryRank;
this.joinedDate = joinedDate;
}
}
export type ChangeRange = "daily" | "weekly" | "monthly";
export type StatisticChange = {
[key in ChangeRange]: PlayerHistory;
};

@ -0,0 +1,8 @@
import ScoreSaberPlayerToken from "../types/token/scoresaber/score-saber-player-token";
export type AroundPlayerResponse = {
/**
* The players around the player.
*/
players: ScoreSaberPlayerToken[];
};

@ -0,0 +1,13 @@
import { BeatSaverMap } from "../model/beatsaver/map";
export type LeaderboardResponse<L> = {
/**
* The leaderboard.
*/
leaderboard: L;
/**
* The beatsaver map.
*/
beatsaver?: BeatSaverMap;
};

@ -0,0 +1,24 @@
import { Metadata } from "../types/metadata";
import { BeatSaverMap } from "../model/beatsaver/map";
export default interface LeaderboardScoresResponse<S, L> {
/**
* The scores that were set.
*/
readonly scores: S[];
/**
* The leaderboard that was used.
*/
readonly leaderboard: L;
/**
* The beatsaver map for the song.
*/
readonly beatSaver?: BeatSaverMap;
/**
* The pagination metadata.
*/
readonly metadata: Metadata;
}

@ -0,0 +1,14 @@
import { Metadata } from "../types/metadata";
import { PlayerScore } from "../score/player-score";
export default interface PlayerScoresResponse<S, L> {
/**
* The scores that were set.
*/
readonly scores: PlayerScore<S, L>[];
/**
* The pagination metadata.
*/
readonly metadata: Metadata;
}

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

@ -0,0 +1,19 @@
/**
* The score modifiers.
*/
export enum Modifier {
NF = "No Fail",
PM = "Pro Mode",
FS = "Faster Song",
SF = "Super Fast Song",
SS = "Slower Song",
GN = "Ghost Notes",
DA = "Disappearing Arrows",
SA = "Strict Angles",
SC = "Small Notes",
IF = "One Life",
NO = "No Obstacles",
BE = "Battery Energy",
NB = "No Bombs",
NA = "No Arrows",
}

@ -0,0 +1,6 @@
export default interface PlayerLeaderboardScore<S> {
/**
* The score that was set.
*/
readonly score: S;
}

@ -0,0 +1,18 @@
import { BeatSaverMap } from "../model/beatsaver/map";
export interface PlayerScore<S, L> {
/**
* The score.
*/
readonly score: S;
/**
* The leaderboard the score was set on.
*/
readonly leaderboard: L;
/**
* The BeatSaver of the song.
*/
readonly beatSaver?: BeatSaverMap;
}

@ -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();

@ -0,0 +1,33 @@
import Service from "../service";
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`;
class BeatSaverService extends Service {
constructor() {
super("BeatSaver");
}
/**
* Gets the map that match the query.
*
* @param query the query to search for
* @returns the map that match the query, or undefined if no map were found
*/
async lookupMap(query: string): Promise<BeatSaverMapToken | undefined> {
const before = performance.now();
this.log(`Looking up map "${query}"...`);
const response = await this.fetch<BeatSaverMapToken>(LOOKUP_MAP_BY_HASH_ENDPOINT.replace(":query", query));
// Map not found
if (response == undefined) {
return undefined;
}
this.log(`Found map "${response.id}" in ${(performance.now() - before).toFixed(0)}ms`);
return response;
}
}
export const beatsaverService = new BeatSaverService();

@ -0,0 +1,299 @@
import Service from "../service";
import { ScoreSaberPlayerSearchToken } from "../../types/token/scoresaber/score-saber-player-search-token";
import ScoreSaberPlayerToken from "../../types/token/scoresaber/score-saber-player-token";
import { ScoreSaberPlayersPageToken } from "../../types/token/scoresaber/score-saber-players-page-token";
import { ScoreSort } from "../../score/score-sort";
import ScoreSaberPlayerScoresPageToken from "../../types/token/scoresaber/score-saber-player-scores-page-token";
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 { SSRCache } from "../../cache";
const API_BASE = "https://scoresaber.com/api";
/**
* Player
*/
const SEARCH_PLAYERS_ENDPOINT = `${API_BASE}/players?search=:query`;
const LOOKUP_PLAYER_ENDPOINT = `${API_BASE}/player/:id/full`;
const LOOKUP_PLAYERS_ENDPOINT = `${API_BASE}/players?page=:page`;
const LOOKUP_PLAYERS_BY_COUNTRY_ENDPOINT = `${API_BASE}/players?page=:page&countries=:country`;
const LOOKUP_PLAYER_SCORES_ENDPOINT = `${API_BASE}/player/:id/scores?limit=:limit&sort=:sort&page=:page`;
/**
* Leaderboard
*/
const LOOKUP_LEADERBOARD_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/info`;
const LOOKUP_LEADERBOARD_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/scores?page=:page`;
const STAR_MULTIPLIER = 42.117208413;
const playerCache = new SSRCache({
ttl: 60 * 30, // 30 minutes
});
class ScoreSaberService extends Service {
private curvePoints = [
new CurvePoint(0, 0),
new CurvePoint(0.6, 0.18223233667439062),
new CurvePoint(0.65, 0.5866010012767576),
new CurvePoint(0.7, 0.6125565959114954),
new CurvePoint(0.75, 0.6451808210101443),
new CurvePoint(0.8, 0.6872268862950283),
new CurvePoint(0.825, 0.7150465663454271),
new CurvePoint(0.85, 0.7462290664143185),
new CurvePoint(0.875, 0.7816934560296046),
new CurvePoint(0.9, 0.825756123560842),
new CurvePoint(0.91, 0.8488375988124467),
new CurvePoint(0.92, 0.8728710341448851),
new CurvePoint(0.93, 0.9039994071865736),
new CurvePoint(0.94, 0.9417362980580238),
new CurvePoint(0.95, 1),
new CurvePoint(0.955, 1.0388633331418984),
new CurvePoint(0.96, 1.0871883573850478),
new CurvePoint(0.965, 1.1552120359501035),
new CurvePoint(0.97, 1.2485807759957321),
new CurvePoint(0.9725, 1.3090333065057616),
new CurvePoint(0.975, 1.3807102743105126),
new CurvePoint(0.9775, 1.4664726399289512),
new CurvePoint(0.98, 1.5702410055532239),
new CurvePoint(0.9825, 1.697536248647543),
new CurvePoint(0.985, 1.8563887693647105),
new CurvePoint(0.9875, 2.058947159052738),
new CurvePoint(0.99, 2.324506282149922),
new CurvePoint(0.99125, 2.4902905794106913),
new CurvePoint(0.9925, 2.685667856592722),
new CurvePoint(0.99375, 2.9190155639254955),
new CurvePoint(0.995, 3.2022017597337955),
new CurvePoint(0.99625, 3.5526145337555373),
new CurvePoint(0.9975, 3.996793606763322),
new CurvePoint(0.99825, 4.325027383589547),
new CurvePoint(0.999, 4.715470646416203),
new CurvePoint(0.9995, 5.019543595874787),
new CurvePoint(1, 5.367394282890631),
];
constructor() {
super("ScoreSaber");
}
/**
* Gets the players that match the query.
*
* @param query the query to search for
* @returns the players that match the query, or undefined if no players were found
*/
public async searchPlayers(query: string): Promise<ScoreSaberPlayerSearchToken | undefined> {
const before = performance.now();
this.log(`Searching for players matching "${query}"...`);
const results = await this.fetch<ScoreSaberPlayerSearchToken>(SEARCH_PLAYERS_ENDPOINT.replace(":query", query));
if (results === undefined) {
return undefined;
}
if (results.players.length === 0) {
return undefined;
}
results.players.sort((a, b) => a.rank - b.rank);
this.log(`Found ${results.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
return results;
}
/**
* Looks up a player by their ID.
*
* @param playerId the ID of the player to look up
* @param cache whether to use the local cache
* @returns the player that matches the ID, or undefined
*/
public async lookupPlayer(playerId: string, cache: boolean = false): Promise<ScoreSaberPlayerToken | undefined> {
if (cache && playerCache.has(playerId)) {
return playerCache.get(playerId);
}
const before = performance.now();
this.log(`Looking up player "${playerId}"...`);
const token = await this.fetch<ScoreSaberPlayerToken>(LOOKUP_PLAYER_ENDPOINT.replace(":id", playerId));
if (token === undefined) {
return undefined;
}
this.log(`Found player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`);
if (cache) {
playerCache.set(playerId, token);
}
return token;
}
/**
* Lookup players on a specific page
*
* @param page the page to get players for
* @returns the players on the page, or undefined
*/
public async lookupPlayers(page: number): Promise<ScoreSaberPlayersPageToken | undefined> {
const before = performance.now();
this.log(`Looking up players on page "${page}"...`);
const response = await this.fetch<ScoreSaberPlayersPageToken>(
LOOKUP_PLAYERS_ENDPOINT.replace(":page", page.toString())
);
if (response === undefined) {
return undefined;
}
this.log(`Found ${response.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
return response;
}
/**
* Lookup players on a specific page and country
*
* @param page the page to get players for
* @param country the country to get players for
* @returns the players on the page, or undefined
*/
public async lookupPlayersByCountry(page: number, country: string): Promise<ScoreSaberPlayersPageToken | undefined> {
const before = performance.now();
this.log(`Looking up players on page "${page}" for country "${country}"...`);
const response = await this.fetch<ScoreSaberPlayersPageToken>(
LOOKUP_PLAYERS_BY_COUNTRY_ENDPOINT.replace(":page", page.toString()).replace(":country", country)
);
if (response === undefined) {
return undefined;
}
this.log(`Found ${response.players.length} players in ${(performance.now() - before).toFixed(0)}ms`);
return response;
}
/**
* Looks up a page of scores for a player
*
* @param playerId the ID of the player to look up
* @param sort the sort to use
* @param page the page to get scores for
* @param limit the amount of scores to fetch
* @param search
* @returns the scores of the player, or undefined
*/
public async lookupPlayerScores({
playerId,
sort,
page,
limit = 8,
search,
}: {
playerId: string;
sort: ScoreSort;
page: number;
limit?: number;
search?: string;
useProxy?: boolean;
}): Promise<ScoreSaberPlayerScoresPageToken | undefined> {
const before = performance.now();
this.log(
`Looking up scores for player "${playerId}", sort "${sort}", page "${page}"${search ? `, search "${search}"` : ""}...`
);
const response = await this.fetch<ScoreSaberPlayerScoresPageToken>(
LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId)
.replace(":limit", limit + "")
.replace(":sort", sort)
.replace(":page", page + "") + (search ? `&search=${search}` : "")
);
if (response === undefined) {
return undefined;
}
this.log(
`Found ${response.playerScores.length} scores for player "${playerId}" in ${(performance.now() - before).toFixed(0)}ms`
);
return response;
}
/**
* Looks up a leaderboard
*
* @param leaderboardId the ID of the leaderboard to look up
*/
public async lookupLeaderboard(leaderboardId: string): Promise<ScoreSaberLeaderboardToken | undefined> {
const before = performance.now();
this.log(`Looking up leaderboard "${leaderboardId}"...`);
const response = await this.fetch<ScoreSaberLeaderboardToken>(
LOOKUP_LEADERBOARD_ENDPOINT.replace(":id", leaderboardId)
);
if (response === undefined) {
return undefined;
}
this.log(`Found leaderboard "${leaderboardId}" in ${(performance.now() - before).toFixed(0)}ms`);
return response;
}
/**
* Looks up a page of scores for a leaderboard
*
* @param leaderboardId the ID of the leaderboard to look up
* @param page the page to get scores for
* @returns the scores of the leaderboard, or undefined
*/
public async lookupLeaderboardScores(
leaderboardId: string,
page: number
): Promise<ScoreSaberLeaderboardScoresPageToken | undefined> {
const before = performance.now();
this.log(`Looking up scores for leaderboard "${leaderboardId}", page "${page}"...`);
const response = await this.fetch<ScoreSaberLeaderboardScoresPageToken>(
LOOKUP_LEADERBOARD_SCORES_ENDPOINT.replace(":id", leaderboardId).replace(":page", page.toString())
);
if (response === undefined) {
return undefined;
}
this.log(
`Found ${response.scores.length} scores for leaderboard "${leaderboardId}" in ${(performance.now() - before).toFixed(0)}ms`
);
return response;
}
/**
* Gets the modifier for the given accuracy.
*
* @param accuracy The accuracy.
* @return The modifier.
*/
public getModifier(accuracy: number): number {
accuracy = clamp(accuracy, 0, 100) / 100; // Normalize accuracy to a range of [0, 1]
if (accuracy <= 0) {
return 0;
}
if (accuracy >= 1) {
return this.curvePoints[this.curvePoints.length - 1].getMultiplier();
}
for (let i = 0; i < this.curvePoints.length - 1; i++) {
const point = this.curvePoints[i];
const nextPoint = this.curvePoints[i + 1];
if (accuracy >= point.getAcc() && accuracy <= nextPoint.getAcc()) {
return lerp(
point.getMultiplier(),
nextPoint.getMultiplier(),
(accuracy - point.getAcc()) / (nextPoint.getAcc() - point.getAcc())
);
}
}
return 0;
}
/**
* Gets the performance points (PP) based on stars and accuracy.
*
* @param stars The star count.
* @param accuracy The accuracy.
* @returns The calculated PP.
*/
public getPp(stars: number, accuracy: number): number {
if (accuracy <= 1) {
accuracy *= 100; // Convert the accuracy to a percentage
}
const pp = stars * STAR_MULTIPLIER; // Calculate base PP value
return this.getModifier(accuracy) * pp; // Calculate and return final PP value
}
}
export const scoresaberService = new ScoreSaberService();

@ -1,10 +1,11 @@
import ky from "ky";
import { isServer } from "../utils/utils";
export default class DataFetcher {
export default class Service {
/**
* The name of the leaderboard.
* The name of the service.
*/
private name: string;
private readonly name: string;
constructor(name: string) {
this.name = name;
@ -33,21 +34,17 @@ export default class DataFetcher {
/**
* Fetches data from the given url.
*
* @param useProxy whether to use proxy or not
* @param url the url to fetch
* @returns the fetched data
*/
public async fetch<T>(useProxy: boolean, url: string): Promise<T | undefined> {
public async fetch<T>(url: string): Promise<T | undefined> {
try {
return await ky
.get<T>(this.buildRequestUrl(useProxy, url), {
next: {
revalidate: 60, // 1 minute
},
})
.json();
const response = await ky.get<T>(this.buildRequestUrl(!isServer(), url));
if (response.headers.has("X-RateLimit-Remaining")) {
this.log(`Rate limit remaining: ${response.headers.get("X-RateLimit-Remaining")}`);
}
return response.json();
} catch (error) {
console.error(`Error fetching data from ${url}:`, error);
return undefined;
}
}

@ -0,0 +1,69 @@
import { ScoreSorter } from "../score-sorter";
import { ScoreSaberScore } from "../../model/score/impl/scoresaber-score";
import { ScoreSortType } from "../sort-type";
import { SortDirection } from "../sort-direction";
export class ScoreSaberScoreSorter extends ScoreSorter<ScoreSaberScore> {
sort(type: ScoreSortType, direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
switch (type) {
case ScoreSortType.date:
return this.sortRecent(direction, items);
case ScoreSortType.pp:
return this.sortPp(direction, items);
case ScoreSortType.accuracy:
return this.sortAccuracy(direction, items);
case ScoreSortType.misses:
return this.sortMisses(direction, items);
default:
return items;
}
}
/**
* Sorts the scores by the time they were set.
*
* @param direction the direction to sort the scores
* @param items the scores to sort
* @returns the sorted scores
*/
sortRecent(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
return items.sort((a, b) =>
direction === SortDirection.ASC
? a.timestamp.getTime() - b.timestamp.getTime()
: b.timestamp.getTime() - a.timestamp.getTime()
);
}
/**
* Sorts the scores by their pp value
*
* @param direction the direction to sort the scores
* @param items the scores to sort
* @returns the sorted scores
*/
sortPp(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
return items.sort((a, b) => (direction === SortDirection.ASC ? a.pp - b.pp : b.pp - a.pp));
}
/**
* Sorts the scores by their accuracy value
*
* @param direction the direction to sort the scores
* @param items the scores to sort
* @returns the sorted scores
*/
sortAccuracy(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
return items.sort((a, b) => (direction === SortDirection.ASC ? a.accuracy - b.accuracy : b.accuracy - a.accuracy));
}
/**
* Sorts the scores by their misses
*
* @param direction the direction to sort the scores
* @param items the scores to sort
* @returns the sorted scores
*/
sortMisses(direction: SortDirection, items: ScoreSaberScore[]): ScoreSaberScore[] {
return items.sort((a, b) => (direction === SortDirection.ASC ? a.misses - b.misses : b.misses - a.misses));
}
}

@ -0,0 +1,14 @@
import { ScoreSortType } from "./sort-type";
import { SortDirection } from "./sort-direction";
export abstract class ScoreSorter<T> {
/**
* Sorts the items
*
* @param type the type of sort
* @param direction the direction of the sort
* @param items the items to sort
* @returns the sorted items
*/
public abstract sort(type: ScoreSortType, direction: SortDirection, items: T[]): T[];
}

@ -0,0 +1,4 @@
export enum SortDirection {
ASC = "asc",
DESC = "desc",
}

@ -0,0 +1,6 @@
export enum ScoreSortType {
date = "date",
pp = "pp",
accuracy = "accuracy",
misses = "misses",
}

@ -0,0 +1,5 @@
import { ScoreSaberScoreSorter } from "./impl/scoresaber-sorter";
export const ScoreSorters = {
scoreSaber: new ScoreSaberScoreSorter(),
};

@ -0,0 +1,331 @@
import ScoreSaberLeaderboard from "./leaderboard/impl/scoresaber-leaderboard";
import ScoreSaberLeaderboardToken from "./types/token/scoresaber/score-saber-leaderboard-token";
import LeaderboardDifficulty from "./leaderboard/leaderboard-difficulty";
import { getDifficultyFromScoreSaberDifficulty } from "./utils/scoresaber-utils";
import { MapCharacteristic } from "./types/map-characteristic";
import { LeaderboardStatus } from "./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: (token.baseScore / leaderboard.maxScore) * 100,
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,
};
}

@ -0,0 +1 @@
export type AroundPlayer = "global" | "country";

@ -0,0 +1,21 @@
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;
};

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

@ -0,0 +1,28 @@
export class Metadata {
/**
* The amount of pages in the pagination
*/
public readonly totalPages: number;
/**
* The total amount of items
*/
public readonly totalItems: number;
/**
* The current page
*/
public readonly page: number;
/**
* The amount of items per page
*/
public readonly itemsPerPage: number;
constructor(totalPages: number, totalItems: number, page: number, itemsPerPage: number) {
this.totalPages = totalPages;
this.totalItems = totalItems;
this.page = page;
this.itemsPerPage = itemsPerPage;
}
}

@ -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;
};

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