From 20d5aaa77bf0a9bee2319fc0f332519f0636308c Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Mon, 24 Aug 2026 11:11:41 +0800 Subject: [PATCH 01/36] Refactor image caching logic in CacheService.java --- .../zcraft/ostella/service/CacheService.java | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) 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) { From ef7c1b0f72d0cf50d7a1d460f259b004b245006a Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Mon, 24 Aug 2026 17:37:59 +0800 Subject: [PATCH 02/36] Update color codes and adjust layout in multiplayer-room-result.html --- src/main/java/xyz/zcraft/ostella/util/Colors.java | 2 +- .../template/multiplayer-room-result.html | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) 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/resources/template/multiplayer-room-result.html b/src/main/resources/template/multiplayer-room-result.html index b8e04c4..86c0b9d 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)); @@ -1535,7 +1535,7 @@
-
+
WINNER
From 92859034a1eead2d429c2ff645a002902601b529 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Mon, 24 Aug 2026 17:41:43 +0800 Subject: [PATCH 03/36] Update color codes and adjust layout in multiplayer-room-result.html --- src/main/resources/template/multiplayer-room-result.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/template/multiplayer-room-result.html b/src/main/resources/template/multiplayer-room-result.html index 86c0b9d..e98f2a6 100644 --- a/src/main/resources/template/multiplayer-room-result.html +++ b/src/main/resources/template/multiplayer-room-result.html @@ -1535,7 +1535,7 @@
-
+
WINNER
From 0b921248b4eac6f5789e3444d9ab45cb34661f70 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sat, 29 Aug 2026 00:56:20 +0800 Subject: [PATCH 04/36] Enhance score analysis with lazer score detection and simulation match results --- .../network/controller/AnalyzeController.java | 34 ++++++++++++- .../zcraft/ostella/service/RenderService.java | 3 ++ .../resources/template/score-analysis.html | 51 ++++++++++++++----- 3 files changed, 73 insertions(+), 15 deletions(-) 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/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/resources/template/score-analysis.html b/src/main/resources/template/score-analysis.html index 7de5637..26ebf2f 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 +
@@ -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%', From c570bef47eed0f17e451e3769e27725529081447 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sat, 29 Aug 2026 01:01:06 +0800 Subject: [PATCH 05/36] Bump version to 1.11.2 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 4d23d4a..eb7114b 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira oStella - 1.11.1 + 1.11.2 25 From bda794b31db098f1ae5b990a29329f66573b3109 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 16:16:14 +0800 Subject: [PATCH 06/36] Optimized random score selection to weight scores according to their beatmap types(#57) --- .../network/controller/ScoreController.java | 90 ++++++++++++------- .../zcraft/ostella/util/WeightedRandom.java | 34 +++++++ 2 files changed, 92 insertions(+), 32 deletions(-) create mode 100644 src/main/java/xyz/zcraft/ostella/util/WeightedRandom.java 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..7adc1a0 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -6,8 +6,8 @@ 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; @@ -18,21 +18,21 @@ import xyz.zcraft.ostella.service.CacheService; import xyz.zcraft.ostella.service.RenderService; import xyz.zcraft.ostella.util.TokenManager; +import xyz.zcraft.ostella.util.WeightedRandom; 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.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.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; @@ -42,6 +42,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, 100, + BeatmapPatternAnalysis.PatternType.READING, 60, + BeatmapPatternAnalysis.PatternType.FLOW, 55, + BeatmapPatternAnalysis.PatternType.STREAM, 40, + BeatmapPatternAnalysis.PatternType.ALT, 20, + BeatmapPatternAnalysis.PatternType.AIM, 10 + ); public final RenderService renderer; public final AsyncService executor; public final TokenManager tokenManager; @@ -54,6 +62,27 @@ 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; + } + public void lookupScore(@NotNull Context context) { if (context.queryParam("of") != null) { lookupScoreOfRefAsync(context); @@ -230,27 +259,6 @@ 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(() -> @@ -314,16 +322,16 @@ private CompletableFuture findAvailableScore(List userIds, in tokenManager.getTokenData(), userId, ScoreType.BEST, - 10 + 50 ) ).thenCompose(scores -> { - if (scores.size() < 8) { + if (scores.size() < 20) { return findAvailableScore(userIds, index + 1, minRank); } List candidates = new ArrayList<>(5); - for (int i = 0; i < 7; i++) { + for (int i = 0; i < 19; i++) { if (!scores.get(i).getHasReplay()) { continue; } @@ -344,9 +352,27 @@ private CompletableFuture findAvailableScore(List userIds, in return findAvailableScore(userIds, index + 1, minRank); } - ScoreEntry selected = candidates.get( - ThreadLocalRandom.current().nextInt(candidates.size()) - ); + WeightedRandom randomScores = new WeightedRandom<>(); + + for (ScoreEntry current : candidates) { + final Long beatmapId = current.score().getBeatmapId(); + try { + final OsuBeatmap osuBeatmap = BeatmapParser.parseBeatmap(CacheService.getBeatmapPath(beatmapId)); + final BeatmapPatternAnalysis patternAnalysis = BeatmapPatternAnalyzer.analyze(osuBeatmap, null); + final Integer patternWeight = PATTERN_WEIGHTS.getOrDefault(patternAnalysis.primaryType().type(), 10); + final int weight = patternWeight * (100 - current.bestIndex()) / 100; + randomScores.add(current, weight); + } catch (ParseException e) { + LOG.warn("Failed to parse beatmap with id {}", beatmapId, e); + } + } + + if (randomScores.size() == 0) { + return findAvailableScore(userIds, index + 1, minRank); + } + + + ScoreEntry selected = randomScores.next(); return CompletableFuture.completedFuture(new TargetScore(selected, user)); }); 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..9801e78 --- /dev/null +++ b/src/main/java/xyz/zcraft/ostella/util/WeightedRandom.java @@ -0,0 +1,34 @@ +package xyz.zcraft.ostella.util; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; + +public class WeightedRandom { + private final List items = new ArrayList<>(); + private final List cumulative = new ArrayList<>(); + private final Random random = new Random(); + + public void add(T item, int weight) { + items.add(item); + int last = cumulative.isEmpty() ? 0 : cumulative.getLast(); + cumulative.add(last + weight); + } + + public T next() { + int total = cumulative.getLast(); + int r = random.nextInt(total); + + for (int i = 0; i < cumulative.size(); i++) { + if (r < cumulative.get(i)) { + return items.get(i); + } + } + + throw new IllegalStateException(); + } + + public int size() { + return items.size(); + } +} From 49e82b79eabaac0c4bb13be18dfdb1e29ed91bb8 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 16:46:32 +0800 Subject: [PATCH 07/36] Add support for @user showcase targets --- pom.xml | 2 +- .../zcraft/ostella/network/controller/ReplayController.java | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index eb7114b..037ee8e 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira oStella - 1.11.2 + 1.11.3 25 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) From 4307ebc5cf966bb90dadf38ab704a84fdc20d70c Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 17:37:09 +0800 Subject: [PATCH 08/36] Adjusted weight and make danser obscure more info --- .../ostella/network/controller/ScoreController.java | 8 ++++---- src/main/resources/danser-config-patch-obscured.json | 9 +++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) 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 7adc1a0..14cbbdb 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -43,10 +43,10 @@ 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, 100, - BeatmapPatternAnalysis.PatternType.READING, 60, - BeatmapPatternAnalysis.PatternType.FLOW, 55, - BeatmapPatternAnalysis.PatternType.STREAM, 40, + BeatmapPatternAnalysis.PatternType.TECH, 150, + BeatmapPatternAnalysis.PatternType.READING, 100, + BeatmapPatternAnalysis.PatternType.FLOW, 60, + BeatmapPatternAnalysis.PatternType.STREAM, 50, BeatmapPatternAnalysis.PatternType.ALT, 20, BeatmapPatternAnalysis.PatternType.AIM, 10 ); diff --git a/src/main/resources/danser-config-patch-obscured.json b/src/main/resources/danser-config-patch-obscured.json index 3c5f85e..0169de9 100644 --- a/src/main/resources/danser-config-patch-obscured.json +++ b/src/main/resources/danser-config-patch-obscured.json @@ -1,11 +1,20 @@ { "Gameplay": { + "Score": { + "Show": false + }, + "ComboCounter": { + "Show": false + }, "PPCounter": { "Show": false }, "ScoreBoard": { "Show": false }, + "HitCounter": { + "Show": false + }, "ShowResultsScreen": false } } \ No newline at end of file From 634fd7644aa02a530083e093c5ee171297ff7901 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 18:18:07 +0800 Subject: [PATCH 09/36] Adjusted weight --- .../network/controller/ScoreController.java | 25 ++++++++++++------- src/main/resources/template/single-score.html | 2 +- 2 files changed, 17 insertions(+), 10 deletions(-) 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 14cbbdb..d20793c 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -45,9 +45,9 @@ public class ScoreController { private static final Map PATTERN_WEIGHTS = Map.of( BeatmapPatternAnalysis.PatternType.TECH, 150, BeatmapPatternAnalysis.PatternType.READING, 100, - BeatmapPatternAnalysis.PatternType.FLOW, 60, - BeatmapPatternAnalysis.PatternType.STREAM, 50, - BeatmapPatternAnalysis.PatternType.ALT, 20, + BeatmapPatternAnalysis.PatternType.FLOW, 80, + BeatmapPatternAnalysis.PatternType.STREAM, 70, + BeatmapPatternAnalysis.PatternType.ALT, 60, BeatmapPatternAnalysis.PatternType.AIM, 10 ); public final RenderService renderer; @@ -355,15 +355,11 @@ private CompletableFuture findAvailableScore(List userIds, in WeightedRandom randomScores = new WeightedRandom<>(); for (ScoreEntry current : candidates) { - final Long beatmapId = current.score().getBeatmapId(); try { - final OsuBeatmap osuBeatmap = BeatmapParser.parseBeatmap(CacheService.getBeatmapPath(beatmapId)); - final BeatmapPatternAnalysis patternAnalysis = BeatmapPatternAnalyzer.analyze(osuBeatmap, null); - final Integer patternWeight = PATTERN_WEIGHTS.getOrDefault(patternAnalysis.primaryType().type(), 10); - final int weight = patternWeight * (100 - current.bestIndex()) / 100; + final int weight = getWeight(current); randomScores.add(current, weight); } catch (ParseException e) { - LOG.warn("Failed to parse beatmap with id {}", beatmapId, e); + LOG.warn("Failed to parse beatmap with id {}", current.score().getBeatmapId(), e); } } @@ -379,6 +375,17 @@ private CompletableFuture findAvailableScore(List userIds, in }); } + private int getWeight(ScoreEntry entry) throws ParseException { + final Long beatmapId = entry.score().getBeatmapId(); + final OsuBeatmap osuBeatmap = BeatmapParser.parseBeatmap(CacheService.getBeatmapPath(beatmapId)); + final BeatmapPatternAnalysis patternAnalysis = BeatmapPatternAnalyzer.analyze(osuBeatmap, null); + int patternWeight = 0; + for (BeatmapPatternAnalysis.PatternScore type : patternAnalysis.types()) { + patternWeight = (int) (patternWeight + PATTERN_WEIGHTS.getOrDefault(type.type(), 10) * 100 * type.percentage()); + } + return patternWeight * (100 - entry.bestIndex()) / 100; + } + private record ScoreEntry(int bestIndex, Score score) { } diff --git a/src/main/resources/template/single-score.html b/src/main/resources/template/single-score.html index fb82479..dd21629 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; } From 9917a5fa3511b833e2f358278870fbe7c398d7c0 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 18:36:44 +0800 Subject: [PATCH 10/36] Adjusted weight --- .../ostella/network/controller/ScoreController.java | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) 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 d20793c..5705ae4 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -48,7 +48,7 @@ public class ScoreController { BeatmapPatternAnalysis.PatternType.FLOW, 80, BeatmapPatternAnalysis.PatternType.STREAM, 70, BeatmapPatternAnalysis.PatternType.ALT, 60, - BeatmapPatternAnalysis.PatternType.AIM, 10 + BeatmapPatternAnalysis.PatternType.AIM, 5 ); public final RenderService renderer; public final AsyncService executor; @@ -317,21 +317,23 @@ private CompletableFuture findAvailableScore(List userIds, in long userId = userIds.get(index); + final int SCORE_LIMIT = 20; + return executor.enqueueAsync(() -> OsuAPI.getUserScores( tokenManager.getTokenData(), userId, ScoreType.BEST, - 50 + SCORE_LIMIT ) ).thenCompose(scores -> { - if (scores.size() < 20) { + if (scores.size() < SCORE_LIMIT) { return findAvailableScore(userIds, index + 1, minRank); } List candidates = new ArrayList<>(5); - for (int i = 0; i < 19; i++) { + for (int i = 0; i < SCORE_LIMIT - 1; i++) { if (!scores.get(i).getHasReplay()) { continue; } @@ -381,7 +383,7 @@ private int getWeight(ScoreEntry entry) throws ParseException { final BeatmapPatternAnalysis patternAnalysis = BeatmapPatternAnalyzer.analyze(osuBeatmap, null); int patternWeight = 0; for (BeatmapPatternAnalysis.PatternScore type : patternAnalysis.types()) { - patternWeight = (int) (patternWeight + PATTERN_WEIGHTS.getOrDefault(type.type(), 10) * 100 * type.percentage()); + patternWeight = (int) (patternWeight + PATTERN_WEIGHTS.getOrDefault(type.type(), 10) * type.percentage()); } return patternWeight * (100 - entry.bestIndex()) / 100; } From 79c313e718dca61c8ed3449c567cfc3d8f7476c8 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 19:02:23 +0800 Subject: [PATCH 11/36] Add function to get random score from given user list --- pom.xml | 2 +- .../xyz/zcraft/ostella/network/WebServer.java | 1 + .../network/controller/ScoreController.java | 82 ++++++++++++------- 3 files changed, 54 insertions(+), 31 deletions(-) diff --git a/pom.xml b/pom.xml index 037ee8e..a994c4d 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira oStella - 1.11.3 + 1.11.4 25 diff --git a/src/main/java/xyz/zcraft/ostella/network/WebServer.java b/src/main/java/xyz/zcraft/ostella/network/WebServer.java index 00e463a..903a961 100644 --- a/src/main/java/xyz/zcraft/ostella/network/WebServer.java +++ b/src/main/java/xyz/zcraft/ostella/network/WebServer.java @@ -58,6 +58,7 @@ 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/{scoreId}", router.scoreController::renderScoreById) .get("/scores/{scoreId}/analysis", router.analyzeController::renderScoreAnalysisById) .get("/scores/{scoreId}/highlight", router.analyzeController::getScoreHighlight) 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 5705ae4..abd2a8a 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -1,7 +1,6 @@ package xyz.zcraft.ostella.network.controller; -import com.google.gson.Gson; -import com.google.gson.JsonObject; +import com.google.gson.*; import io.javalin.http.Context; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -83,6 +82,36 @@ static int scoreLookupFetchLimit(int index, List filters) { return filters.isEmpty() ? index : OsuAPI.MAX_USER_SCORES_LIMIT; } + private static JsonObject getRandomScoreFromUids(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 void lookupScore(@NotNull Context context) { if (context.queryParam("of") != null) { lookupScoreOfRefAsync(context); @@ -273,35 +302,28 @@ public void randomScore(@NotNull Context context) { 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; - } + .thenApply(ScoreController::getRandomScoreFromUids) + .thenAccept(result -> + context.status(200).result(new Response(true, "Success", result).toString()) ) + ); + } + + public void randomScoreFromUsers(@NotNull Context context) { + final JsonArray usersArray = JsonParser.parseString(context.body()).getAsJsonObject().get("users").getAsJsonArray(); + if (usersArray == null || usersArray.isJsonNull() || usersArray.isEmpty()) { + throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, "No users provided!"); + } + + final List userIds = new ArrayList<>(); + + for (JsonElement element : usersArray) { + userIds.add(element.getAsLong()); + } + + context.future(() -> + findAvailableScore(userIds, 0, Long.MAX_VALUE) + .thenApply(ScoreController::getRandomScoreFromUids) .thenAccept(result -> context.status(200).result(new Response(true, "Success", result).toString()) ) From b00131232a60638d7d9e30a5f5301f1d351ed3a0 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 19:04:14 +0800 Subject: [PATCH 12/36] Updated README.md --- README.md | 48 +++++++++++++++++++++++++----------------------- 1 file changed, 25 insertions(+), 23 deletions(-) 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) From 7ac442a9c1cd93b2e17b71d69be479cc4f6f3971 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 19:10:54 +0800 Subject: [PATCH 13/36] Fix key name in randomScoreFromUsers method to use 'uids' instead of 'users' --- .../xyz/zcraft/ostella/network/controller/ScoreController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 abd2a8a..381684d 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -310,7 +310,7 @@ public void randomScore(@NotNull Context context) { } public void randomScoreFromUsers(@NotNull Context context) { - final JsonArray usersArray = JsonParser.parseString(context.body()).getAsJsonObject().get("users").getAsJsonArray(); + final JsonArray usersArray = JsonParser.parseString(context.body()).getAsJsonObject().get("uids").getAsJsonArray(); if (usersArray == null || usersArray.isJsonNull() || usersArray.isEmpty()) { throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, "No users provided!"); } From ab503cd33004e393e5eb307a53f05ca1903f717a Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 19:57:39 +0800 Subject: [PATCH 14/36] Adjust pattern weights and rename score mapping method --- .../network/controller/ScoreController.java | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) 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 381684d..052fd64 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -42,12 +42,12 @@ 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, 150, - BeatmapPatternAnalysis.PatternType.READING, 100, - BeatmapPatternAnalysis.PatternType.FLOW, 80, - BeatmapPatternAnalysis.PatternType.STREAM, 70, - BeatmapPatternAnalysis.PatternType.ALT, 60, - BeatmapPatternAnalysis.PatternType.AIM, 5 + BeatmapPatternAnalysis.PatternType.TECH, 600, + BeatmapPatternAnalysis.PatternType.READING, 500, + BeatmapPatternAnalysis.PatternType.FLOW, 400, + BeatmapPatternAnalysis.PatternType.STREAM, 300, + BeatmapPatternAnalysis.PatternType.ALT, 300, + BeatmapPatternAnalysis.PatternType.AIM, 1 ); public final RenderService renderer; public final AsyncService executor; @@ -82,7 +82,7 @@ static int scoreLookupFetchLimit(int index, List filters) { return filters.isEmpty() ? index : OsuAPI.MAX_USER_SCORES_LIMIT; } - private static JsonObject getRandomScoreFromUids(TargetScore targetScore) { + private static JsonObject mapScoreToJson(TargetScore targetScore) { final OsuBeatmap osuBeatmap; final DiffSpec diffSpec; @@ -302,7 +302,7 @@ public void randomScore(@NotNull Context context) { return userIds; }) .thenCompose(userIds -> findAvailableScore(userIds, 0, minRank)) - .thenApply(ScoreController::getRandomScoreFromUids) + .thenApply(ScoreController::mapScoreToJson) .thenAccept(result -> context.status(200).result(new Response(true, "Success", result).toString()) ) @@ -321,9 +321,11 @@ public void randomScoreFromUsers(@NotNull Context context) { userIds.add(element.getAsLong()); } +// Collections.shuffle(userIds); + context.future(() -> findAvailableScore(userIds, 0, Long.MAX_VALUE) - .thenApply(ScoreController::getRandomScoreFromUids) + .thenApply(ScoreController::mapScoreToJson) .thenAccept(result -> context.status(200).result(new Response(true, "Success", result).toString()) ) @@ -378,19 +380,27 @@ private CompletableFuture findAvailableScore(List userIds, in WeightedRandom randomScores = new WeightedRandom<>(); + HashMap weightedScores = new HashMap<>(); for (ScoreEntry current : candidates) { try { final int weight = getWeight(current); - randomScores.add(current, weight); + weightedScores.put(current, weight); } catch (ParseException e) { LOG.warn("Failed to parse beatmap with id {}", current.score().getBeatmapId(), e); } } - if (randomScores.size() == 0) { + if (weightedScores.isEmpty()) { return findAvailableScore(userIds, index + 1, minRank); } + final int maxWeight = weightedScores.values().stream().max(Integer::compareTo).get(); + + for (Map.Entry entry : weightedScores.entrySet()) { + int finalWeight = (int) (Math.pow(((double) entry.getValue() / maxWeight), 6) * 1000); + randomScores.add(entry.getKey(), finalWeight); +// LOG.debug("{}: {}", entry.getKey().score().getBeatmapset().getTitle(), finalWeight); + } ScoreEntry selected = randomScores.next(); @@ -405,7 +415,7 @@ private int getWeight(ScoreEntry entry) throws ParseException { final BeatmapPatternAnalysis patternAnalysis = BeatmapPatternAnalyzer.analyze(osuBeatmap, null); int patternWeight = 0; for (BeatmapPatternAnalysis.PatternScore type : patternAnalysis.types()) { - patternWeight = (int) (patternWeight + PATTERN_WEIGHTS.getOrDefault(type.type(), 10) * type.percentage()); + patternWeight += (int) (PATTERN_WEIGHTS.getOrDefault(type.type(), 10) * type.percentage()); } return patternWeight * (100 - entry.bestIndex()) / 100; } From bc69254c1e1855c997d1010480b2100a17ef977e Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 20:08:17 +0800 Subject: [PATCH 15/36] Shuffle user IDs before finding available score --- .../xyz/zcraft/ostella/network/controller/ScoreController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 052fd64..86ab97d 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -321,7 +321,7 @@ public void randomScoreFromUsers(@NotNull Context context) { userIds.add(element.getAsLong()); } -// Collections.shuffle(userIds); + Collections.shuffle(userIds); context.future(() -> findAvailableScore(userIds, 0, Long.MAX_VALUE) From 184a3a7aed0df8ee62e7aa777f8037584e5c2fbb Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 21:28:10 +0800 Subject: [PATCH 16/36] Refactor score replay checks and update HTML condition for replay display --- .../zcraft/ostella/network/controller/ScoreController.java | 6 ++++-- src/main/resources/template/single-score.html | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) 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 86ab97d..87db95a 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -358,11 +358,13 @@ private CompletableFuture findAvailableScore(List userIds, in List candidates = new ArrayList<>(5); for (int i = 0; i < SCORE_LIMIT - 1; i++) { - if (!scores.get(i).getHasReplay()) { + final Score score = scores.get(i); + + if (!score.getHasReplay() && !CacheService.hasReplayCache(score.getId())) { continue; } - candidates.add(new ScoreEntry(i + 1, scores.get(i))); + candidates.add(new ScoreEntry(i + 1, score)); } if (candidates.isEmpty()) { diff --git a/src/main/resources/template/single-score.html b/src/main/resources/template/single-score.html index dd21629..9a4ce2e 100644 --- a/src/main/resources/template/single-score.html +++ b/src/main/resources/template/single-score.html @@ -521,7 +521,7 @@ src="https://a.ppy.sh/2" th:src="${cache.getImageSrc(score.getUser().getAvatarUrl())}">
-
+
▶
15 mins ago From 8c8a90ff36db18e824169dac56f932d9a835f9ca Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 22:42:14 +0800 Subject: [PATCH 17/36] Hide Mods section in danser-config-patch-obscured.json --- src/main/resources/danser-config-patch-obscured.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/danser-config-patch-obscured.json b/src/main/resources/danser-config-patch-obscured.json index 0169de9..b1863bb 100644 --- a/src/main/resources/danser-config-patch-obscured.json +++ b/src/main/resources/danser-config-patch-obscured.json @@ -15,6 +15,9 @@ "HitCounter": { "Show": false }, + "Mods": { + "Show": false + }, "ShowResultsScreen": false } } \ No newline at end of file From e42fedc8ec7d2c8edd201aceed6fb0aea84bf023 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 02:33:35 +0800 Subject: [PATCH 18/36] Make score-analysis.html use cv. UR --- pom.xml | 2 +- src/main/resources/template/score-analysis.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index a994c4d..db11ca6 100644 --- a/pom.xml +++ b/pom.xml @@ -118,7 +118,7 @@ com.github.BotSeira osuParser - v1.5.1 + v1.5.2 diff --git a/src/main/resources/template/score-analysis.html b/src/main/resources/template/score-analysis.html index 26ebf2f..453085a 100644 --- a/src/main/resources/template/score-analysis.html +++ b/src/main/resources/template/score-analysis.html @@ -643,7 +643,7 @@
-
Unstable Rate
+
cv. UR
123.45
From 9b5936c7b8d3053fffb76f4eff1013baf19a3410 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 02:57:38 +0800 Subject: [PATCH 19/36] Add user rank endpoint and refactor score filtering methods --- .../xyz/zcraft/ostella/network/WebServer.java | 1 + .../network/controller/UserController.java | 154 ++++++++++-------- 2 files changed, 89 insertions(+), 66 deletions(-) diff --git a/src/main/java/xyz/zcraft/ostella/network/WebServer.java b/src/main/java/xyz/zcraft/ostella/network/WebServer.java index 903a961..afe9f03 100644 --- a/src/main/java/xyz/zcraft/ostella/network/WebServer.java +++ b/src/main/java/xyz/zcraft/ostella/network/WebServer.java @@ -78,6 +78,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/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, From 23565d23c73638d81454f0d7678755251fa1bc7f Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 03:20:36 +0800 Subject: [PATCH 20/36] Adjusted weight --- .../xyz/zcraft/ostella/network/controller/ScoreController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 87db95a..9bb5834 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -399,7 +399,7 @@ private CompletableFuture findAvailableScore(List userIds, in final int maxWeight = weightedScores.values().stream().max(Integer::compareTo).get(); for (Map.Entry entry : weightedScores.entrySet()) { - int finalWeight = (int) (Math.pow(((double) entry.getValue() / maxWeight), 6) * 1000); + int finalWeight = (int) (Math.pow(((double) entry.getValue() / maxWeight), 5) * 1000); randomScores.add(entry.getKey(), finalWeight); // LOG.debug("{}: {}", entry.getKey().score().getBeatmapset().getTitle(), finalWeight); } From 12619a6929e46351636403abca399e260f260183 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 04:08:54 +0800 Subject: [PATCH 21/36] Adjusted weight --- .../network/controller/ScoreController.java | 37 ++++++++++++++----- 1 file changed, 28 insertions(+), 9 deletions(-) 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 9bb5834..c38657d 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -53,6 +53,7 @@ public class ScoreController { public final AsyncService executor; public final TokenManager tokenManager; public final Router router; + private final HashMap previousSelections = new HashMap<>(); public ScoreController(Router router) { this.router = router; @@ -341,7 +342,7 @@ private CompletableFuture findAvailableScore(List userIds, in long userId = userIds.get(index); - final int SCORE_LIMIT = 20; + final int SCORE_LIMIT = 40; return executor.enqueueAsync(() -> OsuAPI.getUserScores( @@ -382,30 +383,48 @@ private CompletableFuture findAvailableScore(List userIds, in WeightedRandom randomScores = new WeightedRandom<>(); - HashMap weightedScores = new HashMap<>(); + HashMap baseWeights = new HashMap<>(); + for (ScoreEntry current : candidates) { try { - final int weight = getWeight(current); - weightedScores.put(current, weight); + baseWeights.put(current, getWeight(current)); } catch (ParseException e) { LOG.warn("Failed to parse beatmap with id {}", current.score().getBeatmapId(), e); } } - if (weightedScores.isEmpty()) { + if (baseWeights.isEmpty()) { return findAvailableScore(userIds, index + 1, minRank); } - final int maxWeight = weightedScores.values().stream().max(Integer::compareTo).get(); + final int maxWeight = baseWeights.values() + .stream() + .max(Integer::compareTo) + .get(); + + for (Map.Entry entry : baseWeights.entrySet()) { + final int selectedTimes = previousSelections.getOrDefault(entry.getKey().score().getId(), 0); + + final double redundantFactor = Math.max(0.4, 1 - selectedTimes * 0.2); + + final double normalizedWeight = (double) entry.getValue() / maxWeight; + + final int finalWeight = Math.max( + 1, + (int) (Math.pow(normalizedWeight, 5) * 1000 * redundantFactor) + ); - for (Map.Entry entry : weightedScores.entrySet()) { - int finalWeight = (int) (Math.pow(((double) entry.getValue() / maxWeight), 5) * 1000); randomScores.add(entry.getKey(), finalWeight); -// LOG.debug("{}: {}", entry.getKey().score().getBeatmapset().getTitle(), finalWeight); } ScoreEntry selected = randomScores.next(); + previousSelections.merge( + selected.score().getId(), + 1, + Integer::sum + ); + return CompletableFuture.completedFuture(new TargetScore(selected, user)); }); }); From 4f9aab03fc01a47f9208768937cff6f3b10f8f22 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 04:19:11 +0800 Subject: [PATCH 22/36] Minor fixes in ScoreController --- .../network/controller/ScoreController.java | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) 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 c38657d..1a8b385 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -33,6 +33,7 @@ import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ThreadLocalRandom; import java.util.stream.Collectors; @@ -53,7 +54,7 @@ public class ScoreController { public final AsyncService executor; public final TokenManager tokenManager; public final Router router; - private final HashMap previousSelections = new HashMap<>(); + private final ConcurrentHashMap previousSelections = new ConcurrentHashMap<>(); public ScoreController(Router router) { this.router = router; @@ -311,7 +312,20 @@ public void randomScore(@NotNull Context context) { } public void randomScoreFromUsers(@NotNull Context context) { - final JsonArray usersArray = JsonParser.parseString(context.body()).getAsJsonObject().get("uids").getAsJsonArray(); + JsonElement usersElement = JsonParser.parseString(context.body()) + .getAsJsonObject() + .get("uids"); + + if (usersElement == null || usersElement.isJsonNull() + || !usersElement.isJsonArray() || usersElement.getAsJsonArray().isEmpty()) { + throw new ApiException( + ErrorCode.ILLEGAL_ARGUMENT, + "No users provided!" + ); + } + + JsonArray usersArray = usersElement.getAsJsonArray(); + if (usersArray == null || usersArray.isJsonNull() || usersArray.isEmpty()) { throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, "No users provided!"); } @@ -358,7 +372,7 @@ private CompletableFuture findAvailableScore(List userIds, in List candidates = new ArrayList<>(5); - for (int i = 0; i < SCORE_LIMIT - 1; i++) { + for (int i = 0; i < SCORE_LIMIT; i++) { final Score score = scores.get(i); if (!score.getHasReplay() && !CacheService.hasReplayCache(score.getId())) { @@ -419,11 +433,7 @@ private CompletableFuture findAvailableScore(List userIds, in ScoreEntry selected = randomScores.next(); - previousSelections.merge( - selected.score().getId(), - 1, - Integer::sum - ); + previousSelections.merge(selected.score().getId(), 1, Integer::sum); return CompletableFuture.completedFuture(new TargetScore(selected, user)); }); From 6b67a8506ce606b94bd9aa6bd7d6428e79cb0e0f Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 04:30:39 +0800 Subject: [PATCH 23/36] Minor fixes in ScoreController --- .../zcraft/ostella/network/controller/ScoreController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1a8b385..c6ced09 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -48,7 +48,7 @@ public class ScoreController { BeatmapPatternAnalysis.PatternType.FLOW, 400, BeatmapPatternAnalysis.PatternType.STREAM, 300, BeatmapPatternAnalysis.PatternType.ALT, 300, - BeatmapPatternAnalysis.PatternType.AIM, 1 + BeatmapPatternAnalysis.PatternType.AIM, 10 ); public final RenderService renderer; public final AsyncService executor; @@ -425,7 +425,7 @@ private CompletableFuture findAvailableScore(List userIds, in final int finalWeight = Math.max( 1, - (int) (Math.pow(normalizedWeight, 5) * 1000 * redundantFactor) + (int) (Math.pow(normalizedWeight, 4) * 1000 * redundantFactor) ); randomScores.add(entry.getKey(), finalWeight); From 8e8486d3ff3f693fc515fa84417d42232f0f3671 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 04:31:17 +0800 Subject: [PATCH 24/36] Minor fixes in ScoreController --- .../xyz/zcraft/ostella/network/controller/ScoreController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 c6ced09..8d8efff 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -370,7 +370,7 @@ private CompletableFuture findAvailableScore(List userIds, in return findAvailableScore(userIds, index + 1, minRank); } - 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); From 4edec0e44e2479c47c7143e60f32e6963ed88a7d Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 19:30:08 +0800 Subject: [PATCH 25/36] Enhance score display with replay icon and style adjustments --- .../ostella/util/format/ScoreFormatUtil.java | 6 +++++ src/main/resources/template/score-list.html | 27 +++++++++++++------ 2 files changed, 25 insertions(+), 8 deletions(-) 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/template/score-list.html b/src/main/resources/template/score-list.html index 7a49ff3..6caf01b 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,10 +380,18 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + margin-left: 5px; } - .score-status { + .replay-icon { margin-left: auto; + font-size: 0.8em; + margin-right: 4px; + transform: translateY(-1px); + } + + .score-status { + /*margin-left: auto;*/ } .acc-number { @@ -593,12 +601,12 @@
- + - mod + mod
jacketThis Is An Example Song Title
- 标题 - // + 标题 + // This Is An Example Artist M123456789
@@ -622,6 +632,7 @@
This Is An Example Diff
+
▶
❤
From f79185052c416af128d4f7708a63a6bfddb6bbd2 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 19:33:18 +0800 Subject: [PATCH 26/36] Enhance score display with replay icon and style adjustments --- src/main/resources/template/score-list.html | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/resources/template/score-list.html b/src/main/resources/template/score-list.html index 6caf01b..da42fb4 100644 --- a/src/main/resources/template/score-list.html +++ b/src/main/resources/template/score-list.html @@ -384,14 +384,13 @@ } .replay-icon { - margin-left: auto; + margin-left: 2px; font-size: 0.8em; - margin-right: 4px; transform: translateY(-1px); } .score-status { - /*margin-left: auto;*/ + margin-left: auto; } .acc-number { @@ -632,7 +631,6 @@
This Is An Example Diff
-
▶
❤
@@ -656,6 +654,7 @@
#1
S1234567890
+
▶
2026-04-11T23:30:27Z
From 961bb996cb45890f0ae6f787a825705350729351 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 19:48:52 +0800 Subject: [PATCH 27/36] Add replay filter support in ScoreFilter --- .../java/xyz/zcraft/ostella/data/ScoreFilter.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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; } } From 184b32ebee2039bcac87a78f5577da48c10fd4ab Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 21:28:52 +0800 Subject: [PATCH 28/36] Update version to 1.11.5 and refactor ScoreController to use WeightedRandom for user selection --- pom.xml | 2 +- .../network/controller/ScoreController.java | 142 +++++++++--------- .../zcraft/ostella/util/WeightedRandom.java | 58 +++++-- 3 files changed, 119 insertions(+), 83 deletions(-) diff --git a/pom.xml b/pom.xml index db11ca6..48fa7a6 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira oStella - 1.11.4 + 1.11.5 25 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 8d8efff..5ed790e 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -1,6 +1,9 @@ package xyz.zcraft.ostella.network.controller; -import com.google.gson.*; +import com.google.gson.Gson; +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; @@ -33,9 +36,6 @@ import java.util.*; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ThreadLocalRandom; -import java.util.stream.Collectors; import static xyz.zcraft.ostella.util.RequestUtil.*; @@ -54,7 +54,6 @@ public class ScoreController { public final AsyncService executor; public final TokenManager tokenManager; public final Router router; - private final ConcurrentHashMap previousSelections = new ConcurrentHashMap<>(); public ScoreController(Router router) { this.router = router; @@ -295,15 +294,16 @@ public void randomScore(@NotNull Context context) { 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)) + .thenCompose(userIds -> findAvailableScore(userIds, minRank, Map.of())) .thenApply(ScoreController::mapScoreToJson) .thenAccept(result -> context.status(200).result(new Response(true, "Success", result).toString()) @@ -312,34 +312,30 @@ public void randomScore(@NotNull Context context) { } public void randomScoreFromUsers(@NotNull Context context) { - JsonElement usersElement = JsonParser.parseString(context.body()) - .getAsJsonObject() - .get("uids"); - - if (usersElement == null || usersElement.isJsonNull() - || !usersElement.isJsonArray() || usersElement.getAsJsonArray().isEmpty()) { - throw new ApiException( - ErrorCode.ILLEGAL_ARGUMENT, - "No users provided!" - ); - } + final JsonObject body = JsonParser.parseString(context.body()).getAsJsonObject(); - JsonArray usersArray = usersElement.getAsJsonArray(); + final RandomScoreRequest randomScoreRequest = GSON.fromJson(body, RandomScoreRequest.class); - if (usersArray == null || usersArray.isJsonNull() || usersArray.isEmpty()) { + if (randomScoreRequest.uids() == null || randomScoreRequest.uids().isEmpty()) { throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, "No users provided!"); } - final List userIds = new ArrayList<>(); + WeightedRandom userIds = new WeightedRandom<>(); - for (JsonElement element : usersArray) { - userIds.add(element.getAsLong()); + for (Long uid : randomScoreRequest.uids()) { + final double weight = Optional.ofNullable(randomScoreRequest.weightFactor()) + .map(WeightFactor::users) + .map(m -> m.get(uid)) + .orElse(1.0); + userIds.add(uid, weight); } - Collections.shuffle(userIds); + final Map scoreWeights = Optional.ofNullable(randomScoreRequest.weightFactor()) + .map(WeightFactor::scores) + .orElse(Map.of()); context.future(() -> - findAvailableScore(userIds, 0, Long.MAX_VALUE) + findAvailableScore(userIds, Long.MAX_VALUE, scoreWeights) .thenApply(ScoreController::mapScoreToJson) .thenAccept(result -> context.status(200).result(new Response(true, "Success", result).toString()) @@ -347,14 +343,14 @@ public void randomScoreFromUsers(@NotNull Context context) { ); } - private CompletableFuture findAvailableScore(List userIds, int index, long minRank) { - if (index >= userIds.size()) { + 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; @@ -367,7 +363,7 @@ private CompletableFuture findAvailableScore(List userIds, in ) ).thenCompose(scores -> { if (scores.size() < SCORE_LIMIT) { - return findAvailableScore(userIds, index + 1, minRank); + return findAvailableScore(userIds, minRank, weights); } List candidates = new ArrayList<>(SCORE_LIMIT); @@ -383,60 +379,52 @@ private CompletableFuture findAvailableScore(List userIds, in } 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 -> { + Long globalRank = user.getStatistics().getGlobalRank(); - if (globalRank == null || globalRank > minRank) { - return findAvailableScore(userIds, index + 1, minRank); - } + if (globalRank == null || globalRank > minRank) { + return findAvailableScore(userIds, minRank, weights); + } - WeightedRandom randomScores = new WeightedRandom<>(); + WeightedRandom randomScores = new WeightedRandom<>(); - HashMap baseWeights = new HashMap<>(); + 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 findAvailableScore(userIds, index + 1, minRank); - } - - final int maxWeight = baseWeights.values() - .stream() - .max(Integer::compareTo) - .get(); + 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); + } + } - for (Map.Entry entry : baseWeights.entrySet()) { - final int selectedTimes = previousSelections.getOrDefault(entry.getKey().score().getId(), 0); + if (baseWeights.isEmpty()) { + return findAvailableScore(userIds, minRank, weights); + } - final double redundantFactor = Math.max(0.4, 1 - selectedTimes * 0.2); + final int maxWeight = baseWeights.values() + .stream() + .max(Integer::compareTo) + .get(); - final double normalizedWeight = (double) entry.getValue() / maxWeight; + for (Map.Entry entry : baseWeights.entrySet()) { + final double normalizedWeight = (double) entry.getValue() / maxWeight; - final int finalWeight = Math.max( - 1, - (int) (Math.pow(normalizedWeight, 4) * 1000 * redundantFactor) - ); + final double weightFactor = weights.getOrDefault(entry.getKey().score().getId(), 1.0); - randomScores.add(entry.getKey(), finalWeight); - } + final double finalWeight = Math.pow(normalizedWeight, 4) * weightFactor; - ScoreEntry selected = randomScores.next(); + randomScores.add(entry.getKey(), finalWeight); + } - previousSelections.merge(selected.score().getId(), 1, Integer::sum); + ScoreEntry selected = randomScores.next(); - return CompletableFuture.completedFuture(new TargetScore(selected, user)); - }); + return CompletableFuture.completedFuture(new TargetScore(selected, user)); + }); }); } @@ -456,4 +444,16 @@ 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/util/WeightedRandom.java b/src/main/java/xyz/zcraft/ostella/util/WeightedRandom.java index 9801e78..e141344 100644 --- a/src/main/java/xyz/zcraft/ostella/util/WeightedRandom.java +++ b/src/main/java/xyz/zcraft/ostella/util/WeightedRandom.java @@ -2,33 +2,69 @@ import java.util.ArrayList; import java.util.List; -import java.util.Random; +import java.util.NoSuchElementException; +import java.util.concurrent.ThreadLocalRandom; public class WeightedRandom { private final List items = new ArrayList<>(); - private final List cumulative = new ArrayList<>(); - private final Random random = new Random(); + private final List cumulative = new ArrayList<>(); + + public void add(T item, double weight) { + if (weight <= 0) { + throw new IllegalArgumentException("Weight must be positive"); + } - public void add(T item, int weight) { items.add(item); - int last = cumulative.isEmpty() ? 0 : cumulative.getLast(); - cumulative.add(last + weight); + + double previous = cumulative.isEmpty() ? 0.0 : cumulative.getLast(); + + cumulative.add(previous + weight); } public T next() { - int total = cumulative.getLast(); - int r = random.nextInt(total); + 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 (r < cumulative.get(i)) { - return items.get(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 From f1dcb3c4b2caac0fd5c1b38feb17ad5c31d6919f Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 02:08:49 +0800 Subject: [PATCH 29/36] Validate weights in ScoreController and WeightedRandom to ensure they are positive and finite --- .../network/controller/ScoreController.java | 19 ++++++++++++++++--- .../zcraft/ostella/util/WeightedRandom.java | 4 ++-- 2 files changed, 18 insertions(+), 5 deletions(-) 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 5ed790e..f5bd8f6 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -327,6 +327,11 @@ public void randomScoreFromUsers(@NotNull Context context) { .map(WeightFactor::users) .map(m -> m.get(uid)) .orElse(1.0); + + if (weight < 0.0) { + continue; + } + userIds.add(uid, weight); } @@ -384,9 +389,9 @@ private CompletableFuture findAvailableScore(WeightedRandom u return executor.enqueueAsync(() -> OsuAPI.getUser(tokenManager.getTokenData(), userId)) .thenCompose(user -> { - Long globalRank = user.getStatistics().getGlobalRank(); - - if (globalRank == null || globalRank > minRank) { + if (user == null + || user.getStatistics().getGlobalRank() == null + || user.getStatistics().getGlobalRank() > minRank) { return findAvailableScore(userIds, minRank, weights); } @@ -418,9 +423,17 @@ private CompletableFuture findAvailableScore(WeightedRandom u final double finalWeight = Math.pow(normalizedWeight, 4) * weightFactor; + if (finalWeight < 0.0) { + continue; + } + randomScores.add(entry.getKey(), finalWeight); } + if (randomScores.isEmpty()) { + return findAvailableScore(userIds, minRank, weights); + } + ScoreEntry selected = randomScores.next(); return CompletableFuture.completedFuture(new TargetScore(selected, user)); diff --git a/src/main/java/xyz/zcraft/ostella/util/WeightedRandom.java b/src/main/java/xyz/zcraft/ostella/util/WeightedRandom.java index e141344..fa1c426 100644 --- a/src/main/java/xyz/zcraft/ostella/util/WeightedRandom.java +++ b/src/main/java/xyz/zcraft/ostella/util/WeightedRandom.java @@ -10,8 +10,8 @@ public class WeightedRandom { private final List cumulative = new ArrayList<>(); public void add(T item, double weight) { - if (weight <= 0) { - throw new IllegalArgumentException("Weight must be positive"); + if (weight <= 0 || Double.isNaN(weight) || Double.isInfinite(weight)) { + throw new IllegalArgumentException("Weight must be positive and finite"); } items.add(item); From e6e8c1a863514fc99a4ecd842367a8285fea4254 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 17:35:10 +0800 Subject: [PATCH 30/36] Add endpoint to retrieve weighted scores for a user and refactor score replay checks --- .../xyz/zcraft/ostella/network/WebServer.java | 1 + .../network/controller/ScoreController.java | 66 ++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/main/java/xyz/zcraft/ostella/network/WebServer.java b/src/main/java/xyz/zcraft/ostella/network/WebServer.java index afe9f03..f9698f3 100644 --- a/src/main/java/xyz/zcraft/ostella/network/WebServer.java +++ b/src/main/java/xyz/zcraft/ostella/network/WebServer.java @@ -59,6 +59,7 @@ 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) .get("/scores/{scoreId}", router.scoreController::renderScoreById) .get("/scores/{scoreId}/analysis", router.analyzeController::renderScoreAnalysisById) .get("/scores/{scoreId}/highlight", router.analyzeController::getScoreHighlight) 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 f5bd8f6..4b380c4 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -19,8 +19,10 @@ 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; @@ -348,6 +350,68 @@ public void randomScoreFromUsers(@NotNull Context context) { ); } + public void randomScoreFromUsersWeights(@NotNull Context context) { + final long userId = requirePathLong(context, "userId"); + + context.future(() -> + executor.enqueueAsync(() -> OsuAPI.getUserScores(tokenManager.getTokenData(), userId, ScoreType.BEST, 40)) + .thenApply(scores -> { + HashMap baseWeights = new HashMap<>(); + + 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); + try { + baseWeights.put(scoreEntry, getWeight(scoreEntry)); + } catch (ParseException e) { + LOG.warn("Failed to parse beatmap with id {}", scoreEntry.score().getBeatmapId(), e); + } + } + + if (baseWeights.isEmpty()) { + return "No scores available"; + } + + final int maxWeight = baseWeights.values() + .stream() + .max(Integer::compareTo) + .get(); + + HashMap finalWeights = new HashMap<>(); + + for (Map.Entry entry : baseWeights.entrySet()) { + final double normalizedWeight = (double) entry.getValue() / maxWeight; + + final double finalWeight = Math.pow(normalizedWeight, 4); + + if (finalWeight < 0.0) { + continue; + } + + finalWeights.put(entry.getKey(), finalWeight); + } + + StringBuilder sb = new StringBuilder(); + + finalWeights.entrySet() + .stream() + .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) + .forEach(e -> + sb.append("__BP").append("%02d".formatted(e.getKey().bestIndex())).append("__:") + .append(String.format("%.5f", e.getValue())).append(" ") + ); + + return sb.toString(); + }) + .thenAccept(result -> RequestUtil.putResult(context, result)) + + ); + } + private CompletableFuture findAvailableScore(WeightedRandom userIds, long minRank, Map weights) { if (userIds.isEmpty()) { return CompletableFuture.failedFuture( @@ -376,7 +440,7 @@ private CompletableFuture findAvailableScore(WeightedRandom u for (int i = 0; i < SCORE_LIMIT; i++) { final Score score = scores.get(i); - if (!score.getHasReplay() && !CacheService.hasReplayCache(score.getId())) { + if (!ScoreFormatUtil.replayPresent(score)) { continue; } From 364aa3ba7f414a0045f5e281a32ef80f5bba292a Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 17:39:04 +0800 Subject: [PATCH 31/36] Add endpoint to retrieve weighted scores for a user and refactor score replay checks --- .../xyz/zcraft/ostella/network/controller/ScoreController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 4b380c4..9877031 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -405,7 +405,7 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { .append(String.format("%.5f", e.getValue())).append(" ") ); - return sb.toString(); + return sb.toString().trim(); }) .thenAccept(result -> RequestUtil.putResult(context, result)) From e65e5ce01d366a5b086ec41f7dbcb011ec01a0a0 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 01:35:23 +0800 Subject: [PATCH 32/36] Refactor weight calculation in ScoreController to use BeatmapAnalyzer and add getModBits method for mod bit manipulation --- .../network/controller/ScoreController.java | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) 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 9877031..2ab9070 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -27,11 +27,13 @@ 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; @@ -508,7 +510,8 @@ private CompletableFuture findAvailableScore(WeightedRandom u private int getWeight(ScoreEntry entry) throws ParseException { final Long beatmapId = entry.score().getBeatmapId(); final OsuBeatmap osuBeatmap = BeatmapParser.parseBeatmap(CacheService.getBeatmapPath(beatmapId)); - final BeatmapPatternAnalysis patternAnalysis = BeatmapPatternAnalyzer.analyze(osuBeatmap, null); + final DifficultyAttribute difficultyAttribute = BeatmapAnalyzer.calculateDifficulty(osuBeatmap, getModBits(entry.score().getMods())); + final BeatmapPatternAnalysis patternAnalysis = BeatmapPatternAnalyzer.analyze(osuBeatmap, difficultyAttribute); int patternWeight = 0; for (BeatmapPatternAnalysis.PatternScore type : patternAnalysis.types()) { patternWeight += (int) (PATTERN_WEIGHTS.getOrDefault(type.type(), 10) * type.percentage()); @@ -516,6 +519,22 @@ private int getWeight(ScoreEntry entry) throws ParseException { return patternWeight * (100 - entry.bestIndex()) / 100; } + 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; + } + private record ScoreEntry(int bestIndex, Score score) { } From 4086b1c2722c1e187dcc6ab98cd8138528830f18 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 12:18:06 +0800 Subject: [PATCH 33/36] Enhance random score calculation in ScoreController to incorporate weight factors and improve output formatting --- .../xyz/zcraft/ostella/network/WebServer.java | 1 + .../network/controller/ScoreController.java | 47 ++++++++++++++++--- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/main/java/xyz/zcraft/ostella/network/WebServer.java b/src/main/java/xyz/zcraft/ostella/network/WebServer.java index f9698f3..41ad6b2 100644 --- a/src/main/java/xyz/zcraft/ostella/network/WebServer.java +++ b/src/main/java/xyz/zcraft/ostella/network/WebServer.java @@ -60,6 +60,7 @@ public WebServer(AppConfig conf, TokenManager tokenManager) throws IOException { .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) 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 2ab9070..b5b52d3 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -1,6 +1,7 @@ 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; @@ -355,6 +356,20 @@ public void randomScoreFromUsers(@NotNull Context context) { 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, 40)) .thenApply(scores -> { @@ -388,7 +403,9 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { for (Map.Entry entry : baseWeights.entrySet()) { final double normalizedWeight = (double) entry.getValue() / maxWeight; - final double finalWeight = Math.pow(normalizedWeight, 4); + final double weightFactor = scoreWeights.getOrDefault(entry.getKey().score().getId(), 1.0); + + final double finalWeight = Math.pow(normalizedWeight, 4) * weightFactor; if (finalWeight < 0.0) { continue; @@ -397,15 +414,31 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { 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); + } + StringBuilder sb = new StringBuilder(); - finalWeights.entrySet() + final List> list = finalWeights.entrySet() .stream() - .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())) - .forEach(e -> - sb.append("__BP").append("%02d".formatted(e.getKey().bestIndex())).append("__:") - .append(String.format("%.5f", e.getValue())).append(" ") - ); + .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())).toList(); + + for (int i = 0; i < Math.min(6, 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() > 6) { + sb.append("\n").append("... and %d more".formatted(list.size() - 6)); + } return sb.toString().trim(); }) From 824c936e50c277660b8bbcce9af8a2bf004488c6 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 13:32:43 +0800 Subject: [PATCH 34/36] Improved weight calculation --- .../network/controller/ScoreController.java | 230 +++++++++++------- 1 file changed, 147 insertions(+), 83 deletions(-) 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 b5b52d3..0964ebf 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -41,6 +41,7 @@ import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; import static xyz.zcraft.ostella.util.RequestUtil.*; @@ -118,6 +119,22 @@ private static JsonObject mapScoreToJson(TargetScore targetScore) { 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); @@ -371,9 +388,9 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { .orElse(Map.of()); } context.future(() -> - executor.enqueueAsync(() -> OsuAPI.getUserScores(tokenManager.getTokenData(), userId, ScoreType.BEST, 40)) + executor.enqueueAsync(() -> OsuAPI.getUserScores(tokenManager.getTokenData(), userId, ScoreType.BEST, 80)) .thenApply(scores -> { - HashMap baseWeights = new HashMap<>(); + List candidates = new ArrayList<>(scores.size()); for (int i = 0; i < scores.size(); i++) { final Score score = scores.get(i); @@ -382,51 +399,19 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { } final ScoreEntry scoreEntry = new ScoreEntry(i + 1, score); - try { - baseWeights.put(scoreEntry, getWeight(scoreEntry)); - } catch (ParseException e) { - LOG.warn("Failed to parse beatmap with id {}", scoreEntry.score().getBeatmapId(), e); - } - } - - if (baseWeights.isEmpty()) { - return "No scores available"; + candidates.add(scoreEntry); } - final int maxWeight = baseWeights.values() - .stream() - .max(Integer::compareTo) - .get(); - - HashMap finalWeights = new HashMap<>(); - - for (Map.Entry entry : baseWeights.entrySet()) { - final double normalizedWeight = (double) entry.getValue() / maxWeight; - - final double weightFactor = scoreWeights.getOrDefault(entry.getKey().score().getId(), 1.0); - - final double finalWeight = Math.pow(normalizedWeight, 4) * weightFactor; - - if (finalWeight < 0.0) { - continue; - } - finalWeights.put(entry.getKey(), finalWeight); - } + final Map weights = getWeights(candidates, scoreWeights); - // 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); + if (weights.isEmpty()) { + return "No scores found!"; } StringBuilder sb = new StringBuilder(); - final List> list = finalWeights.entrySet() + final List> list = weights.entrySet() .stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())).toList(); @@ -496,76 +481,155 @@ private CompletableFuture findAvailableScore(WeightedRandom u WeightedRandom randomScores = new WeightedRandom<>(); - HashMap baseWeights = new HashMap<>(); + final Map finalWeights = getWeights(candidates, weights); - 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 (finalWeights.isEmpty()) { + return findAvailableScore(userIds, minRank, weights); } - if (baseWeights.isEmpty()) { - return findAvailableScore(userIds, minRank, weights); + for (Map.Entry entry : finalWeights.entrySet()) { + randomScores.add(entry.getKey(), entry.getValue()); } - final int maxWeight = baseWeights.values() - .stream() - .max(Integer::compareTo) - .get(); + ScoreEntry selected = randomScores.next(); - for (Map.Entry entry : baseWeights.entrySet()) { - final double normalizedWeight = (double) entry.getValue() / maxWeight; + return CompletableFuture.completedFuture(new TargetScore(selected, user)); + }); + }); + } - final double weightFactor = weights.getOrDefault(entry.getKey().score().getId(), 1.0); + private Map getWeights(List candidates, Map weights) { + HashMap baseWeights = new HashMap<>(); - final double finalWeight = Math.pow(normalizedWeight, 4) * weightFactor; + 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 (finalWeight < 0.0) { - continue; - } + if (baseWeights.isEmpty()) { + return Map.of(); + } - randomScores.add(entry.getKey(), finalWeight); - } + final double maxWeight = baseWeights.values() + .stream() + .max(Double::compareTo) + .get(); - if (randomScores.isEmpty()) { - return findAvailableScore(userIds, minRank, weights); - } + HashMap finalWeights = new HashMap<>(); - ScoreEntry selected = randomScores.next(); + for (Map.Entry entry : baseWeights.entrySet()) { + final double normalizedWeight = entry.getValue() / maxWeight; - return CompletableFuture.completedFuture(new TargetScore(selected, user)); - }); - }); + if (normalizedWeight < 0.6 && 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 int getWeight(ScoreEntry entry) throws ParseException { + 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); - int patternWeight = 0; + double patternWeight = 0; for (BeatmapPatternAnalysis.PatternScore type : patternAnalysis.types()) { - patternWeight += (int) (PATTERN_WEIGHTS.getOrDefault(type.type(), 10) * type.percentage()); + patternWeight += (PATTERN_WEIGHTS.getOrDefault(type.type(), 10) * type.percentage()); } - return patternWeight * (100 - entry.bestIndex()) / 100; + + final double modWeightFactor = getModWeightFactor(entry); + final double attributeFactor = getAttributeFactor(difficultyAttribute); + + return (patternWeight * (100.0 - entry.bestIndex()) * modWeightFactor * attributeFactor) / 100.0; } - public static long getModBits(List mods) { - long bits = 0; + private double getModWeightFactor(ScoreEntry entry) { + final ModSet mods = new ModSet(entry.score().getMods().stream().map(Mod::getAcronym).filter(Objects::nonNull).collect(Collectors.toSet())); - 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; - } + 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; } - return bits; + // 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) { From 66d9ad326cdbb0c0d70da8db52557de346a46ec8 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 13:33:29 +0800 Subject: [PATCH 35/36] Improved weight calculation --- .../xyz/zcraft/ostella/network/controller/ScoreController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0964ebf..3d05e7a 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -523,7 +523,7 @@ private Map getWeights(List candidates, Map entry : baseWeights.entrySet()) { final double normalizedWeight = entry.getValue() / maxWeight; - if (normalizedWeight < 0.6 && entry.getKey().bestIndex() > 40) { + if (normalizedWeight < 0.5 && entry.getKey().bestIndex() > 40) { continue; } From 0a98f1a88c42edec4c79aa2574c0c2de262d1655 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 13:37:51 +0800 Subject: [PATCH 36/36] Expand return cap --- .../zcraft/ostella/network/controller/ScoreController.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 3d05e7a..3f54001 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -415,14 +415,15 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { .stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())).toList(); - for (int i = 0; i < Math.min(6, list.size()); i++) { + 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() > 6) { - sb.append("\n").append("... and %d more".formatted(list.size() - 6)); + if (list.size() > LIMIT) { + sb.append("\n").append("... and %d more".formatted(list.size() - LIMIT)); } return sb.toString().trim();