From 84205f35df514cb36a4c1e9f3496a4b9b87a9e1f Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sat, 5 Sep 2026 23:32:24 +0800 Subject: [PATCH 01/39] Increase score limit to 200 and adjust weight factors for improved score calculations --- .../network/controller/ScoreController.java | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 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 3f54001..7acef70 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -388,7 +388,7 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { .orElse(Map.of()); } context.future(() -> - executor.enqueueAsync(() -> OsuAPI.getUserScores(tokenManager.getTokenData(), userId, ScoreType.BEST, 80)) + executor.enqueueAsync(() -> OsuAPI.getUserScores(tokenManager.getTokenData(), userId, ScoreType.BEST, 200)) .thenApply(scores -> { List candidates = new ArrayList<>(scores.size()); @@ -402,7 +402,6 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { candidates.add(scoreEntry); } - final Map weights = getWeights(candidates, scoreWeights); if (weights.isEmpty()) { @@ -442,23 +441,17 @@ private CompletableFuture findAvailableScore(WeightedRandom u long userId = userIds.getAndRemove(); - final int SCORE_LIMIT = 40; - return executor.enqueueAsync(() -> OsuAPI.getUserScores( tokenManager.getTokenData(), userId, ScoreType.BEST, - SCORE_LIMIT + 200 ) ).thenCompose(scores -> { - if (scores.size() < SCORE_LIMIT) { - return findAvailableScore(userIds, minRank, weights); - } - - List candidates = new ArrayList<>(SCORE_LIMIT); + List candidates = new ArrayList<>(200); - for (int i = 0; i < SCORE_LIMIT; i++) { + for (int i = 0; i < scores.size(); i++) { final Score score = scores.get(i); if (!ScoreFormatUtil.replayPresent(score)) { @@ -524,11 +517,11 @@ private Map getWeights(List candidates, Map entry : baseWeights.entrySet()) { final double normalizedWeight = entry.getValue() / maxWeight; - if (normalizedWeight < 0.5 && entry.getKey().bestIndex() > 40) { + if (normalizedWeight < 0.4 && entry.getKey().bestIndex() > 50) { continue; } - final double powWeight = Math.pow(normalizedWeight, 4); + final double powWeight = Math.pow(normalizedWeight, 3.5); final double extraFactor = weights.getOrDefault(entry.getKey().score().getId(), 1.0); @@ -559,58 +552,67 @@ private double getWeight(ScoreEntry entry) throws ParseException { final OsuBeatmap osuBeatmap = BeatmapParser.parseBeatmap(CacheService.getBeatmapPath(beatmapId)); final DifficultyAttribute difficultyAttribute = BeatmapAnalyzer.calculateDifficulty(osuBeatmap, getModBits(entry.score().getMods())); final BeatmapPatternAnalysis patternAnalysis = BeatmapPatternAnalyzer.analyze(osuBeatmap, difficultyAttribute); + double patternWeight = 0; + for (BeatmapPatternAnalysis.PatternScore type : patternAnalysis.types()) { patternWeight += (PATTERN_WEIGHTS.getOrDefault(type.type(), 10) * type.percentage()); } final double modWeightFactor = getModWeightFactor(entry); final double attributeFactor = getAttributeFactor(difficultyAttribute); + final double bestIndexFactor = (400.0 - entry.bestIndex()) / 200.0; - return (patternWeight * (100.0 - entry.bestIndex()) * modWeightFactor * attributeFactor) / 100.0; + return (patternWeight * (1 + bestIndexFactor + modWeightFactor + attributeFactor)); } private double getModWeightFactor(ScoreEntry entry) { final ModSet mods = new ModSet(entry.score().getMods().stream().map(Mod::getAcronym).filter(Objects::nonNull).collect(Collectors.toSet())); if (mods.is("EZHD")) - return 2.0; + return 2.5; if (mods.is("EZ")) - return 1.5; + return 2.0; if (mods.is("HRHD")) return 1.5; if (mods.is("HR")) - return 1.25; + return 1.0; if (mods.is("HDDT") || mods.is("HDNC")) - return 0.8; + return -0.2; - return 1.0; + return 0.0; } private double getAttributeFactor(DifficultyAttribute difficultyAttribute) { - double attributeFactor = 1.0; + double attributeFactor = 0.0; // Precision... - if (difficultyAttribute.cs() >= 8) { - attributeFactor *= 1.1; - } + attributeFactor += getCsFactor(difficultyAttribute.cs()); // Reading! - attributeFactor *= getArFactor(difficultyAttribute.ar()); + attributeFactor += getArFactor(difficultyAttribute.ar()); return attributeFactor; } private static double getArFactor(double ar) { - if (ar >= 8.0) { - return 1.0; + if (ar >= 8.25) { + return 0.0; + } + + return 2.5 * (1.0 - Math.exp(-1.3 * (8.25 - ar))); + } + + private static double getCsFactor(double cs) { + if (cs <= 8.0) { + return 0.0; } - return 1.0 + 2.5 * Math.pow((8.0 - ar) / 4.0, 1.25); + return Math.pow((cs - 8.0) / 4.0, 1.25); } private record ModSet(Set acronyms) { From b903901c46a78474d5cb0aea5d45af2294df8dde Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sat, 5 Sep 2026 23:43:01 +0800 Subject: [PATCH 02/39] Disable danser logo display --- src/main/resources/danser-config.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/danser-config.json b/src/main/resources/danser-config.json index 9f879b3..d6122c3 100644 --- a/src/main/resources/danser-config.json +++ b/src/main/resources/danser-config.json @@ -52,6 +52,9 @@ } } }, + "Logo": { + "Enabled": false + }, "Cursor": { "CursorSize": 8, "TrailScale": 0.7, From 8a11a8b54ddb60b48cae0c3143d72910506792ef Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 6 Sep 2026 00:19:42 +0800 Subject: [PATCH 03/39] Refine score calculation logic and enhance score filtering criteria --- .../xyz/zcraft/ostella/data/ScoreFilter.java | 26 +++++++++++++------ .../network/controller/ScoreController.java | 12 +++++---- src/main/resources/template/beatmap.html | 2 +- 3 files changed, 26 insertions(+), 14 deletions(-) diff --git a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java index 868175f..2287146 100644 --- a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java +++ b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java @@ -4,19 +4,17 @@ import xyz.zcraft.osu.model.Mod; import xyz.zcraft.osu.model.Score; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Set; +import java.util.*; import java.util.regex.Matcher; import java.util.regex.Pattern; -/** A validated filter applied to a score-list response. */ +/** + * 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|replay" - + "|title|artist|mapper|genre|language|video|storyboard|fullcombo)" + + "|title|artist|mapper|genre|language|video|storyboard|fullcombo|ar|od|cs|hp)" + "(>=|<=|!=|!~|>|<|=|~)(.+)$" ); private static final Pattern DURATION_PATTERN = Pattern.compile("(?i)^(?:(\\d+)m)?(?:(\\d+(?:\\.\\d+)?)s)?$"); @@ -228,7 +226,7 @@ private static String formatValue(Field field, double value) { case STAR -> formatNumber(value) + "★"; case BPM -> formatNumber(value) + " BPM"; case MISS -> formatNumber(value) + " miss"; - case SCORE -> formatNumber(value); + case SCORE, AR, CS, HP, OD -> formatNumber(value); case MODS, RANK, TITLE, ARTIST, MAPPER, GENRE, LANGUAGE, VIDEO, STORYBOARD, FULL_COMBO, REPLAY -> throw new IllegalStateException("Text filter has no numeric value"); }; @@ -258,6 +256,14 @@ public boolean matches(Score score) { && compare(score.getBeatmap().getTotalLength()); case STAR -> score.getBeatmap() != null && score.getBeatmap().getDifficultyRating() != null && compare(score.getBeatmap().getDifficultyRating()); + case AR -> score.getBeatmap() != null && score.getBeatmap().getAr() != null + && compare(score.getBeatmap().getAr()); + case CS -> score.getBeatmap() != null && score.getBeatmap().getCs() != null + && compare(score.getBeatmap().getCs()); + case HP -> score.getBeatmap() != null && score.getBeatmap().getDrain() != null + && compare(score.getBeatmap().getDrain()); + case OD -> score.getBeatmap() != null && score.getBeatmap().getAccuracy() != null + && compare(score.getBeatmap().getAccuracy()); case BPM -> score.getBeatmap() != null && score.getBeatmap().getBpm() != null && compare(score.getBeatmap().getBpm()); case MISS -> score.getStatistics() != null && compare(score.getStatistics().getOrDefault("miss", 0L)); @@ -358,6 +364,10 @@ private enum Field { PP("PP"), LENGTH("Beatmap length"), STAR("Star rating"), + CS("Circle Size"), + AR("Approach Rate"), + HP("HP Drain"), + OD("Overall Difficulty"), BPM("BPM"), MISS("Misses"), SCORE("Score"), 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 7acef70..572aab6 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -517,7 +517,7 @@ private Map getWeights(List candidates, Map entry : baseWeights.entrySet()) { final double normalizedWeight = entry.getValue() / maxWeight; - if (normalizedWeight < 0.4 && entry.getKey().bestIndex() > 50) { + if (normalizedWeight < 0.6 && entry.getKey().bestIndex() > 40) { continue; } @@ -563,17 +563,19 @@ private double getWeight(ScoreEntry entry) throws ParseException { final double attributeFactor = getAttributeFactor(difficultyAttribute); final double bestIndexFactor = (400.0 - entry.bestIndex()) / 200.0; - return (patternWeight * (1 + bestIndexFactor + modWeightFactor + attributeFactor)); + final double factor = 1 + bestIndexFactor + modWeightFactor + attributeFactor; + + return (patternWeight * Math.max(0.1, factor)); } private double getModWeightFactor(ScoreEntry entry) { final ModSet mods = new ModSet(entry.score().getMods().stream().map(Mod::getAcronym).filter(Objects::nonNull).collect(Collectors.toSet())); if (mods.is("EZHD")) - return 2.5; + return 3.0; if (mods.is("EZ")) - return 2.0; + return 2.5; if (mods.is("HRHD")) return 1.5; @@ -582,7 +584,7 @@ private double getModWeightFactor(ScoreEntry entry) { return 1.0; if (mods.is("HDDT") || mods.is("HDNC")) - return -0.2; + return -0.5; return 0.0; } diff --git a/src/main/resources/template/beatmap.html b/src/main/resources/template/beatmap.html index 9277920..930870a 100644 --- a/src/main/resources/template/beatmap.html +++ b/src/main/resources/template/beatmap.html @@ -224,7 +224,7 @@ .diff-spec { margin: 0 0 20px; display: grid; - grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.3fr); + grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.4fr); align-items: stretch; gap: 28px; width: 100%; From afccb6e2dc8e6194aed7682ff430a1349706a126 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 6 Sep 2026 00:22:51 +0800 Subject: [PATCH 04/39] Adjust best index factor calculation and refine mod penalty logic --- .../zcraft/ostella/network/controller/ScoreController.java | 5 ++++- 1 file changed, 4 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 572aab6..b3d5a51 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -561,7 +561,7 @@ private double getWeight(ScoreEntry entry) throws ParseException { final double modWeightFactor = getModWeightFactor(entry); final double attributeFactor = getAttributeFactor(difficultyAttribute); - final double bestIndexFactor = (400.0 - entry.bestIndex()) / 200.0; + final double bestIndexFactor = (250.0 - entry.bestIndex()) / 200.0; final double factor = 1 + bestIndexFactor + modWeightFactor + attributeFactor; @@ -584,6 +584,9 @@ private double getModWeightFactor(ScoreEntry entry) { return 1.0; if (mods.is("HDDT") || mods.is("HDNC")) + return -1.0; + + if (mods.is("DT")) return -0.5; return 0.0; From e34b08cd0bfee6d6ee00497ad3be66f5df2252e7 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 6 Sep 2026 00:42:05 +0800 Subject: [PATCH 05/39] Adjust best index factor calculation and refine mod penalty logic --- .../ostella/network/controller/ScoreController.java | 8 +++++++- 1 file changed, 7 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 b3d5a51..9f07304 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -561,13 +561,19 @@ private double getWeight(ScoreEntry entry) throws ParseException { final double modWeightFactor = getModWeightFactor(entry); final double attributeFactor = getAttributeFactor(difficultyAttribute); - final double bestIndexFactor = (250.0 - entry.bestIndex()) / 200.0; + final double bestIndexFactor = getBestIndexFactor(entry); final double factor = 1 + bestIndexFactor + modWeightFactor + attributeFactor; return (patternWeight * Math.max(0.1, factor)); } + private double getBestIndexFactor(ScoreEntry entry) { + final int x = entry.bestIndex(); + + return Math.clamp((200.0 / (x + 40.0)) - 4.0, -3.0, 1.0); + } + private double getModWeightFactor(ScoreEntry entry) { final ModSet mods = new ModSet(entry.score().getMods().stream().map(Mod::getAcronym).filter(Objects::nonNull).collect(Collectors.toSet())); From c0f76e7f8fb2679147f37a798cbed869a3a520ca Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 6 Sep 2026 01:10:28 +0800 Subject: [PATCH 06/39] Add option to retrieve all user weights and increase score limit for random score generation --- .../zcraft/ostella/network/controller/ScoreController.java | 5 ++++- 1 file changed, 4 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 9f07304..3f383cb 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -373,6 +373,8 @@ public void randomScoreFromUsers(@NotNull Context context) { public void randomScoreFromUsersWeights(@NotNull Context context) { final long userId = requirePathLong(context, "userId"); + final boolean allWeights = optionalBoolean(context, "all", false); + final JsonElement jsonElement = JsonParser.parseString(context.body()); final Map scoreWeights; @@ -414,7 +416,8 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { .stream() .sorted((a, b) -> Double.compare(b.getValue(), a.getValue())).toList(); - final int LIMIT = 12; + final int LIMIT = allWeights ? 1000 : 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("__:") From bf2c96a4e035bb58b8c35b82d4d4ba884d9816a6 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 6 Sep 2026 01:13:19 +0800 Subject: [PATCH 07/39] Add option to retrieve all user weights and increase score limit for random score generation --- .../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 3f383cb..dc83fdd 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -420,7 +420,7 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { 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("__:") + sb.append("__BP").append("%03d".formatted(e.getKey().bestIndex())).append("__:") .append(String.format("%.5f", e.getValue())).append(" "); } From 70d990b1f33a3a5f1a0ce3906fa045bb31d52f17 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 6 Sep 2026 01:22:38 +0800 Subject: [PATCH 08/39] Adjust weight normalization threshold and refine best index factor calculation --- .../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 dc83fdd..fa9d405 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -520,7 +520,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; } @@ -574,7 +574,7 @@ private double getWeight(ScoreEntry entry) throws ParseException { private double getBestIndexFactor(ScoreEntry entry) { final int x = entry.bestIndex(); - return Math.clamp((200.0 / (x + 40.0)) - 4.0, -3.0, 1.0); + return Math.clamp((200.0 / (x + 40.0)) - 4.0, -2.5, 0.5); } private double getModWeightFactor(ScoreEntry entry) { From 395b47ced92a3bf94122f54d902b6b67f5d61028 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 6 Sep 2026 01:31:18 +0800 Subject: [PATCH 09/39] Add support for additional score attributes: approach rate, circle size, health points, and overall difficulty --- src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java index 2287146..f36bdb4 100644 --- a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java +++ b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java @@ -396,6 +396,10 @@ static Field from(String value) { case "pp" -> PP; case "time", "length", "len" -> LENGTH; case "star", "stars", "sr" -> STAR; + case "approachrate", "ar" -> AR; + case "cs" -> CS; + case "hp", "drain" -> HP; + case "od" -> OD; case "bpm" -> BPM; case "miss", "misses" -> MISS; case "score" -> SCORE; From b784c68eeb1223c7713870dda657f01cc590e0eb Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 6 Sep 2026 02:01:39 +0800 Subject: [PATCH 10/39] Refactor error handling in Beatmap rendering and update osuParser dependency to v1.5.3 --- pom.xml | 2 +- .../zcraft/ostella/network/controller/BeatmapController.java | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/pom.xml b/pom.xml index 48fa7a6..faee329 100644 --- a/pom.xml +++ b/pom.xml @@ -118,7 +118,7 @@ com.github.BotSeira osuParser - v1.5.2 + v1.5.3 diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapController.java b/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapController.java index 35a7e6a..5afc67c 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapController.java @@ -219,10 +219,8 @@ public void renderBeatmapById(@NotNull Context context) { .toList(); return renderer.renderBeatmap(beatmap, diffSpec, diff); - } catch (ParseException e) { + } catch (Exception e) { throw new ApiException(ErrorCode.BEATMAP_PARSE_FAILED, e); - } catch (AnalyzeException e) { - throw new ApiException(ErrorCode.SCORE_PARSE_FAILED, e); } }, renderer.getRenderExecutor()) .thenAccept(bytes -> context.status(200).result(bytes))); From 8f8e317c472279a99cc2e0f0f1fd11163500fe3f Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 6 Sep 2026 02:50:43 +0800 Subject: [PATCH 11/39] Calculate and display score percentages in ScoreController --- .../ostella/network/controller/ScoreController.java | 9 +++++++-- 1 file changed, 7 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 fa9d405..08b1dd2 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -410,6 +410,9 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { return "No scores found!"; } + final double sum = weights.values().stream().mapToDouble(Double::doubleValue).sum(); + double showed = 0; + StringBuilder sb = new StringBuilder(); final List> list = weights.entrySet() @@ -420,12 +423,14 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { for (int i = 0; i < Math.min(LIMIT, list.size()); i++) { final Map.Entry e = list.get(i); + final double percentage = (e.getValue() / sum) * 100; sb.append("__BP").append("%03d".formatted(e.getKey().bestIndex())).append("__:") - .append(String.format("%.5f", e.getValue())).append(" "); + .append(String.format("%05.2f%%", percentage)).append(" "); + showed += percentage; } if (list.size() > LIMIT) { - sb.append("\n").append("... and %d more".formatted(list.size() - LIMIT)); + sb.append("\n").append("... and %d more(%05.2f%%)".formatted(list.size() - LIMIT, 100 - showed)); } return sb.toString().trim(); From 9dae5bf4f3d697e40e2f2f3058f4b9eba285e6cd Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 6 Sep 2026 03:11:41 +0800 Subject: [PATCH 12/39] Refactor mod weight factor checks in ScoreController to use has() method for better readability --- .../network/controller/ScoreController.java | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 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 08b1dd2..0ef6e4e 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -585,22 +585,22 @@ private double getBestIndexFactor(ScoreEntry entry) { private double getModWeightFactor(ScoreEntry entry) { final ModSet mods = new ModSet(entry.score().getMods().stream().map(Mod::getAcronym).filter(Objects::nonNull).collect(Collectors.toSet())); - if (mods.is("EZHD")) + if (mods.has("EZHD")) return 3.0; - if (mods.is("EZ")) + if (mods.has("EZ")) return 2.5; - if (mods.is("HRHD")) + if (mods.has("HRHD")) return 1.5; - if (mods.is("HR")) + if (mods.has("HR")) return 1.0; - if (mods.is("HDDT") || mods.is("HDNC")) + if (mods.has("HDDT") || mods.has("HDNC")) return -1.0; - if (mods.is("DT")) + if (mods.has("DT") || mods.has("NC")) return -0.5; return 0.0; @@ -635,6 +635,25 @@ private static double getCsFactor(double cs) { } private record ModSet(Set acronyms) { + public boolean has(Collection acronyms) { + return this.acronyms.containsAll(acronyms); + } + + public boolean has(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.has(result); + } + + public boolean is(Collection acronyms) { return this.acronyms.containsAll(acronyms) && this.acronyms.size() == acronyms.size(); } @@ -650,7 +669,7 @@ public boolean is(String acronyms) { result.add(acronyms.substring(i, i + 2)); } - return this.is(result); + return this.has(result); } } From 92ab44d3d55d8c1a89756153662b684afcf14089 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Mon, 7 Sep 2026 20:15:31 +0800 Subject: [PATCH 13/39] Reduced weight sharping --- .../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 0ef6e4e..b74fe1d 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -529,7 +529,7 @@ private Map getWeights(List candidates, Map Date: Tue, 8 Sep 2026 02:18:23 +0800 Subject: [PATCH 14/39] Update score type parameter from 'bo' to 'bp' in BeatmapController and ScoreController --- README.md | 8 ++++---- .../ostella/network/controller/BeatmapController.java | 4 ++-- .../ostella/network/controller/ScoreController.java | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index de4941e..b34c710 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ mvn -U clean compile exec:java ### 4) Call an Endpoint ```shell -curl "http://localhost:8721/bo?u=12345678&n=20" --output best_of_20.png +curl "http://localhost:8721/bp?u=12345678&n=20" --output best_of_20.png ``` ## Endpoints @@ -252,12 +252,12 @@ You can also look up the score of a beatmap, or the beatmap index of a beatmapse #### Looking up beatmaps, beatmapset, or scores by a user and index (e.g., best-of-N): - `of` = score type -- `i` = index (for `bo` `rs` `rp`, which score index to return) +- `i` = index (for `bp` `rs` `rp`, which score index to return) - `u` = user ID ##### Score Types for `of` parameter: -- `bo` - best scores +- `bp` - best scores - `rs` - recent scores - `rp` - recent **passed** scores - `mp` - current multiplayer playlist item @@ -267,7 +267,7 @@ You can also look up the score of a beatmap, or the beatmap index of a beatmapse - `/beatmaps/lookup?m=12345678` - Look up beatmap by map ID - `/beatmaps/lookup?ms=12345678&i=0` - Look up the first beatmap of a beatmapset - `/beatmaps/lookup?s=12345678` - Look up the beatmap of a score -- `/beatmaps/lookup?of=bo&i=0&u=12345678` - Look up the beatmap of a user's best score #1 +- `/beatmaps/lookup?of=bp&i=0&u=12345678` - Look up the beatmap of a user's best score #1 - `/beatmapsets/lookup?ms=12345678` - Look up beatmapset by mapset ID - `/beatmapsets/lookup?m=12345678` - Look up the beatmapset of a beatmap - `/beatmapsets/lookup?of=mp` - Look up the beatmapset of the current multiplayer playlist item diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapController.java b/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapController.java index 5afc67c..9f44d0f 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/BeatmapController.java @@ -84,7 +84,7 @@ private void lookupBeatmapOfSetAsync(@NotNull Context context) { } private void lookupBeatmapOfRefAsync(@NotNull Context context) { - final String of = requireStringFrom(context, "of", "rs", "bo", "mp", "rp"); + final String of = requireStringFrom(context, "of", "rs", "bp", "mp", "rp"); if ("mp".equals(of)) { lookupBeatmapFromSomeRoom(context); @@ -124,7 +124,7 @@ private void lookupBeatmapFromSomeScore(@NonNull Context context, @NotNull Strin final ScoreType type = switch (of.toLowerCase()) { case "rs" -> ScoreType.RECENT; case "rp" -> ScoreType.RECENT_PASS; - case "bo" -> ScoreType.BEST; + case "bp" -> ScoreType.BEST; default -> throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, "Invalid score type: " + of); }; 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 b74fe1d..79fb20f 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -283,7 +283,7 @@ private void lookupScoreOfBeatmapsetAsync(@NotNull Context context) { } public CompletableFuture getScoreFromRefAsync(@NotNull Context context) { - final String of = requireStringFrom(context, "of", "rs", "bo", "rp"); + final String of = requireStringFrom(context, "of", "rs", "bp", "rp"); final long u = requireLong(context, "u"); final int i = requirePositiveInt(context, "i"); final List filters = requireScoreFilters(context); @@ -291,7 +291,7 @@ public CompletableFuture getScoreFromRefAsync(@NotNull Context context) { final ScoreType type = switch (of.toLowerCase()) { case "rs" -> ScoreType.RECENT; case "rp" -> ScoreType.RECENT_PASS; - case "bo" -> ScoreType.BEST; + case "bp" -> ScoreType.BEST; default -> throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, "Invalid score type: " + of); }; final int fetchLimit = scoreLookupFetchLimit(i, filters); From 1d5e734d2a6578bd58f0650eaf2505d3298e1453 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 8 Sep 2026 02:46:32 +0800 Subject: [PATCH 15/39] Refactor weight calculation logic in ScoreController to improve handling of normalized weights --- .../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 79fb20f..a444a6d 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -525,13 +525,15 @@ private Map getWeights(List candidates, Map entry : baseWeights.entrySet()) { final double normalizedWeight = entry.getValue() / maxWeight; - if (normalizedWeight < 0.5 && entry.getKey().bestIndex() > 40) { - continue; - } + final double extraFactor = weights.getOrDefault(entry.getKey().score().getId(), 1.0); - final double powWeight = Math.pow(normalizedWeight, 3.0); + if (entry.getKey().bestIndex() > 40 && normalizedWeight < 0.5) { + if (extraFactor <= 1.0) { + continue; + } + } - final double extraFactor = weights.getOrDefault(entry.getKey().score().getId(), 1.0); + final double powWeight = Math.pow(normalizedWeight, 2.5); final double finalWeight = powWeight * extraFactor; From 228f1e571da535f0825c56fb3feb70b9c25ce588 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 8 Sep 2026 02:47:19 +0800 Subject: [PATCH 16/39] Bump version to 1.11.6 in pom.xml --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index faee329..6fb1eaa 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira oStella - 1.11.5 + 1.11.6 25 From 72fb2977276f0fd8591934c9eb0b7cbc8edd3a97 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 9 Sep 2026 20:06:33 +0800 Subject: [PATCH 17/39] Add support for mod parameter in getUserScore method in OsuAPI and ScoreController --- .../java/xyz/zcraft/ostella/network/OsuAPI.java | 13 +++++++++++-- .../ostella/network/controller/ScoreController.java | 6 ++++-- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java b/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java index d50820a..238982a 100644 --- a/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java +++ b/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java @@ -185,10 +185,15 @@ static String userScoresUrl(long uid, String type, boolean includeFails, UserSco record UserScoresPage(int limit, int offset) { } - public static Score getUserScore(TokenData tokenData, long uid, long beatmapId) { + public static Score getUserScore(TokenData tokenData, long uid, long beatmapId, String mods) { LOG.debug("Fetching score for user id {} on beatmap id {}", uid, beatmapId); try { - final String url = "/beatmaps/%s/scores/users/%s"; + String url = "/beatmaps/%s/scores/users/%s?mode=osu"; + + if (mods != null && !mods.isBlank()) { + url += "&mods=" + mods; + } + final var request = newRequestBuilder(tokenData, String.format(url, beatmapId, uid)) .GET() .build(); @@ -205,6 +210,10 @@ public static Score getUserScore(TokenData tokenData, long uid, long beatmapId) } } + public static Score getUserScore(TokenData tokenData, long uid, long beatmapId) { + return getUserScore(tokenData, uid, beatmapId, null); + } + public static MultiplayerRoom getCurrentRoom(String auth) { LOG.debug("Getting current room"); try { 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 a444a6d..bf2fa6d 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -192,8 +192,9 @@ private void lookupScoreOfIdAsync(@NotNull Context context) { private void lookupScoreOfBeatmapAsync(@NotNull Context context) { final long m = requireLong(context, "m"); final long u = requireLong(context, "u"); + final String mod = optionalString(context, "mod"); - context.future(() -> executor.enqueueAsync(() -> OsuAPI.getUserScore(tokenManager.getTokenData(), u, m)) + context.future(() -> executor.enqueueAsync(() -> OsuAPI.getUserScore(tokenManager.getTokenData(), u, m, mod)) .thenCompose(score -> { if (score == null) { throw new ApiException(ErrorCode.NO_SCORE_FOUND); @@ -249,6 +250,7 @@ public CompletableFuture getScoreFromBeatmapsetAsync(@NotNull Context con final long ms = requireLong(context, "ms"); final int i = requireInt(context, "i"); final long u = requireLong(context, "u"); + final String mod = optionalString(context, "mod"); return executor.enqueueAsync(() -> OsuAPI.getBeatmapset(tokenManager.getTokenData(), ms)) .thenCompose(beatmapset -> { @@ -264,7 +266,7 @@ public CompletableFuture getScoreFromBeatmapsetAsync(@NotNull Context con context.header("X-Beatmap-Id", String.valueOf(beatmap.getId())); return executor .enqueueAsync(() -> - OsuAPI.getUserScore(tokenManager.getTokenData(), u, beatmap.getId()) + OsuAPI.getUserScore(tokenManager.getTokenData(), u, beatmap.getId(), mod) ) .thenApply(score -> { score.setBeatmap(beatmap); From 6ad729890f25a2b6855c7685ea4735dd0df0dce1 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 10 Sep 2026 02:19:26 +0800 Subject: [PATCH 18/39] Update findAvailableScore method to include maxLimit parameter and adjust weight calculations --- .../network/controller/ScoreController.java | 26 ++++++++++--------- 1 file changed, 14 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 bf2fa6d..b18cbab 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -327,7 +327,7 @@ public void randomScore(@NotNull Context context) { return userIds; }) - .thenCompose(userIds -> findAvailableScore(userIds, minRank, Map.of())) + .thenCompose(userIds -> findAvailableScore(userIds, minRank, Map.of(), 40)) .thenApply(ScoreController::mapScoreToJson) .thenAccept(result -> context.status(200).result(new Response(true, "Success", result).toString()) @@ -364,7 +364,7 @@ public void randomScoreFromUsers(@NotNull Context context) { .orElse(Map.of()); context.future(() -> - findAvailableScore(userIds, Long.MAX_VALUE, scoreWeights) + findAvailableScore(userIds, Long.MAX_VALUE, scoreWeights, 200) .thenApply(ScoreController::mapScoreToJson) .thenAccept(result -> context.status(200).result(new Response(true, "Success", result).toString()) @@ -442,7 +442,9 @@ public void randomScoreFromUsersWeights(@NotNull Context context) { ); } - private CompletableFuture findAvailableScore(WeightedRandom userIds, long minRank, Map weights) { + private CompletableFuture findAvailableScore( + WeightedRandom userIds, long minRank, Map weights, int maxLimit + ) { if (userIds.isEmpty()) { return CompletableFuture.failedFuture( new ApiException(ErrorCode.NO_SCORE_FOUND, "No available scores found!") @@ -456,7 +458,7 @@ private CompletableFuture findAvailableScore(WeightedRandom u tokenManager.getTokenData(), userId, ScoreType.BEST, - 200 + maxLimit ) ).thenCompose(scores -> { List candidates = new ArrayList<>(200); @@ -472,7 +474,7 @@ private CompletableFuture findAvailableScore(WeightedRandom u } if (candidates.isEmpty()) { - return findAvailableScore(userIds, minRank, weights); + return findAvailableScore(userIds, minRank, weights, maxLimit); } return executor.enqueueAsync(() -> OsuAPI.getUser(tokenManager.getTokenData(), userId)) @@ -480,7 +482,7 @@ private CompletableFuture findAvailableScore(WeightedRandom u if (user == null || user.getStatistics().getGlobalRank() == null || user.getStatistics().getGlobalRank() > minRank) { - return findAvailableScore(userIds, minRank, weights); + return findAvailableScore(userIds, minRank, weights, maxLimit); } WeightedRandom randomScores = new WeightedRandom<>(); @@ -488,7 +490,7 @@ private CompletableFuture findAvailableScore(WeightedRandom u final Map finalWeights = getWeights(candidates, weights); if (finalWeights.isEmpty()) { - return findAvailableScore(userIds, minRank, weights); + return findAvailableScore(userIds, minRank, weights, maxLimit); } for (Map.Entry entry : finalWeights.entrySet()) { @@ -590,22 +592,22 @@ private double getModWeightFactor(ScoreEntry entry) { final ModSet mods = new ModSet(entry.score().getMods().stream().map(Mod::getAcronym).filter(Objects::nonNull).collect(Collectors.toSet())); if (mods.has("EZHD")) - return 3.0; + return 2.0; if (mods.has("EZ")) - return 2.5; + return 1.5; if (mods.has("HRHD")) - return 1.5; + return 1.2; if (mods.has("HR")) return 1.0; if (mods.has("HDDT") || mods.has("HDNC")) - return -1.0; + return -0.8; if (mods.has("DT") || mods.has("NC")) - return -0.5; + return -0.4; return 0.0; } From d7c9f1ff93cd083852eafaccb7dd653d8c7db6b8 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sat, 12 Sep 2026 10:33:53 +0800 Subject: [PATCH 19/39] Changed mod factor --- .../network/controller/ScoreController.java | 23 +++++++++---------- 1 file changed, 11 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 b18cbab..85b2722 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -591,25 +591,24 @@ private double getBestIndexFactor(ScoreEntry entry) { private double getModWeightFactor(ScoreEntry entry) { final ModSet mods = new ModSet(entry.score().getMods().stream().map(Mod::getAcronym).filter(Objects::nonNull).collect(Collectors.toSet())); - if (mods.has("EZHD")) - return 2.0; + double factor = 0.0; - if (mods.has("EZ")) - return 1.5; + if (mods.has("FL")) + factor += 0.5; - if (mods.has("HRHD")) - return 1.2; + if (mods.has("EZ")) + factor += 0.5; if (mods.has("HR")) - return 1.0; - - if (mods.has("HDDT") || mods.has("HDNC")) - return -0.8; + factor += 0.4; if (mods.has("DT") || mods.has("NC")) - return -0.4; + factor -= 0.25; + + if (mods.has("HD")) + factor *= 1.1; - return 0.0; + return factor; } private double getAttributeFactor(DifficultyAttribute difficultyAttribute) { From fdb0b3b73246c4f608cff6886c96616c528ad2b5 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sat, 12 Sep 2026 14:32:07 +0800 Subject: [PATCH 20/39] Update getModWeightFactor method to initialize factor to 1.0 --- .../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 85b2722..68886e7 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ScoreController.java @@ -591,7 +591,7 @@ private double getBestIndexFactor(ScoreEntry entry) { private double getModWeightFactor(ScoreEntry entry) { final ModSet mods = new ModSet(entry.score().getMods().stream().map(Mod::getAcronym).filter(Objects::nonNull).collect(Collectors.toSet())); - double factor = 0.0; + double factor = 1.0; if (mods.has("FL")) factor += 0.5; From 386d22d5158ffa260ec785c5611038e06b441a9c Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 13 Sep 2026 01:52:16 +0800 Subject: [PATCH 21/39] Fix username encoding in getUser method to replace spaces with underscores --- pom.xml | 2 +- src/main/java/xyz/zcraft/ostella/network/OsuAPI.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 6fb1eaa..660b67e 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira oStella - 1.11.6 + 1.11.7 25 diff --git a/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java b/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java index 238982a..b71a20b 100644 --- a/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java +++ b/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java @@ -286,7 +286,7 @@ public static UserExtended getUser(TokenData tokenData, long uid) { public static UserExtended getUser(TokenData tokenData, String username) { LOG.debug("Fetching user with username {}", username); try { - final var request = newRequestBuilder(tokenData, "/users/@" + URLEncoder.encode(username, StandardCharsets.UTF_8)) + final var request = newRequestBuilder(tokenData, "/users/@" + URLEncoder.encode(username.replace(" ", "_"), StandardCharsets.UTF_8)) .GET() .build(); From 4dae1e413d4c05a2c806b371d265333436d1ac82 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 13 Sep 2026 19:08:03 +0800 Subject: [PATCH 22/39] Add series score tracking for multiplayer matches and enhance versus display --- README.md | 21 +- pom.xml | 2 +- .../ostella/data/MultiplayerResultData.java | 62 +++- .../xyz/zcraft/ostella/data/ScoreFilter.java | 115 ++++++- .../xyz/zcraft/ostella/network/OsuAPI.java | 50 ++- .../xyz/zcraft/ostella/network/WebServer.java | 1 + .../controller/MultiplayerController.java | 311 +++++++++++++++++- .../network/controller/ReplayController.java | 18 + .../network/controller/UserController.java | 25 +- .../service/MultiplayerResultFactory.java | 18 +- .../zcraft/ostella/service/ReplayService.java | 54 +++ .../template/multiplayer-room-result.html | 66 ++++ 12 files changed, 696 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index b34c710..57abd2e 100644 --- a/README.md +++ b/README.md @@ -169,16 +169,17 @@ persisting it. After osuRenderer uploads the completed MP4, `/status` returns th QQ `qqFile` identifier to SeiraCore. Deploy both service hops behind TLS because the render request can contain a bearer credential. -| Method | Path | Purpose | Params / POST Body | Response | -|--------|-----------------------------------------|----------------------------------------|---------------------------------------------------|-------------| -| GET | `/replays/status` | Replay renderer overview | none | JSON | -| POST | `/cache/control` | Control cache across oStella and workers | `{"operation":"QUERY","type":"BEATMAPSET","id":12345}` | JSON | -| POST | `/replays/renders/score/{scoreId}` | Queue single replay render | path `scoreId` | `202` JSON | -| POST | `/replays/renders/showcase/scores` | Queue multi-score showcase render | POST Body `{"ids":[score ids]}` | `202` JSON | -| POST | `/replays/renders/showcase/{beatmapId}` | Queue multi-score showcase render | path `beatmapId` + POST Body `{"ids":[user ids]}` | `202` JSON | -| GET | `/replays/{jobId}/status` | Get render job state | path `{jobId}` | JSON | -| GET | `/replays/{jobId}/video` | Download rendered video | path `{jobId}` | `video/mp4` | -| DELETE | `/replays/{jobId}/video` | Remove rendered video and job metadata | path `{jobId}` | text | +| Method | Path | Purpose | Params / POST Body | Response | +|--------|-----------------------------------------|------------------------------------------|--------------------------------------------------------|-------------| +| GET | `/replays/status` | Replay renderer overview | none | JSON | +| POST | `/cache/control` | Control cache across oStella and workers | `{"operation":"QUERY","type":"BEATMAPSET","id":12345}` | JSON | +| POST | `/replays/renders/score/{scoreId}` | Queue single replay render | path `scoreId` | `202` JSON | +| POST | `/replays/renders/showcase/scores` | Queue multi-score showcase render | POST Body `{"ids":[score ids]}` | `202` JSON | +| POST | `/replays/renders/showcase/{beatmapId}` | Queue multi-score showcase render | path `beatmapId` + POST Body `{"ids":[user ids]}` | `202` JSON | +| GET | `/replays/{jobId}/status` | Get render job state | path `{jobId}` | JSON | +| POST | `/replays/{jobId}/cancel` | Cancel a queued or active render job | path `{jobId}` | JSON | +| GET | `/replays/{jobId}/video` | Download rendered video | path `{jobId}` | `video/mp4` | +| DELETE | `/replays/{jobId}/video` | Remove rendered video and job metadata | path `{jobId}` | text | ### Miscellaneous diff --git a/pom.xml b/pom.xml index 660b67e..6c3b9db 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira oStella - 1.11.7 + 1.11.8 25 diff --git a/src/main/java/xyz/zcraft/ostella/data/MultiplayerResultData.java b/src/main/java/xyz/zcraft/ostella/data/MultiplayerResultData.java index 8e5722e..91e6dd3 100644 --- a/src/main/java/xyz/zcraft/ostella/data/MultiplayerResultData.java +++ b/src/main/java/xyz/zcraft/ostella/data/MultiplayerResultData.java @@ -2,10 +2,12 @@ import java.util.Comparator; import java.util.List; +import java.util.Map; public record MultiplayerResultData(long roomId, String roomName, long playlistItemId, String playedAt, String client, String scoringType, String teamType, long totalScore, long averageScore, double teamLeadPercent, String winningTeam, + SeriesScore seriesScore, BeatmapInfo beatmap, UserInfo queuedBy, List players, List teams, List unassignedPlayers) { public MultiplayerResultData( @@ -20,6 +22,7 @@ public MultiplayerResultData( long averageScore, double teamLeadPercent, String winningTeam, + SeriesScore seriesScore, BeatmapInfo beatmap, UserInfo queuedBy, List players, @@ -37,6 +40,7 @@ public MultiplayerResultData( this.averageScore = averageScore; this.teamLeadPercent = teamLeadPercent; this.winningTeam = winningTeam; + this.seriesScore = seriesScore == null ? SeriesScore.empty() : seriesScore; this.beatmap = beatmap; this.queuedBy = queuedBy; this.players = List.copyOf(players); @@ -45,11 +49,11 @@ public MultiplayerResultData( } public boolean isTeamVs() { - return teams.size() == 2; + return teams.size() == 2 && !isTeamDuel(); } public boolean isDuel() { - return !isTeamVs() && players.size() == 2; + return isTeamDuel() || (teams.isEmpty() && players.size() == 2); } public List duelPlayers() { @@ -57,10 +61,49 @@ public List duelPlayers() { return players; } return players.stream() - .sorted(Comparator.comparingLong(PlayerResult::userId)) + .sorted(Comparator + .comparingInt((PlayerResult player) -> teamOrder(player.team())) + .thenComparingLong(PlayerResult::userId)) .toList(); } + public List versusScores() { + if (teams.size() == 2) { + return teams.stream() + .map(team -> new VersusScore( + team.key(), + team.name(), + "red".equals(team.key()) ? seriesScore.redWins() : seriesScore.blueWins() + )) + .toList(); + } + if (!isDuel()) { + return List.of(); + } + return duelPlayers().stream() + .map(player -> new VersusScore( + null, + player.username(), + seriesScore.playerWins().getOrDefault(player.userId(), 0) + )) + .toList(); + } + + private static int teamOrder(String team) { + return switch (team == null ? "" : team) { + case "red" -> 0; + case "blue" -> 1; + default -> 2; + }; + } + + private boolean isTeamDuel() { + return players.size() == 2 + && teams.size() == 2 + && teams.get(0).players().size() == 1 + && teams.get(1).players().size() == 1; + } + public record BeatmapInfo(long id, String title, String titleUnicode, String artist, String creator, String difficultyName, Double difficultyRating, Double bpm, Long totalLength, String ruleset, String coverUrl) { @@ -81,4 +124,17 @@ public record TeamResult(String key, String name, long totalScore, List playerWins, int redWins, int blueWins) { + public SeriesScore { + playerWins = playerWins == null ? Map.of() : Map.copyOf(playerWins); + } + + public static SeriesScore empty() { + return new SeriesScore(Map.of(), 0, 0); + } + } + + public record VersusScore(String key, String name, int wins) { + } } diff --git a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java index f36bdb4..0036cca 100644 --- a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java +++ b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java @@ -14,16 +14,20 @@ 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|replay" - + "|title|artist|mapper|genre|language|video|storyboard|fullcombo|ar|od|cs|hp)" + + "|any|title|artist|mapper|genre|language|video|storyboard|fullcombo|ar|od|cs|hp)" + "(>=|<=|!=|!~|>|<|=|~)(.+)$" ); private static final Pattern DURATION_PATTERN = Pattern.compile("(?i)^(?:(\\d+)m)?(?:(\\d+(?:\\.\\d+)?)s)?$"); private static final Pattern MOD_PATTERN = Pattern.compile("[A-Z]{2}"); + private static final Pattern REGEX_FILTER_PREFIX_PATTERN = Pattern.compile( + "(?i)^(?:any|title|artist|mapper|genre|language)(?:=|!=)$" + ); private final Field field; private final Operator operator; private final double numericValue; private final Set textValues; + private final Pattern regexValue; private final String displayText; private ScoreFilter( @@ -31,12 +35,14 @@ private ScoreFilter( Operator operator, double numericValue, Set textValues, + Pattern regexValue, String displayText ) { this.field = field; this.operator = operator; this.numericValue = numericValue; this.textValues = textValues; + this.regexValue = regexValue; this.displayText = displayText; } @@ -46,7 +52,7 @@ public static List parseList(String encodedFilters) { } List filters = new ArrayList<>(); - for (String token : encodedFilters.split(",", -1)) { + for (String token : splitFilterTokens(encodedFilters)) { if (token.isBlank()) { throw new IllegalArgumentException("Filter cannot be empty"); } @@ -88,6 +94,7 @@ public static ScoreFilter parse(String token) { operator, numericValue, Set.of(), + null, field.label + " " + operator.display + " " + formatValue(field, numericValue) ); } @@ -104,6 +111,7 @@ private static ScoreFilter parseMods(Field field, Operator operator, String valu operator, Double.NaN, mods, + null, field.label + " " + operator.display + " " + displayValue ); } @@ -116,21 +124,41 @@ private static ScoreFilter parseRank(Field field, Operator operator, String valu if (!Set.of("XH", "X", "SH", "S", "A", "B", "C", "D", "F").contains(rank)) { throw new IllegalArgumentException("Invalid rank: " + value); } - return new ScoreFilter(field, operator, Double.NaN, Set.of(rank), field.label + " " + operator.display + " " + rank); + return new ScoreFilter(field, operator, Double.NaN, Set.of(rank), null, + field.label + " " + operator.display + " " + rank); } private static ScoreFilter parseMetadataText(Field field, Operator operator, String value) { if (!operator.isText()) { throw new IllegalArgumentException(field.label + " only supports ~, !~, = and !="); } + if (field == Field.ANY && operator != Operator.EQUAL && operator != Operator.NOT_EQUAL) { + throw new IllegalArgumentException("Any metadata only supports = and !="); + } if (value.isBlank()) { throw new IllegalArgumentException(field.label + " cannot be empty"); } + Pattern regex = null; + if (isRegexValue(value)) { + if (operator != Operator.EQUAL && operator != Operator.NOT_EQUAL) { + throw new IllegalArgumentException("Regular expressions only support = and !="); + } + String expression = value.substring(1, value.length() - 1); + if (expression.isEmpty()) { + throw new IllegalArgumentException("Regular expression cannot be empty"); + } + try { + regex = Pattern.compile(expression, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE); + } catch (java.util.regex.PatternSyntaxException e) { + throw new IllegalArgumentException("Invalid regular expression: " + e.getDescription(), e); + } + } return new ScoreFilter( field, operator, Double.NaN, Set.of(value.toLowerCase(Locale.ROOT)), + regex, field.label + " " + operator.display + " " + value ); } @@ -148,10 +176,47 @@ private static ScoreFilter parseBoolean(Field field, Operator operator, String v operator, Double.NaN, Set.of(normalized), + null, field.label + " " + operator.display + " " + normalized ); } + private static boolean isRegexValue(String value) { + return value.length() >= 2 && value.startsWith("[") && value.endsWith("]"); + } + + private static List splitFilterTokens(String encodedFilters) { + List tokens = new ArrayList<>(); + int start = 0; + int bracketDepth = 0; + boolean escaped = false; + for (int index = 0; index < encodedFilters.length(); index++) { + char character = encodedFilters.charAt(index); + if (escaped) { + escaped = false; + continue; + } + if (character == '\\' && bracketDepth > 0) { + escaped = true; + } else if (character == '[') { + if (bracketDepth > 0 + || REGEX_FILTER_PREFIX_PATTERN.matcher(encodedFilters.substring(start, index)).matches()) { + bracketDepth++; + } + } else if (character == ']' && bracketDepth > 0) { + bracketDepth--; + } else if (character == ',' && bracketDepth == 0) { + tokens.add(encodedFilters.substring(start, index)); + start = index + 1; + } + } + if (bracketDepth != 0) { + throw new IllegalArgumentException("Unclosed regular expression filter"); + } + tokens.add(encodedFilters.substring(start)); + return tokens; + } + private static Set parseMods(String value) { String normalized = value.trim().toUpperCase(Locale.ROOT).replace("+", "").replace(" ", ""); if (normalized.equals("NM")) { @@ -227,7 +292,7 @@ private static String formatValue(Field field, double value) { case BPM -> formatNumber(value) + " BPM"; case MISS -> formatNumber(value) + " miss"; case SCORE, AR, CS, HP, OD -> formatNumber(value); - case MODS, RANK, TITLE, ARTIST, MAPPER, GENRE, LANGUAGE, VIDEO, STORYBOARD, FULL_COMBO, REPLAY -> + case MODS, RANK, ANY, TITLE, ARTIST, MAPPER, GENRE, LANGUAGE, VIDEO, STORYBOARD, FULL_COMBO, REPLAY -> throw new IllegalStateException("Text filter has no numeric value"); }; } @@ -270,6 +335,7 @@ public boolean matches(Score score) { case SCORE -> score.getTotalScore() != null && compare(score.getTotalScore()); case MODS -> compareMods(score.getMods()); case RANK -> compareText(score.getRank()); + case ANY -> compareText(allMetadataText(score)); case TITLE -> score.getBeatmapset() != null && compareText(score.getBeatmapset().getTitle(), score.getBeatmapset().getTitleUnicode()); case ARTIST -> score.getBeatmapset() != null @@ -323,10 +389,14 @@ private boolean compareMods(List scoreMods) { } private boolean compareText(String actual) { - return compareText(new String[]{actual}); + return compareText(Collections.singletonList(actual)); } private boolean compareText(String... actualValues) { + return compareText(Arrays.asList(actualValues)); + } + + private boolean compareText(Collection actualValues) { String expected = textValues.iterator().next().toLowerCase(Locale.ROOT); boolean matches = false; boolean present = false; @@ -334,7 +404,9 @@ private boolean compareText(String... actualValues) { if (actual == null) continue; present = true; String normalized = actual.toLowerCase(Locale.ROOT); - if (operator == Operator.CONTAINS || operator == Operator.NOT_CONTAINS) { + if (regexValue != null) { + matches |= regexValue.matcher(actual).find(); + } else if (field == Field.ANY || operator == Operator.CONTAINS || operator == Operator.NOT_CONTAINS) { matches |= normalized.contains(expected); } else { matches |= normalized.equals(expected); @@ -348,6 +420,33 @@ private boolean compareText(String... actualValues) { }; } + private static List allMetadataText(Score score) { + List values = new ArrayList<>(); + if (score.getBeatmap() != null) { + values.add(score.getBeatmap().getVersion()); + } + if (score.getBeatmapset() != null) { + var beatmapset = score.getBeatmapset(); + values.add(beatmapset.getTitle()); + values.add(beatmapset.getTitleUnicode()); + values.add(beatmapset.getArtist()); + values.add(beatmapset.getArtistUnicode()); + values.add(beatmapset.getCreator()); + values.add(beatmapset.getSource()); + values.add(beatmapset.getTags()); + if (beatmapset.getGenre() != null) values.add(beatmapset.getGenre().getName()); + if (beatmapset.getLanguage() != null) values.add(beatmapset.getLanguage().getName()); + if (beatmapset.getPackTags() != null) values.addAll(beatmapset.getPackTags()); + if (beatmapset.getRelatedTags() != null) { + beatmapset.getRelatedTags().forEach(tag -> { + values.add(tag.getName()); + values.add(tag.getDescription()); + }); + } + } + return values; + } + private boolean compareBoolean(Boolean actual) { if (actual == null) return false; boolean equal = actual == Boolean.parseBoolean(textValues.iterator().next()); @@ -373,6 +472,7 @@ private enum Field { SCORE("Score"), MODS("Mods"), RANK("Rank"), + ANY("Any metadata"), TITLE("Title"), ARTIST("Artist"), MAPPER("Mapper"), @@ -405,6 +505,7 @@ static Field from(String value) { case "score" -> SCORE; case "mod", "mods" -> MODS; case "rank" -> RANK; + case "any" -> ANY; case "title" -> TITLE; case "artist" -> ARTIST; case "mapper" -> MAPPER; @@ -419,7 +520,7 @@ static Field from(String value) { } boolean isMetadataText() { - return this == TITLE || this == ARTIST || this == MAPPER || this == GENRE || this == LANGUAGE; + return this == ANY || this == TITLE || this == ARTIST || this == MAPPER || this == GENRE || this == LANGUAGE; } boolean isBoolean() { diff --git a/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java b/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java index b71a20b..cdb0601 100644 --- a/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java +++ b/src/main/java/xyz/zcraft/ostella/network/OsuAPI.java @@ -114,6 +114,10 @@ public static Score getLegacyScore(TokenData tokenData, long legacyScoreId) { } public static List getUserScores(TokenData tokenData, long uid, ScoreType mode, int limit) { + return getUserScores(tokenData, uid, mode, limit, 0); + } + + public static List getUserScores(TokenData tokenData, long uid, ScoreType mode, int limit, int offset) { LOG.debug("Fetching {} scores for user id {} in mode {}", mode.name().toLowerCase(), uid, mode.name().toLowerCase()); try { String type = switch (mode) { @@ -125,7 +129,7 @@ public static List getUserScores(TokenData tokenData, long uid, ScoreType case RECENT -> true; }; final List scores = new ArrayList<>(limit); - for (UserScoresPage page : userScoresPages(limit)) { + for (UserScoresPage page : userScoresPages(limit, offset)) { final String url = userScoresUrl(uid, type, fail, page); final var request = newRequestBuilder(tokenData, url).GET().build(); final HttpResponse send = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); @@ -158,16 +162,21 @@ public static List getUserScores(TokenData tokenData, long uid, ScoreType } static List userScoresPages(int limit) { - if (limit <= 0 || limit > MAX_USER_SCORES_LIMIT) { + return userScoresPages(limit, 0); + } + + static List userScoresPages(int limit, int offset) { + if (limit <= 0 || offset < 0 || (long) offset + limit > MAX_USER_SCORES_LIMIT) { throw new ApiException( ErrorCode.ILLEGAL_ARGUMENT, - "Score limit must be between 1 and " + MAX_USER_SCORES_LIMIT + "Score range must be within positions 1 to " + MAX_USER_SCORES_LIMIT ); } List pages = new ArrayList<>(2); - for (int offset = 0; offset < limit; offset += USER_SCORES_PAGE_LIMIT) { - pages.add(new UserScoresPage(Math.min(USER_SCORES_PAGE_LIMIT, limit - offset), offset)); + int endOffset = offset + limit; + for (int pageOffset = offset; pageOffset < endOffset; pageOffset += USER_SCORES_PAGE_LIMIT) { + pages.add(new UserScoresPage(Math.min(USER_SCORES_PAGE_LIMIT, endOffset - pageOffset), pageOffset)); } return List.copyOf(pages); } @@ -558,7 +567,17 @@ public static MultiplayerRoomDetails.PlaylistItem getRoomEventPlaylistItem( long roomId, long playlistItemId ) { - LOG.debug("Fetching events for multiplayer room {} playlist item {}", roomId, playlistItemId); + return getRoomEventPlaylistItems(tokenData, roomId).stream() + .filter(item -> item.getId() == playlistItemId) + .findFirst() + .orElse(null); + } + + public static List getRoomEventPlaylistItems( + TokenData tokenData, + long roomId + ) { + LOG.debug("Fetching events for multiplayer room {}", roomId); try { final var request = newRequestBuilder(tokenData, "/rooms/" + roomId + "/events") .GET() @@ -566,7 +585,7 @@ public static MultiplayerRoomDetails.PlaylistItem getRoomEventPlaylistItem( final HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() == 404) { - return null; + return List.of(); } if (response.statusCode() >= 400) { throw new ApiException( @@ -575,7 +594,7 @@ public static MultiplayerRoomDetails.PlaylistItem getRoomEventPlaylistItem( ); } - return eventPlaylistItem(response.body(), playlistItemId); + return eventPlaylistItems(response.body()); } catch (JsonSyntaxException | IOException | InterruptedException e) { throw new ApiException( ErrorCode.ROOM_FETCH_FAILED, @@ -586,20 +605,25 @@ public static MultiplayerRoomDetails.PlaylistItem getRoomEventPlaylistItem( } static MultiplayerRoomDetails.PlaylistItem eventPlaylistItem(String body, long playlistItemId) { + return eventPlaylistItems(body).stream() + .filter(item -> item.getId() == playlistItemId) + .findFirst() + .orElse(null); + } + + static List eventPlaylistItems(String body) { JsonObject root = JsonParser.parseString(body).getAsJsonObject(); JsonArray playlistItems = root.has("playlist_items") && root.get("playlist_items").isJsonArray() ? root.getAsJsonArray("playlist_items") : new JsonArray(); + List result = new ArrayList<>(playlistItems.size()); for (JsonElement element : playlistItems) { if (!element.isJsonObject()) { continue; } - JsonObject object = element.getAsJsonObject(); - if (object.has("id") && object.get("id").getAsLong() == playlistItemId) { - return GSON.fromJson(object, MultiplayerRoomDetails.PlaylistItem.class); - } + result.add(GSON.fromJson(element, MultiplayerRoomDetails.PlaylistItem.class)); } - return null; + return List.copyOf(result); } public static MultiplayerMatchDetails getMatch(TokenData tokenData, long matchId) { diff --git a/src/main/java/xyz/zcraft/ostella/network/WebServer.java b/src/main/java/xyz/zcraft/ostella/network/WebServer.java index 41ad6b2..99b6505 100644 --- a/src/main/java/xyz/zcraft/ostella/network/WebServer.java +++ b/src/main/java/xyz/zcraft/ostella/network/WebServer.java @@ -103,6 +103,7 @@ public WebServer(AppConfig conf, TokenManager tokenManager) throws IOException { .post("/replays/renders/showcase/{beatmapId}", router.replayController::renderShowcaseOfUsers) .get("/replays/{jobId}/status", router.replayController::getReplayRenderStatus) + .post("/replays/{jobId}/cancel", router.replayController::cancelReplayRender) .get("/replays/{jobId}/video", router.replayController::getReplayRenderResultStream) .get("/replays/{jobId}/video/replay.mp4", router.replayController::getReplayRenderResultFile) .delete("/replays/{jobId}/video", router.replayController::deleteReplayRenderResult); diff --git a/src/main/java/xyz/zcraft/ostella/network/controller/MultiplayerController.java b/src/main/java/xyz/zcraft/ostella/network/controller/MultiplayerController.java index 00a30d5..6c314c1 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/MultiplayerController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/MultiplayerController.java @@ -26,12 +26,15 @@ import xyz.zcraft.osu.model.UserExtended; import java.util.Comparator; +import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; public class MultiplayerController { private static final Logger LOG = LogManager.getLogger(MultiplayerController.class); @@ -137,7 +140,12 @@ private MultiplayerResultData getLazerResultData(long roomId, long playlistItemI List roomScores = OsuAPI.getRoomPlaylistScores( tokenManager.getTokenData(), roomId, playlistItemId ); - enrichLazerTeamSnapshot(room, item, roomScores); + List eventItems = isTeamMode(room.getType()) + ? OsuAPI.getRoomEventPlaylistItems(tokenManager.getTokenData(), roomId) + : List.of(); + enrichLazerTeamSnapshot(room, item, roomScores, eventItems); + MultiplayerResultData.SeriesScore seriesScore = lazerSeriesScore( + room, item, roomScores, eventItems); enrichScores(roomScores, item); enrichDuelProfiles(roomScores); User owner = resolveOwner(room, item.getOwnerId()); @@ -148,14 +156,16 @@ private MultiplayerResultData getLazerResultData(long roomId, long playlistItemI owner, "lazer", "scorev2", - room.getType() + room.getType(), + seriesScore ); } private void enrichLazerTeamSnapshot( MultiplayerRoomDetails room, MultiplayerRoomDetails.PlaylistItem item, - List roomScores + List roomScores, + List eventItems ) { String roomType = room.getType(); boolean teamVs = roomType != null @@ -166,9 +176,10 @@ private void enrichLazerTeamSnapshot( return; } - MultiplayerRoomDetails.PlaylistItem eventItem = OsuAPI.getRoomEventPlaylistItem( - tokenManager.getTokenData(), room.getId(), item.getId() - ); + MultiplayerRoomDetails.PlaylistItem eventItem = eventItems.stream() + .filter(value -> value.getId() == item.getId()) + .findFirst() + .orElse(null); if (eventItem == null || eventItem.getDetails() == null) { LOG.warn("Room events contain no details for playlist item {} in room {}", item.getId(), room.getId()); return; @@ -195,6 +206,7 @@ private MultiplayerResultData getStableResultData(long matchId, long gameId) { enrichPlaylistItem(item); List scores = stableScores(match, game, item); + MultiplayerResultData.SeriesScore seriesScore = stableSeriesScore(match, game); enrichDuelProfiles(scores); User stableLobby = new User(); stableLobby.setUsername("Stable lobby"); @@ -205,7 +217,8 @@ private MultiplayerResultData getStableResultData(long matchId, long gameId) { stableLobby, "stable", game.getScoringType(), - game.getTeamType() + game.getTeamType(), + seriesScore ); } @@ -341,6 +354,290 @@ private static Comparator stableScoreComparator(String scoringType) { }; } + private MultiplayerResultData.SeriesScore lazerSeriesScore( + MultiplayerRoomDetails room, + MultiplayerRoomDetails.PlaylistItem currentItem, + List currentScores, + List eventItems + ) { + boolean teamMode = isTeamMode(room.getType()); + Set duelUsers = teamMode ? Set.of() : scoreUserIds(currentScores); + if (!teamMode && duelUsers.size() != 2) { + return MultiplayerResultData.SeriesScore.empty(); + } + + Map eventsById = new HashMap<>(); + eventItems.forEach(item -> eventsById.put(item.getId(), item)); + Map playerWins = new HashMap<>(); + int redWins = 0; + int blueWins = 0; + + for (MultiplayerRoomDetails.PlaylistItem item : completedItemsThrough(room, currentItem)) { + List scores; + if (item.getId() == currentItem.getId()) { + scores = currentScores; + } else { + try { + scores = OsuAPI.getRoomPlaylistScores( + tokenManager.getTokenData(), room.getId(), item.getId()); + } catch (ApiException e) { + LOG.warn("Failed to include playlist item {} in room {} series score", + item.getId(), room.getId(), e); + continue; + } + } + + if (teamMode) { + String winner = lazerTeamWinner(scores, item, eventsById.get(item.getId())); + if ("red".equals(winner)) redWins++; + if ("blue".equals(winner)) blueWins++; + } else { + Long winner = lazerDuelWinner(scores, duelUsers); + if (winner != null) playerWins.merge(winner, 1, Integer::sum); + } + } + return new MultiplayerResultData.SeriesScore(playerWins, redWins, blueWins); + } + + private static List completedItemsThrough( + MultiplayerRoomDetails room, + MultiplayerRoomDetails.PlaylistItem currentItem + ) { + Map items = new LinkedHashMap<>(); + if (room.getPlaylist() != null) { + room.getPlaylist().stream().filter(Objects::nonNull) + .forEach(item -> items.putIfAbsent(item.getId(), item)); + } + items.putIfAbsent(currentItem.getId(), currentItem); + + List completed = new ArrayList<>(); + for (MultiplayerRoomDetails.PlaylistItem item : items.values()) { + if (item.getId() == currentItem.getId() + || item.getPlayedAt() != null && !item.getPlayedAt().isBlank()) { + completed.add(item); + } + if (item.getId() == currentItem.getId()) break; + } + return List.copyOf(completed); + } + + private static String lazerTeamWinner( + List scores, + MultiplayerRoomDetails.PlaylistItem item, + MultiplayerRoomDetails.PlaylistItem eventItem + ) { + long red = 0; + long blue = 0; + boolean hasRed = false; + boolean hasBlue = false; + for (MultiplayerRoomScore roomScore : scores) { + Score score = roomScore.score(); + if (score == null || score.getTotalScore() == null) continue; + Long userId = scoreUserId(score); + String team = normalizedTeam(firstNonBlank( + roomScore.team(), + item.teamFor(userId), + eventItem == null ? null : eventItem.teamFor(userId) + )); + if ("red".equals(team)) { + red += score.getTotalScore(); + hasRed = true; + } else if ("blue".equals(team)) { + blue += score.getTotalScore(); + hasBlue = true; + } + } + if (!hasRed || !hasBlue || red == blue) return null; + return red > blue ? "red" : "blue"; + } + + private static Long lazerDuelWinner(List scores, Set duelUsers) { + Map values = new HashMap<>(); + for (MultiplayerRoomScore roomScore : scores) { + Score score = roomScore.score(); + Long userId = scoreUserId(score); + if (userId != null && score.getTotalScore() != null) { + values.put(userId, score.getTotalScore()); + } + } + if (!values.keySet().equals(duelUsers)) return null; + List> ordered = values.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + return ordered.get(0).getValue().equals(ordered.get(1).getValue()) + ? null : ordered.get(0).getKey(); + } + + private static MultiplayerResultData.SeriesScore stableSeriesScore( + MultiplayerMatchDetails match, + MultiplayerMatchDetails.MatchGame currentGame + ) { + boolean teamMode = isTeamMode(currentGame.getTeamType()); + Set duelUsers = teamMode ? Set.of() : stableUserIds(currentGame.getScores()); + if (!teamMode && duelUsers.size() != 2) { + return MultiplayerResultData.SeriesScore.empty(); + } + + Map playerWins = new HashMap<>(); + int redWins = 0; + int blueWins = 0; + for (MultiplayerMatchDetails.MatchGame game : completedGamesThrough(match, currentGame)) { + if (teamMode) { + if (!isTeamMode(game.getTeamType())) continue; + String winner = stableTeamWinner(game); + if ("red".equals(winner)) redWins++; + if ("blue".equals(winner)) blueWins++; + } else { + if (isTeamMode(game.getTeamType())) continue; + Long winner = stableDuelWinner(game, duelUsers); + if (winner != null) playerWins.merge(winner, 1, Integer::sum); + } + } + return new MultiplayerResultData.SeriesScore(playerWins, redWins, blueWins); + } + + private static List completedGamesThrough( + MultiplayerMatchDetails match, + MultiplayerMatchDetails.MatchGame currentGame + ) { + Map games = new LinkedHashMap<>(); + if (match.getEvents() != null) { + match.getEvents().stream() + .filter(Objects::nonNull) + .map(MultiplayerMatchDetails.MatchEvent::getGame) + .filter(Objects::nonNull) + .forEach(game -> games.putIfAbsent(game.getId(), game)); + } + games.putIfAbsent(currentGame.getId(), currentGame); + + List completed = new ArrayList<>(); + for (MultiplayerMatchDetails.MatchGame game : games.values()) { + if (game.getId() == currentGame.getId() + || game.getEndTime() != null && !game.getEndTime().isBlank()) { + completed.add(game); + } + if (game.getId() == currentGame.getId()) break; + } + return List.copyOf(completed); + } + + private static String stableTeamWinner(MultiplayerMatchDetails.MatchGame game) { + double red = 0; + double blue = 0; + boolean hasRed = false; + boolean hasBlue = false; + for (JsonObject score : nullSafeScores(game.getScores())) { + Double value = stableScoringValue(score, game.getScoringType()); + String team = normalizedTeam(scoreTeam(score)); + if (value == null) continue; + if ("red".equals(team)) { + red += value; + hasRed = true; + } else if ("blue".equals(team)) { + blue += value; + hasBlue = true; + } + } + if (!hasRed || !hasBlue || Double.compare(red, blue) == 0) return null; + return red > blue ? "red" : "blue"; + } + + private static Long stableDuelWinner( + MultiplayerMatchDetails.MatchGame game, + Set duelUsers + ) { + Map values = new HashMap<>(); + for (JsonObject score : nullSafeScores(game.getScores())) { + Long userId = stableUserId(score); + Double value = stableScoringValue(score, game.getScoringType()); + if (userId != null && value != null) values.put(userId, value); + } + if (!values.keySet().equals(duelUsers)) return null; + List> ordered = values.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .toList(); + return Double.compare(ordered.get(0).getValue(), ordered.get(1).getValue()) == 0 + ? null : ordered.get(0).getKey(); + } + + private static Double stableScoringValue(JsonObject score, String scoringType) { + String normalized = scoringType == null ? "score" : scoringType.toLowerCase(Locale.ROOT); + if ("accuracy".equals(normalized)) return jsonNumber(score, "accuracy"); + if ("combo".equals(normalized)) return jsonNumber(score, "max_combo"); + return jsonNumber(score, "total_score", "legacy_total_score", "classic_total_score", "score"); + } + + private static Double jsonNumber(JsonObject object, String... names) { + for (String name : names) { + if (object.has(name) && !object.get(name).isJsonNull()) { + return object.get(name).getAsDouble(); + } + } + return null; + } + + private static Set scoreUserIds(List scores) { + Set ids = new LinkedHashSet<>(); + for (MultiplayerRoomScore roomScore : scores) { + Score score = roomScore.score(); + Long userId = scoreUserId(score); + if (userId != null) ids.add(userId); + } + return Set.copyOf(ids); + } + + private static Long scoreUserId(Score score) { + if (score == null) return null; + if (score.getUserId() != null && score.getUserId() > 0) return score.getUserId(); + return score.getUser() == null || score.getUser().getId() <= 0 ? null : score.getUser().getId(); + } + + private static Set stableUserIds(List scores) { + Set ids = new LinkedHashSet<>(); + for (JsonObject score : nullSafeScores(scores)) { + Long userId = stableUserId(score); + if (userId != null) ids.add(userId); + } + return Set.copyOf(ids); + } + + private static Long stableUserId(JsonObject score) { + if (score.has("user_id") && !score.get("user_id").isJsonNull()) { + return score.get("user_id").getAsLong(); + } + if (score.has("user") && score.get("user").isJsonObject()) { + JsonObject user = score.getAsJsonObject("user"); + if (user.has("id") && !user.get("id").isJsonNull()) return user.get("id").getAsLong(); + } + return null; + } + + private static List nullSafeScores(List scores) { + return scores == null ? List.of() : scores.stream().filter(Objects::nonNull).toList(); + } + + private static boolean isTeamMode(String teamType) { + if (teamType == null) return false; + String normalized = teamType.toLowerCase(Locale.ROOT).replace('_', '-'); + return normalized.contains("team") && !normalized.contains("head"); + } + + private static String normalizedTeam(String team) { + if (team == null) return null; + return switch (team.toLowerCase(Locale.ROOT)) { + case "red", "1" -> "red"; + case "blue", "2" -> "blue"; + default -> null; + }; + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) return value; + } + return null; + } + private void enrichPlaylistItem(MultiplayerRoomDetails.PlaylistItem item) { if (item.getBeatmap() == null || item.getBeatmap().getBeatmapset() == null) { BeatmapExtended beatmap = OsuAPI.getBeatmap(tokenManager.getTokenData(), item.getBeatmapId()); 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 5ea4a15..87b89e8 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ReplayController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ReplayController.java @@ -82,6 +82,8 @@ public void getReplayRenderStatus(@NotNull Context context) { context, jobId, "failed", "Render failed", jobProgress.error()); case ReplayService.JobStatus.TIMEOUT -> respondTerminalStatus( context, jobId, "timeout", "Render timed out", jobProgress.error()); + case ReplayService.JobStatus.CANCELED -> respondTerminalStatus( + context, jobId, "canceled", "Render canceled", jobProgress.error()); case ReplayService.JobStatus.QUEUED -> context.status(200).result( new Response(true, "Render is waiting in queue", GSON.toJsonTree(Map.of( @@ -139,6 +141,22 @@ public void deleteReplayRenderResult(@NotNull Context context) throws IOExceptio context.status(200).result("Job cleaned up successfully"); } + public void cancelReplayRender(@NotNull Context context) { + String jobId = context.pathParam("jobId"); + ReplayService.JobProgress progress = replayService.cancelJob(jobId); + if (progress.status() == ReplayService.JobStatus.UNKNOWN) { + context.status(404).result(new Response(false, "Job not found", null).toString()); + return; + } + + String status = progress.status().name().toLowerCase(Locale.ROOT); + JsonObject data = terminalStatusData(jobId, status, progress.error()); + String message = progress.status() == ReplayService.JobStatus.CANCELED + ? "Render canceled" + : "Render can no longer be canceled because it is " + status; + context.status(200).result(new Response(true, message, data).toString()); + } + private CompletionStage finalizeReplay(@NotNull Context context, Score score, ReplayService.QqUploadRequest qqUpload) { final double start = optionalDouble(context, "start"); 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 d6a2f89..5465694 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/UserController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/UserController.java @@ -71,13 +71,17 @@ private static int requireScoreListLimit(Context context) { } static FilteredScores applyFilters(List scores, List filters) { + return applyFilters(scores, filters, 1); + } + + static FilteredScores applyFilters(List scores, List filters, int firstPosition) { 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); + originalPositions.add(index + firstPosition); } } if (!filters.isEmpty() && result.isEmpty()) { @@ -86,6 +90,15 @@ static FilteredScores applyFilters(List scores, List filters return new FilteredScores(List.copyOf(result), List.copyOf(originalPositions)); } + private static int requireScoreListStart(Context context, int endPosition) { + String startParam = context.queryParam("start"); + int start = startParam == null ? 1 : requirePositiveInt(context, "start"); + if (start > endPosition) { + throw new ApiException(ErrorCode.ILLEGAL_ARGUMENT, "Score range start must not exceed its end"); + } + return start; + } + static FilteredScores filterBestScoresSince(List bestScores, Instant cutoff) { List result = new ArrayList<>(); List bestPositions = new ArrayList<>(); @@ -179,12 +192,13 @@ public void getSelf(@NotNull Context context) { public void getRecentScores(@NotNull Context context) { final long u = requirePathLong(context, "userId"); final int n = requireScoreListLimit(context); + final int start = requireScoreListStart(context, n); final boolean fail = requireBoolean(context, "fail", false); final List filters = requireScoreFilters(context); final ScoreType type = fail ? ScoreType.RECENT : ScoreType.RECENT_PASS; context.future(() -> executor.enqueueAsync(() -> OsuAPI.getUserScores( - tokenManager.getTokenData(), u, type, n) + tokenManager.getTokenData(), u, type, n - start + 1, start - 1) ) .thenCompose(scores -> executor.enqueueAsync(() -> OsuAPI.getUser(tokenManager.getTokenData(), u)) .thenApplyAsync(user -> { @@ -195,7 +209,7 @@ public void getRecentScores(@NotNull Context context) { router.ensurePp(score); } - FilteredScores filteredScores = applyFilters(scores, filters); + FilteredScores filteredScores = applyFilters(scores, filters, start); context.header("X-User-Id", String.valueOf(user.getId())); context.header("X-Score-Ids", filteredScores.scores().stream().map(Score::getId).map(String::valueOf).collect(Collectors.joining(","))); @@ -255,9 +269,10 @@ public void getRecentScoresBatch(@NotNull Context context) { public void getBestOfN(@NotNull Context context) { final long u = requirePathLong(context, "userId"); final int n = requireScoreListLimit(context); + final int start = requireScoreListStart(context, n); final List filters = requireScoreFilters(context); context.future(() -> executor.enqueueAsync(() -> OsuAPI.getUserScores( - tokenManager.getTokenData(), u, ScoreType.BEST, n + tokenManager.getTokenData(), u, ScoreType.BEST, n - start + 1, start - 1 )) .thenCompose(scores -> { if (scores == null || scores.isEmpty()) throw new ApiException(ErrorCode.NO_SCORE_FOUND); @@ -267,7 +282,7 @@ public void getBestOfN(@NotNull Context context) { for (Score score : scores) { router.ensurePp(score); } - FilteredScores filteredScores = applyFilters(scores, filters); + FilteredScores filteredScores = applyFilters(scores, filters, start); context.header("X-User-Id", String.valueOf(user.getId())); context.header("X-Score-Ids", filteredScores.scores().stream().map(Score::getId).map(String::valueOf).collect(Collectors.joining(","))); return renderer.renderScores( diff --git a/src/main/java/xyz/zcraft/ostella/service/MultiplayerResultFactory.java b/src/main/java/xyz/zcraft/ostella/service/MultiplayerResultFactory.java index d2ff460..a235a8f 100644 --- a/src/main/java/xyz/zcraft/ostella/service/MultiplayerResultFactory.java +++ b/src/main/java/xyz/zcraft/ostella/service/MultiplayerResultFactory.java @@ -23,7 +23,8 @@ public static MultiplayerResultData create( List roomScores, User owner ) { - return create(room, item, roomScores, owner, "lazer", "scorev2", room.getType()); + return create(room, item, roomScores, owner, "lazer", "scorev2", room.getType(), + MultiplayerResultData.SeriesScore.empty()); } public static MultiplayerResultData create( @@ -34,6 +35,20 @@ public static MultiplayerResultData create( String client, String scoringType, String teamType + ) { + return create(room, item, roomScores, owner, client, scoringType, teamType, + MultiplayerResultData.SeriesScore.empty()); + } + + public static MultiplayerResultData create( + MultiplayerRoomDetails room, + MultiplayerRoomDetails.PlaylistItem item, + List roomScores, + User owner, + String client, + String scoringType, + String teamType, + MultiplayerResultData.SeriesScore seriesScore ) { BeatmapExtended map = item.getBeatmap(); MultiplayerResultData.BeatmapInfo mapInfo = toBeatmapInfo(item.getBeatmapId(), map); @@ -146,6 +161,7 @@ public static MultiplayerResultData create( roundAverage, teamLeadPercent, winningTeam, + seriesScore, mapInfo, queuedBy, players, diff --git a/src/main/java/xyz/zcraft/ostella/service/ReplayService.java b/src/main/java/xyz/zcraft/ostella/service/ReplayService.java index 503b4bb..f7e2483 100644 --- a/src/main/java/xyz/zcraft/ostella/service/ReplayService.java +++ b/src/main/java/xyz/zcraft/ostella/service/ReplayService.java @@ -135,6 +135,59 @@ private JobLookup getJobProgress(RendererWorker worker, String jobId) { ? json.getAsJsonObject("qqFile") : null)); } + public JobProgress cancelJob(String jobId) { + RendererWorker assigned = jobWorkers.get(jobId); + if (assigned != null) { + JobLookup lookup = cancelJob(assigned, jobId); + if (lookup.found()) { + return lookup.progress(); + } + jobWorkers.remove(jobId, assigned); + } + + boolean unavailableWorker = false; + for (RendererWorker worker : rotatedWorkers()) { + try { + JobLookup lookup = cancelJob(worker, jobId); + if (lookup.found()) { + jobWorkers.put(jobId, worker); + return lookup.progress(); + } + } catch (ApiException e) { + unavailableWorker = true; + } + } + if (unavailableWorker) { + throw new ApiException(ErrorCode.RENDERER_UNAVAILABLE, + "Could not cancel replay job because one or more osuRenderer workers are unavailable"); + } + return new JobProgress(JobStatus.UNKNOWN); + } + + private JobLookup cancelJob(RendererWorker worker, String jobId) { + HttpResponse response = sendString(worker, + request(worker, "renders/" + jobId + "/cancel") + .POST(HttpRequest.BodyPublishers.noBody()) + .build()); + if (response.statusCode() == 404) { + return new JobLookup(false, new JobProgress(JobStatus.UNKNOWN)); + } + requireSuccess(response.statusCode(), response.body()); + JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject(); + try { + return new JobLookup(true, new JobProgress( + JobStatus.valueOf(json.get("status").getAsString().toUpperCase(Locale.ROOT)), + stringOrNull(json, "progress"), + stringOrNull(json, "speed"), + stringOrNull(json, "eta"), + stringOrNull(json, "error"), + json.has("qqFile") && json.get("qqFile").isJsonObject() + ? json.getAsJsonObject("qqFile") : null)); + } catch (RuntimeException e) { + throw unavailable("osuRenderer returned an invalid cancellation response", e); + } + } + public int getQueueSize() { List statuses = probeWorkers(); if (statuses.isEmpty()) { @@ -591,6 +644,7 @@ public enum JobStatus { UNKNOWN, RENDERING, UPLOADING, + CANCELED, TIMEOUT, FAILED, DONE diff --git a/src/main/resources/template/multiplayer-room-result.html b/src/main/resources/template/multiplayer-room-result.html index e98f2a6..caf2b4a 100644 --- a/src/main/resources/template/multiplayer-room-result.html +++ b/src/main/resources/template/multiplayer-room-result.html @@ -936,6 +936,59 @@ letter-spacing: 0.08em; } + .versus-totals { + display: grid; + min-height: 72px; + align-items: center; + padding: 12px 24px; + border-bottom: 1px solid var(--line-strong); + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + background: rgba(255, 255, 255, 0.014); + } + + .versus-total { + min-width: 0; + } + + .versus-total.right { + text-align: right; + } + + .versus-total span { + display: block; + overflow: hidden; + color: var(--faint); + font-size: 12px; + font-weight: 850; + letter-spacing: 0.08em; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; + } + + .versus-total strong { + display: block; + margin-top: 4px; + font-size: 28px; + font-weight: 900; + letter-spacing: 3px; + } + + .versus-total.red strong { + color: #fecdd3; + } + + .versus-total.blue strong { + color: #dbeafe; + } + + .versus-total-separator { + padding: 0 24px; + color: var(--faint); + font-size: 18px; + font-weight: 900; + } + .grade-xh { --rank-rgb: 213, 229, 255; } @@ -1841,6 +1894,19 @@ +
+
+ Left side + 3 +
+
WINS · VS
+
+ Right side + 2 +
+
+
No scores available for this round.
From 898b817ebd90fa52376dc3028ffd1431d1d106f1 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 13 Sep 2026 22:36:29 +0800 Subject: [PATCH 23/39] Add IMAGE_RENDER_TIMEOUT error code and handle timeout exceptions in render service --- src/main/java/xyz/zcraft/ostella/network/ErrorCode.java | 3 ++- src/main/java/xyz/zcraft/ostella/service/RenderService.java | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/xyz/zcraft/ostella/network/ErrorCode.java b/src/main/java/xyz/zcraft/ostella/network/ErrorCode.java index c8cdc0b..543cc99 100644 --- a/src/main/java/xyz/zcraft/ostella/network/ErrorCode.java +++ b/src/main/java/xyz/zcraft/ostella/network/ErrorCode.java @@ -33,7 +33,8 @@ public enum ErrorCode { RENDER_QUEUE_FULL(5001), RENDERER_UNAVAILABLE(5002), - PERFORMANCE_PLUS_UNAVAILABLE(5003); + PERFORMANCE_PLUS_UNAVAILABLE(5003), + IMAGE_RENDER_TIMEOUT(5004); private final int code; diff --git a/src/main/java/xyz/zcraft/ostella/service/RenderService.java b/src/main/java/xyz/zcraft/ostella/service/RenderService.java index 6a49ffb..27c6075 100644 --- a/src/main/java/xyz/zcraft/ostella/service/RenderService.java +++ b/src/main/java/xyz/zcraft/ostella/service/RenderService.java @@ -16,6 +16,8 @@ import xyz.zcraft.ostella.data.MultiplayerResultData; import xyz.zcraft.ostella.data.ScoreType; import xyz.zcraft.ostella.data.UserPerformanceSummary; +import xyz.zcraft.ostella.exception.ApiException; +import xyz.zcraft.ostella.network.ErrorCode; import xyz.zcraft.ostella.network.controller.AnalyzeController; import xyz.zcraft.ostella.util.Colors; import xyz.zcraft.ostella.util.MiscUtil; @@ -128,6 +130,8 @@ private byte[] takeScreenshot(String html) { page.waitForLoadState(LoadState.NETWORKIDLE); page.waitForFunction("() => Array.from(document.images).every(img => img.complete)"); return page.locator("body").screenshot(); + } catch (TimeoutError timeoutError) { + throw new ApiException(ErrorCode.IMAGE_RENDER_TIMEOUT, timeoutError.getMessage()); } // page.waitForFunction(""" From 03b6de0da7a5864f23688a2259939e82959edff6 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 13 Sep 2026 22:44:11 +0800 Subject: [PATCH 24/39] Enhance rank validation in ScoreFilter to support SSH and SS ranks --- src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java index 0036cca..e85cbd3 100644 --- a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java +++ b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java @@ -121,9 +121,14 @@ private static ScoreFilter parseRank(Field field, Operator operator, String valu throw new IllegalArgumentException("Rank only supports = and !="); } String rank = value.toUpperCase(Locale.ROOT); - if (!Set.of("XH", "X", "SH", "S", "A", "B", "C", "D", "F").contains(rank)) { + if (!Set.of("SSH", "SS", "XH", "X", "SH", "S", "A", "B", "C", "D", "F").contains(rank)) { throw new IllegalArgumentException("Invalid rank: " + value); } + if ("SSH".equals(rank)) { + rank = "XH"; + } else if ("SS".equals(rank)) { + rank = "X"; + } return new ScoreFilter(field, operator, Double.NaN, Set.of(rank), null, field.label + " " + operator.display + " " + rank); } From 0f2a34d2272a56a13f745ec87cae52d2a5d4f682 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 13 Sep 2026 22:53:11 +0800 Subject: [PATCH 25/39] Enhance rank validation in ScoreFilter to support additional operators and normalize rank values --- .../xyz/zcraft/ostella/data/ScoreFilter.java | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java index e85cbd3..9208cb6 100644 --- a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java +++ b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java @@ -117,22 +117,41 @@ private static ScoreFilter parseMods(Field field, Operator operator, String valu } private static ScoreFilter parseRank(Field field, Operator operator, String value) { - if (operator != Operator.EQUAL && operator != Operator.NOT_EQUAL) { - throw new IllegalArgumentException("Rank only supports = and !="); + if (!operator.isNumeric()) { + throw new IllegalArgumentException("Rank only supports >, >=, <, <=, = and !="); } - String rank = value.toUpperCase(Locale.ROOT); - if (!Set.of("SSH", "SS", "XH", "X", "SH", "S", "A", "B", "C", "D", "F").contains(rank)) { + String rank = normalizeRank(value); + Integer rankValue = rankValue(rank); + if (rankValue == null) { throw new IllegalArgumentException("Invalid rank: " + value); } - if ("SSH".equals(rank)) { - rank = "XH"; - } else if ("SS".equals(rank)) { - rank = "X"; - } - return new ScoreFilter(field, operator, Double.NaN, Set.of(rank), null, + return new ScoreFilter(field, operator, rankValue, Set.of(rank), null, field.label + " " + operator.display + " " + rank); } + private static String normalizeRank(String rank) { + return switch (rank.toUpperCase(Locale.ROOT)) { + case "SSH" -> "XH"; + case "SS" -> "X"; + default -> rank.toUpperCase(Locale.ROOT); + }; + } + + private static Integer rankValue(String rank) { + return switch (normalizeRank(rank)) { + case "XH" -> 8; + case "X" -> 7; + case "SH" -> 6; + case "S" -> 5; + case "A" -> 4; + case "B" -> 3; + case "C" -> 2; + case "D" -> 1; + case "F" -> 0; + default -> null; + }; + } + private static ScoreFilter parseMetadataText(Field field, Operator operator, String value) { if (!operator.isText()) { throw new IllegalArgumentException(field.label + " only supports ~, !~, = and !="); @@ -339,7 +358,7 @@ public boolean matches(Score score) { case MISS -> score.getStatistics() != null && compare(score.getStatistics().getOrDefault("miss", 0L)); case SCORE -> score.getTotalScore() != null && compare(score.getTotalScore()); case MODS -> compareMods(score.getMods()); - case RANK -> compareText(score.getRank()); + case RANK -> compareRank(score.getRank()); case ANY -> compareText(allMetadataText(score)); case TITLE -> score.getBeatmapset() != null && compareText(score.getBeatmapset().getTitle(), score.getBeatmapset().getTitleUnicode()); @@ -393,6 +412,12 @@ private boolean compareMods(List scoreMods) { }; } + private boolean compareRank(String actual) { + if (actual == null) return false; + Integer actualValue = rankValue(actual); + return actualValue != null && compare(actualValue); + } + private boolean compareText(String actual) { return compareText(Collections.singletonList(actual)); } From 6d5d476cd0c25ee30a81b6dc936776275a7e685e Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sun, 13 Sep 2026 23:08:08 +0800 Subject: [PATCH 26/39] Add extra hit stats check in ScoreFormatUtil and update score display in single-score.html --- .../ostella/util/format/ScoreFormatUtil.java | 11 ++++ src/main/resources/template/single-score.html | 51 ++++++++++++------- 2 files changed, 44 insertions(+), 18 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 f77cdac..c229792 100644 --- a/src/main/java/xyz/zcraft/ostella/util/format/ScoreFormatUtil.java +++ b/src/main/java/xyz/zcraft/ostella/util/format/ScoreFormatUtil.java @@ -8,6 +8,17 @@ import xyz.zcraft.osu.model.Score; public class ScoreFormatUtil { + public static boolean hasExtraHitStats(Score score) { + if (score == null) { + return false; + } + + return getSliderTick(score) != 0 || getSliderTickMax(score) != 0 + || getSliderEnd(score) != 0 || getSliderEndMax(score) != 0 + || getSpinnerBonus(score) != 0 || getSpinnerBonusMax(score) != 0 + || getSpinnerSpin(score) != 0 || getSpinnerSpinMax(score) != 0; + } + public static String getRelativeTime(Score score) { if (score == null || score.getEndedAt() == null) { return ""; diff --git a/src/main/resources/template/single-score.html b/src/main/resources/template/single-score.html index 9a4ce2e..3f3305a 100644 --- a/src/main/resources/template/single-score.html +++ b/src/main/resources/template/single-score.html @@ -219,7 +219,7 @@ justify-self: end; } - .replay-container{ + .replay-container { display: flex; background: rgba(102, 255, 204, 0.1); color: #66ffcc; @@ -300,7 +300,7 @@ .data-container { padding: 24px; display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 2fr 1fr; gap: 24px; } @@ -455,23 +455,26 @@
- + - mod + mod
-
+
SS
MS1234567 - M1234567
-
U123456789 - S1234567890 +
U123456789 - + S1234567890
@@ -481,8 +484,11 @@
This Is An Example Song Title
- 标题 - // + 标题 + // Example Artist // By @@ -588,11 +594,19 @@
-
Total Score
-
- 1,234,567 +
PP / Score
+
+
+ 412.5pp +
+ + / 1234.56pp +
+
▣ × ❤ - - 412.5pp / 1234.56pp + + 1,234,567
@@ -639,7 +654,7 @@ MISS
-
+
Slider Tick Date: Sun, 13 Sep 2026 23:39:48 +0800 Subject: [PATCH 27/39] Update osuParser version to 1.5.4 and enhance score filtering logic with difficulty attributes --- pom.xml | 2 +- .../xyz/zcraft/ostella/data/ScoreFilter.java | 56 ++++++++++++++----- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/pom.xml b/pom.xml index 6c3b9db..fcc021f 100644 --- a/pom.xml +++ b/pom.xml @@ -118,7 +118,7 @@ com.github.BotSeira osuParser - v1.5.3 + v1.5.4 diff --git a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java index 9208cb6..f46dd44 100644 --- a/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java +++ b/src/main/java/xyz/zcraft/ostella/data/ScoreFilter.java @@ -1,8 +1,19 @@ package xyz.zcraft.ostella.data; +import org.jline.utils.DiffHelper; import xyz.zcraft.ostella.service.CacheService; +import xyz.zcraft.ostella.util.format.ScoreFormatUtil; +import xyz.zcraft.osu.model.BeatmapExtended; import xyz.zcraft.osu.model.Mod; import xyz.zcraft.osu.model.Score; +import xyz.zcraft.osu.parser.BeatmapAnalyzer; +import xyz.zcraft.osu.parser.BeatmapParser; +import xyz.zcraft.osu.parser.OsuParser; +import xyz.zcraft.osu.parser.data.beatmap.DiffSpec; +import xyz.zcraft.osu.parser.data.beatmap.DifficultyAttribute; +import xyz.zcraft.osu.parser.data.beatmap.OsuBeatmap; +import xyz.zcraft.osu.parser.exception.AnalyzeException; +import xyz.zcraft.osu.parser.exception.ParseException; import java.util.*; import java.util.regex.Matcher; @@ -337,24 +348,41 @@ public boolean matches(Score score) { if (score == null) { return false; } + + final BeatmapExtended beatmap = score.getBeatmap(); + final DifficultyAttribute difficultyAttribute = BeatmapAnalyzer.calculateDifficulty(score); + final DiffSpec diffSpec; + + if (field == Field.LENGTH || field == Field.STAR) { + if (beatmap == null) { + throw new IllegalStateException("Beatmap has not been set"); + } + try { + final OsuBeatmap osuBeatmap = BeatmapParser.parseBeatmap(CacheService.getBeatmapPath(beatmap.getId())); + diffSpec = OsuParser.getDiffSpecForMap(osuBeatmap, score.getMods().stream().map(Mod::getAcronym).reduce("", String::concat)); + } catch (AnalyzeException | ParseException e) { + throw new RuntimeException("Failed to parse beatmap " + beatmap.getId(), e); + } + } else { + diffSpec = null; + } + return switch (field) { case ACCURACY -> score.getAccuracy() != null && compare(score.getAccuracy() * 100); case COMBO -> score.getMaxCombo() != null && compare(score.getMaxCombo()); case PP -> score.getPp() != null && compare(score.getPp()); - case LENGTH -> score.getBeatmap() != null && score.getBeatmap().getTotalLength() != null - && compare(score.getBeatmap().getTotalLength()); - case STAR -> score.getBeatmap() != null && score.getBeatmap().getDifficultyRating() != null - && compare(score.getBeatmap().getDifficultyRating()); - case AR -> score.getBeatmap() != null && score.getBeatmap().getAr() != null - && compare(score.getBeatmap().getAr()); - case CS -> score.getBeatmap() != null && score.getBeatmap().getCs() != null - && compare(score.getBeatmap().getCs()); - case HP -> score.getBeatmap() != null && score.getBeatmap().getDrain() != null - && compare(score.getBeatmap().getDrain()); - case OD -> score.getBeatmap() != null && score.getBeatmap().getAccuracy() != null - && compare(score.getBeatmap().getAccuracy()); - case BPM -> score.getBeatmap() != null && score.getBeatmap().getBpm() != null - && compare(score.getBeatmap().getBpm()); + case LENGTH -> compare(diffSpec.getLength()); + case STAR -> compare(diffSpec.getStar()); + case AR -> beatmap != null && beatmap.getAr() != null + && compare(difficultyAttribute.ar()); + case CS -> beatmap != null && beatmap.getCs() != null + && compare(difficultyAttribute.cs()); + case HP -> beatmap != null && beatmap.getDrain() != null + && compare(difficultyAttribute.hp()); + case OD -> beatmap != null && beatmap.getAccuracy() != null + && compare(difficultyAttribute.od()); + case BPM -> beatmap != null && beatmap.getBpm() != null + && compare(beatmap.getBpm()); case MISS -> score.getStatistics() != null && compare(score.getStatistics().getOrDefault("miss", 0L)); case SCORE -> score.getTotalScore() != null && compare(score.getTotalScore()); case MODS -> compareMods(score.getMods()); From ea51b3d0d9ec373191fa413ab032facb88c01684 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Mon, 14 Sep 2026 00:12:29 +0800 Subject: [PATCH 28/39] Add upload_queued status to ReplayService and update ReplayController response handling --- .../ostella/network/controller/ReplayController.java | 7 +++++++ .../java/xyz/zcraft/ostella/service/ReplayService.java | 1 + 2 files changed, 8 insertions(+) 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 87b89e8..8cd9dbb 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/ReplayController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/ReplayController.java @@ -102,6 +102,13 @@ public void getReplayRenderStatus(@NotNull Context context) { context.status(200).result( new Response(true, "Render in progress", obj).toString()); } + case ReplayService.JobStatus.UPLOAD_QUEUED -> { + JsonObject obj = new JsonObject(); + obj.addProperty("status", "upload_queued"); + obj.addProperty("id", jobId); + context.status(200).result( + new Response(true, "Render is waiting in upload queue", obj).toString()); + } case ReplayService.JobStatus.UPLOADING -> { JsonObject obj = new JsonObject(); obj.addProperty("status", "uploading"); diff --git a/src/main/java/xyz/zcraft/ostella/service/ReplayService.java b/src/main/java/xyz/zcraft/ostella/service/ReplayService.java index f7e2483..2703c23 100644 --- a/src/main/java/xyz/zcraft/ostella/service/ReplayService.java +++ b/src/main/java/xyz/zcraft/ostella/service/ReplayService.java @@ -643,6 +643,7 @@ public enum JobStatus { QUEUED, UNKNOWN, RENDERING, + UPLOAD_QUEUED, UPLOADING, CANCELED, TIMEOUT, From 5150b0d9261ba416cba81feb591e8e29a691febe Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Mon, 14 Sep 2026 00:13:35 +0800 Subject: [PATCH 29/39] Bump version to 1.11.9 in pom.xml --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index fcc021f..463c417 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira oStella - 1.11.8 + 1.11.9 25 From cc3f134dd06b5bfe86b2bb33b20151c213b5786d Mon Sep 17 00:00:00 2001 From: Zayrex Date: Tue, 15 Sep 2026 00:56:53 +0800 Subject: [PATCH 30/39] Update flag image source in single-score.html --- src/main/resources/template/single-score.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/template/single-score.html b/src/main/resources/template/single-score.html index 3f3305a..86b2cef 100644 --- a/src/main/resources/template/single-score.html +++ b/src/main/resources/template/single-score.html @@ -515,7 +515,7 @@
flag + th:src="${cache.getImageSrc(Users.getFlagUrl(user))}"> PlayerName From 816b7e7127c34ed155cb05844767b05da0ade573 Mon Sep 17 00:00:00 2001 From: Zayrex Date: Tue, 15 Sep 2026 00:59:15 +0800 Subject: [PATCH 31/39] Update single-score.html --- src/main/resources/template/single-score.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/template/single-score.html b/src/main/resources/template/single-score.html index 86b2cef..30d5b13 100644 --- a/src/main/resources/template/single-score.html +++ b/src/main/resources/template/single-score.html @@ -515,7 +515,7 @@
flag + th:src="${cache.getImageSrc(Users.getFlagUrl(score.getUser()))}"> PlayerName From de97954748f6c346c83d4b064a2b784a49862af2 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 16 Sep 2026 00:44:23 +0800 Subject: [PATCH 32/39] Enhance performance calculations in AnalyzeController and MissVisualizeService --- pom.xml | 4 +- .../network/controller/AnalyzeController.java | 122 +++++++++++------- .../ostella/service/MissVisualizeService.java | 30 +++-- 3 files changed, 93 insertions(+), 63 deletions(-) diff --git a/pom.xml b/pom.xml index 463c417..c6067ee 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira oStella - 1.11.9 + 1.11.10 25 @@ -118,7 +118,7 @@ com.github.BotSeira osuParser - v1.5.4 + v1.5.5 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 bb57879..f98aa07 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/AnalyzeController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/AnalyzeController.java @@ -24,6 +24,7 @@ import xyz.zcraft.osu.model.Mod; import xyz.zcraft.osu.model.Score; import xyz.zcraft.osu.parser.*; +import xyz.zcraft.osu.parser.data.PerformanceState; import xyz.zcraft.osu.parser.data.beatmap.DiffSpec; import xyz.zcraft.osu.parser.data.beatmap.OsuBeatmap; import xyz.zcraft.osu.parser.data.replay.HitEvent; @@ -113,70 +114,59 @@ private static PerformanceGraphData buildPerformanceGraphData(OsuBeatmap beatmap sliderTickBreaks, sliderEndBreaks, lastObjectTime); } - static List calculateRealtimePp(OsuBeatmap beatmap, - ReplayAnalyze analyze, - String mods, - Long finalMaxCombo) - throws AnalyzeException { + public static List calculateRealtimePp( + OsuBeatmap beatmap, ReplayAnalyze analyze, String mods, Long finalMaxCombo + ) throws AnalyzeException { final List events = analyze.events(); final List realtimePp = new ArrayList<>(); + final int objectCount = beatmap.getHitObjects().size(); - final int sampleStep = Math.max(1, (int) Math.ceil( - objectCount / (double) PERFORMANCE_GRAPH_MAX_POINTS)); + final int sampleStep = Math.max(1, (int) Math.ceil(objectCount / (double) PERFORMANCE_GRAPH_MAX_POINTS)); int eventIndex = 0; - int n300 = 0; - int n100 = 0; - int n50 = 0; - int misses = 0; - int currentCombo = 0; - int maxCombo = 0; + PerformanceState state = new PerformanceState(); try (var rosuBeatmap = new RosuFFI.Beatmap(beatmap.toBeatmapString().getBytes()); var rosuMods = RosuFFI.Mods.fromAcronyms(mods, RosuFFI.Mode.Osu); var performance = new RosuFFI.Performance()) { + performance.mods(rosuMods); for (int objectIndex = 0; objectIndex < objectCount; objectIndex++) { while (eventIndex < events.size() && events.get(eventIndex).objectIndex() <= objectIndex) { - final HitEvent event = events.get(eventIndex++); - if (event.objectIndex() < objectIndex) continue; - - if (event.isObjectStart()) { - switch (event.hitResult()) { - case PERFECT -> n300++; - case OK -> n100++; - case MEH -> n50++; - case MISS -> misses++; - } - } - if (isComboEvent(event)) { - if (event.wasHit()) { - currentCombo++; - maxCombo = Math.max(maxCombo, currentCombo); - } else { - currentCombo = 0; - } + HitEvent event = events.get(eventIndex++); + + if (event.objectIndex() < objectIndex) { + continue; } + + state.process(event, false); + } + + boolean lastObject = objectIndex == objectCount - 1; + + if (objectIndex % sampleStep != 0 && !lastObject) { + continue; } - final boolean lastObject = objectIndex == objectCount - 1; - if (objectIndex % sampleStep != 0 && !lastObject) continue; + ReplayAnalyzer.applyPerformanceState( + performance, + state, + objectIndex + 1L, + lastObject && finalMaxCombo != null + ? finalMaxCombo + : state.maxCombo + ); - performance.passedObjects(objectIndex + 1L); - performance.n300(n300); - performance.n100(n100); - performance.n50(n50); - performance.misses(misses); - performance.combo(lastObject && finalMaxCombo != null ? finalMaxCombo : maxCombo); + double pp = performance.calculate(rosuBeatmap).asOsu().pp; - final double pp = performance.calculate(rosuBeatmap).asOsu().pp; realtimePp.add(new double[]{ beatmap.getHitObjects().get(objectIndex).getTime(), pp }); } + } catch (RuntimeException e) { throw new AnalyzeException("Failed to calculate realtime PP", e); } @@ -184,11 +174,47 @@ static List calculateRealtimePp(OsuBeatmap beatmap, return realtimePp; } - private static boolean isComboEvent(HitEvent event) { - return switch (event.eventType()) { - case HIT_CIRCLE, SLIDER_HEAD, SLIDER_TICK, SLIDER_END, SPINNER -> true; - case SPINNER_SPIN, SPINNER_BONUS -> false; - }; + public static PerformanceState calculateFinalState( + List events, int objectCount, int replaceMissIndex + ) { + PerformanceState state = new PerformanceState(); + int eventIndex = 0; + + for (int objectIndex = 0; objectIndex < objectCount; objectIndex++) { + while (eventIndex < events.size() + && events.get(eventIndex).objectIndex() <= objectIndex) { + + HitEvent event = events.get(eventIndex++); + + if (event.objectIndex() < objectIndex) { + continue; + } + + state.process(event, objectIndex == replaceMissIndex); + } + } + + return state; + } + + public record PPLoss( + double withoutMiss, + double actual + ){}; + + public static PPLoss calculatePpLoss(OsuBeatmap beatmap, ReplayAnalyze analyze, int modBits, int missObjectIndex) { + final List events = analyze.events(); + final int objectCount = beatmap.getHitObjects().size(); + + PerformanceState actual = calculateFinalState(events, objectCount, -1); + + PerformanceState withoutMiss = calculateFinalState(events, objectCount, missObjectIndex); + + double actualPp = ReplayAnalyzer.calculatePp(beatmap, modBits, actual, objectCount); + + double withoutMissPp = ReplayAnalyzer.calculatePp(beatmap, modBits, withoutMiss, objectCount); + + return new PPLoss(withoutMissPp, actualPp); } private static double[] calculatePerformancePoint(OsuBeatmap beatmap, long start, long end) @@ -322,9 +348,9 @@ public void renderScoreAnalysisById(@NotNull Context context) { boolean doSimMatch = simGreat == ScoreFormatUtil.getGreatCount(score) - && simOk == ScoreFormatUtil.getOkCount(score) - && simMeh == ScoreFormatUtil.getMehCount(score) - && simMiss == ScoreFormatUtil.getMissCount(score); + && simOk == ScoreFormatUtil.getOkCount(score) + && simMeh == ScoreFormatUtil.getMehCount(score) + && simMiss == ScoreFormatUtil.getMissCount(score); return perfPlusApi.calculate(score) .exceptionally(error -> { diff --git a/src/main/java/xyz/zcraft/ostella/service/MissVisualizeService.java b/src/main/java/xyz/zcraft/ostella/service/MissVisualizeService.java index b3e75a5..0d0aadc 100644 --- a/src/main/java/xyz/zcraft/ostella/service/MissVisualizeService.java +++ b/src/main/java/xyz/zcraft/ostella/service/MissVisualizeService.java @@ -2,6 +2,7 @@ import xyz.zcraft.ostella.exception.ApiException; import xyz.zcraft.ostella.network.ErrorCode; +import xyz.zcraft.ostella.network.controller.AnalyzeController; import xyz.zcraft.osu.parser.ReplayAnalyzer; import xyz.zcraft.osu.parser.data.beatmap.DifficultyAttribute; import xyz.zcraft.osu.parser.data.beatmap.HitObject; @@ -59,13 +60,17 @@ public static byte[] visualizeMiss(ReplayAnalyze replayAnalyze, int missIndex) { final var keyFrames = replayAnalyze.replay().timedKeyFrames(); + + final var ppLoss = AnalyzeController.calculatePpLoss(replayAnalyze.beatmap(), replayAnalyze, replayAnalyze.replay().mods(), targetMiss.objectIndex()); + return ImageHelper.drawMiss( missIndex, targetMiss, extractNearbyKeyFrames(keyFrames, targetMiss.hitObject()), replayAnalyze.beatmap(), replayAnalyze.calculatedDifficulty(), - ReplayAnalyzer.hasHardRock(replayAnalyze.replay()) + ReplayAnalyzer.hasHardRock(replayAnalyze.replay()), + ppLoss ); } @@ -168,12 +173,10 @@ private static int getHitWindowCategory(long offset, DifficultyAttribute diff) { return isEarly ? 0 : 8; } - private static byte[] drawMiss(int missIndex, - HitEvent targetMiss, - List keyFrames, - OsuBeatmap beatmap, - DifficultyAttribute diff, - boolean hardRock) { + private static byte[] drawMiss( + int missIndex, HitEvent targetMiss, List keyFrames, + OsuBeatmap beatmap, DifficultyAttribute diff, boolean hr, AnalyzeController.PPLoss ppLoss + ) { final HitObject hitObject = targetMiss.hitObject(); final double circleRadius = diff.getCircleRadiusInPixel(); @@ -188,15 +191,15 @@ private static byte[] drawMiss(int missIndex, g2d.setColor(Color.WHITE); g2d.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); - drawNearbyObjects(hitObject, beatmap, circleRadius, hardRock, g2d); + drawNearbyObjects(hitObject, beatmap, circleRadius, hr, g2d); - drawTargetObject(hitObject, circleRadius, hardRock, g2d); + drawTargetObject(hitObject, circleRadius, hr, g2d); - drawCursorPath(hitObject, keyFrames, diff, hardRock, g2d); + drawCursorPath(hitObject, keyFrames, diff, hr, g2d); - drawFramePoints(hitObject, keyFrames, hardRock, g2d, hitTimes); + drawFramePoints(hitObject, keyFrames, hr, g2d, hitTimes); - drawText(missIndex, targetMiss, beatmap, g2d); + drawText(missIndex, targetMiss, beatmap, ppLoss, g2d); drawTimingIndicator(diff, g2d, hitTimes); @@ -280,7 +283,7 @@ private static void drawJudgeLine(double window, double missWindow, Graphics2D g )); } - private static void drawText(int missIndex, HitEvent targetMiss, OsuBeatmap beatmap, Graphics2D g2d) { + private static void drawText(int missIndex, HitEvent targetMiss, OsuBeatmap beatmap, AnalyzeController.PPLoss ppLoss, Graphics2D g2d) { g2d.setColor(Color.BLACK); final Duration duration = Duration.of(targetMiss.hitObject().getTime(), ChronoUnit.MILLIS); @@ -289,6 +292,7 @@ private static void drawText(int missIndex, HitEvent targetMiss, OsuBeatmap beat g2d.setFont(new Font("Dejavu Sans", Font.PLAIN, 20)); g2d.drawString(missInfo, 5, CANVAS_HEIGHT - 8); + g2d.drawString(String.format("%.2fpp→%.2fpp (-%.2f)", ppLoss.withoutMiss(), ppLoss.actual(), ppLoss.withoutMiss() - ppLoss.actual()), 5, CANVAS_HEIGHT - 32); g2d.setFont(new Font("Dejavu Sans", Font.BOLD, 20)); g2d.drawString(beatmap.getBeatmapId() + " - " + beatmap.getTitle(), 5, 20); From d4cbdef8d886d34e72578dc1e99cf78681a62f06 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 16 Sep 2026 00:54:35 +0800 Subject: [PATCH 33/39] Enhance performance calculations in AnalyzeController and MissVisualizeService --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c6067ee..caad9f2 100644 --- a/pom.xml +++ b/pom.xml @@ -118,7 +118,7 @@ com.github.BotSeira osuParser - v1.5.5 + v1.5.6 From 34b0c7dc3b0c118ce6ca57246c88000eda6b525d Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 16 Sep 2026 01:38:24 +0800 Subject: [PATCH 34/39] Refactor PPLoss calculation in AnalyzeController and MissVisualizeService to improve accuracy and handling of target misses --- .../network/controller/AnalyzeController.java | 45 ++++++++++++++++--- .../ostella/service/MissVisualizeService.java | 3 +- 2 files changed, 41 insertions(+), 7 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 f98aa07..870af4b 100644 --- a/src/main/java/xyz/zcraft/ostella/network/controller/AnalyzeController.java +++ b/src/main/java/xyz/zcraft/ostella/network/controller/AnalyzeController.java @@ -203,20 +203,53 @@ public record PPLoss( ){}; public static PPLoss calculatePpLoss(OsuBeatmap beatmap, ReplayAnalyze analyze, int modBits, int missObjectIndex) { - final List events = analyze.events(); - final int objectCount = beatmap.getHitObjects().size(); + HitEvent targetMiss = analyze.events().stream() + .filter(HitEvent::isObjectStart) + .filter(event -> event.objectIndex() == missObjectIndex) + .filter(event -> event.hitResult() == HitEvent.HitResult.MISS) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException( + "No miss found for object index: " + missObjectIndex)); + + return calculatePpLoss(beatmap, analyze, modBits, targetMiss); + } - PerformanceState actual = calculateFinalState(events, objectCount, -1); + public static PPLoss calculatePpLoss(OsuBeatmap beatmap, ReplayAnalyze analyze, + int modBits, HitEvent targetMiss) { + final List events = analyze.events(); + final int passedObjects = targetMiss.objectIndex() + 1; - PerformanceState withoutMiss = calculateFinalState(events, objectCount, missObjectIndex); + if (!targetMiss.isObjectStart() || targetMiss.hitResult() != HitEvent.HitResult.MISS + || passedObjects <= 0 || passedObjects > beatmap.getHitObjects().size()) { + throw new IllegalArgumentException("Target event is not a valid miss"); + } - double actualPp = ReplayAnalyzer.calculatePp(beatmap, modBits, actual, objectCount); + PerformanceState actual = calculateStateAtEvent(events, targetMiss, false); + PerformanceState withoutMiss = calculateStateAtEvent(events, targetMiss, true); - double withoutMissPp = ReplayAnalyzer.calculatePp(beatmap, modBits, withoutMiss, objectCount); + double actualPp = ReplayAnalyzer.calculatePp(beatmap, modBits, actual, passedObjects); + double withoutMissPp = ReplayAnalyzer.calculatePp(beatmap, modBits, withoutMiss, passedObjects); return new PPLoss(withoutMissPp, actualPp); } + static PerformanceState calculateStateAtEvent( + List events, HitEvent targetEvent, boolean replaceTargetMiss + ) { + PerformanceState state = new PerformanceState(); + + for (HitEvent event : events) { + boolean isTarget = event == targetEvent || event.equals(targetEvent); + state.process(event, replaceTargetMiss && isTarget); + + if (isTarget) { + return state; + } + } + + throw new IllegalArgumentException("Target event does not belong to this replay analysis"); + } + private static double[] calculatePerformancePoint(OsuBeatmap beatmap, long start, long end) throws AnalyzeException { try { diff --git a/src/main/java/xyz/zcraft/ostella/service/MissVisualizeService.java b/src/main/java/xyz/zcraft/ostella/service/MissVisualizeService.java index 0d0aadc..8a65f40 100644 --- a/src/main/java/xyz/zcraft/ostella/service/MissVisualizeService.java +++ b/src/main/java/xyz/zcraft/ostella/service/MissVisualizeService.java @@ -61,7 +61,8 @@ public static byte[] visualizeMiss(ReplayAnalyze replayAnalyze, int missIndex) { final var keyFrames = replayAnalyze.replay().timedKeyFrames(); - final var ppLoss = AnalyzeController.calculatePpLoss(replayAnalyze.beatmap(), replayAnalyze, replayAnalyze.replay().mods(), targetMiss.objectIndex()); + final var ppLoss = AnalyzeController.calculatePpLoss( + replayAnalyze.beatmap(), replayAnalyze, replayAnalyze.replay().mods(), targetMiss); return ImageHelper.drawMiss( missIndex, From 2ba7daab03851053b9000d0a80712983d5c7c6de Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 16 Sep 2026 11:22:35 +0800 Subject: [PATCH 35/39] Update avatar URL handling in LocalScoreService and simplify single-score.html layout --- .../ostella/service/LocalScoreService.java | 12 ++--- src/main/resources/template/single-score.html | 52 ++++++++++--------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/main/java/xyz/zcraft/ostella/service/LocalScoreService.java b/src/main/java/xyz/zcraft/ostella/service/LocalScoreService.java index 62abb54..eebfd5d 100644 --- a/src/main/java/xyz/zcraft/ostella/service/LocalScoreService.java +++ b/src/main/java/xyz/zcraft/ostella/service/LocalScoreService.java @@ -5,11 +5,7 @@ import xyz.zcraft.ostella.network.ErrorCode; import xyz.zcraft.ostella.network.OsuAPI; import xyz.zcraft.ostella.util.TokenManager; -import xyz.zcraft.osu.model.BeatmapExtended; -import xyz.zcraft.osu.model.Beatmapset; -import xyz.zcraft.osu.model.Mod; -import xyz.zcraft.osu.model.Score; -import xyz.zcraft.osu.model.User; +import xyz.zcraft.osu.model.*; import xyz.zcraft.osu.parser.data.replay.OsuReplay; import xyz.zcraft.osu.parser.data.replay.ReplayInfo; @@ -186,7 +182,11 @@ private User fallbackUser(OsuReplay replay, ReplayInfo replayInfo) { user.setId(userId); user.setUsername(replay.playerName()); user.setCountryCode("XX"); - user.setAvatarUrl("https://a.ppy.sh/" + userId); + if (userId != 0L) { + user.setAvatarUrl("https://a.ppy.sh/" + userId); + } else { + user.setAvatarUrl("https://osu.ppy.sh/images/layout/avatar-guest.png"); + } return user; } diff --git a/src/main/resources/template/single-score.html b/src/main/resources/template/single-score.html index 30d5b13..ff8157f 100644 --- a/src/main/resources/template/single-score.html +++ b/src/main/resources/template/single-score.html @@ -96,7 +96,6 @@ right: 16px; bottom: 16px; letter-spacing: 1px; - color: #a0a0b8; font-size: 1.1em; text-align: end; } @@ -105,8 +104,8 @@ .meta-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; - grid-template-rows: auto auto auto; - padding: 16px 24px 20px 24px; + grid-template-rows: auto auto; + padding: 0 24px 20px 24px; row-gap: 8px; align-items: end; } @@ -116,14 +115,12 @@ } .song-title { - grid-column: 1 / 2; - grid-row: 1 / 2; font-size: 2.2em; font-weight: 900; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - padding-right: 24px; + padding: 16px 24px 0 24px; } .song-artist { @@ -470,19 +467,30 @@ SS
-
- MS1234567 - M1234567 -
-
U123456789 - - S1234567890 +
+ flag + + PlayerName + + + + + + Avatar
+
This Is An Example Song Title
+ +
+
-
This Is An Example Song Title
-
- flag - - PlayerName - - - - - Avatar -
▶ @@ -678,6 +673,13 @@
+ + + + + + +
@@ -527,7 +514,10 @@
15 mins ago
- 2026-04-19T16:19:16Z +
+ By + Example Mapper +
@@ -673,13 +663,13 @@
- - - - - - - +
+ Ended at 2026-04-19T16:19:16Z +
+ MS1234567 / M1234567 / U123456789 / S1234567890 +
+