Compare commits
135 Commits
277d155ae7
...
renovate/o
Author | SHA1 | Date | |
---|---|---|---|
b489599f58 | |||
f24b907b1a | |||
88e8c4d3e8 | |||
7b560075ba | |||
d888ab1eb5 | |||
8371344a7d | |||
ea0e4bd20a | |||
02290e5bde | |||
ef2aeb5f0a | |||
8e3a46f8bc | |||
456f2afff0 | |||
e0eda1a053 | |||
9355368f54 | |||
c2e0aafda6 | |||
33b1c0357b | |||
0ae7cdb42c | |||
55f1b2fc8d | |||
3b005510e4 | |||
88eea39a2e | |||
728d987b64 | |||
c2044c5f80 | |||
41e248b751 | |||
13840aa1e2 | |||
02e8946b9b | |||
fcf3785477 | |||
135c34f763 | |||
21c6abb443 | |||
f9b95744f8 | |||
4d95e0ee53 | |||
5b7de4150c | |||
b3ae9ca369 | |||
eba857829b | |||
7253bf2eea | |||
067666ef7c | |||
06927f3fd3 | |||
cb1ee3abad | |||
fa60278463 | |||
e397c86963 | |||
1bcb99430c | |||
07c6bc3d0a | |||
c62948627c | |||
0287f4f83a | |||
7307ccc99b | |||
03bfcab616 | |||
53f407081e | |||
eab307882e | |||
aa23919037 | |||
e27258a351 | |||
577c8e7deb | |||
2f24c529e0 | |||
517e9df72b | |||
f38a1156e1 | |||
c04a51de35 | |||
1ec8248c6f | |||
05e1c7170d | |||
bd31254990 | |||
131a5c2efe | |||
1f1c55d41f | |||
21b6de0f15 | |||
64b6ef1a7f | |||
54bdf532fe | |||
6cb86f843d | |||
f75d22fa58 | |||
68ce2ff240 | |||
ba24eabfaa | |||
84eb8a4b94 | |||
6f49d81664 | |||
98223a3293 | |||
7b0c9f54ff | |||
29f5d5983a | |||
8c354b1be1 | |||
de309ea05c | |||
199ee50534 | |||
6fda81e81a | |||
bdad804eed | |||
66d29c343e | |||
96f62d9a01 | |||
4697cd4aec | |||
0abff880c2 | |||
cc351e6cad | |||
f351c7a3c1 | |||
68180f2647 | |||
6a1a2dc2c4 | |||
d4e51d1517 | |||
02fcaf19eb | |||
0f307eb18c | |||
357315990e | |||
de89182c5d | |||
8dfdc8c535 | |||
f68fb48726 | |||
7b1d4a73a5 | |||
139b3bf06d | |||
20576d913f | |||
871ae76a23 | |||
7aa3de3827 | |||
4135af4743 | |||
242a8a2fba | |||
38c7bfcd3d | |||
e4ec89a7d6 | |||
0f7a890e44 | |||
1a13c08da8 | |||
bd4b042c3d | |||
ebeaf60d5a | |||
cfd2ae004d | |||
86167695d8 | |||
8860a46462 | |||
008ca0f6d4 | |||
38b91e6442 | |||
cdba187bc1 | |||
f0c5f0bdbd | |||
47fc07f2dd | |||
90ec976939 | |||
a5a69ae979 | |||
58bdc6f414 | |||
4e828f2c2b | |||
1d4e19bd3c | |||
560fce9b01 | |||
3f5eff3d1a | |||
900ccba67e | |||
09834e9eac | |||
1efab2ed08 | |||
49e223a8b9 | |||
6b11a608bd | |||
e7ea152bf9 | |||
d3268769e1 | |||
8601915f76 | |||
ef4cbc4015 | |||
99d545d81f | |||
bd1b18aabc | |||
27e9a91f31 | |||
b479304d8a | |||
5534f255e0 | |||
ef2b82541c | |||
ce5b6c83ee | |||
c04850a971 |
@ -1,30 +1,29 @@
|
|||||||
name: Deploy to Dokku
|
name: Deploy API
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["master"]
|
branches: ["master"]
|
||||||
paths-ignore:
|
paths: [".gitea/workflows/deploy-api.yml", "API/**"]
|
||||||
- .gitignore
|
|
||||||
- README.md
|
|
||||||
- LICENSE
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
docker:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
arch: ["ubuntu-latest"]
|
java-version: ["17"]
|
||||||
runs-on: ${{ matrix.arch }}
|
maven-version: ["3.8.5"]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: "./API"
|
||||||
|
|
||||||
# Steps to run
|
# Steps to run
|
||||||
steps:
|
steps:
|
||||||
# Checkout the repo
|
# Checkout the repo
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
# Deploy to Dokku
|
# Deploy to Dokku
|
||||||
- name: Push to dokku
|
- name: Deploy to Dokku
|
||||||
uses: dokku/github-action@master
|
uses: dokku/github-action@master
|
||||||
with:
|
with:
|
||||||
git_remote_url: "ssh://dokku@10.0.50.136:22/bs-tracker"
|
git_remote_url: "ssh://dokku@10.0.50.136:22/bs-tracker"
|
26
.gitea/workflows/deploy-frontend.yml
Normal file
26
.gitea/workflows/deploy-frontend.yml
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
name: Deploy Frontend
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["master"]
|
||||||
|
paths: [".gitea/workflows/deploy-frontend.yml", "Frontend/**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: "./Frontend"
|
||||||
|
|
||||||
|
# Steps to run
|
||||||
|
steps:
|
||||||
|
# Checkout the repo
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Deploy to Dokku
|
||||||
|
- name: Deploy to Dokku
|
||||||
|
uses: dokku/github-action@master
|
||||||
|
with:
|
||||||
|
git_remote_url: "ssh://dokku@10.0.50.136:22/bs-tracker-frontend"
|
||||||
|
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
49
.gitea/workflows/release-mod.yml
Normal file
49
.gitea/workflows/release-mod.yml
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
name: Release Mod
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths: [".gitea/workflows/release-mod.yml", "Mod/**"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: "./Mod"
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Running in CI Variable
|
||||||
|
run: echo "RUNNING_IN_CI=true" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Setup dotnet
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 7.0.x
|
||||||
|
|
||||||
|
- name: Initialize modding environment
|
||||||
|
uses: beat-forge/init-beatsaber@v1
|
||||||
|
with:
|
||||||
|
repo: beat-forge/beatsaber-stripped
|
||||||
|
|
||||||
|
- name: Download Mod Dependencies
|
||||||
|
uses: Goobwabber/download-beatmods-deps@1.2
|
||||||
|
with:
|
||||||
|
manifest: ${{ gitea.workspace }}/Mod/manifest.json
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
id: Build
|
||||||
|
run: dotnet build ScoreTracker.csproj --configuration Release
|
||||||
|
|
||||||
|
- name: Echo Filename
|
||||||
|
run: echo $BUILDTEXT \($ASSEMBLYNAME\)
|
||||||
|
env:
|
||||||
|
BUILDTEXT: Filename=${{ steps.Build.outputs.filename }}
|
||||||
|
ASSEMBLYNAME: AssemblyName=${{ steps.Build.outputs.assemblyname }}
|
||||||
|
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: ${{ steps.Build.outputs.filename }}
|
||||||
|
path: ${{ steps.Build.outputs.artifactpath }}
|
31
.gitignore
vendored
31
.gitignore
vendored
@ -1,31 +1,2 @@
|
|||||||
*.class
|
docker
|
||||||
*.log
|
|
||||||
*.ctxt
|
|
||||||
.mtj.tmp/
|
|
||||||
*.jar
|
|
||||||
*.war
|
|
||||||
*.nar
|
|
||||||
*.ear
|
|
||||||
*.zip
|
|
||||||
*.tar.gz
|
|
||||||
*.rar
|
|
||||||
hs_err_pid*
|
|
||||||
replay_pid*
|
|
||||||
.idea
|
.idea
|
||||||
cmake-build-*/
|
|
||||||
.idea/**/mongoSettings.xml
|
|
||||||
*.iws
|
|
||||||
out/
|
|
||||||
build/
|
|
||||||
work/
|
|
||||||
target/
|
|
||||||
.idea_modules/
|
|
||||||
atlassian-ide-plugin.xml
|
|
||||||
com_crashlytics_export_strings.xml
|
|
||||||
crashlytics.properties
|
|
||||||
crashlytics-build.properties
|
|
||||||
fabric.properties
|
|
||||||
git.properties
|
|
||||||
pom.xml.versionsBackup
|
|
||||||
/docker/questdb/
|
|
||||||
/docker/mongodb/
|
|
||||||
|
3
.idea/.gitignore
generated
vendored
3
.idea/.gitignore
generated
vendored
@ -1,3 +0,0 @@
|
|||||||
# Default ignored files
|
|
||||||
/shelf/
|
|
||||||
/workspace.xml
|
|
7
.idea/encodings.xml
generated
7
.idea/encodings.xml
generated
@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="Encoding">
|
|
||||||
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
|
|
||||||
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
6
.idea/git_toolbox_blame.xml
generated
6
.idea/git_toolbox_blame.xml
generated
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="GitToolBoxBlameSettings">
|
|
||||||
<option name="version" value="2" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
14
.idea/misc.xml
generated
14
.idea/misc.xml
generated
@ -1,14 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
|
||||||
<component name="MavenProjectsManager">
|
|
||||||
<option name="originalFiles">
|
|
||||||
<list>
|
|
||||||
<option value="$PROJECT_DIR$/pom.xml" />
|
|
||||||
</list>
|
|
||||||
</option>
|
|
||||||
</component>
|
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="azul-17" project-jdk-type="JavaSDK">
|
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
7
.idea/vcs.xml
generated
7
.idea/vcs.xml
generated
@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
|
||||||
<mapping directory="$PROJECT_DIR$/docker" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
29
API/.gitignore
vendored
Normal file
29
API/.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
*.ctxt
|
||||||
|
.mtj.tmp/
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.nar
|
||||||
|
*.ear
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
*.rar
|
||||||
|
hs_err_pid*
|
||||||
|
replay_pid*
|
||||||
|
.idea
|
||||||
|
cmake-build-*/
|
||||||
|
.idea/**/mongoSettings.xml
|
||||||
|
*.iws
|
||||||
|
out/
|
||||||
|
build/
|
||||||
|
work/
|
||||||
|
target/
|
||||||
|
.idea_modules/
|
||||||
|
atlassian-ide-plugin.xml
|
||||||
|
com_crashlytics_export_strings.xml
|
||||||
|
crashlytics.properties
|
||||||
|
crashlytics-build.properties
|
||||||
|
fabric.properties
|
||||||
|
git.properties
|
||||||
|
pom.xml.versionsBackup
|
@ -11,7 +11,7 @@ COPY . .
|
|||||||
RUN mvn package -q -Dmaven.test.skip -DskipTests -T2C
|
RUN mvn package -q -Dmaven.test.skip -DskipTests -T2C
|
||||||
|
|
||||||
# Stage 2: Create the final lightweight image
|
# Stage 2: Create the final lightweight image
|
||||||
FROM eclipse-temurin:17.0.11_9-jre-focal
|
FROM eclipse-temurin:17.0.12_7-jre-focal
|
||||||
|
|
||||||
# Set the working directory
|
# Set the working directory
|
||||||
WORKDIR /home/container
|
WORKDIR /home/container
|
@ -62,10 +62,6 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
@ -74,6 +70,20 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-mongodb</artifactId>
|
<artifactId>spring-boot-starter-data-mongodb</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
<exclusions>
|
||||||
|
<exclusion>
|
||||||
|
<groupId>io.lettuce</groupId>
|
||||||
|
<artifactId>lettuce-core</artifactId>
|
||||||
|
</exclusion>
|
||||||
|
</exclusions>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>redis.clients</groupId>
|
||||||
|
<artifactId>jedis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Dependencies -->
|
<!-- Dependencies -->
|
||||||
<dependency>
|
<dependency>
|
||||||
@ -85,13 +95,9 @@
|
|||||||
<artifactId>unirest-modules-jackson</artifactId>
|
<artifactId>unirest-modules-jackson</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.questdb</groupId>
|
<groupId>net.jodah</groupId>
|
||||||
<artifactId>questdb</artifactId>
|
<artifactId>expiringmap</artifactId>
|
||||||
<version>8.0.3</version>
|
<version>0.5.11</version>
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.postgresql</groupId>
|
|
||||||
<artifactId>postgresql</artifactId>
|
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Libraries -->
|
<!-- Libraries -->
|
||||||
@ -110,7 +116,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.jetbrains</groupId>
|
<groupId>org.jetbrains</groupId>
|
||||||
<artifactId>annotations</artifactId>
|
<artifactId>annotations</artifactId>
|
||||||
<version>24.1.0</version>
|
<version>26.0.1</version>
|
||||||
<scope>compile</scope>
|
<scope>compile</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
@ -6,8 +6,8 @@ import lombok.extern.log4j.Log4j2;
|
|||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
|
import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration;
|
||||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
|
||||||
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories;
|
import org.springframework.data.mongodb.repository.config.EnableMongoRepositories;
|
||||||
|
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
|
||||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
@ -18,11 +18,11 @@ import java.util.Objects;
|
|||||||
/**
|
/**
|
||||||
* @author Fascinated (fascinated7)
|
* @author Fascinated (fascinated7)
|
||||||
*/
|
*/
|
||||||
@EnableJpaRepositories(basePackages = "cc.fascinated.repository.couchdb")
|
|
||||||
@EnableMongoRepositories(basePackages = "cc.fascinated.repository.mongo")
|
|
||||||
@EnableScheduling
|
@EnableScheduling
|
||||||
|
@EnableMongoRepositories(basePackages = "cc.fascinated.repository.mongo")
|
||||||
|
@EnableRedisRepositories(basePackages = "cc.fascinated.repository.redis")
|
||||||
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
|
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
|
||||||
@Log4j2(topic = "Ember")
|
@Log4j2(topic = "Score Tracker")
|
||||||
public class Main {
|
public class Main {
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
public static void main(@NonNull String[] args) {
|
public static void main(@NonNull String[] args) {
|
90
API/src/main/java/cc/fascinated/common/DateUtils.java
Normal file
90
API/src/main/java/cc/fascinated/common/DateUtils.java
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
package cc.fascinated.common;
|
||||||
|
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.ZonedDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Locale;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@UtilityClass
|
||||||
|
public class DateUtils {
|
||||||
|
private static final ZoneId ZONE_ID = ZoneId.of("Europe/London");
|
||||||
|
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_INSTANT
|
||||||
|
.withLocale(Locale.UK)
|
||||||
|
.withZone(ZONE_ID);
|
||||||
|
private static final DateTimeFormatter SIMPLE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||||
|
.withLocale(Locale.UK)
|
||||||
|
.withZone(ZONE_ID);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the date from a string.
|
||||||
|
*
|
||||||
|
* @param date The date string.
|
||||||
|
* @return The date.
|
||||||
|
*/
|
||||||
|
public static Date getDateFromIsoString(String date) {
|
||||||
|
return Date.from(Instant.from(ISO_FORMATTER.parse(date)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the date from a string.
|
||||||
|
*
|
||||||
|
* @param date The date string.
|
||||||
|
* @return The date.
|
||||||
|
*/
|
||||||
|
public static Date getDateFromString(String date) {
|
||||||
|
LocalDate localDate = LocalDate.parse(date, SIMPLE_FORMATTER);
|
||||||
|
ZonedDateTime zonedDateTime = localDate.atStartOfDay(ZONE_ID);
|
||||||
|
return Date.from(zonedDateTime.toInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date to a string.
|
||||||
|
*
|
||||||
|
* @param date The date to format.
|
||||||
|
* @return The formatted date.
|
||||||
|
*/
|
||||||
|
public String formatDate(Date date) {
|
||||||
|
return SIMPLE_FORMATTER.format(date.toInstant());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aligns the date to the current hour.
|
||||||
|
* <p>
|
||||||
|
* eg: 00:05 -> 00:00
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param date The date to align.
|
||||||
|
* @return The aligned date.
|
||||||
|
*/
|
||||||
|
public static Date alignToCurrentHour(Date date) {
|
||||||
|
return Date.from(Instant.ofEpochMilli(date.getTime()).truncatedTo(ChronoUnit.HOURS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the date from an amount of days ago.
|
||||||
|
*
|
||||||
|
* @param days The amount to go back.
|
||||||
|
* @return The date.
|
||||||
|
*/
|
||||||
|
public static Date getDaysAgo(int days) {
|
||||||
|
return Date.from(Instant.now().minus(days, ChronoUnit.DAYS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the date for midnight today.
|
||||||
|
*
|
||||||
|
* @return The date.
|
||||||
|
*/
|
||||||
|
public static Date getMidnightToday() {
|
||||||
|
return Date.from(Instant.now().truncatedTo(ChronoUnit.DAYS));
|
||||||
|
}
|
||||||
|
}
|
24
API/src/main/java/cc/fascinated/common/EnumUtils.java
Normal file
24
API/src/main/java/cc/fascinated/common/EnumUtils.java
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
package cc.fascinated.common;
|
||||||
|
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@UtilityClass
|
||||||
|
public class EnumUtils {
|
||||||
|
/**
|
||||||
|
* Gets the name of the enum
|
||||||
|
*
|
||||||
|
* @param e the enum
|
||||||
|
* @return the name
|
||||||
|
*/
|
||||||
|
public static String getEnumName(Enum<?> e) {
|
||||||
|
String[] split = e.name().split("_");
|
||||||
|
StringBuilder builder = new StringBuilder();
|
||||||
|
for (String s : split) {
|
||||||
|
builder.append(s.substring(0, 1).toUpperCase()).append(s.substring(1).toLowerCase()).append(" ");
|
||||||
|
}
|
||||||
|
return builder.toString().trim();
|
||||||
|
}
|
||||||
|
}
|
54
API/src/main/java/cc/fascinated/common/IPUtils.java
Normal file
54
API/src/main/java/cc/fascinated/common/IPUtils.java
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package cc.fascinated.common;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@UtilityClass
|
||||||
|
public final class IPUtils {
|
||||||
|
/**
|
||||||
|
* The regex expression for validating IPv4 addresses.
|
||||||
|
*/
|
||||||
|
public static final String IPV4_REGEX = "^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The regex expression for validating IPv6 addresses.
|
||||||
|
*/
|
||||||
|
public static final String IPV6_REGEX = "^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^(([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})?::(([0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4})?$";
|
||||||
|
|
||||||
|
private static final String[] IP_HEADERS = new String[] {
|
||||||
|
"CF-Connecting-IP",
|
||||||
|
"X-Forwarded-For"
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the real IP from the given request.
|
||||||
|
*
|
||||||
|
* @param request the request
|
||||||
|
* @return the real IP
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public static String getRealIp(@NonNull HttpServletRequest request) {
|
||||||
|
String ip = request.getRemoteAddr();
|
||||||
|
for (String headerName : IP_HEADERS) {
|
||||||
|
String header = request.getHeader(headerName);
|
||||||
|
if (header == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!header.contains(",")) { // Handle single IP
|
||||||
|
ip = header;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Handle multiple IPs
|
||||||
|
String[] ips = header.split(",");
|
||||||
|
for (String ipHeader : ips) {
|
||||||
|
ip = ipHeader;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
}
|
@ -2,6 +2,9 @@ package cc.fascinated.common;
|
|||||||
|
|
||||||
import lombok.experimental.UtilityClass;
|
import lombok.experimental.UtilityClass;
|
||||||
|
|
||||||
|
import java.text.DecimalFormat;
|
||||||
|
import java.text.DecimalFormatSymbols;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author Fascinated (fascinated7)
|
* @author Fascinated (fascinated7)
|
||||||
*/
|
*/
|
||||||
@ -31,4 +34,19 @@ public class MathUtils {
|
|||||||
public static double lerp(double a, double b, double t) {
|
public static double lerp(double a, double b, double t) {
|
||||||
return a + t * (b - a);
|
return a + t * (b - a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a number to a specific amount of decimal places.
|
||||||
|
*
|
||||||
|
* @param number the number to format
|
||||||
|
* @param additional the additional decimal places to format
|
||||||
|
* @return the formatted number
|
||||||
|
*/
|
||||||
|
public static double format(double number, int additional) {
|
||||||
|
return Double.parseDouble(
|
||||||
|
new DecimalFormat("#.#" + "#".repeat(Math.max(0, additional - 1)),
|
||||||
|
new DecimalFormatSymbols()
|
||||||
|
).format(number)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
145
API/src/main/java/cc/fascinated/common/PaginationBuilder.java
Normal file
145
API/src/main/java/cc/fascinated/common/PaginationBuilder.java
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
package cc.fascinated.common;
|
||||||
|
|
||||||
|
import cc.fascinated.exception.impl.BadRequestException;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class PaginationBuilder<T> {
|
||||||
|
/**
|
||||||
|
* The number of items per page.
|
||||||
|
*/
|
||||||
|
private int itemsPerPage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total number of items.
|
||||||
|
*/
|
||||||
|
private int totalItems;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The items to paginate.
|
||||||
|
*/
|
||||||
|
private Function<FetchItems, List<T>> items;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the number of items per page.
|
||||||
|
*
|
||||||
|
* @param itemsPerPage The number of items per page.
|
||||||
|
* @return The pagination builder.
|
||||||
|
*/
|
||||||
|
public PaginationBuilder<T> itemsPerPage(int itemsPerPage) {
|
||||||
|
this.itemsPerPage = itemsPerPage;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the total number of items.
|
||||||
|
*
|
||||||
|
* @param totalItems The total number of items.
|
||||||
|
* @return The pagination builder.
|
||||||
|
*/
|
||||||
|
public PaginationBuilder<T> totalItems(Supplier<Integer> totalItems) {
|
||||||
|
this.totalItems = totalItems.get();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the items to paginate.
|
||||||
|
*
|
||||||
|
* @param getItems The items to paginate.
|
||||||
|
* @return The pagination builder.
|
||||||
|
*/
|
||||||
|
public PaginationBuilder<T> items(Function<FetchItems, List<T>> getItems) {
|
||||||
|
this.items = getItems;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the pagination.
|
||||||
|
*
|
||||||
|
* @return The pagination.
|
||||||
|
*/
|
||||||
|
public PaginationBuilder<T> build() {
|
||||||
|
return new PaginationBuilder<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a page of items.
|
||||||
|
*
|
||||||
|
* @param page The page number.
|
||||||
|
* @return The page.
|
||||||
|
*/
|
||||||
|
public Page<T> getPage(int page) {
|
||||||
|
List<T> items = this.items.apply(new FetchItems(page, this.itemsPerPage));
|
||||||
|
int totalPages = (int) Math.ceil((double) this.totalItems / this.itemsPerPage);
|
||||||
|
|
||||||
|
if (page < 1 || page > totalPages) {
|
||||||
|
throw new BadRequestException("Invalid page number");
|
||||||
|
}
|
||||||
|
return new Page<>(
|
||||||
|
items,
|
||||||
|
new Page.Metadata(page, totalPages, this.totalItems)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public static class FetchItems {
|
||||||
|
/**
|
||||||
|
* The current page.
|
||||||
|
*/
|
||||||
|
private final int currentPage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The items per page.
|
||||||
|
*/
|
||||||
|
private final int itemsPerPage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of items to skip.
|
||||||
|
*/
|
||||||
|
public int skipAmount() {
|
||||||
|
return (currentPage - 1) * itemsPerPage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public static class Page<T> {
|
||||||
|
/**
|
||||||
|
* The items on the page.
|
||||||
|
*/
|
||||||
|
private final List<T> items;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata of the page.
|
||||||
|
*/
|
||||||
|
private final Metadata metadata;
|
||||||
|
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public static class Metadata {
|
||||||
|
/**
|
||||||
|
* The page number.
|
||||||
|
*/
|
||||||
|
private final int page;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total number of pages.
|
||||||
|
*/
|
||||||
|
private final int totalPages;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The total number of items.
|
||||||
|
*/
|
||||||
|
private final int totalItems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
93
API/src/main/java/cc/fascinated/common/Request.java
Normal file
93
API/src/main/java/cc/fascinated/common/Request.java
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
package cc.fascinated.common;
|
||||||
|
|
||||||
|
import kong.unirest.core.Headers;
|
||||||
|
import kong.unirest.core.HttpResponse;
|
||||||
|
import kong.unirest.core.Unirest;
|
||||||
|
import kong.unirest.core.UnirestParsingException;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Log4j2(topic = "Request")
|
||||||
|
public class Request {
|
||||||
|
/**
|
||||||
|
* The rate limit headers.
|
||||||
|
*/
|
||||||
|
private static final List<String> rateLimitHeaders = List.of(
|
||||||
|
"X-RateLimit-Remaining",
|
||||||
|
"RateLimit-Remaining"
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rate limit reset headers.
|
||||||
|
*/
|
||||||
|
private static final List<String> rateLimitResetHeaders = List.of(
|
||||||
|
"X-RateLimit-Reset",
|
||||||
|
"RateLimit-Reset"
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a GET request to a URL.
|
||||||
|
*
|
||||||
|
* @param url the URL to send the request to
|
||||||
|
* @param clazz the class to parse the response to
|
||||||
|
* @param <T> the type of the response
|
||||||
|
* @return the response
|
||||||
|
*/
|
||||||
|
public static <T> HttpResponse<T> get(String url, Class<T> clazz) {
|
||||||
|
HttpResponse<T> response = Unirest.get(url).asObject(clazz);
|
||||||
|
int rateLimitRemaining = getRateLimitRemaining(response);
|
||||||
|
if (rateLimitRemaining == 0) {
|
||||||
|
long rateLimitReset = getRateLimitReset(response);
|
||||||
|
long timeLeft = rateLimitReset - System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
Thread.sleep(timeLeft);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
log.error("Failed to sleep for rate limit reset", e);
|
||||||
|
}
|
||||||
|
response = Unirest.get(url).asObject(clazz);
|
||||||
|
}
|
||||||
|
response.getParsingError().ifPresent(e -> log.error("Failed to parse response", e));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the rate limit remaining.
|
||||||
|
*
|
||||||
|
* @param response the response to get the rate limit remaining from
|
||||||
|
* @return the rate limit remaining
|
||||||
|
*/
|
||||||
|
public static int getRateLimitRemaining(HttpResponse<?> response) {
|
||||||
|
Headers headers = response.getHeaders();
|
||||||
|
for (String rateLimitHeader : rateLimitHeaders) {
|
||||||
|
if (headers.containsKey(rateLimitHeader)) {
|
||||||
|
return Integer.parseInt(headers.getFirst(rateLimitHeader));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the rate limit reset absolute time.
|
||||||
|
*
|
||||||
|
* @param response the response to get the rate limit reset time from
|
||||||
|
* @return the rate limit reset time
|
||||||
|
*/
|
||||||
|
public static long getRateLimitReset(HttpResponse<?> response) {
|
||||||
|
Headers headers = response.getHeaders();
|
||||||
|
for (String rateLimitResetHeader : rateLimitResetHeaders) {
|
||||||
|
if (headers.containsKey(rateLimitResetHeader)) {
|
||||||
|
long reset = Long.parseLong(headers.getFirst(rateLimitResetHeader));
|
||||||
|
if (reset < 86400) {// Assume it's in seconds left
|
||||||
|
return System.currentTimeMillis() + reset * 1000;
|
||||||
|
}
|
||||||
|
return reset * 1000; // Assume it's in seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
33
API/src/main/java/cc/fascinated/common/StringUtils.java
Normal file
33
API/src/main/java/cc/fascinated/common/StringUtils.java
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
package cc.fascinated.common;
|
||||||
|
|
||||||
|
import java.math.BigInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
public class StringUtils {
|
||||||
|
/**
|
||||||
|
* Converts a string to a hexadecimal string.
|
||||||
|
*
|
||||||
|
* @param arg the string to convert
|
||||||
|
* @return the hexadecimal string
|
||||||
|
*/
|
||||||
|
public static String toHex(String arg) {
|
||||||
|
return String.format("%040x", new BigInteger(1, arg.getBytes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a random string.
|
||||||
|
*
|
||||||
|
* @param length the length of the string
|
||||||
|
* @return the random string
|
||||||
|
*/
|
||||||
|
public static String randomString(int length) {
|
||||||
|
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
StringBuilder stringBuilder = new StringBuilder();
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
stringBuilder.append(chars.charAt((int) (Math.random() * chars.length())));
|
||||||
|
}
|
||||||
|
return stringBuilder.toString();
|
||||||
|
}
|
||||||
|
}
|
166
API/src/main/java/cc/fascinated/common/TimeUtils.java
Normal file
166
API/src/main/java/cc/fascinated/common/TimeUtils.java
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
package cc.fascinated.common;
|
||||||
|
|
||||||
|
import lombok.*;
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@UtilityClass
|
||||||
|
public final class TimeUtils {
|
||||||
|
/**
|
||||||
|
* Format a time in millis to a readable time format.
|
||||||
|
*
|
||||||
|
* @param millis the millis to format
|
||||||
|
* @return the formatted time
|
||||||
|
*/
|
||||||
|
public static String format(long millis) {
|
||||||
|
return format(millis, BatTimeFormat.FIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a time in millis to a readable time format.
|
||||||
|
*
|
||||||
|
* @param millis the millis to format
|
||||||
|
* @param timeUnit the time unit to format the millis to
|
||||||
|
* @return the formatted time
|
||||||
|
*/
|
||||||
|
public static String format(long millis, BatTimeFormat timeUnit) {
|
||||||
|
return format(millis, timeUnit, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a time in millis to a readable time format.
|
||||||
|
*
|
||||||
|
* @param millis the millis to format
|
||||||
|
* @param timeUnit the time unit to format the millis to
|
||||||
|
* @param compact whether to use a compact display
|
||||||
|
* @return the formatted time
|
||||||
|
*/
|
||||||
|
public static String format(long millis, BatTimeFormat timeUnit, boolean compact) {
|
||||||
|
return format(millis, timeUnit, true, compact);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a time in millis to a readable time format.
|
||||||
|
*
|
||||||
|
* @param millis the millis to format
|
||||||
|
* @param timeUnit the time unit to format the millis to
|
||||||
|
* @param decimals whether to include decimals
|
||||||
|
* @param compact whether to use a compact display
|
||||||
|
* @return the formatted time
|
||||||
|
*/
|
||||||
|
public static String format(long millis, BatTimeFormat timeUnit, boolean decimals, boolean compact) {
|
||||||
|
if (millis == -1L) { // Format permanent
|
||||||
|
return "Perm" + (compact ? "" : "anent");
|
||||||
|
}
|
||||||
|
// Format the time to the best fitting time unit
|
||||||
|
if (timeUnit == BatTimeFormat.FIT) {
|
||||||
|
for (BatTimeFormat otherTimeUnit : BatTimeFormat.VALUES) {
|
||||||
|
if (otherTimeUnit != BatTimeFormat.FIT && millis >= otherTimeUnit.getMillis()) {
|
||||||
|
timeUnit = otherTimeUnit;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double time = MathUtils.format((double) millis / timeUnit.getMillis(), 1); // Format the time
|
||||||
|
if (!decimals) { // Remove decimals
|
||||||
|
time = (int) time;
|
||||||
|
}
|
||||||
|
String formatted = time + (compact ? timeUnit.getSuffix() : " " + timeUnit.getDisplay()); // Append the time unit
|
||||||
|
if (time != 1.0 && !compact) { // Pluralize the time unit
|
||||||
|
formatted += "s";
|
||||||
|
}
|
||||||
|
return formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert the given input into a time in millis.
|
||||||
|
* <p>
|
||||||
|
* E.g: 1d, 1h, 1d1h, etc
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param input the input to parse
|
||||||
|
* @return the time in millis
|
||||||
|
*/
|
||||||
|
public static long fromString(String input) {
|
||||||
|
Matcher matcher = BatTimeFormat.SUFFIX_PATTERN.matcher(input); // Match the given input
|
||||||
|
long millis = 0; // The total millis
|
||||||
|
|
||||||
|
// Match corresponding suffixes and add up the total millis
|
||||||
|
while (matcher.find()) {
|
||||||
|
int amount = Integer.parseInt(matcher.group(1)); // The amount of time to add
|
||||||
|
String suffix = matcher.group(2); // The unit suffix
|
||||||
|
BatTimeFormat timeUnit = BatTimeFormat.fromSuffix(suffix); // The time unit to add
|
||||||
|
if (timeUnit != null) { // Increment the total millis
|
||||||
|
millis += amount * timeUnit.getMillis();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return millis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a unit of time.
|
||||||
|
*/
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter(AccessLevel.PRIVATE)
|
||||||
|
@ToString
|
||||||
|
public enum BatTimeFormat {
|
||||||
|
FIT,
|
||||||
|
YEARS("Year", "y", TimeUnit.DAYS.toMillis(365L)),
|
||||||
|
MONTHS("Month", "mo", TimeUnit.DAYS.toMillis(30L)),
|
||||||
|
WEEKS("Week", "w", TimeUnit.DAYS.toMillis(7L)),
|
||||||
|
DAYS("Day", "d", TimeUnit.DAYS.toMillis(1L)),
|
||||||
|
HOURS("Hour", "h", TimeUnit.HOURS.toMillis(1L)),
|
||||||
|
MINUTES("Minute", "m", TimeUnit.MINUTES.toMillis(1L)),
|
||||||
|
SECONDS("Second", "s", TimeUnit.SECONDS.toMillis(1L)),
|
||||||
|
MILLISECONDS("Millisecond", "ms", 1L);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Our cached unit values.
|
||||||
|
*/
|
||||||
|
public static final BatTimeFormat[] VALUES = values();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Our cached suffix pattern.
|
||||||
|
*/
|
||||||
|
public static final Pattern SUFFIX_PATTERN = Pattern.compile("(\\d+)(mo|ms|[ywdhms])");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The display of this time unit.
|
||||||
|
*/
|
||||||
|
private String display;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The suffix of this time unit.
|
||||||
|
*/
|
||||||
|
private String suffix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The amount of millis in this time unit.
|
||||||
|
*/
|
||||||
|
private long millis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the time unit with the given suffix.
|
||||||
|
*
|
||||||
|
* @param suffix the time unit suffix
|
||||||
|
* @return the time unit, null if not found
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public static BatTimeFormat fromSuffix(String suffix) {
|
||||||
|
for (BatTimeFormat unit : VALUES) {
|
||||||
|
if (unit != FIT && unit.getSuffix().equals(suffix)) {
|
||||||
|
return unit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
21
API/src/main/java/cc/fascinated/common/Tuple.java
Normal file
21
API/src/main/java/cc/fascinated/common/Tuple.java
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package cc.fascinated.common;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public class Tuple<L, R> {
|
||||||
|
/**
|
||||||
|
* The left value of the tuple.
|
||||||
|
*/
|
||||||
|
private final L left;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right value of the tuple.
|
||||||
|
*/
|
||||||
|
private final R right;
|
||||||
|
}
|
73
API/src/main/java/cc/fascinated/config/RedisConfig.java
Normal file
73
API/src/main/java/cc/fascinated/config/RedisConfig.java
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
package cc.fascinated.config;
|
||||||
|
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
|
||||||
|
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Braydon
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@Log4j2(topic = "Redis")
|
||||||
|
public class RedisConfig {
|
||||||
|
/**
|
||||||
|
* The Redis server host.
|
||||||
|
*/
|
||||||
|
@Value("${spring.data.redis.host}")
|
||||||
|
private String host;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Redis server port.
|
||||||
|
*/
|
||||||
|
@Value("${spring.data.redis.port}")
|
||||||
|
private int port;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Redis database index.
|
||||||
|
*/
|
||||||
|
@Value("${spring.data.redis.database}")
|
||||||
|
private int database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The optional Redis password.
|
||||||
|
*/
|
||||||
|
@Value("${spring.data.redis.auth}")
|
||||||
|
private String auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the config to use for Redis.
|
||||||
|
*
|
||||||
|
* @return the config
|
||||||
|
* @see RedisTemplate for config
|
||||||
|
*/
|
||||||
|
@Bean @NonNull
|
||||||
|
public RedisTemplate<String, Object> redisTemplate() {
|
||||||
|
RedisTemplate<String, Object> template = new RedisTemplate<>();
|
||||||
|
template.setConnectionFactory(jedisConnectionFactory());
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the connection factory to use
|
||||||
|
* when making connections to Redis.
|
||||||
|
*
|
||||||
|
* @return the built factory
|
||||||
|
* @see JedisConnectionFactory for factory
|
||||||
|
*/
|
||||||
|
@Bean @NonNull
|
||||||
|
public JedisConnectionFactory jedisConnectionFactory() {
|
||||||
|
log.info("Connecting to Redis at {}:{}/{}", host, port, database);
|
||||||
|
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
|
||||||
|
config.setDatabase(database);
|
||||||
|
if (!auth.trim().isEmpty()) { // Auth with our provided password
|
||||||
|
log.info("Using auth...");
|
||||||
|
config.setPassword(auth);
|
||||||
|
}
|
||||||
|
return new JedisConnectionFactory(config);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
package cc.fascinated.controller;
|
||||||
|
|
||||||
|
import cc.fascinated.model.auth.LoginRequest;
|
||||||
|
import cc.fascinated.model.auth.AuthToken;
|
||||||
|
import cc.fascinated.services.UserService;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = "/auth", produces = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public class AuthenticationController {
|
||||||
|
/**
|
||||||
|
* The user service to use
|
||||||
|
*/
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public AuthenticationController(UserService userService) {
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A POST request to get an auth token from a steam ticket.
|
||||||
|
*/
|
||||||
|
@ResponseBody
|
||||||
|
@PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||||
|
public ResponseEntity<?> getAuthToken(@RequestBody LoginRequest request) {
|
||||||
|
if (request == null || request.getTicket() == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "Invalid request or missing ticket"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
AuthToken authToken = this.userService.getAuthToken(request.getTicket());
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header("Authorization", authToken.getAuthToken())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A POST request to validate an auth token.
|
||||||
|
*/
|
||||||
|
@ResponseBody
|
||||||
|
@PostMapping(value = "/validate")
|
||||||
|
public ResponseEntity<?> validateAuthToken(@RequestHeader("Authorization") String authToken) {
|
||||||
|
String token = authToken == null ? null : authToken.replace("Bearer ", "");
|
||||||
|
if (token == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"error", "Invalid request or missing token"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return new ResponseEntity<>(this.userService.isValidAuthToken(token) ? HttpStatus.OK : HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
|||||||
|
package cc.fascinated.controller;
|
||||||
|
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseBody;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = "/")
|
||||||
|
public class RootController {
|
||||||
|
/**
|
||||||
|
* A GET mapping to show the welcome message.
|
||||||
|
*/
|
||||||
|
@ResponseBody
|
||||||
|
@GetMapping(value = "/")
|
||||||
|
public ResponseEntity<?> getWelcome() {
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"message", "Hello!",
|
||||||
|
"url", "https://git.fascinated.cc/Fascinated/beatsaber-scoretracker"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,85 @@
|
|||||||
|
package cc.fascinated.controller;
|
||||||
|
|
||||||
|
import cc.fascinated.exception.impl.BadRequestException;
|
||||||
|
import cc.fascinated.platform.Platform;
|
||||||
|
import cc.fascinated.services.ScoreService;
|
||||||
|
import cc.fascinated.services.UserService;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping(value = "/scores")
|
||||||
|
public class ScoresController {
|
||||||
|
/**
|
||||||
|
* The tracked score service to use.
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final ScoreService scoreService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user service to use
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ScoresController(@NonNull ScoreService scoreService, @NonNull UserService userService) {
|
||||||
|
this.scoreService = scoreService;
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GET mapping to retrieve the top
|
||||||
|
* scores for a platform
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the scores from
|
||||||
|
* @return the scores
|
||||||
|
* @throws BadRequestException if there were no scores found
|
||||||
|
*/
|
||||||
|
@ResponseBody
|
||||||
|
@GetMapping(value = "/top/{platform}")
|
||||||
|
public ResponseEntity<?> getTopScores(
|
||||||
|
@PathVariable String platform,
|
||||||
|
@RequestParam(defaultValue = "1") int page,
|
||||||
|
@RequestParam(defaultValue = "false") boolean scoresonly
|
||||||
|
) {
|
||||||
|
return ResponseEntity.ok(scoreService.getTopRankedScores(Platform.Platforms.getPlatform(platform), page, scoresonly));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GET mapping to retrieve the total
|
||||||
|
* amount of scores for a platform
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the scores from
|
||||||
|
* @return the amount of scores
|
||||||
|
* @throws BadRequestException if there were no scores found
|
||||||
|
*/
|
||||||
|
@ResponseBody
|
||||||
|
@GetMapping(value = "/count/{platform}")
|
||||||
|
public ResponseEntity<?> getScoresCount(@PathVariable String platform) {
|
||||||
|
return ResponseEntity.ok(scoreService.getTotalScores(Platform.Platforms.getPlatform(platform)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GET mapping to retrieve the score
|
||||||
|
* history for a leaderboard
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the history from
|
||||||
|
* @return the score history
|
||||||
|
* @throws BadRequestException if there were no history found
|
||||||
|
*/
|
||||||
|
@ResponseBody
|
||||||
|
@GetMapping(value = "/history/{platform}/{playerId}/{leaderboardId}")
|
||||||
|
public ResponseEntity<?> getScoreHistory(@PathVariable String platform, @PathVariable String playerId, @PathVariable String leaderboardId) {
|
||||||
|
return ResponseEntity.ok(scoreService.getScoreHistory(
|
||||||
|
Platform.Platforms.getPlatform(platform),
|
||||||
|
userService.getUser(playerId),
|
||||||
|
leaderboardId
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
package cc.fascinated.controller;
|
package cc.fascinated.controller;
|
||||||
|
|
||||||
import cc.fascinated.exception.impl.BadRequestException;
|
import cc.fascinated.exception.impl.BadRequestException;
|
||||||
import cc.fascinated.model.user.User;
|
import cc.fascinated.model.user.UserDTO;
|
||||||
import cc.fascinated.services.UserService;
|
import cc.fascinated.services.UserService;
|
||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@ -17,7 +17,8 @@ public class UserController {
|
|||||||
/**
|
/**
|
||||||
* The user service to use
|
* The user service to use
|
||||||
*/
|
*/
|
||||||
@NonNull private final UserService userService;
|
@NonNull
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public UserController(@NonNull UserService userService) {
|
public UserController(@NonNull UserService userService) {
|
||||||
@ -33,7 +34,21 @@ public class UserController {
|
|||||||
*/
|
*/
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
@GetMapping(value = "/{id}")
|
@GetMapping(value = "/{id}")
|
||||||
public ResponseEntity<User> getUser(@PathVariable String id) {
|
public ResponseEntity<UserDTO> getUser(@PathVariable String id) {
|
||||||
return ResponseEntity.ok(userService.getUser(id));
|
return ResponseEntity.ok(userService.getUser(id).getAsDTO());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A GET mapping to retrieve a user's statistic
|
||||||
|
* history using the users steam id.
|
||||||
|
*
|
||||||
|
* @param id the id of the user
|
||||||
|
* @return the user's statistic history
|
||||||
|
* @throws BadRequestException if the user is not found
|
||||||
|
*/
|
||||||
|
@ResponseBody
|
||||||
|
@GetMapping(value = "/histories/{id}")
|
||||||
|
public ResponseEntity<?> getUserHistories(@PathVariable String id) {
|
||||||
|
return ResponseEntity.ok(userService.getUser(id).getHistory().getPreviousHistories(30));
|
||||||
}
|
}
|
||||||
}
|
}
|
50
API/src/main/java/cc/fascinated/log/TransactionLogger.java
Normal file
50
API/src/main/java/cc/fascinated/log/TransactionLogger.java
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package cc.fascinated.log;
|
||||||
|
|
||||||
|
import cc.fascinated.common.IPUtils;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.core.MethodParameter;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.converter.HttpMessageConverter;
|
||||||
|
import org.springframework.http.server.ServerHttpRequest;
|
||||||
|
import org.springframework.http.server.ServerHttpResponse;
|
||||||
|
import org.springframework.http.server.ServletServerHttpRequest;
|
||||||
|
import org.springframework.http.server.ServletServerHttpResponse;
|
||||||
|
import org.springframework.web.bind.annotation.ControllerAdvice;
|
||||||
|
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Responsible for logging request and
|
||||||
|
* response transactions to the terminal.
|
||||||
|
*
|
||||||
|
* @author Braydon
|
||||||
|
* @see HttpServletRequest for request
|
||||||
|
* @see HttpServletResponse for response
|
||||||
|
*/
|
||||||
|
@ControllerAdvice
|
||||||
|
@Slf4j(topic = "Req/Res Transaction")
|
||||||
|
public class TransactionLogger implements ResponseBodyAdvice<Object> {
|
||||||
|
@Override
|
||||||
|
public boolean supports(@NonNull MethodParameter returnType, @NonNull Class<? extends HttpMessageConverter<?>> converterType) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object beforeBodyWrite(Object body, @NonNull MethodParameter returnType, @NonNull MediaType selectedContentType,
|
||||||
|
@NonNull Class<? extends HttpMessageConverter<?>> selectedConverterType,
|
||||||
|
@NonNull ServerHttpRequest rawRequest, @NonNull ServerHttpResponse rawResponse) {
|
||||||
|
HttpServletRequest request = ((ServletServerHttpRequest) rawRequest).getServletRequest();
|
||||||
|
HttpServletResponse response = ((ServletServerHttpResponse) rawResponse).getServletResponse();
|
||||||
|
|
||||||
|
// Get the request ip ip
|
||||||
|
String ip = IPUtils.getRealIp(request);
|
||||||
|
|
||||||
|
log.info("[Request] %s - %s %s %s".formatted(
|
||||||
|
ip, request.getMethod(), request.getRequestURI(), response.getStatus()
|
||||||
|
));
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
25
API/src/main/java/cc/fascinated/model/Counter.java
Normal file
25
API/src/main/java/cc/fascinated/model/Counter.java
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package cc.fascinated.model;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class Counter {
|
||||||
|
/**
|
||||||
|
* The ID of the counter.
|
||||||
|
*/
|
||||||
|
@Id
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next number in the counter.
|
||||||
|
*/
|
||||||
|
private long next;
|
||||||
|
}
|
29
API/src/main/java/cc/fascinated/model/auth/AuthToken.java
Normal file
29
API/src/main/java/cc/fascinated/model/auth/AuthToken.java
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package cc.fascinated.model.auth;
|
||||||
|
|
||||||
|
import cc.fascinated.common.StringUtils;
|
||||||
|
import cc.fascinated.model.token.steam.SteamAuthenticateUserTicketToken;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.redis.core.RedisHash;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
@RedisHash(value = "AuthToken", timeToLive = 60 * 60 * 6) // 6 hours
|
||||||
|
public class AuthToken {
|
||||||
|
/**
|
||||||
|
* The auth token of the user.
|
||||||
|
*/
|
||||||
|
@Id
|
||||||
|
private final String authToken;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The id of the user.
|
||||||
|
*/
|
||||||
|
private final UUID userId;
|
||||||
|
}
|
14
API/src/main/java/cc/fascinated/model/auth/LoginRequest.java
Normal file
14
API/src/main/java/cc/fascinated/model/auth/LoginRequest.java
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package cc.fascinated.model.auth;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class LoginRequest {
|
||||||
|
/**
|
||||||
|
* The ticket to authenticate the user.
|
||||||
|
*/
|
||||||
|
private String ticket;
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
package cc.fascinated.model.leaderboard;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public class Difficulty {
|
||||||
|
/**
|
||||||
|
* The difficulty of the song.
|
||||||
|
*/
|
||||||
|
private String difficulty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The raw difficulty of the song.
|
||||||
|
*/
|
||||||
|
private String difficultyRaw;
|
||||||
|
}
|
@ -0,0 +1,81 @@
|
|||||||
|
package cc.fascinated.model.leaderboard;
|
||||||
|
|
||||||
|
import cc.fascinated.common.ScoreSaberUtils;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public class Leaderboard {
|
||||||
|
/**
|
||||||
|
* The ID of the leaderboard.
|
||||||
|
*/
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hash of the song.
|
||||||
|
*/
|
||||||
|
private String songHash;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the song.
|
||||||
|
*/
|
||||||
|
private String songName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The sub name of the song.
|
||||||
|
*/
|
||||||
|
private String songSubName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The author of the song.
|
||||||
|
*/
|
||||||
|
private String songAuthorName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mapper of the song.
|
||||||
|
*/
|
||||||
|
private String levelAuthorName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The star rating for this leaderboard.
|
||||||
|
*/
|
||||||
|
private double stars;
|
||||||
|
/**
|
||||||
|
* The image of the song for this leaderboard.
|
||||||
|
*/
|
||||||
|
private String image;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The difficulty of the song.
|
||||||
|
*/
|
||||||
|
private Difficulty difficulty;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new {@link Leaderboard} object
|
||||||
|
* from a {@link ScoreSaberLeaderboardToken} object.
|
||||||
|
*
|
||||||
|
* @param token The token to construct the object from.
|
||||||
|
* @return The leaderboard.
|
||||||
|
*/
|
||||||
|
public static Leaderboard getFromScoreSaberToken(ScoreSaberLeaderboardToken token) {
|
||||||
|
return new Leaderboard(
|
||||||
|
token.getId(),
|
||||||
|
token.getSongHash(),
|
||||||
|
token.getSongName(),
|
||||||
|
token.getSongSubName(),
|
||||||
|
token.getSongAuthorName(),
|
||||||
|
token.getLevelAuthorName(),
|
||||||
|
token.getStars(),
|
||||||
|
token.getCoverImage(),
|
||||||
|
new Difficulty(
|
||||||
|
ScoreSaberUtils.parseDifficulty(token.getDifficulty().getDifficulty()),
|
||||||
|
token.getDifficulty().getDifficultyRaw()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,39 @@
|
|||||||
|
package cc.fascinated.model.score;
|
||||||
|
|
||||||
|
import cc.fascinated.model.user.hmd.DeviceController;
|
||||||
|
import cc.fascinated.model.user.hmd.DeviceHeadset;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public class DeviceInformation {
|
||||||
|
/**
|
||||||
|
* The headset that was used to set the score.
|
||||||
|
*/
|
||||||
|
private final DeviceHeadset headset;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The left controller that was used to set the score.
|
||||||
|
*/
|
||||||
|
private final DeviceController leftController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The right controller that was used to set the score.
|
||||||
|
*/
|
||||||
|
private final DeviceController rightController;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the device information contains unknown values.
|
||||||
|
*
|
||||||
|
* @return if the device information contains unknown values
|
||||||
|
*/
|
||||||
|
public boolean containsUnknownDevices() {
|
||||||
|
return headset == DeviceHeadset.UNKNOWN
|
||||||
|
|| leftController == DeviceController.UNKNOWN
|
||||||
|
|| rightController == DeviceController.UNKNOWN;
|
||||||
|
}
|
||||||
|
}
|
161
API/src/main/java/cc/fascinated/model/score/Score.java
Normal file
161
API/src/main/java/cc/fascinated/model/score/Score.java
Normal file
@ -0,0 +1,161 @@
|
|||||||
|
package cc.fascinated.model.score;
|
||||||
|
|
||||||
|
import cc.fascinated.platform.Platform;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.mongodb.core.index.Indexed;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Document("scores")
|
||||||
|
public class Score {
|
||||||
|
/**
|
||||||
|
* The ID of the score.
|
||||||
|
* <p>
|
||||||
|
* This is an internal ID to avoid clashing with other scores.
|
||||||
|
* This is not the ID of the score on the platform.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Id
|
||||||
|
@JsonIgnore
|
||||||
|
private final long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the player that set the score.
|
||||||
|
*/
|
||||||
|
@Indexed
|
||||||
|
@JsonIgnore
|
||||||
|
private final String playerId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The platform the score was set on.
|
||||||
|
* <p>
|
||||||
|
* eg: {@link Platform.Platforms#SCORESABER}
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Indexed
|
||||||
|
@JsonIgnore
|
||||||
|
private final Platform.Platforms platform;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the score of the platform it was set on.
|
||||||
|
*/
|
||||||
|
@Indexed
|
||||||
|
@JsonProperty("scoreId")
|
||||||
|
private final String platformScoreId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the leaderboard the score was set on.
|
||||||
|
*/
|
||||||
|
@Indexed
|
||||||
|
@JsonIgnore
|
||||||
|
private final String leaderboardId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rank of the score when it was set.
|
||||||
|
*/
|
||||||
|
private int rank;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The accuracy of the score in a percentage.
|
||||||
|
*/
|
||||||
|
private final double accuracy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PP of the score.
|
||||||
|
* <p>
|
||||||
|
* e.g. 500pp
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Indexed
|
||||||
|
private Double pp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The score of the score.
|
||||||
|
*/
|
||||||
|
private final int score;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The list of modifiers used in the score.
|
||||||
|
*/
|
||||||
|
private final String[] modifiers;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of misses in the score.
|
||||||
|
*/
|
||||||
|
private final Integer misses;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of bad cuts in the score.
|
||||||
|
*/
|
||||||
|
private final Integer badCuts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The device information that was used to set the score.
|
||||||
|
* <p>
|
||||||
|
* Headset and controllers information.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private final DeviceInformation deviceInformation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The timestamp of when the score was set.
|
||||||
|
*/
|
||||||
|
private final Date timestamp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the misses of the score.
|
||||||
|
*
|
||||||
|
* @return the misses
|
||||||
|
*/
|
||||||
|
public Integer getMisses() {
|
||||||
|
return misses == null ? 0 : misses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the bad cuts of the score.
|
||||||
|
*
|
||||||
|
* @return the bad cuts
|
||||||
|
*/
|
||||||
|
public Integer getBadCuts() {
|
||||||
|
return badCuts == null ? 0 : badCuts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the weight of the score.
|
||||||
|
*
|
||||||
|
* @return the weight
|
||||||
|
*/
|
||||||
|
public Double getPp() {
|
||||||
|
return pp == null ? 0 : pp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the modifiers of the score.
|
||||||
|
*
|
||||||
|
* @return the modifiers
|
||||||
|
*/
|
||||||
|
public String[] getModifiers() {
|
||||||
|
return modifiers == null ? new String[0] : modifiers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets if the score is ranked.
|
||||||
|
*
|
||||||
|
* @return true if the score is ranked, false otherwise
|
||||||
|
*/
|
||||||
|
public boolean isRanked() {
|
||||||
|
return pp != null;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,60 @@
|
|||||||
|
package cc.fascinated.model.score.impl.scoresaber;
|
||||||
|
|
||||||
|
import cc.fascinated.model.score.DeviceInformation;
|
||||||
|
import cc.fascinated.model.score.Score;
|
||||||
|
import cc.fascinated.platform.Platform;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class ScoreSaberScore extends Score {
|
||||||
|
/**
|
||||||
|
* The weight of the score.
|
||||||
|
*/
|
||||||
|
private final Double weight;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The multiplier of the score.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private final double multiplier;
|
||||||
|
/**
|
||||||
|
* The maximum combo achieved in the score.
|
||||||
|
*/
|
||||||
|
private final int maxCombo;
|
||||||
|
|
||||||
|
public ScoreSaberScore(long id, String playerId, Platform.Platforms platform, String platformScoreId, String leaderboardId, int rank,
|
||||||
|
double accuracy, Double pp, int score, String[] modifiers, Integer misses, Integer badCuts, DeviceInformation deviceInformation,
|
||||||
|
Date timestamp, Double weight, double multiplier, int maxCombo) {
|
||||||
|
super(id, playerId, platform, platformScoreId, leaderboardId, rank, accuracy, pp, score, modifiers, misses,
|
||||||
|
badCuts, deviceInformation, timestamp);
|
||||||
|
this.weight = weight;
|
||||||
|
this.multiplier = multiplier;
|
||||||
|
this.maxCombo = maxCombo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the modified score.
|
||||||
|
*
|
||||||
|
* @return the modified score
|
||||||
|
*/
|
||||||
|
public int getModifiedScore() {
|
||||||
|
if (multiplier == 1) {
|
||||||
|
return getScore();
|
||||||
|
}
|
||||||
|
return (int) (getScore() * multiplier);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the weight of the score.
|
||||||
|
*
|
||||||
|
* @return the weight of the score
|
||||||
|
*/
|
||||||
|
public Double getWeight() {
|
||||||
|
return weight == null ? 0 : weight;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
package cc.fascinated.model.score.impl.scoresaber;
|
||||||
|
|
||||||
|
import cc.fascinated.model.leaderboard.Leaderboard;
|
||||||
|
import cc.fascinated.model.score.DeviceInformation;
|
||||||
|
import cc.fascinated.model.user.UserDTO;
|
||||||
|
import cc.fascinated.platform.Platform;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class ScoreSaberScoreResponse extends ScoreSaberScore {
|
||||||
|
/**
|
||||||
|
* The user that set the score.
|
||||||
|
*/
|
||||||
|
private final UserDTO user;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The leaderboard the score was set on.
|
||||||
|
*/
|
||||||
|
private final Leaderboard leaderboard;
|
||||||
|
|
||||||
|
public ScoreSaberScoreResponse(long id, String playerId, Platform.Platforms platform, String platformScoreId, String leaderboardId, int rank,
|
||||||
|
double accuracy, double pp, int score, String[] modifiers, int misses, int badCuts, DeviceInformation deviceInformation,
|
||||||
|
Date timestamp, double weight, double multiplier, int maxCombo, UserDTO user, Leaderboard leaderboard) {
|
||||||
|
super(id, playerId, platform, platformScoreId, leaderboardId, rank, accuracy, pp, score, modifiers, misses, badCuts,
|
||||||
|
deviceInformation, timestamp, weight, multiplier, maxCombo);
|
||||||
|
this.user = user;
|
||||||
|
this.leaderboard = leaderboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new score saber score response.
|
||||||
|
*
|
||||||
|
* @param score the score to create the response from
|
||||||
|
* @param user the user that set the score
|
||||||
|
* @param leaderboard the leaderboard the score was set on
|
||||||
|
* @return the score saber score response
|
||||||
|
*/
|
||||||
|
public static ScoreSaberScoreResponse fromScore(ScoreSaberScore score, UserDTO user, Leaderboard leaderboard) {
|
||||||
|
return new ScoreSaberScoreResponse(score.getId(), score.getPlayerId(), score.getPlatform(), score.getPlatformScoreId(), score.getLeaderboardId(),
|
||||||
|
score.getRank(), score.getAccuracy(), score.getPp(), score.getScore(), score.getModifiers(), score.getMisses(), score.getBadCuts(),
|
||||||
|
score.getDeviceInformation(), score.getTimestamp(), score.getWeight(), score.getMultiplier(), score.getMaxCombo(), user, leaderboard);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package cc.fascinated.model.token;
|
package cc.fascinated.model.token.scoresaber;
|
||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
@ -0,0 +1,19 @@
|
|||||||
|
package cc.fascinated.model.token.scoresaber;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.ToString;
|
||||||
|
|
||||||
|
@Getter @Setter
|
||||||
|
@ToString
|
||||||
|
public class ScoreSaberLeaderboardPageToken {
|
||||||
|
/**
|
||||||
|
* The scores on this page.
|
||||||
|
*/
|
||||||
|
private ScoreSaberLeaderboardToken[] leaderboards;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata for this page.
|
||||||
|
*/
|
||||||
|
private ScoreSaberPageMetadataToken metadata;
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package cc.fascinated.model.token;
|
package cc.fascinated.model.token.scoresaber;
|
||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.ToString;
|
import lombok.ToString;
|
@ -1,4 +1,4 @@
|
|||||||
package cc.fascinated.model.token;
|
package cc.fascinated.model.token.scoresaber;
|
||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.ToString;
|
import lombok.ToString;
|
@ -1,4 +1,4 @@
|
|||||||
package cc.fascinated.model.token;
|
package cc.fascinated.model.token.scoresaber;
|
||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.ToString;
|
import lombok.ToString;
|
@ -1,4 +1,4 @@
|
|||||||
package cc.fascinated.model.token;
|
package cc.fascinated.model.token.scoresaber;
|
||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.ToString;
|
import lombok.ToString;
|
@ -1,4 +1,4 @@
|
|||||||
package cc.fascinated.model.token;
|
package cc.fascinated.model.token.scoresaber;
|
||||||
|
|
||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
import lombok.Setter;
|
import lombok.Setter;
|
@ -1,4 +1,4 @@
|
|||||||
package cc.fascinated.model.token;
|
package cc.fascinated.model.token.scoresaber;
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
@ -0,0 +1,55 @@
|
|||||||
|
package cc.fascinated.model.token.steam;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class SteamAuthenticateUserTicketToken {
|
||||||
|
/**
|
||||||
|
* The response from the Steam API.
|
||||||
|
*/
|
||||||
|
private Response response;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public static class Response {
|
||||||
|
/**
|
||||||
|
* The params of the response.
|
||||||
|
*/
|
||||||
|
private Params params;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public static class Params {
|
||||||
|
/**
|
||||||
|
* The result of the request.
|
||||||
|
*/
|
||||||
|
private String result;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The steam id of the user.
|
||||||
|
*/
|
||||||
|
@JsonProperty("steamid")
|
||||||
|
private String steamId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The owner steam id of the user.
|
||||||
|
*/
|
||||||
|
@JsonProperty("ownersteamid")
|
||||||
|
private String ownerSteamId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The vac banned status of the user.
|
||||||
|
*/
|
||||||
|
@JsonProperty("vacbanned")
|
||||||
|
private boolean vacBanned;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The publisher banned status of the user.
|
||||||
|
*/
|
||||||
|
@JsonProperty("publisherbanned")
|
||||||
|
private boolean publisherBanned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,63 @@
|
|||||||
|
package cc.fascinated.model.user;
|
||||||
|
|
||||||
|
import cc.fascinated.common.DateUtils;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberAccountToken;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public class ScoreSaberAccount {
|
||||||
|
/**
|
||||||
|
* The avatar of the user.
|
||||||
|
*/
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The country of the user.
|
||||||
|
*/
|
||||||
|
private String country;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rank of the user.
|
||||||
|
*/
|
||||||
|
private int rank;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The country rank of the user.
|
||||||
|
*/
|
||||||
|
private int countryRank;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date the user joined ScoreSaber.
|
||||||
|
*/
|
||||||
|
private Date accountCreated;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The date the user was last updated.
|
||||||
|
*/
|
||||||
|
private Date lastUpdated;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructs a new {@link ScoreSaberAccount} object
|
||||||
|
* from a {@link ScoreSaberAccountToken} object.
|
||||||
|
*
|
||||||
|
* @param token The token to construct the object from.
|
||||||
|
* @return The scoresaber account.
|
||||||
|
*/
|
||||||
|
public static ScoreSaberAccount getFromToken(ScoreSaberAccountToken token) {
|
||||||
|
return new ScoreSaberAccount(
|
||||||
|
token.getProfilePicture(),
|
||||||
|
token.getCountry(),
|
||||||
|
token.getRank(),
|
||||||
|
token.getCountryRank(),
|
||||||
|
DateUtils.getDateFromIsoString(token.getFirstSeen()),
|
||||||
|
new Date()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
111
API/src/main/java/cc/fascinated/model/user/User.java
Normal file
111
API/src/main/java/cc/fascinated/model/user/User.java
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package cc.fascinated.model.user;
|
||||||
|
|
||||||
|
import cc.fascinated.model.user.history.History;
|
||||||
|
import cc.fascinated.model.user.history.HistoryPoint;
|
||||||
|
import cc.fascinated.platform.Platform;
|
||||||
|
import cc.fascinated.services.ScoreService;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
import lombok.ToString;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.data.annotation.Id;
|
||||||
|
import org.springframework.data.mongodb.core.index.Indexed;
|
||||||
|
import org.springframework.data.mongodb.core.mapping.Document;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@Log4j2
|
||||||
|
@ToString
|
||||||
|
@Document("user")
|
||||||
|
public class User {
|
||||||
|
/**
|
||||||
|
* The ID of the user.
|
||||||
|
*/
|
||||||
|
@Id
|
||||||
|
@JsonIgnore
|
||||||
|
private final UUID id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The username of the user.
|
||||||
|
* <p>
|
||||||
|
* Usually their Steam name.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@Indexed
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the users steam profile.
|
||||||
|
*/
|
||||||
|
@Indexed
|
||||||
|
@JsonProperty("id")
|
||||||
|
private String steamId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the user has logged into the website.
|
||||||
|
* <p>
|
||||||
|
* This is used to determine if we should track their profiles or not.
|
||||||
|
* If they haven't logged in, we don't want to track their profiles.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean linkedAccount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's ScoreSaber account.
|
||||||
|
*/
|
||||||
|
public ScoreSaberAccount scoresaberAccount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's statistic history.
|
||||||
|
*/
|
||||||
|
public History history;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the user's statistic history
|
||||||
|
*/
|
||||||
|
public History getHistory() {
|
||||||
|
if (this.history == null) {
|
||||||
|
this.history = new History();
|
||||||
|
}
|
||||||
|
return this.history;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the user's today history.
|
||||||
|
*
|
||||||
|
* @return the user's today history
|
||||||
|
*/
|
||||||
|
public HistoryPoint getTodayHistory() {
|
||||||
|
HistoryPoint todayHistory = this.getHistory().getTodayHistory();
|
||||||
|
if (todayHistory.getTotalPlayCount() == null) {
|
||||||
|
todayHistory.setTotalPlayCount(ScoreService.INSTANCE.getTotalScores(Platform.Platforms.SCORESABER, this));
|
||||||
|
}
|
||||||
|
if (todayHistory.getTotalRankedPlayCount() == null) {
|
||||||
|
todayHistory.setTotalRankedPlayCount(ScoreService.INSTANCE.getTotalRankedScores(Platform.Platforms.SCORESABER, this));
|
||||||
|
}
|
||||||
|
if (todayHistory.getTotalUnrankedPlayCount() == null) {
|
||||||
|
todayHistory.setTotalUnrankedPlayCount(ScoreService.INSTANCE.getTotalUnrankedScores(Platform.Platforms.SCORESABER, this));
|
||||||
|
}
|
||||||
|
return todayHistory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the user as a DTO.
|
||||||
|
*
|
||||||
|
* @return the user as a DTO
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
public UserDTO getAsDTO() {
|
||||||
|
return new UserDTO(this.id, this.username, this.steamId, this.scoresaberAccount);
|
||||||
|
}
|
||||||
|
}
|
36
API/src/main/java/cc/fascinated/model/user/UserDTO.java
Normal file
36
API/src/main/java/cc/fascinated/model/user/UserDTO.java
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package cc.fascinated.model.user;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public class UserDTO {
|
||||||
|
/**
|
||||||
|
* The ID of the user.
|
||||||
|
*/
|
||||||
|
private final UUID id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The username of the user.
|
||||||
|
* <p>
|
||||||
|
* Usually their Steam name.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the users steam profile.
|
||||||
|
*/
|
||||||
|
private String steamId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's ScoreSaber account.
|
||||||
|
*/
|
||||||
|
public ScoreSaberAccount scoresaberAccount;
|
||||||
|
}
|
@ -0,0 +1,78 @@
|
|||||||
|
package cc.fascinated.model.user.history;
|
||||||
|
|
||||||
|
import cc.fascinated.common.DateUtils;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
public class History {
|
||||||
|
/**
|
||||||
|
* The user's history points in time.
|
||||||
|
*/
|
||||||
|
private Map<String, HistoryPoint> histories;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's history points history.
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
public Map<Date, HistoryPoint> getHistories() {
|
||||||
|
if (this.histories == null) {
|
||||||
|
this.histories = new HashMap<>();
|
||||||
|
}
|
||||||
|
Map<Date, HistoryPoint> toReturn = new HashMap<>();
|
||||||
|
this.histories.forEach((key, value) -> toReturn.put(DateUtils.getDateFromString(key), value));
|
||||||
|
return toReturn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the user's history for today.
|
||||||
|
*
|
||||||
|
* @return the user's history for today
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
public HistoryPoint getTodayHistory() {
|
||||||
|
if (this.histories == null) {
|
||||||
|
this.histories = new HashMap<>();
|
||||||
|
}
|
||||||
|
Date midnight = DateUtils.getMidnightToday();
|
||||||
|
return this.histories.computeIfAbsent(DateUtils.formatDate(midnight), key -> new HistoryPoint());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the user's history for a specific date.
|
||||||
|
*
|
||||||
|
* @param date the date to get the history for
|
||||||
|
* @return the user's history for the date
|
||||||
|
*/
|
||||||
|
public HistoryPoint getHistoryForDate(Date date) {
|
||||||
|
if (this.histories == null) {
|
||||||
|
this.histories = new HashMap<>();
|
||||||
|
}
|
||||||
|
return this.histories.get(DateUtils.formatDate(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the user's HistoryPoint history for
|
||||||
|
* an amount of days ago.
|
||||||
|
*
|
||||||
|
* @param days the amount of days ago
|
||||||
|
* @return the user's HistoryPoint history
|
||||||
|
*/
|
||||||
|
public TreeMap<String, HistoryPoint> getPreviousHistories(int days) {
|
||||||
|
Date date = DateUtils.getDaysAgo(days);
|
||||||
|
Map<String, HistoryPoint> toReturn = new HashMap<>();
|
||||||
|
for (Map.Entry<Date, HistoryPoint> history : getHistories().entrySet()) {
|
||||||
|
if (history.getKey().after(date)) {
|
||||||
|
toReturn.put(DateUtils.formatDate(history.getKey()), history.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the history by date (newest > oldest)
|
||||||
|
TreeMap<String, HistoryPoint> sorted = new TreeMap<>(Comparator.comparing(DateUtils::getDateFromString).reversed());
|
||||||
|
sorted.putAll(toReturn);
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,90 @@
|
|||||||
|
package cc.fascinated.model.user.history;
|
||||||
|
|
||||||
|
import cc.fascinated.platform.Platform;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class HistoryPoint {
|
||||||
|
// These are data points that are provided by ScoreSaber, and may not always be here
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rank of the player.
|
||||||
|
*/
|
||||||
|
private Integer rank;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pp of the player.
|
||||||
|
*/
|
||||||
|
private Integer countryRank;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pp of the player.
|
||||||
|
*/
|
||||||
|
private Double pp;
|
||||||
|
|
||||||
|
// Below are data points that are provided by us, therefore they will always be here
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play count of all the player's scores.
|
||||||
|
*/
|
||||||
|
private Integer totalPlayCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play count of all the player's ranked scores.
|
||||||
|
*/
|
||||||
|
private Integer totalRankedPlayCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play count of all the player's unranked scores.
|
||||||
|
*/
|
||||||
|
private Integer totalUnrankedPlayCount;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play count for this day's unranked scores.
|
||||||
|
*/
|
||||||
|
private Integer unrankedPlayCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play count for this day's ranked scores.
|
||||||
|
*/
|
||||||
|
private Integer rankedPlayCount = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the data for this day is possibly inaccurate.
|
||||||
|
*/
|
||||||
|
private Boolean possiblyInaccurateData;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets whether some data is possibly inaccurate.
|
||||||
|
* <p>
|
||||||
|
* eg: if the user doesn't have their data tracked by
|
||||||
|
* {@link Platform#trackPlayerMetrics()} then some
|
||||||
|
* data will be inaccurate or missing.
|
||||||
|
* This will only affect {@link #rank}, {@link #countryRank}, and {@link #pp}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @return true if the data is possibly inaccurate, false otherwise
|
||||||
|
*/
|
||||||
|
public Boolean getPossiblyInaccurateData() {
|
||||||
|
return possiblyInaccurateData == null || possiblyInaccurateData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the total ranked play count for this day.
|
||||||
|
*/
|
||||||
|
public void incrementRankedPlayCount() {
|
||||||
|
rankedPlayCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the total unranked play count for this day.
|
||||||
|
*/
|
||||||
|
public void incrementUnrankedPlayCount() {
|
||||||
|
unrankedPlayCount++;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
package cc.fascinated.model.user.hmd;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public enum DeviceController {
|
||||||
|
UNKNOWN("Unknown"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oculus Controllers
|
||||||
|
*/
|
||||||
|
OCULUS_QUEST_TOUCH("Touch"),
|
||||||
|
OCULUS_QUEST_2_TOUCH("Quest 2 Touch"),
|
||||||
|
OCULUS_QUEST_3_TOUCH("Quest 3 Touch"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HP Controllers
|
||||||
|
*/
|
||||||
|
HP_REVERB("HP Reverb"),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valve Controllers
|
||||||
|
*/
|
||||||
|
VALVE_KNUCKLES("Knuckles");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The controller name
|
||||||
|
*/
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a controller by its name.
|
||||||
|
*
|
||||||
|
* @param name the name of the controller
|
||||||
|
* @return the controller
|
||||||
|
*/
|
||||||
|
public static DeviceController getByName(String name) {
|
||||||
|
for (DeviceController deviceController : values()) {
|
||||||
|
if (deviceController.getName().equalsIgnoreCase(name)) {
|
||||||
|
return deviceController;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,84 @@
|
|||||||
|
package cc.fascinated.model.user.hmd;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public enum DeviceHeadset {
|
||||||
|
UNKNOWN("Unknown", 0),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Oculus HMDs
|
||||||
|
*/
|
||||||
|
OCULUS_CV1("Rift", 1),
|
||||||
|
OCULUS_QUEST("Quest", 32),
|
||||||
|
OCULUS_QUEST_2("Quest 2", -1),
|
||||||
|
OCULUS_QUEST_3("Quest 3", -1),
|
||||||
|
OCULUS_RIFT_S("Rift S", 16),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows Mixed Reality HMDs
|
||||||
|
* todo: find the new format name
|
||||||
|
*/
|
||||||
|
WINDOWS_MR("Windows Mixed Reality", 8),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTC HMDs
|
||||||
|
*/
|
||||||
|
HTC_VIVE("Vive", 2),
|
||||||
|
HTC_VIVE_COSMOS("Vive Cosmos", 128),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HP HMDs
|
||||||
|
*/
|
||||||
|
HP_REVERB("HP Reverb", -1),
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valve HMDs
|
||||||
|
*/
|
||||||
|
VALVE_INDEX("Valve Index", 64);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the headset.
|
||||||
|
*/
|
||||||
|
private final String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fallback value of the headset.
|
||||||
|
*/
|
||||||
|
private final int fallbackValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a headset by its name.
|
||||||
|
*
|
||||||
|
* @param name the name of the headset
|
||||||
|
* @return the headset
|
||||||
|
*/
|
||||||
|
public static DeviceHeadset getByName(String name) {
|
||||||
|
for (DeviceHeadset deviceHeadset : values()) {
|
||||||
|
if (deviceHeadset.getName().equalsIgnoreCase(name)) {
|
||||||
|
return deviceHeadset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a headset by its fallback value.
|
||||||
|
*
|
||||||
|
* @param fallbackValue the fallback value of the headset
|
||||||
|
* @return the headset
|
||||||
|
*/
|
||||||
|
public static DeviceHeadset getByFallbackValue(int fallbackValue) {
|
||||||
|
for (DeviceHeadset deviceHeadset : values()) {
|
||||||
|
if (deviceHeadset.getFallbackValue() == fallbackValue) {
|
||||||
|
return deviceHeadset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
27
API/src/main/java/cc/fascinated/platform/CurvePoint.java
Normal file
27
API/src/main/java/cc/fascinated/platform/CurvePoint.java
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package cc.fascinated.platform;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public class CurvePoint {
|
||||||
|
/**
|
||||||
|
* The acc at the curve point.
|
||||||
|
* <p>
|
||||||
|
* Acc is divided by 100 to get the actual value.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private final double acc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The multiplier of the curve point.
|
||||||
|
* <p>
|
||||||
|
* This is the multiplier for the pp calculation.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
private final double multiplier;
|
||||||
|
}
|
@ -49,29 +49,39 @@ public abstract class Platform {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the curve points for a curve version.
|
||||||
|
*
|
||||||
|
* @param curveVersion the curve version to get the curve points for
|
||||||
|
* @return the curve points
|
||||||
|
*/
|
||||||
|
public CurvePoint[] getCurve(int curveVersion) {
|
||||||
|
this.checkCurveVersion(curveVersion);
|
||||||
|
return curvePoints.get(curveVersion);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the PP amount from the star count.
|
* Gets the PP amount from the star count.
|
||||||
*
|
*
|
||||||
* @param stars the amount of stars
|
* @param stars the amount of stars
|
||||||
* @return the pp amount
|
* @return the pp amount
|
||||||
*/
|
*/
|
||||||
public abstract double getPp(int curveVersion, double stars, double accuracy);
|
public abstract double getPp(double stars, double accuracy);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called every 10 minutes to update
|
* Called to update the players
|
||||||
* the players data in QuestDB.
|
* data in QuestDB.
|
||||||
*/
|
*/
|
||||||
public abstract void updatePlayers();
|
public abstract void trackPlayerMetrics();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called every 10 minutes to update
|
* Called to update the metrics
|
||||||
* the metrics for total scores, etc.
|
* for total scores, etc.
|
||||||
*/
|
*/
|
||||||
public abstract void updateMetrics();
|
public abstract void updateMetrics();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called every day at midnight to update
|
* Called to update the leaderboards.
|
||||||
* the leaderboards.
|
|
||||||
*/
|
*/
|
||||||
public abstract void updateLeaderboards();
|
public abstract void updateLeaderboards();
|
||||||
|
|
@ -0,0 +1,245 @@
|
|||||||
|
package cc.fascinated.platform.impl;
|
||||||
|
|
||||||
|
import cc.fascinated.common.DateUtils;
|
||||||
|
import cc.fascinated.common.MathUtils;
|
||||||
|
import cc.fascinated.model.score.Score;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberAccountToken;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardPageToken;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
|
||||||
|
import cc.fascinated.model.user.User;
|
||||||
|
import cc.fascinated.model.user.history.HistoryPoint;
|
||||||
|
import cc.fascinated.platform.CurvePoint;
|
||||||
|
import cc.fascinated.platform.Platform;
|
||||||
|
import cc.fascinated.services.ScoreSaberService;
|
||||||
|
import cc.fascinated.services.ScoreService;
|
||||||
|
import cc.fascinated.services.UserService;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.DependsOn;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@DependsOn("scoreService")
|
||||||
|
@Log4j2
|
||||||
|
public class ScoreSaberPlatform extends Platform {
|
||||||
|
/**
|
||||||
|
* The base multiplier for stars.
|
||||||
|
*/
|
||||||
|
private final double starMultiplier = 42.117208413;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ScoreSaber service to use
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final ScoreSaberService scoreSaberService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user service to use
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The score service to use
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final ScoreService scoreService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ScoreSaberPlatform(@NonNull ScoreSaberService scoreSaberService, @NonNull UserService userService, @NonNull ScoreService scoreService) {
|
||||||
|
super(Platforms.SCORESABER, 1, Map.of(
|
||||||
|
1, new CurvePoint[]{
|
||||||
|
new CurvePoint(0, 0),
|
||||||
|
new CurvePoint(0.6, 0.18223233667439062),
|
||||||
|
new CurvePoint(0.65, 0.5866010012767576),
|
||||||
|
new CurvePoint(0.7, 0.6125565959114954),
|
||||||
|
new CurvePoint(0.75, 0.6451808210101443),
|
||||||
|
new CurvePoint(0.8, 0.6872268862950283),
|
||||||
|
new CurvePoint(0.825, 0.7150465663454271),
|
||||||
|
new CurvePoint(0.85, 0.7462290664143185),
|
||||||
|
new CurvePoint(0.875, 0.7816934560296046),
|
||||||
|
new CurvePoint(0.9, 0.825756123560842),
|
||||||
|
new CurvePoint(0.91, 0.8488375988124467),
|
||||||
|
new CurvePoint(0.92, 0.8728710341448851),
|
||||||
|
new CurvePoint(0.93, 0.9039994071865736),
|
||||||
|
new CurvePoint(0.94, 0.9417362980580238),
|
||||||
|
new CurvePoint(0.95, 1),
|
||||||
|
new CurvePoint(0.955, 1.0388633331418984),
|
||||||
|
new CurvePoint(0.96, 1.0871883573850478),
|
||||||
|
new CurvePoint(0.965, 1.1552120359501035),
|
||||||
|
new CurvePoint(0.97, 1.2485807759957321),
|
||||||
|
new CurvePoint(0.9725, 1.3090333065057616),
|
||||||
|
new CurvePoint(0.975, 1.3807102743105126),
|
||||||
|
new CurvePoint(0.9775, 1.4664726399289512),
|
||||||
|
new CurvePoint(0.98, 1.5702410055532239),
|
||||||
|
new CurvePoint(0.9825, 1.697536248647543),
|
||||||
|
new CurvePoint(0.985, 1.8563887693647105),
|
||||||
|
new CurvePoint(0.9875, 2.058947159052738),
|
||||||
|
new CurvePoint(0.99, 2.324506282149922),
|
||||||
|
new CurvePoint(0.99125, 2.4902905794106913),
|
||||||
|
new CurvePoint(0.9925, 2.685667856592722),
|
||||||
|
new CurvePoint(0.99375, 2.9190155639254955),
|
||||||
|
new CurvePoint(0.995, 3.2022017597337955),
|
||||||
|
new CurvePoint(0.99625, 3.5526145337555373),
|
||||||
|
new CurvePoint(0.9975, 3.996793606763322),
|
||||||
|
new CurvePoint(0.99825, 4.325027383589547),
|
||||||
|
new CurvePoint(0.999, 4.715470646416203),
|
||||||
|
new CurvePoint(0.9995, 5.019543595874787),
|
||||||
|
new CurvePoint(1, 5.367394282890631),
|
||||||
|
}
|
||||||
|
));
|
||||||
|
this.scoreSaberService = scoreSaberService;
|
||||||
|
this.userService = userService;
|
||||||
|
this.scoreService = scoreService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the modifier for the given accuracy.
|
||||||
|
*
|
||||||
|
* @param accuracy The accuracy.
|
||||||
|
* @return The modifier.
|
||||||
|
*/
|
||||||
|
public double getModifier(double accuracy) {
|
||||||
|
accuracy = MathUtils.clamp(accuracy, 0, 100) / 100;
|
||||||
|
CurvePoint[] curve = this.getCurve(this.getCurrentCurveVersion());
|
||||||
|
if (accuracy <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accuracy >= 1) {
|
||||||
|
return curve[curve.length - 1].getMultiplier();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0; i < curve.length - 1; i++) {
|
||||||
|
CurvePoint point = curve[i];
|
||||||
|
CurvePoint nextPoint = curve[i + 1];
|
||||||
|
if (accuracy >= point.getAcc() && accuracy <= nextPoint.getAcc()) {
|
||||||
|
return MathUtils.lerp(
|
||||||
|
point.getMultiplier(), nextPoint.getMultiplier(),
|
||||||
|
(accuracy - point.getAcc()) / (nextPoint.getAcc() - point.getAcc())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public double getPp(double stars, double accuracy) {
|
||||||
|
if (accuracy <= 1) { // Convert the accuracy to a percentage
|
||||||
|
accuracy *= 100;
|
||||||
|
}
|
||||||
|
double pp = stars * this.starMultiplier;
|
||||||
|
return this.getModifier(accuracy) * pp;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void trackPlayerMetrics() {
|
||||||
|
Date date = DateUtils.getMidnightToday();
|
||||||
|
for (User user : this.userService.getUsers(true)) {
|
||||||
|
HistoryPoint history = user.getHistory().getHistoryForDate(date);
|
||||||
|
if (user.isLinkedAccount()) { // Check if the user has linked their account
|
||||||
|
ScoreSaberAccountToken account = this.scoreSaberService.getAccount(user); // Get the account from the ScoreSaber API
|
||||||
|
history.setPp(account.getPp());
|
||||||
|
history.setRank(account.getRank());
|
||||||
|
history.setCountryRank(account.getCountryRank());
|
||||||
|
history.setPossiblyInaccurateData(false);
|
||||||
|
}
|
||||||
|
// If the player didn't set any scores today, we need to set these values
|
||||||
|
if (history.getTotalPlayCount() == null || history.getTotalRankedPlayCount() == null || history.getTotalUnrankedPlayCount() == null) {
|
||||||
|
history.setTotalPlayCount(this.scoreService.getTotalScores(this.getPlatform(), user)); // Get the total scores for the platform
|
||||||
|
history.setTotalRankedPlayCount(this.scoreService.getTotalRankedScores(this.getPlatform(), user)); // Get the total ranked scores for the platform
|
||||||
|
history.setTotalUnrankedPlayCount(this.scoreService.getTotalUnrankedScores(this.getPlatform(), user)); // Get the total unranked scores for the platform
|
||||||
|
}
|
||||||
|
this.userService.saveUser(user); // Save the user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateMetrics() {
|
||||||
|
// todo: switch to InfluxDB?
|
||||||
|
|
||||||
|
// try (Sender sender = questDBService.getSender()) {
|
||||||
|
// TotalScoresResponse totalScores = ScoreService.INSTANCE.getTotalScores(this.getPlatform());
|
||||||
|
// sender.table("metrics")
|
||||||
|
// .symbol("platform", this.getPlatform().getPlatformName())
|
||||||
|
// .longColumn("total_scores", totalScores.getTotalScores())
|
||||||
|
// .longColumn("total_ranked_scores", totalScores.getTotalRankedScores())
|
||||||
|
// .atNow();
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateLeaderboards() {
|
||||||
|
List<Score> scores = ScoreService.INSTANCE.getRankedScores(this.getPlatform());
|
||||||
|
Map<String, ScoreSaberLeaderboardToken> leaderboards = new HashMap<>();
|
||||||
|
for (ScoreSaberLeaderboardPageToken rankedLeaderboard : this.scoreSaberService.getRankedLeaderboards()) {
|
||||||
|
for (ScoreSaberLeaderboardToken leaderboard : rankedLeaderboard.getLeaderboards()) {
|
||||||
|
leaderboards.put(leaderboard.getId(), leaderboard);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any missing leaderboards
|
||||||
|
for (Score score : scores) {
|
||||||
|
if (leaderboards.containsKey(score.getLeaderboardId())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScoreSaberLeaderboardToken leaderboard = this.scoreSaberService.getLeaderboard(score.getLeaderboardId(), true);
|
||||||
|
leaderboards.put(leaderboard.getId(), leaderboard);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Updating {} leaderboards for platform '{}'",
|
||||||
|
leaderboards.size(),
|
||||||
|
this.getPlatform().getPlatformName()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update the leaderboards
|
||||||
|
int finished = 0;
|
||||||
|
for (Map.Entry<String, ScoreSaberLeaderboardToken> leaderboardEntry : leaderboards.entrySet()) {
|
||||||
|
String id = leaderboardEntry.getKey();
|
||||||
|
ScoreSaberLeaderboardToken leaderboard = leaderboardEntry.getValue();
|
||||||
|
try {
|
||||||
|
List<Score> toUpdate = scores.stream().filter(score -> {
|
||||||
|
if (!score.getLeaderboardId().equals(id)) { // Check if the leaderboard ID matches
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
double pp = this.getPp(leaderboard.getStars(), score.getAccuracy());
|
||||||
|
return pp != score.getPp(); // Check if the pp has changed
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
for (Score score : toUpdate) { // Update the scores
|
||||||
|
if (leaderboard.getStars() == 0) { // The leaderboard was unranked
|
||||||
|
score.setPp(0D);
|
||||||
|
}
|
||||||
|
double pp = this.getPp(leaderboard.getStars(), score.getAccuracy());
|
||||||
|
score.setPp(pp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toUpdate.isEmpty()) { // Save the scores
|
||||||
|
ScoreService.INSTANCE.updateScores(toUpdate.toArray(new Score[0]));
|
||||||
|
}
|
||||||
|
finished++;
|
||||||
|
|
||||||
|
if (finished % 100 == 0 || finished == leaderboards.size()) {
|
||||||
|
log.info("Updated {}/{} leaderboards for platform '{}'",
|
||||||
|
finished,
|
||||||
|
leaderboards.size(),
|
||||||
|
this.getPlatform().getPlatformName()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("An error occurred while updating leaderboard '{}'", id, ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
package cc.fascinated.repository.mongo;
|
||||||
|
|
||||||
|
import cc.fascinated.model.Counter;
|
||||||
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
public interface CounterRepository extends MongoRepository<Counter, String> {}
|
@ -0,0 +1,161 @@
|
|||||||
|
package cc.fascinated.repository.mongo;
|
||||||
|
|
||||||
|
import cc.fascinated.model.score.Score;
|
||||||
|
import cc.fascinated.platform.Platform;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import org.springframework.data.mongodb.repository.Aggregation;
|
||||||
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
public interface ScoreRepository extends MongoRepository<Score, Long> {
|
||||||
|
/**
|
||||||
|
* Gets the top ranked scores from the platform.
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the scores from
|
||||||
|
* @param amount the amount of scores to get
|
||||||
|
* @return the scores
|
||||||
|
*/
|
||||||
|
@Aggregation(pipeline = {
|
||||||
|
"{ $match: { platform: ?0, pp: { $gt: 0 } } }",
|
||||||
|
"{ $sort: { pp: -1 } }",
|
||||||
|
"{ $skip: ?2 }",
|
||||||
|
"{ $limit: ?1 }",
|
||||||
|
})
|
||||||
|
List<Score> getTopRankedScores(@NonNull Platform.Platforms platform, int amount, int skip);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all the ranked scores from the platform.
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the scores from
|
||||||
|
* @return the scores
|
||||||
|
*/
|
||||||
|
@Aggregation(pipeline = {
|
||||||
|
"{ $match: { platform: ?0, pp: { $gt: 0 } } }",
|
||||||
|
"{ $sort: { pp: -1 } }"
|
||||||
|
})
|
||||||
|
List<Score> getRankedScores(@NonNull Platform.Platforms platform);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the improved scores from the platform.
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the scores from
|
||||||
|
* @param playerId the player id to get the scores from
|
||||||
|
* @param after the date to get the scores after
|
||||||
|
* @return the scores
|
||||||
|
*/
|
||||||
|
@Aggregation(pipeline = {
|
||||||
|
"{ $match: { platform: ?0, playerId: ?1, timestamp: { $gt: ?2 } } }",
|
||||||
|
"{ $match: { 'previousScores.0': { $exists: true } } }",
|
||||||
|
"{ $sort: { pp: -1 } }"
|
||||||
|
})
|
||||||
|
List<Score> getUserImprovedScores(@NonNull Platform.Platforms platform, @NonNull String playerId, @NonNull Date after);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the best improved scores from the platform.
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the scores from
|
||||||
|
* @param after the date to get the scores after
|
||||||
|
* @return the scores
|
||||||
|
*/
|
||||||
|
@Aggregation(pipeline = {
|
||||||
|
"{ $match: { platform: ?0, timestamp: { $gt: ?1 } } }",
|
||||||
|
"{ $match: { 'previousScores.0': { $exists: true } } }",
|
||||||
|
"{ $sort: { pp: -1 } }"
|
||||||
|
})
|
||||||
|
List<Score> getBestImprovedScores(@NonNull Platform.Platforms platform, @NonNull Date after);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a score from a platform and leaderboard id.
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the score from
|
||||||
|
* @param playerId the player id to get the score from
|
||||||
|
* @param leaderboardId the leaderboard id to get the score from
|
||||||
|
* @return the score
|
||||||
|
*/
|
||||||
|
@Aggregation(pipeline = {
|
||||||
|
"{ $match: { platform: ?0, playerId: ?1, leaderboardId: ?2 } }",
|
||||||
|
"{ $sort: { timestamp: -1 } }",
|
||||||
|
})
|
||||||
|
List<Score> findScores(@NonNull Platform.Platforms platform, @NonNull String playerId, @NonNull String leaderboardId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a scores pp value.
|
||||||
|
*
|
||||||
|
* @param id The id of the score to update
|
||||||
|
* @param pp The new pp value
|
||||||
|
*/
|
||||||
|
@Aggregation(pipeline = {
|
||||||
|
"{ $match: { _id: ?0 } }",
|
||||||
|
"{ $set: { pp: ?1 } }"
|
||||||
|
})
|
||||||
|
void updateScorePP(@Param("id") long id, @Param("pp") double pp);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total scores for the platform.
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the scores from
|
||||||
|
* @return the total scores
|
||||||
|
*/
|
||||||
|
@Aggregation(pipeline = {
|
||||||
|
"{ $match: { platform: ?0 } }",
|
||||||
|
"{ $count: 'total' }"
|
||||||
|
})
|
||||||
|
Integer getTotalScores(@NonNull Platform.Platforms platform);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total ranked scores for the platform.
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the scores from
|
||||||
|
* @return the total ranked scores
|
||||||
|
*/
|
||||||
|
@Aggregation(pipeline = {
|
||||||
|
"{ $match: { platform: ?0, pp: { $gt: 0 } } }",
|
||||||
|
"{ $count: 'total' }"
|
||||||
|
})
|
||||||
|
Integer getTotalRankedScores(@NonNull Platform.Platforms platform);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total player scores for the platform.
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the scores from
|
||||||
|
* @param playerId the player id to get the scores from
|
||||||
|
* @return the total player scores
|
||||||
|
*/
|
||||||
|
@Aggregation(pipeline = {
|
||||||
|
"{ $match: { platform: ?0, playerId: ?1 } }",
|
||||||
|
"{ $count: 'total' }"
|
||||||
|
})
|
||||||
|
Integer getTotalPlayerScores(@NonNull Platform.Platforms platform, @NonNull String playerId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total player ranked scores for the platform.
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the scores from
|
||||||
|
* @param playerId the player id to get the scores from
|
||||||
|
* @return the total player ranked scores
|
||||||
|
*/
|
||||||
|
@Aggregation(pipeline = {
|
||||||
|
"{ $match: { platform: ?0, playerId: ?1, pp: { $gt: 0 } } }",
|
||||||
|
"{ $count: 'total' }"
|
||||||
|
})
|
||||||
|
Integer getTotalPlayerRankedScores(@NonNull Platform.Platforms platform, @NonNull String playerId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total player unranked scores for the platform.
|
||||||
|
*
|
||||||
|
* @param platform the platform to get the scores from
|
||||||
|
* @param playerId the player id to get the scores from
|
||||||
|
* @return the total player unranked scores
|
||||||
|
*/
|
||||||
|
@Aggregation(pipeline = {
|
||||||
|
"{ $match: { platform: ?0, playerId: ?1, pp: { $eq: null } } }",
|
||||||
|
"{ $count: 'total' }"
|
||||||
|
})
|
||||||
|
Integer getTotalPlayerUnrankedScores(@NonNull Platform.Platforms platform, @NonNull String playerId);
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
package cc.fascinated.repository.mongo;
|
package cc.fascinated.repository.mongo;
|
||||||
|
|
||||||
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
|
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
|
||||||
import org.springframework.data.mongodb.repository.MongoRepository;
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
|
||||||
/**
|
/**
|
@ -0,0 +1,39 @@
|
|||||||
|
package cc.fascinated.repository.mongo;
|
||||||
|
|
||||||
|
import cc.fascinated.model.user.User;
|
||||||
|
import org.springframework.data.mongodb.repository.MongoRepository;
|
||||||
|
import org.springframework.data.mongodb.repository.Query;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
public interface UserRepository extends MongoRepository<User, UUID> {
|
||||||
|
/**
|
||||||
|
* Finds a user by their steam id.
|
||||||
|
*
|
||||||
|
* @param steamId the steam id of the user
|
||||||
|
* @return the user
|
||||||
|
*/
|
||||||
|
Optional<User> findBySteamId(String steamId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches all users and only their steam ids.
|
||||||
|
*
|
||||||
|
* @return the list of users
|
||||||
|
*/
|
||||||
|
@Query(value = "{}", fields = "{ 'steamId' : 1, linkedAccount: 1 }")
|
||||||
|
List<User> fetchAccountsSimple();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a user by their username.
|
||||||
|
*
|
||||||
|
* @param username the username of the user
|
||||||
|
* @return the user
|
||||||
|
*/
|
||||||
|
@Query("{ $text: { $search: ?0 } }")
|
||||||
|
List<User> findUsersByUsername(String username);
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
package cc.fascinated.repository.redis;
|
||||||
|
|
||||||
|
import cc.fascinated.model.auth.AuthToken;
|
||||||
|
import org.springframework.data.repository.CrudRepository;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
public interface AuthTokenRepository extends CrudRepository<AuthToken, String> {}
|
54
API/src/main/java/cc/fascinated/services/CounterService.java
Normal file
54
API/src/main/java/cc/fascinated/services/CounterService.java
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
package cc.fascinated.services;
|
||||||
|
|
||||||
|
import cc.fascinated.model.Counter;
|
||||||
|
import cc.fascinated.repository.mongo.CounterRepository;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CounterService {
|
||||||
|
/**
|
||||||
|
* The counter repository to use.
|
||||||
|
*/
|
||||||
|
private final CounterRepository counterRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public CounterService(CounterRepository counterRepository) {
|
||||||
|
this.counterRepository = counterRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the next number in the sequence.
|
||||||
|
*
|
||||||
|
* @param type The type of the counter.
|
||||||
|
* @return The next number in the sequence.
|
||||||
|
*/
|
||||||
|
public long getNext(CounterType type) {
|
||||||
|
Optional<Counter> counterOptional = counterRepository.findById(type.getId());
|
||||||
|
Counter counter = counterOptional.orElseGet(() -> new Counter(type.getId(), 1));
|
||||||
|
|
||||||
|
long current = counter.getNext();
|
||||||
|
counter.setNext(current + 1);
|
||||||
|
|
||||||
|
counterRepository.save(counter);
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
@AllArgsConstructor
|
||||||
|
@Getter
|
||||||
|
public enum CounterType {
|
||||||
|
SCORE("score");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ID of the counter.
|
||||||
|
*/
|
||||||
|
private final String id;
|
||||||
|
}
|
||||||
|
}
|
@ -2,13 +2,13 @@ package cc.fascinated.services;
|
|||||||
|
|
||||||
import cc.fascinated.platform.Platform;
|
import cc.fascinated.platform.Platform;
|
||||||
import cc.fascinated.platform.impl.ScoreSaberPlatform;
|
import cc.fascinated.platform.impl.ScoreSaberPlatform;
|
||||||
import cc.fascinated.repository.couchdb.TrackedScoreRepository;
|
|
||||||
import com.mongodb.client.model.Filters;
|
import com.mongodb.client.model.Filters;
|
||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
import lombok.extern.log4j.Log4j2;
|
import lombok.extern.log4j.Log4j2;
|
||||||
import org.bson.Document;
|
import org.bson.Document;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.ApplicationContext;
|
import org.springframework.context.ApplicationContext;
|
||||||
|
import org.springframework.context.annotation.DependsOn;
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@ -21,8 +21,10 @@ import java.util.concurrent.Executors;
|
|||||||
* @author Fascinated (fascinated7)
|
* @author Fascinated (fascinated7)
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
|
@DependsOn("mongoService")
|
||||||
@Log4j2(topic = "PlatformService")
|
@Log4j2(topic = "PlatformService")
|
||||||
public class PlatformService {
|
public class PlatformService {
|
||||||
|
public static PlatformService INSTANCE;
|
||||||
private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(1);
|
private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -30,16 +32,9 @@ public class PlatformService {
|
|||||||
*/
|
*/
|
||||||
private final List<Platform> platforms = new ArrayList<>();
|
private final List<Platform> platforms = new ArrayList<>();
|
||||||
|
|
||||||
/**
|
|
||||||
* The tracked score repository to use.
|
|
||||||
*/
|
|
||||||
@NonNull
|
|
||||||
private final TrackedScoreRepository trackedScoreRepository;
|
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
public PlatformService(@NonNull ApplicationContext context, @NonNull TrackedScoreRepository trackedScoreRepository) {
|
public PlatformService(@NonNull ApplicationContext context) {
|
||||||
this.trackedScoreRepository = trackedScoreRepository;
|
INSTANCE = this;
|
||||||
|
|
||||||
log.info("Registering platforms...");
|
log.info("Registering platforms...");
|
||||||
registerPlatform(context.getBean(ScoreSaberPlatform.class));
|
registerPlatform(context.getBean(ScoreSaberPlatform.class));
|
||||||
log.info("Loaded %s platforms.".formatted(this.platforms.size()));
|
log.info("Loaded %s platforms.".formatted(this.platforms.size()));
|
||||||
@ -48,7 +43,7 @@ public class PlatformService {
|
|||||||
/**
|
/**
|
||||||
* Updates the platform metrics.
|
* Updates the platform metrics.
|
||||||
* <p>
|
* <p>
|
||||||
* This method is scheduled to run every minute.
|
* This method is scheduled to run every 5 minutes.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
@Scheduled(cron = "0 */5 * * * *")
|
@Scheduled(cron = "0 */5 * * * *")
|
||||||
@ -63,16 +58,16 @@ public class PlatformService {
|
|||||||
/**
|
/**
|
||||||
* Updates the platform players.
|
* Updates the platform players.
|
||||||
* <p>
|
* <p>
|
||||||
* This method is scheduled to run every 15 minutes.
|
* This method is scheduled to run every hour.
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
@Scheduled(cron = "0 */15 * * * *")
|
@Scheduled(cron = "0 0 * * * *")
|
||||||
public void updateScores() {
|
public void updatePlayerMetrics() {
|
||||||
log.info("Updating %s platform players...".formatted(this.platforms.size()));
|
log.info("Updating %s platform player metrics...".formatted(this.platforms.size()));
|
||||||
for (Platform platform : this.platforms) {
|
for (Platform platform : this.platforms) {
|
||||||
platform.updatePlayers();
|
platform.trackPlayerMetrics();
|
||||||
}
|
}
|
||||||
log.info("Finished updating platform players.");
|
log.info("Finished updating platform player metrics.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -120,12 +115,39 @@ public class PlatformService {
|
|||||||
));
|
));
|
||||||
|
|
||||||
log.info("Updating scores for platform '%s'...".formatted(platform.getPlatform().getPlatformName()));
|
log.info("Updating scores for platform '%s'...".formatted(platform.getPlatform().getPlatformName()));
|
||||||
EXECUTOR_SERVICE.execute(platform::updateLeaderboards); // Update the leaderboards
|
Document finalDocument = document;
|
||||||
|
EXECUTOR_SERVICE.execute(() -> {
|
||||||
|
platform.updateLeaderboards();
|
||||||
|
this.savePlatform(platform, finalDocument);
|
||||||
|
}); // Update the leaderboards
|
||||||
|
} else {
|
||||||
|
this.savePlatform(platform, document);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the document
|
/**
|
||||||
|
* Saves the platform.
|
||||||
|
*
|
||||||
|
* @param platform the platform to save
|
||||||
|
* @param document the document to save
|
||||||
|
*/
|
||||||
|
public void savePlatform(Platform platform, Document document) {
|
||||||
document.put("currentCurveVersion", platform.getCurrentCurveVersion());
|
document.put("currentCurveVersion", platform.getCurrentCurveVersion());
|
||||||
MongoService.INSTANCE.getPlatformsCollection().replaceOne(Filters.eq("_id", platform.getPlatform().getPlatformName()), document);
|
MongoService.INSTANCE.getPlatformsCollection().replaceOne(Filters.eq("_id", platform.getPlatform().getPlatformName()), document);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the ScoreSaber platform.
|
||||||
|
*
|
||||||
|
* @return the ScoreSaber platform
|
||||||
|
*/
|
||||||
|
public ScoreSaberPlatform getScoreSaberPlatform() {
|
||||||
|
for (Platform platform : this.platforms) {
|
||||||
|
if (platform.getPlatform().getPlatformName().equalsIgnoreCase(Platform.Platforms.SCORESABER.getPlatformName())) {
|
||||||
|
return (ScoreSaberPlatform) platform;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
157
API/src/main/java/cc/fascinated/services/ScoreSaberService.java
Normal file
157
API/src/main/java/cc/fascinated/services/ScoreSaberService.java
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
package cc.fascinated.services;
|
||||||
|
|
||||||
|
import cc.fascinated.common.Request;
|
||||||
|
import cc.fascinated.exception.impl.BadRequestException;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberAccountToken;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardPageToken;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
|
||||||
|
import cc.fascinated.model.user.User;
|
||||||
|
import cc.fascinated.repository.mongo.ScoreSaberLeaderboardRepository;
|
||||||
|
import kong.unirest.core.HttpResponse;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import net.jodah.expiringmap.ExpirationPolicy;
|
||||||
|
import net.jodah.expiringmap.ExpiringMap;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.LinkedList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Log4j2(topic = "ScoreSaber Service")
|
||||||
|
public class ScoreSaberService {
|
||||||
|
private static final String SCORESABER_API = "https://scoresaber.com/api/";
|
||||||
|
private static final String GET_PLAYER_ENDPOINT = SCORESABER_API + "player/%s/full";
|
||||||
|
private static final String GET_LEADERBOARD_ENDPOINT = SCORESABER_API + "leaderboard/by-id/%s/info";
|
||||||
|
private static final String GET_RANKED_LEADERBOARDS_ENDPOINT = SCORESABER_API + "leaderboards?ranked=true&page=%s&withMetadata=true";
|
||||||
|
|
||||||
|
private final Map<String, ScoreSaberLeaderboardToken> leaderboardCache = ExpiringMap.builder()
|
||||||
|
.maxSize(5_000)
|
||||||
|
.expirationPolicy(ExpirationPolicy.ACCESSED)
|
||||||
|
.expiration(1, TimeUnit.DAYS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ScoreSaber leaderboard repository to use.
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final ScoreSaberLeaderboardRepository leaderboardRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ScoreSaberService(@NonNull ScoreSaberLeaderboardRepository leaderboardRepository) {
|
||||||
|
this.leaderboardRepository = leaderboardRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the account for a user.
|
||||||
|
*
|
||||||
|
* @param user the user to get the account for
|
||||||
|
* @return the ScoreSaber account
|
||||||
|
* @throws BadRequestException if an error occurred while getting the account
|
||||||
|
*/
|
||||||
|
public ScoreSaberAccountToken getAccount(User user) {
|
||||||
|
if (user.getSteamId() == null) {
|
||||||
|
throw new BadRequestException("The user does not have a steam id");
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse<ScoreSaberAccountToken> response = Request.get(GET_PLAYER_ENDPOINT.formatted(user.getSteamId()), ScoreSaberAccountToken.class);
|
||||||
|
if (response.getParsingError().isPresent()) { // Failed to parse the response
|
||||||
|
throw new BadRequestException("Failed to parse ScoreSaber account for '%s'".formatted(user.getSteamId()));
|
||||||
|
}
|
||||||
|
if (response.getStatus() != 200) { // The response was not successful
|
||||||
|
throw new BadRequestException("Failed to get ScoreSaber account for '%s'".formatted(user.getSteamId()));
|
||||||
|
}
|
||||||
|
return response.getBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a leaderboard for a leaderboard id.
|
||||||
|
*
|
||||||
|
* @param leaderboardId the leaderboard id to get the leaderboard for
|
||||||
|
* @return the ScoreSaber leaderboard
|
||||||
|
* @throws BadRequestException if an error occurred while getting the leaderboard
|
||||||
|
*/
|
||||||
|
public ScoreSaberLeaderboardToken getLeaderboard(String leaderboardId, boolean bypassCache) {
|
||||||
|
if (leaderboardCache.containsKey(leaderboardId) && !bypassCache) { // The leaderboard is cached locally (very fast)
|
||||||
|
return leaderboardCache.get(leaderboardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<ScoreSaberLeaderboardToken> leaderboardOptional = leaderboardRepository.findById(leaderboardId);
|
||||||
|
if (leaderboardOptional.isPresent() && !bypassCache) { // The leaderboard is cached in the database
|
||||||
|
ScoreSaberLeaderboardToken leaderboard = leaderboardOptional.get();
|
||||||
|
leaderboardCache.put(leaderboardId, leaderboard);
|
||||||
|
return leaderboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse<ScoreSaberLeaderboardToken> response = Request.get(GET_LEADERBOARD_ENDPOINT.formatted(leaderboardId), ScoreSaberLeaderboardToken.class);
|
||||||
|
if (response.getParsingError().isPresent()) { // Failed to parse the response
|
||||||
|
throw new BadRequestException("Failed to parse ScoreSaber leaderboard for '%s'".formatted(leaderboardId));
|
||||||
|
}
|
||||||
|
if (response.getStatus() != 200) { // The response was not successful
|
||||||
|
throw new BadRequestException("Failed to get ScoreSaber leaderboard for '%s'".formatted(leaderboardId));
|
||||||
|
}
|
||||||
|
ScoreSaberLeaderboardToken leaderboard = response.getBody();
|
||||||
|
leaderboardRepository.save(leaderboard);
|
||||||
|
leaderboardCache.put(leaderboardId, leaderboard);
|
||||||
|
return leaderboard;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a list of all the ranked leaderboards.
|
||||||
|
*
|
||||||
|
* @return the ranked leaderboards
|
||||||
|
* @throws BadRequestException if an error occurred while getting the leaderboards
|
||||||
|
*/
|
||||||
|
public List<ScoreSaberLeaderboardPageToken> getRankedLeaderboards() {
|
||||||
|
log.info("Getting all ranked leaderboards...");
|
||||||
|
List<ScoreSaberLeaderboardPageToken> pages = new LinkedList<>();
|
||||||
|
int page = 1;
|
||||||
|
do {
|
||||||
|
log.info("Getting ranked leaderboard page '%s'...".formatted(page));
|
||||||
|
ScoreSaberLeaderboardPageToken pageToken = getRankedLeaderboards(page);
|
||||||
|
pages.add(pageToken);
|
||||||
|
for (ScoreSaberLeaderboardToken leaderboard : pageToken.getLeaderboards()) {
|
||||||
|
this.leaderboardRepository.save(leaderboard);
|
||||||
|
}
|
||||||
|
page++;
|
||||||
|
} while (page <= ((pages.get(0).getMetadata().getTotal() / pages.get(0).getMetadata().getItemsPerPage()) + 1));
|
||||||
|
log.info("Finished getting all ranked leaderboards, found '{}' pages.", pages.size());
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a list of the ranked leaderboards for a page.
|
||||||
|
*
|
||||||
|
* @param page the page to get the leaderboards for
|
||||||
|
* @return the ranked leaderboards
|
||||||
|
* @throws BadRequestException if an error occurred while getting the leaderboards
|
||||||
|
*/
|
||||||
|
public ScoreSaberLeaderboardPageToken getRankedLeaderboards(int page) {
|
||||||
|
HttpResponse<ScoreSaberLeaderboardPageToken> response = Request.get(GET_RANKED_LEADERBOARDS_ENDPOINT.formatted(page), ScoreSaberLeaderboardPageToken.class);
|
||||||
|
if (response.getParsingError().isPresent()) { // Failed to parse the response
|
||||||
|
throw new BadRequestException("Failed to parse ScoreSaber leaderboard page for page '%s'".formatted(page));
|
||||||
|
}
|
||||||
|
if (response.getStatus() != 200) { // The response was not successful
|
||||||
|
throw new BadRequestException("Failed to get ScoreSaber leaderboard page for page '%s'".formatted(page));
|
||||||
|
}
|
||||||
|
return response.getBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a leaderboard for a leaderboard id.
|
||||||
|
*
|
||||||
|
* @param leaderboardId the leaderboard id to get the leaderboard for
|
||||||
|
* @return the ScoreSaber leaderboard
|
||||||
|
* @throws BadRequestException if an error occurred while getting the leaderboard
|
||||||
|
*/
|
||||||
|
public ScoreSaberLeaderboardToken getLeaderboard(String leaderboardId) {
|
||||||
|
return getLeaderboard(leaderboardId, false);
|
||||||
|
}
|
||||||
|
}
|
288
API/src/main/java/cc/fascinated/services/ScoreService.java
Normal file
288
API/src/main/java/cc/fascinated/services/ScoreService.java
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
package cc.fascinated.services;
|
||||||
|
|
||||||
|
import cc.fascinated.common.DateUtils;
|
||||||
|
import cc.fascinated.common.EnumUtils;
|
||||||
|
import cc.fascinated.common.MathUtils;
|
||||||
|
import cc.fascinated.common.PaginationBuilder;
|
||||||
|
import cc.fascinated.model.leaderboard.Leaderboard;
|
||||||
|
import cc.fascinated.model.score.DeviceInformation;
|
||||||
|
import cc.fascinated.model.score.Score;
|
||||||
|
import cc.fascinated.model.score.TotalScoresResponse;
|
||||||
|
import cc.fascinated.model.score.impl.scoresaber.ScoreSaberScore;
|
||||||
|
import cc.fascinated.model.score.impl.scoresaber.ScoreSaberScoreResponse;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberPlayerScoreToken;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberScoreToken;
|
||||||
|
import cc.fascinated.model.user.User;
|
||||||
|
import cc.fascinated.model.user.UserDTO;
|
||||||
|
import cc.fascinated.model.user.history.HistoryPoint;
|
||||||
|
import cc.fascinated.model.user.hmd.DeviceController;
|
||||||
|
import cc.fascinated.model.user.hmd.DeviceHeadset;
|
||||||
|
import cc.fascinated.platform.Platform;
|
||||||
|
import cc.fascinated.repository.mongo.ScoreRepository;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Log4j2(topic = "Score Service")
|
||||||
|
public class ScoreService {
|
||||||
|
public static ScoreService INSTANCE;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The counter service to use.
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final CounterService counterService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user service to use.
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ScoreSaber service to use.
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final ScoreSaberService scoreSaberService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The score repository to use.
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final ScoreRepository scoreRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ScoreService(@NonNull CounterService counterService, @NonNull UserService userService, @NonNull ScoreSaberService scoreSaberService,
|
||||||
|
@NonNull ScoreRepository scoreRepository) {
|
||||||
|
INSTANCE = this;
|
||||||
|
this.counterService = counterService;
|
||||||
|
this.userService = userService;
|
||||||
|
this.scoreSaberService = scoreSaberService;
|
||||||
|
this.scoreRepository = scoreRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the top ranked scores from the platform.
|
||||||
|
*
|
||||||
|
* @param platform The platform to get the scores from.
|
||||||
|
* @param scoresOnly Whether to only get the scores.
|
||||||
|
* @return The scores.
|
||||||
|
*/
|
||||||
|
public PaginationBuilder.Page<ScoreSaberScoreResponse> getTopRankedScores(@NonNull Platform.Platforms platform, int pageNumber, boolean scoresOnly) {
|
||||||
|
PaginationBuilder<ScoreSaberScoreResponse> builder = new PaginationBuilder<ScoreSaberScoreResponse>().build();
|
||||||
|
builder.itemsPerPage(15);
|
||||||
|
builder.totalItems(() -> this.scoreRepository.getTotalRankedScores(platform));
|
||||||
|
builder.items((fetchItems) -> {
|
||||||
|
List<Score> foundScores = this.scoreRepository.getTopRankedScores(platform, fetchItems.getItemsPerPage(), fetchItems.skipAmount());
|
||||||
|
List<ScoreSaberScoreResponse> scores = new ArrayList<>();
|
||||||
|
for (Score score : foundScores) {
|
||||||
|
ScoreSaberScore scoreSaberScore = (ScoreSaberScore) score;
|
||||||
|
UserDTO user = scoresOnly ? null : userService.getUser(score.getPlayerId()).getAsDTO();
|
||||||
|
Leaderboard leaderboard = scoresOnly ? null : Leaderboard.getFromScoreSaberToken(scoreSaberService.getLeaderboard(score.getLeaderboardId()));
|
||||||
|
|
||||||
|
scores.add(ScoreSaberScoreResponse.fromScore(scoreSaberScore, user, leaderboard));
|
||||||
|
}
|
||||||
|
return scores;
|
||||||
|
});
|
||||||
|
return builder.getPage(pageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all the ranked scores from the platform.
|
||||||
|
*
|
||||||
|
* @param platform The platform to get the scores from.
|
||||||
|
* @return The scores.
|
||||||
|
*/
|
||||||
|
public List<Score> getRankedScores(@NonNull Platform.Platforms platform) {
|
||||||
|
return scoreRepository.getRankedScores(platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a scores pp value.
|
||||||
|
*
|
||||||
|
* @param scores The scores to update.
|
||||||
|
*/
|
||||||
|
public void updateScores(Score... scores) {
|
||||||
|
for (Score score : scores) {
|
||||||
|
scoreRepository.updateScorePP(score.getId(), score.getPp());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total scores for the platform.
|
||||||
|
*
|
||||||
|
* @param platform The platform to get the scores from.
|
||||||
|
* @return The total scores.
|
||||||
|
*/
|
||||||
|
public TotalScoresResponse getTotalScores(Platform.Platforms platform) {
|
||||||
|
return new TotalScoresResponse(
|
||||||
|
scoreRepository.getTotalScores(platform),
|
||||||
|
scoreRepository.getTotalRankedScores(platform)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total scores for the platform and user.
|
||||||
|
*
|
||||||
|
* @param platform The platform to get the scores from.
|
||||||
|
* @param user The user to get the scores from.
|
||||||
|
* @return The total scores.
|
||||||
|
*/
|
||||||
|
public int getTotalScores(@NonNull Platform.Platforms platform, @NonNull User user) {
|
||||||
|
Integer totalPlayerScores = scoreRepository.getTotalPlayerScores(platform, user.getSteamId());
|
||||||
|
return totalPlayerScores == null ? 0 : totalPlayerScores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total ranked scores for the platform and user.
|
||||||
|
*
|
||||||
|
* @param platform The platform to get the scores from.
|
||||||
|
* @param user The user to get the scores from.
|
||||||
|
* @return The total ranked scores.
|
||||||
|
*/
|
||||||
|
public int getTotalRankedScores(@NonNull Platform.Platforms platform, @NonNull User user) {
|
||||||
|
Integer totalPlayerRankedScores = scoreRepository.getTotalPlayerRankedScores(platform, user.getSteamId());
|
||||||
|
return totalPlayerRankedScores == null ? 0 : totalPlayerRankedScores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the total unranked scores for the platform and user.
|
||||||
|
*
|
||||||
|
* @param platform The platform to get the scores from.
|
||||||
|
* @param user The user to get the scores from.
|
||||||
|
* @return The total unranked scores.
|
||||||
|
*/
|
||||||
|
public int getTotalUnrankedScores(@NonNull Platform.Platforms platform, @NonNull User user) {
|
||||||
|
Integer totalPlayerUnrankedScores = scoreRepository.getTotalPlayerUnrankedScores(platform, user.getSteamId());
|
||||||
|
return totalPlayerUnrankedScores == null ? 0 : totalPlayerUnrankedScores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks a ScoreSaber score.
|
||||||
|
*
|
||||||
|
* @param token The token of the score to track.
|
||||||
|
*/
|
||||||
|
public void trackScoreSaberScore(ScoreSaberPlayerScoreToken token) {
|
||||||
|
ScoreSaberLeaderboardToken leaderboard = this.scoreSaberService.getLeaderboard(token.getLeaderboard().getId());
|
||||||
|
ScoreSaberScoreToken score = token.getScore();
|
||||||
|
User user = userService.getUser(score.getLeaderboardPlayerInfo().getId());
|
||||||
|
|
||||||
|
double accuracy = leaderboard.getMaxScore() != 0 ? ((double) score.getBaseScore() / leaderboard.getMaxScore()) * 100 : 0;
|
||||||
|
if (accuracy == 0) {
|
||||||
|
log.warn("[Scoresaber] Leaderboard '{}' has a max score of 0, unable to calculate accuracy :(", leaderboard.getId());
|
||||||
|
}
|
||||||
|
double pp = score.getPp() != 0 ? PlatformService.INSTANCE.getScoreSaberPlatform().getPp(leaderboard.getStars(), accuracy) : 0; // Recalculate the pp
|
||||||
|
String[] modifiers = !score.getModifiers().isEmpty() ? score.getModifiers().split(",") : new String[0];
|
||||||
|
|
||||||
|
DeviceHeadset deviceHmd;
|
||||||
|
boolean useLegacyHmdIdentification = score.getDeviceHmd() == null && score.getHmd() != 0;
|
||||||
|
if (!useLegacyHmdIdentification) { // Use the new format
|
||||||
|
deviceHmd = DeviceHeadset.getByName(score.getDeviceHmd());
|
||||||
|
} else { // Use the legacy format (only includes the HMD, missing controller information)
|
||||||
|
deviceHmd = DeviceHeadset.getByFallbackValue(score.getHmd());
|
||||||
|
}
|
||||||
|
|
||||||
|
ScoreSaberScore scoreSaberScore = new ScoreSaberScore(
|
||||||
|
this.counterService.getNext(CounterService.CounterType.SCORE),
|
||||||
|
user.getSteamId(),
|
||||||
|
Platform.Platforms.SCORESABER,
|
||||||
|
score.getId(),
|
||||||
|
leaderboard.getId(),
|
||||||
|
score.getRank(),
|
||||||
|
accuracy,
|
||||||
|
pp == 0 ? null : pp, // no pp, set to null to save data
|
||||||
|
score.getBaseScore(),
|
||||||
|
modifiers.length == 0 ? null : modifiers, // no modifiers, set to null to save data
|
||||||
|
score.getMissedNotes() == 0 ? null : score.getMissedNotes(), // no misses, set to null to save data
|
||||||
|
score.getBadCuts() == 0 ? null : score.getBadCuts(), // no bad cuts, set to null to save data
|
||||||
|
new DeviceInformation(
|
||||||
|
deviceHmd,
|
||||||
|
score.getDeviceControllerLeft() == null ? DeviceController.UNKNOWN : DeviceController.getByName(score.getDeviceControllerLeft()),
|
||||||
|
score.getDeviceControllerRight() == null ? DeviceController.UNKNOWN : DeviceController.getByName(score.getDeviceControllerRight())
|
||||||
|
),
|
||||||
|
DateUtils.getDateFromIsoString(score.getTimeSet()),
|
||||||
|
score.getWeight() == 0 ? null : score.getWeight(), // no weight, set to null to save data
|
||||||
|
score.getMultiplier(),
|
||||||
|
score.getMaxCombo()
|
||||||
|
);
|
||||||
|
this.saveScore(user, scoreSaberScore);
|
||||||
|
this.logScore(Platform.Platforms.SCORESABER, Leaderboard.getFromScoreSaberToken(leaderboard), scoreSaberScore, user);
|
||||||
|
if (useLegacyHmdIdentification) {
|
||||||
|
log.info(" - Using legacy device information, headset found: {} (missing controller information)", deviceHmd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the previous scores for a leaderboard.
|
||||||
|
*
|
||||||
|
* @param platform The platform to get the score from.
|
||||||
|
* @param user The user to get the score from.
|
||||||
|
* @param leaderboardId The leaderboard id to get the score from.
|
||||||
|
* @return The previous score.
|
||||||
|
*/
|
||||||
|
public @NonNull List<Score> getScoreHistory(@NonNull Platform.Platforms platform, @NonNull User
|
||||||
|
user, @NonNull String leaderboardId) {
|
||||||
|
List<Score> foundScores = new ArrayList<>(this.scoreRepository.findScores(platform, user.getSteamId(), leaderboardId));
|
||||||
|
|
||||||
|
// Sort previous scores by timestamp (newest -> oldest)
|
||||||
|
foundScores.sort(Comparator.comparing(Score::getTimestamp).reversed());
|
||||||
|
return foundScores;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a score.
|
||||||
|
*
|
||||||
|
* @param score The score to save.
|
||||||
|
*/
|
||||||
|
public void saveScore(User user, Score score) {
|
||||||
|
HistoryPoint todayHistory = user.getTodayHistory();
|
||||||
|
if (score.isRanked()) {
|
||||||
|
todayHistory.incrementRankedPlayCount();
|
||||||
|
} else {
|
||||||
|
todayHistory.incrementUnrankedPlayCount();
|
||||||
|
}
|
||||||
|
Platform.Platforms platform = score.getPlatform();
|
||||||
|
todayHistory.setTotalPlayCount(this.getTotalScores(platform, user)); // Get the total scores for the platform
|
||||||
|
todayHistory.setTotalRankedPlayCount(this.getTotalRankedScores(platform, user)); // Get the total ranked scores for the platform
|
||||||
|
todayHistory.setTotalUnrankedPlayCount(this.getTotalUnrankedScores(platform, user)); // Get the total unranked scores for the platform
|
||||||
|
userService.saveUser(user); // Save the user
|
||||||
|
scoreRepository.save(score); // Save the score
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a score.
|
||||||
|
*
|
||||||
|
* @param platform The platform the score was tracked on.
|
||||||
|
* @param score The score to log.
|
||||||
|
* @param user The user who set the score.
|
||||||
|
*/
|
||||||
|
private void logScore(@NonNull Platform.Platforms platform, @NonNull Leaderboard leaderboard, @NonNull Score
|
||||||
|
score,
|
||||||
|
@NonNull User user) {
|
||||||
|
String platformName = EnumUtils.getEnumName(platform);
|
||||||
|
boolean isRanked = score.getPp() != 0;
|
||||||
|
|
||||||
|
log.info("[{}] Tracked{} Score! id: {}, acc: {}%, {} score id: {},{} leaderboard: {}, difficulty: {}, player: {} ({})",
|
||||||
|
platformName,
|
||||||
|
isRanked ? " Ranked" : "",
|
||||||
|
score.getId(),
|
||||||
|
MathUtils.format(score.getAccuracy(), 2),
|
||||||
|
platformName.toLowerCase(), score.getPlatformScoreId(),
|
||||||
|
isRanked ? " pp: %s,".formatted(MathUtils.format(score.getPp(), 2)) : "",
|
||||||
|
score.getLeaderboardId(),
|
||||||
|
leaderboard.getDifficulty().getDifficulty(),
|
||||||
|
user.getUsername() == null ? user.getSteamId() : user.getUsername(),
|
||||||
|
user.getSteamId()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
51
API/src/main/java/cc/fascinated/services/SteamService.java
Normal file
51
API/src/main/java/cc/fascinated/services/SteamService.java
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package cc.fascinated.services;
|
||||||
|
|
||||||
|
import cc.fascinated.common.Request;
|
||||||
|
import cc.fascinated.model.token.steam.SteamAuthenticateUserTicketToken;
|
||||||
|
import kong.unirest.core.HttpResponse;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Log4j2
|
||||||
|
public class SteamService {
|
||||||
|
/**
|
||||||
|
* Steam API endpoints.
|
||||||
|
*/
|
||||||
|
private static final String STEAM_API_URL = "https://api.steampowered.com/";
|
||||||
|
private static final String USER_AUTH_TICKET = STEAM_API_URL + "ISteamUserAuth/AuthenticateUserTicket/v1/?key=%s&appid=620980&ticket=%s";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The key to use for authentication
|
||||||
|
* with the Steam API.
|
||||||
|
*/
|
||||||
|
private final String steamKey;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public SteamService(@Value("${scoretracker.steam.api-key}") String steamKey) {
|
||||||
|
this.steamKey = steamKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the steam ID from a user's ticket.
|
||||||
|
*
|
||||||
|
* @param ticket the ticket to get the steam ID from
|
||||||
|
* @return the steam ID from the ticket
|
||||||
|
*/
|
||||||
|
public SteamAuthenticateUserTicketToken getSteamUserFromTicket(String ticket) {
|
||||||
|
HttpResponse<SteamAuthenticateUserTicketToken> response = Request.get(
|
||||||
|
USER_AUTH_TICKET.formatted(steamKey, ticket),
|
||||||
|
SteamAuthenticateUserTicketToken.class
|
||||||
|
);
|
||||||
|
if (response.getStatus() != 200) {
|
||||||
|
log.error("Failed to get steam ID from ticket: %s".formatted(response.getStatus()));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return response.getBody();
|
||||||
|
}
|
||||||
|
}
|
192
API/src/main/java/cc/fascinated/services/UserService.java
Normal file
192
API/src/main/java/cc/fascinated/services/UserService.java
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
package cc.fascinated.services;
|
||||||
|
|
||||||
|
import cc.fascinated.common.StringUtils;
|
||||||
|
import cc.fascinated.common.TimeUtils;
|
||||||
|
import cc.fascinated.exception.impl.BadRequestException;
|
||||||
|
import cc.fascinated.model.auth.AuthToken;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberAccountToken;
|
||||||
|
import cc.fascinated.model.token.steam.SteamAuthenticateUserTicketToken;
|
||||||
|
import cc.fascinated.model.user.ScoreSaberAccount;
|
||||||
|
import cc.fascinated.model.user.User;
|
||||||
|
import cc.fascinated.model.user.history.HistoryPoint;
|
||||||
|
import cc.fascinated.repository.mongo.UserRepository;
|
||||||
|
import cc.fascinated.repository.redis.AuthTokenRepository;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import net.jodah.expiringmap.ExpirationPolicy;
|
||||||
|
import net.jodah.expiringmap.ExpiringMap;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Log4j2(topic = "User Service")
|
||||||
|
public class UserService {
|
||||||
|
/**
|
||||||
|
* The interval to refresh the user's account data from external services
|
||||||
|
*/
|
||||||
|
private static long ACCOUNT_REFRESH_INTERVAL = TimeUnit.HOURS.toMillis(1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user repository to use
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The auth token repository to use
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final AuthTokenRepository authTokenRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ScoreSaber service to use
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final ScoreSaberService scoreSaberService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Steam service to use
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
private final SteamService steamService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user cache to use
|
||||||
|
*/
|
||||||
|
private final Map<String, User> userCache = ExpiringMap.builder()
|
||||||
|
.maxSize(5_000)
|
||||||
|
.expirationPolicy(ExpirationPolicy.ACCESSED)
|
||||||
|
.expiration(1, TimeUnit.DAYS)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public UserService(@NonNull UserRepository userRepository, @NonNull AuthTokenRepository authTokenRepository,
|
||||||
|
@NonNull ScoreSaberService scoreSaberService, @NonNull SteamService steamService) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.authTokenRepository = authTokenRepository;
|
||||||
|
this.scoreSaberService = scoreSaberService;
|
||||||
|
this.steamService = steamService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a user by their id
|
||||||
|
*
|
||||||
|
* @param steamId the id of the user's steam profile
|
||||||
|
* @return the user
|
||||||
|
* @throws BadRequestException if the user is not found
|
||||||
|
*/
|
||||||
|
public User getUser(String steamId) {
|
||||||
|
if (!this.isValidSteamId(steamId)) {
|
||||||
|
throw new BadRequestException("Invalid steam id");
|
||||||
|
}
|
||||||
|
if (this.userCache.containsKey(steamId)) {
|
||||||
|
return this.userCache.get(steamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<User> userOptional = this.userRepository.findBySteamId(steamId);
|
||||||
|
User user;
|
||||||
|
boolean shouldUpdate = false;
|
||||||
|
if (userOptional.isEmpty()) {
|
||||||
|
// todo: check the steam API to see if the user exists
|
||||||
|
user = new User(UUID.randomUUID());
|
||||||
|
user.setSteamId(steamId);
|
||||||
|
shouldUpdate = true;
|
||||||
|
} else {
|
||||||
|
user = userOptional.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the users ScoreSaber account is up-to-date
|
||||||
|
ScoreSaberAccount scoresaberAccount = user.getScoresaberAccount();
|
||||||
|
if (scoresaberAccount == null || scoresaberAccount.getLastUpdated().before(new Date(System.currentTimeMillis() - ACCOUNT_REFRESH_INTERVAL))) {
|
||||||
|
try {
|
||||||
|
log.info("[Scoresaber] Updating account for '{}', last update: {}",
|
||||||
|
steamId,
|
||||||
|
scoresaberAccount == null ? "now" : TimeUtils.format(System.currentTimeMillis() - scoresaberAccount.getLastUpdated().getTime())
|
||||||
|
);
|
||||||
|
ScoreSaberAccountToken accountToken = scoreSaberService.getAccount(user);
|
||||||
|
user.setScoresaberAccount(ScoreSaberAccount.getFromToken(accountToken)); // Update the ScoreSaber account
|
||||||
|
user.setUsername(accountToken.getName()); // Update the username
|
||||||
|
|
||||||
|
HistoryPoint historyToday = user.getTodayHistory();
|
||||||
|
historyToday.setRank(accountToken.getRank());
|
||||||
|
historyToday.setCountryRank(accountToken.getCountryRank());
|
||||||
|
historyToday.setPp(accountToken.getPp());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("[Scoresaber] Failed to update account for '{}'", steamId, ex);
|
||||||
|
}
|
||||||
|
shouldUpdate = true;
|
||||||
|
}
|
||||||
|
if (shouldUpdate) {
|
||||||
|
this.saveUser(user); // Save the user
|
||||||
|
}
|
||||||
|
this.userCache.put(steamId, user);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new auth token using a steam ticket
|
||||||
|
*
|
||||||
|
* @param ticket the ticket to get the auth token from
|
||||||
|
* @return the auth token
|
||||||
|
* @throws BadRequestException if the ticket is invalid
|
||||||
|
*/
|
||||||
|
public AuthToken getAuthToken(String ticket) {
|
||||||
|
SteamAuthenticateUserTicketToken steamUser = this.steamService.getSteamUserFromTicket(ticket);
|
||||||
|
assert steamUser != null;
|
||||||
|
User user = this.getUser(steamUser.getResponse().getParams().getSteamId());
|
||||||
|
if (user == null) {
|
||||||
|
throw new BadRequestException("Failed to get user from steam id");
|
||||||
|
}
|
||||||
|
return this.authTokenRepository.save(new AuthToken(
|
||||||
|
StringUtils.randomString(32),
|
||||||
|
user.getId()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates an auth token
|
||||||
|
*
|
||||||
|
* @param authToken the auth token to validate
|
||||||
|
* @return true if the auth token is valid, false otherwise
|
||||||
|
*/
|
||||||
|
public boolean isValidAuthToken(String authToken) {
|
||||||
|
return this.authTokenRepository.existsById(authToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a user to the database
|
||||||
|
*
|
||||||
|
* @param user the user to save
|
||||||
|
*/
|
||||||
|
public void saveUser(User user) {
|
||||||
|
this.userRepository.save(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all users in the database
|
||||||
|
*
|
||||||
|
* @return all users
|
||||||
|
*/
|
||||||
|
public List<User> getUsers(boolean smallerAccount) {
|
||||||
|
if (smallerAccount) {
|
||||||
|
return this.userRepository.fetchAccountsSimple();
|
||||||
|
}
|
||||||
|
return this.userRepository.findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a steam id
|
||||||
|
*
|
||||||
|
* @param steamId the steam id to validate
|
||||||
|
* @return if the steam id is valid
|
||||||
|
*/
|
||||||
|
public boolean isValidSteamId(String steamId) {
|
||||||
|
return steamId != null && steamId.length() == 17;
|
||||||
|
}
|
||||||
|
}
|
@ -5,6 +5,7 @@ import lombok.NonNull;
|
|||||||
import lombok.SneakyThrows;
|
import lombok.SneakyThrows;
|
||||||
import lombok.extern.log4j.Log4j2;
|
import lombok.extern.log4j.Log4j2;
|
||||||
import org.springframework.web.socket.CloseStatus;
|
import org.springframework.web.socket.CloseStatus;
|
||||||
|
import org.springframework.web.socket.TextMessage;
|
||||||
import org.springframework.web.socket.WebSocketSession;
|
import org.springframework.web.socket.WebSocketSession;
|
||||||
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
|
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
|
||||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||||
@ -14,7 +15,7 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
|
|||||||
*/
|
*/
|
||||||
@Log4j2
|
@Log4j2
|
||||||
@Getter
|
@Getter
|
||||||
public class Websocket extends TextWebSocketHandler {
|
public abstract class Websocket extends TextWebSocketHandler {
|
||||||
/**
|
/**
|
||||||
* The name of the WebSocket.
|
* The name of the WebSocket.
|
||||||
*/
|
*/
|
||||||
@ -25,19 +26,36 @@ public class Websocket extends TextWebSocketHandler {
|
|||||||
*/
|
*/
|
||||||
private final String url;
|
private final String url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The WebSocket session.
|
||||||
|
*/
|
||||||
|
private WebSocketSession webSocketSession;
|
||||||
|
|
||||||
public Websocket(@NonNull String name, @NonNull String url) {
|
public Websocket(@NonNull String name, @NonNull String url) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.url = url;
|
this.url = url;
|
||||||
connectWebSocket(); // Connect to the WebSocket.
|
connectWebSocket(); // Connect to the WebSocket.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles a message received from the WebSocket.
|
||||||
|
*
|
||||||
|
* @param message the message received
|
||||||
|
*/
|
||||||
|
public abstract void handleMessage(@NonNull TextMessage message);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected final void handleTextMessage(@NonNull WebSocketSession session, @NonNull TextMessage message) {
|
||||||
|
this.handleMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connects to the ScoreSaber WebSocket.
|
* Connects to the ScoreSaber WebSocket.
|
||||||
*/
|
*/
|
||||||
@SneakyThrows
|
@SneakyThrows
|
||||||
private void connectWebSocket() {
|
private void connectWebSocket() {
|
||||||
log.info("Connecting to the {}", this.getName());
|
log.info("Connecting to the {}", this.getName());
|
||||||
new StandardWebSocketClient().execute(this, this.getUrl()).get();
|
this.webSocketSession = new StandardWebSocketClient().execute(this, this.getUrl()).get();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
@ -0,0 +1,70 @@
|
|||||||
|
package cc.fascinated.websocket.impl;
|
||||||
|
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberPlayerScoreToken;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberScoreToken;
|
||||||
|
import cc.fascinated.model.token.scoresaber.ScoreSaberWebsocketDataToken;
|
||||||
|
import cc.fascinated.services.ScoreService;
|
||||||
|
import cc.fascinated.services.UserService;
|
||||||
|
import cc.fascinated.websocket.Websocket;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.NonNull;
|
||||||
|
import lombok.SneakyThrows;
|
||||||
|
import lombok.extern.log4j.Log4j2;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.socket.TextMessage;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Fascinated (fascinated7)
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Log4j2(topic = "ScoreSaber Websocket")
|
||||||
|
public class ScoreSaberWebsocket extends Websocket {
|
||||||
|
/**
|
||||||
|
* The Jackson deserializer to use.
|
||||||
|
*/
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user service to use
|
||||||
|
*/
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The score service to use
|
||||||
|
*/
|
||||||
|
private final ScoreService scoreService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public ScoreSaberWebsocket(@NonNull ObjectMapper objectMapper, @NonNull UserService userService, @NonNull ScoreService scoreService) {
|
||||||
|
super("ScoreSaber", "wss://scoresaber.com/ws");
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.userService = userService;
|
||||||
|
this.scoreService = scoreService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@SneakyThrows
|
||||||
|
public void handleMessage(@NonNull TextMessage message) {
|
||||||
|
String payload = message.getPayload();
|
||||||
|
if (payload.equals("Connected to the ScoreSaber WSS")) { // Ignore the connection message
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ScoreSaberWebsocketDataToken response = this.objectMapper.readValue(payload, ScoreSaberWebsocketDataToken.class);
|
||||||
|
if (!response.getCommandName().equals("score")) { // Ignore non-score messages
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the message using Jackson
|
||||||
|
ScoreSaberPlayerScoreToken scoreToken = this.objectMapper.readValue(response.getCommandData().toString(), ScoreSaberPlayerScoreToken.class);
|
||||||
|
ScoreSaberScoreToken score = scoreToken.getScore();
|
||||||
|
ScoreSaberScoreToken.LeaderboardPlayerInfo player = score.getLeaderboardPlayerInfo();
|
||||||
|
|
||||||
|
// Ensure the player is valid
|
||||||
|
if (!this.userService.isValidSteamId(player.getId())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scoreService.trackScoreSaberScore(scoreToken);
|
||||||
|
}
|
||||||
|
}
|
@ -3,36 +3,31 @@ server:
|
|||||||
address: 0.0.0.0
|
address: 0.0.0.0
|
||||||
port: 7500
|
port: 7500
|
||||||
|
|
||||||
|
# ScoreTracker Configuration
|
||||||
|
scoretracker:
|
||||||
|
steam:
|
||||||
|
api-key: "xxx"
|
||||||
|
|
||||||
# Spring Configuration
|
# Spring Configuration
|
||||||
spring:
|
spring:
|
||||||
data:
|
data:
|
||||||
|
# Redis Configuration
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
database: 0
|
||||||
|
auth: ""
|
||||||
|
|
||||||
# MongoDB Configuration
|
# MongoDB Configuration
|
||||||
mongodb:
|
mongodb:
|
||||||
uri: "mongodb://bs-tracker:p4$$w0rd@localhost:27017"
|
uri: "mongodb://bs-tracker:p4$$w0rd@localhost:27017"
|
||||||
database: "bs-tracker"
|
database: "bs-tracker"
|
||||||
auto-index-creation: true # Automatically create collection indexes
|
auto-index-creation: true # Automatically create collection indexes
|
||||||
|
|
||||||
datasource:
|
|
||||||
url: jdbc:postgresql://localhost:5432/<YOUR_DATABASE_NAME>
|
|
||||||
username: <YOUR_USERNAME>
|
|
||||||
password: <YOUR_PASSWORD>
|
|
||||||
jpa:
|
|
||||||
hibernate:
|
|
||||||
ddl-auto: <create | create-drop | update | validate | none>
|
|
||||||
properties:
|
|
||||||
hibernate:
|
|
||||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
|
||||||
|
|
||||||
# Don't serialize null values by default with Jackson
|
# Don't serialize null values by default with Jackson
|
||||||
jackson:
|
jackson:
|
||||||
default-property-inclusion: non_null
|
default-property-inclusion: non_null
|
||||||
|
|
||||||
# QuestDB Configuration
|
|
||||||
questdb:
|
|
||||||
host: localhost:9000
|
|
||||||
username: admin
|
|
||||||
password: quest
|
|
||||||
|
|
||||||
# DO NOT TOUCH BELOW
|
# DO NOT TOUCH BELOW
|
||||||
management:
|
management:
|
||||||
# Disable all actuator endpoints
|
# Disable all actuator endpoints
|
3
Frontend/.eslintrc.json
Normal file
3
Frontend/.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
36
Frontend/.gitignore
vendored
Normal file
36
Frontend/.gitignore
vendored
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
.yarn/install-state.gz
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
42
Frontend/Dockerfile
Normal file
42
Frontend/Dockerfile
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
FROM imbios/bun-node AS base
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
FROM base AS depends
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
COPY package.json* bun.lockb* ./
|
||||||
|
RUN bun install --frozen-lockfile --quiet
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
COPY --from=depends /usr/src/app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED 1
|
||||||
|
RUN bun run build
|
||||||
|
|
||||||
|
|
||||||
|
# Run the app
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1007 nextjs
|
||||||
|
RUN adduser --system --uid 1007 nextjs
|
||||||
|
|
||||||
|
RUN mkdir .next
|
||||||
|
RUN chown nextjs:nextjs .next
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextjs:nextjs /usr/src/app/node_modules ./node_modules
|
||||||
|
COPY --from=builder --chown=nextjs:nextjs /usr/src/app/.next ./.next
|
||||||
|
COPY --from=builder --chown=nextjs:nextjs /usr/src/app/next.config.mjs ./next.config.mjs
|
||||||
|
COPY --from=builder --chown=nextjs:nextjs /usr/src/app/package.json ./package.json
|
||||||
|
|
||||||
|
ENV NODE_ENV production
|
||||||
|
|
||||||
|
# Exposting on port 80 so we can
|
||||||
|
# access via a reverse proxy for Dokku
|
||||||
|
ENV HOSTNAME "0.0.0.0"
|
||||||
|
EXPOSE 80
|
||||||
|
ENV PORT 80
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
CMD ["bun", "start"]
|
1
Frontend/README.md
Normal file
1
Frontend/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Frontend
|
BIN
Frontend/bun.lockb
Normal file
BIN
Frontend/bun.lockb
Normal file
Binary file not shown.
4
Frontend/next.config.mjs
Normal file
4
Frontend/next.config.mjs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
26
Frontend/package.json
Normal file
26
Frontend/package.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18",
|
||||||
|
"react-dom": "^18",
|
||||||
|
"next": "14.2.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "14.2.5"
|
||||||
|
}
|
||||||
|
}
|
8
Frontend/postcss.config.mjs
Normal file
8
Frontend/postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
33
Frontend/src/app/globals.css
Normal file
33
Frontend/src/app/globals.css
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--foreground-rgb: 0, 0, 0;
|
||||||
|
--background-start-rgb: 214, 219, 220;
|
||||||
|
--background-end-rgb: 255, 255, 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--foreground-rgb: 255, 255, 255;
|
||||||
|
--background-start-rgb: 0, 0, 0;
|
||||||
|
--background-end-rgb: 0, 0, 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
color: rgb(var(--foreground-rgb));
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent,
|
||||||
|
rgb(var(--background-end-rgb))
|
||||||
|
)
|
||||||
|
rgb(var(--background-start-rgb));
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
}
|
22
Frontend/src/app/layout.tsx
Normal file
22
Frontend/src/app/layout.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Inter } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Score Tracker",
|
||||||
|
description: "Tracking your BeatSaber progress, currently tracks ScoreSaber :)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className={inter.className}>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
17
Frontend/src/app/page.tsx
Normal file
17
Frontend/src/app/page.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<main className="flex flex-col items-center w-screen h-screen">
|
||||||
|
<h1>BeatSaber Metrics</h1>
|
||||||
|
|
||||||
|
<p>this website is currently under construction</p>
|
||||||
|
<Link
|
||||||
|
className="text-blue-500 hover:opacity-80 transform-gpu transition-all"
|
||||||
|
href={"https://beatsaber.fascinated.cc/api/"}
|
||||||
|
>
|
||||||
|
Visit the API!
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
12
Frontend/tailwind.config.ts
Normal file
12
Frontend/tailwind.config.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [
|
||||||
|
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
|
],
|
||||||
|
theme: {},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
export default config;
|
26
Frontend/tsconfig.json
Normal file
26
Frontend/tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Liam (Fascinated).
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
359
Mod/.gitignore
vendored
Normal file
359
Mod/.gitignore
vendored
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
## Ignore References folder
|
||||||
|
References/
|
||||||
|
*.bat
|
||||||
|
PrivateKeys.cs
|
||||||
|
MockUp
|
||||||
|
|
||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET Core
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*[.json, .xml, .info]
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
|
*.pubxml
|
||||||
|
*.publishproj
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
# tools/**
|
||||||
|
# !tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
.localhistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
.ionide/
|
145
Mod/API/Authentication.cs
Normal file
145
Mod/API/Authentication.cs
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
using ScoreTracker.Configuration;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ScoreTracker.API
|
||||||
|
{
|
||||||
|
internal class SigninResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string Response { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class Authentication
|
||||||
|
{
|
||||||
|
private static bool _signedIn = false;
|
||||||
|
private static string _authToken;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validate the auth token and sign in if necessary
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<SigninResponse> ValidateAndSignIn()
|
||||||
|
{
|
||||||
|
if (_signedIn && await ValidateAuthToken())
|
||||||
|
{
|
||||||
|
return new SigninResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Response = null
|
||||||
|
}; // Already signed in
|
||||||
|
}
|
||||||
|
|
||||||
|
bool success = false;
|
||||||
|
string response = null;
|
||||||
|
|
||||||
|
await LoginUser(
|
||||||
|
token => {
|
||||||
|
success = true;
|
||||||
|
Request.PersistHeaders(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "Authorization", $"Bearer {token}" }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
reason =>
|
||||||
|
{
|
||||||
|
response = reason;
|
||||||
|
Request.HttpClient.DefaultRequestHeaders.Clear(); // Clear headers
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return new SigninResponse
|
||||||
|
{
|
||||||
|
Success = success,
|
||||||
|
Response = response
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Get the steam ticket and user info
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>the steam ticket</returns>
|
||||||
|
private static async Task<string> GetSteamTicket()
|
||||||
|
{
|
||||||
|
Plugin.Log.Info("Getting steam ticket...");
|
||||||
|
return (await new SteamPlatformUserModel().GetUserAuthToken()).token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Login the user
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="onSuccess">callback for successful login, returns the token</param>
|
||||||
|
/// <param name="onFail">callback for failed login</param>
|
||||||
|
/// <returns>an IEnumerator</returns>
|
||||||
|
public static async Task LoginUser(Action<string> onSuccess, Action<string> onFail)
|
||||||
|
{
|
||||||
|
if (_signedIn && !string.IsNullOrEmpty(_authToken))
|
||||||
|
{
|
||||||
|
onSuccess(_authToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticketTask = GetSteamTicket();
|
||||||
|
await Task.Run(() => ticketTask.Wait());
|
||||||
|
|
||||||
|
var ticket = ticketTask.Result;
|
||||||
|
if (string.IsNullOrEmpty(ticket))
|
||||||
|
{
|
||||||
|
Plugin.Log.Error("Login failed :( no steam auth token");
|
||||||
|
onFail("No Steam Auth Token");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Plugin.Log.Info("Logging in...");
|
||||||
|
var request = await Request.PostJsonAsync($"{PluginConfig.Instance.ApiUrl}/auth/login", new Dictionary<object, object> {
|
||||||
|
{ "ticket", ticket }
|
||||||
|
}, false);
|
||||||
|
if (request.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var authToken = request.Headers.GetValues("Authorization").First();
|
||||||
|
Plugin.Log.Info($"Login successful! auth token: {authToken}");
|
||||||
|
|
||||||
|
onSuccess(authToken);
|
||||||
|
_signedIn = true;
|
||||||
|
_authToken = authToken;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Plugin.Log.Error($"Login failed! body: {request.StatusCode}");
|
||||||
|
onFail($"Login failed: {request.StatusCode}");
|
||||||
|
|
||||||
|
_signedIn = false;
|
||||||
|
_authToken = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the auth token and logs out if it's invalid
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>whether the token is valid</returns>
|
||||||
|
public static async Task<bool> ValidateAuthToken()
|
||||||
|
{
|
||||||
|
if (!_signedIn || string.IsNullOrEmpty(_authToken)) // If we're not signed in, return false
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = await Request.PostJsonAsync($"{PluginConfig.Instance.ApiUrl}/auth/validate", new Dictionary<object, object> {
|
||||||
|
{ "token", _authToken }
|
||||||
|
}, false);
|
||||||
|
|
||||||
|
if (request.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_signedIn = false;
|
||||||
|
_authToken = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
52
Mod/API/Request.cs
Normal file
52
Mod/API/Request.cs
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
using Newtonsoft.Json;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Net.Http;
|
||||||
|
using System.Text;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace ScoreTracker.API
|
||||||
|
{
|
||||||
|
internal class Request
|
||||||
|
{
|
||||||
|
internal static readonly HttpClient HttpClient = new HttpClient();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persist the given headers for all future requests
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="headers">the headers to persist</param>
|
||||||
|
public static void PersistHeaders(Dictionary<string, string> headers)
|
||||||
|
{
|
||||||
|
HttpClient.DefaultRequestHeaders.Clear(); // Clear existing headers
|
||||||
|
foreach (var header in headers)
|
||||||
|
{
|
||||||
|
HttpClient.DefaultRequestHeaders.Add(header.Key, header.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Create a POST request to the given URL with the given data
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="url">the url to post to</param>
|
||||||
|
/// <param name="data">the data to post</param>
|
||||||
|
/// <param name="checkAuth">whether to check for authentication</param>
|
||||||
|
/// <returns>the task</returns>
|
||||||
|
public static async Task<HttpResponseMessage> PostJsonAsync(string url, Dictionary<object, object> json, bool checkAuth = true)
|
||||||
|
{
|
||||||
|
if (checkAuth)
|
||||||
|
{
|
||||||
|
var signinResponse = await Authentication.ValidateAndSignIn();
|
||||||
|
if (!signinResponse.Success)
|
||||||
|
{
|
||||||
|
throw new Exception($"Failed to log in: {signinResponse.Response}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var jsonString = JsonConvert.SerializeObject(json, Formatting.None);
|
||||||
|
var content = new StringContent(jsonString, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
// Send the POST request
|
||||||
|
var response = await HttpClient.PostAsync(url, content);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
16
Mod/Configuration/PluginConfig.cs
Normal file
16
Mod/Configuration/PluginConfig.cs
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
using IPA.Config.Stores;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo(GeneratedStore.AssemblyVisibilityTarget)]
|
||||||
|
namespace ScoreTracker.Configuration
|
||||||
|
{
|
||||||
|
internal class PluginConfig
|
||||||
|
{
|
||||||
|
public static PluginConfig Instance { get; set; }
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The URL of the API to use
|
||||||
|
*/
|
||||||
|
public virtual string ApiUrl { get; set; } = "https://beatsaber.fascinated.cc/api";
|
||||||
|
}
|
||||||
|
}
|
12
Mod/Directory.Build.props
Normal file
12
Mod/Directory.Build.props
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- This file contains project properties used by the build. -->
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
|
||||||
|
<WarningLevel>4</WarningLevel>
|
||||||
|
</PropertyGroup>
|
||||||
|
<PropertyGroup Condition="'$(RUNNING_IN_CI)' == 'true'">
|
||||||
|
<ContinuousIntegrationBuild>true</ContinuousIntegrationBuild>
|
||||||
|
<DisableCopyToPlugins>true</DisableCopyToPlugins>
|
||||||
|
<DisableZipRelease>true</DisableZipRelease>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user