Compare commits
129 Commits
27e9a91f31
...
renovate/m
Author | SHA1 | Date | |
---|---|---|---|
c5c741464f | |||
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 | |||
ef2b82541c | |||
ce5b6c83ee |
@ -1,31 +1,30 @@
|
||||
name: Deploy to Dokku
|
||||
name: Deploy API
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
paths-ignore:
|
||||
- .gitignore
|
||||
- README.md
|
||||
- LICENSE
|
||||
paths: [".gitea/workflows/deploy-api.yml", "API/**"]
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
strategy:
|
||||
matrix:
|
||||
arch: ["ubuntu-latest"]
|
||||
runs-on: ${{ matrix.arch }}
|
||||
java-version: ["17"]
|
||||
maven-version: ["3.8.5"]
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: "./API"
|
||||
|
||||
# Steps to run
|
||||
steps:
|
||||
# Checkout the repo
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Deploy to Dokku
|
||||
- name: Push to dokku
|
||||
- name: Deploy to Dokku
|
||||
uses: dokku/github-action@master
|
||||
with:
|
||||
git_remote_url: "ssh://dokku@10.0.50.136:22/bs-tracker"
|
||||
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
ssh_private_key: ${{ secrets.SSH_PRIVATE_KEY }}
|
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 }}
|
33
.gitignore
vendored
33
.gitignore
vendored
@ -1,31 +1,2 @@
|
||||
*.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
|
||||
/docker/questdb/
|
||||
/docker/mongodb/
|
||||
docker
|
||||
.idea
|
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
|
@ -1,5 +1,5 @@
|
||||
# Stage 1: Build the application
|
||||
FROM maven:3.9.8-eclipse-temurin-17-alpine AS builder
|
||||
FROM maven:3.9.9-eclipse-temurin-17-alpine AS builder
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /home/container
|
@ -62,10 +62,6 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
@ -74,6 +70,20 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-mongodb</artifactId>
|
||||
</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 -->
|
||||
<dependency>
|
||||
@ -85,13 +95,9 @@
|
||||
<artifactId>unirest-modules-jackson</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.questdb</groupId>
|
||||
<artifactId>questdb</artifactId>
|
||||
<version>8.0.3</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.postgresql</groupId>
|
||||
<artifactId>postgresql</artifactId>
|
||||
<groupId>net.jodah</groupId>
|
||||
<artifactId>expiringmap</artifactId>
|
||||
<version>0.5.11</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Libraries -->
|
@ -6,8 +6,8 @@ import lombok.extern.log4j.Log4j2;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
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.redis.repository.configuration.EnableRedisRepositories;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
import java.io.File;
|
||||
@ -18,11 +18,11 @@ import java.util.Objects;
|
||||
/**
|
||||
* @author Fascinated (fascinated7)
|
||||
*/
|
||||
@EnableJpaRepositories(basePackages = "cc.fascinated.repository.couchdb")
|
||||
@EnableMongoRepositories(basePackages = "cc.fascinated.repository.mongo")
|
||||
@EnableScheduling
|
||||
@EnableMongoRepositories(basePackages = "cc.fascinated.repository.mongo")
|
||||
@EnableRedisRepositories(basePackages = "cc.fascinated.repository.redis")
|
||||
@SpringBootApplication(exclude = UserDetailsServiceAutoConfiguration.class)
|
||||
@Log4j2(topic = "Ember")
|
||||
@Log4j2(topic = "Score Tracker")
|
||||
public class Main {
|
||||
@SneakyThrows
|
||||
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 java.text.DecimalFormat;
|
||||
import java.text.DecimalFormatSymbols;
|
||||
|
||||
/**
|
||||
* @author Fascinated (fascinated7)
|
||||
*/
|
||||
@ -31,4 +34,19 @@ public class MathUtils {
|
||||
public static double lerp(double a, double b, double t) {
|
||||
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;
|
||||
|
||||
import cc.fascinated.exception.impl.BadRequestException;
|
||||
import cc.fascinated.model.user.User;
|
||||
import cc.fascinated.model.user.UserDTO;
|
||||
import cc.fascinated.services.UserService;
|
||||
import lombok.NonNull;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@ -17,7 +17,8 @@ public class UserController {
|
||||
/**
|
||||
* The user service to use
|
||||
*/
|
||||
@NonNull private final UserService userService;
|
||||
@NonNull
|
||||
private final UserService userService;
|
||||
|
||||
@Autowired
|
||||
public UserController(@NonNull UserService userService) {
|
||||
@ -33,7 +34,21 @@ public class UserController {
|
||||
*/
|
||||
@ResponseBody
|
||||
@GetMapping(value = "/{id}")
|
||||
public ResponseEntity<User> getUser(@PathVariable String id) {
|
||||
return ResponseEntity.ok(userService.getUser(id));
|
||||
public ResponseEntity<UserDTO> getUser(@PathVariable String 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;
|
||||
|
@ -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.ToString;
|
@ -1,4 +1,4 @@
|
||||
package cc.fascinated.model.token;
|
||||
package cc.fascinated.model.token.scoresaber;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
@ -1,4 +1,4 @@
|
||||
package cc.fascinated.model.token;
|
||||
package cc.fascinated.model.token.scoresaber;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
@ -1,4 +1,4 @@
|
||||
package cc.fascinated.model.token;
|
||||
package cc.fascinated.model.token.scoresaber;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
@ -1,4 +1,4 @@
|
||||
package cc.fascinated.model.token;
|
||||
package cc.fascinated.model.token.scoresaber;
|
||||
|
||||
import lombok.Getter;
|
||||
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 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.
|
||||
*
|
||||
* @param stars the amount of stars
|
||||
* @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
|
||||
* the players data in QuestDB.
|
||||
* Called to update the players
|
||||
* data in QuestDB.
|
||||
*/
|
||||
public abstract void updatePlayers();
|
||||
public abstract void trackPlayerMetrics();
|
||||
|
||||
/**
|
||||
* Called every 10 minutes to update
|
||||
* the metrics for total scores, etc.
|
||||
* Called to update the metrics
|
||||
* for total scores, etc.
|
||||
*/
|
||||
public abstract void updateMetrics();
|
||||
|
||||
/**
|
||||
* Called every day at midnight to update
|
||||
* the leaderboards.
|
||||
* Called to update the leaderboards.
|
||||
*/
|
||||
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;
|
||||
|
||||
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
|
||||
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
|
||||
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.impl.ScoreSaberPlatform;
|
||||
import cc.fascinated.repository.couchdb.TrackedScoreRepository;
|
||||
import com.mongodb.client.model.Filters;
|
||||
import lombok.NonNull;
|
||||
import lombok.extern.log4j.Log4j2;
|
||||
import org.bson.Document;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@ -21,8 +21,10 @@ import java.util.concurrent.Executors;
|
||||
* @author Fascinated (fascinated7)
|
||||
*/
|
||||
@Service
|
||||
@DependsOn("mongoService")
|
||||
@Log4j2(topic = "PlatformService")
|
||||
public class PlatformService {
|
||||
public static PlatformService INSTANCE;
|
||||
private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(1);
|
||||
|
||||
/**
|
||||
@ -30,16 +32,9 @@ public class PlatformService {
|
||||
*/
|
||||
private final List<Platform> platforms = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* The tracked score repository to use.
|
||||
*/
|
||||
@NonNull
|
||||
private final TrackedScoreRepository trackedScoreRepository;
|
||||
|
||||
@Autowired
|
||||
public PlatformService(@NonNull ApplicationContext context, @NonNull TrackedScoreRepository trackedScoreRepository) {
|
||||
this.trackedScoreRepository = trackedScoreRepository;
|
||||
|
||||
public PlatformService(@NonNull ApplicationContext context) {
|
||||
INSTANCE = this;
|
||||
log.info("Registering platforms...");
|
||||
registerPlatform(context.getBean(ScoreSaberPlatform.class));
|
||||
log.info("Loaded %s platforms.".formatted(this.platforms.size()));
|
||||
@ -48,7 +43,7 @@ public class PlatformService {
|
||||
/**
|
||||
* Updates the platform metrics.
|
||||
* <p>
|
||||
* This method is scheduled to run every minute.
|
||||
* This method is scheduled to run every 5 minutes.
|
||||
* </p>
|
||||
*/
|
||||
@Scheduled(cron = "0 */5 * * * *")
|
||||
@ -63,16 +58,16 @@ public class PlatformService {
|
||||
/**
|
||||
* Updates the platform players.
|
||||
* <p>
|
||||
* This method is scheduled to run every 15 minutes.
|
||||
* This method is scheduled to run every hour.
|
||||
* </p>
|
||||
*/
|
||||
@Scheduled(cron = "0 */15 * * * *")
|
||||
public void updateScores() {
|
||||
log.info("Updating %s platform players...".formatted(this.platforms.size()));
|
||||
@Scheduled(cron = "0 0 * * * *")
|
||||
public void updatePlayerMetrics() {
|
||||
log.info("Updating %s platform player metrics...".formatted(this.platforms.size()));
|
||||
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()));
|
||||
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());
|
||||
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.extern.log4j.Log4j2;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.client.standard.StandardWebSocketClient;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
@ -14,7 +15,7 @@ import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
*/
|
||||
@Log4j2
|
||||
@Getter
|
||||
public class Websocket extends TextWebSocketHandler {
|
||||
public abstract class Websocket extends TextWebSocketHandler {
|
||||
/**
|
||||
* The name of the WebSocket.
|
||||
*/
|
||||
@ -25,19 +26,36 @@ public class Websocket extends TextWebSocketHandler {
|
||||
*/
|
||||
private final String url;
|
||||
|
||||
/**
|
||||
* The WebSocket session.
|
||||
*/
|
||||
private WebSocketSession webSocketSession;
|
||||
|
||||
public Websocket(@NonNull String name, @NonNull String url) {
|
||||
this.name = name;
|
||||
this.url = url;
|
||||
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.
|
||||
*/
|
||||
@SneakyThrows
|
||||
private void connectWebSocket() {
|
||||
log.info("Connecting to the {}", this.getName());
|
||||
new StandardWebSocketClient().execute(this, this.getUrl()).get();
|
||||
this.webSocketSession = new StandardWebSocketClient().execute(this, this.getUrl()).get();
|
||||
}
|
||||
|
||||
@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
|
||||
port: 7500
|
||||
|
||||
# ScoreTracker Configuration
|
||||
scoretracker:
|
||||
steam:
|
||||
api-key: "xxx"
|
||||
|
||||
# Spring Configuration
|
||||
spring:
|
||||
data:
|
||||
# Redis Configuration
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
database: 0
|
||||
auth: ""
|
||||
|
||||
# MongoDB Configuration
|
||||
mongodb:
|
||||
uri: "mongodb://bs-tracker:p4$$w0rd@localhost:27017"
|
||||
database: "bs-tracker"
|
||||
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
|
||||
jackson:
|
||||
default-property-inclusion: non_null
|
||||
|
||||
# QuestDB Configuration
|
||||
questdb:
|
||||
host: localhost:9000
|
||||
username: admin
|
||||
password: quest
|
||||
|
||||
# DO NOT TOUCH BELOW
|
||||
management:
|
||||
# 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