diff --git a/README.md b/README.md index efab95c..e4d99a5 100644 --- a/README.md +++ b/README.md @@ -82,10 +82,13 @@ You can **import** your own **tag ignore list** by doing, type `/tagignore impor To **skin denick** type `/skindenick ` -To use the **number denicker** add your Aurora API key +To use the **number denicker**, select your provider in **Settings > Number Denicker** and add the matching API key - You can obtain one [here](https://discord.com/oauth2/authorize?client_id=1244205279697174539) -- After setup, denicking happens automatically during games, but you can also manually run: `/denick ` +- After setup, denicking happens automatically during games, but you can also manually run `/denick`. +- `/denick` uses the provider selected in **Settings > Number Denicker**: + - **Aurora**: `/denick ` + - **Frosty**: `/denick ` ## Known issues diff --git a/src/main/java/com/roxiun/mellow/Mellow.java b/src/main/java/com/roxiun/mellow/Mellow.java index bdede05..4e52490 100644 --- a/src/main/java/com/roxiun/mellow/Mellow.java +++ b/src/main/java/com/roxiun/mellow/Mellow.java @@ -4,6 +4,7 @@ import com.roxiun.mellow.api.aurora.AuroraApi; import com.roxiun.mellow.api.aurora.AuroraPingService; import com.roxiun.mellow.api.aurora.AuroraWinstreakService; +import com.roxiun.mellow.api.frosty.FrostyApi; import com.roxiun.mellow.api.hypixel.HypixelFeatures; import com.roxiun.mellow.api.luna.LunaPingService; import com.roxiun.mellow.api.mojang.MojangApi; @@ -69,6 +70,7 @@ public class Mellow { public static AuroraPingService auroraPingService; public static AuroraWinstreakService auroraWinstreakService; public static AuroraApi auroraApi; + public static FrostyApi frostyApi; public static LunaPingService lunaPingService; public static SeraphClientCacheService seraphClientCacheService; public static SeraphPingService seraphPingService; @@ -110,6 +112,7 @@ public void init(FMLInitializationEvent event) { seraphApi = new SeraphApi(mojangApi); seraphClientCacheService = new SeraphClientCacheService(seraphApi, config); auroraApi = new AuroraApi(); + frostyApi = new FrostyApi(); playerCache = new PlayerCache( mojangApi, @@ -135,7 +138,8 @@ public void init(FMLInitializationEvent event) { NumberDenicker numberDenicker = new NumberDenicker( config, nickUtils, - auroraApi + auroraApi, + frostyApi ); PregameStats pregameStats = new PregameStats( playerCache, @@ -242,7 +246,13 @@ public void run() { new RefreshCommand(inGameTabStatsSyncService) ); ClientCommandHandler.instance.registerCommand( - new DenickCommand(config, auroraApi) + new DenickCommand(config, auroraApi, frostyApi) + ); + ClientCommandHandler.instance.registerCommand( + new AuroraDenickCommand(config, auroraApi) + ); + ClientCommandHandler.instance.registerCommand( + new FrostyDenickCommand(config, frostyApi) ); ClientCommandHandler.instance.registerCommand( new SkinDenickCommand(playerCache) diff --git a/src/main/java/com/roxiun/mellow/api/frosty/FrostyApi.java b/src/main/java/com/roxiun/mellow/api/frosty/FrostyApi.java new file mode 100644 index 0000000..a4e9af3 --- /dev/null +++ b/src/main/java/com/roxiun/mellow/api/frosty/FrostyApi.java @@ -0,0 +1,112 @@ +package com.roxiun.mellow.api.frosty; + +import com.google.gson.Gson; +import com.roxiun.mellow.util.cache.TimedValueCache; +import java.io.IOException; +import java.util.Locale; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; + +public class FrostyApi { + + private static final long QUERY_CACHE_TTL_MS = 60_000L; + private static final String BASE_URL = + "https://api.sukie.net/v1/bedwars/cosmetics"; + + private final OkHttpClient client; + private final Gson gson; + private final TimedValueCache queryCache = + new TimedValueCache<>(QUERY_CACHE_TTL_MS); + + public FrostyApi() { + this(new OkHttpClient(), new Gson()); + } + + FrostyApi(OkHttpClient client) { + this(client, new Gson()); + } + + FrostyApi(OkHttpClient client, Gson gson) { + this.client = client == null ? new OkHttpClient() : client; + this.gson = gson == null ? new Gson() : gson; + } + + public FrostyReponse queryStats( + int finalKills, + int bedsBroken, + String apiKey + ) throws IOException { + return queryCosmetics(finalKills, bedsBroken, apiKey); + } + + public FrostyReponse queryCosmetics( + int finalKills, + int bedsBroken, + String apiKey + ) throws IOException { + if (apiKey == null || apiKey.isEmpty()) { + return null; + } + + if (finalKills == 0 && bedsBroken == 0) { + return null; + } + + String cacheKey = buildCacheKey(finalKills, bedsBroken, apiKey); + if (queryCache.containsFresh(cacheKey)) { + return parseResponse(queryCache.get(cacheKey)); + } + + String finalsKillsParam = finalKills == 0 ? "" : "&final_kills=" + finalKills; + String bedsBrokenParam = bedsBroken == 0 ? "" : "&beds_broken=" + bedsBroken; + String url = BASE_URL + "?key=" + apiKey + finalsKillsParam + bedsBrokenParam + "&rank=mvp%2B%2B"; + + Request request = new Request.Builder() + .url(url) + .header("User-Agent", "Mellow/4.1.0") + .build(); + + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + System.err.println("Frosty API request failed: " + response); + queryCache.put(cacheKey, null); + return null; + } + + ResponseBody body = response.body(); + if (body == null) { + queryCache.put(cacheKey, null); + return null; + } + + String payload = body.string(); + queryCache.put(cacheKey, payload); + return parseResponse(payload); + } + } + + public void clearCache() { + queryCache.clear(); + } + + private FrostyReponse parseResponse(String payload) { + return payload == null ? null : gson.fromJson(payload, FrostyReponse.class); + } + + private String buildCacheKey(int finalKills, int bedsBroken, String apiKey) { + return ( + finalKills + + "|" + + bedsBroken + + "|" + + safe(apiKey).toLowerCase(Locale.ROOT) + ); + } + + private String safe(String value) { + return value == null ? "" : value.trim(); + } +} + diff --git a/src/main/java/com/roxiun/mellow/api/frosty/FrostyReponse.java b/src/main/java/com/roxiun/mellow/api/frosty/FrostyReponse.java new file mode 100644 index 0000000..b0a10bb --- /dev/null +++ b/src/main/java/com/roxiun/mellow/api/frosty/FrostyReponse.java @@ -0,0 +1,101 @@ +package com.roxiun.mellow.api.frosty; + +import com.google.gson.annotations.SerializedName; +import java.util.List; + +public class FrostyReponse { + + @SerializedName("success") + public boolean success; + + @SerializedName("data") + public List data; + + public static class PlayerResult { + + @SerializedName("username") + public String username; + + @SerializedName("uuid") + public String uuid; + + @SerializedName("game_rank") + public String gameRank; + + @SerializedName("rank_color") + public String rankColor; + + @SerializedName("plus_color") + public String plusColor; + + @SerializedName("network_level") + public long networkLevel; + + @SerializedName("last_online") + public String lastOnline; + + @SerializedName("bedwars_index") + public long bedwarsIndex; + + @SerializedName("bedwars_level") + public long bedwarsLevel; + + @SerializedName("total_final_kills") + public long totalFinalKills; + + @SerializedName("total_final_deaths") + public long totalFinalDeaths; + + @SerializedName("total_beds_broken") + public long totalBedsBroken; + + @SerializedName("total_beds_lost") + public long totalBedsLost; + + @SerializedName("total_wins") + public long totalWins; + + @SerializedName("total_losses") + public long totalLosses; + + @SerializedName("island_topper") + public String islandTopper; + + @SerializedName("victory_dance") + public String victoryDance; + + @SerializedName("kill_effect") + public String killEffect; + + @SerializedName("projectile_trail") + public String projectileTrail; + + @SerializedName("kill_message") + public String killMessage; + + @SerializedName("npc_skin") + public String npcSkin; + + @SerializedName("death_cry") + public String deathCry; + + @SerializedName("spray") + public String spray; + + @SerializedName("bed_destroy") + public String bedDestroy; + + @SerializedName("glyph") + public String glyph; + + @SerializedName("wood_type") + public String woodType; + + @SerializedName("starting_weapon") + public String startingWeapon; + + @SerializedName("figurine") + public String figurine; + } +} + diff --git a/src/main/java/com/roxiun/mellow/commands/AuroraDenickCommand.java b/src/main/java/com/roxiun/mellow/commands/AuroraDenickCommand.java new file mode 100644 index 0000000..e44f810 --- /dev/null +++ b/src/main/java/com/roxiun/mellow/commands/AuroraDenickCommand.java @@ -0,0 +1,142 @@ +package com.roxiun.mellow.commands; + +import com.roxiun.mellow.api.aurora.AuroraApi; +import com.roxiun.mellow.config.MellowOneConfig; +import com.roxiun.mellow.core.async.AsyncExecutor; +import com.roxiun.mellow.core.async.MainThreadDispatcher; +import com.roxiun.mellow.util.ChatUtils; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; +import net.minecraft.command.CommandBase; +import net.minecraft.command.ICommandSender; +import net.minecraft.util.BlockPos; + +public class AuroraDenickCommand extends CommandBase { + + private final MellowOneConfig config; + private final AuroraApi auroraApi; + + public AuroraDenickCommand(MellowOneConfig config, AuroraApi auroraApi) { + this.config = config; + this.auroraApi = auroraApi; + } + + @Override + public String getCommandName() { + return "auroradenick"; + } + + @Override + public String getCommandUsage(ICommandSender sender) { + return "/auroradenick "; + } + + @Override + public void processCommand(ICommandSender sender, String[] args) { + if (args.length != 2) { + ChatUtils.sendCommandMessage( + sender, + "§cInvalid usage. Use: " + getCommandUsage(sender) + ); + return; + } + + String type = args[0]; + if ( + !type.equalsIgnoreCase("finals") && !type.equalsIgnoreCase("beds") + ) { + ChatUtils.sendCommandMessage( + sender, + "§cInvalid type. Use 'finals' or 'beds'." + ); + return; + } + + String numberStr = args[1]; + try { + Integer.parseInt(numberStr.replace(",", "")); + } catch (NumberFormatException e) { + ChatUtils.sendCommandMessage( + sender, + "§cInvalid number: " + numberStr + ); + return; + } + + ChatUtils.sendCommandMessage(sender, "§aSearching for players..."); + + AsyncExecutor.getInstance().command(() -> { + try { + int range = config.getAuroraDenickRange(type); + int max = config.getAuroraDenickMaxResults(); + + AuroraApi.AuroraResponse response = auroraApi.queryStats( + type, + numberStr, + range, + max, + config.auroraApiKey + ); + + MainThreadDispatcher.run(() -> { + if (response != null && response.success) { + if (response.data.isEmpty()) { + ChatUtils.sendCommandMessage( + sender, + "§cNo players found." + ); + } else { + String players = response.data + .stream() + .map( + p -> + "§a" + + p.name + + " §7(distance: " + + p.distance + + ")" + ) + .collect(Collectors.joining(", ")); + ChatUtils.sendCommandMessage( + sender, + "§aFound players: " + players + ); + } + } else { + ChatUtils.sendCommandMessage( + sender, + "§cError fetching data from Aurora API." + ); + } + }); + } catch (IOException e) { + MainThreadDispatcher.run(() -> { + ChatUtils.sendCommandMessage( + sender, + "§cAn error occurred while fetching data." + ); + }); + e.printStackTrace(); + } + }); + } + + @Override + public List addTabCompletionOptions( + ICommandSender sender, + String[] args, + BlockPos pos + ) { + if (args.length == 1) { + return getListOfStringsMatchingLastWord(args, "finals", "beds"); + } + return null; + } + + @Override + public int getRequiredPermissionLevel() { + return 0; + } +} + diff --git a/src/main/java/com/roxiun/mellow/commands/DenickCommand.java b/src/main/java/com/roxiun/mellow/commands/DenickCommand.java index 0f1893a..ea870e7 100644 --- a/src/main/java/com/roxiun/mellow/commands/DenickCommand.java +++ b/src/main/java/com/roxiun/mellow/commands/DenickCommand.java @@ -1,6 +1,8 @@ package com.roxiun.mellow.commands; import com.roxiun.mellow.api.aurora.AuroraApi; +import com.roxiun.mellow.api.frosty.FrostyApi; +import com.roxiun.mellow.api.frosty.FrostyReponse; import com.roxiun.mellow.config.MellowOneConfig; import com.roxiun.mellow.core.async.AsyncExecutor; import com.roxiun.mellow.core.async.MainThreadDispatcher; @@ -9,6 +11,7 @@ import java.util.List; import java.util.stream.Collectors; import net.minecraft.command.CommandBase; +import net.minecraft.command.CommandException; import net.minecraft.command.ICommandSender; import net.minecraft.util.BlockPos; @@ -16,10 +19,16 @@ public class DenickCommand extends CommandBase { private final MellowOneConfig config; private final AuroraApi auroraApi; + private final FrostyApi frostyApi; - public DenickCommand(MellowOneConfig config, AuroraApi auroraApi) { + public DenickCommand( + MellowOneConfig config, + AuroraApi auroraApi, + FrostyApi frostyApi + ) { this.config = config; this.auroraApi = auroraApi; + this.frostyApi = frostyApi; } @Override @@ -29,11 +38,43 @@ public String getCommandName() { @Override public String getCommandUsage(ICommandSender sender) { - return "/denick "; + return config != null && config.numberDenickerProvider == 1 + ? "/denick " + : "/denick "; + } + + @Override + public void processCommand(ICommandSender sender, String[] args) throws CommandException { + if (config != null && config.numberDenickerProvider == 1) { + processFrostyCommand(sender, args); + return; + } + + processAuroraCommand(sender, args); } @Override - public void processCommand(ICommandSender sender, String[] args) { + public List addTabCompletionOptions( + ICommandSender sender, + String[] args, + BlockPos pos + ) { + if (config != null && config.numberDenickerProvider == 1) { + return null; + } + + if (args.length == 1) { + return getListOfStringsMatchingLastWord(args, "finals", "beds"); + } + return null; + } + + @Override + public int getRequiredPermissionLevel() { + return 0; + } + + private void processAuroraCommand(ICommandSender sender, String[] args) { if (args.length != 2) { ChatUtils.sendCommandMessage( sender, @@ -68,21 +109,8 @@ public void processCommand(ICommandSender sender, String[] args) { AsyncExecutor.getInstance().command(() -> { try { - int[] rangeValues = { 100, 200, 500, 1000 }; - int[] maxValues = { 5, 10, 20 }; - - int rangeIndex = type.equals("finals") - ? config.finalsRange - : config.bedsRange; - int maxIndex = config.maxResults; - - if ( - rangeIndex < 0 || rangeIndex >= rangeValues.length - ) rangeIndex = 1; // Default to 200 - if (maxIndex < 0 || maxIndex >= maxValues.length) maxIndex = 0; // Default to 5 - - int range = rangeValues[rangeIndex]; - int max = maxValues[maxIndex]; + int range = config.getAuroraDenickRange(type); + int max = config.getAuroraDenickMaxResults(); AuroraApi.AuroraResponse response = auroraApi.queryStats( type, @@ -135,20 +163,145 @@ public void processCommand(ICommandSender sender, String[] args) { }); } - @Override - public List addTabCompletionOptions( - ICommandSender sender, - String[] args, - BlockPos pos - ) { - if (args.length == 1) { - return getListOfStringsMatchingLastWord(args, "finals", "beds"); + private void processFrostyCommand(ICommandSender sender, String[] args) { + if (args.length != 2) { + ChatUtils.sendCommandMessage( + sender, + "§cInvalid usage. Use: " + getCommandUsage(sender) + ); + return; } - return null; + + if (config.frostyApiKey == null || config.frostyApiKey.trim().isEmpty()) { + ChatUtils.sendCommandMessage( + sender, + "§cMissing Frosty API key. Set it in OneConfig: API Keys > Frosty." + ); + return; + } + + int finalsCount; + int bedsCount; + try { + finalsCount = Integer.parseInt(args[0].replace(",", "")); + bedsCount = Integer.parseInt(args[1].replace(",", "")); + } catch (NumberFormatException e) { + ChatUtils.sendCommandMessage( + sender, + "§cBoth finals_count and beds_count must be valid numbers." + ); + return; + } + + if (finalsCount < 0 || bedsCount < 0) { + ChatUtils.sendCommandMessage( + sender, + "§cfinals_count and beds_count must be 0 or higher." + ); + return; + } + + if (finalsCount == 0 && bedsCount == 0) { + ChatUtils.sendCommandMessage( + sender, + "§cAt least one value must be above 0." + ); + return; + } + + ChatUtils.sendCommandMessage(sender, "§aSearching for players..."); + + AsyncExecutor.getInstance().command(() -> { + try { + FrostyReponse response = frostyApi.queryStats( + finalsCount, + bedsCount, + config.frostyApiKey + ); + + MainThreadDispatcher.run(() -> { + if (response == null || !response.success) { + ChatUtils.sendCommandMessage( + sender, + "§cError fetching data from Frosty API." + ); + return; + } + + if (response.data == null || response.data.isEmpty()) { + ChatUtils.sendCommandMessage(sender, "§cNo players found."); + return; + } + + String players = response.data + .stream() + .map( + p -> { + String finalsText = formatFinalsOutput( + p.totalFinalKills, + finalsCount + ); + String bedsText = formatBedsOutput( + p.totalBedsBroken, + bedsCount + ); + return ( + "§a" + + p.username + + " §7(" + + finalsText + + ", " + + bedsText + + ")" + ); + } + ) + .collect(Collectors.joining(", ")); + + ChatUtils.sendCommandMessage( + sender, + "§aFound players: " + players + ); + }); + } catch (IOException e) { + MainThreadDispatcher.run(() -> { + ChatUtils.sendCommandMessage( + sender, + "§cAn error occurred while fetching data." + ); + }); + e.printStackTrace(); + } + }); } - @Override - public int getRequiredPermissionLevel() { - return 0; + private String formatSignedDelta(long delta) { + return delta >= 0 ? "+" + delta : String.valueOf(delta); + } + + private String formatFinalsOutput(long totalFinalKills, int finalsCount) { + if (finalsCount == 0) { + return "FK: " + totalFinalKills; + } + return ( + "FK: " + + totalFinalKills + + " [" + + formatSignedDelta(totalFinalKills - finalsCount) + + "]" + ); + } + + private String formatBedsOutput(long totalBedsBroken, int bedsCount) { + if (bedsCount == 0) { + return "Beds: " + totalBedsBroken; + } + return ( + "Beds: " + + totalBedsBroken + + " [" + + formatSignedDelta(totalBedsBroken - bedsCount) + + "]" + ); } } diff --git a/src/main/java/com/roxiun/mellow/commands/FrostyDenickCommand.java b/src/main/java/com/roxiun/mellow/commands/FrostyDenickCommand.java new file mode 100644 index 0000000..0ba2c8b --- /dev/null +++ b/src/main/java/com/roxiun/mellow/commands/FrostyDenickCommand.java @@ -0,0 +1,193 @@ +package com.roxiun.mellow.commands; + +import com.roxiun.mellow.api.frosty.FrostyApi; +import com.roxiun.mellow.api.frosty.FrostyReponse; +import com.roxiun.mellow.config.MellowOneConfig; +import com.roxiun.mellow.core.async.AsyncExecutor; +import com.roxiun.mellow.core.async.MainThreadDispatcher; +import com.roxiun.mellow.util.ChatUtils; +import java.io.IOException; +import java.util.List; +import java.util.stream.Collectors; +import net.minecraft.command.CommandBase; +import net.minecraft.command.ICommandSender; +import net.minecraft.util.BlockPos; + +public class FrostyDenickCommand extends CommandBase { + + private final MellowOneConfig config; + private final FrostyApi frostyApi; + + public FrostyDenickCommand(MellowOneConfig config, FrostyApi frostyApi) { + this.config = config; + this.frostyApi = frostyApi; + } + + @Override + public String getCommandName() { + return "frostydenick"; + } + + @Override + public String getCommandUsage(ICommandSender sender) { + return "/frostydenick "; + } + + @Override + public void processCommand(ICommandSender sender, String[] args) { + if (args.length != 2) { + ChatUtils.sendCommandMessage( + sender, + "§cInvalid usage. Use: " + getCommandUsage(sender) + ); + return; + } + + if (config.frostyApiKey == null || config.frostyApiKey.trim().isEmpty()) { + ChatUtils.sendCommandMessage( + sender, + "§cMissing Frosty API key. Set it in OneConfig: API Keys > Frosty." + ); + return; + } + + int finalsCount; + int bedsCount; + try { + finalsCount = Integer.parseInt(args[0].replace(",", "")); + bedsCount = Integer.parseInt(args[1].replace(",", "")); + } catch (NumberFormatException e) { + ChatUtils.sendCommandMessage( + sender, + "§cBoth finals_count and beds_count must be valid numbers." + ); + return; + } + + if (finalsCount < 0 || bedsCount < 0) { + ChatUtils.sendCommandMessage( + sender, + "§cfinals_count and beds_count must be 0 or higher." + ); + return; + } + + if (finalsCount == 0 && bedsCount == 0) { + ChatUtils.sendCommandMessage( + sender, + "§cAt least one value must be above 0." + ); + return; + } + + ChatUtils.sendCommandMessage(sender, "§aSearching for players..."); + + AsyncExecutor.getInstance().command(() -> { + try { + FrostyReponse response = frostyApi.queryStats( + finalsCount, + bedsCount, + config.frostyApiKey + ); + + MainThreadDispatcher.run(() -> { + if (response == null || !response.success) { + ChatUtils.sendCommandMessage( + sender, + "§cError fetching data from Frosty API." + ); + return; + } + + if (response.data == null || response.data.isEmpty()) { + ChatUtils.sendCommandMessage(sender, "§cNo players found."); + return; + } + + String players = response.data + .stream() + .map( + p -> { + String finalsText = formatFinalsOutput( + p.totalFinalKills, + finalsCount + ); + String bedsText = formatBedsOutput( + p.totalBedsBroken, + bedsCount + ); + return ( + "§a" + + p.username + + " §7(" + + finalsText + + ", " + + bedsText + + ")" + ); + } + ) + .collect(Collectors.joining(", ")); + + ChatUtils.sendCommandMessage( + sender, + "§aFound players: " + players + ); + }); + } catch (IOException e) { + MainThreadDispatcher.run(() -> { + ChatUtils.sendCommandMessage( + sender, + "§cAn error occurred while fetching data." + ); + }); + e.printStackTrace(); + } + }); + } + + @Override + public List addTabCompletionOptions( + ICommandSender sender, + String[] args, + BlockPos pos + ) { + return null; + } + + @Override + public int getRequiredPermissionLevel() { + return 0; + } + + private String formatSignedDelta(long delta) { + return delta >= 0 ? "+" + delta : String.valueOf(delta); + } + + private String formatFinalsOutput(long totalFinalKills, int finalsCount) { + if (finalsCount == 0) { + return "FK: " + totalFinalKills; + } + return ( + "FK: " + + totalFinalKills + + " [" + + formatSignedDelta(totalFinalKills - finalsCount) + + "]" + ); + } + + private String formatBedsOutput(long totalBedsBroken, int bedsCount) { + if (bedsCount == 0) { + return "Beds: " + totalBedsBroken; + } + return ( + "Beds: " + + totalBedsBroken + + " [" + + formatSignedDelta(totalBedsBroken - bedsCount) + + "]" + ); + } +} + diff --git a/src/main/java/com/roxiun/mellow/commands/MellowCommand.java b/src/main/java/com/roxiun/mellow/commands/MellowCommand.java index 3dbbcb4..4348df5 100644 --- a/src/main/java/com/roxiun/mellow/commands/MellowCommand.java +++ b/src/main/java/com/roxiun/mellow/commands/MellowCommand.java @@ -100,7 +100,12 @@ public void processCommand(ICommandSender sender, String[] args) { ); sender.addChatMessage( new ChatComponentText( - "§r§5/denick :§d Manually denick a player based on finals or beds.§r" + "§r§5/auroradenick :§d Manually denick a player based on finals or beds using Aurora.§r" + ) + ); + sender.addChatMessage( + new ChatComponentText( + "§r§5/frostydenick :§d Manually denick players using Frosty cosmetics lookup.§r" ) ); sender.addChatMessage( diff --git a/src/main/java/com/roxiun/mellow/config/MellowOneConfig.java b/src/main/java/com/roxiun/mellow/config/MellowOneConfig.java index 54cd84a..e2d5590 100644 --- a/src/main/java/com/roxiun/mellow/config/MellowOneConfig.java +++ b/src/main/java/com/roxiun/mellow/config/MellowOneConfig.java @@ -1004,6 +1004,16 @@ public class MellowOneConfig extends Config { ) public String auroraApiKey = ""; + @Text( + name = "Frosty API Key", + placeholder = "Enter your Frosty API key", + category = "API Keys", + subcategory = "Frosty", + secure = true, + multiline = false + ) + public String frostyApiKey = ""; + @Text( name = "Luna API Key", placeholder = "Enter your Luna API key", @@ -1298,20 +1308,65 @@ public class MellowOneConfig extends Config { ) public static boolean ignoredSeraphPingInfo; - // Number denicker @Info( - text = "This module attempts to denick players based the number of finals and beds broken from chat messages. Configure the Aurora key in API Keys > Aurora.", - type = InfoType.INFO, - size = OptionSize.DUAL, - category = "Number Denicker" + text = "This module attempts to denick players based the number of finals and beds broken from chat messages. Configure the keys in API Keys > Aurora / Frosty.", + type = InfoType.INFO, + size = OptionSize.DUAL, + category = "Number Denicker", + subcategory = "General" + ) + public static boolean ignoredNumberDenickerInfo; + + @Switch(name = "Enable Number Denicker", category = "Number Denicker", subcategory = "General") + public boolean numberDenicker = false; + + @Dropdown( + name = "Provider", + options = { "Aurora", "Frosty" }, + category = "Number Denicker", + subcategory = "General" + ) + public int numberDenickerProvider = 0; // 0 = Aurora, 1 = Frosty + + @Number( + name = "Minimum Finals to Check", + category = "Number Denicker", + subcategory = "General", + min = 0, + max = 500000, + step = 1000 + ) + public int minFinalsForDenick = 15000; + + @Number( + name = "Minimum Beds to Check", + category = "Number Denicker", + subcategory = "General", + min = 0, + max = 500000, + step = 1000 + ) + public int minBedsForDenick = 5000; + + @Button( + name = "Run /generate-key on the bot to get your key. You will need to be whitelisted for it.", + text = "Discord Server", + size = OptionSize.DUAL, + category = "Number Denicker", + subcategory = "Frosty" ) - public static boolean ignoredNumberDenickerInfo; // Useless. Java limitations with @annotation. + Runnable frostyLinkButton = () -> { + NetworkUtils.browseLink( + "https://discord.gg/JwvA3GeDtA" + ); + }; @Button( name = "Run /api view on the bot to get your key", text = "Discord Bot", size = OptionSize.DUAL, - category = "Number Denicker" + category = "Number Denicker", + subcategory = "Aurora" ) Runnable auroraLinkButton = () -> { NetworkUtils.browseLink( @@ -1319,47 +1374,39 @@ public class MellowOneConfig extends Config { ); }; - @Switch(name = "Enable Number Denicker", category = "Number Denicker") - public boolean numberDenicker = false; - - @Switch(name = "Print all potential players", category = "Number Denicker") + @Switch(name = "Print all potential players", category = "Number Denicker", subcategory = "Aurora") public boolean numberDenickerFuzzy = true; @Info( text = "Turning all potential players off, will only print players with both matching beds and finals.", type = InfoType.INFO, size = OptionSize.DUAL, - category = "Number Denicker" + category = "Number Denicker", + subcategory = "Aurora" ) public static boolean ignoredNumberDenickerFuzzyInfo; @Dropdown( name = "Finals Range", options = { "0", "50", "100", "200", "500" }, - category = "Number Denicker" + category = "Number Denicker", + subcategory = "Aurora" ) public int finalsRange = 3; // Index for 100 @Dropdown( name = "Beds Range", options = { "0", "50", "100", "200", "500" }, - category = "Number Denicker" - ) - public int bedsRange = 1; // Index for 50 - - @Number( - name = "Minimum Finals to Check", category = "Number Denicker", - min = 0, - max = 500000, - step = 1000 + subcategory = "Aurora" ) - public int minFinalsForDenick = 15000; + public int bedsRange = 1; // Index for 50 @Dropdown( name = "Max Results", options = { "5", "10", "20" }, - category = "Number Denicker" + category = "Number Denicker", + subcategory = "Aurora" ) public int maxResults = 0; // Index for 5 @@ -1522,6 +1569,19 @@ public MellowOneConfig() { hideIf("hitboxBrightnessOffset", () -> hitboxBrightnessMode != 0); } + public int getAuroraDenickRange(String type) { + int[] rangeValues = { 0, 50, 100, 200, 500, 1000 }; + int rangeIndex = "finals".equalsIgnoreCase(type) + ? finalsRange + : bedsRange; + return rangeValues[clampIndex(rangeIndex, rangeValues.length)]; + } + + public int getAuroraDenickMaxResults() { + int[] maxValues = { 5, 10, 20 }; + return maxValues[clampIndex(maxResults, maxValues.length)]; + } + private void sanitizeDropdownIndexes() { // BedWars tab stats dropdowns: Team..Client (15 options) customStat1 = clampIndex(customStat1, 15); @@ -1571,6 +1631,7 @@ private void sanitizeDropdownIndexes() { 3 ); pingProvider = clampIndex(pingProvider, 4); + numberDenickerProvider = clampIndex(numberDenickerProvider, 2); winstreakMinStars = clampIndex(winstreakMinStars, 51); winstreakMinFkdr = clampIndex(winstreakMinFkdr, 18); finalsRange = clampIndex(finalsRange, 5); diff --git a/src/main/java/com/roxiun/mellow/feature/nicks/NumberDenicker.java b/src/main/java/com/roxiun/mellow/feature/nicks/NumberDenicker.java index 5227910..a1f0e8f 100644 --- a/src/main/java/com/roxiun/mellow/feature/nicks/NumberDenicker.java +++ b/src/main/java/com/roxiun/mellow/feature/nicks/NumberDenicker.java @@ -1,6 +1,8 @@ package com.roxiun.mellow.feature.nicks; import com.roxiun.mellow.api.aurora.AuroraApi; +import com.roxiun.mellow.api.frosty.FrostyApi; +import com.roxiun.mellow.api.frosty.FrostyReponse; import com.roxiun.mellow.config.MellowOneConfig; import com.roxiun.mellow.core.async.AsyncExecutor; import com.roxiun.mellow.util.ChatUtils; @@ -22,6 +24,7 @@ public class NumberDenicker { private final Minecraft mc = Minecraft.getMinecraft(); private final MellowOneConfig config; private final AuroraApi auroraApi; + private final FrostyApi frostyApi; private final NickUtils nickUtils; private boolean gameStarted = false; @@ -37,10 +40,12 @@ public class NumberDenicker { public NumberDenicker( MellowOneConfig config, NickUtils nickUtils, - AuroraApi auroraApi + AuroraApi auroraApi, + FrostyApi frostyApi ) { this.config = config; this.auroraApi = auroraApi; + this.frostyApi = frostyApi; this.nickUtils = nickUtils; } @@ -64,6 +69,8 @@ public void onChat(ClientChatReceivedEvent event) { if (!this.gameStarted) return; + boolean useFrosty = config.numberDenickerProvider == 1; + Matcher finalMatcher = FINAL_KILL_PATTERN.matcher(message); if (finalMatcher.find()) { String nickName = finalMatcher.group(2); @@ -78,20 +85,34 @@ public void onChat(ClientChatReceivedEvent event) { ); if ( isPlayerInGame(nickName) && - nickUtils.isNicked(nickName) && - (!player.finalsChecked || - player.fuzzy_finals_potentials == null) + nickUtils.isNicked(nickName) ) { - mc.addScheduledTask(() -> - ChatUtils.sendMessage( - "§aAttempting to denick " + - nickName + - " with " + - finalNumberStr + - " finals" - ) - ); - processNumbers("finals", nickName, finalNumberStr); + if (useFrosty) { + // For Frosty, accumulate until we have both stats + player.finalsNumber = finalNumber; + player.currentType = "finals"; + // Don't process yet if we don't have beds number + if (player.bedsNumber != -1) { + processNumbers("both", nickName, null); + // Reset for next detection + player.finalsNumber = -1; + player.bedsNumber = -1; + } + } else { + // Aurora API processes each stat independently + if (!player.finalsChecked) { + mc.addScheduledTask(() -> + ChatUtils.sendMessage( + "§aAttempting to denick " + + nickName + + " with " + + finalNumberStr + + " finals" + ) + ); + processNumbers("finals", nickName, finalNumberStr); + } + } } } } catch (NumberFormatException e) { @@ -103,26 +124,49 @@ public void onChat(ClientChatReceivedEvent event) { Matcher bedMatcher = BED_DESTRUCTION_PATTERN.matcher(message); if (bedMatcher.find()) { String nickName = bedMatcher.group(3); - String bedNumber = bedMatcher.group(2).replace(",", ""); - PotentialNick player = nickToPotentials.computeIfAbsent( - nickName, - k -> new PotentialNick() - ); - if ( - isPlayerInGame(nickName) && - nickUtils.isNicked(nickName) && - (!player.bedsChecked || player.fuzzy_beds_potentials == null) - ) { - mc.addScheduledTask(() -> - ChatUtils.sendMessage( - "§aAttempting to denick " + - nickName + - " with " + - bedNumber + - " beds" - ) - ); - processNumbers("beds", nickName, bedNumber); + String bedNumberStr = bedMatcher.group(2).replace(",", ""); + + try { + int bedNumber = Integer.parseInt(bedNumberStr); + if (bedNumber >= config.minBedsForDenick) { + PotentialNick player = nickToPotentials.computeIfAbsent( + nickName, + k -> new PotentialNick() + ); + if ( + isPlayerInGame(nickName) && + nickUtils.isNicked(nickName) + ) { + if (useFrosty) { + // For Frosty, accumulate until we have both stats + player.bedsNumber = bedNumber; + player.currentType = "beds"; + // Process if we have finals number too + if (player.finalsNumber != -1) { + processNumbers("both", nickName, null); + // Reset for next detection + player.finalsNumber = -1; + player.bedsNumber = -1; + } + } else { + // Aurora API processes each stat independently + if (!player.bedsChecked) { + mc.addScheduledTask(() -> + ChatUtils.sendMessage( + "§aAttempting to denick " + + nickName + + " with " + + bedNumberStr + + " beds" + ) + ); + processNumbers("beds", nickName, bedNumberStr); + } + } + } + } + } catch (NumberFormatException e) { + // Ignore if the number is invalid } } } @@ -131,23 +175,136 @@ private void processNumbers(String type, String nickName, String number) { PotentialNick player = nickToPotentials.get(nickName); if (player == null) return; - AsyncExecutor.getInstance().profileIo(() -> { - try { - int[] rangeValues = { 0, 50, 100, 200, 500, 1000 }; - int[] maxValues = { 5, 10, 20 }; + boolean useFrosty = config.numberDenickerProvider == 1; + + if (useFrosty) { + processFrostyNumbers(nickName); + } else { + processAuroraNumbers(type, nickName, number); + } + } + + private void processFrostyNumbers(String nickName) { + PotentialNick player = nickToPotentials.get(nickName); + if (player == null) return; + + try { + final int finalKills = Math.max(player.finalsNumber, 0); + final int bedsBroken = Math.max(player.bedsNumber, 0); + + if (finalKills == 0 && bedsBroken == 0) { + return; + } + + mc.addScheduledTask(() -> + ChatUtils.sendMessage( + "§aAttempting to denick " + + nickName + + " with stats" + ) + ); - int rangeIndex = type.equals("finals") - ? config.finalsRange - : config.bedsRange; - int maxIndex = config.maxResults; + AsyncExecutor.getInstance().profileIo(() -> { + try { + FrostyReponse response = frostyApi.queryStats( + finalKills, + bedsBroken, + config.frostyApiKey + ); + + if (response != null && response.success) { + if (response.data == null || response.data.isEmpty()) { + mc.addScheduledTask(() -> + ChatUtils.sendMessage( + "§cNo fuzzy match found for " + nickName + ) + ); + return; + } + + List playerNames = response.data + .stream() + .map(p -> p.username) + .collect(Collectors.toList()); + + if (config.numberDenickerFuzzy) { + String fuzzyPlayers = response.data + .stream() + .map( + p -> { + String finalsText = finalKills > 0 + ? "FK: " + + p.totalFinalKills + + " [" + + formatSignedDelta( + p.totalFinalKills - finalKills + ) + + "]" + : "FK: " + p.totalFinalKills; + String bedsText = bedsBroken > 0 + ? "Beds: " + + p.totalBedsBroken + + " [" + + formatSignedDelta( + p.totalBedsBroken - bedsBroken + ) + + "]" + : "Beds: " + p.totalBedsBroken; + return ( + "§a" + + p.username + + " §7(" + + finalsText + + ", " + + bedsText + + ")" + ); + } + ) + .collect(Collectors.joining(", ")); + + mc.addScheduledTask(() -> + ChatUtils.sendMessage( + "§aFound potential players: " + fuzzyPlayers + ) + ); + } + + if (!playerNames.isEmpty()) { + String realName = playerNames.get(0); + mc.addScheduledTask(() -> { + sendAlert(nickName, realName); + setNickDisplayName(nickName, realName + "?"); + }); + } + } else { + mc.addScheduledTask(() -> + ChatUtils.sendMessage( + "§cError fetching data from Frosty API." + ) + ); + } + } catch (IOException e) { + mc.addScheduledTask(() -> + ChatUtils.sendMessage( + "§cError fetching data from Frosty API." + ) + ); + } + }); + } catch (NumberFormatException e) { + // Ignore if the number is invalid + } + } - if ( - rangeIndex < 0 || rangeIndex >= rangeValues.length - ) rangeIndex = 1; // Default to 200 - if (maxIndex < 0 || maxIndex >= maxValues.length) maxIndex = 0; // Default to 5 + private void processAuroraNumbers(String type, String nickName, String number) { + PotentialNick player = nickToPotentials.get(nickName); + if (player == null) return; - int range = rangeValues[rangeIndex]; - int max = maxValues[maxIndex]; + AsyncExecutor.getInstance().profileIo(() -> { + try { + int range = config.getAuroraDenickRange(type); + int max = config.getAuroraDenickMaxResults(); AuroraApi.AuroraResponse response = auroraApi.queryStats( type, @@ -275,6 +432,10 @@ private void processNumbers(String type, String nickName, String number) { }); } + private String formatSignedDelta(long delta) { + return delta >= 0 ? "+" + delta : String.valueOf(delta); + } + private void sendAlert(String playerName, String realName) { String alertMsg = "§6" + realName + "§7 might be nicked as " + playerName + "§7."; @@ -319,6 +480,11 @@ private static class PotentialNick { List fuzzy_finals_potentials = null; List fuzzy_beds_potentials = null; + // For Frosty API support + String currentType = null; + int finalsNumber = -1; + int bedsNumber = -1; + void setPotentials(List potentials) { this.potentials = potentials; } diff --git a/src/test/java/com/roxiun/mellow/api/frosty/FrostyApiTest.java b/src/test/java/com/roxiun/mellow/api/frosty/FrostyApiTest.java new file mode 100644 index 0000000..01833dc --- /dev/null +++ b/src/test/java/com/roxiun/mellow/api/frosty/FrostyApiTest.java @@ -0,0 +1,116 @@ +package com.roxiun.mellow.api.frosty; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Protocol; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.junit.Assert; +import org.junit.Test; + +public class FrostyApiTest { + + @Test + public void queryStatsCachesSuccessfulResponsesByRequestParams() throws IOException { + AtomicInteger requestCount = new AtomicInteger(); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(chain -> { + Request request = chain.request(); + requestCount.incrementAndGet(); + Assert.assertEquals("mvp++", request.url().queryParameter("rank")); + Assert.assertEquals( + "19953", + request.url().queryParameter("final_kills") + ); + Assert.assertEquals( + "9183", + request.url().queryParameter("beds_broken") + ); + return response( + request, + 200, + "{" + + "\"success\":true," + + "\"data\":[{" + + "\"username\":\"bashbuta\"," + + "\"uuid\":\"5efb01316aef44219e2f67e36f607c0c\"," + + "\"game_rank\":\"MVP++\"," + + "\"rank_color\":\"GOLD\"," + + "\"plus_color\":\"DARK_AQUA\"," + + "\"network_level\":38201236," + + "\"last_online\":\"2026-04-17T22:22:15.000Z\"," + + "\"bedwars_index\":53343," + + "\"bedwars_level\":2441048," + + "\"total_final_kills\":19953," + + "\"total_final_deaths\":1940," + + "\"total_beds_broken\":9183," + + "\"total_beds_lost\":2608," + + "\"total_wins\":4533," + + "\"total_losses\":1940," + + "\"island_topper\":\"islandtopper_none\"," + + "\"victory_dance\":\"victorydance_toy_stick\"," + + "\"kill_effect\":\"killeffect_blood_explosion\"," + + "\"projectile_trail\":\"projectiletrail_meteorblaze\"," + + "\"kill_message\":\"killmessages_counter\"," + + "\"npc_skin\":\"npcskin_none\"," + + "\"death_cry\":\"deathcry_none\"," + + "\"spray\":\"sprays_carried\"," + + "\"bed_destroy\":\"beddestroy_pig_missile\"," + + "\"glyph\":\"glyph_none\"," + + "\"wood_type\":\"woodskin_dark_oak_log\"," + + "\"starting_weapon\":\"starting_weapon_wooden_sword\"," + + "\"figurine\":\"figurine_none\"" + + "}]" + + "}" + ); + }) + .build(); + + FrostyApi api = new FrostyApi(client); + + FrostyReponse first = api.queryCosmetics(19953, 9183, "frosty-key"); + FrostyReponse second = api.queryCosmetics(19953, 9183, "frosty-key"); + + Assert.assertNotNull(first); + Assert.assertNotNull(second); + Assert.assertTrue(first.success); + Assert.assertEquals(1, first.data.size()); + Assert.assertEquals("bashbuta", first.data.get(0).username); + Assert.assertEquals(38201236L, first.data.get(0).networkLevel); + Assert.assertEquals(1, requestCount.get()); + Assert.assertNotSame(first, second); + } + + @Test + public void queryStatsCachesFailedResponsesByRequestParams() throws IOException { + AtomicInteger requestCount = new AtomicInteger(); + OkHttpClient client = new OkHttpClient.Builder() + .addInterceptor(chain -> { + Request request = chain.request(); + requestCount.incrementAndGet(); + return response(request, 500, "{}"); + }) + .build(); + + FrostyApi api = new FrostyApi(client); + + Assert.assertNull(api.queryCosmetics(19953, 9183, "frosty-key")); + Assert.assertNull(api.queryCosmetics(19953, 9183, "frosty-key")); + Assert.assertEquals(1, requestCount.get()); + } + + private static Response response(Request request, int code, String body) { + return new Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message(code == 200 ? "OK" : "Error") + .body(ResponseBody.create(body, MediaType.parse("application/json"))) + .build(); + } +} + +