/* * Copyright (C) 2022 NotEnoughUpdates contributors * * This file is part of NotEnoughUpdates. * * NotEnoughUpdates is free software: you can redistribute it * and/or modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation, either * version 3 of the License, or (at your option) any later version. * * NotEnoughUpdates is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with NotEnoughUpdates. If not, see . */ package io.github.moulberry.notenoughupdates.miscfeatures; import io.github.moulberry.notenoughupdates.NotEnoughUpdates; import io.github.moulberry.notenoughupdates.autosubscribe.NEUAutoSubscribe; import io.github.moulberry.notenoughupdates.core.util.Line; import io.github.moulberry.notenoughupdates.core.util.StringUtils; import io.github.moulberry.notenoughupdates.core.util.Vec3Comparable; import io.github.moulberry.notenoughupdates.events.SpawnParticleEvent; import io.github.moulberry.notenoughupdates.options.NEUConfig; import io.github.moulberry.notenoughupdates.options.customtypes.NEUDebugFlag; import io.github.moulberry.notenoughupdates.util.NEUDebugLogger; import io.github.moulberry.notenoughupdates.util.SBInfo; import io.github.moulberry.notenoughupdates.util.TabListUtils; import io.github.moulberry.notenoughupdates.util.Utils; import net.minecraft.client.Minecraft; import net.minecraft.event.ClickEvent; import net.minecraft.init.Items; import net.minecraft.item.ItemStack; import net.minecraft.util.AxisAlignedBB; import net.minecraft.util.BlockPos; import net.minecraft.util.ChatComponentText; import net.minecraft.util.EnumChatFormatting; import net.minecraft.util.EnumParticleTypes; import net.minecraft.util.Vec3i; import net.minecraftforge.client.ClientCommandHandler; import net.minecraftforge.event.entity.player.PlayerInteractEvent; import net.minecraftforge.event.world.WorldEvent; import net.minecraftforge.fml.common.Loader; import net.minecraftforge.fml.common.eventhandler.SubscribeEvent; import java.util.ArrayDeque; import java.util.ArrayList; import java.util.EnumSet; import java.util.HashMap; import java.util.Locale; import java.util.function.BooleanSupplier; import java.util.function.LongSupplier; @NEUAutoSubscribe public class CrystalWishingCompassSolver { enum SolverState { NOT_STARTED, PROCESSING_FIRST_USE, NEED_SECOND_COMPASS, PROCESSING_SECOND_USE, SOLVED, FAILED_EXCEPTION, FAILED_TIMEOUT_NO_REPEATING, FAILED_TIMEOUT_NO_PARTICLES, FAILED_INTERSECTION_CALCULATION, FAILED_INVALID_SOLUTION, } enum CompassTarget { GOBLIN_QUEEN, GOBLIN_KING, BAL, JUNGLE_TEMPLE, ODAWA, PRECURSOR_CITY, MINES_OF_DIVAN, CRYSTAL_NUCLEUS, } enum Crystal { AMBER, AMETHYST, JADE, SAPPHIRE, TOPAZ, } enum HollowsZone { CRYSTAL_NUCLEUS, JUNGLE, MITHRIL_DEPOSITS, GOBLIN_HOLDOUT, PRECURSOR_REMNANTS, MAGMA_FIELDS, } private static final CrystalWishingCompassSolver INSTANCE = new CrystalWishingCompassSolver(); public static CrystalWishingCompassSolver getInstance() { return INSTANCE; } private static final Minecraft mc = Minecraft.getMinecraft(); private static boolean isSkytilsPresent = false; private static final ArrayDeque seenParticles = new ArrayDeque<>(); // There is a small set of breakable blocks above the nucleus at Y > 181. While this zone is reported // as the Crystal Nucleus by Hypixel, for wishing compass purposes it is in the appropriate quadrant. private static final AxisAlignedBB NUCLEUS_BB = new AxisAlignedBB(462, 63, 461, 564, 181, 565); // Bounding box around all breakable blocks in the crystal hollows, appears as bedrock in-game private static final AxisAlignedBB HOLLOWS_BB = new AxisAlignedBB(201, 30, 201, 824, 189, 824); // Zone bounding boxes private static final AxisAlignedBB PRECURSOR_REMNANTS_BB = new AxisAlignedBB(512, 63, 512, 824, 189, 824); private static final AxisAlignedBB MITHRIL_DEPOSITS_BB = new AxisAlignedBB(512, 63, 201, 824, 189, 513); private static final AxisAlignedBB GOBLIN_HOLDOUT_BB = new AxisAlignedBB(201, 63, 512, 513, 189, 824); private static final AxisAlignedBB JUNGLE_BB = new AxisAlignedBB(201, 63, 201, 513, 189, 513); private static final AxisAlignedBB MAGMA_FIELDS_BB = new AxisAlignedBB(201, 30, 201, 824, 64, 824); // Structure bounding boxes (size + 2 in each dimension to make it an actual bounding box) private static final AxisAlignedBB PRECURSOR_CITY_BB = new AxisAlignedBB(0, 0, 0, 107, 122, 107); private static final AxisAlignedBB GOBLIN_KING_BB = new AxisAlignedBB(0, 0, 0, 59, 53, 56); private static final AxisAlignedBB GOBLIN_QUEEN_BB = new AxisAlignedBB(0, 0, 0, 108, 114, 108); private static final AxisAlignedBB JUNGLE_TEMPLE_BB = new AxisAlignedBB(0, 0, 0, 108, 120, 108); private static final AxisAlignedBB ODAWA_BB = new AxisAlignedBB(0, 0, 0, 53, 46, 54); private static final AxisAlignedBB MINES_OF_DIVAN_BB = new AxisAlignedBB(0, 0, 0, 108, 125, 108); private static final AxisAlignedBB KHAZAD_DUM_BB = new AxisAlignedBB(0, 0, 0, 110, 46, 108); private static final Vec3Comparable JUNGLE_DOOR_OFFSET_FROM_CRYSTAL = new Vec3Comparable(-57, 36, -21); private static final double MAX_DISTANCE_BETWEEN_PARTICLES = 0.6; private static final double MAX_DISTANCE_FROM_USE_TO_FIRST_PARTICLE = 9.0; // 64.0 is an arbitrary value but seems to work well private static final double MINIMUM_DISTANCE_SQ_BETWEEN_COMPASSES = 64.0; // All particles typically arrive in < 3500, so 5000 should be enough buffer public static final long ALL_PARTICLES_MAX_MILLIS = 5000L; public LongSupplier currentTimeMillis = System::currentTimeMillis; public BooleanSupplier kingsScentPresent = this::isKingsScentPresent; public BooleanSupplier keyInInventory = this::isKeyInInventory; public interface CrystalEnumSetSupplier { EnumSet getAsCrystalEnumSet(); } public CrystalEnumSetSupplier foundCrystals = this::getFoundCrystals; private SolverState solverState; private Compass firstCompass; private Compass secondCompass; private Line solutionIntersectionLine; private EnumSet possibleTargets; private Vec3Comparable solution; private Vec3Comparable originalSolution; private EnumSet solutionPossibleTargets; public SolverState getSolverState() { return solverState; } public Vec3i getSolutionCoords() { return new Vec3i(solution.xCoord, solution.yCoord, solution.zCoord); } public EnumSet getPossibleTargets() { return possibleTargets; } public static HollowsZone getZoneForCoords(BlockPos blockPos) { return getZoneForCoords(new Vec3Comparable(blockPos)); } public static HollowsZone getZoneForCoords(Vec3Comparable coords) { if (NUCLEUS_BB.isVecInside(coords)) return HollowsZone.CRYSTAL_NUCLEUS; if (JUNGLE_BB.isVecInside(coords)) return HollowsZone.JUNGLE; if (MITHRIL_DEPOSITS_BB.isVecInside(coords)) return HollowsZone.MITHRIL_DEPOSITS; if (GOBLIN_HOLDOUT_BB.isVecInside(coords)) return HollowsZone.GOBLIN_HOLDOUT; if (PRECURSOR_REMNANTS_BB.isVecInside(coords)) return HollowsZone.PRECURSOR_REMNANTS; if (MAGMA_FIELDS_BB.isVecInside(coords)) return HollowsZone.MAGMA_FIELDS; throw new IllegalArgumentException("Coordinates do not fall in known zone: " + coords.toString()); } private void resetForNewTarget() { NEUDebugLogger.log(NEUDebugFlag.WISHING, "Resetting for new target"); solverState = SolverState.NOT_STARTED; firstCompass = null; secondCompass = null; solutionIntersectionLine = null; possibleTargets = null; solution = null; originalSolution = null; solutionPossibleTargets = null; } public void initWorld() { resetForNewTarget(); } @SubscribeEvent public void onWorldLoad(WorldEvent.Unload event) { initWorld(); isSkytilsPresent = Loader.isModLoaded("skytils"); } @SubscribeEvent public void onPlayerInteract(PlayerInteractEvent event) { if (!NotEnoughUpdates.INSTANCE.config.mining.wishingCompassSolver || SBInfo.getInstance().getLocation() == null || !SBInfo.getInstance().getLocation().equals("crystal_hollows") || event.entityPlayer != mc.thePlayer || (event.action != PlayerInteractEvent.Action.RIGHT_CLICK_AIR && event.action != PlayerInteractEvent.Action.RIGHT_CLICK_BLOCK) ) { return; } ItemStack heldItem = event.entityPlayer.getHeldItem(); if (heldItem == null || heldItem.getItem() != Items.skull) { return; } String heldInternalName = NotEnoughUpdates.INSTANCE.manager.getInternalNameForItem(heldItem); if (heldInternalName == null || !heldInternalName.equals("WISHING_COMPASS")) { return; } BlockPos playerPos = mc.thePlayer.getPosition().getImmutable(); try { HandleCompassResult result = handleCompassUse(playerPos); switch (result) { case SUCCESS: return; case STILL_PROCESSING_PRIOR_USE: Utils.addChatMessage( EnumChatFormatting.YELLOW + "[NEU] Wait a little longer before using the wishing compass again."); event.setCanceled(true); break; case LOCATION_TOO_CLOSE: Utils.addChatMessage( EnumChatFormatting.YELLOW + "[NEU] Move a little further before using the wishing compass again."); event.setCanceled(true); break; case POSSIBLE_TARGETS_CHANGED: Utils.addChatMessage( EnumChatFormatting.YELLOW + "[NEU] Possible wishing compass targets have changed. Solver has been reset."); event.setCanceled(true); break; case NO_PARTICLES_FOR_PREVIOUS_COMPASS: Utils.addChatMessage(EnumChatFormatting.YELLOW + "[NEU] No particles detected for prior compass use. Need another position to solve."); break; case PLAYER_IN_NUCLEUS: Utils.addChatMessage( EnumChatFormatting.YELLOW + "[NEU] Wishing compass must be used outside the nucleus for accurate results."); event.setCanceled(true); break; default: throw new IllegalStateException("Unexpected wishing compass solver state: \n" + getDiagnosticMessage()); } } catch (Exception e) { Utils.addChatMessage(EnumChatFormatting.RED + "[NEU] Error processing wishing compass action - see log for details"); e.printStackTrace(); event.setCanceled(true); solverState = SolverState.FAILED_EXCEPTION; } } public HandleCompassResult handleCompassUse(BlockPos playerPos) { long lastCompassUsedMillis = 0; switch (solverState) { case PROCESSING_SECOND_USE: if (secondCompass != null) { lastCompassUsedMillis = secondCompass.whenUsedMillis; } case PROCESSING_FIRST_USE: if (lastCompassUsedMillis == 0 && firstCompass != null) { lastCompassUsedMillis = firstCompass.whenUsedMillis; } if (lastCompassUsedMillis != 0 && (currentTimeMillis.getAsLong() > lastCompassUsedMillis + ALL_PARTICLES_MAX_MILLIS)) { return HandleCompassResult.NO_PARTICLES_FOR_PREVIOUS_COMPASS; } return HandleCompassResult.STILL_PROCESSING_PRIOR_USE; case SOLVED: case FAILED_EXCEPTION: case FAILED_TIMEOUT_NO_REPEATING: case FAILED_TIMEOUT_NO_PARTICLES: case FAILED_INTERSECTION_CALCULATION: case FAILED_INVALID_SOLUTION: resetForNewTarget(); // falls through, NOT_STARTED is the state when resetForNewTarget returns case NOT_STARTED: if (NUCLEUS_BB.isVecInside(new Vec3Comparable(playerPos.getX(), playerPos.getY(), playerPos.getZ()))) { return HandleCompassResult.PLAYER_IN_NUCLEUS; } firstCompass = new Compass(playerPos, currentTimeMillis.getAsLong()); seenParticles.clear(); solverState = SolverState.PROCESSING_FIRST_USE; possibleTargets = calculatePossibleTargets(playerPos); return HandleCompassResult.SUCCESS; case NEED_SECOND_COMPASS: if (firstCompass.whereUsed.distanceSq(playerPos) < MINIMUM_DISTANCE_SQ_BETWEEN_COMPASSES) { return HandleCompassResult.LOCATION_TOO_CLOSE; } HollowsZone firstCompassZone = getZoneForCoords(firstCompass.whereUsed); HollowsZone playerZone = getZoneForCoords(playerPos); if (!possibleTargets.equals(calculatePossibleTargets(playerPos)) || firstCompassZone != playerZone) { resetForNewTarget(); return HandleCompassResult.POSSIBLE_TARGETS_CHANGED; } secondCompass = new Compass(playerPos, currentTimeMillis.getAsLong()); solverState = SolverState.PROCESSING_SECOND_USE; return HandleCompassResult.SUCCESS; } throw new IllegalStateException("Unexpected compass state"); } /* * Processes particles if the wishing compass was used within the last 5 seconds. * * The first and the last particles are used to create a line for each wishing compass * use that is then used to calculate the target. * * Once two lines have been calculated, the shortest line between the two is calculated * with the midpoint on that line being the wishing compass target. The accuracy of this * seems to be very high. * * The target location varies based on various criteria, including, but not limited to: * Topaz Crystal (Khazad-dûm) Magma Fields * Odawa (Jungle Village) Jungle w/no Jungle Key in inventory * Amethyst Crystal (Jungle Temple) Jungle w/Jungle Key in inventory * Sapphire Crystal (Lost Precursor City) Precursor Remnants * Jade Crystal (Mines of Divan) Mithril Deposits * King Yolkar Goblin Holdout without "King's Scent I" effect * Goblin Queen Goblin Holdout with "King's Scent I" effect * Crystal Nucleus All Crystals found and none placed * per-area structure missing, or because Hypixel. * Always within 1 block of X=513 Y=106 Z=551. */ @SubscribeEvent public void onSpawnParticle(SpawnParticleEvent event) { EnumParticleTypes particleType = event.getParticleTypes(); double x = event.getXCoord(); double y = event.getYCoord(); double z = event.getZCoord(); if (!NotEnoughUpdates.INSTANCE.config.mining.wishingCompassSolver || particleType != EnumParticleTypes.VILLAGER_HAPPY || !"crystal_hollows".equals(SBInfo.getInstance().getLocation())) { return; } // Capture particle troubleshooting info for two minutes starting when the first compass is used. // This list is reset each time the first compass is used from a NOT_STARTED state. if (firstCompass != null && !solverState.equals(SolverState.SOLVED) && System.currentTimeMillis() < firstCompass.whenUsedMillis + 2 * 60 * 1000) { seenParticles.add(new ParticleData(new Vec3Comparable(x, y, z), System.currentTimeMillis())); } try { SolverState originalSolverState = solverState; solveUsingParticle(x, y, z, currentTimeMillis.getAsLong()); if (solverState != originalSolverState) { switch (solverState) { case SOLVED: showSolution(); break; case FAILED_EXCEPTION: Utils.addChatMessage(EnumChatFormatting.RED + "[NEU] Unable to determine wishing compass target."); logDiagnosticData(false); break; case FAILED_TIMEOUT_NO_REPEATING: Utils.addChatMessage( EnumChatFormatting.RED + "[NEU] Timed out waiting for repeat set of compass particles."); logDiagnosticData(false); break; case FAILED_TIMEOUT_NO_PARTICLES: Utils.addChatMessage(EnumChatFormatting.RED + "[NEU] Timed out waiting for compass particles."); logDiagnosticData(false); break; case FAILED_INTERSECTION_CALCULATION: Utils.addChatMessage( EnumChatFormatting.RED + "[NEU] Unable to determine intersection of wishing compasses."); logDiagnosticData(false); break; case FAILED_INVALID_SOLUTION: Utils.addChatMessage(EnumChatFormatting.RED + "[NEU] Failed to find solution."); logDiagnosticData(false); break; case NEED_SECOND_COMPASS: Utils.addChatMessage( EnumChatFormatting.YELLOW + "[NEU] Need another position to determine wishing compass target."); break; } } } catch (Exception e) { Utils.addChatMessage( EnumChatFormatting.RED + "[NEU] Exception while calculating wishing compass solution - see log for details"); e.printStackTrace(); } } /** * @param x Particle x coordinate * @param y Particle y coordinate * @param z Particle z coordinate */ public void solveUsingParticle(double x, double y, double z, long currentTimeMillis) { Compass currentCompass; switch (solverState) { case PROCESSING_FIRST_USE: currentCompass = firstCompass; break; case PROCESSING_SECOND_USE: currentCompass = secondCompass; break; default: return; } currentCompass.processParticle(x, y, z, currentTimeMillis); switch (currentCompass.compassState) { case FAILED_TIMEOUT_NO_PARTICLES: solverState = SolverState.FAILED_TIMEOUT_NO_PARTICLES; return; case FAILED_TIMEOUT_NO_REPEATING: solverState = SolverState.FAILED_TIMEOUT_NO_REPEATING; return; case WAITING_FOR_FIRST_PARTICLE: case COMPUTING_LAST_PARTICLE: return; case COMPLETED: if (solverState == SolverState.NEED_SECOND_COMPASS) { return; } if (solverState == SolverState.PROCESSING_FIRST_USE) { solverState = SolverState.NEED_SECOND_COMPASS; return; } break; } // First and Second compasses have completed solutionIntersectionLine = firstCompass.line.getIntersectionLineSegment(secondCompass.line); if (solutionIntersectionLine == null) { solverState = SolverState.FAILED_INTERSECTION_CALCULATION; return; } solution = new Vec3Comparable(solutionIntersectionLine.getMidpoint()); Vec3Comparable firstDirection = firstCompass.getDirection(); Vec3Comparable firstSolutionDirection = firstCompass.getDirectionTo(solution); Vec3Comparable secondDirection = secondCompass.getDirection(); Vec3Comparable secondSolutionDirection = secondCompass.getDirectionTo(solution); if (!firstDirection.signumEquals(firstSolutionDirection) || !secondDirection.signumEquals(secondSolutionDirection) || !HOLLOWS_BB.isVecInside(solution)) { solverState = SolverState.FAILED_INVALID_SOLUTION; return; } solutionPossibleTargets = getSolutionTargets( getZoneForCoords(firstCompass.whereUsed), foundCrystals.getAsCrystalEnumSet(), possibleTargets, solution ); // Adjust the Jungle Temple solution coordinates if (solutionPossibleTargets.size() == 1 && solutionPossibleTargets.contains(CompassTarget.JUNGLE_TEMPLE)) { originalSolution = solution; solution = solution.add(JUNGLE_DOOR_OFFSET_FROM_CRYSTAL); } solverState = SolverState.SOLVED; } private boolean isKeyInInventory() { for (ItemStack item : mc.thePlayer.inventory.mainInventory) { if (item != null && item.getDisplayName().contains("Jungle Key")) { return true; } } return false; } private boolean isKingsScentPresent() { if (SBInfo.getInstance().footer.getUnformattedText().contains("King's Scent I")) { return true; } for (String name : TabListUtils.getTabList()) { if (StringUtils.cleanColour(name).contains("King's Scent I")) { return true; } } return false; } private EnumSet getFoundCrystals() { EnumSet foundCrystals = EnumSet.noneOf(Crystal.class); NEUConfig.HiddenProfileSpecific perProfileConfig = NotEnoughUpdates.INSTANCE.config.getProfileSpecific(); if (perProfileConfig == null) return foundCrystals; HashMap crystals = perProfileConfig.crystals; for (String crystalName : crystals.keySet()) { Integer crystalState = crystals.get(crystalName); if (crystalState != null && crystalState > 0) { foundCrystals.add(Crystal.valueOf(crystalName.toUpperCase(Locale.US).replace("İ", "I"))); } } return foundCrystals; } // Returns candidates based on: // - Structure Y levels observed in various lobbies. It is assumed // that structures other than Khazad Dum cannot have any portion // in the Magma Fields. // // - Structure sizes & offsets into other zones that assume at least // one block must be in the correct zone. // // - An assumption that any structure could be missing with a // special exception for the Jungle Temple since it often conflicts // with Bal and a lobby with a missing Jungle Temple has not been // observed. This exception will remove Bal as a target if: // - Target candidates include both Bal & the Jungle Temple. // - The Amethyst crystal has not been acquired. // - The zone that the compass was used in is the Jungle. // // - If the solution is the Crystal Nucleus then a copy of the // passed in possible targets is returned. // // |----------|------------| // | Jungle | Mithril | // | | Deposits | // |----------|----------- | // | Goblin | Precursor | // | Holdout | Deposits | // |----------|------------| static public EnumSet getSolutionTargets( HollowsZone compassUsedZone, EnumSet foundCrystals, EnumSet possibleTargets, Vec3Comparable solution ) { EnumSet solutionPossibleTargets; solutionPossibleTargets = possibleTargets.clone(); HollowsZone solutionZone = getZoneForCoords(solution); if (solutionZone == HollowsZone.CRYSTAL_NUCLEUS) { return solutionPossibleTargets; } solutionPossibleTargets.remove(CompassTarget.CRYSTAL_NUCLEUS); // Y coordinates are 43-71 from 13 samples // Y=41/74 is the absolute min/max based on structure size if // the center of the topaz crystal has to be in magma fields. if (solutionPossibleTargets.contains(CompassTarget.BAL) && solution.yCoord > 75) { solutionPossibleTargets.remove(CompassTarget.BAL); } // Y coordinates are 93-157 from 15 samples. // Y=83/167 is the absolute min/max based on structure size if (solutionPossibleTargets.contains(CompassTarget.GOBLIN_KING) && solution.yCoord < 82 || solution.yCoord > 168) { solutionPossibleTargets.remove(CompassTarget.GOBLIN_KING); } // Y coordinates are 129-139 from 10 samples // Y=126/139 is the absolute min/max based on structure size if (solutionPossibleTargets.contains(CompassTarget.GOBLIN_QUEEN) && (solution.yCoord < 125 || solution.yCoord > 140)) { solutionPossibleTargets.remove(CompassTarget.GOBLIN_QUEEN); } // Y coordinates are 72-80 from 10 samples // Y=73/80 is the absolute min/max based on structure size if (solutionPossibleTargets.contains(CompassTarget.JUNGLE_TEMPLE) && (solution.yCoord < 72 || solution.yCoord > 81)) { solutionPossibleTargets.remove(CompassTarget.JUNGLE_TEMPLE); } // Y coordinates are 87-155 from 7 samples // Y=74/155 is the absolute min/max solution based on structure size if (solutionPossibleTargets.contains(CompassTarget.ODAWA) && (solution.yCoord < 73 || solution.yCoord > 155)) { solutionPossibleTargets.remove(CompassTarget.ODAWA); } // Y coordinates are 122-129 from 8 samples // Y=122/129 is the absolute min/max based on structure size if (solutionPossibleTargets.contains(CompassTarget.PRECURSOR_CITY) && (solution.yCoord < 121 || solution.yCoord > 130)) { solutionPossibleTargets.remove(CompassTarget.PRECURSOR_CITY); } // Y coordinates are 98-102 from 15 samples // Y=98/100 is the absolute min/max based on structure size, // but 102 has been seen - possibly with earlier code that rounded up if (solutionPossibleTargets.contains(CompassTarget.MINES_OF_DIVAN) && (solution.yCoord < 97 || solution.yCoord > 102)) { solutionPossibleTargets.remove(CompassTarget.MINES_OF_DIVAN); } // Now filter by structure offset if (solutionPossibleTargets.contains(CompassTarget.GOBLIN_KING) && (solution.xCoord > GOBLIN_HOLDOUT_BB.maxX + GOBLIN_KING_BB.maxX || solution.zCoord < GOBLIN_HOLDOUT_BB.minZ - GOBLIN_KING_BB.maxZ)) { solutionPossibleTargets.remove(CompassTarget.GOBLIN_KING); } if (solutionPossibleTargets.contains(CompassTarget.GOBLIN_QUEEN) && (solution.xCoord > GOBLIN_HOLDOUT_BB.maxX + GOBLIN_QUEEN_BB.maxX || solution.zCoord < GOBLIN_HOLDOUT_BB.minZ - GOBLIN_QUEEN_BB.maxZ)) { solutionPossibleTargets.remove(CompassTarget.GOBLIN_QUEEN); } if (solutionPossibleTargets.contains(CompassTarget.JUNGLE_TEMPLE) && (solution.xCoord > JUNGLE_BB.maxX + JUNGLE_TEMPLE_BB.maxX || solution.zCoord > JUNGLE_BB.maxZ + JUNGLE_TEMPLE_BB.maxZ)) { solutionPossibleTargets.remove(CompassTarget.JUNGLE_TEMPLE); } if (solutionPossibleTargets.contains(CompassTarget.ODAWA) && (solution.xCoord > JUNGLE_BB.maxX + ODAWA_BB.maxX || solution.zCoord > JUNGLE_BB.maxZ + ODAWA_BB.maxZ)) { solutionPossibleTargets.remove(CompassTarget.ODAWA); } if (solutionPossibleTargets.contains(CompassTarget.PRECURSOR_CITY) && (solution.xCoord < PRECURSOR_REMNANTS_BB.minX - PRECURSOR_CITY_BB.maxX || solution.zCoord < PRECURSOR_REMNANTS_BB.minZ - PRECURSOR_CITY_BB.maxZ)) { solutionPossibleTargets.remove(CompassTarget.PRECURSOR_CITY); } if (solutionPossibleTargets.contains(CompassTarget.MINES_OF_DIVAN) && (solution.xCoord < MITHRIL_DEPOSITS_BB.minX - MINES_OF_DIVAN_BB.maxX || solution.zCoord > MITHRIL_DEPOSITS_BB.maxZ + MINES_OF_DIVAN_BB.maxZ)) { solutionPossibleTargets.remove(CompassTarget.MINES_OF_DIVAN); } // Special case the Jungle Temple if (solutionPossibleTargets.contains(CompassTarget.JUNGLE_TEMPLE) && solutionPossibleTargets.contains(CompassTarget.BAL) && !foundCrystals.contains(Crystal.AMETHYST) && compassUsedZone == HollowsZone.JUNGLE) { solutionPossibleTargets.remove(CompassTarget.BAL); } return solutionPossibleTargets; } private EnumSet calculatePossibleTargets(BlockPos playerPos) { EnumSet candidateTargets = EnumSet.of(CompassTarget.CRYSTAL_NUCLEUS); EnumSet foundCrystals = this.foundCrystals.getAsCrystalEnumSet(); // Add targets based on missing crystals. // NOTE: // We used to assume that only the adjacent zone's targets could be returned. That turned // out to be incorrect (e.g. a compass in the jungle pointed to the Precursor City when // the king would have been a valid target). Now we assume that any structure could be // missing (because Hypixel) and depend on the solution coordinates to filter the list. for (Crystal crystal : Crystal.values()) { if (foundCrystals.contains(crystal)) { continue; } switch (crystal) { case JADE: candidateTargets.add(CompassTarget.MINES_OF_DIVAN); break; case AMBER: candidateTargets.add( kingsScentPresent.getAsBoolean() ? CompassTarget.GOBLIN_QUEEN : CompassTarget.GOBLIN_KING); break; case TOPAZ: candidateTargets.add(CompassTarget.BAL); break; case AMETHYST: candidateTargets.add( keyInInventory.getAsBoolean() ? CompassTarget.JUNGLE_TEMPLE : CompassTarget.ODAWA); break; case SAPPHIRE: candidateTargets.add(CompassTarget.PRECURSOR_CITY); break; } } return candidateTargets; } private String getFriendlyNameForCompassTarget(CompassTarget compassTarget) { switch (compassTarget) { case BAL: return EnumChatFormatting.RED + "Bal"; case ODAWA: return EnumChatFormatting.GREEN + "Odawa"; case JUNGLE_TEMPLE: return EnumChatFormatting.AQUA + "the " + EnumChatFormatting.GREEN + "Jungle Temple"; case GOBLIN_KING: return EnumChatFormatting.GOLD + "King Yolkar"; case GOBLIN_QUEEN: return EnumChatFormatting.AQUA + "the " + EnumChatFormatting.YELLOW + "Goblin Queen"; case PRECURSOR_CITY: return EnumChatFormatting.AQUA + "the " + EnumChatFormatting.WHITE + "Precursor City"; case MINES_OF_DIVAN: return EnumChatFormatting.AQUA + "the " + EnumChatFormatting.BLUE + "Mines of Divan"; default: return EnumChatFormatting.WHITE + "an undetermined location"; } } private String getNameForCompassTarget(CompassTarget compassTarget) { boolean useSkytilsNames = (NotEnoughUpdates.INSTANCE.config.mining.wishingCompassWaypointNames == 1); switch (compassTarget) { case BAL: return useSkytilsNames ? "internal_bal" : "Bal"; case ODAWA: return "Odawa"; case JUNGLE_TEMPLE: return useSkytilsNames ? "internal_temple" : "Temple"; case GOBLIN_KING: return useSkytilsNames ? "internal_king" : "King"; case GOBLIN_QUEEN: return useSkytilsNames ? "internal_den" : "Queen"; case PRECURSOR_CITY: return useSkytilsNames ? "internal_city" : "City"; case MINES_OF_DIVAN: return useSkytilsNames ? "internal_mines" : "Mines"; default: return "WishingTarget"; } } private String getSolutionCoordsText() { return solution == null ? "" : String.format("%.0f %.0f %.0f", solution.xCoord, solution.yCoord, solution.zCoord); } private String getWishingCompassDestinationsMessage() { StringBuilder sb = new StringBuilder(); sb.append(EnumChatFormatting.YELLOW); sb.append("[NEU] "); sb.append(EnumChatFormatting.AQUA); sb.append("Wishing compass points to "); int index = 1; for (CompassTarget target : solutionPossibleTargets) { if (index > 1) { sb.append(EnumChatFormatting.AQUA); if (index == solutionPossibleTargets.size()) { sb.append(" or "); } else { sb.append(", "); } } sb.append(getFriendlyNameForCompassTarget(target)); index++; } sb.append(EnumChatFormatting.AQUA); sb.append(" ("); sb.append(getSolutionCoordsText()); sb.append(")"); return sb.toString(); } private void showSolution() { if (solution == null) return; if (NUCLEUS_BB.isVecInside(solution)) { Utils.addChatMessage(EnumChatFormatting.YELLOW + "[NEU] " + EnumChatFormatting.AQUA + "Wishing compass target is the Crystal Nucleus"); return; } String destinationMessage = getWishingCompassDestinationsMessage(); if (!isSkytilsPresent) { Utils.addChatMessage(destinationMessage); return; } String targetNameForSkytils = solutionPossibleTargets.size() == 1 ? getNameForCompassTarget(solutionPossibleTargets.iterator().next()) : "WishingTarget"; String skytilsCommand = String.format("/sthw add %s %s", getSolutionCoordsText(), targetNameForSkytils); if (NotEnoughUpdates.INSTANCE.config.mining.wishingCompassAutocreateKnownWaypoints && solutionPossibleTargets.size() == 1) { Utils.addChatMessage(destinationMessage); int commandResult = ClientCommandHandler.instance.executeCommand(mc.thePlayer, skytilsCommand); if (commandResult == 1) { return; } Utils.addChatMessage( EnumChatFormatting.RED + "[NEU] Failed to automatically run /sthw"); } destinationMessage += EnumChatFormatting.YELLOW + " [Add Skytils Waypoint]"; ChatComponentText chatMessage = new ChatComponentText(destinationMessage); chatMessage.setChatStyle(Utils.createClickStyle( ClickEvent.Action.RUN_COMMAND, skytilsCommand, EnumChatFormatting.YELLOW + "Set waypoint for wishing target" )); mc.thePlayer.addChatMessage(chatMessage); } private String getDiagnosticMessage() { StringBuilder diagsMessage = new StringBuilder(); diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("Solver State: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(solverState.name()); diagsMessage.append("\n"); if (firstCompass == null) { diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("First Compass: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(""); diagsMessage.append("\n"); } else { firstCompass.appendCompassDiagnostics(diagsMessage, "First Compass"); } if (secondCompass == null) { diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("Second Compass: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(""); diagsMessage.append("\n"); } else { secondCompass.appendCompassDiagnostics(diagsMessage, "Second Compass"); } diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("Intersection Line: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append((solutionIntersectionLine == null) ? "" : solutionIntersectionLine); diagsMessage.append("\n"); diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("Jungle Key in Inventory: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(isKeyInInventory()); diagsMessage.append("\n"); diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("King's Scent Present: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(isKingsScentPresent()); diagsMessage.append("\n"); diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("First Compass Targets: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(possibleTargets == null ? "" : possibleTargets.toString()); diagsMessage.append("\n"); diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("Current Calculated Targets: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(calculatePossibleTargets(mc.thePlayer.getPosition())); diagsMessage.append("\n"); diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("Found Crystals: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(getFoundCrystals()); diagsMessage.append("\n"); if (originalSolution != null) { diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("Original Solution: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(originalSolution); diagsMessage.append("\n"); } diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("Solution: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append((solution == null) ? "" : solution.toString()); diagsMessage.append("\n"); diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("Solution Targets: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append((solutionPossibleTargets == null) ? "" : solutionPossibleTargets.toString()); diagsMessage.append("\n"); diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("Seen particles:\n"); for (ParticleData particleData : seenParticles) { diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(particleData); diagsMessage.append("\n"); } return diagsMessage.toString(); } public void logDiagnosticData(boolean outputAlways) { if (!SBInfo.getInstance().checkForSkyblockLocation()) { return; } if (!NotEnoughUpdates.INSTANCE.config.mining.wishingCompassSolver) { Utils.addChatMessage(EnumChatFormatting.RED + "[NEU] Wishing Compass Solver is not enabled."); return; } boolean wishingDebugFlagSet = NEUDebugFlag.WISHING.isSet(); if (outputAlways || wishingDebugFlagSet) { NEUDebugLogger.logAlways(getDiagnosticMessage()); } } enum CompassState { WAITING_FOR_FIRST_PARTICLE, COMPUTING_LAST_PARTICLE, COMPLETED, FAILED_TIMEOUT_NO_REPEATING, FAILED_TIMEOUT_NO_PARTICLES, } enum HandleCompassResult { SUCCESS, LOCATION_TOO_CLOSE, STILL_PROCESSING_PRIOR_USE, POSSIBLE_TARGETS_CHANGED, NO_PARTICLES_FOR_PREVIOUS_COMPASS, PLAYER_IN_NUCLEUS } static class Compass { public CompassState compassState; public Line line = null; private final BlockPos whereUsed; private final long whenUsedMillis; private Vec3Comparable firstParticle = null; private Vec3Comparable previousParticle = null; private Vec3Comparable lastParticle = null; private final ArrayList processedParticles; Compass(BlockPos whereUsed, long whenUsedMillis) { this.whereUsed = whereUsed; this.whenUsedMillis = whenUsedMillis; compassState = CompassState.WAITING_FOR_FIRST_PARTICLE; processedParticles = new ArrayList<>(); } public Vec3Comparable getDirection() { if (firstParticle == null || lastParticle == null) { return null; } return new Vec3Comparable(firstParticle.subtractReverse(lastParticle).normalize()); } public Vec3Comparable getDirectionTo(Vec3Comparable target) { if (firstParticle == null || target == null) { return null; } return new Vec3Comparable(firstParticle.subtractReverse(target).normalize()); } public double particleSpread() { if (firstParticle == null || lastParticle == null) { return 0.0; } return firstParticle.distanceTo(lastParticle); } public void processParticle(double x, double y, double z, long particleTimeMillis) { if (compassState == CompassState.FAILED_TIMEOUT_NO_REPEATING || compassState == CompassState.FAILED_TIMEOUT_NO_PARTICLES || compassState == CompassState.COMPLETED) { throw new UnsupportedOperationException("processParticle should not be called in a failed or completed state"); } if (particleTimeMillis - this.whenUsedMillis > ALL_PARTICLES_MAX_MILLIS) { // Assume we have failed if we're still trying to process particles compassState = CompassState.FAILED_TIMEOUT_NO_REPEATING; return; } Vec3Comparable currentParticle = new Vec3Comparable(x, y, z); if (compassState == CompassState.WAITING_FOR_FIRST_PARTICLE) { if (currentParticle.distanceTo(new Vec3Comparable(whereUsed)) < MAX_DISTANCE_FROM_USE_TO_FIRST_PARTICLE) { processedParticles.add(new ProcessedParticle(currentParticle, particleTimeMillis)); firstParticle = currentParticle; previousParticle = currentParticle; compassState = CompassState.COMPUTING_LAST_PARTICLE; } return; } // State is COMPUTING_LAST_PARTICLE, keep updating the previousParticle until // the first particle in the second sequence is seen. if (currentParticle.distanceTo(previousParticle) <= MAX_DISTANCE_BETWEEN_PARTICLES) { processedParticles.add(new ProcessedParticle(currentParticle, particleTimeMillis)); previousParticle = currentParticle; return; } if (currentParticle.distanceTo(firstParticle) > MAX_DISTANCE_BETWEEN_PARTICLES) { return; } // It's a repeating particle processedParticles.add(new ProcessedParticle(currentParticle, particleTimeMillis)); lastParticle = previousParticle; line = new Line(firstParticle, lastParticle); compassState = CompassState.COMPLETED; } public void appendCompassDiagnostics(StringBuilder diagsMessage, String compassName) { diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append("Compass State: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(compassState.name()); diagsMessage.append("\n"); diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append(compassName); diagsMessage.append(" Used Millis: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(whenUsedMillis); diagsMessage.append("\n"); diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append(compassName); diagsMessage.append(" Used Position: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append((whereUsed == null) ? "" : whereUsed.toString()); diagsMessage.append("\n"); diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append(compassName); diagsMessage.append(" All Seen Particles: \n"); diagsMessage.append(EnumChatFormatting.WHITE); for (ProcessedParticle particle : processedParticles) { diagsMessage.append(particle.toString()); diagsMessage.append("\n"); } diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append(compassName); diagsMessage.append(" Particle Spread: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append(particleSpread()); diagsMessage.append("\n"); diagsMessage.append(EnumChatFormatting.AQUA); diagsMessage.append(compassName); diagsMessage.append(" Compass Line: "); diagsMessage.append(EnumChatFormatting.WHITE); diagsMessage.append((line == null) ? "" : line.toString()); diagsMessage.append("\n"); } static class ProcessedParticle { Vec3Comparable coords; long particleTimeMillis; ProcessedParticle(Vec3Comparable coords, long particleTimeMillis) { this.coords = coords; this.particleTimeMillis = particleTimeMillis; } @Override public String toString() { return coords.toString() + " " + particleTimeMillis; } } } private static class ParticleData { Vec3Comparable particleLocation; long systemTime; public ParticleData(Vec3Comparable particleLocation, long systemTime) { this.particleLocation = particleLocation; this.systemTime = systemTime; } public String toString() { return "Location: " + particleLocation.toString() + ", systemTime: " + systemTime; } } }