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,也会
+每局结算后自动保存战绩。在群内使用 `/rg stats` 查询自己的本群战绩,使用 `/rg stats all` 查询自己的全部群聊战绩。
+统计包括参与数、获胜数、胜率、前 20% 次数和达成率、平均分、最高分、总得分及平均名次。
+每人每局只统计最终猜测,获胜按最终排行榜第一名计算;前 20% 名额向上取整,仅统计至少 5 人参与的对局,达成率以这些对局为分母。
+战绩与用户绑定共用配置中的 SQLite 数据库,分数采用最终结算得分;历史未记录的对局不会自动补入。
+
### 查询群友的排行榜!
@@ -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 .