Compare commits
220 Commits
0231c6ccfe
...
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 |
@ -32,13 +32,8 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: ssr-backend-secret
|
name: ssr-backend-secret
|
||||||
key: MONGO_URI
|
key: MONGO_URI
|
||||||
- name: NUMBER_ONE_WEBHOOK
|
- name: DISCORD_BOT_TOKEN
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: ssr-backend-secret
|
name: ssr-backend-secret
|
||||||
key: NUMBER_ONE_WEBHOOK
|
key: DISCORD_BOT_TOKEN
|
||||||
- name: TRACKED_PLAYERS_WEBHOOK
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: ssr-backend-secret
|
|
||||||
key: TRACKED_PLAYERS_WEBHOOK
|
|
@ -24,3 +24,27 @@ spec:
|
|||||||
port: 8080
|
port: 8080
|
||||||
tls:
|
tls:
|
||||||
secretName: fascinated-cc
|
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
|
||||||
|
@ -7,9 +7,8 @@ metadata:
|
|||||||
namespace: public-services
|
namespace: public-services
|
||||||
spec:
|
spec:
|
||||||
encryptedData:
|
encryptedData:
|
||||||
MONGO_URI: AgC1aNi0ISr4nYuMufqC0LK769TzVltSosJZXXJ2fxYwFNylIVUzolc1QrMmMBZLGDy1Jr7aOVKCz58LK0xUd2JIlejuzVHcwu7m6l0Qkqv8ghGgZ5CF7w2vlqWXnhBOffmUjvlrWB0UXSeY50M/0M5c8VcvbnEyoQ4+00cA/VJmzoWbZ0P573IQgRax6TZa7wTjLjKcxODFmtitxPZGAio1tEkqDmbvxbBGYHdDj/ZRfUH1FDbcMjlLhFu/l46zYAYW33372J1qTwL8/111XqJREvmsEna/CtpGoqBkPI0MOH6Tm5ggN8GnpmKbZby2eDgLAptu3rqQYAFrdrwAfUBoPrATYYTgyfe9quYJlZj8cxNVNH/y5fVdZZQWJLzqzPSjww6BV9SfTzU1Eo9/cdEKaGWjZsDsYYgicvkj1GLhiN/qPKMmdatF48x02oefT51toFIjb6lu+s0bqDVnk/w07w0ASN1VbJL5s6Z1/aqeZIWYGcRdpj500UI4zVQuI+3AYEBJJzGSYQNluNhhJBv9TAhh2TddTkENEijLJLjthke0yztDvNRrIXhziKOr8TWhQcIv5Hb0kg0J+Cvq/9Fu1BlDydHNcIc4/a3OhHPnqfhVlRwiCjJ8I56wYIQjkLoKK80qjBs86RCC/sKC9w65+deG4KSsclpDY8kNpf9MKg9OreLFMneN6CvQR19yEIaqCoA9moyeyY+EYvgioN4lTjlg0Kbn6D65yleQiO0LEYYutYAZRdJuLI/0oLHtRLsnr0+FL+SDAqGKPyNZ9JNpBCGPVml4Y2Czh4qCV24CxulbAIAiBmbcQos=
|
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=
|
||||||
NUMBER_ONE_WEBHOOK: AgAsmhgyllPfA0/z+vFG/cwT7IOix/x7QG/5SR0xfCSOXwn/oD9Y2MpAVegjFlQ1nEh5o/pVFqPKY44VtoNXy+8R8rnRK+gvRraYliobHBJf8OBNgW8B24lbg8RfO+ll84VrdV4tmK1TTaliuHpb+CyP2i4VMD54Zgu2xqoJzWSS4TGkzaECSr9kEsmW0mPf1wlVxWq3QKZIjJrZn5B0qM8qoUS1Q78eN297Lz4y000Ncb7dR4FybrQdBBcFGkGrPvFyMSVASK8AwGooMSxAU6PcE7XHGUgN8KofQ94CSrtlngrdcq/5XapUU5my2EkWB1/qsEhBwHj1f7HiRxPbBosvc6beszif65c97mEc7GANdDN9H4ywo6Gba/pChnd2EVn+Rr41UlnkPlhjjIuLSe32LdWXZMkjohuuhbmTH38KtYd1rwA9rYVUHVnJW9mIvteA/eiEdvGzcdppxek/ywxv1lwB5eifX/e2ScaRtREot9q7N7XbyUOwKbVJLIpAlhEWREZmwtOdka6xJEWSHNVPKM1M3WO3KyaQKz01P0ADamCcQs5timHArizDFWK28FgVtNR480WBfd19xwYxh8hC3Z+a4LQfGUy/ZPIoan7cQ55S0bnROWTblXRSkrGAWdF5b3dc5ltiE1+OnE1gVx2/KMg17treDkbeG31KkmISfIZA7gUhP/vfagD0ljEb5cpiYo+cOqBrkomW3BKGHmJqebS+EF9+eGA/xAQopQrxQCOzp1CLq+rjW5UPBCICNpaUSMKkln1sRLamwoobc8By8DLcNks743gDOn2+igYj+twoD+mRwY2EK85tBMzuHali1iTHU3B4kxRsf0Z9YE1FJl2l+ugo4H3J
|
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=
|
||||||
TRACKED_PLAYERS_WEBHOOK: AgCPeq3/S63zyqlzYuLncX5BcAagVwVu/nDhRl0e5NmBctZ2eZ2bq1z7rwz7LldVDIs/gAGWe4WzrxPzDTLu1xgTnvzCrSDuZQHsvHph8v9obz+8qSbogtRrPmRkPgIXFJ0KTN1B64aarPBzeAsUW/BMe2M+UeT44JVyVLT3Z1Pq/+a4g2Bc6FLlFiJMBFnGtYsKE4OMhpvKK31S6yG4+OEcYL+RLbSwGPHOsQDh9hYoOYekkAtcOp2+0Ee972dgBq3qbYMDRL29ETBuof4TxasHDwRE9L+HElL1RvsfU+wKfLuaq5Uhm6pNb+zhBtQd8M6XuRpNUQ2J2L0WAWBTdK98unej3ebUnsIIan0l0LfBa5ZqnG3YB9+aaAntx+l/ZQztHlUIOwmRQm0p1hjJoZXhNLWrzNtRLMGbvyJMeSPvuwxoHh3qlcgbC2opMpX6yYRzTIMy6ZVJDBnTIMDYXESIDW/t70v6SqBWens++XMm20tHAAEQF/0gVw3wYZ7Z+EOHH/By7YFGC79xakb8d6Fh98V/s4KeDpxPJSekZEo7ETHOsju5ApPW+6CS1weWGrQPP2+SU5vAysEcu+Mjvs+xWHgxML5YPBfjfpdCMtpA1P0Cdsr28WYWYdUTv8FZbSna+I4oqxn0sx5ONv5VMAUN0Cd4OdfCfiiHgWWGfGHB3QQHvQuPpwR2nl0mrQHEQkaCuUSDNDvrs/h6fXLgd5JsgEz1CeBx5A+j4h03TB1sRIN8KmcC3vgwEIyEoEoqTdJDbo07ttzs41f0i01GFwT/hO9KIxGwTW6yr/iRfu29MJL11xKhVPKx5BOZcSaK2fBq/jMW8rQJbHqMukrHzo+urNzY9iH+wcpq
|
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
creationTimestamp: null
|
creationTimestamp: null
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
name: "Deploy Backend"
|
name: Deploy Backend
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@ -8,57 +8,91 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- projects/backend/**
|
- projects/backend/**
|
||||||
- projects/common/**
|
- projects/common/**
|
||||||
- .gitea/kubernetes/backend/**
|
|
||||||
- .gitea/workflows/deploy-backend.yml
|
- .gitea/workflows/deploy-backend.yml
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: ["ubuntu-latest"]
|
||||||
|
runs-on: ${{ matrix.arch }}
|
||||||
|
|
||||||
|
# Steps to run
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
# Checkout the repo
|
||||||
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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:
|
with:
|
||||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
fetch-depth: 0
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
registry: git.fascinated.cc
|
|
||||||
|
|
||||||
- name: Build Image
|
# Deploy to Dokku
|
||||||
uses: docker/build-push-action@v6
|
- name: Push to dokku
|
||||||
|
uses: dokku/github-action@master
|
||||||
with:
|
with:
|
||||||
context: .
|
git_remote_url: "ssh://dokku@51.158.63.74:22/ssr-backend"
|
||||||
file: ./projects/backend/Dockerfile
|
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
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
|
#name: "Deploy Backend"
|
||||||
uses: azure/setup-kubectl@v4
|
#
|
||||||
id: install
|
#on:
|
||||||
|
# workflow_dispatch:
|
||||||
- name: Setup Kubernetes Context
|
# push:
|
||||||
uses: azure/k8s-set-context@v4
|
# branches:
|
||||||
with:
|
# - master
|
||||||
kubeconfig: ${{ secrets.KUBECONFIG }}
|
# paths:
|
||||||
|
# - projects/backend/**
|
||||||
- name: Deploy to Kubernetes
|
# - projects/common/**
|
||||||
uses: Azure/k8s-deploy@v5
|
# - .gitea/kubernetes/backend/**
|
||||||
with:
|
# - .gitea/workflows/deploy-backend.yml
|
||||||
action: deploy
|
#
|
||||||
namespace: public-services
|
#jobs:
|
||||||
manifests: |
|
# deploy:
|
||||||
.gitea/kubernetes/backend/sealed-secret.yaml
|
# runs-on: ubuntu-latest
|
||||||
.gitea/kubernetes/backend/deployment.yaml
|
# steps:
|
||||||
.gitea/kubernetes/backend/service.yaml
|
# - name: Checkout code
|
||||||
.gitea/kubernetes/backend/strip-api-prefix-middleware.yaml
|
# uses: actions/checkout@v4
|
||||||
.gitea/kubernetes/backend/ingress.yaml
|
#
|
||||||
images: |
|
# - name: Set up Docker Buildx
|
||||||
git.fascinated.cc/fascinated/scoresaber-reloaded-backend:${{ github.sha }}
|
# 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:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@ -8,56 +8,90 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- projects/website/**
|
- projects/website/**
|
||||||
- projects/common/**
|
- projects/common/**
|
||||||
- .gitea/kubernetes/website/**
|
|
||||||
- .gitea/workflows/deploy-website.yml
|
- .gitea/workflows/deploy-website.yml
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
matrix:
|
||||||
|
arch: ["ubuntu-latest"]
|
||||||
|
runs-on: ${{ matrix.arch }}
|
||||||
|
|
||||||
|
# Steps to run
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
# Checkout the repo
|
||||||
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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:
|
with:
|
||||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
fetch-depth: 0
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
|
||||||
registry: git.fascinated.cc
|
|
||||||
|
|
||||||
- name: Build Image
|
# Deploy to Dokku
|
||||||
uses: docker/build-push-action@v6
|
- name: Push to dokku
|
||||||
|
uses: dokku/github-action@master
|
||||||
with:
|
with:
|
||||||
context: .
|
git_remote_url: "ssh://dokku@51.158.63.74:22/ssr-website"
|
||||||
file: ./projects/website/Dockerfile
|
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
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
|
#name: "Deploy Website"
|
||||||
uses: azure/setup-kubectl@v4
|
#
|
||||||
id: install
|
#on:
|
||||||
|
# workflow_dispatch:
|
||||||
- name: Setup Kubernetes Context
|
# push:
|
||||||
uses: azure/k8s-set-context@v4
|
# branches:
|
||||||
with:
|
# - master
|
||||||
kubeconfig: ${{ secrets.KUBECONFIG }}
|
# paths:
|
||||||
|
# - projects/website/**
|
||||||
- name: Deploy to Kubernetes
|
# - projects/common/**
|
||||||
uses: Azure/k8s-deploy@v5
|
# - .gitea/kubernetes/website/**
|
||||||
with:
|
# - .gitea/workflows/deploy-website.yml
|
||||||
action: deploy
|
#
|
||||||
namespace: public-services
|
#jobs:
|
||||||
manifests: |
|
# deploy:
|
||||||
.gitea/kubernetes/website/deployment.yaml
|
# runs-on: ubuntu-latest
|
||||||
.gitea/kubernetes/website/service.yaml
|
# steps:
|
||||||
.gitea/kubernetes/website/ingress.yaml
|
# - name: Checkout code
|
||||||
images: |
|
# uses: actions/checkout@v4
|
||||||
git.fascinated.cc/fascinated/scoresaber-reloaded-website:${{ github.sha }}
|
#
|
||||||
|
# - 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 }}
|
||||||
|
@ -13,6 +13,7 @@
|
|||||||
"author": "fascinated7",
|
"author": "fascinated7",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"concurrently": "^9.0.1"
|
"concurrently": "^9.0.1",
|
||||||
|
"cross-env": "^7.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
FROM oven/bun:1.1.30-alpine AS base
|
FROM oven/bun:1.1.33-alpine AS base
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
FROM base AS depends
|
FROM base AS depends
|
||||||
@ -27,4 +27,8 @@ COPY --from=depends /app/projects/backend ./projects/backend
|
|||||||
# Lint before starting
|
# Lint before starting
|
||||||
RUN bun --filter 'backend' lint
|
RUN bun --filter 'backend' lint
|
||||||
|
|
||||||
|
ARG PORT=8080
|
||||||
|
ENV PORT $PORT
|
||||||
|
EXPOSE $PORT
|
||||||
|
|
||||||
CMD ["bun", "run", "--filter", "backend", "start"]
|
CMD ["bun", "run", "--filter", "backend", "start"]
|
||||||
|
@ -14,16 +14,16 @@
|
|||||||
"@elysiajs/swagger": "^1.1.3",
|
"@elysiajs/swagger": "^1.1.3",
|
||||||
"@ssr/common": "workspace:common",
|
"@ssr/common": "workspace:common",
|
||||||
"@tqman/nice-logger": "^1.0.1",
|
"@tqman/nice-logger": "^1.0.1",
|
||||||
|
"@typegoose/auto-increment": "^4.7.0",
|
||||||
"@typegoose/typegoose": "^12.8.0",
|
"@typegoose/typegoose": "^12.8.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.9.0",
|
"@typescript-eslint/eslint-plugin": "^8.9.0",
|
||||||
"@typescript-eslint/parser": "^8.9.0",
|
"@typescript-eslint/parser": "^8.9.0",
|
||||||
"@vercel/og": "^0.6.3",
|
"@vercel/og": "^0.6.3",
|
||||||
"discord-webhook-node": "^1.1.8",
|
"discordx": "^11.12.1",
|
||||||
"elysia": "latest",
|
"elysia": "latest",
|
||||||
"elysia-autoroutes": "^0.5.0",
|
"elysia-autoroutes": "^0.5.0",
|
||||||
"elysia-decorators": "^1.0.2",
|
"elysia-decorators": "^1.0.2",
|
||||||
"elysia-helmet": "^2.0.0",
|
"elysia-helmet": "^2.0.0",
|
||||||
"elysia-rate-limit": "^4.1.0",
|
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.1",
|
||||||
"extract-colors": "^4.1.0",
|
"extract-colors": "^4.1.0",
|
||||||
"jimp": "^1.6.0",
|
"jimp": "^1.6.0",
|
||||||
|
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!");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
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")
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +1,5 @@
|
|||||||
import { Controller, Get } from "elysia-decorators";
|
import { Controller, Get } from "elysia-decorators";
|
||||||
import { getAppVersion } from "../common/app-utils";
|
import { getAppVersion } from "../common/app.util";
|
||||||
import { AppService } from "../service/app.service";
|
import { AppService } from "../service/app.service";
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
|
@ -3,27 +3,36 @@ import { PlayerService } from "../service/player.service";
|
|||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import { PlayerHistory } from "@ssr/common/player/player-history";
|
import { PlayerHistory } from "@ssr/common/player/player-history";
|
||||||
import { PlayerTrackedSince } from "@ssr/common/player/player-tracked-since";
|
import { PlayerTrackedSince } from "@ssr/common/player/player-tracked-since";
|
||||||
|
import { AroundPlayerResponse } from "@ssr/common/response/around-player-response";
|
||||||
|
|
||||||
@Controller("/player")
|
@Controller("/player")
|
||||||
export default class PlayerController {
|
export default class PlayerController {
|
||||||
@Get("/history/:id", {
|
@Get("/history/:id/:days", {
|
||||||
config: {},
|
config: {},
|
||||||
params: t.Object({
|
params: t.Object({
|
||||||
id: t.String({ required: true }),
|
id: t.String({ required: true }),
|
||||||
|
days: t.Number({ default: 50, required: false }),
|
||||||
}),
|
}),
|
||||||
query: t.Object({
|
query: t.Object({
|
||||||
createIfMissing: t.Boolean({ default: false, required: false }),
|
createIfMissing: t.Boolean({ default: false, required: false }),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
public async getPlayer({
|
public async getPlayer({
|
||||||
params: { id },
|
params: { id, days },
|
||||||
query: { createIfMissing },
|
query: { createIfMissing },
|
||||||
}: {
|
}: {
|
||||||
params: { id: string };
|
params: { id: string; days: number };
|
||||||
query: { createIfMissing: boolean };
|
query: { createIfMissing: boolean };
|
||||||
}): Promise<{ statistics: Record<string, PlayerHistory> }> {
|
}): 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);
|
const player = await PlayerService.getPlayer(id, createIfMissing);
|
||||||
return { statistics: player.getHistoryPreviousDays(50) };
|
return { statistics: player.getHistoryPreviousDays(days) };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("/tracked/:id", {
|
@Get("/tracked/:id", {
|
||||||
@ -51,4 +60,21 @@ export default class PlayerController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,21 +0,0 @@
|
|||||||
import { Controller, Get } from "elysia-decorators";
|
|
||||||
import { t } from "elysia";
|
|
||||||
import { ReplayService } from "../service/replay.service";
|
|
||||||
|
|
||||||
@Controller("/replay")
|
|
||||||
export default class ReplayController {
|
|
||||||
@Get("/:playerId/:leaderboardId", {
|
|
||||||
config: {},
|
|
||||||
params: t.Object({
|
|
||||||
playerId: t.String({ required: true }),
|
|
||||||
leaderboardId: t.String({ required: true }),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
public async getOpenGraphImage({
|
|
||||||
params: { playerId, leaderboardId },
|
|
||||||
}: {
|
|
||||||
params: { playerId: string; leaderboardId: string };
|
|
||||||
}) {
|
|
||||||
return ReplayService.getReplay(playerId, leaderboardId);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get } from "elysia-decorators";
|
import { Controller, Get } from "elysia-decorators";
|
||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
import { Leaderboards } from "@ssr/common/leaderboard";
|
import { Leaderboards } from "@ssr/common/leaderboard";
|
||||||
|
import { TopScoresResponse } from "@ssr/common/response/top-scores-response";
|
||||||
import { ScoreService } from "../service/score.service";
|
import { ScoreService } from "../service/score.service";
|
||||||
|
|
||||||
@Controller("/scores")
|
@Controller("/scores")
|
||||||
@ -52,4 +53,35 @@ export default class ScoresController {
|
|||||||
}): Promise<unknown> {
|
}): Promise<unknown> {
|
||||||
return await ScoreService.getLeaderboardScores(leaderboard, id, page);
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,27 +3,25 @@ import cors from "@elysiajs/cors";
|
|||||||
import { decorators } from "elysia-decorators";
|
import { decorators } from "elysia-decorators";
|
||||||
import { logger } from "@tqman/nice-logger";
|
import { logger } from "@tqman/nice-logger";
|
||||||
import { swagger } from "@elysiajs/swagger";
|
import { swagger } from "@elysiajs/swagger";
|
||||||
import { rateLimit } from "elysia-rate-limit";
|
|
||||||
import { RateLimitError } from "./error/rate-limit-error";
|
|
||||||
import { helmet } from "elysia-helmet";
|
import { helmet } from "elysia-helmet";
|
||||||
import { etag } from "@bogeychan/elysia-etag";
|
import { etag } from "@bogeychan/elysia-etag";
|
||||||
import AppController from "./controller/app.controller";
|
import AppController from "./controller/app.controller";
|
||||||
import * as dotenv from "@dotenvx/dotenvx";
|
import * as dotenv from "@dotenvx/dotenvx";
|
||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
import { setLogLevel } from "@typegoose/typegoose";
|
|
||||||
import PlayerController from "./controller/player.controller";
|
import PlayerController from "./controller/player.controller";
|
||||||
import { PlayerService } from "./service/player.service";
|
import { PlayerService } from "./service/player.service";
|
||||||
import { cron } from "@elysiajs/cron";
|
import { cron } from "@elysiajs/cron";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { isProduction } from "@ssr/common/utils/utils";
|
||||||
import { delay } from "@ssr/common/utils/utils";
|
|
||||||
import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket";
|
|
||||||
import ImageController from "./controller/image.controller";
|
import ImageController from "./controller/image.controller";
|
||||||
import ReplayController from "./controller/replay.controller";
|
|
||||||
import { ScoreService } from "./service/score.service";
|
import { ScoreService } from "./service/score.service";
|
||||||
import { Config } from "@ssr/common/config";
|
import { Config } from "@ssr/common/config";
|
||||||
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
|
|
||||||
import ScoresController from "./controller/scores.controller";
|
import ScoresController from "./controller/scores.controller";
|
||||||
import LeaderboardController from "./controller/leaderboard.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
|
// Load .env file
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
@ -32,13 +30,33 @@ dotenv.config({
|
|||||||
override: true,
|
override: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Connect to Mongo
|
||||||
await mongoose.connect(Config.mongoUri!); // Connect to MongoDB
|
await mongoose.connect(Config.mongoUri!); // Connect to MongoDB
|
||||||
setLogLevel("DEBUG");
|
|
||||||
|
|
||||||
connectScoreSaberWebSocket({
|
// Connect to websockets
|
||||||
onScore: async playerScore => {
|
connectScoresaberWebsocket({
|
||||||
await PlayerService.trackScore(playerScore);
|
onScore: async score => {
|
||||||
await ScoreService.notifyNumberOne(playerScore);
|
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)}`)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -46,44 +64,22 @@ export const app = new Elysia();
|
|||||||
app.use(
|
app.use(
|
||||||
cron({
|
cron({
|
||||||
name: "player-statistics-tracker-cron",
|
name: "player-statistics-tracker-cron",
|
||||||
pattern: "1 0 * * *", // Every day at 00:01
|
pattern: "0 1 * * * *", // Every day at 00:01
|
||||||
timezone: "Europe/London", // UTC time
|
timezone: "Europe/London", // UTC time
|
||||||
|
protect: true,
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const pages = 20; // top 1000 players
|
await PlayerService.updatePlayerStatistics();
|
||||||
const cooldown = 60_000 / 250; // 250 requests per minute
|
},
|
||||||
|
})
|
||||||
let toTrack: PlayerDocument[] = await PlayerModel.find({});
|
);
|
||||||
const toRemoveIds: string[] = [];
|
app.use(
|
||||||
|
cron({
|
||||||
// loop through pages to fetch the top players
|
name: "player-scores-tracker-cron",
|
||||||
console.log(`Fetching ${pages} pages of players from ScoreSaber...`);
|
pattern: "0 4 * * *", // Every day at 04:00
|
||||||
for (let i = 0; i < pages; i++) {
|
timezone: "Europe/London", // UTC time
|
||||||
const pageNumber = i + 1;
|
protect: true,
|
||||||
console.log(`Fetching page ${pageNumber}...`);
|
run: async () => {
|
||||||
const page = await scoresaberService.lookupPlayers(pageNumber);
|
await PlayerService.refreshPlayerScores();
|
||||||
if (page === undefined) {
|
|
||||||
console.log(`Failed to fetch players on page ${pageNumber}, skipping page...`);
|
|
||||||
await delay(cooldown);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (const player of page.players) {
|
|
||||||
const foundPlayer = await PlayerService.getPlayer(player.id, true, player);
|
|
||||||
await PlayerService.trackScoreSaberPlayer(foundPlayer, player);
|
|
||||||
toRemoveIds.push(foundPlayer.id);
|
|
||||||
}
|
|
||||||
await delay(cooldown);
|
|
||||||
}
|
|
||||||
console.log(`Finished tracking player statistics for ${pages} pages, found ${toRemoveIds.length} players.`);
|
|
||||||
|
|
||||||
// remove all players that have been tracked
|
|
||||||
toTrack = toTrack.filter(player => !toRemoveIds.includes(player.id));
|
|
||||||
|
|
||||||
console.log(`Tracking ${toTrack.length} player statistics...`);
|
|
||||||
for (const player of toTrack) {
|
|
||||||
await PlayerService.trackScoreSaberPlayer(player);
|
|
||||||
await delay(cooldown);
|
|
||||||
}
|
|
||||||
console.log("Finished tracking player statistics.");
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -125,26 +121,6 @@ app.use(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limit (100 requests per minute)
|
|
||||||
*/
|
|
||||||
app.use(
|
|
||||||
rateLimit({
|
|
||||||
scoping: "global",
|
|
||||||
duration: 60 * 1000,
|
|
||||||
max: 100,
|
|
||||||
skip: request => {
|
|
||||||
// Skip requests to /
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars,prefer-const
|
|
||||||
let [_, path] = request.url.split("/"); // Get the url parts
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
||||||
path === "" || (path === undefined && (path = "/")); // If we're on /, the path is undefined, so we set it to /
|
|
||||||
return path === "/"; // ignore all requests to /
|
|
||||||
},
|
|
||||||
errorResponse: new RateLimitError("Too many requests, please try again later"),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Security settings
|
* Security settings
|
||||||
*/
|
*/
|
||||||
@ -161,24 +137,40 @@ app.use(
|
|||||||
*/
|
*/
|
||||||
app.use(
|
app.use(
|
||||||
decorators({
|
decorators({
|
||||||
controllers: [
|
controllers: [AppController, PlayerController, ImageController, ScoresController, LeaderboardController],
|
||||||
AppController,
|
|
||||||
PlayerController,
|
|
||||||
ImageController,
|
|
||||||
ReplayController,
|
|
||||||
ScoresController,
|
|
||||||
LeaderboardController,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Swagger Documentation
|
* Swagger Documentation
|
||||||
*/
|
*/
|
||||||
app.use(swagger());
|
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(() => {
|
app.onStart(async () => {
|
||||||
console.log("Listening on port http://localhost:8080");
|
console.log("Listening on port http://localhost:8080");
|
||||||
|
if (isProduction()) {
|
||||||
|
await initDiscordBot();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(8080);
|
app.listen({
|
||||||
|
port: 8080,
|
||||||
|
idleTimeout: 120, // 2 minutes
|
||||||
|
});
|
||||||
|
@ -1,15 +1,38 @@
|
|||||||
import { PlayerModel } from "@ssr/common/model/player";
|
import { PlayerModel } from "@ssr/common/model/player";
|
||||||
import { AppStatistics } from "@ssr/common/types/backend/app-statistics";
|
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 {
|
export class AppService {
|
||||||
/**
|
/**
|
||||||
* Gets the app statistics.
|
* Gets the app statistics.
|
||||||
*/
|
*/
|
||||||
public static async getAppStatistics(): Promise<AppStatistics> {
|
public static async getAppStatistics(): Promise<AppStatistics> {
|
||||||
const trackedPlayers = await PlayerModel.countDocuments();
|
if (statisticsCache.has("app-statistics")) {
|
||||||
|
return statisticsCache.get<AppStatistics>("app-statistics")!;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const trackedPlayers = await PlayerModel.countDocuments();
|
||||||
|
const trackedScores = await ScoreSaberScoreModel.countDocuments();
|
||||||
|
const additionalScoresData = await AdditionalScoreDataModel.countDocuments();
|
||||||
|
const cachedBeatSaverMaps = await BeatSaverMapModel.countDocuments();
|
||||||
|
const cachedScoreSaberLeaderboards = await ScoreSaberLeaderboardModel.countDocuments();
|
||||||
|
|
||||||
|
const response = {
|
||||||
trackedPlayers,
|
trackedPlayers,
|
||||||
|
trackedScores,
|
||||||
|
additionalScoresData,
|
||||||
|
cachedBeatSaverMaps,
|
||||||
|
cachedScoreSaberLeaderboards,
|
||||||
};
|
};
|
||||||
|
statisticsCache.set("app-statistics", response);
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,30 +1,98 @@
|
|||||||
import { beatsaverService } from "@ssr/common/service/impl/beatsaver";
|
import { beatsaverService } from "@ssr/common/service/impl/beatsaver";
|
||||||
import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/beatsaver-map";
|
import { BeatSaverMap, BeatSaverMapModel } from "@ssr/common/model/beatsaver/map";
|
||||||
|
|
||||||
export default class BeatSaverService {
|
export default class BeatSaverService {
|
||||||
/**
|
/**
|
||||||
* Gets a map by its hash.
|
* Gets a map by its hash, updates if necessary, or inserts if not found.
|
||||||
*
|
*
|
||||||
* @param hash the hash of the map
|
* @param hash the hash of the map
|
||||||
* @returns the beatsaver map
|
* @returns the beatsaver map, or undefined if not found
|
||||||
*/
|
*/
|
||||||
public static async getMap(hash: string): Promise<BeatSaverMap | undefined> {
|
public static async getMap(hash: string): Promise<BeatSaverMap | undefined> {
|
||||||
let map = await BeatSaverMapModel.findById(hash);
|
let map = await BeatSaverMapModel.findOne({
|
||||||
if (map != undefined) {
|
"versions.hash": hash.toUpperCase(),
|
||||||
return map.toObject() as BeatSaverMap;
|
});
|
||||||
|
|
||||||
|
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 token = await beatsaverService.lookupMap(hash);
|
||||||
if (token == undefined) {
|
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 undefined;
|
||||||
}
|
}
|
||||||
map = await BeatSaverMapModel.create({
|
|
||||||
_id: hash,
|
|
||||||
bsr: token.id,
|
|
||||||
author: {
|
|
||||||
id: token.uploader.id,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return map.toObject() as BeatSaverMap;
|
return map.toObject() as BeatSaverMap;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,17 +2,21 @@ import { ImageResponse } from "@vercel/og";
|
|||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
|
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
|
||||||
import { getDifficultyFromScoreSaberDifficulty } from "@ssr/common/utils/scoresaber-utils";
|
|
||||||
import { StarIcon } from "../../components/star-icon";
|
import { StarIcon } from "../../components/star-icon";
|
||||||
import { GlobeIcon } from "../../components/globe-icon";
|
import { GlobeIcon } from "../../components/globe-icon";
|
||||||
import NodeCache from "node-cache";
|
import ScoreSaberPlayer from "@ssr/common/player/impl/scoresaber-player";
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
|
||||||
import ScoreSaberPlayer, { getScoreSaberPlayerFromToken } from "@ssr/common/player/impl/scoresaber-player";
|
|
||||||
import { Jimp } from "jimp";
|
import { Jimp } from "jimp";
|
||||||
import { extractColors } from "extract-colors";
|
import { extractColors } from "extract-colors";
|
||||||
import { Config } from "@ssr/common/config";
|
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 NodeCache({ stdTTL: 60 * 60, checkperiod: 120 });
|
const cache = new SSRCache({
|
||||||
|
ttl: 1000 * 60 * 60, // 1 hour
|
||||||
|
});
|
||||||
const imageOptions = { width: 1200, height: 630 };
|
const imageOptions = { width: 1200, height: 630 };
|
||||||
|
|
||||||
export class ImageService {
|
export class ImageService {
|
||||||
@ -26,7 +30,7 @@ export class ImageService {
|
|||||||
public static async getAverageImageColor(src: string): Promise<{ color: string } | undefined> {
|
public static async getAverageImageColor(src: string): Promise<{ color: string } | undefined> {
|
||||||
src = decodeURIComponent(src);
|
src = decodeURIComponent(src);
|
||||||
|
|
||||||
return await this.fetchWithCache<{ color: string }>(`average_color-${src}`, async () => {
|
return await fetchWithCache<{ color: string }>(cache, `average_color-${src}`, async () => {
|
||||||
try {
|
try {
|
||||||
const image = await Jimp.read(src); // Load image using Jimp
|
const image = await Jimp.read(src); // Load image using Jimp
|
||||||
const { width, height, data } = image.bitmap; // Access image dimensions and pixel data
|
const { width, height, data } = image.bitmap; // Access image dimensions and pixel data
|
||||||
@ -54,28 +58,6 @@ export class ImageService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches data with caching.
|
|
||||||
*
|
|
||||||
* @param cacheKey The key used for caching.
|
|
||||||
* @param fetchFn The function to fetch data if it's not in cache.
|
|
||||||
*/
|
|
||||||
private static async fetchWithCache<T>(
|
|
||||||
cacheKey: string,
|
|
||||||
fetchFn: () => Promise<T | undefined>
|
|
||||||
): Promise<T | undefined> {
|
|
||||||
if (cache.has(cacheKey)) {
|
|
||||||
return cache.get<T>(cacheKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await fetchFn();
|
|
||||||
if (data) {
|
|
||||||
cache.set(cacheKey, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The base of the OpenGraph image
|
* The base of the OpenGraph image
|
||||||
*
|
*
|
||||||
@ -120,7 +102,7 @@ export class ImageService {
|
|||||||
* @param id the player's id
|
* @param id the player's id
|
||||||
*/
|
*/
|
||||||
public static async generatePlayerImage(id: string) {
|
public static async generatePlayerImage(id: string) {
|
||||||
const player = await this.fetchWithCache<ScoreSaberPlayer>(`player-${id}`, async () => {
|
const player = await fetchWithCache<ScoreSaberPlayer>(cache, `player-${id}`, async () => {
|
||||||
const token = await scoresaberService.lookupPlayer(id);
|
const token = await scoresaberService.lookupPlayer(id);
|
||||||
return token ? await getScoreSaberPlayerFromToken(token, Config.apiUrl) : undefined;
|
return token ? await getScoreSaberPlayerFromToken(token, Config.apiUrl) : undefined;
|
||||||
});
|
});
|
||||||
@ -147,7 +129,7 @@ export class ImageService {
|
|||||||
|
|
||||||
{/* Player PP */}
|
{/* Player PP */}
|
||||||
<div tw="flex justify-center items-center text-[33px]">
|
<div tw="flex justify-center items-center text-[33px]">
|
||||||
<p tw="text-[#606fff] m-0">{formatPp(player.pp)}pp</p>
|
<p tw="text-[#4858ff] m-0">{formatPp(player.pp)}pp</p>
|
||||||
{this.renderDailyChange(ppChange)}
|
{this.renderDailyChange(ppChange)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -194,12 +176,11 @@ export class ImageService {
|
|||||||
* @param id the leaderboard's id
|
* @param id the leaderboard's id
|
||||||
*/
|
*/
|
||||||
public static async generateLeaderboardImage(id: string) {
|
public static async generateLeaderboardImage(id: string) {
|
||||||
const leaderboard = await this.fetchWithCache<ScoreSaberLeaderboardToken>(`leaderboard-${id}`, () =>
|
const response = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>("scoresaber", id);
|
||||||
scoresaberService.lookupLeaderboard(id)
|
if (!response) {
|
||||||
);
|
|
||||||
if (!leaderboard) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
const { leaderboard } = response;
|
||||||
|
|
||||||
const ranked = leaderboard.stars > 0;
|
const ranked = leaderboard.stars > 0;
|
||||||
|
|
||||||
@ -207,7 +188,7 @@ export class ImageService {
|
|||||||
(
|
(
|
||||||
<ImageService.BaseImage>
|
<ImageService.BaseImage>
|
||||||
{/* Leaderboard Cover Image */}
|
{/* Leaderboard Cover Image */}
|
||||||
<img src={leaderboard.coverImage} width={256} height={256} alt="Leaderboard Cover" tw="rounded-full mb-3" />
|
<img src={leaderboard.songArt} width={256} height={256} alt="Leaderboard Cover" tw="rounded-full mb-3" />
|
||||||
|
|
||||||
{/* Leaderboard Name */}
|
{/* Leaderboard Name */}
|
||||||
<p tw="font-bold text-6xl m-0">
|
<p tw="font-bold text-6xl m-0">
|
||||||
@ -224,9 +205,7 @@ export class ImageService {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Leaderboard Difficulty */}
|
{/* Leaderboard Difficulty */}
|
||||||
<p tw={"font-bold m-0 text-4xl" + (ranked ? " pl-3" : "")}>
|
<p tw={"font-bold m-0 text-4xl" + (ranked ? " pl-3" : "")}>{leaderboard.difficulty.difficulty}</p>
|
||||||
{getDifficultyFromScoreSaberDifficulty(leaderboard.difficulty.difficulty)}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Leaderboard Author */}
|
{/* Leaderboard Author */}
|
||||||
|
@ -1,17 +1,16 @@
|
|||||||
import { Leaderboards } from "@ssr/common/leaderboard";
|
import { Leaderboards } from "@ssr/common/leaderboard";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import { SSRCache } from "@ssr/common/cache";
|
|
||||||
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
|
import { LeaderboardResponse } from "@ssr/common/response/leaderboard-response";
|
||||||
import Leaderboard from "@ssr/common/leaderboard/leaderboard";
|
|
||||||
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboardToken from "@ssr/common/types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
import { NotFoundError } from "elysia";
|
import { NotFoundError } from "elysia";
|
||||||
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
|
||||||
import BeatSaverService from "./beatsaver.service";
|
import BeatSaverService from "./beatsaver.service";
|
||||||
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
|
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
|
||||||
|
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators";
|
||||||
const leaderboardCache = new SSRCache({
|
import {
|
||||||
ttl: 1000 * 60 * 60 * 24,
|
ScoreSaberLeaderboard,
|
||||||
});
|
ScoreSaberLeaderboardModel,
|
||||||
|
} from "@ssr/common/model/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import Leaderboard from "@ssr/common/model/leaderboard/leaderboard";
|
||||||
|
|
||||||
export default class LeaderboardService {
|
export default class LeaderboardService {
|
||||||
/**
|
/**
|
||||||
@ -21,16 +20,9 @@ export default class LeaderboardService {
|
|||||||
* @param id the id
|
* @param id the id
|
||||||
*/
|
*/
|
||||||
private static async getLeaderboardToken<T>(leaderboard: Leaderboards, id: string): Promise<T | undefined> {
|
private static async getLeaderboardToken<T>(leaderboard: Leaderboards, id: string): Promise<T | undefined> {
|
||||||
const cacheKey = `${leaderboard}-${id}`;
|
|
||||||
if (leaderboardCache.has(cacheKey)) {
|
|
||||||
return leaderboardCache.get(cacheKey) as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (leaderboard) {
|
switch (leaderboard) {
|
||||||
case "scoresaber": {
|
case "scoresaber": {
|
||||||
const leaderboard = (await scoresaberService.lookupLeaderboard(id)) as T;
|
return (await scoresaberService.lookupLeaderboard(id)) as T;
|
||||||
leaderboardCache.set(cacheKey, leaderboard);
|
|
||||||
return leaderboard;
|
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
return undefined;
|
return undefined;
|
||||||
@ -45,23 +37,49 @@ export default class LeaderboardService {
|
|||||||
* @param id the players id
|
* @param id the players id
|
||||||
* @returns the scores
|
* @returns the scores
|
||||||
*/
|
*/
|
||||||
public static async getLeaderboard(
|
public static async getLeaderboard<L>(leaderboardName: Leaderboards, id: string): Promise<LeaderboardResponse<L>> {
|
||||||
leaderboardName: Leaderboards,
|
|
||||||
id: string
|
|
||||||
): Promise<LeaderboardResponse<Leaderboard>> {
|
|
||||||
let leaderboard: Leaderboard | undefined;
|
let leaderboard: Leaderboard | undefined;
|
||||||
let beatSaverMap: BeatSaverMap | undefined;
|
let beatSaverMap: BeatSaverMap | undefined;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
switch (leaderboardName) {
|
switch (leaderboardName) {
|
||||||
case "scoresaber": {
|
case "scoresaber": {
|
||||||
const leaderboardToken = await LeaderboardService.getLeaderboardToken<ScoreSaberLeaderboardToken>(
|
let foundLeaderboard = false;
|
||||||
leaderboardName,
|
const cachedLeaderboard = await ScoreSaberLeaderboardModel.findById(id);
|
||||||
id
|
if (cachedLeaderboard != null) {
|
||||||
);
|
leaderboard = cachedLeaderboard.toObject() as unknown as ScoreSaberLeaderboard;
|
||||||
if (leaderboardToken == undefined) {
|
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}"`);
|
throw new NotFoundError(`Leaderboard not found for "${id}"`);
|
||||||
}
|
}
|
||||||
leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
|
|
||||||
beatSaverMap = await BeatSaverService.getMap(leaderboard.songHash);
|
beatSaverMap = await BeatSaverService.getMap(leaderboard.songHash);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -71,7 +89,7 @@ export default class LeaderboardService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
leaderboard: leaderboard,
|
leaderboard: leaderboard as L,
|
||||||
beatsaver: beatSaverMap,
|
beatsaver: beatSaverMap,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,69 +1,82 @@
|
|||||||
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
|
import { PlayerDocument, PlayerModel } from "@ssr/common/model/player";
|
||||||
import { NotFoundError } from "../error/not-found-error";
|
import { NotFoundError } from "@ssr/common/error/not-found-error";
|
||||||
import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils";
|
import { getDaysAgoDate, getMidnightAlignedDate } from "@ssr/common/utils/time-utils";
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
import ScoreSaberPlayerToken from "@ssr/common/types/token/scoresaber/score-saber-player-token";
|
||||||
import { InternalServerError } from "../error/internal-server-error";
|
import { InternalServerError } from "@ssr/common/error/internal-server-error";
|
||||||
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
import { delay, getPageFromRank, isProduction } from "@ssr/common/utils/utils";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
import { AroundPlayer } from "@ssr/common/types/around-player";
|
||||||
// @ts-ignore
|
import { ScoreSort } from "@ssr/common/score/score-sort";
|
||||||
import { MessageBuilder, Webhook } from "discord-webhook-node";
|
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/token-creators";
|
||||||
import { formatPp } from "@ssr/common/utils/number-utils";
|
import { ScoreService } from "./score.service";
|
||||||
import { isProduction } from "@ssr/common/utils/utils";
|
import { logNewTrackedPlayer } from "../common/embds";
|
||||||
import { Config } from "@ssr/common/config";
|
|
||||||
|
const SCORESABER_REQUEST_COOLDOWN = 60_000 / 250; // 250 requests per minute
|
||||||
|
const accountCreationLock: { [id: string]: Promise<PlayerDocument> } = {};
|
||||||
|
|
||||||
export class PlayerService {
|
export class PlayerService {
|
||||||
/**
|
|
||||||
* Get a player from the database.
|
|
||||||
*
|
|
||||||
* @param id the player to fetch
|
|
||||||
* @param create if true, create the player if it doesn't exist
|
|
||||||
* @param playerToken an optional player token for the player
|
|
||||||
* @returns the player
|
|
||||||
* @throws NotFoundError if the player is not found
|
|
||||||
*/
|
|
||||||
public static async getPlayer(
|
public static async getPlayer(
|
||||||
id: string,
|
id: string,
|
||||||
create: boolean = false,
|
create: boolean = false,
|
||||||
playerToken?: ScoreSaberPlayerToken
|
playerToken?: ScoreSaberPlayerToken
|
||||||
): Promise<PlayerDocument> {
|
): 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);
|
let player: PlayerDocument | null = await PlayerModel.findById(id);
|
||||||
|
|
||||||
if (player === null) {
|
if (player === null) {
|
||||||
// If create is on, create the player, otherwise return unknown player
|
if (!create) {
|
||||||
playerToken = create ? (playerToken ? playerToken : await scoresaberService.lookupPlayer(id)) : undefined;
|
|
||||||
if (playerToken === undefined) {
|
|
||||||
throw new NotFoundError(`Player "${id}" not found`);
|
throw new NotFoundError(`Player "${id}" not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Creating player "${id}"...`);
|
playerToken = playerToken || (await scoresaberService.lookupPlayer(id));
|
||||||
try {
|
|
||||||
player = (await PlayerModel.create({ _id: id })) as PlayerDocument;
|
|
||||||
player.trackedSince = new Date();
|
|
||||||
await this.seedPlayerHistory(player, playerToken);
|
|
||||||
|
|
||||||
// Only notify in production
|
if (!playerToken) {
|
||||||
if (isProduction()) {
|
throw new NotFoundError(`Player "${id}" not found`);
|
||||||
const hook = new Webhook({
|
}
|
||||||
url: Config.trackedPlayerWebhook,
|
|
||||||
});
|
// Create a new lock promise and assign it
|
||||||
hook.setUsername("Player Tracker");
|
accountCreationLock[id] = (async () => {
|
||||||
const embed = new MessageBuilder();
|
let newPlayer: PlayerDocument;
|
||||||
embed.setTitle("New Player Tracked");
|
try {
|
||||||
embed.addField("Username", playerToken.name, true);
|
console.log(`Creating player "${id}"...`);
|
||||||
embed.addField("ID", playerToken.id, true);
|
newPlayer = (await PlayerModel.create({ _id: id })) as PlayerDocument;
|
||||||
embed.addField("PP", formatPp(playerToken.pp) + "pp", true);
|
newPlayer.trackedSince = new Date();
|
||||||
embed.setDescription(`https://ssr.fascinated.cc/player/${playerToken.id}`);
|
await newPlayer.save();
|
||||||
embed.setThumbnail(playerToken.profilePicture);
|
|
||||||
embed.setColor("#00ff00");
|
await this.seedPlayerHistory(newPlayer, playerToken);
|
||||||
await hook.send(embed);
|
await this.refreshAllPlayerScores(newPlayer);
|
||||||
|
|
||||||
|
// Notify in production
|
||||||
|
if (isProduction()) {
|
||||||
|
await logNewTrackedPlayer(playerToken);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`Failed to create player document for "${id}"`, err);
|
||||||
|
throw new InternalServerError(`Failed to create player document for "${id}"`);
|
||||||
|
} finally {
|
||||||
|
// Ensure the lock is always removed
|
||||||
|
delete accountCreationLock[id];
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
const message = `Failed to create player document for "${id}"`;
|
return newPlayer;
|
||||||
console.log(message, err);
|
})();
|
||||||
throw new InternalServerError(message);
|
|
||||||
|
// Wait for the player creation to complete
|
||||||
|
player = await accountCreationLock[id];
|
||||||
|
|
||||||
|
// Update player name
|
||||||
|
if (player.name !== playerToken.name) {
|
||||||
|
player.name = playerToken.name;
|
||||||
|
await player.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return player;
|
|
||||||
|
// Ensure that the player is now of type PlayerDocument
|
||||||
|
return player as PlayerDocument;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -80,9 +93,14 @@ export class PlayerService {
|
|||||||
});
|
});
|
||||||
playerRankHistory.push(playerToken.rank);
|
playerRankHistory.push(playerToken.rank);
|
||||||
|
|
||||||
let daysAgo = 1; // Start from yesterday
|
let daysAgo = 0; // Start from today
|
||||||
for (let i = playerRankHistory.length - daysAgo - 1; i >= 0; i--) {
|
for (let i = playerRankHistory.length - daysAgo - 1; i >= 0; i--) {
|
||||||
const rank = playerRankHistory[i];
|
const rank = playerRankHistory[i];
|
||||||
|
// Skip inactive days
|
||||||
|
if (rank == 999_999) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
|
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
|
||||||
player.setStatisticHistory(date, {
|
player.setStatisticHistory(date, {
|
||||||
rank: rank,
|
rank: rank,
|
||||||
@ -116,7 +134,7 @@ export class PlayerService {
|
|||||||
|
|
||||||
// Seed the history with ScoreSaber data if no history exists
|
// Seed the history with ScoreSaber data if no history exists
|
||||||
if (foundPlayer.getDaysTracked() === 0) {
|
if (foundPlayer.getDaysTracked() === 0) {
|
||||||
await this.seedPlayerHistory(foundPlayer, player);
|
await this.seedPlayerHistory(foundPlayer.id, player);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update current day's statistics
|
// Update current day's statistics
|
||||||
@ -124,13 +142,30 @@ export class PlayerService {
|
|||||||
if (history == undefined) {
|
if (history == undefined) {
|
||||||
history = {}; // Initialize if history is not found
|
history = {}; // Initialize if history is not found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scoreStats = player.scoreStats;
|
||||||
|
|
||||||
// Set the history data
|
// Set the history data
|
||||||
history.pp = player.pp;
|
history.pp = player.pp;
|
||||||
history.countryRank = player.countryRank;
|
history.countryRank = player.countryRank;
|
||||||
history.rank = player.rank;
|
history.rank = player.rank;
|
||||||
history.accuracy = {
|
history.accuracy = {
|
||||||
averageRankedAccuracy: player.scoreStats.averageRankedAccuracy,
|
...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.setStatisticHistory(dateToday, history);
|
||||||
foundPlayer.sortStatisticHistory();
|
foundPlayer.sortStatisticHistory();
|
||||||
foundPlayer.lastTracked = new Date();
|
foundPlayer.lastTracked = new Date();
|
||||||
@ -141,43 +176,196 @@ export class PlayerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track player score.
|
* Gets the players around a player.
|
||||||
*
|
*
|
||||||
* @param score the score to track
|
* @param id the player to get around
|
||||||
* @param leaderboard the leaderboard to track
|
* @param type the type to get around
|
||||||
*/
|
*/
|
||||||
public static async trackScore({ score, leaderboard }: ScoreSaberPlayerScoreToken) {
|
public static async getPlayersAroundPlayer(id: string, type: AroundPlayer): Promise<ScoreSaberPlayerToken[]> {
|
||||||
const playerId = score.leaderboardPlayerInfo.id;
|
const getRank = (player: ScoreSaberPlayerToken, type: AroundPlayer) => {
|
||||||
const playerName = score.leaderboardPlayerInfo.name;
|
switch (type) {
|
||||||
const player: PlayerDocument | null = await PlayerModel.findById(playerId);
|
case "global":
|
||||||
// Player is not tracked, so ignore the score.
|
return player.rank;
|
||||||
|
case "country":
|
||||||
|
return player.countryRank;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemsPerPage = 50;
|
||||||
|
const player = await scoresaberService.lookupPlayer(id);
|
||||||
if (player == undefined) {
|
if (player == undefined) {
|
||||||
return;
|
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 today = new Date();
|
const rankings: Map<string, ScoreSaberPlayerToken> = new Map();
|
||||||
let history = player.getHistoryByDate(today);
|
for (const page of pagesToSearch) {
|
||||||
if (history == undefined || Object.keys(history).length === 0) {
|
const response =
|
||||||
history = { scores: { rankedScores: 0, unrankedScores: 0 } }; // Ensure initialization
|
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 scores = history.scores || {};
|
const players = rankings
|
||||||
if (leaderboard.stars > 0) {
|
.values()
|
||||||
scores.rankedScores!++;
|
.toArray()
|
||||||
} else {
|
.sort((a, b) => {
|
||||||
scores.unrankedScores!++;
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
history.scores = scores;
|
return players.slice(start, end);
|
||||||
player.setStatisticHistory(today, history);
|
}
|
||||||
player.sortStatisticHistory();
|
|
||||||
|
|
||||||
// Save the changes
|
/**
|
||||||
player.markModified("statisticHistory");
|
* 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();
|
await player.save();
|
||||||
|
|
||||||
console.log(
|
console.log(`Finished refreshing scores for ${player.id}, total pages refreshed: ${page - 1}.`);
|
||||||
`Updated scores set statistic for "${playerName}"(${playerId}), scores today: ${scores.rankedScores} ranked, ${scores.unrankedScores} unranked`
|
}
|
||||||
);
|
|
||||||
|
/**
|
||||||
|
* 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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,27 +0,0 @@
|
|||||||
import ky from "ky";
|
|
||||||
import { NotFoundError } from "../error/not-found-error";
|
|
||||||
|
|
||||||
const SCORESABER_REPLAY_ENDPOINT = "https://scoresaber.com/api/game/telemetry/downloadReplay";
|
|
||||||
|
|
||||||
export class ReplayService {
|
|
||||||
/**
|
|
||||||
* Gets the app statistics.
|
|
||||||
*/
|
|
||||||
public static async getReplay(playerId: string, leaderboardId: string) {
|
|
||||||
const response = await ky.get(SCORESABER_REPLAY_ENDPOINT, {
|
|
||||||
searchParams: {
|
|
||||||
playerId,
|
|
||||||
leaderboardId,
|
|
||||||
},
|
|
||||||
headers: {
|
|
||||||
"User-Agent": "ScoreSaber-PC/3.3.13",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const replayData = await response.arrayBuffer();
|
|
||||||
if (replayData === undefined) {
|
|
||||||
throw new NotFoundError(`Replay for player "${playerId}" and leaderboard "${leaderboardId}" not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return replayData;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,25 +1,51 @@
|
|||||||
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score-saber-player-score-token";
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
import { formatNumberWithCommas, formatPp } from "@ssr/common/utils/number-utils";
|
||||||
// @ts-ignore
|
|
||||||
import { MessageBuilder, Webhook } from "discord-webhook-node";
|
|
||||||
import { formatPp } from "@ssr/common/utils/number-utils";
|
|
||||||
import { isProduction } from "@ssr/common/utils/utils";
|
import { isProduction } from "@ssr/common/utils/utils";
|
||||||
import { Config } from "@ssr/common/config";
|
|
||||||
import { Metadata } from "@ssr/common/types/metadata";
|
import { Metadata } from "@ssr/common/types/metadata";
|
||||||
import { NotFoundError } from "elysia";
|
import { NotFoundError } from "elysia";
|
||||||
import BeatSaverService from "./beatsaver.service";
|
import BeatSaverService from "./beatsaver.service";
|
||||||
import { getScoreSaberLeaderboardFromToken } from "@ssr/common/leaderboard/impl/scoresaber-leaderboard";
|
|
||||||
import { getScoreSaberScoreFromToken } from "@ssr/common/score/impl/scoresaber-score";
|
|
||||||
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
import { scoresaberService } from "@ssr/common/service/impl/scoresaber";
|
||||||
import { ScoreSort } from "@ssr/common/score/score-sort";
|
import { ScoreSort } from "@ssr/common/score/score-sort";
|
||||||
import { Leaderboards } from "@ssr/common/leaderboard";
|
import { Leaderboards } from "@ssr/common/leaderboard";
|
||||||
import Leaderboard from "@ssr/common/leaderboard/leaderboard";
|
|
||||||
import LeaderboardService from "./leaderboard.service";
|
import LeaderboardService from "./leaderboard.service";
|
||||||
import { BeatSaverMap } from "@ssr/common/model/beatsaver/beatsaver-map";
|
import { BeatSaverMap } from "@ssr/common/model/beatsaver/map";
|
||||||
import { PlayerScore } from "@ssr/common/score/player-score";
|
import { PlayerScore } from "@ssr/common/score/player-score";
|
||||||
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response";
|
||||||
import Score from "@ssr/common/score/score";
|
|
||||||
import PlayerScoresResponse from "@ssr/common/response/player-scores-response";
|
import 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 {
|
export class ScoreService {
|
||||||
/**
|
/**
|
||||||
@ -33,8 +59,10 @@ export class ScoreService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { score, leaderboard } = playerScore;
|
const { score: scoreToken, leaderboard: leaderboardToken } = playerScore;
|
||||||
const player = score.leaderboardPlayerInfo;
|
const leaderboard = getScoreSaberLeaderboardFromToken(leaderboardToken);
|
||||||
|
const score = getScoreSaberScoreFromToken(scoreToken, leaderboard, scoreToken.leaderboardPlayerInfo.id);
|
||||||
|
const playerInfo = score.playerInfo;
|
||||||
|
|
||||||
// Not ranked
|
// Not ranked
|
||||||
if (leaderboard.stars <= 0) {
|
if (leaderboard.stars <= 0) {
|
||||||
@ -45,152 +73,647 @@ export class ScoreService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hook = new Webhook({
|
const player = await scoresaberService.lookupPlayer(playerInfo.id);
|
||||||
url: Config.numberOneWebhook,
|
if (!player) {
|
||||||
});
|
return;
|
||||||
hook.setUsername("Number One Feed");
|
}
|
||||||
const embed = new MessageBuilder();
|
|
||||||
embed.setTitle(`${player.name} set a #${score.rank} on ${leaderboard.songName} ${leaderboard.songSubName}`);
|
await logToChannel(
|
||||||
embed.setDescription(`
|
DiscordChannels.numberOneFeed,
|
||||||
**Player:** https://ssr.fascinated.cc/player/${player.id}
|
new EmbedBuilder()
|
||||||
**Leaderboard:** https://ssr.fascinated.cc/leaderboard/${leaderboard.id}
|
.setTitle(`${player.name} just set a #1!`)
|
||||||
**PP:** ${formatPp(score.pp)}
|
.setDescription(
|
||||||
`);
|
[
|
||||||
embed.setThumbnail(leaderboard.coverImage);
|
`${leaderboard.songName} ${leaderboard.songSubName} (${leaderboard.difficulty.difficulty} ${leaderboard.stars.toFixed(2)}★)`,
|
||||||
embed.setColor("#00ff00");
|
`[[Player]](${Config.websiteUrl}/player/${player.id}) [[Leaderboard]](${Config.websiteUrl}/leaderboard/${leaderboard.id})`,
|
||||||
await hook.send(embed);
|
].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")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets scores for a player.
|
* Updates the players set scores count for today.
|
||||||
*
|
*
|
||||||
* @param leaderboardName the leaderboard to get the scores from
|
* @param score the score
|
||||||
* @param id the players id
|
|
||||||
* @param page the page to get
|
|
||||||
* @param sort the sort to use
|
|
||||||
* @param search the search to use
|
|
||||||
* @returns the scores
|
|
||||||
*/
|
*/
|
||||||
|
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(
|
public static async getPlayerScores(
|
||||||
leaderboardName: Leaderboards,
|
leaderboardName: Leaderboards,
|
||||||
id: string,
|
playerId: string,
|
||||||
page: number,
|
page: number,
|
||||||
sort: string,
|
sort: string,
|
||||||
search?: string
|
search?: string
|
||||||
): Promise<PlayerScoresResponse<unknown, unknown>> {
|
): Promise<PlayerScoresResponse<unknown, unknown> | undefined> {
|
||||||
const scores: PlayerScore<unknown, unknown>[] | undefined = [];
|
return fetchWithCache(
|
||||||
let beatSaverMap: BeatSaverMap | undefined;
|
playerScoresCache,
|
||||||
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
|
`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) {
|
switch (leaderboardName) {
|
||||||
case "scoresaber": {
|
case "scoresaber": {
|
||||||
const leaderboardScores = await scoresaberService.lookupPlayerScores({
|
const leaderboardScores = await scoresaberService.lookupPlayerScores({
|
||||||
playerId: id,
|
playerId,
|
||||||
page: page,
|
page,
|
||||||
sort: sort as ScoreSort,
|
sort: sort as ScoreSort,
|
||||||
search: search,
|
search,
|
||||||
});
|
});
|
||||||
if (leaderboardScores == undefined) {
|
if (leaderboardScores == undefined) {
|
||||||
throw new NotFoundError(
|
break;
|
||||||
`No scores found for "${id}", leaderboard "${leaderboardName}", page "${page}", sort "${sort}", search "${search}"`
|
}
|
||||||
);
|
|
||||||
|
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`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const token of leaderboardScores.playerScores) {
|
return {
|
||||||
const score = getScoreSaberScoreFromToken(token.score);
|
scores: scores,
|
||||||
if (score == undefined) {
|
metadata: metadata,
|
||||||
continue;
|
};
|
||||||
}
|
|
||||||
const tokenLeaderboard = getScoreSaberLeaderboardFromToken(token.leaderboard);
|
|
||||||
if (tokenLeaderboard == undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
beatSaverMap = await BeatSaverService.getMap(tokenLeaderboard.songHash);
|
|
||||||
|
|
||||||
scores.push({
|
|
||||||
score: score,
|
|
||||||
leaderboard: tokenLeaderboard,
|
|
||||||
beatSaver: beatSaverMap,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
metadata: metadata,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets scores for a leaderboard.
|
* Gets scores for a leaderboard.
|
||||||
*
|
*
|
||||||
* @param leaderboardName the leaderboard to get the scores from
|
* @param leaderboardName the leaderboard to get the scores from
|
||||||
* @param id the leaderboard id
|
* @param leaderboardId the leaderboard id
|
||||||
* @param page the page to get
|
* @param page the page to get
|
||||||
* @returns the scores
|
* @returns the scores
|
||||||
*/
|
*/
|
||||||
public static async getLeaderboardScores(
|
public static async getLeaderboardScores(
|
||||||
leaderboardName: Leaderboards,
|
leaderboardName: Leaderboards,
|
||||||
id: string,
|
leaderboardId: string,
|
||||||
page: number
|
page: number
|
||||||
): Promise<LeaderboardScoresResponse<unknown>> {
|
): Promise<LeaderboardScoresResponse<unknown, unknown> | undefined> {
|
||||||
const scores: Score[] = [];
|
return fetchWithCache(
|
||||||
let leaderboard: Leaderboard | undefined;
|
leaderboardScoresCache,
|
||||||
let beatSaverMap: BeatSaverMap | undefined;
|
`leaderboard-scores-${leaderboardName}-${leaderboardId}-${page}`,
|
||||||
let metadata: Metadata = new Metadata(0, 0, 0, 0); // Default values
|
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) {
|
switch (leaderboardName) {
|
||||||
case "scoresaber": {
|
case "scoresaber": {
|
||||||
const leaderboardScores = await scoresaberService.lookupLeaderboardScores(id, page);
|
const leaderboardResponse = await LeaderboardService.getLeaderboard<ScoreSaberLeaderboard>(
|
||||||
if (leaderboardScores == undefined) {
|
leaderboardName,
|
||||||
throw new NotFoundError(`No scores found for "${id}", leaderboard "${leaderboardName}", page "${page}""`);
|
leaderboardId
|
||||||
}
|
);
|
||||||
|
if (leaderboardResponse == undefined) {
|
||||||
|
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
||||||
|
}
|
||||||
|
leaderboard = leaderboardResponse.leaderboard;
|
||||||
|
beatSaverMap = leaderboardResponse.beatsaver;
|
||||||
|
|
||||||
const leaderboardResponse = await LeaderboardService.getLeaderboard(leaderboardName, id);
|
const leaderboardScores = await scoresaberService.lookupLeaderboardScores(leaderboardId, page);
|
||||||
if (leaderboardResponse == undefined) {
|
if (leaderboardScores == undefined) {
|
||||||
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
break;
|
||||||
}
|
}
|
||||||
leaderboard = leaderboardResponse.leaderboard;
|
|
||||||
beatSaverMap = leaderboardResponse.beatsaver;
|
|
||||||
|
|
||||||
for (const token of leaderboardScores.scores) {
|
for (const token of leaderboardScores.scores) {
|
||||||
const score = getScoreSaberScoreFromToken(token);
|
const score = getScoreSaberScoreFromToken(
|
||||||
if (score == undefined) {
|
token,
|
||||||
continue;
|
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`);
|
||||||
}
|
}
|
||||||
scores.push(score);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata = new Metadata(
|
return {
|
||||||
Math.ceil(leaderboardScores.metadata.total / leaderboardScores.metadata.itemsPerPage),
|
scores: scores,
|
||||||
leaderboardScores.metadata.total,
|
leaderboard: leaderboard,
|
||||||
leaderboardScores.metadata.page,
|
beatSaver: beatSaverMap,
|
||||||
leaderboardScores.metadata.itemsPerPage
|
metadata: metadata,
|
||||||
);
|
};
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
throw new NotFoundError(`Leaderboard "${leaderboardName}" not found`);
|
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
return {
|
||||||
scores: scores,
|
score: previousScore.score,
|
||||||
leaderboard: leaderboard,
|
accuracy: previousScore.accuracy,
|
||||||
beatSaver: beatSaverMap,
|
modifiers: previousScore.modifiers,
|
||||||
metadata: metadata,
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,8 +19,9 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@typegoose/auto-increment": "^4.7.0",
|
||||||
|
"@typegoose/typegoose": "^12.8.0",
|
||||||
"ky": "^1.7.2",
|
"ky": "^1.7.2",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0"
|
||||||
"@typegoose/typegoose": "^12.8.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
type CacheOptions = {
|
type CacheOptions = {
|
||||||
/**
|
/**
|
||||||
* The time the cached object will be valid for
|
* The time (in ms) the cached object will be valid for
|
||||||
*/
|
*/
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
|
|
||||||
|
@ -8,7 +8,6 @@ export const Config = {
|
|||||||
/**
|
/**
|
||||||
* Backend
|
* Backend
|
||||||
*/
|
*/
|
||||||
trackedPlayerWebhook: process.env.TRACKED_PLAYERS_WEBHOOK,
|
|
||||||
numberOneWebhook: process.env.NUMBER_ONE_WEBHOOK,
|
|
||||||
mongoUri: process.env.MONGO_URI,
|
mongoUri: process.env.MONGO_URI,
|
||||||
|
discordBotToken: process.env.DISCORD_BOT_TOKEN,
|
||||||
} as const;
|
} as const;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { HttpCode } from "../common/http-codes";
|
import { HttpCode } from "../http-codes";
|
||||||
|
|
||||||
export class InternalServerError extends Error {
|
export class InternalServerError extends Error {
|
||||||
constructor(
|
constructor(
|
@ -1,4 +1,4 @@
|
|||||||
import { HttpCode } from "../common/http-codes";
|
import { HttpCode } from "../http-codes";
|
||||||
|
|
||||||
export class NotFoundError extends Error {
|
export class NotFoundError extends Error {
|
||||||
constructor(
|
constructor(
|
@ -1,4 +1,4 @@
|
|||||||
import { HttpCode } from "../common/http-codes";
|
import { HttpCode } from "../http-codes";
|
||||||
|
|
||||||
export class RateLimitError extends Error {
|
export class RateLimitError extends Error {
|
||||||
constructor(
|
constructor(
|
@ -1,63 +0,0 @@
|
|||||||
import Leaderboard from "../leaderboard";
|
|
||||||
import LeaderboardDifficulty from "../leaderboard-difficulty";
|
|
||||||
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
|
|
||||||
import { getDifficultyFromScoreSaberDifficulty } from "../../utils/scoresaber-utils";
|
|
||||||
import { parseDate } from "../../utils/time-utils";
|
|
||||||
|
|
||||||
export default interface ScoreSaberLeaderboard extends Leaderboard {
|
|
||||||
/**
|
|
||||||
* The star count for the leaderboard.
|
|
||||||
*/
|
|
||||||
readonly stars: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The total amount of plays.
|
|
||||||
*/
|
|
||||||
readonly plays: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The amount of plays today.
|
|
||||||
*/
|
|
||||||
readonly dailyPlays: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a {@link ScoreSaberLeaderboardToken} into a {@link ScoreSaberLeaderboard}.
|
|
||||||
*
|
|
||||||
* @param token the token to parse
|
|
||||||
*/
|
|
||||||
export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardToken): ScoreSaberLeaderboard {
|
|
||||||
const difficulty: LeaderboardDifficulty = {
|
|
||||||
leaderboardId: token.difficulty.leaderboardId,
|
|
||||||
difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty),
|
|
||||||
gameMode: token.difficulty.gameMode,
|
|
||||||
difficultyRaw: token.difficulty.difficultyRaw,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
id: token.id,
|
|
||||||
songHash: token.songHash,
|
|
||||||
songName: token.songName,
|
|
||||||
songSubName: token.songSubName,
|
|
||||||
songAuthorName: token.songAuthorName,
|
|
||||||
levelAuthorName: token.levelAuthorName,
|
|
||||||
difficulty: difficulty,
|
|
||||||
difficulties:
|
|
||||||
token.difficulties != undefined && token.difficulties.length > 0
|
|
||||||
? token.difficulties.map(difficulty => {
|
|
||||||
return {
|
|
||||||
leaderboardId: difficulty.leaderboardId,
|
|
||||||
difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty),
|
|
||||||
gameMode: difficulty.gameMode,
|
|
||||||
difficultyRaw: difficulty.difficultyRaw,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
: [difficulty],
|
|
||||||
maxScore: token.maxScore,
|
|
||||||
ranked: token.ranked,
|
|
||||||
songArt: token.coverImage,
|
|
||||||
timestamp: parseDate(token.createdDate),
|
|
||||||
stars: token.stars,
|
|
||||||
plays: token.plays,
|
|
||||||
dailyPlays: token.dailyPlays,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
import { Difficulty } from "../score/difficulty";
|
|
||||||
|
|
||||||
export default interface LeaderboardDifficulty {
|
|
||||||
/**
|
|
||||||
* The id of the leaderboard.
|
|
||||||
*/
|
|
||||||
leaderboardId: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The difficulty of the leaderboard.
|
|
||||||
*/
|
|
||||||
difficulty: Difficulty;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The game mode of the leaderboard.
|
|
||||||
*/
|
|
||||||
gameMode: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The raw difficulty of the leaderboard.
|
|
||||||
*/
|
|
||||||
difficultyRaw: string;
|
|
||||||
}
|
|
@ -1,75 +0,0 @@
|
|||||||
import LeaderboardDifficulty from "./leaderboard-difficulty";
|
|
||||||
|
|
||||||
export default interface Leaderboard {
|
|
||||||
/**
|
|
||||||
* The id of the leaderboard.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly id: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The hash of the song this leaderboard is for.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly songHash: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The name of the song this leaderboard is for.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly songName: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The sub name of the leaderboard.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly songSubName: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The author of the song this leaderboard is for.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly songAuthorName: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The author of the level this leaderboard is for.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly levelAuthorName: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The difficulty of the leaderboard.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly difficulty: LeaderboardDifficulty;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The difficulties of the leaderboard.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly difficulties: LeaderboardDifficulty[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The maximum score of the leaderboard.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly maxScore: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether the leaderboard is ranked.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly ranked: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The link to the song art.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly songArt: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The date the leaderboard was created.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly timestamp: Date;
|
|
||||||
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,13 +0,0 @@
|
|||||||
import { prop } from "@typegoose/typegoose";
|
|
||||||
|
|
||||||
export default class BeatsaverAuthor {
|
|
||||||
/**
|
|
||||||
* The id of the author.
|
|
||||||
*/
|
|
||||||
@prop({ required: true })
|
|
||||||
id: number;
|
|
||||||
|
|
||||||
constructor(id: number) {
|
|
||||||
this.id = id;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,51 +0,0 @@
|
|||||||
import { getModelForClass, modelOptions, prop, ReturnModelType, Severity } from "@typegoose/typegoose";
|
|
||||||
import { Document } from "mongoose";
|
|
||||||
import BeatsaverAuthor from "./beatsaver-author";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The model for a BeatSaver map.
|
|
||||||
*/
|
|
||||||
@modelOptions({
|
|
||||||
options: { allowMixed: Severity.ALLOW },
|
|
||||||
schemaOptions: {
|
|
||||||
toObject: {
|
|
||||||
virtuals: true,
|
|
||||||
transform: function (_, ret) {
|
|
||||||
ret.id = ret._id;
|
|
||||||
delete ret._id;
|
|
||||||
delete ret.__v;
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export class BeatSaverMap {
|
|
||||||
/**
|
|
||||||
* The internal MongoDB ID (_id).
|
|
||||||
*/
|
|
||||||
@prop({ required: true })
|
|
||||||
private _id!: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The bsr code for the map.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
@prop({ required: true })
|
|
||||||
public bsr!: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The author of the map.
|
|
||||||
*/
|
|
||||||
@prop({ required: true, _id: false, type: () => BeatsaverAuthor })
|
|
||||||
public author!: BeatsaverAuthor;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exposes `id` as a virtual field mapped from `_id`.
|
|
||||||
*/
|
|
||||||
public get id(): string {
|
|
||||||
return this._id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BeatSaverMapDocument = BeatSaverMap & Document;
|
|
||||||
export const BeatSaverMapModel: ReturnModelType<typeof BeatSaverMap> = getModelForClass(BeatSaverMap);
|
|
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;
|
||||||
|
}
|
||||||
|
}
|
@ -14,12 +14,24 @@ export class Player {
|
|||||||
@prop()
|
@prop()
|
||||||
public _id!: string;
|
public _id!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's name.
|
||||||
|
*/
|
||||||
|
@prop()
|
||||||
|
public name?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The player's statistic history.
|
* The player's statistic history.
|
||||||
*/
|
*/
|
||||||
@prop()
|
@prop()
|
||||||
private statisticHistory?: Record<string, PlayerHistory>;
|
private statisticHistory?: Record<string, PlayerHistory>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the player has their scores seeded.
|
||||||
|
*/
|
||||||
|
@prop()
|
||||||
|
public seededScores?: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The date the player was last tracked.
|
* The date the player was last tracked.
|
||||||
*/
|
*/
|
||||||
@ -63,7 +75,7 @@ export class Player {
|
|||||||
const statisticHistory = this.getStatisticHistory();
|
const statisticHistory = this.getStatisticHistory();
|
||||||
const history: Record<string, PlayerHistory> = {};
|
const history: Record<string, PlayerHistory> = {};
|
||||||
|
|
||||||
for (let i = 0; i < days; i++) {
|
for (let i = 0; i <= days; i++) {
|
||||||
const date = formatDateMinimal(getMidnightAlignedDate(getDaysAgoDate(i)));
|
const date = formatDateMinimal(getMidnightAlignedDate(getDaysAgoDate(i)));
|
||||||
const playerHistory = statisticHistory[date];
|
const playerHistory = statisticHistory[date];
|
||||||
if (playerHistory !== undefined && Object.keys(playerHistory).length > 0) {
|
if (playerHistory !== undefined && Object.keys(playerHistory).length > 0) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,5 @@
|
|||||||
import Player, { StatisticChange } from "../player";
|
import Player, { StatisticChange } from "../player";
|
||||||
import ky from "ky";
|
|
||||||
import { PlayerHistory } from "../player-history";
|
import { PlayerHistory } from "../player-history";
|
||||||
import ScoreSaberPlayerToken from "../../types/token/scoresaber/score-saber-player-token";
|
|
||||||
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate } from "../../utils/time-utils";
|
|
||||||
import { getPageFromRank } from "../../utils/utils";
|
|
||||||
import { Config } from "../../config";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A ScoreSaber player.
|
* A ScoreSaber player.
|
||||||
@ -28,7 +23,7 @@ export default interface ScoreSaberPlayer extends Player {
|
|||||||
/**
|
/**
|
||||||
* The role the player has.
|
* The role the player has.
|
||||||
*/
|
*/
|
||||||
role: ScoreSaberRole | undefined;
|
role: string | undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The badges the player has.
|
* The badges the player has.
|
||||||
@ -72,206 +67,6 @@ export default interface ScoreSaberPlayer extends Player {
|
|||||||
isBeingTracked?: boolean;
|
isBeingTracked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the ScoreSaber Player from an {@link ScoreSaberPlayerToken}.
|
|
||||||
*
|
|
||||||
* @param token the player token
|
|
||||||
* @param playerIdCookie the id of the claimed player
|
|
||||||
*/
|
|
||||||
export async function getScoreSaberPlayerFromToken(
|
|
||||||
token: ScoreSaberPlayerToken,
|
|
||||||
playerIdCookie?: string
|
|
||||||
): Promise<ScoreSaberPlayer> {
|
|
||||||
const bio: ScoreSaberBio = {
|
|
||||||
lines: token.bio?.split("\n") || [],
|
|
||||||
linesStripped: token.bio?.replace(/<[^>]+>/g, "")?.split("\n") || [],
|
|
||||||
};
|
|
||||||
const role = token.role == null ? undefined : (token.role as ScoreSaberRole);
|
|
||||||
const badges: ScoreSaberBadge[] =
|
|
||||||
token.badges?.map(badge => {
|
|
||||||
return {
|
|
||||||
url: badge.image,
|
|
||||||
description: badge.description,
|
|
||||||
};
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
let isBeingTracked = false;
|
|
||||||
const todayDate = formatDateMinimal(getMidnightAlignedDate(new Date()));
|
|
||||||
let statisticHistory: { [key: string]: PlayerHistory } = {};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { statistics: history } = await ky
|
|
||||||
.get<{
|
|
||||||
statistics: { [key: string]: PlayerHistory };
|
|
||||||
}>(
|
|
||||||
`${Config.apiUrl}/player/history/${token.id}${playerIdCookie && playerIdCookie == token.id ? "?createIfMissing=true" : ""}`
|
|
||||||
)
|
|
||||||
.json();
|
|
||||||
if (history) {
|
|
||||||
// Use the latest data for today
|
|
||||||
history[todayDate] = {
|
|
||||||
...{
|
|
||||||
scores: {
|
|
||||||
rankedScores: 0,
|
|
||||||
unrankedScores: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...history[todayDate],
|
|
||||||
rank: token.rank,
|
|
||||||
countryRank: token.countryRank,
|
|
||||||
pp: token.pp,
|
|
||||||
accuracy: {
|
|
||||||
averageRankedAccuracy: token.scoreStats.averageRankedAccuracy,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
isBeingTracked = true;
|
|
||||||
}
|
|
||||||
statisticHistory = history;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
const playerRankHistory = token.histories.split(",").map(value => {
|
|
||||||
return parseInt(value);
|
|
||||||
});
|
|
||||||
playerRankHistory.push(token.rank);
|
|
||||||
|
|
||||||
let missingDays = 0;
|
|
||||||
let daysAgo = 0; // Start from current day
|
|
||||||
for (let i = playerRankHistory.length - 1; i >= 0; i--) {
|
|
||||||
const rank = playerRankHistory[i];
|
|
||||||
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
|
|
||||||
daysAgo += 1;
|
|
||||||
|
|
||||||
const dateKey = formatDateMinimal(date);
|
|
||||||
if (!statisticHistory[dateKey] || statisticHistory[dateKey].rank == undefined) {
|
|
||||||
missingDays += 1;
|
|
||||||
statisticHistory[dateKey] = {
|
|
||||||
...statisticHistory[dateKey],
|
|
||||||
rank: rank,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingDays > 0 && missingDays != playerRankHistory.length) {
|
|
||||||
console.log(
|
|
||||||
`Player has ${missingDays} missing day${missingDays > 1 ? "s" : ""}, filling in with fallback history...`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort the fallback history
|
|
||||||
statisticHistory = Object.entries(statisticHistory)
|
|
||||||
.sort((a, b) => Date.parse(b[0]) - Date.parse(a[0]))
|
|
||||||
.reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the change in the given stat
|
|
||||||
*
|
|
||||||
* @param statType the stat to check
|
|
||||||
* @param daysAgo the amount of days ago to get the stat for
|
|
||||||
* @return the change
|
|
||||||
*/
|
|
||||||
const getChange = (statType: "rank" | "countryRank" | "pp", daysAgo: number = 1): number | undefined => {
|
|
||||||
const todayStats = statisticHistory[todayDate];
|
|
||||||
let otherDate: Date | undefined;
|
|
||||||
|
|
||||||
// Use the same logic as the first version to get the date exactly 'daysAgo' days earlier
|
|
||||||
if (daysAgo === 1) {
|
|
||||||
otherDate = getMidnightAlignedDate(getDaysAgoDate(1)); // Yesterday
|
|
||||||
} else {
|
|
||||||
const targetDate = getDaysAgoDate(daysAgo);
|
|
||||||
|
|
||||||
// Filter available dates to find the closest one to the target
|
|
||||||
const availableDates = Object.keys(statisticHistory)
|
|
||||||
.map(dateKey => new Date(dateKey))
|
|
||||||
.filter(date => {
|
|
||||||
// Convert date back to the correct format for statisticHistory lookup
|
|
||||||
const formattedDate = formatDateMinimal(date);
|
|
||||||
const statsForDate = statisticHistory[formattedDate];
|
|
||||||
const hasStat = statsForDate && statType in statsForDate;
|
|
||||||
|
|
||||||
// Only consider past dates with the required statType
|
|
||||||
const isPast = date.getTime() < new Date().getTime();
|
|
||||||
return hasStat && isPast;
|
|
||||||
});
|
|
||||||
|
|
||||||
// If no valid dates are found, return undefined
|
|
||||||
if (availableDates.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the closest date from the filtered available dates
|
|
||||||
otherDate = availableDates.reduce((closestDate, currentDate) => {
|
|
||||||
const currentDiff = Math.abs(currentDate.getTime() - targetDate.getTime());
|
|
||||||
const closestDiff = Math.abs(closestDate.getTime() - targetDate.getTime());
|
|
||||||
return currentDiff < closestDiff ? currentDate : closestDate;
|
|
||||||
}, availableDates[0]); // Start with the first available date
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure todayStats exists and contains the statType
|
|
||||||
if (!todayStats || !(statType in todayStats)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherStats = statisticHistory[formatDateMinimal(otherDate)]; // This is now validated
|
|
||||||
|
|
||||||
// Ensure otherStats exists and contains the statType
|
|
||||||
if (!otherStats || !(statType in otherStats)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const statToday = todayStats[statType];
|
|
||||||
const statOther = otherStats[statType];
|
|
||||||
|
|
||||||
if (statToday === undefined || statOther === undefined) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the difference, accounting for negative changes in ranks
|
|
||||||
return (statToday - statOther) * (statType === "pp" ? 1 : -1);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: token.id,
|
|
||||||
name: token.name,
|
|
||||||
avatar: token.profilePicture,
|
|
||||||
country: token.country,
|
|
||||||
rank: token.rank,
|
|
||||||
countryRank: token.countryRank,
|
|
||||||
joinedDate: new Date(token.firstSeen),
|
|
||||||
bio: bio,
|
|
||||||
pp: token.pp,
|
|
||||||
statisticChange: {
|
|
||||||
daily: {
|
|
||||||
rank: getChange("rank", 1),
|
|
||||||
countryRank: getChange("countryRank", 1),
|
|
||||||
pp: getChange("pp", 1),
|
|
||||||
},
|
|
||||||
weekly: {
|
|
||||||
rank: getChange("rank", 7),
|
|
||||||
countryRank: getChange("countryRank", 7),
|
|
||||||
pp: getChange("pp", 7),
|
|
||||||
},
|
|
||||||
monthly: {
|
|
||||||
rank: getChange("rank", 30),
|
|
||||||
countryRank: getChange("countryRank", 30),
|
|
||||||
pp: getChange("pp", 30),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
role: role,
|
|
||||||
badges: badges,
|
|
||||||
statisticHistory: statisticHistory,
|
|
||||||
statistics: token.scoreStats,
|
|
||||||
rankPages: {
|
|
||||||
global: getPageFromRank(token.rank, 50),
|
|
||||||
country: getPageFromRank(token.countryRank, 50),
|
|
||||||
},
|
|
||||||
permissions: token.permissions,
|
|
||||||
banned: token.banned,
|
|
||||||
inactive: token.inactive,
|
|
||||||
isBeingTracked: isBeingTracked,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A bio of a player.
|
* A bio of a player.
|
||||||
*/
|
*/
|
||||||
@ -287,11 +82,6 @@ export type ScoreSaberBio = {
|
|||||||
linesStripped: string[];
|
linesStripped: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* The ScoreSaber account roles.
|
|
||||||
*/
|
|
||||||
export type ScoreSaberRole = "Admin";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A badge for a player.
|
* A badge for a player.
|
||||||
*/
|
*/
|
||||||
|
@ -15,7 +15,27 @@ export interface PlayerHistory {
|
|||||||
pp?: number;
|
pp?: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The amount of scores set for this day.
|
* How many times replays of the player scores have been watched
|
||||||
|
*/
|
||||||
|
replaysWatched?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's score stats.
|
||||||
|
*/
|
||||||
|
score?: {
|
||||||
|
/**
|
||||||
|
* The total amount of unranked and ranked score.
|
||||||
|
*/
|
||||||
|
totalScore?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total amount of ranked score.
|
||||||
|
*/
|
||||||
|
totalRankedScore?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player's scores stats.
|
||||||
*/
|
*/
|
||||||
scores?: {
|
scores?: {
|
||||||
/**
|
/**
|
||||||
@ -27,10 +47,20 @@ export interface PlayerHistory {
|
|||||||
* The amount of unranked scores set.
|
* The amount of unranked scores set.
|
||||||
*/
|
*/
|
||||||
unrankedScores?: number;
|
unrankedScores?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total amount of ranked scores
|
||||||
|
*/
|
||||||
|
totalRankedScores?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total amount of scores
|
||||||
|
*/
|
||||||
|
totalScores?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The player's accuracy.
|
* The player's accuracy stats.
|
||||||
*/
|
*/
|
||||||
accuracy?: {
|
accuracy?: {
|
||||||
/**
|
/**
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
@ -55,8 +55,7 @@ export default class Player {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ChangeRange = "daily" | "weekly" | "monthly";
|
||||||
export type StatisticChange = {
|
export type StatisticChange = {
|
||||||
daily: PlayerHistory;
|
[key in ChangeRange]: PlayerHistory;
|
||||||
weekly: PlayerHistory;
|
|
||||||
monthly: 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[];
|
||||||
|
};
|
@ -1,4 +1,4 @@
|
|||||||
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
|
import { BeatSaverMap } from "../model/beatsaver/map";
|
||||||
|
|
||||||
export type LeaderboardResponse<L> = {
|
export type LeaderboardResponse<L> = {
|
||||||
/**
|
/**
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import { Metadata } from "../types/metadata";
|
import { Metadata } from "../types/metadata";
|
||||||
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
|
import { BeatSaverMap } from "../model/beatsaver/map";
|
||||||
import Score from "../score/score";
|
|
||||||
|
|
||||||
export default interface LeaderboardScoresResponse<L> {
|
export default interface LeaderboardScoresResponse<S, L> {
|
||||||
/**
|
/**
|
||||||
* The scores that were set.
|
* The scores that were set.
|
||||||
*/
|
*/
|
||||||
readonly scores: Score[];
|
readonly scores: S[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The leaderboard that was used.
|
* The leaderboard that was used.
|
||||||
|
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 +0,0 @@
|
|||||||
export type Difficulty = "Easy" | "Normal" | "Hard" | "Expert" | "Expert+" | "Unknown";
|
|
@ -1,62 +0,0 @@
|
|||||||
import Score from "../score";
|
|
||||||
import { Modifier } from "../modifier";
|
|
||||||
import ScoreSaberScoreToken from "../../types/token/scoresaber/score-saber-score-token";
|
|
||||||
import ScoreSaberLeaderboardPlayerInfoToken from "../../types/token/scoresaber/score-saber-leaderboard-player-info-token";
|
|
||||||
|
|
||||||
export default interface ScoreSaberScore extends Score {
|
|
||||||
/**
|
|
||||||
* The score's id.
|
|
||||||
*/
|
|
||||||
readonly id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The amount of pp for the score.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly pp: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The weight of the score, or undefined if not ranked.s
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly weight?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The player who set the score
|
|
||||||
*/
|
|
||||||
readonly playerInfo: ScoreSaberLeaderboardPlayerInfoToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets a {@link ScoreSaberScore} from a {@link ScoreSaberScoreToken}.
|
|
||||||
*
|
|
||||||
* @param token the token to convert
|
|
||||||
*/
|
|
||||||
export function getScoreSaberScoreFromToken(token: ScoreSaberScoreToken): ScoreSaberScore {
|
|
||||||
const modifiers: Modifier[] =
|
|
||||||
token.modifiers == undefined || token.modifiers === ""
|
|
||||||
? []
|
|
||||||
: token.modifiers.split(",").map(mod => {
|
|
||||||
mod = mod.toUpperCase();
|
|
||||||
const modifier = Modifier[mod as keyof typeof Modifier];
|
|
||||||
if (modifier === undefined) {
|
|
||||||
throw new Error(`Unknown modifier: ${mod}`);
|
|
||||||
}
|
|
||||||
return modifier;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
leaderboard: "scoresaber",
|
|
||||||
score: token.baseScore,
|
|
||||||
rank: token.rank,
|
|
||||||
modifiers: modifiers,
|
|
||||||
misses: token.missedNotes,
|
|
||||||
badCuts: token.badCuts,
|
|
||||||
fullCombo: token.fullCombo,
|
|
||||||
timestamp: new Date(token.timeSet),
|
|
||||||
id: token.id,
|
|
||||||
pp: token.pp,
|
|
||||||
weight: token.weight,
|
|
||||||
playerInfo: token.leaderboardPlayerInfo,
|
|
||||||
};
|
|
||||||
}
|
|
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,19 +2,18 @@
|
|||||||
* The score modifiers.
|
* The score modifiers.
|
||||||
*/
|
*/
|
||||||
export enum Modifier {
|
export enum Modifier {
|
||||||
DA = "Disappearing Arrows",
|
NF = "No Fail",
|
||||||
|
PM = "Pro Mode",
|
||||||
FS = "Faster Song",
|
FS = "Faster Song",
|
||||||
SF = "Super Fast Song",
|
SF = "Super Fast Song",
|
||||||
SS = "Slower Song",
|
SS = "Slower Song",
|
||||||
GN = "Ghost Notes",
|
GN = "Ghost Notes",
|
||||||
NA = "No Arrows",
|
DA = "Disappearing Arrows",
|
||||||
NO = "No Obstacles",
|
|
||||||
SA = "Strict Angles",
|
SA = "Strict Angles",
|
||||||
SC = "Small Notes",
|
SC = "Small Notes",
|
||||||
PM = "Pro Mode",
|
|
||||||
CS = "Fail on Saber Clash",
|
|
||||||
IF = "One Life",
|
IF = "One Life",
|
||||||
|
NO = "No Obstacles",
|
||||||
BE = "Battery Energy",
|
BE = "Battery Energy",
|
||||||
NF = "No Fail",
|
|
||||||
NB = "No Bombs",
|
NB = "No Bombs",
|
||||||
|
NA = "No Arrows",
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { BeatSaverMap } from "../model/beatsaver/beatsaver-map";
|
import { BeatSaverMap } from "../model/beatsaver/map";
|
||||||
|
|
||||||
export interface PlayerScore<S, L> {
|
export interface PlayerScore<S, L> {
|
||||||
/**
|
/**
|
||||||
|
@ -1,51 +0,0 @@
|
|||||||
import { Modifier } from "./modifier";
|
|
||||||
import { Leaderboards } from "../leaderboard";
|
|
||||||
|
|
||||||
export default interface Score {
|
|
||||||
/**
|
|
||||||
* The leaderboard the score is from.
|
|
||||||
*/
|
|
||||||
readonly leaderboard: Leaderboards;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The base score for the score.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly score: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The rank for the score.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly rank: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The modifiers used on the score.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly modifiers: Modifier[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The amount missed notes.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly misses: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The amount of bad cuts.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly badCuts: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether every note was hit.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly fullCombo: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The time the score was set.
|
|
||||||
* @private
|
|
||||||
*/
|
|
||||||
readonly timestamp: Date;
|
|
||||||
}
|
|
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();
|
@ -1,5 +1,5 @@
|
|||||||
import Service from "../service";
|
import Service from "../service";
|
||||||
import { BeatSaverMapToken } from "../../types/token/beatsaver/beat-saver-map-token";
|
import { BeatSaverMapToken } from "../../types/token/beatsaver/map";
|
||||||
|
|
||||||
const API_BASE = "https://api.beatsaver.com";
|
const API_BASE = "https://api.beatsaver.com";
|
||||||
const LOOKUP_MAP_BY_HASH_ENDPOINT = `${API_BASE}/maps/hash/:query`;
|
const LOOKUP_MAP_BY_HASH_ENDPOINT = `${API_BASE}/maps/hash/:query`;
|
||||||
|
@ -7,7 +7,7 @@ import ScoreSaberPlayerScoresPageToken from "../../types/token/scoresaber/score-
|
|||||||
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
|
import ScoreSaberLeaderboardToken from "../../types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token";
|
import ScoreSaberLeaderboardScoresPageToken from "../../types/token/scoresaber/score-saber-leaderboard-scores-page-token";
|
||||||
import { clamp, lerp } from "../../utils/math-utils";
|
import { clamp, lerp } from "../../utils/math-utils";
|
||||||
import { CurvePoint } from "../../utils/curve-point";
|
import { CurvePoint } from "../../curve-point";
|
||||||
import { SSRCache } from "../../cache";
|
import { SSRCache } from "../../cache";
|
||||||
|
|
||||||
const API_BASE = "https://scoresaber.com/api";
|
const API_BASE = "https://scoresaber.com/api";
|
||||||
@ -30,7 +30,7 @@ const LOOKUP_LEADERBOARD_SCORES_ENDPOINT = `${API_BASE}/leaderboard/by-id/:id/sc
|
|||||||
const STAR_MULTIPLIER = 42.117208413;
|
const STAR_MULTIPLIER = 42.117208413;
|
||||||
|
|
||||||
const playerCache = new SSRCache({
|
const playerCache = new SSRCache({
|
||||||
ttl: 60 * 30, // 30 minutes
|
ttl: 60, // 1 minute
|
||||||
});
|
});
|
||||||
|
|
||||||
class ScoreSaberService extends Service {
|
class ScoreSaberService extends Service {
|
||||||
@ -167,18 +167,21 @@ class ScoreSaberService extends Service {
|
|||||||
*
|
*
|
||||||
* @param playerId the ID of the player to look up
|
* @param playerId the ID of the player to look up
|
||||||
* @param sort the sort to use
|
* @param sort the sort to use
|
||||||
|
* @param limit the amount of sores to fetch
|
||||||
* @param page the page to get scores for
|
* @param page the page to get scores for
|
||||||
* @param search
|
* @param search the query to search for
|
||||||
* @returns the scores of the player, or undefined
|
* @returns the scores of the player, or undefined
|
||||||
*/
|
*/
|
||||||
public async lookupPlayerScores({
|
public async lookupPlayerScores({
|
||||||
playerId,
|
playerId,
|
||||||
sort,
|
sort,
|
||||||
|
limit = 8,
|
||||||
page,
|
page,
|
||||||
search,
|
search,
|
||||||
}: {
|
}: {
|
||||||
playerId: string;
|
playerId: string;
|
||||||
sort: ScoreSort;
|
sort: ScoreSort;
|
||||||
|
limit?: number;
|
||||||
page: number;
|
page: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
useProxy?: boolean;
|
useProxy?: boolean;
|
||||||
@ -189,7 +192,7 @@ class ScoreSaberService extends Service {
|
|||||||
);
|
);
|
||||||
const response = await this.fetch<ScoreSaberPlayerScoresPageToken>(
|
const response = await this.fetch<ScoreSaberPlayerScoresPageToken>(
|
||||||
LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId)
|
LOOKUP_PLAYER_SCORES_ENDPOINT.replace(":id", playerId)
|
||||||
.replace(":limit", 8 + "")
|
.replace(":limit", limit + "")
|
||||||
.replace(":sort", sort)
|
.replace(":sort", sort)
|
||||||
.replace(":page", page + "") + (search ? `&search=${search}` : "")
|
.replace(":page", page + "") + (search ? `&search=${search}` : "")
|
||||||
);
|
);
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import ky from "ky";
|
import ky from "ky";
|
||||||
|
import { isServer } from "../utils/utils";
|
||||||
|
|
||||||
export default class Service {
|
export default class Service {
|
||||||
/**
|
/**
|
||||||
@ -38,7 +39,11 @@ export default class Service {
|
|||||||
*/
|
*/
|
||||||
public async fetch<T>(url: string): Promise<T | undefined> {
|
public async fetch<T>(url: string): Promise<T | undefined> {
|
||||||
try {
|
try {
|
||||||
return await ky.get<T>(this.buildRequestUrl(true, url)).json();
|
const response = await ky.get<T>(this.buildRequestUrl(!isServer(), url));
|
||||||
|
if (response.headers.has("X-RateLimit-Remaining")) {
|
||||||
|
this.log(`Rate limit remaining: ${response.headers.get("X-RateLimit-Remaining")}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
331
projects/common/src/token-creators.ts
Normal file
331
projects/common/src/token-creators.ts
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
import ScoreSaberLeaderboard from "./model/leaderboard/impl/scoresaber-leaderboard";
|
||||||
|
import ScoreSaberLeaderboardToken from "./types/token/scoresaber/score-saber-leaderboard-token";
|
||||||
|
import LeaderboardDifficulty from "./model/leaderboard/leaderboard-difficulty";
|
||||||
|
import { getDifficultyFromScoreSaberDifficulty } from "./utils/scoresaber-utils";
|
||||||
|
import { MapCharacteristic } from "./types/map-characteristic";
|
||||||
|
import { LeaderboardStatus } from "./model/leaderboard/leaderboard-status";
|
||||||
|
import { formatDateMinimal, getDaysAgoDate, getMidnightAlignedDate, parseDate } from "./utils/time-utils";
|
||||||
|
import ScoreSaberPlayerToken from "./types/token/scoresaber/score-saber-player-token";
|
||||||
|
import ScoreSaberPlayer, { ScoreSaberBadge, ScoreSaberBio } from "./player/impl/scoresaber-player";
|
||||||
|
import { PlayerHistory } from "./player/player-history";
|
||||||
|
import ky from "ky";
|
||||||
|
import { Config } from "./config";
|
||||||
|
import { getValueFromHistory } from "./utils/player-utils";
|
||||||
|
import { getPageFromRank } from "./utils/utils";
|
||||||
|
import ScoreSaberScoreToken from "./types/token/scoresaber/score-saber-score-token";
|
||||||
|
import { ScoreSaberScore } from "./model/score/impl/scoresaber-score";
|
||||||
|
import { Modifier } from "./score/modifier";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a {@link ScoreSaberLeaderboardToken} into a {@link ScoreSaberLeaderboard}.
|
||||||
|
*
|
||||||
|
* @param token the token to parse
|
||||||
|
*/
|
||||||
|
export function getScoreSaberLeaderboardFromToken(token: ScoreSaberLeaderboardToken): ScoreSaberLeaderboard {
|
||||||
|
const difficulty: LeaderboardDifficulty = {
|
||||||
|
leaderboardId: token.difficulty.leaderboardId,
|
||||||
|
difficulty: getDifficultyFromScoreSaberDifficulty(token.difficulty.difficulty),
|
||||||
|
characteristic: token.difficulty.gameMode.replace("Solo", "") as MapCharacteristic,
|
||||||
|
difficultyRaw: token.difficulty.difficultyRaw,
|
||||||
|
};
|
||||||
|
|
||||||
|
let status: LeaderboardStatus = "Unranked";
|
||||||
|
if (token.qualified) {
|
||||||
|
status = "Qualified";
|
||||||
|
} else if (token.ranked) {
|
||||||
|
status = "Ranked";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: token.id,
|
||||||
|
songHash: token.songHash.toUpperCase(),
|
||||||
|
songName: token.songName,
|
||||||
|
songSubName: token.songSubName,
|
||||||
|
songAuthorName: token.songAuthorName,
|
||||||
|
levelAuthorName: token.levelAuthorName,
|
||||||
|
difficulty: difficulty,
|
||||||
|
difficulties:
|
||||||
|
token.difficulties != undefined && token.difficulties.length > 0
|
||||||
|
? token.difficulties.map(difficulty => {
|
||||||
|
return {
|
||||||
|
leaderboardId: difficulty.leaderboardId,
|
||||||
|
difficulty: getDifficultyFromScoreSaberDifficulty(difficulty.difficulty),
|
||||||
|
characteristic: difficulty.gameMode.replace("Solo", "") as MapCharacteristic,
|
||||||
|
difficultyRaw: difficulty.difficultyRaw,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
: [difficulty],
|
||||||
|
maxScore: token.maxScore,
|
||||||
|
ranked: token.ranked,
|
||||||
|
songArt: token.coverImage,
|
||||||
|
timestamp: parseDate(token.createdDate),
|
||||||
|
stars: token.stars,
|
||||||
|
plays: token.plays,
|
||||||
|
dailyPlays: token.dailyPlays,
|
||||||
|
qualified: token.qualified,
|
||||||
|
status: status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a {@link ScoreSaberScore} from a {@link ScoreSaberScoreToken}.
|
||||||
|
*
|
||||||
|
* @param token the token to convert
|
||||||
|
* @param playerId the id of the player who set the score
|
||||||
|
* @param leaderboard the leaderboard the score was set on
|
||||||
|
*/
|
||||||
|
export function getScoreSaberScoreFromToken(
|
||||||
|
token: ScoreSaberScoreToken,
|
||||||
|
leaderboard: ScoreSaberLeaderboard,
|
||||||
|
playerId?: string
|
||||||
|
): ScoreSaberScore {
|
||||||
|
const modifiers: Modifier[] =
|
||||||
|
token.modifiers == undefined || token.modifiers === ""
|
||||||
|
? []
|
||||||
|
: token.modifiers.split(",").map(mod => {
|
||||||
|
mod = mod.toUpperCase();
|
||||||
|
const modifier = Modifier[mod as keyof typeof Modifier];
|
||||||
|
if (modifier === undefined) {
|
||||||
|
throw new Error(`Unknown modifier: ${mod}`);
|
||||||
|
}
|
||||||
|
return modifier;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
playerId: playerId || token.leaderboardPlayerInfo.id,
|
||||||
|
leaderboardId: leaderboard.id,
|
||||||
|
difficulty: leaderboard.difficulty.difficulty,
|
||||||
|
characteristic: leaderboard.difficulty.characteristic,
|
||||||
|
score: token.baseScore,
|
||||||
|
accuracy: leaderboard.maxScore ? (token.baseScore / leaderboard.maxScore) * 100 : Infinity,
|
||||||
|
rank: token.rank,
|
||||||
|
modifiers: modifiers,
|
||||||
|
misses: token.missedNotes + token.badCuts,
|
||||||
|
missedNotes: token.missedNotes,
|
||||||
|
badCuts: token.badCuts,
|
||||||
|
fullCombo: token.fullCombo,
|
||||||
|
timestamp: new Date(token.timeSet),
|
||||||
|
scoreId: token.id,
|
||||||
|
pp: token.pp,
|
||||||
|
weight: token.weight,
|
||||||
|
maxCombo: token.maxCombo,
|
||||||
|
playerInfo: token.leaderboardPlayerInfo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the ScoreSaber Player from an {@link ScoreSaberPlayerToken}.
|
||||||
|
*
|
||||||
|
* @param token the player token
|
||||||
|
* @param playerIdCookie the id of the claimed player
|
||||||
|
*/
|
||||||
|
export async function getScoreSaberPlayerFromToken(
|
||||||
|
token: ScoreSaberPlayerToken,
|
||||||
|
playerIdCookie?: string
|
||||||
|
): Promise<ScoreSaberPlayer> {
|
||||||
|
const bio: ScoreSaberBio = {
|
||||||
|
lines: token.bio?.split("\n") || [],
|
||||||
|
linesStripped: token.bio?.replace(/<[^>]+>/g, "")?.split("\n") || [], // strips html tags
|
||||||
|
};
|
||||||
|
const badges: ScoreSaberBadge[] =
|
||||||
|
token.badges?.map(badge => {
|
||||||
|
return {
|
||||||
|
url: badge.image,
|
||||||
|
description: badge.description,
|
||||||
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
let isBeingTracked = false;
|
||||||
|
const todayDate = formatDateMinimal(getMidnightAlignedDate(new Date()));
|
||||||
|
let statisticHistory: { [key: string]: PlayerHistory } = {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { statistics: history } = await ky
|
||||||
|
.get<{
|
||||||
|
statistics: { [key: string]: PlayerHistory };
|
||||||
|
}>(
|
||||||
|
`${Config.apiUrl}/player/history/${token.id}/50/${playerIdCookie && playerIdCookie == token.id ? "?createIfMissing=true" : ""}`
|
||||||
|
)
|
||||||
|
.json();
|
||||||
|
if (history) {
|
||||||
|
// Use the latest data for today
|
||||||
|
history[todayDate] = {
|
||||||
|
...history[todayDate],
|
||||||
|
rank: token.rank,
|
||||||
|
countryRank: token.countryRank,
|
||||||
|
pp: token.pp,
|
||||||
|
replaysWatched: token.scoreStats.replaysWatched,
|
||||||
|
accuracy: {
|
||||||
|
...history[todayDate]?.accuracy,
|
||||||
|
averageRankedAccuracy: token.scoreStats.averageRankedAccuracy,
|
||||||
|
},
|
||||||
|
scores: {
|
||||||
|
...history[todayDate]?.scores,
|
||||||
|
totalScores: token.scoreStats.totalPlayCount,
|
||||||
|
totalRankedScores: token.scoreStats.rankedPlayCount,
|
||||||
|
},
|
||||||
|
score: {
|
||||||
|
...history[todayDate]?.score,
|
||||||
|
totalScore: token.scoreStats.totalScore,
|
||||||
|
totalRankedScore: token.scoreStats.totalRankedScore,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
isBeingTracked = true;
|
||||||
|
}
|
||||||
|
statisticHistory = history;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
const playerRankHistory = token.histories.split(",").map(value => {
|
||||||
|
return parseInt(value);
|
||||||
|
});
|
||||||
|
playerRankHistory.push(token.rank);
|
||||||
|
|
||||||
|
let missingDays = 0;
|
||||||
|
let daysAgo = 0; // Start from current day
|
||||||
|
for (let i = playerRankHistory.length - 1; i >= 0; i--) {
|
||||||
|
const rank = playerRankHistory[i];
|
||||||
|
if (rank == 999_999) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = getMidnightAlignedDate(getDaysAgoDate(daysAgo));
|
||||||
|
daysAgo += 1;
|
||||||
|
|
||||||
|
const dateKey = formatDateMinimal(date);
|
||||||
|
if (!statisticHistory[dateKey] || statisticHistory[dateKey].rank == undefined) {
|
||||||
|
missingDays += 1;
|
||||||
|
statisticHistory[dateKey] = {
|
||||||
|
...statisticHistory[dateKey],
|
||||||
|
rank: rank,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingDays > 0 && missingDays != playerRankHistory.length) {
|
||||||
|
console.log(
|
||||||
|
`Player has ${missingDays} missing day${missingDays > 1 ? "s" : ""}, filling in with fallback history...`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the fallback history
|
||||||
|
statisticHistory = Object.entries(statisticHistory)
|
||||||
|
.sort((a, b) => Date.parse(b[0]) - Date.parse(a[0]))
|
||||||
|
.reduce((obj, [key, value]) => ({ ...obj, [key]: value }), {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the change in the given stat
|
||||||
|
*
|
||||||
|
* @param statType the stat to check
|
||||||
|
* @param isNegativeChange whether to multiply the change by 1 or -1
|
||||||
|
* @param daysAgo the amount of days ago to get the stat for
|
||||||
|
* @return the change
|
||||||
|
*/
|
||||||
|
const getStatisticChange = (statType: string, isNegativeChange: boolean, daysAgo: number = 1): number | undefined => {
|
||||||
|
const todayStats = statisticHistory[todayDate];
|
||||||
|
let otherDate: Date | undefined;
|
||||||
|
|
||||||
|
if (daysAgo === 1) {
|
||||||
|
otherDate = getMidnightAlignedDate(getDaysAgoDate(1)); // Yesterday
|
||||||
|
} else {
|
||||||
|
const targetDate = getDaysAgoDate(daysAgo);
|
||||||
|
|
||||||
|
// Filter available dates to find the closest one to the target
|
||||||
|
const availableDates = Object.keys(statisticHistory)
|
||||||
|
.map(dateKey => new Date(dateKey))
|
||||||
|
.filter(date => {
|
||||||
|
// Convert date back to the correct format for statisticHistory lookup
|
||||||
|
const formattedDate = formatDateMinimal(date);
|
||||||
|
const statsForDate = statisticHistory[formattedDate];
|
||||||
|
const hasStat = statsForDate && statType in statsForDate;
|
||||||
|
|
||||||
|
// Only consider past dates with the required statType
|
||||||
|
const isPast = date.getTime() < new Date().getTime();
|
||||||
|
return hasStat && isPast;
|
||||||
|
});
|
||||||
|
|
||||||
|
// If no valid dates are found, return undefined
|
||||||
|
if (availableDates.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the closest date from the filtered available dates
|
||||||
|
otherDate = availableDates.reduce((closestDate, currentDate) => {
|
||||||
|
const currentDiff = Math.abs(currentDate.getTime() - targetDate.getTime());
|
||||||
|
const closestDiff = Math.abs(closestDate.getTime() - targetDate.getTime());
|
||||||
|
return currentDiff < closestDiff ? currentDate : closestDate;
|
||||||
|
}, availableDates[0]); // Start with the first available date
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure todayStats exists and contains the statType
|
||||||
|
if (!todayStats || !getValueFromHistory(todayStats, statType)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherStats = statisticHistory[formatDateMinimal(otherDate)]; // This is now validated
|
||||||
|
|
||||||
|
// Ensure otherStats exists and contains the statType
|
||||||
|
if (!otherStats || !getValueFromHistory(otherStats, statType)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statToday = getValueFromHistory(todayStats, statType);
|
||||||
|
const statOther = getValueFromHistory(otherStats, statType);
|
||||||
|
|
||||||
|
if (statToday === undefined || statOther === undefined) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return (statToday - statOther) * (!isNegativeChange ? 1 : -1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatisticChanges = (daysAgo: number): PlayerHistory => {
|
||||||
|
return {
|
||||||
|
rank: getStatisticChange("rank", true, daysAgo),
|
||||||
|
countryRank: getStatisticChange("countryRank", true, daysAgo),
|
||||||
|
pp: getStatisticChange("pp", false, daysAgo),
|
||||||
|
replaysWatched: getStatisticChange("replaysWatched", false, daysAgo),
|
||||||
|
accuracy: {
|
||||||
|
averageRankedAccuracy: getStatisticChange("accuracy.averageRankedAccuracy", false, daysAgo),
|
||||||
|
},
|
||||||
|
score: {
|
||||||
|
totalScore: getStatisticChange("score.totalScore", false, daysAgo),
|
||||||
|
totalRankedScore: getStatisticChange("score.totalRankedScore", false, daysAgo),
|
||||||
|
},
|
||||||
|
scores: {
|
||||||
|
totalScores: getStatisticChange("scores.totalScores", false, daysAgo),
|
||||||
|
totalRankedScores: getStatisticChange("scores.totalRankedScores", false, daysAgo),
|
||||||
|
rankedScores: getStatisticChange("scores.rankedScores", false, daysAgo),
|
||||||
|
unrankedScores: getStatisticChange("scores.unrankedScores", false, daysAgo),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: token.id,
|
||||||
|
name: token.name,
|
||||||
|
avatar: token.profilePicture,
|
||||||
|
country: token.country,
|
||||||
|
rank: token.rank,
|
||||||
|
countryRank: token.countryRank,
|
||||||
|
joinedDate: new Date(token.firstSeen),
|
||||||
|
bio: bio,
|
||||||
|
pp: token.pp,
|
||||||
|
statisticChange: {
|
||||||
|
daily: getStatisticChanges(1),
|
||||||
|
weekly: getStatisticChanges(7),
|
||||||
|
monthly: getStatisticChanges(30),
|
||||||
|
},
|
||||||
|
role: token.role == null ? undefined : token.role,
|
||||||
|
badges: badges,
|
||||||
|
statisticHistory: statisticHistory,
|
||||||
|
statistics: token.scoreStats,
|
||||||
|
rankPages: {
|
||||||
|
global: getPageFromRank(token.rank, 50),
|
||||||
|
country: getPageFromRank(token.countryRank, 50),
|
||||||
|
},
|
||||||
|
permissions: token.permissions,
|
||||||
|
banned: token.banned,
|
||||||
|
inactive: token.inactive,
|
||||||
|
isBeingTracked: isBeingTracked,
|
||||||
|
};
|
||||||
|
}
|
1
projects/common/src/types/around-player.ts
Normal file
1
projects/common/src/types/around-player.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type AroundPlayer = "global" | "country";
|
@ -3,4 +3,24 @@ export type AppStatistics = {
|
|||||||
* The total amount of players being tracked.
|
* The total amount of players being tracked.
|
||||||
*/
|
*/
|
||||||
trackedPlayers: number;
|
trackedPlayers: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total amount of ScoreSaber scores tracked.
|
||||||
|
*/
|
||||||
|
trackedScores: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total amount of additional data for scores being tracked.
|
||||||
|
*/
|
||||||
|
additionalScoresData: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of cached BeatSaver maps.
|
||||||
|
*/
|
||||||
|
cachedBeatSaverMaps: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of cached ScoreSaber leaderboards.
|
||||||
|
*/
|
||||||
|
cachedScoreSaberLeaderboards: number;
|
||||||
};
|
};
|
||||||
|
1
projects/common/src/types/map-characteristic.ts
Normal file
1
projects/common/src/types/map-characteristic.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export type MapCharacteristic = "Standard" | "Lawless";
|
@ -1,13 +0,0 @@
|
|||||||
import { Metadata } from "./metadata";
|
|
||||||
|
|
||||||
export type Page<T> = {
|
|
||||||
/**
|
|
||||||
* The data to return.
|
|
||||||
*/
|
|
||||||
data: T[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The metadata of the page.
|
|
||||||
*/
|
|
||||||
metadata: Metadata;
|
|
||||||
};
|
|
30
projects/common/src/types/token/beatleader/difficulty.ts
Normal file
30
projects/common/src/types/token/beatleader/difficulty.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { BeatLeaderModifierToken } from "./modifier/modifiers";
|
||||||
|
import { BeatLeaderModifierRatingToken } from "./modifier/modifier-rating";
|
||||||
|
|
||||||
|
export type BeatLeaderDifficultyToken = {
|
||||||
|
id: number;
|
||||||
|
value: number;
|
||||||
|
mode: number;
|
||||||
|
difficultyName: string;
|
||||||
|
modeName: string;
|
||||||
|
status: number;
|
||||||
|
modifierValues: BeatLeaderModifierToken;
|
||||||
|
modifiersRating: BeatLeaderModifierRatingToken;
|
||||||
|
nominatedTime: number;
|
||||||
|
qualifiedTime: number;
|
||||||
|
rankedTime: number;
|
||||||
|
stars: number;
|
||||||
|
predictedAcc: number;
|
||||||
|
passRating: number;
|
||||||
|
accRating: number;
|
||||||
|
techRating: number;
|
||||||
|
type: number;
|
||||||
|
njs: number;
|
||||||
|
nps: number;
|
||||||
|
notes: number;
|
||||||
|
bombs: number;
|
||||||
|
walls: number;
|
||||||
|
maxScore: number;
|
||||||
|
duration: number;
|
||||||
|
requirements: number;
|
||||||
|
};
|
16
projects/common/src/types/token/beatleader/leaderboard.ts
Normal file
16
projects/common/src/types/token/beatleader/leaderboard.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { BeatLeaderSongToken } from "./score/song";
|
||||||
|
import { BeatLeaderDifficultyToken } from "./difficulty";
|
||||||
|
|
||||||
|
export type BeatLeaderLeaderboardToken = {
|
||||||
|
id: string;
|
||||||
|
song: BeatLeaderSongToken;
|
||||||
|
difficulty: BeatLeaderDifficultyToken;
|
||||||
|
scores: null; // ??
|
||||||
|
changes: null; // ??
|
||||||
|
qualification: null; // ??
|
||||||
|
reweight: null; // ??
|
||||||
|
leaderboardGroup: null; // ??
|
||||||
|
plays: number;
|
||||||
|
clan: null; // ??
|
||||||
|
clanRankingContested: boolean;
|
||||||
|
};
|
@ -0,0 +1,18 @@
|
|||||||
|
export type BeatLeaderModifierRatingToken = {
|
||||||
|
id: number;
|
||||||
|
fsPredictedAcc: number;
|
||||||
|
fsPassRating: number;
|
||||||
|
fsAccRating: number;
|
||||||
|
fsTechRating: number;
|
||||||
|
fsStars: number;
|
||||||
|
ssPredictedAcc: number;
|
||||||
|
ssPassRating: number;
|
||||||
|
ssAccRating: number;
|
||||||
|
ssTechRating: number;
|
||||||
|
ssStars: number;
|
||||||
|
sfPredictedAcc: number;
|
||||||
|
sfPassRating: number;
|
||||||
|
sfAccRating: number;
|
||||||
|
sfTechRating: number;
|
||||||
|
sfStars: number;
|
||||||
|
};
|
@ -0,0 +1,16 @@
|
|||||||
|
export type BeatLeaderModifierToken = {
|
||||||
|
modifierId: number;
|
||||||
|
da: number;
|
||||||
|
fs: number;
|
||||||
|
sf: number;
|
||||||
|
ss: number;
|
||||||
|
gn: number;
|
||||||
|
na: number;
|
||||||
|
nb: number;
|
||||||
|
nf: number;
|
||||||
|
no: number;
|
||||||
|
pm: number;
|
||||||
|
sc: number;
|
||||||
|
sa: number;
|
||||||
|
op: number;
|
||||||
|
};
|
10
projects/common/src/types/token/beatleader/player.ts
Normal file
10
projects/common/src/types/token/beatleader/player.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export type BeatLeaderPlayerToken = {
|
||||||
|
id: string;
|
||||||
|
country: string;
|
||||||
|
avatar: string;
|
||||||
|
pp: number;
|
||||||
|
rank: number;
|
||||||
|
countryRank: number;
|
||||||
|
name: string;
|
||||||
|
// todo: finish this
|
||||||
|
};
|
@ -0,0 +1,66 @@
|
|||||||
|
export type ScoreStatsAccuracyTrackerToken = {
|
||||||
|
/**
|
||||||
|
* The accuracy of the right hand.
|
||||||
|
*/
|
||||||
|
accRight: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accuracy of the left hand.
|
||||||
|
*/
|
||||||
|
accLeft: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The left hand pre-swing.
|
||||||
|
*/
|
||||||
|
leftPreswing: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right hand pre-swing.
|
||||||
|
*/
|
||||||
|
rightPreswing: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The average pre-swing.
|
||||||
|
*/
|
||||||
|
averagePreswing: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The left hand post-swing.
|
||||||
|
*/
|
||||||
|
leftPostswing: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right hand post-swing.
|
||||||
|
*/
|
||||||
|
rightPostswing: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The left hand time dependence.
|
||||||
|
*/
|
||||||
|
leftTimeDependence: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right hand time dependence.
|
||||||
|
*/
|
||||||
|
rightTimeDependence: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The left hand average cut.
|
||||||
|
*/
|
||||||
|
leftAverageCut: number[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right hand average cut.
|
||||||
|
*/
|
||||||
|
rightAverageCut: number[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The grid accuracy.
|
||||||
|
*/
|
||||||
|
gridAcc: number[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full combo accuracy.
|
||||||
|
*/
|
||||||
|
fcAcc: number;
|
||||||
|
};
|
@ -0,0 +1,16 @@
|
|||||||
|
export type ScoreStatsHeadPositionToken = {
|
||||||
|
/**
|
||||||
|
* The X position of the head
|
||||||
|
*/
|
||||||
|
x: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Y position of the head
|
||||||
|
*/
|
||||||
|
y: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Z position of the head
|
||||||
|
*/
|
||||||
|
z: number;
|
||||||
|
};
|
@ -0,0 +1,51 @@
|
|||||||
|
export type ScoreStatsHitTrackerToken = {
|
||||||
|
/**
|
||||||
|
* The maximum combo achieved.
|
||||||
|
*/
|
||||||
|
maxCombo: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The highest amount of 115 notes hit in a row.
|
||||||
|
*/
|
||||||
|
maxStreak: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The left hand timing.
|
||||||
|
*/
|
||||||
|
leftTiming: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right hand timing.
|
||||||
|
*/
|
||||||
|
rightTiming: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The left hand misses.
|
||||||
|
*/
|
||||||
|
leftMiss: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right hand misses.
|
||||||
|
*/
|
||||||
|
rightMiss: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The left hand bad cuts.
|
||||||
|
*/
|
||||||
|
leftBadCuts: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right hand bad cuts.
|
||||||
|
*/
|
||||||
|
rightBadCuts: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The left hand bombs.
|
||||||
|
*/
|
||||||
|
leftBombs: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right hand bombs.
|
||||||
|
*/
|
||||||
|
rightBombs: number;
|
||||||
|
};
|
@ -0,0 +1,6 @@
|
|||||||
|
export type ScoreStatsGraphTrackerToken = {
|
||||||
|
/**
|
||||||
|
* The accuracy graph data.
|
||||||
|
*/
|
||||||
|
graph: number[];
|
||||||
|
};
|
@ -0,0 +1,26 @@
|
|||||||
|
import { ScoreStatsHitTrackerToken } from "./hit-tracker";
|
||||||
|
import { ScoreStatsAccuracyTrackerToken } from "./accuracy-tracker";
|
||||||
|
import { ScoreStatsWinTrackerToken } from "./win-tracker";
|
||||||
|
import { ScoreStatsGraphTrackerToken } from "./score-graph-tracker";
|
||||||
|
|
||||||
|
export type ScoreStatsToken = {
|
||||||
|
/**
|
||||||
|
* The hit tracker stats.
|
||||||
|
*/
|
||||||
|
hitTracker: ScoreStatsHitTrackerToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accuracy tracker stats.
|
||||||
|
*/
|
||||||
|
accuracyTracker: ScoreStatsAccuracyTrackerToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The win tracker stats.
|
||||||
|
*/
|
||||||
|
winTracker: ScoreStatsWinTrackerToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The score graph tracker stats.
|
||||||
|
*/
|
||||||
|
scoreGraphTracker: ScoreStatsGraphTrackerToken;
|
||||||
|
};
|
@ -0,0 +1,48 @@
|
|||||||
|
import { ScoreStatsHeadPositionToken } from "./head-position";
|
||||||
|
|
||||||
|
export type ScoreStatsWinTrackerToken = {
|
||||||
|
/**
|
||||||
|
* Whether the score was won. (not failed)
|
||||||
|
*/
|
||||||
|
won: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time the score ended.
|
||||||
|
*/
|
||||||
|
endTime: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total amount of pauses.
|
||||||
|
*/
|
||||||
|
nbOfPause: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total amount of pause time.
|
||||||
|
*/
|
||||||
|
totalPauseDuration: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The jump distance the score was played on.
|
||||||
|
*/
|
||||||
|
jumpDistance: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The average height of the player.
|
||||||
|
*/
|
||||||
|
averageHeight: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The average head position of the player.
|
||||||
|
*/
|
||||||
|
averageHeadPosition: ScoreStatsHeadPositionToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total score.
|
||||||
|
*/
|
||||||
|
totalScore: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum score for this song.
|
||||||
|
*/
|
||||||
|
maxScore: number;
|
||||||
|
};
|
@ -0,0 +1,19 @@
|
|||||||
|
export type BeatLeaderScoreImprovementToken = {
|
||||||
|
id: number;
|
||||||
|
timeset: number;
|
||||||
|
score: number;
|
||||||
|
accuracy: number;
|
||||||
|
pp: number;
|
||||||
|
bonusPp: number;
|
||||||
|
rank: number;
|
||||||
|
accRight: number;
|
||||||
|
accLeft: number;
|
||||||
|
averageRankedAccuracy: number;
|
||||||
|
totalPp: number;
|
||||||
|
totalRank: number;
|
||||||
|
badCuts: number;
|
||||||
|
missedNotes: number;
|
||||||
|
bombCuts: number;
|
||||||
|
wallsHit: number;
|
||||||
|
pauses: number;
|
||||||
|
};
|
@ -0,0 +1,8 @@
|
|||||||
|
export type BeatLeaderScoreOffsetsToken = {
|
||||||
|
id: number;
|
||||||
|
frames: number;
|
||||||
|
notes: number;
|
||||||
|
walls: number;
|
||||||
|
heights: number;
|
||||||
|
pauses: number;
|
||||||
|
};
|
52
projects/common/src/types/token/beatleader/score/score.ts
Normal file
52
projects/common/src/types/token/beatleader/score/score.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { BeatLeaderLeaderboardToken } from "../leaderboard";
|
||||||
|
import { BeatLeaderScoreImprovementToken } from "./score-improvement";
|
||||||
|
import { BeatLeaderScoreOffsetsToken } from "./score-offsets";
|
||||||
|
import { BeatLeaderPlayerToken } from "../player";
|
||||||
|
|
||||||
|
export type BeatLeaderScoreToken = {
|
||||||
|
myScore: null; // ??
|
||||||
|
validContexts: number;
|
||||||
|
leaderboard: BeatLeaderLeaderboardToken;
|
||||||
|
contextExtensions: null; // ??
|
||||||
|
accLeft: number;
|
||||||
|
accRight: number;
|
||||||
|
id: number;
|
||||||
|
baseScore: number;
|
||||||
|
modifiedScore: number;
|
||||||
|
accuracy: number;
|
||||||
|
playerId: string;
|
||||||
|
pp: number;
|
||||||
|
bonusPp: number;
|
||||||
|
passPP: number;
|
||||||
|
accPP: number;
|
||||||
|
techPP: number;
|
||||||
|
rank: number;
|
||||||
|
country: string;
|
||||||
|
fcAccuracy: number;
|
||||||
|
fcPp: number;
|
||||||
|
weight: number;
|
||||||
|
replay: string;
|
||||||
|
modifiers: string;
|
||||||
|
badCuts: number;
|
||||||
|
missedNotes: number;
|
||||||
|
bombCuts: number;
|
||||||
|
wallsHit: number;
|
||||||
|
pauses: number;
|
||||||
|
fullCombo: boolean;
|
||||||
|
platform: string;
|
||||||
|
maxCombo: number;
|
||||||
|
maxStreak: number;
|
||||||
|
hmd: number;
|
||||||
|
controller: number;
|
||||||
|
leaderboardId: string;
|
||||||
|
timeset: string;
|
||||||
|
timepost: number;
|
||||||
|
replaysWatched: number;
|
||||||
|
playCount: number;
|
||||||
|
priority: number;
|
||||||
|
player: BeatLeaderPlayerToken; // ??
|
||||||
|
scoreImprovement: BeatLeaderScoreImprovementToken;
|
||||||
|
rankVoting: null; // ??
|
||||||
|
metadata: null; // ??
|
||||||
|
offsets: BeatLeaderScoreOffsetsToken;
|
||||||
|
};
|
16
projects/common/src/types/token/beatleader/score/song.ts
Normal file
16
projects/common/src/types/token/beatleader/score/song.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export type BeatLeaderSongToken = {
|
||||||
|
id: string;
|
||||||
|
hash: string;
|
||||||
|
name: string;
|
||||||
|
subName: string;
|
||||||
|
author: string;
|
||||||
|
mapperId: string;
|
||||||
|
coverImage: string;
|
||||||
|
fullCoverImage: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
bpm: number;
|
||||||
|
duration: number;
|
||||||
|
tags: string;
|
||||||
|
uploadTime: number;
|
||||||
|
difficulties: null; // ??
|
||||||
|
};
|
@ -1,24 +0,0 @@
|
|||||||
import BeatSaverAccountToken from "./beat-saver-account-token";
|
|
||||||
import BeatSaverMapMetadataToken from "./beat-saver-map-metadata-token";
|
|
||||||
import BeatSaverMapStatsToken from "./beat-saver-map-stats-token";
|
|
||||||
|
|
||||||
export interface BeatSaverMapToken {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
uploader: BeatSaverAccountToken;
|
|
||||||
metadata: BeatSaverMapMetadataToken;
|
|
||||||
stats: BeatSaverMapStatsToken;
|
|
||||||
uploaded: string;
|
|
||||||
automapper: boolean;
|
|
||||||
ranked: boolean;
|
|
||||||
qualified: boolean;
|
|
||||||
// todo: versions
|
|
||||||
createdAt: string;
|
|
||||||
updatedAt: string;
|
|
||||||
lastPublishedAt: string;
|
|
||||||
tags: string[];
|
|
||||||
declaredAi: string;
|
|
||||||
blRanked: boolean;
|
|
||||||
blQualified: boolean;
|
|
||||||
}
|
|
@ -0,0 +1,16 @@
|
|||||||
|
export type MapDifficultyParitySummaryToken = {
|
||||||
|
/**
|
||||||
|
* The amount of parity errors.
|
||||||
|
*/
|
||||||
|
errors: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of parity warnings.
|
||||||
|
*/
|
||||||
|
warns: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of resets in the difficulty.
|
||||||
|
*/
|
||||||
|
resets: number;
|
||||||
|
};
|
90
projects/common/src/types/token/beatsaver/map-difficulty.ts
Normal file
90
projects/common/src/types/token/beatsaver/map-difficulty.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { MapDifficulty } from "../../../score/map-difficulty";
|
||||||
|
import { MapDifficultyParitySummaryToken } from "./difficulty-parity-summary";
|
||||||
|
|
||||||
|
export type BeatSaverMapDifficultyToken = {
|
||||||
|
/**
|
||||||
|
* The NJS of this difficulty.
|
||||||
|
*/
|
||||||
|
njs: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The NJS offset of this difficulty.
|
||||||
|
*/
|
||||||
|
offset: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of notes in this difficulty.
|
||||||
|
*/
|
||||||
|
notes: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of bombs in this difficulty.
|
||||||
|
*/
|
||||||
|
bombs: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of obstacles in this difficulty.
|
||||||
|
*/
|
||||||
|
obstacles: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The notes per second in this difficulty.
|
||||||
|
*/
|
||||||
|
nps: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The length of this difficulty in seconds.
|
||||||
|
*/
|
||||||
|
length: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The characteristic of this difficulty.
|
||||||
|
*/
|
||||||
|
characteristic: "Standard" | "Lawless";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The difficulty of this difficulty.
|
||||||
|
*/
|
||||||
|
difficulty: MapDifficulty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of lighting events in this difficulty.
|
||||||
|
*/
|
||||||
|
events: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this difficulty uses Chroma.
|
||||||
|
*/
|
||||||
|
chroma: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quite frankly I have no fucking idea what these are.
|
||||||
|
*/
|
||||||
|
me: boolean;
|
||||||
|
ne: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this difficulty use cinema?
|
||||||
|
*/
|
||||||
|
cinema: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The length of this difficulty in seconds.
|
||||||
|
*/
|
||||||
|
seconds: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parity summary of this difficulty.
|
||||||
|
*/
|
||||||
|
paritySummary: MapDifficultyParitySummaryToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The maximum score of this difficulty.
|
||||||
|
*/
|
||||||
|
maxScore: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The custom difficulty label.
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
};
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user