diff --git a/.gitignore b/.gitignore index ba17279..685801f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,23 +1,27 @@ -# Gradle / build output -.gradle/ -build/ -run/ -*.class - -# IDE -.idea/ -*.iml -*.ipr -*.iws -.vscode/ -bin/ -CLAUDE.md - -# OS -.DS_Store -Thumbs.db - -# Backups -*.bak -resources/assets/edenmod/font/emotes.json -resources/assets/edenmod/emotes_manifest.json +# Gradle / build output +.gradle/ +build/ +run/ +*.class + +# IDE +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +bin/ +CLAUDE.md + +# OS +.DS_Store +Thumbs.db + +# Backups +*.bak +resources/assets/edenmod/font/emotes.json +resources/assets/edenmod/emotes_manifest.json + +# Agents +.agents/ +AGENTS.md diff --git a/resources/assets/edenmod/textures/gui/target_circle.png b/resources/assets/edenmod/textures/gui/target_circle.png new file mode 100644 index 0000000..1769157 Binary files /dev/null and b/resources/assets/edenmod/textures/gui/target_circle.png differ diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 21e98e6..4d44e02 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -518,6 +518,7 @@ public boolean isChiefOwnerOrStrategist() { public void onWorldRespawn() { enteredWynnGameWorld = true; guildLogSync.onWorldJoin(); + tel.eden.mod.war.eco.TerritoryGuideController.reset(); } /** Packet hooks for the invisible guild-log query (mirrors Wynntils' query path). */ @@ -546,6 +547,13 @@ public boolean shouldMuteBackgroundMenuClick(SoundEvent sound, SoundSource sourc public void onInitializeClient() { instance = this; config = BridgeConfig.load(); + if (config.territoryActivePreset != null && !config.territoryActivePreset.isBlank()) { + try { + tel.eden.mod.war.eco.TerritoryGuideController.setActivePreset(tel.eden.mod.war.eco.TerritoryPresetCodec.parse(config.territoryActivePreset)); + } catch (Exception ignored) { + } + } + tel.eden.mod.gui.TerritoryMenuPanel.init(); refreshCommandKeybinds(); // Relay the guild's live reward storage to the backend counter: the exact value @@ -663,6 +671,7 @@ public void release() { GuildMenuScraper.reset(); AttackTimerMenu.reset(); tel.eden.mod.war.radiance.RadianceTracker.onJoin(); + tel.eden.mod.war.eco.TerritoryGuideController.reset(); guildAllyManager.requestCancel(); // Connect if applicable; the backend reports link/membership standing via // authOk (handleAuthStatus), which drives any "please link" prompt. @@ -2371,6 +2380,7 @@ private synchronized void disconnect() { PartyHealthTracker.reset(); ChatReplyManager.reset(); tel.eden.mod.war.radiance.RadianceTracker.onDisconnect(); + tel.eden.mod.war.eco.TerritoryGuideController.reset(); guildAllyManager.requestCancel(); territoryDefendedTracker.clear(); // Before the socket goes: an outstanding deduct must not outlive the session that diff --git a/src/tel/eden/mod/config/BridgeConfig.java b/src/tel/eden/mod/config/BridgeConfig.java index 18e74bb..3899fe6 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -183,6 +183,16 @@ public void sanitize() { /** Territories excluded from playing the territory loss sound when captured. */ public List territoryLossBlacklist = new ArrayList<>(DEFAULT_TERRITORY_LOSS_BLACKLIST); + // ---- Territory Preset Helper (Wynntils Add-on) ---- + /** Master toggle for the Territory Preset Helper Wynntils add-on. On by default. */ + public boolean territoryMenuSuite = true; + + /** Active territory preset encoded Base64 or JSON string, or empty. */ + public String territoryActivePreset = ""; + + /** Storage of saved territory presets: name -> encoded string or JSON. */ + public Map territorySavedPresets = new java.util.LinkedHashMap<>(); + /** * Run {@code /stream} automatically on entering a Wynncraft world. Off by default: * it changes what the server shows you, so it should be an opt-in. @@ -492,6 +502,9 @@ public static BridgeConfig load() { } else { config.radianceAdvanceNoticeSeconds = Math.max(0.0, Math.min(3.0, Math.round(config.radianceAdvanceNoticeSeconds * 4.0) / 4.0)); } + if (config.territorySavedPresets == null) { + config.territorySavedPresets = new java.util.LinkedHashMap<>(); + } return config; } } catch (IOException | RuntimeException e) { diff --git a/src/tel/eden/mod/gui/BridgeConfigScreen.java b/src/tel/eden/mod/gui/BridgeConfigScreen.java index dd8c606..a906ed2 100644 --- a/src/tel/eden/mod/gui/BridgeConfigScreen.java +++ b/src/tel/eden/mod/gui/BridgeConfigScreen.java @@ -184,6 +184,7 @@ protected void init() { config.save(); EdenModClient.syncRadianceGroup(config); }); + addToggleRow(Category.WAR, "Territory preset helper", () -> config.territoryMenuSuite, v -> config.territoryMenuSuite = v, "On", "Off", true); // Visuals & QoL if (config.isSecretUnlocked(BridgeConfig.SECRET_BABY_PLAYERS)) { diff --git a/src/tel/eden/mod/gui/RenamePresetScreen.java b/src/tel/eden/mod/gui/RenamePresetScreen.java new file mode 100644 index 0000000..6f91f89 --- /dev/null +++ b/src/tel/eden/mod/gui/RenamePresetScreen.java @@ -0,0 +1,85 @@ +package tel.eden.mod.gui; + +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.network.chat.Component; +import org.lwjgl.glfw.GLFW; +import tel.eden.mod.war.eco.TerritoryGuideController; + +/** + * Modal dialog allowing the user to rename a saved territory preset. + */ +public class RenamePresetScreen extends Screen { + private final Screen parent; + private final String oldName; + private EditBox nameField; + + public RenamePresetScreen(Screen parent, String oldName) { + super(Component.literal("Rename Preset")); + this.parent = parent; + this.oldName = oldName; + } + + @Override + protected void init() { + int panelW = 220; + int panelH = 92; + int x = (this.width - panelW) / 2; + int y = (this.height - panelH) / 2; + + nameField = new EditBox(this.font, x + 15, y + 30, panelW - 30, 20, Component.literal("Preset Name")); + nameField.setValue(oldName != null ? oldName : ""); + nameField.setMaxLength(32); + this.addRenderableWidget(nameField); + this.setInitialFocus(nameField); + + int btnW = (panelW - 36) / 2; + this.addRenderableWidget(Button.builder(Component.literal("Save"), b -> commit()).bounds(x + 15, y + 58, btnW, 20).build()); + this.addRenderableWidget(Button.builder(Component.literal("Cancel"), b -> onClose()).bounds(x + 21 + btnW, y + 58, btnW, 20).build()); + } + + private void commit() { + if (nameField != null) { + String newName = nameField.getValue().trim(); + if (newName.isEmpty()) { + newName = "Default"; + } + if (!newName.equalsIgnoreCase(oldName)) { + TerritoryGuideController.renameSavedPreset(oldName, newName); + } + } + onClose(); + } + + @Override + public boolean keyPressed(KeyEvent event) { + if (event.key() == GLFW.GLFW_KEY_ENTER || event.key() == GLFW.GLFW_KEY_KP_ENTER) { + commit(); + return true; + } + return super.keyPressed(event); + } + + @Override + public void onClose() { + if (this.minecraft != null) { + this.minecraft.setScreen(parent); + } + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float delta) { + int panelW = 220; + int panelH = 92; + int x = (this.width - panelW) / 2; + int y = (this.height - panelH) / 2; + + graphics.fill(x, y, x + panelW, y + panelH, 0xF012161E); + graphics.renderOutline(x, y, panelW, panelH, 0xFF3E4A5B); + graphics.drawString(this.font, "§6Rename Preset", x + 15, y + 14, 0xFFFFFFFF); + super.render(graphics, mouseX, mouseY, delta); + } +} diff --git a/src/tel/eden/mod/gui/TerritoryMenuPanel.java b/src/tel/eden/mod/gui/TerritoryMenuPanel.java new file mode 100644 index 0000000..41a04ed --- /dev/null +++ b/src/tel/eden/mod/gui/TerritoryMenuPanel.java @@ -0,0 +1,940 @@ +package tel.eden.mod.gui; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.Set; +import net.fabricmc.fabric.api.client.screen.v1.ScreenEvents; +import net.fabricmc.fabric.api.client.screen.v1.ScreenMouseEvents; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.client.gui.components.events.GuiEventListener; +import net.minecraft.client.gui.screens.ConfirmScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.ItemStack; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.config.BridgeConfig; +import tel.eden.mod.war.eco.TerritoryContainerScanner; +import tel.eden.mod.war.eco.TerritoryGuideController; +import tel.eden.mod.war.eco.TerritoryPreset; +import tel.eden.mod.war.eco.TerritoryPresetCodec; + +/** + * Top Preset HUD bar, Preset Storage Dropdown, and in-menu guide highlights on Wynntils' TerritoryManagementScreen. + */ +public final class TerritoryMenuPanel { + private TerritoryMenuPanel() { + } + + private static boolean dropdownOpen = false; + + public static boolean isDropdownOpen() { + return dropdownOpen; + } + + public static void closeDropdown() { + dropdownOpen = false; + } + + public static void init() { + ScreenEvents.AFTER_INIT.register((client, screen, scaledWidth, scaledHeight) -> { + if (screen == null) { + return; + } + String name = screen.getClass().getName(); + if (name.endsWith("TerritoryManagementScreen")) { + dropdownOpen = false; + ScreenEvents.afterRender(screen).register((scr, graphics, mouseX, mouseY, tickDelta) -> { + renderWynntilsOverlay(scr, graphics, mouseX, mouseY); + }); + ScreenMouseEvents.allowMouseClick(screen).register((scr, event) -> { + return handleWynntilsMouseClick(scr, event.x(), event.y(), event.button()); + }); + } + }); + } + + private static Field holderField = null; + private static boolean holderFieldInit = false; + private static Field holderSelectedField = null; + private static boolean holderSelectedFieldInit = false; + + private static Field renderAreaWidgetsField = null; + private static boolean renderAreaWidgetsFieldInit = false; + + private static Field mapModeField = null; + private static Field territoryPoisField = null; + private static Field mapButtonsField = null; + private static Field mapCenterXField = null; + private static Field mapCenterZField = null; + private static Field centerXField = null; + private static Field centerZField = null; + private static Field zoomRenderScaleField = null; + private static Field mapRenderXField = null; + private static Field mapRenderYField = null; + private static Field mapRenderWidthField = null; + private static Field mapRenderHeightField = null; + private static boolean mapFieldsInit = false; + + private static Method poiGetNameMethod = null; + private static Method poiGetLocationMethod = null; + private static Method locGetXMethod = null; + private static Method locGetZMethod = null; + private static boolean poiMethodsInit = false; + + private static Field widgetTerritoryItemField = null; + private static Method tiGetNameMethod = null; + private static boolean widgetFieldInit = false; + + private static Method holderTerritoryItemsMethod = null; + private static Method pairAMethod = null; + private static Method pairBMethod = null; + private static Method tiIsSelectedMethod = null; + private static Method tiIsPendingMethod = null; + private static Method holderIsSelectionModeMethod = null; + private static boolean holderMethodsInit = false; + + private static Object getHolder(Screen screen) { + if (!holderFieldInit) { + holderFieldInit = true; + try { + holderField = screen.getClass().getDeclaredField("holder"); + holderField.setAccessible(true); + } catch (Throwable ignored) { + } + } + if (holderField != null) { + try { + return holderField.get(screen); + } catch (Throwable ignored) { + } + } + return null; + } + + private static List getRenderAreaWidgets(Screen screen) { + if (!renderAreaWidgetsFieldInit) { + renderAreaWidgetsFieldInit = true; + try { + renderAreaWidgetsField = screen.getClass().getDeclaredField("renderAreaWidgets"); + renderAreaWidgetsField.setAccessible(true); + } catch (Throwable ignored) { + } + } + if (renderAreaWidgetsField != null) { + try { + return (List) renderAreaWidgetsField.get(screen); + } catch (Throwable ignored) { + } + } + return null; + } + + private static void ensureMapFields(Screen screen) { + if (mapFieldsInit || screen == null) { + return; + } + mapFieldsInit = true; + try { + Class cls = screen.getClass(); + Class mapScreenClass = cls.getSuperclass(); + mapModeField = cls.getDeclaredField("mapMode"); + mapModeField.setAccessible(true); + territoryPoisField = cls.getDeclaredField("territoryPois"); + territoryPoisField.setAccessible(true); + + if (mapScreenClass != null) { + mapButtonsField = mapScreenClass.getDeclaredField("mapButtons"); + mapButtonsField.setAccessible(true); + centerXField = mapScreenClass.getDeclaredField("centerX"); + centerXField.setAccessible(true); + centerZField = mapScreenClass.getDeclaredField("centerZ"); + centerZField.setAccessible(true); + mapCenterXField = mapScreenClass.getDeclaredField("mapCenterX"); + mapCenterXField.setAccessible(true); + mapCenterZField = mapScreenClass.getDeclaredField("mapCenterZ"); + mapCenterZField.setAccessible(true); + zoomRenderScaleField = mapScreenClass.getDeclaredField("zoomRenderScale"); + zoomRenderScaleField.setAccessible(true); + mapRenderXField = mapScreenClass.getDeclaredField("renderX"); + mapRenderXField.setAccessible(true); + mapRenderYField = mapScreenClass.getDeclaredField("renderY"); + mapRenderYField.setAccessible(true); + mapRenderWidthField = mapScreenClass.getDeclaredField("renderWidth"); + mapRenderWidthField.setAccessible(true); + mapRenderHeightField = mapScreenClass.getDeclaredField("renderHeight"); + mapRenderHeightField.setAccessible(true); + } + } catch (Throwable ignored) { + } + } + + private static boolean isMapMode(Screen screen) { + ensureMapFields(screen); + if (mapModeField != null) { + try { + return (Boolean) mapModeField.get(screen); + } catch (Throwable ignored) { + } + } + return false; + } + + private static boolean matchesTarget(List targets, String label) { + if (label == null || label.isBlank() || targets == null) { + return false; + } + String cleanLabel = TerritoryGuideController.cleanName(label); + for (String target : targets) { + if (cleanLabel.equalsIgnoreCase(TerritoryGuideController.cleanName(target))) { + return true; + } + } + return false; + } + + private static void highlightMapMode(Screen screen, GuiGraphics graphics, List targets) { + try { + ensureMapFields(screen); + if (territoryPoisField == null || centerXField == null) + return; + List pois = (List) territoryPoisField.get(screen); + if (pois == null || pois.isEmpty()) + return; + + float cX = centerXField.getFloat(screen); + float cZ = centerZField.getFloat(screen); + float mX = mapCenterXField.getFloat(screen); + float mZ = mapCenterZField.getFloat(screen); + float zoom = zoomRenderScaleField.getFloat(screen); + float rX = mapRenderXField.getFloat(screen); + float rY = mapRenderYField.getFloat(screen); + float rW = mapRenderWidthField.getFloat(screen); + float rH = mapRenderHeightField.getFloat(screen); + + if (!poiMethodsInit && !pois.isEmpty()) { + poiMethodsInit = true; + try { + Object samplePoi = pois.iterator().next(); + poiGetNameMethod = samplePoi.getClass().getMethod("getName"); + poiGetNameMethod.setAccessible(true); + poiGetLocationMethod = samplePoi.getClass().getMethod("getLocation"); + poiGetLocationMethod.setAccessible(true); + Object sampleLoc = poiGetLocationMethod.invoke(samplePoi); + if (sampleLoc != null) { + locGetXMethod = sampleLoc.getClass().getMethod("getX"); + locGetXMethod.setAccessible(true); + locGetZMethod = sampleLoc.getClass().getMethod("getZ"); + locGetZMethod.setAccessible(true); + } + } catch (Throwable ignored) { + } + } + + if (poiGetNameMethod != null && poiGetLocationMethod != null && locGetXMethod != null && locGetZMethod != null) { + for (Object poiObj : pois) { + String name = (String) poiGetNameMethod.invoke(poiObj); + if (matchesTarget(targets, name)) { + Object loc = poiGetLocationMethod.invoke(poiObj); + if (loc != null) { + int worldX = (Integer) locGetXMethod.invoke(loc); + int worldZ = (Integer) locGetZMethod.invoke(loc); + float px = cX + (worldX - mX) * zoom; + float pz = cZ + (worldZ - mZ) * zoom; + + if (px >= rX && px <= rX + rW && pz >= rY && pz <= rY + rH) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, Math.round(px) - 8, Math.round(pz) - 8, 16, 16); + } + } + } + } + } + } catch (Throwable ignored) { + } + } + + private static void highlightWidgets(Screen screen, GuiGraphics graphics, TerritoryGuideController.GuideStep step) { + if (step == null || step.action() == TerritoryGuideController.ActionType.NONE || step.action() == TerritoryGuideController.ActionType.ALL_COMPLETE) { + return; + } + + boolean mapMode = isMapMode(screen); + int renderX = (screen.width - 256) / 2; + int renderY = (screen.height - 136) / 2; + + // Highlight Territory Loadouts button or Apply button + if (step.action() == TerritoryGuideController.ActionType.CLICK_LOADOUTS_BUTTON || step.action() == TerritoryGuideController.ActionType.DOUBLE_CLICK_APPLY_BUTTON) { + if (mapMode) { + try { + List mapButtons = (mapButtonsField != null) ? (List) mapButtonsField.get(screen) : null; + AbstractWidget btn = null; + if (mapButtons != null) { + for (Object mb : mapButtons) { + if (mb instanceof AbstractWidget w) { + String wName = w.getClass().getSimpleName(); + if (wName.contains("Apply") || wName.contains("Checkmark")) { + btn = w; + break; + } + } + } + if (btn == null && mapButtons.size() > 3 && mapButtons.get(3) instanceof AbstractWidget w3) { + btn = w3; + } + } + if (btn != null) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, btn.getX(), btn.getY(), btn.getWidth(), btn.getHeight()); + if (step.action() == TerritoryGuideController.ActionType.DOUBLE_CLICK_APPLY_BUTTON) { + Minecraft mc = Minecraft.getInstance(); + if (mc.font != null) { + String msg = "§aConfirm Selection ✔"; + int tx = btn.getX() + (btn.getWidth() - mc.font.width(msg)) / 2; + graphics.drawString(mc.font, msg, tx, btn.getY() - 12, 0xFF55FF55); + } + } + } + } catch (Throwable ignored) { + } + } else { + AbstractWidget btn = null; + for (GuiEventListener child : screen.children()) { + if (child instanceof AbstractWidget w) { + String wName = w.getClass().getSimpleName(); + if (wName.contains("Apply") || wName.contains("Loadout")) { + btn = w; + break; + } + } + } + int btnX = (btn != null) ? btn.getX() : (renderX - 20); + int btnY = (btn != null) ? btn.getY() : (renderY + 115); + int btnW = (btn != null) ? btn.getWidth() : 16; + int btnH = (btn != null) ? btn.getHeight() : 16; + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, btnX, btnY, btnW, btnH); + if (step.action() == TerritoryGuideController.ActionType.DOUBLE_CLICK_APPLY_BUTTON) { + Minecraft mc = Minecraft.getInstance(); + if (mc.font != null) { + graphics.drawString(mc.font, "§aConfirm Selection ✔", btnX - 110, btnY + 4, 0xFF55FF55); + } + } + } + } + + // Highlight matching territory widgets or map POIs on the screen + if (step.action() == TerritoryGuideController.ActionType.SELECT_TERRITORIES || step.action() == TerritoryGuideController.ActionType.OPEN_TERRITORY) { + List targets = step.targetTerritories(); + if (targets != null && !targets.isEmpty()) { + if (mapMode) { + highlightMapMode(screen, graphics, targets); + } else { + List areaWidgets = getRenderAreaWidgets(screen); + if (areaWidgets != null && !areaWidgets.isEmpty()) { + int areaY = renderY + 16; + int areaH = 110; + for (Object obj : areaWidgets) { + if (obj instanceof AbstractWidget widget) { + String label = ChatFormatting.stripFormatting(widget.getMessage().getString()).trim(); + if (label.isEmpty()) { + try { + if (!widgetFieldInit) { + widgetFieldInit = true; + widgetTerritoryItemField = widget.getClass().getDeclaredField("territoryItem"); + widgetTerritoryItemField.setAccessible(true); + } + if (widgetTerritoryItemField != null) { + Object ti = widgetTerritoryItemField.get(widget); + if (ti != null) { + if (tiGetNameMethod == null) { + tiGetNameMethod = ti.getClass().getMethod("getName"); + tiGetNameMethod.setAccessible(true); + } + label = (String) tiGetNameMethod.invoke(ti); + } + } + } catch (Throwable ignored) { + } + } + if (matchesTarget(targets, label)) { + int wx = widget.getX(); + int wy = widget.getY(); + if (wy + widget.getHeight() >= areaY && wy <= areaY + areaH) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, wx, wy, widget.getWidth(), widget.getHeight()); + } + } + } + } + } + + for (GuiEventListener child : screen.children()) { + if (child instanceof AbstractWidget widget) { + String label = ChatFormatting.stripFormatting(widget.getMessage().getString()).trim(); + if (matchesTarget(targets, label)) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, widget.getX(), widget.getY(), widget.getWidth(), widget.getHeight()); + } + } + } + } + } + } + } + + @SuppressWarnings("unchecked") + private static Map readLiveLoadouts(Object holder, Set liveSelectedOut) { + Map loadouts = new HashMap<>(); + if (holder == null) { + return loadouts; + } + try { + Set holderSelected = null; + if (!holderSelectedFieldInit) { + holderSelectedFieldInit = true; + try { + holderSelectedField = holder.getClass().getDeclaredField("selectedTerritories"); + holderSelectedField.setAccessible(true); + } catch (Throwable ignored) { + } + } + if (holderSelectedField != null) { + try { + holderSelected = (Set) holderSelectedField.get(holder); + } catch (Throwable ignored) { + } + } + if (holderSelected != null && liveSelectedOut != null) { + liveSelectedOut.addAll(holderSelected); + } + + if (!holderMethodsInit) { + holderMethodsInit = true; + try { + holderTerritoryItemsMethod = holder.getClass().getMethod("territoryItems"); + holderTerritoryItemsMethod.setAccessible(true); + holderIsSelectionModeMethod = holder.getClass().getMethod("isSelectionMode"); + holderIsSelectionModeMethod.setAccessible(true); + } catch (Throwable ignored) { + } + } + + if (holderTerritoryItemsMethod != null) { + Collection items = (Collection) holderTerritoryItemsMethod.invoke(holder); + if (items != null) { + for (Object pair : items) { + if (pairAMethod == null) { + pairAMethod = pair.getClass().getMethod("a"); + pairAMethod.setAccessible(true); + pairBMethod = pair.getClass().getMethod("b"); + pairBMethod.setAccessible(true); + } + ItemStack stack = (ItemStack) pairAMethod.invoke(pair); + Object territoryItem = pairBMethod.invoke(pair); + if (territoryItem != null) { + if (tiGetNameMethod == null) { + tiGetNameMethod = territoryItem.getClass().getMethod("getName"); + tiGetNameMethod.setAccessible(true); + } + if (tiIsSelectedMethod == null) { + tiIsSelectedMethod = territoryItem.getClass().getMethod("isSelected"); + tiIsSelectedMethod.setAccessible(true); + } + if (tiIsPendingMethod == null) { + try { + tiIsPendingMethod = territoryItem.getClass().getMethod("isPending"); + tiIsPendingMethod.setAccessible(true); + } catch (Throwable ignored) { + } + } + String name = (String) tiGetNameMethod.invoke(territoryItem); + boolean selected = (Boolean) tiIsSelectedMethod.invoke(territoryItem); + boolean pending = false; + if (tiIsPendingMethod != null) { + try { + pending = (Boolean) tiIsPendingMethod.invoke(territoryItem); + } catch (Throwable ignored) { + } + } + if (name != null) { + String clean = TerritoryGuideController.canonicalTerritoryName(name); + if (!clean.isEmpty()) { + loadouts.put(clean, TerritoryContainerScanner.parseLoadout(stack)); + if (liveSelectedOut != null && ((selected && !pending) || (holderSelected != null && (holderSelected.contains(name) || holderSelected.contains(clean))))) { + liveSelectedOut.add(clean); + } + } + } + } + } + } + } + } catch (Throwable ignored) { + } + return loadouts; + } + + private static boolean isSelectionMode(Object holder) { + if (holder == null) { + return false; + } + try { + if (holderIsSelectionModeMethod == null) { + holderIsSelectionModeMethod = holder.getClass().getMethod("isSelectionMode"); + holderIsSelectionModeMethod.setAccessible(true); + } + return (Boolean) holderIsSelectionModeMethod.invoke(holder); + } catch (Throwable ignored) { + return false; + } + } + + public static void renderHudBar(Screen screen, GuiGraphics graphics, int mouseX, int mouseY, TerritoryGuideController.GuideStep step) { + Minecraft mc = Minecraft.getInstance(); + Font font = mc.font; + if (font == null || screen == null) { + return; + } + + // Top HUD Bar Dimensions + int barWidth = Math.min(screen.width - 20, 640); + int barHeight = 24; + int barX = (screen.width - barWidth) / 2; + int barY = 4; + + // Background & Border + graphics.fill(barX, barY, barX + barWidth, barY + barHeight, 0xF012161E); + graphics.renderOutline(barX, barY, barWidth, barHeight, 0xFF3E4A5B); + + // 1. [◀] Prev Preset + int btnPrevX = barX + 4; + int btnPrevY = barY + 3; + int btnPrevW = 14; + int btnPrevH = 18; + renderButton(graphics, font, btnPrevX, btnPrevY, btnPrevW, btnPrevH, "<", mouseX, mouseY); + + // 2. [Preset: Name ▾] Preset Selector + int btnSelX = btnPrevX + btnPrevW + 2; + int btnSelY = barY + 3; + int btnSelW = 114; + int btnSelH = 18; + String activeName = TerritoryGuideController.hasActivePreset() ? TerritoryGuideController.activePreset().name() : "None"; + String arrow = dropdownOpen ? "\u25B2" : "\u25BC"; + String displayPresetName = font.plainSubstrByWidth(activeName, btnSelW - 18) + " " + arrow; + renderButton(graphics, font, btnSelX, btnSelY, btnSelW, btnSelH, displayPresetName, mouseX, mouseY); + + // 3. [▶] Next Preset + int btnNextX = btnSelX + btnSelW + 2; + int btnNextY = barY + 3; + int btnNextW = 14; + int btnNextH = 18; + renderButton(graphics, font, btnNextX, btnNextY, btnNextW, btnNextH, ">", mouseX, mouseY); + + // 4. [Copy] + int btnCopyX = btnNextX + btnNextW + 4; + int btnCopyY = barY + 3; + int btnCopyW = 40; + int btnCopyH = 18; + renderButton(graphics, font, btnCopyX, btnCopyY, btnCopyW, btnCopyH, "Copy", mouseX, mouseY); + + // 5. [Paste] + int btnPasteX = btnCopyX + btnCopyW + 3; + int btnPasteY = barY + 3; + int btnPasteW = 40; + int btnPasteH = 18; + renderButton(graphics, font, btnPasteX, btnPasteY, btnPasteW, btnPasteH, "Paste", mouseX, mouseY); + + // 6. [Rename] + int btnRenameX = btnPasteX + btnPasteW + 3; + int btnRenameY = barY + 3; + int btnRenameW = 46; + int btnRenameH = 18; + renderButton(graphics, font, btnRenameX, btnRenameY, btnRenameW, btnRenameH, "Rename", mouseX, mouseY); + + // 7. [Unselect] + int btnUnselX = btnRenameX + btnRenameW + 3; + int btnUnselY = barY + 3; + int btnUnselW = 48; + int btnUnselH = 18; + renderButton(graphics, font, btnUnselX, btnUnselY, btnUnselW, btnUnselH, "Unselect", mouseX, mouseY); + + // 8. [Skip] (visible during all active instructions) + boolean showSkip = TerritoryGuideController.hasActivePreset() && (step != null && step.action() != TerritoryGuideController.ActionType.NONE && step.action() != TerritoryGuideController.ActionType.ALL_COMPLETE); + int btnSkipX = btnUnselX + btnUnselW + 3; + int btnSkipY = barY + 3; + int btnSkipW = 34; + int btnSkipH = 18; + if (showSkip) { + renderButton(graphics, font, btnSkipX, btnSkipY, btnSkipW, btnSkipH, "Skip", mouseX, mouseY); + } + + // Instruction label + int textX = showSkip ? (btnSkipX + btnSkipW + 6) : (btnUnselX + btnUnselW + 6); + int textMaxW = (barX + barWidth - 4) - textX; + String instruction = (TerritoryGuideController.hasActivePreset() && step != null) ? step.instruction() : "§7No preset active. Select or paste a preset."; + if (font.width(instruction) > textMaxW) { + instruction = font.plainSubstrByWidth(instruction, textMaxW - 10) + "..."; + } + graphics.drawString(font, instruction, textX, barY + 8, 0xFFFFFFFF); + + // Render Dropdown if open (on top of everything) + if (dropdownOpen) { + renderPresetDropdown(graphics, font, btnSelX, btnSelY + btnSelH + 2, mouseX, mouseY); + } + } + + private static void renderWynntilsOverlay(Screen screen, GuiGraphics graphics, int mouseX, int mouseY) { + BridgeConfig config = EdenModClient.instance().config(); + if (config == null || !config.territoryMenuSuite) { + return; + } + + Object holder = getHolder(screen); + Set liveSelected = new HashSet<>(); + Map currentLoadouts = readLiveLoadouts(holder, liveSelected); + boolean selectionMode = isSelectionMode(holder); + String title = screen.getTitle().getString(); + + TerritoryGuideController.GuideStep step = TerritoryGuideController.evaluate(currentLoadouts, liveSelected, title, selectionMode); + + renderHudBar(screen, graphics, mouseX, mouseY, step); + highlightWidgets(screen, graphics, step); + } + + private static void renderPresetDropdown(GuiGraphics graphics, Font font, int x, int y, int mouseX, int mouseY) { + Map presets = TerritoryGuideController.savedPresets(); + int count = presets.size(); + int dropW = 210; + int headerH = 18; + int rowH = 16; + int dropH = headerH + (1 + Math.max(1, count)) * rowH + 4; + + graphics.fill(x, y, x + dropW, y + dropH, 0xF510141D); + graphics.renderOutline(x, y, dropW, dropH, 0xFF3E4A5B); + + // Header + graphics.drawString(font, "§6Saved Presets (" + count + "):", x + 6, y + 5, 0xFFFFAA00); + + int curY = y + headerH; + String active = TerritoryGuideController.hasActivePreset() ? TerritoryGuideController.activePreset().name() : ""; + + // Row 0: None (Unselect) + boolean isNoneActive = !TerritoryGuideController.hasActivePreset(); + boolean noneHover = mouseX >= x + 4 && mouseX < x + dropW - 4 && mouseY >= curY && mouseY < curY + rowH; + if (noneHover) { + graphics.fill(x + 2, curY, x + dropW - 4, curY + rowH, 0xFF2A394D); + } + String noneBullet = isNoneActive ? "§a● " : "§7○ "; + graphics.drawString(font, noneBullet + "§7None §8(Unselect)", x + 6, curY + 4, isNoneActive ? 0xFF55FF55 : 0xFFCCCCCC); + curY += rowH; + + if (presets.isEmpty()) { + graphics.drawString(font, "§7No presets saved yet.", x + 8, curY + 4, 0xFFAAAAAA); + curY += rowH; + } else { + for (String pName : presets.keySet()) { + boolean isActive = pName.equalsIgnoreCase(active); + boolean rowHover = mouseX >= x + 4 && mouseX < x + dropW - 40 && mouseY >= curY && mouseY < curY + rowH; + boolean renHover = mouseX >= x + dropW - 38 && mouseX < x + dropW - 22 && mouseY >= curY && mouseY < curY + rowH; + boolean delHover = mouseX >= x + dropW - 20 && mouseX < x + dropW - 4 && mouseY >= curY && mouseY < curY + rowH; + + if (rowHover) { + graphics.fill(x + 2, curY, x + dropW - 40, curY + rowH, 0xFF2A394D); + } + + String bullet = isActive ? "§a● " : "§7○ "; + String label = bullet + font.plainSubstrByWidth(pName, dropW - 54); + graphics.drawString(font, label, x + 6, curY + 4, isActive ? 0xFF55FF55 : 0xFFDDDDDD); + + // Rename button [R] + graphics.drawString(font, renHover ? "§e[R]" : "§8[r]", x + dropW - 38, curY + 4, renHover ? 0xFFFFAA00 : 0xFF888888); + + // Delete button [x] + graphics.drawString(font, delHover ? "§c[X]" : "§8[x]", x + dropW - 20, curY + 4, delHover ? 0xFFFF5555 : 0xFF888888); + + curY += rowH; + } + } + } + + private static void renderButton(GuiGraphics graphics, Font font, int x, int y, int w, int h, String text, int mouseX, int mouseY) { + boolean hovered = mouseX >= x && mouseX < x + w && mouseY >= y && mouseY < y + h; + int bg = hovered ? 0xFF2A394D : 0xFF18202A; + int border = hovered ? 0xFF55FFFF : 0xFF3E4A5B; + graphics.fill(x, y, x + w, y + h, bg); + graphics.renderOutline(x, y, w, h, border); + int textX = x + (w - font.width(text)) / 2; + int textY = y + (h - 8) / 2; + graphics.drawString(font, text, textX, textY, hovered ? 0xFF55FFFF : 0xFFDDDDDD); + } + + public static boolean handleMouseClick(Screen screen, double mouseX, double mouseY, int button, Map fallbackLoadouts) { + BridgeConfig config = EdenModClient.instance().config(); + if (config == null || !config.territoryMenuSuite || (button != 0 && button != 1)) { + return true; + } + + int barWidth = Math.min(screen.width - 20, 640); + int barX = (screen.width - barWidth) / 2; + int barY = 4; + + int btnPrevX = barX + 4; + int btnPrevY = barY + 3; + int btnPrevW = 14; + int btnPrevH = 18; + + int btnSelX = btnPrevX + btnPrevW + 2; + int btnSelY = barY + 3; + int btnSelW = 114; + int btnSelH = 18; + + int btnNextX = btnSelX + btnSelW + 2; + int btnNextY = barY + 3; + int btnNextW = 14; + int btnNextH = 18; + + int btnCopyX = btnNextX + btnNextW + 4; + int btnCopyY = barY + 3; + int btnCopyW = 40; + int btnCopyH = 18; + + int btnPasteX = btnCopyX + btnCopyW + 3; + int btnPasteY = barY + 3; + int btnPasteW = 40; + int btnPasteH = 18; + + int btnRenameX = btnPasteX + btnPasteW + 3; + int btnRenameY = barY + 3; + int btnRenameW = 46; + int btnRenameH = 18; + + int btnUnselX = btnRenameX + btnRenameW + 3; + int btnUnselY = barY + 3; + int btnUnselW = 48; + int btnUnselH = 18; + + int btnSkipX = btnUnselX + btnUnselW + 3; + int btnSkipY = barY + 3; + int btnSkipW = 34; + int btnSkipH = 18; + + Minecraft mc = Minecraft.getInstance(); + + // Right click on preset selector -> Rename active preset + if (button == 1 && mouseX >= btnSelX && mouseX < btnSelX + btnSelW && mouseY >= btnSelY && mouseY < btnSelY + btnSelH) { + if (TerritoryGuideController.hasActivePreset()) { + dropdownOpen = false; + mc.setScreen(new RenamePresetScreen(screen, TerritoryGuideController.activePreset().name())); + return false; + } + } + + // Handle Dropdown Clicks if open + if (dropdownOpen) { + Map presets = TerritoryGuideController.savedPresets(); + int count = presets.size(); + int dropW = 210; + int headerH = 18; + int rowH = 16; + int dropH = headerH + (1 + Math.max(1, count)) * rowH + 4; + int dropX = btnSelX; + int dropY = btnSelY + btnSelH + 2; + + if (mouseX >= dropX && mouseX < dropX + dropW && mouseY >= dropY && mouseY < dropY + dropH) { + int curY = dropY + headerH; + // Row 0: None (Unselect) + if (mouseX >= dropX + 4 && mouseX < dropX + dropW - 4 && mouseY >= curY && mouseY < curY + rowH) { + TerritoryGuideController.unselectPreset(); + dropdownOpen = false; + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§e[Eden] Unselected territory preset.")); + } + return false; + } + curY += rowH; + + for (String pName : new ArrayList<>(presets.keySet())) { + // Rename button [R] + if (mouseX >= dropX + dropW - 38 && mouseX < dropX + dropW - 22 && mouseY >= curY && mouseY < curY + rowH) { + dropdownOpen = false; + mc.setScreen(new RenamePresetScreen(screen, pName)); + return false; + } + // Delete button [X] + if (mouseX >= dropX + dropW - 20 && mouseX < dropX + dropW && mouseY >= curY && mouseY < curY + rowH) { + dropdownOpen = false; + mc.setScreen(new ConfirmScreen(confirmed -> { + if (confirmed) { + TerritoryGuideController.deleteSavedPreset(pName); + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§e[Eden] Deleted preset: " + pName)); + } + } + mc.setScreen(screen); + }, Component.literal("Delete Territory Preset"), Component.literal("Are you sure you want to delete preset '§e" + pName + "§r'?"))); + return false; + } + // Row select + if (mouseX >= dropX + 4 && mouseX < dropX + dropW - 40 && mouseY >= curY && mouseY < curY + rowH) { + TerritoryGuideController.selectSavedPreset(pName); + dropdownOpen = false; + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§a[Eden] Selected preset: " + pName)); + } + return false; + } + curY += rowH; + } + return false; + } else { + // Clicked outside dropdown + dropdownOpen = false; + return false; + } + } + + // Handle [◀] Prev Preset + if (mouseX >= btnPrevX && mouseX < btnPrevX + btnPrevW && mouseY >= btnPrevY && mouseY < btnPrevY + btnPrevH) { + String next = TerritoryGuideController.cycleSavedPreset(-1); + if (next != null && mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§a[Eden] Preset: " + next)); + } + return false; + } + + // Handle [Preset: Name ▾] Toggle Dropdown + if (mouseX >= btnSelX && mouseX < btnSelX + btnSelW && mouseY >= btnSelY && mouseY < btnSelY + btnSelH) { + dropdownOpen = !dropdownOpen; + return false; + } + + // Handle [▶] Next Preset + if (mouseX >= btnNextX && mouseX < btnNextX + btnNextW && mouseY >= btnNextY && mouseY < btnNextY + btnNextH) { + String next = TerritoryGuideController.cycleSavedPreset(1); + if (next != null && mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§a[Eden] Preset: " + next)); + } + return false; + } + + // Handle [Copy Preset] + if (mouseX >= btnCopyX && mouseX < btnCopyX + btnCopyW && mouseY >= btnCopyY && mouseY < btnCopyY + btnCopyH) { + Object holder = getHolder(screen); + Map currentLoadouts = new java.util.TreeMap<>(String.CASE_INSENSITIVE_ORDER); + currentLoadouts.putAll(TerritoryGuideController.sessionLoadouts()); + if (fallbackLoadouts != null) { + currentLoadouts.putAll(fallbackLoadouts); + } + if (holder != null) { + currentLoadouts.putAll(readLiveLoadouts(holder, null)); + } + if (currentLoadouts.isEmpty()) { + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§c[Eden] No territories loaded yet. Wait for menu to load.")); + } + } else { + Map cleanMap = new LinkedHashMap<>(); + for (Map.Entry entry : currentLoadouts.entrySet()) { + String clean = TerritoryGuideController.canonicalTerritoryName(entry.getKey()); + if (!clean.isEmpty()) { + cleanMap.put(clean, entry.getValue()); + } + } + String stamp = new SimpleDateFormat("MMM d HH:mm", Locale.US).format(new Date()); + String pName = TerritoryGuideController.getUniquePresetName(TerritoryGuideController.savedPresets(), "Claim " + stamp); + TerritoryPreset preset = new TerritoryPreset(pName, "", Set.of(), cleanMap); + String code = TerritoryPresetCodec.encodeBase64(preset); + mc.keyboardHandler.setClipboard(code); + TerritoryGuideController.savePresetToStorage(pName, code); + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§a[Eden] Copied preset to clipboard & saved as '" + pName + "' (" + cleanMap.size() + " territories)!")); + } + } + return false; + } + + // Handle [Paste Preset] + if (mouseX >= btnPasteX && mouseX < btnPasteX + btnPasteW && mouseY >= btnPasteY && mouseY < btnPasteY + btnPasteH) { + String clipboard = mc.keyboardHandler.getClipboard(); + if (clipboard != null && clipboard.length() > TerritoryPresetCodec.MAX_INPUT_CHARS) { + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§c[Eden] Preset code in clipboard is too large (max 64 KB).")); + } + return false; + } + try { + TerritoryPreset preset = TerritoryPresetCodec.parse(clipboard); + if (preset != null && !preset.territories().isEmpty()) { + TerritoryGuideController.savePresetToStorage(preset.name(), clipboard); + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§a[Eden] Imported preset: " + preset.name() + " (" + preset.territories().size() + " territories)!")); + } + } else { + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§c[Eden] Preset code contains no territories.")); + } + } + } catch (Exception e) { + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§c[Eden] Invalid preset in clipboard: " + e.getMessage())); + } + } + return false; + } + + // Handle [Rename] + if (mouseX >= btnRenameX && mouseX < btnRenameX + btnRenameW && mouseY >= btnRenameY && mouseY < btnRenameY + btnRenameH) { + if (TerritoryGuideController.hasActivePreset()) { + dropdownOpen = false; + mc.setScreen(new RenamePresetScreen(screen, TerritoryGuideController.activePreset().name())); + } else { + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§c[Eden] No active preset to rename.")); + } + } + return false; + } + + // Handle [Unselect] + if (mouseX >= btnUnselX && mouseX < btnUnselX + btnUnselW && mouseY >= btnUnselY && mouseY < btnUnselY + btnUnselH) { + TerritoryGuideController.unselectPreset(); + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§e[Eden] Unselected territory preset.")); + } + return false; + } + + // Handle [Skip] + if (mouseX >= btnSkipX && mouseX < btnSkipX + btnSkipW && mouseY >= btnSkipY && mouseY < btnSkipY + btnSkipH) { + if (button == 0) { + TerritoryGuideController.skipForward(); + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§e[Eden] Skipped current instruction.")); + } + } else if (button == 1) { + TerritoryGuideController.skipBackwards(); + if (mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§e[Eden] Returned to previous instruction.")); + } + } + return false; + } + + return true; + } + + private static boolean handleWynntilsMouseClick(Screen screen, double mouseX, double mouseY, int button) { + return handleMouseClick(screen, mouseX, mouseY, button, null); + } +} diff --git a/src/tel/eden/mod/gui/TerritoryUpgradeGuideOverlay.java b/src/tel/eden/mod/gui/TerritoryUpgradeGuideOverlay.java new file mode 100644 index 0000000..4830f13 --- /dev/null +++ b/src/tel/eden/mod/gui/TerritoryUpgradeGuideOverlay.java @@ -0,0 +1,159 @@ +package tel.eden.mod.gui; + +import java.util.HashSet; +import java.util.Set; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.renderer.RenderPipelines; +import net.minecraft.resources.Identifier; +import net.minecraft.world.inventory.Slot; + +/** + * Renders visual guide overlays: pulsating circular target rings and clicks-remaining badges. + */ +public final class TerritoryUpgradeGuideOverlay { + private TerritoryUpgradeGuideOverlay() { + } + + private static final Identifier TARGET_CIRCLE = Identifier.fromNamespaceAndPath("edenmod", "textures/gui/target_circle.png"); + + public static void renderTargetPulse(GuiGraphics graphics, int slotX, int slotY) { + renderTargetPulse(graphics, slotX, slotY, 16, 16); + } + + public static void renderTargetPulse(GuiGraphics graphics, int x, int y, int width, int height) { + int cx = x + width / 2; + int cy = y + height / 2; + + // Gentle breathing pulsation + float sin = (float) Math.sin(System.currentTimeMillis() / 180.0); + int baseSize = Math.max(width, height) + 14; + int size = Math.round(baseSize + 3 * sin); + + int drawX = cx - size / 2; + int drawY = cy - size / 2; + + try { + graphics.blit(RenderPipelines.GUI_TEXTURED, TARGET_CIRCLE, drawX, drawY, 0.0f, 0.0f, size, size, 100, 100, 100, 100); + } catch (Throwable t) { + int alpha = (int) (160 + 95 * sin); + int color = (alpha << 24) | 0x00FF44; + graphics.renderOutline(x - 1, y - 1, width + 2, height + 2, color); + } + } + + public static void renderClickBadge(GuiGraphics graphics, Font font, int slotX, int slotY, int diff, boolean matches) { + if (matches || diff == 0) { + // Checkmark badge in slot corner + graphics.drawString(font, "§a✓", slotX + 10, slotY - 2, 0xFF55FF55); + } else { + String badge = (diff > 0) ? ("+" + diff) : String.valueOf(diff); + int color = (diff > 0) ? 0xFFFFAA00 : 0xFFFF5555; + String prefix = (diff > 0) ? "§6" : "§c"; + int bw = font.width(badge) + 4; + int bx = slotX + 16 - bw; + int by = slotY; + + graphics.fill(bx, by, bx + bw, by + 9, 0xEE111111); + graphics.renderOutline(bx, by, bw, 9, color); + graphics.drawString(font, prefix + badge, bx + 2, by + 1, color); + } + } + + public static int[] renderUpgradeGuideBanner(GuiGraphics graphics, Font font, int guiLeft, int guiTop, int xSize, String territory, tel.eden.mod.war.eco.TerritoryPreset preset, tel.eden.mod.war.eco.TerritoryPreset.TerritoryLoadout target, java.util.List upgrades) { + if (territory == null || preset == null) { + return null; + } + + boolean isBonusView = false; + if (upgrades != null) { + for (tel.eden.mod.war.eco.TerritoryContainerScanner.UpgradeSlotInfo up : upgrades) { + if (!up.upgradeName().equals("Damage") && !up.upgradeName().equals("Attack") && !up.upgradeName().equals("Health") && !up.upgradeName().equals("Defence")) { + isBonusView = true; + break; + } + } + } + + int totalClicksNeeded = 0; + StringBuilder sb = new StringBuilder(); + if (upgrades != null && target != null) { + for (tel.eden.mod.war.eco.TerritoryContainerScanner.UpgradeSlotInfo up : upgrades) { + int targetLvl = switch (up.upgradeName()) { + case "Damage" -> target.damage(); + case "Attack" -> target.attack(); + case "Health" -> target.health(); + case "Defence" -> target.defence(); + default -> target.bonus(up.upgradeName()); + }; + int diff = targetLvl - up.currentLevel(); + if (diff != 0) { + totalClicksNeeded += Math.abs(diff); + if (sb.length() > 0) { + sb.append(", "); + } + String clickType = (diff > 0) ? "L-Click" : "R-Click"; + String sign = (diff > 0) ? ("+" + diff) : String.valueOf(diff); + sb.append(up.upgradeName()).append(" (").append(sign).append(" ").append(clickType).append(")"); + } + } + } + + String typeLabel = isBonusView ? "Bonuses" : "Upgrades"; + String header = "§b[Eden Guide] §f" + territory + " §8(" + typeLabel + " • Preset: §6" + preset.name() + "§8)"; + + Set seenUpgrades = new HashSet<>(); + if (upgrades != null) { + for (tel.eden.mod.war.eco.TerritoryContainerScanner.UpgradeSlotInfo up : upgrades) { + seenUpgrades.add(up.upgradeName()); + } + } + boolean allRequiredSeen; + if (isBonusView) { + boolean allPresetBonusesSeen = true; + if (target != null && target.bonuses() != null) { + for (java.util.Map.Entry b : target.bonuses().entrySet()) { + if (b.getValue() > 0 && !seenUpgrades.contains(b.getKey())) { + allPresetBonusesSeen = false; + break; + } + } + } + allRequiredSeen = allPresetBonusesSeen && !seenUpgrades.isEmpty(); + } else { + allRequiredSeen = seenUpgrades.contains("Damage") && seenUpgrades.contains("Attack") && seenUpgrades.contains("Health") && seenUpgrades.contains("Defence"); + } + + String status; + if (upgrades == null || upgrades.isEmpty() || !allRequiredSeen) { + status = "§eScanning " + typeLabel + "..."; + } else if (totalClicksNeeded > 0) { + status = "§eAdjust highlighted: " + sb.toString() + " §7(§6" + totalClicksNeeded + " clicks left§7)"; + } else { + status = "§a✔ " + typeLabel + " match preset! No further clicks needed."; + } + + int skipW = 34; + int skipH = 18; + int maxContentW = Math.max(font.width(header), font.width(status)); + int bannerWidth = Math.max(Math.max(xSize, 240), maxContentW + skipW + 24); + int bannerHeight = 28; + int bannerX = Math.max(4, guiLeft + (xSize - bannerWidth) / 2); + int bannerY = guiTop - bannerHeight - 4; + + graphics.fill(bannerX, bannerY, bannerX + bannerWidth, bannerY + bannerHeight, 0xF012161E); + graphics.renderOutline(bannerX, bannerY, bannerWidth, bannerHeight, 0xFF3E4A5B); + + graphics.drawString(font, header, bannerX + 8, bannerY + 4, 0xFFFFFFFF); + graphics.drawString(font, status, bannerX + 8, bannerY + 16, 0xFFFFFFFF); + + int skipX = bannerX + bannerWidth - skipW - 4; + int skipY = bannerY + (bannerHeight - skipH) / 2; + graphics.fill(skipX, skipY, skipX + skipW, skipY + skipH, 0xFF18202A); + graphics.renderOutline(skipX, skipY, skipW, skipH, 0xFFFFAA00); + int stx = skipX + (skipW - font.width("Skip")) / 2; + graphics.drawString(font, "§6Skip", stx, skipY + 5, 0xFFFFAA00); + + return new int[]{skipX, skipY, skipW, skipH}; + } +} diff --git a/src/tel/eden/mod/mixin/AbstractContainerScreenMixin.java b/src/tel/eden/mod/mixin/AbstractContainerScreenMixin.java index 81f084c..df83795 100644 --- a/src/tel/eden/mod/mixin/AbstractContainerScreenMixin.java +++ b/src/tel/eden/mod/mixin/AbstractContainerScreenMixin.java @@ -1,10 +1,15 @@ package tel.eden.mod.mixin; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; import net.minecraft.client.gui.screens.inventory.AbstractContainerScreen; +import net.minecraft.client.input.MouseButtonEvent; import net.minecraft.network.chat.Component; import net.minecraft.world.inventory.AbstractContainerMenu; import net.minecraft.world.inventory.ClickType; @@ -13,13 +18,19 @@ import org.jetbrains.annotations.Nullable; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.Unique; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import tel.eden.mod.EdenModClient; import tel.eden.mod.config.BridgeConfig; +import tel.eden.mod.gui.TerritoryMenuPanel; +import tel.eden.mod.gui.TerritoryUpgradeGuideOverlay; import tel.eden.mod.raid.RaidGambitSafety; +import tel.eden.mod.war.eco.TerritoryContainerScanner; +import tel.eden.mod.war.eco.TerritoryGuideController; +import tel.eden.mod.war.eco.TerritoryPreset; @Mixin(AbstractContainerScreen.class) public abstract class AbstractContainerScreenMixin { @@ -30,9 +41,245 @@ public abstract class AbstractContainerScreenMixin { @Shadow public abstract AbstractContainerMenu getMenu(); + @Shadow + protected int leftPos; + + @Shadow + protected int topPos; + + @Shadow + protected int imageWidth; + + @Shadow + protected int imageHeight; + + @Unique + private String edenmod$title() { + return net.minecraft.ChatFormatting.stripFormatting(((net.minecraft.client.gui.screens.Screen) (Object) this).getTitle().getString()).trim(); + } + + @Unique + private int edenmod$skipBtnX = -1; + @Unique + private int edenmod$skipBtnY = -1; + @Unique + private int edenmod$skipBtnW = 34; + @Unique + private int edenmod$skipBtnH = 16; + @Unique + private boolean edenmod$skipBtnVisible = false; + + @Unique + private void edenmod$renderBanner(GuiGraphics graphics, Font font, int left, int top, int width, String text) { + int skipW = 34; + int skipH = 16; + int textW = font.width(text); + int bw = Math.max(Math.max(width, 240), textW + skipW + 24); + int bx = Math.max(4, left + (width - bw) / 2); + int by = top - 24; + graphics.fill(bx, by, bx + bw, by + 20, 0xF012161E); + graphics.renderOutline(bx, by, bw, 20, 0xFF3E4A5B); + graphics.drawString(font, text, bx + 8, by + 6, 0xFFFFFFFF); + + edenmod$skipBtnX = bx + bw - skipW - 4; + edenmod$skipBtnY = by + 2; + edenmod$skipBtnW = skipW; + edenmod$skipBtnH = skipH; + edenmod$skipBtnVisible = true; + + graphics.fill(edenmod$skipBtnX, edenmod$skipBtnY, edenmod$skipBtnX + edenmod$skipBtnW, edenmod$skipBtnY + edenmod$skipBtnH, 0xFF18202A); + graphics.renderOutline(edenmod$skipBtnX, edenmod$skipBtnY, edenmod$skipBtnW, edenmod$skipBtnH, 0xFFFFAA00); + int sx = edenmod$skipBtnX + (edenmod$skipBtnW - font.width("Skip")) / 2; + graphics.drawString(font, "§6Skip", sx, edenmod$skipBtnY + 4, 0xFFFFAA00); + } + + @Unique + private List edenmod$cachedUpgrades = null; + @Unique + private boolean edenmod$upgradesDirty = true; + @Unique + private long edenmod$lastUpgradeScanTime = 0L; + @Unique + private int edenmod$lastMenuSlotCount = 0; + @Unique + private long edenmod$lastSlotClickTime = 0; + @Unique + private String edenmod$lastScannedTerritory = null; + @Unique + private String edenmod$lastScannedTitle = null; + + @Unique + private List edenmod$getUpgrades(AbstractContainerMenu menu, String territory) { + long now = System.currentTimeMillis(); + String currentTitle = edenmod$title(); + boolean sameContext = java.util.Objects.equals(territory, edenmod$lastScannedTerritory) && java.util.Objects.equals(currentTitle, edenmod$lastScannedTitle); + if (!sameContext) { + edenmod$cachedUpgrades = null; + } + if (edenmod$cachedUpgrades == null || edenmod$cachedUpgrades.isEmpty() || edenmod$upgradesDirty || now - edenmod$lastUpgradeScanTime >= 100L || menu.slots.size() != edenmod$lastMenuSlotCount) { + List scanned = TerritoryContainerScanner.scanUpgradeMenu(menu); + if (!scanned.isEmpty()) { + if (sameContext && edenmod$cachedUpgrades != null && scanned.size() < edenmod$cachedUpgrades.size()) { + java.util.Map merged = new java.util.LinkedHashMap<>(); + for (TerritoryContainerScanner.UpgradeSlotInfo oldUp : edenmod$cachedUpgrades) { + merged.put(oldUp.upgradeName(), oldUp); + } + for (TerritoryContainerScanner.UpgradeSlotInfo newUp : scanned) { + merged.put(newUp.upgradeName(), newUp); + } + edenmod$cachedUpgrades = new java.util.ArrayList<>(merged.values()); + } else { + edenmod$cachedUpgrades = scanned; + } + } + edenmod$upgradesDirty = false; + edenmod$lastUpgradeScanTime = now; + edenmod$lastMenuSlotCount = menu.slots.size(); + edenmod$lastScannedTerritory = territory; + edenmod$lastScannedTitle = currentTitle; + if (territory != null && edenmod$cachedUpgrades != null && !edenmod$cachedUpgrades.isEmpty()) { + TerritoryGuideController.recordScannedUpgrades(territory, edenmod$cachedUpgrades); + } + } + return edenmod$cachedUpgrades != null ? edenmod$cachedUpgrades : List.of(); + } + + @Unique + private TerritoryContainerScanner.ScanResult edenmod$cachedTerritoryScan = null; + @Unique + private long edenmod$lastTerritoryScanTime = 0L; + @Unique + private int edenmod$lastTerritoryMenuSlotCount = 0; + + @Unique + private TerritoryContainerScanner.ScanResult edenmod$getTerritoryScan(AbstractContainerMenu menu) { + long now = System.currentTimeMillis(); + if (edenmod$cachedTerritoryScan == null || edenmod$upgradesDirty || now - edenmod$lastTerritoryScanTime >= 150L || menu.slots.size() != edenmod$lastTerritoryMenuSlotCount) { + edenmod$cachedTerritoryScan = TerritoryContainerScanner.scanTerritoriesMenu(menu); + edenmod$lastTerritoryScanTime = now; + edenmod$lastTerritoryMenuSlotCount = menu.slots.size(); + } + return edenmod$cachedTerritoryScan; + } + + @Unique + private boolean edenmod$isTerritoryOverview(String title, AbstractContainerMenu menu) { + if (title == null || title.isBlank()) { + return false; + } + if (title.contains("Bonus") || title.contains("Bonuses") || title.contains("Guild Tower") || title.contains("Upgrades")) { + return false; + } + String clean = TerritoryContainerScanner.cleanTerritoryTitle(title); + if (!TerritoryGuideController.isKnownTerritory(clean)) { + return false; + } + Slot towerSlot = TerritoryContainerScanner.findSlotByName(menu, "guild tower"); + if (towerSlot == null) + towerSlot = TerritoryContainerScanner.findSlotByName(menu, "tower attack"); + + Slot bonusSlot = TerritoryContainerScanner.findSlotByName(menu, "bonus"); + if (bonusSlot == null) + bonusSlot = TerritoryContainerScanner.findSlotByName(menu, "bonuses"); + + return towerSlot != null && bonusSlot != null; + } + + @Unique + private AbstractContainerMenu edenmod$cachedCheckedMenu = null; + @Unique + private boolean edenmod$cachedIsEcoContainer = false; + @Unique + private long edenmod$lastEcoCheckTime = 0L; + @Unique + private int edenmod$lastEcoSlotCount = 0; + + @Unique + private boolean edenmod$checkAllowedEcoContainer(String title, AbstractContainerMenu menu) { + if (menu == null || title == null) { + return false; + } + if (menu == edenmod$cachedCheckedMenu && edenmod$cachedIsEcoContainer) { + return true; + } + long now = System.currentTimeMillis(); + if (menu == edenmod$cachedCheckedMenu && !edenmod$upgradesDirty && now - edenmod$lastEcoCheckTime < 150L && menu.slots.size() == edenmod$lastEcoSlotCount) { + return edenmod$cachedIsEcoContainer; + } + boolean allowed = edenmod$isAllowedEcoContainer(title, menu); + edenmod$cachedCheckedMenu = menu; + edenmod$cachedIsEcoContainer = allowed; + edenmod$lastEcoCheckTime = now; + edenmod$lastEcoSlotCount = menu.slots.size(); + return allowed; + } + + @Unique + private boolean edenmod$isAllowedEcoContainer(String title, AbstractContainerMenu menu) { + if (title == null || menu == null) + return false; + // 1. "Eden: Territories" + if (title.contains("Territories") || title.equalsIgnoreCase("Territories")) + return true; + // 2. "Eden's Territory Loadouts" + if (title.contains("Territory Loadouts") || title.equalsIgnoreCase("Loadouts")) + return true; + // 3. "Select Territories" + if (title.contains("Select Territories")) + return true; + // 4. "{Territory Name} Bonus" / "{Territory Name} Guild Tower" + if (title.contains("Bonus") || title.contains("Bonuses") || title.contains("Guild Tower") || title.contains("Upgrades")) { + String terr = TerritoryContainerScanner.parseTerritoryFromUpgradeTitle(title); + return terr != null && TerritoryGuideController.isKnownTerritory(terr) && TerritoryContainerScanner.isUpgradeOrBonusMenu(menu, title); + } + // 5. Territory Overview (e.g. "Mesquis Tower", has Guild Tower & Bonus buttons) + if (edenmod$isTerritoryOverview(title, menu)) + return true; + + return false; + } + + @Unique + private Slot edenmod$findBackSlot(AbstractContainerMenu menu) { + if (menu == null) + return null; + Slot slot = TerritoryContainerScanner.findSlotByName(menu, "back"); + if (slot != null) + return slot; + slot = TerritoryContainerScanner.findSlotByName(menu, "return"); + if (slot != null) + return slot; + slot = TerritoryContainerScanner.findSlotByName(menu, "cancel"); + if (slot != null) + return slot; + int containerSlots = menu.slots.size() - 36; + if (containerSlots == 54 && menu.slots.size() > 45 && menu.slots.get(45).hasItem()) { + return menu.slots.get(45); + } + if (menu.slots.size() > 9 && menu.slots.get(9).hasItem()) { + return menu.slots.get(9); + } + return null; + } + @Inject(method = "slotClicked(Lnet/minecraft/world/inventory/Slot;IILnet/minecraft/world/inventory/ClickType;)V", at = @At("HEAD"), cancellable = true) private void edenmod$onSlotClicked(Slot slot, int slotId, int mouseButton, ClickType clickType, CallbackInfo ci) { + edenmod$lastSlotClickTime = System.currentTimeMillis(); + edenmod$upgradesDirty = true; try { + if (slot != null && edenmod$title().contains("Select Territories")) { + if (TerritoryContainerScanner.isContainerSlot(getMenu(), slot)) { + Slot confirmSlot = (!getMenu().slots.isEmpty()) ? getMenu().slots.get(0) : null; + if (confirmSlot != null && slot == confirmSlot) { + TerritoryGuideController.commitPendingBatch(); + } else { + Slot backSlot = edenmod$findBackSlot(getMenu()); + if (backSlot != null && slot == backSlot) { + TerritoryGuideController.clearPendingBatch(); + } + } + } + } BridgeConfig config = EdenModClient.instance().config(); List reasons = RaidGambitSafety.getActiveBlockReasons(getMenu(), slot, clickType, config); if (!reasons.isEmpty()) { @@ -51,20 +298,324 @@ public abstract class AbstractContainerScreenMixin { if (mc != null && mc.font != null && mc.getWindow() != null) { RaidGambitSafety.renderPopupWarning(graphics, mc.font, mc.getWindow().getGuiScaledWidth()); } + + edenmod$skipBtnVisible = false; + BridgeConfig config = EdenModClient.instance().config(); + if (config != null && config.territoryMenuSuite && mc != null && mc.font != null) { + String title = edenmod$title(); + AbstractContainerMenu menu = getMenu(); + if (!edenmod$checkAllowedEcoContainer(title, menu)) { + return; + } + + boolean isTerritoryMap = title.contains("Territories") || title.equalsIgnoreCase("Territories"); + boolean isLoadoutsMenu = title.contains("Territory Loadouts") || title.equalsIgnoreCase("Loadouts"); + boolean isSelectScreen = title.contains("Select Territories"); + + if (isTerritoryMap || isLoadoutsMenu || isSelectScreen) { + TerritoryContainerScanner.ScanResult scan = (isTerritoryMap || isSelectScreen) ? edenmod$getTerritoryScan(menu) : TerritoryContainerScanner.ScanResult.EMPTY; + TerritoryGuideController.GuideStep step = TerritoryGuideController.evaluate(scan.currentLoadouts(), scan.selectedTerritories(), title, isSelectScreen); + TerritoryMenuPanel.renderHudBar((Screen) (Object) this, graphics, mouseX, mouseY, step); + + if (TerritoryGuideController.hasActivePreset()) { + if (isLoadoutsMenu) { + Slot targetSlot = null; + if (step.action() == TerritoryGuideController.ActionType.CLICK_FAKE_MEDIUM_OPTION) { + targetSlot = TerritoryContainerScanner.findSlotByName(menu, "Fake Medium"); + } else if (step.action() == TerritoryGuideController.ActionType.CLICK_FAKE_HIGH_OPTION) { + targetSlot = TerritoryContainerScanner.findSlotByName(menu, "Fake High"); + } + if (targetSlot != null) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + targetSlot.x, this.topPos + targetSlot.y); + } else if (step.action() != TerritoryGuideController.ActionType.CLICK_FAKE_MEDIUM_OPTION && step.action() != TerritoryGuideController.ActionType.CLICK_FAKE_HIGH_OPTION) { + Slot backSlot = edenmod$findBackSlot(menu); + if (backSlot != null) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + backSlot.x, this.topPos + backSlot.y); + } + } + } else if (isSelectScreen) { + if (step.action() == TerritoryGuideController.ActionType.SELECT_TERRITORIES) { + for (String target : step.targetTerritories()) { + for (java.util.Map.Entry entry : scan.territorySlots().entrySet()) { + if (TerritoryGuideController.cleanName(entry.getKey()).equalsIgnoreCase(TerritoryGuideController.cleanName(target))) { + Slot slot = entry.getValue(); + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + slot.x, this.topPos + slot.y); + } + } + } + } else if (step.action() == TerritoryGuideController.ActionType.DOUBLE_CLICK_APPLY_BUTTON) { + Slot confirmSlot = (!menu.slots.isEmpty()) ? menu.slots.get(0) : null; + if (confirmSlot != null) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + confirmSlot.x, this.topPos + confirmSlot.y); + } + } else { + Slot backSlot = edenmod$findBackSlot(menu); + if (backSlot != null) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + backSlot.x, this.topPos + backSlot.y); + } + } + } else if (isTerritoryMap) { + if (step.action() == TerritoryGuideController.ActionType.CLICK_LOADOUTS_BUTTON) { + Slot loadoutSlot = TerritoryContainerScanner.findSlotByName(menu, "Territory Loadouts"); + if (loadoutSlot == null) { + loadoutSlot = TerritoryContainerScanner.findSlotByName(menu, "Loadouts"); + } + if (loadoutSlot != null) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + loadoutSlot.x, this.topPos + loadoutSlot.y); + } + } else if (step.action() == TerritoryGuideController.ActionType.OPEN_TERRITORY) { + for (String target : step.targetTerritories()) { + for (java.util.Map.Entry entry : scan.territorySlots().entrySet()) { + if (TerritoryGuideController.cleanName(entry.getKey()).equalsIgnoreCase(TerritoryGuideController.cleanName(target))) { + Slot slot = entry.getValue(); + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + slot.x, this.topPos + slot.y); + } + } + } + if (!step.targetTerritories().isEmpty()) { + TerritoryGuideController.setLastSelectedTerritory(step.targetTerritories().get(0)); + } + } + } + } + return; + } + + if (!TerritoryGuideController.hasActivePreset()) { + return; + } + TerritoryPreset preset = TerritoryGuideController.activePreset(); + if (preset == null || preset.territories().isEmpty()) { + return; + } + + // 4. Upgrades or Bonuses screen + if ((title.contains("Upgrades") || title.contains("Guild Tower") || title.contains("Bonus") || title.contains("Bonuses")) && TerritoryGuideController.isKnownTerritory(TerritoryContainerScanner.parseTerritoryFromUpgradeTitle(title))) { + String territory = TerritoryContainerScanner.parseTerritoryFromUpgradeTitle(title); + Slot backSlot = edenmod$findBackSlot(menu); + boolean isSkipped = (territory != null) && TerritoryGuideController.isSkipped(territory); + if (isSkipped) { + if (backSlot != null) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + backSlot.x, this.topPos + backSlot.y); + edenmod$renderBanner(graphics, mc.font, this.leftPos, this.topPos, this.imageWidth, "§e" + territory + " skipped! Click §e[Back] §fto return."); + } + } else { + if (territory != null) { + TerritoryGuideController.setLastSelectedTerritory(territory); + TerritoryGuideController.setActiveTarget(territory); + } + TerritoryPreset.TerritoryLoadout target = (preset != null && territory != null) ? preset.loadout(territory) : TerritoryPreset.TerritoryLoadout.ZERO; + List upgrades = edenmod$getUpgrades(menu, territory); + + if (!upgrades.isEmpty()) { + int[] skipBox = TerritoryUpgradeGuideOverlay.renderUpgradeGuideBanner(graphics, mc.font, this.leftPos, this.topPos, this.imageWidth, territory, preset, target, upgrades); + if (skipBox != null) { + edenmod$skipBtnX = skipBox[0]; + edenmod$skipBtnY = skipBox[1]; + edenmod$skipBtnW = skipBox[2]; + edenmod$skipBtnH = skipBox[3]; + edenmod$skipBtnVisible = true; + } + + int totalDiff = 0; + for (TerritoryContainerScanner.UpgradeSlotInfo up : upgrades) { + int targetLvl = switch (up.upgradeName()) { + case "Damage" -> target.damage(); + case "Attack" -> target.attack(); + case "Health" -> target.health(); + case "Defence" -> target.defence(); + default -> target.bonus(up.upgradeName()); + }; + int diff = targetLvl - up.currentLevel(); + totalDiff += Math.abs(diff); + int sx = this.leftPos + up.slot().x; + int sy = this.topPos + up.slot().y; + if (diff != 0) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, sx, sy); + } + TerritoryUpgradeGuideOverlay.renderClickBadge(graphics, mc.font, sx, sy, diff, diff == 0); + } + + long now = System.currentTimeMillis(); + boolean debounced = (now - edenmod$lastSlotClickTime >= 350L); + boolean isBonusView = false; + Set seenUpgrades = new HashSet<>(); + for (TerritoryContainerScanner.UpgradeSlotInfo up : upgrades) { + seenUpgrades.add(up.upgradeName()); + if (!up.upgradeName().equals("Damage") && !up.upgradeName().equals("Attack") && !up.upgradeName().equals("Health") && !up.upgradeName().equals("Defence")) { + isBonusView = true; + } + } + boolean allRequiredSeen; + if (isBonusView) { + boolean allPresetBonusesSeen = true; + if (target != null && target.bonuses() != null) { + for (java.util.Map.Entry b : target.bonuses().entrySet()) { + if (b.getValue() > 0 && !seenUpgrades.contains(b.getKey())) { + allPresetBonusesSeen = false; + break; + } + } + } + allRequiredSeen = allPresetBonusesSeen && !seenUpgrades.isEmpty(); + } else { + allRequiredSeen = seenUpgrades.contains("Damage") && seenUpgrades.contains("Attack") && seenUpgrades.contains("Health") && seenUpgrades.contains("Defence"); + } + if (totalDiff == 0 && debounced && allRequiredSeen) { + boolean allSatisfied = true; + for (TerritoryContainerScanner.UpgradeSlotInfo up : upgrades) { + int targetLvl = switch (up.upgradeName()) { + case "Damage" -> target.damage(); + case "Attack" -> target.attack(); + case "Health" -> target.health(); + case "Defence" -> target.defence(); + default -> target.bonus(up.upgradeName()); + }; + if (up.currentLevel() != targetLvl) { + allSatisfied = false; + break; + } + } + if (allSatisfied && backSlot != null) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + backSlot.x, this.topPos + backSlot.y); + } + } + } + } + } + // 5. Territory Overview screen (e.g. "Path to Talor" with [Guild Tower] and [Bonus] buttons) + else if (edenmod$isTerritoryOverview(title, menu)) { + Slot towerSlot = TerritoryContainerScanner.findSlotByName(menu, "guild tower"); + if (towerSlot == null) + towerSlot = TerritoryContainerScanner.findSlotByName(menu, "tower attack"); + + Slot bonusSlot = TerritoryContainerScanner.findSlotByName(menu, "bonus"); + if (bonusSlot == null) + bonusSlot = TerritoryContainerScanner.findSlotByName(menu, "bonuses"); + + String territory = TerritoryContainerScanner.cleanTerritoryTitle(title); + if (territory == null || !TerritoryGuideController.isKnownTerritory(territory)) { + territory = TerritoryContainerScanner.parseTerritoryFromUpgradeTitle(title); + } + if (territory == null || !TerritoryGuideController.isKnownTerritory(territory)) { + territory = TerritoryGuideController.lastSelectedTerritory(); + } + if (territory != null) { + Slot backSlot = edenmod$findBackSlot(menu); + boolean isSkipped = TerritoryGuideController.isSkipped(territory); + if (isSkipped) { + if (backSlot != null) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + backSlot.x, this.topPos + backSlot.y); + edenmod$renderBanner(graphics, mc.font, this.leftPos, this.topPos, this.imageWidth, "§e" + territory + " skipped! Click §e[Back] §fto return to territory list."); + } + } else { + TerritoryGuideController.setLastSelectedTerritory(territory); + TerritoryGuideController.setActiveTarget(territory); + TerritoryPreset.TerritoryLoadout target = (preset != null) ? preset.loadout(territory) : TerritoryPreset.TerritoryLoadout.ZERO; + TerritoryPreset.TerritoryLoadout current = TerritoryGuideController.getLiveOrSessionLoadout(territory); + + boolean needTower = target.damage() != current.damage() || target.attack() != current.attack() || target.health() != current.health() || target.defence() != current.defence(); + boolean needBonuses = false; + Set allB = new HashSet<>(); + if (target.bonuses() != null) + allB.addAll(target.bonuses().keySet()); + if (current.bonuses() != null) + allB.addAll(current.bonuses().keySet()); + for (String bName : allB) { + if (target.bonus(bName) != current.bonus(bName)) { + needBonuses = true; + break; + } + } + + if (needTower && towerSlot != null) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + towerSlot.x, this.topPos + towerSlot.y); + String slotName = net.minecraft.ChatFormatting.stripFormatting(towerSlot.getItem().getHoverName().getString()); + edenmod$renderBanner(graphics, mc.font, this.leftPos, this.topPos, this.imageWidth, "§b[Eden Guide] §fClick §e[" + slotName + "] §fto configure tower defense."); + } else if (needBonuses && bonusSlot != null) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + bonusSlot.x, this.topPos + bonusSlot.y); + String slotName = net.minecraft.ChatFormatting.stripFormatting(bonusSlot.getItem().getHoverName().getString()); + edenmod$renderBanner(graphics, mc.font, this.leftPos, this.topPos, this.imageWidth, "§b[Eden Guide] §fTower defense complete! Click §e[" + slotName + "] §fto configure territory bonuses."); + } else { + if (backSlot != null) { + TerritoryUpgradeGuideOverlay.renderTargetPulse(graphics, this.leftPos + backSlot.x, this.topPos + backSlot.y); + edenmod$renderBanner(graphics, mc.font, this.leftPos, this.topPos, this.imageWidth, "§a✔ All upgrades complete for " + territory + "! Click §e[Back] §fto return to territory list."); + } + } + } + } + } + } } catch (Throwable ignored) { } } @Inject(method = "removed", at = @At("TAIL")) private void edenmod$onContainerScreenRemoved(CallbackInfo ci) { + TerritoryMenuPanel.closeDropdown(); RaidGambitSafety.clearPopupWarning(); + edenmod$cachedUpgrades = null; + edenmod$upgradesDirty = true; + edenmod$lastSlotClickTime = 0; + edenmod$skipBtnVisible = false; + edenmod$cachedCheckedMenu = null; + edenmod$cachedIsEcoContainer = false; + edenmod$lastEcoCheckTime = 0L; + edenmod$lastEcoSlotCount = 0; + edenmod$lastScannedTerritory = null; + edenmod$lastScannedTitle = null; + if (edenmod$title().contains("Select Territories")) { + TerritoryGuideController.clearPendingBatch(); + } + } + + @Inject(method = "mouseClicked", at = @At("HEAD"), cancellable = true) + private void edenmod$onMouseClicked(MouseButtonEvent event, boolean bl, CallbackInfoReturnable cir) { + BridgeConfig config = EdenModClient.instance().config(); + if (config != null && config.territoryMenuSuite) { + String title = edenmod$title(); + AbstractContainerMenu menu = getMenu(); + if (edenmod$checkAllowedEcoContainer(title, menu)) { + boolean isTerritoryMap = title.contains("Territories") || title.equalsIgnoreCase("Territories"); + boolean isLoadoutsMenu = title.contains("Territory Loadouts") || title.equalsIgnoreCase("Loadouts"); + boolean isSelectScreen = title.contains("Select Territories"); + if (isTerritoryMap || isLoadoutsMenu || isSelectScreen) { + java.util.Map containerLoadouts = null; + if (isTerritoryMap) { + containerLoadouts = edenmod$getTerritoryScan(menu).currentLoadouts(); + } + if (!TerritoryMenuPanel.handleMouseClick((Screen) (Object) this, event.x(), event.y(), event.button(), containerLoadouts)) { + cir.setReturnValue(true); + return; + } + } + } + } + + if ((event.button() == 0 || event.button() == 1) && edenmod$skipBtnVisible) { + if (event.x() >= edenmod$skipBtnX && event.x() < edenmod$skipBtnX + edenmod$skipBtnW && event.y() >= edenmod$skipBtnY && event.y() < edenmod$skipBtnY + edenmod$skipBtnH) { + Minecraft mc = Minecraft.getInstance(); + if (event.button() == 0) { + TerritoryGuideController.skipForward(); + if (mc != null && mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§e[Eden] Skipped current instruction.")); + } + } else { + TerritoryGuideController.skipBackwards(); + if (mc != null && mc.gui != null) { + mc.gui.getChat().addMessage(Component.literal("§e[Eden] Returned to previous instruction.")); + } + } + cir.setReturnValue(true); + } + } } @Inject(method = "getTooltipFromContainerItem(Lnet/minecraft/world/item/ItemStack;)Ljava/util/List;", at = @At("RETURN"), cancellable = true) private void edenmod$appendRaidReadyTooltip(ItemStack stack, CallbackInfoReturnable> cir) { try { + BridgeConfig config = EdenModClient.instance().config(); if (this.hoveredSlot != null && RaidGambitSafety.isReadyClickSlot(this.hoveredSlot)) { - BridgeConfig config = EdenModClient.instance().config(); if (config != null) { AbstractContainerMenu menu = getMenu(); if (RaidGambitSafety.isRaidReadyMenu(menu) && !RaidGambitSafety.isPlayerAlreadyReadied(menu)) { diff --git a/src/tel/eden/mod/war/eco/TerritoryContainerScanner.java b/src/tel/eden/mod/war/eco/TerritoryContainerScanner.java new file mode 100644 index 0000000..4493bd6 --- /dev/null +++ b/src/tel/eden/mod/war/eco/TerritoryContainerScanner.java @@ -0,0 +1,406 @@ +package tel.eden.mod.war.eco; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.minecraft.ChatFormatting; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.world.inventory.AbstractContainerMenu; +import net.minecraft.world.inventory.Slot; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.item.component.ItemLore; +import tel.eden.mod.war.TerritoryData; + +/** + * Scans container menus for territory claims, loadouts, selection states, and upgrades. + */ +public final class TerritoryContainerScanner { + private TerritoryContainerScanner() { + } + + private static final Pattern UPGRADE_LINE_PATTERN = Pattern.compile("-\\s*(.+?)\\s*\\[Lv\\.\\s*(\\d+)\\]", Pattern.CASE_INSENSITIVE); + private static final Pattern CLEAN_NAME_PATTERN = Pattern.compile("([A-Za-z0-9][A-Za-z0-9'\\-\\.\\s]+)"); + private static final Pattern LEVEL_PATTERN = Pattern.compile("(?i)(?:\\[(?:Lv\\.?\\s*)?([0-9]+|[IVXLCDM]+)\\]|Lv\\.?\\s*([0-9]+|[IVXLCDM]+)|Level:?\\s*([0-9]+|[IVXLCDM]+))"); + + public record ScanResult(Set claimedTerritories, String hqTerritory, Map currentLoadouts, Set selectedTerritories, Map territorySlots) { + public static final ScanResult EMPTY = new ScanResult(Set.of(), null, Map.of(), Set.of(), Map.of()); + } + + public static List getLoreLines(ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return List.of(); + } + ItemLore itemLore = stack.get(DataComponents.LORE); + if (itemLore != null) { + List lines = new ArrayList<>(); + for (Component c : itemLore.lines()) { + lines.add(ChatFormatting.stripFormatting(c.getString()).trim()); + } + return lines; + } + try { + List tooltip = stack.getTooltipLines(net.minecraft.world.item.Item.TooltipContext.EMPTY, null, net.minecraft.world.item.TooltipFlag.NORMAL); + if (tooltip != null && !tooltip.isEmpty()) { + List lines = new ArrayList<>(); + for (Component c : tooltip) { + lines.add(ChatFormatting.stripFormatting(c.getString()).trim()); + } + return lines; + } + } catch (Throwable ignored) { + } + return List.of(); + } + + public static String cleanTerritoryName(ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return null; + } + String raw = ChatFormatting.stripFormatting(stack.getHoverName().getString()).trim(); + if (raw.isEmpty() || raw.equalsIgnoreCase("Back") || raw.contains("Page") || raw.contains("Territory Loadouts")) { + return null; + } + if (raw.endsWith("(HQ)")) { + raw = raw.substring(0, raw.length() - 4).trim(); + } + Matcher m = CLEAN_NAME_PATTERN.matcher(raw); + if (m.find()) { + return m.group(1).trim(); + } + return raw; + } + + public static boolean isHeadquartersItem(ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return false; + } + String raw = ChatFormatting.stripFormatting(stack.getHoverName().getString()).trim(); + return raw.endsWith("(HQ)"); + } + + public static boolean isTerritorySlot(Slot slot) { + if (slot == null || !slot.hasItem()) { + return false; + } + int idx = slot.index; + if (idx < 0 || idx >= 45 || idx % 9 < 2) { + return false; + } + ItemStack stack = slot.getItem(); + String name = cleanTerritoryName(stack); + return name != null && !name.isBlank(); + } + + public static boolean isTerritorySelected(ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return false; + } + if ((stack.getItem() == Items.DIAMOND_AXE || stack.getItem() == Items.GOLDEN_SHOVEL) && stack.getDamageValue() == 20) { + return true; + } + for (String line : getLoreLines(stack)) { + if (line.contains("Click to Unselect") || line.contains("Click to unselect")) { + return true; + } + } + return false; + } + + public static TerritoryPreset.TerritoryLoadout parseLoadout(ItemStack stack) { + if (stack == null || stack.isEmpty()) { + return TerritoryPreset.TerritoryLoadout.ZERO; + } + int damage = 0, attack = 0, health = 0, defence = 0; + Map bonuses = new HashMap<>(); + for (String str : getLoreLines(stack)) { + Matcher upMatcher = UPGRADE_LINE_PATTERN.matcher(str); + if (upMatcher.find()) { + String upName = upMatcher.group(1).trim(); + int lvl = Integer.parseInt(upMatcher.group(2).trim()); + switch (upName) { + case "Damage" -> damage = lvl; + case "Attack" -> attack = lvl; + case "Health" -> health = lvl; + case "Defence" -> defence = lvl; + default -> bonuses.put(upName, lvl); + } + } + } + return new TerritoryPreset.TerritoryLoadout(damage, attack, health, defence, bonuses); + } + + public static ScanResult scanTerritoriesMenu(AbstractContainerMenu menu) { + if (menu == null) { + return ScanResult.EMPTY; + } + + Set claimed = new HashSet<>(); + String hq = null; + Map loadouts = new HashMap<>(); + Set selected = new HashSet<>(); + Map slots = new HashMap<>(); + + for (Slot slot : menu.slots) { + if (!isTerritorySlot(slot)) { + continue; + } + ItemStack stack = slot.getItem(); + String name = cleanTerritoryName(stack); + if (name == null || name.isBlank()) { + continue; + } + + claimed.add(name); + slots.put(name, slot); + + if (isHeadquartersItem(stack)) { + hq = name; + } + if (isTerritorySelected(stack)) { + selected.add(name); + } + + loadouts.put(name, parseLoadout(stack)); + } + + return new ScanResult(Set.copyOf(claimed), hq, Map.copyOf(loadouts), Set.copyOf(selected), Map.copyOf(slots)); + } + + public static boolean isContainerSlot(AbstractContainerMenu menu, Slot slot) { + if (slot == null || menu == null) { + return false; + } + if (slot.container instanceof net.minecraft.world.entity.player.Inventory) { + return false; + } + int containerSlots = menu.slots.size() - 36; + return containerSlots <= 0 || slot.index < containerSlots; + } + + public static Slot findSlotByName(AbstractContainerMenu menu, String targetName) { + if (menu == null || targetName == null || targetName.isBlank()) { + return null; + } + String target = targetName.toLowerCase().trim(); + for (Slot slot : menu.slots) { + if (!isContainerSlot(menu, slot) || !slot.hasItem()) { + continue; + } + String name = ChatFormatting.stripFormatting(slot.getItem().getHoverName().getString()).toLowerCase().trim(); + if (name.contains(target)) { + return slot; + } + } + return null; + } + + public record UpgradeSlotInfo(String upgradeName, int currentLevel, Slot slot) { + } + + private static int parseLevel(String str) { + if (str == null) + return -1; + str = str.trim(); + try { + return Integer.parseInt(str); + } catch (NumberFormatException ignored) { + } + return switch (str.toUpperCase()) { + case "I" -> 1; + case "II" -> 2; + case "III" -> 3; + case "IV" -> 4; + case "V" -> 5; + case "VI" -> 6; + case "VII" -> 7; + case "VIII" -> 8; + case "IX" -> 9; + case "X" -> 10; + default -> -1; + }; + } + + public static List scanUpgradeMenu(AbstractContainerMenu menu) { + if (menu == null) { + return List.of(); + } + + List result = new ArrayList<>(); + for (Slot slot : menu.slots) { + if (!isContainerSlot(menu, slot) || !slot.hasItem()) { + continue; + } + ItemStack stack = slot.getItem(); + if (stack.getItem() instanceof net.minecraft.world.item.PotionItem) { + continue; + } + List lore = getLoreLines(stack); + boolean isIngredient = false; + for (String line : lore) { + String low = line.toLowerCase(); + if (low.contains("crafting level") || low.contains("recipes for") || low.contains("duration")) { + isIngredient = true; + break; + } + } + if (isIngredient) { + continue; + } + + String rawName = ChatFormatting.stripFormatting(stack.getHoverName().getString()).trim(); + if (rawName.isEmpty() || rawName.equalsIgnoreCase("Back") || rawName.contains("Page")) { + continue; + } + + int lvl = -1; + String cleanName = rawName; + Matcher lvlMatcher = LEVEL_PATTERN.matcher(rawName); + if (lvlMatcher.find()) { + String g1 = lvlMatcher.group(1); + String g2 = lvlMatcher.group(2); + String g3 = lvlMatcher.group(3); + String numStr = g1 != null ? g1 : (g2 != null ? g2 : g3); + lvl = parseLevel(numStr); + cleanName = rawName.substring(0, lvlMatcher.start()).trim(); + } + + if (lvl == -1) { + for (String line : lore) { + Matcher lm = LEVEL_PATTERN.matcher(line); + if (lm.find()) { + String g1 = lm.group(1); + String g2 = lm.group(2); + String g3 = lm.group(3); + String numStr = g1 != null ? g1 : (g2 != null ? g2 : g3); + lvl = parseLevel(numStr); + break; + } + } + } + + Matcher nameMatcher = CLEAN_NAME_PATTERN.matcher(cleanName); + if (nameMatcher.find()) { + cleanName = nameMatcher.group(1).trim(); + } + + String canonical = canonicalUpgradeName(cleanName); + if (canonical != null) { + result.add(new UpgradeSlotInfo(canonical, Math.max(0, lvl), slot)); + } + } + return result; + } + + public static String canonicalUpgradeName(String raw) { + if (raw == null) { + return null; + } + String low = raw.toLowerCase().trim(); + if (low.contains("damage") && !low.contains("pvp") && !low.contains("mob")) + return "Damage"; + if (low.contains("attack") && !low.contains("multi")) + return "Attack"; + if (low.contains("health")) + return "Health"; + if (low.contains("defen")) + return "Defence"; + if (low.contains("aura") && (low.contains("tower") || low.equals("aura"))) + return "Tower Aura"; + if (low.contains("volley")) + return "Tower Volley"; + if (low.contains("multi")) + return "Tower Multi-Attacks"; + if (low.contains("minion") || low.contains("stronger mobs")) + return "Stronger Minions"; + if (low.contains("larger resource") || low.contains("resource storage")) + return "Larger Resource Storage"; + if (low.contains("larger emerald") || low.contains("emerald storage")) + return "Larger Emerald Storage"; + if (low.contains("efficient resource")) + return "Efficient Resources"; + if (low.contains("efficient emerald")) + return "Efficient Emeralds"; + if (low.contains("resource rate")) + return "Resource Rate"; + if (low.contains("emerald rate")) + return "Emerald Rate"; + if (low.contains("tome seeking")) + return "Tome Seeking"; + if (low.contains("emerald seeking")) + return "Emerald Seeking"; + if (low.contains("xp seeking")) + return "XP Seeking"; + if (low.contains("gathering exp")) + return "Gathering Experience"; + if (low.contains("mob exp")) + return "Mob Experience"; + if (low.contains("mob dam")) + return "Mob Damage"; + if (low.contains("pvp dam")) + return "PvP Damage"; + return null; + } + + public static String cleanTerritoryTitle(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + String clean = ChatFormatting.stripFormatting(raw).trim(); + if (clean.endsWith("(HQ)")) { + clean = clean.substring(0, clean.length() - 4).trim(); + } + Matcher m = CLEAN_NAME_PATTERN.matcher(clean); + if (m.find()) { + return m.group(1).trim(); + } + return clean; + } + + public static boolean isUpgradeOrBonusMenu(AbstractContainerMenu menu, String title) { + if (menu == null || title == null) { + return false; + } + if (title.contains("󏿰")) { + return false; + } + String terr = parseTerritoryFromUpgradeTitle(title); + if (terr == null || !TerritoryGuideController.isKnownTerritory(terr)) { + return false; + } + return !scanUpgradeMenu(menu).isEmpty(); + } + + public static String parseTerritoryFromUpgradeTitle(String title) { + if (title == null || title.isBlank()) { + return null; + } + String clean = ChatFormatting.stripFormatting(title).trim(); + int idx = clean.lastIndexOf(':'); + if (idx > 0) { + clean = clean.substring(0, idx).trim(); + } else { + for (String suffix : new String[]{" Guild Tower", " Upgrades", " Bonuses", " Bonus", " Manage"}) { + if (clean.toLowerCase().endsWith(suffix.toLowerCase())) { + clean = clean.substring(0, clean.length() - suffix.length()).trim(); + break; + } + } + } + Matcher m = CLEAN_NAME_PATTERN.matcher(clean); + if (m.find()) { + String candidate = m.group(1).trim(); + if (candidate.endsWith("(HQ)")) { + candidate = candidate.substring(0, candidate.length() - 4).trim(); + } + return candidate; + } + return clean; + } +} diff --git a/src/tel/eden/mod/war/eco/TerritoryGuideController.java b/src/tel/eden/mod/war/eco/TerritoryGuideController.java new file mode 100644 index 0000000..128d414 --- /dev/null +++ b/src/tel/eden/mod/war/eco/TerritoryGuideController.java @@ -0,0 +1,830 @@ +package tel.eden.mod.war.eco; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.config.BridgeConfig; + +/** + * Orchestrates preset application: storage management, Fake Medium, Fake High, and individual territory upgrades. + */ +public final class TerritoryGuideController { + private TerritoryGuideController() { + } + + public static final TerritoryPreset.TerritoryLoadout FAKE_MEDIUM = new TerritoryPreset.TerritoryLoadout(2, 2, 3, 2, Map.of("Tower Aura", 1, "Tower Volley", 1)); + public static final TerritoryPreset.TerritoryLoadout FAKE_HIGH = new TerritoryPreset.TerritoryLoadout(5, 4, 5, 5, Map.of("Tower Aura", 1, "Tower Volley", 3)); + + private static TerritoryPreset activePreset = TerritoryPreset.empty("Default"); + private static final List skippedQueue = new ArrayList<>(); + private static boolean skippedFakeMedium = false; + private static boolean skippedFakeHigh = false; + private static ActionType lastEvaluatedAction = ActionType.NONE; + private static String activeTarget = null; + private static String lastSelectedTerritory = null; + private static final Map sessionLoadouts = new java.util.TreeMap<>(String.CASE_INSENSITIVE_ORDER); + private static List pendingBatchTerritories = null; + private static TerritoryPreset.TerritoryLoadout pendingBatchLoadout = null; + + public enum ActionType { + NONE, CLICK_LOADOUTS_BUTTON, CLICK_FAKE_MEDIUM_OPTION, CLICK_FAKE_HIGH_OPTION, SELECT_TERRITORIES, DOUBLE_CLICK_APPLY_BUTTON, OPEN_TERRITORY, CLICK_UPGRADES_BUTTON, CLICK_BONUSES_BUTTON, UPGRADE_SLOTS, CLICK_BACK_BUTTON, ALL_COMPLETE + } + + public record GuideStep(ActionType action, String instruction, List targetTerritories, String highlightSlotName, int totalCount, int completedCount) { + public static final GuideStep NONE = new GuideStep(ActionType.NONE, "", List.of(), null, 0, 0); + } + + public static TerritoryPreset activePreset() { + return activePreset; + } + + public static void setActivePreset(TerritoryPreset preset) { + activePreset = (preset != null) ? preset : TerritoryPreset.empty("Default"); + skippedQueue.clear(); + skippedFakeMedium = false; + skippedFakeHigh = false; + lastEvaluatedAction = ActionType.NONE; + activeTarget = null; + pendingBatchTerritories = null; + pendingBatchLoadout = null; + sessionLoadouts.clear(); + } + + public static void reset() { + skippedQueue.clear(); + skippedFakeMedium = false; + skippedFakeHigh = false; + lastEvaluatedAction = ActionType.NONE; + activeTarget = null; + lastSelectedTerritory = null; + pendingBatchTerritories = null; + pendingBatchLoadout = null; + sessionLoadouts.clear(); + } + + public static String lastSelectedTerritory() { + return lastSelectedTerritory; + } + + public static String activeTarget() { + return activeTarget; + } + + public static void setActiveTarget(String target) { + activeTarget = target; + } + + public static void setLastSelectedTerritory(String territory) { + lastSelectedTerritory = territory; + } + + public static boolean isSkipped(String name) { + if (name == null) + return false; + for (String s : skippedQueue) { + if (s.equalsIgnoreCase(name)) + return true; + } + return false; + } + + public static boolean isSkippedFakeMedium() { + return skippedFakeMedium; + } + + public static boolean isSkippedFakeHigh() { + return skippedFakeHigh; + } + + public static void commitPendingBatch() { + if (pendingBatchTerritories != null && pendingBatchLoadout != null) { + for (String t : pendingBatchTerritories) { + sessionLoadouts.put(canonicalTerritoryName(t), pendingBatchLoadout); + } + pendingBatchTerritories = null; + pendingBatchLoadout = null; + } + } + + public static void clearPendingBatch() { + pendingBatchTerritories = null; + pendingBatchLoadout = null; + } + + public static void skipCurrentTarget() { + String target = (activeTarget != null) ? activeTarget : lastSelectedTerritory; + if (target != null) { + if (!isSkipped(target)) { + skippedQueue.add(target); + } + activeTarget = null; + } + } + + public static void skipForward() { + if (lastEvaluatedAction == ActionType.CLICK_FAKE_MEDIUM_OPTION || (!skippedFakeMedium && (lastEvaluatedAction == ActionType.CLICK_LOADOUTS_BUTTON || lastEvaluatedAction == ActionType.SELECT_TERRITORIES || lastEvaluatedAction == ActionType.DOUBLE_CLICK_APPLY_BUTTON))) { + skippedFakeMedium = true; + pendingBatchTerritories = null; + pendingBatchLoadout = null; + } else if (lastEvaluatedAction == ActionType.CLICK_FAKE_HIGH_OPTION || (!skippedFakeHigh && (lastEvaluatedAction == ActionType.CLICK_LOADOUTS_BUTTON || lastEvaluatedAction == ActionType.SELECT_TERRITORIES || lastEvaluatedAction == ActionType.DOUBLE_CLICK_APPLY_BUTTON))) { + skippedFakeHigh = true; + pendingBatchTerritories = null; + pendingBatchLoadout = null; + } else if (lastEvaluatedAction == ActionType.ALL_COMPLETE) { + skippedFakeMedium = false; + skippedFakeHigh = false; + skippedQueue.clear(); + activeTarget = null; + } else { + skipCurrentTarget(); + } + } + + public static void skipBackwards() { + if (!skippedQueue.isEmpty()) { + String prev = skippedQueue.remove(skippedQueue.size() - 1); + activeTarget = prev; + return; + } + + if (lastEvaluatedAction == ActionType.OPEN_TERRITORY || lastEvaluatedAction == ActionType.UPGRADE_SLOTS || lastEvaluatedAction == ActionType.CLICK_UPGRADES_BUTTON || lastEvaluatedAction == ActionType.CLICK_BONUSES_BUTTON || lastEvaluatedAction == ActionType.CLICK_BACK_BUTTON || lastEvaluatedAction == ActionType.ALL_COMPLETE) { + if (skippedFakeHigh) { + skippedFakeHigh = false; + activeTarget = null; + return; + } + if (skippedFakeMedium) { + skippedFakeMedium = false; + activeTarget = null; + return; + } + } + + if (lastEvaluatedAction == ActionType.CLICK_FAKE_HIGH_OPTION || (!skippedFakeHigh && (lastEvaluatedAction == ActionType.CLICK_LOADOUTS_BUTTON || lastEvaluatedAction == ActionType.SELECT_TERRITORIES || lastEvaluatedAction == ActionType.DOUBLE_CLICK_APPLY_BUTTON))) { + if (skippedFakeMedium) { + skippedFakeMedium = false; + activeTarget = null; + return; + } + } + + // At start: loop backwards to the end + skippedFakeMedium = true; + skippedFakeHigh = true; + List cands = getPhase3Candidates(); + if (!cands.isEmpty()) { + for (int i = 0; i < cands.size() - 1; i++) { + skippedQueue.add(cands.get(i)); + } + activeTarget = cands.get(cands.size() - 1); + } else { + skippedFakeHigh = false; + activeTarget = null; + } + } + + private static List getPhase3Candidates() { + if (activePreset == null) + return List.of(); + Set owned = new HashSet<>(); + for (String k : sessionLoadouts.keySet()) { + owned.add(cleanName(k).toLowerCase()); + } + List result = new ArrayList<>(); + for (Map.Entry entry : activePreset.territories().entrySet()) { + String name = entry.getKey(); + if (activePreset.isExcluded(name)) + continue; + if (!owned.isEmpty() && !owned.contains(cleanName(name).toLowerCase())) + continue; + if (isFakeMedium(entry.getValue()) || isFakeHigh(entry.getValue())) + continue; + TerritoryPreset.TerritoryLoadout current = getCaseInsensitive(sessionLoadouts, name); + if (remainingClicks(current, entry.getValue()) > 0) { + result.add(name); + } + } + return result; + } + + public static void skipCurrentInstruction() { + skipForward(); + } + + public static Map sessionLoadouts() { + return Collections.unmodifiableMap(sessionLoadouts); + } + + public static void recordScannedUpgrades(String territory, List upgrades) { + if (territory == null || upgrades == null) + return; + TerritoryPreset.TerritoryLoadout base = getLiveOrSessionLoadout(territory); + int dmg = base.damage(), atk = base.attack(), hp = base.health(), def = base.defence(); + Map bonuses = new HashMap<>(base.bonuses()); + for (TerritoryContainerScanner.UpgradeSlotInfo up : upgrades) { + switch (up.upgradeName()) { + case "Damage" -> dmg = up.currentLevel(); + case "Attack" -> atk = up.currentLevel(); + case "Health" -> hp = up.currentLevel(); + case "Defence" -> def = up.currentLevel(); + default -> { + if (up.currentLevel() > 0) { + bonuses.put(up.upgradeName(), up.currentLevel()); + } else { + bonuses.remove(up.upgradeName()); + } + } + } + } + sessionLoadouts.put(canonicalTerritoryName(territory), new TerritoryPreset.TerritoryLoadout(dmg, atk, hp, def, bonuses)); + } + + public static TerritoryPreset.TerritoryLoadout getLiveOrSessionLoadout(String territory) { + if (territory == null) + return TerritoryPreset.TerritoryLoadout.ZERO; + TerritoryPreset.TerritoryLoadout s = sessionLoadouts.get(canonicalTerritoryName(territory)); + if (s != null) + return s; + return TerritoryPreset.TerritoryLoadout.ZERO; + } + + public static TerritoryPreset.TerritoryLoadout getCaseInsensitive(Map map, String key) { + if (map == null || key == null) + return TerritoryPreset.TerritoryLoadout.ZERO; + TerritoryPreset.TerritoryLoadout exact = map.get(key); + if (exact != null) + return exact; + for (Map.Entry e : map.entrySet()) { + if (e.getKey().equalsIgnoreCase(key)) + return e.getValue(); + } + TerritoryPreset.TerritoryLoadout s = sessionLoadouts.get(canonicalTerritoryName(key)); + if (s != null) + return s; + return TerritoryPreset.TerritoryLoadout.ZERO; + } + + // ---- Preset Storage Management ---- + + private static BridgeConfig config() { + try { + EdenModClient client = EdenModClient.instance(); + return client != null ? client.config() : null; + } catch (Throwable ignored) { + return null; + } + } + + public static Map savedPresets() { + BridgeConfig config = config(); + if (config == null) + return Map.of(); + if (config.territorySavedPresets == null) { + config.territorySavedPresets = new LinkedHashMap<>(); + } + return config.territorySavedPresets; + } + + public static boolean hasPresetName(Map map, String name) { + if (map == null || name == null) { + return false; + } + for (String key : map.keySet()) { + if (key.equalsIgnoreCase(name.trim())) { + return true; + } + } + return false; + } + + public static String getUniquePresetName(Map map, String desiredName) { + String base = (desiredName != null && !desiredName.isBlank()) ? desiredName.trim() : "Default"; + if (map != null) { + for (String key : map.keySet()) { + if (key.equalsIgnoreCase(base)) { + base = key; + break; + } + } + } + if (!hasPresetName(map, base)) { + return base; + } + int counter = 2; + while (hasPresetName(map, base + " (" + counter + ")")) { + counter++; + } + return base + " (" + counter + ")"; + } + + public static boolean isKnownTerritory(String name) { + if (name == null || name.isBlank()) { + return false; + } + String clean = cleanName(name); + if (clean.isEmpty()) { + return false; + } + if (activePreset != null && activePreset.territories() != null) { + for (String presetTerr : activePreset.territories().keySet()) { + if (cleanName(presetTerr).equalsIgnoreCase(clean)) { + return true; + } + } + } + for (String t : tel.eden.mod.war.TerritoryData.territoryNames()) { + if (cleanName(t).equalsIgnoreCase(clean)) { + return true; + } + } + if (lastSelectedTerritory != null && cleanName(lastSelectedTerritory).equalsIgnoreCase(clean)) { + return true; + } + return false; + } + + public static void savePresetToStorage(String name, String code) { + BridgeConfig config = config(); + if (config == null || code == null || code.isBlank()) + return; + try { + TerritoryPreset preset = TerritoryPresetCodec.parse(code); + String rawName = (name != null && !name.isBlank()) ? name.trim() : preset.name(); + String finalName = getUniquePresetName(config.territorySavedPresets, rawName); + if (!finalName.equals(preset.name())) { + preset = new TerritoryPreset(finalName, preset.notes(), preset.excludedTerritories(), preset.territories()); + code = TerritoryPresetCodec.encodeBase64(preset); + } + config.territorySavedPresets.put(finalName, code); + config.territoryActivePreset = code; + setActivePreset(preset); + config.save(); + } catch (Exception ignored) { + } + } + + public static boolean selectSavedPreset(String name) { + BridgeConfig config = config(); + if (config == null || name == null) + return false; + String code = config.territorySavedPresets.get(name); + if (code == null) + return false; + try { + TerritoryPreset preset = TerritoryPresetCodec.parse(code); + config.territoryActivePreset = code; + setActivePreset(preset); + config.save(); + return true; + } catch (Exception ignored) { + return false; + } + } + + public static boolean hasActivePreset() { + return activePreset != null && !activePreset.territories().isEmpty(); + } + + public static void unselectPreset() { + BridgeConfig config = config(); + if (config != null) { + config.territoryActivePreset = ""; + config.save(); + } + setActivePreset(TerritoryPreset.empty("None")); + } + + public static boolean renameSavedPreset(String oldName, String newName) { + BridgeConfig config = config(); + if (config == null || oldName == null) + return false; + String cleanOld = oldName.trim(); + String cleanNew = (newName != null) ? newName.trim() : ""; + if (cleanNew.isEmpty()) { + cleanNew = "Default"; + } + if (cleanOld.equalsIgnoreCase(cleanNew)) + return false; + + String code = config.territorySavedPresets.get(cleanOld); + if (code == null) + return false; + + // Prevent clobbering existing presets: if new name matches another preset, make unique + if (hasPresetName(config.territorySavedPresets, cleanNew)) { + int counter = 2; + String base = cleanNew; + while (hasPresetName(config.territorySavedPresets, base + " (" + counter + ")")) { + counter++; + } + cleanNew = base + " (" + counter + ")"; + } + + String newCode = code; + try { + TerritoryPreset preset = TerritoryPresetCodec.parse(code); + TerritoryPreset renamed = new TerritoryPreset(cleanNew, preset.notes(), preset.excludedTerritories(), preset.territories()); + newCode = TerritoryPresetCodec.encodeBase64(renamed); + if (activePreset != null && activePreset.name().equalsIgnoreCase(cleanOld)) { + setActivePreset(renamed); + config.territoryActivePreset = newCode; + } + } catch (Exception ignored) { + } + + Map updated = new LinkedHashMap<>(); + for (Map.Entry entry : config.territorySavedPresets.entrySet()) { + if (entry.getKey().equalsIgnoreCase(cleanOld)) { + updated.put(cleanNew, newCode); + } else { + updated.put(entry.getKey(), entry.getValue()); + } + } + config.territorySavedPresets = updated; + config.save(); + return true; + } + + public static void deleteSavedPreset(String name) { + BridgeConfig config = config(); + if (config == null || name == null) + return; + config.territorySavedPresets.remove(name); + if (activePreset != null && activePreset.name().equalsIgnoreCase(name)) { + if (!config.territorySavedPresets.isEmpty()) { + String nextName = config.territorySavedPresets.keySet().iterator().next(); + selectSavedPreset(nextName); + } else { + unselectPreset(); + } + } + config.save(); + } + + public static String cycleSavedPreset(int offset) { + Map presets = savedPresets(); + List keys = new ArrayList<>(presets.keySet()); + keys.add(0, "None"); + String currentName = hasActivePreset() ? activePreset().name() : "None"; + int idx = 0; + for (int i = 0; i < keys.size(); i++) { + if (keys.get(i).equalsIgnoreCase(currentName)) { + idx = i; + break; + } + } + int nextIdx = (idx + offset) % keys.size(); + if (nextIdx < 0) + nextIdx += keys.size(); + String nextName = keys.get(nextIdx); + if (nextName.equalsIgnoreCase("None")) { + unselectPreset(); + return "None"; + } + selectSavedPreset(nextName); + return nextName; + } + + public static boolean isFakeMedium(TerritoryPreset.TerritoryLoadout loadout) { + if (loadout == null) { + return false; + } + long activeBonuses = loadout.bonuses().values().stream().filter(v -> v != null && v > 0).count(); + return loadout.damage() == 2 && loadout.attack() == 2 && loadout.health() == 3 && loadout.defence() == 2 && loadout.bonus("Tower Aura") == 1 && loadout.bonus("Tower Volley") == 1 && activeBonuses == 2; + } + + public static boolean isFakeHigh(TerritoryPreset.TerritoryLoadout loadout) { + if (loadout == null) { + return false; + } + long activeBonuses = loadout.bonuses().values().stream().filter(v -> v != null && v > 0).count(); + return loadout.damage() == 5 && loadout.attack() == 4 && loadout.health() == 5 && loadout.defence() == 5 && loadout.bonus("Tower Aura") == 1 && loadout.bonus("Tower Volley") == 3 && activeBonuses == 2; + } + + private static final java.util.regex.Pattern CLEAN_NAME_REGEX = java.util.regex.Pattern.compile("([A-Za-z0-9][A-Za-z0-9'\\-\\.\\s]+)"); + + public static String cleanName(String name) { + if (name == null) { + return ""; + } + String clean = net.minecraft.ChatFormatting.stripFormatting(name).trim(); + if (clean.endsWith("(HQ)")) { + clean = clean.substring(0, clean.length() - 4).trim(); + } + java.util.regex.Matcher m = CLEAN_NAME_REGEX.matcher(clean); + if (m.find()) { + String cand = m.group(1).trim(); + if (cand.endsWith("(HQ)")) { + cand = cand.substring(0, cand.length() - 4).trim(); + } + return cand; + } + return clean; + } + + public static String canonicalTerritoryName(String name) { + if (name == null || name.isBlank()) { + return ""; + } + String clean = cleanName(name); + for (String known : tel.eden.mod.war.TerritoryData.territoryNames()) { + if (cleanName(known).equalsIgnoreCase(clean)) { + return cleanName(known); + } + } + if (activePreset != null && activePreset.territories() != null) { + for (String presetTerr : activePreset.territories().keySet()) { + if (cleanName(presetTerr).equalsIgnoreCase(clean)) { + return cleanName(presetTerr); + } + } + } + return clean; + } + + public static int remainingClicks(TerritoryPreset.TerritoryLoadout current, TerritoryPreset.TerritoryLoadout target) { + if (target == null) { + return 0; + } + if (current == null) { + current = TerritoryPreset.TerritoryLoadout.ZERO; + } + int c = 0; + c += Math.abs(target.damage() - current.damage()); + c += Math.abs(target.attack() - current.attack()); + c += Math.abs(target.health() - current.health()); + c += Math.abs(target.defence() - current.defence()); + + Set allBonuses = new HashSet<>(); + if (target.bonuses() != null) + allBonuses.addAll(target.bonuses().keySet()); + if (current.bonuses() != null) + allBonuses.addAll(current.bonuses().keySet()); + + for (String bName : allBonuses) { + c += Math.abs(target.bonus(bName) - current.bonus(bName)); + } + return c; + } + + /** + * Computes the active instruction step based on the preset and live scanned territories. + * Territories not on the player's menu or not owned by Eden are automatically voided. + */ + public static GuideStep evaluate(Map currentLoadouts, Set liveSelectedTerritories, String containerTitle, boolean isSelectionMode) { + GuideStep step = evaluateInternal(currentLoadouts, liveSelectedTerritories, containerTitle, isSelectionMode); + lastEvaluatedAction = (step != null) ? step.action() : ActionType.NONE; + return step; + } + + private static GuideStep evaluateInternal(Map currentLoadouts, Set liveSelectedTerritories, String containerTitle, boolean isSelectionMode) { + if (activePreset == null || activePreset.territories().isEmpty()) { + return new GuideStep(ActionType.NONE, "No active preset loaded. Click [Paste Preset] to import one.", List.of(), null, 0, 0); + } + + String cleanTitle = (containerTitle != null) ? containerTitle.trim() : ""; + boolean isLoadoutsMenu = cleanTitle.contains("Territory Loadouts") || cleanTitle.equalsIgnoreCase("Loadouts"); + boolean isSelectScreen = isSelectionMode || cleanTitle.contains("Select Territories"); + boolean isUpgradesMenu = cleanTitle.contains("Upgrades") || cleanTitle.contains("Guild Tower"); + boolean isBonusesMenu = cleanTitle.contains("Bonuses") || cleanTitle.contains("Bonus"); + + if ((currentLoadouts == null || currentLoadouts.isEmpty()) && sessionLoadouts.isEmpty()) { + return new GuideStep(ActionType.NONE, "Waiting for territory menu data...", List.of(), null, 0, 0); + } + + if (!isSelectScreen && pendingBatchTerritories != null && pendingBatchLoadout != null) { + commitPendingBatch(); + } + + // Cache live loadouts into session memory. In "Select Territories", selected slots preview the + // new loadout in their lore, so do not overwrite pre-selection sessionLoadouts there. + if (currentLoadouts != null) { + for (Map.Entry e : currentLoadouts.entrySet()) { + String key = canonicalTerritoryName(e.getKey()); + if (isSelectScreen || isLoadoutsMenu) { + sessionLoadouts.putIfAbsent(key, e.getValue()); + } else { + sessionLoadouts.put(key, e.getValue()); + } + } + } + + Set owned = new HashSet<>(); + for (String k : sessionLoadouts.keySet()) { + owned.add(cleanName(k).toLowerCase()); + } + if (currentLoadouts != null) { + for (String k : currentLoadouts.keySet()) { + owned.add(cleanName(k).toLowerCase()); + } + } + + Map lookupMap = (isSelectScreen || isLoadoutsMenu) ? Map.of() : currentLoadouts; + + // Phase 1: Bulk Fake Medium (Only for territories owned by Eden) + List needFakeMedium = new ArrayList<>(); + if (!skippedFakeMedium) { + for (Map.Entry entry : activePreset.territories().entrySet()) { + String name = entry.getKey(); + if (activePreset.isExcluded(name)) + continue; + if (!owned.contains(cleanName(name).toLowerCase())) + continue; // Voided: not owned / not in menu + if (isFakeMedium(entry.getValue())) { + TerritoryPreset.TerritoryLoadout current = getCaseInsensitive(lookupMap, name); + if (!isFakeMedium(current)) { + needFakeMedium.add(name); + } + } + } + } + + if (!needFakeMedium.isEmpty()) { + if (isSelectScreen) { + List unselected = new ArrayList<>(); + for (String name : needFakeMedium) { + boolean isSelected = false; + if (liveSelectedTerritories != null) { + for (String sel : liveSelectedTerritories) { + if (cleanName(sel).equalsIgnoreCase(cleanName(name))) { + isSelected = true; + break; + } + } + } + if (!isSelected) { + unselected.add(name); + } + } + if (!unselected.isEmpty()) { + pendingBatchTerritories = null; + pendingBatchLoadout = null; + int done = needFakeMedium.size() - unselected.size(); + return new GuideStep(ActionType.SELECT_TERRITORIES, "Click every Fake Medium territory (" + done + "/" + needFakeMedium.size() + " selected): " + String.join(", ", unselected), unselected, null, needFakeMedium.size(), done); + } else { + pendingBatchTerritories = new ArrayList<>(needFakeMedium); + pendingBatchLoadout = FAKE_MEDIUM; + return new GuideStep(ActionType.DOUBLE_CLICK_APPLY_BUTTON, "All Fake Medium territories selected! Click to confirm selection (Apply ✔).", List.of(), "Apply", needFakeMedium.size(), needFakeMedium.size()); + } + } else if (isLoadoutsMenu) { + return new GuideStep(ActionType.CLICK_FAKE_MEDIUM_OPTION, "Click on the [Fake Medium] option in this menu.", needFakeMedium, "Fake Medium", needFakeMedium.size(), 0); + } else { + return new GuideStep(ActionType.CLICK_LOADOUTS_BUTTON, "Step 1: Click the [Territory Loadouts] button to apply Fake Medium to " + needFakeMedium.size() + " territories.", needFakeMedium, "Territory Loadouts", needFakeMedium.size(), 0); + } + } + + // Phase 2: Bulk Fake High (Only for territories owned by Eden) + List needFakeHigh = new ArrayList<>(); + if (!skippedFakeHigh) { + for (Map.Entry entry : activePreset.territories().entrySet()) { + String name = entry.getKey(); + if (activePreset.isExcluded(name)) + continue; + if (!owned.contains(cleanName(name).toLowerCase())) + continue; // Voided: not owned / not in menu + if (isFakeHigh(entry.getValue())) { + TerritoryPreset.TerritoryLoadout current = getCaseInsensitive(lookupMap, name); + if (!isFakeHigh(current)) { + needFakeHigh.add(name); + } + } + } + } + + if (!needFakeHigh.isEmpty()) { + if (isSelectScreen) { + List unselected = new ArrayList<>(); + for (String name : needFakeHigh) { + boolean isSelected = false; + if (liveSelectedTerritories != null) { + for (String sel : liveSelectedTerritories) { + if (cleanName(sel).equalsIgnoreCase(cleanName(name))) { + isSelected = true; + break; + } + } + } + if (!isSelected) { + unselected.add(name); + } + } + if (!unselected.isEmpty()) { + pendingBatchTerritories = null; + pendingBatchLoadout = null; + int done = needFakeHigh.size() - unselected.size(); + return new GuideStep(ActionType.SELECT_TERRITORIES, "Click every Fake High territory (" + done + "/" + needFakeHigh.size() + " selected): " + String.join(", ", unselected), unselected, null, needFakeHigh.size(), done); + } else { + pendingBatchTerritories = new ArrayList<>(needFakeHigh); + pendingBatchLoadout = FAKE_HIGH; + return new GuideStep(ActionType.DOUBLE_CLICK_APPLY_BUTTON, "All Fake High territories selected! Click to confirm selection (Apply ✔).", List.of(), "Apply", needFakeHigh.size(), needFakeHigh.size()); + } + } else if (isLoadoutsMenu) { + return new GuideStep(ActionType.CLICK_FAKE_HIGH_OPTION, "Click on the [Fake High] option in this menu.", needFakeHigh, "Fake High", needFakeHigh.size(), 0); + } else { + return new GuideStep(ActionType.CLICK_LOADOUTS_BUTTON, "Step 2: Click the [Territory Loadouts] button to apply Fake High to " + needFakeHigh.size() + " territories.", needFakeHigh, "Territory Loadouts", needFakeHigh.size(), 0); + } + } + + // Phase 3: Individual Territories (Only for territories owned by Eden) + int totalOwnedInPreset = 0; + List needIndividual = new ArrayList<>(); + for (Map.Entry entry : activePreset.territories().entrySet()) { + String name = entry.getKey(); + if (activePreset.isExcluded(name)) + continue; + if (!owned.contains(cleanName(name).toLowerCase())) + continue; // Voided: not owned / not in menu + if (isFakeMedium(entry.getValue()) && skippedFakeMedium) + continue; + if (isFakeHigh(entry.getValue()) && skippedFakeHigh) + continue; + totalOwnedInPreset++; + TerritoryPreset.TerritoryLoadout current = getCaseInsensitive(lookupMap, name); + if (remainingClicks(current, entry.getValue()) > 0) { + needIndividual.add(name); + } + } + + if (needIndividual.isEmpty()) { + return new GuideStep(ActionType.ALL_COMPLETE, "✔ All " + totalOwnedInPreset + " territories match the preset!", List.of(), null, totalOwnedInPreset, totalOwnedInPreset); + } + + // Determine active target territory + if (activeTarget == null || !needIndividual.contains(activeTarget) || isSkipped(activeTarget)) { + activeTarget = null; + for (String cand : needIndividual) { + if (!isSkipped(cand)) { + activeTarget = cand; + break; + } + } + if (activeTarget == null) { + skippedFakeMedium = false; + skippedFakeHigh = false; + skippedQueue.clear(); + return evaluateInternal(currentLoadouts, liveSelectedTerritories, containerTitle, isSelectionMode); + } + } + + String targetTerritory = activeTarget; + TerritoryPreset.TerritoryLoadout targetLoadout = activePreset.loadout(targetTerritory); + TerritoryPreset.TerritoryLoadout currentLoadout = getCaseInsensitive(lookupMap, targetTerritory); + + if (isUpgradesMenu) { + int upDiff = Math.abs(targetLoadout.damage() - currentLoadout.damage()) + Math.abs(targetLoadout.attack() - currentLoadout.attack()) + Math.abs(targetLoadout.health() - currentLoadout.health()) + Math.abs(targetLoadout.defence() - currentLoadout.defence()); + if (upDiff <= 0) { + return new GuideStep(ActionType.CLICK_BACK_BUTTON, "✔ Upgrades match! Click [Back].", List.of(targetTerritory), "Back", needIndividual.size(), 0); + } + return new GuideStep(ActionType.UPGRADE_SLOTS, "Click highlighted upgrade slots to match preset (" + targetTerritory + ").", List.of(targetTerritory), null, needIndividual.size(), 0); + } + + if (isBonusesMenu) { + int bonusClicks = 0; + Set allBonuses = new HashSet<>(); + if (targetLoadout.bonuses() != null) + allBonuses.addAll(targetLoadout.bonuses().keySet()); + if (currentLoadout.bonuses() != null) + allBonuses.addAll(currentLoadout.bonuses().keySet()); + for (String bName : allBonuses) { + bonusClicks += Math.abs(targetLoadout.bonus(bName) - currentLoadout.bonus(bName)); + } + if (bonusClicks <= 0) { + return new GuideStep(ActionType.CLICK_BACK_BUTTON, "✔ Bonuses match! Click [Back].", List.of(targetTerritory), "Back", needIndividual.size(), 0); + } + return new GuideStep(ActionType.UPGRADE_SLOTS, "Click highlighted bonus slots to match preset (" + targetTerritory + ").", List.of(targetTerritory), null, needIndividual.size(), 0); + } + + // Check if inside target territory's overview menu (: Manage) + String titleTerritory = TerritoryContainerScanner.parseTerritoryFromUpgradeTitle(cleanTitle); + if (titleTerritory != null && titleTerritory.equalsIgnoreCase(targetTerritory)) { + boolean needUpgrades = targetLoadout.damage() != currentLoadout.damage() || targetLoadout.attack() != currentLoadout.attack() || targetLoadout.health() != currentLoadout.health() || targetLoadout.defence() != currentLoadout.defence(); + if (needUpgrades) { + return new GuideStep(ActionType.CLICK_UPGRADES_BUTTON, "Click [Guild Tower] to configure tower defense.", List.of(targetTerritory), "Guild Tower", needIndividual.size(), 0); + } + boolean needBonuses = false; + Set checkBonuses = new HashSet<>(); + if (targetLoadout.bonuses() != null) + checkBonuses.addAll(targetLoadout.bonuses().keySet()); + if (currentLoadout.bonuses() != null) + checkBonuses.addAll(currentLoadout.bonuses().keySet()); + for (String b : checkBonuses) { + if (targetLoadout.bonus(b) != currentLoadout.bonus(b)) { + needBonuses = true; + break; + } + } + if (needBonuses) { + return new GuideStep(ActionType.CLICK_BONUSES_BUTTON, "Click [Bonus] to configure territory bonuses.", List.of(targetTerritory), "Bonus", needIndividual.size(), 0); + } + return new GuideStep(ActionType.CLICK_BACK_BUTTON, "✔ All upgrades complete for " + targetTerritory + "! Click [Back].", List.of(targetTerritory), "Back", needIndividual.size(), 0); + } + + return new GuideStep(ActionType.OPEN_TERRITORY, "Next territory: Click [" + targetTerritory + "] (" + remainingClicks(currentLoadout, targetLoadout) + " clicks needed).", List.of(targetTerritory), null, needIndividual.size(), 0); + } +} diff --git a/src/tel/eden/mod/war/eco/TerritoryPreset.java b/src/tel/eden/mod/war/eco/TerritoryPreset.java new file mode 100644 index 0000000..4e2e3cf --- /dev/null +++ b/src/tel/eden/mod/war/eco/TerritoryPreset.java @@ -0,0 +1,107 @@ +package tel.eden.mod.war.eco; + +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * Immutable preset holding target upgrade levels, notes, and exclusions. + */ +public record TerritoryPreset(String name, String notes, Set excludedTerritories, Map territories) { + public TerritoryPreset { + name = (name == null || name.isBlank()) ? "Unnamed Preset" : name.trim(); + notes = (notes == null) ? "" : notes.trim(); + excludedTerritories = (excludedTerritories == null) ? Set.of() : Set.copyOf(excludedTerritories); + Map cleanTerrs = new java.util.LinkedHashMap<>(); + if (territories != null) { + Map caseInsensitive = new java.util.TreeMap<>(String.CASE_INSENSITIVE_ORDER); + for (Map.Entry entry : territories.entrySet()) { + if (entry.getKey() == null) + continue; + String clean = entry.getKey().trim(); + if (clean.endsWith("(HQ)")) { + clean = clean.substring(0, clean.length() - 4).trim(); + } + if (!clean.isEmpty()) { + if (!caseInsensitive.containsKey(clean) || Character.isUpperCase(clean.charAt(0))) { + caseInsensitive.put(clean, entry.getValue()); + } + } + } + cleanTerrs.putAll(caseInsensitive); + } + territories = Collections.unmodifiableMap(cleanTerrs); + } + + public static TerritoryPreset empty(String name) { + return new TerritoryPreset(name, "", Set.of(), Map.of()); + } + + public boolean isExcluded(String territory) { + return excludedTerritories.contains(territory); + } + + public TerritoryLoadout loadout(String territory) { + if (territory == null) + return TerritoryLoadout.ZERO; + TerritoryLoadout exact = territories.get(territory); + if (exact != null) + return exact; + for (Map.Entry e : territories.entrySet()) { + if (e.getKey().equalsIgnoreCase(territory)) { + return e.getValue(); + } + } + return TerritoryLoadout.ZERO; + } + + public TerritoryPreset withToggledExclusion(String territory) { + Set next = new HashSet<>(excludedTerritories); + if (!next.add(territory)) { + next.remove(territory); + } + return new TerritoryPreset(name, notes, next, territories); + } + + public TerritoryPreset withNotes(String newNotes) { + return new TerritoryPreset(name, newNotes, excludedTerritories, territories); + } + + public TerritoryPreset withLoadout(String territory, TerritoryLoadout loadout) { + Map map = new HashMap<>(territories); + if (loadout == null || loadout.isZero()) { + map.remove(territory); + } else { + map.put(territory, loadout); + } + return new TerritoryPreset(name, notes, excludedTerritories, map); + } + + public record TerritoryLoadout(int damage, int attack, int health, int defence, Map bonuses) { + public static final TerritoryLoadout ZERO = new TerritoryLoadout(0, 0, 0, 0, Map.of()); + + public TerritoryLoadout { + bonuses = (bonuses == null) ? Map.of() : Collections.unmodifiableMap(new HashMap<>(bonuses)); + } + + public int bonus(String name) { + if (name == null || bonuses == null) + return 0; + Integer val = bonuses.get(name); + if (val != null) + return val; + for (Map.Entry e : bonuses.entrySet()) { + if (e.getKey().equalsIgnoreCase(name)) { + return e.getValue(); + } + } + return 0; + } + + public boolean isZero() { + return damage == 0 && attack == 0 && health == 0 && defence == 0 && bonuses.isEmpty(); + } + } +} diff --git a/src/tel/eden/mod/war/eco/TerritoryPresetCodec.java b/src/tel/eden/mod/war/eco/TerritoryPresetCodec.java new file mode 100644 index 0000000..f01a934 --- /dev/null +++ b/src/tel/eden/mod/war/eco/TerritoryPresetCodec.java @@ -0,0 +1,256 @@ +package tel.eden.mod.war.eco; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.zip.Deflater; +import java.util.zip.DeflaterOutputStream; +import java.util.zip.InflaterInputStream; + +/** + * Dual codec for TerritoryPreset: compressed Base64 ("EDEN1:...") and JSON. + */ +public final class TerritoryPresetCodec { + private TerritoryPresetCodec() { + } + + public static final String PREFIX = "EDEN1:"; + private static final byte VERSION = 1; + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + + public static String encodeBase64(TerritoryPreset preset) { + if (preset == null) { + preset = TerritoryPreset.empty("Default"); + } + String name = (preset.name() != null) ? preset.name().trim() : ""; + if (name.isEmpty()) { + name = "Default"; + } + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + Deflater deflater = new Deflater(Deflater.BEST_COMPRESSION); + try (DeflaterOutputStream dos = new DeflaterOutputStream(baos, deflater); DataOutputStream out = new DataOutputStream(dos)) { + out.writeByte(VERSION); + out.writeUTF(name); + out.writeUTF(preset.notes()); + + // Excluded territories + Set excluded = preset.excludedTerritories(); + out.writeShort(excluded.size()); + for (String ex : excluded) { + out.writeUTF(ex); + } + + // Territories map + Map terrs = preset.territories(); + out.writeShort(terrs.size()); + for (Map.Entry entry : terrs.entrySet()) { + out.writeUTF(entry.getKey()); + TerritoryPreset.TerritoryLoadout l = entry.getValue(); + out.writeByte(l.damage()); + out.writeByte(l.attack()); + out.writeByte(l.health()); + out.writeByte(l.defence()); + + Map bonuses = l.bonuses(); + out.writeByte(bonuses.size()); + for (Map.Entry b : bonuses.entrySet()) { + out.writeUTF(b.getKey()); + out.writeByte(b.getValue()); + } + } + } finally { + deflater.end(); + } + return PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(baos.toByteArray()) + ":" + name; + } catch (IOException e) { + throw new RuntimeException("Failed to encode preset to Base64", e); + } + } + + public static final int MAX_INPUT_CHARS = 65_536; // 64 KB max input + public static final int MAX_DECOMPRESSED_BYTES = 1024 * 1024; // 1 MB max decompressed + public static final int MAX_TERRITORIES = 500; + public static final int MAX_NAME_LENGTH = 100; + public static final int MAX_NOTES_LENGTH = 2000; + + private static class LimitedInputStream extends java.io.FilterInputStream { + private int remaining; + + LimitedInputStream(java.io.InputStream in, int max) { + super(in); + this.remaining = max; + } + + @Override + public int read() throws IOException { + if (remaining <= 0) { + throw new IOException("Decompressed preset exceeded " + (MAX_DECOMPRESSED_BYTES / 1024 / 1024) + " MB limit"); + } + int b = super.read(); + if (b >= 0) { + remaining--; + } + return b; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + if (remaining <= 0) { + throw new IOException("Decompressed preset exceeded " + (MAX_DECOMPRESSED_BYTES / 1024 / 1024) + " MB limit"); + } + int toRead = Math.min(len, remaining); + int count = super.read(b, off, toRead); + if (count > 0) { + remaining -= count; + } + return count; + } + } + + public static TerritoryPreset decodeBase64(String input) { + if (input == null || !input.startsWith(PREFIX)) { + throw new IllegalArgumentException("Invalid Eden preset code: missing " + PREFIX + " prefix"); + } + if (input.length() > MAX_INPUT_CHARS) { + throw new IllegalArgumentException("Preset code too large (max " + (MAX_INPUT_CHARS / 1024) + " KB)"); + } + String payload = input.substring(PREFIX.length()).trim(); + String overrideName = null; + int colonIdx = payload.indexOf(':'); + if (colonIdx < 0) { + colonIdx = payload.indexOf('#'); + } + String base64; + if (colonIdx >= 0) { + base64 = payload.substring(0, colonIdx).trim(); + overrideName = payload.substring(colonIdx + 1).trim(); + if (overrideName.isEmpty()) { + overrideName = "Default"; + } else if (overrideName.length() > MAX_NAME_LENGTH) { + overrideName = overrideName.substring(0, MAX_NAME_LENGTH); + } + } else { + base64 = payload; + } + + try { + byte[] compressed = Base64.getUrlDecoder().decode(base64); + try (ByteArrayInputStream bais = new ByteArrayInputStream(compressed); InflaterInputStream iis = new InflaterInputStream(bais); LimitedInputStream lis = new LimitedInputStream(iis, MAX_DECOMPRESSED_BYTES); DataInputStream in = new DataInputStream(lis)) { + byte ver = in.readByte(); + if (ver != VERSION) { + throw new IllegalArgumentException("Unsupported preset version: " + ver); + } + String name = in.readUTF(); + if (name.length() > MAX_NAME_LENGTH) { + name = name.substring(0, MAX_NAME_LENGTH); + } + String notes = in.readUTF(); + if (notes.length() > MAX_NOTES_LENGTH) { + notes = notes.substring(0, MAX_NOTES_LENGTH); + } + + int exCount = in.readUnsignedShort(); + if (exCount > MAX_TERRITORIES) { + throw new IllegalArgumentException("Too many excluded territories (max " + MAX_TERRITORIES + ")"); + } + Set excluded = new HashSet<>(exCount); + for (int i = 0; i < exCount; i++) { + String ex = in.readUTF(); + if (ex.length() > MAX_NAME_LENGTH) { + ex = ex.substring(0, MAX_NAME_LENGTH); + } + excluded.add(ex); + } + + int terrCount = in.readUnsignedShort(); + if (terrCount > MAX_TERRITORIES) { + throw new IllegalArgumentException("Too many territories in preset (max " + MAX_TERRITORIES + ")"); + } + Map map = new HashMap<>(terrCount); + for (int i = 0; i < terrCount; i++) { + String tName = in.readUTF(); + if (tName.length() > MAX_NAME_LENGTH) { + tName = tName.substring(0, MAX_NAME_LENGTH); + } + int dmg = in.readUnsignedByte(); + int atk = in.readUnsignedByte(); + int hp = in.readUnsignedByte(); + int def = in.readUnsignedByte(); + + int bonusCount = in.readUnsignedByte(); + if (bonusCount > 50) { + throw new IllegalArgumentException("Too many bonuses in territory (max 50)"); + } + Map bonuses = new HashMap<>(bonusCount); + for (int b = 0; b < bonusCount; b++) { + String bName = in.readUTF(); + if (bName.length() > MAX_NAME_LENGTH) { + bName = bName.substring(0, MAX_NAME_LENGTH); + } + bonuses.put(bName, (int) in.readUnsignedByte()); + } + map.put(tName, new TerritoryPreset.TerritoryLoadout(dmg, atk, hp, def, bonuses)); + } + String finalName = (overrideName != null) ? overrideName : name; + if (finalName == null || finalName.isBlank()) { + finalName = "Default"; + } + return new TerritoryPreset(finalName, notes, excluded, map); + } + } catch (Exception e) { + throw new IllegalArgumentException("Corrupt preset code: " + e.getMessage(), e); + } + } + + public static String encodeJson(TerritoryPreset preset) { + return GSON.toJson(preset); + } + + public static TerritoryPreset decodeJson(String json) { + if (json == null || json.length() > MAX_INPUT_CHARS) { + throw new IllegalArgumentException("Preset JSON too large (max " + (MAX_INPUT_CHARS / 1024) + " KB)"); + } + TerritoryPreset preset = GSON.fromJson(json, TerritoryPreset.class); + if (preset != null) { + if (preset.territories() != null && preset.territories().size() > MAX_TERRITORIES) { + throw new IllegalArgumentException("Too many territories in preset JSON (max " + MAX_TERRITORIES + ")"); + } + if (preset.name() == null || preset.name().isBlank()) { + preset = new TerritoryPreset("Default", preset.notes(), preset.excludedTerritories(), preset.territories()); + } + } + return preset; + } + + public static TerritoryPreset parse(String input) { + if (input == null || input.isBlank()) { + return TerritoryPreset.empty("Default"); + } + if (input.length() > MAX_INPUT_CHARS) { + throw new IllegalArgumentException("Preset code too large (max " + (MAX_INPUT_CHARS / 1024) + " KB)"); + } + String trimmed = input.trim(); + if (trimmed.startsWith(PREFIX)) { + return decodeBase64(trimmed); + } + if (trimmed.startsWith("{")) { + return decodeJson(trimmed); + } + // Try raw Base64 if prefix was omitted + try { + return decodeBase64(PREFIX + trimmed); + } catch (Exception ignored) { + } + throw new IllegalArgumentException("Unrecognized preset format: must start with " + PREFIX + " or be a JSON object"); + } +} diff --git a/test/tel/eden/mod/war/eco/TerritoryPresetDeduplicationTest.java b/test/tel/eden/mod/war/eco/TerritoryPresetDeduplicationTest.java new file mode 100644 index 0000000..add56d5 --- /dev/null +++ b/test/tel/eden/mod/war/eco/TerritoryPresetDeduplicationTest.java @@ -0,0 +1,33 @@ +package tel.eden.mod.war.eco; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +public class TerritoryPresetDeduplicationTest { + + @Test + void testTerritoryPresetCaseAndHqDeduplication() { + Map loadouts = new HashMap<>(); + loadouts.put("cinfras outskirts", TerritoryPreset.TerritoryLoadout.ZERO); + loadouts.put("Cinfras Outskirts (HQ)", TerritoryPreset.TerritoryLoadout.ZERO); + loadouts.put("Thanos", TerritoryPreset.TerritoryLoadout.ZERO); + loadouts.put("thanos", TerritoryPreset.TerritoryLoadout.ZERO); + + TerritoryPreset preset = new TerritoryPreset("Test Claim", "Notes", Set.of(), loadouts); + assertEquals(2, preset.territories().size(), "Should deduplicate case differences and (HQ) suffix"); + assertTrue(preset.territories().containsKey("Cinfras Outskirts"), "Preferred title case key"); + assertTrue(preset.territories().containsKey("Thanos"), "Preferred title case key"); + assertFalse(preset.territories().containsKey("Cinfras Outskirts (HQ)")); + } + + @Test + void testCanonicalTerritoryName() { + assertEquals("Cinfras Outskirts", TerritoryGuideController.canonicalTerritoryName("§f§lÀCinfras Outskirts (HQ)")); + assertEquals("Cinfras Outskirts", TerritoryGuideController.canonicalTerritoryName("Cinfras Outskirts")); + assertEquals("Thanos", TerritoryGuideController.canonicalTerritoryName("§4§l[§c§l!§4§l] §fThanos")); + } +} diff --git a/test/tel/eden/mod/war/eco/TerritoryScannerTest.java b/test/tel/eden/mod/war/eco/TerritoryScannerTest.java new file mode 100644 index 0000000..c34411c --- /dev/null +++ b/test/tel/eden/mod/war/eco/TerritoryScannerTest.java @@ -0,0 +1,467 @@ +package tel.eden.mod.war.eco; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; + +public class TerritoryScannerTest { + + @Test + public void testParseTerritoryFromUpgradeTitle() { + // Clean ASCII + assertEquals("Cinfras Outskirts", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("Cinfras Outskirts: Upgrades")); + // With Wynncraft custom font glyphs (e.g. \u00C0 / À) + assertEquals("Cinfras Outskirts", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("\u00C0Cinfras Outskirts: Upgrades")); + assertEquals("Thanos", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("\u00C0[!] Thanos: Upgrades")); + // With trailing (HQ) + assertEquals("Cinfras Outskirts", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("\u00C0Cinfras Outskirts (HQ): Upgrades")); + // With Bonuses title + assertEquals("Cinfras Outskirts", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("\u00C0Cinfras Outskirts: Bonuses")); + // Clean territory overview title (no colon) + assertEquals("Path to Talor", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("Path to Talor")); + assertEquals("Dark Forest Village", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("Dark Forest Village")); + + // Submenu titles without colons + assertEquals("Twisted Ridge", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("Twisted Ridge Guild Tower")); + assertEquals("Twisted Ridge", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("Twisted Ridge Bonus")); + assertEquals("Cinfras Outskirts", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("\u00C0Cinfras Outskirts (HQ) Guild Tower")); + assertEquals("Mesquis Tower", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("Mesquis Tower Guild Tower")); + assertEquals("Mesquis Tower", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("Mesquis Tower Bonus")); + assertEquals("Mesquis Tower", TerritoryContainerScanner.parseTerritoryFromUpgradeTitle("Mesquis Tower")); + } + + @Test + public void testFakeMediumAndHighAbsoluteMatching() { + // Exact Fake Medium + TerritoryPreset.TerritoryLoadout exactMed = new TerritoryPreset.TerritoryLoadout(2, 2, 3, 2, Map.of("Tower Aura", 1, "Tower Volley", 1)); + assertTrue(TerritoryGuideController.isFakeMedium(exactMed)); + + // Fake Medium stats with extra economy bonus -> MUST NOT match Fake Medium + TerritoryPreset.TerritoryLoadout medWithEco = new TerritoryPreset.TerritoryLoadout(2, 2, 3, 2, Map.of("Tower Aura", 1, "Tower Volley", 1, "Larger Resource Storage", 2)); + assertFalse(TerritoryGuideController.isFakeMedium(medWithEco)); + + // Exact Fake High + TerritoryPreset.TerritoryLoadout exactHigh = new TerritoryPreset.TerritoryLoadout(5, 4, 5, 5, Map.of("Tower Aura", 1, "Tower Volley", 3)); + assertTrue(TerritoryGuideController.isFakeHigh(exactHigh)); + + // Fake High stats with extra bonus -> MUST NOT match Fake High + TerritoryPreset.TerritoryLoadout highWithEco = new TerritoryPreset.TerritoryLoadout(5, 4, 5, 5, Map.of("Tower Aura", 1, "Tower Volley", 3, "Emerald Rate", 3)); + assertFalse(TerritoryGuideController.isFakeHigh(highWithEco)); + } + + @Test + public void testBidirectionalRemainingClicks() { + TerritoryPreset.TerritoryLoadout target = new TerritoryPreset.TerritoryLoadout(2, 2, 3, 2, Map.of("Tower Aura", 1)); + + // Current has higher damage (needs right-click downgrade from 7 to 2 = 5 clicks) + TerritoryPreset.TerritoryLoadout overLeveled = new TerritoryPreset.TerritoryLoadout(7, 2, 3, 2, Map.of("Tower Aura", 1)); + assertEquals(5, TerritoryGuideController.remainingClicks(overLeveled, target)); + + // Current has unwanted bonus (needs right-click downgrade from 3 to 0 = 3 clicks) + TerritoryPreset.TerritoryLoadout extraBonus = new TerritoryPreset.TerritoryLoadout(2, 2, 3, 2, Map.of("Tower Aura", 1, "Tome Seeking", 3)); + assertEquals(3, TerritoryGuideController.remainingClicks(extraBonus, target)); + + // Both upgrade and downgrade needed + TerritoryPreset.TerritoryLoadout mixed = new TerritoryPreset.TerritoryLoadout(7, 1, 3, 2, Map.of("Tower Aura", 1, "Tome Seeking", 2)); + // Damage -5, Attack +1, Tome Seeking -2 -> 5 + 1 + 2 = 8 + assertEquals(8, TerritoryGuideController.remainingClicks(mixed, target)); + } + + @Test + public void testOverviewRoutingLogic() { + TerritoryPreset.TerritoryLoadout target = new TerritoryPreset.TerritoryLoadout(2, 2, 3, 2, Map.of("Tower Aura", 1, "Tower Volley", 1)); + TerritoryPreset.TerritoryLoadout currentZero = TerritoryPreset.TerritoryLoadout.ZERO; + + // When tower stats are incomplete -> needs tower + boolean needTower = target.damage() > currentZero.damage() || target.attack() > currentZero.attack() || target.health() > currentZero.health() || target.defence() > currentZero.defence(); + assertTrue(needTower); + + // When tower stats are complete, but bonuses incomplete -> needs bonus + TerritoryPreset.TerritoryLoadout currentTowerDone = new TerritoryPreset.TerritoryLoadout(2, 2, 3, 2, Map.of()); + boolean needTower2 = target.damage() > currentTowerDone.damage() || target.attack() > currentTowerDone.attack() || target.health() > currentTowerDone.health() || target.defence() > currentTowerDone.defence(); + assertFalse(needTower2); + boolean needBonuses = false; + for (Map.Entry b : target.bonuses().entrySet()) { + if (b.getValue() > currentTowerDone.bonus(b.getKey())) { + needBonuses = true; + break; + } + } + assertTrue(needBonuses); + + // When both tower stats and bonuses complete -> all complete (Back) + TerritoryPreset.TerritoryLoadout currentAllDone = new TerritoryPreset.TerritoryLoadout(2, 2, 3, 2, Map.of("Tower Aura", 1, "Tower Volley", 1)); + boolean needTower3 = target.damage() > currentAllDone.damage() || target.attack() > currentAllDone.attack() || target.health() > currentAllDone.health() || target.defence() > currentAllDone.defence(); + boolean needBonuses3 = false; + for (Map.Entry b : target.bonuses().entrySet()) { + if (b.getValue() > currentAllDone.bonus(b.getKey())) { + needBonuses3 = true; + break; + } + } + assertFalse(needTower3); + assertFalse(needBonuses3); + } + + @Test + public void testCanonicalUpgradeName() { + assertEquals("Damage", TerritoryContainerScanner.canonicalUpgradeName("Damage")); + assertEquals("Damage", TerritoryContainerScanner.canonicalUpgradeName("\u00C0Damage")); + assertEquals("Defence", TerritoryContainerScanner.canonicalUpgradeName("Defense")); + assertEquals("Tower Aura", TerritoryContainerScanner.canonicalUpgradeName("Aura")); + assertEquals("Tower Volley", TerritoryContainerScanner.canonicalUpgradeName("Tower Volley")); + assertEquals("Stronger Minions", TerritoryContainerScanner.canonicalUpgradeName("Stronger Mobs")); + assertEquals("Larger Resource Storage", TerritoryContainerScanner.canonicalUpgradeName("Resource Storage")); + assertEquals("Efficient Resources", TerritoryContainerScanner.canonicalUpgradeName("Efficient Resources")); + assertEquals("Tome Seeking", TerritoryContainerScanner.canonicalUpgradeName("Tome Seeking")); + } + + @Test + public void testRemainingClicksMatchingTarget() { + TerritoryPreset.TerritoryLoadout current = new TerritoryPreset.TerritoryLoadout(8, 8, 8, 8, Map.of("Tower Aura", 2, "Tower Volley", 1)); + TerritoryPreset.TerritoryLoadout target = new TerritoryPreset.TerritoryLoadout(8, 8, 8, 8, Map.of("Tower Aura", 2, "Tower Volley", 1)); + assertEquals(0, TerritoryGuideController.remainingClicks(current, target)); + + TerritoryPreset.TerritoryLoadout under = new TerritoryPreset.TerritoryLoadout(6, 8, 8, 7, Map.of("Tower Aura", 1, "Tower Volley", 1)); + assertEquals(2 + 1 + 1, TerritoryGuideController.remainingClicks(under, target)); + } + + @Test + public void testPresetCodecRoundTrip() { + Map map = Map.of("Detlas", TerritoryGuideController.FAKE_MEDIUM, "Ragni", TerritoryGuideController.FAKE_HIGH, "Almuj", new TerritoryPreset.TerritoryLoadout(8, 8, 8, 8, Map.of())); + TerritoryPreset preset = new TerritoryPreset("WarPreset", "Test notes", Set.of("Detlas"), map); + + // Base64 roundtrip + String encoded = TerritoryPresetCodec.encodeBase64(preset); + assertTrue(encoded.startsWith("EDEN1:")); + TerritoryPreset decoded = TerritoryPresetCodec.decodeBase64(encoded); + assertEquals("WarPreset", decoded.name()); + assertEquals("Test notes", decoded.notes()); + assertTrue(decoded.isExcluded("Detlas")); + assertFalse(decoded.isExcluded("Ragni")); + assertEquals(3, decoded.territories().size()); + assertTrue(TerritoryGuideController.isFakeMedium(decoded.loadout("Detlas"))); + assertTrue(TerritoryGuideController.isFakeHigh(decoded.loadout("Ragni"))); + + // Parse auto-detection + TerritoryPreset parsed = TerritoryPresetCodec.parse(encoded); + assertEquals("WarPreset", parsed.name()); + } + + @Test + public void testGuideControllerPhases() { + Map presetMap = Map.of("TerrA", TerritoryGuideController.FAKE_MEDIUM, "TerrB", TerritoryGuideController.FAKE_HIGH, "HQ", new TerritoryPreset.TerritoryLoadout(8, 8, 8, 8, Map.of())); + TerritoryPreset preset = new TerritoryPreset("Phased", "", Set.of(), presetMap); + TerritoryGuideController.setActivePreset(preset); + + // State: all at zero + Map current = Map.of("TerrA", TerritoryPreset.TerritoryLoadout.ZERO, "TerrB", TerritoryPreset.TerritoryLoadout.ZERO, "HQ", TerritoryPreset.TerritoryLoadout.ZERO); + + // Phase 1: Fake Medium needed -> instruct CLICK_LOADOUTS_BUTTON + TerritoryGuideController.GuideStep step1 = TerritoryGuideController.evaluate(current, Set.of(), "Territories", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_LOADOUTS_BUTTON, step1.action()); + assertEquals(List.of("TerrA"), step1.targetTerritories()); + + // Phase 1 in Selection screen with TerrA unselected + TerritoryGuideController.GuideStep stepSelect = TerritoryGuideController.evaluate(current, Set.of(), "Select Territories", true); + assertEquals(TerritoryGuideController.ActionType.SELECT_TERRITORIES, stepSelect.action()); + + // Phase 1 in Selection screen with TerrA selected -> DOUBLE_CLICK_APPLY_BUTTON + TerritoryGuideController.GuideStep stepApply = TerritoryGuideController.evaluate(current, Set.of("TerrA"), "Select Territories", true); + assertEquals(TerritoryGuideController.ActionType.DOUBLE_CLICK_APPLY_BUTTON, stepApply.action()); + + // State: TerrA has Fake Medium, TerrB at zero + Map current2 = Map.of("TerrA", TerritoryGuideController.FAKE_MEDIUM, "TerrB", TerritoryPreset.TerritoryLoadout.ZERO, "HQ", TerritoryPreset.TerritoryLoadout.ZERO); + + // Phase 2: Fake High needed + TerritoryGuideController.GuideStep step2 = TerritoryGuideController.evaluate(current2, Set.of(), "Territories", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_LOADOUTS_BUTTON, step2.action()); + assertEquals(List.of("TerrB"), step2.targetTerritories()); + + // State: TerrA has Fake Medium, TerrB has Fake High, HQ at zero + Map current3 = Map.of("TerrA", TerritoryGuideController.FAKE_MEDIUM, "TerrB", TerritoryGuideController.FAKE_HIGH, "HQ", TerritoryPreset.TerritoryLoadout.ZERO); + + // Phase 3: Individual territory HQ + TerritoryGuideController.GuideStep step3 = TerritoryGuideController.evaluate(current3, Set.of(), "Territories", false); + assertEquals(TerritoryGuideController.ActionType.OPEN_TERRITORY, step3.action()); + assertEquals(List.of("HQ"), step3.targetTerritories()); + + // State: all complete + Map currentComplete = Map.of("TerrA", TerritoryGuideController.FAKE_MEDIUM, "TerrB", TerritoryGuideController.FAKE_HIGH, "HQ", new TerritoryPreset.TerritoryLoadout(8, 8, 8, 8, Map.of())); + TerritoryGuideController.GuideStep stepDone = TerritoryGuideController.evaluate(currentComplete, Set.of(), "Territories", false); + assertEquals(TerritoryGuideController.ActionType.ALL_COMPLETE, stepDone.action()); + } + + @Test + public void testUnselectAndPresetManagement() { + TerritoryPreset preset = new TerritoryPreset("TestPreset", "", Set.of(), Map.of("Detlas", TerritoryGuideController.FAKE_MEDIUM)); + TerritoryGuideController.setActivePreset(preset); + assertTrue(TerritoryGuideController.hasActivePreset()); + assertEquals("TestPreset", TerritoryGuideController.activePreset().name()); + + TerritoryGuideController.unselectPreset(); + assertFalse(TerritoryGuideController.hasActivePreset()); + assertEquals("None", TerritoryGuideController.activePreset().name()); + + TerritoryGuideController.GuideStep step = TerritoryGuideController.evaluate(Map.of("Detlas", TerritoryPreset.TerritoryLoadout.ZERO), Set.of(), "Territories", false); + assertEquals(TerritoryGuideController.ActionType.NONE, step.action()); + assertTrue(step.targetTerritories().isEmpty()); + } + + @Test + public void testBatchLoadoutSelectionPreservesTargetsWhenSlotPreviewsNewLore() { + Map presetMap = new java.util.LinkedHashMap<>(); + presetMap.put("Med1", TerritoryGuideController.FAKE_MEDIUM); + presetMap.put("Med2", TerritoryGuideController.FAKE_MEDIUM); + presetMap.put("High1", TerritoryGuideController.FAKE_HIGH); + presetMap.put("High2", TerritoryGuideController.FAKE_HIGH); + TerritoryGuideController.setActivePreset(new TerritoryPreset("BatchTest", "", Set.of(), presetMap)); + + Map initial = Map.of("Med1", TerritoryPreset.TerritoryLoadout.ZERO, "Med2", TerritoryPreset.TerritoryLoadout.ZERO, "High1", TerritoryPreset.TerritoryLoadout.ZERO, "High2", TerritoryPreset.TerritoryLoadout.ZERO); + + // 1. On Eden: Territories -> prompts CLICK_LOADOUTS_BUTTON for both Med1 and Med2 + TerritoryGuideController.GuideStep s1 = TerritoryGuideController.evaluate(initial, Set.of(), "Eden: Territories", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_LOADOUTS_BUTTON, s1.action()); + assertEquals(Set.of("Med1", "Med2"), Set.copyOf(s1.targetTerritories())); + + // 2. On Eden's Territory Loadouts (empty live map) -> prompts CLICK_FAKE_MEDIUM_OPTION + TerritoryGuideController.GuideStep s2 = TerritoryGuideController.evaluate(Map.of(), Set.of(), "Eden's Territory Loadouts", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_FAKE_MEDIUM_OPTION, s2.action()); + + // 3. In Select Territories: user clicks Med1, and Wynncraft previews FAKE_MEDIUM lore on Med1's item + Map afterClickMed1 = Map.of("Med1", TerritoryGuideController.FAKE_MEDIUM, "Med2", TerritoryPreset.TerritoryLoadout.ZERO, "High1", TerritoryPreset.TerritoryLoadout.ZERO, "High2", TerritoryPreset.TerritoryLoadout.ZERO); + TerritoryGuideController.GuideStep s3 = TerritoryGuideController.evaluate(afterClickMed1, Set.of("Med1"), "Select Territories", true); + assertEquals(TerritoryGuideController.ActionType.SELECT_TERRITORIES, s3.action()); + assertEquals(List.of("Med2"), s3.targetTerritories()); + assertEquals(2, s3.totalCount()); + assertEquals(1, s3.completedCount()); + + // 4. User clicks Med2 as well (both preview FAKE_MEDIUM in Select Territories) -> must prompt DOUBLE_CLICK_APPLY_BUTTON + Map afterClickBoth = Map.of("Med1", TerritoryGuideController.FAKE_MEDIUM, "Med2", TerritoryGuideController.FAKE_MEDIUM, "High1", TerritoryPreset.TerritoryLoadout.ZERO, "High2", TerritoryPreset.TerritoryLoadout.ZERO); + TerritoryGuideController.GuideStep s4 = TerritoryGuideController.evaluate(afterClickBoth, Set.of("Med1", "Med2"), "Select Territories", true); + assertEquals(TerritoryGuideController.ActionType.DOUBLE_CLICK_APPLY_BUTTON, s4.action()); + assertEquals(2, s4.totalCount()); + assertEquals(2, s4.completedCount()); + + // 5. Return to Eden: Territories (even if only Page 1 loaded so far) -> moves to Fake High batch + Map page1AfterApply = Map.of("Med1", TerritoryGuideController.FAKE_MEDIUM, "High1", TerritoryPreset.TerritoryLoadout.ZERO); + TerritoryGuideController.GuideStep s5 = TerritoryGuideController.evaluate(page1AfterApply, Set.of(), "Eden: Territories", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_LOADOUTS_BUTTON, s5.action()); + assertEquals(Set.of("High1", "High2"), Set.copyOf(s5.targetTerritories())); + + // 6. On Eden's Territory Loadouts -> now prompts CLICK_FAKE_HIGH_OPTION + TerritoryGuideController.GuideStep s6 = TerritoryGuideController.evaluate(Map.of(), Set.of(), "Eden's Territory Loadouts", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_FAKE_HIGH_OPTION, s6.action()); + } + + @Test + public void testPresetTrailingNameEncodingAndDefaults() { + TerritoryPreset preset = new TerritoryPreset("My Preset 1", "Notes", Set.of(), Map.of("Detlas", TerritoryGuideController.FAKE_MEDIUM)); + String encoded = TerritoryPresetCodec.encodeBase64(preset); + // Trailing name format: EDEN1::My Preset 1 + assertTrue(encoded.startsWith("EDEN1:")); + assertTrue(encoded.endsWith(":My Preset 1")); + + // Decoding parses trailing name + TerritoryPreset decoded = TerritoryPresetCodec.decodeBase64(encoded); + assertEquals("My Preset 1", decoded.name()); + + // When trailing name is empty or blank -> defaults to "Default" + String emptyTrailing = encoded.substring(0, encoded.lastIndexOf(':') + 1) + " "; + TerritoryPreset decodedEmpty = TerritoryPresetCodec.decodeBase64(emptyTrailing); + assertEquals("Default", decodedEmpty.name()); + + // Backwards compatibility: decode legacy preset without trailing colon + String legacy = encoded.substring(0, encoded.lastIndexOf(':')); + TerritoryPreset decodedLegacy = TerritoryPresetCodec.decodeBase64(legacy); + assertEquals("My Preset 1", decodedLegacy.name()); + } + + @Test + public void testSkipCurrentInstruction() { + Map presetMap = new java.util.LinkedHashMap<>(); + presetMap.put("Med1", TerritoryGuideController.FAKE_MEDIUM); + presetMap.put("High1", TerritoryGuideController.FAKE_HIGH); + presetMap.put("Custom1", new TerritoryPreset.TerritoryLoadout(8, 8, 8, 8, Map.of())); + presetMap.put("Custom2", new TerritoryPreset.TerritoryLoadout(7, 7, 7, 7, Map.of())); + TerritoryGuideController.setActivePreset(new TerritoryPreset("SkipTest", "", Set.of(), presetMap)); + + Map current = Map.of("Med1", TerritoryPreset.TerritoryLoadout.ZERO, "High1", TerritoryPreset.TerritoryLoadout.ZERO, "Custom1", TerritoryPreset.TerritoryLoadout.ZERO, "Custom2", TerritoryPreset.TerritoryLoadout.ZERO); + + // Step 1: Fake Medium is evaluated + TerritoryGuideController.GuideStep s1 = TerritoryGuideController.evaluate(current, Set.of(), "Eden: Territories", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_LOADOUTS_BUTTON, s1.action()); + assertEquals(List.of("Med1"), s1.targetTerritories()); + + // Skip Fake Medium instruction + TerritoryGuideController.skipCurrentInstruction(); + + // Step 2: Now advances to Fake High + TerritoryGuideController.GuideStep s2 = TerritoryGuideController.evaluate(current, Set.of(), "Eden: Territories", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_LOADOUTS_BUTTON, s2.action()); + assertEquals(List.of("High1"), s2.targetTerritories()); + + // Skip Fake High instruction + TerritoryGuideController.skipCurrentInstruction(); + + // Step 3: Now advances to Phase 3 Custom1 + TerritoryGuideController.GuideStep s3 = TerritoryGuideController.evaluate(current, Set.of(), "Eden: Territories", false); + assertEquals(TerritoryGuideController.ActionType.OPEN_TERRITORY, s3.action()); + assertEquals(List.of("Custom1"), s3.targetTerritories()); + + // Skip Custom1 + TerritoryGuideController.skipCurrentInstruction(); + + // Step 4: Now advances to Custom2 + TerritoryGuideController.GuideStep s4 = TerritoryGuideController.evaluate(current, Set.of(), "Eden: Territories", false); + assertEquals(TerritoryGuideController.ActionType.OPEN_TERRITORY, s4.action()); + assertEquals(List.of("Custom2"), s4.targetTerritories()); + + // Skip Custom2 -> All skipped, so instructions loop back to Fake Medium! + TerritoryGuideController.skipForward(); + TerritoryGuideController.GuideStep sLoop = TerritoryGuideController.evaluate(current, Set.of(), "Eden: Territories", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_LOADOUTS_BUTTON, sLoop.action()); + assertEquals(List.of("Med1"), sLoop.targetTerritories()); + + // Test skipBackwards: + // Going backwards from Fake Medium loops to Custom2 + TerritoryGuideController.skipBackwards(); + TerritoryGuideController.GuideStep sBack1 = TerritoryGuideController.evaluate(current, Set.of(), "Eden: Territories", false); + assertEquals(TerritoryGuideController.ActionType.OPEN_TERRITORY, sBack1.action()); + assertEquals(List.of("Custom2"), sBack1.targetTerritories()); + + // Going backwards from Custom2 returns to Custom1 + TerritoryGuideController.skipBackwards(); + TerritoryGuideController.GuideStep sBack2 = TerritoryGuideController.evaluate(current, Set.of(), "Eden: Territories", false); + assertEquals(TerritoryGuideController.ActionType.OPEN_TERRITORY, sBack2.action()); + assertEquals(List.of("Custom1"), sBack2.targetTerritories()); + + // Going backwards from Custom1 returns to Fake High + TerritoryGuideController.skipBackwards(); + TerritoryGuideController.GuideStep sBack3 = TerritoryGuideController.evaluate(current, Set.of(), "Eden: Territories", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_LOADOUTS_BUTTON, sBack3.action()); + assertEquals(List.of("High1"), sBack3.targetTerritories()); + + // Going backwards from Fake High returns to Fake Medium + TerritoryGuideController.skipBackwards(); + TerritoryGuideController.GuideStep sBack4 = TerritoryGuideController.evaluate(current, Set.of(), "Eden: Territories", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_LOADOUTS_BUTTON, sBack4.action()); + assertEquals(List.of("Med1"), sBack4.targetTerritories()); + } + + @Test + public void testApplyLoadoutReturnsToLoadoutContainerInstructsBack() { + Map presetMap = Map.of("Terr1", TerritoryGuideController.FAKE_MEDIUM, "Terr2", TerritoryGuideController.FAKE_MEDIUM); + TerritoryGuideController.setActivePreset(new TerritoryPreset("LoadoutReturnTest", "", Set.of(), presetMap)); + + Map initial = Map.of("Terr1", TerritoryPreset.TerritoryLoadout.ZERO, "Terr2", TerritoryPreset.TerritoryLoadout.ZERO); + + // 1. Initial screen: Eden: Territories -> prompts CLICK_LOADOUTS_BUTTON + TerritoryGuideController.GuideStep step1 = TerritoryGuideController.evaluate(initial, Set.of(), "Eden: Territories", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_LOADOUTS_BUTTON, step1.action()); + + // 2. Open loadouts container: Eden's Territory Loadouts -> prompts CLICK_FAKE_MEDIUM_OPTION + TerritoryGuideController.GuideStep step2 = TerritoryGuideController.evaluate(Map.of(), Set.of(), "Eden's Territory Loadouts", false); + assertEquals(TerritoryGuideController.ActionType.CLICK_FAKE_MEDIUM_OPTION, step2.action()); + + // 3. User selects both territories in Select Territories -> prompts DOUBLE_CLICK_APPLY_BUTTON + TerritoryGuideController.GuideStep step3 = TerritoryGuideController.evaluate(initial, Set.of("Terr1", "Terr2"), "Select Territories", true); + assertEquals(TerritoryGuideController.ActionType.DOUBLE_CLICK_APPLY_BUTTON, step3.action()); + + // 4. User applies; Wynncraft loads the player back into Eden's Territory Loadouts + // Pending batch must be committed to sessionLoadouts, NOT prompt CLICK_FAKE_MEDIUM_OPTION again! + TerritoryGuideController.GuideStep step4 = TerritoryGuideController.evaluate(Map.of(), Set.of(), "Eden's Territory Loadouts", false); + assertFalse(step4.action() == TerritoryGuideController.ActionType.CLICK_FAKE_MEDIUM_OPTION, "Should not re-prompt Fake Medium after applying!"); + assertEquals(TerritoryGuideController.ActionType.ALL_COMPLETE, step4.action()); + } + + @Test + public void testPresetSecurityLimits() throws Exception { + // 1. Rejects codes > 64 KB (MAX_INPUT_CHARS) + String hugeCode = TerritoryPresetCodec.PREFIX + "A".repeat(TerritoryPresetCodec.MAX_INPUT_CHARS + 10); + assertThrows(IllegalArgumentException.class, () -> TerritoryPresetCodec.parse(hugeCode)); + + // 2. Rejects zip bomb expanding to > 1 MB + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); + java.util.zip.Deflater deflater = new java.util.zip.Deflater(java.util.zip.Deflater.BEST_COMPRESSION); + try (java.util.zip.DeflaterOutputStream dos = new java.util.zip.DeflaterOutputStream(baos, deflater); java.io.DataOutputStream out = new java.io.DataOutputStream(dos)) { + out.writeByte(1); + out.writeUTF("Bomb"); + byte[] payload = new byte[1_200_000]; // 1.2 MB > 1 MB limit + java.util.Arrays.fill(payload, (byte) 'A'); + out.writeInt(payload.length); + out.write(payload); + } finally { + deflater.end(); + } + String bombCode = TerritoryPresetCodec.PREFIX + java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(baos.toByteArray()); + assertThrows(IllegalArgumentException.class, () -> TerritoryPresetCodec.decodeBase64(bombCode)); + + // 3. Rejects presets with > 500 territories + java.io.ByteArrayOutputStream baos2 = new java.io.ByteArrayOutputStream(); + java.util.zip.Deflater deflater2 = new java.util.zip.Deflater(); + try (java.util.zip.DeflaterOutputStream dos = new java.util.zip.DeflaterOutputStream(baos2, deflater2); java.io.DataOutputStream out = new java.io.DataOutputStream(dos)) { + out.writeByte(1); + out.writeUTF("TooMany"); + out.writeUTF(""); + out.writeShort(0); + out.writeShort(501); // 501 > 500 max + } finally { + deflater2.end(); + } + String tooManyCode = TerritoryPresetCodec.PREFIX + java.util.Base64.getUrlEncoder().withoutPadding().encodeToString(baos2.toByteArray()); + assertThrows(IllegalArgumentException.class, () -> TerritoryPresetCodec.decodeBase64(tooManyCode)); + } + + @Test + public void testUniquePresetNaming() { + Map existing = new java.util.LinkedHashMap<>(); + existing.put("Default", "code1"); + existing.put("Claim Oct 18 12:37", "code2"); + existing.put("Claim Oct 18 12:37 (2)", "code3"); + + // Non-conflicting name remains unchanged + assertEquals("My Preset", TerritoryGuideController.getUniquePresetName(existing, "My Preset")); + + // Conflicting name appends (2) + assertEquals("Default (2)", TerritoryGuideController.getUniquePresetName(existing, "Default")); + + // Case-insensitive conflict + assertEquals("Default (2)", TerritoryGuideController.getUniquePresetName(existing, "default")); + + // Multiple conflicts increment counter + assertEquals("Claim Oct 18 12:37 (3)", TerritoryGuideController.getUniquePresetName(existing, "Claim Oct 18 12:37")); + } + + @Test + public void testKnownTerritoryAndMenuDetection() { + TerritoryPreset preset = new TerritoryPreset("Test", "", Set.of(), Map.of("Cinfras Outskirts", new TerritoryPreset.TerritoryLoadout(0, 0, 0, 0, Map.of()), "Mesquis Tower", new TerritoryPreset.TerritoryLoadout(0, 0, 0, 0, Map.of()))); + TerritoryGuideController.setActivePreset(preset); + + // Preset territories are known + assertTrue(TerritoryGuideController.isKnownTerritory("Cinfras Outskirts")); + assertTrue(TerritoryGuideController.isKnownTerritory("Mesquis Tower")); + assertTrue(TerritoryGuideController.isKnownTerritory("mesquis tower")); + + // Clean territory title removes (HQ) and special chars + assertEquals("Cinfras Outskirts", TerritoryContainerScanner.cleanTerritoryTitle("§f§lÀCinfras Outskirts (HQ)")); + assertEquals("Mesquis Tower", TerritoryContainerScanner.cleanTerritoryTitle("Mesquis Tower")); + + // Unrelated menus and chests are NOT known territories + assertFalse(TerritoryGuideController.isKnownTerritory("Chest")); + assertFalse(TerritoryGuideController.isKnownTerritory("Large Chest")); + assertFalse(TerritoryGuideController.isKnownTerritory("Bank")); + assertFalse(TerritoryGuideController.isKnownTerritory("Daily Login")); + assertFalse(TerritoryGuideController.isKnownTerritory("Bonus")); + assertFalse(TerritoryGuideController.isKnownTerritory("Guild Tower")); + assertFalse(TerritoryGuideController.isKnownTerritory("")); + assertFalse(TerritoryGuideController.isKnownTerritory(null)); + } +}