Skip to content

Commit 1c4aaf5

Browse files
committed
graphical help XP leaderboard
1 parent ff22fd0 commit 1c4aaf5

File tree

4 files changed

+237
-118
lines changed

4 files changed

+237
-118
lines changed

src/main/java/net/javadiscord/javabot/systems/help/HelpExperienceService.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
import net.javadiscord.javabot.systems.help.dao.HelpTransactionRepository;
1212
import net.javadiscord.javabot.systems.help.model.HelpAccount;
1313
import net.javadiscord.javabot.systems.help.model.HelpTransaction;
14+
import net.javadiscord.javabot.systems.user_commands.leaderboard.ExperienceLeaderboardSubcommand;
1415
import net.javadiscord.javabot.util.ExceptionLogger;
16+
import net.javadiscord.javabot.util.ImageCache;
1517
import net.javadiscord.javabot.util.Pair;
1618
import org.jetbrains.annotations.NotNull;
1719
import org.springframework.dao.DataAccessException;
@@ -97,6 +99,7 @@ public void performTransaction(long recipient, double value, Guild guild, long c
9799
helpTransactionRepository.save(transaction);
98100
checkExperienceRoles(guild, account);
99101
log.info("Added {} help experience to {}'s help account", value, recipient);
102+
ImageCache.removeCachedImagesByKeyword(ExperienceLeaderboardSubcommand.CACHE_PREFIX);
100103
}
101104

102105
private void checkExperienceRoles(@NotNull Guild guild, @NotNull HelpAccount account) {
@@ -114,10 +117,10 @@ private void checkExperienceRoles(@NotNull Guild guild, @NotNull HelpAccount acc
114117
}
115118
}), e -> {});
116119
}
117-
120+
118121
/**
119122
* add XP to all helpers depending on the messages they sent.
120-
*
123+
*
121124
* @param post The {@link ThreadChannel} post
122125
* @param allowIfXPAlreadyGiven {@code true} if XP should be awarded if XP have already been awarded
123126
*/

src/main/java/net/javadiscord/javabot/systems/user_commands/leaderboard/ExperienceLeaderboardSubcommand.java

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
import xyz.dynxsty.dih4jda.interactions.components.ButtonHandler;
77
import net.dv8tion.jda.api.EmbedBuilder;
88
import net.dv8tion.jda.api.entities.Guild;
9+
import net.dv8tion.jda.api.entities.Member;
910
import net.dv8tion.jda.api.entities.MessageEmbed;
10-
import net.dv8tion.jda.api.entities.MessageEmbed.Field;
1111
import net.dv8tion.jda.api.entities.Role;
12-
import net.dv8tion.jda.api.entities.User;
1312
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
1413
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
1514
import net.dv8tion.jda.api.interactions.commands.OptionMapping;
@@ -18,6 +17,8 @@
1817
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
1918
import net.dv8tion.jda.api.interactions.components.ActionRow;
2019
import net.dv8tion.jda.api.interactions.components.buttons.Button;
20+
import net.dv8tion.jda.api.utils.FileUpload;
21+
import net.dv8tion.jda.api.utils.messages.MessageEditBuilder;
2122
import net.javadiscord.javabot.annotations.AutoDetectableComponentHandler;
2223
import net.javadiscord.javabot.systems.help.dao.HelpAccountRepository;
2324
import net.javadiscord.javabot.systems.help.dao.HelpTransactionRepository;
@@ -28,7 +29,9 @@
2829
import org.jetbrains.annotations.NotNull;
2930
import org.springframework.dao.DataAccessException;
3031

32+
import java.io.IOException;
3133
import java.util.List;
34+
import java.util.Objects;
3235
import java.util.concurrent.ExecutorService;
3336
import java.util.function.BiFunction;
3437

@@ -37,7 +40,12 @@
3740
*/
3841
@AutoDetectableComponentHandler("experience-leaderboard")
3942
public class ExperienceLeaderboardSubcommand extends SlashCommand.Subcommand implements ButtonHandler {
40-
private static final int PAGE_SIZE = 5;
43+
/**
44+
* prefix contained in the image cache.
45+
*/
46+
public static final String CACHE_PREFIX = "xp_leaderboard";
47+
private static final int PAGE_SIZE = 10;
48+
4149
private final ExecutorService asyncPool;
4250
private final HelpAccountRepository helpAccountRepository;
4351
private final HelpTransactionRepository helpTransactionRepository;
@@ -90,58 +98,74 @@ public void handleButton(@NotNull ButtonInteractionEvent event, Button button) {
9098
if (page > maxPage) {
9199
page = 1;
92100
}
101+
Pair<MessageEmbed, FileUpload> messageInfo = buildExperienceLeaderboard(event.getGuild(), page, type);
93102
event.getHook()
94-
.editOriginalEmbeds(buildExperienceLeaderboard(event.getGuild(), page, type))
103+
.editOriginal(new MessageEditBuilder().setEmbeds(messageInfo.first()).setAttachments(messageInfo.second()).build())
95104
.setComponents(buildPageControls(page, type)).queue();
96-
} catch (DataAccessException e) {
105+
} catch (DataAccessException | IOException e) {
97106
ExceptionLogger.capture(e, ExperienceLeaderboardSubcommand.class.getSimpleName());
98107
}
99108
});
100109
}
101110

102111

103-
private @NotNull MessageEmbed buildExperienceLeaderboard(Guild guild, int page, LeaderboardType type) throws DataAccessException {
112+
private @NotNull Pair<MessageEmbed, FileUpload> buildExperienceLeaderboard(Guild guild, int page, LeaderboardType type) throws DataAccessException, IOException {
104113
return switch (type) {
105114
case TOTAL -> buildGenericExperienceLeaderboard(page, helpAccountRepository.getTotalAccounts(),
106115
"total Leaderboard of help experience",
107116
helpAccountRepository::getAccounts, (position, account) -> {
108117
Pair<Role, Double> currentRole = account.getCurrentExperienceGoal(guild);
109-
return buildEmbed(guild, position, account.getExperience(), account.getUserId(), currentRole.first() != null ? currentRole.first().getAsMention() + ": " : "");
118+
return createUserData(guild, position, account.getExperience(), account.getUserId(), currentRole.first() != null ? currentRole.first().getAsMention() + ": " : "");
110119
});
111120
case MONTH -> buildGenericExperienceLeaderboard(page, helpTransactionRepository.getNumberOfUsersWithHelpXPInLastMonth(),
112121
"""
113122
help experience leaderboard from the last 30 days
114123
This leaderboard does not include experience decay.
115124
""",
116125
helpTransactionRepository::getTotalTransactionWeightsInLastMonth, (position, xpInfo) -> {
117-
return buildEmbed(guild, position, (double) xpInfo.second(), xpInfo.first(), "");
126+
return createUserData(guild, position, (double) xpInfo.second(), xpInfo.first(), "");
118127
});
119128
};
120129
}
121130

122-
private <T> @NotNull MessageEmbed buildGenericExperienceLeaderboard(int page, int totalAccounts, String description,
123-
BiFunction<Integer, Integer, List<T>> accountsReader, BiFunction<Integer, T, MessageEmbed.Field> fieldExtractor) throws DataAccessException {
131+
private <T> @NotNull Pair<MessageEmbed, FileUpload> buildGenericExperienceLeaderboard(int page, int totalAccounts, String description,
132+
BiFunction<Integer, Integer, List<T>> accountsReader, BiFunction<Integer, T, UserData> fieldExtractor) throws DataAccessException, IOException {
133+
124134
int maxPage = totalAccounts / PAGE_SIZE;
125135
int actualPage = Math.max(1, Math.min(page, maxPage));
126136
List<T> accounts = accountsReader.apply(actualPage, PAGE_SIZE);
137+
127138
EmbedBuilder builder = new EmbedBuilder()
128139
.setTitle("Experience Leaderboard")
129140
.setDescription(description)
130141
.setColor(Responses.Type.DEFAULT.getColor())
131142
.setFooter(String.format("Page %s/%s", actualPage, maxPage));
132-
for (int i = 0; i < accounts.size(); i++) {
133-
int position = (i + 1) + (actualPage - 1) * PAGE_SIZE;
134-
builder.addField(fieldExtractor.apply(position, accounts.get(i)));
135-
}
136-
return builder.build();
143+
144+
String pageCachePrefix = CACHE_PREFIX + "_" + page;
145+
String cacheName = pageCachePrefix + "_" + accounts.hashCode();
146+
byte[] bytes = LeaderboardCreator.attemptLoadFromCache(cacheName, ()->{
147+
try (LeaderboardCreator creator = new LeaderboardCreator(accounts.size(), null)){
148+
for (int i = 0; i < accounts.size(); i++) {
149+
int position = (i + 1) + (actualPage - 1) * PAGE_SIZE;
150+
UserData userInfo = fieldExtractor.apply(position, accounts.get(i));
151+
creator.drawLeaderboardEntry(userInfo.member(), userInfo.displayName(), userInfo.xp(), position);
152+
}
153+
return creator.getImageBytes(cacheName, pageCachePrefix);
154+
}
155+
});
156+
builder.setImage("attachment://leaderboard.png");
157+
return new Pair<MessageEmbed, FileUpload>(builder.build(), FileUpload.fromData(bytes, "leaderboard.png"));
137158
}
138159

139-
private Field buildEmbed(Guild guild, Integer position, double experience, long userId, String prefix) {
140-
User user = guild.getJDA().getUserById(userId);
141-
return new MessageEmbed.Field(
142-
String.format("**%s.** %s", position, user == null ? userId : UserUtils.getUserTag(user)),
143-
String.format("%s`%.0f XP`\n", prefix, experience),
144-
false);
160+
private UserData createUserData(Guild guild, Integer position, double experience, long userId, String prefix) {
161+
Member member = guild.getMemberById(userId);
162+
String displayName;
163+
if (member == null) {
164+
displayName = String.valueOf(userId);
165+
} else {
166+
displayName = UserUtils.getUserTag(member.getUser());
167+
}
168+
return new UserData(member, displayName, (long)experience);
145169
}
146170

147171
@Contract("_ -> new")
@@ -159,10 +183,12 @@ public void execute(@NotNull SlashCommandInteractionEvent event) {
159183
event.deferReply().queue();
160184
asyncPool.execute(() -> {
161185
try {
162-
event.getHook().sendMessageEmbeds(buildExperienceLeaderboard(event.getGuild(), page, type))
186+
Pair<MessageEmbed, FileUpload> messageInfo = buildExperienceLeaderboard(event.getGuild(), page, type);
187+
event.getHook().sendMessageEmbeds(messageInfo.first())
188+
.addFiles(messageInfo.second())
163189
.setComponents(buildPageControls(page, type))
164190
.queue();
165-
}catch (DataAccessException e) {
191+
}catch (DataAccessException | IOException e) {
166192
ExceptionLogger.capture(e, ExperienceLeaderboardSubcommand.class.getSimpleName());
167193
}
168194
});
@@ -171,4 +197,10 @@ public void execute(@NotNull SlashCommandInteractionEvent event) {
171197
private enum LeaderboardType{
172198
TOTAL, MONTH
173199
}
200+
201+
private record UserData(Member member, String displayName, long xp) {
202+
UserData {
203+
Objects.requireNonNull(displayName);
204+
}
205+
}
174206
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package net.javadiscord.javabot.systems.user_commands.leaderboard;
2+
3+
import java.awt.Color;
4+
import java.awt.Font;
5+
import java.awt.Graphics2D;
6+
import java.awt.RenderingHints;
7+
import java.awt.image.BufferedImage;
8+
import java.io.ByteArrayOutputStream;
9+
import java.io.IOException;
10+
11+
import javax.imageio.ImageIO;
12+
13+
import org.jetbrains.annotations.NotNull;
14+
import org.jetbrains.annotations.Nullable;
15+
16+
import net.dv8tion.jda.api.entities.Member;
17+
import net.javadiscord.javabot.util.ImageCache;
18+
import net.javadiscord.javabot.util.ImageGenerationUtils;
19+
20+
/**
21+
* Creates graphical leaderboards.
22+
*/
23+
class LeaderboardCreator implements AutoCloseable{
24+
25+
private static final Color PRIMARY_COLOR = Color.WHITE;
26+
private static final Color SECONDARY_COLOR = Color.decode("#414A52");
27+
private static final int MARGIN = 40;
28+
/**
29+
* The image's width.
30+
*/
31+
private static final int WIDTH = 3000;
32+
33+
private static final Color BACKGROUND_COLOR = Color.decode("#011E2F");
34+
private Graphics2D g2d;
35+
private int y;
36+
private boolean left;
37+
private BufferedImage image;
38+
39+
/**
40+
* Prepares drawing a leaderboard.
41+
* @param numberOfEntries the number of entries in the leaderboard
42+
* @param logoName the name of the logo put at the top of the leaderboard or {@code null} if no logo shall be used
43+
* @throws IOException if anything goes wrong
44+
*/
45+
LeaderboardCreator(int numberOfEntries, String logoName) throws IOException{
46+
47+
int logoHeight = 0;
48+
BufferedImage logo = null;
49+
if (logoName != null) {
50+
logo = ImageGenerationUtils.getResourceImage("assets/images/" + logoName + ".png");
51+
logoHeight = logo.getHeight();
52+
}
53+
54+
int height = (logoHeight + MARGIN * 3) +
55+
(ImageGenerationUtils.getResourceImage("assets/images/LeaderboardUserCard.png").getHeight() + MARGIN) * ((int)Math.ceil(numberOfEntries / 2f)) + MARGIN;
56+
image = new BufferedImage(WIDTH, height, BufferedImage.TYPE_INT_RGB);
57+
g2d = image.createGraphics();
58+
59+
g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB);
60+
g2d.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
61+
g2d.setPaint(BACKGROUND_COLOR);
62+
g2d.fillRect(0, 0, WIDTH, height);
63+
if (logo != null) {
64+
g2d.drawImage(logo, WIDTH / 2 - logo.getWidth() / 2, MARGIN, null);
65+
}
66+
67+
left = true;
68+
y = logoHeight + 3 * MARGIN;
69+
}
70+
71+
/**
72+
* adds a single entry in the leaderboard.
73+
* @param member the {@link Member} this entry is responsible for or {@code null} if no member can be associated
74+
* @param displayName the name to display
75+
* @param points the amount of points
76+
* @param rankNumber the rank of the given user
77+
* @throws IOException if anything goes wrong
78+
*/
79+
public void drawLeaderboardEntry(@Nullable Member member, @NotNull String displayName, long points, int rankNumber) throws IOException {
80+
BufferedImage card = ImageGenerationUtils.getResourceImage("assets/images/LeaderboardUserCard.png");
81+
int x = left ? MARGIN * 5 : WIDTH - MARGIN * 5 - card.getWidth();
82+
if (member != null) {
83+
g2d.drawImage(ImageGenerationUtils.getImageFromUrl(member.getEffectiveAvatarUrl() + "?size=4096"), x + 185, y + 43, 200, 200, null);
84+
}
85+
// draw card
86+
g2d.drawImage(card, x, y, null);
87+
g2d.setColor(PRIMARY_COLOR);
88+
g2d.setFont(ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", 65).orElseThrow());
89+
90+
int stringWidth = g2d.getFontMetrics().stringWidth(displayName);
91+
while (stringWidth > 750) {
92+
Font currentFont = g2d.getFont();
93+
Font newFont = currentFont.deriveFont(currentFont.getSize() - 1F);
94+
g2d.setFont(newFont);
95+
stringWidth = g2d.getFontMetrics().stringWidth(displayName);
96+
}
97+
g2d.drawString(displayName, x + 430, y + 130);
98+
g2d.setColor(SECONDARY_COLOR);
99+
g2d.setFont(ImageGenerationUtils.getResourceFont("assets/fonts/Uni-Sans-Heavy.ttf", 72).orElseThrow());
100+
101+
String text = points + (points > 1 ? " points" : " point");
102+
String rank = "#" + rankNumber;
103+
g2d.drawString(text, x + 430, y + 210);
104+
int stringLength = (int) g2d.getFontMetrics().getStringBounds(rank, g2d).getWidth();
105+
int start = 185 / 2 - stringLength / 2;
106+
g2d.drawString(rank, x + start, y + 173);
107+
108+
left = !left;
109+
if (left) y = y + card.getHeight() + MARGIN;
110+
}
111+
112+
/**
113+
* convert the drawn image to a {@code byte[]}.
114+
*
115+
* This also caches the image and invalidates all caches matching {@code invalidateCacheKeyword}
116+
* @param cacheName the name of the cache where the image should be cached
117+
* @param invalidateCacheKeyword all image caches containing this keyword will be invalidated, should be a substring of {@code cacheName}
118+
* @return the drawn image as a {@code byte[]}
119+
* @throws IOException if anything goes wrong
120+
*/
121+
public @NotNull byte[] getImageBytes(String cacheName, String invalidateCacheKeyword) throws IOException {
122+
ImageCache.removeCachedImagesByKeyword(invalidateCacheKeyword);
123+
ImageCache.cacheImage(cacheName, image);
124+
try (ByteArrayOutputStream baos = getOutputStreamFromImage(image)) {
125+
return baos.toByteArray();
126+
}
127+
}
128+
129+
/**
130+
* load an image from the cache as a {@code byte[]}.
131+
* @param cacheName the name of the cache to load
132+
* @param fallback a callback which is executed in case the cache could not be found
133+
* @return the cached image or the fallback image
134+
* @throws IOException if anything goes wrong
135+
*/
136+
public static byte[] attemptLoadFromCache(String cacheName, ByteArrayLoader fallback) throws IOException {
137+
return ImageCache.isCached(cacheName) ?
138+
// retrieve the image from the cache
139+
getOutputStreamFromImage(ImageCache.getCachedImage(cacheName)).toByteArray() :
140+
// generate an entirely new image
141+
fallback.load();
142+
}
143+
144+
/**
145+
* Retrieves the image's {@link ByteArrayOutputStream}.
146+
*
147+
* @param image The image.
148+
* @return The image's {@link ByteArrayOutputStream}.
149+
* @throws IOException If an error occurs.
150+
*/
151+
private static @NotNull ByteArrayOutputStream getOutputStreamFromImage(BufferedImage image) throws IOException {
152+
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
153+
ImageIO.write(image, "png", outputStream);
154+
return outputStream;
155+
}
156+
157+
@Override
158+
public void close() {
159+
g2d.dispose();
160+
}
161+
162+
@FunctionalInterface
163+
interface ByteArrayLoader{
164+
byte[] load() throws IOException;
165+
}
166+
167+
}

0 commit comments

Comments
 (0)