diff --git a/src/main/java/cc/fascinated/bat/common/MathUtils.java b/src/main/java/cc/fascinated/bat/common/MathUtils.java index 256ee41..8b8e300 100644 --- a/src/main/java/cc/fascinated/bat/common/MathUtils.java +++ b/src/main/java/cc/fascinated/bat/common/MathUtils.java @@ -48,4 +48,15 @@ public final class MathUtils { public static double lerp(double a, double b, double t) { return a + t * (b - a); } + + /** + * Generates a random number between a minimum and maximum. + * + * @param min The minimum value. + * @param max The maximum value. + * @return The random value. + */ + public static double random(double min, double max) { + return Math.random() * (max - min) + min; + } } diff --git a/src/main/java/cc/fascinated/bat/features/leveling/LevelingFeature.java b/src/main/java/cc/fascinated/bat/features/leveling/LevelingFeature.java new file mode 100644 index 0000000..7c65599 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/leveling/LevelingFeature.java @@ -0,0 +1,52 @@ +package cc.fascinated.bat.features.leveling; + +import cc.fascinated.bat.features.Feature; +import cc.fascinated.bat.features.FeatureProfile; +import cc.fascinated.bat.features.leveling.command.LevelingCommand; +import cc.fascinated.bat.service.CommandService; +import lombok.NonNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author Fascinated (fascinated7) + */ +@Component +public class LevelingFeature extends Feature { + /** + * The cache of the required XP for each level. + */ + public static final Map xpRequiredCache = new HashMap<>(); + + @Autowired + public LevelingFeature(@NonNull ApplicationContext context, @NonNull CommandService commandService) { + super("Leveling", FeatureProfile.FeatureState.DISABLED, true); + + super.registerCommand(commandService, context.getBean(LevelingCommand.class)); + } + + /** + * Gets the amount of XP needed to level up. + * + * @param level The level. + * @param currentXp The current XP. + * @return The needed XP. + */ + public static double getNeededXP(int level, double currentXp) { + return xpRequiredCache.computeIfAbsent(level, integer -> getBaseXP(level) - currentXp); + } + + /** + * Gets the base XP for a level. + * + * @param level The level. + * @return The base XP. + */ + public static double getBaseXP(int level) { + return 5 * (Math.pow(level, 2)) + (50 * level) + 100; + } +} diff --git a/src/main/java/cc/fascinated/bat/features/leveling/LevelingListener.java b/src/main/java/cc/fascinated/bat/features/leveling/LevelingListener.java new file mode 100644 index 0000000..fb1c980 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/leveling/LevelingListener.java @@ -0,0 +1,54 @@ +package cc.fascinated.bat.features.leveling; + +import cc.fascinated.bat.common.MathUtils; +import cc.fascinated.bat.event.EventListener; +import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.BatUser; +import lombok.NonNull; +import lombok.extern.log4j.Log4j2; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component +@Log4j2(topic = "Leveling Listener") +public class LevelingListener implements EventListener { + private final LevelingFeature levelingFeature; + + @Autowired + public LevelingListener(@NonNull LevelingFeature levelingFeature) { + this.levelingFeature = levelingFeature; + } + + @Override + public void onGuildMessageReceive(@NonNull BatGuild guild, @NonNull BatUser user, @NonNull MessageReceivedEvent event) { + if (guild.getFeatureProfile().isFeatureDisabled(levelingFeature)) { // The feature is disabled + return; + } + LevelingProfile profile = guild.getLevelingProfile(); + if (profile == null) { + return; + } + + UserLevel userLevel = profile.getUserLevel(user.getId()); + userLevel.addXp((int) MathUtils.random(15, 25)); + + if (!userLevel.canLevelUp()) { + return; + } + userLevel.levelup(); + + TextChannel notificationChannel = profile.getNotificationChannel(); + if (notificationChannel == null) { + return; + } + notificationChannel.sendMessage("Congratulations %s, you have leveled up to `%s`! :tada:".formatted( + event.getAuthor().getAsMention(), + userLevel.getLevel() + )).queue(); + } +} diff --git a/src/main/java/cc/fascinated/bat/features/leveling/LevelingProfile.java b/src/main/java/cc/fascinated/bat/features/leveling/LevelingProfile.java new file mode 100644 index 0000000..56ea8c6 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/leveling/LevelingProfile.java @@ -0,0 +1,76 @@ +package cc.fascinated.bat.features.leveling; + +import cc.fascinated.bat.common.ChannelUtils; +import cc.fascinated.bat.common.Serializable; +import com.google.gson.Gson; +import lombok.Setter; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import org.bson.Document; + +import java.util.HashMap; +import java.util.Map; + +/** + * @author Fascinated (fascinated7) + */ +@Setter +public class LevelingProfile extends Serializable { + /** + * The levels of each user. + */ + private final Map userLevels = new HashMap<>(); + + /** + * The id of the level up notification channel. + */ + private String notificationChannelId; + + /** + * Gets the user level of a user. + * + * @param userId The user ID. + * @return The user level. + */ + public UserLevel getUserLevel(String userId) { + return userLevels.computeIfAbsent(userId, s -> new UserLevel(1, 0, -1)); + } + + /** + * Gets the notification channel. + * + * @return The notification channel. + */ + public TextChannel getNotificationChannel() { + return ChannelUtils.getTextChannel(notificationChannelId); + } + + @Override + public void load(Document document, Gson gson) { + for (String userId : document.keySet()) { + Document userDocument = document.get(userId, Document.class); + userLevels.put(userId, new UserLevel( + userDocument.getInteger("level"), + userDocument.getDouble("currentXp"), + (long) userDocument.getOrDefault("lastXpTimestamp", (long) -1) + )); + } + } + + @Override + public Document serialize(Gson gson) { + Document document = new Document(); + for (Map.Entry entry : userLevels.entrySet()) { + document.put(entry.getKey(), new Document() + .append("level", entry.getValue().getLevel()) + .append("currentXp", entry.getValue().getCurrentXp()) + .append("lastXpTimestamp", entry.getValue().getLastXpTimestamp()) + ); + } + return document; + } + + @Override + public void reset() { + userLevels.clear(); + } +} diff --git a/src/main/java/cc/fascinated/bat/features/leveling/UserLevel.java b/src/main/java/cc/fascinated/bat/features/leveling/UserLevel.java new file mode 100644 index 0000000..acf9556 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/leveling/UserLevel.java @@ -0,0 +1,63 @@ +package cc.fascinated.bat.features.leveling; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * @author Fascinated (fascinated7) + */ +@AllArgsConstructor +@Getter +public class UserLevel { + /** + * The current level of the user. + */ + private int level; + + /** + * The current XP of the user. + */ + private double currentXp; + + /** + * The timestamp of the last message sent by the user. + */ + private long lastXpTimestamp; + + /** + * Adds XP to the user. + */ + public void addXp(double xp) { + this.currentXp += xp; + } + + /** + * Checks if the user can level up. + * + * @return If the user can level up. + */ + public boolean canLevelUp() { + // Handle XP cooldown + long cooldown = System.currentTimeMillis() - this.lastXpTimestamp; + if (cooldown > 30_000) { + return false; + } + this.lastXpTimestamp = System.currentTimeMillis(); + return this.currentXp >= LevelingFeature.getNeededXP(this.level + 1, this.currentXp); + } + + /** + * Levels up the user. + */ + public void levelup() { + this.level++; + } + + /** + * Resets the user's level and XP. + */ + public void reset() { + this.level = 1; + this.currentXp = 0; + } +} diff --git a/src/main/java/cc/fascinated/bat/features/leveling/command/ChannelSubCommand.java b/src/main/java/cc/fascinated/bat/features/leveling/command/ChannelSubCommand.java new file mode 100644 index 0000000..fc9c92e --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/leveling/command/ChannelSubCommand.java @@ -0,0 +1,52 @@ +package cc.fascinated.bat.features.leveling.command; + +import cc.fascinated.bat.command.BatCommand; +import cc.fascinated.bat.command.CommandInfo; +import cc.fascinated.bat.common.EmbedUtils; +import cc.fascinated.bat.features.leveling.LevelingProfile; +import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.BatUser; +import lombok.NonNull; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component("leveling.channel:sub") +@CommandInfo( + name = "channel", + description = "Sets the notification channel for leveling" +) +public class ChannelSubCommand extends BatCommand { + @Autowired + public ChannelSubCommand() { + super.addOptions( + new OptionData(OptionType.CHANNEL, "channel", "The channel to set as the notification channel", true) + ); + } + + @Override + public void execute(BatGuild guild, @NonNull BatUser user, @NonNull MessageChannel channel, Member member, @NonNull SlashCommandInteraction event) { + OptionMapping channelOption = event.getOption("channel"); + if (channelOption == null) { + return; + } + GuildChannelUnion channelUnion = channelOption.getAsChannel(); + TextChannel textChannel = channelUnion.asTextChannel(); + LevelingProfile profile = guild.getLevelingProfile(); + + profile.setNotificationChannelId(textChannel.getId()); + event.replyEmbeds(EmbedUtils.successEmbed() + .setDescription("Successfully set the notification channel to %s".formatted(textChannel.getAsMention())) + .build()).queue(); + } +} diff --git a/src/main/java/cc/fascinated/bat/features/leveling/command/CurrentSubCommand.java b/src/main/java/cc/fascinated/bat/features/leveling/command/CurrentSubCommand.java new file mode 100644 index 0000000..80b0bb2 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/leveling/command/CurrentSubCommand.java @@ -0,0 +1,62 @@ +package cc.fascinated.bat.features.leveling.command; + +import cc.fascinated.bat.command.BatCommand; +import cc.fascinated.bat.command.CommandInfo; +import cc.fascinated.bat.common.EmbedDescriptionBuilder; +import cc.fascinated.bat.common.EmbedUtils; +import cc.fascinated.bat.common.NumberFormatter; +import cc.fascinated.bat.features.leveling.LevelingFeature; +import cc.fascinated.bat.features.leveling.LevelingProfile; +import cc.fascinated.bat.features.leveling.UserLevel; +import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.BatUser; +import cc.fascinated.bat.service.UserService; +import lombok.NonNull; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component("leveling.current:sub") +@CommandInfo( + name = "current", + description = "Shows the current level of the user" +) +public class CurrentSubCommand extends BatCommand { + private final UserService userService; + + @Autowired + public CurrentSubCommand(@NonNull UserService userService) { + this.userService = userService; + super.addOptions( + new OptionData(OptionType.USER, "user", "The user to get the level of", false) + ); + } + + @Override + public void execute(BatGuild guild, @NonNull BatUser user, @NonNull MessageChannel channel, Member member, @NonNull SlashCommandInteraction event) { + OptionMapping userOption = event.getOption("user"); + BatUser target = userOption == null ? user : userService.getUser(userOption.getAsUser().getId()); + LevelingProfile profile = guild.getLevelingProfile(); + UserLevel level = profile.getUserLevel(target.getId()); + + EmbedDescriptionBuilder description = new EmbedDescriptionBuilder("Current Level"); + description.appendLine("User: %s".formatted(target.getDiscordUser().getAsMention()), true); + description.appendLine("Level: `%s`".formatted(level.getLevel()), true); + description.appendLine("XP: `%s`/`%s`".formatted( + NumberFormatter.format(level.getCurrentXp()), + NumberFormatter.format(LevelingFeature.getBaseXP(level.getLevel())) + ), true); + event.replyEmbeds(EmbedUtils.successEmbed() + .setDescription(description.build()) + .setThumbnail(target.getDiscordUser().getEffectiveAvatarUrl()) + .build()).queue(); + } +} diff --git a/src/main/java/cc/fascinated/bat/features/leveling/command/LevelingCommand.java b/src/main/java/cc/fascinated/bat/features/leveling/command/LevelingCommand.java new file mode 100644 index 0000000..4abbc45 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/leveling/command/LevelingCommand.java @@ -0,0 +1,27 @@ +package cc.fascinated.bat.features.leveling.command; + +import cc.fascinated.bat.command.BatCommand; +import cc.fascinated.bat.command.CommandInfo; +import lombok.NonNull; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component +@CommandInfo( + name = "leveling", + description = "The main command for the leveling feature" +) +public class LevelingCommand extends BatCommand { + @Autowired + public LevelingCommand(@NonNull ApplicationContext context) { + super.addSubCommands( + context.getBean(CurrentSubCommand.class), + context.getBean(ChannelSubCommand.class), + context.getBean(ResetSubCommand.class) + ); + } +} diff --git a/src/main/java/cc/fascinated/bat/features/leveling/command/ResetSubCommand.java b/src/main/java/cc/fascinated/bat/features/leveling/command/ResetSubCommand.java new file mode 100644 index 0000000..7ed5837 --- /dev/null +++ b/src/main/java/cc/fascinated/bat/features/leveling/command/ResetSubCommand.java @@ -0,0 +1,54 @@ +package cc.fascinated.bat.features.leveling.command; + +import cc.fascinated.bat.command.BatCommand; +import cc.fascinated.bat.command.CommandInfo; +import cc.fascinated.bat.common.EmbedUtils; +import cc.fascinated.bat.features.leveling.LevelingProfile; +import cc.fascinated.bat.features.leveling.UserLevel; +import cc.fascinated.bat.model.BatGuild; +import cc.fascinated.bat.model.BatUser; +import cc.fascinated.bat.service.UserService; +import lombok.NonNull; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.SlashCommandInteraction; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * @author Fascinated (fascinated7) + */ +@Component("leveling.reset:sub") +@CommandInfo( + name = "reset", + description = "Resets the level and xp of the user", + requiredPermissions = Permission.MANAGE_SERVER +) +public class ResetSubCommand extends BatCommand { + public ResetSubCommand() { + super.addOptions( + new OptionData(OptionType.USER, "user", "The user to reset", true) + ); + } + + @Override + public void execute(BatGuild guild, @NonNull BatUser user, @NonNull MessageChannel channel, Member member, @NonNull SlashCommandInteraction event) { + OptionMapping userOption = event.getOption("user"); + if (userOption == null) { + return; + } + User target = userOption.getAsUser(); + LevelingProfile profile = guild.getLevelingProfile(); + UserLevel level = profile.getUserLevel(target.getId()); + + level.reset(); + event.replyEmbeds(EmbedUtils.successEmbed() + .setDescription("Reset Level and XP for %s!".formatted(target.getAsMention())) + .build()).queue(); + } +} diff --git a/src/main/java/cc/fascinated/bat/model/BatGuild.java b/src/main/java/cc/fascinated/bat/model/BatGuild.java index b02fd00..ae2a8eb 100644 --- a/src/main/java/cc/fascinated/bat/model/BatGuild.java +++ b/src/main/java/cc/fascinated/bat/model/BatGuild.java @@ -6,6 +6,8 @@ import cc.fascinated.bat.common.Serializable; import cc.fascinated.bat.features.FeatureProfile; import cc.fascinated.bat.features.birthday.profile.BirthdayProfile; import cc.fascinated.bat.features.counter.CounterProfile; +import cc.fascinated.bat.features.leveling.LevelingFeature; +import cc.fascinated.bat.features.leveling.LevelingProfile; import cc.fascinated.bat.features.logging.LogProfile; import cc.fascinated.bat.features.namehistory.profile.guild.NameHistoryProfile; import cc.fascinated.bat.features.reminder.ReminderProfile; @@ -160,6 +162,15 @@ public class BatGuild extends ProfileHolder { return getProfile(CounterProfile.class); } + /** + * Gets the leveling profile + * + * @return the leveling profile + */ + public LevelingProfile getLevelingProfile() { + return getProfile(LevelingProfile.class); + } + /** * Saves the user */