fix: ratings reliability + ratingstop/ratingbottom board commands

- generateratings: MemberCachePolicy.ALL + ChunkingFilter.ALL + guild.loadMembers()
  (JDA cached only 2 members on small guilds by default)
- RatingService: new Random(seed) per call instead of shared Random.setSeed (race)
- LogEmbeds ban/kick: null reason -> 'Не указана'
- RatingsRepository.getTopRatings/getBottomRatings (ORDER BY value, LIMIT)
- RatingBoardCommand: ratingstop/ratingbottom, top-3 medals, guild-scoped
This commit is contained in:
loki5512344 2026-08-19 13:52:54 +02:00
parent 7cf4b9debb
commit 78645e5727
Signed by: boba
GPG key ID: 253067914055423B
8 changed files with 245 additions and 7 deletions

View file

@ -7,6 +7,8 @@ import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.OnlineStatus; import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.entities.Activity; import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.requests.GatewayIntent; import net.dv8tion.jda.api.requests.GatewayIntent;
import net.dv8tion.jda.api.utils.ChunkingFilter;
import net.dv8tion.jda.api.utils.MemberCachePolicy;
import net.dv8tion.jda.api.utils.cache.CacheFlag; import net.dv8tion.jda.api.utils.cache.CacheFlag;
public final class JdaFactory { public final class JdaFactory {
@ -18,7 +20,9 @@ public final class JdaFactory {
.setStatus(OnlineStatus.ONLINE) .setStatus(OnlineStatus.ONLINE)
.setActivity(Activity.playing(activity)) .setActivity(Activity.playing(activity))
.enableIntents(List.of(intents)) .enableIntents(List.of(intents))
.enableCache(CacheFlag.VOICE_STATE); .enableCache(CacheFlag.VOICE_STATE)
.setMemberCachePolicy(MemberCachePolicy.ALL)
.setChunkingFilter(ChunkingFilter.ALL);
} }
public static JDA awaitJda(final JDA jda) { public static JDA awaitJda(final JDA jda) {

View file

@ -69,7 +69,7 @@ public class GenerateRatingsCommand implements Command {
var guildId = context.getGuild().getId(); var guildId = context.getGuild().getId();
var memberCount = 0; var memberCount = 0;
var generatedCount = 0; var generatedCount = 0;
for (Member member : context.getGuild().getMembers()) { for (Member member : context.getGuild().loadMembers().get()) {
if (member.getUser().isBot()) { if (member.getUser().isBot()) {
continue; continue;
} }

View file

@ -12,7 +12,6 @@ import net.dv8tion.jda.api.entities.Member;
@Singleton @Singleton
public class RatingService { public class RatingService {
private static final Random RANDOM = new Random();
private static final Duration COOLDOWN = Duration.ofSeconds(5); private static final Duration COOLDOWN = Duration.ofSeconds(5);
private final CooldownManager cooldownManager; private final CooldownManager cooldownManager;
@ -25,8 +24,7 @@ public class RatingService {
public int getDeterministicValue( public int getDeterministicValue(
final String userId, final int salt, final int min, final int max) { final String userId, final int salt, final int min, final int max) {
long seed = Long.parseLong(userId) + salt; long seed = Long.parseLong(userId) + salt;
RANDOM.setSeed(seed); return new Random(seed).nextInt(max - min + 1) + min;
return RANDOM.nextInt(max - min + 1) + min;
} }
public Member resolveTarget(final CommandContext context) { public Member resolveTarget(final CommandContext context) {

View file

@ -47,7 +47,7 @@ public final class LogEmbeds {
.setTitle("🔨 Пользователь забанен") .setTitle("🔨 Пользователь забанен")
.addField("Пользователь", userMention, true) .addField("Пользователь", userMention, true)
.addField("Модератор", moderatorMention, true) .addField("Модератор", moderatorMention, true)
.addField("Причина", reason, false) .addField("Причина", reason != null ? reason : "Не указана", false)
.setColor(Color.RED) .setColor(Color.RED)
.setThumbnail(avatarUrl); .setThumbnail(avatarUrl);
} }
@ -71,7 +71,7 @@ public final class LogEmbeds {
.setTitle("👢 Пользователь кикнут") .setTitle("👢 Пользователь кикнут")
.addField("Пользователь", userMention, true) .addField("Пользователь", userMention, true)
.addField("Модератор", moderatorMention, true) .addField("Модератор", moderatorMention, true)
.addField("Причина", reason, false) .addField("Причина", reason != null ? reason : "Не указана", false)
.setColor(Color.ORANGE) .setColor(Color.ORANGE)
.setThumbnail(avatarUrl); .setThumbnail(avatarUrl);
} }

View file

@ -0,0 +1,130 @@
package creator.loki.feature.rating.commands.board;
import java.awt.Color;
import java.util.List;
import java.util.Set;
import creator.loki.command.Command;
import creator.loki.command.CommandCategories;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.feature.rating.repository.RatingsRepository;
/** Топ/анти-топ пользователей по значению рейтинга (после !generateratings). */
public class RatingBoardCommand implements Command {
private static final Set<String> TYPES =
Set.of("ship", "gay", "iq", "pp", "mustache", "fat", "boykisser");
private static final String DEFAULT_TYPE = "iq";
private static final int BOARD_SIZE = 10;
public enum Flavor {
TOP("ratingstop", "Топ-10 по значению рейтинга", "🏆", new Color(255, 215, 0), true),
BOTTOM(
"ratingbottom", "Анти-топ-10 по значению рейтинга", "📉", new Color(176, 196, 222), false);
final String name;
final String description;
final String emoji;
final Color color;
final boolean descending;
Flavor(String name, String description, String emoji, Color color, boolean descending) {
this.name = name;
this.description = description;
this.emoji = emoji;
this.color = color;
this.descending = descending;
}
}
private final Flavor flavor;
private final RatingsRepository ratingsRepository;
public RatingBoardCommand(Flavor flavor, RatingsRepository ratingsRepository) {
this.flavor = flavor;
this.ratingsRepository = ratingsRepository;
}
@Override
public String getName() {
return flavor.name;
}
@Override
public String getDescription() {
return flavor.description;
}
@Override
public String getUsage() {
return flavor.name + " [тип]";
}
@Override
public String getCategory() {
return CommandCategories.MEASUREMENTS;
}
@Override
public void execute(final CommandContext context) {
if (context.getGuild() == null) {
context.getChannel().sendMessage("❌ Команда работает только на сервере.").queue();
return;
}
String type = context.getArgs().isEmpty() ? DEFAULT_TYPE : context.getArgs().get(0);
if (!TYPES.contains(type)) {
context
.getChannel()
.sendMessage("❌ Неизвестный тип: `" + type + "`. Доступно: " + String.join(", ", TYPES))
.queue();
return;
}
List<RatingsRepository.RatingEntry> board =
flavor.descending
? ratingsRepository.getTopRatings(context.getGuild().getId(), type, BOARD_SIZE)
: ratingsRepository.getBottomRatings(context.getGuild().getId(), type, BOARD_SIZE);
if (board.isEmpty()) {
context
.getChannel()
.sendMessage("❌ Пока никто не имеет рейтинга `" + type + "`. Сначала `!generateratings`.")
.queue();
return;
}
StringBuilder description = new StringBuilder();
for (int i = 0; i < board.size(); i++) {
RatingsRepository.RatingEntry entry = board.get(i);
String medal =
switch (i) {
case 0 -> "🥇";
case 1 -> "🥈";
case 2 -> "🥉";
default -> "⭐";
};
description
.append(medal)
.append(" **#")
.append(i + 1)
.append("** <@")
.append(entry.userId())
.append("> — **")
.append(entry.value())
.append("**\n");
}
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle(flavor.emoji + " " + flavor.name + " · " + type)
.withDescription(description.toString())
.withColor(flavor.color)
.withFooter("Типы: " + String.join(", ", TYPES))
.build())
.queue();
}
}

View file

@ -3,7 +3,9 @@ package creator.loki.feature.rating.repository;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import creator.loki.database.Database; import creator.loki.database.Database;
@ -134,4 +136,45 @@ public class RatingsRepository {
logger.error("Ошибка удаления всех рейтингов на сервере", e); logger.error("Ошибка удаления всех рейтингов на сервере", e);
} }
} }
/** Пользователь и его значение рейтинга */
public record RatingEntry(String userId, int value) {}
/** Топ-N пользователей с самым высоким значением указанного типа рейтинга */
public List<RatingEntry> getTopRatings(
final String guildId, final String ratingType, final int limit) {
return getRatingBoard(guildId, ratingType, limit, true);
}
/** Топ-N пользователей с самым низким значением указанного типа рейтинга */
public List<RatingEntry> getBottomRatings(
final String guildId, final String ratingType, final int limit) {
return getRatingBoard(guildId, ratingType, limit, false);
}
private List<RatingEntry> getRatingBoard(
final String guildId, final String ratingType, final int limit, final boolean descending) {
String sql =
"SELECT user_id, value FROM ratings WHERE guild_id = ? AND rating_type = ?"
+ " ORDER BY value "
+ (descending ? "DESC" : "ASC")
+ " LIMIT ?";
List<RatingEntry> entries = new ArrayList<>();
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, guildId);
stmt.setString(2, ratingType);
stmt.setInt(3, limit);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
entries.add(new RatingEntry(rs.getString("user_id"), rs.getInt("value")));
}
}
} catch (SQLException e) {
logger.error("Ошибка получения борда рейтинга {} для сервера {}", ratingType, guildId, e);
}
return entries;
}
} }

View file

@ -17,6 +17,7 @@ import creator.loki.feature.info.commands.PingCommand;
import creator.loki.feature.info.commands.ServerStatsCommand; import creator.loki.feature.info.commands.ServerStatsCommand;
import creator.loki.feature.metrics.MetricsCollector; import creator.loki.feature.metrics.MetricsCollector;
import creator.loki.feature.metrics.commands.MetricsCommand; import creator.loki.feature.metrics.commands.MetricsCommand;
import creator.loki.feature.rating.commands.board.RatingBoardCommand;
import creator.loki.feature.rating.repository.RatingsRepository; import creator.loki.feature.rating.repository.RatingsRepository;
import creator.loki.feature.reputation.MinusRepCommand; import creator.loki.feature.reputation.MinusRepCommand;
import creator.loki.feature.reputation.RepCommand; import creator.loki.feature.reputation.RepCommand;
@ -78,6 +79,8 @@ public class UtilityCommandRegistrar implements CommandRegistrar {
registry.register(new CounterSetupPrefixCommand(counterManager, configRepository)); registry.register(new CounterSetupPrefixCommand(counterManager, configRepository));
registry.register(new GenerateRatingsCommand(ratingsRepository, config)); registry.register(new GenerateRatingsCommand(ratingsRepository, config));
registry.register(new RatingBoardCommand(RatingBoardCommand.Flavor.TOP, ratingsRepository));
registry.register(new RatingBoardCommand(RatingBoardCommand.Flavor.BOTTOM, ratingsRepository));
registry.register(new RepCommand(reputationRepository, cooldownManager)); registry.register(new RepCommand(reputationRepository, cooldownManager));
registry.register(new MinusRepCommand(reputationRepository, cooldownManager)); registry.register(new MinusRepCommand(reputationRepository, cooldownManager));

View file

@ -199,4 +199,64 @@ class RatingsRepositoryTest {
repository.deleteAllRatings("nonexistent"); repository.deleteAllRatings("nonexistent");
} }
} }
@Nested
class RatingBoard {
@Test
void topRatingsDescending() {
repository.setRating("guild1", "user1", "iq", 100);
repository.setRating("guild1", "user2", "iq", 150);
repository.setRating("guild1", "user3", "iq", 50);
var top = repository.getTopRatings("guild1", "iq", 10);
assertEquals(3, top.size());
assertEquals("user2", top.get(0).userId());
assertEquals(150, top.get(0).value());
assertEquals("user3", top.get(2).userId());
assertEquals(50, top.get(2).value());
}
@Test
void topRatingsRespectsLimit() {
repository.setRating("guild1", "user1", "iq", 100);
repository.setRating("guild1", "user2", "iq", 150);
repository.setRating("guild1", "user3", "iq", 50);
var top = repository.getTopRatings("guild1", "iq", 2);
assertEquals(2, top.size());
assertEquals("user2", top.get(0).userId());
assertEquals("user1", top.get(1).userId());
}
@Test
void bottomRatingsAscending() {
repository.setRating("guild1", "user1", "iq", 100);
repository.setRating("guild1", "user2", "iq", 150);
repository.setRating("guild1", "user3", "iq", 50);
var bottom = repository.getBottomRatings("guild1", "iq", 10);
assertEquals(3, bottom.size());
assertEquals("user3", bottom.get(0).userId());
assertEquals(50, bottom.get(0).value());
assertEquals("user2", bottom.get(2).userId());
assertEquals(150, bottom.get(2).value());
}
@Test
void boardFiltersByGuildAndType() {
repository.setRating("guild1", "user1", "iq", 100);
repository.setRating("guild2", "user1", "iq", 200);
repository.setRating("guild1", "user1", "gay", 90);
assertEquals(1, repository.getTopRatings("guild1", "iq", 10).size());
assertEquals(200, repository.getTopRatings("guild2", "iq", 10).get(0).value());
assertEquals(90, repository.getTopRatings("guild1", "gay", 10).get(0).value());
}
@Test
void boardEmptyWhenNoRatings() {
assertTrue(repository.getTopRatings("guild1", "iq", 10).isEmpty());
assertTrue(repository.getBottomRatings("guild1", "iq", 10).isEmpty());
}
}
} }