diff --git a/README.md b/README.md index 7ef121a..de4941e 100644 --- a/README.md +++ b/README.md @@ -105,12 +105,12 @@ Image endpoints return PNG bytes. Replay download returns `video/mp4`. ### Beatmaps -| Method | Path | Purpose | Params / POST Body | Response | -|--------|--------------------------------------|--------------------------------|----------------------------------------------------|----------| -| GET | `/beatmaps/lookup` | Resolve beatmap IDs | See section below | JSON | -| GET | `/beatmaps/{beatmapId}` | Beatmap card image | path `beatmapId` (+ optional query param `mod`) | PNG | -| GET | `/beatmaps/{beatmapId}/analysis` | PP+ and object-pattern analysis | path `beatmapId` (+ optional query param `mod`) | PNG | -| POST | `/beatmaps/{beatmapId}/leaderboards` | Compare players on one beatmap | path `beatmapId` + POST Body `{"uids":[user ids]}` | PNG | +| Method | Path | Purpose | Params / POST Body | Response | +|--------|--------------------------------------|---------------------------------|----------------------------------------------------|----------| +| GET | `/beatmaps/lookup` | Resolve beatmap IDs | See section below | JSON | +| GET | `/beatmaps/{beatmapId}` | Beatmap card image | path `beatmapId` (+ optional query param `mod`) | PNG | +| GET | `/beatmaps/{beatmapId}/analysis` | PP+ and object-pattern analysis | path `beatmapId` (+ optional query param `mod`) | PNG | +| POST | `/beatmaps/{beatmapId}/leaderboards` | Compare players on one beatmap | path `beatmapId` + POST Body `{"uids":[user ids]}` | PNG | ### Beatmapsets @@ -123,14 +123,16 @@ Image endpoints return PNG bytes. Replay download returns `video/mp4`. ### Scores -| Method | Path | Purpose | Params / POST Body | Response | -|--------|--------------------------------------------------|--------------------------------|----------------------------|----------| -| GET | `/scores/lookup` | Resolve score IDs | See section below | JSON | -| GET | `/scores/{scoreId}` | Score card image | path `scoreId` | PNG | -| GET | `/scores/{scoreId}/analysis` | Score analysis card image | path `scoreId` | PNG | -| GET | `/scores/{scoreId}/highlight` | 20s highlight range of a score | path `scoreId` | JSON | -| GET | `/scores/{scoreId}/misses` | List the misses of the score | path `scoreId` | JSON | -| GET | `/scores/{scoreId}/misses/{missIndex}/visualize` | Visualize misses | path `scoreId` `missIndex` | PNG | +| Method | Path | Purpose | Params / POST Body | Response | +|--------|--------------------------------------------------|-------------------------------------|---------------------------------|----------| +| GET | `/scores/lookup` | Resolve score IDs | See section below | JSON | +| GET | `/scores/random` | Get a random score | `min_rank` | JSON | +| POST | `/scores/random/users` | Get a random score from given users | POST Body `{"uids":[user ids]}` | JSON | +| GET | `/scores/{scoreId}` | Score card image | path `scoreId` | PNG | +| GET | `/scores/{scoreId}/analysis` | Score analysis card image | path `scoreId` | PNG | +| GET | `/scores/{scoreId}/highlight` | 20s highlight range of a score | path `scoreId` | JSON | +| GET | `/scores/{scoreId}/misses` | List the misses of the score | path `scoreId` | JSON | +| GET | `/scores/{scoreId}/misses/{missIndex}/visualize` | Visualize misses | path `scoreId` `missIndex` | PNG | ### Multiplayer Rooms @@ -141,15 +143,15 @@ Image endpoints return PNG bytes. Replay download returns `video/mp4`. ### Users -| Method | Path | Purpose | Params / POST Body | Response | -|--------|---------------------------------|---------------------------|----------------------------------|----------| -| POST | `/users` | Get multiple user data | POST Body `{"ids":[user ids]}` | JSON | -| GET | `/users/me` | User data | Requires Authorization Header | JSON | -| GET | `/users/me/friends` | Friends list for user | Requires Authorization Header | JSON | -| POST | `/users/leaderboards` | User PP leaderboard image | `{"uids":[user ids]}` | PNG | -| GET | `/users/{userId}/scores/bestof` | Best-of-N scores image | path `userId`, query `n` (count) | PNG | -| GET | `/users/{userId}/scores/recent` | Recent scores image | path `userId`, query `n` (count) | PNG | -| GET | `/users/{userId}/scores/today-best` | Best scores achieved recently | path `userId`, optional query `days` (default 1) | PNG | +| Method | Path | Purpose | Params / POST Body | Response | +|--------|-------------------------------------|-------------------------------|--------------------------------------------------|----------| +| POST | `/users` | Get multiple user data | POST Body `{"ids":[user ids]}` | JSON | +| GET | `/users/me` | User data | Requires Authorization Header | JSON | +| GET | `/users/me/friends` | Friends list for user | Requires Authorization Header | JSON | +| POST | `/users/leaderboards` | User PP leaderboard image | `{"uids":[user ids]}` | PNG | +| GET | `/users/{userId}/scores/bestof` | Best-of-N scores image | path `userId`, query `n` (count) | PNG | +| GET | `/users/{userId}/scores/recent` | Recent scores image | path `userId`, query `n` (count) | PNG | +| GET | `/users/{userId}/scores/today-best` | Best scores achieved recently | path `userId`, optional query `days` (default 1) | PNG | ### Replays (enabled when `replayRender.enabled` is true) diff --git a/pom.xml b/pom.xml index 4d23d4a..48fa7a6 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira oStella - 1.11.1 + 1.11.5 25 @@ -118,7 +118,7 @@ com.github.BotSeira osuParser - v1.5.1 + v1.5.2 diff --git a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java index eed65f6..868175f 100644 --- a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java +++ b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java @@ -1,5 +1,6 @@ package xyz.zcraft.ostella.data; +import xyz.zcraft.ostella.service.CacheService; import xyz.zcraft.osu.model.Mod; import xyz.zcraft.osu.model.Score; @@ -14,7 +15,7 @@ /** A validated filter applied to a score-list response. */ public final class ScoreFilter { private static final Pattern FILTER_PATTERN = Pattern.compile( - "(?i)^(acc(?:uracy)?|combo|pp|time|length|len|star|stars|sr|bpm|miss|misses|score|mod|mods|rank" + "(?i)^(acc(?:uracy)?|combo|pp|time|length|len|star|stars|sr|bpm|miss|misses|score|mod|mods|rank|replay" + "|title|artist|mapper|genre|language|video|storyboard|fullcombo)" + "(>=|<=|!=|!~|>|<|=|~)(.+)$" ); @@ -228,7 +229,7 @@ private static String formatValue(Field field, double value) { case BPM -> formatNumber(value) + " BPM"; case MISS -> formatNumber(value) + " miss"; case SCORE -> formatNumber(value); - case MODS, RANK, TITLE, ARTIST, MAPPER, GENRE, LANGUAGE, VIDEO, STORYBOARD, FULL_COMBO -> + case MODS, RANK, TITLE, ARTIST, MAPPER, GENRE, LANGUAGE, VIDEO, STORYBOARD, FULL_COMBO, REPLAY -> throw new IllegalStateException("Text filter has no numeric value"); }; } @@ -274,6 +275,7 @@ public boolean matches(Score score) { && compareText(score.getBeatmapset().getLanguage().getName()); case VIDEO -> score.getBeatmapset() != null && compareBoolean(score.getBeatmapset().getVideo()); case STORYBOARD -> score.getBeatmapset() != null && compareBoolean(score.getBeatmapset().getStoryboard()); + case REPLAY -> compareBoolean(score.getHasReplay() || CacheService.hasReplayCache(score.getId())); case FULL_COMBO -> compareBoolean(score.getIsPerfectCombo()); }; } @@ -343,7 +345,7 @@ private boolean compareText(String... actualValues) { private boolean compareBoolean(Boolean actual) { if (actual == null) return false; boolean equal = actual == Boolean.parseBoolean(textValues.iterator().next()); - return operator == Operator.EQUAL ? equal : !equal; + return (operator == Operator.EQUAL) == equal; } public String displayText() { @@ -368,6 +370,7 @@ private enum Field { LANGUAGE("Language"), VIDEO("Video"), STORYBOARD("Storyboard"), + REPLAY("Replay"), FULL_COMBO("Full combo"); private final String label; @@ -395,6 +398,7 @@ static Field from(String value) { case "language" -> LANGUAGE; case "video" -> VIDEO; case "storyboard" -> STORYBOARD; + case "replay" -> REPLAY; case "fullcombo" -> FULL_COMBO; default -> throw new IllegalArgumentException("Unknown filter field: " + value); }; @@ -405,7 +409,7 @@ boolean isMetadataText() { } boolean isBoolean() { - return this == VIDEO || this == STORYBOARD || this == FULL_COMBO; + return this == VIDEO || this == STORYBOARD || this == FULL_COMBO || this == REPLAY; } } diff --git a/src/main/java/xyz/zcraft/ostella/network/WebServer.java b/src/main/java/xyz/zcraft/ostella/network/WebServer.java index 00e463a..41ad6b2 100644 --- a/src/main/java/xyz/zcraft/ostella/network/WebServer.java +++ b/src/main/java/xyz/zcraft/ostella/network/WebServer.java @@ -58,6 +58,9 @@ public WebServer(AppConfig conf, TokenManager tokenManager) throws IOException { .get("/scores/lookup", router.scoreController::lookupScore) .get("/scores/random", router.scoreController::randomScore) + .post("/scores/random/users", router.scoreController::randomScoreFromUsers) + .get("/scores/random/users/{userId}/weights", router.scoreController::randomScoreFromUsersWeights) + .post("/scores/random/users/{userId}/weights", router.scoreController::randomScoreFromUsersWeights) .get("/scores/{scoreId}", router.scoreController::renderScoreById) .get("/scores/{scoreId}/analysis", router.analyzeController::renderScoreAnalysisById) .get("/scores/{scoreId}/highlight", router.analyzeController::getScoreHighlight) @@ -77,6 +80,7 @@ public WebServer(AppConfig conf, TokenManager tokenManager) throws IOException { .post("/users/leaderboards", router.leaderboardController::getLeaderboard) .post("/users/scores/recent/batch", router.userController::getRecentScoresBatch) .get("/users/{userId}", router.userController::getUserInfo) + .get("/users/{userId}/rank", router.userController::getUserRank) .get("/users/{userId}/scores/bestof", router.userController::getBestOfN) .get("/users/{userId}/scores/recent", router.userController::getRecentScores) .get("/users/{userId}/scores/today-best", router.userController::getTodayBestScores) diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/AnalyzeController.java b/src/main/java/xyz/zcraft/ostella/network/controller/AnalyzeController.java index 5dbd300..bb57879 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/AnalyzeController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/AnalyzeController.java @@ -19,6 +19,7 @@ import xyz.zcraft.ostella.service.MissVisualizeService; import xyz.zcraft.ostella.service.RenderService; import xyz.zcraft.ostella.util.TokenManager; +import xyz.zcraft.ostella.util.format.ScoreFormatUtil; import xyz.zcraft.osu.model.BeatmapExtended; import xyz.zcraft.osu.model.Mod; import xyz.zcraft.osu.model.Score; @@ -34,6 +35,7 @@ import java.nio.file.Path; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Objects; @@ -299,6 +301,31 @@ public void renderScoreAnalysisById(@NotNull Context context) { throw new ApiException(ErrorCode.SCORE_PARSE_FAILED, e); } + boolean isLazerScore = + osuReplay.gameVersion() > 30000001 + && score.getMods().stream() + .map(Mod::getAcronym) + .noneMatch(s -> Objects.equals(s, "CL") || Objects.equals(s, "V2")); + + HashMap simResult = new HashMap<>(); + analyze.events().stream() + .filter(e -> e.eventType().equals(HitEvent.EventType.HIT_CIRCLE) + || e.eventType().equals(HitEvent.EventType.SLIDER_HEAD)) + .forEach(e -> simResult.merge(e.hitResult(), 1, Integer::sum)); + + final Integer simGreat = simResult.getOrDefault(HitEvent.HitResult.PERFECT, 0); + final Integer simOk = simResult.getOrDefault(HitEvent.HitResult.OK, 0); + final Integer simMeh = simResult.getOrDefault(HitEvent.HitResult.MEH, 0); + final Integer simMiss = simResult.getOrDefault(HitEvent.HitResult.MISS, 0); + + String simHitResult = simGreat + " / " + simOk + " / " + simMeh + " / " + simMiss; + + boolean doSimMatch = + simGreat == ScoreFormatUtil.getGreatCount(score) + && simOk == ScoreFormatUtil.getOkCount(score) + && simMeh == ScoreFormatUtil.getMehCount(score) + && simMiss == ScoreFormatUtil.getMissCount(score); + return perfPlusApi.calculate(score) .exceptionally(error -> { LOG.warn("Could not calculate performance+ for score {}. " @@ -309,7 +336,7 @@ public void renderScoreAnalysisById(@NotNull Context context) { .thenApply(performancePlus -> new ScoreAnalyzeData( score, diffSpec, hitErrors, hitPos, hitPosAbs, missPos, missPosAbs, aimBias, avgTimingError, analyze, - performanceGraph, performancePlus)); + performanceGraph, performancePlus, isLazerScore, doSimMatch, simHitResult)); }) .thenApplyAsync(renderer::renderScoreAnalysis, renderer.getRenderExecutor()) .thenAccept(bytes -> context.status(200).result(bytes))); @@ -406,7 +433,10 @@ public record ScoreAnalyzeData( double avgTimingError, ReplayAnalyze replayAnalyze, PerformanceGraphData performanceGraph, - PerfPlusApi.PerformancePlus performancePlus + PerfPlusApi.PerformancePlus performancePlus, + boolean isLazerScore, + boolean doSimMatch, + String simHitResult ) { } diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/ReplayController.java b/src/main/java/xyz/zcraft/ostella/network/controller/ReplayController.java index d8fa850..5ea4a15 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ReplayController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ReplayController.java @@ -206,7 +206,11 @@ public void renderShowcaseOfUsers(@NotNull Context context) { List> scoreFutures = new ArrayList<>(ids.stream() .filter(id -> !id.startsWith("s")) .map(id -> { - if (id.startsWith("u")) return id.substring(1); + if (id.startsWith("u")) { + return id.substring(1); + } else if (id.startsWith("@")) { + return String.valueOf(executor.enqueueAsync(() -> OsuAPI.getUser(tokenManager.getTokenData(), id.substring(1))).join().getId()); + } return id; }) .map(Long::parseLong) diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java index 0e06a1d..3f54001 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -1,13 +1,16 @@ package xyz.zcraft.ostella.network.controller; import com.google.gson.Gson; +import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.annotations.SerializedName; import io.javalin.http.Context; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; -import xyz.zcraft.ostella.data.ScoreId; import xyz.zcraft.ostella.data.ScoreFilter; +import xyz.zcraft.ostella.data.ScoreId; import xyz.zcraft.ostella.data.ScoreType; import xyz.zcraft.ostella.exception.ApiException; import xyz.zcraft.ostella.network.ErrorCode; @@ -17,24 +20,27 @@ import xyz.zcraft.ostella.service.AsyncService; import xyz.zcraft.ostella.service.CacheService; import xyz.zcraft.ostella.service.RenderService; +import xyz.zcraft.ostella.util.RequestUtil; import xyz.zcraft.ostella.util.TokenManager; +import xyz.zcraft.ostella.util.WeightedRandom; +import xyz.zcraft.ostella.util.format.ScoreFormatUtil; import xyz.zcraft.osu.model.BeatmapExtended; import xyz.zcraft.osu.model.Mod; import xyz.zcraft.osu.model.Score; import xyz.zcraft.osu.model.UserExtended; +import xyz.zcraft.osu.parser.BeatmapAnalyzer; import xyz.zcraft.osu.parser.BeatmapParser; +import xyz.zcraft.osu.parser.BeatmapPatternAnalyzer; import xyz.zcraft.osu.parser.OsuParser; +import xyz.zcraft.osu.parser.data.beatmap.BeatmapPatternAnalysis; import xyz.zcraft.osu.parser.data.beatmap.DiffSpec; +import xyz.zcraft.osu.parser.data.beatmap.DifficultyAttribute; import xyz.zcraft.osu.parser.data.beatmap.OsuBeatmap; import xyz.zcraft.osu.parser.exception.AnalyzeException; import xyz.zcraft.osu.parser.exception.ParseException; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; +import java.util.*; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; import static xyz.zcraft.ostella.util.RequestUtil.*; @@ -42,6 +48,14 @@ public class ScoreController { private static final Logger LOG = LogManager.getLogger(ScoreController.class); private static final Gson GSON = new Gson(); + private static final Map PATTERN_WEIGHTS = Map.of( + BeatmapPatternAnalysis.PatternType.TECH, 600, + BeatmapPatternAnalysis.PatternType.READING, 500, + BeatmapPatternAnalysis.PatternType.FLOW, 400, + BeatmapPatternAnalysis.PatternType.STREAM, 300, + BeatmapPatternAnalysis.PatternType.ALT, 300, + BeatmapPatternAnalysis.PatternType.AIM, 10 + ); public final RenderService renderer; public final AsyncService executor; public final TokenManager tokenManager; @@ -54,6 +68,73 @@ public ScoreController(Router router) { this.tokenManager = router.tokenManager; } + private static List requireScoreFilters(Context context) { + try { + return ScoreFilter.parseList(context.queryParam("filters")); + } catch (IllegalArgumentException e) { + throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, e.getMessage(), e); + } + } + + static List applyFilters(List scores, List filters) { + if (filters.isEmpty()) { + return scores; + } + return scores.stream() + .filter(score -> filters.stream().allMatch(filter -> filter.matches(score))) + .toList(); + } + + static int scoreLookupFetchLimit(int index, List filters) { + return filters.isEmpty() ? index : OsuAPI.MAX_USER_SCORES_LIMIT; + } + + private static JsonObject mapScoreToJson(TargetScore targetScore) { + final OsuBeatmap osuBeatmap; + final DiffSpec diffSpec; + + final ScoreEntry entry = targetScore.entry(); + final Score score = entry.score(); + final UserExtended user = targetScore.user(); + + try { + osuBeatmap = BeatmapParser.parseBeatmap(CacheService.getBeatmapPath(score.getBeatmap().getId())); + diffSpec = OsuParser.getDiffSpecForMap(osuBeatmap, score.getMods().stream().map(Mod::getAcronym).reduce("", String::concat)); + } catch (Exception e) { + throw new ApiException(ErrorCode.BEATMAP_PARSE_FAILED, e); + } + + JsonObject result = new JsonObject(); + result.add("user", GSON.toJsonTree(user)); + result.add("score", GSON.toJsonTree(score)); + result.addProperty("diff", "%.2f★ (CS %.2f / AR %.2f / OD %.2f / HP %.2f)".formatted( + diffSpec.getStar(), + diffSpec.getDifficulty().cs(), + diffSpec.getDifficulty().ar(), + diffSpec.getDifficulty().od(), + diffSpec.getDifficulty().hp() + )); + result.addProperty("best_index", entry.bestIndex()); + result.addProperty("pp_weight", entry.score().getWeight().getPercentage()); + return result; + } + + public static long getModBits(List mods) { + long bits = 0; + + for (Mod mod : mods) { + switch (mod.getAcronym()) { + case "EZ" -> bits |= 2L; + case "HR" -> bits |= 16L; + case "DT" -> bits |= 64L; + case "HT" -> bits |= 256L; + case "NC" -> bits |= 512L | 64L; + } + } + + return bits; + } + public void lookupScore(@NotNull Context context) { if (context.queryParam("of") != null) { lookupScoreOfRefAsync(context); @@ -230,132 +311,343 @@ public CompletableFuture getScoreFromRefAsync(@NotNull Context context) { }); } - private static List requireScoreFilters(Context context) { - try { - return ScoreFilter.parseList(context.queryParam("filters")); - } catch (IllegalArgumentException e) { - throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, e.getMessage(), e); - } - } - - static List applyFilters(List scores, List filters) { - if (filters.isEmpty()) { - return scores; - } - return scores.stream() - .filter(score -> filters.stream().allMatch(filter -> filter.matches(score))) - .toList(); - } - - static int scoreLookupFetchLimit(int index, List filters) { - return filters.isEmpty() ? index : OsuAPI.MAX_USER_SCORES_LIMIT; - } - public void randomScore(@NotNull Context context) { final long minRank = optionalLong(context, "min_rank", Long.MAX_VALUE); context.future(() -> executor.enqueueAsync(() -> OsuAPI.getLatestPassedScores(tokenManager.getTokenData())) .thenApply(scores -> { - List userIds = scores.stream() + WeightedRandom userIds = new WeightedRandom<>(); + + scores.stream() .map(Score::getUserId) .distinct() - .collect(Collectors.toCollection(ArrayList::new)); + .forEach(l -> userIds.add(l, 1)); - Collections.shuffle(userIds, ThreadLocalRandom.current()); return userIds; }) - .thenCompose(userIds -> findAvailableScore(userIds, 0, minRank)) - .thenApply(targetScore -> { - final OsuBeatmap osuBeatmap; - final DiffSpec diffSpec; - - final ScoreEntry entry = targetScore.entry(); - final Score score = entry.score(); - final UserExtended user = targetScore.user(); - - try { - osuBeatmap = BeatmapParser.parseBeatmap(CacheService.getBeatmapPath(score.getBeatmap().getId())); - diffSpec = OsuParser.getDiffSpecForMap(osuBeatmap, score.getMods().stream().map(Mod::getAcronym).reduce("", String::concat)); - } catch (Exception e) { - throw new ApiException(ErrorCode.BEATMAP_PARSE_FAILED, e); - } - - JsonObject result = new JsonObject(); - result.add("user", GSON.toJsonTree(user)); - result.add("score", GSON.toJsonTree(score)); - result.addProperty("diff", "%.2f★ (CS %.2f / AR %.2f / OD %.2f / HP %.2f)".formatted( - diffSpec.getStar(), - diffSpec.getDifficulty().cs(), - diffSpec.getDifficulty().ar(), - diffSpec.getDifficulty().od(), - diffSpec.getDifficulty().hp() - )); - result.addProperty("best_index", entry.bestIndex()); - return result; - } + .thenCompose(userIds -> findAvailableScore(userIds, minRank, Map.of())) + .thenApply(ScoreController::mapScoreToJson) + .thenAccept(result -> + context.status(200).result(new Response(true, "Success", result).toString()) ) + ); + } + + public void randomScoreFromUsers(@NotNull Context context) { + final JsonObject body = JsonParser.parseString(context.body()).getAsJsonObject(); + + final RandomScoreRequest randomScoreRequest = GSON.fromJson(body, RandomScoreRequest.class); + + if (randomScoreRequest.uids() == null || randomScoreRequest.uids().isEmpty()) { + throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, "No users provided!"); + } + + WeightedRandom userIds = new WeightedRandom<>(); + + for (Long uid : randomScoreRequest.uids()) { + final double weight = Optional.ofNullable(randomScoreRequest.weightFactor()) + .map(WeightFactor::users) + .map(m -> m.get(uid)) + .orElse(1.0); + + if (weight < 0.0) { + continue; + } + + userIds.add(uid, weight); + } + + final Map scoreWeights = Optional.ofNullable(randomScoreRequest.weightFactor()) + .map(WeightFactor::scores) + .orElse(Map.of()); + + context.future(() -> + findAvailableScore(userIds, Long.MAX_VALUE, scoreWeights) + .thenApply(ScoreController::mapScoreToJson) .thenAccept(result -> context.status(200).result(new Response(true, "Success", result).toString()) ) ); } - private CompletableFuture findAvailableScore(List userIds, int index, long minRank) { - if (index >= userIds.size()) { + public void randomScoreFromUsersWeights(@NotNull Context context) { + final long userId = requirePathLong(context, "userId"); + + final JsonElement jsonElement = JsonParser.parseString(context.body()); + final Map scoreWeights; + + if (jsonElement == null || !jsonElement.isJsonObject()) { + scoreWeights = Map.of(); + } else { + final JsonObject body = jsonElement.getAsJsonObject(); + + final RandomScoreRequest randomScoreRequest = GSON.fromJson(body, RandomScoreRequest.class); + + scoreWeights = Optional.ofNullable(randomScoreRequest.weightFactor()) + .map(WeightFactor::scores) + .orElse(Map.of()); + } + context.future(() -> + executor.enqueueAsync(() -> OsuAPI.getUserScores(tokenManager.getTokenData(), userId, ScoreType.BEST, 80)) + .thenApply(scores -> { + List candidates = new ArrayList<>(scores.size()); + + for (int i = 0; i < scores.size(); i++) { + final Score score = scores.get(i); + if (!ScoreFormatUtil.replayPresent(score)) { + continue; + } + + final ScoreEntry scoreEntry = new ScoreEntry(i + 1, score); + candidates.add(scoreEntry); + } + + + final Map weights = getWeights(candidates, scoreWeights); + + if (weights.isEmpty()) { + return "No scores found!"; + } + + StringBuilder sb = new StringBuilder(); + + final List> list = weights.entrySet() + .stream() + .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())).toList(); + + final int LIMIT = 12; + for (int i = 0; i < Math.min(LIMIT, list.size()); i++) { + final Map.Entry e = list.get(i); + sb.append("__BP").append("%02d".formatted(e.getKey().bestIndex())).append("__:") + .append(String.format("%.5f", e.getValue())).append(" "); + } + + if (list.size() > LIMIT) { + sb.append("\n").append("... and %d more".formatted(list.size() - LIMIT)); + } + + return sb.toString().trim(); + }) + .thenAccept(result -> RequestUtil.putResult(context, result)) + + ); + } + + private CompletableFuture findAvailableScore(WeightedRandom userIds, long minRank, Map weights) { + if (userIds.isEmpty()) { return CompletableFuture.failedFuture( new ApiException(ErrorCode.NO_SCORE_FOUND, "No available scores found!") ); } - long userId = userIds.get(index); + long userId = userIds.getAndRemove(); + + final int SCORE_LIMIT = 40; return executor.enqueueAsync(() -> OsuAPI.getUserScores( tokenManager.getTokenData(), userId, ScoreType.BEST, - 10 + SCORE_LIMIT ) ).thenCompose(scores -> { - if (scores.size() < 8) { - return findAvailableScore(userIds, index + 1, minRank); + if (scores.size() < SCORE_LIMIT) { + return findAvailableScore(userIds, minRank, weights); } - List candidates = new ArrayList<>(5); + List candidates = new ArrayList<>(SCORE_LIMIT); + + for (int i = 0; i < SCORE_LIMIT; i++) { + final Score score = scores.get(i); - for (int i = 0; i < 7; i++) { - if (!scores.get(i).getHasReplay()) { + if (!ScoreFormatUtil.replayPresent(score)) { continue; } - candidates.add(new ScoreEntry(i + 1, scores.get(i))); + candidates.add(new ScoreEntry(i + 1, score)); } if (candidates.isEmpty()) { - return findAvailableScore(userIds, index + 1, minRank); + return findAvailableScore(userIds, minRank, weights); } - return executor.enqueueAsync(() -> - OsuAPI.getUser(tokenManager.getTokenData(), userId) - ).thenCompose(user -> { - Long globalRank = user.getStatistics().getGlobalRank(); + return executor.enqueueAsync(() -> OsuAPI.getUser(tokenManager.getTokenData(), userId)) + .thenCompose(user -> { + if (user == null + || user.getStatistics().getGlobalRank() == null + || user.getStatistics().getGlobalRank() > minRank) { + return findAvailableScore(userIds, minRank, weights); + } - if (globalRank == null || globalRank > minRank) { - return findAvailableScore(userIds, index + 1, minRank); - } + WeightedRandom randomScores = new WeightedRandom<>(); - ScoreEntry selected = candidates.get( - ThreadLocalRandom.current().nextInt(candidates.size()) - ); + final Map finalWeights = getWeights(candidates, weights); - return CompletableFuture.completedFuture(new TargetScore(selected, user)); - }); + if (finalWeights.isEmpty()) { + return findAvailableScore(userIds, minRank, weights); + } + + for (Map.Entry entry : finalWeights.entrySet()) { + randomScores.add(entry.getKey(), entry.getValue()); + } + + ScoreEntry selected = randomScores.next(); + + return CompletableFuture.completedFuture(new TargetScore(selected, user)); + }); }); } + private Map getWeights(List candidates, Map weights) { + HashMap baseWeights = new HashMap<>(); + + for (ScoreEntry current : candidates) { + try { + baseWeights.put(current, getWeight(current)); + } catch (ParseException e) { + LOG.warn("Failed to parse beatmap with id {}", current.score().getBeatmapId(), e); + } + } + + if (baseWeights.isEmpty()) { + return Map.of(); + } + + final double maxWeight = baseWeights.values() + .stream() + .max(Double::compareTo) + .get(); + + HashMap finalWeights = new HashMap<>(); + + for (Map.Entry entry : baseWeights.entrySet()) { + final double normalizedWeight = entry.getValue() / maxWeight; + + if (normalizedWeight < 0.5 && entry.getKey().bestIndex() > 40) { + continue; + } + + final double powWeight = Math.pow(normalizedWeight, 4); + + final double extraFactor = weights.getOrDefault(entry.getKey().score().getId(), 1.0); + + final double finalWeight = powWeight * extraFactor; + + if (finalWeight < 0.0) { + continue; + } + + finalWeights.put(entry.getKey(), finalWeight); + } + + // Extra normalization to make the result more human-friendly, maybe... + final double maxFinalWeight = finalWeights.values() + .stream() + .max(Double::compareTo) + .orElseThrow(); + + for (var entry : finalWeights.entrySet()) { + entry.setValue(entry.getValue() / maxFinalWeight); + } + + return finalWeights; + } + + private double getWeight(ScoreEntry entry) throws ParseException { + final Long beatmapId = entry.score().getBeatmapId(); + final OsuBeatmap osuBeatmap = BeatmapParser.parseBeatmap(CacheService.getBeatmapPath(beatmapId)); + final DifficultyAttribute difficultyAttribute = BeatmapAnalyzer.calculateDifficulty(osuBeatmap, getModBits(entry.score().getMods())); + final BeatmapPatternAnalysis patternAnalysis = BeatmapPatternAnalyzer.analyze(osuBeatmap, difficultyAttribute); + double patternWeight = 0; + for (BeatmapPatternAnalysis.PatternScore type : patternAnalysis.types()) { + patternWeight += (PATTERN_WEIGHTS.getOrDefault(type.type(), 10) * type.percentage()); + } + + final double modWeightFactor = getModWeightFactor(entry); + final double attributeFactor = getAttributeFactor(difficultyAttribute); + + return (patternWeight * (100.0 - entry.bestIndex()) * modWeightFactor * attributeFactor) / 100.0; + } + + private double getModWeightFactor(ScoreEntry entry) { + final ModSet mods = new ModSet(entry.score().getMods().stream().map(Mod::getAcronym).filter(Objects::nonNull).collect(Collectors.toSet())); + + if (mods.is("EZHD")) + return 2.0; + + if (mods.is("EZ")) + return 1.5; + + if (mods.is("HRHD")) + return 1.5; + + if (mods.is("HR")) + return 1.25; + + if (mods.is("HDDT") || mods.is("HDNC")) + return 0.8; + + return 1.0; + } + + private double getAttributeFactor(DifficultyAttribute difficultyAttribute) { + double attributeFactor = 1.0; + + // Precision... + if (difficultyAttribute.cs() >= 8) { + attributeFactor *= 1.1; + } + + // Reading! + attributeFactor *= getArFactor(difficultyAttribute.ar()); + + return attributeFactor; + } + + private static double getArFactor(double ar) { + if (ar >= 8.0) { + return 1.0; + } + + return 1.0 + 2.5 * Math.pow((8.0 - ar) / 4.0, 1.25); + } + + private record ModSet(Set acronyms) { + public boolean is(Collection acronyms) { + return this.acronyms.containsAll(acronyms) && this.acronyms.size() == acronyms.size(); + } + + public boolean is(String acronyms) { + if (acronyms.length() % 2 != 0) { + throw new IllegalArgumentException("Invalid mod string: " + acronyms); + } + + List result = new ArrayList<>(); + + for (int i = 0; i < acronyms.length(); i += 2) { + result.add(acronyms.substring(i, i + 2)); + } + + return this.is(result); + } + } + private record ScoreEntry(int bestIndex, Score score) { } private record TargetScore(ScoreEntry entry, UserExtended user) { } + + private record RandomScoreRequest( + List uids, + @SerializedName("weight_factor") WeightFactor weightFactor + ) { + } + + private record WeightFactor( + Map scores, + Map users + ) { + } } diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/UserController.java b/src/main/java/xyz/zcraft/ostella/network/controller/UserController.java index 2fd230c..d6a2f89 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/UserController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/UserController.java @@ -6,10 +6,13 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; -import xyz.zcraft.ostella.data.ScoreType; import xyz.zcraft.ostella.data.ScoreFilter; +import xyz.zcraft.ostella.data.ScoreType; import xyz.zcraft.ostella.exception.ApiException; -import xyz.zcraft.ostella.network.*; +import xyz.zcraft.ostella.network.ErrorCode; +import xyz.zcraft.ostella.network.OsuAPI; +import xyz.zcraft.ostella.network.Response; +import xyz.zcraft.ostella.network.Router; import xyz.zcraft.ostella.service.AsyncService; import xyz.zcraft.ostella.service.RenderService; import xyz.zcraft.ostella.util.TokenManager; @@ -48,6 +51,62 @@ public UserController(Router router) { this.tokenManager = router.tokenManager; } + private static List requireScoreFilters(Context context) { + try { + return ScoreFilter.parseList(context.queryParam("filters")); + } catch (IllegalArgumentException e) { + throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, e.getMessage(), e); + } + } + + private static int requireScoreListLimit(Context context) { + int limit = requirePositiveInt(context, "n"); + if (limit > OsuAPI.MAX_USER_SCORES_LIMIT) { + throw new ApiException( + ErrorCode.ILLEGAL_ARGUMENT, + "Score limit must not exceed " + OsuAPI.MAX_USER_SCORES_LIMIT + ); + } + return limit; + } + + static FilteredScores applyFilters(List scores, List filters) { + List result = new ArrayList<>(); + List originalPositions = new ArrayList<>(); + for (int index = 0; index < scores.size(); index++) { + Score score = scores.get(index); + if (filters.stream().allMatch(filter -> filter.matches(score))) { + result.add(score); + originalPositions.add(index + 1); + } + } + if (!filters.isEmpty() && result.isEmpty()) { + throw new ApiException(ErrorCode.NO_SCORE_FOUND, "No scores matched the filters"); + } + return new FilteredScores(List.copyOf(result), List.copyOf(originalPositions)); + } + + static FilteredScores filterBestScoresSince(List bestScores, Instant cutoff) { + List result = new ArrayList<>(); + List bestPositions = new ArrayList<>(); + for (int index = 0; index < bestScores.size(); index++) { + Score bestScore = bestScores.get(index); + try { + if (bestScore.getEndedAt() != null && !Instant.parse(bestScore.getEndedAt()).isBefore(cutoff)) { + result.add(bestScore); + bestPositions.add(index + 1); + } + } catch (DateTimeParseException e) { + LOG.warn("Ignoring best score {} with invalid ended_at: {}", bestScore.getId(), bestScore.getEndedAt()); + } + } + return new FilteredScores(List.copyOf(result), List.copyOf(bestPositions)); + } + + private static List filterLabels(List filters) { + return filters.stream().map(ScoreFilter::displayText).toList(); + } + public void getUsers(@NotNull Context context) { final JsonElement body = JsonParser.parseString(context.body()); final var uidArr = body.getAsJsonObject().getAsJsonArray("ids"); @@ -260,6 +319,23 @@ public void getUserInfo(@NotNull Context context) { .thenAccept(bytes -> context.status(200).result(bytes))); } + public void getUserRank(@NotNull Context context) { + final long userId = requirePathLong(context, "userId"); + final CompletableFuture userFuture = executor.enqueueAsync(() -> + OsuAPI.getUser(tokenManager.getTokenData(), userId)); + + context.future(() -> userFuture.thenApply(userExtended -> { + JsonObject result = new JsonObject(); + result.addProperty("id", userExtended.getId()); + result.addProperty("username", userExtended.getUsername()); + result.addProperty("global_rank", userExtended.getStatistics().getGlobalRank()); + result.addProperty("country_rank", userExtended.getStatistics().getRank().getCountry()); + result.addProperty("country_code", userExtended.getCountryCode()); + return result; + }) + .thenAccept(json -> putResult(context, json))); + } + public void getTodayBestScores(@NotNull Context context) { final long userId = requirePathLong(context, "userId"); final String daysParam = context.queryParam("days"); @@ -303,68 +379,6 @@ public void getTodayBestScores(@NotNull Context context) { .thenAccept(bytes -> context.status(200).result(bytes))); } - private static List requireScoreFilters(Context context) { - try { - return ScoreFilter.parseList(context.queryParam("filters")); - } catch (IllegalArgumentException e) { - throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, e.getMessage(), e); - } - } - - private static int requireScoreListLimit(Context context) { - int limit = requirePositiveInt(context, "n"); - if (limit > OsuAPI.MAX_USER_SCORES_LIMIT) { - throw new ApiException( - ErrorCode.ILLEGAL_ARGUMENT, - "Score limit must not exceed " + OsuAPI.MAX_USER_SCORES_LIMIT - ); - } - return limit; - } - - static FilteredScores applyFilters(List scores, List filters) { - List result = new ArrayList<>(); - List originalPositions = new ArrayList<>(); - for (int index = 0; index < scores.size(); index++) { - Score score = scores.get(index); - if (filters.stream().allMatch(filter -> filter.matches(score))) { - result.add(score); - originalPositions.add(index + 1); - } - } - if (!filters.isEmpty() && result.isEmpty()) { - throw new ApiException(ErrorCode.NO_SCORE_FOUND, "No scores matched the filters"); - } - return new FilteredScores(List.copyOf(result), List.copyOf(originalPositions)); - } - - static FilteredScores filterBestScoresSince(List bestScores, Instant cutoff) { - List result = new ArrayList<>(); - List bestPositions = new ArrayList<>(); - for (int index = 0; index < bestScores.size(); index++) { - Score bestScore = bestScores.get(index); - try { - if (bestScore.getEndedAt() != null && !Instant.parse(bestScore.getEndedAt()).isBefore(cutoff)) { - result.add(bestScore); - bestPositions.add(index + 1); - } - } catch (DateTimeParseException e) { - LOG.warn("Ignoring best score {} with invalid ended_at: {}", bestScore.getId(), bestScore.getEndedAt()); - } - } - return new FilteredScores(List.copyOf(result), List.copyOf(bestPositions)); - } - - private static List filterLabels(List filters) { - return filters.stream().map(ScoreFilter::displayText).toList(); - } - - record FilteredScores(List scores, List originalPositions) { - } - - private record UserInfoData(UserExtended user, List topScores) { - } - public void getFriends(@NotNull Context context) { final String auth = context.header("Authorization"); @@ -403,13 +417,20 @@ public void lookupUser(@NotNull Context context) { context.future(() -> executor .enqueueAsync(() -> OsuAPI.getUser(tokenManager.getTokenData(), body.userName())) .thenApply(u -> { - if (u == null) throw new ApiException(ErrorCode.NO_USER_FOUND, "No user found for the provided username!"); + if (u == null) + throw new ApiException(ErrorCode.NO_USER_FOUND, "No user found for the provided username!"); return (User) u; }) .thenAccept(u -> context.status(200).result(new Response(true, "Success", GSON.toJsonTree(u)).toString())) ); } + record FilteredScores(List scores, List originalPositions) { + } + + private record UserInfoData(UserExtended user, List topScores) { + } + public record UserLookupBody( @SerializedName("user_name") String userName ) { @@ -419,7 +440,8 @@ public record RecentScoresBatchBody( @SerializedName("user_ids") List userIds, @SerializedName("limit") int limit, @SerializedName("include_fails") boolean includeFails - ) {} + ) { + } public record BatchScore( @SerializedName("beatmap_id") long beatmapId, diff --git a/src/main/java/xyz/zcraft/ostella/service/CacheService.java b/src/main/java/xyz/zcraft/ostella/service/CacheService.java index c58a423..3f9be9d 100644 --- a/src/main/java/xyz/zcraft/ostella/service/CacheService.java +++ b/src/main/java/xyz/zcraft/ostella/service/CacheService.java @@ -7,9 +7,9 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; -import xyz.zcraft.ostella.data.TokenData; import xyz.zcraft.ostella.cache.CacheControlRequest; import xyz.zcraft.ostella.cache.CacheControlResult; +import xyz.zcraft.ostella.data.TokenData; import xyz.zcraft.ostella.network.OsuAPI; import xyz.zcraft.osu.model.Score; @@ -26,12 +26,7 @@ import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.time.Instant; -import java.util.Iterator; -import java.util.Comparator; -import java.util.Objects; -import java.util.Optional; -import java.util.List; -import java.util.Locale; +import java.util.*; import java.util.stream.Stream; import java.util.zip.Deflater; import java.util.zip.ZipEntry; @@ -151,11 +146,16 @@ public static String getImageSrc(String url) { if (!Files.exists(IMAGE_CACHE.resolve(fileName))) { try { - cacheImage(fileName, url); - LOG.debug("Image {} cached", fileName); - } catch (IOException e) { + if (cacheImage(fileName, url)) { + LOG.debug("Image {} cached", fileName); + } else { + LOG.error("Image {} failed to cache", fileName); + return ""; + } + } catch (Exception e) { LOG.error("Failed to download image!", e); - throw new RuntimeException("Failed to download image!", e); + return ""; +// throw new RuntimeException("Failed to download image!", e); } } @@ -163,9 +163,16 @@ public static String getImageSrc(String url) { + IMAGE_CACHE.resolve(getFileName(url)).toAbsolutePath().toString().replace("\\", "/"); } - private static void cacheImage(String fileName, String url) throws IOException { - Files.deleteIfExists(IMAGE_CACHE.resolve(fileName)); - Files.write(IMAGE_CACHE.resolve(fileName), Objects.requireNonNull(OsuAPI.getImageBytes(url))); + private static boolean cacheImage(String fileName, String url) throws IOException { + final byte[] imageBytes = OsuAPI.getImageBytes(url); + if (imageBytes == null) { + return false; + } + if (Files.exists(REPLAY_CACHE.resolve(fileName))) { + Files.deleteIfExists(IMAGE_CACHE.resolve(fileName)); + } + Files.write(IMAGE_CACHE.resolve(fileName), imageBytes); + return true; } public static Path getImagePathFromFilename(String filename) { diff --git a/src/main/java/xyz/zcraft/ostella/service/RenderService.java b/src/main/java/xyz/zcraft/ostella/service/RenderService.java index d88dee3..6a49ffb 100644 --- a/src/main/java/xyz/zcraft/ostella/service/RenderService.java +++ b/src/main/java/xyz/zcraft/ostella/service/RenderService.java @@ -335,6 +335,9 @@ public byte[] renderScoreAnalysis(AnalyzeController.ScoreAnalyzeData analyzeData ctx.setVariable("sliderTickBreakTimes", analyzeData.performanceGraph().sliderTickBreaks()); ctx.setVariable("sliderEndBreakTimes", analyzeData.performanceGraph().sliderEndBreaks()); ctx.setVariable("mapEndTime", analyzeData.performanceGraph().mapEndTime()); + ctx.setVariable("isLazerScore", analyzeData.isLazerScore()); + ctx.setVariable("doSimMatch", analyzeData.doSimMatch()); + ctx.setVariable("simHitResult", analyzeData.simHitResult()); String finalHtml = templateEngine.process("score-analysis", ctx); diff --git a/src/main/java/xyz/zcraft/ostella/util/Colors.java b/src/main/java/xyz/zcraft/ostella/util/Colors.java index 0a63819..b3c9935 100644 --- a/src/main/java/xyz/zcraft/ostella/util/Colors.java +++ b/src/main/java/xyz/zcraft/ostella/util/Colors.java @@ -80,7 +80,7 @@ public static String getModColor(String acronym) { return switch (acronym) { case "EZ", "NF", "HT", "DC" -> "#b2ff66"; case "HR", "SD", "PF", "DT", "NC", "HD", "TC", "FL", "BL", "ST", "AC" -> "#ff6666"; - case "AT", "CN", "RX", "AP", "SO", "V2" -> "#66ccff"; + case "AT", "CN", "RX", "AP", "SO" -> "#66ccff"; case "TP", "DA", "CL", "RD", "MR", "AL", "SG" -> "#8c66ff"; case "TR", "WG", "SI", "GR", "DF", "WU", "WD", "BR", "AD", "MU", "NS", "MG", "RP", "AS", "FR", "BU", "SY", "DP", "BM" -> "#ff66ab"; diff --git a/src/main/java/xyz/zcraft/ostella/util/WeightedRandom.java b/src/main/java/xyz/zcraft/ostella/util/WeightedRandom.java new file mode 100644 index 0000000..fa1c426 --- /dev/null +++ b/src/main/java/xyz/zcraft/ostella/util/WeightedRandom.java @@ -0,0 +1,70 @@ +package xyz.zcraft.ostella.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.concurrent.ThreadLocalRandom; + +public class WeightedRandom { + private final List items = new ArrayList<>(); + private final List cumulative = new ArrayList<>(); + + public void add(T item, double weight) { + if (weight <= 0 || Double.isNaN(weight) || Double.isInfinite(weight)) { + throw new IllegalArgumentException("Weight must be positive and finite"); + } + + items.add(item); + + double previous = cumulative.isEmpty() ? 0.0 : cumulative.getLast(); + + cumulative.add(previous + weight); + } + + public T next() { + return items.get(randomIndex()); + } + + public T getAndRemove() { + int index = randomIndex(); + + T result = items.remove(index); + + double previous = index == 0 ? 0.0 : cumulative.get(index - 1); + + double removedWeight = cumulative.get(index) - previous; + + cumulative.remove(index); + + for (int i = index; i < cumulative.size(); i++) { + cumulative.set(i, cumulative.get(i) - removedWeight); + } + + return result; + } + + private int randomIndex() { + if (items.isEmpty()) { + throw new NoSuchElementException("WeightedRandom is empty"); + } + + double total = cumulative.getLast(); + double random = ThreadLocalRandom.current().nextDouble(total); + + for (int i = 0; i < cumulative.size(); i++) { + if (random < cumulative.get(i)) { + return i; + } + } + + throw new IllegalStateException(); + } + + public boolean isEmpty() { + return items.isEmpty(); + } + + public int size() { + return items.size(); + } +} \ No newline at end of file diff --git a/src/main/java/xyz/zcraft/ostella/util/format/ScoreFormatUtil.java b/src/main/java/xyz/zcraft/ostella/util/format/ScoreFormatUtil.java index 3b6f7cf..f77cdac 100644 --- a/src/main/java/xyz/zcraft/ostella/util/format/ScoreFormatUtil.java +++ b/src/main/java/xyz/zcraft/ostella/util/format/ScoreFormatUtil.java @@ -1,5 +1,6 @@ package xyz.zcraft.ostella.util.format; +import xyz.zcraft.ostella.service.CacheService; import xyz.zcraft.ostella.util.Colors; import xyz.zcraft.ostella.util.MiscUtil; import xyz.zcraft.ostella.data.ScoreId; @@ -133,6 +134,11 @@ public static long getSliderTickMax(Score score) { public static long getSliderEndMax(Score score) { return score.getMaximumStatistics().getOrDefault("slider_tail_hit", 0L); } + + public static boolean replayPresent(Score score) { + if (score == null) return false; + return score.getHasReplay() || CacheService.hasReplayCache(score.getId()); + } } diff --git a/src/main/resources/danser-config-patch-obscured.json b/src/main/resources/danser-config-patch-obscured.json index 3c5f85e..b1863bb 100644 --- a/src/main/resources/danser-config-patch-obscured.json +++ b/src/main/resources/danser-config-patch-obscured.json @@ -1,11 +1,23 @@ { "Gameplay": { + "Score": { + "Show": false + }, + "ComboCounter": { + "Show": false + }, "PPCounter": { "Show": false }, "ScoreBoard": { "Show": false }, + "HitCounter": { + "Show": false + }, + "Mods": { + "Show": false + }, "ShowResultsScreen": false } } \ No newline at end of file diff --git a/src/main/resources/template/multiplayer-room-result.html b/src/main/resources/template/multiplayer-room-result.html index b8e04c4..e98f2a6 100644 --- a/src/main/resources/template/multiplayer-room-result.html +++ b/src/main/resources/template/multiplayer-room-result.html @@ -702,7 +702,7 @@ clip-path: polygon( 0 0, calc(99.2%) 0, - calc(98.88%) 100%, + calc(98.7%) 100%, 0 100%); text-align: right; @@ -713,10 +713,10 @@ width: 97%; right: 0; clip-path: polygon( - calc(1.4%) 0, + calc(1.3%) 0, 100% 0, 100% 100%, - calc(0.5%) 100% + calc(0.7%) 100% ); text-align: left; background-color: var(--blue-team); @@ -732,9 +732,9 @@ text-align: right; } - .duelist.winner { - box-shadow: inset 0 4px 0 var(--accent); - } + /*.duelist.winner {*/ + /* box-shadow: inset 0 4px 0 var(--accent);*/ + /*}*/ .duelist.winner::after { background: linear-gradient(90deg, rgba(244, 114, 182, 0.13), rgba(21, 24, 41, 0.94)); diff --git a/src/main/resources/template/score-analysis.html b/src/main/resources/template/score-analysis.html index 7de5637..453085a 100644 --- a/src/main/resources/template/score-analysis.html +++ b/src/main/resources/template/score-analysis.html @@ -81,7 +81,7 @@ .score-info { display: flex; padding: 16px; - margin-bottom: 16px; + margin-bottom: 4px; background: rgba(0, 0, 0, 0.3); } @@ -268,6 +268,17 @@ align-items: baseline; } + .warning { + font-size: 0.8em; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.3); + color: #a0a0b8; + padding: 2px 100px; + margin-bottom: 4px; + } + .charts-container { padding: 0 24px 24px 24px; display: flex; @@ -277,7 +288,7 @@ } .performance-chart-container { - height: 310px; + height: 350px; padding: 0 24px 24px 24px; } @@ -455,12 +466,12 @@
- + - mod + mod
@@ -497,13 +508,13 @@ ★6.84
-
Example Difficulty
M1234567 - S1234567890
- ▣ + ▣
@@ -568,6 +579,19 @@
+
+
+
⚠
+
Score was not set in lazer. Results may differ from game.
+
⚠
+
+
+
⚠
+
Replay sim result mismatch (123 / 456 / 789 / 123). Analysis may be inaccurate.
+
⚠
+
+
+
Performance Graph
@@ -595,7 +619,8 @@ 20.0%
186.2pp
+ th:text="${#numbers.formatDecimal(skill.pp(), 1, 1) + 'pp'}">186.2pp +
@@ -618,7 +643,7 @@
-
Unstable Rate
+
cv. UR
123.45
@@ -876,10 +901,10 @@ showSymbol: false, silent: true, - lineStyle: { opacity: 0 }, - itemStyle: { opacity: 0 }, + lineStyle: {opacity: 0}, + itemStyle: {opacity: 0}, - emphasis: { disabled: true }, + emphasis: {disabled: true}, z: -1 }, @@ -1226,7 +1251,7 @@ textVerticalAlign: 'middle', opacity: 0.85 } - },{ + }, { type: 'text', id: 'overshoot-label', right: '1%', diff --git a/src/main/resources/template/score-list.html b/src/main/resources/template/score-list.html index 7a49ff3..da42fb4 100644 --- a/src/main/resources/template/score-list.html +++ b/src/main/resources/template/score-list.html @@ -371,7 +371,7 @@ .diff { display: flex; align-items: center; - gap: 10px; + /*gap: 10px;*/ } .diff-title { @@ -380,6 +380,13 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + margin-left: 5px; + } + + .replay-icon { + margin-left: 2px; + font-size: 0.8em; + transform: translateY(-1px); } .score-status { @@ -593,12 +600,12 @@
- + - mod + mod
jacketThis Is An Example Song Title
- 标题 - // + 标题 + // This Is An Example Artist M123456789
@@ -645,6 +654,7 @@
#1
S1234567890
+
▶
2026-04-11T23:30:27Z
diff --git a/src/main/resources/template/single-score.html b/src/main/resources/template/single-score.html index fb82479..9a4ce2e 100644 --- a/src/main/resources/template/single-score.html +++ b/src/main/resources/template/single-score.html @@ -231,7 +231,7 @@ } .replay-badge { - transform: translate(2px); + transform: translate(2px, 1px); font-size: 0.85em; font-weight: bold; } @@ -521,7 +521,7 @@ src="https://a.ppy.sh/2" th:src="${cache.getImageSrc(score.getUser().getAvatarUrl())}">
-
+
▶
15 mins ago