From c507dc522d726dfb1512a024cd5d8ac1e8941855 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 25 Aug 2026 11:47:24 +0800 Subject: [PATCH 01/54] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=8C=87=E4=BB=A4?= =?UTF-8?q?=E9=A2=84=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xyz/zcraft/seira/command/Context.java | 16 ++-- .../MultiplayerRoomWatchCommandHandler.java | 4 +- .../seira/command/parse/CommandParser.java | 82 +++++++++---------- .../zcraft/seira/command/parse/Resolver.java | 8 +- .../zcraft/seira/command/route/Router.java | 6 +- 5 files changed, 62 insertions(+), 54 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/Context.java b/src/main/java/xyz/zcraft/seira/command/Context.java index 6306d29d..58273bc9 100644 --- a/src/main/java/xyz/zcraft/seira/command/Context.java +++ b/src/main/java/xyz/zcraft/seira/command/Context.java @@ -11,6 +11,7 @@ public record Context( String command, String[] args, String query, + String rawContent, CommandReplyChannel replies) { public Context( String senderUserId, @@ -18,9 +19,10 @@ public Context( String messageId, String command, String[] args, + String rawContent, String query ) { - this(senderUserId, groupId, messageId, command, args, query, null); + this(senderUserId, groupId, messageId, command, args, query, rawContent, null); } public Context { @@ -48,18 +50,20 @@ public boolean inGroup() { public Context withReplies(CommandReplyChannel replyChannel) { return new Context( - senderUserId, groupId, messageId, command, args, query, + senderUserId, groupId, messageId, command, args, query, rawContent, Objects.requireNonNull(replyChannel, "replyChannel") ); } public Context asCommand(String nextCommand, String[] nextArgs, String nextQuery) { return new Context( - senderUserId, groupId, messageId, nextCommand, nextArgs, nextQuery, replies + senderUserId, groupId, messageId, nextCommand, nextArgs, nextQuery, rawContent, replies ); } - /** Sends a passive reply associated with the message that invoked this command. */ + /** + * Sends a passive reply associated with the message that invoked this command. + */ public boolean sendReply(PendingMessage message) { return requireReplies().sendReply(Objects.requireNonNull(message, "message")); } @@ -68,7 +72,9 @@ public boolean sendReply(String message) { return requireReplies().sendReply(PendingMessage.ofString(message)); } - /** Sends an active message to the same user or group, without an inbound message reference. */ + /** + * Sends an active message to the same user or group, without an inbound message reference. + */ public boolean sendMessage(PendingMessage message) { return requireReplies().sendProactive(Objects.requireNonNull(message, "message")); } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java index f5c5333c..8a0b5406 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java @@ -96,8 +96,8 @@ private void handleStart(Context ctx, int argumentOffset) { RoomWatchView view = watchService.watch( ctx.groupId(), ctx.senderUserId(), target.version(), target.roomId() ); - ctx.sendReply(PendingMessage.ofString( - "已开始监视" + formatRoom(view) + "。" + ctx.sendReply(PendingMessage.ofMarkdownRaw( + "已开始监视 `" + formatRoom(view) + "` 。" + "之后完成的每张图都会自动推送结果。" )); } catch (IllegalArgumentException | IllegalStateException e) { diff --git a/src/main/java/xyz/zcraft/seira/command/parse/CommandParser.java b/src/main/java/xyz/zcraft/seira/command/parse/CommandParser.java index c29bd778..9c341d1d 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/CommandParser.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/CommandParser.java @@ -2,51 +2,21 @@ import xyz.zcraft.seira.command.Context; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Locale; -import java.util.Objects; +import java.util.*; import java.util.function.UnaryOperator; public final class CommandParser { private static final String PREFIX = "/"; - private final UnaryOperator preProcessor; + private final UnaryOperator sanitizer; - public CommandParser(UnaryOperator preProcessor) { - this.preProcessor = Objects.requireNonNull(preProcessor); + public CommandParser(UnaryOperator sanitizer) { + this.sanitizer = Objects.requireNonNull(sanitizer); } - public ParseResult parse(String rawContent, String senderUserId, String groupId, String messageId) { - if (rawContent == null) { - return ParseResult.ignored(); - } - - String normalized = rawContent.trim(); - if (!normalized.startsWith(PREFIX)) { - return ParseResult.ignored(); - } - - String body = normalized.substring(PREFIX.length()).trim(); - if (body.isEmpty()) { - return ParseResult.emptyCommand(); - } - - body = Objects.requireNonNull(preProcessor.apply(body), "Command pre-processor returned null").trim(); - if (body.isEmpty()) { - return ParseResult.emptyCommand(); - } - - String[] parts = splitArguments(body); - String command = parts[0].toLowerCase(Locale.ROOT); - String query = body.substring(parts[0].length()).trim(); - String[] args = Arrays.copyOfRange(parts, 1, parts.length); - - return ParseResult.parsed(new Context(senderUserId, groupId, messageId, command, args, query)); - } - - /** Splits arguments on unquoted whitespace and removes double-quote delimiters. */ + /** + * Splits arguments on unquoted whitespace and removes double-quote delimiters. + */ private static String[] splitArguments(String body) { List parts = new ArrayList<>(); StringBuilder current = new StringBuilder(); @@ -75,13 +45,35 @@ private static String[] splitArguments(String body) { return parts.toArray(String[]::new); } - public record ParseResult(Status status, Context context) { - public enum Status { - IGNORED, - EMPTY_COMMAND, - PARSED + public ParseResult parse(String rawContent, String senderUserId, String groupId, String messageId) { + if (rawContent == null) { + return ParseResult.ignored(); + } + + String normalized = rawContent.trim(); + if (!normalized.startsWith(PREFIX)) { + return ParseResult.ignored(); } + String body = normalized.substring(PREFIX.length()).trim(); + if (body.isEmpty()) { + return ParseResult.emptyCommand(); + } + + body = Objects.requireNonNull(sanitizer.apply(body), "Command pre-processor returned null").trim(); + if (body.isEmpty()) { + return ParseResult.emptyCommand(); + } + + String[] parts = splitArguments(body); + String command = parts[0].toLowerCase(Locale.ROOT); + String query = body.substring(parts[0].length()).trim(); + String[] args = Arrays.copyOfRange(parts, 1, parts.length); + + return ParseResult.parsed(new Context(senderUserId, groupId, messageId, command, args, rawContent, query)); + } + + public record ParseResult(Status status, Context context) { public ParseResult { if ((status == Status.PARSED) == (context == null)) { throw new IllegalArgumentException("Only parsed results may contain a context"); @@ -99,5 +91,11 @@ static ParseResult emptyCommand() { static ParseResult parsed(Context context) { return new ParseResult(Status.PARSED, Objects.requireNonNull(context)); } + + public enum Status { + IGNORED, + EMPTY_COMMAND, + PARSED + } } } diff --git a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java index cc90f4f7..25a3ca8e 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java @@ -12,12 +12,15 @@ public final class Resolver { private static final ArrayList USER_MACRO_TYPES = new ArrayList<>(List.of("rs", "bo", "rp")); - public String preProcess(String rawContent) { + public String sanitize(String rawContent) { Matcher matcher = Patterns.USER_MACRO_PATTERN.matcher(rawContent); if (matcher.matches()) { - return "s " + rawContent; + rawContent = "s " + rawContent; } + // Add surrounding space to <@> + rawContent = Patterns.QQ_INLINE_AT_PATTERN.matcher(rawContent).replaceAll(r -> " " + r.group() + " "); + return rawContent; } @@ -274,6 +277,7 @@ private static final class Patterns { private static final Pattern BEATMAP_MACRO_PATTERN = Pattern.compile("^m(\\d+)$"); private static final Pattern LOCAL_SCORE_PATTERN = Pattern.compile("(?i)^loc[1-9]\\d*$"); private static final Pattern QQ_AT_PATTERN = Pattern.compile("^<@([A-Z|0-9]{32})>$"); + private static final Pattern QQ_INLINE_AT_PATTERN = Pattern.compile("(<@[A-Z|0-9]{32}>)"); private static final Pattern PLAIN_AT_PATTERN = Pattern.compile("^@(\\d+)$"); private static final Pattern SEARCH_PATTERN = Pattern.compile("^(?:#(\\d+) )?(.+)$"); private static final Pattern RSC_TARGET_PATTERN = Pattern.compile("^[us]?\\d+$"); diff --git a/src/main/java/xyz/zcraft/seira/command/route/Router.java b/src/main/java/xyz/zcraft/seira/command/route/Router.java index cb141dd4..a1dff3ae 100644 --- a/src/main/java/xyz/zcraft/seira/command/route/Router.java +++ b/src/main/java/xyz/zcraft/seira/command/route/Router.java @@ -96,7 +96,7 @@ public Router( taskCoordinator, replyFactory, rankGuessGameService, admins::isAdmin ); this.unknownCommand = generalCommands::handleUnknown; - this.commandParser = new CommandParser(resolver::preProcess); + this.commandParser = new CommandParser(resolver::sanitize); this.commandRegistry = createCommandRegistry( bindingCommands, scoreCommands, @@ -130,7 +130,6 @@ public void onGroupMessageReceived(String groupId, String senderUserId, String m } private void handleMessageReceived(String targetId, String groupId, String userId, String messageId, String rawContent, boolean groupMessage) { - LOG.info("Received {} message : {}", groupMessage ? "group" : "private", rawContent); AtomicInteger messageSeqCounter = new AtomicInteger(1); try { final boolean group = groupMessage && groupId != null && !groupId.isBlank(); @@ -167,6 +166,7 @@ private void handleMessageReceived(String targetId, String groupId, String userI Context context = parseResult.context().withReplies(replies); commandExecutor.execute(() -> { try { + LOG.info("Routing {} message : {}", groupMessage ? "group" : "private", context.rawContent()); dispatch(context); } catch (Exception e) { context.sendReply(PendingMessage.ofString("处理指令时发生错误,请稍后再试。")); @@ -241,7 +241,7 @@ private static CommandRegistry createCommandRegistry( .register(generalCommands::handleFaq, "faq") .register(watchCommands::handleWatch, "watch") .register(specificScoreWatchCommands::handleWx, "wx") - .register(multiplayerRoomWatchCommands::handleMpWatch, "mpwatch") + .register(multiplayerRoomWatchCommands::handleMpWatch, "mpwatch", "mpw") .register(dcsCommands::handleDcs, "dcs") .register(rankGuessCommands::handleRankGuess, "rg") .build(); From 749b5e3c3954ef556c792a9214dd6edb90cf7433 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sat, 29 Aug 2026 01:09:54 +0800 Subject: [PATCH 02/54] =?UTF-8?q?=E5=A2=9E=E5=BC=BA=E5=9B=9E=E6=94=BE?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E5=A4=B1=E8=B4=A5=E4=BF=A1=E6=81=AF=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=8F=AF=E8=83=BD=E5=8E=9F=E5=9B=A0=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xyz/zcraft/seira/api/ReplayRenderException.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java b/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java index 38f99d01..21a4b495 100644 --- a/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java +++ b/src/main/java/xyz/zcraft/seira/api/ReplayRenderException.java @@ -7,8 +7,19 @@ public ReplayRenderException(String status, String error) { static String formatMessage(String status, String error) { if (error != null && !error.isBlank()) { - return "回放渲染失败:" + error.trim(); + String possibleReason = tryParseError(error); + return "回放渲染失败" + (possibleReason != null ? ",这可能是由于" + possibleReason : "") + "。日志输出:\n```\n" + error.trim() + "\n```\n"; } return "回放渲染失败,状态:" + (status == null || status.isBlank() ? "failed" : status); } + + static String tryParseError(String error) { + if (error.contains("Beatmap not found, closing...")) { + return "镜像站谱面版本不正确"; + } else if (error.contains("Cannot run program \"xvfb-run\": Failed to exec spawn helper")) { + return "服务器正在进行更新"; + } else { + return null; + } + } } From 4b42982ce5296218017850479aac56ac295119d5 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sat, 29 Aug 2026 01:10:47 +0800 Subject: [PATCH 03/54] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 93b65a1c..2f862148 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira SeiraCore - 1.11.0 + 1.11.1 25 From 6fe6aa70516f4f703f7262c904c814ef2b4e2f45 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sat, 29 Aug 2026 01:13:22 +0800 Subject: [PATCH 04/54] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=97=A5=E5=BF=97=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/xyz/zcraft/seira/runtime/SeiraApplication.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/xyz/zcraft/seira/runtime/SeiraApplication.java b/src/main/java/xyz/zcraft/seira/runtime/SeiraApplication.java index 4cd98d76..7b3e0563 100644 --- a/src/main/java/xyz/zcraft/seira/runtime/SeiraApplication.java +++ b/src/main/java/xyz/zcraft/seira/runtime/SeiraApplication.java @@ -92,5 +92,6 @@ public void close() { bot.close(); DailyLuck.saveToFile(); BotStat.shutdown(); + LOG.info("Shutdown complete"); } } From fc6af953f6c4a26c42f3a3c4ea09f04dfb89c578 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 16:25:57 +0800 Subject: [PATCH 05/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=B8=A6=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E5=99=A8=E7=9A=84=E6=8C=87=E4=BB=A4=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E8=A7=A3=E6=9E=90(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xyz/zcraft/seira/binding/BindingService.java | 6 +++++- .../seira/command/handler/ScoreCommandHandler.java | 4 ++-- .../java/xyz/zcraft/seira/command/parse/Resolver.java | 10 +++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/binding/BindingService.java b/src/main/java/xyz/zcraft/seira/binding/BindingService.java index 901c84c6..175b73a1 100644 --- a/src/main/java/xyz/zcraft/seira/binding/BindingService.java +++ b/src/main/java/xyz/zcraft/seira/binding/BindingService.java @@ -165,7 +165,11 @@ public synchronized void close() { Javalin currentServer = server; server = null; if (currentServer != null) { - currentServer.stop(); + try { + currentServer.stop(); + } catch (Exception e) { + LOG.warn("Error stopping server", e); + } } bindingTasks.clear(); } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java index c59a8634..0c69c177 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ScoreCommandHandler.java @@ -163,7 +163,7 @@ private void handleFilteredSingleScore(Context ctx, String macroType) { UserRef targetUser = null; int startIndex = 0; - if (resolver.looksLikeMention(ctx.args()[0])) { + if (resolver.looksLikeMention(ctx.args()[0]) || resolver.looksLikeUid(ctx.args()[0])) { final UserRefResolution userRefResolution = resolver.resolveUserRefArgument(ctx.args()[0]); if (userRefResolution.errorMessage() != null) { ctx.sendReply(PendingMessage.ofString(userRefResolution.errorMessage())); @@ -207,7 +207,7 @@ private ScoreListRequest parseScoreListRequest(Context ctx, String usage) { int nextArg = 1; UserRef userRef; - if (nextArg < args.length && resolver.looksLikeMention(args[nextArg])) { + if (nextArg < args.length && (resolver.looksLikeMention(args[nextArg]) || resolver.looksLikeUid(args[nextArg]))) { UserRefResolution resolution = resolver.resolveUserRefArgument(args[nextArg]); if (resolution.errorMessage() != null) { ctx.sendReply(PendingMessage.ofString(resolution.errorMessage())); diff --git a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java index 25a3ca8e..fa8952ba 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java @@ -91,9 +91,9 @@ public UserRefResolution resolveUserRefArgument(String arg) { return new UserRefResolution(new UserRef.ByUid(boundUid), null); } - if (looksLikeMention(arg)) { - return new UserRefResolution(null, "@用户格式无效,请使用 @用户 后再输入指令。示例:/bo 5 @123456"); - } +// if (looksLikeMention(arg)) { +// return new UserRefResolution(null, "@用户格式无效,请使用 @用户 后再输入指令。示例:/bo 5 @123456"); +// } String username = arg == null ? "" : arg.trim(); return new UserRefResolution(username.isEmpty() ? null : new UserRef.ByUsername(username), null); @@ -271,6 +271,10 @@ public Long parsePositiveLong(String value) { } } + public boolean looksLikeUid(String arg) { + return Pattern.compile("^\\d+$").matcher(arg).matches(); + } + private static final class Patterns { private static final Pattern USER_MACRO_PATTERN = Pattern.compile("(?i)^(rs|bo|rp|bp)(\\d+)?$"); private static final Pattern SET_MACRO_PATTERN = Pattern.compile("^(\\d+)#(\\d+)$"); From 14177d92e751a83cc14e23a87d145b45e93f2286 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 16:56:10 +0800 Subject: [PATCH 06/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=83=A8=E5=88=86?= =?UTF-8?q?=E6=8C=87=E4=BB=A4=E7=9B=AE=E6=A0=87=E5=8F=82=E6=95=B0=E8=A7=A3?= =?UTF-8?q?=E6=9E=90(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 2 +- .../java/xyz/zcraft/seira/api/APIHelper.java | 18 ++++++------- .../command/handler/ReplayCommandHandler.java | 16 ++++++------ .../zcraft/seira/command/parse/Resolver.java | 25 ++++++++++++++++--- .../zcraft/seira/command/parse/RscTarget.java | 2 +- 5 files changed, 40 insertions(+), 23 deletions(-) diff --git a/pom.xml b/pom.xml index 2f862148..a7f46474 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira SeiraCore - 1.11.1 + 1.11.2 25 diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index 672c82d3..5e431d3c 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -138,7 +138,7 @@ public static Response getGroupLeaderboardResponse(ShortcutTarget t return getBase64BytesResponse( "/beatmaps/" + beatmapId + "/leaderboards", "获取群排行失败", - GSON.toJsonTree(Map.of("uids", uids)).toString() + GSON.toJsonTree(Map.of("targets", uids)).toString() ); } @@ -146,7 +146,7 @@ public static Response getLeaderboardResponse(List uids) { return getBase64BytesResponse( "/users/leaderboards", "获取排行失败", - GSON.toJsonTree(Map.of("uids", uids)).toString() + GSON.toJsonTree(Map.of("targets", uids)).toString() ); } @@ -587,22 +587,22 @@ public static ReplayTaskInfo createBeatmapPreviewTask(ShortcutTarget target, Str return getReplayTaskInfo(request); } - public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget target, String[] ids, String auth, + public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget beatmapTarget, String[] scoreTargets, String auth, QqUploadRequest qqUpload) { - ids = ids == null ? new String[0] : ids; + scoreTargets = scoreTargets == null ? new String[0] : scoreTargets; - if (target.isLocalScore()) { - ids = Stream.concat(Stream.of("s" + target.localScoreId()), Arrays.stream(ids)) + if (beatmapTarget.isLocalScore()) { + scoreTargets = Stream.concat(Stream.of("s" + beatmapTarget.localScoreId()), Arrays.stream(scoreTargets)) .distinct() .toArray(String[]::new); } - if (ids.length == 0) { + if (scoreTargets.length == 0) { throw new RuntimeException("同屏回放需要至少一个ID。"); } - final long beatmapId = lookupBeatmap(target, auth); + final long beatmapId = lookupBeatmap(beatmapTarget, auth); - JsonObject body = GSON.toJsonTree(Map.of("ids", ids)).getAsJsonObject(); + JsonObject body = GSON.toJsonTree(Map.of("ids", scoreTargets)).getAsJsonObject(); if (qqUpload != null) { body.add("qqUpload", GSON.toJsonTree(qqUpload)); } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java index 537a924d..33c1ec8c 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java @@ -92,7 +92,7 @@ public void handleRsc(Context ctx) { TargetResolution targetResolution = targetHistory.resolveOptionalTarget( ctx, resolver, - arg -> arg.startsWith("+") || TimeDurationParser.isTimeRange(arg) + arg -> arg.startsWith("+") || arg.startsWith("=") ); ShortcutTarget target = targetResolution.target(); if (target == null) { @@ -106,13 +106,11 @@ public void handleRsc(Context ctx) { String extraUidArg = null; - for (int i = targetResolution.consumedArgs(); i < ctx.args().length; i++) { + int i = targetResolution.consumedArgs(); + + if (i < ctx.args().length) { if (ctx.args()[i].startsWith("+") || ctx.args()[i].startsWith("=")) { - if (extraUidArg != null) { - ctx.sendReply(PendingMessage.ofString(CommandUsage.RSC)); - return; - } - extraUidArg = ctx.args()[i]; + extraUidArg = ctx.query().substring(Math.max(ctx.query().indexOf("+"), ctx.query().indexOf("="))); } else { ctx.sendReply(PendingMessage.ofString(CommandUsage.RSC)); return; @@ -127,7 +125,7 @@ public void handleRsc(Context ctx) { return; } - String[] uidArray = rscTarget.uids(); + String[] targetsArray = rscTarget.targets(); targetHistory.rememberExplicitTarget(ctx, targetResolution); @@ -136,7 +134,7 @@ public void handleRsc(Context ctx) { "Showcase Render", qqUpload -> { var task = APIHelper.createReplayShowcaseTask( - target, uidArray, accessTokenProvider.apply(ctx.senderUserId()), qqUpload); + target, targetsArray, accessTokenProvider.apply(ctx.senderUserId()), qqUpload); videoRenderRecord.updateRenderTask(ctx.senderUserId(), task.taskId()); return task; }, diff --git a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java index fa8952ba..bb00b82a 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java @@ -96,7 +96,15 @@ public UserRefResolution resolveUserRefArgument(String arg) { // } String username = arg == null ? "" : arg.trim(); - return new UserRefResolution(username.isEmpty() ? null : new UserRef.ByUsername(username), null); + if (username.startsWith("@")) { + username = username.substring(1); + } + + if (username.isEmpty()) { + return new UserRefResolution(null, null); + } + + return new UserRefResolution(new UserRef.ByUsername(username), null); } public RscTarget resolveRscTarget(String groupId, String extraUidArg) { @@ -120,10 +128,21 @@ public RscTarget resolveRscTarget(String groupId, String extraUidArg) { String[] extraTokens = body.split(","); for (String token : extraTokens) { - if (!Patterns.RSC_TARGET_PATTERN.matcher(token.trim()).matches()) { + if (Patterns.RSC_TARGET_PATTERN.matcher(token.trim()).matches()) { + merged.add(token); + } else if (looksLikeMention(token)) { + final UserRefResolution userRefResolution = resolveUserRefArgument(token); + if (userRefResolution.errorMessage() != null) { + return new RscTarget(null, "解析 " + token + " 时出错:" + userRefResolution.errorMessage()); + } + if (userRefResolution.userRef() instanceof UserRef.ByUid ref) { + merged.add("u" + ref.getUid()); + } else if (userRefResolution.userRef() instanceof UserRef.ByUsername ref) { + merged.add("@" + ref.getUsername()); + } + } else { return new RscTarget(null, "追加ID列表包含非法值。"); } - merged.add(token.trim()); } return new RscTarget(merged.toArray(String[]::new), null); diff --git a/src/main/java/xyz/zcraft/seira/command/parse/RscTarget.java b/src/main/java/xyz/zcraft/seira/command/parse/RscTarget.java index aaf01516..9193b455 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/RscTarget.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/RscTarget.java @@ -1,4 +1,4 @@ package xyz.zcraft.seira.command.parse; -public record RscTarget(String[] uids, String errorMessage) { +public record RscTarget(String[] targets, String errorMessage) { } From 71c4d9c5b509f43f370f34148d36e3835d2100d4 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 17:05:25 +0800 Subject: [PATCH 07/54] =?UTF-8?q?=E4=BD=BF/rg=E7=8C=9C=E6=B5=8B=E6=97=A0?= =?UTF-8?q?=E9=9C=80=E5=B8=A6#?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zcraft/seira/command/handler/RankGuessCommandHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index cabd1310..568311b4 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -18,7 +18,7 @@ public final class RankGuessCommandHandler { private static final String USAGE = "用法:/rg start | /rg #Rank | /rg end"; - private static final Pattern RANK_PATTERN = Pattern.compile("^#(\\d+)[wk]?$"); + private static final Pattern RANK_PATTERN = Pattern.compile("^#?(\\d+)[wk]?$"); private final TaskCoordinator taskCoordinator; private final ReplyFactory replyFactory; private final RankGuessGameService games; From c99827fc58d8fecfcca76ed4175f264b3702291d Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 17:17:47 +0800 Subject: [PATCH 08/54] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E9=83=A8=E5=88=86?= =?UTF-8?q?=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zcraft/seira/command/handler/RankGuessCommandHandler.java | 2 +- .../xyz/zcraft/seira/command/handler/WatchCommandHandler.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 568311b4..5e853c06 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -108,7 +108,7 @@ private void start(Context ctx) { String content = at(ctx) + "随机用户与成绩已选定,正在渲染回放片段..."; if (!activeMessageEnabled) { - content += "\n\n> 提示: 由于缺少主动消息权限,阶段提示已禁用。权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)。"; + content += "\n\n> 提示: 由于缺少主动消息权限,阶段提示与自动结束已禁用。稍后需要使用 `/rg end` 手动结束。权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)。"; } ctx.sendReply(PendingMessage.ofMarkdownRaw(content)); diff --git a/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java index 32631686..08a054d1 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java @@ -106,7 +106,7 @@ private void handleAdd(Context ctx) { at(ctx) + "正在尝试添加监视..." )); if (!b) { - ctx.sendReply(PendingMessage.ofString("由于缺少主动消息权限,无法添加监视!权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)~")); + ctx.sendReply(PendingMessage.ofMarkdownRaw("由于缺少主动消息权限,无法添加监视!权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)~")); return; } From 9006e67a2a1edd388dc34ab96071ce05b5b63857 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 17:28:33 +0800 Subject: [PATCH 09/54] =?UTF-8?q?=E4=BF=AE=E5=A4=8Drg=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E7=9A=84300=E6=95=B0=E9=87=8Fbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xyz/zcraft/seira/rankguess/RankGuessGameService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 93396a98..c720eae8 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -443,7 +443,7 @@ public LinkedList getNormalHints() { RankGuessGame.Hint.HintStrength.MEDIUM )); - final long perfect = score.getStatistics().getOrDefault("perfect", 0L); + final long perfect = score.getStatistics().getOrDefault("great", 0L); final long ok = score.getStatistics().getOrDefault("ok", 0L); final long meh = score.getStatistics().getOrDefault("meh", 0L); final long miss = score.getStatistics().getOrDefault("miss", 0L); From 0384115761a0c12969479a72d4159a44fde4dc78 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 17:37:19 +0800 Subject: [PATCH 10/54] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=8A=84=E8=A2=AD?= =?UTF-8?q?=E6=83=A9=E7=BD=9A=E6=8F=90=E7=A4=BA=E5=A4=9A=E6=AC=A1=E7=9A=84?= =?UTF-8?q?bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java | 1 + .../xyz/zcraft/seira/rankguess/RankGuessGameService.java | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java index a46521c5..e7578920 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java @@ -11,6 +11,7 @@ public final class RankGuessGame { public final UUID token; public final String starterUserId; public final Map guesses = new LinkedHashMap<>(); + public boolean copyPunishmentReduced = false; public final AtomicInteger guessCount = new AtomicInteger(0); private final List revealedHints = new ArrayList<>(); public RankGuessGameService.Round round; diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index c720eae8..1782d09f 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -118,9 +118,10 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon - Math.log10(guess) ) < 0.005; - final int i = game.guessCount.get(); + final int i = game.guesses.size(); - if (i >= 10) { + if (i >= 10 && !game.copyPunishmentReduced) { + game.copyPunishmentReduced = true; message = "提示:由于本次游戏参与人数较多,所有猜测的抄袭惩罚已降至 `-2.5%` ~"; } From 9fdf60506e9c3cf665bd03cdd5b7e743ca1c4bc5 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 17:47:44 +0800 Subject: [PATCH 11/54] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=87=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seira/command/handler/RankGuessCommandHandler.java | 3 ++- .../xyz/zcraft/seira/rankguess/RankGuessGameService.java | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 5e853c06..0bc69261 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -217,7 +217,8 @@ private void guess(Context ctx, long rank) { at(ctx) + "已" + (result.status() == RankGuessGameService.GuessStatus.UPDATED ? "更新" : "记录") + "你的猜测:" + "`#" + String.format(Locale.US, "%,d", rank) + "`" - + " " + result.multiplierString() + + " " + result.multiplierString() + "\n\n" + + "目前已经有 `" + result.guessCount() + "` 个猜测~" ); }; ctx.sendReply(message); diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 1782d09f..da35da1b 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -136,7 +136,8 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon previousGuess == null ? GuessStatus.RECORDED : GuessStatus.UPDATED, guess, multipliers, - game.getMultipliersString(multipliers) + game.getMultipliersString(multipliers), + game.guesses.size() ); return GuessResponse.of(guessResult, message); @@ -368,7 +369,7 @@ static class HintMultiplier extends ScoreMultiplier { public record GuessResponse(GuessResult guessResult, String message) { public static GuessResponse ofStatus(GuessStatus status) { - return new GuessResponse(new GuessResult(status, 0, null, null), null); + return new GuessResponse(new GuessResult(status, 0, null, null, 0), null); } public static GuessResponse of(GuessResult guessResult, String message) { @@ -376,7 +377,7 @@ public static GuessResponse of(GuessResult guessResult, String message) { } } - public record GuessResult(GuessStatus status, long rank, List multipliers, String multiplierString) { + public record GuessResult(GuessStatus status, long rank, List multipliers, String multiplierString, int guessCount) { } From 17ca723ccf247b23c68a4a3d2c389e994300cd57 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 18:02:25 +0800 Subject: [PATCH 12/54] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86rg=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=9D=83=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xyz/zcraft/seira/rankguess/RankGuessGameService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index da35da1b..5278feb1 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -143,6 +143,7 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon return GuessResponse.of(guessResult, message); } + // TODO This is so messed up. Need to rewrite in the future. public static LinkedList prepareHints(List source, int maxCount) { var random = ThreadLocalRandom.current(); @@ -244,8 +245,10 @@ private static RankGuessGame.Hint selectWeightedByStrength( private static EnumMap strengthWeights(double progress) { double[] first = {50, 35, 15, 0, 0}; double[] middle = {25, 35, 30, 10, 0}; - double[] late = {10, 20, 35, 35, 0}; + double[] late = {0, 5, 40, 55, 0}; + double phase = progress <= 0.5 ? progress * 2 : (progress - 0.5) * 2; + double[] from = progress <= 0.5 ? first : middle; double[] to = progress <= 0.5 ? middle : late; From 4d86970e095565326aa238c64daf23b115c35e94 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 18:05:42 +0800 Subject: [PATCH 13/54] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86rg=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=9D=83=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xyz/zcraft/seira/rankguess/RankGuessGameService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 5278feb1..b9497c08 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -243,9 +243,9 @@ private static RankGuessGame.Hint selectWeightedByStrength( } private static EnumMap strengthWeights(double progress) { - double[] first = {50, 35, 15, 0, 0}; - double[] middle = {25, 35, 30, 10, 0}; - double[] late = {0, 5, 40, 55, 0}; + double[] first = {50, 35, 15, 0, 0}; + double[] middle = {10, 30, 45, 15, 0}; + double[] late = { 0, 5, 40, 55, 0}; double phase = progress <= 0.5 ? progress * 2 : (progress - 0.5) * 2; From 0761c512bd51069575f04fc5b8f91d1c6cfd56c4 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 20:07:53 +0800 Subject: [PATCH 14/54] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=8C=9C=E7=BE=A4?= =?UTF-8?q?=E5=8F=8Brank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 2 +- .../java/xyz/zcraft/seira/api/APIHelper.java | 42 ++++++++++ .../zcraft/seira/command/TaskCoordinator.java | 6 +- .../handler/RankGuessCommandHandler.java | 78 ++++++++++++++++--- .../seira/command/reply/ReplyFactory.java | 9 ++- 5 files changed, 119 insertions(+), 18 deletions(-) diff --git a/pom.xml b/pom.xml index a7f46474..89a8c460 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira SeiraCore - 1.11.2 + 1.11.3 25 diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index 5e431d3c..807c5107 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -564,6 +564,48 @@ public static RandomScore getRandomScore() { } } + public static RandomScore getRandomScoreFromUsers(List uids) { + try { + JsonObject body = new JsonObject(); + final JsonArray uidsArray = new JsonArray(); + for (Long uid : uids) { + uidsArray.add(uid); + } + + body.add("uids", uidsArray); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(ENDPOINT + "/scores/random/users")) + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) + .build(); + + HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (codeNotOk(response.statusCode())) { + throw parseHttpError(response.body(), response.statusCode(), "获取随机成绩失败"); + } + + RawResponse payload = GSON.fromJson(response.body(), RawResponse.class); + ensureApiSuccess(payload, "获取随机成绩失败"); + JsonObject data = requireDataObject(payload, "随机成绩响应缺少data"); + if (!data.has("user") || !data.get("user").isJsonObject() + || !data.has("score") || !data.get("score").isJsonObject()) { + throw new RuntimeException("随机成绩响应缺少用户或成绩数据"); + } + + return new RandomScore( + GSON.fromJson(data.getAsJsonObject("user"), UserExtended.class), + GSON.fromJson(data.getAsJsonObject("score"), Score.class), + data.get("best_index").getAsInt(), + data.get("diff").getAsString() + ); + } catch (IOException e) { + throw requestFailure(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("随机成绩请求被中断", e); + } + } + public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget target, String[] ids, String auth) { return createReplayShowcaseTask(target, ids, auth, null); } diff --git a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java index 9244ffd2..33472ff3 100644 --- a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java +++ b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java @@ -97,10 +97,8 @@ public APIHelper.ReplayRenderResult waitForReplay(APIHelper.ReplayTaskInfo taskI } APIHelper.ReplayRenderResult result = APIHelper.waitReplayVideo(taskInfo.taskId()); - if (result != null) { - replayResults.put(taskInfo.taskId(), result); - BotStat.incrementReplays(); - } + replayResults.put(taskInfo.taskId(), result); + BotStat.incrementReplays(); return result; } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 0bc69261..9c3ef9bc 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -1,6 +1,10 @@ package xyz.zcraft.seira.command.handler; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import xyz.zcraft.seira.api.APIHelper; +import xyz.zcraft.seira.api.data.RandomScore; +import xyz.zcraft.seira.binding.UserDataStore; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.TaskCoordinator; @@ -8,6 +12,7 @@ import xyz.zcraft.seira.rankguess.RankGuessGame; import xyz.zcraft.seira.rankguess.RankGuessGameService; +import java.util.List; import java.util.Locale; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; @@ -17,7 +22,8 @@ import static xyz.zcraft.seira.command.reply.ReplyFactory.at; public final class RankGuessCommandHandler { - private static final String USAGE = "用法:/rg start | /rg #Rank | /rg end"; + private static final Logger LOG = LogManager.getLogger(RankGuessCommandHandler.class); + private static final String USAGE = "用法:/rg start | /rg group | /rg #Rank | /rg end"; private static final Pattern RANK_PATTERN = Pattern.compile("^#?(\\d+)[wk]?$"); private final TaskCoordinator taskCoordinator; private final ReplyFactory replyFactory; @@ -61,17 +67,37 @@ public void handleRankGuess(Context ctx) { ctx.sendReply(PendingMessage.ofString("/rg 仅支持群聊使用。")); return; } - if (ctx.argumentCount() != 1) { + + String argument = ctx.argument(0); + if ("start".equalsIgnoreCase(argument)) { + if (ctx.argumentCount() == 2) { + final String arg = ctx.argument(1); + if ("group".equalsIgnoreCase(arg) || "g".equalsIgnoreCase(arg)) { + start(ctx, true); + return; + } + } else if (ctx.argumentCount() == 1) { + start(ctx, false); + return; + } + ctx.sendReply(PendingMessage.ofString(USAGE)); return; } + if ("group".equalsIgnoreCase(argument)) { + if (ctx.argumentCount() != 1) { + ctx.sendReply(PendingMessage.ofString(USAGE)); + return; + } - String argument = ctx.argument(0); - if ("start".equalsIgnoreCase(argument)) { - start(ctx); + start(ctx, true); return; } if ("end".equalsIgnoreCase(argument)) { + if (ctx.argumentCount() != 1) { + ctx.sendReply(PendingMessage.ofString(USAGE)); + return; + } end(ctx, true); return; } @@ -84,7 +110,7 @@ public void handleRankGuess(Context ctx) { guess(ctx, rank); } - private void start(Context ctx) { + private void start(Context ctx, boolean fromGroup) { RankGuessGameService.Reservation reservation = games.reserve(ctx.groupId(), ctx.senderUserId()); if (reservation == null) { ctx.sendReply(PendingMessage.ofString("本群已有一轮 Rank Guess 正在进行。")); @@ -102,10 +128,30 @@ private void start(Context ctx) { ctx.sendReply(message); } - var randomScore = APIHelper.getRandomScore(); + RandomScore randomScore; + + if (fromGroup) { + final List uids = UserDataStore.findBoundUidsByGroup(ctx.groupId()); + if (uids.isEmpty()) { + ctx.sendReply(PendingMessage.ofMarkdownRaw("本群没有绑定的用户,无法开始游戏喵")); + return; + } + randomScore = APIHelper.getRandomScoreFromUsers(uids); + } else { + randomScore = APIHelper.getRandomScore(); + } + RankGuessGameService.Round round = RankGuessGameService.Round.from(randomScore); - String content = at(ctx) + "随机用户与成绩已选定,正在渲染回放片段..."; + String content = at(ctx); + + if (fromGroup) { + content += "随机群友及其成绩已选定"; + } else { + content += "随机用户与成绩已选定"; + } + + content += ",正在渲染回放片段..."; if (!activeMessageEnabled) { content += "\n\n> 提示: 由于缺少主动消息权限,阶段提示与自动结束已禁用。稍后需要使用 `/rg end` 手动结束。权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)。"; @@ -117,7 +163,13 @@ private void start(Context ctx) { round.scoreId(), taskCoordinator.createVideoUploadRequest(ctx) ); - var replay = taskCoordinator.waitForReplay(renderTask); + APIHelper.ReplayRenderResult replay = null; + try { + replay = taskCoordinator.waitForReplay(renderTask); + } catch (Exception e) { + LOG.error("Failed to render replay for rank guess", e); + } + if (replay == null) { ctx.sendReply(PendingMessage.ofMarkdownRaw("由于回放渲染失败,本轮游戏已取消~")); return; @@ -142,6 +194,10 @@ private void start(Context ctx) { StringBuilder result = new StringBuilder("回放渲染完成,游戏已开始!请在群内发送 `/rg #Rank` 猜测排名~"); + if (fromGroup) { + result.append("\n").append("__Tip: 这是一位群友的成绩喵~__").append("\n"); + } + var hints = RankGuessGameService.prepareHints(round.getNormalHints(), 4); if (!activeMessageEnabled) { @@ -217,7 +273,7 @@ private void guess(Context ctx, long rank) { at(ctx) + "已" + (result.status() == RankGuessGameService.GuessStatus.UPDATED ? "更新" : "记录") + "你的猜测:" + "`#" + String.format(Locale.US, "%,d", rank) + "`" - + " " + result.multiplierString() + "\n\n" + + " " + result.multiplierString() + "\n" + "目前已经有 `" + result.guessCount() + "` 个猜测~" ); }; @@ -237,7 +293,7 @@ private void end(Context ctx, boolean fromCommand) { case FORBIDDEN -> PendingMessage.ofString( "开始猜测后的3分钟内,仅发起者和机器人管理员可以结束游戏喵" ); - case FINISHED -> replyFactory.rankGuessResultMessage(result.round()); + case FINISHED -> replyFactory.rankGuessResultMessage(ctx, result.round()); }; if (fromCommand) { diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index ec20a8b2..0469b291 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -74,15 +74,20 @@ private Buttons buttons() { return new Buttons(configSupplier.get().seira().directUrl()); } - public PendingMessage rankGuessResultMessage(FinishedRound result) { + public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result) { RankGuessGameService.Round round = result.round(); String rank = String.format(Locale.US, "%,d", round.actualRank()); String pp = round.pp() == null ? "未知" : "%.1f".formatted(round.pp()) + "pp"; + String userAt = UserDataStore.findGroupOpenIdByUid(ctx.groupId(), round.userId()) + .map(e -> "(" + at(e) + ")") + .orElse(""); + StringBuilder content = new StringBuilder() .append("本轮猜测结束~\n") + .append("> 玩家:`%s` %s\n".formatted(round.randomScore().user().getUsername(), userAt)) .append("> 实际Rank:`#%s` (%s)\n".formatted(rank, cmd("/u " + round.userId(), String.valueOf(round.userId())))) .append("> 成绩PP:`%s` (%s)\n".formatted(pp, cmd("/s " + round.scoreId(), String.valueOf(round.scoreId())))) .append("\n猜测排行榜:\n"); @@ -599,7 +604,7 @@ public static String helpContent(Context ctx) { > /bma <谱面ID或快捷查询> [Mod] - 分析谱面PP构成和类型 > /ms <谱面集ID或快捷查询> - 获取谱面集 > /r [成绩ID或快捷查询] [[mm:ss]-[mm:ss]] - 生成成绩高光视频或指定片段 - > /rg - 在群聊中进行猜 Rank 游戏 + > /rg - 在群聊中进行猜 Rank 游戏 > /lb <谱面ID> [玩家ID列表] - 获取指定谱面排行榜 > /watch add <玩家ID/用户名/@用户> [分钟] - 监视群友的新成绩 > /wx start <谱面ID列表> - 监视指定玩家在指定谱面的成绩 From 8b90aca35c05a11693cffae98170ec917367690e Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Tue, 1 Sep 2026 21:49:14 +0800 Subject: [PATCH 15/54] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seira/command/handler/RankGuessCommandHandler.java | 5 +++++ .../java/xyz/zcraft/seira/command/reply/ReplyFactory.java | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 9c3ef9bc..740e71ad 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -68,6 +68,11 @@ public void handleRankGuess(Context ctx) { return; } + if (ctx.argumentCount() == 0) { + ctx.sendReply(PendingMessage.ofString(USAGE)); + return; + } + String argument = ctx.argument(0); if ("start".equalsIgnoreCase(argument)) { if (ctx.argumentCount() == 2) { diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 0469b291..b78685d5 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -64,7 +64,7 @@ public static PendingMessage replayUploadMessage(ReplayUploadInfo info) { return PendingMessage.ofMarkdownRaw( ("\n" + "## Replay上传成功~" + "\n" + "> 成绩: " + cmd("/s " + info.scoreId(), String.valueOf(info.scoreId())) + "\n" + - "> 铺面: " + cmd("/m " + info.beatmapId(), String.valueOf(info.beatmapId())) + "\n" + + "> 谱面: " + cmd("/m " + info.beatmapId(), String.valueOf(info.beatmapId())) + "\n" + "> 用户: " + cmd("/u " + info.userId(), info.username()) + "\n").trim(), null ); From 3e98ad7807f7f4a7966c9026313f1970876af426 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 03:18:41 +0800 Subject: [PATCH 16/54] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E7=94=A8=E6=88=B7Rank=E7=9A=84=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=BC=98=E5=8C=96Rank=20Guess=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xyz/zcraft/seira/api/APIHelper.java | 27 ++++++++++ .../handler/BindingCommandHandler.java | 2 +- .../handler/RankGuessCommandHandler.java | 51 ++++++++++++++----- .../zcraft/seira/command/parse/Resolver.java | 4 +- .../zcraft/seira/command/route/Router.java | 2 +- .../zcraft/seira/rankguess/RankGuessGame.java | 9 +++- .../seira/rankguess/RankGuessGameService.java | 10 ++-- 7 files changed, 83 insertions(+), 22 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index 807c5107..f2b36e4e 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -1045,6 +1045,33 @@ public static long resolveUid(UserRef userRef) { throw new ResolutionException("无法识别指定的玩家"); } + public static long getUserRank(UserRef userRef) { + long uid = resolveUid(userRef); + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(ENDPOINT + "/users/" + uid + "/rank")) + .header("Content-Type", "application/json") + .GET() + .build(); + + final HttpResponse send = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + + if (send.statusCode() != 200) { + throw parseHttpError(send.body(), send.statusCode(), "获取玩家Rank失败"); + } + + final RawResponse response = GSON.fromJson(send.body(), RawResponse.class); + ensureApiSuccess(response, "获取玩家Rank失败"); + final JsonObject data = requireDataObject(response, "获取玩家Rank响应缺少用户数据"); + return data.get("global_rank").getAsLong(); + } catch (IOException e) { + throw requestFailure(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("获取玩家Rank请求被中断", e); + } + } + public static Response lookupUser(String username) { try { HttpRequest request = HttpRequest.newBuilder() diff --git a/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java index 9a81bcf5..08a7a126 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java @@ -30,7 +30,7 @@ public void handleBind(Context ctx) { } if (ctx.args().length != 0) { - ctx.sendReply(PendingMessage.ofString("用法:/bind")); + ctx.sendReply(PendingMessage.ofString("用法(无需ID):/bind")); return; } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 740e71ad..f55756b2 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -8,7 +8,10 @@ import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.TaskCoordinator; +import xyz.zcraft.seira.command.parse.Resolver; +import xyz.zcraft.seira.command.parse.UserRefResolution; import xyz.zcraft.seira.command.reply.ReplyFactory; +import xyz.zcraft.seira.data.UserRef; import xyz.zcraft.seira.rankguess.RankGuessGame; import xyz.zcraft.seira.rankguess.RankGuessGameService; @@ -28,17 +31,20 @@ public final class RankGuessCommandHandler { private final TaskCoordinator taskCoordinator; private final ReplyFactory replyFactory; private final RankGuessGameService games; + private final Resolver resolver; private final Predicate adminAuthorizer; public RankGuessCommandHandler( TaskCoordinator taskCoordinator, ReplyFactory replyFactory, RankGuessGameService games, + Resolver resolver, Predicate adminAuthorizer ) { this.taskCoordinator = taskCoordinator; this.replyFactory = replyFactory; this.games = games; + this.resolver = resolver; this.adminAuthorizer = adminAuthorizer; } @@ -103,15 +109,30 @@ public void handleRankGuess(Context ctx) { ctx.sendReply(PendingMessage.ofString(USAGE)); return; } - end(ctx, true); + end(ctx, false); return; } - Long rank = parseRank(argument); - if (rank == null) { - ctx.sendReply(PendingMessage.ofString(USAGE)); - return; + Long rank = null; + + if (resolver.looksLikeMention(argument)) { + final UserRefResolution userRefResolution = resolver.resolveUserRefArgument(argument); + + if (userRefResolution.errorMessage() != null) { + ctx.sendReply(userRefResolution.errorMessage()); + } + + final UserRef userRef = userRefResolution.userRef(); + + rank = APIHelper.getUserRank(userRef); + } else { + rank = parseRank(argument); + if (rank == null) { + ctx.sendReply(PendingMessage.ofString(USAGE)); + return; + } } + guess(ctx, rank); } @@ -224,7 +245,7 @@ private void start(Context ctx, boolean fromGroup) { boolean firstHint = true; - while (!hints.isEmpty()) { + while (!hints.isEmpty() && !game.isEnded()) { try { //noinspection BusyWait Thread.sleep((firstHint ? 60 : 30) * 1000); @@ -260,7 +281,9 @@ private void start(Context ctx, boolean fromGroup) { Thread.currentThread().interrupt(); } - end(ctx, false); + if (!game.isEnded()) { + end(ctx, true); + } } ); if (!activated.get()) { @@ -286,12 +309,18 @@ private void guess(Context ctx, long rank) { if (response.message() != null && !response.message().isBlank()) { ctx.sendReply(PendingMessage.ofMarkdownRaw(response.message())); } + + if (rank == response.game().getRound().actualRank()) { + ctx.sendReply(PendingMessage.ofMarkdownRaw("看来已经有人知晓了答案喵!游戏将会自动结束~")); + end(ctx, true); + } } - private void end(Context ctx, boolean fromCommand) { + private void end(Context ctx, boolean force) { RankGuessGameService.EndResult result = games.end( - ctx.groupId(), ctx.senderUserId(), adminAuthorizer.test(ctx.senderUserId()), !fromCommand + ctx.groupId(), ctx.senderUserId(), adminAuthorizer.test(ctx.senderUserId()), force ); + PendingMessage message = switch (result.status()) { case NO_GAME -> PendingMessage.ofString("本群当前没有进行中的 Rank Guess 喵"); case STARTING -> PendingMessage.ofString("高光仍在渲染,请等待视频发送后再结束游戏喵"); @@ -301,9 +330,7 @@ private void end(Context ctx, boolean fromCommand) { case FINISHED -> replyFactory.rankGuessResultMessage(ctx, result.round()); }; - if (fromCommand) { - ctx.sendReply(message); - } else { + if (!ctx.sendReply(message)) { ctx.sendMessage(message); } } diff --git a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java index bb00b82a..2f5eec36 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java @@ -86,7 +86,7 @@ public UserRefResolution resolveUserRefArgument(String arg) { if (mentionedUserId != null) { Long boundUid = resolveBoundUid(mentionedUserId); if (boundUid == null) { - return new UserRefResolution(null, "被@的用户还没有绑定玩家ID,请先让对方使用 /bind <玩家ID>"); + return new UserRefResolution(null, "被@的用户还没有绑定玩家ID,请先让对方使用 /bind"); } return new UserRefResolution(new UserRef.ByUid(boundUid), null); } @@ -113,7 +113,7 @@ public RscTarget resolveRscTarget(String groupId, String extraUidArg) { if (extraUidArg == null || extraUidArg.trim().startsWith("+")) { List groupBoundUids = UserDataStore.findBoundUidsByGroup(groupId); if (groupBoundUids.isEmpty()) { - return new RscTarget(null, "本群还没有已绑定的玩家,请先使用 /bind <玩家ID>"); + return new RscTarget(null, "本群还没有已绑定的玩家,请先使用 /bind"); } groupBoundUids.stream().map(String::valueOf).forEach(merged::add); } diff --git a/src/main/java/xyz/zcraft/seira/command/route/Router.java b/src/main/java/xyz/zcraft/seira/command/route/Router.java index a1dff3ae..87e831a7 100644 --- a/src/main/java/xyz/zcraft/seira/command/route/Router.java +++ b/src/main/java/xyz/zcraft/seira/command/route/Router.java @@ -93,7 +93,7 @@ public Router( new MultiplayerRoomWatchCommandHandler(taskCoordinator, multiplayerRoomWatchService); DcsCommandHandler dcsCommands = new DcsCommandHandler(discordBridgeService); RankGuessCommandHandler rankGuessCommands = new RankGuessCommandHandler( - taskCoordinator, replyFactory, rankGuessGameService, admins::isAdmin + taskCoordinator, replyFactory, rankGuessGameService, resolver, admins::isAdmin ); this.unknownCommand = generalCommands::handleUnknown; this.commandParser = new CommandParser(resolver::sanitize); diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java index e7578920..a33a80c1 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java @@ -18,7 +18,7 @@ public final class RankGuessGame { public Instant guessingStartedAt; public long nextSequence; @Getter - private boolean ended = false; + private volatile boolean ended = false; RankGuessGame(UUID token, String starterUserId) { this.token = token; @@ -91,6 +91,13 @@ public String getMultipliersString(List mu return builder.toString(); } + public double getNextMaxPoints() { + final double hintsMultiplier = -revealedHints.stream().mapToDouble(h -> h.strength().penalty()).sum(); + final double orderMultiplier = Math.max(-0.10, 0.00 - (guessCount.get() - 2) * 0.01); + + return 1000 * (1 + hintsMultiplier + orderMultiplier); + } + public record Hint(String content, String name, HintCategory category, HintStrength strength) { public enum HintCategory { RANK, diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index b9497c08..5df45f1d 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -140,7 +140,7 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon game.guesses.size() ); - return GuessResponse.of(guessResult, message); + return GuessResponse.of(game, guessResult, message); } // TODO This is so messed up. Need to rewrite in the future. @@ -370,13 +370,13 @@ static class HintMultiplier extends ScoreMultiplier { } } - public record GuessResponse(GuessResult guessResult, String message) { + public record GuessResponse(RankGuessGame game, GuessResult guessResult, String message) { public static GuessResponse ofStatus(GuessStatus status) { - return new GuessResponse(new GuessResult(status, 0, null, null, 0), null); + return new GuessResponse(null, new GuessResult(status, 0, null, null, 0), null); } - public static GuessResponse of(GuessResult guessResult, String message) { - return new GuessResponse(guessResult, message); + public static GuessResponse of(RankGuessGame game, GuessResult guessResult, String message) { + return new GuessResponse(game, guessResult, message); } } From 7f846a89f90e486c2332c078ab1558679ed1c54f Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 19:52:44 +0800 Subject: [PATCH 17/54] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=AF=B9replay?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E7=9A=84=E6=94=AF=E6=8C=81=EF=BC=8C=E6=9B=B4?= =?UTF-8?q?=E6=96=B0ScoreFilterArguments=E4=BB=A5=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E7=9B=B8=E5=85=B3=E8=BF=87=E6=BB=A4=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zcraft/seira/command/parse/ScoreFilterArguments.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java b/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java index 56869cd3..031413c3 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java @@ -10,7 +10,7 @@ /** Parses score-list filters and converts aliases to oStella's compact filter syntax. */ public final class ScoreFilterArguments { private static final Pattern FILTER_PATTERN = Pattern.compile( - "(?i)^(acc(?:uracy)?|combo|pp|time|length|len|star|stars|sr|bpm|miss|misses|score|mod|mods|rank" + "(?i)^(acc(?:uracy)?|combo|pp|time|length|len|star|stars|sr|bpm|miss|misses|score|mod|mods|rank|replay" + "|title|artist|mapper|genre|language|video|storyboard|fullcombo)" + "(>=|<=|!=|!~|>|<|=|~)(.+)$" ); @@ -76,7 +76,7 @@ private static String parseOne(String token) { if (value.isBlank()) { throw new IllegalArgumentException(field + " 不能为空"); } - } else if (Set.of("video", "storyboard", "fullcombo").contains(field)) { + } else if (Set.of("video", "storyboard", "fullcombo", "replay").contains(field)) { if (!Set.of("=", "!=").contains(operator)) { throw new IllegalArgumentException(field + " 仅支持 =、!="); } @@ -120,6 +120,7 @@ private static String normalizeField(String value) { case "video" -> "video"; case "storyboard" -> "storyboard"; case "fullcombo" -> "fullcombo"; + case "replay" -> "replay"; default -> throw new IllegalArgumentException("未知字段 " + value); }; } @@ -136,6 +137,8 @@ private static String expandShorthand(String value) { case "sb", "storyboard" -> "storyboard=true"; case "!sb", "!storyboard" -> "storyboard=false"; case "fc", "fullcombo" -> "fullcombo=true"; + case "replay" -> "replay=true"; + case "!replay" -> "replay=false"; case "!fc", "!fullcombo" -> "fullcombo=false"; default -> null; }; From 93be08d2b8ef1327036cd860063a6bfe3b66cc6f Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 21:16:59 +0800 Subject: [PATCH 18/54] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=AE=B8=E6=84=BF?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=9B=B4=E6=96=B0RankGuessCommandHa?= =?UTF-8?q?ndler=E4=BB=A5=E6=94=AF=E6=8C=81=E7=94=A8=E6=88=B7=E8=AE=B8?= =?UTF-8?q?=E6=84=BF=EF=BC=8C=E5=B9=B6=E4=BC=98=E5=8C=96=E6=9D=83=E9=87=8D?= =?UTF-8?q?=E7=94=9F=E6=88=90=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xyz/zcraft/seira/api/APIHelper.java | 3 +- .../handler/RankGuessCommandHandler.java | 33 +- .../seira/command/reply/ReplyFactory.java | 2 +- .../seira/rankguess/RankGuessGameService.java | 282 +++++++++--------- .../seira/rankguess/RankGuessWeights.java | 131 ++++++++ .../zcraft/seira/rankguess/WishResult.java | 7 + 6 files changed, 319 insertions(+), 139 deletions(-) create mode 100644 src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java create mode 100644 src/main/java/xyz/zcraft/seira/rankguess/WishResult.java diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index f2b36e4e..37b1653d 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -564,7 +564,7 @@ public static RandomScore getRandomScore() { } } - public static RandomScore getRandomScoreFromUsers(List uids) { + public static RandomScore getRandomScoreFromUsers(List uids, JsonObject weights) { try { JsonObject body = new JsonObject(); final JsonArray uidsArray = new JsonArray(); @@ -573,6 +573,7 @@ public static RandomScore getRandomScoreFromUsers(List uids) { } body.add("uids", uidsArray); + body.add("weight_factor", weights); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(ENDPOINT + "/scores/random/users")) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index f55756b2..2db5a5a2 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -14,6 +14,7 @@ import xyz.zcraft.seira.data.UserRef; import xyz.zcraft.seira.rankguess.RankGuessGame; import xyz.zcraft.seira.rankguess.RankGuessGameService; +import xyz.zcraft.seira.rankguess.WishResult; import java.util.List; import java.util.Locale; @@ -26,7 +27,7 @@ public final class RankGuessCommandHandler { private static final Logger LOG = LogManager.getLogger(RankGuessCommandHandler.class); - private static final String USAGE = "用法:/rg start | /rg group | /rg #Rank | /rg end"; + private static final String USAGE = "用法:/rg start | /rg group | /rg #Rank | /rg end | /rg wish"; private static final Pattern RANK_PATTERN = Pattern.compile("^#?(\\d+)[wk]?$"); private final TaskCoordinator taskCoordinator; private final ReplyFactory replyFactory; @@ -112,8 +113,16 @@ public void handleRankGuess(Context ctx) { end(ctx, false); return; } + if ("wish".equalsIgnoreCase(argument)) { + if (ctx.argumentCount() != 1) { + ctx.sendReply(PendingMessage.ofString(USAGE)); + return; + } + wish(ctx); + return; + } - Long rank = null; + Long rank; if (resolver.looksLikeMention(argument)) { final UserRefResolution userRefResolution = resolver.resolveUserRefArgument(argument); @@ -136,6 +145,24 @@ public void handleRankGuess(Context ctx) { guess(ctx, rank); } + private void wish(Context ctx) { + final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId()); + + if (boundUid == null) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于未绑定,无法进行许愿喵~")); + return; + } + + final WishResult wish = games.wish(ctx.groupId(), boundUid); + + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + switch (wish) { + case SUCCESS -> "小星听到你的愿望啦!"; + case ALREADY_WISHED -> "已经许过愿了喵~"; + case RECENTLY_PICKED -> "最近已经被抽到过了喵~"; + case null -> "发生了一些不好的事情..."; + })); + } + private void start(Context ctx, boolean fromGroup) { RankGuessGameService.Reservation reservation = games.reserve(ctx.groupId(), ctx.senderUserId()); if (reservation == null) { @@ -162,7 +189,7 @@ private void start(Context ctx, boolean fromGroup) { ctx.sendReply(PendingMessage.ofMarkdownRaw("本群没有绑定的用户,无法开始游戏喵")); return; } - randomScore = APIHelper.getRandomScoreFromUsers(uids); + randomScore = APIHelper.getRandomScoreFromUsers(uids, games.generateWeights(ctx.groupId())); } else { randomScore = APIHelper.getRandomScore(); } diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index b78685d5..5b52cdad 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -117,7 +117,7 @@ public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result) public PendingMessage boMessage(Context ctx, Response response) { return PendingMessage.ofMarkdownRaw( - at(ctx) + "B" + response.getScoreIds().size() + "查询完成\n" + + at(ctx) + "查询完成,共" + response.getScoreIds().size() + "个成绩\n" + "> 玩家: " + cmd("/u " + response.getUserId(), response.getUserId()), buttons().boButtons(response.getUserId()) ); diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 5df45f1d..29574871 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -1,5 +1,6 @@ package xyz.zcraft.seira.rankguess; +import com.google.gson.JsonObject; import lombok.Getter; import org.jetbrains.annotations.NotNull; import xyz.zcraft.osu.model.Score; @@ -16,6 +17,7 @@ public final class RankGuessGameService { private static final Duration END_PROTECTION_DURATION = Duration.ofMinutes(3); private final Map games = new HashMap<>(); + private final RankGuessWeights weights = new RankGuessWeights(); private final Clock clock; public RankGuessGameService() { @@ -30,117 +32,8 @@ static double logarithmicError(long guess, long actualRank) { return Math.abs(Math.log10(guess) - Math.log10(actualRank)); } - public synchronized Reservation reserve(String groupId, String starterUserId) { - if (games.containsKey(groupId)) { - return null; - } - - Reservation reservation = new Reservation(groupId, UUID.randomUUID()); - games.put(groupId, new RankGuessGame(reservation.token(), starterUserId)); - return reservation; - } - - public synchronized RankGuessGame activate(Reservation reservation, Round round) { - if (round == null) { - throw new IllegalArgumentException("Round must not be null"); - } - RankGuessGame game = games.get(reservation.groupId()); - if (game == null || !game.token.equals(reservation.token())) { - return null; - } - - game.round = round; - game.guessingStartedAt = clock.instant(); - - return game; - } - - public synchronized void cancel(Reservation reservation) { - RankGuessGame game = games.get(reservation.groupId()); - if (game != null && game.token.equals(reservation.token())) { - games.remove(reservation.groupId()); - } - } - - public synchronized Set activeGroupIds() { - return Set.copyOf(games.keySet()); - } - - public synchronized GuessResponse guess(String groupId, String senderUserId, long guess) { - if (guess <= 0) { - throw new IllegalArgumentException("Rank must be positive"); - } - - RankGuessGame game = games.get(groupId); - if (game == null) { - return GuessResponse.ofStatus(GuessStatus.NO_GAME); - } - if (game.round == null) { - return GuessResponse.ofStatus(GuessStatus.STARTING); - } - - final int guessNumber = game.guessCount.incrementAndGet(); - - LinkedList multipliers = new LinkedList<>(); - - for (RankGuessGame.Hint hint : game.getRevealedHints()) { - multipliers.add(new ScoreMultiplier.HintMultiplier(hint)); - } - - if (guessNumber == 1) { - multipliers.add(new ScoreMultiplier.FirstGuessMultiplier()); - } else { - multipliers.add(new ScoreMultiplier.OrderMultiplier(guessNumber)); - } - - String message = null; - - List otherGuesses = game.guesses.entrySet().stream() - .filter(entry -> !Objects.equals(entry.getKey(), senderUserId)) - .map(Map.Entry::getValue) - .toList(); - - long closestGuess = otherGuesses.stream() - .mapToLong(Guess::rank) - .boxed() - .min(Comparator.comparingLong(previous -> Math.abs(previous - guess))) - .orElse(-1L); - - if (closestGuess != -1) { - double absoluteDifference = Math.abs(closestGuess - guess); - double relativeDifference = absoluteDifference - / (double) Math.max(closestGuess, guess); - - boolean copied = - (absoluteDifference <= 100 && relativeDifference <= 0.01) - || Math.abs( - Math.log10(closestGuess) - - Math.log10(guess) - ) < 0.005; - - final int i = game.guesses.size(); - - if (i >= 10 && !game.copyPunishmentReduced) { - game.copyPunishmentReduced = true; - message = "提示:由于本次游戏参与人数较多,所有猜测的抄袭惩罚已降至 `-2.5%` ~"; - } - - if (copied) { - multipliers.add(new ScoreMultiplier.CopyPunishmentMultiplier()); - } - } - - Guess previousGuess = game.guesses.put(senderUserId, new Guess(guess, game.nextSequence++, multipliers)); - - final GuessResult guessResult = new GuessResult( - previousGuess == null ? GuessStatus.RECORDED : GuessStatus.UPDATED, - guess, - multipliers, - game.getMultipliersString(multipliers), - game.guesses.size() - ); - - return GuessResponse.of(game, guessResult, message); + public JsonObject generateWeights(String groupId) { + return weights.generateWeights(groupId); } // TODO This is so messed up. Need to rewrite in the future. @@ -243,9 +136,9 @@ private static RankGuessGame.Hint selectWeightedByStrength( } private static EnumMap strengthWeights(double progress) { - double[] first = {50, 35, 15, 0, 0}; + double[] first = {50, 35, 15, 0, 0}; double[] middle = {10, 30, 45, 15, 0}; - double[] late = { 0, 5, 40, 55, 0}; + double[] late = {0, 5, 40, 55, 0}; double phase = progress <= 0.5 ? progress * 2 : (progress - 0.5) * 2; @@ -261,6 +154,141 @@ private static EnumMap strengthWeights( return weights; } + @NotNull + public static String getGlobalRankRange(long l) { + String range; + + if (l <= 10_000) { + range = "#1 - #10k"; + } else if (l <= 50_000) { + range = "#10k - #50k"; + } else if (l <= 200_000) { + range = "#50k - #200k"; + } else if (l <= 500_000) { + range = "#200k - #500k"; + } else { + range = ">#500k"; + } + + return range; + } + + public synchronized Reservation reserve(String groupId, String starterUserId) { + if (games.containsKey(groupId)) { + return null; + } + + Reservation reservation = new Reservation(groupId, UUID.randomUUID()); + games.put(groupId, new RankGuessGame(reservation.token(), starterUserId)); + return reservation; + } + + public synchronized RankGuessGame activate(Reservation reservation, Round round) { + if (round == null) { + throw new IllegalArgumentException("Round must not be null"); + } + RankGuessGame game = games.get(reservation.groupId()); + if (game == null || !game.token.equals(reservation.token())) { + return null; + } + + game.round = round; + game.guessingStartedAt = clock.instant(); + + weights.recordScore(reservation.groupId(), round.scoreId()); + weights.recordUser(reservation.groupId(), round.userId()); + + return game; + } + + public synchronized void cancel(Reservation reservation) { + RankGuessGame game = games.get(reservation.groupId()); + if (game != null && game.token.equals(reservation.token())) { + games.remove(reservation.groupId()); + } + } + + public synchronized Set activeGroupIds() { + return Set.copyOf(games.keySet()); + } + + public synchronized GuessResponse guess(String groupId, String senderUserId, long guess) { + if (guess <= 0) { + throw new IllegalArgumentException("Rank must be positive"); + } + + RankGuessGame game = games.get(groupId); + if (game == null) { + return GuessResponse.ofStatus(GuessStatus.NO_GAME); + } + if (game.round == null) { + return GuessResponse.ofStatus(GuessStatus.STARTING); + } + + final int guessNumber = game.guessCount.incrementAndGet(); + + LinkedList multipliers = new LinkedList<>(); + + for (RankGuessGame.Hint hint : game.getRevealedHints()) { + multipliers.add(new ScoreMultiplier.HintMultiplier(hint)); + } + + if (guessNumber == 1) { + multipliers.add(new ScoreMultiplier.FirstGuessMultiplier()); + } else { + multipliers.add(new ScoreMultiplier.OrderMultiplier(guessNumber)); + } + + String message = null; + + List otherGuesses = game.guesses.entrySet().stream() + .filter(entry -> !Objects.equals(entry.getKey(), senderUserId)) + .map(Map.Entry::getValue) + .toList(); + + long closestGuess = otherGuesses.stream() + .mapToLong(Guess::rank) + .boxed() + .min(Comparator.comparingLong(previous -> Math.abs(previous - guess))) + .orElse(-1L); + + if (closestGuess != -1) { + double absoluteDifference = Math.abs(closestGuess - guess); + double relativeDifference = absoluteDifference + / (double) Math.max(closestGuess, guess); + + boolean copied = + (absoluteDifference <= 100 && relativeDifference <= 0.01) + || Math.abs( + Math.log10(closestGuess) + - Math.log10(guess) + ) < 0.005; + + final int i = game.guesses.size(); + + if (i >= 10 && !game.copyPunishmentReduced) { + game.copyPunishmentReduced = true; + message = "提示:由于本次游戏参与人数较多,所有猜测的抄袭惩罚已降至 `-2.5%` ~"; + } + + if (copied) { + multipliers.add(new ScoreMultiplier.CopyPunishmentMultiplier()); + } + } + + Guess previousGuess = game.guesses.put(senderUserId, new Guess(guess, game.nextSequence++, multipliers)); + + final GuessResult guessResult = new GuessResult( + previousGuess == null ? GuessStatus.RECORDED : GuessStatus.UPDATED, + guess, + multipliers, + game.getMultipliersString(multipliers), + game.guesses.size() + ); + + return GuessResponse.of(game, guessResult, message); + } + public synchronized EndResult end(String groupId, String senderUserId, boolean admin, boolean force) { RankGuessGame game = games.get(groupId); if (game == null) { @@ -312,6 +340,10 @@ public synchronized EndResult end(String groupId, String senderUserId, boolean a ); } + public WishResult wish(String groupId, Long boundUid) { + return weights.tryWish(groupId, boundUid); + } + public enum GuessStatus { NO_GAME, STARTING, @@ -370,7 +402,7 @@ static class HintMultiplier extends ScoreMultiplier { } } - public record GuessResponse(RankGuessGame game, GuessResult guessResult, String message) { + public record GuessResponse(RankGuessGame game, GuessResult guessResult, String message) { public static GuessResponse ofStatus(GuessStatus status) { return new GuessResponse(null, new GuessResult(status, 0, null, null, 0), null); } @@ -380,7 +412,8 @@ public static GuessResponse of(RankGuessGame game, GuessResult guessResult, Stri } } - public record GuessResult(GuessStatus status, long rank, List multipliers, String multiplierString, int guessCount) { + public record GuessResult(GuessStatus status, long rank, List multipliers, String multiplierString, + int guessCount) { } @@ -547,23 +580,4 @@ public LinkedList getNormalHints() { return hints; } } - - @NotNull - public static String getGlobalRankRange(long l) { - String range; - - if (l <= 10_000) { - range = "#1 - #10k"; - } else if (l <= 50_000) { - range = "#10k - #50k"; - } else if (l <= 200_000) { - range = "#50k - #200k"; - } else if (l <= 500_000) { - range = "#200k - #500k"; - } else { - range = ">#500k"; - } - - return range; - } } diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java new file mode 100644 index 00000000..d1c2884c --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java @@ -0,0 +1,131 @@ +package xyz.zcraft.seira.rankguess; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +public class RankGuessWeights { + private static final Gson GSON = new Gson(); + + private static final int RECENT_USER_LIMIT = 5; + private static final double WISH_WEIGHT = 2; + private static final double RECENT_USER_WEIGHT = 0.75; + private static final double SCORE_REPEAT_FACTOR = 0.75; + + private final Map groups = new ConcurrentHashMap<>(); + + private static class GroupState { + private final Map scoreRecords = new HashMap<>(); + private final LinkedList userRecords = new LinkedList<>(); + private final Set userWishes = new HashSet<>(); + } + + private GroupState getGroup(String groupId) { + return groups.computeIfAbsent(groupId, _ -> new GroupState()); + } + + public void recordScore(String groupId, long scoreId) { + final GroupState state = getGroup(groupId); + + synchronized (state) { + state.scoreRecords.merge(scoreId, 1, Integer::sum); + } + } + + public void recordUser(String groupId, long userId) { + final GroupState state = getGroup(groupId); + + synchronized (state) { + state.userRecords.add(userId); + + if (state.userRecords.size() > RECENT_USER_LIMIT) { + state.userRecords.removeFirst(); + } + + state.userWishes.remove(userId); + } + } + + public boolean recentPicked(String groupId, long userId) { + final GroupState state = getGroup(groupId); + + synchronized (state) { + return state.userRecords.contains(userId); + } + } + + public WishResult tryWish(String groupId, long userId) { + final GroupState state = getGroup(groupId); + + synchronized (state) { + if (state.userWishes.contains(userId)) { + return WishResult.ALREADY_WISHED; + } + + if (state.userRecords.contains(userId)) { + return WishResult.RECENTLY_PICKED; + } + + state.userWishes.add(userId); + + return WishResult.SUCCESS; + } + } + + public JsonObject generateWeights(String groupId) { + final GroupState state = getGroup(groupId); + + final Map users = new HashMap<>(); + final Map scores = new HashMap<>(); + + synchronized (state) { + for (Long wishedId : state.userWishes) { + users.put(wishedId, WISH_WEIGHT); + } + + for (Long pickedId : state.userRecords) { + users.put(pickedId, RECENT_USER_WEIGHT); + } + + state.scoreRecords.forEach((scoreId, count) -> + scores.put( + scoreId, + Math.pow(SCORE_REPEAT_FACTOR, count) + ) + ); + } + + final JsonObject weights = new JsonObject(); + + weights.add("users", GSON.toJsonTree(users)); + weights.add("scores", GSON.toJsonTree(scores)); + + return weights; + } + + public List getGroupWishes(String groupId) { + final GroupState state = getGroup(groupId); + + synchronized (state) { + return List.copyOf(state.userWishes); + } + } + + public List getGroupUserRecords(String groupId) { + final GroupState state = getGroup(groupId); + + synchronized (state) { + return List.copyOf(state.userRecords); + } + } + + public Map getGroupScoreRecords(String groupId) { + final GroupState state = getGroup(groupId); + + synchronized (state) { + return Map.copyOf(state.scoreRecords); + } + } +} \ No newline at end of file diff --git a/src/main/java/xyz/zcraft/seira/rankguess/WishResult.java b/src/main/java/xyz/zcraft/seira/rankguess/WishResult.java new file mode 100644 index 00000000..1c1d9e98 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/rankguess/WishResult.java @@ -0,0 +1,7 @@ +package xyz.zcraft.seira.rankguess; + +public enum WishResult { + SUCCESS, + ALREADY_WISHED, + RECENTLY_PICKED +} From c72abda31116a59fbbdddedd996af24d81bbd482 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 21:29:20 +0800 Subject: [PATCH 19/54] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E8=87=B31.11.4=EF=BC=8C=E5=B9=B6=E5=9C=A8=E5=B8=AE?= =?UTF-8?q?=E5=8A=A9=E4=BF=A1=E6=81=AF=E4=B8=AD=E6=B7=BB=E5=8A=A0=E8=AE=B8?= =?UTF-8?q?=E6=84=BF=E5=8A=9F=E8=83=BD=E7=9A=84=E5=91=BD=E4=BB=A4=E5=8F=82?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- pom.xml | 2 +- src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 5b0ae5d9..9e7303d6 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 | `/ma` | `/ma [id/locId/rsN/boN] [n/#n]` | 获取指定或最近目标成绩的Miss分析;省略目标时用`#n`指定Miss | | `/u` | `/u [uid/username/@user]` | 获取指定用户信息 | | `/r` | `/r [id/locId/rsN/boN] [[mm:ss]-[mm:ss]]` | 生成并发送指定或最近目标的回放视频。省略范围时自动识别高光,使用`-`渲染整个回放 | -| `/rg` | `/rg ` | 在群聊中开始、参与或结束猜 Rank 游戏 | +| `/rg` | `/rg ` | 在群聊中开始、参与或结束猜 Rank 游戏 | | `/rsc` | `/rsc [id/locId/rsN/boN] [+,...]` | 生成并发送指定或最近目标的成绩同屏回放视频;追加用户和范围顺序不限 | | `/rstat` | `/rstat [id]` | 获取视频生成进度 | | `/ms` | `/ms ` | 获取指定谱面集信息 | diff --git a/pom.xml b/pom.xml index 89a8c460..562b7166 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira SeiraCore - 1.11.3 + 1.11.4 25 diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 5b52cdad..38c207ed 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -604,7 +604,7 @@ public static String helpContent(Context ctx) { > /bma <谱面ID或快捷查询> [Mod] - 分析谱面PP构成和类型 > /ms <谱面集ID或快捷查询> - 获取谱面集 > /r [成绩ID或快捷查询] [[mm:ss]-[mm:ss]] - 生成成绩高光视频或指定片段 - > /rg - 在群聊中进行猜 Rank 游戏 + > /rg - 在群聊中进行猜 Rank 游戏 > /lb <谱面ID> [玩家ID列表] - 获取指定谱面排行榜 > /watch add <玩家ID/用户名/@用户> [分钟] - 监视群友的新成绩 > /wx start <谱面ID列表> - 监视指定玩家在指定谱面的成绩 From 6fb0e65e0d3b31f7a348524d2576cbbf8b2c6894 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Wed, 2 Sep 2026 21:41:04 +0800 Subject: [PATCH 20/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A5=BD=E5=8F=8B?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=98=BE=E7=A4=BA=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=8A=98=E5=8F=A0=E5=8A=9F=E8=83=BD=E4=BB=A5=E6=94=B9=E5=96=84?= =?UTF-8?q?=E7=BE=A4=E7=BB=84=E4=B8=AD=E7=9A=84=E4=BF=A1=E6=81=AF=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seira/command/reply/ReplyFactory.java | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 38c207ed..e3323160 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -495,28 +495,40 @@ public static String friendContent(Context ctx, sb.append("\n"); + boolean collapsed = false; + sb.append("> 好友←→ (").append(mutual.size()).append(")\n>"); - for (User p : mutual) { - sb.append(getFriendItem(p)).append(" "); - } + collapsed |= appendFriends(ctx, mutual, sb); sb.append("\n> 仅关注→ (").append(onlyFollowed.size()).append(")\n>"); - for (User p : onlyFollowed) { - sb.append(getFriendItem(p)).append(" "); - } + collapsed |= appendFriends(ctx, onlyFollowed, sb); sb.append("\n> 仅粉丝← ("); sb.append(onlyFollower.size()).append(" 已知"); if (all) sb.append(" 共 ").append(self.getFollowerCount() - allMutualCount); sb.append(")\n>"); + collapsed |= appendFriends(ctx, onlyFollower, sb); - for (User p : onlyFollower) { - sb.append(getFriendItem(p)).append(" "); + if (ctx.inGroup() && collapsed) { + sb.append("\n部分结果已折叠,如需查看完整结果请在私聊中使用指令~"); } return sb.toString().trim(); } + private static boolean appendFriends(Context ctx, List onlyFollowed, StringBuilder sb) { + int count = 0; + for (User p : onlyFollowed) { + if (count >= 20 && ctx.inGroup()) { + sb.append("\n...剩余").append(onlyFollowed.size() - count).append("个"); + return true; + } + sb.append(getFriendItem(p)).append(" "); + count++; + } + return false; + } + private static String getFriendItem(User u) { return cmd("/u " + u.getId(), "[" + (u.isOnline() ? "▶" : "") + u.getUsername() + "]"); } From 7a7b75e12a0c8793eaf13f525bab9e4bbc1c038c Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 01:00:48 +0800 Subject: [PATCH 21/54] =?UTF-8?q?=E4=B8=BA/rg=E6=B7=BB=E5=8A=A0=E6=9D=83?= =?UTF-8?q?=E9=87=8D=E6=9F=A5=E8=AF=A2=E5=92=8C=E9=99=90=E5=88=B6=E9=A2=91?= =?UTF-8?q?=E7=B9=81=E7=8C=9C=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/handler/RankGuessCommandHandler.java | 16 ++++++++++++++++ .../java/xyz/zcraft/seira/rankguess/Guess.java | 5 ++++- .../seira/rankguess/RankGuessGameService.java | 13 ++++++++++++- .../zcraft/seira/rankguess/RankGuessWeights.java | 2 +- 4 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 2db5a5a2..b4205dc6 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -113,6 +113,7 @@ public void handleRankGuess(Context ctx) { end(ctx, false); return; } + if ("wish".equalsIgnoreCase(argument)) { if (ctx.argumentCount() != 1) { ctx.sendReply(PendingMessage.ofString(USAGE)); @@ -122,6 +123,15 @@ public void handleRankGuess(Context ctx) { return; } + if ("weight".equalsIgnoreCase(argument)) { + if (ctx.argumentCount() != 1) { + ctx.sendReply(PendingMessage.ofString(USAGE)); + return; + } + weight(ctx); + return; + } + Long rank; if (resolver.looksLikeMention(argument)) { @@ -145,6 +155,11 @@ public void handleRankGuess(Context ctx) { guess(ctx, rank); } + private void weight(Context ctx) { + final String string = games.generateWeights(ctx.groupId()).toString(); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "目前本群权重:\n```json\n" + string + "\n```")); + } + private void wish(Context ctx) { final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId()); @@ -324,6 +339,7 @@ private void guess(Context ctx, long rank) { PendingMessage message = switch (result.status()) { case NO_GAME -> PendingMessage.ofString("本群当前没有进行中的 Rank Guess 喵"); case STARTING -> PendingMessage.ofString("回放仍在渲染,请等待视频发送后再猜测喵"); + case TOO_SOON -> PendingMessage.ofString("距离上次猜测不足20秒,无法修改猜测喵"); case UPDATED, RECORDED -> PendingMessage.ofMarkdownRaw( at(ctx) + "已" + (result.status() == RankGuessGameService.GuessStatus.UPDATED ? "更新" : "记录") + "你的猜测:" diff --git a/src/main/java/xyz/zcraft/seira/rankguess/Guess.java b/src/main/java/xyz/zcraft/seira/rankguess/Guess.java index 8522a9e5..c2619090 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/Guess.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/Guess.java @@ -2,5 +2,8 @@ import java.util.List; -public record Guess(long rank, long sequence, List multipliers) { +public record Guess(long rank, long sequence, List multipliers, long timestamp) { + public static Guess of(long rank, long sequence, List multipliers) { + return new Guess(rank, sequence, multipliers, System.currentTimeMillis()); + } } diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 29574871..dd223384 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -225,6 +225,16 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon return GuessResponse.ofStatus(GuessStatus.STARTING); } + final Guess previousGuess = game.guesses.get(senderUserId); + + if (previousGuess != null) { + final long l = (System.currentTimeMillis() - previousGuess.timestamp()) / 1000; + + if (l < 20) { + return GuessResponse.ofStatus(GuessStatus.TOO_SOON); + } + } + final int guessNumber = game.guessCount.incrementAndGet(); LinkedList multipliers = new LinkedList<>(); @@ -276,7 +286,7 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon } } - Guess previousGuess = game.guesses.put(senderUserId, new Guess(guess, game.nextSequence++, multipliers)); + game.guesses.put(senderUserId, Guess.of(guess, game.nextSequence++, multipliers)); final GuessResult guessResult = new GuessResult( previousGuess == null ? GuessStatus.RECORDED : GuessStatus.UPDATED, @@ -348,6 +358,7 @@ public enum GuessStatus { NO_GAME, STARTING, RECORDED, + TOO_SOON, UPDATED } diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java index d1c2884c..c5ae8aae 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java @@ -10,7 +10,7 @@ public class RankGuessWeights { private static final Gson GSON = new Gson(); private static final int RECENT_USER_LIMIT = 5; - private static final double WISH_WEIGHT = 2; + private static final double WISH_WEIGHT = 2.0; private static final double RECENT_USER_WEIGHT = 0.75; private static final double SCORE_REPEAT_FACTOR = 0.75; From c85881d7139181af7142852c49a051615a379b2c Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 01:03:26 +0800 Subject: [PATCH 22/54] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=9D=83=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xyz/zcraft/seira/rankguess/RankGuessWeights.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java index c5ae8aae..8b81a736 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java @@ -10,9 +10,9 @@ public class RankGuessWeights { private static final Gson GSON = new Gson(); private static final int RECENT_USER_LIMIT = 5; - private static final double WISH_WEIGHT = 2.0; - private static final double RECENT_USER_WEIGHT = 0.75; - private static final double SCORE_REPEAT_FACTOR = 0.75; + private static final double WISH_WEIGHT = 2.5; + private static final double RECENT_USER_WEIGHT = 0.7; + private static final double SCORE_REPEAT_FACTOR = 0.7; private final Map groups = new ConcurrentHashMap<>(); From c125c6317cd268703cb52d220d6674f830328f48 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 01:08:01 +0800 Subject: [PATCH 23/54] =?UTF-8?q?=E7=A7=BB=E9=99=A4weight?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/handler/RankGuessCommandHandler.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index b4205dc6..aaed2f81 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -123,14 +123,14 @@ public void handleRankGuess(Context ctx) { return; } - if ("weight".equalsIgnoreCase(argument)) { - if (ctx.argumentCount() != 1) { - ctx.sendReply(PendingMessage.ofString(USAGE)); - return; - } - weight(ctx); - return; - } +// if ("weight".equalsIgnoreCase(argument)) { +// if (ctx.argumentCount() != 1) { +// ctx.sendReply(PendingMessage.ofString(USAGE)); +// return; +// } +// weight(ctx); +// return; +// } Long rank; From 394ca0b3f7f903e937900a2e8d4834efc828587f Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 01:09:34 +0800 Subject: [PATCH 24/54] =?UTF-8?q?=E6=81=A2=E5=A4=8D=E6=9D=83=E9=87=8D?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=8A=9F=E8=83=BD=EF=BC=8C=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?=E5=9C=A8=E6=B8=B8=E6=88=8F=E7=BB=93=E6=9D=9F=E6=97=B6=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E8=AE=B0=E5=BD=95=E5=88=86=E6=95=B0=E5=92=8C=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/handler/RankGuessCommandHandler.java | 16 ++++++++-------- .../seira/rankguess/RankGuessGameService.java | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index aaed2f81..b4205dc6 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -123,14 +123,14 @@ public void handleRankGuess(Context ctx) { return; } -// if ("weight".equalsIgnoreCase(argument)) { -// if (ctx.argumentCount() != 1) { -// ctx.sendReply(PendingMessage.ofString(USAGE)); -// return; -// } -// weight(ctx); -// return; -// } + if ("weight".equalsIgnoreCase(argument)) { + if (ctx.argumentCount() != 1) { + ctx.sendReply(PendingMessage.ofString(USAGE)); + return; + } + weight(ctx); + return; + } Long rank; diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index dd223384..5c44cf69 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -195,9 +195,6 @@ public synchronized RankGuessGame activate(Reservation reservation, Round round) game.round = round; game.guessingStartedAt = clock.instant(); - weights.recordScore(reservation.groupId(), round.scoreId()); - weights.recordUser(reservation.groupId(), round.userId()); - return game; } @@ -344,6 +341,9 @@ public synchronized EndResult end(String groupId, String senderUserId, boolean a .thenComparingDouble(Standing::error) .thenComparingLong(Standing::sequence)); + weights.recordScore(groupId, game.round.scoreId()); + weights.recordUser(groupId, game.round.userId()); + return new EndResult( EndStatus.FINISHED, new FinishedRound(game.round, List.copyOf(standings)) From 88114f44861abe04956772775c24dd964002ce4e Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 02:08:59 +0800 Subject: [PATCH 25/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9D=83=E9=87=8D?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E4=B8=80=E4=BA=9B=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seira/command/handler/RankGuessCommandHandler.java | 10 ++++++++-- .../zcraft/seira/rankguess/RankGuessGameService.java | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index b4205dc6..271ebfda 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -63,8 +63,9 @@ private static Long parseRank(String argument) { } else if (argument.endsWith("k")) { multiplier = 1000; } - return base * multiplier; - } catch (NumberFormatException _) { + + return Math.multiplyExact(base, multiplier); + } catch (NumberFormatException | ArithmeticException _) { return null; } } @@ -353,6 +354,11 @@ private void guess(Context ctx, long rank) { ctx.sendReply(PendingMessage.ofMarkdownRaw(response.message())); } + if (result.status() != RankGuessGameService.GuessStatus.UPDATED + && result.status() != RankGuessGameService.GuessStatus.RECORDED) { + return; + } + if (rank == response.game().getRound().actualRank()) { ctx.sendReply(PendingMessage.ofMarkdownRaw("看来已经有人知晓了答案喵!游戏将会自动结束~")); end(ctx, true); diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 5c44cf69..03bff225 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -49,7 +49,7 @@ public static LinkedList prepareHints(List strengthWeights(double progress) { double[] first = {50, 35, 15, 0, 0}; double[] middle = {10, 30, 45, 15, 0}; - double[] late = {0, 5, 40, 55, 0}; + double[] late = {0, 10, 40, 50, 0}; double phase = progress <= 0.5 ? progress * 2 : (progress - 0.5) * 2; From 7df3d1dcaf5f80fa40d570b7618a725257bd2d33 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 02:21:04 +0800 Subject: [PATCH 26/54] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=9D=83=E9=87=8D?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=8A=9F=E8=83=BD=EF=BC=8C=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E7=BB=93=E6=9D=9F=E6=97=B6=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=9D=83=E9=87=8D=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/xyz/zcraft/seira/bot/QQBot.java | 1 + .../seira/rankguess/RankGuessGameService.java | 14 +- .../seira/rankguess/RankGuessWeights.java | 139 ++++++++++++++++-- 3 files changed, 142 insertions(+), 12 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/bot/QQBot.java b/src/main/java/xyz/zcraft/seira/bot/QQBot.java index 348e256f..44d68ad3 100644 --- a/src/main/java/xyz/zcraft/seira/bot/QQBot.java +++ b/src/main/java/xyz/zcraft/seira/bot/QQBot.java @@ -216,6 +216,7 @@ public void close() { return; } stop(); + rankGuessGameService.saveWeights(); cos.close(); } diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 03bff225..91357215 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -17,7 +17,7 @@ public final class RankGuessGameService { private static final Duration END_PROTECTION_DURATION = Duration.ofMinutes(3); private final Map games = new HashMap<>(); - private final RankGuessWeights weights = new RankGuessWeights(); + private final RankGuessWeights weights; private final Clock clock; public RankGuessGameService() { @@ -25,7 +25,16 @@ public RankGuessGameService() { } RankGuessGameService(Clock clock) { + this(clock, new RankGuessWeights()); + } + + RankGuessGameService(Clock clock, RankGuessWeights weights) { this.clock = clock; + this.weights = weights; + } + + public void saveWeights() { + weights.saveToFile(); } static double logarithmicError(long guess, long actualRank) { @@ -341,8 +350,7 @@ public synchronized EndResult end(String groupId, String senderUserId, boolean a .thenComparingDouble(Standing::error) .thenComparingLong(Standing::sequence)); - weights.recordScore(groupId, game.round.scoreId()); - weights.recordUser(groupId, game.round.userId()); + weights.recordRound(groupId, game.round.userId(), game.round.scoreId()); return new EndResult( EndStatus.FINISHED, diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java index 8b81a736..593e33b2 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java @@ -2,12 +2,22 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; - +import com.google.gson.JsonParser; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.*; import java.util.concurrent.ConcurrentHashMap; public class RankGuessWeights { private static final Gson GSON = new Gson(); + private static final Logger LOG = LogManager.getLogger(RankGuessWeights.class); + private static final Path WEIGHTS_FILE = Path.of("data", "rank-guess-weights.json"); private static final int RECENT_USER_LIMIT = 5; private static final double WISH_WEIGHT = 2.5; @@ -15,6 +25,17 @@ public class RankGuessWeights { private static final double SCORE_REPEAT_FACTOR = 0.7; private final Map groups = new ConcurrentHashMap<>(); + private final Path store; + private final Object persistenceLock = new Object(); + + public RankGuessWeights() { + this(WEIGHTS_FILE); + } + + RankGuessWeights(Path store) { + this.store = Objects.requireNonNull(store, "store").toAbsolutePath(); + loadFromFile(); + } private static class GroupState { private final Map scoreRecords = new HashMap<>(); @@ -22,6 +43,93 @@ private static class GroupState { private final Set userWishes = new HashSet<>(); } + private record GroupSnapshot(Map scoreRecords, List userRecords, Set userWishes) { + } + + private void loadFromFile() { + if (!Files.exists(store)) { + return; + } + try { + JsonObject savedGroups = JsonParser.parseString(Files.readString(store)) + .getAsJsonObject().getAsJsonObject("groups"); + Map restored = new HashMap<>(); + for (var entry : Objects.requireNonNull(savedGroups, "Missing groups").entrySet()) { + if (entry.getKey().isBlank()) { + continue; + } + GroupSnapshot snapshot = Objects.requireNonNull( + GSON.fromJson(entry.getValue(), GroupSnapshot.class), "Missing group state"); + GroupState state = new GroupState(); + if (snapshot.scoreRecords() != null) { + snapshot.scoreRecords().forEach((scoreId, count) -> { + if (scoreId != null && scoreId > 0 && count != null && count > 0) { + state.scoreRecords.put(scoreId, count); + } + }); + } + if (snapshot.userRecords() != null) { + for (Long userId : snapshot.userRecords()) { + if (userId != null && userId > 0) { + state.userRecords.add(userId); + if (state.userRecords.size() > RECENT_USER_LIMIT) { + state.userRecords.removeFirst(); + } + } + } + } + if (snapshot.userWishes() != null) { + for (Long userId : snapshot.userWishes()) { + if (userId != null && userId > 0 && !state.userRecords.contains(userId)) { + state.userWishes.add(userId); + } + } + } + restored.put(entry.getKey(), state); + } + groups.putAll(restored); + } catch (IOException | RuntimeException e) { + LOG.warn("Failed to load rank guess weights from {}, starting with empty history", store, e); + } + } + + public void saveToFile() { + // Take the snapshot after acquiring the file lock so an older save cannot overwrite a newer one. + synchronized (persistenceLock) { + Map snapshot = new TreeMap<>(); + groups.forEach((groupId, state) -> { + synchronized (state) { + snapshot.put(groupId, new GroupSnapshot( + new TreeMap<>(state.scoreRecords), List.copyOf(state.userRecords), + new TreeSet<>(state.userWishes))); + } + }); + JsonObject data = new JsonObject(); + data.add("groups", GSON.toJsonTree(snapshot)); + Path temporary = null; + try { + Files.createDirectories(store.getParent()); + temporary = Files.createTempFile(store.getParent(), "rank-guess-weights-", ".tmp"); + Files.writeString(temporary, data.toString()); + try { + Files.move(temporary, store, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temporary, store, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + LOG.error("Failed to save rank guess weights to {}", store, e); + } finally { + if (temporary != null) { + try { + Files.deleteIfExists(temporary); + } catch (IOException e) { + LOG.warn("Failed to remove temporary rank guess weights file {}", temporary, e); + } + } + } + } + } + private GroupState getGroup(String groupId) { return groups.computeIfAbsent(groupId, _ -> new GroupState()); } @@ -32,20 +140,33 @@ public void recordScore(String groupId, long scoreId) { synchronized (state) { state.scoreRecords.merge(scoreId, 1, Integer::sum); } + saveToFile(); } public void recordUser(String groupId, long userId) { final GroupState state = getGroup(groupId); synchronized (state) { - state.userRecords.add(userId); + recordUser(state, userId); + } + saveToFile(); + } - if (state.userRecords.size() > RECENT_USER_LIMIT) { - state.userRecords.removeFirst(); - } + public void recordRound(String groupId, long userId, long scoreId) { + final GroupState state = getGroup(groupId); + synchronized (state) { + state.scoreRecords.merge(scoreId, 1, Integer::sum); + recordUser(state, userId); + } + saveToFile(); + } - state.userWishes.remove(userId); + private static void recordUser(GroupState state, long userId) { + state.userRecords.add(userId); + if (state.userRecords.size() > RECENT_USER_LIMIT) { + state.userRecords.removeFirst(); } + state.userWishes.remove(userId); } public boolean recentPicked(String groupId, long userId) { @@ -69,9 +190,9 @@ public WishResult tryWish(String groupId, long userId) { } state.userWishes.add(userId); - - return WishResult.SUCCESS; } + saveToFile(); + return WishResult.SUCCESS; } public JsonObject generateWeights(String groupId) { @@ -128,4 +249,4 @@ public Map getGroupScoreRecords(String groupId) { return Map.copyOf(state.scoreRecords); } } -} \ No newline at end of file +} From 8ec870a73ddaa316eee3659618b56fc90961707b Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 02:32:36 +0800 Subject: [PATCH 27/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E6=A0=BC=E5=BC=8F=EF=BC=8C=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=86=85=E5=AE=B9=E4=BB=A5=E6=8F=90=E5=8D=87?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zcraft/seira/command/handler/RankGuessCommandHandler.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 271ebfda..b1043e8f 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -305,12 +305,12 @@ private void start(Context ctx, boolean fromGroup) { final var hint = hints.removeFirst(); - String hintContent = "> 提示: " + hint.content(); + String hintContent = "__猜Rank提示:__\n" + hint.content(); if (!hints.isEmpty()) { hintContent += "\n" + "> 下一个提示将在 30 秒后揭晓~"; } else { - hintContent += "\n" + "> 所有提示已经揭晓!游戏将在 1 分钟后自动结束~"; + hintContent += "\n" + "> 所有提示已经揭晓啦!游戏将在 1 分钟后自动结束~"; } if (ctx.sendMessage(PendingMessage.ofMarkdownRaw(hintContent))) { From d6050faa10aee583161c0f90bd6273cd42f040db Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 02:56:50 +0800 Subject: [PATCH 28/54] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E7=A0=81=E5=92=8C=E6=8F=90=E7=A4=BA=E4=BF=A1?= =?UTF-8?q?=E6=81=AF=EF=BC=8C=E4=BC=98=E5=8C=96=E7=94=A8=E6=88=B7=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/xyz/zcraft/seira/api/ApiRequestException.java | 5 ++++- src/main/java/xyz/zcraft/seira/api/data/ErrorCode.java | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/api/ApiRequestException.java b/src/main/java/xyz/zcraft/seira/api/ApiRequestException.java index 08143fdf..b7e044fe 100644 --- a/src/main/java/xyz/zcraft/seira/api/ApiRequestException.java +++ b/src/main/java/xyz/zcraft/seira/api/ApiRequestException.java @@ -43,8 +43,11 @@ public static String getDefaultMessage(Integer code) { case BEATMAP_PARSE_FAILED -> "谱面数据解析失败,请稍后重试喵"; case SCORE_PARSE_FAILED -> "成绩数据解析失败,请稍后重试喵"; case REPLAY_PARSE_FAILED -> "回放数据解析失败,请稍后重试喵"; - case RENDER_QUEUE_FULL -> "回放渲染队列已满,请稍后再试喵"; case REPLAY_UPLOAD_FAILED -> "回放上传失败,请稍后再试喵"; + + case RENDER_QUEUE_FULL -> "回放渲染队列已满,请稍后再试喵"; + case RENDERER_UNAVAILABLE -> "回放渲染服务暂不可用,请稍后再试喵"; + case PERFORMANCE_PLUS_UNAVAILABLE -> "Performance+服务暂不可用,请稍后再试喵"; }; } diff --git a/src/main/java/xyz/zcraft/seira/api/data/ErrorCode.java b/src/main/java/xyz/zcraft/seira/api/data/ErrorCode.java index ebe91de9..15a1b9fb 100644 --- a/src/main/java/xyz/zcraft/seira/api/data/ErrorCode.java +++ b/src/main/java/xyz/zcraft/seira/api/data/ErrorCode.java @@ -30,7 +30,9 @@ public enum ErrorCode { REPLAY_PARSE_FAILED(4004), REPLAY_UPLOAD_FAILED(4005), - RENDER_QUEUE_FULL(5001); + RENDER_QUEUE_FULL(5001), + RENDERER_UNAVAILABLE(5002), + PERFORMANCE_PLUS_UNAVAILABLE(5003); private final int code; From 9c0cd3264b7d7a8544985439b00580c8469235c5 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 15:15:38 +0800 Subject: [PATCH 29/54] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E5=8F=91=E9=80=81=E9=80=BB=E8=BE=91=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=8C=9Crank=E7=BB=9F=E8=AE=A1=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 +- pom.xml | 2 +- src/main/java/xyz/zcraft/seira/Seira.java | 1 - .../seira/{runtime => }/SeiraApplication.java | 11 +- .../xyz/zcraft/seira/bot/MessageSender.java | 22 +- src/main/java/xyz/zcraft/seira/bot/QQApi.java | 10 +- src/main/java/xyz/zcraft/seira/bot/QQBot.java | 6 +- .../RealtimeServiceInterruptionNotifier.java | 2 +- .../xyz/zcraft/seira/bot/data/Message.java | 3 + .../seira/bot/data/MessageReference.java | 23 ++ .../zcraft/seira/bot/data/SentMessage.java | 14 ++ .../seira/command/CommandReplyChannel.java | 7 +- .../xyz/zcraft/seira/command/Context.java | 9 +- .../zcraft/seira/command/TaskCoordinator.java | 28 +-- .../handler/BindingCommandHandler.java | 4 +- .../command/handler/DcsCommandHandler.java | 2 +- .../MultiplayerRoomWatchCommandHandler.java | 4 +- .../handler/RankGuessCommandHandler.java | 88 ++++++- .../command/handler/ReplayCommandHandler.java | 2 +- .../command/handler/SocialCommandHandler.java | 2 +- .../SpecificScoreWatchCommandHandler.java | 2 +- .../command/handler/WatchCommandHandler.java | 4 +- .../zcraft/seira/command/parse/Resolver.java | 2 +- .../seira/command/reply/ReplyFactory.java | 31 ++- .../seira/command/route/DebugRoutes.java | 2 +- .../zcraft/seira/command/route/Router.java | 6 +- .../console/ConsoleCommandProcessor.java | 11 +- .../seira/console/ConsoleDataAccess.java | 4 +- .../seira/console/UserDataConsoleAccess.java | 7 +- .../xyz/zcraft/seira/data/SendResult.java | 9 + .../zcraft/seira/db/RankGuessRecordStore.java | 221 ++++++++++++++++ .../xyz/zcraft/seira/db/SqliteDatabase.java | 145 +++++++++++ .../seira/{binding => db}/UserDataStore.java | 235 +++++------------- .../seira/discord/DiscordBridgeService.java | 4 +- .../zcraft/seira/rankguess/FinishedRound.java | 10 +- .../zcraft/seira/rankguess/RankGuessGame.java | 9 +- .../seira/rankguess/RankGuessGameService.java | 51 ++-- .../{binding => services}/BindingService.java | 2 +- .../{security => util}/AdminRegistry.java | 2 +- .../ApplicationExecutors.java | 2 +- .../xyz/zcraft/seira/util/OsuAuthHelper.java | 2 +- .../watch/QqMultiplayerRoomNotifier.java | 4 +- .../seira/watch/SpecificScoreNotifier.java | 2 +- .../watch/SqliteSpecificScoreWatchStore.java | 2 +- .../seira/watch/WatchScoreNotifier.java | 2 +- 45 files changed, 727 insertions(+), 291 deletions(-) rename src/main/java/xyz/zcraft/seira/{runtime => }/SeiraApplication.java (92%) create mode 100644 src/main/java/xyz/zcraft/seira/bot/data/MessageReference.java create mode 100644 src/main/java/xyz/zcraft/seira/bot/data/SentMessage.java create mode 100644 src/main/java/xyz/zcraft/seira/data/SendResult.java create mode 100644 src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java create mode 100644 src/main/java/xyz/zcraft/seira/db/SqliteDatabase.java rename src/main/java/xyz/zcraft/seira/{binding => db}/UserDataStore.java (79%) rename src/main/java/xyz/zcraft/seira/{binding => services}/BindingService.java (99%) rename src/main/java/xyz/zcraft/seira/{security => util}/AdminRegistry.java (99%) rename src/main/java/xyz/zcraft/seira/{runtime => util}/ApplicationExecutors.java (98%) diff --git a/README.md b/README.md index 9e7303d6..09837902 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,11 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 image +每局结算后自动保存战绩。在群内使用 `/rg stats` 查询自己的本群战绩,使用 `/rg stats all` 查询自己的全部群聊战绩。 +统计包括参与数、获胜数、胜率、前 20% 次数和达成率、平均分、最高分、总得分及平均名次。 +每人每局只统计最终猜测,获胜按最终排行榜第一名计算;前 20% 名额向上取整,仅统计至少 5 人参与的对局,达成率以这些对局为分母。 +战绩与用户绑定共用配置中的 SQLite 数据库,分数采用最终结算得分;历史未记录的对局不会自动补入。 + ### 查询群友的排行榜! image @@ -85,7 +90,7 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 | `/ma` | `/ma [id/locId/rsN/boN] [n/#n]` | 获取指定或最近目标成绩的Miss分析;省略目标时用`#n`指定Miss | | `/u` | `/u [uid/username/@user]` | 获取指定用户信息 | | `/r` | `/r [id/locId/rsN/boN] [[mm:ss]-[mm:ss]]` | 生成并发送指定或最近目标的回放视频。省略范围时自动识别高光,使用`-`渲染整个回放 | -| `/rg` | `/rg ` | 在群聊中开始、参与或结束猜 Rank 游戏 | +| `/rg` | `/rg ` | 猜 Rank 游戏及个人战绩查询 | | `/rsc` | `/rsc [id/locId/rsN/boN] [+,...]` | 生成并发送指定或最近目标的成绩同屏回放视频;追加用户和范围顺序不限 | | `/rstat` | `/rstat [id]` | 获取视频生成进度 | | `/ms` | `/ms ` | 获取指定谱面集信息 | diff --git a/pom.xml b/pom.xml index 562b7166..a36a69f5 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira SeiraCore - 1.11.4 + 1.11.5 25 diff --git a/src/main/java/xyz/zcraft/seira/Seira.java b/src/main/java/xyz/zcraft/seira/Seira.java index 654c7493..aab9e820 100644 --- a/src/main/java/xyz/zcraft/seira/Seira.java +++ b/src/main/java/xyz/zcraft/seira/Seira.java @@ -6,7 +6,6 @@ import org.apache.logging.log4j.core.config.Configurator; import xyz.zcraft.seira.config.AppConfig; import xyz.zcraft.seira.config.ConfigLoader; -import xyz.zcraft.seira.runtime.SeiraApplication; import java.io.IOException; diff --git a/src/main/java/xyz/zcraft/seira/runtime/SeiraApplication.java b/src/main/java/xyz/zcraft/seira/SeiraApplication.java similarity index 92% rename from src/main/java/xyz/zcraft/seira/runtime/SeiraApplication.java rename to src/main/java/xyz/zcraft/seira/SeiraApplication.java index 7b3e0563..3197f83c 100644 --- a/src/main/java/xyz/zcraft/seira/runtime/SeiraApplication.java +++ b/src/main/java/xyz/zcraft/seira/SeiraApplication.java @@ -1,20 +1,21 @@ -package xyz.zcraft.seira.runtime; +package xyz.zcraft.seira; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Level; import org.apache.logging.log4j.core.config.Configurator; -import xyz.zcraft.seira.binding.BindingService; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.bot.QQBot; import xyz.zcraft.seira.console.ConsoleCommandProcessor; import xyz.zcraft.seira.console.JLineConsole; import xyz.zcraft.seira.console.UserDataConsoleAccess; import xyz.zcraft.seira.config.AppConfig; import xyz.zcraft.seira.config.RuntimeConfig; -import xyz.zcraft.seira.security.AdminRegistry; +import xyz.zcraft.seira.db.SqliteDatabase; +import xyz.zcraft.seira.util.AdminRegistry; import xyz.zcraft.seira.services.BotStat; import xyz.zcraft.seira.services.DailyLuck; +import xyz.zcraft.seira.util.ApplicationExecutors; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; @@ -31,7 +32,7 @@ public final class SeiraApplication implements AutoCloseable { public SeiraApplication(AppConfig config) { Objects.requireNonNull(config, "config"); - UserDataStore.init(config.seira().sqlitePath()); + SqliteDatabase.init(config.seira().sqlitePath()); RuntimeConfig createdRuntimeConfig = new RuntimeConfig(config); AdminRegistry createdAdmins = new AdminRegistry(config.seira().adminIds()); createdRuntimeConfig.addReloadListener(result -> { diff --git a/src/main/java/xyz/zcraft/seira/bot/MessageSender.java b/src/main/java/xyz/zcraft/seira/bot/MessageSender.java index 533cbdb5..1ceabb15 100644 --- a/src/main/java/xyz/zcraft/seira/bot/MessageSender.java +++ b/src/main/java/xyz/zcraft/seira/bot/MessageSender.java @@ -7,6 +7,8 @@ import xyz.zcraft.seira.bot.data.Message; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.api.data.QqUploadRequest; +import xyz.zcraft.seira.bot.data.SentMessage; +import xyz.zcraft.seira.data.SendResult; import xyz.zcraft.seira.data.UploadedImage; import xyz.zcraft.seira.services.CosService; import xyz.zcraft.seira.util.TokenManager; @@ -25,23 +27,21 @@ protected MessageSender(TokenManager tokenManager, CosService cos) { this.cos = cos; } - public boolean sendPrivateMessage(String userId, Message message) { + public SentMessage sendPrivateMessage(String userId, Message message) { try { - QQApi.sendPrivateMessage(tokenManager.getToken(), userId, message); - return true; + return QQApi.sendPrivateMessage(tokenManager.getToken(), userId, message); } catch (RuntimeException e) { LOG.error("Failed to send message to private {}", userId, e); - return false; + return null; } } - public boolean sendGroupMessage(String groupId, Message message) { + public SentMessage sendGroupMessage(String groupId, Message message) { try { - QQApi.sendGroupMessage(tokenManager.getToken(), groupId, message); - return true; + return QQApi.sendGroupMessage(tokenManager.getToken(), groupId, message); } catch (RuntimeException e) { LOG.error("Failed to send message to group {}", groupId, e); - return false; + return null; } } @@ -133,21 +133,21 @@ public FileInfo uploadGroupMediaBase64(String groupId, int fileType, String base } } - public boolean sendPrivateText(String userId, String content) { + public SentMessage sendPrivateText(String userId, String content) { Message message = new Message(); message.setMsgType(PendingMessage.MSG_TYPE_TEXT); message.setContent(content); return sendPrivateMessage(userId, message); } - public boolean sendGroupText(String groupId, String content) { + public SentMessage sendGroupText(String groupId, String content) { Message message = new Message(); message.setMsgType(PendingMessage.MSG_TYPE_TEXT); message.setContent(content); return sendGroupMessage(groupId, message); } - public boolean sendGroupMarkdown(String groupId, String content) { + public SentMessage sendGroupMarkdown(String groupId, String content) { Message message = new Message(); message.setMsgType(PendingMessage.MSG_TYPE_MARKDOWN); message.setMarkdown(new Gson().toJsonTree(Map.of("content", content)).getAsJsonObject()); diff --git a/src/main/java/xyz/zcraft/seira/bot/QQApi.java b/src/main/java/xyz/zcraft/seira/bot/QQApi.java index 18b3e0a1..3189b660 100644 --- a/src/main/java/xyz/zcraft/seira/bot/QQApi.java +++ b/src/main/java/xyz/zcraft/seira/bot/QQApi.java @@ -73,7 +73,7 @@ public static AccessToken getAccessToken(String appId, String appSecret) throws ); } - public static void sendPrivateMessage(AccessToken accessToken, String openId, Message message) { + public static SentMessage sendPrivateMessage(AccessToken accessToken, String openId, Message message) { try { final var request = newRequestBuilder(accessToken) .uri(URI.create(ENDPOINT + "/v2/users/" + openId + "/messages")) @@ -84,12 +84,14 @@ public static void sendPrivateMessage(AccessToken accessToken, String openId, Me if (send.statusCode() != 200) { throw new RuntimeException("Failed to send private message to " + openId + " " + send.body()); } + + return GSON.fromJson(send.body(), SentMessage.class); } catch (IOException | InterruptedException e) { throw requestFailure(e); } } - public static void sendGroupMessage(AccessToken accessToken, String groupId, Message message) { + public static SentMessage sendGroupMessage(AccessToken accessToken, String groupId, Message message) { try { final var request = newRequestBuilder(accessToken) .uri(URI.create(ENDPOINT + "/v2/groups/" + groupId + "/messages")) @@ -100,11 +102,15 @@ public static void sendGroupMessage(AccessToken accessToken, String groupId, Mes if (send.statusCode() != 200) { throw new RuntimeException("Failed to send group message to " + groupId + " " + send.body()); } + + return GSON.fromJson(send.body(), SentMessage.class); } catch (IOException | InterruptedException e) { throw requestFailure(e); } } + private static final Gson GSON = new Gson(); + public static String buildMessageJson(Message message) { final JsonObject asJsonObject = new Gson().toJsonTree(message).getAsJsonObject(); diff --git a/src/main/java/xyz/zcraft/seira/bot/QQBot.java b/src/main/java/xyz/zcraft/seira/bot/QQBot.java index 44d68ad3..f96468c4 100644 --- a/src/main/java/xyz/zcraft/seira/bot/QQBot.java +++ b/src/main/java/xyz/zcraft/seira/bot/QQBot.java @@ -8,7 +8,7 @@ import xyz.zcraft.seira.bot.data.PanelItem; import xyz.zcraft.seira.bot.data.PanelRecord; import xyz.zcraft.seira.bot.data.QQUser; -import xyz.zcraft.seira.binding.BindingService; +import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.command.AttachmentHandler; import xyz.zcraft.seira.command.route.Router; import xyz.zcraft.seira.console.ConsoleCommandProcessor; @@ -20,8 +20,8 @@ import xyz.zcraft.seira.rankguess.RankGuessGameService; import xyz.zcraft.seira.services.BotStat; import xyz.zcraft.seira.services.CosService; -import xyz.zcraft.seira.runtime.ApplicationExecutors; -import xyz.zcraft.seira.security.AdminRegistry; +import xyz.zcraft.seira.util.ApplicationExecutors; +import xyz.zcraft.seira.util.AdminRegistry; import xyz.zcraft.seira.util.TokenManager; import xyz.zcraft.seira.watch.OstellaWatchApi; import xyz.zcraft.seira.watch.OstellaMultiplayerRoomWatchApi; diff --git a/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java b/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java index 50a53400..75ca044a 100644 --- a/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java +++ b/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java @@ -30,7 +30,7 @@ public NotificationResult notifyGroups( int sent = 0; for (Map.Entry> entry : servicesByGroup.entrySet()) { - if (sender.sendGroupText(entry.getKey(), message(entry.getValue()))) { + if (sender.sendGroupText(entry.getKey(), message(entry.getValue())) != null) { sent++; } } diff --git a/src/main/java/xyz/zcraft/seira/bot/data/Message.java b/src/main/java/xyz/zcraft/seira/bot/data/Message.java index 06146246..53c2c0dd 100644 --- a/src/main/java/xyz/zcraft/seira/bot/data/Message.java +++ b/src/main/java/xyz/zcraft/seira/bot/data/Message.java @@ -36,4 +36,7 @@ public class Message { @SerializedName("is_wakeup") private Boolean isWakeup; + + @SerializedName("message_reference") + private MessageReference messageReference; } diff --git a/src/main/java/xyz/zcraft/seira/bot/data/MessageReference.java b/src/main/java/xyz/zcraft/seira/bot/data/MessageReference.java new file mode 100644 index 00000000..d08a74ec --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/bot/data/MessageReference.java @@ -0,0 +1,23 @@ +package xyz.zcraft.seira.bot.data; + +import com.google.gson.annotations.SerializedName; + +import java.util.Optional; + +public record MessageReference( + @SerializedName("message_id") String messageId +) { + public static MessageReference of(SentMessage message) { + return Optional.ofNullable(message) + .map(SentMessage::extInfo) + .map(SentMessage.MessageExtInfo::refIdx) + .map(MessageReference::new) + .orElse(null); + } + + public static MessageReference of(String messageId) { + return Optional.ofNullable(messageId) + .map(MessageReference::new) + .orElse(null); + } +} diff --git a/src/main/java/xyz/zcraft/seira/bot/data/SentMessage.java b/src/main/java/xyz/zcraft/seira/bot/data/SentMessage.java new file mode 100644 index 00000000..c196e9ea --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/bot/data/SentMessage.java @@ -0,0 +1,14 @@ +package xyz.zcraft.seira.bot.data; + +import com.google.gson.annotations.SerializedName; + +public record SentMessage( + String id, + String timestamp, + @SerializedName("ext_info") MessageExtInfo extInfo +) { + public record MessageExtInfo( + @SerializedName("ref_idx") String refIdx + ) { + } +} diff --git a/src/main/java/xyz/zcraft/seira/command/CommandReplyChannel.java b/src/main/java/xyz/zcraft/seira/command/CommandReplyChannel.java index 8a8e3fa5..5b9228fd 100644 --- a/src/main/java/xyz/zcraft/seira/command/CommandReplyChannel.java +++ b/src/main/java/xyz/zcraft/seira/command/CommandReplyChannel.java @@ -1,6 +1,7 @@ package xyz.zcraft.seira.command; import xyz.zcraft.seira.bot.data.PendingMessage; +import xyz.zcraft.seira.data.SendResult; /** * The outbound side of one command invocation. @@ -10,9 +11,9 @@ * not consume the passive reply sequence.

*/ public interface CommandReplyChannel { - boolean sendReply(PendingMessage message); + SendResult sendReply(PendingMessage message); - boolean sendProactive(PendingMessage message); + SendResult sendProactive(PendingMessage message); - boolean sendQueueNotice(PendingMessage message); + SendResult sendQueueNotice(PendingMessage message); } diff --git a/src/main/java/xyz/zcraft/seira/command/Context.java b/src/main/java/xyz/zcraft/seira/command/Context.java index 58273bc9..ccc13d70 100644 --- a/src/main/java/xyz/zcraft/seira/command/Context.java +++ b/src/main/java/xyz/zcraft/seira/command/Context.java @@ -1,6 +1,7 @@ package xyz.zcraft.seira.command; import xyz.zcraft.seira.bot.data.PendingMessage; +import xyz.zcraft.seira.data.SendResult; import java.util.Objects; @@ -64,22 +65,22 @@ public Context asCommand(String nextCommand, String[] nextArgs, String nextQuery /** * Sends a passive reply associated with the message that invoked this command. */ - public boolean sendReply(PendingMessage message) { + public SendResult sendReply(PendingMessage message) { return requireReplies().sendReply(Objects.requireNonNull(message, "message")); } - public boolean sendReply(String message) { + public SendResult sendReply(String message) { return requireReplies().sendReply(PendingMessage.ofString(message)); } /** * Sends an active message to the same user or group, without an inbound message reference. */ - public boolean sendMessage(PendingMessage message) { + public SendResult sendMessage(PendingMessage message) { return requireReplies().sendProactive(Objects.requireNonNull(message, "message")); } - public boolean sendQueueNotice(PendingMessage message) { + public SendResult sendQueueNotice(PendingMessage message) { return requireReplies().sendQueueNotice(Objects.requireNonNull(message, "message")); } diff --git a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java index 33472ff3..e5dabfa0 100644 --- a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java +++ b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java @@ -10,10 +10,8 @@ import xyz.zcraft.seira.api.data.QqUploadRequest; import xyz.zcraft.seira.api.data.Response; import xyz.zcraft.seira.bot.MessageSender; -import xyz.zcraft.seira.bot.data.FileInfo; -import xyz.zcraft.seira.bot.data.MDMessage; -import xyz.zcraft.seira.bot.data.Message; -import xyz.zcraft.seira.bot.data.PendingMessage; +import xyz.zcraft.seira.bot.data.*; +import xyz.zcraft.seira.data.SendResult; import xyz.zcraft.seira.data.UploadedImage; import xyz.zcraft.seira.discord.DiscordBridgeService; import xyz.zcraft.seira.services.ApiRequestStats; @@ -144,7 +142,7 @@ public void runReplayRequest( return; } - if (ctx.sendReply(replayVideoMessage(result))) { + if (ctx.sendReply(replayVideoMessage(result)).success()) { removeReplayResult(taskInfo.taskId()); } }); @@ -180,7 +178,7 @@ private PendingMessage combineImageAndCompletion(UploadedImage image, PendingMes ); } - public boolean sendOutboundMessage(String targetId, String messageId, boolean groupMessage, PendingMessage pendingMsg, AtomicInteger messageSeqCounter) { + public SendResult sendOutboundMessage(String targetId, String messageId, boolean groupMessage, PendingMessage pendingMsg, AtomicInteger messageSeqCounter) { Message message = new Message(); message.setMsgType(pendingMsg.getMsgType()); message.setMsgId(messageId); @@ -232,21 +230,21 @@ public boolean sendOutboundMessage(String targetId, String messageId, boolean gr } } - boolean sendResult; + SentMessage sentMessage; if (groupMessage) { - sendResult = messageSender.sendGroupMessage(targetId, message); + sentMessage = messageSender.sendGroupMessage(targetId, message); } else { - sendResult = messageSender.sendPrivateMessage(targetId, message); + sentMessage = messageSender.sendPrivateMessage(targetId, message); } - if (sendResult && groupMessage) { + if (sentMessage != null && groupMessage) { PendingMessage portableResult = uploadResult ? pendingMsg : PendingMessage.ofString("媒体文件上传失败"); discordBridgeService.acceptQqCommandReply(targetId, portableResult); } - return uploadResult && sendResult; + return new SendResult(uploadResult && sentMessage != null, sentMessage); } private final class OutboundReplyChannel implements CommandReplyChannel { @@ -269,19 +267,19 @@ private OutboundReplyChannel( } @Override - public synchronized boolean sendReply(PendingMessage message) { + public synchronized SendResult sendReply(PendingMessage message) { return sendOutboundMessage(targetId, messageId, groupMessage, message, passiveSequence); } @Override - public synchronized boolean sendProactive(PendingMessage message) { + public synchronized SendResult sendProactive(PendingMessage message) { return sendOutboundMessage(targetId, null, groupMessage, message, null); } @Override - public synchronized boolean sendQueueNotice(PendingMessage message) { + public synchronized SendResult sendQueueNotice(PendingMessage message) { if (groupMessage && !queueMessageInGroup) { - return true; + return new SendResult(true, null); } return sendReply(message); } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java index 08a7a126..266d4954 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java @@ -1,7 +1,7 @@ package xyz.zcraft.seira.command.handler; -import xyz.zcraft.seira.binding.BindingService; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.services.BindingService; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.reply.ReplyFactory; diff --git a/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java index a5b2a698..1782038c 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java @@ -44,7 +44,7 @@ private void handleStart(Context ctx) { return; } - final boolean b = ctx.sendMessage(PendingMessage.ofString("正在尝试开启 Discord 消息同步,请稍候...")); + final boolean b = ctx.sendMessage(PendingMessage.ofString("正在尝试开启 Discord 消息同步,请稍候...")).success(); if (!b) { ctx.sendReply(PendingMessage.ofMarkdownRaw("由于缺少主动消息权限,无法添加消息同步!权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)~")); return; diff --git a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java index 8a0b5406..a8136b05 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java @@ -4,7 +4,7 @@ import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.data.OsuToken; import xyz.zcraft.seira.api.data.Response; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.ResolutionException; @@ -86,7 +86,7 @@ private void handleStart(Context ctx, int argumentOffset) { } taskCoordinator.runApiRequest(ctx, "Start Multiplayer Room Watch", () -> { - if (!ctx.sendMessage(PendingMessage.ofString("正在尝试启动多人房间监视……"))) { + if (!ctx.sendMessage(PendingMessage.ofString("正在尝试启动多人房间监视……")).success()) { ctx.sendReply(PendingMessage.ofString( "由于缺少主动消息权限,无法启动监视!权限配置请见:https://docs.seira.top/overview/use.html#extra-permission" )); diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index b1043e8f..7d3801f5 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -4,7 +4,8 @@ import org.apache.logging.log4j.Logger; import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.data.RandomScore; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.UserDataStore; +import xyz.zcraft.seira.db.RankGuessRecordStore; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.TaskCoordinator; @@ -27,7 +28,7 @@ public final class RankGuessCommandHandler { private static final Logger LOG = LogManager.getLogger(RankGuessCommandHandler.class); - private static final String USAGE = "用法:/rg start | /rg group | /rg #Rank | /rg end | /rg wish"; + private static final String USAGE = "用法:/rg start | /rg group | /rg #Rank | /rg end | /rg wish | /rg stats [all]"; private static final Pattern RANK_PATTERN = Pattern.compile("^#?(\\d+)[wk]?$"); private final TaskCoordinator taskCoordinator; private final ReplyFactory replyFactory; @@ -82,6 +83,16 @@ public void handleRankGuess(Context ctx) { } String argument = ctx.argument(0); + if ("stats".equalsIgnoreCase(argument)) { + if (ctx.argumentCount() == 1) { + statistics(ctx, false); + } else if (ctx.argumentCount() == 2 && "all".equalsIgnoreCase(ctx.argument(1))) { + statistics(ctx, true); + } else { + ctx.sendReply(PendingMessage.ofString(USAGE)); + } + return; + } if ("start".equalsIgnoreCase(argument)) { if (ctx.argumentCount() == 2) { final String arg = ctx.argument(1); @@ -161,6 +172,49 @@ private void weight(Context ctx) { ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "目前本群权重:\n```json\n" + string + "\n```")); } + private void statistics(Context ctx, boolean allGroups) { + try { + RankGuessRecordStore.Statistics statistics = RankGuessRecordStore.getStatistics( + ctx.senderUserId(), + allGroups ? null : ctx.groupId() + ); + + ctx.sendReply(replyFactory.rankGuessStatisticsMessage(ctx, statistics, allGroups, getRank(statistics))); + } catch (RuntimeException e) { + LOG.error("Failed to query rank guess statistics", e); + ctx.sendReply(PendingMessage.ofString("战绩查询失败,请稍后重试喵。")); + } + } + + private String getRank(RankGuessRecordStore.Statistics statistics) { + if (statistics.participation() < 3) { + return "?"; + } + + double averageScoreRate = Math.clamp( + statistics.averageScore() / 1000.0, + 0.0, 1.0 + ); + + double rawRating = + averageScoreRate * 0.50 + + statistics.winRate() * 0.20 + + statistics.topTwentyRate() * 0.30; + + double confidence = 1.0 - Math.exp(-statistics.participation() / 10.0); + + double rating = 0.50 * (1.0 - confidence) + rawRating * confidence; + + + if (rating >= 0.90) return "SS"; + if (rating >= 0.82) return "S"; + if (rating >= 0.74) return "A"; + if (rating >= 0.64) return "B"; + if (rating >= 0.52) return "C"; + if (rating >= 0.40) return "D"; + return "F"; + } + private void wish(Context ctx) { final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId()); @@ -180,7 +234,7 @@ private void wish(Context ctx) { } private void start(Context ctx, boolean fromGroup) { - RankGuessGameService.Reservation reservation = games.reserve(ctx.groupId(), ctx.senderUserId()); + RankGuessGameService.Reservation reservation = games.reserve(ctx.groupId(), ctx.senderUserId(), fromGroup); if (reservation == null) { ctx.sendReply(PendingMessage.ofString("本群已有一轮 Rank Guess 正在进行。")); return; @@ -192,7 +246,7 @@ private void start(Context ctx, boolean fromGroup) { "Rank Guess Render", () -> { final PendingMessage message = PendingMessage.ofMarkdownRaw(at(ctx) + "正在选定随机成绩..."); - final boolean activeMessageEnabled = ctx.sendMessage(message); + final boolean activeMessageEnabled = ctx.sendMessage(message).success(); if (!activeMessageEnabled) { ctx.sendReply(message); } @@ -244,7 +298,7 @@ private void start(Context ctx, boolean fromGroup) { return; } - boolean videoSent = ctx.sendReply(taskCoordinator.replayVideoMessage(replay)); + boolean videoSent = ctx.sendReply(taskCoordinator.replayVideoMessage(replay)).success(); if (!videoSent) { taskCoordinator.removeReplayResult(renderTask.taskId()); ctx.sendReply(PendingMessage.ofMarkdownRaw("由于回放发送失败,本轮游戏已取消~")); @@ -277,7 +331,7 @@ private void start(Context ctx, boolean fromGroup) { } else { result.append("\n").append("> 第一个提示将在 1 分钟后揭晓~"); } - boolean startMessageSent = ctx.sendReply(PendingMessage.ofMarkdownRaw(result.toString().trim())); + boolean startMessageSent = ctx.sendReply(PendingMessage.ofMarkdownRaw(result.toString().trim())).success(); if (!activeMessageEnabled) { if (startMessageSent) { @@ -288,6 +342,8 @@ private void start(Context ctx, boolean fromGroup) { boolean firstHint = true; + StringBuilder hintString = new StringBuilder(); + while (!hints.isEmpty() && !game.isEnded()) { try { //noinspection BusyWait @@ -305,7 +361,9 @@ private void start(Context ctx, boolean fromGroup) { final var hint = hints.removeFirst(); - String hintContent = "__猜Rank提示:__\n" + hint.content(); + hintString.insert(0, "- " + hint.content() + "\n"); + + String hintContent = "__猜Rank提示:__\n" + hintString; if (!hints.isEmpty()) { hintContent += "\n" + "> 下一个提示将在 30 秒后揭晓~"; @@ -313,7 +371,7 @@ private void start(Context ctx, boolean fromGroup) { hintContent += "\n" + "> 所有提示已经揭晓啦!游戏将在 1 分钟后自动结束~"; } - if (ctx.sendMessage(PendingMessage.ofMarkdownRaw(hintContent))) { + if (ctx.sendMessage(PendingMessage.ofMarkdownRaw(hintContent)).success()) { game.revealHint(hint); } } @@ -366,9 +424,15 @@ private void guess(Context ctx, long rank) { } private void end(Context ctx, boolean force) { - RankGuessGameService.EndResult result = games.end( - ctx.groupId(), ctx.senderUserId(), adminAuthorizer.test(ctx.senderUserId()), force - ); + RankGuessGameService.EndResult result; + try { + result = games.end(ctx.groupId(), ctx.senderUserId(), adminAuthorizer.test(ctx.senderUserId()), force); + } catch (RankGuessRecordStore.RecordSaveException e) { + LOG.error("Failed to record rank guess round in group {}", ctx.groupId(), e); + PendingMessage failure = PendingMessage.ofString("战绩保存失败,本轮尚未结算,请稍后使用 /rg end 重试喵。"); + if (!ctx.sendReply(failure).success()) ctx.sendMessage(failure); + return; + } PendingMessage message = switch (result.status()) { case NO_GAME -> PendingMessage.ofString("本群当前没有进行中的 Rank Guess 喵"); @@ -379,7 +443,7 @@ private void end(Context ctx, boolean force) { case FINISHED -> replyFactory.rankGuessResultMessage(ctx, result.round()); }; - if (!ctx.sendReply(message)) { + if (!ctx.sendReply(message).success()) { ctx.sendMessage(message); } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java index 33c1ec8c..143d2084 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java @@ -164,7 +164,7 @@ public void handleRstat(Context ctx) { PendingMessage video = replayResult.qqFile() != null ? PendingMessage.ofUploadedVideo(replayResult.qqFile(), replayResult.videoUrl()) : PendingMessage.ofVideoUrl(replayResult.videoUrl()); - if (ctx.sendReply(video)) { + if (ctx.sendReply(video).success()) { replayResults.remove(jobId); } return; diff --git a/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java index a062fbaf..1411c5d2 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java @@ -6,7 +6,7 @@ import xyz.zcraft.seira.api.data.FriendEntry; import xyz.zcraft.seira.api.data.OsuToken; import xyz.zcraft.seira.api.data.Response; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.*; import xyz.zcraft.seira.command.parse.Resolver; diff --git a/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java index 4721ac0b..f25f07f0 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java @@ -54,7 +54,7 @@ private void handleStart(Context ctx) { } taskCoordinator.runApiRequest(ctx, "Start Specific Score Watch", () -> { - if (!ctx.sendMessage(PendingMessage.ofString("正在尝试启动指定谱面成绩监视……"))) { + if (!ctx.sendMessage(PendingMessage.ofString("正在尝试启动指定谱面成绩监视……")).success()) { ctx.sendReply(PendingMessage.ofString( "由于缺少主动消息权限,无法启动监视!权限配置请见:https://docs.seira.top/overview/use.html#extra-permission" )); diff --git a/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java index 08a054d1..1d705ab9 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java @@ -2,7 +2,7 @@ import xyz.zcraft.osu.model.User; import xyz.zcraft.seira.api.APIHelper; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.ResolutionException; @@ -104,7 +104,7 @@ private void handleAdd(Context ctx) { WatchTarget target = targetResolver.apply(ctx.groupId(), targetArgument); final boolean b = ctx.sendMessage(PendingMessage.ofMarkdownRaw( at(ctx) + "正在尝试添加监视..." - )); + )).success(); if (!b) { ctx.sendReply(PendingMessage.ofMarkdownRaw("由于缺少主动消息权限,无法添加监视!权限配置请见[这里](https://docs.seira.top/overview/use.html#extra-permission)~")); return; diff --git a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java index 2f5eec36..37c5dd21 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java @@ -1,7 +1,7 @@ package xyz.zcraft.seira.command.parse; import xyz.zcraft.seira.api.data.SearchQuery; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.data.UserRef; import java.util.*; diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index e3323160..958293fe 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -7,8 +7,9 @@ import xyz.zcraft.osu.model.*; import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.data.*; -import xyz.zcraft.seira.binding.BindingService; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.services.BindingService; +import xyz.zcraft.seira.db.UserDataStore; +import xyz.zcraft.seira.db.RankGuessRecordStore; import xyz.zcraft.seira.bot.data.Button; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; @@ -115,6 +116,30 @@ public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result) return PendingMessage.ofMarkdownRaw(content.toString().trim()); } + public PendingMessage rankGuessStatisticsMessage( + Context ctx, RankGuessRecordStore.Statistics statistics, boolean allGroups, String rank + ) { + String scope = allGroups ? "全部群聊" : "本群"; + if (statistics.participation() == 0) { + return PendingMessage.ofMarkdownRaw(at(ctx) + "你在" + scope + "还没有已结算的猜 Rank 战绩喵~"); + } + + String rankText = "?".equals(rank) ? "" : "根据你的总体表现,可以给到一个 `%s` 喵!".formatted(rank); + return PendingMessage.ofMarkdownRaw(at(ctx) + String.format(Locale.ROOT, """ + 你的猜 Rank 战绩(%s) + > 总参与数:`%d` + > 获胜数:`%d`,胜率:`%.2f%%` + > 前20%%次数:`%d`,达成率:`%.2f%%` + > 平均分:`%.2f`,最高分:`%.2f` + > 总得分:`%.2f`,平均名次:`%.2f` + + %s + """, scope, statistics.participation(), statistics.wins(), statistics.winRate() * 100, + statistics.topTwentyCount(), statistics.topTwentyRate() * 100, + statistics.averageScore(), statistics.highestScore(), statistics.totalScore(), + statistics.averagePlacement(), rankText).strip()); + } + public PendingMessage boMessage(Context ctx, Response response) { return PendingMessage.ofMarkdownRaw( at(ctx) + "查询完成,共" + response.getScoreIds().size() + "个成绩\n" + @@ -616,7 +641,7 @@ public static String helpContent(Context ctx) { > /bma <谱面ID或快捷查询> [Mod] - 分析谱面PP构成和类型 > /ms <谱面集ID或快捷查询> - 获取谱面集 > /r [成绩ID或快捷查询] [[mm:ss]-[mm:ss]] - 生成成绩高光视频或指定片段 - > /rg - 在群聊中进行猜 Rank 游戏 + > /rg - 猜 Rank 游戏与个人战绩 > /lb <谱面ID> [玩家ID列表] - 获取指定谱面排行榜 > /watch add <玩家ID/用户名/@用户> [分钟] - 监视群友的新成绩 > /wx start <谱面ID列表> - 监视指定玩家在指定谱面的成绩 diff --git a/src/main/java/xyz/zcraft/seira/command/route/DebugRoutes.java b/src/main/java/xyz/zcraft/seira/command/route/DebugRoutes.java index b520a59a..a9ced51f 100644 --- a/src/main/java/xyz/zcraft/seira/command/route/DebugRoutes.java +++ b/src/main/java/xyz/zcraft/seira/command/route/DebugRoutes.java @@ -8,7 +8,7 @@ import xyz.zcraft.seira.api.data.FriendEntry; import xyz.zcraft.seira.api.data.OsuToken; import xyz.zcraft.seira.api.data.Response; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.bot.data.FileInfo; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.bot.MessageSender; diff --git a/src/main/java/xyz/zcraft/seira/command/route/Router.java b/src/main/java/xyz/zcraft/seira/command/route/Router.java index 87e831a7..aa62f862 100644 --- a/src/main/java/xyz/zcraft/seira/command/route/Router.java +++ b/src/main/java/xyz/zcraft/seira/command/route/Router.java @@ -5,8 +5,8 @@ import org.apache.logging.log4j.Logger; import xyz.zcraft.seira.api.data.OsuToken; import xyz.zcraft.seira.api.data.VideoRenderRecord; -import xyz.zcraft.seira.binding.UserDataStore; -import xyz.zcraft.seira.binding.BindingService; +import xyz.zcraft.seira.db.UserDataStore; +import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.bot.MessageSender; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.*; @@ -17,7 +17,7 @@ import xyz.zcraft.seira.config.AppConfig; import xyz.zcraft.seira.discord.DiscordBridgeService; import xyz.zcraft.seira.rankguess.RankGuessGameService; -import xyz.zcraft.seira.security.AdminRegistry; +import xyz.zcraft.seira.util.AdminRegistry; import xyz.zcraft.seira.util.OsuAuthHelper; import xyz.zcraft.seira.watch.ScoreWatchService; import xyz.zcraft.seira.watch.MultiplayerRoomWatchService; diff --git a/src/main/java/xyz/zcraft/seira/console/ConsoleCommandProcessor.java b/src/main/java/xyz/zcraft/seira/console/ConsoleCommandProcessor.java index b67feedb..d68c1134 100644 --- a/src/main/java/xyz/zcraft/seira/console/ConsoleCommandProcessor.java +++ b/src/main/java/xyz/zcraft/seira/console/ConsoleCommandProcessor.java @@ -4,13 +4,14 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.core.config.Configurator; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.SqliteDatabase; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.bot.MessageSender; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.route.Router; import xyz.zcraft.seira.config.AppConfig; import xyz.zcraft.seira.config.RuntimeConfig; -import xyz.zcraft.seira.security.AdminRegistry; +import xyz.zcraft.seira.util.AdminRegistry; import xyz.zcraft.seira.services.BotStat; import xyz.zcraft.seira.watch.WatchView; @@ -469,8 +470,8 @@ private ConsoleResult send(ConsoleInputParser.ParsedInput input) { } boolean sent = switch (targetType) { - case "group" -> messenger.sendGroupText(targetId, content); - case "private" -> messenger.sendPrivateText(targetId, content); + case "group" -> messenger.sendGroupText(targetId, content) != null; + case "private" -> messenger.sendPrivateText(targetId, content) != null; default -> throw new IllegalArgumentException("Message type must be 'group' or 'private'."); }; return sent @@ -671,7 +672,7 @@ private static String formatWatches(String groupId, List watches) { return output.toString(); } - private static String formatQueryResult(UserDataStore.QueryResult result) { + private static String formatQueryResult(SqliteDatabase.QueryResult result) { StringBuilder output = new StringBuilder(); output.append(result.columns().stream().map(ConsoleCommandProcessor::cell).reduce( (left, right) -> left + " | " + right diff --git a/src/main/java/xyz/zcraft/seira/console/ConsoleDataAccess.java b/src/main/java/xyz/zcraft/seira/console/ConsoleDataAccess.java index 80b7fc2c..f015049a 100644 --- a/src/main/java/xyz/zcraft/seira/console/ConsoleDataAccess.java +++ b/src/main/java/xyz/zcraft/seira/console/ConsoleDataAccess.java @@ -1,9 +1,9 @@ package xyz.zcraft.seira.console; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.SqliteDatabase; public interface ConsoleDataAccess { - UserDataStore.QueryResult query(String sql, int maxRows); + SqliteDatabase.QueryResult query(String sql, int maxRows); int boundUsers(); diff --git a/src/main/java/xyz/zcraft/seira/console/UserDataConsoleAccess.java b/src/main/java/xyz/zcraft/seira/console/UserDataConsoleAccess.java index 032346e4..1a712fa2 100644 --- a/src/main/java/xyz/zcraft/seira/console/UserDataConsoleAccess.java +++ b/src/main/java/xyz/zcraft/seira/console/UserDataConsoleAccess.java @@ -1,11 +1,12 @@ package xyz.zcraft.seira.console; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.SqliteDatabase; +import xyz.zcraft.seira.db.UserDataStore; public final class UserDataConsoleAccess implements ConsoleDataAccess { @Override - public UserDataStore.QueryResult query(String sql, int maxRows) { - return UserDataStore.queryReadOnly(sql, maxRows); + public SqliteDatabase.QueryResult query(String sql, int maxRows) { + return SqliteDatabase.queryReadOnly(sql, maxRows); } @Override diff --git a/src/main/java/xyz/zcraft/seira/data/SendResult.java b/src/main/java/xyz/zcraft/seira/data/SendResult.java new file mode 100644 index 00000000..678f7805 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/data/SendResult.java @@ -0,0 +1,9 @@ +package xyz.zcraft.seira.data; + +import xyz.zcraft.seira.bot.data.SentMessage; + +public record SendResult( + boolean success, + SentMessage sentMessage +) { +} diff --git a/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java b/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java new file mode 100644 index 00000000..841e214c --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java @@ -0,0 +1,221 @@ +package xyz.zcraft.seira.db; + +import xyz.zcraft.seira.rankguess.FinishedRound; +import xyz.zcraft.seira.rankguess.Standing; + +import java.sql.*; +import java.util.Objects; + +public class RankGuessRecordStore { + public static final int TOP_TWENTY_MIN_PARTICIPANTS = 1; + + /** Returns false when this round has already been saved. All rows are committed together. */ + public static boolean save(FinishedRound finished) { + validate(finished); + String gameSql = """ + INSERT INTO rank_guess_games ( + round_id, group_id, source_mode, target_user_id, target_score_id, actual_rank, + started_at, ended_at, participant_count, scoring_version + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(round_id) DO NOTHING + """; + String resultSql = """ + INSERT INTO rank_guess_results ( + round_id, user_id, guessed_rank, placement, raw_score, multiplier, final_score + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """; + try (Connection connection = SqliteDatabase.getConnection()) { + connection.setAutoCommit(false); + try { + String roundId = finished.id().toString(); + try (PreparedStatement statement = connection.prepareStatement(gameSql)) { + statement.setString(1, roundId); + statement.setString(2, finished.groupId()); + statement.setString(3, finished.fromGroup() ? "group" : "random"); + statement.setLong(4, finished.round().userId()); + statement.setLong(5, finished.round().scoreId()); + statement.setLong(6, finished.round().actualRank()); + statement.setLong(7, finished.startedAt().toEpochMilli()); + statement.setLong(8, finished.endedAt().toEpochMilli()); + statement.setInt(9, finished.standings().size()); + statement.setInt(10, finished.scoringVersion()); + if (statement.executeUpdate() == 0) { + connection.rollback(); + return false; + } + } + try (PreparedStatement statement = connection.prepareStatement(resultSql)) { + for (int i = 0; i < finished.standings().size(); i++) { + Standing result = finished.standings().get(i); + statement.setString(1, roundId); + statement.setString(2, result.senderUserId()); + statement.setLong(3, result.guess()); + statement.setInt(4, i + 1); + statement.setDouble(5, result.pointsRaw()); + statement.setDouble(6, result.multiplier()); + statement.setDouble(7, result.points()); + statement.addBatch(); + } + statement.executeBatch(); + } + connection.commit(); + return true; + } catch (SQLException | RuntimeException e) { + try { + connection.rollback(); + } catch (SQLException rollbackFailure) { + e.addSuppressed(rollbackFailure); + } + throw e; + } + } catch (SQLException e) { + throw new RecordSaveException("Failed to save rank guess round " + finished.id(), e); + } + } + + public static Statistics getStatistics(String userId) { + return getStatistics(userId, null); + } + + /** A null groupId includes this user's records from every group. */ + public static Statistics getStatistics(String userId, String groupId) { + return getStatistics(userId, groupId, null); + } + + public static Statistics getStatistics(String userId, String groupId, Integer scoringVersion) { + requireText(userId, "userId"); + if (groupId != null) requireText(groupId, "groupId"); + if (scoringVersion != null && scoringVersion < 1) { + throw new IllegalArgumentException("scoringVersion must be positive"); + } + String sql = """ + SELECT COUNT(*) AS participation, + COALESCE(SUM(CASE WHEN r.placement = 1 THEN 1 ELSE 0 END), 0) AS wins, + COALESCE(SUM(CASE WHEN g.participant_count >= ? + AND r.placement <= (g.participant_count + 4) / 5 THEN 1 ELSE 0 END), 0) AS top_twenty, + COALESCE(SUM(CASE WHEN g.participant_count >= ? THEN 1 ELSE 0 END), 0) AS top_twenty_eligible, + COALESCE(SUM(r.final_score), 0) AS total_score, + COALESCE(AVG(r.final_score), 0) AS average_score, + COALESCE(MAX(r.final_score), 0) AS highest_score, + COALESCE(AVG(r.placement), 0) AS average_placement + FROM rank_guess_results r + JOIN rank_guess_games g ON g.round_id = r.round_id + WHERE r.user_id = ? + """; + if (groupId != null) sql += " AND g.group_id = ?"; + if (scoringVersion != null) sql += " AND g.scoring_version = ?"; + try (Connection connection = SqliteDatabase.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, TOP_TWENTY_MIN_PARTICIPANTS); + statement.setInt(2, TOP_TWENTY_MIN_PARTICIPANTS); + statement.setString(3, userId); + int index = 4; + if (groupId != null) statement.setString(index++, groupId); + if (scoringVersion != null) statement.setInt(index, scoringVersion); + try (ResultSet result = statement.executeQuery()) { + result.next(); + return new Statistics( + result.getLong("participation"), result.getLong("wins"), + result.getLong("top_twenty"), result.getLong("top_twenty_eligible"), + result.getDouble("total_score"), result.getDouble("average_score"), + result.getDouble("highest_score"), result.getDouble("average_placement") + ); + } + } catch (SQLException e) { + throw new IllegalStateException("Failed to query rank guess statistics", e); + } + } + + public record Statistics( + long participation, long wins, long topTwentyCount, long topTwentyEligibleParticipation, + double totalScore, double averageScore, double highestScore, double averagePlacement + ) { + public double winRate() { + return participation == 0 ? 0 : wins / (double) participation; + } + + public double topTwentyRate() { + return topTwentyEligibleParticipation == 0 ? 0 + : topTwentyCount / (double) topTwentyEligibleParticipation; + } + } + + public static final class RecordSaveException extends RuntimeException { + public RecordSaveException(String message, Throwable cause) { + super(message, cause); + } + } + + private static void validate(FinishedRound finished) { + Objects.requireNonNull(finished, "finished"); + Objects.requireNonNull(finished.id(), "id"); + Objects.requireNonNull(finished.round(), "round"); + Objects.requireNonNull(finished.startedAt(), "startedAt"); + Objects.requireNonNull(finished.endedAt(), "endedAt"); + requireText(finished.groupId(), "groupId"); + if (finished.endedAt().isBefore(finished.startedAt()) || finished.scoringVersion() < 1) { + throw new IllegalArgumentException("Invalid round timestamps or scoring version"); + } + for (Standing result : finished.standings()) { + requireText(result.senderUserId(), "userId"); + if (result.guess() <= 0 || !Double.isFinite(result.pointsRaw()) || result.pointsRaw() < 0 + || !Double.isFinite(result.multiplier()) || result.multiplier() < 0 + || !Double.isFinite(result.points()) || result.points() < 0) { + throw new IllegalArgumentException("Invalid rank guess result"); + } + } + } + + private static void requireText(String value, String name) { + if (value == null || value.isBlank()) throw new IllegalArgumentException(name + " must not be blank"); + } + + public static void createTablesIfNeeded(Connection connection) throws SQLException { + String rgGameSql = """ + CREATE TABLE IF NOT EXISTS rank_guess_games ( + round_id TEXT PRIMARY KEY, + group_id TEXT NOT NULL, + source_mode TEXT NOT NULL + CHECK (source_mode IN ('random', 'group')), + + target_user_id INTEGER NOT NULL, + target_score_id INTEGER NOT NULL, + actual_rank INTEGER NOT NULL CHECK (actual_rank > 0), + + started_at INTEGER NOT NULL, + ended_at INTEGER NOT NULL, + participant_count INTEGER NOT NULL CHECK (participant_count >= 0), + scoring_version INTEGER NOT NULL DEFAULT 1 + ); + """; + String rgResultSql = """ + CREATE TABLE IF NOT EXISTS rank_guess_results ( + round_id TEXT NOT NULL, + user_id TEXT NOT NULL, + + guessed_rank INTEGER NOT NULL CHECK (guessed_rank > 0), + placement INTEGER NOT NULL CHECK (placement > 0), + raw_score REAL NOT NULL, + multiplier REAL NOT NULL, + final_score REAL NOT NULL, + + PRIMARY KEY (round_id, user_id), + FOREIGN KEY (round_id) REFERENCES rank_guess_games(round_id) + ); + """; + String rgResultIndex = """ + CREATE INDEX IF NOT EXISTS idx_rg_results_user + ON rank_guess_results(user_id, round_id); + """; + String rgGameIndex = """ + CREATE INDEX IF NOT EXISTS idx_rg_games_group_time + ON rank_guess_games(group_id, ended_at); + """; + try (Statement statement = connection.createStatement()) { + statement.execute(rgGameSql); + statement.execute(rgResultSql); + statement.execute(rgResultIndex); + statement.execute(rgGameIndex); + } + } +} diff --git a/src/main/java/xyz/zcraft/seira/db/SqliteDatabase.java b/src/main/java/xyz/zcraft/seira/db/SqliteDatabase.java new file mode 100644 index 00000000..da430df7 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/db/SqliteDatabase.java @@ -0,0 +1,145 @@ +package xyz.zcraft.seira.db; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public class SqliteDatabase { + private static final Logger LOG = LogManager.getLogger(SqliteDatabase.class); + private static final Object INIT_LOCK = new Object(); + + private static volatile String jdbcUrl; + private static volatile Path initializedDbPath; + + public static void init(String sqlitePath) { + synchronized (INIT_LOCK) { + if (jdbcUrl != null && initializedDbPath != null && Files.exists(initializedDbPath)) { + return; + } + try { + Path dbPath = Path.of(sqlitePath).toAbsolutePath().normalize(); + Path parent = dbPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + String createdUrl = "jdbc:sqlite:" + dbPath; + try (Connection connection = openConnection(createdUrl)) { + connection.setAutoCommit(false); + UserDataStore.createTablesIfNeeded(connection); + RankGuessRecordStore.createTablesIfNeeded(connection); + connection.commit(); + } + initializedDbPath = dbPath; + jdbcUrl = createdUrl; + + LOG.info("SQLite database initialized at {}", dbPath); + } catch (Exception e) { + throw new IllegalStateException("Failed to initialize sqlite store: " + sqlitePath, e); + } + } + } + + public static Connection getConnection() throws SQLException { + ensureInitialized(); + return openConnection(jdbcUrl); + } + + private static Connection openConnection(String url) throws SQLException { + Connection connection = DriverManager.getConnection(url); + try (Statement statement = connection.createStatement()) { + statement.execute("PRAGMA foreign_keys = ON"); + statement.execute("PRAGMA busy_timeout = 5000"); + } catch (SQLException e) { + connection.close(); + throw e; + } + return connection; + } + + public static void ensureInitialized() { + if (jdbcUrl == null) { + throw new IllegalStateException("SQLite database is not initialized"); + } + } + + public record QueryResult(List columns, List> rows, boolean truncated) { + } + + public static QueryResult queryReadOnly(String sql, int maxRows) { + SqliteDatabase.ensureInitialized(); + if (sql == null || sql.isBlank()) { + throw new IllegalArgumentException("SQL query must not be blank"); + } + if (maxRows < 1 || maxRows > 200) { + throw new IllegalArgumentException("maxRows must be between 1 and 200"); + } + + String statementSql = sql.strip(); + if (statementSql.endsWith(";")) { + statementSql = statementSql.substring(0, statementSql.length() - 1).stripTrailing(); + } + if (statementSql.contains(";")) { + throw new IllegalArgumentException("Multiple SQL statements are not allowed"); + } + String normalized = statementSql.toLowerCase(Locale.ROOT); + String keyword = normalized.split("\\s+", 2)[0]; + if (!List.of("select", "with", "pragma", "explain").contains(keyword)) { + throw new IllegalArgumentException("Only SELECT, WITH, PRAGMA and EXPLAIN queries are allowed"); + } + if ("pragma".equals(keyword) && !isAllowedReadOnlyPragma(normalized)) { + throw new IllegalArgumentException("This PRAGMA is not allowed in the read-only console"); + } + + try (Connection connection = SqliteDatabase.getConnection(); + Statement queryOnly = connection.createStatement(); + Statement statement = connection.createStatement()) { + queryOnly.execute("PRAGMA query_only = ON"); + statement.setMaxRows(maxRows + 1); + statement.setQueryTimeout(10); + // SQL is intentionally accepted from the trusted local console. The + // SQLite connection is query-only and the result size is bounded. + //noinspection SqlSourceToSinkFlow + try (ResultSet resultSet = statement.executeQuery(statementSql)) { + ResultSetMetaData metadata = resultSet.getMetaData(); + int columnCount = metadata.getColumnCount(); + List columns = new ArrayList<>(columnCount); + for (int index = 1; index <= columnCount; index++) { + columns.add(metadata.getColumnLabel(index)); + } + + List> rows = new ArrayList<>(); + boolean truncated = false; + while (resultSet.next()) { + if (rows.size() == maxRows) { + truncated = true; + break; + } + List row = new ArrayList<>(columnCount); + for (int index = 1; index <= columnCount; index++) { + row.add(resultSet.getString(index)); + } + rows.add(List.copyOf(row)); + } + return new QueryResult(List.copyOf(columns), List.copyOf(rows), truncated); + } + } catch (SQLException e) { + throw new RuntimeException("Failed to execute read-only SQL query", e); + } + } + + private static boolean isAllowedReadOnlyPragma(String normalizedSql) { + String pragma = normalizedSql.substring("pragma".length()).stripLeading(); + int separator = pragma.indexOf('('); + String name = (separator >= 0 ? pragma.substring(0, separator) : pragma).strip(); + return List.of( + "table_info", "table_xinfo", "table_list", "index_list", "index_info", + "index_xinfo", "foreign_key_list", "database_list", "compile_options" + ).contains(name); + } +} diff --git a/src/main/java/xyz/zcraft/seira/binding/UserDataStore.java b/src/main/java/xyz/zcraft/seira/db/UserDataStore.java similarity index 79% rename from src/main/java/xyz/zcraft/seira/binding/UserDataStore.java rename to src/main/java/xyz/zcraft/seira/db/UserDataStore.java index f3a878cb..68b28137 100644 --- a/src/main/java/xyz/zcraft/seira/binding/UserDataStore.java +++ b/src/main/java/xyz/zcraft/seira/db/UserDataStore.java @@ -1,4 +1,4 @@ -package xyz.zcraft.seira.binding; +package xyz.zcraft.seira.db; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -7,8 +7,6 @@ import xyz.zcraft.seira.util.OsuAuthHelper; import xyz.zcraft.seira.watch.SpecificScoreWatchState; -import java.nio.file.Files; -import java.nio.file.Path; import java.sql.*; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -22,34 +20,9 @@ public final class UserDataStore { private static final Logger LOG = LogManager.getLogger(UserDataStore.class); - private static final Object INIT_LOCK = new Object(); - - private static volatile String jdbcUrl; - private static volatile Path initializedDbPath; - - public static void init(String sqlitePath) { - synchronized (INIT_LOCK) { - if (jdbcUrl != null && initializedDbPath != null && Files.exists(initializedDbPath)) { - return; - } - try { - Path dbPath = Path.of(sqlitePath).toAbsolutePath().normalize(); - Path parent = dbPath.getParent(); - if (parent != null) { - Files.createDirectories(parent); - } - jdbcUrl = "jdbc:sqlite:" + dbPath; - initializedDbPath = dbPath; - createTablesIfNeeded(); - LOG.info("SQLite binding store initialized at {}", dbPath); - } catch (Exception e) { - throw new IllegalStateException("Failed to initialize sqlite store: " + sqlitePath, e); - } - } - } public static void bind(String openId, long osuUid) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); long now = System.currentTimeMillis(); String sql = """ INSERT INTO user_bindings(open_id, osu_uid, created_at, updated_at) @@ -58,7 +31,7 @@ ON CONFLICT(open_id) DO UPDATE SET osu_uid = excluded.osu_uid, updated_at = excluded.updated_at """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, openId); statement.setLong(2, osuUid); @@ -71,7 +44,7 @@ ON CONFLICT(open_id) DO UPDATE SET } public static void storeToken(String openId, OsuToken token) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = """ INSERT INTO token_store(open_id, access_token, refresh_token, expires_in, refreshed_at) VALUES(?, ?, ?, ?, ?) @@ -81,7 +54,7 @@ ON CONFLICT(open_id) DO UPDATE SET expires_in = excluded.expires_in, refreshed_at = excluded.refreshed_at """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, openId); statement.setString(2, token.accessToken()); @@ -95,12 +68,12 @@ ON CONFLICT(open_id) DO UPDATE SET } public static void removeToken(String openId) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = """ DELETE FROM token_store WHERE open_id = ? """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, openId); statement.executeUpdate(); @@ -110,14 +83,14 @@ public static void removeToken(String openId) { } public static void storeUserInfo(long osuId, String username) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = """ INSERT INTO user_info(uid, username) VALUES(?, ?) ON CONFLICT(uid) DO UPDATE SET username = excluded.username """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setLong(1, osuId); statement.setString(2, username); @@ -128,12 +101,12 @@ ON CONFLICT(uid) DO UPDATE SET } public static Optional findUsername(long osuId) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = """ SELECT username FROM user_info WHERE uid = ? """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setLong(1, osuId); final ResultSet resultSet = statement.executeQuery(); @@ -147,9 +120,9 @@ public static Optional findUsername(long osuId) { } public static Long findBoundUid(String openId) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = "SELECT osu_uid FROM user_bindings WHERE open_id = ?"; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, openId); try (ResultSet resultSet = statement.executeQuery()) { @@ -164,9 +137,9 @@ public static Long findBoundUid(String openId) { } public static OsuToken findOsuToken(String openId) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = "SELECT access_token, refresh_token, expires_in, refreshed_at FROM token_store WHERE open_id = ?"; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, openId); try (ResultSet resultSet = statement.executeQuery()) { @@ -186,10 +159,10 @@ public static OsuToken findOsuToken(String openId) { } public static List getAllOsuTokens() { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); List tokens = new LinkedList<>(); String sql = "SELECT open_id, access_token, refresh_token, expires_in, refreshed_at FROM token_store"; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); Statement statement = connection.createStatement(); final ResultSet rs = statement.executeQuery(sql)) { while (rs.next()) { @@ -212,10 +185,10 @@ public static List getAllOsuTokens() { } public static List findFollower(long uid) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); List followers = new ArrayList<>(); String sql = "SELECT self FROM user_follows WHERE followed = ?"; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setLong(1, uid); try (ResultSet resultSet = statement.executeQuery()) { @@ -231,13 +204,13 @@ public static List findFollower(long uid) { } public static void storeFollowed(long selfId, long followed) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = """ INSERT INTO user_follows(self, followed) VALUES(?, ?) ON CONFLICT(self, followed) DO NOTHING """; - try (Connection connection = DriverManager.getConnection(jdbcUrl)) { + try (Connection connection = SqliteDatabase.getConnection()) { PreparedStatement statement = connection.prepareStatement(sql); statement.setLong(1, selfId); statement.setLong(2, followed); @@ -248,13 +221,13 @@ ON CONFLICT(self, followed) DO NOTHING } public static void removeFollowed(long selfId, long followed) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = """ DELETE FROM user_follows WHERE self = ? AND followed = ?; """; - try (Connection connection = DriverManager.getConnection(jdbcUrl)) { + try (Connection connection = SqliteDatabase.getConnection()) { PreparedStatement statement = connection.prepareStatement(sql); statement.setLong(1, selfId); statement.setLong(2, followed); @@ -265,12 +238,12 @@ public static void removeFollowed(long selfId, long followed) { } public static int clearFollowed(long selfId) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = """ DELETE FROM user_follows WHERE self = ?; """; - try (Connection connection = DriverManager.getConnection(jdbcUrl)) { + try (Connection connection = SqliteDatabase.getConnection()) { PreparedStatement statement = connection.prepareStatement(sql); statement.setLong(1, selfId); return statement.executeUpdate(); @@ -281,13 +254,13 @@ public static int clearFollowed(long selfId) { @SuppressWarnings("BooleanMethodIsAlwaysInverted") public static boolean haveFollowed(long selfId, long followed) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = """ SELECT * FROM user_follows WHERE self = ? AND followed = ?; """; - try (Connection connection = DriverManager.getConnection(jdbcUrl)) { + try (Connection connection = SqliteDatabase.getConnection()) { PreparedStatement statement = connection.prepareStatement(sql); statement.setLong(1, selfId); statement.setLong(2, followed); @@ -301,10 +274,10 @@ public static boolean haveFollowed(long selfId, long followed) { } public static boolean unbind(String openId) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String bindingSql = "DELETE FROM user_bindings WHERE open_id = ?"; String tokenSql = "DELETE FROM token_store WHERE open_id = ?"; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement bindingStatement = connection.prepareStatement(bindingSql); PreparedStatement tokenStatement = connection.prepareStatement(tokenSql)) { connection.setAutoCommit(false); @@ -332,7 +305,7 @@ public static boolean unbind(String openId) { } public static void upsertGroupMember(String groupId, String openId) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); long now = System.currentTimeMillis(); String sql = """ INSERT INTO group_members(group_id, open_id, updated_at) @@ -340,7 +313,7 @@ INSERT INTO group_members(group_id, open_id, updated_at) ON CONFLICT(group_id, open_id) DO UPDATE SET updated_at = excluded.updated_at """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, groupId); statement.setString(2, openId); @@ -352,9 +325,9 @@ ON CONFLICT(group_id, open_id) DO UPDATE SET } public static int clearGroupMember(String openId) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = "DELETE FROM group_members WHERE open_id = ?"; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, openId); return statement.executeUpdate(); @@ -364,7 +337,7 @@ public static int clearGroupMember(String openId) { } public static List findBoundUidsByGroup(String groupId) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = """ SELECT DISTINCT ub.osu_uid FROM group_members gm @@ -374,7 +347,7 @@ public static List findBoundUidsByGroup(String groupId) { ORDER BY ub.osu_uid """; List uids = new ArrayList<>(); - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, groupId); try (ResultSet resultSet = statement.executeQuery()) { @@ -389,9 +362,9 @@ public static List findBoundUidsByGroup(String groupId) { } public static boolean isGroupMember(String groupId, String openId) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = "SELECT 1 FROM group_members WHERE group_id = ? AND open_id = ?"; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, groupId); statement.setString(2, openId); @@ -404,7 +377,7 @@ public static boolean isGroupMember(String groupId, String openId) { } public static Optional findGroupOpenIdByUid(String groupId, long osuUid) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = """ SELECT gm.open_id FROM group_members gm @@ -415,7 +388,7 @@ public static Optional findGroupOpenIdByUid(String groupId, long osuUid) ORDER BY gm.updated_at DESC LIMIT 1 """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, groupId); statement.setLong(2, osuUid); @@ -430,7 +403,7 @@ public static Optional findGroupOpenIdByUid(String groupId, long osuUid) } public static void upsertDiscordBridge(DiscordBridgeMapping mapping) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = """ INSERT INTO discord_bridges(group_id, guild_id, channel_id, updated_at) VALUES(?, ?, ?, ?) @@ -439,7 +412,7 @@ ON CONFLICT(group_id) DO UPDATE SET channel_id = excluded.channel_id, updated_at = excluded.updated_at """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, mapping.groupId()); statement.setString(2, mapping.guildId()); @@ -452,9 +425,9 @@ ON CONFLICT(group_id) DO UPDATE SET } public static boolean removeDiscordBridge(String groupId) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = "DELETE FROM discord_bridges WHERE group_id = ?"; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setString(1, groupId); return statement.executeUpdate() > 0; @@ -464,10 +437,10 @@ public static boolean removeDiscordBridge(String groupId) { } public static List findAllDiscordBridges() { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = "SELECT group_id, guild_id, channel_id FROM discord_bridges ORDER BY group_id"; List mappings = new ArrayList<>(); - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql); ResultSet resultSet = statement.executeQuery()) { while (resultSet.next()) { @@ -484,7 +457,7 @@ public static List findAllDiscordBridges() { } public static void saveSpecificScoreWatch(SpecificScoreWatchState state) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String upsertWatch = """ INSERT INTO specific_score_watches(group_id, updated_at) VALUES(?, ?) @@ -500,7 +473,7 @@ INSERT INTO specific_score_watch_users(group_id, user_id, last_score_id) INSERT INTO specific_score_watch_beatmaps(group_id, beatmap_id) VALUES(?, ?) """; - try (Connection connection = DriverManager.getConnection(jdbcUrl)) { + try (Connection connection = SqliteDatabase.getConnection()) { connection.setAutoCommit(false); try (PreparedStatement watchStatement = connection.prepareStatement(upsertWatch); PreparedStatement deleteUsersStatement = connection.prepareStatement(deleteUsers); @@ -546,8 +519,8 @@ INSERT INTO specific_score_watch_beatmaps(group_id, beatmap_id) } public static boolean removeSpecificScoreWatch(String groupId) { - ensureInitialized(); - try (Connection connection = DriverManager.getConnection(jdbcUrl)) { + SqliteDatabase.ensureInitialized(); + try (Connection connection = SqliteDatabase.getConnection()) { connection.setAutoCommit(false); try (PreparedStatement users = connection.prepareStatement( "DELETE FROM specific_score_watch_users WHERE group_id = ?"); @@ -573,13 +546,13 @@ public static boolean removeSpecificScoreWatch(String groupId) { } public static void updateSpecificScoreWatchCursor(String groupId, long userId, long scoreId) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = """ UPDATE specific_score_watch_users SET last_score_id = ? WHERE group_id = ? AND user_id = ? """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setLong(1, scoreId); statement.setString(2, groupId); @@ -593,7 +566,7 @@ public static void updateSpecificScoreWatchCursor(String groupId, long userId, l } public static List findAllSpecificScoreWatches() { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); Map> usersByGroup = new LinkedHashMap<>(); Map> beatmapsByGroup = new LinkedHashMap<>(); Map> cursorsByGroup = new LinkedHashMap<>(); @@ -608,7 +581,7 @@ public static List findAllSpecificScoreWatches() { FROM specific_score_watch_beatmaps ORDER BY group_id, beatmap_id """; - try (Connection connection = DriverManager.getConnection(jdbcUrl)) { + try (Connection connection = SqliteDatabase.getConnection()) { try (Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(groupsSql)) { while (resultSet.next()) { @@ -658,10 +631,10 @@ public static List findAllSpecificScoreWatches() { } public static int countBoundUser() { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = "SELECT COUNT(*) AS count FROM user_bindings"; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); Statement statement = connection.createStatement()) { try (ResultSet resultSet = statement.executeQuery(sql)) { if (resultSet.next()) { @@ -676,10 +649,10 @@ public static int countBoundUser() { } public static int countGroups() { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); String sql = "SELECT COUNT(DISTINCT group_id) AS count FROM group_members"; - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); Statement statement = connection.createStatement()) { try (ResultSet resultSet = statement.executeQuery(sql)) { if (resultSet.next()) { @@ -694,9 +667,9 @@ public static int countGroups() { } public static String executeQueryOrEdit(String sql) { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); - try (Connection c = DriverManager.getConnection(jdbcUrl); + try (Connection c = SqliteDatabase.getConnection(); Statement stmt = c.createStatement()) { // This method should be invoked only in debug context. @@ -735,80 +708,8 @@ public static String executeQueryOrEdit(String sql) { } } - public static QueryResult queryReadOnly(String sql, int maxRows) { - ensureInitialized(); - if (sql == null || sql.isBlank()) { - throw new IllegalArgumentException("SQL query must not be blank"); - } - if (maxRows < 1 || maxRows > 200) { - throw new IllegalArgumentException("maxRows must be between 1 and 200"); - } - - String statementSql = sql.strip(); - if (statementSql.endsWith(";")) { - statementSql = statementSql.substring(0, statementSql.length() - 1).stripTrailing(); - } - if (statementSql.contains(";")) { - throw new IllegalArgumentException("Multiple SQL statements are not allowed"); - } - String normalized = statementSql.toLowerCase(Locale.ROOT); - String keyword = normalized.split("\\s+", 2)[0]; - if (!List.of("select", "with", "pragma", "explain").contains(keyword)) { - throw new IllegalArgumentException("Only SELECT, WITH, PRAGMA and EXPLAIN queries are allowed"); - } - if ("pragma".equals(keyword) && !isAllowedReadOnlyPragma(normalized)) { - throw new IllegalArgumentException("This PRAGMA is not allowed in the read-only console"); - } - - try (Connection connection = DriverManager.getConnection(jdbcUrl); - Statement queryOnly = connection.createStatement(); - Statement statement = connection.createStatement()) { - queryOnly.execute("PRAGMA query_only = ON"); - statement.setMaxRows(maxRows + 1); - statement.setQueryTimeout(10); - // SQL is intentionally accepted from the trusted local console. The - // SQLite connection is query-only and the result size is bounded. - //noinspection SqlSourceToSinkFlow - try (ResultSet resultSet = statement.executeQuery(statementSql)) { - ResultSetMetaData metadata = resultSet.getMetaData(); - int columnCount = metadata.getColumnCount(); - List columns = new ArrayList<>(columnCount); - for (int index = 1; index <= columnCount; index++) { - columns.add(metadata.getColumnLabel(index)); - } - - List> rows = new ArrayList<>(); - boolean truncated = false; - while (resultSet.next()) { - if (rows.size() == maxRows) { - truncated = true; - break; - } - List row = new ArrayList<>(columnCount); - for (int index = 1; index <= columnCount; index++) { - row.add(resultSet.getString(index)); - } - rows.add(List.copyOf(row)); - } - return new QueryResult(List.copyOf(columns), List.copyOf(rows), truncated); - } - } catch (SQLException e) { - throw new RuntimeException("Failed to execute read-only SQL query", e); - } - } - - private static boolean isAllowedReadOnlyPragma(String normalizedSql) { - String pragma = normalizedSql.substring("pragma".length()).stripLeading(); - int separator = pragma.indexOf('('); - String name = (separator >= 0 ? pragma.substring(0, separator) : pragma).strip(); - return List.of( - "table_info", "table_xinfo", "table_list", "index_list", "index_info", - "index_xinfo", "foreign_key_list", "database_list", "compile_options" - ).contains(name); - } - public static List findAllUsers() { - ensureInitialized(); + SqliteDatabase.ensureInitialized(); List result = new LinkedList<>(); @@ -820,7 +721,7 @@ public static List findAllUsers() { }; for (String sql : queries) { - try (Connection connection = DriverManager.getConnection(jdbcUrl); + try (Connection connection = SqliteDatabase.getConnection(); Statement statement = connection.createStatement(); ResultSet rs = statement.executeQuery(sql)) { while (rs.next()) { @@ -835,7 +736,7 @@ public static List findAllUsers() { return result.stream().distinct().toList(); } - private static void createTablesIfNeeded() throws SQLException { + public static void createTablesIfNeeded(Connection connection) throws SQLException { String bindingSql = """ CREATE TABLE IF NOT EXISTS user_bindings ( open_id TEXT NOT NULL, @@ -917,8 +818,7 @@ CREATE TABLE IF NOT EXISTS specific_score_watch_beatmaps ( PRIMARY KEY(group_id, beatmap_id) ) """; - try (Connection connection = DriverManager.getConnection(jdbcUrl); - Statement statement = connection.createStatement()) { + try (Statement statement = connection.createStatement()) { statement.execute(bindingSql); statement.execute(groupMemberSql); statement.execute(tokenStoreSql); @@ -932,14 +832,5 @@ PRIMARY KEY(group_id, beatmap_id) statement.execute(specificScoreWatchBeatmapSql); } } - - private static void ensureInitialized() { - if (jdbcUrl == null) { - throw new IllegalStateException("UserBindingStore is not initialized"); - } - } - - public record QueryResult(List columns, List> rows, boolean truncated) { - } } diff --git a/src/main/java/xyz/zcraft/seira/discord/DiscordBridgeService.java b/src/main/java/xyz/zcraft/seira/discord/DiscordBridgeService.java index 8ca57626..83a8e1b8 100644 --- a/src/main/java/xyz/zcraft/seira/discord/DiscordBridgeService.java +++ b/src/main/java/xyz/zcraft/seira/discord/DiscordBridgeService.java @@ -13,7 +13,7 @@ import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.bot.MessageSender; import xyz.zcraft.seira.bot.data.Attachment; import xyz.zcraft.seira.bot.data.FileInfo; @@ -291,7 +291,7 @@ private void sendPreparedQqMedia(String groupId, PreparedQqMedia item) { Message outbound = new Message(); outbound.setMsgType(PendingMessage.MSG_TYPE_MEDIA); outbound.setMedia(item.uploaded()); - if (!qqSender.sendGroupMessage(groupId, outbound)) { + if (qqSender.sendGroupMessage(groupId, outbound) == null) { qqSender.sendGroupText(groupId, "[媒体发送到 QQ 失败: " + item.media().filename() + "]"); } } diff --git a/src/main/java/xyz/zcraft/seira/rankguess/FinishedRound.java b/src/main/java/xyz/zcraft/seira/rankguess/FinishedRound.java index 19c3d969..0781f9d4 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/FinishedRound.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/FinishedRound.java @@ -1,6 +1,14 @@ package xyz.zcraft.seira.rankguess; +import java.time.Instant; import java.util.List; +import java.util.UUID; -public record FinishedRound(RankGuessGameService.Round round, List standings) { +public record FinishedRound( + UUID id, String groupId, boolean fromGroup, Instant startedAt, Instant endedAt, + int scoringVersion, RankGuessGameService.Round round, List standings +) { + public FinishedRound { + standings = List.copyOf(standings); + } } diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java index a33a80c1..fdde5640 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java @@ -10,6 +10,7 @@ public final class RankGuessGame { public final UUID token; public final String starterUserId; + public final boolean fromGroup; public final Map guesses = new LinkedHashMap<>(); public boolean copyPunishmentReduced = false; public final AtomicInteger guessCount = new AtomicInteger(0); @@ -20,9 +21,10 @@ public final class RankGuessGame { @Getter private volatile boolean ended = false; - RankGuessGame(UUID token, String starterUserId) { + RankGuessGame(UUID token, String starterUserId, boolean fromGroup) { this.token = token; this.starterUserId = starterUserId; + this.fromGroup = fromGroup; } public void markEnded() { @@ -44,13 +46,14 @@ public synchronized List getRevealedHints() { return List.copyOf(revealedHints); } + public static final int COPY_PUNISHMENT_THRESHOLD = 8; public double getMultiplierDelta(RankGuessGameService.ScoreMultiplier multiplier) { if (multiplier instanceof RankGuessGameService.ScoreMultiplier.FirstGuessMultiplier) { return 0.05; } else if (multiplier instanceof RankGuessGameService.ScoreMultiplier.OrderMultiplier orderMultiplier) { return Math.max(-0.10, 0.00 - (orderMultiplier.getOrder() - 2) * 0.01); } else if (multiplier instanceof RankGuessGameService.ScoreMultiplier.CopyPunishmentMultiplier) { - if (guessCount.get() >= 10) { + if (guessCount.get() >= COPY_PUNISHMENT_THRESHOLD) { return -0.025; } else { return -0.05; @@ -82,7 +85,7 @@ public String getMultipliersString(List mu .append(": ") .append(String.format( Locale.US, - "%+.0f%%", + "%+.2f%%", this.getMultiplierDelta(multiplier) * 100 )) .append("\n"); diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 91357215..61ce6558 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -6,19 +6,25 @@ import xyz.zcraft.osu.model.Score; import xyz.zcraft.osu.model.UserExtended; import xyz.zcraft.seira.api.data.RandomScore; +import xyz.zcraft.seira.db.RankGuessRecordStore; import java.time.Clock; import java.time.Duration; import java.util.*; import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; import java.util.random.RandomGenerator; +import static xyz.zcraft.seira.rankguess.RankGuessGame.COPY_PUNISHMENT_THRESHOLD; + public final class RankGuessGameService { + public static final int SCORING_VERSION = 1; private static final Duration END_PROTECTION_DURATION = Duration.ofMinutes(3); private final Map games = new HashMap<>(); private final RankGuessWeights weights; private final Clock clock; + private final Consumer recordWriter; public RankGuessGameService() { this(Clock.systemUTC()); @@ -29,8 +35,13 @@ public RankGuessGameService() { } RankGuessGameService(Clock clock, RankGuessWeights weights) { + this(clock, weights, RankGuessRecordStore::save); + } + + RankGuessGameService(Clock clock, RankGuessWeights weights, Consumer recordWriter) { this.clock = clock; this.weights = weights; + this.recordWriter = Objects.requireNonNull(recordWriter); } public void saveWeights() { @@ -183,12 +194,16 @@ public static String getGlobalRankRange(long l) { } public synchronized Reservation reserve(String groupId, String starterUserId) { + return reserve(groupId, starterUserId, false); + } + + public synchronized Reservation reserve(String groupId, String starterUserId, boolean fromGroup) { if (games.containsKey(groupId)) { return null; } Reservation reservation = new Reservation(groupId, UUID.randomUUID()); - games.put(groupId, new RankGuessGame(reservation.token(), starterUserId)); + games.put(groupId, new RankGuessGame(reservation.token(), starterUserId, fromGroup)); return reservation; } @@ -270,28 +285,24 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon if (closestGuess != -1) { double absoluteDifference = Math.abs(closestGuess - guess); - double relativeDifference = absoluteDifference - / (double) Math.max(closestGuess, guess); + double relativeDifference = absoluteDifference / (double) Math.max(closestGuess, guess); boolean copied = (absoluteDifference <= 100 && relativeDifference <= 0.01) - || Math.abs( - Math.log10(closestGuess) - - Math.log10(guess) - ) < 0.005; - - final int i = game.guesses.size(); - - if (i >= 10 && !game.copyPunishmentReduced) { - game.copyPunishmentReduced = true; - message = "提示:由于本次游戏参与人数较多,所有猜测的抄袭惩罚已降至 `-2.5%` ~"; - } + || Math.abs(Math.log10(closestGuess) - Math.log10(guess)) < 0.005; if (copied) { multipliers.add(new ScoreMultiplier.CopyPunishmentMultiplier()); } } + final int i = game.guesses.size(); + + if (i >= COPY_PUNISHMENT_THRESHOLD && !game.copyPunishmentReduced) { + game.copyPunishmentReduced = true; + message = "提示:由于本次游戏参与人数较多,所有猜测的抄袭惩罚已降至 `-2.5%` ~"; + } + game.guesses.put(senderUserId, Guess.of(guess, game.nextSequence++, multipliers)); final GuessResult guessResult = new GuessResult( @@ -320,8 +331,6 @@ public synchronized EndResult end(String groupId, String senderUserId, boolean a return new EndResult(EndStatus.FORBIDDEN, null); } - games.remove(groupId); - game.markEnded(); List standings = new ArrayList<>(game.guesses.size()); for (Map.Entry entry : game.guesses.entrySet()) { Guess guess = entry.getValue(); @@ -350,11 +359,19 @@ public synchronized EndResult end(String groupId, String senderUserId, boolean a .thenComparingDouble(Standing::error) .thenComparingLong(Standing::sequence)); + FinishedRound finished = new FinishedRound( + game.token, groupId, game.fromGroup, game.guessingStartedAt, clock.instant(), + SCORING_VERSION, game.round, standings + ); + // Keep the game available for retry if recording fails; no history should advance before commit. + recordWriter.accept(finished); + games.remove(groupId); + game.markEnded(); weights.recordRound(groupId, game.round.userId(), game.round.scoreId()); return new EndResult( EndStatus.FINISHED, - new FinishedRound(game.round, List.copyOf(standings)) + finished ); } diff --git a/src/main/java/xyz/zcraft/seira/binding/BindingService.java b/src/main/java/xyz/zcraft/seira/services/BindingService.java similarity index 99% rename from src/main/java/xyz/zcraft/seira/binding/BindingService.java rename to src/main/java/xyz/zcraft/seira/services/BindingService.java index 175b73a1..50ffca77 100644 --- a/src/main/java/xyz/zcraft/seira/binding/BindingService.java +++ b/src/main/java/xyz/zcraft/seira/services/BindingService.java @@ -1,4 +1,4 @@ -package xyz.zcraft.seira.binding; +package xyz.zcraft.seira.services; import io.javalin.Javalin; import io.javalin.http.Context; diff --git a/src/main/java/xyz/zcraft/seira/security/AdminRegistry.java b/src/main/java/xyz/zcraft/seira/util/AdminRegistry.java similarity index 99% rename from src/main/java/xyz/zcraft/seira/security/AdminRegistry.java rename to src/main/java/xyz/zcraft/seira/util/AdminRegistry.java index 32031700..f0ff7fdd 100644 --- a/src/main/java/xyz/zcraft/seira/security/AdminRegistry.java +++ b/src/main/java/xyz/zcraft/seira/util/AdminRegistry.java @@ -1,4 +1,4 @@ -package xyz.zcraft.seira.security; +package xyz.zcraft.seira.util; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/src/main/java/xyz/zcraft/seira/runtime/ApplicationExecutors.java b/src/main/java/xyz/zcraft/seira/util/ApplicationExecutors.java similarity index 98% rename from src/main/java/xyz/zcraft/seira/runtime/ApplicationExecutors.java rename to src/main/java/xyz/zcraft/seira/util/ApplicationExecutors.java index ddc67977..59def38c 100644 --- a/src/main/java/xyz/zcraft/seira/runtime/ApplicationExecutors.java +++ b/src/main/java/xyz/zcraft/seira/util/ApplicationExecutors.java @@ -1,4 +1,4 @@ -package xyz.zcraft.seira.runtime; +package xyz.zcraft.seira.util; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/src/main/java/xyz/zcraft/seira/util/OsuAuthHelper.java b/src/main/java/xyz/zcraft/seira/util/OsuAuthHelper.java index c96dec7e..adc4e5c4 100644 --- a/src/main/java/xyz/zcraft/seira/util/OsuAuthHelper.java +++ b/src/main/java/xyz/zcraft/seira/util/OsuAuthHelper.java @@ -4,7 +4,7 @@ import org.apache.logging.log4j.Logger; import xyz.zcraft.seira.api.OsuAuthApi; import xyz.zcraft.seira.api.data.OsuToken; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.config.BindingConfig; public class OsuAuthHelper { diff --git a/src/main/java/xyz/zcraft/seira/watch/QqMultiplayerRoomNotifier.java b/src/main/java/xyz/zcraft/seira/watch/QqMultiplayerRoomNotifier.java index 255af82e..bd2f6639 100644 --- a/src/main/java/xyz/zcraft/seira/watch/QqMultiplayerRoomNotifier.java +++ b/src/main/java/xyz/zcraft/seira/watch/QqMultiplayerRoomNotifier.java @@ -29,7 +29,7 @@ public boolean sendResult(String groupId, byte[] imageBytes) { Message message = new Message(); message.setMsgType(PendingMessage.MSG_TYPE_MEDIA); message.setMedia(media); - return messageSender.sendGroupMessage(groupId, message); + return messageSender.sendGroupMessage(groupId, message) != null; } @Override @@ -37,6 +37,6 @@ public boolean sendRoomEnded(String groupId, RoomWatchSnapshot snapshot) { return messageSender.sendGroupText( groupId, "多人房间“" + snapshot.roomName() + "” (#" + snapshot.roomId() + ") 已结束,监视已自动停止。" - ); + ) != null; } } diff --git a/src/main/java/xyz/zcraft/seira/watch/SpecificScoreNotifier.java b/src/main/java/xyz/zcraft/seira/watch/SpecificScoreNotifier.java index 40097be9..1f6404cd 100644 --- a/src/main/java/xyz/zcraft/seira/watch/SpecificScoreNotifier.java +++ b/src/main/java/xyz/zcraft/seira/watch/SpecificScoreNotifier.java @@ -14,6 +14,6 @@ public SpecificScoreNotifier(MessageSender messageSender) { } public boolean sendScoreId(String groupId, long scoreId) { - return messageSender.sendGroupMarkdown(groupId, "捕获到成绩 ID:" + cmd("/s " + scoreId, String.valueOf(scoreId))); + return messageSender.sendGroupMarkdown(groupId, "捕获到成绩 ID:" + cmd("/s " + scoreId, String.valueOf(scoreId))) != null; } } diff --git a/src/main/java/xyz/zcraft/seira/watch/SqliteSpecificScoreWatchStore.java b/src/main/java/xyz/zcraft/seira/watch/SqliteSpecificScoreWatchStore.java index ddc58ed5..9537db7e 100644 --- a/src/main/java/xyz/zcraft/seira/watch/SqliteSpecificScoreWatchStore.java +++ b/src/main/java/xyz/zcraft/seira/watch/SqliteSpecificScoreWatchStore.java @@ -1,6 +1,6 @@ package xyz.zcraft.seira.watch; -import xyz.zcraft.seira.binding.UserDataStore; +import xyz.zcraft.seira.db.UserDataStore; import java.util.List; diff --git a/src/main/java/xyz/zcraft/seira/watch/WatchScoreNotifier.java b/src/main/java/xyz/zcraft/seira/watch/WatchScoreNotifier.java index 888c66b8..265029bf 100644 --- a/src/main/java/xyz/zcraft/seira/watch/WatchScoreNotifier.java +++ b/src/main/java/xyz/zcraft/seira/watch/WatchScoreNotifier.java @@ -29,6 +29,6 @@ public boolean sendScore(String groupId, byte[] imageBytes) { Message message = new Message(); message.setMsgType(PendingMessage.MSG_TYPE_MEDIA); message.setMedia(media); - return messageSender.sendGroupMessage(groupId, message); + return messageSender.sendGroupMessage(groupId, message) != null; } } From 1bbdad0d8680ed8e0e16dc5a3869199665ce4adc Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 15:26:45 +0800 Subject: [PATCH 30/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8A=84=E8=A2=AD?= =?UTF-8?q?=E6=83=A9=E7=BD=9A=E9=99=8D=E4=BD=8E=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xyz/zcraft/seira/rankguess/RankGuessGameService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 61ce6558..f85c937a 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -296,6 +296,8 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon } } + game.guesses.put(senderUserId, Guess.of(guess, game.nextSequence++, multipliers)); + final int i = game.guesses.size(); if (i >= COPY_PUNISHMENT_THRESHOLD && !game.copyPunishmentReduced) { @@ -303,8 +305,6 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon message = "提示:由于本次游戏参与人数较多,所有猜测的抄袭惩罚已降至 `-2.5%` ~"; } - game.guesses.put(senderUserId, Guess.of(guess, game.nextSequence++, multipliers)); - final GuessResult guessResult = new GuessResult( previousGuess == null ? GuessStatus.RECORDED : GuessStatus.UPDATED, guess, From b428dc46da15a701a67b628a7fef0e5853667da1 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 15:33:19 +0800 Subject: [PATCH 31/54] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E6=89=80=E6=9C=89=E7=8C=9C=E6=B5=8B=E6=B8=B8=E6=88=8F=E7=9A=84?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E7=A1=AE=E4=BF=9D=E5=9C=A8=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E5=81=9C=E6=AD=A2=E6=97=B6=E6=AD=A3=E7=A1=AE=E7=BB=93?= =?UTF-8?q?=E6=9D=9F=E8=BF=9B=E8=A1=8C=E4=B8=AD=E7=9A=84=E6=B8=B8=E6=88=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/xyz/zcraft/seira/bot/QQBot.java | 2 ++ .../xyz/zcraft/seira/rankguess/RankGuessGameService.java | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/src/main/java/xyz/zcraft/seira/bot/QQBot.java b/src/main/java/xyz/zcraft/seira/bot/QQBot.java index f96468c4..74d8d47d 100644 --- a/src/main/java/xyz/zcraft/seira/bot/QQBot.java +++ b/src/main/java/xyz/zcraft/seira/bot/QQBot.java @@ -299,6 +299,8 @@ public CacheControlResult controlCache(String operation, String type, long id) { @Override public void requestStop() { + rankGuessGameService.stopAll(); + RealtimeServiceInterruptionNotifier.NotificationResult result = interruptionNotifier.notifyGroups( watchService.activeTransientGroupIds(), rankGuessGameService.activeGroupIds(), diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index f85c937a..4b5dec9d 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -379,6 +379,14 @@ public WishResult wish(String groupId, Long boundUid) { return weights.tryWish(groupId, boundUid); } + public void stopAll() { + games.forEach((s, g) -> { + if (g.isEnded()) return; + + end(s, null, true, true); + }); + } + public enum GuessStatus { NO_GAME, STARTING, From 4629b00e0a7743854c25739d78d5510c21dcb768 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 16:13:06 +0800 Subject: [PATCH 32/54] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E6=B6=88=E6=81=AF=E5=BC=95=E7=94=A8=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=8C=9C=20Rank=20=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zcraft/seira/bot/data/PendingMessage.java | 6 +++ .../zcraft/seira/command/TaskCoordinator.java | 4 ++ .../handler/RankGuessCommandHandler.java | 29 +++++++++-- .../zcraft/seira/rankguess/RankGuessGame.java | 6 ++- .../seira/rankguess/RankGuessGameService.java | 50 ++++++++++++++++++- 5 files changed, 90 insertions(+), 5 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/bot/data/PendingMessage.java b/src/main/java/xyz/zcraft/seira/bot/data/PendingMessage.java index 6f6f7ffb..d9da7f41 100644 --- a/src/main/java/xyz/zcraft/seira/bot/data/PendingMessage.java +++ b/src/main/java/xyz/zcraft/seira/bot/data/PendingMessage.java @@ -21,6 +21,7 @@ public class PendingMessage { private FileInfo uploadedMedia = null; private int fileType = -1; private boolean upload = true; + private MessageReference messageReference = null; public static PendingMessage ofString(String content) { final PendingMessage message = new PendingMessage(); @@ -89,4 +90,9 @@ public PendingMessage doUpload(boolean upload) { this.upload = upload; return this; } + + public PendingMessage ref(MessageReference reference) { + this.messageReference = reference; + return this; + } } diff --git a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java index e5dabfa0..e3b058b4 100644 --- a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java +++ b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java @@ -186,6 +186,10 @@ public SendResult sendOutboundMessage(String targetId, String messageId, boolean message.setMsgSeq(messageSeqCounter.getAndIncrement()); } + if (pendingMsg.getMessageReference() != null) { + message.setMessageReference(pendingMsg.getMessageReference()); + } + if (pendingMsg instanceof MDMessage md) { message.setMsgType(PendingMessage.MSG_TYPE_MARKDOWN); message.setMarkdown(new Gson().toJsonTree(Map.of("content", md.getMarkdown())).getAsJsonObject()); diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 7d3801f5..a39806cf 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -4,6 +4,8 @@ import org.apache.logging.log4j.Logger; import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.data.RandomScore; +import xyz.zcraft.seira.bot.data.MessageReference; +import xyz.zcraft.seira.data.SendResult; import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.db.RankGuessRecordStore; import xyz.zcraft.seira.bot.data.PendingMessage; @@ -78,7 +80,7 @@ public void handleRankGuess(Context ctx) { } if (ctx.argumentCount() == 0) { - ctx.sendReply(PendingMessage.ofString(USAGE)); + currentStatus(ctx); return; } @@ -167,6 +169,26 @@ public void handleRankGuess(Context ctx) { guess(ctx, rank); } + private void currentStatus(Context ctx) { + final RankGuessGameService.GameStatus status = games.getStatus(ctx.groupId()); + if (status == RankGuessGameService.GameStatus.NO_GAME) { + ctx.sendReply(PendingMessage.ofString("目前本群没有进行中的猜 Rank 游戏喵!可以使用 /rg group 或 /rg start 开始游戏喵~")); + return; + } + + final MessageReference videoMessageRef = games.getVideoMessageRef(ctx.groupId()); + + if (status == RankGuessGameService.GameStatus.STARTING || videoMessageRef == null) { + ctx.sendReply(PendingMessage.ofString("游戏即将开始,稍等片刻喵~")); + return; + } + + String reply = "本群猜 Rank 正火热进行中🔥🔥🔥" + + "\n目前已经有 " + games.getParticipantCount(ctx.groupId()) + " 个参与者~"; + + ctx.sendReply(PendingMessage.ofString(reply).ref(games.getVideoMessageRef(ctx.groupId()))); + } + private void weight(Context ctx) { final String string = games.generateWeights(ctx.groupId()).toString(); ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "目前本群权重:\n```json\n" + string + "\n```")); @@ -298,7 +320,8 @@ private void start(Context ctx, boolean fromGroup) { return; } - boolean videoSent = ctx.sendReply(taskCoordinator.replayVideoMessage(replay)).success(); + final SendResult sendResult = ctx.sendReply(taskCoordinator.replayVideoMessage(replay)); + boolean videoSent = sendResult.success(); if (!videoSent) { taskCoordinator.removeReplayResult(renderTask.taskId()); ctx.sendReply(PendingMessage.ofMarkdownRaw("由于回放发送失败,本轮游戏已取消~")); @@ -306,7 +329,7 @@ private void start(Context ctx, boolean fromGroup) { } taskCoordinator.removeReplayResult(renderTask.taskId()); - var game = games.activate(reservation, round); + var game = games.activate(reservation, round, MessageReference.of(sendResult.sentMessage())); if (game == null) { ctx.sendReply(PendingMessage.ofMarkdownRaw("无法开始游戏,请稍后再试喵")); diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java index fdde5640..f046aeff 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java @@ -1,6 +1,7 @@ package xyz.zcraft.seira.rankguess; import lombok.Getter; +import xyz.zcraft.seira.bot.data.MessageReference; import java.time.Instant; import java.util.*; @@ -21,6 +22,9 @@ public final class RankGuessGame { @Getter private volatile boolean ended = false; + @Getter + public MessageReference videoRef = null; + RankGuessGame(UUID token, String starterUserId, boolean fromGroup) { this.token = token; this.starterUserId = starterUserId; @@ -53,7 +57,7 @@ public double getMultiplierDelta(RankGuessGameService.ScoreMultiplier multiplier } else if (multiplier instanceof RankGuessGameService.ScoreMultiplier.OrderMultiplier orderMultiplier) { return Math.max(-0.10, 0.00 - (orderMultiplier.getOrder() - 2) * 0.01); } else if (multiplier instanceof RankGuessGameService.ScoreMultiplier.CopyPunishmentMultiplier) { - if (guessCount.get() >= COPY_PUNISHMENT_THRESHOLD) { + if (guesses.size() >= COPY_PUNISHMENT_THRESHOLD) { return -0.025; } else { return -0.05; diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 4b5dec9d..2223c8fa 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -6,6 +6,7 @@ import xyz.zcraft.osu.model.Score; import xyz.zcraft.osu.model.UserExtended; import xyz.zcraft.seira.api.data.RandomScore; +import xyz.zcraft.seira.bot.data.MessageReference; import xyz.zcraft.seira.db.RankGuessRecordStore; import java.time.Clock; @@ -56,6 +57,18 @@ public JsonObject generateWeights(String groupId) { return weights.generateWeights(groupId); } + public String getRevealedHintsString(String groupId) { + final RankGuessGame rankGuessGame = games.get(groupId); + if (rankGuessGame == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (RankGuessGame.Hint revealedHint : rankGuessGame.getRevealedHints()) { + sb.append(revealedHint.content()).append("\n"); + } + return sb.toString().trim(); + } + // TODO This is so messed up. Need to rewrite in the future. public static LinkedList prepareHints(List source, int maxCount) { var random = ThreadLocalRandom.current(); @@ -207,7 +220,7 @@ public synchronized Reservation reserve(String groupId, String starterUserId, bo return reservation; } - public synchronized RankGuessGame activate(Reservation reservation, Round round) { + public synchronized RankGuessGame activate(Reservation reservation, Round round, MessageReference videoRef) { if (round == null) { throw new IllegalArgumentException("Round must not be null"); } @@ -217,6 +230,7 @@ public synchronized RankGuessGame activate(Reservation reservation, Round round) } game.round = round; + game.videoRef = videoRef; game.guessingStartedAt = clock.instant(); return game; @@ -387,6 +401,40 @@ public void stopAll() { }); } + public GameStatus getStatus(String s) { + final RankGuessGame rankGuessGame = games.get(s); + if (rankGuessGame == null) { + return GameStatus.NO_GAME; + } + if (rankGuessGame.getRound() == null) { + return GameStatus.STARTING; + } + return GameStatus.RUNNING; + } + + public MessageReference getVideoMessageRef(String s) { + final RankGuessGame rankGuessGame = games.get(s); + if (rankGuessGame == null || rankGuessGame.getRound() == null || rankGuessGame.getVideoRef() == null) { + return null; + } + + return rankGuessGame.getVideoRef(); + } + + public int getParticipantCount(String groupId) { + final RankGuessGame rankGuessGame = games.get(groupId); + if (rankGuessGame == null) { + return 0; + } + return rankGuessGame.guesses.size(); + } + + public enum GameStatus { + NO_GAME, + STARTING, + RUNNING + } + public enum GuessStatus { NO_GAME, STARTING, From 42d1369c538a036f90aa7f397c31aef99c01bbe0 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 16:29:22 +0800 Subject: [PATCH 33/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=8C=9CRank=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E7=BB=93=E6=9D=9F=E9=80=BB=E8=BE=91=EF=BC=8C=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=88=98=E7=BB=A9=E8=AE=B0=E5=BD=95=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E5=92=8C=E5=8F=8D=E9=A6=88=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/RankGuessCommandHandler.java | 2 +- .../seira/command/reply/ReplyFactory.java | 14 +++-- .../seira/rankguess/RankGuessGameService.java | 62 ++++++++++--------- 3 files changed, 45 insertions(+), 33 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index a39806cf..413428f9 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -463,7 +463,7 @@ private void end(Context ctx, boolean force) { case FORBIDDEN -> PendingMessage.ofString( "开始猜测后的3分钟内,仅发起者和机器人管理员可以结束游戏喵" ); - case FINISHED -> replyFactory.rankGuessResultMessage(ctx, result.round()); + case FINISHED -> replyFactory.rankGuessResultMessage(ctx, result.round(), result.recorded()); }; if (!ctx.sendReply(message).success()) { diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 958293fe..07140a5f 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -75,7 +75,7 @@ private Buttons buttons() { return new Buttons(configSupplier.get().seira().directUrl()); } - public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result) { + public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result, boolean recorded) { RankGuessGameService.Round round = result.round(); String rank = String.format(Locale.US, "%,d", round.actualRank()); String pp = round.pp() == null @@ -86,9 +86,15 @@ public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result) .map(e -> "(" + at(e) + ")") .orElse(""); - StringBuilder content = new StringBuilder() - .append("本轮猜测结束~\n") - .append("> 玩家:`%s` %s\n".formatted(round.randomScore().user().getUsername(), userAt)) + StringBuilder content = new StringBuilder("本轮猜测结束"); + + if (recorded) { + content.append(",战绩已记录~\n"); + } else { + content.append(",由于参与人数过少,战绩不会记录~\n"); + } + + content.append("> 玩家:`%s` %s\n".formatted(round.randomScore().user().getUsername(), userAt)) .append("> 实际Rank:`#%s` (%s)\n".formatted(rank, cmd("/u " + round.userId(), String.valueOf(round.userId())))) .append("> 成绩PP:`%s` (%s)\n".formatted(pp, cmd("/s " + round.scoreId(), String.valueOf(round.scoreId())))) .append("\n猜测排行榜:\n"); diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 2223c8fa..06ff4d2d 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -21,7 +21,7 @@ public final class RankGuessGameService { public static final int SCORING_VERSION = 1; private static final Duration END_PROTECTION_DURATION = Duration.ofMinutes(3); - + private static final int MIN_PARTICIPANT_TO_RECORD = 4; private final Map games = new HashMap<>(); private final RankGuessWeights weights; private final Clock clock; @@ -45,30 +45,10 @@ public RankGuessGameService() { this.recordWriter = Objects.requireNonNull(recordWriter); } - public void saveWeights() { - weights.saveToFile(); - } - static double logarithmicError(long guess, long actualRank) { return Math.abs(Math.log10(guess) - Math.log10(actualRank)); } - public JsonObject generateWeights(String groupId) { - return weights.generateWeights(groupId); - } - - public String getRevealedHintsString(String groupId) { - final RankGuessGame rankGuessGame = games.get(groupId); - if (rankGuessGame == null) { - return null; - } - StringBuilder sb = new StringBuilder(); - for (RankGuessGame.Hint revealedHint : rankGuessGame.getRevealedHints()) { - sb.append(revealedHint.content()).append("\n"); - } - return sb.toString().trim(); - } - // TODO This is so messed up. Need to rewrite in the future. public static LinkedList prepareHints(List source, int maxCount) { var random = ThreadLocalRandom.current(); @@ -206,6 +186,26 @@ public static String getGlobalRankRange(long l) { return range; } + public void saveWeights() { + weights.saveToFile(); + } + + public JsonObject generateWeights(String groupId) { + return weights.generateWeights(groupId); + } + + public String getRevealedHintsString(String groupId) { + final RankGuessGame rankGuessGame = games.get(groupId); + if (rankGuessGame == null) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (RankGuessGame.Hint revealedHint : rankGuessGame.getRevealedHints()) { + sb.append(revealedHint.content()).append("\n"); + } + return sb.toString().trim(); + } + public synchronized Reservation reserve(String groupId, String starterUserId) { return reserve(groupId, starterUserId, false); } @@ -333,16 +333,16 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon public synchronized EndResult end(String groupId, String senderUserId, boolean admin, boolean force) { RankGuessGame game = games.get(groupId); if (game == null) { - return new EndResult(EndStatus.NO_GAME, null); + return new EndResult(EndStatus.NO_GAME, null, false); } if (game.round == null) { - return new EndResult(EndStatus.STARTING, null); + return new EndResult(EndStatus.STARTING, null, false); } if (!force && clock.instant().isBefore(game.guessingStartedAt.plus(END_PROTECTION_DURATION)) && !Objects.equals(game.starterUserId, senderUserId) && !admin) { - return new EndResult(EndStatus.FORBIDDEN, null); + return new EndResult(EndStatus.FORBIDDEN, null, false); } List standings = new ArrayList<>(game.guesses.size()); @@ -377,15 +377,21 @@ public synchronized EndResult end(String groupId, String senderUserId, boolean a game.token, groupId, game.fromGroup, game.guessingStartedAt, clock.instant(), SCORING_VERSION, game.round, standings ); - // Keep the game available for retry if recording fails; no history should advance before commit. - recordWriter.accept(finished); + + final boolean shouldRecord = game.guesses.size() >= MIN_PARTICIPANT_TO_RECORD; + + if (shouldRecord) { + recordWriter.accept(finished); + } + games.remove(groupId); game.markEnded(); weights.recordRound(groupId, game.round.userId(), game.round.scoreId()); return new EndResult( EndStatus.FINISHED, - finished + finished, + shouldRecord ); } @@ -509,7 +515,7 @@ public record GuessResult(GuessStatus status, long rank, List m } - public record EndResult(EndStatus status, FinishedRound round) { + public record EndResult(EndStatus status, FinishedRound round, boolean recorded) { } public record Round(long userId, long scoreId, int bestIndex, long actualRank, Double pp, RandomScore randomScore) { From c294c261ca272442d3f3b5b3b5054b4e564696cb Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 16:41:02 +0800 Subject: [PATCH 34/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E6=89=80=E6=9C=89=E7=8C=9C=E6=B5=8B=E6=B8=B8=E6=88=8F=E7=9A=84?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E7=A1=AE=E4=BF=9D=E5=9C=A8=E5=81=9C?= =?UTF-8?q?=E6=AD=A2=E6=97=B6=E4=B8=8D=E4=BC=9A=E5=BD=B1=E5=93=8D=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E4=B8=AD=E7=9A=84=E6=B8=B8=E6=88=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xyz/zcraft/seira/rankguess/RankGuessGameService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 06ff4d2d..eb3152b3 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -400,7 +400,7 @@ public WishResult wish(String groupId, Long boundUid) { } public void stopAll() { - games.forEach((s, g) -> { + Map.copyOf(games).forEach((s, g) -> { if (g.isEnded()) return; end(s, null, true, true); From de598636ca14abb9b358fd5bd506691e6b448c59 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 17:42:11 +0800 Subject: [PATCH 35/54] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=8E=B7=E5=8F=96?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=88=90=E7=BB=A9=E6=9D=83=E9=87=8D=E7=9A=84?= =?UTF-8?q?API=E8=B0=83=E7=94=A8=EF=BC=8C=E5=B9=B6=E5=9C=A8=E6=9D=83?= =?UTF-8?q?=E9=87=8D=E5=91=BD=E4=BB=A4=E4=B8=AD=E6=98=BE=E7=A4=BA=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=9A=84=E6=88=90=E7=BB=A9=E6=9D=83=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xyz/zcraft/seira/api/APIHelper.java | 24 +++++++++++++++++++ .../handler/RankGuessCommandHandler.java | 15 ++++++++++-- .../zcraft/seira/command/parse/Resolver.java | 4 ++-- 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index 37b1653d..523693fc 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -564,6 +564,30 @@ public static RandomScore getRandomScore() { } } + public static String getRandomScoreWeight(Long userId) { + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(ENDPOINT + "/scores/random/users/" + userId + "/weights")) + .GET() + .build(); + + HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + if (codeNotOk(response.statusCode())) { + throw parseHttpError(response.body(), response.statusCode(), "获取成绩权重失败"); + } + + RawResponse payload = GSON.fromJson(response.body(), RawResponse.class); + ensureApiSuccess(payload, "获取成绩权重失败"); + + return payload.getData().getAsString(); + } catch (IOException e) { + throw requestFailure(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("成绩权重请求被中断", e); + } + } + public static RandomScore getRandomScoreFromUsers(List uids, JsonObject weights) { try { JsonObject body = new JsonObject(); diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 413428f9..8bc7c5f4 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -190,8 +190,19 @@ private void currentStatus(Context ctx) { } private void weight(Context ctx) { - final String string = games.generateWeights(ctx.groupId()).toString(); - ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "目前本群权重:\n```json\n" + string + "\n```")); + StringBuilder reply = new StringBuilder(); + final String groupWeight = games.generateWeights(ctx.groupId()).toString(); + reply.append(at(ctx)).append("目前本群权重:\n```json\n").append(groupWeight).append("\n```\n"); + + final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId()); + if (boundUid != null) { + final String randomScoreWeight = APIHelper.getRandomScoreWeight(boundUid); + + reply.append("你的成绩权重:\n>").append(randomScoreWeight).append("\n"); + } + + + ctx.sendReply(PendingMessage.ofMarkdownRaw(reply.toString().trim())); } private void statistics(Context ctx, boolean allGroups) { diff --git a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java index 37c5dd21..c0640552 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java @@ -195,8 +195,8 @@ private ShortcutTarget parseTarget(String arg, UserRef userRef, boolean mentione index = 1L; } - if (index < 1 || index > 100) { - return new ShortcutTarget(null, null, null, null, "快捷指令索引无效,请输入 1-100 之间的数字。例如: rs5"); + if (index < 1 || index > 200) { + return new ShortcutTarget(null, null, null, null, "快捷指令索引无效,请输入 1-200 之间的数字。例如: rs5"); } if (userRef == null) { From b5b92d7311af6004835cfb9308ac98f43fade2b0 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Thu, 3 Sep 2026 17:48:33 +0800 Subject: [PATCH 36/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E5=A4=84=E7=90=86=E5=9B=9E=E5=A4=8D=EF=BC=8C=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=BC=95=E7=94=A8=E4=BF=A1=E6=81=AF=E4=BB=A5?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E5=8F=8D=E9=A6=88=E6=B8=85=E6=99=B0=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/xyz/zcraft/seira/command/route/Router.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/xyz/zcraft/seira/command/route/Router.java b/src/main/java/xyz/zcraft/seira/command/route/Router.java index aa62f862..d8ec9c9c 100644 --- a/src/main/java/xyz/zcraft/seira/command/route/Router.java +++ b/src/main/java/xyz/zcraft/seira/command/route/Router.java @@ -28,6 +28,8 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; +import static xyz.zcraft.seira.command.reply.ReplyFactory.at; + public class Router { private static final Logger LOG = LogManager.getLogger(Router.class); @@ -169,7 +171,7 @@ private void handleMessageReceived(String targetId, String groupId, String userI LOG.info("Routing {} message : {}", groupMessage ? "group" : "private", context.rawContent()); dispatch(context); } catch (Exception e) { - context.sendReply(PendingMessage.ofString("处理指令时发生错误,请稍后再试。")); + context.sendReply(PendingMessage.ofMarkdownRaw(at(context) + "处理指令时发生错误,请稍后再试。")); LOG.error("Failed to process inbound message {}", messageId, e); } }); From a9491f58d2437a1b3f5b000c9f9656d5ef9bc236 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 00:11:56 +0800 Subject: [PATCH 37/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=8C=9C=20Rank=20?= =?UTF-8?q?=E6=88=98=E7=BB=A9=E7=BB=9F=E8=AE=A1=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7=E5=8F=82=E4=B8=8E=E6=AC=A1?= =?UTF-8?q?=E6=95=B0=E5=92=8C=E8=83=9C=E7=8E=87=E5=8F=8D=E9=A6=88=EF=BC=8C?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=8E=92=E5=90=8D=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/RankGuessCommandHandler.java | 156 ++++++++++++++---- .../seira/command/reply/ReplyFactory.java | 21 ++- .../zcraft/seira/db/RankGuessRecordStore.java | 97 +++++++++-- .../xyz/zcraft/seira/db/UserDataStore.java | 23 ++- .../seira/rankguess/RankGuessGameService.java | 3 +- 5 files changed, 245 insertions(+), 55 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 8bc7c5f4..d10b0381 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -5,22 +5,21 @@ import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.data.RandomScore; import xyz.zcraft.seira.bot.data.MessageReference; -import xyz.zcraft.seira.data.SendResult; -import xyz.zcraft.seira.db.UserDataStore; -import xyz.zcraft.seira.db.RankGuessRecordStore; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.TaskCoordinator; import xyz.zcraft.seira.command.parse.Resolver; import xyz.zcraft.seira.command.parse.UserRefResolution; import xyz.zcraft.seira.command.reply.ReplyFactory; +import xyz.zcraft.seira.data.SendResult; import xyz.zcraft.seira.data.UserRef; +import xyz.zcraft.seira.db.RankGuessRecordStore; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.rankguess.RankGuessGame; import xyz.zcraft.seira.rankguess.RankGuessGameService; import xyz.zcraft.seira.rankguess.WishResult; -import java.util.List; -import java.util.Locale; +import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Predicate; import java.util.regex.Matcher; @@ -95,6 +94,16 @@ public void handleRankGuess(Context ctx) { } return; } + if ("lb".equalsIgnoreCase(argument)) { + if (ctx.argumentCount() == 1) { + leaderboard(ctx, false); + } else if (ctx.argumentCount() == 2 && "all".equalsIgnoreCase(ctx.argument(1))) { + leaderboard(ctx, true); + } else { + ctx.sendReply(PendingMessage.ofString(USAGE)); + } + return; + } if ("start".equalsIgnoreCase(argument)) { if (ctx.argumentCount() == 2) { final String arg = ctx.argument(1); @@ -206,46 +215,88 @@ private void weight(Context ctx) { } private void statistics(Context ctx, boolean allGroups) { + final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId()); try { - RankGuessRecordStore.Statistics statistics = RankGuessRecordStore.getStatistics( + RankGuessRecordStore.Statistics.Personal statistics = RankGuessRecordStore.getPersonalStatistics( ctx.senderUserId(), - allGroups ? null : ctx.groupId() + allGroups ? null : ctx.groupId(), + null ); - ctx.sendReply(replyFactory.rankGuessStatisticsMessage(ctx, statistics, allGroups, getRank(statistics))); + final Rank rank = Rank.from(statistics); + + Long groupGameCount = boundUid == null ? null : RankGuessRecordStore.getGroupGameCount(ctx.groupId(), null); + Long pickedTimes = boundUid == null ? null : RankGuessRecordStore.getPickedTimes(boundUid, ctx.groupId()); + + ctx.sendReply(replyFactory.rankGuessStatisticsMessage(ctx, statistics, allGroups, rank, pickedTimes, groupGameCount)); } catch (RuntimeException e) { LOG.error("Failed to query rank guess statistics", e); ctx.sendReply(PendingMessage.ofString("战绩查询失败,请稍后重试喵。")); } } - private String getRank(RankGuessRecordStore.Statistics statistics) { - if (statistics.participation() < 3) { - return "?"; - } + private void leaderboard(Context ctx, boolean all) { + try { + final List allGroupMembers = UserDataStore.findAllGroupMembers(ctx.groupId()); + final Map ranks = new HashMap<>(); - double averageScoreRate = Math.clamp( - statistics.averageScore() / 1000.0, - 0.0, 1.0 - ); + for (String openId : allGroupMembers) { + if (!RankGuessRecordStore.canBeRanked(openId, ctx.groupId())) { + continue; + } - double rawRating = - averageScoreRate * 0.50 - + statistics.winRate() * 0.20 - + statistics.topTwentyRate() * 0.30; + ranks.put( + openId, + Rank.from( + RankGuessRecordStore.getPersonalStatistics( + openId, ctx.groupId(), null + ) + ) + ); + } - double confidence = 1.0 - Math.exp(-statistics.participation() / 10.0); + final List> groupRanks = ranks.entrySet().stream() + .sorted(Comparator.comparingDouble(entry -> entry.getValue().rating())) + .toList() + .reversed(); - double rating = 0.50 * (1.0 - confidence) + rawRating * confidence; + StringBuilder reply = new StringBuilder(); + if (all) { + reply.append(at(ctx)).append("本群猜 Rank 战绩排行:\n"); + for (int i = 0; i < groupRanks.size(); i++) { + final Map.Entry aRank = groupRanks.get(i); + reply.append("> __\\#").append(i + 1).append("__ ").append(at(aRank.getKey())).append(" (%.2f)".formatted(aRank.getValue().rating())).append("\n"); + } + } else { + if (!RankGuessRecordStore.canBeRanked(ctx.senderUserId(), ctx.groupId()) || !ranks.containsKey(ctx.senderUserId())) { + reply.append(at(ctx)).append("你还未在本群参加过猜 Rank,或参与次数不足喵~"); + } else { + int placement = 0; + for (int i = 0; i < groupRanks.size(); i++) { + if (groupRanks.get(i).getKey().equals(ctx.senderUserId())) { + placement = i + 1; + break; + } + } + reply.append(at(ctx)).append("你在本群猜 Rank 战绩排行第 __").append(placement).append("__ 名!\n"); + reply.append("以下是你附近的玩家:\n"); + for (int i = Math.max(0, placement - 1 - 2); i < groupRanks.size() && i < placement - 1 + 3; i++) { + final Map.Entry aRank = groupRanks.get(i); + reply.append("> __\\#").append(i + 1).append("__ ").append(at(aRank.getKey())) + .append(" (%.2f) (%+.3f)".formatted( + aRank.getValue().rating(), + groupRanks.get(placement - 1).getValue().rating() - aRank.getValue().rating()) + ).append("\n"); + } + } + } - if (rating >= 0.90) return "SS"; - if (rating >= 0.82) return "S"; - if (rating >= 0.74) return "A"; - if (rating >= 0.64) return "B"; - if (rating >= 0.52) return "C"; - if (rating >= 0.40) return "D"; - return "F"; + ctx.sendReply(PendingMessage.ofMarkdownRaw(reply.toString().trim())); + } catch (RuntimeException e) { + LOG.error("Failed to query rank guess statistics", e); + ctx.sendReply(PendingMessage.ofString("战绩查询失败,请稍后重试喵。")); + } } private void wish(Context ctx) { @@ -481,4 +532,51 @@ private void end(Context ctx, boolean force) { ctx.sendMessage(message); } } + + public record Rank(double rating, double ratingRaw, String rank) { + public static Rank from(RankGuessRecordStore.Statistics.Personal statistics) { + final double pendingRatingRaw = getRatingRaw(statistics); + final double pendingRating = standardRating(pendingRatingRaw); + final String pendingRank = getRankText(statistics); + + return new Rank(pendingRating, pendingRatingRaw, pendingRank); + } + + private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal statistics) { + double averageScoreRate = Math.clamp( + statistics.averageScore() / 1000.0, + 0.0, 1.0 + ); + + double rawRating = averageScoreRate * 0.50 + + statistics.winRate() * 0.20 + + statistics.topTwentyRate() * 0.30; + + double confidence = 1.0 - Math.exp(-statistics.participation() / 10.0); + + return 0.50 * (1.0 - confidence) + rawRating * confidence; + } + + private static double standardRating(double ratingRaw) { + ratingRaw = Math.clamp(ratingRaw, 0.0, 1.0); + + return 1 + Math.pow(2 * ratingRaw - 1, 3); + } + + private static String getRankText(RankGuessRecordStore.Statistics.Personal statistics) { + if (statistics.participation() < 3) { + return "?"; + } + + double rawRating = getRatingRaw(statistics); + + if (rawRating >= 0.90) return "SS"; + if (rawRating >= 0.82) return "S"; + if (rawRating >= 0.74) return "A"; + if (rawRating >= 0.64) return "B"; + if (rawRating >= 0.52) return "C"; + if (rawRating >= 0.40) return "D"; + return "F"; + } + } } diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 07140a5f..43886df2 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -7,6 +7,7 @@ import xyz.zcraft.osu.model.*; import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.data.*; +import xyz.zcraft.seira.command.handler.RankGuessCommandHandler; import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.db.RankGuessRecordStore; @@ -123,27 +124,33 @@ public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result, } public PendingMessage rankGuessStatisticsMessage( - Context ctx, RankGuessRecordStore.Statistics statistics, boolean allGroups, String rank + Context ctx, RankGuessRecordStore.Statistics.Personal statistics, + boolean allGroups, RankGuessCommandHandler.Rank rank, + Long pickedTimes, Long groupGameCount ) { String scope = allGroups ? "全部群聊" : "本群"; if (statistics.participation() == 0) { return PendingMessage.ofMarkdownRaw(at(ctx) + "你在" + scope + "还没有已结算的猜 Rank 战绩喵~"); } - String rankText = "?".equals(rank) ? "" : "根据你的总体表现,可以给到一个 `%s` 喵!".formatted(rank); + String rankText = "?".equals(rank.rank()) ? "" : "根据你的总体表现,可以给到一个 `%s` 喵!\n".formatted(rank.rank()); + String groupCountText = ""; + if (allGroups && pickedTimes != null && groupGameCount != null) { + groupCountText = "> 被猜次数:`%d`,占本群:`%.3f%%`".formatted(pickedTimes, (double) pickedTimes / groupGameCount * 100); + } return PendingMessage.ofMarkdownRaw(at(ctx) + String.format(Locale.ROOT, """ 你的猜 Rank 战绩(%s) - > 总参与数:`%d` + > 总参与数:`%d`,Rating:`%.2f` > 获胜数:`%d`,胜率:`%.2f%%` > 前20%%次数:`%d`,达成率:`%.2f%%` > 平均分:`%.2f`,最高分:`%.2f` > 总得分:`%.2f`,平均名次:`%.2f` - - %s - """, scope, statistics.participation(), statistics.wins(), statistics.winRate() * 100, + %s%s + """, scope, statistics.participation(), rank.rating(), + statistics.wins(), statistics.winRate() * 100, statistics.topTwentyCount(), statistics.topTwentyRate() * 100, statistics.averageScore(), statistics.highestScore(), statistics.totalScore(), - statistics.averagePlacement(), rankText).strip()); + statistics.averagePlacement(), groupCountText, rankText).strip()); } public PendingMessage boMessage(Context ctx, Response response) { diff --git a/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java b/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java index 841e214c..f7dfc33f 100644 --- a/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java +++ b/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java @@ -6,6 +6,8 @@ import java.sql.*; import java.util.Objects; +import static xyz.zcraft.seira.rankguess.RankGuessGameService.MIN_GAMES_TO_RANK; + public class RankGuessRecordStore { public static final int TOP_TWENTY_MIN_PARTICIPANTS = 1; @@ -73,16 +75,75 @@ INSERT INTO rank_guess_results ( } } - public static Statistics getStatistics(String userId) { - return getStatistics(userId, null); + public static long getPickedTimes(Long osuUid, String groupId) { + String sql = """ + SELECT COUNT(*) + FROM rank_guess_games g + WHERE g.target_score_id = ? + """; + if (groupId != null) sql += " AND g.group_id = ?"; + try (Connection connection = SqliteDatabase.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setLong(1, osuUid); + if (groupId != null) statement.setString(2, groupId); + try (ResultSet result = statement.executeQuery()) { + result.next(); + return result.getLong(1); + } + } catch (SQLException e) { + throw new IllegalStateException("Failed to query picked times", e); + } } - /** A null groupId includes this user's records from every group. */ - public static Statistics getStatistics(String userId, String groupId) { - return getStatistics(userId, groupId, null); + public static long getGroupGameCount(String groupId, Integer scoringVersion) { + requireText(groupId, "groupId"); + if (scoringVersion != null && scoringVersion < 1) { + throw new IllegalArgumentException("scoringVersion must be positive"); + } + + String sql = """ + SELECT COUNT(*) + FROM rank_guess_games g + WHERE g.group_id = ? + """; + + if (scoringVersion != null) sql += " AND g.scoring_version = ?"; + try (Connection connection = SqliteDatabase.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, groupId); + if (scoringVersion != null) statement.setInt(2, scoringVersion); + + try (ResultSet result = statement.executeQuery()) { + result.next(); + return result.getLong(1); + } + } catch (SQLException e) { + throw new IllegalStateException("Failed to query rank guess statistics", e); + } } - public static Statistics getStatistics(String userId, String groupId, Integer scoringVersion) { + public static boolean canBeRanked(String userId, String groupId) { + String sql = """ + SELECT COUNT(*) AS participation + FROM rank_guess_results r + JOIN rank_guess_games g ON g.round_id = r.round_id + WHERE r.user_id = ? + """; + if (groupId != null) sql += " AND g.group_id = ?"; + try (Connection connection = SqliteDatabase.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, userId); + if (groupId != null) statement.setString(2, groupId); + try (ResultSet result = statement.executeQuery()) { + result.next(); + return result.getLong("participation") >= MIN_GAMES_TO_RANK; + } + } catch (SQLException e) { + throw new IllegalStateException("Failed to query rank guess statistics", e); + } + } + + public static Statistics.Personal getPersonalStatistics(String userId, String groupId, Integer scoringVersion) { requireText(userId, "userId"); if (groupId != null) requireText(groupId, "groupId"); if (scoringVersion != null && scoringVersion < 1) { @@ -114,7 +175,7 @@ SELECT COUNT(*) AS participation, if (scoringVersion != null) statement.setInt(index, scoringVersion); try (ResultSet result = statement.executeQuery()) { result.next(); - return new Statistics( + return new Statistics.Personal( result.getLong("participation"), result.getLong("wins"), result.getLong("top_twenty"), result.getLong("top_twenty_eligible"), result.getDouble("total_score"), result.getDouble("average_score"), @@ -126,17 +187,19 @@ SELECT COUNT(*) AS participation, } } - public record Statistics( - long participation, long wins, long topTwentyCount, long topTwentyEligibleParticipation, - double totalScore, double averageScore, double highestScore, double averagePlacement - ) { - public double winRate() { - return participation == 0 ? 0 : wins / (double) participation; - } + public static class Statistics { + public record Personal( + long participation, long wins, long topTwentyCount, long topTwentyEligibleParticipation, + double totalScore, double averageScore, double highestScore, double averagePlacement + ) { + public double winRate() { + return participation == 0 ? 0 : wins / (double) participation; + } - public double topTwentyRate() { - return topTwentyEligibleParticipation == 0 ? 0 - : topTwentyCount / (double) topTwentyEligibleParticipation; + public double topTwentyRate() { + return topTwentyEligibleParticipation == 0 ? 0 + : topTwentyCount / (double) topTwentyEligibleParticipation; + } } } diff --git a/src/main/java/xyz/zcraft/seira/db/UserDataStore.java b/src/main/java/xyz/zcraft/seira/db/UserDataStore.java index 68b28137..8af8a72f 100644 --- a/src/main/java/xyz/zcraft/seira/db/UserDataStore.java +++ b/src/main/java/xyz/zcraft/seira/db/UserDataStore.java @@ -15,7 +15,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.Locale; import java.util.Set; public final class UserDataStore { @@ -336,6 +335,28 @@ public static int clearGroupMember(String openId) { } } + public static List findAllGroupMembers(String groupId) { + SqliteDatabase.ensureInitialized(); + String sql = """ + SELECT open_id + FROM group_members + WHERE group_id = ?; + """; + List groupMembers = new LinkedList<>(); + try (Connection connection = SqliteDatabase.getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, groupId); + try (ResultSet resultSet = statement.executeQuery()) { + while (resultSet.next()) { + groupMembers.add(resultSet.getString("open_id")); + } + } + } catch (SQLException e) { + throw new RuntimeException("Failed to query group members", e); + } + return groupMembers; + } + public static List findBoundUidsByGroup(String groupId) { SqliteDatabase.ensureInitialized(); String sql = """ diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index eb3152b3..0c96b2bc 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -21,7 +21,8 @@ public final class RankGuessGameService { public static final int SCORING_VERSION = 1; private static final Duration END_PROTECTION_DURATION = Duration.ofMinutes(3); - private static final int MIN_PARTICIPANT_TO_RECORD = 4; + private static final int MIN_PARTICIPANT_TO_RECORD = 3; + public static final int MIN_GAMES_TO_RANK = 3; private final Map games = new HashMap<>(); private final RankGuessWeights weights; private final Clock clock; From 6833c532bcbecd7900d1c4e47ad71a1a23607375 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 00:20:57 +0800 Subject: [PATCH 38/54] =?UTF-8?q?=E6=95=B4=E7=90=86=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E4=B8=80=E4=BA=9B=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xyz/zcraft/seira/SeiraApplication.java | 14 +- .../java/xyz/zcraft/seira/api/APIHelper.java | 14 +- .../xyz/zcraft/seira/bot/MessageSender.java | 3 +- src/main/java/xyz/zcraft/seira/bot/QQApi.java | 4 +- src/main/java/xyz/zcraft/seira/bot/QQBot.java | 18 +- .../xyz/zcraft/seira/bot/QqFaceNames.java | 7 +- .../RealtimeServiceInterruptionNotifier.java | 41 +- .../java/xyz/zcraft/seira/bot/WSClient.java | 20 +- .../seira/command/AttachmentHandler.java | 1 + .../zcraft/seira/command/CommandRegistry.java | 14 +- .../seira/command/ReplayResultStore.java | 14 +- .../zcraft/seira/command/TaskCoordinator.java | 52 +- .../handler/BeatmapCommandHandler.java | 14 +- .../handler/BindingCommandHandler.java | 4 +- .../command/handler/DcsCommandHandler.java | 8 +- .../MultiplayerRoomWatchCommandHandler.java | 98 ++-- .../command/handler/ReplayCommandHandler.java | 9 +- .../command/handler/SocialCommandHandler.java | 14 +- .../SpecificScoreWatchCommandHandler.java | 38 +- .../command/handler/WatchCommandHandler.java | 108 ++--- .../zcraft/seira/command/parse/Resolver.java | 2 +- .../command/parse/ScoreFilterArguments.java | 4 +- .../seira/command/reply/ReplyFactory.java | 30 +- .../seira/command/route/DebugRoutes.java | 6 +- .../zcraft/seira/command/route/Router.java | 122 +++-- .../zcraft/seira/config/ConfigValidator.java | 4 +- .../zcraft/seira/config/RuntimeConfig.java | 76 +-- .../console/ConsoleCommandProcessor.java | 326 ++++++------- .../zcraft/seira/console/JLineConsole.java | 12 +- .../zcraft/seira/console/JLineLogBridge.java | 4 +- .../java/xyz/zcraft/seira/data/UserRef.java | 3 +- .../zcraft/seira/db/RankGuessRecordStore.java | 58 +-- .../xyz/zcraft/seira/db/SqliteDatabase.java | 6 +- .../xyz/zcraft/seira/db/UserDataStore.java | 17 +- .../seira/discord/DiscordBridgeService.java | 452 +++++++++--------- .../zcraft/seira/discord/MediaDownloader.java | 34 +- .../zcraft/seira/rankguess/RankGuessGame.java | 9 +- .../seira/rankguess/RankGuessGameService.java | 2 +- .../seira/rankguess/RankGuessWeights.java | 30 +- .../zcraft/seira/services/BindingService.java | 11 +- .../xyz/zcraft/seira/services/BotStat.java | 2 +- .../xyz/zcraft/seira/services/CosService.java | 10 +- .../xyz/zcraft/seira/util/AdminRegistry.java | 30 +- .../seira/util/ApplicationExecutors.java | 32 +- .../xyz/zcraft/seira/util/OsuAuthHelper.java | 2 +- .../seira/watch/MultiplayerRoomVersion.java | 8 +- .../watch/MultiplayerRoomWatchService.java | 52 +- .../watch/OstellaMultiplayerRoomWatchApi.java | 82 ++-- .../zcraft/seira/watch/OstellaWatchApi.java | 44 +- .../zcraft/seira/watch/ScoreWatchService.java | 102 ++-- .../seira/watch/SpecificScoreWatchState.java | 10 +- .../seira/watch/SpecificScoreWatchStore.java | 22 - .../java/xyz/zcraft/seira/watch/WatchApi.java | 4 +- .../seira/watch/WatchScoreNotifier.java | 2 +- 54 files changed, 1014 insertions(+), 1091 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/SeiraApplication.java b/src/main/java/xyz/zcraft/seira/SeiraApplication.java index 3197f83c..20f8b800 100644 --- a/src/main/java/xyz/zcraft/seira/SeiraApplication.java +++ b/src/main/java/xyz/zcraft/seira/SeiraApplication.java @@ -1,26 +1,28 @@ package xyz.zcraft.seira; +import org.apache.logging.log4j.Level; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.apache.logging.log4j.Level; import org.apache.logging.log4j.core.config.Configurator; -import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.bot.QQBot; +import xyz.zcraft.seira.config.AppConfig; +import xyz.zcraft.seira.config.RuntimeConfig; import xyz.zcraft.seira.console.ConsoleCommandProcessor; import xyz.zcraft.seira.console.JLineConsole; import xyz.zcraft.seira.console.UserDataConsoleAccess; -import xyz.zcraft.seira.config.AppConfig; -import xyz.zcraft.seira.config.RuntimeConfig; import xyz.zcraft.seira.db.SqliteDatabase; -import xyz.zcraft.seira.util.AdminRegistry; +import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.services.BotStat; import xyz.zcraft.seira.services.DailyLuck; +import xyz.zcraft.seira.util.AdminRegistry; import xyz.zcraft.seira.util.ApplicationExecutors; import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; -/** Composition root and single owner of all long-lived application resources. */ +/** + * Composition root and single owner of all long-lived application resources. + */ public final class SeiraApplication implements AutoCloseable { private static final Logger LOG = LogManager.getLogger(SeiraApplication.class); diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index 523693fc..afa86106 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -9,9 +9,9 @@ import xyz.zcraft.osu.model.*; import xyz.zcraft.seira.Seira; import xyz.zcraft.seira.api.data.*; +import xyz.zcraft.seira.bot.data.FileInfo; import xyz.zcraft.seira.command.ResolutionException; import xyz.zcraft.seira.command.parse.ShortcutTarget; -import xyz.zcraft.seira.bot.data.FileInfo; import xyz.zcraft.seira.data.UserRef; import xyz.zcraft.seira.util.TimeDurationParser; @@ -505,8 +505,8 @@ public static ReplayTaskInfo createReplayRenderTask(ShortcutTarget target, TimeD } public static ReplayTaskInfo createReplayRenderTask(ShortcutTarget target, - TimeDurationParser.TimeRange timeRange, - QqUploadRequest qqUpload) { + TimeDurationParser.TimeRange timeRange, + QqUploadRequest qqUpload) { return createReplayTask(target, timeRange, qqUpload); } @@ -636,7 +636,7 @@ public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget target, Str } public static ReplayTaskInfo createBeatmapPreviewTask(ShortcutTarget target, String mods, String auth, - QqUploadRequest qqUpload) { + QqUploadRequest qqUpload) { long beatmapId = lookupBeatmap(target, auth); JsonObject body = new JsonObject(); if (mods != null && !mods.isBlank()) { @@ -655,7 +655,7 @@ public static ReplayTaskInfo createBeatmapPreviewTask(ShortcutTarget target, Str } public static ReplayTaskInfo createReplayShowcaseTask(ShortcutTarget beatmapTarget, String[] scoreTargets, String auth, - QqUploadRequest qqUpload) { + QqUploadRequest qqUpload) { scoreTargets = scoreTargets == null ? new String[0] : scoreTargets; if (beatmapTarget.isLocalScore()) { @@ -689,8 +689,8 @@ public static ReplayRenderResult waitReplayVideo(String taskId) { } private static ReplayTaskInfo createReplayTask(ShortcutTarget target, - TimeDurationParser.TimeRange timeRange, - QqUploadRequest qqUpload) { + TimeDurationParser.TimeRange timeRange, + QqUploadRequest qqUpload) { String scoreId = lookupScoreId(target); if (timeRange == null) { diff --git a/src/main/java/xyz/zcraft/seira/bot/MessageSender.java b/src/main/java/xyz/zcraft/seira/bot/MessageSender.java index 1ceabb15..11f491a3 100644 --- a/src/main/java/xyz/zcraft/seira/bot/MessageSender.java +++ b/src/main/java/xyz/zcraft/seira/bot/MessageSender.java @@ -3,12 +3,11 @@ import com.google.gson.Gson; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import xyz.zcraft.seira.api.data.QqUploadRequest; import xyz.zcraft.seira.bot.data.FileInfo; import xyz.zcraft.seira.bot.data.Message; import xyz.zcraft.seira.bot.data.PendingMessage; -import xyz.zcraft.seira.api.data.QqUploadRequest; import xyz.zcraft.seira.bot.data.SentMessage; -import xyz.zcraft.seira.data.SendResult; import xyz.zcraft.seira.data.UploadedImage; import xyz.zcraft.seira.services.CosService; import xyz.zcraft.seira.util.TokenManager; diff --git a/src/main/java/xyz/zcraft/seira/bot/QQApi.java b/src/main/java/xyz/zcraft/seira/bot/QQApi.java index 3189b660..9dde38f6 100644 --- a/src/main/java/xyz/zcraft/seira/bot/QQApi.java +++ b/src/main/java/xyz/zcraft/seira/bot/QQApi.java @@ -34,7 +34,7 @@ public class QQApi { private static final long MAX_MEDIA_SIZE = 200L * 1024 * 1024; private static final Gson gson = new Gson(); private static final Logger LOG = LogManager.getLogger(QQApi.class); - + private static final Gson GSON = new Gson(); public static String getWSSEndpoint(AccessToken accessToken) { try { @@ -109,8 +109,6 @@ public static SentMessage sendGroupMessage(AccessToken accessToken, String group } } - private static final Gson GSON = new Gson(); - public static String buildMessageJson(Message message) { final JsonObject asJsonObject = new Gson().toJsonTree(message).getAsJsonObject(); diff --git a/src/main/java/xyz/zcraft/seira/bot/QQBot.java b/src/main/java/xyz/zcraft/seira/bot/QQBot.java index 74d8d47d..7db86c07 100644 --- a/src/main/java/xyz/zcraft/seira/bot/QQBot.java +++ b/src/main/java/xyz/zcraft/seira/bot/QQBot.java @@ -8,30 +8,22 @@ import xyz.zcraft.seira.bot.data.PanelItem; import xyz.zcraft.seira.bot.data.PanelRecord; import xyz.zcraft.seira.bot.data.QQUser; -import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.command.AttachmentHandler; import xyz.zcraft.seira.command.route.Router; +import xyz.zcraft.seira.config.AppConfig; +import xyz.zcraft.seira.config.RuntimeConfig; import xyz.zcraft.seira.console.ConsoleCommandProcessor; import xyz.zcraft.seira.console.ConsoleRuntimeControl; import xyz.zcraft.seira.console.OstellaCacheControlClient; -import xyz.zcraft.seira.config.AppConfig; -import xyz.zcraft.seira.config.RuntimeConfig; import xyz.zcraft.seira.discord.DiscordBridgeService; import xyz.zcraft.seira.rankguess.RankGuessGameService; +import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.services.BotStat; import xyz.zcraft.seira.services.CosService; -import xyz.zcraft.seira.util.ApplicationExecutors; import xyz.zcraft.seira.util.AdminRegistry; +import xyz.zcraft.seira.util.ApplicationExecutors; import xyz.zcraft.seira.util.TokenManager; -import xyz.zcraft.seira.watch.OstellaWatchApi; -import xyz.zcraft.seira.watch.OstellaMultiplayerRoomWatchApi; -import xyz.zcraft.seira.watch.MultiplayerRoomWatchService; -import xyz.zcraft.seira.watch.QqMultiplayerRoomNotifier; -import xyz.zcraft.seira.watch.SpecificScoreNotifier; -import xyz.zcraft.seira.watch.WatchScoreNotifier; -import xyz.zcraft.seira.watch.ScoreWatchService; -import xyz.zcraft.seira.watch.SqliteSpecificScoreWatchStore; -import xyz.zcraft.seira.watch.WatchView; +import xyz.zcraft.seira.watch.*; import java.net.URI; import java.nio.file.Files; diff --git a/src/main/java/xyz/zcraft/seira/bot/QqFaceNames.java b/src/main/java/xyz/zcraft/seira/bot/QqFaceNames.java index fa46e28b..2d662d5d 100644 --- a/src/main/java/xyz/zcraft/seira/bot/QqFaceNames.java +++ b/src/main/java/xyz/zcraft/seira/bot/QqFaceNames.java @@ -4,7 +4,9 @@ import static java.util.Map.entry; -/** Human-readable names for QQ's documented built-in face IDs. */ +/** + * Human-readable names for QQ's documented built-in face IDs. + */ public final class QqFaceNames { private static final Map NAMES = Map.ofEntries( entry("0", "惊讶"), entry("1", "撇嘴"), entry("2", "色"), entry("3", "发呆"), @@ -78,7 +80,8 @@ public final class QqFaceNames { entry("346", "你真棒棒"), entry("347", "大展宏兔"), entry("348", "福萝卜") ); - private QqFaceNames() {} + private QqFaceNames() { + } public static String describe(String id) { if (id == null || id.isBlank()) return "[表情]"; diff --git a/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java b/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java index 75ca044a..0a75db7b 100644 --- a/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java +++ b/src/main/java/xyz/zcraft/seira/bot/RealtimeServiceInterruptionNotifier.java @@ -1,11 +1,6 @@ package xyz.zcraft.seira.bot; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Objects; -import java.util.Set; +import java.util.*; public final class RealtimeServiceInterruptionNotifier { private static final String SCORE_WATCH = "成绩监视"; @@ -18,6 +13,23 @@ public RealtimeServiceInterruptionNotifier(MessageSender sender) { this.sender = Objects.requireNonNull(sender); } + private static void addService( + Map> servicesByGroup, + Set groupIds, + String service + ) { + Objects.requireNonNull(groupIds); + groupIds.stream().sorted().forEach(groupId -> servicesByGroup + .computeIfAbsent(groupId, ignored -> new LinkedHashSet<>()) + .add(service)); + } + + private static String message(Set services) { + return "服务器即将重启,本群正在运行的实时服务(" + + String.join("、", new ArrayList<>(services)) + + ")将会中断。服务器恢复后,请重新启动相关服务。"; + } + public NotificationResult notifyGroups( Set scoreWatchGroups, Set rankGuessGroups, @@ -37,23 +49,6 @@ public NotificationResult notifyGroups( return new NotificationResult(servicesByGroup.size(), sent); } - private static void addService( - Map> servicesByGroup, - Set groupIds, - String service - ) { - Objects.requireNonNull(groupIds); - groupIds.stream().sorted().forEach(groupId -> servicesByGroup - .computeIfAbsent(groupId, ignored -> new LinkedHashSet<>()) - .add(service)); - } - - private static String message(Set services) { - return "服务器即将重启,本群正在运行的实时服务(" - + String.join("、", new ArrayList<>(services)) - + ")将会中断。服务器恢复后,请重新启动相关服务。"; - } - public record NotificationResult(int targetGroups, int sentGroups) { public int failedGroups() { return targetGroups - sentGroups; diff --git a/src/main/java/xyz/zcraft/seira/bot/WSClient.java b/src/main/java/xyz/zcraft/seira/bot/WSClient.java index 645f3aab..54a4db30 100644 --- a/src/main/java/xyz/zcraft/seira/bot/WSClient.java +++ b/src/main/java/xyz/zcraft/seira/bot/WSClient.java @@ -64,6 +64,16 @@ public WSClient( LOG.info("QQ Gateway WebSocket Client created"); } + private static String firstText(JsonObject object, String... names) { + for (String name : names) { + if (object.has(name) && !object.get(name).isJsonNull()) { + String value = object.get(name).getAsString(); + if (!value.isBlank()) return value; + } + } + return "QQ用户"; + } + @Override public void onOpen(ServerHandshake handshake) { LOG.info("Gateway connected"); @@ -229,16 +239,6 @@ private String stripSelfMention(String content) { : normalized; } - private static String firstText(JsonObject object, String... names) { - for (String name : names) { - if (object.has(name) && !object.get(name).isJsonNull()) { - String value = object.get(name).getAsString(); - if (!value.isBlank()) return value; - } - } - return "QQ用户"; - } - private void sendIdentify() { JsonObject data = new JsonObject(); data.addProperty("token", "QQBot " + tokenSupplier.get().token()); diff --git a/src/main/java/xyz/zcraft/seira/command/AttachmentHandler.java b/src/main/java/xyz/zcraft/seira/command/AttachmentHandler.java index 3c14234d..932a6014 100644 --- a/src/main/java/xyz/zcraft/seira/command/AttachmentHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/AttachmentHandler.java @@ -7,6 +7,7 @@ import xyz.zcraft.seira.bot.data.Attachment; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.reply.ReplyFactory; + import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; diff --git a/src/main/java/xyz/zcraft/seira/command/CommandRegistry.java b/src/main/java/xyz/zcraft/seira/command/CommandRegistry.java index a13f9259..7029c832 100644 --- a/src/main/java/xyz/zcraft/seira/command/CommandRegistry.java +++ b/src/main/java/xyz/zcraft/seira/command/CommandRegistry.java @@ -1,10 +1,6 @@ package xyz.zcraft.seira.command; -import java.util.LinkedHashMap; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Set; +import java.util.*; public final class CommandRegistry { private final Map handlers; @@ -17,6 +13,10 @@ public static Builder builder() { return new Builder(); } + private static String normalize(String command) { + return Objects.requireNonNull(command, "command").trim().toLowerCase(Locale.ROOT); + } + public void dispatch(Context context, CommandHandler fallback) { CommandHandler handler = handlers.get(normalize(context.command())); (handler == null ? fallback : handler).handle(context); @@ -55,8 +55,4 @@ public CommandRegistry build() { return new CommandRegistry(handlers); } } - - private static String normalize(String command) { - return Objects.requireNonNull(command, "command").trim().toLowerCase(Locale.ROOT); - } } diff --git a/src/main/java/xyz/zcraft/seira/command/ReplayResultStore.java b/src/main/java/xyz/zcraft/seira/command/ReplayResultStore.java index dcac0e85..de5e26c3 100644 --- a/src/main/java/xyz/zcraft/seira/command/ReplayResultStore.java +++ b/src/main/java/xyz/zcraft/seira/command/ReplayResultStore.java @@ -9,6 +9,13 @@ public final class ReplayResultStore { private final ConcurrentMap results = new ConcurrentHashMap<>(); + private static String requireTaskId(String taskId) { + if (taskId == null || taskId.isBlank()) { + throw new IllegalArgumentException("Task ID must not be blank"); + } + return taskId; + } + void put(String taskId, APIHelper.ReplayRenderResult result) { results.put(requireTaskId(taskId), Objects.requireNonNull(result)); } @@ -22,11 +29,4 @@ public void remove(String taskId) { results.remove(taskId); } } - - private static String requireTaskId(String taskId) { - if (taskId == null || taskId.isBlank()) { - throw new IllegalArgumentException("Task ID must not be blank"); - } - return taskId; - } } diff --git a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java index e3b058b4..5a7a0f26 100644 --- a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java +++ b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java @@ -44,6 +44,30 @@ public TaskCoordinator( this.discordBridgeService = java.util.Objects.requireNonNull(discordBridgeService); } + static String resolveErrorMessage(Exception exception) { + Throwable cursor = exception; + while (cursor != null) { + switch (cursor) { + case ApiRequestException e -> { + return ApiRequestException.getDefaultMessage(e.getErrorCode()); + } + case ClosedChannelException _ -> { + return "oStella API 无法连接,请稍后再试。"; + } + case ResolutionException e -> { + return e.getMessage(); + } + case ReplayRenderException e -> { + return e.getMessage(); + } + default -> { + } + } + cursor = cursor.getCause(); + } + return "请求处理失败,请稍后再试。"; + } + public CommandReplyChannel openReplyChannel( String targetId, String messageId, @@ -148,7 +172,9 @@ public void runReplayRequest( }); } - /** Waits for an image renderer and returns a sendable message without sending it. */ + /** + * Waits for an image renderer and returns a sendable message without sending it. + */ private PendingMessage waitForImage( Context ctx, Supplier> creator, @@ -289,28 +315,4 @@ public synchronized SendResult sendQueueNotice(PendingMessage message) { } } - - static String resolveErrorMessage(Exception exception) { - Throwable cursor = exception; - while (cursor != null) { - switch (cursor) { - case ApiRequestException e -> { - return ApiRequestException.getDefaultMessage(e.getErrorCode()); - } - case ClosedChannelException _ -> { - return "oStella API 无法连接,请稍后再试。"; - } - case ResolutionException e -> { - return e.getMessage(); - } - case ReplayRenderException e -> { - return e.getMessage(); - } - default -> { - } - } - cursor = cursor.getCause(); - } - return "请求处理失败,请稍后再试。"; - } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java index 24cf7b32..b5671813 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/BeatmapCommandHandler.java @@ -6,12 +6,14 @@ import xyz.zcraft.seira.api.data.SearchResultItem; import xyz.zcraft.seira.api.data.VideoRenderRecord; import xyz.zcraft.seira.bot.data.PendingMessage; -import xyz.zcraft.seira.command.*; +import xyz.zcraft.seira.command.Context; +import xyz.zcraft.seira.command.TargetHistory; +import xyz.zcraft.seira.command.TaskCoordinator; import xyz.zcraft.seira.command.parse.Resolver; -import xyz.zcraft.seira.command.reply.CommandUsage; -import xyz.zcraft.seira.command.reply.ReplyFactory; import xyz.zcraft.seira.command.parse.ShortcutTarget; import xyz.zcraft.seira.command.parse.TargetResolution; +import xyz.zcraft.seira.command.reply.CommandUsage; +import xyz.zcraft.seira.command.reply.ReplyFactory; import java.util.List; import java.util.function.Function; @@ -302,9 +304,9 @@ public void handleSms(Context ctx) { return; } taskCoordinator.runApiRequest(ctx, "Search Beatmapset", () -> { - Response> searchResponse = APIHelper.searchBeatmapSetResponse(searchQuery); - ctx.sendReply(replyFactory.searchMessage(ctx, searchResponse, searchQuery)); - }); + Response> searchResponse = APIHelper.searchBeatmapSetResponse(searchQuery); + ctx.sendReply(replyFactory.searchMessage(ctx, searchResponse, searchQuery)); + }); } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java index 266d4954..26fe2a67 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/BindingCommandHandler.java @@ -1,11 +1,11 @@ package xyz.zcraft.seira.command.handler; -import xyz.zcraft.seira.services.BindingService; -import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.reply.ReplyFactory; import xyz.zcraft.seira.config.AppConfig; +import xyz.zcraft.seira.db.UserDataStore; +import xyz.zcraft.seira.services.BindingService; public final class BindingCommandHandler { private final AppConfig config; diff --git a/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java index 1782038c..63c83970 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/DcsCommandHandler.java @@ -17,6 +17,10 @@ public DcsCommandHandler(DiscordBridgeService bridgeService) { this.bridgeService = Objects.requireNonNull(bridgeService); } + private static void usage(Context ctx) { + ctx.sendReply(PendingMessage.ofString(USAGE)); + } + public void handleDcs(Context ctx) { if (!ctx.inGroup()) { ctx.sendReply(PendingMessage.ofString("/dcs 仅支持在群聊中使用。")); @@ -70,8 +74,4 @@ private void handleStop(Context ctx) { removed ? "Discord 消息同步已解除。" : "当前群聊尚未开启 Discord 消息同步。" )); } - - private static void usage(Context ctx) { - ctx.sendReply(PendingMessage.ofString(USAGE)); - } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java index a8136b05..1da47a28 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/MultiplayerRoomWatchCommandHandler.java @@ -4,13 +4,13 @@ import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.data.OsuToken; import xyz.zcraft.seira.api.data.Response; -import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.ResolutionException; import xyz.zcraft.seira.command.TaskCoordinator; -import xyz.zcraft.seira.watch.MultiplayerRoomWatchService; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.watch.MultiplayerRoomVersion; +import xyz.zcraft.seira.watch.MultiplayerRoomWatchService; import xyz.zcraft.seira.watch.RoomWatchView; import java.util.Locale; @@ -40,6 +40,53 @@ public MultiplayerRoomWatchCommandHandler( this.watchService = Objects.requireNonNull(watchService); } + static RoomTarget parseRoomTarget(String value, String explicitVersion) { + if (value == null || value.isBlank()) { + return null; + } + String normalized = value.trim(); + String numeric = normalized; + MultiplayerRoomVersion inferredVersion = null; + Matcher lazerMatcher = LAZER_ROOM_URL.matcher(normalized); + Matcher stableMatcher = STABLE_ROOM_URL.matcher(normalized); + if (lazerMatcher.matches()) { + numeric = lazerMatcher.group(1); + inferredVersion = MultiplayerRoomVersion.LAZER; + } else if (stableMatcher.matches()) { + numeric = stableMatcher.group(1); + inferredVersion = MultiplayerRoomVersion.STABLE; + } else if (!normalized.matches("\\d+")) { + return null; + } + + MultiplayerRoomVersion requestedVersion = explicitVersion == null + ? null + : MultiplayerRoomVersion.parse(explicitVersion); + if (explicitVersion != null && requestedVersion == null) { + return null; + } + if (inferredVersion != null && requestedVersion != null && inferredVersion != requestedVersion) { + return null; + } + MultiplayerRoomVersion version = inferredVersion != null + ? inferredVersion + : requestedVersion == null ? MultiplayerRoomVersion.LAZER : requestedVersion; + try { + long roomId = Long.parseLong(numeric); + return roomId > 0 ? new RoomTarget(roomId, version) : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + private static String formatRoom(RoomWatchView view) { + return view.version().value() + " 多人房间“" + view.roomName() + "” (#" + view.roomId() + ")"; + } + + private static void usage(Context ctx) { + ctx.sendReply(PendingMessage.ofString(USAGE)); + } + public void handleMpWatch(Context ctx) { if (!ctx.inGroup()) { ctx.sendReply(PendingMessage.ofString("/mpwatch 仅支持群聊使用。")); @@ -141,53 +188,6 @@ private void handleStatus(Context ctx) { )); } - static RoomTarget parseRoomTarget(String value, String explicitVersion) { - if (value == null || value.isBlank()) { - return null; - } - String normalized = value.trim(); - String numeric = normalized; - MultiplayerRoomVersion inferredVersion = null; - Matcher lazerMatcher = LAZER_ROOM_URL.matcher(normalized); - Matcher stableMatcher = STABLE_ROOM_URL.matcher(normalized); - if (lazerMatcher.matches()) { - numeric = lazerMatcher.group(1); - inferredVersion = MultiplayerRoomVersion.LAZER; - } else if (stableMatcher.matches()) { - numeric = stableMatcher.group(1); - inferredVersion = MultiplayerRoomVersion.STABLE; - } else if (!normalized.matches("\\d+")) { - return null; - } - - MultiplayerRoomVersion requestedVersion = explicitVersion == null - ? null - : MultiplayerRoomVersion.parse(explicitVersion); - if (explicitVersion != null && requestedVersion == null) { - return null; - } - if (inferredVersion != null && requestedVersion != null && inferredVersion != requestedVersion) { - return null; - } - MultiplayerRoomVersion version = inferredVersion != null - ? inferredVersion - : requestedVersion == null ? MultiplayerRoomVersion.LAZER : requestedVersion; - try { - long roomId = Long.parseLong(numeric); - return roomId > 0 ? new RoomTarget(roomId, version) : null; - } catch (NumberFormatException ignored) { - return null; - } - } - - private static String formatRoom(RoomWatchView view) { - return view.version().value() + " 多人房间“" + view.roomName() + "” (#" + view.roomId() + ")"; - } - - private static void usage(Context ctx) { - ctx.sendReply(PendingMessage.ofString(USAGE)); - } - record RoomTarget(long roomId, MultiplayerRoomVersion version) { } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java index 143d2084..b3357de2 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/ReplayCommandHandler.java @@ -3,13 +3,16 @@ import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.data.VideoRenderRecord; import xyz.zcraft.seira.bot.data.PendingMessage; -import xyz.zcraft.seira.command.*; +import xyz.zcraft.seira.command.Context; +import xyz.zcraft.seira.command.ReplayResultStore; +import xyz.zcraft.seira.command.TargetHistory; +import xyz.zcraft.seira.command.TaskCoordinator; import xyz.zcraft.seira.command.parse.Resolver; -import xyz.zcraft.seira.command.reply.CommandUsage; -import xyz.zcraft.seira.command.reply.ReplyFactory; import xyz.zcraft.seira.command.parse.RscTarget; import xyz.zcraft.seira.command.parse.ShortcutTarget; import xyz.zcraft.seira.command.parse.TargetResolution; +import xyz.zcraft.seira.command.reply.CommandUsage; +import xyz.zcraft.seira.command.reply.ReplyFactory; import xyz.zcraft.seira.util.TimeDurationParser; import java.util.function.Function; diff --git a/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java index 1411c5d2..85b99dca 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/SocialCommandHandler.java @@ -6,17 +6,21 @@ import xyz.zcraft.seira.api.data.FriendEntry; import xyz.zcraft.seira.api.data.OsuToken; import xyz.zcraft.seira.api.data.Response; -import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.bot.data.PendingMessage; -import xyz.zcraft.seira.command.*; +import xyz.zcraft.seira.command.Context; +import xyz.zcraft.seira.command.TaskCoordinator; import xyz.zcraft.seira.command.parse.Resolver; -import xyz.zcraft.seira.command.reply.CommandUsage; -import xyz.zcraft.seira.command.reply.ReplyFactory; import xyz.zcraft.seira.command.parse.ShortcutTarget; import xyz.zcraft.seira.command.parse.TargetResolution; +import xyz.zcraft.seira.command.reply.CommandUsage; +import xyz.zcraft.seira.command.reply.ReplyFactory; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.util.OsuAuthHelper; -import java.util.*; +import java.util.Comparator; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; import java.util.function.Function; import java.util.function.Predicate; diff --git a/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java index f25f07f0..9b281b87 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/SpecificScoreWatchCommandHandler.java @@ -23,6 +23,25 @@ public SpecificScoreWatchCommandHandler(TaskCoordinator taskCoordinator, ScoreWa this.watchService = Objects.requireNonNull(watchService); } + static Set parseIds(String value) { + if (value == null || value.isBlank()) { + return null; + } + LinkedHashSet ids = new LinkedHashSet<>(); + for (String part : value.split("[,,]", -1)) { + try { + long id = Long.parseLong(part.trim()); + if (id <= 0) { + return null; + } + ids.add(id); + } catch (NumberFormatException e) { + return null; + } + } + return ids.isEmpty() ? null : Set.copyOf(ids); + } + public void handleWx(Context ctx) { if (!ctx.inGroup()) { ctx.sendReply(PendingMessage.ofString("/wx 仅支持群聊使用。")); @@ -82,23 +101,4 @@ private void handleStop(Context ctx) { private void usage(Context ctx) { ctx.sendReply(PendingMessage.ofString(USAGE)); } - - static Set parseIds(String value) { - if (value == null || value.isBlank()) { - return null; - } - LinkedHashSet ids = new LinkedHashSet<>(); - for (String part : value.split("[,,]", -1)) { - try { - long id = Long.parseLong(part.trim()); - if (id <= 0) { - return null; - } - ids.add(id); - } catch (NumberFormatException e) { - return null; - } - } - return ids.isEmpty() ? null : Set.copyOf(ids); - } } diff --git a/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java index 1d705ab9..cbbf2a24 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/WatchCommandHandler.java @@ -2,12 +2,12 @@ import xyz.zcraft.osu.model.User; import xyz.zcraft.seira.api.APIHelper; -import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.ResolutionException; import xyz.zcraft.seira.command.TaskCoordinator; import xyz.zcraft.seira.command.parse.Resolver; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.watch.ScoreWatchService; import xyz.zcraft.seira.watch.WatchTarget; import xyz.zcraft.seira.watch.WatchView; @@ -40,18 +40,52 @@ public WatchCommandHandler(Resolver resolver, TaskCoordinator taskCoordinator, S this.targetResolver = this::resolveTarget; } - WatchCommandHandler( - Resolver resolver, - TaskCoordinator taskCoordinator, - ScoreWatchService watchService, - Predicate adminAuthorizer, - BiFunction targetResolver - ) { - this.resolver = Objects.requireNonNull(resolver); - this.taskCoordinator = Objects.requireNonNull(taskCoordinator); - this.watchService = watchService; - this.adminAuthorizer = Objects.requireNonNull(adminAuthorizer); - this.targetResolver = Objects.requireNonNull(targetResolver); + private static User findUserById(long userId) { + return APIHelper.getUsers(List.of(userId)).stream() + .filter(user -> user.getId() == userId) + .findFirst() + .orElseThrow(() -> new ResolutionException("未找到指定的玩家。")); + } + + private static PendingMessage removedMessage(WatchView removed) { + return PendingMessage.ofMarkdownRaw( + removed == null + ? "当前群聊中没有该用户的监视任务。" + : displayTarget(removed.target()) + " 的监视已移除。" + ); + } + + private static Integer parseDurationMinutes(String value) { + if (value == null || !value.matches("\\d+")) { + return null; + } + try { + int minutes = Integer.parseInt(value); + return minutes >= 1 && minutes <= MAX_DURATION_MINUTES ? minutes : null; + } catch (NumberFormatException ignored) { + return null; + } + } + + private static String displayTarget(WatchTarget target) { + return target.username() + "()"; + } + + private static String formatRemaining(Duration duration) { + long totalSeconds = Math.max(1, (duration.toMillis() + 999) / 1000); + long minutes = totalSeconds / 60; + long seconds = totalSeconds % 60; + if (minutes == 0) { + return seconds + "秒"; + } + if (seconds == 0) { + return minutes + "分钟"; + } + return minutes + "分" + seconds + "秒"; + } + + private static void usage(Context ctx) { + ctx.sendReply(PendingMessage.ofString(USAGE)); } public void handleWatch(Context ctx) { @@ -196,52 +230,4 @@ private WatchTarget resolveTarget(String groupId, String argument) { UserDataStore.storeUserInfo(user.getId(), user.getUsername()); return new WatchTarget(user.getId(), user.getUsername(), qqOpenId); } - - private static User findUserById(long userId) { - return APIHelper.getUsers(List.of(userId)).stream() - .filter(user -> user.getId() == userId) - .findFirst() - .orElseThrow(() -> new ResolutionException("未找到指定的玩家。")); - } - - private static PendingMessage removedMessage(WatchView removed) { - return PendingMessage.ofMarkdownRaw( - removed == null - ? "当前群聊中没有该用户的监视任务。" - : displayTarget(removed.target()) + " 的监视已移除。" - ); - } - - private static Integer parseDurationMinutes(String value) { - if (value == null || !value.matches("\\d+")) { - return null; - } - try { - int minutes = Integer.parseInt(value); - return minutes >= 1 && minutes <= MAX_DURATION_MINUTES ? minutes : null; - } catch (NumberFormatException ignored) { - return null; - } - } - - private static String displayTarget(WatchTarget target) { - return target.username() + "()"; - } - - private static String formatRemaining(Duration duration) { - long totalSeconds = Math.max(1, (duration.toMillis() + 999) / 1000); - long minutes = totalSeconds / 60; - long seconds = totalSeconds % 60; - if (minutes == 0) { - return seconds + "秒"; - } - if (seconds == 0) { - return minutes + "分钟"; - } - return minutes + "分" + seconds + "秒"; - } - - private static void usage(Context ctx) { - ctx.sendReply(PendingMessage.ofString(USAGE)); - } } diff --git a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java index c0640552..9f98ab36 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/Resolver.java @@ -1,8 +1,8 @@ package xyz.zcraft.seira.command.parse; import xyz.zcraft.seira.api.data.SearchQuery; -import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.data.UserRef; +import xyz.zcraft.seira.db.UserDataStore; import java.util.*; import java.util.regex.MatchResult; diff --git a/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java b/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java index 031413c3..253575b3 100644 --- a/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java +++ b/src/main/java/xyz/zcraft/seira/command/parse/ScoreFilterArguments.java @@ -7,7 +7,9 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -/** Parses score-list filters and converts aliases to oStella's compact filter syntax. */ +/** + * Parses score-list filters and converts aliases to oStella's compact filter syntax. + */ public final class ScoreFilterArguments { 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" diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 43886df2..33b7cba0 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -7,19 +7,19 @@ import xyz.zcraft.osu.model.*; import xyz.zcraft.seira.api.APIHelper; import xyz.zcraft.seira.api.data.*; -import xyz.zcraft.seira.command.handler.RankGuessCommandHandler; -import xyz.zcraft.seira.services.BindingService; -import xyz.zcraft.seira.db.UserDataStore; -import xyz.zcraft.seira.db.RankGuessRecordStore; import xyz.zcraft.seira.bot.data.Button; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; +import xyz.zcraft.seira.command.handler.RankGuessCommandHandler; import xyz.zcraft.seira.config.AppConfig; import xyz.zcraft.seira.config.BindingConfig; import xyz.zcraft.seira.data.UploadedImage; +import xyz.zcraft.seira.db.RankGuessRecordStore; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.rankguess.FinishedRound; import xyz.zcraft.seira.rankguess.RankGuessGameService; import xyz.zcraft.seira.rankguess.Standing; +import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.services.BotStat; import xyz.zcraft.seira.services.DailyLuck; import xyz.zcraft.seira.util.VersionInfo; @@ -135,18 +135,18 @@ public PendingMessage rankGuessStatisticsMessage( String rankText = "?".equals(rank.rank()) ? "" : "根据你的总体表现,可以给到一个 `%s` 喵!\n".formatted(rank.rank()); String groupCountText = ""; - if (allGroups && pickedTimes != null && groupGameCount != null) { - groupCountText = "> 被猜次数:`%d`,占本群:`%.3f%%`".formatted(pickedTimes, (double) pickedTimes / groupGameCount * 100); + if (!allGroups && pickedTimes != null && groupGameCount != null) { + groupCountText = "> 被猜次数:`%d`,占本群:`%.3f%%`\n".formatted(pickedTimes, (double) pickedTimes / groupGameCount * 100); } return PendingMessage.ofMarkdownRaw(at(ctx) + String.format(Locale.ROOT, """ - 你的猜 Rank 战绩(%s) - > 总参与数:`%d`,Rating:`%.2f` - > 获胜数:`%d`,胜率:`%.2f%%` - > 前20%%次数:`%d`,达成率:`%.2f%%` - > 平均分:`%.2f`,最高分:`%.2f` - > 总得分:`%.2f`,平均名次:`%.2f` - %s%s - """, scope, statistics.participation(), rank.rating(), + 你的猜 Rank 战绩(%s) + > 总参与数:`%d`,Rating:`%.2f` + > 获胜数:`%d`,胜率:`%.2f%%` + > 前20%%次数:`%d`,达成率:`%.2f%%` + > 平均分:`%.2f`,最高分:`%.2f` + > 总得分:`%.2f`,平均名次:`%.2f` + %s%s + """, scope, statistics.participation(), rank.rating(), statistics.wins(), statistics.winRate() * 100, statistics.topTwentyCount(), statistics.topTwentyRate() * 100, statistics.averageScore(), statistics.highestScore(), statistics.totalScore(), @@ -155,7 +155,7 @@ public PendingMessage rankGuessStatisticsMessage( public PendingMessage boMessage(Context ctx, Response response) { return PendingMessage.ofMarkdownRaw( - at(ctx) + "查询完成,共" + response.getScoreIds().size() + "个成绩\n" + + at(ctx) + "查询完成,共" + response.getScoreIds().size() + "个成绩\n" + "> 玩家: " + cmd("/u " + response.getUserId(), response.getUserId()), buttons().boButtons(response.getUserId()) ); diff --git a/src/main/java/xyz/zcraft/seira/command/route/DebugRoutes.java b/src/main/java/xyz/zcraft/seira/command/route/DebugRoutes.java index a9ced51f..b2cfb9e5 100644 --- a/src/main/java/xyz/zcraft/seira/command/route/DebugRoutes.java +++ b/src/main/java/xyz/zcraft/seira/command/route/DebugRoutes.java @@ -8,15 +8,15 @@ import xyz.zcraft.seira.api.data.FriendEntry; import xyz.zcraft.seira.api.data.OsuToken; import xyz.zcraft.seira.api.data.Response; -import xyz.zcraft.seira.db.UserDataStore; +import xyz.zcraft.seira.bot.MessageSender; import xyz.zcraft.seira.bot.data.FileInfo; import xyz.zcraft.seira.bot.data.PendingMessage; -import xyz.zcraft.seira.bot.MessageSender; -import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.CommandHandler; +import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.TaskCoordinator; import xyz.zcraft.seira.command.reply.ReplyFactory; import xyz.zcraft.seira.config.AppConfig; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.util.OsuAuthHelper; import java.io.ByteArrayOutputStream; diff --git a/src/main/java/xyz/zcraft/seira/command/route/Router.java b/src/main/java/xyz/zcraft/seira/command/route/Router.java index d8ec9c9c..b9df6464 100644 --- a/src/main/java/xyz/zcraft/seira/command/route/Router.java +++ b/src/main/java/xyz/zcraft/seira/command/route/Router.java @@ -5,8 +5,6 @@ import org.apache.logging.log4j.Logger; import xyz.zcraft.seira.api.data.OsuToken; import xyz.zcraft.seira.api.data.VideoRenderRecord; -import xyz.zcraft.seira.db.UserDataStore; -import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.bot.MessageSender; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.*; @@ -15,12 +13,14 @@ import xyz.zcraft.seira.command.parse.Resolver; import xyz.zcraft.seira.command.reply.ReplyFactory; import xyz.zcraft.seira.config.AppConfig; +import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.discord.DiscordBridgeService; import xyz.zcraft.seira.rankguess.RankGuessGameService; +import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.util.AdminRegistry; import xyz.zcraft.seira.util.OsuAuthHelper; -import xyz.zcraft.seira.watch.ScoreWatchService; import xyz.zcraft.seira.watch.MultiplayerRoomWatchService; +import xyz.zcraft.seira.watch.ScoreWatchService; import java.util.Optional; import java.util.Set; @@ -32,7 +32,8 @@ public class Router { private static final Logger LOG = LogManager.getLogger(Router.class); - + @Getter + private static volatile Context lastContext = null; private final Supplier configSupplier; private final TaskCoordinator taskCoordinator; private final OsuAuthHelper authHelper; @@ -123,6 +124,61 @@ public Router( ); } + private static CommandRegistry createCommandRegistry( + BindingCommandHandler bindingCommands, + ScoreCommandHandler scoreCommands, + BeatmapCommandHandler beatmapCommands, + SocialCommandHandler socialCommands, + ReplayCommandHandler replayCommands, + GeneralCommandHandler generalCommands, + WatchCommandHandler watchCommands, + SpecificScoreWatchCommandHandler specificScoreWatchCommands, + MultiplayerRoomWatchCommandHandler multiplayerRoomWatchCommands, + DcsCommandHandler dcsCommands, + RankGuessCommandHandler rankGuessCommands + ) { + return CommandRegistry.builder() + .register(bindingCommands::handleBind, "bind") + .register(bindingCommands::handleUnbind, "unbind") + .register(bindingCommands::handleClearHistory, "clearhistory") + .register(scoreCommands::handleBo, "bp", "bo") + .register(beatmapCommands::handleDaily, "daily") + .register(socialCommands::handleMp, "mp") + .register(ctx -> scoreCommands.handleRs(ctx, true), "rs") + .register(ctx -> scoreCommands.handleRs(ctx, false), "rp") + .register(scoreCommands::handleTb, "tb") + .register(beatmapCommands::handleM, "m") + .register(beatmapCommands::handleBma, "bma") + .register(beatmapCommands::handleAp, "ap") + .register(beatmapCommands::handleBpv, "bpv") + .register(beatmapCommands::handleBgp, "bgp") + .register(ctx -> socialCommands.handleF(ctx, !ctx.inGroup()), "f") + .register(ctx -> socialCommands.handleF(ctx, true), "fall") + .register(socialCommands::handleFclear, "fclear") + .register(beatmapCommands::handleDl, "dl") + .register(scoreCommands::handleS, "s") + .register(scoreCommands::handleSa, "sa") + .register(scoreCommands::handleMa, "ma") + .register(replayCommands::handleR, "r") + .register(replayCommands::handleRsc, "rsc") + .register(beatmapCommands::handleMs, "ms") + .register(beatmapCommands::handleSms, "sms") + .register(socialCommands::handleLb, "lb") + .register(generalCommands::handleStat, "stat") + .register(generalCommands::handleU, "u") + .register(generalCommands::handleLuck, "luck") + .register(replayCommands::handleRstat, "rstat") + .register(generalCommands::handleInspect, "inspect") + .register(generalCommands::handleHelp, "help") + .register(generalCommands::handleFaq, "faq") + .register(watchCommands::handleWatch, "watch") + .register(specificScoreWatchCommands::handleWx, "wx") + .register(multiplayerRoomWatchCommands::handleMpWatch, "mpwatch", "mpw") + .register(dcsCommands::handleDcs, "dcs") + .register(rankGuessCommands::handleRankGuess, "rg") + .build(); + } + public void onPrivateMessageReceived(String userId, String messageId, String rawContent) { handleMessageReceived(userId, null, userId, messageId, rawContent, false); } @@ -181,9 +237,6 @@ private void handleMessageReceived(String targetId, String groupId, String userI } } - @Getter - private static volatile Context lastContext = null; - private void dispatch(Context ctx) { lastContext = ctx; commandMetric.run(); @@ -194,61 +247,6 @@ private void dispatch(Context ctx) { commandRegistry.dispatch(ctx, unknownCommand); } - private static CommandRegistry createCommandRegistry( - BindingCommandHandler bindingCommands, - ScoreCommandHandler scoreCommands, - BeatmapCommandHandler beatmapCommands, - SocialCommandHandler socialCommands, - ReplayCommandHandler replayCommands, - GeneralCommandHandler generalCommands, - WatchCommandHandler watchCommands, - SpecificScoreWatchCommandHandler specificScoreWatchCommands, - MultiplayerRoomWatchCommandHandler multiplayerRoomWatchCommands, - DcsCommandHandler dcsCommands, - RankGuessCommandHandler rankGuessCommands - ) { - return CommandRegistry.builder() - .register(bindingCommands::handleBind, "bind") - .register(bindingCommands::handleUnbind, "unbind") - .register(bindingCommands::handleClearHistory, "clearhistory") - .register(scoreCommands::handleBo, "bp", "bo") - .register(beatmapCommands::handleDaily, "daily") - .register(socialCommands::handleMp, "mp") - .register(ctx -> scoreCommands.handleRs(ctx, true), "rs") - .register(ctx -> scoreCommands.handleRs(ctx, false), "rp") - .register(scoreCommands::handleTb, "tb") - .register(beatmapCommands::handleM, "m") - .register(beatmapCommands::handleBma, "bma") - .register(beatmapCommands::handleAp, "ap") - .register(beatmapCommands::handleBpv, "bpv") - .register(beatmapCommands::handleBgp, "bgp") - .register(ctx -> socialCommands.handleF(ctx, !ctx.inGroup()), "f") - .register(ctx -> socialCommands.handleF(ctx, true), "fall") - .register(socialCommands::handleFclear, "fclear") - .register(beatmapCommands::handleDl, "dl") - .register(scoreCommands::handleS, "s") - .register(scoreCommands::handleSa, "sa") - .register(scoreCommands::handleMa, "ma") - .register(replayCommands::handleR, "r") - .register(replayCommands::handleRsc, "rsc") - .register(beatmapCommands::handleMs, "ms") - .register(beatmapCommands::handleSms, "sms") - .register(socialCommands::handleLb, "lb") - .register(generalCommands::handleStat, "stat") - .register(generalCommands::handleU, "u") - .register(generalCommands::handleLuck, "luck") - .register(replayCommands::handleRstat, "rstat") - .register(generalCommands::handleInspect, "inspect") - .register(generalCommands::handleHelp, "help") - .register(generalCommands::handleFaq, "faq") - .register(watchCommands::handleWatch, "watch") - .register(specificScoreWatchCommands::handleWx, "wx") - .register(multiplayerRoomWatchCommands::handleMpWatch, "mpwatch", "mpw") - .register(dcsCommands::handleDcs, "dcs") - .register(rankGuessCommands::handleRankGuess, "rg") - .build(); - } - @SuppressWarnings("unused") public Set registeredCommands() { return commandRegistry.registeredCommands(); diff --git a/src/main/java/xyz/zcraft/seira/config/ConfigValidator.java b/src/main/java/xyz/zcraft/seira/config/ConfigValidator.java index 6bf09d8c..05defeba 100644 --- a/src/main/java/xyz/zcraft/seira/config/ConfigValidator.java +++ b/src/main/java/xyz/zcraft/seira/config/ConfigValidator.java @@ -4,7 +4,9 @@ import java.util.ArrayList; import java.util.List; -/** Validates configuration once at the application boundary. */ +/** + * Validates configuration once at the application boundary. + */ public final class ConfigValidator { private ConfigValidator() { } diff --git a/src/main/java/xyz/zcraft/seira/config/RuntimeConfig.java b/src/main/java/xyz/zcraft/seira/config/RuntimeConfig.java index 7e6edbc7..3252f236 100644 --- a/src/main/java/xyz/zcraft/seira/config/RuntimeConfig.java +++ b/src/main/java/xyz/zcraft/seira/config/RuntimeConfig.java @@ -35,43 +35,6 @@ public RuntimeConfig(AppConfig initial) { this.loader = Objects.requireNonNull(loader, "loader"); } - public AppConfig current() { - return current.get(); - } - - public List pendingRestart() { - return pendingRestart; - } - - public void addReloadListener(Consumer listener) { - listeners.add(Objects.requireNonNull(listener)); - } - - /** Reads and validates the source configuration without applying it. */ - public AppConfig validateSource() { - return loader.get(); - } - - public synchronized ReloadResult reload() { - AppConfig previous = current.get(); - AppConfig loaded = loader.get(); - List applied = appliedChanges(previous, loaded); - List restartRequired = restartRequiredChanges(previous, loaded); - AppConfig effective = mergeReloadable(previous, loaded); - - current.set(effective); - pendingRestart = List.copyOf(restartRequired); - ReloadResult result = new ReloadResult(effective, applied, pendingRestart); - for (Consumer listener : listeners) { - try { - listener.accept(result); - } catch (RuntimeException e) { - LOG.error("Runtime configuration listener failed", e); - } - } - return result; - } - private static AppConfig mergeReloadable(AppConfig previous, AppConfig loaded) { SeiraConfig oldSeira = previous.seira(); SeiraConfig newSeira = loaded.seira(); @@ -128,6 +91,45 @@ private static void addIfChanged(List changed, String name, Object befor } } + public AppConfig current() { + return current.get(); + } + + public List pendingRestart() { + return pendingRestart; + } + + public void addReloadListener(Consumer listener) { + listeners.add(Objects.requireNonNull(listener)); + } + + /** + * Reads and validates the source configuration without applying it. + */ + public AppConfig validateSource() { + return loader.get(); + } + + public synchronized ReloadResult reload() { + AppConfig previous = current.get(); + AppConfig loaded = loader.get(); + List applied = appliedChanges(previous, loaded); + List restartRequired = restartRequiredChanges(previous, loaded); + AppConfig effective = mergeReloadable(previous, loaded); + + current.set(effective); + pendingRestart = List.copyOf(restartRequired); + ReloadResult result = new ReloadResult(effective, applied, pendingRestart); + for (Consumer listener : listeners) { + try { + listener.accept(result); + } catch (RuntimeException e) { + LOG.error("Runtime configuration listener failed", e); + } + } + return result; + } + public record ReloadResult( AppConfig effectiveConfig, List applied, diff --git a/src/main/java/xyz/zcraft/seira/console/ConsoleCommandProcessor.java b/src/main/java/xyz/zcraft/seira/console/ConsoleCommandProcessor.java index d68c1134..f7e3ced9 100644 --- a/src/main/java/xyz/zcraft/seira/console/ConsoleCommandProcessor.java +++ b/src/main/java/xyz/zcraft/seira/console/ConsoleCommandProcessor.java @@ -4,25 +4,20 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.core.config.Configurator; -import xyz.zcraft.seira.db.SqliteDatabase; -import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.bot.MessageSender; import xyz.zcraft.seira.command.Context; import xyz.zcraft.seira.command.route.Router; import xyz.zcraft.seira.config.AppConfig; import xyz.zcraft.seira.config.RuntimeConfig; -import xyz.zcraft.seira.util.AdminRegistry; +import xyz.zcraft.seira.db.SqliteDatabase; import xyz.zcraft.seira.services.BotStat; +import xyz.zcraft.seira.util.AdminRegistry; import xyz.zcraft.seira.watch.WatchView; import java.io.IOException; import java.io.InputStream; import java.time.Duration; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Objects; -import java.util.Properties; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; @@ -68,6 +63,152 @@ public ConsoleCommandProcessor( this.runtimeControl = Objects.requireNonNull(runtimeControl); } + static List rootCommands() { + return ROOT_COMMANDS; + } + + static List subcommands(String command) { + return SUBCOMMANDS.getOrDefault(command.toLowerCase(Locale.ROOT), List.of()); + } + + private static String formatCacheControl(ConsoleRuntimeControl.CacheControlResult result) { + StringBuilder output = new StringBuilder(result.operation().toLowerCase(Locale.ROOT)) + .append(' ').append(result.type().toLowerCase(Locale.ROOT)).append(' ').append(result.id()); + for (ConsoleRuntimeControl.CacheNodeResult node : result.nodes()) { + output.append("\n ").append(node.node()).append(": ").append(node.status()); + if (node.path() != null) output.append(" | path=").append(node.path()); + if (node.sizeBytes() != null) output.append(" | size=").append(formatBytes(node.sizeBytes())); + if (node.modifiedAt() != null) output.append(" | modified=").append(node.modifiedAt()); + if (node.message() != null) output.append(" | ").append(node.message()); + } + return output.toString(); + } + + private static ConsoleResult exact( + ConsoleInputParser.ParsedInput input, + int size, + java.util.function.Supplier action, + String usage + ) { + return input.size() == size ? action.get() : ConsoleResult.failure(usage); + } + + private static String formatWatches(String groupId, List watches) { + if (watches.isEmpty()) { + return "Group " + groupId + ": no active watches."; + } + StringBuilder output = new StringBuilder("Group " + groupId + " (" + watches.size() + "):"); + watches.forEach(watch -> output.append("\n ") + .append(watch.target().username()) + .append(" | UID ").append(watch.target().userId()) + .append(" | QQ ").append(watch.target().qqOpenId()) + .append(" | remaining ").append(formatDuration(watch.remaining()))); + return output.toString(); + } + + private static String formatQueryResult(SqliteDatabase.QueryResult result) { + StringBuilder output = new StringBuilder(); + output.append(result.columns().stream().map(ConsoleCommandProcessor::cell).reduce( + (left, right) -> left + " | " + right + ).orElse("(no columns)")); + for (List row : result.rows()) { + output.append('\n').append(row.stream().map(ConsoleCommandProcessor::cell).reduce( + (left, right) -> left + " | " + right + ).orElse("")); + } + output.append("\nReturned ").append(result.rows().size()).append(" row(s)"); + if (result.truncated()) { + output.append(" (result truncated)"); + } + return output.toString(); + } + + private static String cell(String value) { + if (value == null) { + return "NULL"; + } + String normalized = value.replace('\n', ' ').replace('\r', ' ').replace('\t', ' '); + return normalized.length() <= MAX_CELL_LENGTH + ? normalized + : normalized.substring(0, MAX_CELL_LENGTH - 1) + "..."; + } + + private static long positiveLong(String value, String name) { + try { + long parsed = Long.parseLong(value); + if (parsed > 0) { + return parsed; + } + } catch (NumberFormatException ignored) { + } + throw new IllegalArgumentException(name + " must be a positive integer."); + } + + private static String formatDuration(Duration duration) { + if (duration == null || duration.isNegative()) { + return "unknown"; + } + long seconds = duration.toSeconds(); + long days = seconds / 86_400; + long hours = seconds % 86_400 / 3_600; + long minutes = seconds % 3_600 / 60; + long remainingSeconds = seconds % 60; + if (days > 0) { + return "%dd %02dh %02dm".formatted(days, hours, minutes); + } + if (hours > 0) { + return "%dh %02dm %02ds".formatted(hours, minutes, remainingSeconds); + } + if (minutes > 0) { + return "%dm %02ds".formatted(minutes, remainingSeconds); + } + return remainingSeconds + "s"; + } + + private static String formatBytes(long bytes) { + if (bytes < 1024) { + return bytes + " B"; + } + double value = bytes; + String[] units = {"KiB", "MiB", "GiB", "TiB"}; + int unit = -1; + do { + value /= 1024.0; + unit++; + } while (value >= 1024 && unit < units.length - 1); + return String.format(Locale.ROOT, "%.1f %s", value, units[unit]); + } + + private static String blankAs(String value, String fallback) { + return value == null || value.isBlank() ? fallback : value; + } + + private static String version() { + Package currentPackage = ConsoleCommandProcessor.class.getPackage(); + String implementation = currentPackage == null ? null : currentPackage.getImplementationVersion(); + if (implementation != null && !implementation.isBlank()) { + return implementation; + } + Properties properties = new Properties(); + try (InputStream input = ConsoleCommandProcessor.class.getResourceAsStream("/version.properties")) { + if (input != null) { + properties.load(input); + return properties.getProperty("version", "development"); + } + } catch (IOException ignored) { + } + return "development"; + } + + private static String rootMessage(Throwable error) { + Throwable cursor = error; + while (cursor.getCause() != null) { + cursor = cursor.getCause(); + } + String message = cursor.getMessage(); + return message == null || message.isBlank() ? cursor.getClass().getSimpleName() : message; + } + public ConsoleResult execute(String line) { try { ConsoleInputParser.ParsedInput input = ConsoleInputParser.parse(line); @@ -102,14 +243,6 @@ public ConsoleResult execute(String line) { } } - static List rootCommands() { - return ROOT_COMMANDS; - } - - static List subcommands(String command) { - return SUBCOMMANDS.getOrDefault(command.toLowerCase(Locale.ROOT), List.of()); - } - private ConsoleResult help(ConsoleInputParser.ParsedInput input) { if (input.size() > 2) { return ConsoleResult.failure("Usage: help [command]"); @@ -135,7 +268,7 @@ private ConsoleResult help(ConsoleInputParser.ParsedInput input) { log Inspect or change the runtime log level inspect Show the last dispatched message context stop confirm Gracefully stop SeiraCore - + Aliases: ? (help), stat (status), shutdown/exit/quit (stop) Use 'help ' for examples and safety notes. """.stripTrailing()); @@ -195,7 +328,8 @@ private ConsoleResult help(ConsoleInputParser.ParsedInput input) { panel edit Manage command panels to be displayed on QQ platform."""; case "inspect" -> "inspect\nShows IDs and parsed command data from the last dispatched QQ message."; - case "stop", "shutdown", "exit", "quit" -> "stop confirm\nGracefully stops the gateway and all application services."; + case "stop", "shutdown", "exit", "quit" -> + "stop confirm\nGracefully stops the gateway and all application services."; default -> null; }; return detail == null @@ -367,11 +501,16 @@ private ConsoleResult panel(ConsoleInputParser.ParsedInput input) { return ConsoleResult.failure("Usage: panel [args]"); } return switch (input.value(1).toLowerCase(Locale.ROOT)) { - case "list" -> input.size() == 3 ? runtimeControl.listPanels(input.value(2)) : ConsoleResult.failure("Usage: panel list "); - case "delete" -> input.size() == 3 ? runtimeControl.deletePanel(input.value(2)) : ConsoleResult.failure("Usage: panel delete "); - case "get" -> input.size() == 3 ? runtimeControl.getPanel(input.value(2)) : ConsoleResult.failure("Usage: panel get "); - case "create" -> input.size() == 4 ? runtimeControl.createPanel(input.value(2), input.value(3)) : ConsoleResult.failure("Usage: panel create "); - case "edit" -> input.size() == 4 ? runtimeControl.editPanel(input.value(2), input.value(3)) : ConsoleResult.failure("Usage: panel edit "); + case "list" -> + input.size() == 3 ? runtimeControl.listPanels(input.value(2)) : ConsoleResult.failure("Usage: panel list "); + case "delete" -> + input.size() == 3 ? runtimeControl.deletePanel(input.value(2)) : ConsoleResult.failure("Usage: panel delete "); + case "get" -> + input.size() == 3 ? runtimeControl.getPanel(input.value(2)) : ConsoleResult.failure("Usage: panel get "); + case "create" -> + input.size() == 4 ? runtimeControl.createPanel(input.value(2), input.value(3)) : ConsoleResult.failure("Usage: panel create "); + case "edit" -> + input.size() == 4 ? runtimeControl.editPanel(input.value(2), input.value(3)) : ConsoleResult.failure("Usage: panel edit "); default -> ConsoleResult.failure("Usage: panel [args]"); }; } @@ -485,7 +624,8 @@ private ConsoleResult watch(ConsoleInputParser.ParsedInput input) { } return switch (input.value(1).toLowerCase(Locale.ROOT)) { case "status" -> input.size() == 2 ? watchStatus() : ConsoleResult.failure("Usage: watch status"); - case "list" -> input.size() <= 3 ? listWatches(input) : ConsoleResult.failure("Usage: watch list [group-id]"); + case "list" -> + input.size() <= 3 ? listWatches(input) : ConsoleResult.failure("Usage: watch list [group-id]"); case "poll" -> input.size() == 2 ? pollWatches() : ConsoleResult.failure("Usage: watch poll"); case "remove" -> removeWatch(input); case "clear" -> clearWatches(input); @@ -584,19 +724,6 @@ private ConsoleResult cache(ConsoleInputParser.ParsedInput input) { return ConsoleResult.success(formatCacheControl(runtimeControl.controlCache(operation, type, id))); } - private static String formatCacheControl(ConsoleRuntimeControl.CacheControlResult result) { - StringBuilder output = new StringBuilder(result.operation().toLowerCase(Locale.ROOT)) - .append(' ').append(result.type().toLowerCase(Locale.ROOT)).append(' ').append(result.id()); - for (ConsoleRuntimeControl.CacheNodeResult node : result.nodes()) { - output.append("\n ").append(node.node()).append(": ").append(node.status()); - if (node.path() != null) output.append(" | path=").append(node.path()); - if (node.sizeBytes() != null) output.append(" | size=").append(formatBytes(node.sizeBytes())); - if (node.modifiedAt() != null) output.append(" | modified=").append(node.modifiedAt()); - if (node.message() != null) output.append(" | ").append(node.message()); - } - return output.toString(); - } - private ConsoleResult log(ConsoleInputParser.ParsedInput input) { if (input.size() == 2 && "show".equalsIgnoreCase(input.value(1))) { return ConsoleResult.success("Root log level: " + LogManager.getRootLogger().getLevel()); @@ -650,131 +777,6 @@ private ConsoleResult stop(ConsoleInputParser.ParsedInput input) { return ConsoleResult.success("Graceful shutdown requested."); } - private static ConsoleResult exact( - ConsoleInputParser.ParsedInput input, - int size, - java.util.function.Supplier action, - String usage - ) { - return input.size() == size ? action.get() : ConsoleResult.failure(usage); - } - - private static String formatWatches(String groupId, List watches) { - if (watches.isEmpty()) { - return "Group " + groupId + ": no active watches."; - } - StringBuilder output = new StringBuilder("Group " + groupId + " (" + watches.size() + "):"); - watches.forEach(watch -> output.append("\n ") - .append(watch.target().username()) - .append(" | UID ").append(watch.target().userId()) - .append(" | QQ ").append(watch.target().qqOpenId()) - .append(" | remaining ").append(formatDuration(watch.remaining()))); - return output.toString(); - } - - private static String formatQueryResult(SqliteDatabase.QueryResult result) { - StringBuilder output = new StringBuilder(); - output.append(result.columns().stream().map(ConsoleCommandProcessor::cell).reduce( - (left, right) -> left + " | " + right - ).orElse("(no columns)")); - for (List row : result.rows()) { - output.append('\n').append(row.stream().map(ConsoleCommandProcessor::cell).reduce( - (left, right) -> left + " | " + right - ).orElse("")); - } - output.append("\nReturned ").append(result.rows().size()).append(" row(s)"); - if (result.truncated()) { - output.append(" (result truncated)"); - } - return output.toString(); - } - - private static String cell(String value) { - if (value == null) { - return "NULL"; - } - String normalized = value.replace('\n', ' ').replace('\r', ' ').replace('\t', ' '); - return normalized.length() <= MAX_CELL_LENGTH - ? normalized - : normalized.substring(0, MAX_CELL_LENGTH - 1) + "..."; - } - - private static long positiveLong(String value, String name) { - try { - long parsed = Long.parseLong(value); - if (parsed > 0) { - return parsed; - } - } catch (NumberFormatException ignored) { - } - throw new IllegalArgumentException(name + " must be a positive integer."); - } - - private static String formatDuration(Duration duration) { - if (duration == null || duration.isNegative()) { - return "unknown"; - } - long seconds = duration.toSeconds(); - long days = seconds / 86_400; - long hours = seconds % 86_400 / 3_600; - long minutes = seconds % 3_600 / 60; - long remainingSeconds = seconds % 60; - if (days > 0) { - return "%dd %02dh %02dm".formatted(days, hours, minutes); - } - if (hours > 0) { - return "%dh %02dm %02ds".formatted(hours, minutes, remainingSeconds); - } - if (minutes > 0) { - return "%dm %02ds".formatted(minutes, remainingSeconds); - } - return remainingSeconds + "s"; - } - - private static String formatBytes(long bytes) { - if (bytes < 1024) { - return bytes + " B"; - } - double value = bytes; - String[] units = {"KiB", "MiB", "GiB", "TiB"}; - int unit = -1; - do { - value /= 1024.0; - unit++; - } while (value >= 1024 && unit < units.length - 1); - return String.format(Locale.ROOT, "%.1f %s", value, units[unit]); - } - - private static String blankAs(String value, String fallback) { - return value == null || value.isBlank() ? fallback : value; - } - - private static String version() { - Package currentPackage = ConsoleCommandProcessor.class.getPackage(); - String implementation = currentPackage == null ? null : currentPackage.getImplementationVersion(); - if (implementation != null && !implementation.isBlank()) { - return implementation; - } - Properties properties = new Properties(); - try (InputStream input = ConsoleCommandProcessor.class.getResourceAsStream("/version.properties")) { - if (input != null) { - properties.load(input); - return properties.getProperty("version", "development"); - } - } catch (IOException ignored) { - } - return "development"; - } - - private static String rootMessage(Throwable error) { - Throwable cursor = error; - while (cursor.getCause() != null) { - cursor = cursor.getCause(); - } - String message = cursor.getMessage(); - return message == null || message.isBlank() ? cursor.getClass().getSimpleName() : message; - } - public record ConsoleResult(boolean success, String message) { public static ConsoleResult success(String message) { return new ConsoleResult(true, message); diff --git a/src/main/java/xyz/zcraft/seira/console/JLineConsole.java b/src/main/java/xyz/zcraft/seira/console/JLineConsole.java index 4cdcad12..eb877b0d 100644 --- a/src/main/java/xyz/zcraft/seira/console/JLineConsole.java +++ b/src/main/java/xyz/zcraft/seira/console/JLineConsole.java @@ -2,13 +2,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import org.jline.reader.Candidate; -import org.jline.reader.Completer; -import org.jline.reader.EndOfFileException; -import org.jline.reader.LineReader; -import org.jline.reader.LineReaderBuilder; -import org.jline.reader.ParsedLine; -import org.jline.reader.UserInterruptException; +import org.jline.reader.*; import org.jline.reader.impl.DefaultParser; import org.jline.terminal.Terminal; import org.jline.terminal.TerminalBuilder; @@ -21,7 +15,9 @@ import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; -/** Interactive local administration console backed by JLine. */ +/** + * Interactive local administration console backed by JLine. + */ public final class JLineConsole implements AutoCloseable { private static final Logger LOG = LogManager.getLogger(JLineConsole.class); private static final String PROMPT = "seira> "; diff --git a/src/main/java/xyz/zcraft/seira/console/JLineLogBridge.java b/src/main/java/xyz/zcraft/seira/console/JLineLogBridge.java index 8709c79a..63479575 100644 --- a/src/main/java/xyz/zcraft/seira/console/JLineLogBridge.java +++ b/src/main/java/xyz/zcraft/seira/console/JLineLogBridge.java @@ -17,7 +17,9 @@ import java.util.Objects; import java.util.concurrent.atomic.AtomicBoolean; -/** Routes terminal log events through JLine so the active prompt can be redrawn safely. */ +/** + * Routes terminal log events through JLine so the active prompt can be redrawn safely. + */ final class JLineLogBridge implements AutoCloseable { private static final String CONSOLE_APPENDER_NAME = "CONSOLE"; private static final String JLINE_APPENDER_NAME = "JLINE_CONSOLE"; diff --git a/src/main/java/xyz/zcraft/seira/data/UserRef.java b/src/main/java/xyz/zcraft/seira/data/UserRef.java index b8c4409f..87dc0cc1 100644 --- a/src/main/java/xyz/zcraft/seira/data/UserRef.java +++ b/src/main/java/xyz/zcraft/seira/data/UserRef.java @@ -3,7 +3,8 @@ import lombok.Getter; public class UserRef { - private UserRef() {} + private UserRef() { + } public static class ByUid extends UserRef { @Getter diff --git a/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java b/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java index f7dfc33f..e1944e22 100644 --- a/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java +++ b/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java @@ -11,7 +11,9 @@ public class RankGuessRecordStore { public static final int TOP_TWENTY_MIN_PARTICIPANTS = 1; - /** Returns false when this round has already been saved. All rows are committed together. */ + /** + * Returns false when this round has already been saved. All rows are committed together. + */ public static boolean save(FinishedRound finished) { validate(finished); String gameSql = """ @@ -79,7 +81,7 @@ public static long getPickedTimes(Long osuUid, String groupId) { String sql = """ SELECT COUNT(*) FROM rank_guess_games g - WHERE g.target_score_id = ? + WHERE g.target_user_id = ? """; if (groupId != null) sql += " AND g.group_id = ?"; try (Connection connection = SqliteDatabase.getConnection(); @@ -187,28 +189,6 @@ SELECT COUNT(*) AS participation, } } - public static class Statistics { - public record Personal( - long participation, long wins, long topTwentyCount, long topTwentyEligibleParticipation, - double totalScore, double averageScore, double highestScore, double averagePlacement - ) { - public double winRate() { - return participation == 0 ? 0 : wins / (double) participation; - } - - public double topTwentyRate() { - return topTwentyEligibleParticipation == 0 ? 0 - : topTwentyCount / (double) topTwentyEligibleParticipation; - } - } - } - - public static final class RecordSaveException extends RuntimeException { - public RecordSaveException(String message, Throwable cause) { - super(message, cause); - } - } - private static void validate(FinishedRound finished) { Objects.requireNonNull(finished, "finished"); Objects.requireNonNull(finished.id(), "id"); @@ -240,11 +220,11 @@ CREATE TABLE IF NOT EXISTS rank_guess_games ( group_id TEXT NOT NULL, source_mode TEXT NOT NULL CHECK (source_mode IN ('random', 'group')), - + target_user_id INTEGER NOT NULL, target_score_id INTEGER NOT NULL, actual_rank INTEGER NOT NULL CHECK (actual_rank > 0), - + started_at INTEGER NOT NULL, ended_at INTEGER NOT NULL, participant_count INTEGER NOT NULL CHECK (participant_count >= 0), @@ -255,13 +235,13 @@ participant_count INTEGER NOT NULL CHECK (participant_count >= 0), CREATE TABLE IF NOT EXISTS rank_guess_results ( round_id TEXT NOT NULL, user_id TEXT NOT NULL, - + guessed_rank INTEGER NOT NULL CHECK (guessed_rank > 0), placement INTEGER NOT NULL CHECK (placement > 0), raw_score REAL NOT NULL, multiplier REAL NOT NULL, final_score REAL NOT NULL, - + PRIMARY KEY (round_id, user_id), FOREIGN KEY (round_id) REFERENCES rank_guess_games(round_id) ); @@ -281,4 +261,26 @@ FOREIGN KEY (round_id) REFERENCES rank_guess_games(round_id) statement.execute(rgGameIndex); } } + + public static class Statistics { + public record Personal( + long participation, long wins, long topTwentyCount, long topTwentyEligibleParticipation, + double totalScore, double averageScore, double highestScore, double averagePlacement + ) { + public double winRate() { + return participation == 0 ? 0 : wins / (double) participation; + } + + public double topTwentyRate() { + return topTwentyEligibleParticipation == 0 ? 0 + : topTwentyCount / (double) topTwentyEligibleParticipation; + } + } + } + + public static final class RecordSaveException extends RuntimeException { + public RecordSaveException(String message, Throwable cause) { + super(message, cause); + } + } } diff --git a/src/main/java/xyz/zcraft/seira/db/SqliteDatabase.java b/src/main/java/xyz/zcraft/seira/db/SqliteDatabase.java index da430df7..74da1b15 100644 --- a/src/main/java/xyz/zcraft/seira/db/SqliteDatabase.java +++ b/src/main/java/xyz/zcraft/seira/db/SqliteDatabase.java @@ -68,9 +68,6 @@ public static void ensureInitialized() { } } - public record QueryResult(List columns, List> rows, boolean truncated) { - } - public static QueryResult queryReadOnly(String sql, int maxRows) { SqliteDatabase.ensureInitialized(); if (sql == null || sql.isBlank()) { @@ -142,4 +139,7 @@ private static boolean isAllowedReadOnlyPragma(String normalizedSql) { "index_xinfo", "foreign_key_list", "database_list", "compile_options" ).contains(name); } + + public record QueryResult(List columns, List> rows, boolean truncated) { + } } diff --git a/src/main/java/xyz/zcraft/seira/db/UserDataStore.java b/src/main/java/xyz/zcraft/seira/db/UserDataStore.java index 8af8a72f..e589b994 100644 --- a/src/main/java/xyz/zcraft/seira/db/UserDataStore.java +++ b/src/main/java/xyz/zcraft/seira/db/UserDataStore.java @@ -8,14 +8,7 @@ import xyz.zcraft.seira.watch.SpecificScoreWatchState; import java.sql.*; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.LinkedList; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; +import java.util.*; public final class UserDataStore { private static final Logger LOG = LogManager.getLogger(UserDataStore.class); @@ -338,10 +331,10 @@ public static int clearGroupMember(String openId) { public static List findAllGroupMembers(String groupId) { SqliteDatabase.ensureInitialized(); String sql = """ - SELECT open_id - FROM group_members - WHERE group_id = ?; - """; + SELECT open_id + FROM group_members + WHERE group_id = ?; + """; List groupMembers = new LinkedList<>(); try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { diff --git a/src/main/java/xyz/zcraft/seira/discord/DiscordBridgeService.java b/src/main/java/xyz/zcraft/seira/discord/DiscordBridgeService.java index 83a8e1b8..1860d260 100644 --- a/src/main/java/xyz/zcraft/seira/discord/DiscordBridgeService.java +++ b/src/main/java/xyz/zcraft/seira/discord/DiscordBridgeService.java @@ -13,28 +13,16 @@ import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import xyz.zcraft.seira.db.UserDataStore; +import org.jetbrains.annotations.NotNull; import xyz.zcraft.seira.bot.MessageSender; -import xyz.zcraft.seira.bot.data.Attachment; -import xyz.zcraft.seira.bot.data.FileInfo; -import xyz.zcraft.seira.bot.data.MDMessage; -import xyz.zcraft.seira.bot.data.Message; -import xyz.zcraft.seira.bot.data.PendingMessage; +import xyz.zcraft.seira.bot.data.*; import xyz.zcraft.seira.config.BridgeConfig; import xyz.zcraft.seira.config.DiscordConfig; import xyz.zcraft.seira.config.DiscordProxyConfig; +import xyz.zcraft.seira.db.UserDataStore; import java.net.URI; -import java.util.ArrayDeque; -import java.util.ArrayList; -import java.util.Base64; -import java.util.HashSet; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Queue; -import java.util.Set; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; @@ -71,6 +59,187 @@ public DiscordBridgeService(DiscordConfig discordConfig, BridgeConfig bridgeConf UserDataStore.findAllDiscordBridges().forEach(mapping -> mappings.put(mapping.groupId(), mapping)); } + private static List collectDiscordAttachments(MessageReceivedEvent event) { + List attachments = new ArrayList<>(); + Set seen = new HashSet<>(); + for (String url : BridgeFormatter.findImageUrls(event.getMessage().getContentDisplay())) { + if (seen.add(url)) { + attachments.add(new BridgeAttachment( + filenameFromUrl(url), List.of(url), url, isGifUrl(url) + )); + } + } + event.getMessage().getAttachments().forEach(item -> { + if (seen.add(item.getUrl())) { + attachments.add(new BridgeAttachment( + item.getFileName(), distinct(item.getUrl(), item.getProxyUrl()), null, + "image/gif".equalsIgnoreCase(item.getContentType()) + )); + } + }); + for (MessageEmbed embed : event.getMessage().getEmbeds()) { + if (embed.getType() == EmbedType.GIFV && embed.getVideoInfo() != null) { + List candidates = gifCandidates(embed); + String key = candidates.isEmpty() ? null : candidates.getFirst(); + if (key != null && seen.add(key)) { + attachments.add(new BridgeAttachment("discord-gif.gif", candidates, embed.getUrl(), true)); + } + continue; + } + MessageEmbed.ImageInfo image = embed.getImage(); + if (image != null && seen.add(image.getUrl())) { + attachments.add(new BridgeAttachment( + "embed-image", distinct(image.getUrl(), image.getProxyUrl()), embed.getUrl(), false + )); + } + } + event.getMessage().getStickers().forEach(sticker -> { + if (seen.add(sticker.getIconUrl())) { + attachments.add(new BridgeAttachment( + sticker.getName(), List.of(sticker.getIconUrl()), null, + sticker.getIconUrl().toLowerCase(Locale.ROOT).contains(".gif") + )); + } + }); + event.getMessage().getMentions().getCustomEmojis().forEach(emoji -> { + if (seen.add(emoji.getImageUrl())) { + attachments.add(new BridgeAttachment( + "emoji-" + emoji.getName(), List.of(emoji.getImageUrl()), null, emoji.isAnimated() + )); + } + }); + return List.copyOf(attachments); + } + + private static String filenameFromUrl(String value) { + try { + String path = URI.create(value).getPath(); + if (path != null && path.lastIndexOf('/') < path.length() - 1) { + return path.substring(path.lastIndexOf('/') + 1); + } + } catch (IllegalArgumentException ignored) { + // The downloader will report malformed URLs if one reaches it. + } + return "discord-image"; + } + + private static boolean isGifUrl(String value) { + try { + String path = URI.create(value).getPath(); + return path != null && path.toLowerCase(Locale.ROOT).endsWith(".gif"); + } catch (IllegalArgumentException ignored) { + return false; + } + } + + static List gifCandidates(MessageEmbed embed) { + LinkedHashSet candidates = new LinkedHashSet<>(); + MessageEmbed.VideoInfo video = embed.getVideoInfo(); + if (video != null) { + addDerivedGif(candidates, video.getUrl()); + addDerivedGif(candidates, video.getProxyUrl()); + add(candidates, video.getUrl()); + add(candidates, video.getProxyUrl()); + } + MessageEmbed.ImageInfo image = embed.getImage(); + if (image != null) { + add(candidates, image.getUrl()); + add(candidates, image.getProxyUrl()); + } + return List.copyOf(candidates); + } + + static String deriveTenorGifUrl(String mediaUrl) { + if (mediaUrl == null || mediaUrl.isBlank()) return null; + try { + URI uri = URI.create(mediaUrl); + String host = uri.getHost(); + String path = uri.getRawPath(); + if (host == null || path == null || !host.toLowerCase(Locale.ROOT) + .matches("media[0-9]*\\.tenor\\.(com|co)")) { + return replaceVideoExtension(mediaUrl); + } + String[] segments = path.split("/"); + for (int index = 0; index < segments.length; index++) { + if (segments[index].endsWith("AAAPo")) { + segments[index] = segments[index].substring(0, segments[index].length() - 5) + "AAAAC"; + } + } + String rebuilt = String.join("/", segments); + int dot = rebuilt.lastIndexOf('.'); + if (dot > rebuilt.lastIndexOf('/')) rebuilt = rebuilt.substring(0, dot) + ".gif"; + String query = uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery(); + return uri.getScheme() + "://" + uri.getRawAuthority() + rebuilt + query; + } catch (IllegalArgumentException ignored) { + return null; + } + } + + private static String replaceVideoExtension(String value) { + int queryIndex = value.indexOf('?'); + String path = queryIndex < 0 ? value : value.substring(0, queryIndex); + String query = queryIndex < 0 ? "" : value.substring(queryIndex); + String lower = path.toLowerCase(Locale.ROOT); + if (!lower.endsWith(".mp4") && !lower.endsWith(".webm")) return null; + return path.substring(0, path.lastIndexOf('.')) + ".gif" + query; + } + + private static void addDerivedGif(Set target, String value) { + add(target, deriveTenorGifUrl(value)); + } + + private static void add(Set target, String value) { + if (value != null && !value.isBlank()) target.add(value); + } + + private static List distinct(String... values) { + LinkedHashSet result = new LinkedHashSet<>(); + for (String value : values) add(result, value); + return List.copyOf(result); + } + + private static int qqFileType(String contentType) { + if (contentType == null) return PendingMessage.FILE_TYPE_FILE; + if (contentType.startsWith("image/")) return PendingMessage.FILE_TYPE_IMAGE; + if (contentType.startsWith("video/")) return PendingMessage.FILE_TYPE_VIDEO; + if (contentType.startsWith("audio/")) return PendingMessage.FILE_TYPE_VOICE; + return PendingMessage.FILE_TYPE_FILE; + } + + private static String commandMediaContentType(int fileType, byte[] data) { + return MediaFormat.detectContentType(data).orElse(switch (fileType) { + case PendingMessage.FILE_TYPE_IMAGE -> "image/png"; + case PendingMessage.FILE_TYPE_VIDEO -> "video/mp4"; + case PendingMessage.FILE_TYPE_VOICE -> "audio/ogg"; + default -> "application/octet-stream"; + }); + } + + private static void replaceBody(StringBuilder target, String value) { + target.setLength(0); + target.append(value); + } + + private static boolean isDcsCommand(String text) { + if (text == null) return false; + String normalized = text.stripLeading().toLowerCase(Locale.ROOT); + return normalized.equals("/dcs") || normalized.startsWith("/dcs "); + } + + private static String displayFilename(String value) { + return value == null || value.isBlank() ? "media" : value; + } + + private static String firstUrl(List values) { + return values == null || values.isEmpty() ? "" : values.getFirst(); + } + + private static void appendLine(StringBuilder target, String value) { + if (value == null || value.isBlank()) return; + if (!target.isEmpty() && target.charAt(target.length() - 1) != '\n') target.append('\n'); + target.append(value); + } + public void start() { if (closed.get()) throw new IllegalStateException("Discord bridge has already been closed"); if (!started.compareAndSet(false, true)) return; @@ -131,7 +300,9 @@ public void acceptQqMessage(QqIncomingMessage message) { .execute(() -> relayQqToDiscord(mapping, message)); } - /** Relays the portable part of a QQ command result to the mapped Discord channel. */ + /** + * Relays the portable part of a QQ command result to the mapped Discord channel. + */ public void acceptQqCommandReply(String groupId, PendingMessage message) { DiscordBridgeMapping mapping = mappings.get(groupId); if (mapping == null || message == null) return; @@ -365,219 +536,6 @@ public void close() { workers.shutdownNow(); } - private final class DiscordListener extends ListenerAdapter { - @Override - public void onReady(ReadyEvent event) { - connectionError = null; - LOG.info("Discord bridge connected as {}", event.getJDA().getSelfUser().getName()); - } - - @Override - public void onMessageReceived(MessageReceivedEvent event) { - if (!event.isFromGuild() || event.getAuthor().isBot() || event.isWebhookMessage()) return; - String guildId = event.getGuild().getId(); - String channelId = event.getChannel().getId(); - List groups = groupsFor(guildId, channelId); - if (groups.isEmpty()) return; - - String authorName = event.getMember() == null - ? event.getAuthor().getEffectiveName() - : event.getMember().getEffectiveName(); - DiscordIncomingMessage incoming = new DiscordIncomingMessage( - guildId, - channelId, - event.getAuthor().getId(), - authorName, - event.getMessage().getContentDisplay(), - collectDiscordAttachments(event) - ); - for (String groupId : groups) { - queue("discord:" + groupId).execute(() -> relayDiscordToQq(groupId, incoming)); - } - } - } - - private static List collectDiscordAttachments(MessageReceivedEvent event) { - List attachments = new ArrayList<>(); - Set seen = new HashSet<>(); - for (String url : BridgeFormatter.findImageUrls(event.getMessage().getContentDisplay())) { - if (seen.add(url)) { - attachments.add(new BridgeAttachment( - filenameFromUrl(url), List.of(url), url, isGifUrl(url) - )); - } - } - event.getMessage().getAttachments().forEach(item -> { - if (seen.add(item.getUrl())) { - attachments.add(new BridgeAttachment( - item.getFileName(), distinct(item.getUrl(), item.getProxyUrl()), null, - "image/gif".equalsIgnoreCase(item.getContentType()) - )); - } - }); - for (MessageEmbed embed : event.getMessage().getEmbeds()) { - if (embed.getType() == EmbedType.GIFV && embed.getVideoInfo() != null) { - List candidates = gifCandidates(embed); - String key = candidates.isEmpty() ? null : candidates.getFirst(); - if (key != null && seen.add(key)) { - attachments.add(new BridgeAttachment("discord-gif.gif", candidates, embed.getUrl(), true)); - } - continue; - } - MessageEmbed.ImageInfo image = embed.getImage(); - if (image != null && seen.add(image.getUrl())) { - attachments.add(new BridgeAttachment( - "embed-image", distinct(image.getUrl(), image.getProxyUrl()), embed.getUrl(), false - )); - } - } - event.getMessage().getStickers().forEach(sticker -> { - if (seen.add(sticker.getIconUrl())) { - attachments.add(new BridgeAttachment( - sticker.getName(), List.of(sticker.getIconUrl()), null, - sticker.getIconUrl().toLowerCase(Locale.ROOT).contains(".gif") - )); - } - }); - event.getMessage().getMentions().getCustomEmojis().forEach(emoji -> { - if (seen.add(emoji.getImageUrl())) { - attachments.add(new BridgeAttachment( - "emoji-" + emoji.getName(), List.of(emoji.getImageUrl()), null, emoji.isAnimated() - )); - } - }); - return List.copyOf(attachments); - } - - private static String filenameFromUrl(String value) { - try { - String path = URI.create(value).getPath(); - if (path != null && path.lastIndexOf('/') < path.length() - 1) { - return path.substring(path.lastIndexOf('/') + 1); - } - } catch (IllegalArgumentException ignored) { - // The downloader will report malformed URLs if one reaches it. - } - return "discord-image"; - } - - private static boolean isGifUrl(String value) { - try { - String path = URI.create(value).getPath(); - return path != null && path.toLowerCase(Locale.ROOT).endsWith(".gif"); - } catch (IllegalArgumentException ignored) { - return false; - } - } - - static List gifCandidates(MessageEmbed embed) { - LinkedHashSet candidates = new LinkedHashSet<>(); - MessageEmbed.VideoInfo video = embed.getVideoInfo(); - if (video != null) { - addDerivedGif(candidates, video.getUrl()); - addDerivedGif(candidates, video.getProxyUrl()); - add(candidates, video.getUrl()); - add(candidates, video.getProxyUrl()); - } - MessageEmbed.ImageInfo image = embed.getImage(); - if (image != null) { - add(candidates, image.getUrl()); - add(candidates, image.getProxyUrl()); - } - return List.copyOf(candidates); - } - - static String deriveTenorGifUrl(String mediaUrl) { - if (mediaUrl == null || mediaUrl.isBlank()) return null; - try { - URI uri = URI.create(mediaUrl); - String host = uri.getHost(); - String path = uri.getRawPath(); - if (host == null || path == null || !host.toLowerCase(Locale.ROOT) - .matches("media[0-9]*\\.tenor\\.(com|co)")) { - return replaceVideoExtension(mediaUrl); - } - String[] segments = path.split("/"); - for (int index = 0; index < segments.length; index++) { - if (segments[index].endsWith("AAAPo")) { - segments[index] = segments[index].substring(0, segments[index].length() - 5) + "AAAAC"; - } - } - String rebuilt = String.join("/", segments); - int dot = rebuilt.lastIndexOf('.'); - if (dot > rebuilt.lastIndexOf('/')) rebuilt = rebuilt.substring(0, dot) + ".gif"; - String query = uri.getRawQuery() == null ? "" : "?" + uri.getRawQuery(); - return uri.getScheme() + "://" + uri.getRawAuthority() + rebuilt + query; - } catch (IllegalArgumentException ignored) { - return null; - } - } - - private static String replaceVideoExtension(String value) { - int queryIndex = value.indexOf('?'); - String path = queryIndex < 0 ? value : value.substring(0, queryIndex); - String query = queryIndex < 0 ? "" : value.substring(queryIndex); - String lower = path.toLowerCase(Locale.ROOT); - if (!lower.endsWith(".mp4") && !lower.endsWith(".webm")) return null; - return path.substring(0, path.lastIndexOf('.')) + ".gif" + query; - } - - private static void addDerivedGif(Set target, String value) { - add(target, deriveTenorGifUrl(value)); - } - - private static void add(Set target, String value) { - if (value != null && !value.isBlank()) target.add(value); - } - - private static List distinct(String... values) { - LinkedHashSet result = new LinkedHashSet<>(); - for (String value : values) add(result, value); - return List.copyOf(result); - } - - private static int qqFileType(String contentType) { - if (contentType == null) return PendingMessage.FILE_TYPE_FILE; - if (contentType.startsWith("image/")) return PendingMessage.FILE_TYPE_IMAGE; - if (contentType.startsWith("video/")) return PendingMessage.FILE_TYPE_VIDEO; - if (contentType.startsWith("audio/")) return PendingMessage.FILE_TYPE_VOICE; - return PendingMessage.FILE_TYPE_FILE; - } - - private static String commandMediaContentType(int fileType, byte[] data) { - return MediaFormat.detectContentType(data).orElse(switch (fileType) { - case PendingMessage.FILE_TYPE_IMAGE -> "image/png"; - case PendingMessage.FILE_TYPE_VIDEO -> "video/mp4"; - case PendingMessage.FILE_TYPE_VOICE -> "audio/ogg"; - default -> "application/octet-stream"; - }); - } - - private static void replaceBody(StringBuilder target, String value) { - target.setLength(0); - target.append(value); - } - - private static boolean isDcsCommand(String text) { - if (text == null) return false; - String normalized = text.stripLeading().toLowerCase(Locale.ROOT); - return normalized.equals("/dcs") || normalized.startsWith("/dcs "); - } - - private static String displayFilename(String value) { - return value == null || value.isBlank() ? "media" : value; - } - - private static String firstUrl(List values) { - return values == null || values.isEmpty() ? "" : values.getFirst(); - } - - private static void appendLine(StringBuilder target, String value) { - if (value == null || value.isBlank()) return; - if (!target.isEmpty() && target.charAt(target.length() - 1) != '\n') target.append('\n'); - target.append(value); - } - public record BindResult(boolean success, String message, String guildName, String channelName) { static BindResult success(String guildName, String channelName) { return new BindResult(true, "", guildName, channelName); @@ -601,7 +559,7 @@ private SerialExecutor(Executor executor) { } @Override - public synchronized void execute(Runnable command) { + public synchronized void execute(@NotNull Runnable command) { tasks.offer(() -> { try { command.run(); @@ -616,4 +574,36 @@ private synchronized void scheduleNext() { if ((active = tasks.poll()) != null) executor.execute(active); } } + + private final class DiscordListener extends ListenerAdapter { + @Override + public void onReady(ReadyEvent event) { + connectionError = null; + LOG.info("Discord bridge connected as {}", event.getJDA().getSelfUser().getName()); + } + + @Override + public void onMessageReceived(MessageReceivedEvent event) { + if (!event.isFromGuild() || event.getAuthor().isBot() || event.isWebhookMessage()) return; + String guildId = event.getGuild().getId(); + String channelId = event.getChannel().getId(); + List groups = groupsFor(guildId, channelId); + if (groups.isEmpty()) return; + + String authorName = event.getMember() == null + ? event.getAuthor().getEffectiveName() + : event.getMember().getEffectiveName(); + DiscordIncomingMessage incoming = new DiscordIncomingMessage( + guildId, + channelId, + event.getAuthor().getId(), + authorName, + event.getMessage().getContentDisplay(), + collectDiscordAttachments(event) + ); + for (String groupId : groups) { + queue("discord:" + groupId).execute(() -> relayDiscordToQq(groupId, incoming)); + } + } + } } diff --git a/src/main/java/xyz/zcraft/seira/discord/MediaDownloader.java b/src/main/java/xyz/zcraft/seira/discord/MediaDownloader.java index ca3da244..a9f5b58a 100644 --- a/src/main/java/xyz/zcraft/seira/discord/MediaDownloader.java +++ b/src/main/java/xyz/zcraft/seira/discord/MediaDownloader.java @@ -5,11 +5,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; -import java.net.Authenticator; -import java.net.InetSocketAddress; -import java.net.PasswordAuthentication; -import java.net.ProxySelector; -import java.net.URI; +import java.net.*; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -38,6 +34,20 @@ protected PasswordAuthentication getPasswordAuthentication() { this.client = builder.build(); } + private static String safeFilename(String suggested, URI uri, String contentType) { + String filename = suggested; + if (filename == null || filename.isBlank() || filename.startsWith("http://") || filename.startsWith("https://")) { + String path = uri.getPath(); + filename = path == null ? "" : path.substring(path.lastIndexOf('/') + 1); + } + filename = filename.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_"); + if (filename.isBlank() || filename.length() > 150) { + filename = "media" + MediaFormat.extensionFor(contentType); + } + if (!filename.contains(".")) filename += MediaFormat.extensionFor(contentType); + return filename; + } + DownloadedMedia download(String url, String suggestedFilename) throws IOException, InterruptedException { URI uri; try { @@ -84,18 +94,4 @@ DownloadedMedia download(String url, String suggestedFilename) throws IOExceptio String filename = safeFilename(suggestedFilename, uri, contentType); return new DownloadedMedia(data, MediaFormat.normalizeFilename(filename, contentType), contentType, url); } - - private static String safeFilename(String suggested, URI uri, String contentType) { - String filename = suggested; - if (filename == null || filename.isBlank() || filename.startsWith("http://") || filename.startsWith("https://")) { - String path = uri.getPath(); - filename = path == null ? "" : path.substring(path.lastIndexOf('/') + 1); - } - filename = filename == null ? "" : filename.replaceAll("[\\\\/:*?\"<>|\\p{Cntrl}]", "_"); - if (filename.isBlank() || filename.length() > 150) { - filename = "media" + MediaFormat.extensionFor(contentType); - } - if (!filename.contains(".")) filename += MediaFormat.extensionFor(contentType); - return filename; - } } diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java index f046aeff..31013594 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java @@ -9,21 +9,21 @@ @Getter public final class RankGuessGame { + public static final int COPY_PUNISHMENT_THRESHOLD = 8; public final UUID token; public final String starterUserId; public final boolean fromGroup; public final Map guesses = new LinkedHashMap<>(); - public boolean copyPunishmentReduced = false; public final AtomicInteger guessCount = new AtomicInteger(0); private final List revealedHints = new ArrayList<>(); + public boolean copyPunishmentReduced = false; public RankGuessGameService.Round round; public Instant guessingStartedAt; public long nextSequence; - @Getter - private volatile boolean ended = false; - @Getter public MessageReference videoRef = null; + @Getter + private volatile boolean ended = false; RankGuessGame(UUID token, String starterUserId, boolean fromGroup) { this.token = token; @@ -50,7 +50,6 @@ public synchronized List getRevealedHints() { return List.copyOf(revealedHints); } - public static final int COPY_PUNISHMENT_THRESHOLD = 8; public double getMultiplierDelta(RankGuessGameService.ScoreMultiplier multiplier) { if (multiplier instanceof RankGuessGameService.ScoreMultiplier.FirstGuessMultiplier) { return 0.05; diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 0c96b2bc..cd4087f7 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -20,9 +20,9 @@ public final class RankGuessGameService { public static final int SCORING_VERSION = 1; + public static final int MIN_GAMES_TO_RANK = 3; private static final Duration END_PROTECTION_DURATION = Duration.ofMinutes(3); private static final int MIN_PARTICIPANT_TO_RECORD = 3; - public static final int MIN_GAMES_TO_RANK = 3; private final Map games = new HashMap<>(); private final RankGuessWeights weights; private final Clock clock; diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java index 593e33b2..916d47fa 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java @@ -37,13 +37,12 @@ public RankGuessWeights() { loadFromFile(); } - private static class GroupState { - private final Map scoreRecords = new HashMap<>(); - private final LinkedList userRecords = new LinkedList<>(); - private final Set userWishes = new HashSet<>(); - } - - private record GroupSnapshot(Map scoreRecords, List userRecords, Set userWishes) { + private static void recordUser(GroupState state, long userId) { + state.userRecords.add(userId); + if (state.userRecords.size() > RECENT_USER_LIMIT) { + state.userRecords.removeFirst(); + } + state.userWishes.remove(userId); } private void loadFromFile() { @@ -161,14 +160,6 @@ public void recordRound(String groupId, long userId, long scoreId) { saveToFile(); } - private static void recordUser(GroupState state, long userId) { - state.userRecords.add(userId); - if (state.userRecords.size() > RECENT_USER_LIMIT) { - state.userRecords.removeFirst(); - } - state.userWishes.remove(userId); - } - public boolean recentPicked(String groupId, long userId) { final GroupState state = getGroup(groupId); @@ -249,4 +240,13 @@ public Map getGroupScoreRecords(String groupId) { return Map.copyOf(state.scoreRecords); } } + + private static class GroupState { + private final Map scoreRecords = new HashMap<>(); + private final LinkedList userRecords = new LinkedList<>(); + private final Set userWishes = new HashSet<>(); + } + + private record GroupSnapshot(Map scoreRecords, List userRecords, Set userWishes) { + } } diff --git a/src/main/java/xyz/zcraft/seira/services/BindingService.java b/src/main/java/xyz/zcraft/seira/services/BindingService.java index 50ffca77..ea2fdb38 100644 --- a/src/main/java/xyz/zcraft/seira/services/BindingService.java +++ b/src/main/java/xyz/zcraft/seira/services/BindingService.java @@ -13,16 +13,13 @@ import java.time.Duration; import java.util.Objects; import java.util.UUID; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executor; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; +import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.BiConsumer; -/** Owns the OAuth callback server and its pending binding requests. */ +/** + * Owns the OAuth callback server and its pending binding requests. + */ public final class BindingService implements AutoCloseable { private static final Logger LOG = LogManager.getLogger(BindingService.class); private static final Duration TASK_TTL = Duration.ofMinutes(20); diff --git a/src/main/java/xyz/zcraft/seira/services/BotStat.java b/src/main/java/xyz/zcraft/seira/services/BotStat.java index 5ac485e7..94cbd882 100644 --- a/src/main/java/xyz/zcraft/seira/services/BotStat.java +++ b/src/main/java/xyz/zcraft/seira/services/BotStat.java @@ -12,8 +12,8 @@ import java.util.LinkedList; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; public class BotStat { private static final Path STAT_FILE = Path.of("data", "bot-stat.json"); diff --git a/src/main/java/xyz/zcraft/seira/services/CosService.java b/src/main/java/xyz/zcraft/seira/services/CosService.java index 93a6f7ab..56b8cea3 100644 --- a/src/main/java/xyz/zcraft/seira/services/CosService.java +++ b/src/main/java/xyz/zcraft/seira/services/CosService.java @@ -291,6 +291,11 @@ private String trimTrailingSlash(String value) { return result; } + @Override + public void close() { + client.shutdown(); + } + private record ImageDimensions(int width, int height) { } @@ -329,10 +334,5 @@ private boolean isExpired(long now) { } } - @Override - public void close() { - client.shutdown(); - } - } diff --git a/src/main/java/xyz/zcraft/seira/util/AdminRegistry.java b/src/main/java/xyz/zcraft/seira/util/AdminRegistry.java index f0ff7fdd..52d95e0d 100644 --- a/src/main/java/xyz/zcraft/seira/util/AdminRegistry.java +++ b/src/main/java/xyz/zcraft/seira/util/AdminRegistry.java @@ -14,7 +14,9 @@ import java.util.Set; import java.util.concurrent.locks.ReentrantReadWriteLock; -/** Combines administrators declared in config with administrators added from the console. */ +/** + * Combines administrators declared in config with administrators added from the console. + */ public final class AdminRegistry { public static final Path DEFAULT_STORE = Path.of("data", "console-admins.txt"); @@ -22,8 +24,8 @@ public final class AdminRegistry { private final Path store; private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); - private Set configured = Set.of(); private final Set persisted = new LinkedHashSet<>(); + private Set configured = Set.of(); public AdminRegistry(Collection configured) { this(configured, DEFAULT_STORE); @@ -35,6 +37,18 @@ public AdminRegistry(Collection configured, Path store) { loadPersisted(); } + private static String validate(String openId) { + if (openId == null || openId.isBlank()) { + throw new IllegalArgumentException("Administrator ID must not be blank"); + } + String normalized = openId.trim(); + if (normalized.length() > 256 || normalized.chars().anyMatch(Character::isWhitespace) + || normalized.chars().anyMatch(Character::isISOControl)) { + throw new IllegalArgumentException("Administrator ID contains invalid characters"); + } + return normalized; + } + public boolean isAdmin(String openId) { if (openId == null || openId.isBlank()) { return false; @@ -158,18 +172,6 @@ private void savePersisted() { } } - private static String validate(String openId) { - if (openId == null || openId.isBlank()) { - throw new IllegalArgumentException("Administrator ID must not be blank"); - } - String normalized = openId.trim(); - if (normalized.length() > 256 || normalized.chars().anyMatch(Character::isWhitespace) - || normalized.chars().anyMatch(Character::isISOControl)) { - throw new IllegalArgumentException("Administrator ID contains invalid characters"); - } - return normalized; - } - public enum AddResult { ADDED, ALREADY_PRESENT diff --git a/src/main/java/xyz/zcraft/seira/util/ApplicationExecutors.java b/src/main/java/xyz/zcraft/seira/util/ApplicationExecutors.java index 59def38c..926ac1e1 100644 --- a/src/main/java/xyz/zcraft/seira/util/ApplicationExecutors.java +++ b/src/main/java/xyz/zcraft/seira/util/ApplicationExecutors.java @@ -32,6 +32,22 @@ public final class ApplicationExecutors implements AutoCloseable { daemonThreadFactory("seira-attachment-") ); + private static void awaitTermination(ExecutorService executor) { + try { + if (!executor.awaitTermination(SHUTDOWN_TIMEOUT.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + LOG.warn("Interrupted while shutting down application executor"); + } + } + + private static ThreadFactory daemonThreadFactory(String prefix) { + return Thread.ofPlatform().daemon().name(prefix, 0).factory(); + } + public ExecutorService gatewayEvents() { return gatewayEvents; } @@ -51,20 +67,4 @@ public void close() { awaitTermination(executor); } } - - private static void awaitTermination(ExecutorService executor) { - try { - if (!executor.awaitTermination(SHUTDOWN_TIMEOUT.toMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)) { - executor.shutdownNow(); - } - } catch (InterruptedException e) { - executor.shutdownNow(); - Thread.currentThread().interrupt(); - LOG.warn("Interrupted while shutting down application executor"); - } - } - - private static ThreadFactory daemonThreadFactory(String prefix) { - return Thread.ofPlatform().daemon().name(prefix, 0).factory(); - } } diff --git a/src/main/java/xyz/zcraft/seira/util/OsuAuthHelper.java b/src/main/java/xyz/zcraft/seira/util/OsuAuthHelper.java index adc4e5c4..50a16558 100644 --- a/src/main/java/xyz/zcraft/seira/util/OsuAuthHelper.java +++ b/src/main/java/xyz/zcraft/seira/util/OsuAuthHelper.java @@ -4,8 +4,8 @@ import org.apache.logging.log4j.Logger; import xyz.zcraft.seira.api.OsuAuthApi; import xyz.zcraft.seira.api.data.OsuToken; -import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.config.BindingConfig; +import xyz.zcraft.seira.db.UserDataStore; public class OsuAuthHelper { private static final Logger LOG = LogManager.getLogger(); diff --git a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomVersion.java b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomVersion.java index 0bc8cc42..0ab2ac8f 100644 --- a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomVersion.java +++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomVersion.java @@ -12,10 +12,6 @@ public enum MultiplayerRoomVersion { this.value = value; } - public String value() { - return value; - } - public static MultiplayerRoomVersion parse(String value) { if (value == null) { return null; @@ -26,4 +22,8 @@ public static MultiplayerRoomVersion parse(String value) { default -> null; }; } + + public String value() { + return value; + } } diff --git a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java index eecad975..27c2c466 100644 --- a/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java +++ b/src/main/java/xyz/zcraft/seira/watch/MultiplayerRoomWatchService.java @@ -4,15 +4,7 @@ import org.apache.logging.log4j.Logger; import java.time.Duration; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; +import java.util.*; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -45,6 +37,23 @@ public MultiplayerRoomWatchService( }); } + private static Duration requirePositive(Duration duration) { + if (duration == null || duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException("pollInterval must be positive"); + } + return duration; + } + + private static void requireIdentifier(String value, String name) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(name + " must not be blank"); + } + } + + private static RoomWatchView view(WatchEntry entry) { + return new RoomWatchView(entry.version, entry.roomId, entry.roomName); + } + public void start() { if (closed.get()) { throw new IllegalStateException("Multiplayer room watch service has been closed"); @@ -274,23 +283,6 @@ private void pollSafely() { } } - private static Duration requirePositive(Duration duration) { - if (duration == null || duration.isZero() || duration.isNegative()) { - throw new IllegalArgumentException("pollInterval must be positive"); - } - return duration; - } - - private static void requireIdentifier(String value, String name) { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException(name + " must not be blank"); - } - } - - private static RoomWatchView view(WatchEntry entry) { - return new RoomWatchView(entry.version, entry.roomId, entry.roomName); - } - @Override public void close() { if (closed.compareAndSet(false, true)) { @@ -298,12 +290,8 @@ public void close() { } } - private static final class WatchEntry { - private final MultiplayerRoomVersion version; - private final long roomId; - private final String roomName; - private final Set sentPlaylistItemIds; - + private record WatchEntry(MultiplayerRoomVersion version, long roomId, String roomName, + Set sentPlaylistItemIds) { private WatchEntry( MultiplayerRoomVersion version, long roomId, diff --git a/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java b/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java index 43133f68..57c69ac3 100644 --- a/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java +++ b/src/main/java/xyz/zcraft/seira/watch/OstellaMultiplayerRoomWatchApi.java @@ -27,6 +27,47 @@ public OstellaMultiplayerRoomWatchApi(String endpoint) { this.gson = gson; } + private static JsonElement successfulData(JsonObject root, String action) { + if (root == null || !root.has("success") || !root.get("success").getAsBoolean()) { + String message = root != null && root.has("message") && !root.get("message").isJsonNull() + ? root.get("message").getAsString() + : "未知错误"; + throw new IllegalStateException(action + "失败: " + message); + } + JsonElement data = root.get("data"); + if (data == null || data.isJsonNull()) { + throw new IllegalStateException(action + "失败: 响应缺少 data"); + } + return data; + } + + private static void ensureSuccessfulStatus(int statusCode, Object body, String action) { + if (statusCode >= 200 && statusCode < 300) { + return; + } + String detail = body instanceof byte[] bytes + ? new String(bytes, StandardCharsets.UTF_8) + : String.valueOf(body); + if (detail.length() > 500) { + detail = detail.substring(0, 500); + } + throw new IllegalStateException(action + "失败: HTTP " + statusCode + " " + detail); + } + + private static long requirePositive(long value, String name) { + if (value <= 0) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } + + private static MultiplayerRoomVersion requireVersion(MultiplayerRoomVersion version) { + if (version == null) { + throw new IllegalArgumentException("version is required"); + } + return version; + } + @Override public RoomWatchSnapshot getSnapshot(MultiplayerRoomVersion version, long roomId) { HttpResponse response = get( @@ -78,45 +119,4 @@ private HttpResponse get(String path, HttpResponse.BodyHandler handler throw new RuntimeException("无法连接 oStella", e); } } - - private static JsonElement successfulData(JsonObject root, String action) { - if (root == null || !root.has("success") || !root.get("success").getAsBoolean()) { - String message = root != null && root.has("message") && !root.get("message").isJsonNull() - ? root.get("message").getAsString() - : "未知错误"; - throw new IllegalStateException(action + "失败: " + message); - } - JsonElement data = root.get("data"); - if (data == null || data.isJsonNull()) { - throw new IllegalStateException(action + "失败: 响应缺少 data"); - } - return data; - } - - private static void ensureSuccessfulStatus(int statusCode, Object body, String action) { - if (statusCode >= 200 && statusCode < 300) { - return; - } - String detail = body instanceof byte[] bytes - ? new String(bytes, StandardCharsets.UTF_8) - : String.valueOf(body); - if (detail.length() > 500) { - detail = detail.substring(0, 500); - } - throw new IllegalStateException(action + "失败: HTTP " + statusCode + " " + detail); - } - - private static long requirePositive(long value, String name) { - if (value <= 0) { - throw new IllegalArgumentException(name + " must be positive"); - } - return value; - } - - private static MultiplayerRoomVersion requireVersion(MultiplayerRoomVersion version) { - if (version == null) { - throw new IllegalArgumentException("version is required"); - } - return version; - } } diff --git a/src/main/java/xyz/zcraft/seira/watch/OstellaWatchApi.java b/src/main/java/xyz/zcraft/seira/watch/OstellaWatchApi.java index 42dfd78e..e06a8f4d 100644 --- a/src/main/java/xyz/zcraft/seira/watch/OstellaWatchApi.java +++ b/src/main/java/xyz/zcraft/seira/watch/OstellaWatchApi.java @@ -36,6 +36,28 @@ public OstellaWatchApi(String endpoint) { this.gson = gson; } + private static void ensureSuccessfulResponse(JsonObject root, String action) { + if (root == null || !root.has("success") || !root.get("success").getAsBoolean()) { + String message = root != null && root.has("message") && !root.get("message").isJsonNull() + ? root.get("message").getAsString() + : "未知错误"; + throw new IllegalStateException(action + "失败: " + message); + } + } + + private static void ensureSuccessfulStatus(int statusCode, Object body, String action) { + if (statusCode >= 200 && statusCode < 300) { + return; + } + String detail = body instanceof byte[] bytes + ? new String(bytes, StandardCharsets.UTF_8) + : String.valueOf(body); + if (detail.length() > 500) { + detail = detail.substring(0, 500); + } + throw new IllegalStateException(action + "失败: HTTP " + statusCode + " " + detail); + } + @Override public Map> getRecentScores(Collection userIds, int limit) { JsonObject body = new JsonObject(); @@ -104,26 +126,4 @@ private HttpResponse send(String path, String body, HttpResponse.BodyHand throw new RuntimeException("无法连接 oStella", e); } } - - private static void ensureSuccessfulResponse(JsonObject root, String action) { - if (root == null || !root.has("success") || !root.get("success").getAsBoolean()) { - String message = root != null && root.has("message") && !root.get("message").isJsonNull() - ? root.get("message").getAsString() - : "未知错误"; - throw new IllegalStateException(action + "失败: " + message); - } - } - - private static void ensureSuccessfulStatus(int statusCode, Object body, String action) { - if (statusCode >= 200 && statusCode < 300) { - return; - } - String detail = body instanceof byte[] bytes - ? new String(bytes, StandardCharsets.UTF_8) - : String.valueOf(body); - if (detail.length() > 500) { - detail = detail.substring(0, 500); - } - throw new IllegalStateException(action + "失败: HTTP " + statusCode + " " + detail); - } } diff --git a/src/main/java/xyz/zcraft/seira/watch/ScoreWatchService.java b/src/main/java/xyz/zcraft/seira/watch/ScoreWatchService.java index ae6ffaa2..d0631a6d 100644 --- a/src/main/java/xyz/zcraft/seira/watch/ScoreWatchService.java +++ b/src/main/java/xyz/zcraft/seira/watch/ScoreWatchService.java @@ -6,15 +6,7 @@ import java.time.Clock; import java.time.Duration; import java.time.Instant; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; +import java.util.*; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; @@ -61,6 +53,47 @@ public ScoreWatchService( }); } + private static List scoresAfter(List scores, Long lastScoreId) { + if (scores.isEmpty()) { + return List.of(); + } + if (lastScoreId == null) { + return List.copyOf(scores); + } + for (int index = 0; index < scores.size(); index++) { + if (scores.get(index).scoreId() == lastScoreId) { + return List.copyOf(scores.subList(0, index)); + } + } + return List.copyOf(scores); + } + + private static WatchView view(WatchEntry entry, Instant now) { + Duration remaining = Duration.between(now, entry.expiresAt); + return new WatchView(entry.target, remaining.isNegative() ? Duration.ZERO : remaining); + } + + private static Duration requirePositive(Duration duration, String name) { + if (duration == null || duration.isZero() || duration.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + return duration; + } + + private static Set requirePositiveIds(Set ids, String name) { + if (ids == null || ids.isEmpty()) { + throw new IllegalArgumentException(name + " must not be empty"); + } + LinkedHashSet result = new LinkedHashSet<>(); + for (Long id : ids) { + if (id == null || id <= 0) { + throw new IllegalArgumentException(name + " must contain only positive IDs"); + } + result.add(id); + } + return Set.copyOf(result); + } + public void start() { if (closed.get()) { throw new IllegalStateException("Score watch service has been closed"); @@ -176,7 +209,9 @@ public Map> listAll() { } } - /** Returns groups whose temporary score watches will be lost when this process stops. */ + /** + * Returns groups whose temporary score watches will be lost when this process stops. + */ public Set activeTransientGroupIds() { synchronized (lock) { removeExpiredLocked(clock.instant()); @@ -396,47 +431,6 @@ private void removeExpiredLocked(Instant now) { emptyGroups.forEach(watchesByGroup::remove); } - private static List scoresAfter(List scores, Long lastScoreId) { - if (scores.isEmpty()) { - return List.of(); - } - if (lastScoreId == null) { - return List.copyOf(scores); - } - for (int index = 0; index < scores.size(); index++) { - if (scores.get(index).scoreId() == lastScoreId) { - return List.copyOf(scores.subList(0, index)); - } - } - return List.copyOf(scores); - } - - private static WatchView view(WatchEntry entry, Instant now) { - Duration remaining = Duration.between(now, entry.expiresAt); - return new WatchView(entry.target, remaining.isNegative() ? Duration.ZERO : remaining); - } - - private static Duration requirePositive(Duration duration, String name) { - if (duration == null || duration.isZero() || duration.isNegative()) { - throw new IllegalArgumentException(name + " must be positive"); - } - return duration; - } - - private static Set requirePositiveIds(Set ids, String name) { - if (ids == null || ids.isEmpty()) { - throw new IllegalArgumentException(name + " must not be empty"); - } - LinkedHashSet result = new LinkedHashSet<>(); - for (Long id : ids) { - if (id == null || id <= 0) { - throw new IllegalArgumentException(name + " must contain only positive IDs"); - } - result.add(id); - } - return Set.copyOf(result); - } - @Override public void close() { if (closed.compareAndSet(false, true)) { @@ -459,11 +453,7 @@ private WatchEntry(WatchTarget target, Instant expiresAt, Long lastScoreId) { private record WatchRef(String groupId, WatchEntry entry) { } - private static final class SpecificWatchEntry { - private final Set userIds; - private final Set beatmapIds; - private final Map lastScoreIds; - + private record SpecificWatchEntry(Set userIds, Set beatmapIds, Map lastScoreIds) { private SpecificWatchEntry(Set userIds, Set beatmapIds, Map lastScoreIds) { this.userIds = userIds; this.beatmapIds = beatmapIds; diff --git a/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchState.java b/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchState.java index ab42a855..543ea080 100644 --- a/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchState.java +++ b/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchState.java @@ -1,12 +1,10 @@ package xyz.zcraft.seira.watch; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.Map; -import java.util.Objects; -import java.util.Set; +import java.util.*; -/** Persisted state for one group's /wx watch. */ +/** + * Persisted state for one group's /wx watch. + */ public record SpecificScoreWatchState( String groupId, Set userIds, diff --git a/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchStore.java b/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchStore.java index 17d2c0df..99de98f2 100644 --- a/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchStore.java +++ b/src/main/java/xyz/zcraft/seira/watch/SpecificScoreWatchStore.java @@ -10,26 +10,4 @@ public interface SpecificScoreWatchStore { boolean delete(String groupId); void updateLastScoreId(String groupId, long userId, long scoreId); - - static SpecificScoreWatchStore none() { - return new SpecificScoreWatchStore() { - @Override - public List loadAll() { - return List.of(); - } - - @Override - public void save(SpecificScoreWatchState state) { - } - - @Override - public boolean delete(String groupId) { - return false; - } - - @Override - public void updateLastScoreId(String groupId, long userId, long scoreId) { - } - }; - } } diff --git a/src/main/java/xyz/zcraft/seira/watch/WatchApi.java b/src/main/java/xyz/zcraft/seira/watch/WatchApi.java index bd97bbec..30bc9d11 100644 --- a/src/main/java/xyz/zcraft/seira/watch/WatchApi.java +++ b/src/main/java/xyz/zcraft/seira/watch/WatchApi.java @@ -4,7 +4,9 @@ import java.util.List; import java.util.Map; -/** Backend boundary used by the score watch domain service. */ +/** + * Backend boundary used by the score watch domain service. + */ public interface WatchApi { Map> getRecentScores(Collection userIds, int limit); diff --git a/src/main/java/xyz/zcraft/seira/watch/WatchScoreNotifier.java b/src/main/java/xyz/zcraft/seira/watch/WatchScoreNotifier.java index 265029bf..9003f73c 100644 --- a/src/main/java/xyz/zcraft/seira/watch/WatchScoreNotifier.java +++ b/src/main/java/xyz/zcraft/seira/watch/WatchScoreNotifier.java @@ -14,7 +14,7 @@ public final class WatchScoreNotifier { public WatchScoreNotifier(MessageSender messageSender) { this.messageSender = Objects.requireNonNull(messageSender); } - + public boolean sendScore(String groupId, byte[] imageBytes) { String base64 = Base64.getEncoder().encodeToString(imageBytes); FileInfo media = messageSender.uploadGroupMediaBase64( From bfe7cf0e10ce6074eea68095c695a18885a2bb8d Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 00:35:44 +0800 Subject: [PATCH 39/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=8E=92=E5=90=8D?= =?UTF-8?q?=E8=AF=84=E5=88=86=E9=80=BB=E8=BE=91=EF=BC=8C=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E8=AF=84=E5=88=86=E8=8C=83=E5=9B=B4=E5=92=8C=E7=AD=89=E7=BA=A7?= =?UTF-8?q?=E5=88=92=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/RankGuessCommandHandler.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index d10b0381..03402ba5 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -558,9 +558,10 @@ private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal stat } private static double standardRating(double ratingRaw) { - ratingRaw = Math.clamp(ratingRaw, 0.0, 1.0); + double z = 2 * ratingRaw - 1; + double p = 1.2; - return 1 + Math.pow(2 * ratingRaw - 1, 3); + return 1 + Math.copySign(Math.pow(Math.abs(z), p), z); } private static String getRankText(RankGuessRecordStore.Statistics.Personal statistics) { @@ -570,12 +571,12 @@ private static String getRankText(RankGuessRecordStore.Statistics.Personal stati double rawRating = getRatingRaw(statistics); - if (rawRating >= 0.90) return "SS"; - if (rawRating >= 0.82) return "S"; - if (rawRating >= 0.74) return "A"; - if (rawRating >= 0.64) return "B"; - if (rawRating >= 0.52) return "C"; - if (rawRating >= 0.40) return "D"; + if (rawRating >= 0.85) return "SS"; + if (rawRating >= 0.77) return "S"; + if (rawRating >= 0.69) return "A"; + if (rawRating >= 0.59) return "B"; + if (rawRating >= 0.47) return "C"; + if (rawRating >= 0.35) return "D"; return "F"; } } From 400260b371eafd8a5645820176eb40908c3c5a3e Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 00:39:22 +0800 Subject: [PATCH 40/54] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=8E=92=E5=90=8D?= =?UTF-8?q?=E8=AF=84=E5=88=86=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E8=83=9C=E7=8E=87=E5=92=8C=E5=89=8D=E4=BA=8C=E5=8D=81=E5=90=8D?= =?UTF-8?q?=E6=AF=94=E9=87=8D=E8=AE=A1=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zcraft/seira/command/handler/RankGuessCommandHandler.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 03402ba5..e5bc095c 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -549,8 +549,8 @@ private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal stat ); double rawRating = averageScoreRate * 0.50 - + statistics.winRate() * 0.20 - + statistics.topTwentyRate() * 0.30; + + statistics.winRate() * 0.10 + + statistics.topTwentyRate() * 0.40; double confidence = 1.0 - Math.exp(-statistics.participation() / 10.0); From 0c12f7bb2c6e825bbf9b02235ab4f9f4bc02abb8 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 00:40:29 +0800 Subject: [PATCH 41/54] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=AF=94=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seira/command/handler/RankGuessCommandHandler.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index e5bc095c..aa6692b0 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -548,9 +548,9 @@ private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal stat 0.0, 1.0 ); - double rawRating = averageScoreRate * 0.50 - + statistics.winRate() * 0.10 - + statistics.topTwentyRate() * 0.40; + double rawRating = averageScoreRate * 0.40 + + statistics.winRate() * 0.15 + + statistics.topTwentyRate() * 0.45; double confidence = 1.0 - Math.exp(-statistics.participation() / 10.0); From 8d37e7510c00632d08f4b5de0d4eb5a79a143641 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 00:41:45 +0800 Subject: [PATCH 42/54] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=AF=94=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seira/command/handler/RankGuessCommandHandler.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index aa6692b0..5357f2ff 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -544,13 +544,13 @@ public static Rank from(RankGuessRecordStore.Statistics.Personal statistics) { private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal statistics) { double averageScoreRate = Math.clamp( - statistics.averageScore() / 1000.0, + statistics.averageScore() / 950.0, 0.0, 1.0 ); - double rawRating = averageScoreRate * 0.40 - + statistics.winRate() * 0.15 - + statistics.topTwentyRate() * 0.45; + double rawRating = averageScoreRate * 0.50 + + statistics.winRate() * 0.10 + + statistics.topTwentyRate() * 0.40; double confidence = 1.0 - Math.exp(-statistics.participation() / 10.0); From 206214be72c9dc12232b3da4364d9542a1c2a1af Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 00:43:17 +0800 Subject: [PATCH 43/54] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=AF=94=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/handler/RankGuessCommandHandler.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 5357f2ff..676179e7 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -559,7 +559,7 @@ private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal stat private static double standardRating(double ratingRaw) { double z = 2 * ratingRaw - 1; - double p = 1.2; + double p = 1.15; return 1 + Math.copySign(Math.pow(Math.abs(z), p), z); } @@ -571,12 +571,12 @@ private static String getRankText(RankGuessRecordStore.Statistics.Personal stati double rawRating = getRatingRaw(statistics); - if (rawRating >= 0.85) return "SS"; - if (rawRating >= 0.77) return "S"; - if (rawRating >= 0.69) return "A"; - if (rawRating >= 0.59) return "B"; - if (rawRating >= 0.47) return "C"; - if (rawRating >= 0.35) return "D"; + if (rawRating >= 0.83) return "SS"; + if (rawRating >= 0.75) return "S"; + if (rawRating >= 0.67) return "A"; + if (rawRating >= 0.57) return "B"; + if (rawRating >= 0.45) return "C"; + if (rawRating >= 0.33) return "D"; return "F"; } } From 1ccdd3c30dd7479207f107e11ef2cd6aa17ec9a0 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 00:44:50 +0800 Subject: [PATCH 44/54] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=AF=94=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zcraft/seira/command/handler/RankGuessCommandHandler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 676179e7..d0c965af 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -559,7 +559,7 @@ private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal stat private static double standardRating(double ratingRaw) { double z = 2 * ratingRaw - 1; - double p = 1.15; + double p = 1.25; return 1 + Math.copySign(Math.pow(Math.abs(z), p), z); } From 2fe7e1177d96eea8350b6cccc5d041b5879393d2 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 00:47:35 +0800 Subject: [PATCH 45/54] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=91=BD=E4=BB=A4?= =?UTF-8?q?=E7=94=A8=E6=B3=95=EF=BC=8C=E7=AE=80=E5=8C=96/rg=E6=8C=87?= =?UTF-8?q?=E4=BB=A4=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 74 +++++++++---------- .../handler/RankGuessCommandHandler.java | 2 +- 2 files changed, 38 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 09837902..cde85722 100644 --- a/README.md +++ b/README.md @@ -70,43 +70,43 @@ Seira正在活跃开发中,在使用的过程中可能会有一些Bug,也会 > 所有命令都以 `/` 开头。指令详情请见[文档](https://docs.seira.top/overview/commands.html)。 -| 命令 | 用法 | 结果 | -|-----------------|----------------------------------------------------|---------------------------------------------------------------------------------| -| `/bind` | `/bind` | 开始osu账号绑定流程 | -| `/unbind` | `/unbind` | 解除当前用户的osu账号绑定 | -| `/clearhistory` | `/clearhistory` | 清除当前用户在群聊中的记录 | -| `/f` | `/f` | 获取好友列表 | -| `/fall` | `/fall` | 获取全部好友列表 | -| `/fclear` | `/fclear` | 清除好友记录 | -| `/bo` | `/bo [n] [uid/username/@user] [filters...]` | 获取最多200条最好成绩后过滤;省略n时返回第一个匹配成绩详情 | -| `/rs` | `/rs [n] [uid/username/@user] [filters...]` | 获取最多200条最近成绩后过滤;省略n时返回第一个匹配成绩详情 | -| `/rp` | `/rp [n] [uid/username/@user] [filters...]` | 获取最多200条最近通过成绩后过滤;省略n时返回第一个匹配成绩详情 | -| `/tb` | `/tb [#days] [uid/username/@user]` | 获取近N天达成的BP(默认1天),并保留完整BP排名编号 | -| `/m` | `/m [Mod]` | 获取指定谱面信息 | -| `/ap` | `/ap ` | 获取指定谱面音频预览 | -| `/bgp` | `/bgp ` | 获取指定谱面背景预览 | -| `/s` | `/s ` | 获取指定的在线或本地成绩图 | -| `/sa` | `/sa ` | 获取指定成绩分析图 | -| `/ma` | `/ma [id/locId/rsN/boN] [n/#n]` | 获取指定或最近目标成绩的Miss分析;省略目标时用`#n`指定Miss | -| `/u` | `/u [uid/username/@user]` | 获取指定用户信息 | -| `/r` | `/r [id/locId/rsN/boN] [[mm:ss]-[mm:ss]]` | 生成并发送指定或最近目标的回放视频。省略范围时自动识别高光,使用`-`渲染整个回放 | -| `/rg` | `/rg ` | 猜 Rank 游戏及个人战绩查询 | -| `/rsc` | `/rsc [id/locId/rsN/boN] [+,...]` | 生成并发送指定或最近目标的成绩同屏回放视频;追加用户和范围顺序不限 | -| `/rstat` | `/rstat [id]` | 获取视频生成进度 | -| `/ms` | `/ms ` | 获取指定谱面集信息 | -| `/dl` | `/dl ` | 获取指定谱面集的镜像下载链接 | -| `/sms` | `/sms ` | 搜索谱面集 | -| `/lb` | `/lb [id] [,...]` | 列出指定谱面排行或表现分排行 | -| `/daily` | `/daily` | 每日挑战信息 | -| `/luck` | `/luck` | 今日人品 | -| `/mp` | `/mp` | 多人房间列表 | -| `/watch` | `/watch add/del/list [目标]` | 添加/删除/列出监视任务 | -| `/mpwatch` | `/mpwatch start/stop/status [目标]` | 按群成员添加、停止或查看多人房间监视;`stop all` 可停止本群全部监视 | -| `/wx` | `/wx start <谱面ID列表>` / `/wx stop` | 监视指定玩家在指定谱面取得的成绩,重启后自动恢复 | -| `/dcs` | `/dcs start .` / `/dcs stop` | 开启或解除当前 QQ 群与 Discord 频道的双向消息同步 | -| `/stat` | `/stat` | 服务状态和统计信息文本 | -| `/inspect` | `/inspect` | 获取当前上下文信息 | -| `/help` | `/help` | 显示帮助信息 | +| 命令 | 用法 | 结果 | +|-----------------|---------------------------------------------------------|---------------------------------------------------------------------------------| +| `/bind` | `/bind` | 开始osu账号绑定流程 | +| `/unbind` | `/unbind` | 解除当前用户的osu账号绑定 | +| `/clearhistory` | `/clearhistory` | 清除当前用户在群聊中的记录 | +| `/f` | `/f` | 获取好友列表 | +| `/fall` | `/fall` | 获取全部好友列表 | +| `/fclear` | `/fclear` | 清除好友记录 | +| `/bo` | `/bo [n] [uid/username/@user] [filters...]` | 获取最多200条最好成绩后过滤;省略n时返回第一个匹配成绩详情 | +| `/rs` | `/rs [n] [uid/username/@user] [filters...]` | 获取最多200条最近成绩后过滤;省略n时返回第一个匹配成绩详情 | +| `/rp` | `/rp [n] [uid/username/@user] [filters...]` | 获取最多200条最近通过成绩后过滤;省略n时返回第一个匹配成绩详情 | +| `/tb` | `/tb [#days] [uid/username/@user]` | 获取近N天达成的BP(默认1天),并保留完整BP排名编号 | +| `/m` | `/m [Mod]` | 获取指定谱面信息 | +| `/ap` | `/ap ` | 获取指定谱面音频预览 | +| `/bgp` | `/bgp ` | 获取指定谱面背景预览 | +| `/s` | `/s ` | 获取指定的在线或本地成绩图 | +| `/sa` | `/sa ` | 获取指定成绩分析图 | +| `/ma` | `/ma [id/locId/rsN/boN] [n/#n]` | 获取指定或最近目标成绩的Miss分析;省略目标时用`#n`指定Miss | +| `/u` | `/u [uid/username/@user]` | 获取指定用户信息 | +| `/r` | `/r [id/locId/rsN/boN] [[mm:ss]-[mm:ss]]` | 生成并发送指定或最近目标的回放视频。省略范围时自动识别高光,使用`-`渲染整个回放 | +| `/rg` | `/rg ` | 猜 Rank 游戏及个人战绩查询 | +| `/rsc` | `/rsc [id/locId/rsN/boN] [+,...]` | 生成并发送指定或最近目标的成绩同屏回放视频;追加用户和范围顺序不限 | +| `/rstat` | `/rstat [id]` | 获取视频生成进度 | +| `/ms` | `/ms ` | 获取指定谱面集信息 | +| `/dl` | `/dl ` | 获取指定谱面集的镜像下载链接 | +| `/sms` | `/sms ` | 搜索谱面集 | +| `/lb` | `/lb [id] [,...]` | 列出指定谱面排行或表现分排行 | +| `/daily` | `/daily` | 每日挑战信息 | +| `/luck` | `/luck` | 今日人品 | +| `/mp` | `/mp` | 多人房间列表 | +| `/watch` | `/watch add/del/list [目标]` | 添加/删除/列出监视任务 | +| `/mpwatch` | `/mpwatch start/stop/status [目标]` | 按群成员添加、停止或查看多人房间监视;`stop all` 可停止本群全部监视 | +| `/wx` | `/wx start <谱面ID列表>` / `/wx stop` | 监视指定玩家在指定谱面取得的成绩,重启后自动恢复 | +| `/dcs` | `/dcs start .` / `/dcs stop` | 开启或解除当前 QQ 群与 Discord 频道的双向消息同步 | +| `/stat` | `/stat` | 服务状态和统计信息文本 | +| `/inspect` | `/inspect` | 获取当前上下文信息 | +| `/help` | `/help` | 显示帮助信息 | 部分指令会先回复“请求已加入队列,预计等待时间 X 秒”,待异步请求完成后再额外发送结果消息。 diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index d0c965af..c485ed98 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -29,7 +29,7 @@ public final class RankGuessCommandHandler { private static final Logger LOG = LogManager.getLogger(RankGuessCommandHandler.class); - private static final String USAGE = "用法:/rg start | /rg group | /rg #Rank | /rg end | /rg wish | /rg stats [all]"; + private static final String USAGE = "用法:/rg start|group|#Rank|end|wish|stats|lb"; private static final Pattern RANK_PATTERN = Pattern.compile("^#?(\\d+)[wk]?$"); private final TaskCoordinator taskCoordinator; private final ReplyFactory replyFactory; From 88f4cce350887f150b37f1b14ff6b218891fe19c Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 01:20:22 +0800 Subject: [PATCH 46/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=9D=83=E9=87=8D?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E6=A6=82=E7=8E=87=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/RankGuessCommandHandler.java | 19 +++++---- .../seira/rankguess/RankGuessGameService.java | 4 ++ .../seira/rankguess/RankGuessWeights.java | 42 ++++++++++++++++++- 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index c485ed98..89cfddfb 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -199,17 +199,22 @@ private void currentStatus(Context ctx) { } private void weight(Context ctx) { + final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId()); + if (boundUid == null) { + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + "由于未绑定,无法查看权重喵~")); + return; + } StringBuilder reply = new StringBuilder(); - final String groupWeight = games.generateWeights(ctx.groupId()).toString(); - reply.append(at(ctx)).append("目前本群权重:\n```json\n").append(groupWeight).append("\n```\n"); - final Long boundUid = UserDataStore.findBoundUid(ctx.senderUserId()); - if (boundUid != null) { - final String randomScoreWeight = APIHelper.getRandomScoreWeight(boundUid); + final var probability = games.getProbabilityFor(ctx.groupId(), boundUid); + final int totalPlayer = UserDataStore.findBoundUidsByGroup(ctx.groupId()).size(); - reply.append("你的成绩权重:\n>").append(randomScoreWeight).append("\n"); - } + reply.append(at(ctx)).append("目前你在本群权重为 `%.2f`\n".formatted(probability.weight())); + reply.append("在本群 `%d` 名玩家中,你被选中的概率为 `%.3f%%`\n".formatted(totalPlayer, probability.chance() * 100)); + + final String randomScoreWeight = APIHelper.getRandomScoreWeight(boundUid); + reply.append("你的成绩权重:\n>").append(randomScoreWeight).append("\n"); ctx.sendReply(PendingMessage.ofMarkdownRaw(reply.toString().trim())); } diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index cd4087f7..b79af73e 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -436,6 +436,10 @@ public int getParticipantCount(String groupId) { return rankGuessGame.guesses.size(); } + public RankGuessWeights.Probability getProbabilityFor(String groupId, Long boundUid) { + return weights.getProbability(groupId, boundUid); + } + public enum GameStatus { NO_GAME, STARTING, diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java index 916d47fa..686f4241 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java @@ -5,6 +5,7 @@ import com.google.gson.JsonParser; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import xyz.zcraft.seira.db.UserDataStore; import java.io.IOException; import java.nio.file.AtomicMoveNotSupportedException; @@ -21,8 +22,8 @@ public class RankGuessWeights { private static final int RECENT_USER_LIMIT = 5; private static final double WISH_WEIGHT = 2.5; - private static final double RECENT_USER_WEIGHT = 0.7; - private static final double SCORE_REPEAT_FACTOR = 0.7; + private static final double RECENT_USER_WEIGHT = 0.25; + private static final double SCORE_REPEAT_FACTOR = 0.4; private final Map groups = new ConcurrentHashMap<>(); private final Path store; @@ -241,6 +242,43 @@ public Map getGroupScoreRecords(String groupId) { } } + public record Probability( + double weight, + double chance + ){} + + public Probability getProbability(String groupId, long boundUid) { + final GroupState state = getGroup(groupId); + + final Map users = new HashMap<>(); + + double weight; + + synchronized (state) { + for (Long wishedId : state.userWishes) { + users.put(wishedId, WISH_WEIGHT); + } + + for (Long pickedId : state.userRecords) { + users.put(pickedId, RECENT_USER_WEIGHT); + } + } + + weight = users.getOrDefault(boundUid, 1.0); + + double accumulation = 0; + + for (Long l : UserDataStore.findBoundUidsByGroup(groupId)) { + accumulation += users.getOrDefault(l, 1.0); + } + + if (accumulation == 0) throw new IllegalStateException("Error calculating probability for " + groupId + " " + boundUid); + + double chance = weight / accumulation; + + return new Probability(weight, chance); + } + private static class GroupState { private final Map scoreRecords = new HashMap<>(); private final LinkedList userRecords = new LinkedList<>(); From 851e4b44ffc11dfd0ce700976b83e2cb88acfea1 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 03:05:31 +0800 Subject: [PATCH 47/54] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E8=A1=A8=E7=8E=B0=E7=9A=84=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/RankGuessCommandHandler.java | 25 ++++++++--- .../seira/command/reply/ReplyFactory.java | 34 +++++++++----- .../zcraft/seira/db/RankGuessRecordStore.java | 44 ++++++++++++++----- .../seira/rankguess/RankGuessGameService.java | 2 +- 4 files changed, 75 insertions(+), 30 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 89cfddfb..b68a8d9b 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -228,12 +228,21 @@ private void statistics(Context ctx, boolean allGroups) { null ); - final Rank rank = Rank.from(statistics); + RankGuessRecordStore.Statistics.Personal recentStatistics = RankGuessRecordStore.getRecentPersonalStatistics( + ctx.senderUserId(), + allGroups ? null : ctx.groupId(), + null, + Rank.RECENT_GAME_LIMIT + ); + + final Rank rank = Rank.from(recentStatistics); Long groupGameCount = boundUid == null ? null : RankGuessRecordStore.getGroupGameCount(ctx.groupId(), null); Long pickedTimes = boundUid == null ? null : RankGuessRecordStore.getPickedTimes(boundUid, ctx.groupId()); - ctx.sendReply(replyFactory.rankGuessStatisticsMessage(ctx, statistics, allGroups, rank, pickedTimes, groupGameCount)); + ctx.sendReply(replyFactory.rankGuessStatisticsMessage( + ctx, statistics, recentStatistics, allGroups, rank, pickedTimes, groupGameCount + )); } catch (RuntimeException e) { LOG.error("Failed to query rank guess statistics", e); ctx.sendReply(PendingMessage.ofString("战绩查询失败,请稍后重试喵。")); @@ -253,8 +262,8 @@ private void leaderboard(Context ctx, boolean all) { ranks.put( openId, Rank.from( - RankGuessRecordStore.getPersonalStatistics( - openId, ctx.groupId(), null + RankGuessRecordStore.getRecentPersonalStatistics( + openId, ctx.groupId(), null, Rank.RECENT_GAME_LIMIT ) ) ); @@ -539,6 +548,8 @@ private void end(Context ctx, boolean force) { } public record Rank(double rating, double ratingRaw, String rank) { + public static final int RECENT_GAME_LIMIT = 10; + public static Rank from(RankGuessRecordStore.Statistics.Personal statistics) { final double pendingRatingRaw = getRatingRaw(statistics); final double pendingRating = standardRating(pendingRatingRaw); @@ -557,6 +568,8 @@ private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal stat + statistics.winRate() * 0.10 + statistics.topTwentyRate() * 0.40; + rawRating *= 1.1; + double confidence = 1.0 - Math.exp(-statistics.participation() / 10.0); return 0.50 * (1.0 - confidence) + rawRating * confidence; @@ -564,13 +577,13 @@ private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal stat private static double standardRating(double ratingRaw) { double z = 2 * ratingRaw - 1; - double p = 1.25; + double p = 1.1; return 1 + Math.copySign(Math.pow(Math.abs(z), p), z); } private static String getRankText(RankGuessRecordStore.Statistics.Personal statistics) { - if (statistics.participation() < 3) { + if (statistics.participation() < 5) { return "?"; } diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 33b7cba0..33f17dd0 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -125,6 +125,7 @@ public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result, public PendingMessage rankGuessStatisticsMessage( Context ctx, RankGuessRecordStore.Statistics.Personal statistics, + RankGuessRecordStore.Statistics.Personal recentStatistics, boolean allGroups, RankGuessCommandHandler.Rank rank, Long pickedTimes, Long groupGameCount ) { @@ -133,24 +134,35 @@ public PendingMessage rankGuessStatisticsMessage( return PendingMessage.ofMarkdownRaw(at(ctx) + "你在" + scope + "还没有已结算的猜 Rank 战绩喵~"); } - String rankText = "?".equals(rank.rank()) ? "" : "根据你的总体表现,可以给到一个 `%s` 喵!\n".formatted(rank.rank()); + String rankText = "?".equals(rank.rank()) ? "" : "根据你最近 %d 场的表现,可以给到一个 `%s` 喵!\n" + .formatted(RankGuessCommandHandler.Rank.RECENT_GAME_LIMIT, rank.rank()); String groupCountText = ""; if (!allGroups && pickedTimes != null && groupGameCount != null) { groupCountText = "> 被猜次数:`%d`,占本群:`%.3f%%`\n".formatted(pickedTimes, (double) pickedTimes / groupGameCount * 100); } return PendingMessage.ofMarkdownRaw(at(ctx) + String.format(Locale.ROOT, """ - 你的猜 Rank 战绩(%s) + 你的猜 Rank 战绩(%s,括号为近 %d 场) > 总参与数:`%d`,Rating:`%.2f` - > 获胜数:`%d`,胜率:`%.2f%%` - > 前20%%次数:`%d`,达成率:`%.2f%%` - > 平均分:`%.2f`,最高分:`%.2f` - > 总得分:`%.2f`,平均名次:`%.2f` + > 获胜数:`%d`(`%d`) + > 胜率:`%.2f%%`(`%.2f%%`) + > 前20%%:`%d`(`%d`) + > 前20%%达成率:`%.2f%%`(`%.2f%%`) + > 平均分:`%.2f`(`%.2f`) + > 最高分:`%.2f`(`%.2f`) + > 平均名次:`%.2f`(`%.2f`) + > 总得分:`%.2f` %s%s - """, scope, statistics.participation(), rank.rating(), - statistics.wins(), statistics.winRate() * 100, - statistics.topTwentyCount(), statistics.topTwentyRate() * 100, - statistics.averageScore(), statistics.highestScore(), statistics.totalScore(), - statistics.averagePlacement(), groupCountText, rankText).strip()); + """, + scope, RankGuessCommandHandler.Rank.RECENT_GAME_LIMIT, + statistics.participation(), rank.rating(), + statistics.wins(), recentStatistics.wins(), + statistics.winRate() * 100, recentStatistics.winRate() * 100, + statistics.topTwentyCount(), recentStatistics.topTwentyCount(), + statistics.topTwentyRate() * 100, recentStatistics.topTwentyRate() * 100, + statistics.averageScore(), recentStatistics.averageScore(), + statistics.highestScore(), recentStatistics.highestScore(), + statistics.averagePlacement(), statistics.totalScore(), + recentStatistics.averagePlacement(), groupCountText, rankText).strip()); } public PendingMessage boMessage(Context ctx, Response response) { diff --git a/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java b/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java index e1944e22..ac7cefb2 100644 --- a/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java +++ b/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java @@ -146,6 +146,21 @@ SELECT COUNT(*) AS participation } public static Statistics.Personal getPersonalStatistics(String userId, String groupId, Integer scoringVersion) { + return getPersonalStatistics(userId, groupId, scoringVersion, null); + } + + public static Statistics.Personal getRecentPersonalStatistics( + String userId, String groupId, Integer scoringVersion, int gameLimit + ) { + if (gameLimit < 1) { + throw new IllegalArgumentException("gameLimit must be positive"); + } + return getPersonalStatistics(userId, groupId, scoringVersion, gameLimit); + } + + private static Statistics.Personal getPersonalStatistics( + String userId, String groupId, Integer scoringVersion, Integer gameLimit + ) { requireText(userId, "userId"); if (groupId != null) requireText(groupId, "groupId"); if (scoringVersion != null && scoringVersion < 1) { @@ -153,20 +168,24 @@ public static Statistics.Personal getPersonalStatistics(String userId, String gr } String sql = """ SELECT COUNT(*) AS participation, - COALESCE(SUM(CASE WHEN r.placement = 1 THEN 1 ELSE 0 END), 0) AS wins, - COALESCE(SUM(CASE WHEN g.participant_count >= ? - AND r.placement <= (g.participant_count + 4) / 5 THEN 1 ELSE 0 END), 0) AS top_twenty, - COALESCE(SUM(CASE WHEN g.participant_count >= ? THEN 1 ELSE 0 END), 0) AS top_twenty_eligible, - COALESCE(SUM(r.final_score), 0) AS total_score, - COALESCE(AVG(r.final_score), 0) AS average_score, - COALESCE(MAX(r.final_score), 0) AS highest_score, - COALESCE(AVG(r.placement), 0) AS average_placement - FROM rank_guess_results r - JOIN rank_guess_games g ON g.round_id = r.round_id - WHERE r.user_id = ? + COALESCE(SUM(CASE WHEN recent.placement = 1 THEN 1 ELSE 0 END), 0) AS wins, + COALESCE(SUM(CASE WHEN recent.participant_count >= ? + AND recent.placement <= (recent.participant_count + 4) / 5 THEN 1 ELSE 0 END), 0) AS top_twenty, + COALESCE(SUM(CASE WHEN recent.participant_count >= ? THEN 1 ELSE 0 END), 0) AS top_twenty_eligible, + COALESCE(SUM(recent.final_score), 0) AS total_score, + COALESCE(AVG(recent.final_score), 0) AS average_score, + COALESCE(MAX(recent.final_score), 0) AS highest_score, + COALESCE(AVG(recent.placement), 0) AS average_placement + FROM ( + SELECT r.placement, r.final_score, g.participant_count + FROM rank_guess_results r + JOIN rank_guess_games g ON g.round_id = r.round_id + WHERE r.user_id = ? """; if (groupId != null) sql += " AND g.group_id = ?"; if (scoringVersion != null) sql += " AND g.scoring_version = ?"; + if (gameLimit != null) sql += " ORDER BY g.ended_at DESC, g.round_id DESC LIMIT ?"; + sql += ") recent"; try (Connection connection = SqliteDatabase.getConnection(); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setInt(1, TOP_TWENTY_MIN_PARTICIPANTS); @@ -174,7 +193,8 @@ SELECT COUNT(*) AS participation, statement.setString(3, userId); int index = 4; if (groupId != null) statement.setString(index++, groupId); - if (scoringVersion != null) statement.setInt(index, scoringVersion); + if (scoringVersion != null) statement.setInt(index++, scoringVersion); + if (gameLimit != null) statement.setInt(index, gameLimit); try (ResultSet result = statement.executeQuery()) { result.next(); return new Statistics.Personal( diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index b79af73e..78818596 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -20,7 +20,7 @@ public final class RankGuessGameService { public static final int SCORING_VERSION = 1; - public static final int MIN_GAMES_TO_RANK = 3; + public static final int MIN_GAMES_TO_RANK = 10; private static final Duration END_PROTECTION_DURATION = Duration.ofMinutes(3); private static final int MIN_PARTICIPANT_TO_RECORD = 3; private final Map games = new HashMap<>(); From 2974491cba8e82a409bccf27a5608f4b4362c090 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 03:31:58 +0800 Subject: [PATCH 48/54] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=BB=9F=E8=AE=A1?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E8=A1=A8=E7=8E=B0=E7=9A=84=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../command/handler/RankGuessCommandHandler.java | 14 +++++++------- .../zcraft/seira/command/reply/ReplyFactory.java | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index b68a8d9b..0d14c5ce 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -560,7 +560,7 @@ public static Rank from(RankGuessRecordStore.Statistics.Personal statistics) { private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal statistics) { double averageScoreRate = Math.clamp( - statistics.averageScore() / 950.0, + statistics.averageScore() / 900.0, 0.0, 1.0 ); @@ -589,12 +589,12 @@ private static String getRankText(RankGuessRecordStore.Statistics.Personal stati double rawRating = getRatingRaw(statistics); - if (rawRating >= 0.83) return "SS"; - if (rawRating >= 0.75) return "S"; - if (rawRating >= 0.67) return "A"; - if (rawRating >= 0.57) return "B"; - if (rawRating >= 0.45) return "C"; - if (rawRating >= 0.33) return "D"; + if (rawRating >= 0.82) return "SS"; + if (rawRating >= 0.74) return "S"; + if (rawRating >= 0.66) return "A"; + if (rawRating >= 0.56) return "B"; + if (rawRating >= 0.44) return "C"; + if (rawRating >= 0.32) return "D"; return "F"; } } diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 33f17dd0..0f74473d 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -161,8 +161,8 @@ public PendingMessage rankGuessStatisticsMessage( statistics.topTwentyRate() * 100, recentStatistics.topTwentyRate() * 100, statistics.averageScore(), recentStatistics.averageScore(), statistics.highestScore(), recentStatistics.highestScore(), - statistics.averagePlacement(), statistics.totalScore(), - recentStatistics.averagePlacement(), groupCountText, rankText).strip()); + statistics.averagePlacement(), recentStatistics.averagePlacement(), + statistics.totalScore(), groupCountText, rankText).strip()); } public PendingMessage boMessage(Context ctx, Response response) { From d766f4a78d7d0d5a74c0aaf8676a53fb42b67fad Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 03:32:53 +0800 Subject: [PATCH 49/54] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E8=AF=84=E5=88=86=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=9D=83=E9=87=8D=E5=9B=A0=E5=AD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../seira/command/handler/RankGuessCommandHandler.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 0d14c5ce..b3764021 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -565,10 +565,10 @@ private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal stat ); double rawRating = averageScoreRate * 0.50 - + statistics.winRate() * 0.10 - + statistics.topTwentyRate() * 0.40; + + statistics.winRate() * 0.15 + + statistics.topTwentyRate() * 0.35; - rawRating *= 1.1; + rawRating *= 1.05; double confidence = 1.0 - Math.exp(-statistics.participation() / 10.0); From 49f287dc820da70c207d0563c0bfcae6cc785893 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 12:18:45 +0800 Subject: [PATCH 50/54] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E9=9A=8F=E6=9C=BA?= =?UTF-8?q?=E8=AF=84=E5=88=86=E6=9D=83=E9=87=8D=E8=AE=A1=E7=AE=97=E9=80=BB?= =?UTF-8?q?=E8=BE=91=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=9D=83=E9=87=8D=E5=9B=A0?= =?UTF-8?q?=E5=AD=90=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/xyz/zcraft/seira/api/APIHelper.java | 8 ++++++-- .../seira/command/handler/RankGuessCommandHandler.java | 10 +++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/api/APIHelper.java b/src/main/java/xyz/zcraft/seira/api/APIHelper.java index afa86106..42b5cd03 100644 --- a/src/main/java/xyz/zcraft/seira/api/APIHelper.java +++ b/src/main/java/xyz/zcraft/seira/api/APIHelper.java @@ -564,11 +564,15 @@ public static RandomScore getRandomScore() { } } - public static String getRandomScoreWeight(Long userId) { + public static String getRandomScoreWeight(Long userId, JsonObject weights) { try { + JsonObject body = new JsonObject(); + + body.add("weight_factor", weights); + HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(ENDPOINT + "/scores/random/users/" + userId + "/weights")) - .GET() + .POST(HttpRequest.BodyPublishers.ofString(body.toString())) .build(); HttpResponse response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index b3764021..9e265be8 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -212,9 +212,9 @@ private void weight(Context ctx) { reply.append(at(ctx)).append("目前你在本群权重为 `%.2f`\n".formatted(probability.weight())); reply.append("在本群 `%d` 名玩家中,你被选中的概率为 `%.3f%%`\n".formatted(totalPlayer, probability.chance() * 100)); - final String randomScoreWeight = APIHelper.getRandomScoreWeight(boundUid); + final String randomScoreWeight = APIHelper.getRandomScoreWeight(boundUid, games.generateWeights(ctx.groupId())); - reply.append("你的成绩权重:\n>").append(randomScoreWeight).append("\n"); + reply.append("你的成绩在本群权重:\n>").append(randomScoreWeight).append("\n"); ctx.sendReply(PendingMessage.ofMarkdownRaw(reply.toString().trim())); } @@ -590,9 +590,9 @@ private static String getRankText(RankGuessRecordStore.Statistics.Personal stati double rawRating = getRatingRaw(statistics); if (rawRating >= 0.82) return "SS"; - if (rawRating >= 0.74) return "S"; - if (rawRating >= 0.66) return "A"; - if (rawRating >= 0.56) return "B"; + if (rawRating >= 0.72) return "S"; + if (rawRating >= 0.62) return "A"; + if (rawRating >= 0.52) return "B"; if (rawRating >= 0.44) return "C"; if (rawRating >= 0.32) return "D"; return "F"; From 397084aa1de978aa67c13756bdcc2e6f3f0aad41 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Fri, 4 Sep 2026 13:34:14 +0800 Subject: [PATCH 51/54] =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=9C=80=E8=BF=91?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E9=99=90=E5=88=B6=E5=92=8C=E8=AF=84=E5=88=86?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=E5=9B=A0=E5=AD=90=EF=BC=8C=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=9D=83=E9=87=8D=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/xyz/zcraft/seira/rankguess/RankGuessWeights.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java index 686f4241..16b57619 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java @@ -20,10 +20,10 @@ public class RankGuessWeights { private static final Logger LOG = LogManager.getLogger(RankGuessWeights.class); private static final Path WEIGHTS_FILE = Path.of("data", "rank-guess-weights.json"); - private static final int RECENT_USER_LIMIT = 5; + private static final int RECENT_USER_LIMIT = 8; private static final double WISH_WEIGHT = 2.5; private static final double RECENT_USER_WEIGHT = 0.25; - private static final double SCORE_REPEAT_FACTOR = 0.4; + private static final double SCORE_REPEAT_FACTOR = 0.25; private final Map groups = new ConcurrentHashMap<>(); private final Path store; From 523be06d0c7400896a2fe354159ad5398a64a3cb Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sat, 5 Sep 2026 02:07:47 +0800 Subject: [PATCH 52/54] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/RankGuessCommandHandler.java | 19 +- .../seira/command/reply/ReplyFactory.java | 8 +- .../zcraft/seira/db/RankGuessRecordStore.java | 4 +- .../xyz/zcraft/seira/rankguess/Guess.java | 9 - .../xyz/zcraft/seira/rankguess/HintUtil.java | 125 ++++++ .../zcraft/seira/rankguess/RankGuessGame.java | 19 +- .../seira/rankguess/RankGuessGameService.java | 369 +----------------- .../seira/rankguess/RankGuessWeights.java | 8 +- .../zcraft/seira/rankguess/WishResult.java | 7 - .../seira/rankguess/data/EndResult.java | 10 + .../rankguess/{ => data}/FinishedRound.java | 4 +- .../zcraft/seira/rankguess/data/Guess.java | 9 + .../seira/rankguess/data/GuessResponse.java | 14 + .../seira/rankguess/data/GuessResult.java | 11 + .../seira/rankguess/data/Reservation.java | 6 + .../zcraft/seira/rankguess/data/Round.java | 170 ++++++++ .../seira/rankguess/data/ScoreMultiplier.java | 47 +++ .../seira/rankguess/{ => data}/Standing.java | 2 +- 18 files changed, 434 insertions(+), 407 deletions(-) delete mode 100644 src/main/java/xyz/zcraft/seira/rankguess/Guess.java create mode 100644 src/main/java/xyz/zcraft/seira/rankguess/HintUtil.java delete mode 100644 src/main/java/xyz/zcraft/seira/rankguess/WishResult.java create mode 100644 src/main/java/xyz/zcraft/seira/rankguess/data/EndResult.java rename src/main/java/xyz/zcraft/seira/rankguess/{ => data}/FinishedRound.java (69%) create mode 100644 src/main/java/xyz/zcraft/seira/rankguess/data/Guess.java create mode 100644 src/main/java/xyz/zcraft/seira/rankguess/data/GuessResponse.java create mode 100644 src/main/java/xyz/zcraft/seira/rankguess/data/GuessResult.java create mode 100644 src/main/java/xyz/zcraft/seira/rankguess/data/Reservation.java create mode 100644 src/main/java/xyz/zcraft/seira/rankguess/data/Round.java create mode 100644 src/main/java/xyz/zcraft/seira/rankguess/data/ScoreMultiplier.java rename src/main/java/xyz/zcraft/seira/rankguess/{ => data}/Standing.java (84%) diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 9e265be8..9b75660b 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -15,9 +15,8 @@ import xyz.zcraft.seira.data.UserRef; import xyz.zcraft.seira.db.RankGuessRecordStore; import xyz.zcraft.seira.db.UserDataStore; -import xyz.zcraft.seira.rankguess.RankGuessGame; -import xyz.zcraft.seira.rankguess.RankGuessGameService; -import xyz.zcraft.seira.rankguess.WishResult; +import xyz.zcraft.seira.rankguess.*; +import xyz.zcraft.seira.rankguess.data.*; import java.util.*; import java.util.concurrent.atomic.AtomicBoolean; @@ -321,7 +320,7 @@ private void wish(Context ctx) { return; } - final WishResult wish = games.wish(ctx.groupId(), boundUid); + final RankGuessGameService.WishResult wish = games.wish(ctx.groupId(), boundUid); ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + switch (wish) { case SUCCESS -> "小星听到你的愿望啦!"; @@ -332,7 +331,7 @@ private void wish(Context ctx) { } private void start(Context ctx, boolean fromGroup) { - RankGuessGameService.Reservation reservation = games.reserve(ctx.groupId(), ctx.senderUserId(), fromGroup); + Reservation reservation = games.reserve(ctx.groupId(), ctx.senderUserId(), fromGroup); if (reservation == null) { ctx.sendReply(PendingMessage.ofString("本群已有一轮 Rank Guess 正在进行。")); return; @@ -362,7 +361,7 @@ private void start(Context ctx, boolean fromGroup) { randomScore = APIHelper.getRandomScore(); } - RankGuessGameService.Round round = RankGuessGameService.Round.from(randomScore); + Round round = Round.from(randomScore); String content = at(ctx); @@ -420,7 +419,7 @@ private void start(Context ctx, boolean fromGroup) { result.append("\n").append("__Tip: 这是一位群友的成绩喵~__").append("\n"); } - var hints = RankGuessGameService.prepareHints(round.getNormalHints(), 4); + var hints = HintUtil.prepareHints(round.getNormalHints(), 4); if (!activeMessageEnabled) { result.append("\n").append("> 提示: "); @@ -492,8 +491,8 @@ private void start(Context ctx, boolean fromGroup) { } private void guess(Context ctx, long rank) { - RankGuessGameService.GuessResponse response = games.guess(ctx.groupId(), ctx.senderUserId(), rank); - final RankGuessGameService.GuessResult result = response.guessResult(); + GuessResponse response = games.guess(ctx.groupId(), ctx.senderUserId(), rank); + final GuessResult result = response.guessResult(); PendingMessage message = switch (result.status()) { case NO_GAME -> PendingMessage.ofString("本群当前没有进行中的 Rank Guess 喵"); case STARTING -> PendingMessage.ofString("回放仍在渲染,请等待视频发送后再猜测喵"); @@ -523,7 +522,7 @@ private void guess(Context ctx, long rank) { } private void end(Context ctx, boolean force) { - RankGuessGameService.EndResult result; + EndResult result; try { result = games.end(ctx.groupId(), ctx.senderUserId(), adminAuthorizer.test(ctx.senderUserId()), force); } catch (RankGuessRecordStore.RecordSaveException e) { diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index 0f74473d..ada58d98 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -16,9 +16,9 @@ import xyz.zcraft.seira.data.UploadedImage; import xyz.zcraft.seira.db.RankGuessRecordStore; import xyz.zcraft.seira.db.UserDataStore; -import xyz.zcraft.seira.rankguess.FinishedRound; -import xyz.zcraft.seira.rankguess.RankGuessGameService; -import xyz.zcraft.seira.rankguess.Standing; +import xyz.zcraft.seira.rankguess.data.FinishedRound; +import xyz.zcraft.seira.rankguess.data.Round; +import xyz.zcraft.seira.rankguess.data.Standing; import xyz.zcraft.seira.services.BindingService; import xyz.zcraft.seira.services.BotStat; import xyz.zcraft.seira.services.DailyLuck; @@ -77,7 +77,7 @@ private Buttons buttons() { } public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result, boolean recorded) { - RankGuessGameService.Round round = result.round(); + Round round = result.round(); String rank = String.format(Locale.US, "%,d", round.actualRank()); String pp = round.pp() == null ? "未知" diff --git a/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java b/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java index ac7cefb2..287c33e3 100644 --- a/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java +++ b/src/main/java/xyz/zcraft/seira/db/RankGuessRecordStore.java @@ -1,7 +1,7 @@ package xyz.zcraft.seira.db; -import xyz.zcraft.seira.rankguess.FinishedRound; -import xyz.zcraft.seira.rankguess.Standing; +import xyz.zcraft.seira.rankguess.data.FinishedRound; +import xyz.zcraft.seira.rankguess.data.Standing; import java.sql.*; import java.util.Objects; diff --git a/src/main/java/xyz/zcraft/seira/rankguess/Guess.java b/src/main/java/xyz/zcraft/seira/rankguess/Guess.java deleted file mode 100644 index c2619090..00000000 --- a/src/main/java/xyz/zcraft/seira/rankguess/Guess.java +++ /dev/null @@ -1,9 +0,0 @@ -package xyz.zcraft.seira.rankguess; - -import java.util.List; - -public record Guess(long rank, long sequence, List multipliers, long timestamp) { - public static Guess of(long rank, long sequence, List multipliers) { - return new Guess(rank, sequence, multipliers, System.currentTimeMillis()); - } -} diff --git a/src/main/java/xyz/zcraft/seira/rankguess/HintUtil.java b/src/main/java/xyz/zcraft/seira/rankguess/HintUtil.java new file mode 100644 index 00000000..e71a1048 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/rankguess/HintUtil.java @@ -0,0 +1,125 @@ +package xyz.zcraft.seira.rankguess; + +import java.util.*; +import java.util.concurrent.ThreadLocalRandom; +import java.util.random.RandomGenerator; + +public class HintUtil { + // TODO This is so messed up. Need to rewrite in the future. + public static LinkedList prepareHints(List source, int maxCount) { + var random = ThreadLocalRandom.current(); + + List remaining = new ArrayList<>(source); + limitCategory(remaining, RankGuessGame.Hint.HintCategory.ACTIVITY, 2, random); + + LinkedList result = new LinkedList<>(); + while (!remaining.isEmpty() && result.size() < maxCount) { + List candidates = candidatesForNextHint(remaining, result); + RankGuessGame.Hint selected = selectWeightedByStrength( + candidates, + result.size(), + Math.min(remaining.size() + result.size(), maxCount), + random + ); + result.add(selected); + remaining.remove(selected); + } + return result; + } + + private static void limitCategory( + List hints, + RankGuessGame.Hint.HintCategory category, + int maximum, + RandomGenerator random + ) { + List categoryHints = hints.stream() + .filter(hint -> hint.category() == category) + .toList(); + if (categoryHints.size() <= maximum) { + return; + } + + List shuffled = new ArrayList<>(categoryHints); + Collections.shuffle(shuffled, new Random(random.nextLong())); + Set retained = new HashSet<>(shuffled.subList(0, maximum)); + hints.removeIf(hint -> hint.category() == category && !retained.contains(hint)); + } + + private static List candidatesForNextHint( + List remaining, + List selected + ) { + List candidates = remaining; + if (selected.isEmpty()) { + List nonRevealing = remaining.stream() + .filter(hint -> hint.strength() != RankGuessGame.Hint.HintStrength.VERY_STRONG) + .filter(hint -> hint.strength() != RankGuessGame.Hint.HintStrength.REVEALING) + .toList(); + if (!nonRevealing.isEmpty()) { + candidates = nonRevealing; + } + } + + if (!selected.isEmpty()) { + RankGuessGame.Hint.HintCategory lastCategory = selected.getLast().category(); + List differentCategory = candidates.stream() + .filter(hint -> hint.category() != lastCategory) + .toList(); + if (!differentCategory.isEmpty()) { + candidates = differentCategory; + } + } + return candidates; + } + + private static RankGuessGame.Hint selectWeightedByStrength( + List candidates, + int position, + int total, + RandomGenerator random + ) { + double progress = total <= 1 ? 1.0 : position / (double) (total - 1); + EnumMap weights = strengthWeights(progress); + + EnumMap> byStrength = + new EnumMap<>(RankGuessGame.Hint.HintStrength.class); + for (RankGuessGame.Hint hint : candidates) { + byStrength.computeIfAbsent(hint.strength(), ignored -> new ArrayList<>()).add(hint); + } + + double totalWeight = byStrength.keySet().stream().mapToDouble(weights::get).sum(); + if (totalWeight <= 0) { + return candidates.get(random.nextInt(candidates.size())); + } + + double roll = random.nextDouble(totalWeight); + for (Map.Entry> entry : byStrength.entrySet()) { + roll -= weights.get(entry.getKey()); + if (roll < 0) { + List hints = entry.getValue(); + return hints.get(random.nextInt(hints.size())); + } + } + return candidates.getLast(); + } + + private static EnumMap strengthWeights(double progress) { + double[] first = {50, 35, 15, 0, 0}; + double[] middle = {10, 30, 45, 15, 0}; + double[] late = {0, 10, 40, 50, 0}; + + double phase = progress <= 0.5 ? progress * 2 : (progress - 0.5) * 2; + + double[] from = progress <= 0.5 ? first : middle; + double[] to = progress <= 0.5 ? middle : late; + + EnumMap weights = + new EnumMap<>(RankGuessGame.Hint.HintStrength.class); + RankGuessGame.Hint.HintStrength[] strengths = RankGuessGame.Hint.HintStrength.values(); + for (int i = 0; i < strengths.length; i++) { + weights.put(strengths[i], from[i] + (to[i] - from[i]) * phase); + } + return weights; + } +} diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java index 31013594..073fc52a 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGame.java @@ -2,6 +2,9 @@ import lombok.Getter; import xyz.zcraft.seira.bot.data.MessageReference; +import xyz.zcraft.seira.rankguess.data.Guess; +import xyz.zcraft.seira.rankguess.data.Round; +import xyz.zcraft.seira.rankguess.data.ScoreMultiplier; import java.time.Instant; import java.util.*; @@ -17,7 +20,7 @@ public final class RankGuessGame { public final AtomicInteger guessCount = new AtomicInteger(0); private final List revealedHints = new ArrayList<>(); public boolean copyPunishmentReduced = false; - public RankGuessGameService.Round round; + public Round round; public Instant guessingStartedAt; public long nextSequence; @Getter @@ -50,24 +53,24 @@ public synchronized List getRevealedHints() { return List.copyOf(revealedHints); } - public double getMultiplierDelta(RankGuessGameService.ScoreMultiplier multiplier) { - if (multiplier instanceof RankGuessGameService.ScoreMultiplier.FirstGuessMultiplier) { + public double getMultiplierDelta(ScoreMultiplier multiplier) { + if (multiplier instanceof ScoreMultiplier.FirstGuessMultiplier) { return 0.05; - } else if (multiplier instanceof RankGuessGameService.ScoreMultiplier.OrderMultiplier orderMultiplier) { + } else if (multiplier instanceof ScoreMultiplier.OrderMultiplier orderMultiplier) { return Math.max(-0.10, 0.00 - (orderMultiplier.getOrder() - 2) * 0.01); - } else if (multiplier instanceof RankGuessGameService.ScoreMultiplier.CopyPunishmentMultiplier) { + } else if (multiplier instanceof ScoreMultiplier.CopyPunishmentMultiplier) { if (guesses.size() >= COPY_PUNISHMENT_THRESHOLD) { return -0.025; } else { return -0.05; } - } else if (multiplier instanceof RankGuessGameService.ScoreMultiplier.HintMultiplier hintMultiplier) { + } else if (multiplier instanceof ScoreMultiplier.HintMultiplier hintMultiplier) { return -hintMultiplier.getHint().strength().penalty(); } return 0; } - public String getMultipliersString(List multipliers) { + public String getMultipliersString(List multipliers) { if (multipliers == null || multipliers.isEmpty()) { return "倍率: `x1.00`\n"; } @@ -82,7 +85,7 @@ public String getMultipliersString(List mu .append(String.format(Locale.US, "%.2f", 1 + sum)) .append("`\n"); - for (RankGuessGameService.ScoreMultiplier multiplier : multipliers) { + for (ScoreMultiplier multiplier : multipliers) { builder.append("> ") .append(multiplier.getReason()) .append(": ") diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java index 78818596..c4de7c94 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessGameService.java @@ -1,20 +1,15 @@ package xyz.zcraft.seira.rankguess; import com.google.gson.JsonObject; -import lombok.Getter; import org.jetbrains.annotations.NotNull; -import xyz.zcraft.osu.model.Score; -import xyz.zcraft.osu.model.UserExtended; -import xyz.zcraft.seira.api.data.RandomScore; import xyz.zcraft.seira.bot.data.MessageReference; import xyz.zcraft.seira.db.RankGuessRecordStore; +import xyz.zcraft.seira.rankguess.data.*; import java.time.Clock; import java.time.Duration; import java.util.*; -import java.util.concurrent.ThreadLocalRandom; import java.util.function.Consumer; -import java.util.random.RandomGenerator; import static xyz.zcraft.seira.rankguess.RankGuessGame.COPY_PUNISHMENT_THRESHOLD; @@ -50,124 +45,6 @@ static double logarithmicError(long guess, long actualRank) { return Math.abs(Math.log10(guess) - Math.log10(actualRank)); } - // TODO This is so messed up. Need to rewrite in the future. - public static LinkedList prepareHints(List source, int maxCount) { - var random = ThreadLocalRandom.current(); - - List remaining = new ArrayList<>(source); - limitCategory(remaining, RankGuessGame.Hint.HintCategory.ACTIVITY, 2, random); - - LinkedList result = new LinkedList<>(); - while (!remaining.isEmpty() && result.size() < maxCount) { - List candidates = candidatesForNextHint(remaining, result); - RankGuessGame.Hint selected = selectWeightedByStrength( - candidates, - result.size(), - Math.min(remaining.size() + result.size(), maxCount), - random - ); - result.add(selected); - remaining.remove(selected); - } - return result; - } - - private static void limitCategory( - List hints, - RankGuessGame.Hint.HintCategory category, - int maximum, - RandomGenerator random - ) { - List categoryHints = hints.stream() - .filter(hint -> hint.category() == category) - .toList(); - if (categoryHints.size() <= maximum) { - return; - } - - List shuffled = new ArrayList<>(categoryHints); - Collections.shuffle(shuffled, new Random(random.nextLong())); - Set retained = new HashSet<>(shuffled.subList(0, maximum)); - hints.removeIf(hint -> hint.category() == category && !retained.contains(hint)); - } - - private static List candidatesForNextHint( - List remaining, - List selected - ) { - List candidates = remaining; - if (selected.isEmpty()) { - List nonRevealing = remaining.stream() - .filter(hint -> hint.strength() != RankGuessGame.Hint.HintStrength.VERY_STRONG) - .filter(hint -> hint.strength() != RankGuessGame.Hint.HintStrength.REVEALING) - .toList(); - if (!nonRevealing.isEmpty()) { - candidates = nonRevealing; - } - } - - if (!selected.isEmpty()) { - RankGuessGame.Hint.HintCategory lastCategory = selected.getLast().category(); - List differentCategory = candidates.stream() - .filter(hint -> hint.category() != lastCategory) - .toList(); - if (!differentCategory.isEmpty()) { - candidates = differentCategory; - } - } - return candidates; - } - - private static RankGuessGame.Hint selectWeightedByStrength( - List candidates, - int position, - int total, - RandomGenerator random - ) { - double progress = total <= 1 ? 1.0 : position / (double) (total - 1); - EnumMap weights = strengthWeights(progress); - - EnumMap> byStrength = - new EnumMap<>(RankGuessGame.Hint.HintStrength.class); - for (RankGuessGame.Hint hint : candidates) { - byStrength.computeIfAbsent(hint.strength(), ignored -> new ArrayList<>()).add(hint); - } - - double totalWeight = byStrength.keySet().stream().mapToDouble(weights::get).sum(); - if (totalWeight <= 0) { - return candidates.get(random.nextInt(candidates.size())); - } - - double roll = random.nextDouble(totalWeight); - for (Map.Entry> entry : byStrength.entrySet()) { - roll -= weights.get(entry.getKey()); - if (roll < 0) { - List hints = entry.getValue(); - return hints.get(random.nextInt(hints.size())); - } - } - return candidates.getLast(); - } - - private static EnumMap strengthWeights(double progress) { - double[] first = {50, 35, 15, 0, 0}; - double[] middle = {10, 30, 45, 15, 0}; - double[] late = {0, 10, 40, 50, 0}; - - double phase = progress <= 0.5 ? progress * 2 : (progress - 0.5) * 2; - - double[] from = progress <= 0.5 ? first : middle; - double[] to = progress <= 0.5 ? middle : late; - - EnumMap weights = - new EnumMap<>(RankGuessGame.Hint.HintStrength.class); - RankGuessGame.Hint.HintStrength[] strengths = RankGuessGame.Hint.HintStrength.values(); - for (int i = 0; i < strengths.length; i++) { - weights.put(strengths[i], from[i] + (to[i] - from[i]) * phase); - } - return weights; - } - @NotNull public static String getGlobalRankRange(long l) { String range; @@ -207,10 +84,6 @@ public String getRevealedHintsString(String groupId) { return sb.toString().trim(); } - public synchronized Reservation reserve(String groupId, String starterUserId) { - return reserve(groupId, starterUserId, false); - } - public synchronized Reservation reserve(String groupId, String starterUserId, boolean fromGroup) { if (games.containsKey(groupId)) { return null; @@ -334,16 +207,16 @@ public synchronized GuessResponse guess(String groupId, String senderUserId, lon public synchronized EndResult end(String groupId, String senderUserId, boolean admin, boolean force) { RankGuessGame game = games.get(groupId); if (game == null) { - return new EndResult(EndStatus.NO_GAME, null, false); + return new EndResult(EndResult.EndStatus.NO_GAME, null, false); } if (game.round == null) { - return new EndResult(EndStatus.STARTING, null, false); + return new EndResult(EndResult.EndStatus.STARTING, null, false); } if (!force && clock.instant().isBefore(game.guessingStartedAt.plus(END_PROTECTION_DURATION)) && !Objects.equals(game.starterUserId, senderUserId) && !admin) { - return new EndResult(EndStatus.FORBIDDEN, null, false); + return new EndResult(EndResult.EndStatus.FORBIDDEN, null, false); } List standings = new ArrayList<>(game.guesses.size()); @@ -390,7 +263,7 @@ public synchronized EndResult end(String groupId, String senderUserId, boolean a weights.recordRound(groupId, game.round.userId(), game.round.scoreId()); return new EndResult( - EndStatus.FINISHED, + EndResult.EndStatus.FINISHED, finished, shouldRecord ); @@ -454,233 +327,9 @@ public enum GuessStatus { UPDATED } - public enum EndStatus { - NO_GAME, - STARTING, - FORBIDDEN, - FINISHED - } - - public record Reservation(String groupId, UUID token) { - } - - public abstract static class ScoreMultiplier { - @Getter - private final String reason; - - protected ScoreMultiplier(String reason) { - this.reason = reason; - } - - static class FirstGuessMultiplier extends ScoreMultiplier { - FirstGuessMultiplier() { - super("首猜加成"); - } - } - - static class OrderMultiplier extends ScoreMultiplier { - @Getter - private final int order; - - OrderMultiplier(int order) { - this.order = order; - super("第" + order + "猜"); - } - } - - static class CopyPunishmentMultiplier extends ScoreMultiplier { - CopyPunishmentMultiplier() { - super("抄袭惩罚"); - } - } - - static class HintMultiplier extends ScoreMultiplier { - @Getter - private final RankGuessGame.Hint hint; - - HintMultiplier(RankGuessGame.Hint hint) { - super("提示 " + hint.name() + " 已揭晓"); - this.hint = Objects.requireNonNull(hint, "hint"); - } - } - } - - public record GuessResponse(RankGuessGame game, GuessResult guessResult, String message) { - public static GuessResponse ofStatus(GuessStatus status) { - return new GuessResponse(null, new GuessResult(status, 0, null, null, 0), null); - } - - public static GuessResponse of(RankGuessGame game, GuessResult guessResult, String message) { - return new GuessResponse(game, guessResult, message); - } - } - - public record GuessResult(GuessStatus status, long rank, List multipliers, String multiplierString, - int guessCount) { - - } - - public record EndResult(EndStatus status, FinishedRound round, boolean recorded) { - } - - public record Round(long userId, long scoreId, int bestIndex, long actualRank, Double pp, RandomScore randomScore) { - public Round { - if (userId <= 0 || scoreId <= 0 || actualRank <= 0) { - throw new IllegalArgumentException("Rank Guess 数据必须包含有效的用户、成绩和排名"); - } - } - - public static Round from(RandomScore randomScore) { - if (randomScore == null || randomScore.user() == null || randomScore.score() == null) { - throw new IllegalArgumentException("随机成绩响应缺少用户或成绩数据"); - } - if (randomScore.user().getStatistics() == null - || randomScore.user().getStatistics().getGlobalRank() == null - || randomScore.user().getStatistics().getGlobalRank() <= 0) { - throw new IllegalArgumentException("随机用户缺少有效的全球排名"); - } - if (randomScore.score().getId() == null || randomScore.score().getId() <= 0) { - throw new IllegalArgumentException("随机成绩缺少有效的成绩ID"); - } - if (randomScore.user().getId() <= 0) { - throw new IllegalArgumentException("随机用户缺少有效的用户ID"); - } - - return new Round( - randomScore.user().getId(), - randomScore.score().getId(), - randomScore.bestIndex(), - randomScore.user().getStatistics().getGlobalRank(), - randomScore.score().getPp(), - randomScore - ); - } - - public LinkedList getNormalHints() { - LinkedList hints = new LinkedList<>(); - - final UserExtended user = this.randomScore.user(); - final Score score = this.randomScore.score(); - - // ===== Rank ===== - - String range = getGlobalRankRange( - user.getStatistics().getGlobalRank() - ); - - hints.add(new RankGuessGame.Hint( - "本玩家的排名范围为 `" + range + "`", - "排名范围", - RankGuessGame.Hint.HintCategory.RANK, - RankGuessGame.Hint.HintStrength.VERY_STRONG - )); - - // ===== Target score ===== - - hints.add(new RankGuessGame.Hint( - "这是此玩家的 `BP" + this.bestIndex + "`", - "BP位置", - RankGuessGame.Hint.HintCategory.TARGET_SCORE, - RankGuessGame.Hint.HintStrength.MEDIUM - )); - - final long perfect = score.getStatistics().getOrDefault("great", 0L); - final long ok = score.getStatistics().getOrDefault("ok", 0L); - final long meh = score.getStatistics().getOrDefault("meh", 0L); - final long miss = score.getStatistics().getOrDefault("miss", 0L); - - hints.add(new RankGuessGame.Hint( - "本成绩的结果为: `300: %d / 100: %d / 50: %d / Miss: %d (%.2f%%)`" - .formatted( - perfect, - ok, - meh, - miss, - score.getAccuracy() * 100 - ), - "成绩结果", - RankGuessGame.Hint.HintCategory.TARGET_SCORE, - RankGuessGame.Hint.HintStrength.WEAK - )); - - hints.add(new RankGuessGame.Hint( - "本成绩的最大连击为 `%d`".formatted(score.getMaxCombo()), - "成绩连击", - RankGuessGame.Hint.HintCategory.TARGET_SCORE, - RankGuessGame.Hint.HintStrength.WEAK - )); - -// if (score.getEndedAt() != null) { -// hints.add(new RankGuessGame.Hint( -// "本成绩完成于 `%s`".formatted(score.getEndedAt()), -// "成绩时间", -// RankGuessGame.Hint.HintCategory.TARGET_SCORE, -// RankGuessGame.Hint.HintStrength.WEAK -// )); -// } - - // ===== Difficulty ===== - - hints.add(new RankGuessGame.Hint( - "本谱面的难度为: `%s`".formatted(this.randomScore.beatmapDiff()), - "谱面难度", - RankGuessGame.Hint.HintCategory.DIFFICULTY, - RankGuessGame.Hint.HintStrength.STRONG - )); - - // ===== Account activity ===== - - if (user.getStatistics() != null) { - var stats = user.getStatistics(); - - hints.add(new RankGuessGame.Hint( - "本玩家的总游玩次数为 `%d`".formatted(stats.getPlayCount()), - "游玩次数", - RankGuessGame.Hint.HintCategory.ACTIVITY, - RankGuessGame.Hint.HintStrength.WEAK - )); - - hints.add(new RankGuessGame.Hint( - "本玩家的总游玩时间约为 `%.0f 小时`".formatted(stats.getPlayTime() / 3600.0), - "游玩时间", - RankGuessGame.Hint.HintCategory.ACTIVITY, - RankGuessGame.Hint.HintStrength.WEAK - )); - -// hints.add(new RankGuessGame.Hint( -// "本玩家的总命中数约为 `%,d`".formatted(stats.getTotalHits()), -// "总命中数", -// RankGuessGame.Hint.HintCategory.ACTIVITY, -// RankGuessGame.Hint.HintStrength.WEAK -// )); - -// hints.add(new RankGuessGame.Hint( -// "本玩家的历史最大连击为 `%d`".formatted(stats.getMaximumCombo()), -// "最大连击", -// RankGuessGame.Hint.HintCategory.ACTIVITY, -// RankGuessGame.Hint.HintStrength.WEAK -// )); - - hints.add(new RankGuessGame.Hint( - "本玩家当前等级约为 `%.1f`".formatted(stats.getLevel().getCurrent() + stats.getLevel().getProgress() / 100.0), - "玩家等级", - RankGuessGame.Hint.HintCategory.ACTIVITY, - RankGuessGame.Hint.HintStrength.WEAK - )); - } - - // ===== Account metadata ===== - - if (user.getJoinDate() != null) { - hints.add(new RankGuessGame.Hint( - "本玩家于 `%s` 注册 osu!".formatted(user.getJoinDate()), - "注册时间", - RankGuessGame.Hint.HintCategory.ACTIVITY, - RankGuessGame.Hint.HintStrength.WEAK - )); - } - - return hints; - } + public enum WishResult { + SUCCESS, + ALREADY_WISHED, + RECENTLY_PICKED } } diff --git a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java index 16b57619..baa1738c 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/RankGuessWeights.java @@ -169,22 +169,22 @@ public boolean recentPicked(String groupId, long userId) { } } - public WishResult tryWish(String groupId, long userId) { + public RankGuessGameService.WishResult tryWish(String groupId, long userId) { final GroupState state = getGroup(groupId); synchronized (state) { if (state.userWishes.contains(userId)) { - return WishResult.ALREADY_WISHED; + return RankGuessGameService.WishResult.ALREADY_WISHED; } if (state.userRecords.contains(userId)) { - return WishResult.RECENTLY_PICKED; + return RankGuessGameService.WishResult.RECENTLY_PICKED; } state.userWishes.add(userId); } saveToFile(); - return WishResult.SUCCESS; + return RankGuessGameService.WishResult.SUCCESS; } public JsonObject generateWeights(String groupId) { diff --git a/src/main/java/xyz/zcraft/seira/rankguess/WishResult.java b/src/main/java/xyz/zcraft/seira/rankguess/WishResult.java deleted file mode 100644 index 1c1d9e98..00000000 --- a/src/main/java/xyz/zcraft/seira/rankguess/WishResult.java +++ /dev/null @@ -1,7 +0,0 @@ -package xyz.zcraft.seira.rankguess; - -public enum WishResult { - SUCCESS, - ALREADY_WISHED, - RECENTLY_PICKED -} diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/EndResult.java b/src/main/java/xyz/zcraft/seira/rankguess/data/EndResult.java new file mode 100644 index 00000000..a1f5cc03 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/rankguess/data/EndResult.java @@ -0,0 +1,10 @@ +package xyz.zcraft.seira.rankguess.data; + +public record EndResult(EndStatus status, FinishedRound round, boolean recorded) { + public enum EndStatus { + NO_GAME, + STARTING, + FORBIDDEN, + FINISHED + } +} diff --git a/src/main/java/xyz/zcraft/seira/rankguess/FinishedRound.java b/src/main/java/xyz/zcraft/seira/rankguess/data/FinishedRound.java similarity index 69% rename from src/main/java/xyz/zcraft/seira/rankguess/FinishedRound.java rename to src/main/java/xyz/zcraft/seira/rankguess/data/FinishedRound.java index 0781f9d4..71bb6fb3 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/FinishedRound.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/data/FinishedRound.java @@ -1,4 +1,4 @@ -package xyz.zcraft.seira.rankguess; +package xyz.zcraft.seira.rankguess.data; import java.time.Instant; import java.util.List; @@ -6,7 +6,7 @@ public record FinishedRound( UUID id, String groupId, boolean fromGroup, Instant startedAt, Instant endedAt, - int scoringVersion, RankGuessGameService.Round round, List standings + int scoringVersion, Round round, List standings ) { public FinishedRound { standings = List.copyOf(standings); diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/Guess.java b/src/main/java/xyz/zcraft/seira/rankguess/data/Guess.java new file mode 100644 index 00000000..918936a6 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/rankguess/data/Guess.java @@ -0,0 +1,9 @@ +package xyz.zcraft.seira.rankguess.data; + +import java.util.List; + +public record Guess(long rank, long sequence, List multipliers, long timestamp) { + public static Guess of(long rank, long sequence, List multipliers) { + return new Guess(rank, sequence, multipliers, System.currentTimeMillis()); + } +} diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/GuessResponse.java b/src/main/java/xyz/zcraft/seira/rankguess/data/GuessResponse.java new file mode 100644 index 00000000..2ae8fb7d --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/rankguess/data/GuessResponse.java @@ -0,0 +1,14 @@ +package xyz.zcraft.seira.rankguess.data; + +import xyz.zcraft.seira.rankguess.RankGuessGame; +import xyz.zcraft.seira.rankguess.RankGuessGameService; + +public record GuessResponse(RankGuessGame game, GuessResult guessResult, String message) { + public static GuessResponse ofStatus(RankGuessGameService.GuessStatus status) { + return new GuessResponse(null, new GuessResult(status, 0, null, null, 0), null); + } + + public static GuessResponse of(RankGuessGame game, GuessResult guessResult, String message) { + return new GuessResponse(game, guessResult, message); + } +} diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/GuessResult.java b/src/main/java/xyz/zcraft/seira/rankguess/data/GuessResult.java new file mode 100644 index 00000000..18ba8207 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/rankguess/data/GuessResult.java @@ -0,0 +1,11 @@ +package xyz.zcraft.seira.rankguess.data; + +import xyz.zcraft.seira.rankguess.RankGuessGameService; + +import java.util.List; + +public record GuessResult(RankGuessGameService.GuessStatus status, long rank, List multipliers, + String multiplierString, + int guessCount) { + +} diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/Reservation.java b/src/main/java/xyz/zcraft/seira/rankguess/data/Reservation.java new file mode 100644 index 00000000..92fbaa94 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/rankguess/data/Reservation.java @@ -0,0 +1,6 @@ +package xyz.zcraft.seira.rankguess.data; + +import java.util.UUID; + +public record Reservation(String groupId, UUID token) { +} diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/Round.java b/src/main/java/xyz/zcraft/seira/rankguess/data/Round.java new file mode 100644 index 00000000..1c07972b --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/rankguess/data/Round.java @@ -0,0 +1,170 @@ +package xyz.zcraft.seira.rankguess.data; + +import xyz.zcraft.osu.model.Score; +import xyz.zcraft.osu.model.UserExtended; +import xyz.zcraft.seira.api.data.RandomScore; +import xyz.zcraft.seira.rankguess.RankGuessGame; +import xyz.zcraft.seira.rankguess.RankGuessGameService; + +import java.util.LinkedList; + +public record Round(long userId, long scoreId, int bestIndex, long actualRank, Double pp, RandomScore randomScore) { + public Round { + if (userId <= 0 || scoreId <= 0 || actualRank <= 0) { + throw new IllegalArgumentException("Rank Guess 数据必须包含有效的用户、成绩和排名"); + } + } + + public static Round from(RandomScore randomScore) { + if (randomScore == null || randomScore.user() == null || randomScore.score() == null) { + throw new IllegalArgumentException("随机成绩响应缺少用户或成绩数据"); + } + if (randomScore.user().getStatistics() == null + || randomScore.user().getStatistics().getGlobalRank() == null + || randomScore.user().getStatistics().getGlobalRank() <= 0) { + throw new IllegalArgumentException("随机用户缺少有效的全球排名"); + } + if (randomScore.score().getId() == null || randomScore.score().getId() <= 0) { + throw new IllegalArgumentException("随机成绩缺少有效的成绩ID"); + } + if (randomScore.user().getId() <= 0) { + throw new IllegalArgumentException("随机用户缺少有效的用户ID"); + } + + return new Round( + randomScore.user().getId(), + randomScore.score().getId(), + randomScore.bestIndex(), + randomScore.user().getStatistics().getGlobalRank(), + randomScore.score().getPp(), + randomScore + ); + } + + public LinkedList getNormalHints() { + LinkedList hints = new LinkedList<>(); + + final UserExtended user = this.randomScore.user(); + final Score score = this.randomScore.score(); + + // ===== Rank ===== + + String range = RankGuessGameService.getGlobalRankRange( + user.getStatistics().getGlobalRank() + ); + + hints.add(new RankGuessGame.Hint( + "本玩家的排名范围为 `" + range + "`", + "排名范围", + RankGuessGame.Hint.HintCategory.RANK, + RankGuessGame.Hint.HintStrength.VERY_STRONG + )); + + // ===== Target score ===== + + hints.add(new RankGuessGame.Hint( + "这是此玩家的 `BP" + this.bestIndex + "`", + "BP位置", + RankGuessGame.Hint.HintCategory.TARGET_SCORE, + RankGuessGame.Hint.HintStrength.MEDIUM + )); + + final long perfect = score.getStatistics().getOrDefault("great", 0L); + final long ok = score.getStatistics().getOrDefault("ok", 0L); + final long meh = score.getStatistics().getOrDefault("meh", 0L); + final long miss = score.getStatistics().getOrDefault("miss", 0L); + + hints.add(new RankGuessGame.Hint( + "本成绩的结果为: `300: %d / 100: %d / 50: %d / Miss: %d (%.2f%%)`" + .formatted( + perfect, + ok, + meh, + miss, + score.getAccuracy() * 100 + ), + "成绩结果", + RankGuessGame.Hint.HintCategory.TARGET_SCORE, + RankGuessGame.Hint.HintStrength.WEAK + )); + + hints.add(new RankGuessGame.Hint( + "本成绩的最大连击为 `%d`".formatted(score.getMaxCombo()), + "成绩连击", + RankGuessGame.Hint.HintCategory.TARGET_SCORE, + RankGuessGame.Hint.HintStrength.WEAK + )); + +// if (score.getEndedAt() != null) { +// hints.add(new RankGuessGame.Hint( +// "本成绩完成于 `%s`".formatted(score.getEndedAt()), +// "成绩时间", +// RankGuessGame.Hint.HintCategory.TARGET_SCORE, +// RankGuessGame.Hint.HintStrength.WEAK +// )); +// } + + // ===== Difficulty ===== + + hints.add(new RankGuessGame.Hint( + "本谱面的难度为: `%s`".formatted(this.randomScore.beatmapDiff()), + "谱面难度", + RankGuessGame.Hint.HintCategory.DIFFICULTY, + RankGuessGame.Hint.HintStrength.STRONG + )); + + // ===== Account activity ===== + + if (user.getStatistics() != null) { + var stats = user.getStatistics(); + + hints.add(new RankGuessGame.Hint( + "本玩家的总游玩次数为 `%d`".formatted(stats.getPlayCount()), + "游玩次数", + RankGuessGame.Hint.HintCategory.ACTIVITY, + RankGuessGame.Hint.HintStrength.WEAK + )); + + hints.add(new RankGuessGame.Hint( + "本玩家的总游玩时间约为 `%.0f 小时`".formatted(stats.getPlayTime() / 3600.0), + "游玩时间", + RankGuessGame.Hint.HintCategory.ACTIVITY, + RankGuessGame.Hint.HintStrength.WEAK + )); + +// hints.add(new RankGuessGame.Hint( +// "本玩家的总命中数约为 `%,d`".formatted(stats.getTotalHits()), +// "总命中数", +// RankGuessGame.Hint.HintCategory.ACTIVITY, +// RankGuessGame.Hint.HintStrength.WEAK +// )); + +// hints.add(new RankGuessGame.Hint( +// "本玩家的历史最大连击为 `%d`".formatted(stats.getMaximumCombo()), +// "最大连击", +// RankGuessGame.Hint.HintCategory.ACTIVITY, +// RankGuessGame.Hint.HintStrength.WEAK +// )); + + hints.add(new RankGuessGame.Hint( + "本玩家当前等级约为 `%.1f`".formatted(stats.getLevel().getCurrent() + stats.getLevel().getProgress() / 100.0), + "玩家等级", + RankGuessGame.Hint.HintCategory.ACTIVITY, + RankGuessGame.Hint.HintStrength.WEAK + )); + } + + // ===== Account metadata ===== + + if (user.getJoinDate() != null) { + hints.add(new RankGuessGame.Hint( + "本玩家于 `%s` 注册 osu!".formatted(user.getJoinDate()), + "注册时间", + RankGuessGame.Hint.HintCategory.ACTIVITY, + RankGuessGame.Hint.HintStrength.WEAK + )); + } + + return hints; + } +} diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/ScoreMultiplier.java b/src/main/java/xyz/zcraft/seira/rankguess/data/ScoreMultiplier.java new file mode 100644 index 00000000..7e6fe3f5 --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/rankguess/data/ScoreMultiplier.java @@ -0,0 +1,47 @@ +package xyz.zcraft.seira.rankguess.data; + +import lombok.Getter; +import xyz.zcraft.seira.rankguess.RankGuessGame; + +import java.util.Objects; + +public abstract class ScoreMultiplier { + @Getter + private final String reason; + + protected ScoreMultiplier(String reason) { + this.reason = reason; + } + + public static class FirstGuessMultiplier extends ScoreMultiplier { + public FirstGuessMultiplier() { + super("首猜加成"); + } + } + + public static class OrderMultiplier extends ScoreMultiplier { + @Getter + private final int order; + + public OrderMultiplier(int order) { + this.order = order; + super("第" + order + "猜"); + } + } + + public static class CopyPunishmentMultiplier extends ScoreMultiplier { + public CopyPunishmentMultiplier() { + super("抄袭惩罚"); + } + } + + public static class HintMultiplier extends ScoreMultiplier { + @Getter + private final RankGuessGame.Hint hint; + + public HintMultiplier(RankGuessGame.Hint hint) { + super("提示 " + hint.name() + " 已揭晓"); + this.hint = Objects.requireNonNull(hint, "hint"); + } + } +} diff --git a/src/main/java/xyz/zcraft/seira/rankguess/Standing.java b/src/main/java/xyz/zcraft/seira/rankguess/data/Standing.java similarity index 84% rename from src/main/java/xyz/zcraft/seira/rankguess/Standing.java rename to src/main/java/xyz/zcraft/seira/rankguess/data/Standing.java index 80d66136..ce49254a 100644 --- a/src/main/java/xyz/zcraft/seira/rankguess/Standing.java +++ b/src/main/java/xyz/zcraft/seira/rankguess/data/Standing.java @@ -1,4 +1,4 @@ -package xyz.zcraft.seira.rankguess; +package xyz.zcraft.seira.rankguess.data; public record Standing( String senderUserId, From 73f0928f197b2d0d4dc7264c47157f3d3d9ce8e7 Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sat, 5 Sep 2026 02:40:07 +0800 Subject: [PATCH 53/54] =?UTF-8?q?=E4=BC=98=E5=8C=96/rg=E7=9A=84Rank?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zcraft/seira/command/TaskCoordinator.java | 2 +- .../handler/RankGuessCommandHandler.java | 74 ++++--------------- .../seira/command/reply/ReplyFactory.java | 8 +- .../xyz/zcraft/seira/rankguess/data/Rank.java | 64 ++++++++++++++++ 4 files changed, 83 insertions(+), 65 deletions(-) create mode 100644 src/main/java/xyz/zcraft/seira/rankguess/data/Rank.java diff --git a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java index 5a7a0f26..988d6f7f 100644 --- a/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java +++ b/src/main/java/xyz/zcraft/seira/command/TaskCoordinator.java @@ -95,7 +95,7 @@ public boolean runApiRequest(Context ctx, String requestType, Runnable action) { action.run(); return true; } catch (Exception e) { - ctx.sendReply(PendingMessage.ofString(resolveErrorMessage(e))); + ctx.sendReply(PendingMessage.ofMarkdownRaw(at(ctx) + resolveErrorMessage(e))); String message = e.getMessage(); if (e instanceof ApiRequestException apiException) { message += " - " + apiException.getDefaultMessage(); diff --git a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java index 9b75660b..5b855093 100644 --- a/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java +++ b/src/main/java/xyz/zcraft/seira/command/handler/RankGuessCommandHandler.java @@ -51,7 +51,7 @@ public RankGuessCommandHandler( } private static Long parseRank(String argument) { - final Matcher matcher = RANK_PATTERN.matcher(argument); + final Matcher matcher = RANK_PATTERN.matcher(argument.replace(",","")); if (!matcher.matches()) { return null; } @@ -234,7 +234,7 @@ private void statistics(Context ctx, boolean allGroups) { Rank.RECENT_GAME_LIMIT ); - final Rank rank = Rank.from(recentStatistics); + final Rank rank = Rank.from(recentStatistics, statistics); Long groupGameCount = boundUid == null ? null : RankGuessRecordStore.getGroupGameCount(ctx.groupId(), null); Long pickedTimes = boundUid == null ? null : RankGuessRecordStore.getPickedTimes(boundUid, ctx.groupId()); @@ -258,14 +258,20 @@ private void leaderboard(Context ctx, boolean all) { continue; } - ranks.put( + RankGuessRecordStore.Statistics.Personal statistics = RankGuessRecordStore.getPersonalStatistics( openId, - Rank.from( - RankGuessRecordStore.getRecentPersonalStatistics( - openId, ctx.groupId(), null, Rank.RECENT_GAME_LIMIT - ) - ) + ctx.groupId(), + null ); + + RankGuessRecordStore.Statistics.Personal recentStatistics = RankGuessRecordStore.getRecentPersonalStatistics( + openId, + ctx.groupId(), + null, + Rank.RECENT_GAME_LIMIT + ); + + ranks.put(openId, Rank.from(recentStatistics, statistics)); } final List> groupRanks = ranks.entrySet().stream() @@ -545,56 +551,4 @@ private void end(Context ctx, boolean force) { ctx.sendMessage(message); } } - - public record Rank(double rating, double ratingRaw, String rank) { - public static final int RECENT_GAME_LIMIT = 10; - - public static Rank from(RankGuessRecordStore.Statistics.Personal statistics) { - final double pendingRatingRaw = getRatingRaw(statistics); - final double pendingRating = standardRating(pendingRatingRaw); - final String pendingRank = getRankText(statistics); - - return new Rank(pendingRating, pendingRatingRaw, pendingRank); - } - - private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal statistics) { - double averageScoreRate = Math.clamp( - statistics.averageScore() / 900.0, - 0.0, 1.0 - ); - - double rawRating = averageScoreRate * 0.50 - + statistics.winRate() * 0.15 - + statistics.topTwentyRate() * 0.35; - - rawRating *= 1.05; - - double confidence = 1.0 - Math.exp(-statistics.participation() / 10.0); - - return 0.50 * (1.0 - confidence) + rawRating * confidence; - } - - private static double standardRating(double ratingRaw) { - double z = 2 * ratingRaw - 1; - double p = 1.1; - - return 1 + Math.copySign(Math.pow(Math.abs(z), p), z); - } - - private static String getRankText(RankGuessRecordStore.Statistics.Personal statistics) { - if (statistics.participation() < 5) { - return "?"; - } - - double rawRating = getRatingRaw(statistics); - - if (rawRating >= 0.82) return "SS"; - if (rawRating >= 0.72) return "S"; - if (rawRating >= 0.62) return "A"; - if (rawRating >= 0.52) return "B"; - if (rawRating >= 0.44) return "C"; - if (rawRating >= 0.32) return "D"; - return "F"; - } - } } diff --git a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java index ada58d98..cfc925de 100644 --- a/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java +++ b/src/main/java/xyz/zcraft/seira/command/reply/ReplyFactory.java @@ -10,13 +10,13 @@ import xyz.zcraft.seira.bot.data.Button; import xyz.zcraft.seira.bot.data.PendingMessage; import xyz.zcraft.seira.command.Context; -import xyz.zcraft.seira.command.handler.RankGuessCommandHandler; import xyz.zcraft.seira.config.AppConfig; import xyz.zcraft.seira.config.BindingConfig; import xyz.zcraft.seira.data.UploadedImage; import xyz.zcraft.seira.db.RankGuessRecordStore; import xyz.zcraft.seira.db.UserDataStore; import xyz.zcraft.seira.rankguess.data.FinishedRound; +import xyz.zcraft.seira.rankguess.data.Rank; import xyz.zcraft.seira.rankguess.data.Round; import xyz.zcraft.seira.rankguess.data.Standing; import xyz.zcraft.seira.services.BindingService; @@ -126,7 +126,7 @@ public PendingMessage rankGuessResultMessage(Context ctx, FinishedRound result, public PendingMessage rankGuessStatisticsMessage( Context ctx, RankGuessRecordStore.Statistics.Personal statistics, RankGuessRecordStore.Statistics.Personal recentStatistics, - boolean allGroups, RankGuessCommandHandler.Rank rank, + boolean allGroups, Rank rank, Long pickedTimes, Long groupGameCount ) { String scope = allGroups ? "全部群聊" : "本群"; @@ -135,7 +135,7 @@ public PendingMessage rankGuessStatisticsMessage( } String rankText = "?".equals(rank.rank()) ? "" : "根据你最近 %d 场的表现,可以给到一个 `%s` 喵!\n" - .formatted(RankGuessCommandHandler.Rank.RECENT_GAME_LIMIT, rank.rank()); + .formatted(Rank.RECENT_GAME_LIMIT, rank.rank()); String groupCountText = ""; if (!allGroups && pickedTimes != null && groupGameCount != null) { groupCountText = "> 被猜次数:`%d`,占本群:`%.3f%%`\n".formatted(pickedTimes, (double) pickedTimes / groupGameCount * 100); @@ -153,7 +153,7 @@ public PendingMessage rankGuessStatisticsMessage( > 总得分:`%.2f` %s%s """, - scope, RankGuessCommandHandler.Rank.RECENT_GAME_LIMIT, + scope, Rank.RECENT_GAME_LIMIT, statistics.participation(), rank.rating(), statistics.wins(), recentStatistics.wins(), statistics.winRate() * 100, recentStatistics.winRate() * 100, diff --git a/src/main/java/xyz/zcraft/seira/rankguess/data/Rank.java b/src/main/java/xyz/zcraft/seira/rankguess/data/Rank.java new file mode 100644 index 00000000..72f83b4a --- /dev/null +++ b/src/main/java/xyz/zcraft/seira/rankguess/data/Rank.java @@ -0,0 +1,64 @@ +package xyz.zcraft.seira.rankguess.data; + +import xyz.zcraft.seira.db.RankGuessRecordStore; + +public record Rank(double rating, double ratingRaw, String rank) { + public static final int RECENT_GAME_LIMIT = 10; + + public static Rank from(RankGuessRecordStore.Statistics.Personal recent, + RankGuessRecordStore.Statistics.Personal all) { + final double pendingRatingRaw = getRatingRaw(recent, all); + final double pendingRating = standardRating(pendingRatingRaw); + final String pendingRank = getRankText(all.participation(), pendingRatingRaw); + + return new Rank(pendingRating, pendingRatingRaw, pendingRank); + } + + private static double getRatingRaw(RankGuessRecordStore.Statistics.Personal recent, + RankGuessRecordStore.Statistics.Personal all) { + double averageScoreRate = Math.clamp( + recent.averageScore() / 900.0, + 0.0, 1.0 + ); + + double rawRating = averageScoreRate * 0.55 + + recent.winRate() * 0.10 + + recent.topTwentyRate() * 0.35; + + rawRating *= 1.025; + + double confidence = 1.0 - Math.exp(-all.participation() / 10.0); + + return 0.50 * (1.0 - confidence) + rawRating * confidence; + } + + private static double standardRating(double ratingRaw) { + double z = 2 * ratingRaw - 1; + double p = 1.1; + + return 1 + Math.copySign(Math.pow(Math.abs(z), p), z); + } + + private static String getRankText(long totalParticipation, double rawRating) { + if (totalParticipation < 5) { + return "?"; + } + + String rank; + + if (rawRating >= 1.00) rank = "SS"; + else if (rawRating >= 0.82) rank = "S"; + else if (rawRating >= 0.68) rank = "A"; + else if (rawRating >= 0.57) rank = "B"; + else if (rawRating >= 0.42) rank = "C"; + else rank = "D"; + + if (totalParticipation < 10) { + rank += "?"; + } else if (rawRating >= 1.00) { + rank += "(强强!?!)"; + } + + return rank; + } +} \ No newline at end of file From ff2f3a6b138e13be4c384ae245aaa617b88e357b Mon Sep 17 00:00:00 2001 From: ZayrexDev Date: Sat, 5 Sep 2026 02:43:13 +0800 Subject: [PATCH 54/54] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=8F=B7=E8=87=B31.11.6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a36a69f5..5d0e389f 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.github.BotSeira SeiraCore - 1.11.5 + 1.11.6 25