93 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 2024-08-15 18:22:16 +01:00
d888ab1eb5 Merge remote-tracking branch 'origin/master' 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' () from renovate/actions-checkout-4.x into master
Reviewed-on: 
2024-08-07 07:44:10 +00:00
Lee
02290e5bde Merge pull request 'Update actions/setup-dotnet action to v4' () from renovate/actions-setup-dotnet-4.x into master
Reviewed-on: 
2024-08-07 07:44:00 +00:00
Lee
ef2aeb5f0a Merge pull request 'Update dependency BepInEx.AssemblyPublicizer.MSBuild to v0.4.2' () from renovate/bepinex.assemblypublicizer.msbuild-0.x into master
Reviewed-on: 
2024-08-07 07:43:47 +00:00
8e3a46f8bc mod: impl config for api url 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 2024-08-07 07:50:44 +01:00
c2e0aafda6 this will do for now im too tired 2024-08-07 07:46:33 +01:00
33b1c0357b yes 2024-08-07 07:37:51 +01:00
0ae7cdb42c yes 2024-08-07 07:36:24 +01:00
55f1b2fc8d yes 2024-08-07 07:34:46 +01:00
3b005510e4 yes 2024-08-07 07:33:43 +01:00
88eea39a2e yes 2024-08-07 07:31:23 +01:00
728d987b64 yes 2024-08-07 07:26:33 +01:00
c2044c5f80 yes 2024-08-07 07:20:45 +01:00
41e248b751 yes 2024-08-07 07:19:17 +01:00
13840aa1e2 yes 2024-08-07 07:15:36 +01:00
02e8946b9b yes 2024-08-07 07:12:04 +01:00
fcf3785477 yes 2024-08-07 07:11:13 +01:00
135c34f763 yes 2024-08-07 07:07:22 +01:00
21c6abb443 yes 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 2024-08-07 07:00:57 +01:00
5b7de4150c yes 2024-08-07 06:57:27 +01:00
b3ae9ca369 yes 2024-08-07 06:54:45 +01:00
eba857829b yes 2024-08-07 06:22:18 +01:00
7253bf2eea yes 2024-08-07 06:19:56 +01:00
067666ef7c yes 2024-08-07 06:18:27 +01:00
06927f3fd3 yes 2024-08-07 06:16:32 +01:00
cb1ee3abad yes 2024-08-07 06:07:37 +01:00
fa60278463 yes 2024-08-07 06:05:32 +01:00
e397c86963 yes 2024-08-07 06:03:46 +01:00
1bcb99430c yes 2024-08-07 05:53:01 +01:00
07c6bc3d0a yes 2024-08-07 05:49:11 +01:00
c62948627c yes 2024-08-07 05:47:47 +01:00
0287f4f83a yes 2024-08-07 05:45:21 +01:00
7307ccc99b yes 2024-08-07 05:39:34 +01:00
03bfcab616 ci 2024-08-07 05:36:40 +01:00
53f407081e ci 2024-08-07 05:36:10 +01:00
eab307882e ci 2024-08-07 05:34:16 +01:00
aa23919037 ci 2024-08-07 05:33:24 +01:00
e27258a351 ci 2024-08-07 05:32:40 +01:00
577c8e7deb ci 2024-08-07 05:31:11 +01:00
2f24c529e0 oopsie 2024-08-07 05:30:00 +01:00
517e9df72b ci 2024-08-07 05:28:21 +01:00
f38a1156e1 ci 2024-08-07 05:27:54 +01:00
c04a51de35 why 2024-08-07 05:19:39 +01:00
1ec8248c6f IMPL BASIC AUTH INTO MOD AND BACKEND 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 2024-08-06 21:12:59 +01:00
131a5c2efe api: optimize pagination!! 2024-08-05 08:53:07 +01:00
1f1c55d41f api: make items cache for longer 2024-08-05 08:37:49 +01:00
21b6de0f15 api: impl pagination for top scores endpoint and add user + leaderboard caching 2024-08-05 08:35:16 +01:00
64b6ef1a7f api: oopsie doodle 2024-08-05 05:59:41 +01:00
54bdf532fe api: add fallback values for hmd identification 2024-08-05 05:57:50 +01:00
6cb86f843d cleanup score history 2024-08-05 05:48:01 +01:00
f75d22fa58 api: update method comment 2024-08-05 05:01:18 +01:00
68ce2ff240 api: add possiblyInaccurateData field to show if the history for that day could be inaccurate 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 2024-08-05 04:32:06 +01:00
84eb8a4b94 api: don't fetch all data when tracking player metrics 2024-08-05 04:25:34 +01:00
6f49d81664 api: handle player not setting scores for the day 2024-08-05 04:18:48 +01:00
98223a3293 api: change how some data points are got for histories 2024-08-05 04:15:01 +01:00
7b0c9f54ff api: re-impl histories (muchhhhhhhhhh better now) 2024-08-05 03:57:59 +01:00
29f5d5983a rename statistics to histories 2024-08-04 04:25:51 +01:00
8c354b1be1 api: make log less fat 2024-08-04 01:34:19 +01:00
de309ea05c api: add /improved/best/{platform} endpoint 2024-08-04 00:36:59 +01:00
199ee50534 api: add score history endpoints (per leaderboard and last 30 day improvements (all maps)) 2024-08-04 00:32:39 +01:00
6fda81e81a oopsie 2024-08-03 20:24:37 +01:00
bdad804eed api: oopsie doodle 2024-08-02 19:28:30 +01:00
66d29c343e api: fix mongo indexes 2024-08-02 18:02:43 +01:00
96f62d9a01 api: change how scores are stored 2024-08-02 17:36:24 +01:00
4697cd4aec api: return the last updated 2024-08-02 02:51:58 +01:00
0abff880c2 API: make the pp slightly more accurate (revert) 2024-08-02 02:50:12 +01:00
cc351e6cad API: make the pp slightly more accurate 2024-08-02 02:25:33 +01:00
f351c7a3c1 API: don't return the whole user 2024-08-02 01:54:50 +01:00
68180f2647 api: oops 2024-08-02 00:38:27 +01:00
6a1a2dc2c4 API: fix stats 2024-08-02 00:38:08 +01:00
d4e51d1517 api: rename has logged in field 2024-08-02 00:07:13 +01:00
02fcaf19eb api: don't return the user early 2024-08-01 23:50:37 +01:00
0f307eb18c api: oopsie again 2024-08-01 23:48:45 +01:00
357315990e API: oopsie 2024-08-01 23:46:09 +01:00
de89182c5d impl user history tracking 2024-08-01 23:44:20 +01:00
8dfdc8c535 API: switch to Mongo for score tracking 2024-08-01 23:24:34 +01:00
f68fb48726 API: show the whole user 2024-08-01 16:44:32 +01:00
7b1d4a73a5 API: move around 2024-08-01 16:35:10 +01:00
139b3bf06d API: hide duplicated difficulty 2024-08-01 16:32:09 +01:00
20576d913f API: don't return the lastUpdate field 2024-08-01 16:30:12 +01:00
871ae76a23 fix leaderboard object order 2024-08-01 16:26:57 +01:00
7aa3de3827 move where the accounts get updated 2024-08-01 16:25:21 +01:00
78 changed files with 3178 additions and 879 deletions
.gitea/workflows
API
Dockerfilepom.xml
src/main
Mod

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

@ -1,5 +1,5 @@
# Stage 1: Build the application
FROM maven:3.9.8-eclipse-temurin-17-alpine AS builder
FROM maven:3.9.9-eclipse-temurin-17-alpine AS builder
# Set the working directory
WORKDIR /home/container

@ -62,10 +62,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
@ -74,6 +70,20 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
<!-- Dependencies -->
<dependency>
@ -85,13 +95,9 @@
<artifactId>unirest-modules-jackson</artifactId>
</dependency>
<dependency>
<groupId>org.questdb</groupId>
<artifactId>questdb</artifactId>
<version>8.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 -->

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

@ -3,16 +3,36 @@ 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);
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_INSTANT;
/**
* 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.
@ -21,6 +41,50 @@ public class DateUtils {
* @return The date.
*/
public static Date getDateFromString(String date) {
return Date.from(Instant.from(FORMATTER.parse(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));
}
}

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

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

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

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

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

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

@ -0,0 +1,73 @@
package cc.fascinated.config;
import lombok.NonNull;
import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
/**
* @author Braydon
*/
@Configuration
@Log4j2(topic = "Redis")
public class RedisConfig {
/**
* The Redis server host.
*/
@Value("${spring.data.redis.host}")
private String host;
/**
* The Redis server port.
*/
@Value("${spring.data.redis.port}")
private int port;
/**
* The Redis database index.
*/
@Value("${spring.data.redis.database}")
private int database;
/**
* The optional Redis password.
*/
@Value("${spring.data.redis.auth}")
private String auth;
/**
* Build the config to use for Redis.
*
* @return the config
* @see RedisTemplate for config
*/
@Bean @NonNull
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(jedisConnectionFactory());
return template;
}
/**
* Build the connection factory to use
* when making connections to Redis.
*
* @return the built factory
* @see JedisConnectionFactory for factory
*/
@Bean @NonNull
public JedisConnectionFactory jedisConnectionFactory() {
log.info("Connecting to Redis at {}:{}/{}", host, port, database);
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(host, port);
config.setDatabase(database);
if (!auth.trim().isEmpty()) { // Auth with our provided password
log.info("Using auth...");
config.setPassword(auth);
}
return new JedisConnectionFactory(config);
}
}

@ -0,0 +1,63 @@
package cc.fascinated.controller;
import cc.fascinated.model.auth.LoginRequest;
import cc.fascinated.model.auth.AuthToken;
import cc.fascinated.services.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* @author Fascinated (fascinated7)
*/
@RestController
@RequestMapping(value = "/auth", produces = MediaType.APPLICATION_JSON_VALUE)
public class AuthenticationController {
/**
* The user service to use
*/
private final UserService userService;
@Autowired
public AuthenticationController(UserService userService) {
this.userService = userService;
}
/**
* A POST request to get an auth token from a steam ticket.
*/
@ResponseBody
@PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<?> getAuthToken(@RequestBody LoginRequest request) {
if (request == null || request.getTicket() == null) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid request or missing ticket"
));
}
AuthToken authToken = this.userService.getAuthToken(request.getTicket());
return ResponseEntity.ok()
.header("Authorization", authToken.getAuthToken())
.build();
}
/**
* A POST request to validate an auth token.
*/
@ResponseBody
@PostMapping(value = "/validate")
public ResponseEntity<?> validateAuthToken(@RequestHeader("Authorization") String authToken) {
String token = authToken == null ? null : authToken.replace("Bearer ", "");
if (token == null) {
return ResponseEntity.badRequest().body(Map.of(
"error", "Invalid request or missing token"
));
}
return new ResponseEntity<>(this.userService.isValidAuthToken(token) ? HttpStatus.OK : HttpStatus.UNAUTHORIZED);
}
}

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

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

@ -17,7 +17,8 @@ public class UserController {
/**
* The user service to use
*/
@NonNull private final UserService userService;
@NonNull
private final UserService userService;
@Autowired
public UserController(@NonNull UserService userService) {
@ -36,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));
}
}

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

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

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

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

@ -1,7 +1,7 @@
package cc.fascinated.model.leaderboard;
import cc.fascinated.common.ScoreSaberUtils;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
import lombok.AllArgsConstructor;
import lombok.Getter;
@ -41,19 +41,19 @@ public class Leaderboard {
*/
private String levelAuthorName;
/**
* The difficulty of the song.
*/
private Difficulty difficulty;
/**
* The star rating for this leaderboard.
*/
private double stars;
/**
* The cover image for this leaderboard.
* The image of the song for this leaderboard.
*/
private String coverImage;
private String image;
/**
* The difficulty of the song.
*/
private Difficulty difficulty;
/**
* Constructs a new {@link Leaderboard} object
@ -70,12 +70,12 @@ public class Leaderboard {
token.getSongSubName(),
token.getSongAuthorName(),
token.getLevelAuthorName(),
token.getStars(),
token.getCoverImage(),
new Difficulty(
ScoreSaberUtils.parseDifficulty(token.getDifficulty().getDifficulty()),
token.getDifficulty().getDifficultyRaw()
),
token.getStars(),
token.getCoverImage()
)
);
}
}

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

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

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

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

@ -1,95 +0,0 @@
package cc.fascinated.model.score;
import cc.fascinated.model.leaderboard.Leaderboard;
import cc.fascinated.model.user.ScoreSaberAccount;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public class ScoreResponse {
/**
* The ID of the score.
*/
private String scoreId;
/**
* The ID of the player who set the score.
*/
private ScoreSaberAccount player;
/**
* The ID of the leaderboard.
*/
private Leaderboard leaderboard;
/**
* 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;
}

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

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

@ -0,0 +1,60 @@
package cc.fascinated.model.score.impl.scoresaber;
import cc.fascinated.model.score.DeviceInformation;
import cc.fascinated.model.score.Score;
import cc.fascinated.platform.Platform;
import lombok.Getter;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@Getter
public class ScoreSaberScore extends Score {
/**
* The weight of the score.
*/
private final Double weight;
/**
* The multiplier of the score.
*/
private final double multiplier;
/**
* The maximum combo achieved in the score.
*/
private final int maxCombo;
public ScoreSaberScore(long id, String playerId, Platform.Platforms platform, String platformScoreId, String leaderboardId, int rank,
double accuracy, Double pp, int score, String[] modifiers, Integer misses, Integer badCuts, DeviceInformation deviceInformation,
Date timestamp, Double weight, double multiplier, int maxCombo) {
super(id, playerId, platform, platformScoreId, leaderboardId, rank, accuracy, pp, score, modifiers, misses,
badCuts, deviceInformation, timestamp);
this.weight = weight;
this.multiplier = multiplier;
this.maxCombo = maxCombo;
}
/**
* Gets the modified score.
*
* @return the modified score
*/
public int getModifiedScore() {
if (multiplier == 1) {
return getScore();
}
return (int) (getScore() * multiplier);
}
/**
* Gets the weight of the score.
*
* @return the weight of the score
*/
public Double getWeight() {
return weight == null ? 0 : weight;
}
}

@ -0,0 +1,48 @@
package cc.fascinated.model.score.impl.scoresaber;
import cc.fascinated.model.leaderboard.Leaderboard;
import cc.fascinated.model.score.DeviceInformation;
import cc.fascinated.model.user.UserDTO;
import cc.fascinated.platform.Platform;
import lombok.Getter;
import java.util.Date;
/**
* @author Fascinated (fascinated7)
*/
@Getter
public class ScoreSaberScoreResponse extends ScoreSaberScore {
/**
* The user that set the score.
*/
private final UserDTO user;
/**
* The leaderboard the score was set on.
*/
private final Leaderboard leaderboard;
public ScoreSaberScoreResponse(long id, String playerId, Platform.Platforms platform, String platformScoreId, String leaderboardId, int rank,
double accuracy, double pp, int score, String[] modifiers, int misses, int badCuts, DeviceInformation deviceInformation,
Date timestamp, double weight, double multiplier, int maxCombo, UserDTO user, Leaderboard leaderboard) {
super(id, playerId, platform, platformScoreId, leaderboardId, rank, accuracy, pp, score, modifiers, misses, badCuts,
deviceInformation, timestamp, weight, multiplier, maxCombo);
this.user = user;
this.leaderboard = leaderboard;
}
/**
* Creates a new score saber score response.
*
* @param score the score to create the response from
* @param user the user that set the score
* @param leaderboard the leaderboard the score was set on
* @return the score saber score response
*/
public static ScoreSaberScoreResponse fromScore(ScoreSaberScore score, UserDTO user, Leaderboard leaderboard) {
return new ScoreSaberScoreResponse(score.getId(), score.getPlayerId(), score.getPlatform(), score.getPlatformScoreId(), score.getLeaderboardId(),
score.getRank(), score.getAccuracy(), score.getPp(), score.getScore(), score.getModifiers(), score.getMisses(), score.getBadCuts(),
score.getDeviceInformation(), score.getTimestamp(), score.getWeight(), score.getMultiplier(), score.getMaxCombo(), user, leaderboard);
}
}

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

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

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

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

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

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

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

@ -1,4 +1,4 @@
package cc.fascinated.model.token;
package cc.fascinated.model.token.scoresaber;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.AllArgsConstructor;

@ -0,0 +1,55 @@
package cc.fascinated.model.token.steam;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@Getter
public class SteamAuthenticateUserTicketToken {
/**
* The response from the Steam API.
*/
private Response response;
@Getter
public static class Response {
/**
* The params of the response.
*/
private Params params;
@Getter
public static class Params {
/**
* The result of the request.
*/
private String result;
/**
* The steam id of the user.
*/
@JsonProperty("steamid")
private String steamId;
/**
* The owner steam id of the user.
*/
@JsonProperty("ownersteamid")
private String ownerSteamId;
/**
* The vac banned status of the user.
*/
@JsonProperty("vacbanned")
private boolean vacBanned;
/**
* The publisher banned status of the user.
*/
@JsonProperty("publisherbanned")
private boolean publisherBanned;
}
}
}

@ -1,7 +1,7 @@
package cc.fascinated.model.user;
import cc.fascinated.common.DateUtils;
import cc.fascinated.model.token.ScoreSaberAccountToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberAccountToken;
import lombok.AllArgsConstructor;
import lombok.Getter;
@ -56,7 +56,7 @@ public class ScoreSaberAccount {
token.getCountry(),
token.getRank(),
token.getCountryRank(),
DateUtils.getDateFromString(token.getFirstSeen()),
DateUtils.getDateFromIsoString(token.getFirstSeen()),
new Date()
);
}

@ -1,9 +1,16 @@
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;
@ -16,6 +23,7 @@ import java.util.UUID;
@RequiredArgsConstructor
@Getter
@Setter
@Log4j2
@ToString
@Document("user")
public class User {
@ -23,6 +31,7 @@ public class User {
* The ID of the user.
*/
@Id
@JsonIgnore
private final UUID id;
/**
@ -37,7 +46,8 @@ public class User {
/**
* The ID of the users steam profile.
*/
@Indexed(unique = true)
@Indexed
@JsonProperty("id")
private String steamId;
/**
@ -47,19 +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;
/**
* The user's ScoreSaber account token.
* The user's ScoreSaber account.
*/
public ScoreSaberAccount scoresaberAccount;
/**
* Converts the User object to a UserDTO object.
*
* @return The UserDTO object.
* 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);
}
}

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

@ -0,0 +1,78 @@
package cc.fascinated.model.user.history;
import cc.fascinated.common.DateUtils;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.util.*;
/**
* @author Fascinated (fascinated7)
*/
public class History {
/**
* The user's history points in time.
*/
private Map<String, HistoryPoint> histories;
/**
* The user's history points history.
*/
@JsonIgnore
public Map<Date, HistoryPoint> getHistories() {
if (this.histories == null) {
this.histories = new HashMap<>();
}
Map<Date, HistoryPoint> toReturn = new HashMap<>();
this.histories.forEach((key, value) -> toReturn.put(DateUtils.getDateFromString(key), value));
return toReturn;
}
/**
* Gets the user's history for today.
*
* @return the user's history for today
*/
@JsonIgnore
public HistoryPoint getTodayHistory() {
if (this.histories == null) {
this.histories = new HashMap<>();
}
Date midnight = DateUtils.getMidnightToday();
return this.histories.computeIfAbsent(DateUtils.formatDate(midnight), key -> new HistoryPoint());
}
/**
* Gets the user's history for a specific date.
*
* @param date the date to get the history for
* @return the user's history for the date
*/
public HistoryPoint getHistoryForDate(Date date) {
if (this.histories == null) {
this.histories = new HashMap<>();
}
return this.histories.get(DateUtils.formatDate(date));
}
/**
* Gets the user's HistoryPoint history for
* an amount of days ago.
*
* @param days the amount of days ago
* @return the user's HistoryPoint history
*/
public TreeMap<String, HistoryPoint> getPreviousHistories(int days) {
Date date = DateUtils.getDaysAgo(days);
Map<String, HistoryPoint> toReturn = new HashMap<>();
for (Map.Entry<Date, HistoryPoint> history : getHistories().entrySet()) {
if (history.getKey().after(date)) {
toReturn.put(DateUtils.formatDate(history.getKey()), history.getValue());
}
}
// Sort the history by date (newest > oldest)
TreeMap<String, HistoryPoint> sorted = new TreeMap<>(Comparator.comparing(DateUtils::getDateFromString).reversed());
sorted.putAll(toReturn);
return sorted;
}
}

@ -0,0 +1,90 @@
package cc.fascinated.model.user.history;
import cc.fascinated.platform.Platform;
import lombok.Getter;
import lombok.Setter;
/**
* @author Fascinated (fascinated7)
*/
@Getter
@Setter
public class HistoryPoint {
// These are data points that are provided by ScoreSaber, and may not always be here
/**
* The rank of the player.
*/
private Integer rank;
/**
* The pp of the player.
*/
private Integer countryRank;
/**
* The pp of the player.
*/
private Double pp;
// Below are data points that are provided by us, therefore they will always be here
/**
* Play count of all the player's scores.
*/
private Integer totalPlayCount;
/**
* Play count of all the player's ranked scores.
*/
private Integer totalRankedPlayCount;
/**
* Play count of all the player's unranked scores.
*/
private Integer totalUnrankedPlayCount;
/**
* Play count for this day's unranked scores.
*/
private Integer unrankedPlayCount = 0;
/**
* Play count for this day's ranked scores.
*/
private Integer rankedPlayCount = 0;
/**
* Whether the data for this day is possibly inaccurate.
*/
private Boolean possiblyInaccurateData;
/**
* Gets whether some data is possibly inaccurate.
* <p>
* eg: if the user doesn't have their data tracked by
* {@link Platform#trackPlayerMetrics()} then some
* data will be inaccurate or missing.
* This will only affect {@link #rank}, {@link #countryRank}, and {@link #pp}.
* </p>
*
* @return true if the data is possibly inaccurate, false otherwise
*/
public Boolean getPossiblyInaccurateData() {
return possiblyInaccurateData == null || possiblyInaccurateData;
}
/**
* Increment the total ranked play count for this day.
*/
public void incrementRankedPlayCount() {
rankedPlayCount++;
}
/**
* Increment the total unranked play count for this day.
*/
public void incrementUnrankedPlayCount() {
unrankedPlayCount++;
}
}

@ -0,0 +1,50 @@
package cc.fascinated.model.user.hmd;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public enum DeviceController {
UNKNOWN("Unknown"),
/**
* Oculus Controllers
*/
OCULUS_QUEST_TOUCH("Touch"),
OCULUS_QUEST_2_TOUCH("Quest 2 Touch"),
OCULUS_QUEST_3_TOUCH("Quest 3 Touch"),
/**
* HP Controllers
*/
HP_REVERB("HP Reverb"),
/**
* Valve Controllers
*/
VALVE_KNUCKLES("Knuckles");
/**
* The controller name
*/
private final String name;
/**
* Gets a controller by its name.
*
* @param name the name of the controller
* @return the controller
*/
public static DeviceController getByName(String name) {
for (DeviceController deviceController : values()) {
if (deviceController.getName().equalsIgnoreCase(name)) {
return deviceController;
}
}
return null;
}
}

@ -0,0 +1,84 @@
package cc.fascinated.model.user.hmd;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author Fascinated (fascinated7)
*/
@AllArgsConstructor
@Getter
public enum DeviceHeadset {
UNKNOWN("Unknown", 0),
/**
* Oculus HMDs
*/
OCULUS_CV1("Rift", 1),
OCULUS_QUEST("Quest", 32),
OCULUS_QUEST_2("Quest 2", -1),
OCULUS_QUEST_3("Quest 3", -1),
OCULUS_RIFT_S("Rift S", 16),
/**
* Windows Mixed Reality HMDs
* todo: find the new format name
*/
WINDOWS_MR("Windows Mixed Reality", 8),
/**
* HTC HMDs
*/
HTC_VIVE("Vive", 2),
HTC_VIVE_COSMOS("Vive Cosmos", 128),
/**
* HP HMDs
*/
HP_REVERB("HP Reverb", -1),
/**
* Valve HMDs
*/
VALVE_INDEX("Valve Index", 64);
/**
* The name of the headset.
*/
private final String name;
/**
* The fallback value of the headset.
*/
private final int fallbackValue;
/**
* Gets a headset by its name.
*
* @param name the name of the headset
* @return the headset
*/
public static DeviceHeadset getByName(String name) {
for (DeviceHeadset deviceHeadset : values()) {
if (deviceHeadset.getName().equalsIgnoreCase(name)) {
return deviceHeadset;
}
}
return null;
}
/**
* Gets a headset by its fallback value.
*
* @param fallbackValue the fallback value of the headset
* @return the headset
*/
public static DeviceHeadset getByFallbackValue(int fallbackValue) {
for (DeviceHeadset deviceHeadset : values()) {
if (deviceHeadset.getFallbackValue() == fallbackValue) {
return deviceHeadset;
}
}
return null;
}
}

@ -72,7 +72,7 @@ public abstract class Platform {
* Called to update the players
* data in QuestDB.
*/
public abstract void updatePlayers();
public abstract void trackPlayerMetrics();
/**
* Called to update the metrics

@ -1,24 +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.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -27,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
@ -52,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),
@ -109,8 +99,7 @@ public class ScoreSaberPlatform extends Platform {
));
this.scoreSaberService = scoreSaberService;
this.userService = userService;
this.questDBService = questDBService;
this.trackedScoreService = trackedScoreService;
this.scoreService = scoreService;
}
/**
@ -134,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())
);
}
}
@ -151,46 +143,44 @@ public class ScoreSaberPlatform extends Platform {
}
@Override
public void updatePlayers() {
for (User user : this.userService.getUsers(false)) {
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()) {
@ -199,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;
}
@ -218,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);
}
@ -246,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++;

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

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

@ -0,0 +1,9 @@
package cc.fascinated.repository.mongo;
import cc.fascinated.model.Counter;
import org.springframework.data.mongodb.repository.MongoRepository;
/**
* @author Fascinated (fascinated7)
*/
public interface CounterRepository extends MongoRepository<Counter, String> {}

@ -0,0 +1,161 @@
package cc.fascinated.repository.mongo;
import cc.fascinated.model.score.Score;
import cc.fascinated.platform.Platform;
import lombok.NonNull;
import org.springframework.data.mongodb.repository.Aggregation;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.data.repository.query.Param;
import java.util.Date;
import java.util.List;
/**
* @author Fascinated (fascinated7)
*/
public interface ScoreRepository extends MongoRepository<Score, Long> {
/**
* Gets the top ranked scores from the platform.
*
* @param platform the platform to get the scores from
* @param amount the amount of scores to get
* @return the scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, pp: { $gt: 0 } } }",
"{ $sort: { pp: -1 } }",
"{ $skip: ?2 }",
"{ $limit: ?1 }",
})
List<Score> getTopRankedScores(@NonNull Platform.Platforms platform, int amount, int skip);
/**
* Gets all the ranked scores from the platform.
*
* @param platform the platform to get the scores from
* @return the scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, pp: { $gt: 0 } } }",
"{ $sort: { pp: -1 } }"
})
List<Score> getRankedScores(@NonNull Platform.Platforms platform);
/**
* Gets the improved scores from the platform.
*
* @param platform the platform to get the scores from
* @param playerId the player id to get the scores from
* @param after the date to get the scores after
* @return the scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, playerId: ?1, timestamp: { $gt: ?2 } } }",
"{ $match: { 'previousScores.0': { $exists: true } } }",
"{ $sort: { pp: -1 } }"
})
List<Score> getUserImprovedScores(@NonNull Platform.Platforms platform, @NonNull String playerId, @NonNull Date after);
/**
* Gets the best improved scores from the platform.
*
* @param platform the platform to get the scores from
* @param after the date to get the scores after
* @return the scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, timestamp: { $gt: ?1 } } }",
"{ $match: { 'previousScores.0': { $exists: true } } }",
"{ $sort: { pp: -1 } }"
})
List<Score> getBestImprovedScores(@NonNull Platform.Platforms platform, @NonNull Date after);
/**
* Gets a score from a platform and leaderboard id.
*
* @param platform the platform to get the score from
* @param playerId the player id to get the score from
* @param leaderboardId the leaderboard id to get the score from
* @return the score
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, playerId: ?1, leaderboardId: ?2 } }",
"{ $sort: { timestamp: -1 } }",
})
List<Score> findScores(@NonNull Platform.Platforms platform, @NonNull String playerId, @NonNull String leaderboardId);
/**
* Updates a scores pp value.
*
* @param id The id of the score to update
* @param pp The new pp value
*/
@Aggregation(pipeline = {
"{ $match: { _id: ?0 } }",
"{ $set: { pp: ?1 } }"
})
void updateScorePP(@Param("id") long id, @Param("pp") double pp);
/**
* Gets the total scores for the platform.
*
* @param platform the platform to get the scores from
* @return the total scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0 } }",
"{ $count: 'total' }"
})
Integer getTotalScores(@NonNull Platform.Platforms platform);
/**
* Gets the total ranked scores for the platform.
*
* @param platform the platform to get the scores from
* @return the total ranked scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, pp: { $gt: 0 } } }",
"{ $count: 'total' }"
})
Integer getTotalRankedScores(@NonNull Platform.Platforms platform);
/**
* Gets the total player scores for the platform.
*
* @param platform the platform to get the scores from
* @param playerId the player id to get the scores from
* @return the total player scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, playerId: ?1 } }",
"{ $count: 'total' }"
})
Integer getTotalPlayerScores(@NonNull Platform.Platforms platform, @NonNull String playerId);
/**
* Gets the total player ranked scores for the platform.
*
* @param platform the platform to get the scores from
* @param playerId the player id to get the scores from
* @return the total player ranked scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, playerId: ?1, pp: { $gt: 0 } } }",
"{ $count: 'total' }"
})
Integer getTotalPlayerRankedScores(@NonNull Platform.Platforms platform, @NonNull String playerId);
/**
* Gets the total player unranked scores for the platform.
*
* @param platform the platform to get the scores from
* @param playerId the player id to get the scores from
* @return the total player unranked scores
*/
@Aggregation(pipeline = {
"{ $match: { platform: ?0, playerId: ?1, pp: { $eq: null } } }",
"{ $count: 'total' }"
})
Integer getTotalPlayerUnrankedScores(@NonNull Platform.Platforms platform, @NonNull String playerId);
}

@ -1,6 +1,6 @@
package cc.fascinated.repository.mongo;
import cc.fascinated.model.token.ScoreSaberLeaderboardToken;
import cc.fascinated.model.token.scoresaber.ScoreSaberLeaderboardToken;
import org.springframework.data.mongodb.repository.MongoRepository;
/**

@ -25,8 +25,8 @@ public interface UserRepository extends MongoRepository<User, UUID> {
*
* @return the list of users
*/
@Query(value = "{}", fields = "{ 'steamId' : 1 }")
List<User> fetchOnlySteamIds();
@Query(value = "{}", fields = "{ 'steamId' : 1, linkedAccount: 1 }")
List<User> fetchAccountsSimple();
/**
* Finds a user by their username.

@ -0,0 +1,11 @@
package cc.fascinated.repository.redis;
import cc.fascinated.model.auth.AuthToken;
import org.springframework.data.repository.CrudRepository;
import java.util.UUID;
/**
* @author Fascinated (fascinated7)
*/
public interface AuthTokenRepository extends CrudRepository<AuthToken, String> {}

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

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

@ -8,6 +8,7 @@ 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;
@ -20,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);
/**
@ -31,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()));
@ -39,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 * * * *")
@ -54,14 +58,14 @@ 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 * * * *")
@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 player metrics.");
}

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

@ -2,20 +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 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)
@ -28,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.
*/
@ -69,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);
@ -83,6 +99,7 @@ public class ScoreSaberService {
}
ScoreSaberLeaderboardToken leaderboard = response.getBody();
leaderboardRepository.save(leaderboard);
leaderboardCache.put(leaderboardId, leaderboard);
return leaderboard;
}

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

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

@ -1,166 +0,0 @@
package cc.fascinated.services;
import cc.fascinated.exception.impl.BadRequestException;
import cc.fascinated.model.leaderboard.Leaderboard;
import cc.fascinated.model.score.ScoreResponse;
import cc.fascinated.model.score.ScoresOverResponse;
import cc.fascinated.model.score.TotalScoresResponse;
import cc.fascinated.model.score.TrackedScore;
import cc.fascinated.model.user.User;
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.LinkedList;
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;
/**
* The user service to use.
*/
@NonNull
private final UserService userService;
/**
* The ScoreSaber service to use.
*/
@NonNull
private final ScoreSaberService scoreSaberService;
@Autowired
public TrackedScoreService(@NonNull TrackedScoreRepository trackedScoreRepository, @NonNull UserService userService,
@NonNull ScoreSaberService scoreSaberService) {
this.trackedScoreRepository = trackedScoreRepository;
this.userService = userService;
this.scoreSaberService = scoreSaberService;
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<ScoreResponse> getTopScores(Platform.Platforms platform, int amount) {
List<TrackedScore> foundScores = trackedScoreRepository.findTopRankedScores(platform.getPlatformName(), amount);
if (foundScores.isEmpty()) {
throw new BadRequestException("No scores found for platform " + platform.getPlatformName());
}
List<ScoreResponse> scores = new LinkedList<>();
for (TrackedScore trackedScore : foundScores) {
User user = this.userService.getUser(trackedScore.getPlayerId());
Leaderboard leaderboard = null;
switch (platform) {
case SCORESABER -> leaderboard = Leaderboard.getFromScoreSaberToken(this.scoreSaberService.getLeaderboard(trackedScore.getLeaderboardId()));
}
assert leaderboard != null; // This should never be null
scores.add(new ScoreResponse(
trackedScore.getScoreId(),
user.getScoresaberAccount(),
leaderboard,
trackedScore.getPp(),
trackedScore.getRank(),
trackedScore.getScore(),
trackedScore.getModifiedScore(),
trackedScore.getWeight(),
trackedScore.getModifiers(),
trackedScore.getMultiplier(),
trackedScore.getMissedNotes(),
trackedScore.getBadCuts(),
trackedScore.getMaxCombo(),
trackedScore.getAccuracy(),
trackedScore.getDifficulty(),
trackedScore.getTimestamp()
));
}
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);
}
}
}

@ -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;
}
/**
@ -37,15 +85,78 @@ public class UserService {
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 new auth token using a steam ticket
*
* @param ticket the ticket to get the auth token from
* @return the auth token
* @throws BadRequestException if the ticket is invalid
*/
public AuthToken getAuthToken(String ticket) {
SteamAuthenticateUserTicketToken steamUser = this.steamService.getSteamUserFromTicket(ticket);
assert steamUser != null;
User user = this.getUser(steamUser.getResponse().getParams().getSteamId());
if (user == null) {
throw new BadRequestException("Failed to get user from steam id");
}
return this.authTokenRepository.save(new AuthToken(
StringUtils.randomString(32),
user.getId()
));
}
/**
* Validates an auth token
*
* @param authToken the auth token to validate
* @return true if the auth token is valid, false otherwise
*/
public boolean isValidAuthToken(String authToken) {
return this.authTokenRepository.existsById(authToken);
}
/**
@ -62,9 +173,9 @@ public class UserService {
*
* @return all users
*/
public List<User> getUsers(boolean steamIdsOnly) {
if (steamIdsOnly) {
return this.userRepository.fetchOnlySteamIds();
public List<User> getUsers(boolean smallerAccount) {
if (smallerAccount) {
return this.userRepository.fetchAccountsSimple();
}
return this.userRepository.findAll();
}

@ -1,18 +1,12 @@
package cc.fascinated.websocket.impl;
import cc.fascinated.common.ScoreSaberUtils;
import cc.fascinated.common.TimeUtils;
import cc.fascinated.model.token.*;
import cc.fascinated.model.user.ScoreSaberAccount;
import cc.fascinated.model.user.User;
import cc.fascinated.platform.Platform;
import cc.fascinated.services.PlatformService;
import cc.fascinated.services.QuestDBService;
import cc.fascinated.services.ScoreSaberService;
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;
@ -20,20 +14,12 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.TextMessage;
import java.util.Date;
import java.util.concurrent.TimeUnit;
/**
* @author Fascinated (fascinated7)
*/
@Component
@Log4j2(topic = "ScoreSaber Websocket")
public class ScoreSaberWebsocket extends Websocket {
/**
* The interval to force update the user's account.
*/
private static long FORCE_UPDATE_INTERVAL = TimeUnit.HOURS.toMillis(12);
/**
* The Jackson deserializer to use.
*/
@ -45,29 +31,16 @@ 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;
/**
* The ScoreSaber service to use
*/
private final ScoreSaberService scoreSaberService;
private final ScoreService scoreService;
@Autowired
public ScoreSaberWebsocket(@NonNull ObjectMapper objectMapper, @NonNull UserService userService, @NonNull QuestDBService questDBService,
@NonNull PlatformService platformService, @NonNull ScoreSaberService scoreSaberService) {
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.scoreSaberService = scoreSaberService;
this.scoreService = scoreService;
}
@Override
@ -86,54 +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();
// Ensure the player is valid
if (!this.userService.isValidSteamId(player.getId())) {
return;
}
User user = userService.getUser(player.getId());
ScoreSaberAccount scoresaberAccount = user.getScoresaberAccount();
// Ensure the users account is up-to-date
if (scoresaberAccount == null || scoresaberAccount.getLastUpdated().before(new Date(System.currentTimeMillis() - FORCE_UPDATE_INTERVAL))) {
log.info("Updating account for '{}', last update: {}",
player.getName(),
scoresaberAccount == null ? "now" : TimeUtils.format(System.currentTimeMillis() - scoresaberAccount.getLastUpdated().getTime())
);
ScoreSaberAccountToken accountToken = scoreSaberService.getAccount(user);
user.setScoresaberAccount(ScoreSaberAccount.getFromToken(accountToken));
userService.saveUser(user); // Save the user
}
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);
}
}

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

359
Mod/.gitignore vendored Normal file

@ -0,0 +1,359 @@
## Ignore References folder
References/
*.bat
PrivateKeys.cs
MockUp
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*[.json, .xml, .info]
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/

145
Mod/API/Authentication.cs Normal file

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

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

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

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

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

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

30
Mod/Mod.sln Normal 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

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

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

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

@ -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" ]
}