Skip to content

Commit

Permalink
Player Secrets Tracker
Browse files Browse the repository at this point in the history
  • Loading branch information
Aaron committed Oct 30, 2023
1 parent 1389107 commit c2431f4
Show file tree
Hide file tree
Showing 10 changed files with 283 additions and 15 deletions.
4 changes: 4 additions & 0 deletions src/main/java/de/hysky/skyblocker/SkyblockerMod.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import de.hysky.skyblocker.skyblock.diana.MythologicalRitual;
import de.hysky.skyblocker.skyblock.dungeon.*;
import de.hysky.skyblocker.skyblock.dungeon.secrets.DungeonSecrets;
import de.hysky.skyblocker.skyblock.dungeon.secrets.SecretsTracker;
import de.hysky.skyblocker.skyblock.dwarven.DwarvenHud;
import de.hysky.skyblocker.skyblock.item.*;
import de.hysky.skyblocker.skyblock.item.tooltip.BackpackPreview;
Expand All @@ -20,6 +21,7 @@
import de.hysky.skyblocker.skyblock.tabhud.TabHud;
import de.hysky.skyblocker.skyblock.tabhud.screenbuilder.ScreenMaster;
import de.hysky.skyblocker.skyblock.tabhud.util.PlayerListMgr;
import de.hysky.skyblocker.utils.ApiUtils;
import de.hysky.skyblocker.utils.NEURepoManager;
import de.hysky.skyblocker.utils.Utils;
import de.hysky.skyblocker.utils.chat.ChatMessageListener;
Expand Down Expand Up @@ -109,6 +111,8 @@ public void onInitializeClient() {
CreeperBeams.init();
ItemRarityBackgrounds.init();
MuseumItemCache.init();
SecretsTracker.init();
ApiUtils.init();
containerSolverManager.init();
statusBarTracker.init();
Scheduler.INSTANCE.scheduleCyclic(Utils::update, 20);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,9 @@ public static class Dungeons {

@SerialEntry
public int mapY = 2;

@SerialEntry
public boolean playerSecretsTracker = false;

@SerialEntry
public boolean starredMobGlow = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,14 @@ public static ConfigCategory create(SkyblockerConfig defaults, SkyblockerConfig
newValue -> config.locations.dungeons.mapScaling = newValue)
.controller(FloatFieldControllerBuilder::create)
.build())
.option(Option.<Boolean>createBuilder()
.name(Text.translatable("text.autoconfig.skyblocker.option.locations.dungeons.secretsTracker"))
.description(OptionDescription.of(Text.translatable("text.autoconfig.skyblocker.option.locations.dungeons.secretsTracker.@Tooltip")))
.binding(defaults.locations.dungeons.playerSecretsTracker,
() -> config.locations.dungeons.playerSecretsTracker,
newValue -> config.locations.dungeons.playerSecretsTracker = newValue)
.controller(ConfigUtils::createBooleanController)
.build())
.option(Option.<Boolean>createBuilder()
.name(Text.translatable("text.autoconfig.skyblocker.option.locations.dungeons.starredMobGlow"))
.description(OptionDescription.of(Text.translatable("text.autoconfig.skyblocker.option.locations.dungeons.starredMobGlow.@Tooltip")))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
package de.hysky.skyblocker.skyblock.dungeon.secrets;

import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.gson.JsonObject;
import com.google.gson.JsonParser;

import de.hysky.skyblocker.config.SkyblockerConfigManager;
import de.hysky.skyblocker.skyblock.tabhud.util.PlayerListMgr;
import de.hysky.skyblocker.skyblock.tabhud.widget.DungeonPlayerWidget;
import de.hysky.skyblocker.utils.ApiUtils;
import de.hysky.skyblocker.utils.Constants;
import de.hysky.skyblocker.utils.Http;
import de.hysky.skyblocker.utils.Http.ApiResponse;
import de.hysky.skyblocker.utils.Utils;
import it.unimi.dsi.fastutil.ints.IntIntPair;
import it.unimi.dsi.fastutil.objects.Object2IntMap.Entry;
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
import it.unimi.dsi.fastutil.objects.ObjectOpenHashSet;
import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
import net.minecraft.client.MinecraftClient;
import net.minecraft.text.HoverEvent;
import net.minecraft.text.Text;
import net.minecraft.util.Formatting;

/**
* Tracks the amount of secrets players get every run
*/
public class SecretsTracker {
private static final Logger LOGGER = LoggerFactory.getLogger(SecretsTracker.class);
private static final Pattern TEAM_SCORE_PATTERN = Pattern.compile(" +Team Score: [0-9]+ \\([A-z+]+\\)");

private static volatile TrackedRun currentRun = null;
private static volatile TrackedRun lastRun = null;
private static volatile long lastRunEnded = 0L;

public static void init() {
ClientReceiveMessageEvents.GAME.register(SecretsTracker::onMessage);
}

//If -1 is somehow encountered, it would be very rare so I just disregard its possibility for now
//people would probably recognize if it was inaccurate so yeah
private static void calculate(RunPhase phase) {
switch (phase) {
case START -> {
CompletableFuture.runAsync(() -> {
TrackedRun newlyStartedRun = new TrackedRun();

//Initialize players in new run
for (int i = 0; i < 5; i++) {
String playerName = getPlayerNameAt(i + 1);

//The player name will be blank if there isn't a player at that index
if (!playerName.isEmpty()) {
newlyStartedRun.playerNames().add(playerName);

//If the player was apart of the last run (and didn't have -1 secret count) and that run ended less than 5 mins ago then copy the secrets over
if (lastRun != null && System.currentTimeMillis() <= lastRunEnded + 300_000 && lastRun.secretCounts().getOrDefault(playerName, -1) != -1) {
newlyStartedRun.secretCounts().put(playerName, lastRun.secretCounts().getInt(playerName));
} else {
newlyStartedRun.secretCounts().put(playerName, getPlayerSecrets(playerName).leftInt());
}
}
}

currentRun = newlyStartedRun;
});
}

case END -> {
CompletableFuture.runAsync(() -> {
//In case the game crashes from something
if (currentRun != null) {
Object2ObjectOpenHashMap<String, IntIntPair> secretsFound = new Object2ObjectOpenHashMap<>();

//Update secret counts
for (Entry<String> entry : currentRun.secretCounts().object2IntEntrySet()) {
String playerName = entry.getKey();
int startingSecrets = entry.getIntValue();
IntIntPair secretsNow = getPlayerSecrets(playerName);
int secretsPlayerFound = secretsNow.leftInt() - startingSecrets;

secretsFound.put(playerName, IntIntPair.of(secretsPlayerFound, secretsNow.rightInt()));
entry.setValue(secretsNow.leftInt());
}

//Print the results all in one go, so its clean and less of a chance of it being broken up
for (Map.Entry<String, IntIntPair> entry : secretsFound.entrySet()) {
sendResultMessage(entry.getKey(), entry.getValue().leftInt(), entry.getValue().rightInt(), true);
}

//Swap the current and last run as well as mark the run end time
lastRunEnded = System.currentTimeMillis();
lastRun = currentRun;
currentRun = null;
} else {
sendResultMessage(null, -1, -1, false);
}
});
}
}
}

@SuppressWarnings("resource")
private static void sendResultMessage(String player, int secrets, int cacheAge, boolean success) {
if (success) {
MinecraftClient.getInstance().player.sendMessage(Constants.PREFIX.get().append(Text.translatable("skyblocker.dungeons.secretsTracker.feedback", Text.literal(player).styled(Constants.WITH_COLOR.apply(0xf57542)), "§7" + secrets, getCacheText(cacheAge))));
} else {
MinecraftClient.getInstance().player.sendMessage(Constants.PREFIX.get().append(Text.translatable("skyblocker.dungeons.secretsTracker.failFeedback")));
}
}

private static Text getCacheText(int cacheAge) {
return Text.literal("\u2139").styled(style -> style.withColor(cacheAge == -1 ? 0x218bff : 0xeac864).withHoverEvent(
new HoverEvent(HoverEvent.Action.SHOW_TEXT, cacheAge == -1 ? Text.translatable("skyblocker.api.cache.MISS") : Text.translatable("skyblocker.api.cache.HIT", cacheAge))));
}

private static void onMessage(Text text, boolean overlay) {
if (Utils.isInDungeons() && SkyblockerConfigManager.get().locations.dungeons.playerSecretsTracker) {
String message = Formatting.strip(text.getString());

try {
if (message.equals("[NPC] Mort: Here, I found this map when I first entered the dungeon.")) calculate(RunPhase.START);
if (TEAM_SCORE_PATTERN.matcher(message).matches()) calculate(RunPhase.END);
} catch (Exception e) {
LOGGER.error("[Skyblocker] Encountered an unknown error while trying to track player secrets!", e);
}
}
}

private static String getPlayerNameAt(int index) {
Matcher matcher = PlayerListMgr.regexAt(1 + (index - 1) * 4, DungeonPlayerWidget.PLAYER_PATTERN);

return matcher != null ? matcher.group("name") : "";
}

private static IntIntPair getPlayerSecrets(String name) {
String uuid = ApiUtils.name2Uuid(name);

if (!uuid.isEmpty()) {
try (ApiResponse response = Http.sendHypixelRequest("player", "?uuid=" + uuid)) {
return IntIntPair.of(getSecretCountFromAchievements(JsonParser.parseString(response.content()).getAsJsonObject()), response.age());
} catch (Exception e) {
LOGGER.error("[Skyblocker] Encountered an error while trying to fetch {} secret count!", name + "'s", e);
}
}

return IntIntPair.of(-1, -1);
}

/**
* Gets a player's secret count from their hypixel achievements
*/
private static int getSecretCountFromAchievements(JsonObject playerJson) {
JsonObject player = playerJson.get("player").getAsJsonObject();
JsonObject achievements = (player.has("achievements")) ? player.get("achievements").getAsJsonObject() : null;
int secrets = (achievements != null && achievements.has("skyblock_treasure_hunter")) ? achievements.get("skyblock_treasure_hunter").getAsInt() : 0;

return secrets;
}

private record TrackedRun(ObjectOpenHashSet<String> playerNames, Object2IntOpenHashMap<String> secretCounts) {
private TrackedRun() {
this(new ObjectOpenHashSet<>(), new Object2IntOpenHashMap<>());
}

/**
* This will either reflect the value at the start or the end depending on when this is called
*/
@Override
public Object2IntOpenHashMap<String> secretCounts() {
return secretCounts;
}
}

private enum RunPhase {
START,
END;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,7 @@ private static void save() {

private static void updateData4ProfileMember(String uuid, String profileId) {
CompletableFuture.runAsync(() -> {
try {
ApiResponse response = Http.sendHypixelRequest("skyblock/museum", "?profile=" + profileId);

try (ApiResponse response = Http.sendHypixelRequest("skyblock/museum", "?profile=" + profileId)) {
//The request was successful
if (response.ok()) {
JsonObject profileData = JsonParser.parseString(response.content()).getAsJsonObject();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ public class DungeonPlayerWidget extends Widget {
// group 3: level (or nothing, if pre dungeon start)
// this regex filters out the ironman icon as well as rank prefixes and emblems
// \[\d*\] (?:\[[A-Za-z]+\] )?(?<name>[A-Za-z0-9_]*) (?:.* )?\((?<class>\S*) ?(?<level>[LXVI]*)\)
private static final Pattern PLAYER_PATTERN = Pattern
public static final Pattern PLAYER_PATTERN = Pattern
.compile("\\[\\d*\\] (?:\\[[A-Za-z]+\\] )?(?<name>[A-Za-z0-9_]*) (?:.* )?\\((?<class>\\S*) ?(?<level>[LXVI]*)\\)");

private static final HashMap<String, ItemStack> ICOS = new HashMap<>();
Expand Down
53 changes: 53 additions & 0 deletions src/main/java/de/hysky/skyblocker/utils/ApiUtils.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package de.hysky.skyblocker.utils;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.google.gson.JsonParser;
import com.mojang.util.UndashedUuid;

import de.hysky.skyblocker.utils.Http.ApiResponse;
import de.hysky.skyblocker.utils.scheduler.Scheduler;
import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
import net.minecraft.client.MinecraftClient;
import net.minecraft.client.session.Session;

/*
* Contains only basic helpers for using Http APIs
*/
public class ApiUtils {
private static final Logger LOGGER = LoggerFactory.getLogger(ApiUtils.class);
/**
* Do not iterate over this map, it will be accessed and modified by multiple threads.
*/
private static final Object2ObjectOpenHashMap<String, String> NAME_2_UUID_CACHE = new Object2ObjectOpenHashMap<>();

public static void init() {
//Clear cache every 20 minutes
Scheduler.INSTANCE.scheduleCyclic(NAME_2_UUID_CACHE::clear, 24_000, true);
}

/**
* Multithreading is to be handled by the method caller
*/
public static String name2Uuid(String name) {
Session session = MinecraftClient.getInstance().getSession();

if (session.getUsername().equals(name)) return UndashedUuid.toString(session.getUuidOrNull());
if (NAME_2_UUID_CACHE.containsKey(name)) return NAME_2_UUID_CACHE.get(name);

try (ApiResponse response = Http.sendName2UuidRequest(name)) {
if (response.ok()) {
String uuid = JsonParser.parseString(response.content()).getAsJsonObject().get("id").getAsString();

NAME_2_UUID_CACHE.put(name, uuid);

return uuid;
}
} catch (Exception e) {
LOGGER.error("[Skyblocker] Name to uuid lookup failed! Name: {}", name, e);
}

return "";
}
}
22 changes: 17 additions & 5 deletions src/main/java/de/hysky/skyblocker/utils/Http.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ private static ApiResponse sendCacheableGetRequest(String url) throws IOExceptio

HttpResponse<InputStream> response = HTTP_CLIENT.send(request, BodyHandlers.ofInputStream());
InputStream decodedInputStream = getDecodedInputStream(response);

String body = new String(decodedInputStream.readAllBytes());
HttpHeaders headers = response.headers();

return new ApiResponse(body, response.statusCode(), getCacheStatus(response.headers()));
return new ApiResponse(body, response.statusCode(), getCacheStatus(headers), getAge(headers));
}

public static HttpHeaders sendHeadRequest(String url) throws IOException, InterruptedException {
Expand All @@ -66,8 +68,8 @@ public static HttpHeaders sendHeadRequest(String url) throws IOException, Interr
return response.headers();
}

public static String sendName2UuidRequest(String name) throws IOException, InterruptedException {
return sendGetRequest(NAME_2_UUID + name);
public static ApiResponse sendName2UuidRequest(String name) throws IOException, InterruptedException {
return sendCacheableGetRequest(NAME_2_UUID + name);
}

/**
Expand Down Expand Up @@ -115,12 +117,16 @@ public static String getLastModified(HttpHeaders headers) {
*
* @see <a href="https://developers.cloudflare.com/cache/concepts/default-cache-behavior/#cloudflare-cache-responses">Cloudflare Cache Docs</a>
*/
public static String getCacheStatus(HttpHeaders headers) {
private static String getCacheStatus(HttpHeaders headers) {
return headers.firstValue("CF-Cache-Status").orElse("UNKNOWN");
}

private static int getAge(HttpHeaders headers) {
return Integer.parseInt(headers.firstValue("Age").orElse("-1"));
}

//TODO If ever needed, we could just replace cache status with the response headers and go from there
public record ApiResponse(String content, int statusCode, String cacheStatus) {
public record ApiResponse(String content, int statusCode, String cacheStatus, int age) implements AutoCloseable {

public boolean ok() {
return statusCode == 200;
Expand All @@ -129,5 +135,11 @@ public boolean ok() {
public boolean cached() {
return cacheStatus.equals("HIT");
}

@Override
public void close() {
//Allows for nice syntax when dealing with api requests in try catch blocks
//Maybe one day we'll have some resources to free
}
}
}

0 comments on commit c2431f4

Please sign in to comment.