Compare commits
420 Commits
04ce91b459
...
renovate/l
Author | SHA1 | Date | |
---|---|---|---|
59699f05f8 | |||
ad568ddf5d | |||
df297d0c99 | |||
c8fb08b192 | |||
981bc13a1f | |||
8314cbcf2d | |||
f52b62ba83 | |||
0a5d42f6ac | |||
ce65116db4 | |||
6c81316364 | |||
b83fb6f3a8 | |||
c58f24103f | |||
e146d20f4f | |||
ffa4ab2b6c | |||
b889eee7ff | |||
28e8561020 | |||
de3768559f | |||
4be0b072b2 | |||
d086e922c4 | |||
96ab9be79a | |||
3357939071 | |||
f3737ce7a5 | |||
9626931b91 | |||
5ff0d11f5a | |||
5f4d3829e2 | |||
b3cd770724 | |||
c3a75b139a | |||
ba80b9623b | |||
e57e725639 | |||
f8b0f7c6cd | |||
0d39a905f6 | |||
7be8c37779 | |||
6bc2e09f43 | |||
da7f5f1c62 | |||
fe888d9fb6 | |||
a8eb2372cb | |||
e0c719eaba | |||
413d72182d | |||
d7929cc36a | |||
dd162bf77c | |||
5d7bdc17b1 | |||
ff287222f7 | |||
3abffec9cb | |||
f20d83a436 | |||
97fba47fd8 | |||
9fb5317bc8 | |||
7e1d172b43 | |||
da950e08f2 | |||
2b9a777506 | |||
90b0994524 | |||
53e0ce007d | |||
a421243973 | |||
b911072a47 | |||
59d5cdb2ae | |||
a9338393f5 | |||
6d0c6aa47f | |||
aaee96ad7b | |||
cd1f010698 | |||
1d9433ef02 | |||
9f4e3afffd | |||
4232add9c7 | |||
3b2a42a995 | |||
02083204f7 | |||
fc38aba6f4 | |||
781a3e8cdc | |||
20376070c3 | |||
42264ece64 | |||
2852e0c0ed | |||
0a87877373 | |||
b8f6829f71 | |||
44bc812ad8 | |||
d1d12b4193 | |||
d42c888e82 | |||
3c4406c4b7 | |||
2a681e6b32 | |||
90c57ad086 | |||
0731d20edc | |||
0d12e7c024 | |||
e403d1f241 | |||
b4bcf32a43 | |||
56b2f272b9 | |||
55b9f0e4ef | |||
1bc2b35ec0 | |||
ed4bcc93e1 | |||
de3dec22de | |||
4b5c2acad5 | |||
6e38f36945 | |||
584af8c5a4 | |||
0f68b2b69e | |||
33b931b5f1 | |||
62090b8054 | |||
f8e0326dec | |||
c09a50b8a2 | |||
55cbcb3d66 | |||
fd95f9414f | |||
05e10424ef | |||
d3ba6eedc4 | |||
6bbf628ab5 | |||
56ae9b717c | |||
08295d7b04 | |||
8090361615 | |||
299cf20cb9 | |||
ff9ff5b96b | |||
c3cf48e731 | |||
1befe6cc57 | |||
7b008d8e55 | |||
68e343083b | |||
989d66780d | |||
ca8fb41fab | |||
6a1b18581f | |||
a33c1b81b7 | |||
6495db7588 | |||
c3ab9851ab | |||
ef287d6c3c | |||
cf84ebe456 | |||
220cf31511 | |||
3f63225f16 | |||
50bc341c38 | |||
7b87188e98 | |||
75f79e34b7 | |||
2fc8b265d2 | |||
f090c0dcbb | |||
9c20aff89d | |||
36ab7eb4cf | |||
f3dee6a7d2 | |||
fa2ba83c7a | |||
074d4de123 | |||
854f88c43a | |||
15e6cb85c4 | |||
696da236d5 | |||
f89207f306 | |||
be25896c5e | |||
fbf5603866 | |||
d62b6524f7 | |||
de47905e28 | |||
9a621eea82 | |||
42e0c3a7b2 | |||
0b92cec911 | |||
5933074569 | |||
af8c87f5af | |||
173703664d | |||
077bb6d73b | |||
78e3ec43d7 | |||
b7349f0226 | |||
ad826d7a3f | |||
6baeab930d | |||
87b2c7c48a | |||
b9587feb9e | |||
fad22274fd | |||
577fcb0e0d | |||
c3d4d1fe1f | |||
a15893ea56 | |||
d0bcd29796 | |||
81640c3c4e | |||
06a13bedc8 | |||
bded9969fe | |||
cd2f8c0925 | |||
d1a9654e33 | |||
336518ff70 | |||
a68e53734d | |||
9d2a26fa07 | |||
a0dd1b4601 | |||
fcb84f820b | |||
d806907604 | |||
511f56af91 | |||
57a9780fe8 | |||
4d8debe333 | |||
899c3e11e6 | |||
8f617aca82 | |||
9b549f8dc6 | |||
1e8c38eb26 | |||
2df95d140a | |||
337331538a | |||
d3ce922f00 | |||
982202f813 | |||
a1148d0f59 | |||
0d182d3ff4 | |||
7465f854e0 | |||
670f2047a0 | |||
d2be3d833b | |||
16c34adc19 | |||
caf5f01a09 | |||
e0aeec5d5a | |||
79bdb801ff | |||
cec3541345 | |||
ac6aaee208 | |||
a773488e9b | |||
1f4be74c54 | |||
31f57cbe6b | |||
de05aceb9f | |||
4eb96da1f1 | |||
0931e52df5 | |||
37b491a0b5 | |||
a8c40f50d6 | |||
e1f5a13f57 | |||
c5bfdc8b9c | |||
c40b8b5d8e | |||
7421c47959 | |||
238ec6e254 | |||
a6576e9730 | |||
60ac8d17c5 | |||
8713ee3e02 | |||
3a734075e0 | |||
6c8ef89bb5 | |||
0317eae926 | |||
a636e7aa08 | |||
9fb276ec4e | |||
4a966344f2 | |||
dd8befa9e0 | |||
1350cdc0b1 | |||
c43f27a6ac | |||
a086bebc40 | |||
373a6355a6 | |||
0614b52745 | |||
c72230a98d | |||
73b7d17597 | |||
ba0a406eb4 | |||
ccf229ade4 | |||
d08f81b25d | |||
e37f0d5548 | |||
0231c6ccfe | |||
b3c124631a | |||
118dc9d9f1 | |||
7f5587546c | |||
64f918c325 | |||
82b0a0ee71 | |||
b8553c3138 | |||
f83492ffdc | |||
b5cfbf384a | |||
c64f046df3 | |||
42d133bbbb | |||
ae4e6912e5 | |||
5263509bac | |||
24a15f97d1 | |||
2367a03516 | |||
b5ae8a8ae0 | |||
8ab81b1b27 | |||
2e2c03241e | |||
1e8a9b9a59 | |||
cb7143ed3d | |||
1eed0e1e99 | |||
6d6e59ed13 | |||
3dcf03ce53 | |||
045f605cc6 | |||
ff9408fb8c | |||
7f42a27d8f | |||
ed21d3d780 | |||
7bacc30f33 | |||
74385252a4 | |||
6ee4c5b754 | |||
78c88acddf | |||
ee1c33bcc9 | |||
013d866391 | |||
9355f53ee5 | |||
a5e00e4850 | |||
3b691dae3c | |||
5998eac6f7 | |||
22abdab10b | |||
da7345b929 | |||
eb1d2899b9 | |||
a8af4a9d45 | |||
2af45b1508 | |||
f2ef170f01 | |||
970ab22e2f | |||
d56a85c342 | |||
f303794f5c | |||
6f88ab8f30 | |||
ef634194b8 | |||
005e05d8fb | |||
b803362360 | |||
80c1c95014 | |||
9d38e095fe | |||
5b3218c205 | |||
8133d18ca2 | |||
383f41f9ca | |||
5871b82f75 | |||
52e3ac9cec | |||
055e0869b8 | |||
04e0898b3c | |||
a6b99219e1 | |||
eb06801026 | |||
ac4298c765 | |||
a15f8f46f9 | |||
cdf9942924 | |||
67c1775edb | |||
4d27fe9bae | |||
aa4ef05b55 | |||
0f282fd003 | |||
c4b5bace5d | |||
0e3b2252a5 | |||
684ac4660e | |||
4cc5893757 | |||
ee212150fd | |||
2a61ed26a6 | |||
bc64e6ef3f | |||
0b8e693f80 | |||
17193fe18a | |||
97d7ab2d0a | |||
81fe9c3bb6 | |||
8c3ca26c9c | |||
ae2f30a97a | |||
b7783f5a4d | |||
783da27b1e | |||
1998049509 | |||
ba8579d60c | |||
6d1c911c9f | |||
e2d9a23974 | |||
b86fb3a609 | |||
cb9bc2143c | |||
5cc3cca2d7 | |||
3f2dd7ea90 | |||
5e3ab8435b | |||
1917a55725 | |||
20a0208e92 | |||
988d8cb17e | |||
c73f5c6373 | |||
98e8273c07 | |||
f26b997fbb | |||
e67fcf328e | |||
eb89987614 | |||
27c88cdb75 | |||
0ac70f4781 | |||
fd03e3d6c2 | |||
f8b97e3471 | |||
97a91d7249 | |||
7327b8d169 | |||
1d6647b74e | |||
8f62c6c694 | |||
afdbe0a3dc | |||
2ab3d6b023 | |||
6947c30c23 | |||
786bc69cf3 | |||
6ae69c2fec | |||
6cd141544c | |||
306269f1f9 | |||
9f6a58e325 | |||
d99feecc8f | |||
ad87365a66 | |||
d7a3b734ec | |||
ccedfa2645 | |||
a0681d7b1c | |||
d16cf9e4af | |||
29f9b305e7 | |||
dad8afe282 | |||
570f5e1eab | |||
b059ee3537 | |||
0e4feb4181 | |||
544e850540 | |||
4894e0597c | |||
e4f0376af3 | |||
e35c1c77d3 | |||
f649fb9c7f | |||
26e34c32f1 | |||
e89ff73b76 | |||
6b2f9fa308 | |||
130016957d | |||
19ab2a2e3d | |||
526167d4f1 | |||
f75897007c | |||
fc287be481 | |||
0f1c101acc | |||
bca3732f1c | |||
d5cc35da05 | |||
82116a7405 | |||
ee45e41d6d | |||
886ed4b20c | |||
ec40f1b564 | |||
6443bac879 | |||
6440001839 | |||
a69cf8a033 | |||
0293e50cee | |||
963f62d6a6 | |||
392f7c0db8 | |||
![]() |
179dee0702 | ||
2ebc04243c | |||
ee77a8f626 | |||
be28191005 | |||
5be22493fa | |||
598b6881e8 | |||
1faf896c08 | |||
d0e4e1553b | |||
![]() |
10c41d820e | ||
7eac8569f3 | |||
d5d5b2a36d | |||
25ffd37d0b | |||
4f0a42472c | |||
d58a6e0863 | |||
543429852b | |||
ee042fe91e | |||
6b8244fa48 | |||
094e030f11 | |||
580665b2f6 | |||
b2368dd1d3 | |||
a72b098dea | |||
c9e102d3d6 | |||
946b3c52dc | |||
104a08b0d9 | |||
cd09148acb | |||
8ec865d985 | |||
3b7b3b7e50 | |||
516402863c | |||
3d2904c6f0 | |||
046007af21 | |||
acd5dcd522 | |||
6ac3f485f3 | |||
045e811dbb | |||
e6600a8b48 | |||
b6831b4a50 | |||
e87d73bbdf | |||
935d4d5589 | |||
e0fca1168a | |||
30bdb07510 | |||
debe0f13a2 | |||
a83c05aa01 | |||
31aad41015 | |||
0d1cbf4c42 | |||
3adb895a87 | |||
aa0a0c4c16 | |||
f7e5ab8937 | |||
fa2091069a |
@ -26,3 +26,14 @@ spec:
|
||||
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
|
@ -24,3 +24,27 @@ spec:
|
||||
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
|
||||
|
17
.gitea/kubernetes/backend/sealed-secret.yaml
Normal file
17
.gitea/kubernetes/backend/sealed-secret.yaml
Normal file
@ -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
|
@ -25,30 +25,4 @@ spec:
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 1000m # 1 vCPU
|
||||
memory: 256Mi
|
||||
env:
|
||||
- name: MONGO_URI
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ssr-secret
|
||||
key: MONGO_URI
|
||||
- name: NEXT_PUBLIC_SITE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ssr-secret
|
||||
key: NEXT_PUBLIC_SITE_URL
|
||||
- name: NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ssr-secret
|
||||
key: NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY
|
||||
- name: TRIGGER_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ssr-secret
|
||||
key: TRIGGER_API_KEY
|
||||
- name: TRIGGER_API_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: ssr-secret
|
||||
key: TRIGGER_API_URL
|
||||
memory: 1024Mi
|
||||
|
@ -1,20 +0,0 @@
|
||||
---
|
||||
apiVersion: bitnami.com/v1alpha1
|
||||
kind: SealedSecret
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: ssr-secret
|
||||
namespace: public-services
|
||||
spec:
|
||||
encryptedData:
|
||||
MONGO_URI: AgBDDZhuphvpFZJZq31CA8OmiTr1J8p5Fy/rcr/zvm2nl64GnpbmVCuYgUiH6PwZPCUa9zcyUeZJO9/Xqe9PbJ8hA82j0Pb+Pcl1Rk3+B6jkDaEzcJKDmXS/zx8Q+JPWFOGVpRNy0HCKxm8azy88A9iyiKseIFsMWWrkJMkEObokRBCB4joD9Mh+aOsE2vaUkoE49ASxwVXU9MnL/34eksqGD6D5/BGpVZGftvY/x5eOuhULtK7z3tcd/orc//21AXUSAlVgWcekstEfZWQovk7Rwl67pgpHYf+KuegY4i+0ybge1qEngjvwt76yObTqfmhrdVQNfrV21FpTfoBeZS6ZoHdli6DBanPZgXJdKU2Ttr3C5EJ8c0Gir20J3wRs11SQ1gaKu6bxL4EH4kAtgdVoD5t6MSqvDzkfovAcJUHfXLA2HPhs1CEcu7Y6Kv/v+aGWSlo9jPVQg8JJ7IPF/+DDbF4JgEnwr34e7M5Z/CKVhwm7mK8Nr1yzgEhkucjZ6fcEVmt91fjx1usxDvtN+mllibc7HS2a/ObMDx3MtfHxXhTpt0wXyNyhXtnKNvKbICR5LGZfosF3viNfuRcEFTGvC2Ak9hlhVrznp0FRUiQJSNWsBQKZUKG5Yd5ckQwJUY8B/OLAjg0Keo26LIkciZ0jZD3JtK+bxU5LntdfSCwuO9+xHgaMgCxY+7plPQOgXL9BAOk9Zerc/6xQXJ7y4Q1PogrNaiPn6xCr368utFH2bA4zOAZCwrngnZtmT4pB6r6C/425JoZiQQw6qVQklpC7UhWpV1SbMvdGrlYgBW9TkT5rJPNIE3Bp3kA=
|
||||
NEXT_PUBLIC_SITE_URL: AgCpMUZ2MFY8mHgQ3fizTzcBImnwFmWzccRCtMAThI0cAIOcDe15Drk2a5a4UjcYgl1F+JrHB3b3IPbflr1E4dNAANKRgiGW+gyI2S7J/oDpb+ANCv/0RJIlfQh9Pcb/E4noKVOoUfe4dg5asq1kQjOob4uOn6MfQXoC5WfgK8u8q0T5tEPcuGxXt2Q1OnyAAWm/0Z7JSLfgQN2sKaAbRbWqKfwfsc4LgjxY98m/+BkXN7x6R7BJmXXMd0cb5ctdgM1ZpU+gYhhwyO0xsxYWURcJb9EsrNZR6OY4DbwXw2tpoagFxA20u5J2ZUhUeVRg2x2R5AdkL7OBIT73Xbh3WxIYVAqGDhs90aRrmlCdr61eBLCLtytC33LJ/6Odq2Pa9DLaKqRlqRX/IWk7+cgHOKfSd8/k5R1roA3A96ShFby9RdXGudGLA2G4dvLtrruLCYVRfxMJB2k3UYtGZB21o+3SAV0jx/83eoYzoBGHM6K8ySCpL1uDCo8ATL2iYJcacgYZGKaGxBumzEjAMBqTLBSUl0Jhx3mr59p6mrYKFtbewa9rJUOkNniYvdCeokLyVntxUMx60Jtrtg05G3vSFaP34Gp6Oq6J0jSzvYi/A3/iSe+cNB1fpNJvJVLRFmJ6f7qyMMoSujIoql5SfIhx/tyUHueiOFQ5KXKTeNhbu6byakY1ZHa2o03+Mooca2ATwUnlNNi73sKluFKhnRysANIiVoRZLDQniLwV
|
||||
NEXT_PUBLIC_TRIGGER_PUBLIC_API_KEY: AgCZwhhUNhSMwuR1pCP5qDY9fD99u78PFq89ej141pc/L/y/UCydLvftFKT62bXzIFhoq77dlU3yFx2FqbApdiDv3sDltZkIQh/afYwySPw3bXxoQoHcAix5qGhWrpDkPFDOi+sJkkPnnZC1OBncrqz8xAwfYAhwOscW9mjugRMJPynqSlnVHS1RdYm6z7eSJpZEMEHIT4tptPnzP+icRwbolgKL66JXFXvuS6SnTZ+ZOtub39L+wpWE9dQ83E5YqtWl3hci2G+rK9KBk89zuBM7Ho+MTpcdcaes64ApMqaUnFPelqJKSk6PK7mEX9DZhCUqNyCu897ktfHKulVZQ5Wy2+pVHXx9e1IBI7YqNph64CbX6N0V6ABfNlO2sS+zFG3dGuEGj/lI9hfSxqQauYOWXR7r8zM86WvNuxWuQFQbO4B1TDd8oofhZ+wwcUfJ0/pZIqyxcINB13opF107wa4MlfoCI6sgB4/adq/bbMP/JO10/GBiuJRhE63NhVJEZovJoRNV2+wBRNSVRfZpEQ9AXSACm1BtqOxhYhAmDnJt6ThF6VDWB2ZoDZfWul/kPUTUiOulGHmsRdn/bzTS8GjhY93G1/FpNmhNSOC8YbO3FDw8vXg2Vy6jpdKOhy08H9R/9UqbiHxnXPyBGyoizbnjP0sDx4jYYXtix03ZPFf6Dxz6iwwy5BbHpk9Ik+3l2iKI7IcxOOS9P8ljlsB0cCivpTax1iuDZ4hlJ7zm
|
||||
TRIGGER_API_KEY: AgARH8DdSu8INQ2OW6I4s2W+HZqHGZHn0i54l02Ui48Oph9koB6pfTvAkYspQ6LI2zh/R/uiAeOHorybTMZ9X0EEwk5GxTuXBUn4f5Ifpd2QkoHeDVWP6MA951PVanfPuXLklwKJm2O70oFKIVE61v52yZbk0L3wAOiYdRTj0igrSEDkmmc9iHorGdbDCI3CkZHpOMMl37zdIwCvbpHaCnSBpKEuQ0PmvRtAw9ydM3FhVpTxNVh3KhTgvGBBYwrGXOZuKOayLGvQ16pYmTSPoN6DNRFSLjmE/BOjwKnYfZU0C0qkpGPlNLSUteuLLvHtzlS8IOSboOspreQJMVaSRpg+Qp1/cV0XGEhmU/CWVTYqkNx5QtfgaxWllrKrQxNW0WMDJmnQI83scsAiweSFUffsfiX8BCMjHkD2nvlXCz6vzUcJ7Zn0bDPoHcv/uG7efZbsJXLie1PxQiGwFYpuyr0b7+A+RVgx0G/WNwKJIUjFC7acI7jY4dGE04zKe1STYhMhoc1gjKGhXe0BG73LAX/O5/x6W4iYUyc4n0HL7gLwlbpfR3zLkvuiiAtzFeKGRr+SF24mj95pfw+MPFoKEi9htLdPgHxTYomfQ+1I8R7Iya0sHtyW2fI/1e5XzJOMHub/tYh5y9h0UqE5n7ByapRMyj0mOrKXXPUoT4btQDz0U6aNRX+MrlwMsuXYjSfUCuXmy30RKQImmT+9vaukIq1CX7WJ2LQ8fHaYACnp
|
||||
TRIGGER_API_URL: AgAOwyGxQEScm5T3Hh1armqqcEcMEo0v5Mwf9JjEf3G+3svlDDPGHlyHdQolcC2YlkX7DhsenEp6rokh1grwyVoruyUc6OmRdRR70+PV5qMgSC3HY6lZ5f2gcGfA0uh9A5sm4qkOw4rliRddpJqKOqDz28zcrcu5RmusPxric+KF6Hcdy+ugqmq0KZl9VU2+D4z3QWkdokHk3WahdLneS4a3bHYC/NIpKyI5SveK6QAaQlU3NXrqKcof6VzDQG20bnCKGo+Y935LgzEIEmWKw2C9lwCV+/RUIjeaK2qzZpeMiZue9zgoq1dyNNjrar9B6zb+rSxcgnbqBolXUAVk1If3+egVNEaB9SjU22n+WoTA6HK1MOSwsaMtf1Tug/8nSQfFHdw1nZzBVtiVaFMtzmg0aKyrUpAYyz4XTn6xn9EhEKgcPSaWINf4zVcmceLOYenOP/y7S3cVx9KHBjUNGf/eDJVmXSiOzeguIJBfdEOla/lqv7Zx2/wvfHeEdurn5ENTkG2aQAekIvWiJ1HzPwrKKR6WcBpNTgjoDRMNxVoMcZ3QB9iJlp3AoLfJW72B2soTVeIikcNlT0Q0S91hiqvEcE+WuE5bDSttzhnb9nvEJXz6gC6AykCKH1VLIJJuiMI6R7V9z8fo7pFVXbQsM10VUph/9vxhib2XZ1c/25YMfj5vaI1+N7UiVDFlEfE2YJQMwd2vj+wTa3wHJz+2KXc/9rhBoaIpznN4LmKR6g==
|
||||
template:
|
||||
metadata:
|
||||
creationTimestamp: null
|
||||
name: ssr-secret
|
||||
namespace: public-services
|
||||
type: Opaque
|
@ -1,4 +1,4 @@
|
||||
name: "Deploy Backend"
|
||||
name: Deploy Backend
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@ -6,58 +6,93 @@ on:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- backend/**
|
||||
- common/**
|
||||
- .gitea/kubernetes/backend/**
|
||||
- projects/backend/**
|
||||
- projects/common/**
|
||||
- .gitea/workflows/deploy-backend.yml
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
docker:
|
||||
strategy:
|
||||
matrix:
|
||||
arch: ["ubuntu-latest"]
|
||||
runs-on: ${{ matrix.arch }}
|
||||
|
||||
# Steps to run
|
||||
steps:
|
||||
- name: Checkout code
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
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
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v6
|
||||
# Deploy to Dokku
|
||||
- name: Push to dokku
|
||||
uses: dokku/github-action@master
|
||||
with:
|
||||
context: .
|
||||
file: ./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 }}
|
||||
git_remote_url: "ssh://dokku@51.158.63.74:22/ssr-backend"
|
||||
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
|
||||
- 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/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 }}
|
||||
#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 }}
|
||||
|
@ -1,4 +1,4 @@
|
||||
name: "Deploy Website"
|
||||
name: Deploy Website
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@ -6,59 +6,92 @@ on:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- website/**
|
||||
- common/**
|
||||
- .gitea/kubernetes/website/**
|
||||
- projects/website/**
|
||||
- projects/common/**
|
||||
- .gitea/workflows/deploy-website.yml
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
docker:
|
||||
strategy:
|
||||
matrix:
|
||||
arch: ["ubuntu-latest"]
|
||||
runs-on: ${{ matrix.arch }}
|
||||
|
||||
# Steps to run
|
||||
steps:
|
||||
- name: Checkout code
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
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
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build Image
|
||||
uses: docker/build-push-action@v6
|
||||
# Deploy to Dokku
|
||||
- name: Push to dokku
|
||||
uses: dokku/github-action@master
|
||||
with:
|
||||
context: .
|
||||
file: ./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 }}
|
||||
git_remote_url: "ssh://dokku@51.158.63.74:22/ssr-website"
|
||||
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
|
||||
- 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/sealed-secrets.yaml
|
||||
.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 }}
|
||||
#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 }}
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -77,3 +77,5 @@ sketch
|
||||
.env*.local
|
||||
.vercel
|
||||
next-env.d.ts
|
||||
.idea
|
||||
secret.yaml
|
||||
|
@ -1,25 +0,0 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
project: 'tsconfig.json',
|
||||
tsconfigRootDir: __dirname,
|
||||
sourceType: 'module',
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
node: true,
|
||||
jest: true,
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/interface-name-prefix': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
};
|
@ -1,31 +0,0 @@
|
||||
FROM node:20-alpine3.17
|
||||
|
||||
# Install pnpm globally
|
||||
RUN npm install -g pnpm
|
||||
ENV PNPM_HOME=/usr/local/bin
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG GIT_REV
|
||||
ENV GIT_REV=${GIT_REV}
|
||||
|
||||
# Copy necessary files for installation
|
||||
COPY package.json* pnpm-lock.yaml* pnpm-workspace.yaml* ./
|
||||
COPY common ./common
|
||||
COPY backend ./backend
|
||||
|
||||
# Install all dependencies (for common and backend)
|
||||
RUN pnpm install
|
||||
|
||||
# Run in production mode
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Build the common workspace first, then the backend
|
||||
RUN pnpm --filter ...common build
|
||||
RUN pnpm --filter ...backend build
|
||||
|
||||
# Expose the port your application runs on
|
||||
EXPOSE 8080
|
||||
|
||||
# Command to run your app
|
||||
CMD ["node", "backend/dist/main.js"]
|
@ -1,8 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"author": "fascinated7",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nest start --watch --webpack webpack-hmr.config.js",
|
||||
"build": "nest build",
|
||||
"start": "nest start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/one-line-logger": "^2.0.0",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/platform-fastify": "^10.4.4",
|
||||
"@ssr/common": "workspace:*",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
||||
"@typescript-eslint/parser": "^8.0.0",
|
||||
"concurrently": "^9.0.1",
|
||||
"eslint": "^8.42.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"nodemon": "^2.0.20",
|
||||
"prettier": "^3.0.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsup": "^8.3.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AppController } from "./controller/app.controller";
|
||||
import { PlayerService } from "./service/player.service";
|
||||
import { PlayerController } from "./controller/player.controller";
|
||||
import { AppService } from "./service/app.service";
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController, PlayerController],
|
||||
providers: [AppService, PlayerService],
|
||||
})
|
||||
export class AppModule {}
|
@ -1,15 +0,0 @@
|
||||
import { Controller, Get } from "@nestjs/common";
|
||||
import { AppService } from "../service/app.service";
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get("/")
|
||||
getHome() {
|
||||
return {
|
||||
message: "ScoreSaber Reloaded API",
|
||||
version: this.appService.getVersion(),
|
||||
};
|
||||
}
|
||||
}
|
@ -1,12 +0,0 @@
|
||||
import { Controller, Get, Param } from "@nestjs/common";
|
||||
import { PlayerService } from "../service/player.service";
|
||||
|
||||
@Controller("/player")
|
||||
export class PlayerController {
|
||||
constructor(private readonly playerService: PlayerService) {}
|
||||
|
||||
@Get("/history/:id")
|
||||
getHistory(@Param("id") id: string) {
|
||||
return this.playerService.getHistory(id);
|
||||
}
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { FastifyAdapter, NestFastifyApplication } from "@nestjs/platform-fastify";
|
||||
import { AppModule } from "./app.module";
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create<NestFastifyApplication>(
|
||||
AppModule,
|
||||
new FastifyAdapter({
|
||||
logger: {
|
||||
transport: {
|
||||
target: "@fastify/one-line-logger",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
logger: ["error", "warn", "log"],
|
||||
}
|
||||
);
|
||||
await app.listen(8080, "0.0.0.0");
|
||||
}
|
||||
bootstrap();
|
@ -1,14 +0,0 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { isProduction } from "@ssr/common/dist";
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
/**
|
||||
* Gets the app version.
|
||||
*
|
||||
* @returns the app version
|
||||
*/
|
||||
getVersion(): string {
|
||||
return `1.0.0-${isProduction() ? process.env.GIT_REV.substring(0, 7) : "dev"}`;
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class PlayerService {
|
||||
/**
|
||||
* Gets the statistic history for the given player
|
||||
*
|
||||
* @param id the id of the player
|
||||
* @returns the players statistic history
|
||||
*/
|
||||
getHistory(id: string) {
|
||||
return {
|
||||
id: id,
|
||||
};
|
||||
}
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"]
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
module.exports = function (options) {
|
||||
return {
|
||||
...options,
|
||||
stats: "minimal", // This disables the full-screen mode and simplifies the output
|
||||
};
|
||||
};
|
@ -1,13 +0,0 @@
|
||||
{
|
||||
"name": "@ssr/common",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "tsup src/index.ts --watch",
|
||||
"build": "tsup src/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.7.4",
|
||||
"tsup": "^6.5.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
export * from "src/utils";
|
@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Checks if we're in production
|
||||
*/
|
||||
export function isProduction() {
|
||||
return process.env.NODE_ENV === "production";
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
dts: true, // This line enables type declaration file generation
|
||||
});
|
22
package.json
22
package.json
@ -1,15 +1,19 @@
|
||||
{
|
||||
"name": "scoresaber-reloadedv3",
|
||||
"name": "scoresaber-reloaded",
|
||||
"version": "1.0.0",
|
||||
"workspaces": [
|
||||
"projects/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "pnpm --parallel --workspace-concurrency=4 run -r dev",
|
||||
|
||||
"build:website": "pnpm --filter website build",
|
||||
"build:backend": "pnpm --filter backend build",
|
||||
|
||||
"start:website": "pnpm --filter website start",
|
||||
"start:backend": "pnpm --filter backend start"
|
||||
"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"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"concurrently": "^9.0.1",
|
||||
"cross-env": "^7.0.3"
|
||||
}
|
||||
}
|
||||
|
10721
pnpm-lock.yaml
generated
10721
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,4 +0,0 @@
|
||||
packages:
|
||||
- "common"
|
||||
- "website"
|
||||
- "backend"
|
2
projects/backend/.env-example
Normal file
2
projects/backend/.env-example
Normal file
@ -0,0 +1,2 @@
|
||||
MONGO_URI=mongodb://localhost:27017
|
||||
API_URL=http://localhost:8080
|
20
projects/backend/.eslintrc.json
Normal file
20
projects/backend/.eslintrc.json
Normal file
@ -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
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
|
34
projects/backend/Dockerfile
Normal file
34
projects/backend/Dockerfile
Normal file
@ -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"]
|
9
projects/backend/README.md
Normal file
9
projects/backend/README.md
Normal file
@ -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.
|
20
projects/backend/components/globe-icon.tsx
Normal file
20
projects/backend/components/globe-icon.tsx
Normal file
@ -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>
|
||||
);
|
20
projects/backend/components/star-icon.tsx
Normal file
20
projects/backend/components/star-icon.tsx
Normal file
@ -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>
|
||||
);
|
39
projects/backend/package.json
Normal file
39
projects/backend/package.json
Normal file
@ -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"
|
||||
}
|
70
projects/backend/src/bot/bot.ts
Normal file
70
projects/backend/src/bot/bot.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { Client, MetadataStorage } from "discordx";
|
||||
import { ActivityType, EmbedBuilder } from "discord.js";
|
||||
import { Config } from "@ssr/common/config";
|
||||
|
||||
export const guildId = "1295984874942894100";
|
||||
export enum DiscordChannels {
|
||||
trackedPlayerLogs = "1295985197262569512",
|
||||
numberOneFeed = "1295988063817830430",
|
||||
backendLogs = "1296524935237468250",
|
||||
}
|
||||
|
||||
const client = new Client({
|
||||
intents: ["Guilds", "GuildMessages"],
|
||||
presence: {
|
||||
status: "online",
|
||||
|
||||
activities: [
|
||||
{
|
||||
name: "scores...",
|
||||
type: ActivityType.Watching,
|
||||
url: "https://ssr.fascinated.cc",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
client.once("ready", () => {
|
||||
console.log("Discord bot ready!");
|
||||
});
|
||||
|
||||
export async function initDiscordBot() {
|
||||
console.log("Initializing discord bot...");
|
||||
|
||||
// We will now build our application to load all the commands/events for both bots.
|
||||
MetadataStorage.instance.build().then(async () => {
|
||||
// Setup slash commands
|
||||
client.once("ready", async () => {
|
||||
await client.initApplicationCommands();
|
||||
console.log(client.applicationCommands);
|
||||
});
|
||||
client.on("interactionCreate", interaction => {
|
||||
client.executeInteraction(interaction);
|
||||
});
|
||||
|
||||
// Login
|
||||
await client.login(Config.discordBotToken!);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
try {
|
||||
const channel = await client.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] });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
import { Discord, Guild, Slash } from "discordx";
|
||||
import { CommandInteraction } from "discord.js";
|
||||
import { PlayerService } from "../../service/player.service";
|
||||
import { guildId } from "../bot";
|
||||
|
||||
@Discord()
|
||||
export class RefreshPlayerScoresCommand {
|
||||
@Guild(guildId)
|
||||
@Slash({
|
||||
description: "Refreshes scores for all tracked players",
|
||||
name: "refresh-player-scores",
|
||||
defaultMemberPermissions: ["Administrator"],
|
||||
})
|
||||
hello(interaction: CommandInteraction) {
|
||||
interaction.reply("Updating player scores...").then(async response => {
|
||||
await PlayerService.refreshPlayerScores();
|
||||
await response.edit("Done!");
|
||||
});
|
||||
}
|
||||
}
|
10
projects/backend/src/common/app.util.ts
Normal file
10
projects/backend/src/common/app.util.ts
Normal file
@ -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");
|
||||
}
|
35
projects/backend/src/common/cache.util.ts
Normal file
35
projects/backend/src/common/cache.util.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import { SSRCache } from "@ssr/common/cache";
|
||||
import { InternalServerError } from "@ssr/common/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;
|
||||
}
|
37
projects/backend/src/common/embds.ts
Normal file
37
projects/backend/src/common/embds.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||
import { DiscordChannels, logToChannel } from "../bot/bot";
|
||||
import { EmbedBuilder } from "discord.js";
|
||||
import { formatPp } from "@ssr/common/utils/number-utils";
|
||||
|
||||
/**
|
||||
* Logs that a new player is being tracked
|
||||
*
|
||||
* @param player the player being tracked
|
||||
*/
|
||||
export async function logNewTrackedPlayer(player: ScoreSaberPlayerToken) {
|
||||
await logToChannel(
|
||||
DiscordChannels.trackedPlayerLogs,
|
||||
new EmbedBuilder()
|
||||
.setTitle("New Player Tracked")
|
||||
.setDescription(`https://ssr.fascinated.cc/player/${player.id}`)
|
||||
.addFields([
|
||||
{
|
||||
name: "Username",
|
||||
value: player.name,
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: "ID",
|
||||
value: player.id,
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: "PP",
|
||||
value: formatPp(player.pp) + "pp",
|
||||
inline: true,
|
||||
},
|
||||
])
|
||||
.setThumbnail(player.profilePicture)
|
||||
.setColor("#00ff00")
|
||||
);
|
||||
}
|
26
projects/backend/src/controller/app.controller.ts
Normal file
26
projects/backend/src/controller/app.controller.ts
Normal file
@ -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();
|
||||
}
|
||||
}
|
36
projects/backend/src/controller/image.controller.ts
Normal file
36
projects/backend/src/controller/image.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
26
projects/backend/src/controller/leaderboard.controller.ts
Normal file
26
projects/backend/src/controller/leaderboard.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
80
projects/backend/src/controller/player.controller.ts
Normal file
80
projects/backend/src/controller/player.controller.ts
Normal file
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
87
projects/backend/src/controller/scores.controller.ts
Normal file
87
projects/backend/src/controller/scores.controller.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { Controller, Get } from "elysia-decorators";
|
||||
import { t } from "elysia";
|
||||
import { Leaderboards } from "@ssr/common/leaderboard";
|
||||
import { TopScoresResponse } from "@ssr/common/response/top-scores-response";
|
||||
import { ScoreService } from "../service/score.service";
|
||||
|
||||
@Controller("/scores")
|
||||
export default class ScoresController {
|
||||
@Get("/player/:leaderboard/:id/:page/:sort", {
|
||||
config: {},
|
||||
params: t.Object({
|
||||
leaderboard: t.String({ required: true }),
|
||||
id: t.String({ required: true }),
|
||||
page: t.Number({ required: true }),
|
||||
sort: t.String({ required: true }),
|
||||
}),
|
||||
query: t.Object({
|
||||
search: t.Optional(t.String()),
|
||||
}),
|
||||
})
|
||||
public async getScores({
|
||||
params: { leaderboard, id, page, sort },
|
||||
query: { search },
|
||||
}: {
|
||||
params: {
|
||||
leaderboard: Leaderboards;
|
||||
id: string;
|
||||
page: number;
|
||||
sort: string;
|
||||
};
|
||||
query: { search?: string };
|
||||
}): Promise<unknown> {
|
||||
return await ScoreService.getPlayerScores(leaderboard, id, page, sort, 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);
|
||||
}
|
||||
|
||||
@Get("/history/:playerId/:leaderboardId/:page", {
|
||||
config: {},
|
||||
params: t.Object({
|
||||
playerId: t.String({ required: true }),
|
||||
leaderboardId: t.String({ required: true }),
|
||||
page: t.Number({ required: true }),
|
||||
}),
|
||||
})
|
||||
public async getScoreHistory({
|
||||
params: { playerId, leaderboardId, page },
|
||||
}: {
|
||||
params: {
|
||||
playerId: string;
|
||||
leaderboardId: string;
|
||||
page: number;
|
||||
};
|
||||
query: { search?: string };
|
||||
}): Promise<unknown> {
|
||||
return (await ScoreService.getScoreHistory(playerId, leaderboardId, page)).toJSON();
|
||||
}
|
||||
|
||||
@Get("/top", {
|
||||
config: {},
|
||||
})
|
||||
public async getTopScores(): Promise<TopScoresResponse> {
|
||||
const scores = await ScoreService.getTopScores();
|
||||
return {
|
||||
scores,
|
||||
};
|
||||
}
|
||||
}
|
176
projects/backend/src/index.ts
Normal file
176
projects/backend/src/index.ts
Normal file
@ -0,0 +1,176 @@
|
||||
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 { 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 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";
|
||||
|
||||
// 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: "0 1 * * * *", // Every day at 00:01
|
||||
timezone: "Europe/London", // UTC time
|
||||
protect: true,
|
||||
run: async () => {
|
||||
await PlayerService.updatePlayerStatistics();
|
||||
},
|
||||
})
|
||||
);
|
||||
app.use(
|
||||
cron({
|
||||
name: "player-scores-tracker-cron",
|
||||
pattern: "0 4 * * *", // Every day at 04:00
|
||||
timezone: "Europe/London", // UTC time
|
||||
protect: true,
|
||||
run: async () => {
|
||||
await PlayerService.refreshPlayerScores();
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* 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(async () => {
|
||||
console.log("Listening on port http://localhost:8080");
|
||||
if (isProduction()) {
|
||||
await initDiscordBot();
|
||||
}
|
||||
});
|
||||
|
||||
app.listen({
|
||||
port: 8080,
|
||||
idleTimeout: 120, // 2 minutes
|
||||
});
|
38
projects/backend/src/service/app.service.ts
Normal file
38
projects/backend/src/service/app.service.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { PlayerModel } from "@ssr/common/model/player";
|
||||
import { AppStatistics } from "@ssr/common/types/backend/app-statistics";
|
||||
import { ScoreSaberScoreModel } from "@ssr/common/model/score/impl/scoresaber-score";
|
||||
import { AdditionalScoreDataModel } from "@ssr/common/model/additional-score-data/additional-score-data";
|
||||
import { BeatSaverMapModel } from "@ssr/common/model/beatsaver/map";
|
||||
import { ScoreSaberLeaderboardModel } from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
|
||||
import { SSRCache } from "@ssr/common/cache";
|
||||
|
||||
const statisticsCache = new SSRCache({
|
||||
ttl: 120 * 1000, // 2 minutes
|
||||
});
|
||||
|
||||
export class AppService {
|
||||
/**
|
||||
* Gets the app statistics.
|
||||
*/
|
||||
public static async getAppStatistics(): Promise<AppStatistics> {
|
||||
if (statisticsCache.has("app-statistics")) {
|
||||
return statisticsCache.get<AppStatistics>("app-statistics")!;
|
||||
}
|
||||
|
||||
const trackedPlayers = await PlayerModel.countDocuments();
|
||||
const trackedScores = await ScoreSaberScoreModel.countDocuments();
|
||||
const additionalScoresData = await AdditionalScoreDataModel.countDocuments();
|
||||
const cachedBeatSaverMaps = await BeatSaverMapModel.countDocuments();
|
||||
const cachedScoreSaberLeaderboards = await ScoreSaberLeaderboardModel.countDocuments();
|
||||
|
||||
const response = {
|
||||
trackedPlayers,
|
||||
trackedScores,
|
||||
additionalScoresData,
|
||||
cachedBeatSaverMaps,
|
||||
cachedScoreSaberLeaderboards,
|
||||
};
|
||||
statisticsCache.set("app-statistics", response);
|
||||
return response;
|
||||
}
|
||||
}
|
98
projects/backend/src/service/beatsaver.service.ts
Normal file
98
projects/backend/src/service/beatsaver.service.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
218
projects/backend/src/service/image.service.tsx
Normal file
218
projects/backend/src/service/image.service.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
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 { StarIcon } from "../../components/star-icon";
|
||||
import { GlobeIcon } from "../../components/globe-icon";
|
||||
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||
import { Jimp } from "jimp";
|
||||
import { extractColors } from "extract-colors";
|
||||
import { Config } from "@ssr/common/config";
|
||||
import { fetchWithCache } from "../common/cache.util";
|
||||
import { SSRCache } from "@ssr/common/cache";
|
||||
import { getScoreSaberPlayerFromToken } from "@ssr/common/token-creators";
|
||||
import LeaderboardService from "./leaderboard.service";
|
||||
import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
|
||||
|
||||
const cache = new SSRCache({
|
||||
ttl: 1000 * 60 * 60, // 1 hour
|
||||
});
|
||||
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 response = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>("scoresaber", id);
|
||||
if (!response) {
|
||||
return undefined;
|
||||
}
|
||||
const { leaderboard } = response;
|
||||
|
||||
const ranked = leaderboard.stars > 0;
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<ImageService.BaseImage>
|
||||
{/* Leaderboard Cover Image */}
|
||||
<img src={leaderboard.songArt} width={256} height={256} alt="Leaderboard Cover" tw="rounded-full mb-3" />
|
||||
|
||||
{/* Leaderboard Name */}
|
||||
<p tw="font-bold text-6xl m-0">
|
||||
{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" : "")}>{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
|
||||
);
|
||||
}
|
||||
}
|
96
projects/backend/src/service/leaderboard.service.ts
Normal file
96
projects/backend/src/service/leaderboard.service.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import { Leaderboards } from "@ssr/common/leaderboard";
|
||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
|
||||
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";
|
||||
import {
|
||||
ScoreSaberLeaderboard,
|
||||
ScoreSaberLeaderboardModel,
|
||||
} from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
|
||||
import Leaderboard from "@ssr/common/model/leaderboard/leaderboard";
|
||||
|
||||
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> {
|
||||
switch (leaderboard) {
|
||||
case "scoresaber": {
|
||||
return (await scoresaberService.lookupLeaderboard(id)) as T;
|
||||
}
|
||||
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;
|
||||
|
||||
const now = new Date();
|
||||
switch (leaderboardName) {
|
||||
case "scoresaber": {
|
||||
let foundLeaderboard = false;
|
||||
const cachedLeaderboard = await ScoreSaberLeaderboardModel.findById(id);
|
||||
if (cachedLeaderboard != null) {
|
||||
leaderboard = cachedLeaderboard.toObject() as unknown as ScoreSaberLeaderboard;
|
||||
if (
|
||||
leaderboard &&
|
||||
(leaderboard.ranked || // Never refresh ranked leaderboards (it will get refreshed every night)
|
||||
leaderboard.lastRefreshed == undefined || // Refresh if it has never been refreshed
|
||||
now.getTime() - leaderboard.lastRefreshed.getTime() > 1000 * 60 * 60 * 24) // Refresh every day
|
||||
) {
|
||||
foundLeaderboard = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundLeaderboard) {
|
||||
const leaderboardToken = await LeaderboardService.getLeaderboardToken<ScoreSaberLeaderboardToken>(
|
||||
leaderboardName,
|
||||
id
|
||||
);
|
||||
if (leaderboardToken == undefined) {
|
||||
throw new NotFoundError(`Leaderboard not found for "${id}"`);
|
||||
}
|
||||
|
||||
leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
|
||||
leaderboard.lastRefreshed = new Date();
|
||||
|
||||
await ScoreSaberLeaderboardModel.findOneAndUpdate({ _id: id }, leaderboard, {
|
||||
upsert: true,
|
||||
new: true,
|
||||
setDefaultsOnInsert: true,
|
||||
});
|
||||
}
|
||||
if (leaderboard == undefined) {
|
||||
throw new NotFoundError(`Leaderboard not found for "${id}"`);
|
||||
}
|
||||
|
||||
beatSaverMap = await BeatSaverService.getMap(leaderboard.songHash);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
leaderboard: leaderboard as L,
|
||||
beatsaver: beatSaverMap,
|
||||
};
|
||||
}
|
||||
}
|
371
projects/backend/src/service/player.service.ts
Normal file
371
projects/backend/src/service/player.service.ts
Normal file
@ -0,0 +1,371 @@
|
||||
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
|
||||
import { NotFoundError } from "@ssr/common/error/not-found-error";
|
||||
import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils";
|
||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||
import { InternalServerError } from "@ssr/common/error/internal-server-error";
|
||||
import { delay, getPageFromRank, isProduction } from "@ssr/common/utils/utils";
|
||||
import { AroundPlayer } from "@ssr/common/types/around-player";
|
||||
import { ScoreSort } from "@ssr/common/score/score-sort";
|
||||
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators";
|
||||
import { ScoreService } from "./score.service";
|
||||
import { logNewTrackedPlayer } from "../common/embds";
|
||||
|
||||
const SCORESABER_REQUEST_COOLDOWN = 60_000 / 250; // 250 requests per minute
|
||||
const accountCreationLock: { [id: string]: Promise<PlayerDocument> } = {};
|
||||
|
||||
export class PlayerService {
|
||||
public static async getPlayer(
|
||||
id: string,
|
||||
create: boolean = false,
|
||||
playerToken?: ScoreSaberPlayerToken
|
||||
): Promise<PlayerDocument> {
|
||||
// Wait for the existing lock if it's in progress
|
||||
if (accountCreationLock[id] !== undefined) {
|
||||
await accountCreationLock[id];
|
||||
}
|
||||
|
||||
let player: PlayerDocument | null = await PlayerModel.findById(id);
|
||||
|
||||
if (player === null) {
|
||||
if (!create) {
|
||||
throw new NotFoundError(`Player "${id}" not found`);
|
||||
}
|
||||
|
||||
playerToken = playerToken || (await scoresaberService.lookupPlayer(id));
|
||||
|
||||
if (!playerToken) {
|
||||
throw new NotFoundError(`Player "${id}" not found`);
|
||||
}
|
||||
|
||||
// Create a new lock promise and assign it
|
||||
accountCreationLock[id] = (async () => {
|
||||
let newPlayer: PlayerDocument;
|
||||
try {
|
||||
console.log(`Creating player "${id}"...`);
|
||||
newPlayer = (await PlayerModel.create({ _id: id })) as PlayerDocument;
|
||||
newPlayer.trackedSince = new Date();
|
||||
await newPlayer.save();
|
||||
|
||||
await this.seedPlayerHistory(newPlayer, playerToken);
|
||||
await this.refreshAllPlayerScores(newPlayer);
|
||||
|
||||
// Notify in production
|
||||
if (isProduction()) {
|
||||
await logNewTrackedPlayer(playerToken);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(`Failed to create player document for "${id}"`, err);
|
||||
throw new InternalServerError(`Failed to create player document for "${id}"`);
|
||||
} finally {
|
||||
// Ensure the lock is always removed
|
||||
delete accountCreationLock[id];
|
||||
}
|
||||
|
||||
return newPlayer;
|
||||
})();
|
||||
|
||||
// Wait for the player creation to complete
|
||||
player = await accountCreationLock[id];
|
||||
|
||||
// Update player name
|
||||
if (player.name !== playerToken.name) {
|
||||
player.name = playerToken.name;
|
||||
await player.save();
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure that the player is now of type PlayerDocument
|
||||
return player as PlayerDocument;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = 0; // Start from today
|
||||
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.id, 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes all the players scores.
|
||||
*
|
||||
* @param player the player to refresh
|
||||
*/
|
||||
public static async refreshAllPlayerScores(player: PlayerDocument) {
|
||||
await this.refreshPlayerScoreSaberScores(player);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures that all the players scores from the
|
||||
* ScoreSaber API are up-to-date.
|
||||
*
|
||||
* @param player the player to refresh
|
||||
* @private
|
||||
*/
|
||||
private static async refreshPlayerScoreSaberScores(player: PlayerDocument) {
|
||||
console.log(`Refreshing scores for ${player.id}...`);
|
||||
let page = 1;
|
||||
let hasMorePages = true;
|
||||
|
||||
while (hasMorePages) {
|
||||
const scoresPage = await scoresaberService.lookupPlayerScores({
|
||||
playerId: player.id,
|
||||
page: page,
|
||||
limit: 100,
|
||||
sort: ScoreSort.recent,
|
||||
});
|
||||
|
||||
if (!scoresPage) {
|
||||
console.warn(`Failed to fetch scores for ${player.id} on page ${page}.`);
|
||||
break;
|
||||
}
|
||||
|
||||
let missingScores = 0;
|
||||
for (const score of scoresPage.playerScores) {
|
||||
const leaderboard = getScoreSaberLeaderboardFromToken(score.leaderboard);
|
||||
const scoreSaberScore = await ScoreService.getScoreSaberScore(
|
||||
player.id,
|
||||
leaderboard.id + "",
|
||||
leaderboard.difficulty.difficulty,
|
||||
leaderboard.difficulty.characteristic,
|
||||
score.score.baseScore
|
||||
);
|
||||
|
||||
if (scoreSaberScore == null) {
|
||||
missingScores++;
|
||||
}
|
||||
await ScoreService.trackScoreSaberScore(score.score, score.leaderboard, player.id);
|
||||
}
|
||||
|
||||
// Stop paginating if no scores are missing OR if player has seededScores marked true
|
||||
if ((missingScores === 0 && player.seededScores) || page >= Math.ceil(scoresPage.metadata.total / 100)) {
|
||||
hasMorePages = false;
|
||||
}
|
||||
|
||||
page++;
|
||||
await delay(SCORESABER_REQUEST_COOLDOWN); // Cooldown between page requests
|
||||
}
|
||||
|
||||
// Mark player as seeded
|
||||
player.seededScores = true;
|
||||
await player.save();
|
||||
|
||||
console.log(`Finished refreshing scores for ${player.id}, total pages refreshed: ${page - 1}.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures all player scores are up-to-date.
|
||||
*/
|
||||
public static async refreshPlayerScores() {
|
||||
console.log(`Refreshing player score data...`);
|
||||
|
||||
const players = await PlayerModel.find({});
|
||||
console.log(`Found ${players.length} players to refresh.`);
|
||||
|
||||
for (const player of players) {
|
||||
await this.refreshAllPlayerScores(player.id);
|
||||
await delay(SCORESABER_REQUEST_COOLDOWN); // Cooldown between players
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the player statistics for all players.
|
||||
*/
|
||||
public static async updatePlayerStatistics() {
|
||||
const pages = 20; // top 1000 players
|
||||
|
||||
let toTrack: PlayerDocument[] = await PlayerModel.find({});
|
||||
const toRemoveIds: string[] = [];
|
||||
|
||||
// loop through pages to fetch the top players
|
||||
console.log(`Fetching ${pages} pages of players from ScoreSaber...`);
|
||||
for (let i = 0; i < pages; i++) {
|
||||
const pageNumber = i + 1;
|
||||
console.log(`Fetching page ${pageNumber}...`);
|
||||
const page = await scoresaberService.lookupPlayers(pageNumber);
|
||||
if (page === undefined) {
|
||||
console.log(`Failed to fetch players on page ${pageNumber}, skipping page...`);
|
||||
await delay(SCORESABER_REQUEST_COOLDOWN);
|
||||
continue;
|
||||
}
|
||||
for (const player of page.players) {
|
||||
const foundPlayer = await PlayerService.getPlayer(player.id, true, player);
|
||||
await PlayerService.trackScoreSaberPlayer(foundPlayer, player);
|
||||
toRemoveIds.push(foundPlayer.id);
|
||||
}
|
||||
await delay(SCORESABER_REQUEST_COOLDOWN);
|
||||
}
|
||||
console.log(`Finished tracking player statistics for ${pages} pages, found ${toRemoveIds.length} players.`);
|
||||
|
||||
// remove all players that have been tracked
|
||||
toTrack = toTrack.filter(player => !toRemoveIds.includes(player.id));
|
||||
|
||||
console.log(`Tracking ${toTrack.length} player statistics...`);
|
||||
for (const player of toTrack) {
|
||||
await PlayerService.trackScoreSaberPlayer(player);
|
||||
await delay(SCORESABER_REQUEST_COOLDOWN);
|
||||
}
|
||||
console.log("Finished tracking player statistics.");
|
||||
}
|
||||
}
|
719
projects/backend/src/service/score.service.ts
Normal file
719
projects/backend/src/service/score.service.ts
Normal file
@ -0,0 +1,719 @@
|
||||
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 BeatSaverService from "./beatsaver.service";
|
||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||
import { ScoreSort } from "@ssr/common/score/score-sort";
|
||||
import { Leaderboards } from "@ssr/common/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 { ScoreType } from "@ssr/common/model/score/score";
|
||||
import { getScoreSaberLeaderboardFromToken, getScoreSaberScoreFromToken } from "@ssr/common/token-creators";
|
||||
import {
|
||||
ScoreSaberPreviousScore,
|
||||
ScoreSaberScore,
|
||||
ScoreSaberScoreModel,
|
||||
} from "@ssr/common/model/score/impl/scoresaber-score";
|
||||
import ScoreSaberScoreToken from "@ssr/common/types/token/scoresaber/score-saber-score-token";
|
||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
||||
import { MapDifficulty } from "@ssr/common/score/map-difficulty";
|
||||
import { MapCharacteristic } from "@ssr/common/types/map-characteristic";
|
||||
import { Page, Pagination } from "@ssr/common/pagination";
|
||||
import ScoreSaberLeaderboard from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
|
||||
import Leaderboard from "@ssr/common/model/leaderboard/leaderboard";
|
||||
|
||||
const playerScoresCache = new SSRCache({
|
||||
ttl: 1000 * 60, // 1 minute
|
||||
});
|
||||
|
||||
const leaderboardScoresCache = new SSRCache({
|
||||
ttl: 1000 * 60, // 1 minute
|
||||
});
|
||||
|
||||
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);
|
||||
player.markModified("statisticHistory");
|
||||
await player.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks ScoreSaber score.
|
||||
*
|
||||
* @param scoreToken the score to track
|
||||
* @param leaderboardToken the leaderboard for the score
|
||||
* @param playerId the id of the player
|
||||
*/
|
||||
public static async trackScoreSaberScore(
|
||||
scoreToken: ScoreSaberScoreToken,
|
||||
leaderboardToken: ScoreSaberLeaderboardToken,
|
||||
playerId?: string
|
||||
) {
|
||||
playerId = (scoreToken.leaderboardPlayerInfo && scoreToken.leaderboardPlayerInfo.id) || playerId;
|
||||
if (!playerId) {
|
||||
console.error(`Player ID is undefined, unable to track score: ${scoreToken.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const playerName = (scoreToken.leaderboardPlayerInfo && scoreToken.leaderboardPlayerInfo.name) || "Unknown";
|
||||
|
||||
const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
|
||||
const score = getScoreSaberScoreFromToken(scoreToken, leaderboard, playerId);
|
||||
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
|
||||
// Player is not tracked, so ignore the score.
|
||||
if (player == undefined) {
|
||||
return;
|
||||
}
|
||||
// Update player name
|
||||
player.name = playerName;
|
||||
await player.save();
|
||||
|
||||
// The score has already been tracked, so ignore it.
|
||||
if (
|
||||
(await this.getScoreSaberScore(
|
||||
playerId,
|
||||
leaderboard.id + "",
|
||||
leaderboard.difficulty.difficulty,
|
||||
leaderboard.difficulty.characteristic,
|
||||
score.score
|
||||
)) !== null
|
||||
) {
|
||||
await logToChannel(
|
||||
DiscordChannels.backendLogs,
|
||||
new EmbedBuilder().setDescription(`Score ${score.scoreId} already tracked`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
delete score.playerInfo;
|
||||
|
||||
await ScoreSaberScoreModel.create(score);
|
||||
console.log(
|
||||
`Tracked ScoreSaber score for "${playerName}"(${playerId}), difficulty: ${score.difficulty}, score: ${score.score}, pp: ${score.pp.toFixed(2)}pp, leaderboard: ${leaderboard.id}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks BeatLeader score.
|
||||
*
|
||||
* @param score the score to track
|
||||
*/
|
||||
public static async trackBeatLeaderScore(score: BeatLeaderScoreToken) {
|
||||
const { playerId, player: scorePlayer, leaderboard } = score;
|
||||
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
|
||||
// Player is not tracked, so ignore the score.
|
||||
if (player == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The score has already been tracked, so ignore it.
|
||||
if (
|
||||
(await this.getAdditionalScoreData(
|
||||
playerId,
|
||||
leaderboard.song.hash,
|
||||
leaderboard.difficulty.difficultyName,
|
||||
score.baseScore
|
||||
)) !== undefined
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getMisses = (score: BeatLeaderScoreToken | BeatLeaderScoreImprovementToken) => {
|
||||
return score.missedNotes + score.badCuts + score.bombCuts;
|
||||
};
|
||||
|
||||
const difficulty = leaderboard.difficulty;
|
||||
const difficultyKey = `${difficulty.difficultyName}-${difficulty.modeName}`;
|
||||
const rawScoreImprovement = score.scoreImprovement;
|
||||
const data = {
|
||||
playerId: playerId,
|
||||
songHash: leaderboard.song.hash.toUpperCase(),
|
||||
songDifficulty: difficultyKey,
|
||||
songScore: score.baseScore,
|
||||
scoreId: score.id,
|
||||
leaderboardId: leaderboard.id,
|
||||
misses: {
|
||||
misses: getMisses(score),
|
||||
missedNotes: score.missedNotes,
|
||||
bombCuts: score.bombCuts,
|
||||
badCuts: score.badCuts,
|
||||
wallsHit: score.wallsHit,
|
||||
},
|
||||
pauses: score.pauses,
|
||||
fcAccuracy: score.fcAccuracy * 100,
|
||||
fullCombo: score.fullCombo,
|
||||
handAccuracy: {
|
||||
left: score.accLeft,
|
||||
right: score.accRight,
|
||||
},
|
||||
timestamp: new Date(Number(score.timeset) * 1000),
|
||||
} as AdditionalScoreData;
|
||||
if (rawScoreImprovement && rawScoreImprovement.score > 0) {
|
||||
data.scoreImprovement = {
|
||||
score: rawScoreImprovement.score,
|
||||
misses: {
|
||||
misses: getMisses(rawScoreImprovement),
|
||||
missedNotes: rawScoreImprovement.missedNotes,
|
||||
bombCuts: rawScoreImprovement.bombCuts,
|
||||
badCuts: rawScoreImprovement.badCuts,
|
||||
wallsHit: rawScoreImprovement.wallsHit,
|
||||
},
|
||||
accuracy: rawScoreImprovement.accuracy * 100,
|
||||
handAccuracy: {
|
||||
left: rawScoreImprovement.accLeft,
|
||||
right: rawScoreImprovement.accRight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await AdditionalScoreDataModel.create(data);
|
||||
console.log(
|
||||
`Tracked additional score data for "${scorePlayer.name}"(${playerId}), difficulty: ${difficultyKey}, score: ${score.baseScore}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the top tracked scores.
|
||||
*
|
||||
* @param amount the amount of scores to get
|
||||
* @returns the top scores
|
||||
*/
|
||||
public static async getTopScores(amount: number = 100) {
|
||||
const foundScores = await ScoreSaberScoreModel.aggregate([
|
||||
// Start sorting by timestamp descending using the new compound index
|
||||
{ $sort: { leaderboardId: 1, playerId: 1, timestamp: -1 } },
|
||||
{
|
||||
$group: {
|
||||
_id: { leaderboardId: "$leaderboardId", playerId: "$playerId" },
|
||||
latestScore: { $first: "$$ROOT" }, // Retrieve the latest score per group
|
||||
},
|
||||
},
|
||||
// Sort by pp of the latest scores in descending order
|
||||
{ $sort: { "latestScore.pp": -1 } },
|
||||
{ $limit: amount },
|
||||
]);
|
||||
|
||||
// Collect unique leaderboard IDs
|
||||
const leaderboardIds = [...new Set(foundScores.map(s => s.latestScore.leaderboardId))];
|
||||
const leaderboardMap = await this.fetchLeaderboardsInBatch(leaderboardIds);
|
||||
|
||||
// Collect player IDs for batch retrieval
|
||||
const playerIds = foundScores.map(result => result.latestScore.playerId);
|
||||
const players = await PlayerModel.find({ _id: { $in: playerIds } }).exec();
|
||||
const playerMap = new Map(players.map(player => [player._id.toString(), player]));
|
||||
|
||||
// Prepare to fetch additional data concurrently
|
||||
const scoreDataPromises = foundScores.map(async result => {
|
||||
const score: ScoreSaberScore = result.latestScore;
|
||||
const leaderboardResponse = leaderboardMap[score.leaderboardId];
|
||||
if (!leaderboardResponse) {
|
||||
return null; // Skip if leaderboard data is not available
|
||||
}
|
||||
|
||||
const { leaderboard, beatsaver } = leaderboardResponse;
|
||||
|
||||
// Fetch additional data concurrently
|
||||
const [additionalData, previousScore] = await Promise.all([
|
||||
this.getAdditionalScoreData(
|
||||
score.playerId,
|
||||
leaderboard.songHash,
|
||||
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
|
||||
score.score
|
||||
),
|
||||
this.getPreviousScore(score.playerId, leaderboard.id + "", score.timestamp),
|
||||
]);
|
||||
|
||||
// Attach additional and previous score data if available
|
||||
if (additionalData) score.additionalData = additionalData;
|
||||
if (previousScore) score.previousScore = previousScore;
|
||||
|
||||
// Attach player info if available
|
||||
const player = playerMap.get(score.playerId.toString());
|
||||
if (player) {
|
||||
score.playerInfo = {
|
||||
id: player._id,
|
||||
name: player.name,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
score: score as ScoreSaberScore,
|
||||
leaderboard: leaderboard,
|
||||
beatSaver: beatsaver,
|
||||
};
|
||||
});
|
||||
return (await Promise.all(scoreDataPromises)).filter(score => score !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches leaderboards in a batch.
|
||||
*
|
||||
* @param leaderboardIds the ids of the leaderboards
|
||||
* @returns the fetched leaderboards
|
||||
* @private
|
||||
*/
|
||||
private static async fetchLeaderboardsInBatch(leaderboardIds: string[]) {
|
||||
// Remove duplicates from leaderboardIds
|
||||
const uniqueLeaderboardIds = Array.from(new Set(leaderboardIds));
|
||||
|
||||
const leaderboardResponses = await Promise.all(
|
||||
uniqueLeaderboardIds.map(id => LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>("scoresaber", id))
|
||||
);
|
||||
|
||||
return leaderboardResponses.reduce(
|
||||
(map, response) => {
|
||||
if (response) map[response.leaderboard.id] = response;
|
||||
return map;
|
||||
},
|
||||
{} as Record<string, { leaderboard: ScoreSaberLeaderboard; beatsaver?: BeatSaverMap }>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the additional score data for a player's score.
|
||||
*
|
||||
* @param playerId the id of the player
|
||||
* @param songHash the hash of the map
|
||||
* @param songDifficulty the difficulty of the map
|
||||
* @param songScore the score of the play
|
||||
* @private
|
||||
*/
|
||||
private static async getAdditionalScoreData(
|
||||
playerId: string,
|
||||
songHash: string,
|
||||
songDifficulty: string,
|
||||
songScore: number
|
||||
): Promise<AdditionalScoreData | undefined> {
|
||||
const additionalData = await AdditionalScoreDataModel.findOne({
|
||||
playerId: playerId,
|
||||
songHash: songHash.toUpperCase(),
|
||||
songDifficulty: songDifficulty,
|
||||
songScore: songScore,
|
||||
});
|
||||
if (!additionalData) {
|
||||
return undefined;
|
||||
}
|
||||
return additionalData.toObject();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a ScoreSaber score.
|
||||
*
|
||||
* @param playerId the player who set the score
|
||||
* @param leaderboardId the leaderboard id the score was set on
|
||||
* @param difficulty the difficulty played
|
||||
* @param characteristic the characteristic played
|
||||
* @param score the score of the score set
|
||||
*/
|
||||
public static async getScoreSaberScore(
|
||||
playerId: string,
|
||||
leaderboardId: string,
|
||||
difficulty: MapDifficulty,
|
||||
characteristic: MapCharacteristic,
|
||||
score: number
|
||||
) {
|
||||
return ScoreSaberScoreModel.findOne({
|
||||
playerId: playerId,
|
||||
leaderboardId: leaderboardId,
|
||||
difficulty: difficulty,
|
||||
characteristic: characteristic,
|
||||
score: score,
|
||||
});
|
||||
}
|
||||
|
||||
public static async getPlayerScores(
|
||||
leaderboardName: Leaderboards,
|
||||
playerId: string,
|
||||
page: number,
|
||||
sort: string,
|
||||
search?: string
|
||||
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
|
||||
return fetchWithCache(
|
||||
playerScoresCache,
|
||||
`player-scores-${leaderboardName}-${playerId}-${page}-${sort}-${search}`,
|
||||
async () => {
|
||||
const scores: PlayerScore<unknown, unknown>[] = [];
|
||||
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
|
||||
|
||||
switch (leaderboardName) {
|
||||
case "scoresaber": {
|
||||
const leaderboardScores = await scoresaberService.lookupPlayerScores({
|
||||
playerId,
|
||||
page,
|
||||
sort: sort as ScoreSort,
|
||||
search,
|
||||
});
|
||||
if (leaderboardScores == undefined) {
|
||||
break;
|
||||
}
|
||||
|
||||
metadata = new Metadata(
|
||||
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
|
||||
leaderboardScores.metadata.total,
|
||||
leaderboardScores.metadata.page,
|
||||
leaderboardScores.metadata.itemsPerPage
|
||||
);
|
||||
|
||||
const scorePromises = leaderboardScores.playerScores.map(async token => {
|
||||
const leaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
|
||||
if (!leaderboard) return undefined;
|
||||
|
||||
const score = getScoreSaberScoreFromToken(token.score, leaderboard, playerId);
|
||||
if (!score) return undefined;
|
||||
|
||||
// Fetch additional data, previous score, and BeatSaver map concurrently
|
||||
const [additionalData, previousScore, beatSaverMap] = await Promise.all([
|
||||
this.getAdditionalScoreData(
|
||||
playerId,
|
||||
leaderboard.songHash,
|
||||
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
|
||||
score.score
|
||||
),
|
||||
this.getPreviousScore(playerId, leaderboard.id + "", score.timestamp),
|
||||
BeatSaverService.getMap(leaderboard.songHash),
|
||||
]);
|
||||
|
||||
if (additionalData) {
|
||||
score.additionalData = additionalData;
|
||||
}
|
||||
if (previousScore) {
|
||||
score.previousScore = previousScore;
|
||||
}
|
||||
|
||||
return {
|
||||
score: score,
|
||||
leaderboard: leaderboard,
|
||||
beatSaver: beatSaverMap,
|
||||
} as PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>;
|
||||
});
|
||||
|
||||
const resolvedScores = (await Promise.all(scorePromises)).filter(
|
||||
(s): s is PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard> => s !== undefined
|
||||
);
|
||||
scores.push(...resolvedScores);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scores: scores,
|
||||
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,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the player's score history for a map.
|
||||
*
|
||||
* @param playerId the player's id to get the previous scores for
|
||||
* @param leaderboardId the leaderboard to get the previous scores on
|
||||
* @param page the page to get
|
||||
*/
|
||||
public static async getScoreHistory(
|
||||
playerId: string,
|
||||
leaderboardId: string,
|
||||
page: number
|
||||
): Promise<Page<PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>>> {
|
||||
const scores = await ScoreSaberScoreModel.find({ playerId: playerId, leaderboardId: leaderboardId })
|
||||
.sort({ timestamp: -1 })
|
||||
.skip(1);
|
||||
if (scores == null || scores.length == 0) {
|
||||
throw new NotFoundError(`No previous scores found for ${playerId} in ${leaderboardId}`);
|
||||
}
|
||||
|
||||
return new Pagination<PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>>()
|
||||
.setItemsPerPage(8)
|
||||
.setTotalItems(scores.length)
|
||||
.getPage(page, async () => {
|
||||
const toReturn: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[] = [];
|
||||
for (const score of scores) {
|
||||
const leaderboardResponse = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
|
||||
"scoresaber",
|
||||
leaderboardId
|
||||
);
|
||||
if (leaderboardResponse == undefined) {
|
||||
throw new NotFoundError(`Leaderboard "${leaderboardId}" not found`);
|
||||
}
|
||||
const { leaderboard, beatsaver } = leaderboardResponse;
|
||||
|
||||
const additionalData = await this.getAdditionalScoreData(
|
||||
playerId,
|
||||
leaderboard.songHash,
|
||||
`${leaderboard.difficulty.difficulty}-${leaderboard.difficulty.characteristic}`,
|
||||
score.score
|
||||
);
|
||||
if (additionalData !== undefined) {
|
||||
score.additionalData = additionalData;
|
||||
}
|
||||
const previousScore = await this.getPreviousScore(playerId, leaderboardId, score.timestamp);
|
||||
if (previousScore !== undefined) {
|
||||
score.previousScore = previousScore;
|
||||
}
|
||||
|
||||
toReturn.push({
|
||||
score: score as unknown as ScoreSaberScore,
|
||||
leaderboard: leaderboard,
|
||||
beatSaver: beatsaver,
|
||||
});
|
||||
}
|
||||
|
||||
return toReturn;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the player's previous score for a map.
|
||||
*
|
||||
* @param playerId the player's id to get the previous score for
|
||||
* @param leaderboardId the leaderboard to get the previous score on
|
||||
* @param timestamp the score's timestamp to get the previous score for
|
||||
* @returns the score, or undefined if none
|
||||
*/
|
||||
public static async getPreviousScore(
|
||||
playerId: string,
|
||||
leaderboardId: string,
|
||||
timestamp: Date
|
||||
): Promise<ScoreSaberPreviousScore | undefined> {
|
||||
const scores = await ScoreSaberScoreModel.find({ playerId: playerId, leaderboardId: leaderboardId });
|
||||
if (scores == null || scores.length == 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scoreIndex = scores.findIndex(score => score.timestamp.getTime() == timestamp.getTime());
|
||||
const score = scores.find(score => score.timestamp.getTime() == timestamp.getTime());
|
||||
if (scoreIndex == -1 || score == undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const previousScore = scores[scoreIndex - 1];
|
||||
if (previousScore == undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
score: previousScore.score,
|
||||
accuracy: previousScore.accuracy,
|
||||
modifiers: previousScore.modifiers,
|
||||
misses: previousScore.misses,
|
||||
missedNotes: previousScore.missedNotes,
|
||||
badCuts: previousScore.badCuts,
|
||||
fullCombo: previousScore.fullCombo,
|
||||
pp: previousScore.pp,
|
||||
weight: previousScore.weight,
|
||||
maxCombo: previousScore.maxCombo,
|
||||
change: {
|
||||
score: score.score - previousScore.score,
|
||||
accuracy: score.accuracy - previousScore.accuracy,
|
||||
misses: score.misses - previousScore.misses,
|
||||
missedNotes: score.missedNotes - previousScore.missedNotes,
|
||||
badCuts: score.badCuts - previousScore.badCuts,
|
||||
pp: score.pp - previousScore.pp,
|
||||
weight: score.weight && previousScore.weight && score.weight - previousScore.weight,
|
||||
maxCombo: score.maxCombo - previousScore.maxCombo,
|
||||
},
|
||||
} as ScoreSaberPreviousScore;
|
||||
}
|
||||
}
|
15
projects/backend/tsconfig.json
Normal file
15
projects/backend/tsconfig.json
Normal file
@ -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",
|
||||
},
|
||||
}
|
27
projects/common/package.json
Normal file
27
projects/common/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
129
projects/common/src/cache.ts
Normal file
129
projects/common/src/cache.ts
Normal file
@ -0,0 +1,129 @@
|
||||
type CacheOptions = {
|
||||
/**
|
||||
* The time (in ms) 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`);
|
||||
}
|
||||
}
|
||||
}
|
13
projects/common/src/config.ts
Normal file
13
projects/common/src/config.ts
Normal file
@ -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;
|
14
projects/common/src/curve-point.ts
Normal file
14
projects/common/src/curve-point.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export class CurvePoint {
|
||||
constructor(
|
||||
private acc: number,
|
||||
private multiplier: number
|
||||
) {}
|
||||
|
||||
getAcc(): number {
|
||||
return this.acc;
|
||||
}
|
||||
|
||||
getMultiplier(): number {
|
||||
return this.multiplier;
|
||||
}
|
||||
}
|
10
projects/common/src/error/internal-server-error.ts
Normal file
10
projects/common/src/error/internal-server-error.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { HttpCode } from "../http-codes";
|
||||
|
||||
export class InternalServerError extends Error {
|
||||
constructor(
|
||||
public message: string = "internal-server-error",
|
||||
public status: number = HttpCode.INTERNAL_SERVER_ERROR.code
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
10
projects/common/src/error/not-found-error.ts
Normal file
10
projects/common/src/error/not-found-error.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { HttpCode } from "../http-codes";
|
||||
|
||||
export class NotFoundError extends Error {
|
||||
constructor(
|
||||
public message: string = "not-found",
|
||||
public status: number = HttpCode.NOT_FOUND.code
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
10
projects/common/src/error/rate-limit-error.ts
Normal file
10
projects/common/src/error/rate-limit-error.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { HttpCode } from "../http-codes";
|
||||
|
||||
export class RateLimitError extends Error {
|
||||
constructor(
|
||||
public message: string = "rate-limited",
|
||||
public status: number = HttpCode.TOO_MANY_REQUESTS.code
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
75
projects/common/src/http-codes.ts
Normal file
75
projects/common/src/http-codes.ts
Normal file
@ -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];
|
5
projects/common/src/leaderboard.ts
Normal file
5
projects/common/src/leaderboard.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const Leaderboards = {
|
||||
SCORESABER: "scoresaber",
|
||||
} as const;
|
||||
|
||||
export type Leaderboards = (typeof Leaderboards)[keyof typeof Leaderboards];
|
@ -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;
|
||||
}
|
33
projects/common/src/model/additional-score-data/misses.ts
Normal file
33
projects/common/src/model/additional-score-data/misses.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { prop } from "@typegoose/typegoose";
|
||||
|
||||
export class Misses {
|
||||
/**
|
||||
* The amount of misses notes + bad cuts.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
misses!: number;
|
||||
|
||||
/**
|
||||
* The total amount of notes that were missed.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
missedNotes!: number;
|
||||
|
||||
/**
|
||||
* The amount of times a bomb was hit.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
bombCuts!: number;
|
||||
|
||||
/**
|
||||
* The amount of walls hit in the play.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
wallsHit!: number;
|
||||
|
||||
/**
|
||||
* The number of bad cuts.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
badCuts!: number;
|
||||
}
|
27
projects/common/src/model/beatsaver/author.ts
Normal file
27
projects/common/src/model/beatsaver/author.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { prop } from "@typegoose/typegoose";
|
||||
|
||||
export default class BeatSaverAuthor {
|
||||
/**
|
||||
* The id of the author.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* The name of the mapper.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The avatar URL for the mapper.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
avatar: string;
|
||||
|
||||
constructor(id: number, name: string, avatar: string) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.avatar = avatar;
|
||||
}
|
||||
}
|
128
projects/common/src/model/beatsaver/map-difficulty.ts
Normal file
128
projects/common/src/model/beatsaver/map-difficulty.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { prop } from "@typegoose/typegoose";
|
||||
import { type MapDifficulty } from "../../score/map-difficulty";
|
||||
|
||||
export default class BeatSaverMapDifficulty {
|
||||
/**
|
||||
* The NJS of this difficulty.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
njs: number;
|
||||
|
||||
/**
|
||||
* The NJS offset of this difficulty.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
offset: number;
|
||||
|
||||
/**
|
||||
* The amount of notes in this difficulty.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
notes: number;
|
||||
|
||||
/**
|
||||
* The amount of bombs in this difficulty.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
bombs: number;
|
||||
|
||||
/**
|
||||
* The amount of obstacles in this difficulty.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
obstacles: number;
|
||||
|
||||
/**
|
||||
* The notes per second in this difficulty.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
nps: number;
|
||||
|
||||
/**
|
||||
* The characteristic of this difficulty.
|
||||
*/
|
||||
@prop({ required: true, enum: ["Standard", "Lawless"] })
|
||||
characteristic: "Standard" | "Lawless";
|
||||
|
||||
/**
|
||||
* The difficulty level.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
difficulty: MapDifficulty;
|
||||
|
||||
/**
|
||||
* The amount of lighting events in this difficulty.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
events: number;
|
||||
|
||||
/**
|
||||
* Whether this difficulty uses Chroma.
|
||||
*/
|
||||
@prop({ required: true, default: false })
|
||||
chroma: boolean;
|
||||
|
||||
/**
|
||||
* Does this difficulty use Mapping Extensions.
|
||||
*/
|
||||
@prop({ required: true, default: false })
|
||||
mappingExtensions: boolean;
|
||||
|
||||
/**
|
||||
* Does this difficulty use Noodle Extensions.
|
||||
*/
|
||||
@prop({ required: true, default: false })
|
||||
noodleExtensions: boolean;
|
||||
|
||||
/**
|
||||
* Whether this difficulty uses cinema mode.
|
||||
*/
|
||||
@prop({ required: true, default: false })
|
||||
cinema: boolean;
|
||||
|
||||
/**
|
||||
* The maximum score achievable in this difficulty.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
maxScore: number;
|
||||
|
||||
/**
|
||||
* The custom label for this difficulty.
|
||||
*/
|
||||
@prop()
|
||||
label: string;
|
||||
|
||||
constructor(
|
||||
njs: number,
|
||||
offset: number,
|
||||
notes: number,
|
||||
bombs: number,
|
||||
obstacles: number,
|
||||
nps: number,
|
||||
characteristic: "Standard" | "Lawless",
|
||||
difficulty: MapDifficulty,
|
||||
events: number,
|
||||
chroma: boolean,
|
||||
mappingExtensions: boolean,
|
||||
noodleExtensions: boolean,
|
||||
cinema: boolean,
|
||||
maxScore: number,
|
||||
label: string
|
||||
) {
|
||||
this.njs = njs;
|
||||
this.offset = offset;
|
||||
this.notes = notes;
|
||||
this.bombs = bombs;
|
||||
this.obstacles = obstacles;
|
||||
this.nps = nps;
|
||||
this.characteristic = characteristic;
|
||||
this.difficulty = difficulty;
|
||||
this.events = events;
|
||||
this.chroma = chroma;
|
||||
this.mappingExtensions = mappingExtensions;
|
||||
this.noodleExtensions = noodleExtensions;
|
||||
this.cinema = cinema;
|
||||
this.maxScore = maxScore;
|
||||
this.label = label;
|
||||
}
|
||||
}
|
55
projects/common/src/model/beatsaver/map-metadata.ts
Normal file
55
projects/common/src/model/beatsaver/map-metadata.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { prop } from "@typegoose/typegoose";
|
||||
|
||||
export default class BeatSaverMapMetadata {
|
||||
/**
|
||||
* The bpm of the song.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
bpm: number;
|
||||
|
||||
/**
|
||||
* The song's length in seconds.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
duration: number;
|
||||
|
||||
/**
|
||||
* The song's name.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
songName: string;
|
||||
|
||||
/**
|
||||
* The song's sub name.
|
||||
*/
|
||||
@prop({ required: false })
|
||||
songSubName: string;
|
||||
|
||||
/**
|
||||
* The artist(s) name.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
songAuthorName: string;
|
||||
|
||||
/**
|
||||
* The level mapper(s) name.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
levelAuthorName: string;
|
||||
|
||||
constructor(
|
||||
bpm: number,
|
||||
duration: number,
|
||||
songName: string,
|
||||
songSubName: string,
|
||||
songAuthorName: string,
|
||||
levelAuthorName: string
|
||||
) {
|
||||
this.bpm = bpm;
|
||||
this.duration = duration;
|
||||
this.songName = songName;
|
||||
this.songSubName = songSubName;
|
||||
this.songAuthorName = songAuthorName;
|
||||
this.levelAuthorName = levelAuthorName;
|
||||
}
|
||||
}
|
31
projects/common/src/model/beatsaver/map-version.ts
Normal file
31
projects/common/src/model/beatsaver/map-version.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { modelOptions, prop, Severity } from "@typegoose/typegoose";
|
||||
import BeatSaverMapDifficulty from "./map-difficulty";
|
||||
|
||||
@modelOptions({
|
||||
options: { allowMixed: Severity.ALLOW },
|
||||
})
|
||||
export default class BeatSaverMapVersion {
|
||||
/**
|
||||
* The hash of this map.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
hash: string;
|
||||
|
||||
/**
|
||||
* The date the map was created.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
createdAt: Date;
|
||||
|
||||
/**
|
||||
* The difficulties of this map.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
difficulties: BeatSaverMapDifficulty[];
|
||||
|
||||
constructor(hash: string, createdAt: Date, difficulties: BeatSaverMapDifficulty[]) {
|
||||
this.hash = hash;
|
||||
this.createdAt = createdAt;
|
||||
this.difficulties = difficulties;
|
||||
}
|
||||
}
|
99
projects/common/src/model/beatsaver/map.ts
Normal file
99
projects/common/src/model/beatsaver/map.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
|
||||
import { Document } from "mongoose";
|
||||
import BeatSaverAuthor from "./author";
|
||||
import BeatSaverMapVersion from "./map-version";
|
||||
import BeatSaverMapMetadata from "./map-metadata";
|
||||
|
||||
/**
|
||||
* The model for a BeatSaver map.
|
||||
*/
|
||||
@modelOptions({
|
||||
options: { allowMixed: Severity.ALLOW },
|
||||
schemaOptions: {
|
||||
collection: "beatsaver-maps",
|
||||
toObject: {
|
||||
virtuals: true,
|
||||
transform: function (_, ret) {
|
||||
ret.id = ret._id;
|
||||
delete ret._id;
|
||||
delete ret.__v;
|
||||
return ret;
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
export class BeatSaverMap {
|
||||
/**
|
||||
* The internal MongoDB ID (_id).
|
||||
*/
|
||||
@prop({ required: true })
|
||||
private _id!: string;
|
||||
|
||||
/**
|
||||
* The name of the map.
|
||||
*/
|
||||
@prop({ required: false })
|
||||
public name!: string;
|
||||
|
||||
/**
|
||||
* The description of the map.
|
||||
*/
|
||||
@prop({ required: false })
|
||||
public description!: string;
|
||||
|
||||
/**
|
||||
* The bsr code for the map.
|
||||
*/
|
||||
@prop({ required: false })
|
||||
public bsr!: string;
|
||||
|
||||
/**
|
||||
* The author of the map.
|
||||
*/
|
||||
@prop({ required: false, _id: false, type: () => BeatSaverAuthor })
|
||||
public author!: BeatSaverAuthor;
|
||||
|
||||
/**
|
||||
* The versions of the map.
|
||||
*/
|
||||
@prop({ required: false, _id: false, type: () => [BeatSaverMapVersion] })
|
||||
public versions!: BeatSaverMapVersion[];
|
||||
|
||||
/**
|
||||
* The metadata of the map.
|
||||
*/
|
||||
@prop({ required: false, _id: false, type: () => BeatSaverMapMetadata })
|
||||
public metadata!: BeatSaverMapMetadata;
|
||||
|
||||
/**
|
||||
* True if the map is not found on beatsaver.
|
||||
*/
|
||||
@prop({ required: false })
|
||||
public notFound?: boolean;
|
||||
|
||||
/**
|
||||
* The last time the map data was refreshed.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
public lastRefreshed!: Date;
|
||||
|
||||
/**
|
||||
* Exposes `id` as a virtual field mapped from `_id`.
|
||||
*/
|
||||
public get id(): string {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Should the map data be refreshed?
|
||||
*
|
||||
* @returns true if the map data should be refreshed
|
||||
*/
|
||||
public shouldRefresh(): boolean {
|
||||
const now = new Date();
|
||||
return now.getTime() - this.lastRefreshed.getTime() > 1000 * 60 * 60 * 24 * 3; // 3 days
|
||||
}
|
||||
}
|
||||
|
||||
export type BeatSaverMapDocument = BeatSaverMap & Document;
|
||||
export const BeatSaverMapModel: ReturnModelType<typeof BeatSaverMap> = getModelForClass(BeatSaverMap);
|
@ -0,0 +1,56 @@
|
||||
import Leaderboard from "../leaderboard";
|
||||
import { type LeaderboardStatus } from "../leaderboard-status";
|
||||
import { getModelForClass, modelOptions, Prop, ReturnModelType, Severity } from "@typegoose/typegoose";
|
||||
import { Document } from "mongoose";
|
||||
|
||||
@modelOptions({
|
||||
options: { allowMixed: Severity.ALLOW },
|
||||
schemaOptions: {
|
||||
collection: "scoresaber-leaderboards",
|
||||
toObject: {
|
||||
virtuals: true,
|
||||
transform: function (_, ret) {
|
||||
ret.id = ret._id;
|
||||
delete ret._id;
|
||||
delete ret.__v;
|
||||
return ret;
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
export default class ScoreSaberLeaderboardInternal extends Leaderboard {
|
||||
/**
|
||||
* The star count for the leaderboard.
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly stars!: number;
|
||||
|
||||
/**
|
||||
* The total amount of plays.
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly plays!: number;
|
||||
|
||||
/**
|
||||
* The amount of plays today.
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly dailyPlays!: number;
|
||||
|
||||
/**
|
||||
* Whether this leaderboard is qualified to be ranked.
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly qualified!: boolean;
|
||||
|
||||
/**
|
||||
* The status of the map.
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly status!: LeaderboardStatus;
|
||||
}
|
||||
|
||||
export type ScoreSaberLeaderboard = InstanceType<typeof ScoreSaberLeaderboardInternal>;
|
||||
export type ScoreSaberLeaderboardDocument = ScoreSaberLeaderboard & Document;
|
||||
export const ScoreSaberLeaderboardModel: ReturnModelType<typeof ScoreSaberLeaderboardInternal> =
|
||||
getModelForClass(ScoreSaberLeaderboardInternal);
|
@ -0,0 +1,29 @@
|
||||
import { type MapDifficulty } from "../../score/map-difficulty";
|
||||
import { type MapCharacteristic } from "../../types/map-characteristic";
|
||||
import { Prop } from "@typegoose/typegoose";
|
||||
|
||||
export default class LeaderboardDifficulty {
|
||||
/**
|
||||
* The id of the leaderboard.
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
leaderboardId!: number;
|
||||
|
||||
/**
|
||||
* The difficulty of the leaderboard.
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
difficulty!: MapDifficulty;
|
||||
|
||||
/**
|
||||
* The characteristic of the leaderboard.
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
characteristic!: MapCharacteristic;
|
||||
|
||||
/**
|
||||
* The raw difficulty of the leaderboard.
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
difficultyRaw!: string;
|
||||
}
|
@ -0,0 +1,4 @@
|
||||
/**
|
||||
* The status of the leaderboard.
|
||||
*/
|
||||
export type LeaderboardStatus = "Unranked" | "Ranked" | "Qualified";
|
99
projects/common/src/model/leaderboard/leaderboard.ts
Normal file
99
projects/common/src/model/leaderboard/leaderboard.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import LeaderboardDifficulty from "./leaderboard-difficulty";
|
||||
import { Prop } from "@typegoose/typegoose";
|
||||
|
||||
export default class Leaderboard {
|
||||
/**
|
||||
* The id of the leaderboard.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
private readonly _id?: number;
|
||||
|
||||
/**
|
||||
* The hash of the song this leaderboard is for.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly songHash!: string;
|
||||
|
||||
/**
|
||||
* The name of the song this leaderboard is for.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly songName!: string;
|
||||
|
||||
/**
|
||||
* The sub name of the leaderboard.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly songSubName!: string;
|
||||
|
||||
/**
|
||||
* The author of the song this leaderboard is for.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly songAuthorName!: string;
|
||||
|
||||
/**
|
||||
* The author of the level this leaderboard is for.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly levelAuthorName!: string;
|
||||
|
||||
/**
|
||||
* The difficulty of the leaderboard.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true, _id: false, type: () => LeaderboardDifficulty })
|
||||
readonly difficulty!: LeaderboardDifficulty;
|
||||
|
||||
/**
|
||||
* The difficulties of the leaderboard.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true, _id: false, type: () => [LeaderboardDifficulty] })
|
||||
readonly difficulties!: LeaderboardDifficulty[];
|
||||
|
||||
/**
|
||||
* The maximum score of the leaderboard.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly maxScore!: number;
|
||||
|
||||
/**
|
||||
* Whether the leaderboard is ranked.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly ranked!: boolean;
|
||||
|
||||
/**
|
||||
* The link to the song art.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly songArt!: string;
|
||||
|
||||
/**
|
||||
* The date the leaderboard was created.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
readonly timestamp!: Date;
|
||||
|
||||
/**
|
||||
* The date the leaderboard was last refreshed.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
lastRefreshed?: Date;
|
||||
|
||||
get id(): number {
|
||||
return this._id ?? 0;
|
||||
}
|
||||
}
|
125
projects/common/src/model/player.ts
Normal file
125
projects/common/src/model/player.ts
Normal file
@ -0,0 +1,125 @@
|
||||
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 name.
|
||||
*/
|
||||
@prop()
|
||||
public name?: string;
|
||||
|
||||
/**
|
||||
* The player's statistic history.
|
||||
*/
|
||||
@prop()
|
||||
private statisticHistory?: Record<string, PlayerHistory>;
|
||||
|
||||
/**
|
||||
* Whether the player has their scores seeded.
|
||||
*/
|
||||
@prop()
|
||||
public seededScores?: boolean;
|
||||
|
||||
/**
|
||||
* The date the player was last tracked.
|
||||
*/
|
||||
@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);
|
102
projects/common/src/model/score/impl/scoresaber-score.ts
Normal file
102
projects/common/src/model/score/impl/scoresaber-score.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { getModelForClass, index, modelOptions, plugin, Prop, ReturnModelType, Severity } from "@typegoose/typegoose";
|
||||
import Score from "../score";
|
||||
import { type ScoreSaberLeaderboardPlayerInfoToken } from "../../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
|
||||
import { Document } from "mongoose";
|
||||
import { AutoIncrementID } from "@typegoose/auto-increment";
|
||||
import { PreviousScore } from "../previous-score";
|
||||
|
||||
@modelOptions({
|
||||
options: { allowMixed: Severity.ALLOW },
|
||||
schemaOptions: {
|
||||
collection: "scoresaber-scores",
|
||||
toObject: {
|
||||
virtuals: true,
|
||||
transform: function (_, ret) {
|
||||
ret.id = ret._id;
|
||||
delete ret._id;
|
||||
delete ret.__v;
|
||||
return ret;
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@index({ leaderboardId: 1, playerId: 1, timestamp: -1 }) // Compound index for optimized queries
|
||||
@plugin(AutoIncrementID, {
|
||||
field: "_id",
|
||||
startAt: 1,
|
||||
trackerModelName: "scores",
|
||||
trackerCollection: "increments",
|
||||
overwriteModelName: "scoresaber-scores",
|
||||
})
|
||||
export class ScoreSaberScoreInternal extends Score {
|
||||
/**
|
||||
* The score's id.
|
||||
*/
|
||||
@Prop({ required: true, index: true })
|
||||
public readonly scoreId!: string;
|
||||
|
||||
/**
|
||||
* The leaderboard the score was set on.
|
||||
*/
|
||||
@Prop({ required: true, index: true })
|
||||
public readonly leaderboardId!: number;
|
||||
|
||||
/**
|
||||
* The amount of pp for the score.
|
||||
* @private
|
||||
*/
|
||||
@Prop({ required: true, index: true })
|
||||
public pp!: number;
|
||||
|
||||
/**
|
||||
* The weight of the score, or undefined if not ranked.
|
||||
* @private
|
||||
*/
|
||||
@Prop()
|
||||
public readonly weight?: number;
|
||||
|
||||
/**
|
||||
* The max combo of the score.
|
||||
*/
|
||||
@Prop({ required: true })
|
||||
public readonly maxCombo!: number;
|
||||
|
||||
/**
|
||||
* The previous score, if any.
|
||||
*/
|
||||
public previousScore?: ScoreSaberPreviousScore;
|
||||
}
|
||||
|
||||
class ScoreSaberScorePublic extends ScoreSaberScoreInternal {
|
||||
/**
|
||||
* The player who set the score.
|
||||
*/
|
||||
public playerInfo!: ScoreSaberLeaderboardPlayerInfoToken;
|
||||
}
|
||||
|
||||
export type ScoreSaberPreviousScore = PreviousScore & {
|
||||
/**
|
||||
* The pp of the previous score.
|
||||
*/
|
||||
pp: number;
|
||||
|
||||
/**
|
||||
* The weight of the previous score.
|
||||
*/
|
||||
weight: number;
|
||||
|
||||
/**
|
||||
* The max combo of the previous score.
|
||||
*/
|
||||
maxCombo: number;
|
||||
|
||||
/**
|
||||
* The change between the previous score and the current score.
|
||||
*/
|
||||
change?: ScoreSaberPreviousScore;
|
||||
};
|
||||
|
||||
export type ScoreSaberScore = InstanceType<typeof ScoreSaberScorePublic>;
|
||||
export type ScoreSaberScoreDocument = ScoreSaberScore & Document;
|
||||
export const ScoreSaberScoreModel: ReturnModelType<typeof ScoreSaberScoreInternal> =
|
||||
getModelForClass(ScoreSaberScoreInternal);
|
38
projects/common/src/model/score/previous-score.ts
Normal file
38
projects/common/src/model/score/previous-score.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Modifier } from "../../score/modifier";
|
||||
|
||||
export type PreviousScore = {
|
||||
/**
|
||||
* The score of the previous score.
|
||||
*/
|
||||
score: number;
|
||||
|
||||
/**
|
||||
* The accuracy of the previous score.
|
||||
*/
|
||||
accuracy: number;
|
||||
|
||||
/**
|
||||
* The modifiers of the previous score.
|
||||
*/
|
||||
modifiers?: Modifier[];
|
||||
|
||||
/**
|
||||
* The misses of the previous score.
|
||||
*/
|
||||
misses: number;
|
||||
|
||||
/**
|
||||
* The missed notes of the previous score.
|
||||
*/
|
||||
missedNotes: number;
|
||||
|
||||
/**
|
||||
* The bad cuts of the previous score.
|
||||
*/
|
||||
badCuts: number;
|
||||
|
||||
/**
|
||||
* The full combo of the previous score.
|
||||
*/
|
||||
fullCombo?: boolean;
|
||||
};
|
102
projects/common/src/model/score/score.ts
Normal file
102
projects/common/src/model/score/score.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import { Modifier } from "../../score/modifier";
|
||||
import { AdditionalScoreData } from "../additional-score-data/additional-score-data";
|
||||
import { prop } from "@typegoose/typegoose";
|
||||
import { type MapDifficulty } from "../../score/map-difficulty";
|
||||
import { type MapCharacteristic } from "../../types/map-characteristic";
|
||||
|
||||
/**
|
||||
* The model for a score.
|
||||
*/
|
||||
export default class Score {
|
||||
/**
|
||||
* The internal score id.
|
||||
*/
|
||||
@prop()
|
||||
public _id?: number;
|
||||
|
||||
/**
|
||||
* The id of the player who set the score.
|
||||
*/
|
||||
@prop({ required: true, index: true })
|
||||
public readonly playerId!: string;
|
||||
|
||||
/**
|
||||
* The map difficulty played in the score.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
public readonly difficulty!: MapDifficulty;
|
||||
|
||||
/**
|
||||
* The characteristic of the map.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
public readonly characteristic!: MapCharacteristic;
|
||||
|
||||
/**
|
||||
* The base score for the score.
|
||||
* @private
|
||||
*/
|
||||
@prop({ required: true })
|
||||
public readonly score!: number;
|
||||
|
||||
/**
|
||||
* The accuracy of the score.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
public readonly accuracy!: number;
|
||||
|
||||
/**
|
||||
* The rank for the score.
|
||||
* @private
|
||||
*/
|
||||
@prop({ required: true })
|
||||
public readonly rank!: number;
|
||||
|
||||
/**
|
||||
* The modifiers used on the score.
|
||||
* @private
|
||||
*/
|
||||
@prop({ enum: () => Modifier, type: String, required: true })
|
||||
public readonly modifiers!: Modifier[];
|
||||
|
||||
/**
|
||||
* The total amount of misses.
|
||||
* @private
|
||||
*/
|
||||
@prop({ required: true })
|
||||
public readonly misses!: number;
|
||||
|
||||
/**
|
||||
* The amount of missed notes.
|
||||
*/
|
||||
@prop({ required: true })
|
||||
public readonly missedNotes!: number;
|
||||
|
||||
/**
|
||||
* The amount of bad cuts.
|
||||
* @private
|
||||
*/
|
||||
@prop({ required: true })
|
||||
public readonly badCuts!: number;
|
||||
|
||||
/**
|
||||
* Whether every note was hit.
|
||||
* @private
|
||||
*/
|
||||
@prop({ required: true })
|
||||
public readonly fullCombo!: boolean;
|
||||
|
||||
/**
|
||||
* The additional data for the score.
|
||||
*/
|
||||
public additionalData?: AdditionalScoreData;
|
||||
|
||||
/**
|
||||
* The time the score was set.
|
||||
* @private
|
||||
*/
|
||||
@prop({ required: true, index: true })
|
||||
public readonly timestamp!: Date;
|
||||
}
|
||||
|
||||
export type ScoreType = InstanceType<typeof Score>;
|
103
projects/common/src/pagination.ts
Normal file
103
projects/common/src/pagination.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { Metadata } from "./types/metadata";
|
||||
import { NotFoundError } from "./error/not-found-error";
|
||||
|
||||
type FetchItemsFunction<T> = (fetchItems: FetchItems) => Promise<T[]>;
|
||||
|
||||
export class Pagination<T> {
|
||||
private itemsPerPage: number = 0;
|
||||
private totalItems: number = 0;
|
||||
private items: T[] | null = null; // Optional array to hold set items
|
||||
|
||||
/**
|
||||
* Sets the number of items per page.
|
||||
* @param itemsPerPage - The number of items per page.
|
||||
* @returns the pagination
|
||||
*/
|
||||
setItemsPerPage(itemsPerPage: number): Pagination<T> {
|
||||
this.itemsPerPage = itemsPerPage;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the items to paginate.
|
||||
* @param items - The items to paginate.
|
||||
* @returns the pagination
|
||||
*/
|
||||
setItems(items: T[]): Pagination<T> {
|
||||
this.items = items;
|
||||
this.totalItems = items.length;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the total number of items.
|
||||
* @param totalItems - Total number of items.
|
||||
* @returns the pagination
|
||||
*/
|
||||
setTotalItems(totalItems: number): Pagination<T> {
|
||||
this.totalItems = totalItems;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a page of items, using either static items or a dynamic fetchItems callback.
|
||||
* @param page - The page number to retrieve.
|
||||
* @param fetchItems - The async function to fetch items if setItems was not used.
|
||||
* @returns A promise resolving to the page of items.
|
||||
* @throws throws an error if the page number is invalid.
|
||||
*/
|
||||
async getPage(page: number, fetchItems?: FetchItemsFunction<T>): Promise<Page<T>> {
|
||||
const totalPages = Math.ceil(this.totalItems / this.itemsPerPage);
|
||||
|
||||
if (page < 1 || page > totalPages) {
|
||||
throw new NotFoundError("Invalid page number");
|
||||
}
|
||||
|
||||
// Calculate the range of items to fetch for the current page
|
||||
const start = (page - 1) * this.itemsPerPage;
|
||||
const end = start + this.itemsPerPage;
|
||||
|
||||
let pageItems: T[];
|
||||
|
||||
// Use set items if they are present, otherwise use fetchItems callback
|
||||
if (this.items) {
|
||||
pageItems = this.items.slice(start, end);
|
||||
} else if (fetchItems) {
|
||||
pageItems = await fetchItems(new FetchItems(start, end));
|
||||
} else {
|
||||
throw new Error("Items function is not set and no fetchItems callback provided");
|
||||
}
|
||||
|
||||
return new Page<T>(pageItems, new Metadata(totalPages, this.totalItems, page, this.itemsPerPage));
|
||||
}
|
||||
}
|
||||
|
||||
class FetchItems {
|
||||
readonly start: number;
|
||||
readonly end: number;
|
||||
|
||||
constructor(start: number, end: number) {
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
}
|
||||
}
|
||||
|
||||
export class Page<T> {
|
||||
readonly items: T[];
|
||||
readonly metadata: Metadata;
|
||||
|
||||
constructor(items: T[], metadata: Metadata) {
|
||||
this.items = items;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the page to a JSON object.
|
||||
*/
|
||||
toJSON() {
|
||||
return {
|
||||
items: this.items,
|
||||
metadata: this.metadata,
|
||||
};
|
||||
}
|
||||
}
|
145
projects/common/src/player/impl/scoresaber-player.ts
Normal file
145
projects/common/src/player/impl/scoresaber-player.ts
Normal file
@ -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;
|
||||
};
|
71
projects/common/src/player/player-history.ts
Normal file
71
projects/common/src/player/player-history.ts
Normal file
@ -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;
|
||||
};
|
||||
}
|
64
projects/common/src/player/player-stat-change.ts
Normal file
64
projects/common/src/player/player-stat-change.ts
Normal file
@ -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,
|
||||
},
|
||||
};
|
@ -4,11 +4,6 @@ export interface PlayerTrackedSince {
|
||||
*/
|
||||
tracked: boolean;
|
||||
|
||||
/**
|
||||
* The date the player was first tracked
|
||||
*/
|
||||
trackedSince?: string;
|
||||
|
||||
/**
|
||||
* The amount of days the player has been tracked
|
||||
*/
|
@ -1,4 +1,4 @@
|
||||
import { PlayerHistory } from "@/common/player/player-history";
|
||||
import { PlayerHistory } from "./player-history";
|
||||
|
||||
export default class Player {
|
||||
/**
|
||||
@ -55,4 +55,7 @@ export default class Player {
|
||||
}
|
||||
}
|
||||
|
||||
export type StatisticChange = PlayerHistory;
|
||||
export type ChangeRange = "daily" | "weekly" | "monthly";
|
||||
export type StatisticChange = {
|
||||
[key in ChangeRange]: PlayerHistory;
|
||||
};
|
8
projects/common/src/response/around-player-response.ts
Normal file
8
projects/common/src/response/around-player-response.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import ScoreSaberPlayerToken from "../types/token/scoresaber/score-saber-player-token";
|
||||
|
||||
export type AroundPlayerResponse = {
|
||||
/**
|
||||
* The players around the player.
|
||||
*/
|
||||
players: ScoreSaberPlayerToken[];
|
||||
};
|
13
projects/common/src/response/leaderboard-response.ts
Normal file
13
projects/common/src/response/leaderboard-response.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { BeatSaverMap } from "../model/beatsaver/map";
|
||||
|
||||
export type LeaderboardResponse<L> = {
|
||||
/**
|
||||
* The leaderboard.
|
||||
*/
|
||||
leaderboard: L;
|
||||
|
||||
/**
|
||||
* The beatsaver map.
|
||||
*/
|
||||
beatsaver?: BeatSaverMap;
|
||||
};
|
24
projects/common/src/response/leaderboard-scores-response.ts
Normal file
24
projects/common/src/response/leaderboard-scores-response.ts
Normal file
@ -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;
|
||||
}
|
14
projects/common/src/response/player-scores-response.ts
Normal file
14
projects/common/src/response/player-scores-response.ts
Normal file
@ -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;
|
||||
}
|
10
projects/common/src/response/top-scores-response.ts
Normal file
10
projects/common/src/response/top-scores-response.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { ScoreSaberLeaderboard } from "src/model/leaderboard/impl/scoresaber-leaderboard";
|
||||
import { ScoreSaberScore } from "../model/score/impl/scoresaber-score";
|
||||
import { PlayerScore } from "../score/player-score";
|
||||
|
||||
export type TopScoresResponse = {
|
||||
/**
|
||||
* The top scores.
|
||||
*/
|
||||
scores: PlayerScore<ScoreSaberScore, ScoreSaberLeaderboard>[];
|
||||
};
|
1
projects/common/src/score/map-difficulty.ts
Normal file
1
projects/common/src/score/map-difficulty.ts
Normal file
@ -0,0 +1 @@
|
||||
export type MapDifficulty = "Easy" | "Normal" | "Hard" | "Expert" | "ExpertPlus" | "Unknown";
|
@ -2,17 +2,18 @@
|
||||
* The score modifiers.
|
||||
*/
|
||||
export enum Modifier {
|
||||
DA = "Disappearing Arrows",
|
||||
NF = "No Fail",
|
||||
PM = "Pro Mode",
|
||||
FS = "Faster Song",
|
||||
SF = "Super Fast Song",
|
||||
SS = "Slower Song",
|
||||
GN = "Ghost Notes",
|
||||
NA = "No Arrows",
|
||||
NO = "No Obstacles",
|
||||
DA = "Disappearing Arrows",
|
||||
SA = "Strict Angles",
|
||||
SC = "Small Notes",
|
||||
PM = "Pro Mode",
|
||||
CS = "Fail on Saber Clash",
|
||||
IF = "One Life",
|
||||
NO = "No Obstacles",
|
||||
BE = "Battery Energy",
|
||||
NB = "No Bombs",
|
||||
NA = "No Arrows",
|
||||
}
|
6
projects/common/src/score/player-leaderboard-score.ts
Normal file
6
projects/common/src/score/player-leaderboard-score.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export default interface PlayerLeaderboardScore<S> {
|
||||
/**
|
||||
* The score that was set.
|
||||
*/
|
||||
readonly score: S;
|
||||
}
|
18
projects/common/src/score/player-score.ts
Normal file
18
projects/common/src/score/player-score.ts
Normal file
@ -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;
|
||||
}
|
34
projects/common/src/service/impl/beatleader.ts
Normal file
34
projects/common/src/service/impl/beatleader.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import Service from "../service";
|
||||
import { ScoreStatsToken } from "../../types/token/beatleader/score-stats/score-stats";
|
||||
|
||||
const LOOKUP_MAP_STATS_BY_SCORE_ID_ENDPOINT = `https://cdn.scorestats.beatleader.xyz/:scoreId.json`;
|
||||
|
||||
class BeatLeaderService extends Service {
|
||||
constructor() {
|
||||
super("BeatLeader");
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the score stats for a score
|
||||
*
|
||||
* @param scoreId the score id to get
|
||||
* @returns the score stats, or undefined if nothing was found
|
||||
*/
|
||||
async lookupScoreStats(scoreId: number): Promise<ScoreStatsToken | undefined> {
|
||||
const before = performance.now();
|
||||
this.log(`Looking score stats for "${scoreId}"...`);
|
||||
|
||||
const response = await this.fetch<ScoreStatsToken>(
|
||||
LOOKUP_MAP_STATS_BY_SCORE_ID_ENDPOINT.replace(":scoreId", scoreId + "")
|
||||
);
|
||||
// Score stats not found
|
||||
if (response == undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this.log(`Found score stats for score "${scoreId}" in ${(performance.now() - before).toFixed(0)}ms`);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
export const beatLeaderService = new BeatLeaderService();
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user