diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 8ec8d3a..73aa794 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -34,6 +34,7 @@ import tel.eden.mod.chat.TerritoryDefendedEvent; import tel.eden.mod.chat.TerritoryDefendedTracker; import tel.eden.mod.config.BridgeConfig; +import tel.eden.mod.guild.GuildAllyManager; import tel.eden.mod.guild.GuildLogSync; import tel.eden.mod.guild.GuildMenuScraper; import tel.eden.mod.guild.GuildRewardStorageSnapshot; @@ -47,6 +48,7 @@ import tel.eden.mod.item.DecodedItem; import tel.eden.mod.item.ItemCardRenderer; import tel.eden.mod.item.ItemStringDetector; +import tel.eden.mod.net.AllyActivityEntry; import tel.eden.mod.net.BridgeWebSocketClient; import tel.eden.mod.net.PartyInfo; import tel.eden.mod.net.GiveawayCandidate; @@ -175,6 +177,7 @@ public final class EdenModClient implements ClientModInitializer { private final BackgroundContainerSession backgroundContainers = new BackgroundContainerSession(); private final GuildRewards guildRewards = new GuildRewards(backgroundContainers); private final GuildLogSync guildLogSync = new GuildLogSync(backgroundContainers); + private final GuildAllyManager guildAllyManager = new GuildAllyManager(backgroundContainers); private final List trackedCommandKeybinds = new ArrayList<>(); private KeyMapping openConfigKey; private KeyMapping createPartyKey; @@ -492,6 +495,31 @@ public GuildLogSync guildLogSync() { return guildLogSync; } + public GuildAllyManager guildAllyManager() { + return guildAllyManager; + } + + /** + * Whether the player is a Chief, Owner, or Strategist according to the bridge or Wynncraft. + * If rank has not yet been fetched/verified, defaults to true so legitimate Strategists + * are not locked out; the in-game diplomacy menu validates permissions definitively. + */ + public boolean isChiefOwnerOrStrategist() { + if (liveGuildRank != null && !liveGuildRank.isBlank()) { + return liveGuildRank.equalsIgnoreCase("owner") || liveGuildRank.equalsIgnoreCase("chief") || liveGuildRank.equalsIgnoreCase("strategist"); + } + if (guildRewards.hasKnownRank()) { + return guildRewards.isChiefOwnerOrStrategist(); + } + guildRewards.ensureFresh(playerName()); + return true; + } + + public void onWorldRespawn() { + enteredWynnGameWorld = true; + guildLogSync.onWorldJoin(); + } + /** Packet hooks for the invisible guild-log query (mirrors Wynntils' query path). */ public boolean onGuildLogOpenScreen(ClientboundOpenScreenPacket packet) { return guildLogSync.onOpenScreen(packet); @@ -634,6 +662,7 @@ public void release() { AllianceMenuScraper.reset(); GuildMenuScraper.reset(); AttackTimerMenu.reset(); + guildAllyManager.requestCancel(); // Connect if applicable; the backend reports link/membership standing via // authOk (handleAuthStatus), which drives any "please link" prompt. evaluateGating(client); @@ -670,7 +699,7 @@ public void release() { }).then(ClientCommandManager.literal("download").executes(ctx -> { updateDownload(ctx.getSource()); return 1; - }))).then(buildGiftCommand()).then(buildDeductCommand()).then(ClientCommandManager.literal("dump").then(ClientCommandManager.argument("member", StringArgumentType.word()).suggests(this::suggestMembers).executes(ctx -> dumpEmeralds(ctx.getSource(), StringArgumentType.getString(ctx, "member"))))).then(ClientCommandManager.literal("wars").executes(ctx -> { + }))).then(buildGiftCommand()).then(buildDeductCommand()).then(buildAllyCommand()).then(ClientCommandManager.literal("dump").then(ClientCommandManager.argument("member", StringArgumentType.word()).suggests(this::suggestMembers).executes(ctx -> dumpEmeralds(ctx.getSource(), StringArgumentType.getString(ctx, "member"))))).then(ClientCommandManager.literal("wars").executes(ctx -> { requestWarCounts(ctx.getSource(), 7); return 1; }).then(ClientCommandManager.argument("days", com.mojang.brigadier.arguments.IntegerArgumentType.integer(1, 365)).executes(ctx -> { @@ -1057,6 +1086,12 @@ public void onGameFeedback(String message, String color) { displayColored(color, () -> DiscordChatFormatter.systemLine(message, net.minecraft.ChatFormatting.GOLD)); } + @Override + public void onAllyActivity(java.util.List entries) { + boolean canManage = isChiefOwnerOrStrategist(); + display(() -> DiscordChatFormatter.allyActivity(entries, canManage)); + } + @Override public void onAuthStatus(boolean linked, boolean member, String guildRank, String discordRank) { handleAuthStatus(linked, member, guildRank, discordRank); @@ -1264,6 +1299,40 @@ private int deductReward(FabricClientCommandSource source, String rewardKind, St return 1; } + /** Build {@code /eden ally (view | add | remove )}. */ + private LiteralArgumentBuilder buildAllyCommand() { + return ClientCommandManager.literal("ally").executes(ctx -> { + requestAllyView(ctx.getSource()); + return 1; + }).then(ClientCommandManager.literal("view").executes(ctx -> { + requestAllyView(ctx.getSource()); + return 1; + })).then(ClientCommandManager.literal("add").then(ClientCommandManager.argument("name", StringArgumentType.greedyString()).executes(ctx -> { + if (!isChiefOwnerOrStrategist()) { + ctx.getSource().sendFeedback(Component.literal(tel.eden.mod.guild.GuildAllyManager.PERMISSION_ERROR).withStyle(ChatFormatting.RED)); + return 0; + } + guildAllyManager.addAlly(StringArgumentType.getString(ctx, "name")); + return 1; + }))).then(ClientCommandManager.literal("remove").then(ClientCommandManager.argument("name", StringArgumentType.greedyString()).executes(ctx -> { + if (!isChiefOwnerOrStrategist()) { + ctx.getSource().sendFeedback(Component.literal(tel.eden.mod.guild.GuildAllyManager.PERMISSION_ERROR).withStyle(ChatFormatting.RED)); + return 0; + } + guildAllyManager.removeAlly(StringArgumentType.getString(ctx, "name")); + return 1; + }))); + } + + private void requestAllyView(FabricClientCommandSource source) { + BridgeWebSocketClient current = socket; + if (current == null) { + source.sendFeedback(notConnected()); + return; + } + current.sendAllyActivityRequest(); + } + private CompletableFuture suggestMembers(CommandContext context, SuggestionsBuilder builder) { String remaining = builder.getRemaining().toLowerCase(Locale.ROOT); for (String name : guildRewards.memberNames()) { @@ -2081,7 +2150,7 @@ private int handleReplyCommand(String messageId) { private record HelpEntry(String command, String description) { } - private static final List HELP_ENTRIES = List.of(new HelpEntry("/eden config", "open the config screen"), new HelpEntry("/eden online", "who's connected to the bridge"), new HelpEntry("/eden cf", "flip a coin"), new HelpEntry("/eden diceroll", "roll a die"), new HelpEntry("/eden wars [days]", "guild war counts (same as Discord)"), new HelpEntry("/eden emojis", "open the chat emote picker"), new HelpEntry("/eden party", "list open parties (click to join)"), new HelpEntry("/eden party create [note]", "open a raid party"), new HelpEntry("/eden party join ", "join a party"), new HelpEntry("/eden party leave [id]", "leave your party"), new HelpEntry("/eden party disband", "disband your hosted party"), new HelpEntry("/eden anni [note]", "open an Annihilation party (2-10)"), new HelpEntry("/eden command alias", "open the command alias editor"), new HelpEntry("/eden command keybind", "open the command keybind editor"), new HelpEntry("/eden update", "check for a pending update"), new HelpEntry("/eden update download", "download the update now (applies on exit)"), new HelpEntry("/eden aspects pending", "members' pending aspects — Chiefs only"), new HelpEntry("/eden gift ", "gift guild rewards — Chiefs only"), new HelpEntry("/eden dump ", "gift all guild-bank emeralds to a member — Chiefs only"), new HelpEntry("/eden deduct ", "deduct a payout from pending rewards — Chiefs only"), new HelpEntry("/eden help", "this help screen")); + private static final List HELP_ENTRIES = List.of(new HelpEntry("/eden config", "open the config screen"), new HelpEntry("/eden online", "who's connected to the bridge"), new HelpEntry("/eden cf", "flip a coin"), new HelpEntry("/eden diceroll", "roll a die"), new HelpEntry("/eden wars [days]", "guild war counts (same as Discord)"), new HelpEntry("/eden emojis", "open the chat emote picker"), new HelpEntry("/eden party", "list open parties (click to join)"), new HelpEntry("/eden party create [note]", "open a raid party"), new HelpEntry("/eden party join ", "join a party"), new HelpEntry("/eden party leave [id]", "leave your party"), new HelpEntry("/eden party disband", "disband your hosted party"), new HelpEntry("/eden anni [note]", "open an Annihilation party (2-10)"), new HelpEntry("/eden command alias", "open the command alias editor"), new HelpEntry("/eden command keybind", "open the command keybind editor"), new HelpEntry("/eden update", "check for a pending update"), new HelpEntry("/eden update download", "download the update now (applies on exit)"), new HelpEntry("/eden aspects pending", "members' pending aspects — Chiefs only"), new HelpEntry("/eden gift ", "gift guild rewards — Chiefs only"), new HelpEntry("/eden dump ", "gift all guild-bank emeralds to a member — Chiefs only"), new HelpEntry("/eden deduct ", "deduct a payout from pending rewards — Chiefs only"), new HelpEntry("/eden ally view", "view guild ally activity (most inactive to active)"), new HelpEntry("/eden ally add ", "add a guild ally — Strategist+ only"), new HelpEntry("/eden ally remove ", "remove a guild ally — Strategist+ only"), new HelpEntry("/eden help", "this help screen")); private static final class TrackedCommandKeybind { private final String input; @@ -2245,6 +2314,7 @@ private synchronized void disconnect() { RaidReadyTracker.reset(); PartyHealthTracker.reset(); ChatReplyManager.reset(); + guildAllyManager.requestCancel(); territoryDefendedTracker.clear(); // Before the socket goes: an outstanding deduct must not outlive the session that // made it, or it resurfaces at the next connect naming a player from another @@ -2291,6 +2361,7 @@ private static void sendStreamCommand() { public void handleSystemChat(Component message) { PlayerNameResolver.observeMessage(message); handleRewardFeedback(message); + guildAllyManager.onSystemChat(message.getString()); // A real chat line breaks any in-progress Discord emblem block, so the next // relayed Discord message starts with a fresh shield (like guild chat). DiscordChatFormatter.onServerChatLine(); diff --git a/src/tel/eden/mod/chat/ChatText.java b/src/tel/eden/mod/chat/ChatText.java index 5559a05..bd201f4 100644 --- a/src/tel/eden/mod/chat/ChatText.java +++ b/src/tel/eden/mod/chat/ChatText.java @@ -199,7 +199,7 @@ private record MetaChar(char value, String hover, String insertion) { * fixing spacing before punctuation so {@code name: message} and raid-completion * shapes are cleanly matchable. */ - static String normalize(String raw) { + public static String normalize(String raw) { return normalizeWhitespace(raw).replaceAll("\\s+([,.:;!?])", "$1"); } @@ -241,7 +241,7 @@ static String unwrapWynncraftSoftWraps(String raw) { * without changing punctuation spacing. Use this for user-authored text, where a * space before {@code :shortcode:} or punctuation may be intentional. */ - static String normalizeWhitespace(String raw) { + public static String normalizeWhitespace(String raw) { if (raw == null || raw.isBlank()) { return ""; } diff --git a/src/tel/eden/mod/chat/DiscordChatFormatter.java b/src/tel/eden/mod/chat/DiscordChatFormatter.java index fab7171..da8b989 100644 --- a/src/tel/eden/mod/chat/DiscordChatFormatter.java +++ b/src/tel/eden/mod/chat/DiscordChatFormatter.java @@ -16,6 +16,7 @@ import net.minecraft.network.chat.MutableComponent; import net.minecraft.network.chat.Style; import net.minecraft.resources.Identifier; +import tel.eden.mod.guild.AllyTagCache; /** * Renders a relayed Discord message for in-game display so it sits flush with @@ -143,6 +144,45 @@ public static Component warCounts(int days, java.util.List entries, boolean canManage) { + MutableComponent out = Component.empty().append(prefix(SHIELD)); + if (entries.isEmpty()) { + return out.append(Component.literal("No guild allies currently found.").withStyle(ChatFormatting.GOLD)); + } + out.append(Component.literal("Guild Allies (by inactivity):").withStyle(ChatFormatting.GREEN)); + for (tel.eden.mod.net.AllyActivityEntry entry : entries) { + ChatFormatting color; + if (entry.elapsedMinutes() > 45) { + color = ChatFormatting.RED; + } else if (entry.elapsedMinutes() >= 30) { + color = ChatFormatting.YELLOW; + } else { + color = ChatFormatting.GREEN; + } + long mins = Math.round(entry.elapsedMinutes()); + String timeStr = mins < 60 ? mins + "m ago" : (mins / 60) + "h " + (mins % 60) + "m ago"; + String tag = (entry.tag() != null && !entry.tag().isEmpty()) ? entry.tag() : AllyTagCache.getTag(entry.name()); + if (tag != null && !tag.isEmpty()) { + AllyTagCache.remember(entry.name(), tag); + } + String tagStr = (tag != null && !tag.isEmpty()) ? " [" + tag + "]" : ""; + String lineText = "\n " + entry.name() + tagStr + " — " + timeStr; + + MutableComponent line = Component.literal(lineText).withStyle(color); + if (canManage) { + String target = (tag != null && !tag.isEmpty()) ? tag : entry.name(); + line = line.withStyle(style -> style.withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to suggest /eden ally remove " + target).withStyle(ChatFormatting.GRAY))).withClickEvent(new ClickEvent.SuggestCommand("/eden ally remove " + target))); + } + out.append(line); + } + return out; + } + // Inline reply excerpt is truncated to this many characters (full text on hover). private static final int EXCERPT_MAX = 50; diff --git a/src/tel/eden/mod/guild/AllyTagCache.java b/src/tel/eden/mod/guild/AllyTagCache.java new file mode 100644 index 0000000..1ef2e87 --- /dev/null +++ b/src/tel/eden/mod/guild/AllyTagCache.java @@ -0,0 +1,47 @@ +package tel.eden.mod.guild; + +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Thread-safe cache of known allied guild names to their tags. + * Populated from diplomacy screen scraping, root manage menu tooltips, + * and ally activity replies to ensure tags are always present in output. + */ +public final class AllyTagCache { + private static final Map NAME_TO_TAG = new ConcurrentHashMap<>(); + private static final Map TAG_TO_NAME = new ConcurrentHashMap<>(); + + private AllyTagCache() { + } + + public static void remember(String name, String tag) { + if (name == null || name.isBlank() || tag == null || tag.isBlank()) { + return; + } + String cleanName = name.trim(); + String cleanTag = tag.trim(); + NAME_TO_TAG.put(cleanName.toLowerCase(Locale.ROOT), cleanTag); + TAG_TO_NAME.put(cleanTag.toLowerCase(Locale.ROOT), cleanName); + } + + public static String getTag(String name) { + if (name == null || name.isBlank()) { + return null; + } + return NAME_TO_TAG.get(name.trim().toLowerCase(Locale.ROOT)); + } + + public static String getName(String tag) { + if (tag == null || tag.isBlank()) { + return null; + } + return TAG_TO_NAME.get(tag.trim().toLowerCase(Locale.ROOT)); + } + + public static void clear() { + NAME_TO_TAG.clear(); + TAG_TO_NAME.clear(); + } +} diff --git a/src/tel/eden/mod/guild/BackgroundContainerSession.java b/src/tel/eden/mod/guild/BackgroundContainerSession.java index d2c1a4a..113cb45 100644 --- a/src/tel/eden/mod/guild/BackgroundContainerSession.java +++ b/src/tel/eden/mod/guild/BackgroundContainerSession.java @@ -46,6 +46,7 @@ public final class BackgroundContainerSession { private volatile int stateId; private volatile boolean open; private volatile boolean aborted; + private volatile boolean serverClosed; private volatile List items = List.of(); private volatile long lastContentAt; private volatile long snapshotRevision; @@ -68,31 +69,38 @@ public void onWorldLeaving() { /** Claim the next matching server menu. Returns false if a real foreign menu is open. */ public boolean begin(String owner, Predicate expectedTitle, int menuSlotCount) { Minecraft mc = Minecraft.getInstance(); - if (owner == null || owner.isBlank() || expectedTitle == null || menuSlotCount <= 0 || mc.player == null || mc.getConnection() == null || open || !this.owner.isEmpty()) { - return false; - } - if (System.currentTimeMillis() - joinedAt < WORLD_COMMAND_GRACE_MS) { + if (owner == null || owner.isBlank() || expectedTitle == null || menuSlotCount <= 0 || mc.player == null || mc.getConnection() == null) { return false; } if (mc.screen instanceof AbstractContainerScreen) { return false; } - if (observedServerContainerId > 0) { - long elapsed = System.currentTimeMillis() - joinedAt; - boolean localInventoryOnly = mc.player.containerMenu == mc.player.inventoryMenu && !(mc.screen instanceof AbstractContainerScreen); - if (elapsed >= STARTUP_ORPHAN_GRACE_MS && elapsed <= STARTUP_ORPHAN_WINDOW_MS && localInventoryOnly) { - LOGGER.warn("Background container: closing startup orphan {} ('{}') after {}ms", observedServerContainerId, observedServerTitle, elapsed); - mc.getConnection().send(new ServerboundContainerClosePacket(observedServerContainerId)); - observedServerContainerId = -1; - observedServerTitle = ""; + if (open || !this.owner.isEmpty()) { + if ("guild_log".equals(this.owner) && !"guild_log".equals(owner)) { + LOGGER.info("Background container: {} preempting background guild_log", owner); + close(); } else { return false; } } + if (System.currentTimeMillis() - joinedAt < WORLD_COMMAND_GRACE_MS) { + return false; + } + if (mc.player.containerMenu != mc.player.inventoryMenu) { + LOGGER.info("Background container: closing orphan containerMenu {} ('{}')", mc.player.containerMenu.containerId, observedServerTitle); + mc.player.closeContainer(); + } + if (observedServerContainerId > 0) { + LOGGER.info("Background container: closing lingering container {} ('{}')", observedServerContainerId, observedServerTitle); + mc.getConnection().send(new ServerboundContainerClosePacket(observedServerContainerId)); + observedServerContainerId = -1; + observedServerTitle = ""; + } this.owner = owner; this.expectedTitle = expectedTitle; this.menuSlotCount = menuSlotCount; this.aborted = false; + this.serverClosed = false; return true; } @@ -155,6 +163,14 @@ public boolean isAborted() { return aborted; } + public boolean isClosed() { + return serverClosed || aborted || !open; + } + + public boolean isServerClosed() { + return serverClosed; + } + public boolean inStartupRecoveryWindow() { long elapsed = System.currentTimeMillis() - joinedAt; return elapsed >= 0L && elapsed <= STARTUP_ORPHAN_WINDOW_MS; @@ -277,6 +293,7 @@ public void onContainerClosed(int closedId) { } if (closedId == containerId) { aborted = true; + serverClosed = true; } } @@ -288,6 +305,7 @@ private void clearActive() { stateId = 0; open = false; aborted = false; + serverClosed = false; items = List.of(); lastContentAt = 0L; snapshotRevision = 0L; diff --git a/src/tel/eden/mod/guild/GuildAllyManager.java b/src/tel/eden/mod/guild/GuildAllyManager.java new file mode 100644 index 0000000..b42f559 --- /dev/null +++ b/src/tel/eden/mod/guild/GuildAllyManager.java @@ -0,0 +1,563 @@ +package tel.eden.mod.guild; + +import java.util.List; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +import java.util.function.Predicate; +import java.util.function.Supplier; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.world.inventory.ClickType; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.component.ItemLore; +import tel.eden.mod.EdenLogger; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.chat.ChatText; +import tel.eden.mod.gui.AllyProgressScreen; + +/** + * Automates in-game ally addition and removal through Wynncraft's /guild manage + * and Diplomacy menus using BackgroundContainerSession while displaying a modal + * progress screen (AllyProgressScreen) to block gameplay input. + */ +public final class GuildAllyManager { + private static final EdenLogger LOGGER = EdenLogger.get(); + private static final int MANAGE_SLOTS = 27; + private static final int DIPLOMACY_SLOT = 26; + private static final int DIPLOMACY_SLOTS = 27; + private static final int ADD_ALLY_SLOT = 0; + private static final int REMOVE_ALLIES_SLOT = 18; + private static final int[] ALLY_SLOTS = {2, 3, 4, 5, 6, 7, 8}; + + private static final Pattern ALLY_ITEM = Pattern.compile("^(.+?) \\[([A-Za-z0-9]{2,5})]$"); + private static final Pattern COLOR_CODE = Pattern.compile("[§&][0-9a-fk-orA-FK-OR]"); + private static final long MENU_TIMEOUT_MS = 6_000L; + public static final String PERMISSION_ERROR = "Only guild Strategist+ members can manage allies."; + + private final BackgroundContainerSession session; + private final ExecutorService worker = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "edenmod-ally-manager"); + t.setDaemon(true); + return t; + }); + + private volatile boolean running; + private volatile boolean cancelled; + private volatile boolean promptAppeared; + private volatile boolean addAllySuccess; + private volatile String addAllyFailureReason; + private volatile boolean removeAllySuccess; + private volatile String removeAllyFailureReason; + + public GuildAllyManager(BackgroundContainerSession session) { + this.session = session; + } + + public boolean isRunning() { + return running; + } + + public void requestCancel() { + cancelled = true; + onClientRun(session::close); + } + + public void removeAlly(String nameOrTag) { + if (EdenModClient.instance() != null && !EdenModClient.instance().isChiefOwnerOrStrategist()) { + chat(PERMISSION_ERROR, ChatFormatting.RED); + return; + } + if (running) { + chat("An ally management action is already in progress.", ChatFormatting.RED); + return; + } + String target = normalizeTarget(nameOrTag); + if (target.isEmpty()) { + chat("Please specify an ally name or tag to remove.", ChatFormatting.RED); + return; + } + + running = true; + cancelled = false; + removeAllySuccess = false; + removeAllyFailureReason = null; + AllyProgressScreen progress = showProgress("Removing Ally: " + target); + + worker.submit(() -> { + try { + progress.setStatus("Opening guild manage..."); + if (!openAndCommand("ally_remove", title -> title.toLowerCase(Locale.ROOT).contains("manage"), MANAGE_SLOTS, "guild manage")) { + if (!cancelled) { + chat("Couldn't open the guild manage menu — try again.", ChatFormatting.RED); + } + return; + } + if (cancelled) + return; + + progress.setStatus("Opening diplomacy menu..."); + if (!transitionClick(DIPLOMACY_SLOT, title -> title.toLowerCase(Locale.ROOT).contains("diplomacy"), DIPLOMACY_SLOTS)) { + if (!cancelled) { + chat("Couldn't open the diplomacy menu.", ChatFormatting.RED); + } + return; + } + if (cancelled) + return; + + List items = session.items(); + if (items.size() <= REMOVE_ALLIES_SLOT || items.get(REMOVE_ALLIES_SLOT).isEmpty()) { + chat(PERMISSION_ERROR, ChatFormatting.RED); + return; + } + + ItemStack slot18 = items.get(REMOVE_ALLIES_SLOT); + String slot18Name = cleanFailureReason(slot18.getHoverName().getString()).toLowerCase(Locale.ROOT); + if (slot18Name.contains("permission") || slot18Name.contains("locked") || slot18Name.contains("cannot")) { + chat(PERMISSION_ERROR, ChatFormatting.RED); + return; + } + ItemLore removeLore = slot18.get(DataComponents.LORE); + if (removeLore != null) { + for (var line : removeLore.lines()) { + String s = stripCodes(line.getString()).toLowerCase(Locale.ROOT); + if (s.contains("do not have permission") || s.contains("requires strategist") || s.contains("only chief") || s.contains("only owner") || s.contains("permission to remove")) { + chat(PERMISSION_ERROR, ChatFormatting.RED); + return; + } + } + } + + progress.setStatus("Entering removal mode..."); + Boolean enterClick = onClient(() -> session.click(REMOVE_ALLIES_SLOT, 0, ClickType.PICKUP)); + if (!Boolean.TRUE.equals(enterClick)) { + chat("Failed to enter removal mode.", ChatFormatting.RED); + return; + } + sleep(400); + if (cancelled) + return; + + int targetSlot = -1; + String matchedName = null; + items = session.items(); + for (int slot : ALLY_SLOTS) { + if (slot >= items.size()) + continue; + ItemStack stack = items.get(slot); + if (stack.isEmpty()) + continue; + String displayed = cleanFailureReason(stack.getHoverName().getString()); + Matcher m = ALLY_ITEM.matcher(displayed); + if (m.matches()) { + String guildName = m.group(1).trim(); + String tag = m.group(2).trim(); + AllyTagCache.remember(guildName, tag); + if (guildName.equalsIgnoreCase(target) || tag.equalsIgnoreCase(target)) { + targetSlot = slot; + matchedName = guildName + " [" + tag + "]"; + break; + } + } + } + + if (targetSlot == -1) { + chat("Could not find ally matching \"" + target + "\" in diplomacy menu.", ChatFormatting.RED); + return; + } + + if (cancelled) + return; + progress.setStatus("Selecting " + matchedName + "..."); + final int slotToClick = targetSlot; + Boolean selectClick = onClient(() -> session.click(slotToClick, 0, ClickType.PICKUP)); + if (!Boolean.TRUE.equals(selectClick)) { + chat("Failed to select ally for removal.", ChatFormatting.RED); + return; + } + sleep(400); + + if (cancelled) + return; + progress.setStatus("Confirming removal..."); + long beforeConfirmRev = session.snapshotRevision(); + removeAllySuccess = false; + removeAllyFailureReason = null; + Boolean confirmClick = onClient(() -> session.click(REMOVE_ALLIES_SLOT, 0, ClickType.PICKUP)); + if (!Boolean.TRUE.equals(confirmClick)) { + chat("Failed to confirm ally removal.", ChatFormatting.RED); + return; + } + + boolean confirmed = waitUntil(() -> { + if (cancelled || removeAllySuccess || removeAllyFailureReason != null) { + return true; + } + if (session.snapshotRevision() > beforeConfirmRev && session.isSettled()) { + return !isAllyPresentInMenu(target); + } + return session.isClosed(); + }, 4000L); + + if (cancelled) + return; + + if (removeAllyFailureReason != null) { + chat("Failed to remove ally: " + removeAllyFailureReason, ChatFormatting.RED); + return; + } + + boolean verifiedRemoved = removeAllySuccess || (session.snapshotRevision() > beforeConfirmRev && !isAllyPresentInMenu(target)); + if (verifiedRemoved) { + chat("Successfully removed ally " + matchedName + ".", ChatFormatting.GREEN); + } else if (session.isOpen() && isAllyPresentInMenu(target)) { + chat("Failed to remove ally " + matchedName + " (ally still present in diplomacy menu).", ChatFormatting.RED); + } else { + chat("Could not verify removal of ally " + matchedName + " (timed out waiting for confirmation).", ChatFormatting.RED); + } + } catch (Exception e) { + LOGGER.error("Error removing ally", e); + chat("Failed to remove ally: " + e.getMessage(), ChatFormatting.RED); + } finally { + onClientRun(session::close); + progress.markDone(); + running = false; + } + }); + } + + public void addAlly(String nameOrTag) { + if (EdenModClient.instance() != null && !EdenModClient.instance().isChiefOwnerOrStrategist()) { + chat(PERMISSION_ERROR, ChatFormatting.RED); + return; + } + if (running) { + chat("An ally management action is already in progress.", ChatFormatting.RED); + return; + } + String target = normalizeTarget(nameOrTag); + if (target.isEmpty()) { + chat("Please specify an ally name or tag to add.", ChatFormatting.RED); + return; + } + + running = true; + cancelled = false; + promptAppeared = false; + addAllySuccess = false; + addAllyFailureReason = null; + AllyProgressScreen progress = showProgress("Adding Ally: " + target); + + worker.submit(() -> { + try { + progress.setStatus("Opening guild manage..."); + if (!openAndCommand("ally_add", title -> title.toLowerCase(Locale.ROOT).contains("manage"), MANAGE_SLOTS, "guild manage")) { + if (!cancelled) { + chat("Couldn't open the guild manage menu — try again.", ChatFormatting.RED); + } + return; + } + if (cancelled) + return; + + progress.setStatus("Opening diplomacy menu..."); + if (!transitionClick(DIPLOMACY_SLOT, title -> title.toLowerCase(Locale.ROOT).contains("diplomacy"), DIPLOMACY_SLOTS)) { + if (!cancelled) { + chat("Couldn't open the diplomacy menu.", ChatFormatting.RED); + } + return; + } + if (cancelled) + return; + + List items = session.items(); + if (items.size() <= ADD_ALLY_SLOT || items.get(ADD_ALLY_SLOT).isEmpty()) { + chat(PERMISSION_ERROR, ChatFormatting.RED); + return; + } + + progress.setStatus("Checking ally capacity..."); + ItemStack slot0 = items.get(ADD_ALLY_SLOT); + String slot0Name = cleanFailureReason(slot0.getHoverName().getString()).toLowerCase(Locale.ROOT); + if (slot0Name.contains("permission") || slot0Name.contains("locked") || slot0Name.contains("cannot")) { + chat(PERMISSION_ERROR, ChatFormatting.RED); + return; + } + ItemLore lore = slot0.get(DataComponents.LORE); + if (lore != null) { + for (var line : lore.lines()) { + String s = stripCodes(line.getString()).toLowerCase(Locale.ROOT); + if (s.contains("do not have permission") || s.contains("requires strategist") || s.contains("only chief") || s.contains("only owner") || s.contains("permission to add")) { + chat(PERMISSION_ERROR, ChatFormatting.RED); + return; + } + if (s.contains("do not have any") || s.contains("7/7") || s.contains("no ally slots available")) { + chat("Cannot add ally: no ally slots available (all slots used).", ChatFormatting.RED); + return; + } + } + } + + if (cancelled) + return; + progress.setStatus("Submitting alliance request for " + target + "..."); + promptAppeared = false; + Boolean addClick = onClient(() -> session.click(ADD_ALLY_SLOT, 0, ClickType.PICKUP)); + if (!Boolean.TRUE.equals(addClick)) { + chat("Failed to click add ally.", ChatFormatting.RED); + return; + } + + // Wait for the server container to close or the prompt message to appear + // before sending the chat packet, preventing blurting the tag into public chat under lag. + boolean promptReady = waitUntil(() -> promptAppeared || session.isClosed() || cancelled, 5000L); + if (cancelled) + return; + if (!promptReady) { + chat("Timed out waiting for server ally prompt (server lag).", ChatFormatting.RED); + return; + } + + addAllySuccess = false; + addAllyFailureReason = null; + + Minecraft mc = Minecraft.getInstance(); + onClientRun(() -> { + if (mc.getConnection() != null) { + mc.getConnection().sendChat(target); + } + }); + + long deadline = System.currentTimeMillis() + 5000L; + while (System.currentTimeMillis() < deadline && addAllyFailureReason == null && !addAllySuccess && !cancelled) { + sleep(50); + } + if (cancelled) + return; + + if (addAllyFailureReason != null) { + String cleanReason = cleanFailureReason(addAllyFailureReason); + chat("Failed to add ally: " + (cleanReason.isEmpty() ? "Unknown error" : cleanReason), ChatFormatting.RED); + } else if (addAllySuccess) { + chat("Sent alliance request to " + target + ".", ChatFormatting.GREEN); + } else { + chat("Timed out waiting for alliance response for " + target + " (server may be lagging).", ChatFormatting.YELLOW); + } + } catch (Exception e) { + LOGGER.error("Error adding ally", e); + chat("Failed to add ally: " + e.getMessage(), ChatFormatting.RED); + } finally { + onClientRun(session::close); + progress.markDone(); + running = false; + } + }); + } + + private AllyProgressScreen showProgress(String title) { + Minecraft mc = Minecraft.getInstance(); + AllyProgressScreen.ProgressListener listener = new AllyProgressScreen.ProgressListener() { + @Override + public void onProgress(String status) { + } + + @Override + public void onDone() { + } + + @Override + public void requestCancel() { + GuildAllyManager.this.requestCancel(); + } + }; + AllyProgressScreen screen = new AllyProgressScreen(title, listener); + mc.execute(() -> { + if (!screen.isDone()) { + mc.setScreen(screen); + } + }); + return screen; + } + + private boolean openAndCommand(String owner, Predicate title, int slots, String command) { + long deadline = System.currentTimeMillis() + 5000L; + boolean opened = false; + while (System.currentTimeMillis() < deadline && !cancelled) { + Boolean attempt = onClient(() -> { + Minecraft mc = Minecraft.getInstance(); + if (mc.getConnection() == null) { + return false; + } + if (!session.begin(owner, title, slots)) { + return false; + } + mc.getConnection().sendCommand(command); + return true; + }); + if (Boolean.TRUE.equals(attempt)) { + opened = true; + break; + } + sleep(100); + } + if (!opened || cancelled) { + LOGGER.info("Guild ally: session.begin failed for {} — {}", owner, session.blockingState()); + return false; + } + if (!waitUntil(() -> (session.isOpen() && session.isSettled() && session.items().size() >= slots) || session.isAborted() || cancelled, MENU_TIMEOUT_MS)) { + onClientRun(session::close); + return false; + } + return !session.isAborted() && !cancelled; + } + + private boolean transitionClick(int slot, Predicate nextTitle, int nextSlots) { + Long before = onClient(() -> { + long rev = session.snapshotRevision(); + return session.prepareInPlaceTransition(nextTitle, nextSlots) && session.click(slot, 0, ClickType.PICKUP) ? rev : null; + }); + if (before == null) { + return false; + } + boolean settled = waitUntil(() -> session.isAborted() || cancelled || (session.snapshotRevision() > before && session.isSettled()), MENU_TIMEOUT_MS); + return settled && !session.isAborted() && !cancelled; + } + + private static boolean waitUntil(BooleanSupplier condition, long timeoutMs) { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) { + return true; + } + sleep(50); + } + return condition.getAsBoolean(); + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private static T onClient(Supplier action) { + Minecraft mc = Minecraft.getInstance(); + if (mc.isSameThread()) { + return action.get(); + } + CompletableFuture future = new CompletableFuture<>(); + mc.execute(() -> { + try { + future.complete(action.get()); + } catch (Throwable t) { + future.completeExceptionally(t); + } + }); + try { + return future.get(5, TimeUnit.SECONDS); + } catch (Exception e) { + return null; + } + } + + private static void onClientRun(Runnable action) { + Minecraft mc = Minecraft.getInstance(); + if (mc.isSameThread()) { + action.run(); + } else { + mc.execute(action); + } + } + + private static void chat(String message, ChatFormatting color) { + Minecraft mc = Minecraft.getInstance(); + onClientRun(() -> { + if (mc.player != null) { + mc.player.displayClientMessage(Component.literal(message).withStyle(color), false); + } + }); + } + + private boolean isAllyPresentInMenu(String target) { + List items = session.items(); + for (int slot : ALLY_SLOTS) { + if (slot >= items.size()) { + continue; + } + ItemStack stack = items.get(slot); + if (stack.isEmpty()) { + continue; + } + String displayed = cleanFailureReason(stack.getHoverName().getString()); + Matcher m = ALLY_ITEM.matcher(displayed); + if (m.matches()) { + String guildName = m.group(1).trim(); + String tag = m.group(2).trim(); + if (guildName.equalsIgnoreCase(target) || tag.equalsIgnoreCase(target)) { + return true; + } + } + } + return false; + } + + public static boolean isPromptMessage(String lowerText) { + if (lowerText == null) { + return false; + } + return (lowerText.contains("type") && (lowerText.contains("guild") || lowerText.contains("tag") || lowerText.contains("name"))) || (lowerText.contains("enter") && (lowerText.contains("guild") || lowerText.contains("tag") || lowerText.contains("name"))) || (lowerText.contains("ally") && (lowerText.contains("guild name") || lowerText.contains("guild tag") || lowerText.contains("type") || lowerText.contains("enter"))); + } + + public void onSystemChat(String text) { + if (text == null || !running) { + return; + } + String cleaned = cleanFailureReason(text); + String lower = cleaned.toLowerCase(Locale.ROOT); + if (isPromptMessage(lower)) { + promptAppeared = true; + } + if (lower.contains("not found or invalid") || lower.contains("already allied") || lower.contains("already pending") || lower.contains("cannot ally") || (lower.contains("maximum") && lower.contains("allies")) || lower.contains("no ally slots") || lower.contains("do not have permission")) { + addAllyFailureReason = cleaned; + } else if (lower.contains("alliance request") || lower.contains("requested an alliance") || lower.contains("sent an alliance request") || lower.contains("alliance request sent")) { + addAllySuccess = true; + } else if (lower.contains("no longer allied") || (lower.contains("removed") && lower.contains("ally")) || lower.contains("ended alliance") || (lower.contains("alliance") && lower.contains("ended")) || lower.contains("successfully removed")) { + removeAllySuccess = true; + } else if (lower.contains("failed to remove") || lower.contains("cannot remove") || lower.contains("could not remove")) { + removeAllyFailureReason = cleaned; + } + } + + static String normalizeTarget(String input) { + if (input == null) { + return ""; + } + String trimmed = input.trim(); + if ((trimmed.startsWith("\"") && trimmed.endsWith("\"") && trimmed.length() >= 2) || (trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length() >= 2)) { + trimmed = trimmed.substring(1, trimmed.length() - 1).trim(); + } + return trimmed.replaceAll("\\s+", " "); + } + + static String cleanFailureReason(String text) { + if (text == null) { + return ""; + } + String noCodes = stripCodes(text); + return ChatText.normalize(noCodes).strip(); + } + + private static String stripCodes(String text) { + return COLOR_CODE.matcher(text).replaceAll(""); + } +} diff --git a/src/tel/eden/mod/guild/GuildManageMenuParser.java b/src/tel/eden/mod/guild/GuildManageMenuParser.java index fce2d56..c620343 100644 --- a/src/tel/eden/mod/guild/GuildManageMenuParser.java +++ b/src/tel/eden/mod/guild/GuildManageMenuParser.java @@ -9,6 +9,7 @@ import net.minecraft.core.component.DataComponents; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.component.ItemLore; +import tel.eden.mod.chat.ChatText; /** Extracts stable status fields from the 27-slot root {@code /gu man} menu. */ public final class GuildManageMenuParser { @@ -97,7 +98,7 @@ static List alliances(List rawLines) { boolean inAllianceSection = false; List result = new java.util.ArrayList<>(); for (String raw : rawLines) { - String line = ChatFormatting.stripFormatting(raw).trim(); + String line = ChatText.normalize(ChatFormatting.stripFormatting(raw)).strip(); if (ALLIANCE_HEADING.matcher(line).matches()) { headingSeen = true; inAllianceSection = true; @@ -116,7 +117,10 @@ static List alliances(List rawLines) { } Matcher matcher = ALLIANCE.matcher(line); if (matcher.matches()) { - result.add(new GuildManageSnapshot.Alliance(matcher.group(1).trim(), matcher.group(2))); + String guildName = matcher.group(1).trim(); + String tag = matcher.group(2).trim(); + result.add(new GuildManageSnapshot.Alliance(guildName, tag)); + AllyTagCache.remember(guildName, tag); } } return headingSeen ? List.copyOf(result) : null; diff --git a/src/tel/eden/mod/guild/GuildMenuScraper.java b/src/tel/eden/mod/guild/GuildMenuScraper.java index d1de523..d2602e0 100644 --- a/src/tel/eden/mod/guild/GuildMenuScraper.java +++ b/src/tel/eden/mod/guild/GuildMenuScraper.java @@ -50,10 +50,10 @@ public static void onTick(Minecraft mc) { if (!(mc.screen instanceof AbstractContainerScreen screen)) { return; } - GuildRewards guildRewards = EdenModClient.instance().guildRewards(); - if (!guildRewards.isChiefOwnerOrStrategist()) { + if (!EdenModClient.instance().isChiefOwnerOrStrategist()) { return; } + GuildRewards guildRewards = EdenModClient.instance().guildRewards(); String title = stripCodes(screen.getTitle().getString()).trim(); AbstractContainerMenu menu = screen.getMenu(); int logIndex = title.indexOf("Log: "); diff --git a/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java b/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java index 46a003b..ca15337 100644 --- a/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java +++ b/src/tel/eden/mod/mixin/ClientPacketListenerMixin.java @@ -14,6 +14,7 @@ import net.minecraft.network.protocol.game.ClientboundOpenScreenPacket; import net.minecraft.network.protocol.game.ClientboundSoundPacket; import net.minecraft.network.protocol.game.ClientboundSetActionBarTextPacket; +import net.minecraft.network.protocol.game.ClientboundRespawnPacket; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -55,6 +56,17 @@ public class ClientPacketListenerMixin { } } + @Inject(method = "handleRespawn", at = @At("HEAD")) + private void edenBridge$onRespawn(ClientboundRespawnPacket packet, CallbackInfo ci) { + Minecraft minecraft = Minecraft.getInstance(); + if (minecraft.isSameThread()) { + EdenModClient mod = EdenModClient.instance(); + if (mod != null) { + mod.onWorldRespawn(); + } + } + } + @Inject(method = "handleOpenScreen", at = @At("HEAD"), cancellable = true) private void edenBridge$openGuildLogInBackground(ClientboundOpenScreenPacket packet, CallbackInfo ci) { Minecraft minecraft = Minecraft.getInstance(); diff --git a/src/tel/eden/mod/net/AllyActivityEntry.java b/src/tel/eden/mod/net/AllyActivityEntry.java new file mode 100644 index 0000000..a0edd50 --- /dev/null +++ b/src/tel/eden/mod/net/AllyActivityEntry.java @@ -0,0 +1,5 @@ +package tel.eden.mod.net; + +/** One allied guild's activity state from an {@code allyActivityReply} (backend-authoritative). */ +public record AllyActivityEntry(String name, String tag, double elapsedMinutes, String lastRaidName) { +} diff --git a/src/tel/eden/mod/net/BridgeWebSocketClient.java b/src/tel/eden/mod/net/BridgeWebSocketClient.java index cad4672..07fb2f3 100644 --- a/src/tel/eden/mod/net/BridgeWebSocketClient.java +++ b/src/tel/eden/mod/net/BridgeWebSocketClient.java @@ -136,6 +136,9 @@ default void onDiscordMessage(String author, String content, String replyTo, Str */ void onWarCounts(int days, java.util.List entries, String requester, String color); + /** Response to an {@code allyActivityRequest}: allies ordered by inactivity. */ + void onAllyActivity(java.util.List entries); + /** * The live war board changed: each attacked territory's scraped defence rating * (from someone's {@code /guild attack} menu) and who is heading there. A full @@ -666,6 +669,17 @@ public void sendWarCountsRequest(int days) { enqueueSend(current, obj.toString()); } + /** Ask for the guild's current allies ordered by inactivity (same as Discord). */ + public void sendAllyActivityRequest() { + WebSocket current = socket; + if (current == null) { + return; + } + JsonObject obj = new JsonObject(); + obj.addProperty("type", "allyActivityRequest"); + enqueueSend(current, obj.toString()); + } + /** * Tell the server whether this client is active in a game world ({@code true}) * or dormant (in queue, hub, or AFK with no recent guild activity — {@code false}). @@ -1052,6 +1066,7 @@ private void handlePayload(String payload) { case "gameFeedback" -> sink.onGameFeedback(get(obj, "message"), get(obj, "color")); case "pillMessage" -> sink.onPillMessage(get(obj, "label"), get(obj, "content"), get(obj, "color"), getBool(obj, "bold")); case "warCountsReply" -> sink.onWarCounts(getInt(obj, "days", 7), parseWarCounts(obj), get(obj, "requester"), get(obj, "color")); + case "allyActivityReply" -> sink.onAllyActivity(parseAllyActivity(obj)); case "warBoard" -> sink.onWarBoard(parseWarBoard(obj)); case "authChallenge" -> handleAuthChallenge(get(obj, "serverId")); case "authOk" -> { @@ -1211,6 +1226,23 @@ private static java.util.List parseWarCounts(JsonObject obj) { return out; } + private static java.util.List parseAllyActivity(JsonObject obj) { + java.util.List out = new java.util.ArrayList<>(); + if (obj.has("allies") && obj.get("allies").isJsonArray()) { + for (var element : obj.get("allies").getAsJsonArray()) { + if (element.isJsonObject()) { + JsonObject ally = element.getAsJsonObject(); + String name = get(ally, "name"); + String tag = get(ally, "tag"); + double elapsed = ally.has("elapsedMinutes") && ally.get("elapsedMinutes").isJsonPrimitive() ? ally.get("elapsedMinutes").getAsDouble() : 0.0; + String lastRaid = get(ally, "lastRaidName"); + out.add(new AllyActivityEntry(name, tag.isEmpty() ? null : tag, elapsed, lastRaid.isEmpty() ? null : lastRaid)); + } + } + } + return out; + } + private static java.util.List parseWarBoard(JsonObject obj) { java.util.List out = new java.util.ArrayList<>(); if (obj.has("territories") && obj.get("territories").isJsonArray()) { diff --git a/src/tel/eden/mod/reward/GuildRewards.java b/src/tel/eden/mod/reward/GuildRewards.java index 9ccaf33..f281483 100644 --- a/src/tel/eden/mod/reward/GuildRewards.java +++ b/src/tel/eden/mod/reward/GuildRewards.java @@ -517,6 +517,11 @@ public boolean isChiefOwnerOrStrategist() { return isChief() || rank.equalsIgnoreCase("strategist"); } + /** Whether this player's guild rank is known from the API. */ + public boolean hasKnownRank() { + return rank != null && !rank.isBlank(); + } + /** Current known member usernames (for command tab-completion). */ public List memberNames() { return new ArrayList<>(members.keySet()); diff --git a/src/tel/eden/mod/war/AllianceMenuScraper.java b/src/tel/eden/mod/war/AllianceMenuScraper.java index bd21fd9..a53c724 100644 --- a/src/tel/eden/mod/war/AllianceMenuScraper.java +++ b/src/tel/eden/mod/war/AllianceMenuScraper.java @@ -13,6 +13,8 @@ import net.minecraft.world.item.ItemStack; import tel.eden.mod.EdenLogger; import tel.eden.mod.EdenModClient; +import tel.eden.mod.chat.ChatText; +import tel.eden.mod.guild.AllyTagCache; import tel.eden.mod.net.BridgeWebSocketClient; /** @@ -79,7 +81,7 @@ public static void onTick(Minecraft mc) { } // Strategists can edit the alliance menu themselves, so let them keep it synced // too, not just Chiefs/Owners — see GuildRewards.isChiefOwnerOrStrategist's doc. - if (!EdenModClient.instance().guildRewards().isChiefOwnerOrStrategist()) { + if (!EdenModClient.instance().isChiefOwnerOrStrategist()) { return; } AbstractContainerMenu menu = screen.getMenu(); @@ -215,7 +217,7 @@ private static List parseAllies(AbstractContainerMenu menu) { if (stack.isEmpty()) { continue; } - String displayed = stripCodes(stack.getHoverName().getString()).trim(); + String displayed = ChatText.normalize(stripCodes(stack.getHoverName().getString())).strip(); Matcher matcher = ALLY_ITEM.matcher(displayed); if (!matcher.matches()) { LOGGER.warn("Diplomacy slot {} holds {} named \"{}\", which is not an ally entry", slot, stack.getItem(), displayed); @@ -227,7 +229,9 @@ private static List parseAllies(AbstractContainerMenu menu) { LOGGER.warn("Diplomacy slot {} yielded an implausible guild name: \"{}\"", slot, name); return null; } - unique.putIfAbsent(name.toLowerCase(Locale.ROOT), new Ally(name, matcher.group(2).trim())); + String tag = matcher.group(2).trim(); + unique.putIfAbsent(name.toLowerCase(Locale.ROOT), new Ally(name, tag)); + AllyTagCache.remember(name, tag); if (unique.size() > MAX_ALLIES) { return null; } diff --git a/test/tel/eden/mod/chat/DiscordChatFormatterTest.java b/test/tel/eden/mod/chat/DiscordChatFormatterTest.java index 946c4dd..a645e51 100644 --- a/test/tel/eden/mod/chat/DiscordChatFormatterTest.java +++ b/test/tel/eden/mod/chat/DiscordChatFormatterTest.java @@ -53,4 +53,37 @@ void substitutesAKnownEmoteShortcodeWithAnInlineGlyph() { // The literal ":shortcode:" text is replaced by a glyph, not left as-is. assertNotEquals("hi :" + shortcode + ": there", result.getString()); } + + @Test + void allyActivityFormatsTagWhenPresentAndRemembersInCache() { + tel.eden.mod.guild.AllyTagCache.clear(); + var entries = List.of(new tel.eden.mod.net.AllyActivityEntry("Blood Orchid", "Wilt", 15.0, "Nexus")); + Component component = DiscordChatFormatter.allyActivity(entries, false); + String text = component.getString(); + + org.junit.jupiter.api.Assertions.assertTrue(text.contains("Blood Orchid [Wilt] — 15m ago")); + assertEquals("Wilt", tel.eden.mod.guild.AllyTagCache.getTag("Blood Orchid")); + } + + @Test + void allyActivityFallsBackToCachedTagWhenMissingFromEntry() { + tel.eden.mod.guild.AllyTagCache.clear(); + tel.eden.mod.guild.AllyTagCache.remember("Fallen Angels", "FA"); + + var entries = List.of(new tel.eden.mod.net.AllyActivityEntry("Fallen Angels", null, 5.0, null)); + Component component = DiscordChatFormatter.allyActivity(entries, false); + String text = component.getString(); + + org.junit.jupiter.api.Assertions.assertTrue(text.contains("Fallen Angels [FA] — 5m ago")); + } + + @Test + void allyActivityFormatsCleanlyWhenTagUnknown() { + tel.eden.mod.guild.AllyTagCache.clear(); + var entries = List.of(new tel.eden.mod.net.AllyActivityEntry("Unknown Guild", null, 10.0, null)); + Component component = DiscordChatFormatter.allyActivity(entries, false); + String text = component.getString(); + + org.junit.jupiter.api.Assertions.assertTrue(text.contains("Unknown Guild — 10m ago")); + } } diff --git a/test/tel/eden/mod/guild/GuildAllyManagerTest.java b/test/tel/eden/mod/guild/GuildAllyManagerTest.java new file mode 100644 index 0000000..f768c95 --- /dev/null +++ b/test/tel/eden/mod/guild/GuildAllyManagerTest.java @@ -0,0 +1,47 @@ +package tel.eden.mod.guild; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class GuildAllyManagerTest { + + @Test + void normalizeTargetHandlesQuotesAndWhitespace() { + assertEquals("", GuildAllyManager.normalizeTarget(null)); + assertEquals("", GuildAllyManager.normalizeTarget(" ")); + assertEquals("The Simpleton", GuildAllyManager.normalizeTarget("The Simpleton")); + assertEquals("The Simpleton", GuildAllyManager.normalizeTarget(" The Simpleton ")); + assertEquals("The Simpleton", GuildAllyManager.normalizeTarget("\"The Simpleton\"")); + assertEquals("The Simpleton", GuildAllyManager.normalizeTarget("'The Simpleton'")); + assertEquals("EDN", GuildAllyManager.normalizeTarget("EDN")); + assertEquals("EDN", GuildAllyManager.normalizeTarget("\"EDN\"")); + assertEquals("Kingdom of Greater Lucerne", GuildAllyManager.normalizeTarget(" \"Kingdom of Greater Lucerne\" ")); + } + + @Test + void cleanFailureReasonStripsPrivateUseGlyphsColorCodesAndExtraSpaces() { + assertEquals("", GuildAllyManager.cleanFailureReason(null)); + assertEquals("", GuildAllyManager.cleanFailureReason(" ")); + assertEquals("That guild was not found or invalid, try again!", GuildAllyManager.cleanFailureReason("That guild was not found or invalid, try again!")); + assertEquals("That guild was not found or invalid, try again!", GuildAllyManager.cleanFailureReason("§cThat guild was not found or invalid, try again!")); + assertEquals("That guild was not found or invalid, try again!", GuildAllyManager.cleanFailureReason("§c That guild was not found or invalid, try again!")); + assertEquals("That guild was not found or invalid, try again!", GuildAllyManager.cleanFailureReason(" \uE001 That guild was not found or invalid, try again!")); + assertEquals("That guild was not found or invalid, try again!", GuildAllyManager.cleanFailureReason("§c\uDAFF\uDFFC\uE001 That guild was not found or invalid, try again!§r")); + assertEquals("You do not have permission to manage guild allies.", GuildAllyManager.cleanFailureReason("§c You do not have permission to manage guild allies.")); + } + + @Test + void recognizesPromptMessages() { + org.junit.jupiter.api.Assertions.assertTrue(GuildAllyManager.isPromptMessage("type the guild name or tag in chat")); + org.junit.jupiter.api.Assertions.assertTrue(GuildAllyManager.isPromptMessage("enter the guild name or tag:")); + org.junit.jupiter.api.Assertions.assertTrue(GuildAllyManager.isPromptMessage("type the name of the guild you want to ally with")); + org.junit.jupiter.api.Assertions.assertFalse(GuildAllyManager.isPromptMessage("that guild was not found or invalid")); + org.junit.jupiter.api.Assertions.assertFalse(GuildAllyManager.isPromptMessage(null)); + } + + @Test + void permissionErrorStringIsAccurate() { + assertEquals("Only guild Strategist+ members can manage allies.", GuildAllyManager.PERMISSION_ERROR); + } +}