97 Commits

Author SHA1 Message Date
c5c741464f chore(deps): update maven docker tag to v3.9.9 2024-08-19 21:01:41 +00:00
7b560075ba mod: cleanup auth
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 35s
Release Mod / Build (push) Successful in 30s
2024-08-15 18:22:16 +01:00
d888ab1eb5 Merge remote-tracking branch 'origin/master'
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m21s
2024-08-09 00:52:04 +01:00
8371344a7d change account refresh interval 2024-08-09 00:51:36 +01:00
Lee
ea0e4bd20a Merge pull request 'Update actions/checkout action to v4' (#10) from renovate/actions-checkout-4.x into master
All checks were successful
Release Mod / Build (push) Successful in 28s
Reviewed-on: #10
2024-08-07 07:44:10 +00:00
Lee
02290e5bde Merge pull request 'Update actions/setup-dotnet action to v4' (#11) from renovate/actions-setup-dotnet-4.x into master
Some checks failed
Release Mod / Build (push) Has been cancelled
Reviewed-on: #11
2024-08-07 07:44:00 +00:00
Lee
ef2aeb5f0a Merge pull request 'Update dependency BepInEx.AssemblyPublicizer.MSBuild to v0.4.2' (#12) from renovate/bepinex.assemblypublicizer.msbuild-0.x into master
Some checks failed
Release Mod / Build (push) Has been cancelled
Reviewed-on: #12
2024-08-07 07:43:47 +00:00
8e3a46f8bc mod: impl config for api url
All checks were successful
Release Mod / Build (push) Successful in 25s
2024-08-07 08:26:12 +01:00
456f2afff0 Update actions/setup-dotnet action to v4 2024-08-07 07:01:18 +00:00
e0eda1a053 Update actions/checkout action to v4 2024-08-07 07:01:17 +00:00
9355368f54 update api url
All checks were successful
Release Mod / Build (push) Successful in 29s
2024-08-07 07:50:44 +01:00
c2e0aafda6 this will do for now im too tired
All checks were successful
Release Mod / Build (push) Successful in 25s
2024-08-07 07:46:33 +01:00
33b1c0357b yes
Some checks failed
Release Mod / Build (push) Failing after 35s
2024-08-07 07:37:51 +01:00
0ae7cdb42c yes
Some checks failed
Release Mod / Build (push) Failing after 53s
2024-08-07 07:36:24 +01:00
55f1b2fc8d yes
Some checks failed
Release Mod / Build (push) Failing after 26s
2024-08-07 07:34:46 +01:00
3b005510e4 yes
Some checks failed
Release Mod / Build (push) Failing after 26s
2024-08-07 07:33:43 +01:00
88eea39a2e yes
Some checks failed
Release Mod / Build (push) Failing after 25s
2024-08-07 07:31:23 +01:00
728d987b64 yes
Some checks failed
Release Mod / Build (push) Failing after 32s
2024-08-07 07:26:33 +01:00
c2044c5f80 yes
Some checks failed
Release Mod / Build (push) Failing after 27s
2024-08-07 07:20:45 +01:00
41e248b751 yes
Some checks failed
Release Mod / Build (push) Failing after 3s
2024-08-07 07:19:17 +01:00
13840aa1e2 yes
Some checks failed
Release Mod / Build (push) Failing after 26s
2024-08-07 07:15:36 +01:00
02e8946b9b yes
Some checks failed
Release Mod / Build (push) Failing after 27s
2024-08-07 07:12:04 +01:00
fcf3785477 yes
Some checks failed
Release Mod / Build (push) Failing after 4s
2024-08-07 07:11:13 +01:00
135c34f763 yes
Some checks failed
Release Mod / Build (push) Failing after 25s
2024-08-07 07:07:22 +01:00
21c6abb443 yes
Some checks failed
Release Mod / Build (push) Failing after 24s
2024-08-07 07:04:37 +01:00
f9b95744f8 Update dependency BepInEx.AssemblyPublicizer.MSBuild to v0.4.2 2024-08-07 06:01:08 +00:00
4d95e0ee53 yes
Some checks failed
Release Mod / Build (push) Failing after 27s
2024-08-07 07:00:57 +01:00
5b7de4150c yes
Some checks failed
Release Mod / Build (push) Failing after 30s
2024-08-07 06:57:27 +01:00
b3ae9ca369 yes
All checks were successful
Release Mod / Build (push) Successful in 25s
2024-08-07 06:54:45 +01:00
eba857829b yes
Some checks failed
Release Mod / Build (push) Failing after 8s
2024-08-07 06:22:18 +01:00
7253bf2eea yes
Some checks failed
Release Mod / Build (push) Failing after 8s
2024-08-07 06:19:56 +01:00
067666ef7c yes
Some checks failed
Release Mod / Build (push) Failing after 5s
2024-08-07 06:18:27 +01:00
06927f3fd3 yes
Some checks failed
Release Mod / Build (push) Failing after 21s
2024-08-07 06:16:32 +01:00
cb1ee3abad yes
Some checks failed
Release Mod / Build (push) Failing after 21s
2024-08-07 06:07:37 +01:00
fa60278463 yes
Some checks failed
Release Mod / Build (push) Failing after 22s
2024-08-07 06:05:32 +01:00
e397c86963 yes
Some checks failed
Release Mod / Build (push) Failing after 32s
2024-08-07 06:03:46 +01:00
1bcb99430c yes
Some checks failed
Release Mod / Build (push) Failing after 24s
2024-08-07 05:53:01 +01:00
07c6bc3d0a yes
Some checks failed
Release Mod / Build (push) Failing after 21s
2024-08-07 05:49:11 +01:00
c62948627c yes
Some checks failed
Release Mod / Build (push) Failing after 21s
2024-08-07 05:47:47 +01:00
0287f4f83a yes
Some checks failed
Release Mod / Build (push) Failing after 23s
2024-08-07 05:45:21 +01:00
7307ccc99b yes
Some checks failed
Release Mod / Build (push) Failing after 22s
2024-08-07 05:39:34 +01:00
03bfcab616 ci
Some checks failed
Release Mod / Build (push) Failing after 24s
2024-08-07 05:36:40 +01:00
53f407081e ci
Some checks failed
Release Mod / Build (push) Failing after 3s
2024-08-07 05:36:10 +01:00
eab307882e ci
Some checks failed
Release Mod / Build (push) Failing after 19s
2024-08-07 05:34:16 +01:00
aa23919037 ci
Some checks failed
Release Mod / Build (push) Failing after 18s
2024-08-07 05:33:24 +01:00
e27258a351 ci
Some checks failed
Release Mod / Build (push) Failing after 4s
2024-08-07 05:32:40 +01:00
577c8e7deb ci
Some checks failed
Release Mod / Build (push) Failing after 20s
2024-08-07 05:31:11 +01:00
2f24c529e0 oopsie
Some checks failed
Release Mod / Build (push) Failing after 21s
2024-08-07 05:30:00 +01:00
517e9df72b ci
Some checks failed
Release Mod / Build (push) Failing after 25s
2024-08-07 05:28:21 +01:00
f38a1156e1 ci 2024-08-07 05:27:54 +01:00
c04a51de35 why
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m32s
2024-08-07 05:19:39 +01:00
1ec8248c6f IMPL BASIC AUTH INTO MOD AND BACKEND
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Has been cancelled
2024-08-07 05:19:24 +01:00
05e1c7170d inital commit 2024-08-06 23:57:47 +01:00
bd31254990 api: track player histories more often to keep them more up to date
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m4s
2024-08-06 21:12:59 +01:00
131a5c2efe api: optimize pagination!!
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 08:53:07 +01:00
1f1c55d41f api: make items cache for longer
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 08:37:49 +01:00
21b6de0f15 api: impl pagination for top scores endpoint and add user + leaderboard caching
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 47s
2024-08-05 08:35:16 +01:00
64b6ef1a7f api: oopsie doodle
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 05:59:41 +01:00
54bdf532fe api: add fallback values for hmd identification
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 31s
2024-08-05 05:57:50 +01:00
6cb86f843d cleanup score history
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 05:48:01 +01:00
f75d22fa58 api: update method comment
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 33s
2024-08-05 05:01:18 +01:00
68ce2ff240 api: add possiblyInaccurateData field to show if the history for that day could be inaccurate
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 04:58:46 +01:00
ba24eabfaa api: since we have this, might aswell track it, but it won't be be accurate if they ether: don't set a score that day, or only set 1 score in the morning, but it's better than no data for that day
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 04:32:06 +01:00
84eb8a4b94 api: don't fetch all data when tracking player metrics
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-05 04:25:34 +01:00
6f49d81664 api: handle player not setting scores for the day
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 29s
2024-08-05 04:18:48 +01:00
98223a3293 api: change how some data points are got for histories
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 31s
2024-08-05 04:15:01 +01:00
7b0c9f54ff api: re-impl histories (muchhhhhhhhhh better now)
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 37s
2024-08-05 03:57:59 +01:00
29f5d5983a rename statistics to histories
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 34s
2024-08-04 04:25:51 +01:00
8c354b1be1 api: make log less fat
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m8s
2024-08-04 01:34:19 +01:00
de309ea05c api: add /improved/best/{platform} endpoint
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 33s
2024-08-04 00:36:59 +01:00
199ee50534 api: add score history endpoints (per leaderboard and last 30 day improvements (all maps))
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 47s
2024-08-04 00:32:39 +01:00
6fda81e81a oopsie
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m5s
2024-08-03 20:24:37 +01:00
bdad804eed api: oopsie doodle
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 33s
2024-08-02 19:28:30 +01:00
66d29c343e api: fix mongo indexes
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 32s
2024-08-02 18:02:43 +01:00
96f62d9a01 api: change how scores are stored
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 34s
2024-08-02 17:36:24 +01:00
4697cd4aec api: return the last updated
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 31s
2024-08-02 02:51:58 +01:00
0abff880c2 API: make the pp slightly more accurate (revert)
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 32s
2024-08-02 02:50:12 +01:00
cc351e6cad API: make the pp slightly more accurate
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 33s
2024-08-02 02:25:33 +01:00
f351c7a3c1 API: don't return the whole user
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 1m21s
2024-08-02 01:54:50 +01:00
68180f2647 api: oops
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 5s
2024-08-02 00:38:27 +01:00
6a1a2dc2c4 API: fix stats
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Has been cancelled
2024-08-02 00:38:08 +01:00
d4e51d1517 api: rename has logged in field
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 37s
2024-08-02 00:07:13 +01:00
02fcaf19eb api: don't return the user early
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 31s
2024-08-01 23:50:37 +01:00
0f307eb18c api: oopsie again
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-01 23:48:45 +01:00
357315990e API: oopsie
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 30s
2024-08-01 23:46:09 +01:00
de89182c5d impl user history tracking
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 14s
2024-08-01 23:44:20 +01:00
8dfdc8c535 API: switch to Mongo for score tracking
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 39s
2024-08-01 23:24:34 +01:00
f68fb48726 API: show the whole user
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 36s
2024-08-01 16:44:32 +01:00
7b1d4a73a5 API: move around
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 36s
2024-08-01 16:35:10 +01:00
139b3bf06d API: hide duplicated difficulty
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 37s
2024-08-01 16:32:09 +01:00
20576d913f API: don't return the lastUpdate field
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 37s
2024-08-01 16:30:12 +01:00
871ae76a23 fix leaderboard object order
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 36s
2024-08-01 16:26:57 +01:00
7aa3de3827 move where the accounts get updated
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 45s
2024-08-01 16:25:21 +01:00
4135af4743 Merge remote-tracking branch 'origin/master'
Some checks failed
Deploy API / docker (17, 3.8.5) (push) Failing after 40s
2024-08-01 16:19:43 +01:00
242a8a2fba store the users scoresaber profile and add more data to the top scores endpoint 2024-08-01 16:19:27 +01:00
Lee
38c7bfcd3d Merge pull request 'chore(deps): update dependency @types/node to v22' (#6) from renovate/node-22.x into master
Some checks failed
Deploy Frontend / docker (push) Failing after 1m7s
Reviewed-on: #6
2024-08-01 13:17:14 +00:00
e4ec89a7d6 chore(deps): update dependency @types/node to v22 2024-08-01 06:01:37 +00:00
82 changed files with 3589 additions and 717 deletions

View 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 }}

View File

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

View File

@ -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.1.0</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<groupId>net.jodah</groupId>
<artifactId>expiringmap</artifactId>
<version>0.5.11</version>
</dependency>
<!-- Libraries -->

View File

@ -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) {
@ -39,7 +39,5 @@ public class Main {
// Start the app
SpringApplication.run(Main.class, args);
}
}

View 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));
}
}

View 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();
}
}

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

View File

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

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

View File

@ -3,9 +3,11 @@ 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)
@ -49,6 +51,7 @@ public class Request {
}
response = Unirest.get(url).asObject(clazz);
}
response.getParsingError().ifPresent(e -> log.error("Failed to parse response", e));
return response;
}

View 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();
}
}

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

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

View 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);
}
}

View File

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

View File

@ -1,13 +1,13 @@
package cc.fascinated.controller;
import java.util.Map;
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)
*/
@ -20,6 +20,9 @@ public class RootController {
@ResponseBody
@GetMapping(value = "/")
public ResponseEntity<?> getWelcome() {
return ResponseEntity.ok(Map.of("message", "Hello!"));
return ResponseEntity.ok(Map.of(
"message", "Hello!",
"url", "https://git.fascinated.cc/Fascinated/beatsaber-scoretracker"
));
}
}

View File

@ -2,14 +2,13 @@ package cc.fascinated.controller;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.platform.Platform;
import cc.fascinated.services.TrackedScoreService;
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.*;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
@ -20,11 +19,18 @@ public class ScoresController {
* The tracked score service to use.
*/
@NonNull
private final TrackedScoreService trackedScoreService;
private final ScoreService scoreService;
/**
* The user service to use
*/
@NonNull
private final UserService userService;
@Autowired
public ScoresController(@NonNull TrackedScoreService trackedScoreService) {
this.trackedScoreService = trackedScoreService;
public ScoresController(@NonNull ScoreService scoreService, @NonNull UserService userService) {
this.scoreService = scoreService;
this.userService = userService;
}
/**
@ -37,8 +43,12 @@ public class ScoresController {
*/
@ResponseBody
@GetMapping(value = "/top/{platform}")
public ResponseEntity<List<?>> getTopScores(@PathVariable String platform) {
return ResponseEntity.ok(trackedScoreService.getTopScores(Platform.Platforms.getPlatform(platform), 100));
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));
}
/**
@ -52,20 +62,24 @@ public class ScoresController {
@ResponseBody
@GetMapping(value = "/count/{platform}")
public ResponseEntity<?> getScoresCount(@PathVariable String platform) {
return ResponseEntity.ok(trackedScoreService.getTotalScores(Platform.Platforms.getPlatform(platform)));
return ResponseEntity.ok(scoreService.getTotalScores(Platform.Platforms.getPlatform(platform)));
}
/**
* A GET mapping to retrieve the total
* amount of scores over pp thresholds
* A GET mapping to retrieve the score
* history for a leaderboard
*
* @param platform the platform to get the scores from
* @return the amount of scores
* @throws BadRequestException if there were no scores found
* @param platform the platform to get the history from
* @return the score history
* @throws BadRequestException if there were no history found
*/
@ResponseBody
@GetMapping(value = "/ppthresholds/{platform}")
public ResponseEntity<?> getScoresOver(@PathVariable String platform) {
return ResponseEntity.ok(trackedScoreService.getScoresOver(Platform.Platforms.getPlatform(platform)));
@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
));
}
}

View File

@ -1,7 +1,6 @@
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;
@ -18,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) {
@ -37,4 +37,18 @@ public class UserController {
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));
}
}

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

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

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

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

View File

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

View File

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

View File

@ -1,29 +0,0 @@
package cc.fascinated.model.platform;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@Entity
@Table(name = "metrics")
public class DailyScoresSet {
/**
* The platform for the metric.
*/
@Id private String platform;
/**
* The amount of scores set for the day.
*/
private long scoresSet;
/**
* The day the scores were set.
*/
private Date timestamp;
}

View File

@ -1,31 +0,0 @@
package cc.fascinated.model.platform;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
/**
* @author Fascinated (fascinated7)
*/
@Entity
@Getter
@Setter
@Table(name = "metric")
public class TrackedPlatformMetric {
/**
* The platform for the metric.
*/
@Id private String platform;
/**
* The total amount of scores.
*/
private long totalScores;
/**
* The total amount of ranked scores.
*/
private long totalRankedScores;
}

View File

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

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

View File

@ -1,24 +0,0 @@
package cc.fascinated.model.score;
import java.util.HashMap;
import java.util.Map;
/**
* @author Fascinated (fascinated7)
*/
public class ScoresOverResponse {
/**
* Scores over a certain pp threshold.
*/
public Map<Integer, Integer> scoresOver = new HashMap<>();
/**
* Adds scores over a certain pp threshold.
*
* @param pp the pp threshold
* @param scoreAmount the amount of scores over the pp threshold
*/
public void addScores(int pp, int scoreAmount) {
scoresOver.put(pp, scoreAmount);
}
}

View File

@ -1,99 +0,0 @@
package cc.fascinated.model.score;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@Entity
@Getter
@Setter
@Table(name = "score")
public class TrackedScore {
/**
* The ID of the score.
*/
@Id
private String scoreId;
/**
* The ID of the player who set the score.
*/
private String playerId;
/**
* The ID of the leaderboard.
*/
private String leaderboardId;
/**
* The PP of the score.
*/
private Double pp;
/**
* The rank of the score.
*/
private Long rank;
/**
* The base score of the score.
*/
private Long score;
/**
* The modified score of the score.
*/
private Long modifiedScore;
/**
* The weight of the score.
*/
private Double weight;
/**
* The modifiers of the score.
*/
private String modifiers;
/**
* The multiplier of the score.
*/
private double multiplier;
/**
* The number of misses in the score.
*/
private Long missedNotes;
/**
* The number of bad cuts in the score.
*/
private Long badCuts;
/**
* The highest combo in the score.
*/
private Long maxCombo;
/**
* The accuracy of the score.
*/
private Double accuracy;
/**
* The difficulty the score was set on.
*/
private String difficulty;
/**
* The timestamp of the score.
*/
private Date timestamp;
}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;
import lombok.Setter;

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;
import lombok.ToString;

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;
import lombok.ToString;

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;
import lombok.ToString;

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;
import lombok.ToString;

View File

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import lombok.Getter;
import lombok.Setter;

View File

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

View File

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

View File

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

View File

@ -1,10 +1,19 @@
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;
@ -14,12 +23,15 @@ import java.util.UUID;
@RequiredArgsConstructor
@Getter
@Setter
@Log4j2
@ToString
@Document("user")
public class User {
/**
* The ID of the user.
*/
@Id
@JsonIgnore
private final UUID id;
/**
@ -28,11 +40,14 @@ public class User {
* Usually their Steam name.
* </p>
*/
@Indexed
private String username;
/**
* The ID of the users steam profile.
*/
@Indexed
@JsonProperty("id")
private String steamId;
/**
@ -42,14 +57,55 @@ public class User {
* If they haven't logged in, we don't want to track their profiles.
* </p>
*/
public boolean hasLoggedIn;
@JsonIgnore
public boolean linkedAccount;
/**
* Converts the User object to a UserDTO object.
*
* @return The UserDTO object.
* 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(id, username, steamId);
return new UserDTO(this.id, this.username, this.steamId, this.scoresaberAccount);
}
}

View File

@ -2,7 +2,6 @@ package cc.fascinated.model.user;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.data.annotation.Id;
import java.util.UUID;
@ -15,7 +14,6 @@ public class UserDTO {
/**
* The ID of the user.
*/
@Id
private final UUID id;
/**
@ -30,4 +28,9 @@ public class UserDTO {
* The ID of the users steam profile.
*/
private String steamId;
/**
* The user's ScoreSaber account.
*/
public ScoreSaberAccount scoresaberAccount;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -69,20 +69,19 @@ public abstract class Platform {
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();

View File

@ -1,25 +1,25 @@
package cc.fascinated.platform.impl;
import cc.fascinated.common.DateUtils;
import cc.fascinated.common.MathUtils;
import cc.fascinated.model.score.TotalScoresResponse;
import cc.fascinated.model.score.TrackedScore;
import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardPageToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
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.QuestDBService;
import cc.fascinated.services.ScoreSaberService;
import cc.fascinated.services.TrackedScoreService;
import cc.fascinated.services.ScoreService;
import cc.fascinated.services.UserService;
import io.questdb.client.Sender;
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.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -28,17 +28,13 @@ import java.util.Map;
* @author Fascinated (fascinated7)
*/
@Component
@DependsOn("scoreService")
@Log4j2
public class ScoreSaberPlatform extends Platform {
/**
* Delay in ms for requests per minute.
*/
private static final long UPDATE_DELAY = 1000L / 250L; // 150 requests per minute
/**
* The base multiplier for stars.
*/
private final double starMultiplier = 42.114296;
private final double starMultiplier = 42.117208413;
/**
* The ScoreSaber service to use
@ -53,20 +49,13 @@ public class ScoreSaberPlatform extends Platform {
private final UserService userService;
/**
* The Influx service to use
* The score service to use
*/
@NonNull
private final QuestDBService questDBService;
/**
* The tracked score service to use
*/
@NonNull
private final TrackedScoreService trackedScoreService;
private final ScoreService scoreService;
@Autowired
public ScoreSaberPlatform(@NonNull ScoreSaberService scoreSaberService, @NonNull UserService userService, @NonNull QuestDBService questDBService,
@NonNull TrackedScoreService trackedScoreService) {
public ScoreSaberPlatform(@NonNull ScoreSaberService scoreSaberService, @NonNull UserService userService, @NonNull ScoreService scoreService) {
super(Platforms.SCORESABER, 1, Map.of(
1, new CurvePoint[]{
new CurvePoint(0, 0),
@ -110,8 +99,7 @@ public class ScoreSaberPlatform extends Platform {
));
this.scoreSaberService = scoreSaberService;
this.userService = userService;
this.questDBService = questDBService;
this.trackedScoreService = trackedScoreService;
this.scoreService = scoreService;
}
/**
@ -135,7 +123,10 @@ public class ScoreSaberPlatform extends Platform {
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 MathUtils.lerp(
point.getMultiplier(), nextPoint.getMultiplier(),
(accuracy - point.getAcc()) / (nextPoint.getAcc() - point.getAcc())
);
}
}
@ -152,46 +143,44 @@ public class ScoreSaberPlatform extends Platform {
}
@Override
public void updatePlayers() {
for (User user : this.userService.getUsers()) {
if (!user.isHasLoggedIn()) { // Check if the user has linked their account
continue;
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);
}
ScoreSaberAccountToken account = scoreSaberService.getAccount(user); // Get the account from the ScoreSaber API
try (Sender sender = questDBService.getSender()) {
sender.table("player")
.symbol("platform", this.getPlatform().getPlatformName())
.symbol("user_id", user.getId().toString())
.doubleColumn("pp", account.getPp())
.longColumn("rank", account.getRank())
.longColumn("country_rank", account.getCountryRank())
.longColumn("total_score", account.getScoreStats().getTotalScore())
.longColumn("total_ranked_score", account.getScoreStats().getTotalRankedScore())
.doubleColumn("average_ranked_accuracy", account.getScoreStats().getAverageRankedAccuracy())
.longColumn("total_play_count", account.getScoreStats().getTotalPlayCount())
.longColumn("ranked_play_count", account.getScoreStats().getRankedPlayCount())
.atNow();
// 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() {
try (Sender sender = questDBService.getSender()) {
TotalScoresResponse totalScores = trackedScoreService.getTotalScores(this.getPlatform());
sender.table("metrics")
.symbol("platform", this.getPlatform().getPlatformName())
.longColumn("total_scores", totalScores.getTotalScores())
.longColumn("total_ranked_scores", totalScores.getTotalRankedScores())
.atNow();
}
// 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() {
// TODO: PUSH THIS
List<TrackedScore> scores = this.trackedScoreService.getTrackedScores(this.getPlatform(), true);
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()) {
@ -200,7 +189,7 @@ public class ScoreSaberPlatform extends Platform {
}
// Add any missing leaderboards
for (TrackedScore score : scores) {
for (Score score : scores) {
if (leaderboards.containsKey(score.getLeaderboardId())) {
continue;
}
@ -219,26 +208,16 @@ public class ScoreSaberPlatform extends Platform {
for (Map.Entry<String, ScoreSaberLeaderboardToken> leaderboardEntry : leaderboards.entrySet()) {
String id = leaderboardEntry.getKey();
ScoreSaberLeaderboardToken leaderboard = leaderboardEntry.getValue();
if (finished > 0) {
// Sleep to prevent rate limiting
try {
Thread.sleep(UPDATE_DELAY);
} catch (InterruptedException e) {
log.error("Failed to sleep for rate limit reset", e);
}
}
try {
List<TrackedScore> toUpdate = scores.stream().filter(score -> {
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() == null ? 0D : score.getPp()); // Check if the pp has changed
return pp != score.getPp(); // Check if the pp has changed
}).toList();
for (TrackedScore score : toUpdate) { // Update the scores
for (Score score : toUpdate) { // Update the scores
if (leaderboard.getStars() == 0) { // The leaderboard was unranked
score.setPp(0D);
}
@ -247,7 +226,7 @@ public class ScoreSaberPlatform extends Platform {
}
if (!toUpdate.isEmpty()) { // Save the scores
this.trackedScoreService.updateScores(toUpdate.toArray(TrackedScore[]::new));
ScoreService.INSTANCE.updateScores(toUpdate.toArray(new Score[0]));
}
finished++;

View File

@ -1,21 +0,0 @@
package cc.fascinated.repository.couchdb;
import cc.fascinated.model.platform.TrackedPlatformMetric;
import org.springframework.data.repository.CrudRepository;
/**
* @author Fascinated (fascinated7)
*/
public interface MetricsRepository extends CrudRepository<TrackedPlatformMetric, String> {
/**
* SELECT
* platform,
* last(total_scores) - first(total_scores) AS scores_set,
* timestamp
* FROM metrics
* TIMESTAMP(timestamp)
* SAMPLE BY 1d
* ORDER BY timestamp DESC;
*/
}

View File

@ -1,90 +0,0 @@
package cc.fascinated.repository.couchdb;
import cc.fascinated.model.score.TrackedScore;
import jakarta.transaction.Transactional;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
public interface TrackedScoreRepository extends CrudRepository<TrackedScore, String> {
/**
* Ensures that the deduplication of the scores is done.
*/
@Modifying @Transactional
@Query(value = "ALTER TABLE score DEDUP ENABLE UPSERT KEYS(timestamp, score_id)", nativeQuery = true)
void ensureDeduplication();
/**
* Updates the pp of a score.
*
* @param scoreId the ID of the score
* @param pp the new pp of the score
*/
@Modifying @Transactional
@Query(value = "UPDATE score SET pp = :pp WHERE score_id = :scoreId", nativeQuery = true)
void updateScorePp(@Param("scoreId") String scoreId, @Param("pp") double pp);
/**
* Gets a list of top tracked scores
* sorted by pp from the platform
*
* @param platform the platform to get the scores from
* @param amount the amount of scores to get
* @return the scores
*/
@Query(value = "SELECT * FROM score WHERE platform = :platform AND pp > 0 ORDER BY pp DESC LIMIT :amount", nativeQuery = true)
List<TrackedScore> findTopRankedScores(@Param("platform") String platform, @Param("amount") int amount);
/**
* Gets all tracked scores from a platform.
*
* @param platform the platform to get the scores from
* @return the scores
*/
@Query(value = "SELECT * FROM score WHERE platform = :platform", nativeQuery = true)
List<TrackedScore> findAllByPlatform(String platform);
/**
* Gets all tracked scores from a platform.
*
* @param platform the platform to get the scores from
* @return the scores
*/
@Query(value = "SELECT * FROM score WHERE platform = :platform AND pp > 0", nativeQuery = true)
List<TrackedScore> findAllByPlatformRankedOnly(String platform);
/**
* Gets the total amount of scores
* for a platform.
*
* @param platform the platform to get the scores from
* @return the total amount of scores for the platform
*/
@Query(value = "SELECT COUNT(*) FROM score WHERE platform = :platform", nativeQuery = true)
int countTotalScores(@Param("platform") String platform);
/**
* Gets the total amount of ranked scores
* for a platform.
*
* @param platform the platform to get the scores from
* @return the total amount of ranked scores for the platform
*/
@Query(value = "SELECT COUNT(*) FROM score WHERE platform = :platform AND pp > 0", nativeQuery = true)
int countTotalRankedScores(@Param("platform") String platform);
/**
* Gets all scores for a platform.
*
* @param platform the platform to get the scores from
* @return the scores
*/
@Query(value = "SELECT COUNT(*) FROM score WHERE platform = :platform AND pp > :pp", nativeQuery = true)
int getScoreCountOverPpThreshold(@Param("platform") String platform, @Param("pp") double pp);
}

View File

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

View File

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

View File

@ -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;
/**

View File

@ -2,7 +2,9 @@ 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;
@ -17,4 +19,21 @@ public interface UserRepository extends MongoRepository<User, UUID> {
* @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);
}

View File

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

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

View File

@ -1,10 +0,0 @@
package cc.fascinated.services;
import org.springframework.stereotype.Service;
/**
* @author Fascinated (fascinated7)
*/
@Service
public class PlatformMetricsService {
}

View File

@ -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);
/**
@ -32,6 +34,7 @@ public class PlatformService {
@Autowired
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()));
@ -40,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 * * * *")
@ -55,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.");
}
/**

View File

@ -1,55 +0,0 @@
package cc.fascinated.services;
import io.questdb.client.Sender;
import lombok.Getter;
import lombok.NonNull;
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
@Getter
@Log4j2(topic = "QuestDB Service")
public class QuestDBService {
/*
* The host of the QuestDB instance.
*/
private final String host;
/*
* The username of the QuestDB instance.
*/
private final String username;
/*
* The password of the QuestDB instance.
*/
private final String password;
@Autowired
public QuestDBService(@Value("${questdb.host}") @NonNull String host,
@Value("${questdb.username}") @NonNull String username,
@Value("${questdb.password}") @NonNull String password
) {
this.host = host;
this.username = username;
this.password = password;
}
/**
* Gets a new sender instance for QuestDB.
*
* @return the sender
*/
public Sender getSender() {
return Sender.builder(Sender.Transport.HTTP)
.address(host) // set host
.httpUsernamePassword(username, password) // set username and password
.retryTimeoutMillis(3000) // 3 seconds
.build();
}
}

View File

@ -2,22 +2,24 @@ package cc.fascinated.services;
import cc.fascinated.common.Request;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardPageToken;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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)
@ -30,6 +32,12 @@ public class ScoreSaberService {
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.
*/
@ -50,15 +58,15 @@ public class ScoreSaberService {
*/
public ScoreSaberAccountToken getAccount(User user) {
if (user.getSteamId() == null) {
throw new BadRequestException("%s does not have a linked ScoreSaber account".formatted(user.getUsername()));
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.getUsername()));
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.getUsername()));
throw new BadRequestException("Failed to get ScoreSaber account for '%s'".formatted(user.getSteamId()));
}
return response.getBody();
}
@ -71,9 +79,15 @@ public class ScoreSaberService {
* @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
return leaderboardOptional.get();
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);
@ -85,6 +99,7 @@ public class ScoreSaberService {
}
ScoreSaberLeaderboardToken leaderboard = response.getBody();
leaderboardRepository.save(leaderboard);
leaderboardCache.put(leaderboardId, leaderboard);
return leaderboard;
}

View 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()
);
}
}

View 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();
}
}

View File

@ -1,117 +0,0 @@
package cc.fascinated.services;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.score.ScoresOverResponse;
import cc.fascinated.model.score.TotalScoresResponse;
import cc.fascinated.model.score.TrackedScore;
import cc.fascinated.platform.Platform;
import cc.fascinated.repository.couchdb.TrackedScoreRepository;
import lombok.NonNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
@Service
public class TrackedScoreService {
/**
* The scores over thresholds.
*/
private static final int[] SCORES_OVER = {1000, 900, 800, 700, 600, 500, 400, 300, 200, 100};
/**
* The tracked score repository to use.
*/
@NonNull
private final TrackedScoreRepository trackedScoreRepository;
@Autowired
public TrackedScoreService(@NonNull TrackedScoreRepository trackedScoreRepository) {
this.trackedScoreRepository = trackedScoreRepository;
this.trackedScoreRepository.ensureDeduplication();
}
/**
* Gets a list of top tracked scores
* sorted by pp from the platform
*
* @param platform the platform to get the scores from
* @param amount the amount of scores to get
* @return the scores
*/
public List<TrackedScore> getTopScores(Platform.Platforms platform, int amount) {
List<TrackedScore> scores = trackedScoreRepository.findTopRankedScores(platform.getPlatformName(), amount);
if (scores.isEmpty()) {
throw new BadRequestException("No scores found for platform " + platform.getPlatformName());
}
return scores;
}
/**
* Gets the amount of scores over pp thresholds.
*
* @param platform the platform to get the scores from
* @return the scores over pp thresholds
*/
public ScoresOverResponse getScoresOver(Platform.Platforms platform) {
ScoresOverResponse scoresOverResponse = new ScoresOverResponse();
for (int i : SCORES_OVER) {
scoresOverResponse.addScores(i, trackedScoreRepository.getScoreCountOverPpThreshold(platform.getPlatformName(), i));
}
return scoresOverResponse;
}
/**
* Gets the total amount of scores
* for a platform.
*
* @param platform the platform to get the scores from
* @return the total amount of scores for the platform
*/
public TotalScoresResponse getTotalScores(Platform.Platforms platform) {
return new TotalScoresResponse(
trackedScoreRepository.countTotalScores(platform.getPlatformName()),
trackedScoreRepository.countTotalRankedScores(platform.getPlatformName())
);
}
/**
* Gets a list of tracked scores
* for a platform.
*
* @param platform the platform to get the scores from
* @return the tracked scores
*/
public List<TrackedScore> getTrackedScores(Platform.Platforms platform, boolean onlyRanked) {
if (onlyRanked) {
return trackedScoreRepository.findAllByPlatformRankedOnly(platform.getPlatformName());
}
return trackedScoreRepository.findAllByPlatform(platform.getPlatformName());
}
/**
* Saves a list of tracked scores.
*
* @param scores the scores to save
*/
public void updateScores(TrackedScore... scores) {
for (TrackedScore score : scores) {
this.trackedScoreRepository.updateScorePp(score.getScoreId(), score.getPp());
}
}
/**
* Deletes a list of tracked scores.
*
* @param scores the scores to delete
*/
public void deleteScores(TrackedScore... scores) {
for (TrackedScore score : scores) {
this.trackedScoreRepository.delete(score);
}
}
}

View File

@ -1,29 +1,77 @@
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.List;
import java.util.Optional;
import java.util.UUID;
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;
@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) {
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;
}
/**
@ -34,28 +82,90 @@ public class UserService {
* @throws BadRequestException if the user is not found
*/
public User getUser(String steamId) {
if (!this.validateSteamId(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 user = new User(UUID.randomUUID());
user = new User(UUID.randomUUID());
user.setSteamId(steamId);
return userRepository.save(user);
shouldUpdate = true;
} else {
user = userOptional.get();
}
return 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 user in the database
* Creates a new auth token using a steam ticket
*
* @param user the user to create
* @return the created user
* @param ticket the ticket to get the auth token from
* @return the auth token
* @throws BadRequestException if the ticket is invalid
*/
public User createUser(User user) {
return this.userRepository.save(user);
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);
}
/**
@ -63,8 +173,11 @@ public class UserService {
*
* @return all users
*/
public List<User> getUsers() {
return (List<User>) this.userRepository.findAll();
public List<User> getUsers(boolean smallerAccount) {
if (smallerAccount) {
return this.userRepository.fetchAccountsSimple();
}
return this.userRepository.findAll();
}
/**
@ -73,7 +186,7 @@ public class UserService {
* @param steamId the steam id to validate
* @return if the steam id is valid
*/
public boolean validateSteamId(String steamId) {
public boolean isValidSteamId(String steamId) {
return steamId != null && steamId.length() == 17;
}
}

View File

@ -1,18 +1,12 @@
package cc.fascinated.websocket.impl;
import cc.fascinated.common.ScoreSaberUtils;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.token.ScoreSaberPlayerScoreToken;
import cc.fascinated.model.token.ScoreSaberScoreToken;
import cc.fascinated.model.token.ScoreSaberWebsocketDataToken;
import cc.fascinated.platform.Platform;
import cc.fascinated.platform.impl.ScoreSaberPlatform;
import cc.fascinated.services.PlatformService;
import cc.fascinated.services.QuestDBService;
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 io.questdb.client.Sender;
import lombok.NonNull;
import lombok.SneakyThrows;
import lombok.extern.log4j.Log4j2;
@ -37,26 +31,20 @@ public class ScoreSaberWebsocket extends Websocket {
private final UserService userService;
/**
* The Influx service to use
* The score service to use
*/
private final QuestDBService questDBService;
/**
* The platform service to use
*/
private final PlatformService platformService;
private final ScoreService scoreService;
@Autowired
public ScoreSaberWebsocket(@NonNull ObjectMapper objectMapper, @NonNull UserService userService, @NonNull QuestDBService questDBService,
@NonNull PlatformService platformService) {
public ScoreSaberWebsocket(@NonNull ObjectMapper objectMapper, @NonNull UserService userService, @NonNull ScoreService scoreService) {
super("ScoreSaber", "wss://scoresaber.com/ws");
this.objectMapper = objectMapper;
this.userService = userService;
this.questDBService = questDBService;
this.platformService = platformService;
this.scoreService = scoreService;
}
@Override @SneakyThrows
@Override
@SneakyThrows
public void handleMessage(@NonNull TextMessage message) {
String payload = message.getPayload();
if (payload.equals("Connected to the ScoreSaber WSS")) { // Ignore the connection message
@ -71,40 +59,12 @@ public class ScoreSaberWebsocket extends Websocket {
// Decode the message using Jackson
ScoreSaberPlayerScoreToken scoreToken = this.objectMapper.readValue(response.getCommandData().toString(), ScoreSaberPlayerScoreToken.class);
ScoreSaberScoreToken score = scoreToken.getScore();
ScoreSaberLeaderboardToken leaderboard = scoreToken.getLeaderboard();
ScoreSaberScoreToken.LeaderboardPlayerInfo player = score.getLeaderboardPlayerInfo();
if (!userService.validateSteamId(player.getId())) { // Validate the Steam ID
// Ensure the player is valid
if (!this.userService.isValidSteamId(player.getId())) {
return;
}
double accuracy = ((double) score.getBaseScore() / leaderboard.getMaxScore()) * 100;
String difficulty = ScoreSaberUtils.parseDifficulty(leaderboard.getDifficulty().getDifficulty());
double pp = platformService.getScoreSaberPlatform().getPp(leaderboard.getStars(), accuracy); // Recalculate the PP
try (Sender sender = questDBService.getSender()) {
sender.table("score")
.symbol("platform", Platform.Platforms.SCORESABER.getPlatformName())
// Player information
.symbol("player_id", player.getId())
// Score information
.symbol("leaderboard_id", leaderboard.getId())
.symbol("score_id", score.getId())
.doubleColumn("pp", pp)
.longColumn("rank", score.getRank())
.longColumn("score", score.getBaseScore())
.longColumn("modified_score", score.getModifiedScore())
.doubleColumn("weight", score.getWeight())
.stringColumn("modifiers", score.getModifiers())
.doubleColumn("multiplier", score.getMultiplier())
.longColumn("missed_notes", score.getMissedNotes())
.longColumn("bad_cuts", score.getBadCuts())
.longColumn("max_combo", score.getMaxCombo())
.doubleColumn("accuracy", accuracy)
.stringColumn("difficulty", difficulty)
.atNow();
}
log.info("Tracked score for {} with a score of {} and {}pp on {} with a rank of {}",
player.getId(), score.getBaseScore(), score.getPp(), leaderboard.getId(), score.getRank());
scoreService.trackScoreSaberScore(scoreToken);
}
}

View File

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

Binary file not shown.

View File

@ -15,7 +15,7 @@
},
"devDependencies": {
"typescript": "^5",
"@types/node": "^20",
"@types/node": "^22.0.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"postcss": "^8",

359
Mod/.gitignore vendored Normal file
View 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
View 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
View 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;
}
}
}

View 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
View 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>

101
Mod/Directory.Build.targets Normal file
View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- This file contains the build tasks and targets for verifying the manifest, zipping Release builds,
and copying the plugin to to your Beat Saber folder. Only edit this if you know what you are doing. -->
<Project>
<PropertyGroup>
<BuildTargetsVersion>2.0</BuildTargetsVersion>
<!--Set this to true if you edit this file to prevent automatic updates-->
<BuildTargetsModified>false</BuildTargetsModified>
<!--Output assembly path without extension-->
<OutputAssemblyName>$(OutputPath)$(AssemblyName)</OutputAssemblyName>
<!--Path to folder to be zipped. Needs to be relative to the project directory to work without changes to the 'BuildForCI' target.-->
<ArtifactDestination>$(OutputPath)Final</ArtifactDestination>
<ErrorOnMismatchedVersions Condition="'$(Configuration)' == 'Release'">True</ErrorOnMismatchedVersions>
</PropertyGroup>
<!--Build Targets-->
<!--Displays a warning if BeatSaberModdingTools.Tasks is not installed.-->
<Target Name="CheckBSMTInstalled" AfterTargets="BeforeBuild" Condition="'$(BSMTTaskAssembly)' == ''">
<Warning Text="The BeatSaberModdingTools.Tasks nuget package doesn't seem to be installed, advanced build targets will not work."/>
</Target>
<!--Runs a build task to get info about the project used by later targets.-->
<Target Name="GetProjectInfo" AfterTargets="CheckBSMTInstalled" DependsOnTargets="CheckBSMTInstalled" Condition="'$(BSMTTaskAssembly)' != ''">
<Message Text="Using AssemblyVersion defined in project instead of 'Properties\AssemblyInfo.cs'" Importance="high" Condition="'$(AssemblyVersion)' != ''"/>
<GetManifestInfo FailOnError="$(ErrorOnMismatchedVersions)">
<Output TaskParameter="PluginVersion" PropertyName="PluginVersion"/>
<Output TaskParameter="BasePluginVersion" PropertyName="BasePluginVersion"/>
<Output TaskParameter="GameVersion" PropertyName="GameVersion"/>
</GetManifestInfo>
<PropertyGroup>
<AssemblyVersion>$(BasePluginVersion)</AssemblyVersion>
<FileVersion>$(BasePluginVersion)</FileVersion>
<InformationalVersion>$(BasePluginVersion)</InformationalVersion>
</PropertyGroup>
<GetCommitInfo ProjectDir="$(ProjectDir)">
<Output TaskParameter="CommitHash" PropertyName="CommitHash"/>
<Output TaskParameter="Branch" PropertyName="Branch"/>
<Output TaskParameter="Modified" PropertyName="GitModified"/>
</GetCommitInfo>
<PropertyGroup>
<!--Build name for artifact/zip file-->
<ArtifactName>$(AssemblyName)</ArtifactName>
<ArtifactName Condition="'$(PluginVersion)' != ''">$(ArtifactName)-$(PluginVersion)</ArtifactName>
<ArtifactName Condition="'$(GameVersion)' != ''">$(ArtifactName)-bs$(GameVersion)</ArtifactName>
<ArtifactName Condition="'$(CommitHash)' != '' AND '$(CommitHash)' != 'local'">$(ArtifactName)-$(CommitHash)</ArtifactName>
</PropertyGroup>
</Target>
<!--Build target for Continuous Integration builds. Set up for GitHub Actions.-->
<Target Name="BuildForCI" AfterTargets="Build" DependsOnTargets="GetProjectInfo" Condition="'$(ContinuousIntegrationBuild)' == 'True' AND '$(BSMTTaskAssembly)' != ''">
<PropertyGroup>
<!--Set 'ArtifactName' if it failed before.-->
<ArtifactName Condition="'$(ArtifactName)' == ''">$(AssemblyName)</ArtifactName>
</PropertyGroup>
<Message Text="Building for CI" Importance="high"/>
<Message Text="PluginVersion: $(PluginVersion), AssemblyVersion: $(AssemblyVersion), GameVersion: $(GameVersion)" Importance="high"/>
<Message Text="::set-output name=filename::$(ArtifactName)" Importance="high"/>
<Message Text="::set-output name=assemblyname::$(AssemblyName)" Importance="high"/>
<Message Text="::set-output name=artifactpath::$(ProjectDir)$(ArtifactDestination)" Importance="high"/>
<Message Text="Copying '$(OutputAssemblyName).dll' to '$(ProjectDir)$(ArtifactDestination)\Plugins\$(AssemblyName).dll'" Importance="high"/>
<Copy SourceFiles="$(OutputAssemblyName).dll" DestinationFiles="$(ProjectDir)$(ArtifactDestination)\Plugins\$(AssemblyName).dll"/>
</Target>
<!--Creates a BeatMods compliant zip file with the release.-->
<Target Name="ZipRelease" AfterTargets="Build" Condition="'$(DisableZipRelease)' != 'True' AND '$(Configuration)' == 'Release' AND '$(BSMTTaskAssembly)' != ''">
<PropertyGroup>
<!--Set 'ArtifactName' if it failed before.-->
<ArtifactName Condition="'$(ArtifactName)' == ''">$(AssemblyName)</ArtifactName>
<DestinationDirectory>$(OutDir)zip\</DestinationDirectory>
</PropertyGroup>
<ItemGroup>
<OldZips Include="$(DestinationDirectory)$(AssemblyName)*.zip"/>
</ItemGroup>
<Copy SourceFiles="$(OutputAssemblyName).dll" DestinationFiles="$(ArtifactDestination)\Plugins\$(AssemblyName).dll"/>
<Message Text="PluginVersion: $(PluginVersion), AssemblyVersion: $(AssemblyVersion), GameVersion: $(GameVersion)" Importance="high"/>
<Delete Files="@(OldZips)" TreatErrorsAsWarnings="true" ContinueOnError="true"/>
<ZipDir SourceDirectory="$(ArtifactDestination)" DestinationFile="$(DestinationDirectory)$(ArtifactName).zip"/>
</Target>
<!--Copies the assembly and pdb to the Beat Saber folder.-->
<Target Name="CopyToPlugins" AfterTargets="Build" Condition="'$(DisableCopyToPlugins)' != 'True' AND '$(ContinuousIntegrationBuild)' != 'True'">
<PropertyGroup>
<PluginDir>$(BeatSaberDir)\Plugins</PluginDir>
<CanCopyToPlugins>True</CanCopyToPlugins>
<CopyToPluginsError Condition="!Exists('$(PluginDir)')">Unable to copy assembly to game folder, did you set 'BeatSaberDir' correctly in your 'csproj.user' file? Plugins folder doesn't exist: '$(PluginDir)'.</CopyToPluginsError>
<!--Error if 'BeatSaberDir' does not have 'Beat Saber.exe'-->
<CopyToPluginsError Condition="!Exists('$(BeatSaberDir)\Beat Saber.exe')">Unable to copy to Plugins folder, '$(BeatSaberDir)' does not appear to be a Beat Saber game install.</CopyToPluginsError>
<!--Error if 'BeatSaberDir' is the same as 'LocalRefsDir'-->
<CopyToPluginsError Condition="'$(BeatSaberDir)' == '$(LocalRefsDir)' OR '$(BeatSaberDir)' == ''">Unable to copy to Plugins folder, 'BeatSaberDir' has not been set in your 'csproj.user' file.</CopyToPluginsError>
<CanCopyToPlugins Condition="'$(CopyToPluginsError)' != ''">False</CanCopyToPlugins>
</PropertyGroup>
<!--Check if Beat Saber is running-->
<IsProcessRunning ProcessName="Beat Saber" Condition="'$(BSMTTaskAssembly)' != ''">
<Output TaskParameter="IsRunning" PropertyName="IsRunning"/>
</IsProcessRunning>
<PropertyGroup>
<!--If Beat Saber is running, output to the Pending folder-->
<PluginDir Condition="'$(IsRunning)' == 'True'">$(BeatSaberDir)\IPA\Pending\Plugins</PluginDir>
</PropertyGroup>
<Warning Text="$(CopyToPluginsError)" Condition="'$(CopyToPluginsError)' != ''"/>
<Message Text="Copying '$(OutputAssemblyName).dll' to '$(PluginDir)'." Importance="high" Condition="$(CanCopyToPlugins)"/>
<Copy SourceFiles="$(OutputAssemblyName).dll" DestinationFiles="$(PluginDir)\$(AssemblyName).dll" Condition="$(CanCopyToPlugins)"/>
<Copy SourceFiles="$(OutputAssemblyName).pdb" DestinationFiles="$(PluginDir)\$(AssemblyName).pdb" Condition="'$(CanCopyToPlugins)' == 'True' AND Exists('$(OutputAssemblyName).pdb')"/>
<Warning Text="Beat Saber is running, restart the game to use the latest build." Condition="'$(IsRunning)' == 'True'"/>
</Target>
</Project>

View File

@ -0,0 +1,13 @@
using Zenject;
namespace ScoreTracker.Installers
{
internal class AppInstaller : Installer
{
public override void InstallBindings()
{
}
}
}

30
Mod/Mod.sln Normal file
View File

@ -0,0 +1,30 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.8.34330.188
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ScoreTracker", "ScoreTracker.csproj", "{E8654F87-DB97-4565-8B0C-7C9D55CD6805}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{1A6E1103-4D66-40E2-BE41-5DEDAF2E95FD}"
ProjectSection(SolutionItems) = preProject
.gitignore = .gitignore
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{E8654F87-DB97-4565-8B0C-7C9D55CD6805}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E8654F87-DB97-4565-8B0C-7C9D55CD6805}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E8654F87-DB97-4565-8B0C-7C9D55CD6805}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8654F87-DB97-4565-8B0C-7C9D55CD6805}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {2D2B1200-F5BC-48DA-97CB-ECDC616F0BB4}
EndGlobalSection
EndGlobal

54
Mod/Plugin.cs Normal file
View File

@ -0,0 +1,54 @@
using IPA;
using IPALogger = IPA.Logging.Logger;
using SiraUtil.Zenject;
using System.Threading.Tasks;
using ScoreTracker.API;
using IPA.Config.Stores;
using IPA.Config;
using ScoreTracker.Configuration;
using ScoreTracker.Installers;
namespace ScoreTracker
{
[Plugin(RuntimeOptions.DynamicInit)]
[NoEnableDisable]
public class Plugin
{
internal static Plugin Instance { get; private set; }
internal static IPALogger Log { get; private set; }
[Init]
public Plugin(IPALogger logger, Zenjector zenjector, Config config)
{
Instance = this;
Log = logger; // Setup the logger
// Setup Zenject
zenjector.UseLogger(logger);
zenjector.UseMetadataBinder<Plugin>();
zenjector.Install<AppInstaller>(Location.App);
// Setup the config
PluginConfig.Instance = config.Generated<PluginConfig>();
}
[OnStart]
public void OnApplicationStart()
{
Log.Info("OnApplicationStart");
// Ensure the user is logged in
Task.Factory.StartNew(async () =>
{
await Authentication.ValidateAndSignIn(); // Ensure the user is signed in
});
}
[OnExit]
public void OnApplicationQuit()
{
Log.Info("OnApplicationQuit");
}
}
}

111
Mod/ScoreTracker.csproj Normal file
View File

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ProjectGuid>{E8654F87-DB97-4565-8B0C-7C9D55CD6805}</ProjectGuid>
<TargetFramework>net472</TargetFramework>
<OutputType>Library</OutputType>
<LangVersion>7.3</LangVersion>
<LocalRefsDir Condition="Exists('..\Refs')">..\Refs</LocalRefsDir>
<BeatSaberDir>$(LocalRefsDir)</BeatSaberDir>
<AppOutputBase>$(MSBuildProjectDirectory)\</AppOutputBase>
<AssemblyName>ScoreTracker</AssemblyName>
<RootNamespace>ScoreTracker</RootNamespace>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<BSMTProjectType>BSIPA</BSMTProjectType>
<SignAssembly>false</SignAssembly>
<ErrorReport>prompt</ErrorReport>
<Configurations>Release</Configurations>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>none</DebugType>
</PropertyGroup>
<ItemGroup>
<Reference Include="BS_Utils, Version=1.12.0.0, Culture=neutral, processorArchitecture=MSIL">
<Private>False</Private>
<HintPath>$(BeatSaberDir)\Plugins\BS_Utils.dll</HintPath>
<SpecificVersion>False</SpecificVersion>
</Reference>
<Reference Include="Newtonsoft.Json, Version=12.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<Private>False</Private>
<HintPath>$(BeatSaberDir)\Libs\Newtonsoft.Json.dll</HintPath>
<SpecificVersion>False</SpecificVersion>
</Reference>
<Reference Include="SiraUtil">
<HintPath>$(BeatSaberDir)\Plugins\SiraUtil.dll</HintPath>
<Private>False</Private>
<SpecificVersion>False</SpecificVersion>
</Reference>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="System.Data" />
<Reference Include="System.Xml" />
<Reference Include="Main">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Main.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="HMLib">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\HMLib.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="HMUI">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\HMUI.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="IPA.Loader">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\IPA.Loader.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Unity.TextMeshPro">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Unity.TextMeshPro.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.UI">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UI.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.UIElementsModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UIElementsModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.UIModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UIModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="UnityEngine.UnityWebRequestModule, Version=0.0.0.0, Culture=neutral, processorArchitecture=MSIL">
<Private>False</Private>
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.UnityWebRequestModule.dll</HintPath>
<SpecificVersion>False</SpecificVersion>
</Reference>
<Reference Include="UnityEngine.VRModule">
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\UnityEngine.VRModule.dll</HintPath>
<Private>False</Private>
</Reference>
<Reference Include="Zenject, Version=0.0.0.0, Culture=neutral, processorArchitecture=MSIL">
<Private>False</Private>
<HintPath>$(BeatSaberDir)\Beat Saber_Data\Managed\Zenject.dll</HintPath>
<SpecificVersion>False</SpecificVersion>
</Reference>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="manifest.json" />
</ItemGroup>
<ItemGroup>
<None Include="ScoreTracker.csproj.user" Condition="Exists('ScoreTracker.csproj.user')" />
<None Include="Directory.Build.props" Condition="Exists('Directory.Build.props')" />
<None Include="Directory.Build.targets" Condition="Exists('Directory.Build.targets')" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BeatSaberModdingTools.Tasks" Version="2.0.0-beta7" PrivateAssets="all" />
<PackageReference Include="BepInEx.AssemblyPublicizer.MSBuild" Version="0.4.2" PrivateAssets="all" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeInspection/CSharpLanguageProject/LanguageLevel/@EntryValue">CSharp80</s:String></wpf:ResourceDictionary>

15
Mod/manifest.json Normal file
View File

@ -0,0 +1,15 @@
{
"$schema": "https://raw.githubusercontent.com/nike4613/ModSaber-MetadataFileSchema/master/Schema.json",
"id": "ScoreTracker",
"name": "ScoreTracker",
"author": "fascinated7",
"version": "0.0.1",
"description": "Uploads your scores to ScoreTracker to show or use the data elsewhere",
"gameVersion": "1.21.0",
"dependsOn": {
"BSIPA": "^4.2.2",
"BS Utils": "^1.12.0",
"SiraUtil": "^3.1.0"
},
"loadAfter": [ "BS Utils", "SiraUtil" ]
}