diff --git a/gradle.properties b/gradle.properties index b8d5207..21c8983 100644 --- a/gradle.properties +++ b/gradle.properties @@ -8,7 +8,7 @@ loader_version=0.19.3 fabric_version=0.141.4+1.21.11 loom_version=1.17.8 -mod_version=1.4.4 +mod_version=1.5.0 maven_group=tel.eden archives_base_name=edenmod modmenu_version=17.0.0 diff --git a/resources/assets/edenmod/textures/catplush/cry.png b/resources/assets/edenmod/textures/catplush/cry.png new file mode 100644 index 0000000..6da4472 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/cry.png differ diff --git a/resources/assets/edenmod/textures/catplush/gregplush.png b/resources/assets/edenmod/textures/catplush/gregplush.png new file mode 100644 index 0000000..bd7aa67 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/gregplush.png differ diff --git a/resources/assets/edenmod/textures/catplush/meeplaugh.png b/resources/assets/edenmod/textures/catplush/meeplaugh.png new file mode 100644 index 0000000..93d3641 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/meeplaugh.png differ diff --git a/resources/assets/edenmod/textures/catplush/nukegrootplush.png b/resources/assets/edenmod/textures/catplush/nukegrootplush.png new file mode 100644 index 0000000..fd5344d Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/nukegrootplush.png differ diff --git a/resources/assets/edenmod/textures/catplush/orphlush.png b/resources/assets/edenmod/textures/catplush/orphlush.png new file mode 100644 index 0000000..9b1cf94 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/orphlush.png differ diff --git a/resources/assets/edenmod/textures/catplush/tccplush.png b/resources/assets/edenmod/textures/catplush/tccplush.png new file mode 100644 index 0000000..d14d331 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/tccplush.png differ diff --git a/resources/assets/edenmod/textures/catplush/wtplush.png b/resources/assets/edenmod/textures/catplush/wtplush.png new file mode 100644 index 0000000..e18e060 Binary files /dev/null and b/resources/assets/edenmod/textures/catplush/wtplush.png differ diff --git a/resources/assets/edenmod/textures/gui/healthbars/party_bar.png b/resources/assets/edenmod/textures/gui/healthbars/party_bar.png new file mode 100644 index 0000000..4e65f6d Binary files /dev/null and b/resources/assets/edenmod/textures/gui/healthbars/party_bar.png differ diff --git a/resources/edenmod.mixins.json b/resources/edenmod.mixins.json index 7bc536f..3b70655 100644 --- a/resources/edenmod.mixins.json +++ b/resources/edenmod.mixins.json @@ -4,6 +4,8 @@ "package": "tel.eden.mod.mixin", "compatibilityLevel": "JAVA_21", "client": [ + "AvatarRendererMixin", + "AvatarRenderStateMixin", "BabyPlayerEmoteDisplayMixin", "BabyPlayerEmoteMixin", "BabyPlayerEmoteHeldItemMixin", @@ -17,10 +19,12 @@ "ClientCommonPacketListenerMixin", "ClientPacketListenerMixin", "ConnectionScoreboardMixin", + "EntityOutlineMixin", "GroundItemVisibilityMixin", "GuiGraphicsMixin", "ItemDecorationMixin", "ItemRenderMixin", + "LivingEntityRendererMixin", "TitleScreenMixin" ], "injectors": { diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 19e927c..adb4e3d 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -20,10 +20,14 @@ import tel.eden.mod.chat.LevelUp; import tel.eden.mod.chat.LevelUpParser; import tel.eden.mod.chat.OccurrenceSequencer; +import tel.eden.mod.chat.PlayerNameResolver; +import tel.eden.mod.chat.ChatReplyManager; import tel.eden.mod.chat.PartyFormatter; import tel.eden.mod.chat.RaidCompletion; import tel.eden.mod.chat.RaidCompletionParser; import tel.eden.mod.chat.RaidPartyTracker; +import tel.eden.mod.chat.RaidReadyTracker; +import tel.eden.mod.party.PartyHealthTracker; import tel.eden.mod.chat.RankChange; import tel.eden.mod.chat.RankChangeParser; import tel.eden.mod.chat.ShoutParser; @@ -656,7 +660,7 @@ public void release() { })).then(ClientCommandManager.literal("emojis").executes(ctx -> { showEmojis(ctx.getSource()); return 1; - })).then(buildPartyCommand()).then(buildAnnihilationCommand()).then(buildCommandCommand()).then(ClientCommandManager.literal("update").executes(ctx -> { + })).then(buildPartyCommand()).then(buildAnnihilationCommand()).then(buildCommandCommand()).then(ClientCommandManager.literal("reply").then(ClientCommandManager.argument("messageId", StringArgumentType.word()).executes(ctx -> handleReplyCommand(StringArgumentType.getString(ctx, "messageId"))))).then(ClientCommandManager.literal("update").executes(ctx -> { updatePrompt(ctx.getSource()); return 1; }).then(ClientCommandManager.literal("download").executes(ctx -> { @@ -671,12 +675,18 @@ public void release() { }))).then(ClientCommandManager.literal("congratulate").then(ClientCommandManager.argument("name", StringArgumentType.word()).executes(ctx -> { congratulate(ctx.getSource(), StringArgumentType.getString(ctx, "name")); return 1; - }))).then(ClientCommandManager.literal("wartest").executes(ctx -> { + }))).then(ClientCommandManager.literal("debug").executes(ctx -> { for (String line : AttackTimerMenu.debugSidebarLines()) { - ctx.getSource().sendFeedback(Component.literal("[wartest] " + line).withStyle(ChatFormatting.AQUA)); + ctx.getSource().sendFeedback(Component.literal("[debug] " + line).withStyle(ChatFormatting.AQUA)); } for (String line : RaidPartyTracker.debugState()) { - ctx.getSource().sendFeedback(Component.literal("[wartest] " + line).withStyle(ChatFormatting.AQUA)); + ctx.getSource().sendFeedback(Component.literal("[debug] " + line).withStyle(ChatFormatting.AQUA)); + } + for (String line : RaidReadyTracker.debugState()) { + ctx.getSource().sendFeedback(Component.literal("[debug] " + line).withStyle(ChatFormatting.AQUA)); + } + for (String line : PartyHealthTracker.debugState()) { + ctx.getSource().sendFeedback(Component.literal("[debug] " + line).withStyle(ChatFormatting.GREEN)); } return 1; })).then(ClientCommandManager.literal("help").executes(ctx -> { @@ -714,6 +724,8 @@ private void onClientTick(Minecraft client) { WarDPS.onTick(config); WarTracker.onTick(); RaidPartyTracker.onTick(); + RaidReadyTracker.onClientTick(); + PartyHealthTracker.tick(); AttackMenuScraper.onTick(client); AllianceMenuScraper.onTick(client); GuildMenuScraper.onTick(client); @@ -777,6 +789,7 @@ private void onClientTick(Minecraft client) { pendingBootAttestationFailed = false; display(() -> Component.empty().append(Component.literal("[EdenMod] ").withStyle(ChatFormatting.RED)).append(Component.literal("WARNING: ").withStyle(Style.EMPTY.withColor(ChatFormatting.RED).withBold(true))).append(Component.literal("This mod jar could not be verified against any official EdenMod release — it may have been tampered with. Try restarting your game, then if the issue persists, reinstall EdenMod from the official releases. If it still remains, contact FadeDave.").withStyle(ChatFormatting.RED))); } + String connCode = pendingConnectionCode; if (connCode != null && client.player != null) { pendingConnectionCode = null; @@ -860,8 +873,8 @@ private synchronized void connect() { try { socket = BridgeWebSocketClient.create(BridgeConfig.DEFAULT_BACKEND_URL, MOD_VERSION, new BridgeWebSocketClient.MessageSink() { @Override - public void onDiscordMessage(String author, String content, String replyTo, String replyExcerpt, String color) { - displayColored(color, () -> DiscordChatFormatter.format(author, content, replyTo, replyExcerpt)); + public void onDiscordMessage(String author, String content, String replyTo, String replyExcerpt, String color, String messageId) { + displayColored(color, () -> DiscordChatFormatter.format(author, content, replyTo, replyExcerpt, messageId)); } @Override @@ -1452,7 +1465,7 @@ private LiteralArgumentBuilder buildPartyCommand() { return ClientCommandManager.literal("party").executes(ctx -> openPartyListGui(ctx.getSource())).then(ClientCommandManager.literal("list").executes(ctx -> openPartyListGui(ctx.getSource()))).then(ClientCommandManager.literal("create").executes(ctx -> openPartyCreateGui(ctx.getSource())).then(raidLiteral("notg", "Nest of the Grootslangs")).then(raidLiteral("nol", "Orphion's Nexus of Light")).then(raidLiteral("tcc", "The Canyon Colossus")).then(raidLiteral("tna", "The Nameless Anomaly")).then(raidLiteral("wtp", "The Wartorn Palace")).then(otherLiteral())).then(ClientCommandManager.literal("join").executes(ctx -> openPartyListGui(ctx.getSource())).then(ClientCommandManager.argument("id", IntegerArgumentType.integer()).executes(ctx -> partyJoin(ctx.getSource(), IntegerArgumentType.getInteger(ctx, "id"))))).then(ClientCommandManager.literal("leave").executes(ctx -> partyLeave(ctx.getSource(), null)).then(ClientCommandManager.argument("id", IntegerArgumentType.integer()).executes(ctx -> partyLeave(ctx.getSource(), IntegerArgumentType.getInteger(ctx, "id"))))) // Driven by the "[Create party]" prompt shown when a party fills: runs // /party create then invites each listed member in-game. - .then(ClientCommandManager.literal("makeingame").then(ClientCommandManager.argument("members", StringArgumentType.greedyString()).executes(ctx -> makeInGameParty(ctx.getSource(), StringArgumentType.getString(ctx, "members"))))).then(ClientCommandManager.literal("note").executes(ctx -> openPartyManageGui(ctx.getSource())).then(ClientCommandManager.argument("text", StringArgumentType.greedyString()).executes(ctx -> partyManage(ctx.getSource(), "note", StringArgumentType.getString(ctx, "text"), 0, "")))).then(ClientCommandManager.literal("filled").executes(ctx -> openPartyManageGui(ctx.getSource())).then(ClientCommandManager.argument("slots", IntegerArgumentType.integer(0, 8)).executes(ctx -> partyManage(ctx.getSource(), "filled", "", IntegerArgumentType.getInteger(ctx, "slots"), "")))).then(ClientCommandManager.literal("add").executes(ctx -> openPartyManageGui(ctx.getSource())).then(ClientCommandManager.argument("player", StringArgumentType.word()).suggests(this::suggestMembers).executes(ctx -> partyManage(ctx.getSource(), "add", "", 0, StringArgumentType.getString(ctx, "player"))))).then(ClientCommandManager.literal("remove").executes(ctx -> openPartyManageGui(ctx.getSource())).then(ClientCommandManager.argument("player", StringArgumentType.word()).suggests(this::suggestMembers).executes(ctx -> partyManage(ctx.getSource(), "remove", "", 0, StringArgumentType.getString(ctx, "player"))))); + .then(ClientCommandManager.literal("makeingame").then(ClientCommandManager.argument("members", StringArgumentType.greedyString()).executes(ctx -> makeInGameParty(ctx.getSource(), StringArgumentType.getString(ctx, "members"))))).then(ClientCommandManager.literal("note").executes(ctx -> openPartyManageGui(ctx.getSource())).then(ClientCommandManager.argument("text", StringArgumentType.greedyString()).executes(ctx -> partyManage(ctx.getSource(), "note", StringArgumentType.getString(ctx, "text"), 0, "")))).then(ClientCommandManager.literal("filled").executes(ctx -> openPartyManageGui(ctx.getSource())).then(ClientCommandManager.argument("slots", IntegerArgumentType.integer(0, 8)).executes(ctx -> partyManage(ctx.getSource(), "filled", "", IntegerArgumentType.getInteger(ctx, "slots"), "")))).then(ClientCommandManager.literal("add").executes(ctx -> openPartyManageGui(ctx.getSource())).then(ClientCommandManager.argument("player", StringArgumentType.word()).suggests(this::suggestMembers).executes(ctx -> partyManage(ctx.getSource(), "add", "", 0, StringArgumentType.getString(ctx, "player"))))).then(ClientCommandManager.literal("remove").executes(ctx -> openPartyManageGui(ctx.getSource())).then(ClientCommandManager.argument("player", StringArgumentType.word()).suggests(this::suggestMembers).executes(ctx -> partyManage(ctx.getSource(), "remove", "", 0, StringArgumentType.getString(ctx, "player"))))).then(ClientCommandManager.literal("disband").executes(ctx -> partyManage(ctx.getSource(), "close", "", 0, ""))).then(ClientCommandManager.literal("close").executes(ctx -> partyManage(ctx.getSource(), "close", "", 0, ""))); } private int partyManage(FabricClientCommandSource source, String action, String text, int value, String ign) { @@ -2048,10 +2061,23 @@ private int showEmojis(FabricClientCommandSource source) { return 1; } + private int handleReplyCommand(String messageId) { + ChatReplyManager.setActiveReply(messageId); + Minecraft mc = Minecraft.getInstance(); + mc.execute(() -> { + if (!(mc.screen instanceof net.minecraft.client.gui.screens.ChatScreen)) { + mc.setScreen(new net.minecraft.client.gui.screens.ChatScreen("/g ", false)); + } else { + ChatReplyManager.ensureGuildPrefix(); + } + }); + return 1; + } + 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 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 help", "this help screen")); private static final class TrackedCommandKeybind { private final String input; @@ -2212,6 +2238,9 @@ private synchronized void disconnect() { // Likewise the raid tally: it holds players from the session that just ended, and // after an account switch could attribute them to another guild's raid. RaidPartyTracker.reset(); + RaidReadyTracker.reset(); + PartyHealthTracker.reset(); + ChatReplyManager.reset(); // 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 // server — or, after an account switch, another guild. @@ -2255,11 +2284,13 @@ private static void sendStreamCommand() { /** Called from the chat-capture mixin for every non-overlay system-chat component. */ public void handleSystemChat(Component message) { + PlayerNameResolver.observeMessage(message); handleRewardFeedback(message); // 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(); if (onWynncraft) { + RaidReadyTracker.onSystemChat(message); // War chat cues (start countdown → attendance capture; end → HUD summary) // work even while the bridge socket is down. Keep these deferred until after // this packet handler returns, alongside the rest of the client-tick state. @@ -2417,7 +2448,8 @@ public void handleSystemChat(Component message) { // 0-based: a first occurrence is seq 0, which matches what an old mod (no // seq field) defaults to on the backend, so mixed mod versions still dedup. int seq = chatSeq.next(line.username() + "|" + line.message()) - 1; - current.sendGuildChat(line.username(), line.nickname(), line.message(), seq); + String replyToId = ChatReplyManager.consumePendingReply(line.username()); + current.sendGuildChat(line.username(), line.nickname(), line.message(), seq, replyToId); } } @@ -2438,6 +2470,7 @@ private void relayRaid(BridgeWebSocketClient current, RaidCompletion line, Strin // arriving within the retention window would inherit this raid's players. if (ownRaid) { RaidPartyTracker.reset(); + RaidReadyTracker.reset(); } } diff --git a/src/tel/eden/mod/chat/ChatReplyManager.java b/src/tel/eden/mod/chat/ChatReplyManager.java new file mode 100644 index 0000000..0dc446b --- /dev/null +++ b/src/tel/eden/mod/chat/ChatReplyManager.java @@ -0,0 +1,208 @@ +package tel.eden.mod.chat; + +import java.util.LinkedHashMap; +import java.util.Map; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; + +/** + * Manages active and pending Discord reply state for the in-game chat UI and bridge. + */ +public final class ChatReplyManager { + public record ReplyInfo(String messageId, String author, String excerpt) { + } + + private static final int MAX_CACHE_SIZE = 100; + private static final Map RECENT_MESSAGES = new LinkedHashMap<>() { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_CACHE_SIZE; + } + }; + + private static ReplyInfo activeReply = null; + private static String pendingReplyId = null; + private static long pendingReplyTime = 0L; + private static final long PENDING_TIMEOUT_MS = 15_000L; + + private static final int BANNER_HEIGHT = 14; + private static final int CLOSE_BUTTON_WIDTH = 14; + + @FunctionalInterface + public interface ChatScreenInputHolder { + void ensureGuildPrefix(); + } + + private static ChatScreenInputHolder activeInputHolder = null; + + private ChatReplyManager() { + } + + /** Register callback to manipulate the active ChatScreen input box. */ + public static synchronized void registerInputHolder(ChatScreenInputHolder holder) { + activeInputHolder = holder; + } + + /** Unregister callback when ChatScreen is closed. */ + public static synchronized void unregisterInputHolder() { + activeInputHolder = null; + } + + /** Ensure the chat input box starts with /g so messages are routed to guild chat. */ + public static synchronized void ensureGuildPrefix() { + if (activeInputHolder != null) { + activeInputHolder.ensureGuildPrefix(); + } + } + + /** Record a received Discord message so it can be replied to by ID. */ + public static synchronized void recordDiscordMessage(String messageId, String author, String content) { + if (messageId == null || messageId.isEmpty() || author == null) { + return; + } + String excerpt = content != null ? content.strip() : ""; + if (excerpt.length() > 60) { + excerpt = excerpt.substring(0, 57).strip() + "..."; + } + RECENT_MESSAGES.put(messageId, new ReplyInfo(messageId, author, excerpt)); + } + + /** Retrieve cached message metadata by Discord message ID. */ + static synchronized ReplyInfo getRecent(String messageId) { + return RECENT_MESSAGES.get(messageId); + } + + /** Set active reply context looking up cached metadata. */ + public static synchronized void setActiveReply(String messageId) { + ReplyInfo info = RECENT_MESSAGES.get(messageId); + if (info != null) { + activeReply = info; + } else { + activeReply = new ReplyInfo(messageId, "Discord", ""); + } + } + + /** Currently active reply context in the chat UI. */ + static synchronized ReplyInfo activeReplyForTesting() { + return activeReply; + } + + /** Whether a reply banner should be rendered in the chat UI. */ + public static synchronized boolean hasActiveReply() { + return activeReply != null; + } + + /** Clear the active reply banner in the chat UI. */ + public static synchronized void clearActiveReply() { + activeReply = null; + } + + /** Move the active reply context into a pending reply state when chat is submitted. */ + public static synchronized void markPendingReply() { + if (activeReply != null) { + pendingReplyId = activeReply.messageId(); + pendingReplyTime = System.currentTimeMillis(); + activeReply = null; + } + } + + /** + * Consume and return the pending reply message ID if the echoed guild message + * matches the local player and hasn't timed out. + */ + public static synchronized String consumePendingReply(String username) { + if (pendingReplyId == null) { + return null; + } + if (System.currentTimeMillis() - pendingReplyTime > PENDING_TIMEOUT_MS) { + pendingReplyId = null; + return null; + } + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.getUser() != null) { + String myName = mc.getUser().getName(); + if (username != null && !username.equalsIgnoreCase(myName)) { + return null; + } + } + String id = pendingReplyId; + pendingReplyId = null; + return id; + } + + /** Reset both active and pending reply states. */ + public static synchronized void reset() { + activeReply = null; + pendingReplyId = null; + } + + /** Check if a click hit the close button on the active reply banner. */ + public static boolean isCloseButtonClicked(int inputX, int inputY, int inputWidth, double mouseX, double mouseY) { + if (activeReply == null) { + return false; + } + int bannerX = inputX; + int bannerY = inputY - BANNER_HEIGHT; + int closeX = bannerX + inputWidth - CLOSE_BUTTON_WIDTH; + return mouseX >= closeX && mouseX <= closeX + CLOSE_BUTTON_WIDTH && mouseY >= bannerY && mouseY <= bannerY + BANNER_HEIGHT; + } + + /** Render the Discord-style interactive reply banner above the chat box. */ + public static void renderReplyBanner(GuiGraphics graphics, Font font, int inputX, int inputY, int inputWidth, int mouseX, int mouseY) { + ReplyInfo reply = activeReply; + if (reply == null) { + return; + } + int bannerX = inputX; + int bannerY = inputY - BANNER_HEIGHT; + int bannerW = inputWidth; + int bannerH = BANNER_HEIGHT - 2; + + // Background: semi-transparent dark grey/slate (Discord style) + graphics.fill(bannerX, bannerY, bannerX + bannerW, bannerY + bannerH, 0xEE1E1F22); + // Top border subtle line + graphics.fill(bannerX, bannerY, bannerX + bannerW, bannerY + 1, 0xFF35373C); + // Left vertical accent: Discord Blurple (#5865F2) + graphics.fill(bannerX, bannerY, bannerX + 2, bannerY + bannerH, 0xFF5865F2); + + int textY = bannerY + 2; + int curX = bannerX + 6; + + // Arrow and label: "⤹ Replying to @" + String prefix = "\u2939 Replying to @"; + graphics.drawString(font, prefix, curX, textY, 0xFFAAAAAA); + curX += font.width(prefix); + + // Author name in Discord Blurple + String author = reply.author(); + graphics.drawString(font, author, curX, textY, 0xFF5865F2); + curX += font.width(author); + + int closeX = bannerX + bannerW - CLOSE_BUTTON_WIDTH; + boolean closeHovered = mouseX >= closeX && mouseX <= closeX + CLOSE_BUTTON_WIDTH && mouseY >= bannerY && mouseY <= bannerY + bannerH; + + // Excerpt if available + if (!reply.excerpt().isEmpty()) { + String sep = ": \""; + graphics.drawString(font, sep, curX, textY, 0xFF888888); + curX += font.width(sep); + + int maxExcerptW = (closeX - 6) - curX - font.width("\""); + if (maxExcerptW > 20) { + String excerpt = font.plainSubstrByWidth(reply.excerpt(), maxExcerptW); + graphics.drawString(font, excerpt, curX, textY, 0xFF888888); + curX += font.width(excerpt); + graphics.drawString(font, "\"", curX, textY, 0xFF888888); + } + } + + // Close button [✕] + if (closeHovered) { + graphics.fill(closeX, bannerY, closeX + CLOSE_BUTTON_WIDTH, bannerY + bannerH, 0x40FF0000); + graphics.drawString(font, "\u2715", closeX + 3, textY, 0xFFFF5555); + } else { + graphics.drawString(font, "\u2715", closeX + 3, textY, 0xFF888888); + } + } +} diff --git a/src/tel/eden/mod/chat/ChatText.java b/src/tel/eden/mod/chat/ChatText.java index 71dda51..5559a05 100644 --- a/src/tel/eden/mod/chat/ChatText.java +++ b/src/tel/eden/mod/chat/ChatText.java @@ -17,8 +17,8 @@ * lines with private-use glyphs (rank badges, banners) and control characters * that render in-game but get in the way of parsing; these collapse them away. */ -final class ChatText { - static final Pattern IGN = Pattern.compile("[a-zA-Z0-9_]{3,16}"); +public final class ChatText { + public static final Pattern IGN = Pattern.compile("[a-zA-Z0-9_]{3,16}"); // Wynncraft 2.1 soft-wraps chat as "\n" followed by this private-use marker // and one injected leading space. The marker is invisible in-game but must be // removed before forwarding text, otherwise a URL becomes several tokens. @@ -73,7 +73,7 @@ static String resolveRealName(Component message, String displayed) { * is a prefix of, else any candidate that differs from the nickname. Returns null when * no metadata is present (caller falls back to the displayed name). */ - static String resolveRealNameAnywhere(Component message, String displayed) { + public static String resolveRealNameAnywhere(Component message, String displayed) { Set candidates = new LinkedHashSet<>(); message.visit((style, fragment) -> { String hover = hoverRealName(style); @@ -147,7 +147,7 @@ private static String text(List chars) { return builder.toString(); } - private static String hoverRealName(Style style) { + static String hoverRealName(Style style) { HoverEvent hover = style.getHoverEvent(); if (hover instanceof HoverEvent.ShowText showText) { String text = showText.value().getString().replace('’', '\'').replace('‘', '\''); diff --git a/src/tel/eden/mod/chat/DiscordChatFormatter.java b/src/tel/eden/mod/chat/DiscordChatFormatter.java index e70e471..fab7171 100644 --- a/src/tel/eden/mod/chat/DiscordChatFormatter.java +++ b/src/tel/eden/mod/chat/DiscordChatFormatter.java @@ -158,13 +158,36 @@ public static Component format(String author, String content) { * message is shown inline (gray) and the full quote appears on hover. */ public static Component format(String author, String content, String replyTo, String replyExcerpt) { - MutableComponent body = Component.empty().append(Component.literal(pillLabel("discord")).withStyle(Style.EMPTY.withFont(PILL_FONT).withColor(ChatFormatting.GREEN))); + return format(author, content, replyTo, replyExcerpt, null); + } + + /** + * Build the guild-styled chat line for a relayed Discord message with reply context + * and interactive reply click events. + */ + public static Component format(String author, String content, String replyTo, String replyExcerpt, String messageId) { + if (messageId != null && !messageId.isEmpty()) { + ChatReplyManager.recordDiscordMessage(messageId, author, content); + } + Style replyStyle = (messageId != null && !messageId.isEmpty()) ? Style.EMPTY.withClickEvent(new ClickEvent.RunCommand("/eden reply " + messageId)).withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to reply to @" + author).withStyle(ChatFormatting.GRAY))) : null; + + Style pillStyle = Style.EMPTY.withFont(PILL_FONT).withColor(ChatFormatting.GREEN); + if (replyStyle != null) { + pillStyle = pillStyle.withClickEvent(replyStyle.getClickEvent()).withHoverEvent(replyStyle.getHoverEvent()); + } + MutableComponent body = Component.empty().append(Component.literal(pillLabel("discord")).withStyle(pillStyle)); + + String authorText = (replyTo != null && !replyTo.isEmpty()) ? " " + author : " " + author + ": "; + MutableComponent authorComp = Component.literal(authorText).withStyle(ChatFormatting.GREEN); + if (replyStyle != null) { + authorComp.setStyle(authorComp.getStyle().withClickEvent(replyStyle.getClickEvent()).withHoverEvent(replyStyle.getHoverEvent())); + } + body.append(authorComp); + if (replyTo != null && !replyTo.isEmpty()) { - body.append(Component.literal(" " + author).withStyle(ChatFormatting.GREEN)).append(Component.literal(" replied to ").withStyle(ChatFormatting.GRAY)).append(replyTarget(replyTo, replyExcerpt)).append(Component.literal(": ").withStyle(ChatFormatting.GREEN)); - } else { - body.append(Component.literal(" " + author + ": ").withStyle(ChatFormatting.GREEN)); + body.append(Component.literal(" replied to ").withStyle(ChatFormatting.GRAY)).append(replyTarget(replyTo, replyExcerpt)).append(Component.literal(": ").withStyle(ChatFormatting.GREEN)); } - body.append(linkify(content)); + body.append(linkify(content, replyStyle)); return withGuildPrefix(body); } @@ -212,7 +235,6 @@ private static MutableComponent replyTarget(String replyTo, String replyExcerpt) return segment; } - /** Render text with http(s) URLs as clickable, underlined aqua links. */ /** * Render message text with any http(s) URLs as clickable, underlined aqua links, * and any {@code :shortcode:} token matching a known emote (see @@ -221,12 +243,26 @@ private static MutableComponent replyTarget(String replyTo, String replyExcerpt) * message never loses information just because an emote isn't recognized. */ private static MutableComponent linkify(String content) { + return linkify(content, null); + } + + private static MutableComponent linkify(String content, Style replyStyle) { MutableComponent out = Component.empty(); Matcher matcher = TOKEN_PATTERN.matcher(content); int last = 0; + // Click event for the text body (without hover popup so reading isn't obstructed) + ClickEvent textClick = replyStyle != null ? replyStyle.getClickEvent() : null; + java.util.function.Consumer appendText = text -> { + MutableComponent comp = Component.literal(text).withStyle(ChatFormatting.GREEN); + if (textClick != null) { + comp.setStyle(comp.getStyle().withClickEvent(textClick)); + } + out.append(comp); + }; + while (matcher.find()) { if (matcher.start() > last) { - out.append(Component.literal(content.substring(last, matcher.start())).withStyle(ChatFormatting.GREEN)); + appendText.accept(content.substring(last, matcher.start())); } String stickerName = matcher.group("sname"); String url = matcher.group("url"); @@ -249,12 +285,16 @@ private static MutableComponent linkify(String content) { } else { String shortcode = matcher.group("emote"); Component emote = emoteComponent(shortcode); - out.append(emote != null ? emote : Component.literal(matcher.group()).withStyle(ChatFormatting.GREEN)); + if (emote != null) { + out.append(emote); + } else { + appendText.accept(matcher.group()); + } } last = matcher.end(); } if (last < content.length()) { - out.append(Component.literal(content.substring(last)).withStyle(ChatFormatting.GREEN)); + appendText.accept(content.substring(last)); } return out; } diff --git a/src/tel/eden/mod/chat/PlayerNameResolver.java b/src/tel/eden/mod/chat/PlayerNameResolver.java index e291fec..e75aa80 100644 --- a/src/tel/eden/mod/chat/PlayerNameResolver.java +++ b/src/tel/eden/mod/chat/PlayerNameResolver.java @@ -1,20 +1,34 @@ package tel.eden.mod.chat; import java.util.Locale; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import net.minecraft.client.Minecraft; import net.minecraft.client.multiplayer.PlayerInfo; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; /** Canonical player identity: real Minecraft usernames, with nicknames remembered as aliases. */ public final class PlayerNameResolver { private static final ConcurrentMap USERNAME_BY_DISPLAY = new ConcurrentHashMap<>(); + private static final Pattern NICKNAME_USERNAME_PAREN = Pattern.compile("(?:\\[[^\\]]+\\]\\s*)?([a-zA-Z0-9_][a-zA-Z0-9_ ]*?)\\s*\\(([a-zA-Z0-9_]{3,16})\\)"); + private static final Pattern PARTY_JOINED_MSG = Pattern.compile("^(.+?)\\s+has joined (?:your|the)\\s+party", Pattern.CASE_INSENSITIVE); + private static final Pattern PARTY_MEMBERS_MSG = Pattern.compile("^Party members:\\s*(.+)", Pattern.CASE_INSENSITIVE); + private static final Pattern SLASH_ALIAS_PATTERN = Pattern.compile("(? URL_TOKENS = Set.of("http", "https", "com", "org", "net", "io", "gg"); private PlayerNameResolver() { } + public static void reset() { + USERNAME_BY_DISPLAY.clear(); + } + /** Resolve a rendered player name and retain any hover-provided nickname mapping. */ public static String resolve(Component message, String displayed) { // A reward/bank line can contain several player names. Resolving from every @@ -35,7 +49,28 @@ public static String canonicalize(String name) { if (name == null) { return null; } - return USERNAME_BY_DISPLAY.getOrDefault(key(name), name.trim()); + String trimmed = name.trim(); + String direct = USERNAME_BY_DISPLAY.get(key(trimmed)); + if (direct != null) { + return direct; + } + String clean = cleanKey(trimmed); + if (clean.length() >= 3) { + String cleanDirect = USERNAME_BY_DISPLAY.get(clean); + if (cleanDirect != null) { + return cleanDirect; + } + for (Map.Entry entry : USERNAME_BY_DISPLAY.entrySet()) { + if (entry.getKey().equalsIgnoreCase(entry.getValue())) { + continue; + } + String entryClean = cleanKey(entry.getKey()); + if (entryClean.equals(clean) || (clean.length() >= 4 && entryClean.startsWith(clean))) { + return entry.getValue(); + } + } + } + return trimmed; } /** @@ -61,6 +96,22 @@ public static Optional resolveKnown(String name) { if (cached != null) { return Optional.of(cached); } + String clean = cleanKey(trimmed); + if (clean.length() >= 3) { + String cleanDirect = USERNAME_BY_DISPLAY.get(clean); + if (cleanDirect != null) { + return Optional.of(cleanDirect); + } + for (Map.Entry entry : USERNAME_BY_DISPLAY.entrySet()) { + if (entry.getKey().equalsIgnoreCase(entry.getValue())) { + continue; + } + String entryClean = cleanKey(entry.getKey()); + if (entryClean.equals(clean) || (clean.length() >= 4 && entryClean.startsWith(clean))) { + return Optional.of(entry.getValue()); + } + } + } String fromTabList = resolveFromTabList(trimmed); if (fromTabList != null) { recordAlias(trimmed, fromTabList); @@ -71,27 +122,32 @@ public static Optional resolveKnown(String name) { private static String resolveFromTabList(String displayed) { Minecraft mc = Minecraft.getInstance(); - if (mc.getConnection() == null) { + if (mc == null || mc.getConnection() == null) { return null; } + String cleanDisplayed = cleanKey(displayed); for (PlayerInfo info : mc.getConnection().getOnlinePlayers()) { - Component tabName = info.getTabListDisplayName(); - if (tabName == null) { + String profileName = (info.getProfile() != null) ? info.getProfile().name() : null; + if (profileName == null || !ChatText.IGN.matcher(profileName).matches()) { continue; } - String visible = tabName.getString().replaceAll("[^a-zA-Z0-9_]", ""); - boolean plausibleMatch = visible.equalsIgnoreCase(displayed) || visible.toLowerCase(Locale.ROOT).contains(displayed.toLowerCase(Locale.ROOT)); - if (!plausibleMatch) { - continue; + if (profileName.equalsIgnoreCase(displayed)) { + return profileName; } - String hoverResolved = ChatText.resolveRealNameAnywhere(tabName, displayed); - if (hoverResolved != null && ChatText.IGN.matcher(hoverResolved).matches()) { - return hoverResolved; + String cleanProfile = profileName.toLowerCase(Locale.ROOT); + if (!cleanDisplayed.isEmpty() && (cleanProfile.equals(cleanDisplayed) || (cleanDisplayed.length() >= 3 && cleanProfile.startsWith(cleanDisplayed)))) { + return profileName; } - if (visible.equalsIgnoreCase(displayed) && ChatText.IGN.matcher(visible).matches()) { - // No contradicting hover, and this online player's own tab entry shows - // exactly this name — not nicknamed right now, so it's already the real one. - return visible; + Component tabName = info.getTabListDisplayName(); + if (tabName != null) { + String hoverResolved = ChatText.resolveRealNameAnywhere(tabName, displayed); + if (hoverResolved != null && ChatText.IGN.matcher(hoverResolved).matches()) { + return hoverResolved; + } + String visible = cleanKey(tabName.getString()); + if (!cleanDisplayed.isEmpty() && (visible.equals(cleanDisplayed) || (cleanDisplayed.length() >= 3 && visible.contains(cleanDisplayed)))) { + return profileName; + } } } return null; @@ -104,9 +160,130 @@ public static void recordAlias(String displayed, String username) { } USERNAME_BY_DISPLAY.put(key(displayed), username); USERNAME_BY_DISPLAY.put(key(username), username); + String clean = cleanKey(displayed); + if (!clean.isEmpty() && !clean.equals(key(displayed))) { + USERNAME_BY_DISPLAY.put(clean, username); + } + } + + /** + * Inspect any incoming chat message (party, guild, whispers, system alerts) + * and learn nickname -> real username mappings from text and component styles. + */ + public static void observeMessage(Component message) { + if (message == null) { + return; + } + + message.visit((style, text) -> { + if (text == null || text.isBlank()) { + return Optional.empty(); + } + String trimmedText = text.trim().replaceAll("[^a-zA-Z0-9_]", ""); + String hoverReal = ChatText.hoverRealName(style); + if (hoverReal != null && ChatText.IGN.matcher(hoverReal).matches()) { + if (!trimmedText.isEmpty() && !trimmedText.equalsIgnoreCase(hoverReal)) { + recordAlias(trimmedText, hoverReal); + } + } + String insertion = style.getInsertion(); + if (insertion != null && ChatText.IGN.matcher(insertion).matches()) { + if (!trimmedText.isEmpty() && !trimmedText.equalsIgnoreCase(insertion)) { + recordAlias(trimmedText, insertion); + } + } + return Optional.empty(); + }, Style.EMPTY); + + String plain = message.getString(); + learnFromText(plain); + + Matcher parenMatcher = NICKNAME_USERNAME_PAREN.matcher(plain); + while (parenMatcher.find()) { + String nick = parenMatcher.group(1).trim(); + String real = parenMatcher.group(2).trim(); + if (!nick.isEmpty() && ChatText.IGN.matcher(real).matches()) { + recordAlias(nick, real); + } + } + + Matcher joinedMatcher = PARTY_JOINED_MSG.matcher(plain); + if (joinedMatcher.find()) { + String joinedName = joinedMatcher.group(1).trim(); + String real = ChatText.resolveRealNameAnywhere(message, joinedName); + if (real != null) { + recordAlias(joinedName, real); + } + } + + Matcher membersMatcher = PARTY_MEMBERS_MSG.matcher(plain); + if (membersMatcher.find()) { + String tail = membersMatcher.group(1).trim(); + String[] tokens = tail.split("\\s*,\\s*(?:and\\s+)?|\\s+and\\s+"); + for (String token : tokens) { + String item = token.trim().replaceAll("[^a-zA-Z0-9_]", ""); + if (!item.isEmpty()) { + String real = ChatText.resolveRealNameAnywhere(message, item); + if (real != null) { + recordAlias(item, real); + } + } + } + } + } + + /** Learn any explicit real/nickname pairs embedded in text (e.g. "Username/Nickname"). */ + public static void learnFromText(String text) { + if (text == null || !text.contains("/")) { + return; + } + // If the text contains player chat (e.g. "Player: text"), do not learn aliases from the chat content + int colonIdx = text.indexOf(": "); + Matcher matcher = SLASH_ALIAS_PATTERN.matcher(text); + while (matcher.find()) { + if (colonIdx >= 0 && matcher.start() > colonIdx) { + // Inside player chat body - ignore to avoid normal conversational words + continue; + } + String real = matcher.group(1); + String nick = matcher.group(2); + if (isUrlToken(real) || isUrlToken(nick)) { + continue; + } + Minecraft mc = Minecraft.getInstance(); + boolean hasConnection = (mc != null && mc.getConnection() != null); + if (hasConnection) { + boolean realKnown = USERNAME_BY_DISPLAY.containsKey(key(real)) || resolveFromTabList(real) != null; + boolean nickKnown = USERNAME_BY_DISPLAY.containsKey(key(nick)) || resolveFromTabList(nick) != null; + if (realKnown) { + recordAlias(nick, real); + } else if (nickKnown) { + recordAlias(real, nick); + } + } else { + // Offline / test environment without live tab list + boolean realKnown = USERNAME_BY_DISPLAY.containsKey(key(real)); + boolean nickKnown = USERNAME_BY_DISPLAY.containsKey(key(nick)); + if (realKnown) { + recordAlias(nick, real); + } else if (nickKnown) { + recordAlias(real, nick); + } else { + recordAlias(nick, real); + } + } + } + } + + private static boolean isUrlToken(String token) { + return token != null && URL_TOKENS.contains(token.toLowerCase(Locale.ROOT)); } private static String key(String name) { return name.trim().toLowerCase(Locale.ROOT); } + + private static String cleanKey(String name) { + return name.replaceAll("[^a-zA-Z0-9_]", "").toLowerCase(Locale.ROOT); + } } diff --git a/src/tel/eden/mod/chat/RaidCompletionParser.java b/src/tel/eden/mod/chat/RaidCompletionParser.java index 0e900ca..c8e1507 100644 --- a/src/tel/eden/mod/chat/RaidCompletionParser.java +++ b/src/tel/eden/mod/chat/RaidCompletionParser.java @@ -67,6 +67,9 @@ public static Optional parse(Component message) { // Flatten color codes/formatting for regex parsing String rawText = message.getString(); String cleaned = ChatText.normalize(rawText).replace(",and ", ", and "); + if (cleaned.contains("shouts:")) { + return Optional.empty(); + } Matcher detect = RAID_DETECT.matcher(cleaned); if (!detect.find()) { return Optional.empty(); @@ -76,8 +79,12 @@ public static Optional parse(Component message) { if (raidName == null || !names.find()) { return Optional.empty(); } - List party = resolveParty(splitNames(names.group(1)), message); - if (party.isEmpty()) { + String namesGroup = names.group(1); + if (namesGroup.contains(":") || namesGroup.contains(">")) { + return Optional.empty(); + } + List party = resolveParty(splitNames(namesGroup), message); + if (party.isEmpty() || party.size() > 4) { return Optional.empty(); } // Loot is best-effort: it's frequently a custom-font glyph run, not literal diff --git a/src/tel/eden/mod/chat/RaidPartyTracker.java b/src/tel/eden/mod/chat/RaidPartyTracker.java index c0d3b85..106f3a5 100644 --- a/src/tel/eden/mod/chat/RaidPartyTracker.java +++ b/src/tel/eden/mod/chat/RaidPartyTracker.java @@ -95,6 +95,12 @@ private RaidPartyTracker() { // Hold the tally this long after the raid ends: the completion message lands just after. private static final long RETENTION_MS = 60_000L; + // Sampling runs on the client thread; a raid completion arrives on the netty thread + // (the chat mixin injects ahead of vanilla's ensureRunningOnSameThread), and that is + // what calls extraPlayers and reset. Every field below is therefore touched from both, + // and is guarded by LOCK — without it the completion can read a stale "no raid is + // running", judge the tally stale, and report no allies at all. + private static final Object LOCK = new Object(); private static final Map sightings = new HashMap<>(); private static boolean raiding; private static long raidEndedAt; @@ -107,25 +113,36 @@ private RaidPartyTracker() { * at frame rate, against a raid that lasts minutes and a presence threshold in seconds. */ public static void onTick() { - tickCounter++; - if (tickCounter % SIDEBAR_CHECK_INTERVAL_TICKS == 0) { - updateRaidState(sidebarShowsRaid()); + int tick; + boolean sampling; + synchronized (LOCK) { + tick = ++tickCounter; + sampling = raiding; } - if (!raiding) { + // Read the sidebar outside the lock: it walks the whole scoreboard, and the only + // other holder is a raid completion that should never wait on that. + if (tick % SIDEBAR_CHECK_INTERVAL_TICKS == 0) { + boolean inRaid = sidebarShowsRaid(); + synchronized (LOCK) { + updateRaidState(inRaid); + sampling = raiding; + } + } + if (!sampling) { return; } - if (tickCounter % SAMPLE_INTERVAL_TICKS == 0) { + if (tick % SAMPLE_INTERVAL_TICKS == 0) { sampleNearbyPlayers(); } } - /** Fold the latest sidebar reading into whether a raid is being tracked. */ + /** Fold the latest sidebar reading into whether a raid is being tracked. Holds LOCK. */ private static void updateRaidState(boolean inRaid) { if (inRaid && !raiding) { // A gap long enough to be a different raid drops the previous tally; a short one is // a room transition briefly clearing the sidebar, so the count carries on. if (raidEndedAt != 0 && System.currentTimeMillis() - raidEndedAt > NEW_RAID_GAP_MS) { - clear(); + sightings.clear(); } raiding = true; raidEndedAt = 0; @@ -149,12 +166,15 @@ private static void updateRaidState(boolean inRaid) { * player. */ public static List extraPlayers(List announcedParty) { - if (announcedParty.size() >= RAID_PARTY_SIZE || isStale()) { + if (announcedParty.size() >= RAID_PARTY_SIZE) { return List.of(); } Set announced = announcedParty.stream().map(name -> name.toLowerCase(Locale.ROOT)).collect(Collectors.toSet()); List> ranked; - synchronized (sightings) { + synchronized (LOCK) { + if (isStale()) { + return List.of(); + } ranked = sightings.entrySet().stream().filter(entry -> entry.getValue() >= MIN_SIGHTINGS).filter(entry -> !announced.contains(entry.getKey().toLowerCase(Locale.ROOT))).sorted(Map.Entry.comparingByValue().reversed()).limit(MAX_CANDIDATES).collect(Collectors.toList()); } return ranked.stream().map(Map.Entry::getKey).collect(Collectors.toUnmodifiableList()); @@ -162,15 +182,17 @@ public static List extraPlayers(List announcedParty) { /** Drop the tally once a completion has consumed it, or on world change. */ public static void reset() { - raiding = false; - raidEndedAt = 0; - tickCounter = 0; - clear(); + synchronized (LOCK) { + raiding = false; + raidEndedAt = 0; + tickCounter = 0; + sightings.clear(); + } } /** * Whether the tally can still be trusted: a raid is running, or one ended recently enough - * that this completion is plausibly its own. + * that this completion is plausibly its own. Holds LOCK. */ private static boolean isStale() { if (raiding) { @@ -238,7 +260,7 @@ private static void sampleNearbyPlayers() { } AABB box = new AABB(self.getX() - SAMPLE_RADIUS, self.getY() - SAMPLE_RADIUS, self.getZ() - SAMPLE_RADIUS, self.getX() + SAMPLE_RADIUS, self.getY() + SAMPLE_RADIUS, self.getZ() + SAMPLE_RADIUS); List players = mc.level.getEntitiesOfClass(Player.class, box); - synchronized (sightings) { + synchronized (LOCK) { for (Player player : players) { String name = accountName(player); if (name != null) { @@ -254,12 +276,6 @@ private static String accountName(Player player) { return profileName != null && IGN.matcher(profileName).matches() ? profileName : null; } - private static void clear() { - synchronized (sightings) { - sightings.clear(); - } - } - private static String stripCodes(String text) { return COLOR_CODE.matcher(text).replaceAll(""); } @@ -270,8 +286,8 @@ private static String stripCodes(String text) { */ public static List debugState() { List out = new ArrayList<>(); - out.add("raid sampling: " + raiding + " (need " + MIN_SIGHTINGS + " sightings, " + (MIN_PRESENCE_MS / 1000) + "s)"); - synchronized (sightings) { + synchronized (LOCK) { + out.add("raid sampling: " + raiding + " (need " + MIN_SIGHTINGS + " sightings, " + (MIN_PRESENCE_MS / 1000) + "s)"); if (sightings.isEmpty()) { out.add(" (nobody sampled)"); return out; diff --git a/src/tel/eden/mod/chat/RaidReadyTracker.java b/src/tel/eden/mod/chat/RaidReadyTracker.java new file mode 100644 index 0000000..7da8969 --- /dev/null +++ b/src/tel/eden/mod/chat/RaidReadyTracker.java @@ -0,0 +1,557 @@ +package tel.eden.mod.chat; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.resources.sounds.SimpleSoundInstance; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextColor; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.world.scores.DisplaySlot; +import net.minecraft.world.scores.Objective; +import net.minecraft.world.scores.PlayerScoreEntry; +import net.minecraft.world.scores.PlayerTeam; +import net.minecraft.world.scores.Scoreboard; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.config.BridgeConfig; +import tel.eden.mod.war.ScoreboardCapture; + +/** + * Tracks raid ready-up messages in chat and live scoreboard pawns, + * plays a ping sound when someone readies, and sends an alert if 3 party members + * have been waiting for the player for 1 minute. + */ +public final class RaidReadyTracker { + private static final Pattern READY_PATTERN = Pattern.compile("^(?:[\\u27A4\\u25C6\\u25B6\\u00BB\\u2192>\\[!\\]\\s*]+)?([a-zA-Z0-9_/ ()-]+?)\\s+is ready!\\s*$", Pattern.CASE_INSENSITIVE); + private static final Pattern UNREADY_PATTERN = Pattern.compile("^(?:[\\u27A4\\u25C6\\u25B6\\u00BB\\u2192>\\[!\\]\\s*]+)?([a-zA-Z0-9_/ ()-]+?)\\s+is no longer ready!\\s*$", Pattern.CASE_INSENSITIVE); + private static final Pattern RAID_START_PATTERN = Pattern.compile("^(?:[\\u27A4\\u25C6\\u25B6\\u00BB\\u2192>\\[!\\]\\s*]+)?([a-zA-Z0-9_/ ()-]+?)\\s+would like to start\\s+(.+?)!", Pattern.CASE_INSENSITIVE); + + private static final Set readyPlayers = new HashSet<>(); + private static long waitingSince = 0L; + private static boolean alertSent = false; + private static int tickCounter = 0; + private static PawnStatus lastPawnStatus = null; + private static String lastRaidStartText = ""; + private static long lastRaidStartTime = 0L; + + public record PawnStatus(int ready, int unready, int empty, List details, String rawLine) { + public int partySize() { + return ready + unready; + } + + public int totalPieces() { + return ready + unready + empty; + } + } + + private enum PawnColor { + GREEN, GRAY, DARK_GRAY, UNKNOWN + } + + private RaidReadyTracker() { + } + + public static void onSystemChat(Component message) { + String text = ChatText.normalizeWhitespace(message.getString()); + if (text.isEmpty()) { + return; + } + + Matcher startMatcher = RAID_START_PATTERN.matcher(text); + if (startMatcher.find()) { + long now = System.currentTimeMillis(); + if (now - lastRaidStartTime > 2000L || !text.equalsIgnoreCase(lastRaidStartText)) { + lastRaidStartTime = now; + lastRaidStartText = text; + reset(); + String rawName = startMatcher.group(1).trim(); + String realName = extractRealName(message, rawName); + if (realName != null) { + handlePlayerReady(realName); + } + } + return; + } + + if (text.toLowerCase(Locale.ROOT).contains("would like to start")) { + long now = System.currentTimeMillis(); + if (now - lastRaidStartTime > 2000L || !text.equalsIgnoreCase(lastRaidStartText)) { + lastRaidStartTime = now; + lastRaidStartText = text; + reset(); + } + return; + } + + Matcher readyMatcher = READY_PATTERN.matcher(text); + if (readyMatcher.matches()) { + if (!isRaidPromptActive()) { + return; + } + String rawName = readyMatcher.group(1).trim(); + String realName = extractRealName(message, rawName); + if (realName != null) { + handlePlayerReady(realName); + } + return; + } + + Matcher unreadyMatcher = UNREADY_PATTERN.matcher(text); + if (unreadyMatcher.matches()) { + if (!isRaidPromptActive()) { + return; + } + String rawName = unreadyMatcher.group(1).trim(); + String realName = extractRealName(message, rawName); + if (realName != null) { + handlePlayerUnready(realName); + } + } + } + + public static void onClientTick() { + tickCounter++; + if (tickCounter % 10 == 0) { + checkWaitingState(); + } + + if (waitingSince > 0L && !alertSent) { + EdenModClient client = EdenModClient.instance(); + BridgeConfig config = client != null ? client.config() : null; + int waitSec = (config != null) ? Math.max(5, config.raidReadyReminderSeconds) : 60; + if (System.currentTimeMillis() - waitingSince >= waitSec * 1000L) { + alertSent = true; + triggerWaitingReminderAlert(waitSec); + } + } + } + + private static void handlePlayerReady(String realName) { + readyPlayers.add(realName.toLowerCase(Locale.ROOT)); + + EdenModClient client = EdenModClient.instance(); + BridgeConfig config = client != null ? client.config() : null; + if (config == null || config.raidReadyPing) { + playPingSound(1.2f); + } + + checkWaitingState(); + } + + private static void handlePlayerUnready(String realName) { + readyPlayers.remove(realName.toLowerCase(Locale.ROOT)); + checkWaitingState(); + } + + private static void checkWaitingState() { + // Scan live scoreboard pawns for authoritative party readiness + lastPawnStatus = scanScoreboardPawns(); + if (lastPawnStatus == null) { + Scoreboard scoreboard = sidebarScoreboard(); + if (scoreboard != null && System.currentTimeMillis() - lastRaidStartTime > 3000L) { + if (!readyPlayers.isEmpty() || waitingSince > 0L) { + reset(); + } + } + return; + } + + EdenModClient client = EdenModClient.instance(); + String self = client != null ? client.playerName() : null; + if (self == null) { + try { + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.player != null) { + self = mc.player.getGameProfile().name(); + } + } catch (Throwable ignored) { + } + } + + if (self == null) { + return; + } + + String selfLower = self.toLowerCase(Locale.ROOT); + boolean selfReady = readyPlayers.contains(selfLower); + int chatOthersReady = readyPlayers.size() - (selfReady ? 1 : 0); + + int scoreboardOthersReady = 0; + if (lastPawnStatus.totalPieces() > 0) { + int readyPieces = lastPawnStatus.ready(); + // If all 4 slots are ready, raid is starting / all ready -> cancel waiting + if (readyPieces >= 4) { + waitingSince = 0L; + alertSent = false; + return; + } + if (selfReady) { + scoreboardOthersReady = Math.max(0, readyPieces - 1); + } else { + scoreboardOthersReady = readyPieces; + } + } + + int othersReady = Math.max(chatOthersReady, scoreboardOthersReady); + + if (!selfReady && othersReady >= 3) { + if (waitingSince == 0L) { + waitingSince = System.currentTimeMillis(); + alertSent = false; + } + } else { + waitingSince = 0L; + alertSent = false; + } + } + + public static boolean isPawn(char c) { + return c == '\uE085' || (c >= '\u2654' && c <= '\u265F'); + } + + public static PawnStatus scanScoreboardPawns() { + Scoreboard scoreboard = sidebarScoreboard(); + if (scoreboard == null) { + return null; + } + Objective sidebar = scoreboard.getDisplayObjective(DisplaySlot.SIDEBAR); + if (sidebar != null) { + PawnStatus status = scanObjectivePawns(scoreboard, sidebar); + if (status != null) { + return status; + } + } + for (Objective obj : scoreboard.getObjectives()) { + if (obj != sidebar) { + PawnStatus status = scanObjectivePawns(scoreboard, obj); + if (status != null) { + return status; + } + } + } + return null; + } + + private static PawnStatus scanObjectivePawns(Scoreboard scoreboard, Objective obj) { + for (PlayerScoreEntry entry : scoreboard.listPlayerScores(obj)) { + PlayerTeam team = scoreboard.getPlayersTeam(entry.owner()); + Component component = team != null ? PlayerTeam.formatNameForTeam(team, entry.ownerName()) : entry.ownerName(); + String plain = component.getString(); + if (plain.toLowerCase(Locale.ROOT).contains("players") || plain.contains("\uE085")) { + PawnStatus status = parsePawnComponent(component); + if (status != null && status.totalPieces() > 0) { + return status; + } + } + } + return null; + } + + public static PawnStatus parsePawnComponent(Component component) { + int[] ready = new int[1]; + int[] unready = new int[1]; + int[] empty = new int[1]; + List details = new ArrayList<>(); + String rawLine = component.getString(); + + component.visit((style, text) -> { + if (text == null || text.isEmpty()) { + return Optional.empty(); + } + + TextColor styleColor = style.getColor(); + TextColor currentColor = styleColor; + + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + + // Handle section sign legacy formatting if embedded in string + if (c == '\u00A7' && i + 1 < text.length()) { + char code = Character.toLowerCase(text.charAt(i + 1)); + ChatFormatting format = ChatFormatting.getByCode(code); + if (format != null) { + if (format == ChatFormatting.RESET) { + currentColor = styleColor; + } else if (format.getColor() != null) { + currentColor = TextColor.fromRgb(format.getColor()); + } + } + i++; + continue; + } + + if (isPawn(c)) { + PawnColor colorType = classifyPawnColor(currentColor); + String hex = colorHex(currentColor); + int idx = ready[0] + unready[0] + empty[0] + 1; + switch (colorType) { + case GREEN -> { + ready[0]++; + details.add("piece " + idx + ": green [READY] (" + hex + ")"); + } + case GRAY -> { + unready[0]++; + details.add("piece " + idx + ": gray [UNREADY] (" + hex + ")"); + } + case DARK_GRAY -> { + empty[0]++; + details.add("piece " + idx + ": dark gray [EMPTY] (" + hex + ")"); + } + case UNKNOWN -> { + unready[0]++; + details.add("piece " + idx + ": unknown [treated as UNREADY] (" + hex + ")"); + } + } + } + } + return Optional.empty(); + }, Style.EMPTY); + + return new PawnStatus(ready[0], unready[0], empty[0], details, rawLine); + } + + private static PawnColor classifyPawnColor(TextColor color) { + if (color == null) { + return PawnColor.GRAY; + } + int rgb = color.getValue(); + if (rgb == 0x55FF55 || rgb == 0x00AA00) { + return PawnColor.GREEN; + } + if (rgb == 0x555555) { + return PawnColor.DARK_GRAY; + } + if (rgb == 0xAAAAAA || rgb == 0xFFFFFF) { + return PawnColor.GRAY; + } + return PawnColor.UNKNOWN; + } + + private static String colorHex(TextColor color) { + if (color == null) { + return "none"; + } + return String.format("#%06X", color.getValue()); + } + + private static Scoreboard sidebarScoreboard() { + if (ScoreboardCapture.hasSidebar()) { + return ScoreboardCapture.scoreboard(); + } + try { + Minecraft mc = Minecraft.getInstance(); + return mc != null && mc.level != null ? mc.level.getScoreboard() : null; + } catch (Throwable ignored) { + return null; + } + } + + private static void triggerWaitingReminderAlert(int waitSeconds) { + EdenModClient client = EdenModClient.instance(); + BridgeConfig config = client != null ? client.config() : null; + if (config == null || config.raidReadyPing) { + playAnvilDropSound(); + } + + try { + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.player != null) { + String durationStr = formatWaitDuration(waitSeconds); + Component alert = Component.empty().append(Component.literal("[EdenMod] ").withStyle(ChatFormatting.DARK_GREEN)).append(Component.literal("Ready up! ").withStyle(Style.EMPTY.withColor(ChatFormatting.RED).withBold(true))).append(Component.literal("The other 3 party members have been ready for " + durationStr + ".").withStyle(ChatFormatting.YELLOW)); + mc.player.displayClientMessage(alert, false); + } + } catch (Throwable ignored) { + } + } + + public static String formatWaitDuration(int seconds) { + if (seconds <= 0) { + return "0 seconds"; + } + if (seconds == 60) { + return "1 minute"; + } + if (seconds % 60 == 0) { + return (seconds / 60) + " minutes"; + } + if (seconds < 60) { + return seconds + (seconds == 1 ? " second" : " seconds"); + } + int mins = seconds / 60; + int secs = seconds % 60; + return mins + (mins == 1 ? " minute " : " minutes ") + secs + (secs == 1 ? " second" : " seconds"); + } + + private static void playAnvilDropSound() { + try { + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.getSoundManager() != null) { + mc.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.ANVIL_LAND, 1.0f)); + } + } catch (Throwable ignored) { + } + } + + private static void playPingSound(float pitch) { + try { + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.getSoundManager() != null) { + mc.getSoundManager().play(SimpleSoundInstance.forUI(SoundEvents.EXPERIENCE_ORB_PICKUP, pitch)); + } + } catch (Throwable ignored) { + } + } + + public static void reset() { + readyPlayers.clear(); + waitingSince = 0L; + alertSent = false; + lastPawnStatus = null; + } + + public static boolean isRaidPromptActive() { + Scoreboard scoreboard = sidebarScoreboard(); + if (scoreboard == null) { + // In offline test environment without a world, allow testing chat patterns + return true; + } + return scanScoreboardPawns() != null || (System.currentTimeMillis() - lastRaidStartTime <= 3000L); + } + + public static List debugState() { + List lines = new ArrayList<>(); + lines.add("--- Raid Ready Tracker ---"); + lines.add("chat ready players: " + (readyPlayers.isEmpty() ? "(none)" : String.join(", ", readyPlayers))); + + EdenModClient client = EdenModClient.instance(); + String self = client != null ? client.playerName() : null; + if (self == null) { + try { + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.player != null) { + self = mc.player.getGameProfile().name(); + } + } catch (Throwable ignored) { + } + } + boolean selfReady = self != null && readyPlayers.contains(self.toLowerCase(Locale.ROOT)); + lines.add("local player: " + (self != null ? self : "unknown") + " (selfReady=" + selfReady + ")"); + + PawnStatus pawns = scanScoreboardPawns(); + if (pawns == null) { + lines.add("scoreboard pawns: (none active - ready check inactive)"); + } else { + lines.add("scoreboard line: " + pawns.rawLine()); + lines.add("scoreboard breakdown: " + pawns.ready() + " ready, " + pawns.unready() + " unready, " + pawns.empty() + " empty (party: " + pawns.partySize() + "/4)"); + for (String detail : pawns.details()) { + lines.add(" " + detail); + } + } + + int chatOthersReady = readyPlayers.size() - (selfReady ? 1 : 0); + int sbOthersReady = 0; + if (pawns != null && pawns.totalPieces() > 0) { + sbOthersReady = selfReady ? Math.max(0, pawns.ready() - 1) : pawns.ready(); + } + int othersReady = pawns != null ? Math.max(chatOthersReady, sbOthersReady) : 0; + lines.add("effective others ready: " + othersReady + " (chat=" + chatOthersReady + ", scoreboard=" + sbOthersReady + ")"); + + BridgeConfig config = client != null ? client.config() : null; + int waitSec = (config != null) ? Math.max(5, config.raidReadyReminderSeconds) : 60; + + if (pawns == null) { + lines.add("waiting alert: inactive (no raid ready prompt on scoreboard)"); + } else if (waitingSince > 0L) { + long elapsedSec = (System.currentTimeMillis() - waitingSince) / 1000L; + lines.add("waiting alert: ACTIVE (" + elapsedSec + "s / " + waitSec + "s elapsed, alertSent=" + alertSent + ")"); + } else { + lines.add("waiting alert: inactive" + (othersReady >= 3 && selfReady ? " (you are already ready)" : (othersReady < 3 ? " (need 3 others ready)" : ""))); + } + return lines; + } + + /** + * De-nicknames a displayed name using IGN/nickname, IGN(nickname), nickname(IGN), + * or hover/insertion metadata. + */ + public static String extractRealName(Component message, String displayed) { + if (displayed == null || displayed.isBlank()) { + return displayed; + } + String s = displayed.trim(); + + // 1. Hover/insertion resolution first (most authoritative) + String hoverResolved = ChatText.resolveRealNameAnywhere(message, s); + if (hoverResolved != null && ChatText.IGN.matcher(hoverResolved).matches()) { + return hoverResolved; + } + + // 2. "realIGN/nickname" format + int slash = s.indexOf('/'); + if (slash > 0) { + String resolved = resolveCandidatePair(s.substring(0, slash).trim(), s.substring(slash + 1).trim()); + if (resolved != null) { + return resolved; + } + } + + // 3. "IGN(nickname)" or "nickname(IGN)" format + int paren = s.indexOf('('); + if (paren > 0 && s.endsWith(")")) { + String resolved = resolveCandidatePair(s.substring(0, paren).trim(), s.substring(paren + 1, s.length() - 1).trim()); + if (resolved != null) { + return resolved; + } + } + + // 4. Fall back to PlayerNameResolver (checks alias cache and tab list) + String resolved = PlayerNameResolver.resolve(message, s); + if (resolved != null && ChatText.IGN.matcher(resolved).matches()) { + return resolved; + } + return s; + } + + private static String resolveCandidatePair(String first, String second) { + String known1 = PlayerNameResolver.resolveKnown(first).orElse(null); + if (known1 != null) { + return known1; + } + String known2 = PlayerNameResolver.resolveKnown(second).orElse(null); + if (known2 != null) { + return known2; + } + if (ChatText.IGN.matcher(first).matches()) { + return first; + } + if (ChatText.IGN.matcher(second).matches()) { + return second; + } + return null; + } + + // Package-private testing methods + static Set readyPlayersForTesting() { + return readyPlayers; + } + + static long waitingSinceForTesting() { + return waitingSince; + } + + static boolean alertSentForTesting() { + return alertSent; + } + + static void setWaitingSinceForTesting(long time) { + waitingSince = time; + } +} diff --git a/src/tel/eden/mod/config/BridgeConfig.java b/src/tel/eden/mod/config/BridgeConfig.java index 4d85897..f24709f 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -189,6 +189,34 @@ public void sanitize() { /** Whether players are rendered at the unlocked baby-player size. */ public boolean babyPlayers = false; + /** Unique party member outline highlights in raids/dungeons (requires Wynntils). */ + public boolean partyHighlightEnabled = true; + + /** Dynamic overhead health bars for party members in raids and dungeons. */ + public boolean partyHealthBarEnabled = true; + + /** Overhead health bar size scale percentage (range 50-200, default 100). */ + public int partyHealthBarScale = 100; + + /** + * Distance-based overhead health bar size boost percentage starting at 20 blocks + * (range 0-200, default 0 = off). Farther players have their health bars enlarged + * so they stay clearly legible across raid rooms and arenas. + */ + public int partyHealthBarDistanceScale = 0; + + /** Show your own overhead health bar in third-person view (F5). */ + public boolean partyHealthBarShowSelf = true; + + /** Send a sound when party members ready up, and alert if waiting for you. */ + public boolean raidReadyPing = true; + + /** Delay in seconds before alerting that teammates are waiting (range 10-300, default 60). */ + public int raidReadyReminderSeconds = 60; + + /** Custom palette for the 10 party slots. Defaults to PartyHighlightManager.DEFAULT_PALETTE. */ + public List partyColors = new ArrayList<>(tel.eden.mod.party.PartyHighlightManager.DEFAULT_PALETTE); + /** * Saved HUD element positions as {@code name -> [xFraction, yFraction]} (0-1 of * the screen). Absent elements fall back to their built-in default anchor. @@ -325,6 +353,9 @@ public static BridgeConfig load() { if (config.hudScales == null) { config.hudScales = new HashMap<>(); } + if (config.partyColors == null || config.partyColors.isEmpty()) { + config.partyColors = new ArrayList<>(tel.eden.mod.party.PartyHighlightManager.DEFAULT_PALETTE); + } if (config.emoteWheelFavorites == null) { config.emoteWheelFavorites = new ArrayList<>(); } @@ -361,6 +392,9 @@ public static BridgeConfig load() { config.consumableLabelScale = Math.max(0.25f, Math.min(2.0f, config.consumableLabelScale)); config.consumableLabelOffsetX = Math.max(-64, Math.min(64, config.consumableLabelOffsetX)); config.consumableLabelOffsetY = Math.max(-64, Math.min(64, config.consumableLabelOffsetY)); + config.partyHealthBarScale = Math.max(50, Math.min(200, config.partyHealthBarScale)); + config.partyHealthBarDistanceScale = Math.max(0, Math.min(200, config.partyHealthBarDistanceScale)); + config.raidReadyReminderSeconds = Math.max(10, Math.min(300, config.raidReadyReminderSeconds)); 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 c6c6a63..d40999b 100644 --- a/src/tel/eden/mod/gui/BridgeConfigScreen.java +++ b/src/tel/eden/mod/gui/BridgeConfigScreen.java @@ -50,12 +50,30 @@ public final class BridgeConfigScreen extends Screen { // Fraction of the remaining distance the list glides each frame (smoothness). private static final double SCROLL_EASE = 0.4; + public enum Category { + ALL("All"), GENERAL("General"), PARTY_RAIDS("Party & Raids"), WAR("War & Territory"), VISUALS("Visuals & QoL"); + + private final String label; + + Category(String label) { + this.label = label; + } + + public String label() { + return label; + } + } + + private static Category selectedCategory = Category.ALL; + private final Screen parent; private final EdenModClient mod; private final BridgeConfig config; - private final List rows = new ArrayList<>(); + private final List allRows = new ArrayList<>(); + private final List visibleRows = new ArrayList<>(); private Button linkButton; + private EdenDropdown categoryDropdown; private double scroll; private double scrollTarget; private boolean draggingScrollbar; @@ -74,37 +92,31 @@ private BridgeConfigScreen(Screen parent, EdenModClient mod) { @Override protected void init() { super.init(); - rows.clear(); + allRows.clear(); + visibleRows.clear(); int cx = contentX(); int cw = contentWidth(); - linkButton = this.addRenderableWidget(Button.builder(Component.literal("Link account"), button -> startLinkFlow()).bounds(cx, 92, cw, 20).build()); + int btnW = Math.max(100, Math.min(130, cw / 3)); + int dropW = cw - btnW - 8; - // --- One line per setting; the list handles layout + smooth scrolling. --- - if (config.isSecretUnlocked(BridgeConfig.SECRET_BABY_PLAYERS)) { - addToggleRow("Baby players", EdenMenuScreen::isBabyModeEnabled, EdenMenuScreen::setBabyModeEnabled, "On", "Off", false); - } - addToggleRow("Bridge", () -> config.enabled, v -> config.enabled = v, "Enabled", "Disabled", true); - addToggleRow("My login/logout messages", () -> config.announceSelfPresence, v -> config.announceSelfPresence = v, "On", "Off", true); - addToggleRow("Party feed", () -> config.partyAnnounce, v -> config.partyAnnounce = v, "On", "Off", true); - addCycleRow("Chat emote tools", () -> config.chatEmoteToolsMode.label(), () -> config.chatEmoteToolsMode = nextChatEmoteToolsMode(config.chatEmoteToolsMode), () -> config.chatEmoteToolsMode = BridgeConfig.ChatEmoteToolsMode.UI_AND_AUTO); - addToggleRow("Allow emote picker outside chat", () -> config.emotePickerOpenFromGameplay, v -> config.emotePickerOpenFromGameplay = v, "Allowed", "Chat only", true); - addCycleRow("Game messages", () -> shortGameModeLabel(config.gameDisplayMode), () -> config.gameDisplayMode = nextGameMode(config.gameDisplayMode), () -> config.gameDisplayMode = BridgeConfig.GameDisplayMode.ALL); - PreviewSizeSlider slider = new PreviewSizeSlider(CONTROL_W, 20); - addSliderRow("Image preview size", slider, slider::syncFromConfig, () -> config.imagePreviewSize = 40); + linkButton = this.addRenderableWidget(Button.builder(Component.literal("Link account"), button -> startLinkFlow()).bounds(cx, 92, btnW, 20).build()); - // --- Territory / war suite --- - addToggleRow("Attack timers HUD", () -> config.warAttackTimers, v -> config.warAttackTimers = v, "On", "Off", true); - AttackTimerRowsSlider rowsSlider = new AttackTimerRowsSlider(CONTROL_W, 20); - addSliderRow("Max attack-timer rows", rowsSlider, rowsSlider::syncFromConfig, () -> config.warAttackTimerMaxRows = 14); - addToggleRow("Green beacon at soonest war", () -> config.warGreenBeacon, v -> config.warGreenBeacon = v, "On", "Off", true); - addToggleRow("War info overlay (DPS/EHP)", () -> config.warDpsHud, v -> config.warDpsHud = v, "On", "Off", true); - addToggleRow("Weekly war count HUD", () -> config.warWeeklyCountHud, v -> config.warWeeklyCountHud = v, "On", "Off", false); - addToggleRow("Auto /stream on join", () -> config.autoStream, v -> config.autoStream = v, "On", "Off", false); - addToggleRow("Click shouts to reply", () -> config.shoutsClickable, v -> config.shoutsClickable = v, "On", "Off", true); - addToggleRow("Click-to-congratulate", () -> config.clickToCongratulate, v -> config.clickToCongratulate = v, "On", "Off", false); - addButtonRow("HUD layout", () -> Component.literal("Edit..."), () -> this.minecraft.setScreen(new HudLayoutScreen(this, config)), () -> { - }); + EdenDropdown.PopupSettings popupSettings = new EdenDropdown.PopupSettings(HEADER_BOTTOM, this.height - FOOTER_HEIGHT, 20, Category.values().length); + categoryDropdown = this.addRenderableWidget(new EdenDropdown<>(cx + btnW + 8, 92, dropW, 20, this.font, List.of(Category.values()), selectedCategory, cat -> "Category: " + cat.label(), newCat -> { + selectedCategory = newCat; + updateVisibleRows(); + }, openDrop -> { + }, popupSettings)); + + // --- One line per setting; the list handles layout + smooth scrolling. --- + // General + addToggleRow(Category.GENERAL, "Bridge", () -> config.enabled, v -> config.enabled = v, "Enabled", "Disabled", true); + addToggleRow(Category.GENERAL, "My login/logout messages", () -> config.announceSelfPresence, v -> config.announceSelfPresence = v, "On", "Off", true); + addToggleRow(Category.GENERAL, "Party feed", () -> config.partyAnnounce, v -> config.partyAnnounce = v, "On", "Off", true); + addToggleRow(Category.GENERAL, "Auto /stream on join", () -> config.autoStream, v -> config.autoStream = v, "On", "Off", false); + addToggleRow(Category.GENERAL, "Click shouts to reply", () -> config.shoutsClickable, v -> config.shoutsClickable = v, "On", "Off", true); + addToggleRow(Category.GENERAL, "Click-to-congratulate", () -> config.clickToCongratulate, v -> config.clickToCongratulate = v, "On", "Off", false); EditBox congratsBox = new EditBox(this.font, 0, 0, CONTROL_W, 20, Component.literal("Congrats message")); congratsBox.setMaxLength(80); congratsBox.setValue(config.congratsMessage); @@ -112,8 +124,7 @@ protected void init() { config.congratsMessage = value; config.save(); }); - addRow("Congrats message", congratsBox, () -> { - // Don't clobber in-progress typing; the responder already saved it. + addRow(Category.GENERAL, "Congrats message", congratsBox, () -> { if (!congratsBox.isFocused() && !congratsBox.getValue().equals(config.congratsMessage)) { congratsBox.setValue(config.congratsMessage); } @@ -121,43 +132,74 @@ protected void init() { config.congratsMessage = "Congrats!"; congratsBox.setValue("Congrats!"); }); - addToggleRow("Custom item textures", () -> config.customItemTextures, v -> config.customItemTextures = v, "On", "Off", true); - addToggleRow("Consumable labels", () -> config.consumableLabels, v -> config.consumableLabels = v, "On", "Off", true); - addToggleRow("Dropped item scaling", () -> config.groundItemVisibility, v -> config.groundItemVisibility = v, "On", "Off", false); - addButtonRow("Dropped item rules", () -> Component.literal("Edit..."), () -> this.minecraft.setScreen(new GroundItemVisibilityScreen(this, config)), () -> config.groundItemVisibilityRules = new ArrayList<>()); - addToggleRow("Emote wheel", () -> config.emoteWheelEnabled, v -> config.emoteWheelEnabled = v, "On", "Off", true); - addButtonRow("Emote wheel favorites", () -> Component.literal("Edit..."), () -> this.minecraft.setScreen(new tel.eden.mod.emote.EmoteConfigScreen(this, config)), () -> { + + // Party & Raids + addToggleRow(Category.PARTY_RAIDS, "Party member outlines", () -> config.partyHighlightEnabled, v -> config.partyHighlightEnabled = v, "On", "Off", true); + addToggleRow(Category.PARTY_RAIDS, "Party health bars", () -> config.partyHealthBarEnabled, v -> config.partyHealthBarEnabled = v, "On", "Off", true); + addToggleRow(Category.PARTY_RAIDS, "Show own health bar (F5)", () -> config.partyHealthBarShowSelf, v -> config.partyHealthBarShowSelf = v, "On", "Off", true); + IntConfigSlider scaleSlider = new IntConfigSlider(CONTROL_W, 20, 50, 200, 5, () -> config.partyHealthBarScale, v -> config.partyHealthBarScale = v, v -> Component.literal(v + "%")); + addSliderRow(Category.PARTY_RAIDS, "Party health bar size", scaleSlider, scaleSlider::syncFromConfig, () -> config.partyHealthBarScale = 100); + IntConfigSlider distanceScaleSlider = new IntConfigSlider(CONTROL_W, 20, 0, 200, 5, () -> config.partyHealthBarDistanceScale, v -> config.partyHealthBarDistanceScale = v, v -> Component.literal(v <= 0 ? "Off" : v + "%")); + addSliderRow(Category.PARTY_RAIDS, "Distance size boost", distanceScaleSlider, distanceScaleSlider::syncFromConfig, () -> config.partyHealthBarDistanceScale = 0); + addToggleRow(Category.PARTY_RAIDS, "Raid ready-up ping", () -> config.raidReadyPing, v -> config.raidReadyPing = v, "On", "Off", true); + IntConfigSlider reminderSlider = new IntConfigSlider(CONTROL_W, 20, 10, 300, 5, () -> config.raidReadyReminderSeconds, v -> config.raidReadyReminderSeconds = v, v -> Component.literal(formatReminderDuration(v))); + addSliderRow(Category.PARTY_RAIDS, "Ready reminder delay", reminderSlider, reminderSlider::syncFromConfig, () -> config.raidReadyReminderSeconds = 60); + + // War & Territory + addToggleRow(Category.WAR, "Attack timers HUD", () -> config.warAttackTimers, v -> config.warAttackTimers = v, "On", "Off", true); + AttackTimerRowsSlider rowsSlider = new AttackTimerRowsSlider(CONTROL_W, 20); + addSliderRow(Category.WAR, "Max attack-timer rows", rowsSlider, rowsSlider::syncFromConfig, () -> config.warAttackTimerMaxRows = 14); + addToggleRow(Category.WAR, "Green beacon at soonest war", () -> config.warGreenBeacon, v -> config.warGreenBeacon = v, "On", "Off", true); + addToggleRow(Category.WAR, "War info overlay (DPS/EHP)", () -> config.warDpsHud, v -> config.warDpsHud = v, "On", "Off", true); + addToggleRow(Category.WAR, "Weekly war count HUD", () -> config.warWeeklyCountHud, v -> config.warWeeklyCountHud = v, "On", "Off", false); + + // Visuals & QoL + if (config.isSecretUnlocked(BridgeConfig.SECRET_BABY_PLAYERS)) { + addToggleRow(Category.VISUALS, "Baby players", EdenMenuScreen::isBabyModeEnabled, EdenMenuScreen::setBabyModeEnabled, "On", "Off", false); + } + addButtonRow(Category.VISUALS, "HUD layout", () -> Component.literal("Edit..."), () -> this.minecraft.setScreen(new HudLayoutScreen(this, config)), () -> { + }); + addCycleRow(Category.VISUALS, "Chat emote tools", () -> config.chatEmoteToolsMode.label(), () -> config.chatEmoteToolsMode = nextChatEmoteToolsMode(config.chatEmoteToolsMode), () -> config.chatEmoteToolsMode = BridgeConfig.ChatEmoteToolsMode.UI_AND_AUTO); + addToggleRow(Category.VISUALS, "Allow emote picker outside chat", () -> config.emotePickerOpenFromGameplay, v -> config.emotePickerOpenFromGameplay = v, "Allowed", "Chat only", true); + addToggleRow(Category.VISUALS, "Emote wheel", () -> config.emoteWheelEnabled, v -> config.emoteWheelEnabled = v, "On", "Off", true); + addButtonRow(Category.VISUALS, "Emote wheel favorites", () -> Component.literal("Edit..."), () -> this.minecraft.setScreen(new tel.eden.mod.emote.EmoteConfigScreen(this, config)), () -> { }); + addCycleRow(Category.VISUALS, "Game messages", () -> shortGameModeLabel(config.gameDisplayMode), () -> config.gameDisplayMode = nextGameMode(config.gameDisplayMode), () -> config.gameDisplayMode = BridgeConfig.GameDisplayMode.ALL); + PreviewSizeSlider slider = new PreviewSizeSlider(CONTROL_W, 20); + addSliderRow(Category.VISUALS, "Image preview size", slider, slider::syncFromConfig, () -> config.imagePreviewSize = 40); + addToggleRow(Category.VISUALS, "Custom item textures", () -> config.customItemTextures, v -> config.customItemTextures = v, "On", "Off", true); + addToggleRow(Category.VISUALS, "Consumable labels", () -> config.consumableLabels, v -> config.consumableLabels = v, "On", "Off", true); + addToggleRow(Category.VISUALS, "Dropped item scaling", () -> config.groundItemVisibility, v -> config.groundItemVisibility = v, "On", "Off", false); + addButtonRow(Category.VISUALS, "Dropped item rules", () -> Component.literal("Edit..."), () -> this.minecraft.setScreen(new GroundItemVisibilityScreen(this, config)), () -> config.groundItemVisibilityRules = new ArrayList<>()); // ------------------------------------------------------------------------ this.addRenderableWidget(Button.builder(Component.literal("Done"), button -> onClose()).bounds(cx, this.height - 30, cw, 20).build()); refreshRows(); - scrollTarget = clampScroll(scrollTarget); - scroll = scrollTarget; + updateVisibleRows(); } - private void addToggleRow(String label, Supplier get, Consumer set, String onText, String offText, boolean resetValue) { - addButtonRow(label, () -> Component.literal(get.get() ? onText : offText), () -> set.accept(!get.get()), () -> set.accept(resetValue)); + private void addToggleRow(Category category, String label, Supplier get, Consumer set, String onText, String offText, boolean resetValue) { + addButtonRow(category, label, () -> Component.literal(get.get() ? onText : offText), () -> set.accept(!get.get()), () -> set.accept(resetValue)); } - private void addCycleRow(String label, Supplier valueLabel, Runnable onClick, Runnable onReset) { - addButtonRow(label, () -> Component.literal(valueLabel.get()), onClick, onReset); + private void addCycleRow(Category category, String label, Supplier valueLabel, Runnable onClick, Runnable onReset) { + addButtonRow(category, label, () -> Component.literal(valueLabel.get()), onClick, onReset); } - private void addButtonRow(String label, Supplier valueText, Runnable onClick, Runnable onReset) { + private void addButtonRow(Category category, String label, Supplier valueText, Runnable onClick, Runnable onReset) { Button control = Button.builder(Component.empty(), button -> { onClick.run(); saveConfig(); }).bounds(0, 0, CONTROL_W, 20).build(); - addRow(label, control, () -> control.setMessage(valueText.get()), onReset); + addRow(category, label, control, () -> control.setMessage(valueText.get()), onReset); } - private void addSliderRow(String label, AbstractSliderButton slider, Runnable sync, Runnable onReset) { - addRow(label, slider, sync, onReset); + private void addSliderRow(Category category, String label, AbstractSliderButton slider, Runnable sync, Runnable onReset) { + addRow(category, label, slider, sync, onReset); } - private void addRow(String label, AbstractWidget control, Runnable refresh, Runnable onReset) { + private void addRow(Category category, String label, AbstractWidget control, Runnable refresh, Runnable onReset) { Button reset = Button.builder(Component.literal("R"), button -> { onReset.run(); saveConfig(); @@ -166,7 +208,22 @@ private void addRow(String label, AbstractWidget control, Runnable refresh, Runn // them natively — so the slider works — while we draw them inside a scissor. this.addWidget(control); this.addWidget(reset); - rows.add(new SettingRow(label, control, reset, refresh)); + allRows.add(new SettingRow(category, label, control, reset, refresh)); + } + + private void updateVisibleRows() { + visibleRows.clear(); + for (SettingRow row : allRows) { + if (selectedCategory == Category.ALL || row.category == selectedCategory) { + visibleRows.add(row); + } else { + row.control.visible = false; + row.reset.visible = false; + } + } + scrollTarget = 0; + scroll = 0; + layoutRows(); } private void saveConfig() { @@ -175,7 +232,7 @@ private void saveConfig() { } private void refreshRows() { - for (SettingRow row : rows) { + for (SettingRow row : allRows) { row.refresh.run(); } } @@ -246,7 +303,7 @@ private int listHeight() { } private int contentHeight() { - return LIST_TOP_PADDING + rows.size() * ROW_HEIGHT; + return LIST_TOP_PADDING + visibleRows.size() * ROW_HEIGHT; } private double maxScroll() { @@ -295,10 +352,10 @@ private void advanceScroll() { /** Position each row and hide the ones scrolled outside the viewport. */ private void layoutRows() { - for (int i = 0; i < rows.size(); i++) { + for (int i = 0; i < visibleRows.size(); i++) { int rowY = rowY(i); boolean inView = rowInView(rowY); - SettingRow row = rows.get(i); + SettingRow row = visibleRows.get(i); row.control.visible = inView; row.reset.visible = inView; if (inView) { @@ -331,12 +388,12 @@ public void render(GuiGraphics g, int mouseX, int mouseY, float delta) { int labelX = cx + 10; g.enableScissor(cx, top, cx + cw, top + height); - for (int i = 0; i < rows.size(); i++) { + for (int i = 0; i < visibleRows.size(); i++) { int rowY = rowY(i); if (!rowInView(rowY)) { continue; } - SettingRow row = rows.get(i); + SettingRow row = visibleRows.get(i); row.control.render(g, mouseX, mouseY, delta); row.reset.render(g, mouseX, mouseY, delta); g.drawString(this.font, row.label, labelX, rowY + 6, 0xFFA0A0A0); @@ -354,6 +411,10 @@ public void render(GuiGraphics g, int mouseX, int mouseY, float delta) { String updateText = pendingUpdate != null ? "Update Available: " + pendingUpdate.version() : "Up to date"; g.drawString(this.font, versionText, this.width - this.font.width(versionText) - 6, 6, 0xFFAAAAAA); g.drawString(this.font, updateText, this.width - this.font.width(updateText) - 6, 18, pendingUpdate != null ? 0xFF55FF55 : 0xFFAAAAAA); + + if (categoryDropdown != null) { + categoryDropdown.renderPopup(g, mouseX, mouseY); + } } private void drawScrollbar(GuiGraphics g, int x, int y, int width, int height) { @@ -398,6 +459,15 @@ private void scrollToMouse(double mouseY) { @Override public boolean mouseClicked(MouseButtonEvent event, boolean bl) { + if (categoryDropdown != null && categoryDropdown.isOpen()) { + if (categoryDropdown.isOverPopup(event.x(), event.y())) { + categoryDropdown.mouseClicked(event, bl); + return true; + } + if (!categoryDropdown.isMouseOver(event.x(), event.y())) { + categoryDropdown.close(); + } + } if (event.button() == 0 && isOverScrollbar(event.x(), event.y())) { draggingScrollbar = true; scrollToMouse(event.y()); @@ -423,6 +493,9 @@ public boolean mouseDragged(MouseButtonEvent event, double dx, double dy) { @Override public boolean mouseScrolled(double mouseX, double mouseY, double dx, double dy) { + if (categoryDropdown != null && categoryDropdown.isOpen() && categoryDropdown.isOverPopup(mouseX, mouseY)) { + return categoryDropdown.mouseScrolled(mouseX, mouseY, dx, dy); + } if (maxScroll() > 0 && (isOverList(mouseX, mouseY) || isOverScrollbar(mouseX, mouseY))) { scrollTarget = clampScroll(scrollTarget - dy * ROW_HEIGHT * 2); return true; @@ -437,12 +510,14 @@ public void onClose() { } private static final class SettingRow { + private final Category category; private final String label; private final AbstractWidget control; private final Button reset; private final Runnable refresh; - private SettingRow(String label, AbstractWidget control, Button reset, Runnable refresh) { + private SettingRow(Category category, String label, AbstractWidget control, Button reset, Runnable refresh) { + this.category = category; this.label = label; this.control = control; this.reset = reset; @@ -509,4 +584,61 @@ protected void applyValue() { updateMessage(); } } + + private static String formatReminderDuration(int sec) { + if (sec == 60) { + return "1 min"; + } else if (sec % 60 == 0) { + return (sec / 60) + " mins"; + } else if (sec < 60) { + return sec + "s"; + } else { + return (sec / 60) + "m " + (sec % 60) + "s"; + } + } + + private final class IntConfigSlider extends AbstractSliderButton { + private final int min; + private final int max; + private final int step; + private final Supplier getter; + private final Consumer setter; + private final java.util.function.Function labeler; + + private IntConfigSlider(int width, int height, int min, int max, int step, Supplier getter, Consumer setter, java.util.function.Function labeler) { + super(0, 0, width, height, Component.empty(), 0.0d); + this.min = min; + this.max = max; + this.step = step; + this.getter = getter; + this.setter = setter; + this.labeler = labeler; + syncFromConfig(); + } + + private void syncFromConfig() { + int current = Math.max(min, Math.min(max, getter.get())); + this.value = (current - min) / (double) (max - min); + updateMessage(); + } + + @Override + protected void updateMessage() { + setMessage(labeler.apply(getter.get())); + } + + @Override + protected void applyValue() { + int snapped = min + (int) Math.round(this.value * (max - min)); + if (step > 1) { + snapped = Math.round((float) snapped / step) * step; + } + snapped = Math.max(min, Math.min(max, snapped)); + if (snapped != getter.get()) { + setter.accept(snapped); + config.save(); + } + updateMessage(); + } + } } diff --git a/src/tel/eden/mod/mixin/AvatarRenderStateMixin.java b/src/tel/eden/mod/mixin/AvatarRenderStateMixin.java new file mode 100644 index 0000000..b12656b --- /dev/null +++ b/src/tel/eden/mod/mixin/AvatarRenderStateMixin.java @@ -0,0 +1,23 @@ +package tel.eden.mod.mixin; + +import java.util.UUID; +import net.minecraft.client.renderer.entity.state.AvatarRenderState; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Unique; +import tel.eden.mod.render.EdenAvatarState; + +@Mixin(AvatarRenderState.class) +public abstract class AvatarRenderStateMixin implements EdenAvatarState { + @Unique + private UUID edenmod$playerUuid; + + @Override + public UUID edenmod$getPlayerUuid() { + return edenmod$playerUuid; + } + + @Override + public void edenmod$setPlayerUuid(UUID playerUuid) { + this.edenmod$playerUuid = playerUuid; + } +} diff --git a/src/tel/eden/mod/mixin/AvatarRendererMixin.java b/src/tel/eden/mod/mixin/AvatarRendererMixin.java new file mode 100644 index 0000000..f393aa1 --- /dev/null +++ b/src/tel/eden/mod/mixin/AvatarRendererMixin.java @@ -0,0 +1,23 @@ +package tel.eden.mod.mixin; + +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.player.AvatarRenderer; +import net.minecraft.client.renderer.entity.state.AvatarRenderState; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.world.entity.Avatar; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import tel.eden.mod.render.EdenAvatarState; + +@Mixin(AvatarRenderer.class) +public abstract class AvatarRendererMixin { + @Inject(method = "extractRenderState(Lnet/minecraft/world/entity/Avatar;Lnet/minecraft/client/renderer/entity/state/AvatarRenderState;F)V", at = @At("RETURN")) + private void edenmod$captureAvatarState(Avatar player, AvatarRenderState state, float partialTick, CallbackInfo ci) { + if (state instanceof EdenAvatarState ext) { + ext.edenmod$setPlayerUuid(player.getUUID()); + } + } +} diff --git a/src/tel/eden/mod/mixin/ChatScreenMixin.java b/src/tel/eden/mod/mixin/ChatScreenMixin.java index 6bf0f5a..bb301a1 100644 --- a/src/tel/eden/mod/mixin/ChatScreenMixin.java +++ b/src/tel/eden/mod/mixin/ChatScreenMixin.java @@ -23,6 +23,7 @@ import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; import tel.eden.mod.EdenModClient; import tel.eden.mod.chat.ChatEmoteFormatter; +import tel.eden.mod.chat.ChatReplyManager; import tel.eden.mod.chat.EmoteRegistry; import tel.eden.mod.config.BridgeConfig; import tel.eden.mod.war.AttackTimerMenu; @@ -161,10 +162,43 @@ public abstract class ChatScreenMixin { EdenModClient.instance().requestCenteredEmotePicker(); } edenmod$openCenteredPickerIfRequested(); + ChatReplyManager.registerInputHolder(() -> { + if (input != null) { + String val = input.getValue(); + if (val.isEmpty()) { + input.setValue("/g "); + } else if (!val.startsWith("/")) { + input.setValue("/g " + val); + } + } + }); + } + + @Inject(method = "removed", at = @At("TAIL")) + private void edenmod$onChatScreenRemoved(CallbackInfo ci) { + ChatReplyManager.clearActiveReply(); + ChatReplyManager.unregisterInputHolder(); + } + + @Inject(method = "handleChatInput", at = @At("HEAD")) + private void edenmod$captureReplyOnSend(String message, boolean addToHistory, CallbackInfo ci) { + if (ChatReplyManager.hasActiveReply()) { + String trimmed = message != null ? message.trim() : ""; + if (trimmed.startsWith("/g ") && trimmed.length() > 3 && !trimmed.substring(3).isBlank()) { + ChatReplyManager.markPendingReply(); + } else { + ChatReplyManager.clearActiveReply(); + } + } } @Inject(method = "keyPressed", at = @At("HEAD"), cancellable = true) private void edenmod$handleChatOverlayKeys(KeyEvent event, CallbackInfoReturnable cir) { + if (event.key() == GLFW.GLFW_KEY_ESCAPE && ChatReplyManager.hasActiveReply()) { + ChatReplyManager.clearActiveReply(); + cir.setReturnValue(true); + return; + } if (!edenmod$isChatEmoteUiVisible() && !edenmod$isChatEmoteAutocompleteEnabled()) { edenmod$resetOverlayState(); return; @@ -225,6 +259,16 @@ public abstract class ChatScreenMixin { } } + @Inject(method = "mouseClicked", at = @At("HEAD"), cancellable = true) + private void edenmod$handleReplyBannerClick(MouseButtonEvent event, boolean bl, CallbackInfoReturnable cir) { + if (event.button() == GLFW.GLFW_MOUSE_BUTTON_LEFT && ChatReplyManager.hasActiveReply()) { + if (ChatReplyManager.isCloseButtonClicked(input.getX(), input.getY(), input.getWidth(), event.x(), event.y())) { + ChatReplyManager.clearActiveReply(); + cir.setReturnValue(true); + } + } + } + @Inject(method = "mouseClicked", at = @At("HEAD"), cancellable = true) private void edenmod$handleAttackTimerClick(MouseButtonEvent event, boolean bl, CallbackInfoReturnable cir) { // Runs regardless of the emote-tool settings: left-clicking an attack-timer row @@ -281,6 +325,9 @@ public abstract class ChatScreenMixin { // Hovering an attack-timer head shows that player's IGN (works regardless of the // emote-tool settings, like the timer click). AttackTimerMenu.renderGoerTooltip(graphics, mouseX, mouseY); + if (ChatReplyManager.hasActiveReply()) { + ChatReplyManager.renderReplyBanner(graphics, Minecraft.getInstance().font, input.getX(), input.getY(), input.getWidth(), mouseX, mouseY); + } if (!edenmod$isChatEmoteUiVisible() && !edenmod$isChatEmoteAutocompleteEnabled()) { edenmod$resetOverlayState(); return; diff --git a/src/tel/eden/mod/mixin/EntityOutlineMixin.java b/src/tel/eden/mod/mixin/EntityOutlineMixin.java new file mode 100644 index 0000000..bec242b --- /dev/null +++ b/src/tel/eden/mod/mixin/EntityOutlineMixin.java @@ -0,0 +1,22 @@ +package tel.eden.mod.mixin; + +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.player.Player; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; +import tel.eden.mod.party.PartyHighlightManager; + +@Mixin(Entity.class) +public abstract class EntityOutlineMixin { + @Inject(method = "getTeamColor", at = @At("HEAD"), cancellable = true) + private void edenmod$partyOutlineColor(CallbackInfoReturnable cir) { + if ((Object) this instanceof Player player) { + Integer color = PartyHighlightManager.getPartyOutlineColor(player); + if (color != null) { + cir.setReturnValue(color); + } + } + } +} diff --git a/src/tel/eden/mod/mixin/LivingEntityRendererMixin.java b/src/tel/eden/mod/mixin/LivingEntityRendererMixin.java new file mode 100644 index 0000000..f34501f --- /dev/null +++ b/src/tel/eden/mod/mixin/LivingEntityRendererMixin.java @@ -0,0 +1,23 @@ +package tel.eden.mod.mixin; + +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.LivingEntityRenderer; +import net.minecraft.client.renderer.entity.state.AvatarRenderState; +import net.minecraft.client.renderer.entity.state.LivingEntityRenderState; +import net.minecraft.client.renderer.state.CameraRenderState; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; +import tel.eden.mod.party.PartyHealthBarRenderer; + +@Mixin(LivingEntityRenderer.class) +public abstract class LivingEntityRendererMixin { + @Inject(method = "submit(Lnet/minecraft/client/renderer/entity/state/LivingEntityRenderState;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;Lnet/minecraft/client/renderer/state/CameraRenderState;)V", at = @At("RETURN")) + private void edenmod$renderOverheadHealthBar(LivingEntityRenderState state, PoseStack poseStack, SubmitNodeCollector submitNodeCollector, CameraRenderState cameraRenderState, CallbackInfo ci) { + if (state instanceof AvatarRenderState avatarState) { + PartyHealthBarRenderer.renderIfVisible(avatarState, poseStack, submitNodeCollector, cameraRenderState); + } + } +} diff --git a/src/tel/eden/mod/net/BridgeWebSocketClient.java b/src/tel/eden/mod/net/BridgeWebSocketClient.java index bf81b2c..3cca8a6 100644 --- a/src/tel/eden/mod/net/BridgeWebSocketClient.java +++ b/src/tel/eden/mod/net/BridgeWebSocketClient.java @@ -51,7 +51,11 @@ public interface MessageSink { * A relayed Discord message. {@code replyTo}/{@code replyExcerpt} are non-empty * when the Discord message was a reply (the replied-to author and a short quote). */ - void onDiscordMessage(String author, String content, String replyTo, String replyExcerpt, String color); + default void onDiscordMessage(String author, String content, String replyTo, String replyExcerpt, String color) { + onDiscordMessage(author, content, replyTo, replyExcerpt, color, null); + } + + void onDiscordMessage(String author, String content, String replyTo, String replyExcerpt, String color, String messageId); /** A bridge user just logged in (presence notice). */ void onLoginNotice(String username, String color); @@ -246,6 +250,11 @@ private synchronized void enqueueSend(WebSocket ws, String text) { /** Send one captured guild-chat line to the backend. */ public void sendGuildChat(String username, String nickname, String message, int seq) { + sendGuildChat(username, nickname, message, seq, null); + } + + /** Send one captured guild-chat line to the backend, optionally referencing a replied-to Discord message. */ + public void sendGuildChat(String username, String nickname, String message, int seq, String replyToMessageId) { WebSocket current = socket; if (current == null) { return; @@ -258,6 +267,9 @@ public void sendGuildChat(String username, String nickname, String message, int } obj.addProperty("message", message); obj.addProperty("seq", seq); + if (replyToMessageId != null && !replyToMessageId.isEmpty()) { + obj.addProperty("replyToMessageId", replyToMessageId); + } enqueueSend(current, obj.toString()); } @@ -1009,7 +1021,7 @@ private void handlePayload(String payload) { try { JsonObject obj = JsonParser.parseString(payload).getAsJsonObject(); switch (get(obj, "type")) { - case "discordMessage" -> sink.onDiscordMessage(get(obj, "author"), get(obj, "content"), get(obj, "replyTo"), get(obj, "replyExcerpt"), get(obj, "color")); + case "discordMessage" -> sink.onDiscordMessage(get(obj, "author"), get(obj, "content"), get(obj, "replyTo"), get(obj, "replyExcerpt"), get(obj, "color"), get(obj, "messageId")); case "loginNotice" -> sink.onLoginNotice(get(obj, "username"), get(obj, "color")); case "logoutNotice" -> sink.onLogoutNotice(get(obj, "username"), get(obj, "color")); case "onlineList" -> sink.onOnlineList(getStringArray(obj, "users"), get(obj, "color")); diff --git a/src/tel/eden/mod/party/PartyHealthBarRenderer.java b/src/tel/eden/mod/party/PartyHealthBarRenderer.java new file mode 100644 index 0000000..d5e4672 --- /dev/null +++ b/src/tel/eden/mod/party/PartyHealthBarRenderer.java @@ -0,0 +1,236 @@ +package tel.eden.mod.party; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.state.AvatarRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.client.renderer.state.CameraRenderState; +import net.minecraft.resources.Identifier; +import net.minecraft.world.phys.Vec3; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.config.BridgeConfig; +import tel.eden.mod.render.EdenAvatarState; + +/** + * Renders an overhead health bar above party members' nametags. + * + *

Features: + *

    + *
  • Trapezoidal styled bar with sloped sides and dark deficit underlay
  • + *
  • Outer border matches the player's party position outline color
  • + *
  • Inner bezel matches a darker shade of the party position color
  • + *
  • Smooth health bar gliding animation when HP changes
  • + *
  • Dynamic health fill progression: Vibrant Green (>50%) -> Amber (>25%) -> Crimson (<=25%)
  • + *
+ */ +public final class PartyHealthBarRenderer { + private static final float NAMETAG_SCALE = 0.025f; + private static final float BAR_WIDTH = 40.0f; + private static final float BAR_HEIGHT = 4.375f; + private static final int FULL_BRIGHT = 0xF000F0; + + private static final int COLOR_PROVI_GREEN = 0xFF00E817; + private static final int COLOR_AMBER = 0xFFF59E0B; + private static final int COLOR_CRIMSON = 0xFFEF4444; + private static final int COLOR_GOLD = 0xFFFBBF24; + private static final boolean WYNNTILS_LOADED = FabricLoader.getInstance().isModLoaded("wynntils"); + private static final float DISTANCE_SCALE_START = 20.0f; + private static final float DISTANCE_SCALE_PER_BLOCK = 0.025f; + + private static final Identifier BAR_TEXTURE = Identifier.fromNamespaceAndPath("edenmod", "textures/gui/healthbars/party_bar.png"); + + private static final class SmoothHealthState { + float displayedPercent = 1.0f; + float displayedOverPercent = 0.0f; + long lastUpdateMs = 0L; + boolean initialized = false; + } + + private static final Map SMOOTH_STATES = new ConcurrentHashMap<>(); + + private PartyHealthBarRenderer() { + } + + public static void reset() { + SMOOTH_STATES.clear(); + } + + public static void renderIfVisible(AvatarRenderState state, PoseStack poseStack, SubmitNodeCollector submitNodeCollector, CameraRenderState cameraRenderState) { + EdenModClient client = EdenModClient.instance(); + BridgeConfig config = client != null ? client.config() : null; + if (config != null && !config.partyHealthBarEnabled) { + return; + } + + if (!(state instanceof EdenAvatarState ext)) { + return; + } + + UUID uuid = ext.edenmod$getPlayerUuid(); + if (uuid == null) { + return; + } + + Minecraft mc = Minecraft.getInstance(); + if (mc.player != null && uuid.equals(mc.player.getUUID())) { + if (config == null || !config.partyHealthBarShowSelf) { + return; + } + } + + PartyHealthTracker.PlayerHealthData health = PartyHealthTracker.getHealth(uuid); + if (health == null) { + return; + } + + Vec3 attachment = state.nameTagAttachment != null ? state.nameTagAttachment : new Vec3(0, 2.0, 0); + + poseStack.pushPose(); + float scaleFactor = (config != null ? Math.max(50, Math.min(200, config.partyHealthBarScale)) : 100) / 100.0f; + float distanceMultiplier = config != null ? computeDistanceScale(state.distanceToCameraSq, config.partyHealthBarDistanceScale) : 1.0f; + float effectiveScale = NAMETAG_SCALE * scaleFactor * distanceMultiplier; + float yOffset = computeWorldYOffset(state); + poseStack.translate(attachment.x, attachment.y + yOffset, attachment.z); + poseStack.mulPose(cameraRenderState.orientation); + poseStack.scale(effectiveScale, -effectiveScale, effectiveScale); + + submitEdenBar(submitNodeCollector, poseStack, uuid, health, config); + poseStack.popPose(); + } + + static float computeDistanceScale(double distanceToCameraSq, int distanceScalePercent) { + if (distanceScalePercent <= 0 || distanceToCameraSq <= (DISTANCE_SCALE_START * DISTANCE_SCALE_START)) { + return 1.0f; + } + float dist = (float) Math.sqrt(distanceToCameraSq); + float intensity = Math.max(0, Math.min(200, distanceScalePercent)) / 100.0f; + return 1.0f + Math.min((dist - DISTANCE_SCALE_START) * DISTANCE_SCALE_PER_BLOCK, 2.5f) * intensity; + } + + private static float computeWorldYOffset(AvatarRenderState state) { + int lines = 0; + if (state.nameTag != null) { + lines++; + } + if (state.scoreText != null) { + lines++; + } + float pixelOffset = (Math.max(1, lines) * 10.0f) + 14.0f; + if (state.nameTag == null) { + // When nametag is hidden by Wynncraft (e.g. housing holograms), add clearance for title displays + pixelOffset += 12.0f; + } + if (state.showExtraEars) { + pixelOffset += 10.0f; + } + if (WYNNTILS_LOADED) { + pixelOffset += 8.0f; + } + return pixelOffset * NAMETAG_SCALE; + } + + private static void submitEdenBar(SubmitNodeCollector collector, PoseStack poseStack, UUID uuid, PartyHealthTracker.PlayerHealthData health, BridgeConfig config) { + float rawPercent = health.percent(); + float targetPercent = Float.isNaN(rawPercent) ? 1.0f : Math.max(0.0f, Math.min(1.0f, rawPercent)); + float rawOverPercent = health.overPercent(); + float targetOverPercent = Float.isNaN(rawOverPercent) ? 0.0f : Math.max(0.0f, Math.min(1.0f, rawOverPercent)); + + long now = System.currentTimeMillis(); + SmoothHealthState smooth = SMOOTH_STATES.computeIfAbsent(uuid, k -> new SmoothHealthState()); + float displayedPercent; + float displayedOverPercent; + if (!smooth.initialized || (now - smooth.lastUpdateMs) > 1000L) { + smooth.displayedPercent = targetPercent; + smooth.displayedOverPercent = targetOverPercent; + smooth.lastUpdateMs = now; + smooth.initialized = true; + displayedPercent = targetPercent; + displayedOverPercent = targetOverPercent; + } else { + long dtMs = Math.min(100L, Math.max(1L, now - smooth.lastUpdateMs)); + smooth.lastUpdateMs = now; + float dtSec = dtMs / 1000.0f; + // Frame-rate independent exponential approach: glides smoothly to new HP in ~0.35s + float blend = 1.0f - (float) Math.exp(-8.0f * dtSec); + smooth.displayedPercent += (targetPercent - smooth.displayedPercent) * blend; + smooth.displayedOverPercent += (targetOverPercent - smooth.displayedOverPercent) * blend; + if (Math.abs(targetPercent - smooth.displayedPercent) < 0.002f) { + smooth.displayedPercent = targetPercent; + } + if (Math.abs(targetOverPercent - smooth.displayedOverPercent) < 0.002f) { + smooth.displayedOverPercent = targetOverPercent; + } + displayedPercent = Math.max(0.0f, Math.min(1.0f, smooth.displayedPercent)); + displayedOverPercent = Math.max(0.0f, Math.min(1.0f, smooth.displayedOverPercent)); + } + + float x = -BAR_WIDTH / 2.0f; + float y = 0.0f; + + int fillColor; + if (displayedPercent > 0.50f) { + fillColor = COLOR_PROVI_GREEN; + } else if (displayedPercent > 0.25f) { + fillColor = COLOR_AMBER; + } else { + fillColor = COLOR_CRIMSON; + } + + List palette = (config != null && config.partyColors != null && config.partyColors.size() == 10) ? config.partyColors : PartyHighlightManager.DEFAULT_PALETTE; + int slotIndex = health.partySlot(); + int slotColor = (slotIndex >= 0 && slotIndex < palette.size()) ? palette.get(slotIndex) | 0xFF000000 : COLOR_PROVI_GREEN; + + int r = (slotColor >> 16) & 0xFF; + int g = (slotColor >> 8) & 0xFF; + int b = slotColor & 0xFF; + int darkerSlotColor = 0xFF000000 | ((int) (r * 0.38f) << 16) | ((int) (g * 0.38f) << 8) | (int) (b * 0.38f); + + collector.submitCustomGeometry(poseStack, RenderTypes.text(BAR_TEXTURE), (pose, vertices) -> { + // 1. Dark Deficit Background (V: 24..31 / 64) - back layer + drawTexturedQuad(vertices, pose, FULL_BRIGHT, x, y, BAR_WIDTH, BAR_HEIGHT, 0.0f, 0.0f, 24.0f / 64.0f, 1.0f, 31.0f / 64.0f, 0xFFFFFFFF); + + // 2. Smooth Base Health Bar Fill (V: 0..7 / 64) - on top of deficit background + if (displayedPercent > 0.0f) { + float fillWidth = BAR_WIDTH * displayedPercent; + drawTexturedQuad(vertices, pose, FULL_BRIGHT, x, y, fillWidth, BAR_HEIGHT, 0.01f, 0.0f, 0.0f, displayedPercent, 7.0f / 64.0f, fillColor); + } + + // 3. Smooth Overhealth Fill (V: 0..7 / 64) - gold overlay on top of base health fill + if (displayedOverPercent > 0.0f) { + float overWidth = BAR_WIDTH * displayedOverPercent; + drawTexturedQuad(vertices, pose, FULL_BRIGHT, x, y, overWidth, BAR_HEIGHT, 0.015f, 0.0f, 0.0f, displayedOverPercent, 7.0f / 64.0f, COLOR_GOLD); + } + + // 4. Inner Bezel (V: 16..23 / 64) - on top of fill edge, darker shade of party slot color + drawTexturedQuad(vertices, pose, FULL_BRIGHT, x, y, BAR_WIDTH, BAR_HEIGHT, 0.02f, 0.0f, 16.0f / 64.0f, 1.0f, 23.0f / 64.0f, darkerSlotColor); + + // 5. Outer Border (V: 8..15 / 64) - front layer, party slot position color + drawTexturedQuad(vertices, pose, FULL_BRIGHT, x, y, BAR_WIDTH, BAR_HEIGHT, 0.03f, 0.0f, 8.0f / 64.0f, 1.0f, 15.0f / 64.0f, slotColor); + + // 5. Quarter Dividers (25%, 50%, 75%) + float dividerWidth = BAR_WIDTH / 64.0f; + float dividerY = y + (BAR_HEIGHT * (2.0f / 7.0f)); + float dividerHeight = BAR_HEIGHT * (3.0f / 7.0f); + float whiteU = 1.5f / 64.0f; + float whiteV = 33.5f / 64.0f; + for (float mark = 0.25f; mark < 1.0f; mark += 0.25f) { + float markX = x + (BAR_WIDTH * mark) - (dividerWidth / 2.0f); + drawTexturedQuad(vertices, pose, FULL_BRIGHT, markX, dividerY, dividerWidth, dividerHeight, 0.04f, whiteU, whiteV, whiteU, whiteV, darkerSlotColor); + } + }); + } + + private static void drawTexturedQuad(VertexConsumer vertices, PoseStack.Pose pose, int light, float x, float y, float width, float height, float z, float minU, float minV, float maxU, float maxV, int color) { + vertices.addVertex(pose, x, y, z).setColor(color).setUv(minU, minV).setLight(light); + vertices.addVertex(pose, x, y + height, z).setColor(color).setUv(minU, maxV).setLight(light); + vertices.addVertex(pose, x + width, y + height, z).setColor(color).setUv(maxU, maxV).setLight(light); + vertices.addVertex(pose, x + width, y, z).setColor(color).setUv(maxU, minV).setLight(light); + } +} diff --git a/src/tel/eden/mod/party/PartyHealthTracker.java b/src/tel/eden/mod/party/PartyHealthTracker.java new file mode 100644 index 0000000..6937755 --- /dev/null +++ b/src/tel/eden/mod/party/PartyHealthTracker.java @@ -0,0 +1,1109 @@ +package tel.eden.mod.party; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.minecraft.ChatFormatting; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.PlayerInfo; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.Style; +import net.minecraft.network.chat.TextColor; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.scores.DisplaySlot; +import net.minecraft.world.scores.Objective; +import net.minecraft.world.scores.PlayerScoreEntry; +import net.minecraft.world.scores.PlayerTeam; +import net.minecraft.world.scores.Scoreboard; +import tel.eden.mod.chat.ChatText; +import tel.eden.mod.chat.PlayerNameResolver; +import tel.eden.mod.war.ScoreboardCapture; + +/** + * Tracks party and raid members' live HP from the sidebar scoreboard, deducing + * maximum HP from observed full-health indicators. + */ +public final class PartyHealthTracker { + private static final Pattern FIRST_NUMBER = Pattern.compile("(\\d+)"); + private static final Pattern BRACKETED_NUMBER = Pattern.compile("\\[(\\d+)\\]"); + private static final long FULL_BAR_STABLE_MS = 2000L; + private static final long FULL_BAR_DOWNWARD_STABLE_MS = 20000L; + private static final long REFRESH_INTERVAL_MS = 250L; + + public record PlayerHealthData(UUID uuid, String name, int hp, int maxHp, float percent, boolean overMax, float overPercent, int partySlot) { + public PlayerHealthData(UUID uuid, String name, int hp, int maxHp, float percent, boolean overMax, int partySlot) { + this(uuid, name, hp, maxHp, percent, overMax, calculateOverPercent(hp, maxHp, overMax), partySlot); + } + + public static float calculateOverPercent(int hp, int maxHp, boolean overMax) { + if (!overMax || maxHp <= 0 || hp <= maxHp) { + return 0.0f; + } + return Math.max(0.0f, Math.min(1.0f, (float) (hp - maxHp) / (float) maxHp)); + } + } + + record ParsedPartyLine(int hp, String nickname, int level, boolean online, boolean fullHealthBar, float visualFraction) { + } + + record BarAnalysis(boolean fullBar, float visualFraction) { + } + + record HealthCalculation(int maxHp, float percent, boolean overMax) { + } + + static class MaxHpState { + int stableMaxHp; + int candidateHp; + long candidateSinceMs; + long downwardCandidateSinceMs; + + MaxHpState() { + this.stableMaxHp = 0; + this.candidateHp = 0; + this.candidateSinceMs = 0L; + this.downwardCandidateSinceMs = 0L; + } + } + + private static final Map HEALTH_BY_UUID = new ConcurrentHashMap<>(); + private static final Map MAX_HP_BY_UUID = new ConcurrentHashMap<>(); + private static final Map UUID_BY_PLAYER_NAME = new ConcurrentHashMap<>(); + private static long lastRefreshMs = 0L; + private static int tickCounter = 0; + + private PartyHealthTracker() { + } + + public static PlayerHealthData getHealth(UUID uuid) { + if (uuid == null) { + return null; + } + return HEALTH_BY_UUID.get(uuid); + } + + public static void tick() { + tickCounter++; + if (tickCounter % 5 != 0) { + return; + } + long now = System.currentTimeMillis(); + if (now - lastRefreshMs < REFRESH_INTERVAL_MS) { + return; + } + lastRefreshMs = now; + updatePartyHealth(now); + } + + public static void reset() { + HEALTH_BY_UUID.clear(); + MAX_HP_BY_UUID.clear(); + UUID_BY_PLAYER_NAME.clear(); + lastRefreshMs = 0L; + PartyHealthBarRenderer.reset(); + PlayerNameResolver.reset(); + } + + private static void updatePartyHealth(long now) { + Minecraft mc = Minecraft.getInstance(); + if (mc.level == null) { + reset(); + return; + } + + Scoreboard scoreboard = ScoreboardCapture.hasSidebar() ? ScoreboardCapture.scoreboard() : mc.level.getScoreboard(); + if (scoreboard == null) { + return; + } + + Objective sidebar = scoreboard.getDisplayObjective(DisplaySlot.SIDEBAR); + if (sidebar == null) { + HEALTH_BY_UUID.clear(); + MAX_HP_BY_UUID.clear(); + return; + } + + List entries = new ArrayList<>(scoreboard.listPlayerScores(sidebar)); + entries.sort(Comparator.comparingInt(PlayerScoreEntry::value).reversed()); + + boolean inPartySection = false; + List parsedRows = new ArrayList<>(); + List rowComponents = new ArrayList<>(); + + for (PlayerScoreEntry entry : entries) { + Component component = entry.display(); + if (component == null) { + PlayerTeam team = scoreboard.getPlayersTeam(entry.owner()); + component = team != null ? PlayerTeam.formatNameForTeam(team, entry.ownerName()) : entry.ownerName(); + } + String text = component.getString().trim(); + + if (text.contains("Party:") || text.contains("Raid:")) { + inPartySection = true; + continue; + } + if (inPartySection && (text.startsWith("Lv.") || text.contains("Territory:") || text.contains("Defense:") || text.contains("Area:") || text.contains("Objectives:") || text.contains("Guild Obj:") || text.startsWith("À") || text.isEmpty())) { + inPartySection = false; + } + + if (inPartySection) { + ParsedPartyLine parsed = parsePartyLine(component, true); + if (parsed != null) { + parsedRows.add(parsed); + rowComponents.add(component); + } + } + } + + if (parsedRows.isEmpty()) { + HEALTH_BY_UUID.clear(); + MAX_HP_BY_UUID.clear(); + return; + } + + List wynnPartyMembers = WynntilsPartyBridge.getPartyMembers(); + String localName = (mc.player != null && mc.player.getGameProfile() != null) ? mc.player.getGameProfile().name() : null; + List candidateRoster = matchCandidateRoster(parsedRows, wynnPartyMembers, localName); + + Set seenUuids = new HashSet<>(); + + // 1. If local player is present, record exact live health if available from Wynntils or vanilla stats + if (mc.player != null) { + UUID myUuid = mc.player.getUUID(); + String myName = (mc.player.getGameProfile() != null) ? mc.player.getGameProfile().name() : ""; + WynntilsPartyBridge.LiveHealth localLive = WynntilsPartyBridge.getLocalPlayerHealth(); + int currentHp; + int maxHp; + float percent; + boolean overMax; + + if (localLive != null) { + currentHp = localLive.current(); + maxHp = localLive.max(); + percent = localLive.percent(); + overMax = currentHp > maxHp; + } else { + float vHp = mc.player.getHealth(); + float vMax = mc.player.getMaxHealth(); + currentHp = (int) Math.ceil(vHp); + maxHp = (int) Math.ceil(vMax); + percent = (maxHp > 0) ? Math.max(0.0f, Math.min(1.0f, vHp / vMax)) : 1.0f; + overMax = false; + } + + int slot = resolvePartySlot(myName, null, wynnPartyMembers, 0, false); + float overPercent = PlayerHealthData.calculateOverPercent(currentHp, maxHp, overMax); + HEALTH_BY_UUID.put(myUuid, new PlayerHealthData(myUuid, myName, currentHp, maxHp, percent, overMax, overPercent, slot)); + seenUuids.add(myUuid); + } + + UUID[] rowUuids = resolvePartyUuids(mc, parsedRows, rowComponents, candidateRoster, wynnPartyMembers); + + for (int i = 0; i < parsedRows.size(); i++) { + ParsedPartyLine row = parsedRows.get(i); + if (!row.online()) { + continue; + } + + UUID uuid = i < rowUuids.length ? rowUuids[i] : null; + if (uuid == null) { + continue; + } + seenUuids.add(uuid); + + if (mc.player != null && uuid.equals(mc.player.getUUID()) && HEALTH_BY_UUID.containsKey(uuid)) { + // Local player already accurately tracked from Wynntils CharacterStats + continue; + } + + WynntilsPartyBridge.LiveHealth hadesLive = WynntilsPartyBridge.getHadesHealth(uuid); + int maxHp; + float percent; + boolean overMax; + + if (hadesLive != null) { + maxHp = hadesLive.max(); + percent = hadesLive.percent(); + overMax = hadesLive.current() > hadesLive.max(); + if (maxHp > 0) { + MaxHpState maxState = MAX_HP_BY_UUID.computeIfAbsent(uuid, k -> new MaxHpState()); + maxState.stableMaxHp = maxHp; + } + } else { + MaxHpState maxState = MAX_HP_BY_UUID.computeIfAbsent(uuid, k -> new MaxHpState()); + HealthCalculation calc = calculateHealth(maxState, row.hp(), row.level(), row.fullHealthBar(), row.visualFraction(), now); + maxHp = calc.maxHp(); + percent = calc.percent(); + overMax = calc.overMax(); + } + + boolean sharedNick = isSharedNickname(parsedRows, row.nickname()); + String realName = resolvePlayerRealName(mc, uuid, candidateRoster, i); + int slot = resolvePartySlot(realName, row.nickname(), wynnPartyMembers, i, sharedNick); + float overPercent = PlayerHealthData.calculateOverPercent(row.hp(), maxHp, overMax); + HEALTH_BY_UUID.put(uuid, new PlayerHealthData(uuid, row.nickname(), row.hp(), maxHp, percent, overMax, overPercent, slot)); + } + + HEALTH_BY_UUID.keySet().retainAll(seenUuids); + } + + record StyledChar(char ch, int color) { + } + + static List extractStyledChars(Component message) { + List result = new ArrayList<>(); + if (message == null) { + return result; + } + for (Component fragment : message.toFlatList()) { + Style style = fragment.getStyle(); + TextColor textColor = style.getColor(); + int baseColor = textColor != null ? textColor.getValue() : -1; + int currentColor = baseColor; + + String text = fragment.getString(); + for (int i = 0; i < text.length(); i++) { + char c = text.charAt(i); + if (c == '§' || c == '\u00A7') { + if (i + 1 < text.length()) { + char code = Character.toLowerCase(text.charAt(i + 1)); + ChatFormatting formatting = ChatFormatting.getByCode(code); + if (formatting == ChatFormatting.RESET) { + currentColor = baseColor; + } else if (formatting != null && formatting.getColor() != null) { + currentColor = formatting.getColor(); + } + i++; + } + continue; + } + result.add(new StyledChar(c, currentColor)); + } + } + return result; + } + + static ParsedPartyLine parsePartyLine(Component component, boolean inPartySection) { + if (component == null || !inPartySection) { + return null; + } + List styledChars = extractStyledChars(component); + if (styledChars.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder(styledChars.size()); + for (StyledChar sc : styledChars) { + sb.append(sc.ch()); + } + String text = sb.toString().trim(); + if (text.isEmpty()) { + return null; + } + + BracketedNumber level = lastBracketedNumber(text); + if (level == null) { + // Offline member: "- PlayerName" + if (text.startsWith("-")) { + String name = text.substring(1).trim(); + if (ChatText.IGN.matcher(name).matches()) { + return new ParsedPartyLine(0, name, 0, false, false, 0.0f); + } + } + return null; + } + + int openBracket = text.indexOf('['); + int closeBracket = openBracket >= 0 ? text.indexOf(']', openBracket) : -1; + int hp; + String nickname; + int barContentStart = -1; + int barContentEnd = -1; + + if (openBracket >= 0 && closeBracket > openBracket && closeBracket < level.start()) { + // Structured health bar: "- [||18630||] meep [120]" or "[18630] meep [120]" + String barContent = text.substring(openBracket + 1, closeBracket); + Matcher barMatcher = FIRST_NUMBER.matcher(barContent); + if (!barMatcher.find()) { + return null; + } + hp = Integer.parseInt(barMatcher.group(1)); + barContentStart = openBracket + 1; + barContentEnd = closeBracket; + nickname = text.substring(closeBracket + 1, level.start()).trim(); + } else { + // Fallback: e.g. "18630 meep [120]" + Matcher hpMatcher = FIRST_NUMBER.matcher(text); + hpMatcher.region(0, level.start()); + if (!hpMatcher.find()) { + return null; + } + hp = Integer.parseInt(hpMatcher.group(1)); + barContentStart = hpMatcher.start(); + barContentEnd = hpMatcher.end(); + nickname = text.substring(hpMatcher.end(), level.start()).trim(); + } + + // Trim any non-nickname characters from nickname bounds + int nickStart = 0; + while (nickStart < nickname.length() && !isNicknameChar(nickname.charAt(nickStart))) { + nickStart++; + } + int nickEnd = nickname.length(); + while (nickEnd > nickStart && !isNicknameChar(nickname.charAt(nickEnd - 1))) { + nickEnd--; + } + if (nickStart >= nickEnd) { + return null; + } + nickname = nickname.substring(nickStart, nickEnd).trim(); + if (nickname.isEmpty()) { + return null; + } + + BarAnalysis bar = analyzeHealthBar(styledChars, barContentStart, barContentEnd); + return new ParsedPartyLine(hp, nickname, level.value(), true, bar.fullBar(), bar.visualFraction()); + } + + private static boolean isNicknameChar(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; + } + + private static BarAnalysis analyzeHealthBar(List styledChars, int barStart, int barEnd) { + if (styledChars == null || barStart < 0 || barEnd <= barStart || barEnd > styledChars.size()) { + return new BarAnalysis(false, 1.0f); + } + + int healthChars = 0; + int deficitChars = 0; + + for (int i = barStart; i < barEnd; i++) { + StyledChar sc = styledChars.get(i); + char c = sc.ch(); + if (Character.isWhitespace(c) || c == '[' || c == ']') { + continue; + } + int rgb = sc.color(); + if (isHealthColor(rgb)) { + healthChars++; + } else if (isDeficitColor(rgb)) { + deficitChars++; + } + } + + int total = healthChars + deficitChars; + if (total == 0) { + return new BarAnalysis(false, 1.0f); + } + boolean full = deficitChars == 0 && healthChars > 0; + float fraction = (float) healthChars / (float) total; + return new BarAnalysis(full, fraction); + } + + private static boolean isHealthColor(int rgb) { + if (rgb < 0) { + return false; + } + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + if (r >= 130 && g <= 100 && b <= 100 && r > g && r > b) { + return true; + } + if (g >= 130 && r <= 100 && b <= 100 && g > r && g > b) { + return true; + } + return false; + } + + private static boolean isDeficitColor(int rgb) { + if (rgb < 0) { + return false; + } + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + return rgb == 0x555555 || (r >= 50 && r <= 110 && g >= 50 && g <= 110 && b >= 50 && b <= 110 && Math.abs(r - g) <= 15 && Math.abs(r - b) <= 15); + } + + static HealthCalculation calculateHealth(MaxHpState state, int currentHp, int level, boolean fullBar, float visualFraction, long now) { + if (fullBar) { + if (state.stableMaxHp <= 0) { + state.stableMaxHp = currentHp; + state.candidateHp = currentHp; + state.candidateSinceMs = now; + state.downwardCandidateSinceMs = 0L; + } else if (currentHp != state.candidateHp) { + state.candidateHp = currentHp; + state.candidateSinceMs = now; + state.downwardCandidateSinceMs = (currentHp < state.stableMaxHp) ? now : 0L; + } else if (currentHp > state.stableMaxHp && now - state.candidateSinceMs >= FULL_BAR_STABLE_MS) { + state.stableMaxHp = currentHp; + } else if (currentHp < state.stableMaxHp && state.downwardCandidateSinceMs > 0L && now - state.downwardCandidateSinceMs >= FULL_BAR_DOWNWARD_STABLE_MS) { + state.stableMaxHp = currentHp; + state.downwardCandidateSinceMs = 0L; + } + } else { + state.candidateHp = currentHp; + state.candidateSinceMs = now; + state.downwardCandidateSinceMs = 0L; + } + + int maxHp; + float percent; + + if (state.stableMaxHp > 0) { + maxHp = state.stableMaxHp; + percent = Math.max(0.0f, Math.min(1.0f, currentHp / (float) maxHp)); + } else { + int estimatedMax = 0; + if (visualFraction > 0.05f) { + estimatedMax = Math.round(currentHp / visualFraction); + } else { + estimatedMax = currentHp * 8; + } + + if (level > 0) { + int levelBasedMin = 50 + (level * 115); + estimatedMax = Math.max(estimatedMax, levelBasedMin); + } + + maxHp = Math.max(currentHp, estimatedMax); + percent = Math.max(0.0f, Math.min(1.0f, currentHp / (float) maxHp)); + } + + boolean overMax = state.stableMaxHp > 0 && currentHp > state.stableMaxHp; + return new HealthCalculation(maxHp, percent, overMax); + } + + private static boolean isRealPlayer(Minecraft mc, Player player) { + if (player == null) { + return false; + } + if (mc != null && mc.player != null && player.getUUID().equals(mc.player.getUUID())) { + return true; + } + if (mc != null && mc.getConnection() != null) { + PlayerInfo info = mc.getConnection().getPlayerInfo(player.getUUID()); + if (info == null) { + return false; + } + } + String name = player.getGameProfile() != null ? player.getGameProfile().name() : null; + if (name == null || !ChatText.IGN.matcher(name).matches()) { + return false; + } + String custom = player.getCustomName() != null ? player.getCustomName().getString() : ""; + if (custom.contains("Click") || custom.contains("Leave") || custom.contains("Merchant") || custom.contains("Master") || custom.contains("Select")) { + return false; + } + return true; + } + + static UUID[] resolvePartyUuids(Minecraft mc, List rows, List rowComponents, List candidateRoster, List wynnPartyMembers) { + if (mc == null || mc.level == null || rows == null || rows.isEmpty()) { + return new UUID[0]; + } + + UUID[] result = new UUID[rows.size()]; + Set claimed = new HashSet<>(); + + String localIgn = (mc.player != null && mc.player.getGameProfile() != null) ? mc.player.getGameProfile().name() : ""; + UUID localUuid = mc.player != null ? mc.player.getUUID() : null; + + // Pass 1: Local player identification + for (int i = 0; i < rows.size(); i++) { + ParsedPartyLine row = rows.get(i); + if (isLocalRow(row, localIgn)) { + result[i] = localUuid; + if (localUuid != null) { + claimed.add(localUuid); + if (!localIgn.isEmpty()) { + UUID_BY_PLAYER_NAME.put(localIgn.toLowerCase(Locale.ROOT), localUuid); + } + } + if (!localIgn.isEmpty() && !isSharedNickname(rows, row.nickname())) { + PlayerNameResolver.recordAlias(row.nickname(), localIgn); + } + break; + } + } + + // Pass 2: Direct / known / candidate matching with real world players + for (int i = 0; i < rows.size(); i++) { + if (result[i] != null || !rows.get(i).online()) { + continue; + } + ParsedPartyLine row = rows.get(i); + Component comp = (rowComponents != null && i < rowComponents.size()) ? rowComponents.get(i) : null; + boolean sharedNick = isSharedNickname(rows, row.nickname()); + + String real = null; + if (comp != null) { + real = ChatText.resolveRealNameAnywhere(comp, row.nickname()); + } + + // If nickname is unique, check learned alias; if shared, candidate roster is positional truth + if (real == null && !sharedNick) { + real = PlayerNameResolver.resolveKnown(row.nickname()).orElse(null); + } + + // If the resolved alias belongs to a player already claimed by another row, discard it + if (real != null && isPlayerClaimed(mc, claimed, real)) { + real = null; + } + + // Candidate roster fallback + if (real == null && candidateRoster != null && i < candidateRoster.size() && candidateRoster.get(i) != null) { + String cand = candidateRoster.get(i); + if (!isPlayerClaimed(mc, claimed, cand)) { + real = cand; + } + } + + String nameToFind = real != null ? real : row.nickname(); + String nickLower = row.nickname().toLowerCase(Locale.ROOT); + String findLower = nameToFind.toLowerCase(Locale.ROOT); + + for (Player p : mc.level.players()) { + if (!isRealPlayer(mc, p) || claimed.contains(p.getUUID())) { + continue; + } + String pName = p.getGameProfile() != null ? p.getGameProfile().name() : null; + if (pName == null) { + continue; + } + String pNameLower = pName.toLowerCase(Locale.ROOT); + + // 1. Exact match on real name or nickname (if nickname is not shared across rows) + if (pNameLower.equals(findLower) || (!sharedNick && pNameLower.equals(nickLower))) { + result[i] = p.getUUID(); + claimed.add(p.getUUID()); + UUID_BY_PLAYER_NAME.put(pName.toLowerCase(Locale.ROOT), p.getUUID()); + if (!sharedNick) { + PlayerNameResolver.recordAlias(row.nickname(), pName); + } + break; + } + // 2. Prefix match (e.g. nickname "Eternal" matches real IGN "EternalOblivion") + if (!sharedNick && nickLower.length() >= 3 && pNameLower.startsWith(nickLower)) { + result[i] = p.getUUID(); + claimed.add(p.getUUID()); + UUID_BY_PLAYER_NAME.put(pName.toLowerCase(Locale.ROOT), p.getUUID()); + PlayerNameResolver.recordAlias(row.nickname(), pName); + break; + } + // 3. Custom nametag / display contains nickname + String custom = p.getCustomName() != null ? p.getCustomName().getString().toLowerCase(Locale.ROOT) : ""; + if (!sharedNick && !custom.isBlank() && custom.contains(nickLower)) { + result[i] = p.getUUID(); + claimed.add(p.getUUID()); + UUID_BY_PLAYER_NAME.put(pName.toLowerCase(Locale.ROOT), p.getUUID()); + PlayerNameResolver.recordAlias(row.nickname(), pName); + break; + } + } + + // Fallback: If player entity is out of render distance, check persistent UUID cache or server tab list + if (result[i] == null && real != null) { + result[i] = claimOfflineUuid(mc, real, claimed); + } + } + + // Pass 3: Confirmed party member elimination pass + // If rows remain unmatched, and confirmed party members from Wynntils remain unclaimed, + // match them safely (never matching arbitrary strangers). + List unmatchedRowIndices = new ArrayList<>(); + for (int i = 0; i < rows.size(); i++) { + if (result[i] == null && rows.get(i).online()) { + unmatchedRowIndices.add(i); + } + } + + if (!unmatchedRowIndices.isEmpty() && wynnPartyMembers != null && !wynnPartyMembers.isEmpty()) { + List unclaimedPartyPlayersInWorld = new ArrayList<>(); + for (Player p : mc.level.players()) { + if (!isRealPlayer(mc, p) || claimed.contains(p.getUUID())) { + continue; + } + String pName = p.getGameProfile() != null ? p.getGameProfile().name() : null; + if (pName != null && containsIgnoreCase(wynnPartyMembers, pName)) { + unclaimedPartyPlayersInWorld.add(p); + } + } + + // Step 3a: Match candidate roster expected player for each unmatched row + for (Iterator it = unmatchedRowIndices.iterator(); it.hasNext();) { + int rowIdx = it.next(); + if (candidateRoster != null && rowIdx < candidateRoster.size()) { + String expected = candidateRoster.get(rowIdx); + if (expected != null) { + Player matched = null; + for (Player p : unclaimedPartyPlayersInWorld) { + if (p.getGameProfile() != null && p.getGameProfile().name().equalsIgnoreCase(expected)) { + matched = p; + break; + } + } + if (matched != null) { + result[rowIdx] = matched.getUUID(); + claimed.add(matched.getUUID()); + String pName = matched.getGameProfile().name(); + if (pName != null) { + UUID_BY_PLAYER_NAME.put(pName.toLowerCase(Locale.ROOT), matched.getUUID()); + } + unclaimedPartyPlayersInWorld.remove(matched); + it.remove(); + if (!isSharedNickname(rows, rows.get(rowIdx).nickname())) { + PlayerNameResolver.recordAlias(rows.get(rowIdx).nickname(), matched.getGameProfile().name()); + } + } else { + UUID target = claimOfflineUuid(mc, expected, claimed); + if (target != null) { + result[rowIdx] = target; + it.remove(); + } + } + } + } + } + + // Step 3b: Unique prefix match for remaining unmatched non-shared nicknames + if (unclaimedPartyPlayersInWorld.size() > 1) { + for (Iterator it = unmatchedRowIndices.iterator(); it.hasNext();) { + int rowIdx = it.next(); + if (isSharedNickname(rows, rows.get(rowIdx).nickname())) { + continue; + } + String nickLower = rows.get(rowIdx).nickname().toLowerCase(Locale.ROOT); + String cleanNick = nickLower.replaceAll("[^a-z0-9_]", ""); + Player matchedPlayer = null; + for (Player p : unclaimedPartyPlayersInWorld) { + String pName = p.getGameProfile() != null ? p.getGameProfile().name() : null; + if (pName == null) { + continue; + } + String cleanP = pName.toLowerCase(Locale.ROOT); + if (!cleanNick.isEmpty() && (cleanP.startsWith(cleanNick) || cleanNick.startsWith(cleanP))) { + matchedPlayer = p; + break; + } + } + if (matchedPlayer != null) { + result[rowIdx] = matchedPlayer.getUUID(); + claimed.add(matchedPlayer.getUUID()); + String pName = matchedPlayer.getGameProfile().name(); + if (pName != null) { + UUID_BY_PLAYER_NAME.put(pName.toLowerCase(Locale.ROOT), matchedPlayer.getUUID()); + } + unclaimedPartyPlayersInWorld.remove(matchedPlayer); + it.remove(); + PlayerNameResolver.recordAlias(rows.get(rowIdx).nickname(), pName); + } + } + } + + // Step 3c: Single remaining party player match + if (unmatchedRowIndices.size() == 1 && unclaimedPartyPlayersInWorld.size() == 1) { + int rowIdx = unmatchedRowIndices.get(0); + Player partyPlayer = unclaimedPartyPlayersInWorld.get(0); + result[rowIdx] = partyPlayer.getUUID(); + claimed.add(partyPlayer.getUUID()); + String pName = partyPlayer.getGameProfile() != null ? partyPlayer.getGameProfile().name() : null; + if (pName != null) { + UUID_BY_PLAYER_NAME.put(pName.toLowerCase(Locale.ROOT), partyPlayer.getUUID()); + if (!isSharedNickname(rows, rows.get(rowIdx).nickname())) { + PlayerNameResolver.recordAlias(rows.get(rowIdx).nickname(), pName); + } + } + unmatchedRowIndices.clear(); + unclaimedPartyPlayersInWorld.clear(); + } + + // Step 3d: Positional alignment fallback for any remaining rows and party members + if (!unmatchedRowIndices.isEmpty() && unmatchedRowIndices.size() == unclaimedPartyPlayersInWorld.size()) { + unclaimedPartyPlayersInWorld.sort(Comparator.comparingInt(p -> { + String pName = p.getGameProfile() != null ? p.getGameProfile().name() : ""; + for (int k = 0; k < wynnPartyMembers.size(); k++) { + if (wynnPartyMembers.get(k).equalsIgnoreCase(pName)) { + return k; + } + } + return 999; + })); + for (int k = 0; k < unmatchedRowIndices.size(); k++) { + int rowIdx = unmatchedRowIndices.get(k); + Player p = unclaimedPartyPlayersInWorld.get(k); + result[rowIdx] = p.getUUID(); + claimed.add(p.getUUID()); + } + } + } + + return result; + } + + private static boolean isPlayerClaimed(Minecraft mc, Set claimed, String username) { + if (mc == null || mc.level == null || claimed == null || username == null) { + return false; + } + for (Player p : mc.level.players()) { + if (p.getGameProfile() != null && p.getGameProfile().name().equalsIgnoreCase(username)) { + return claimed.contains(p.getUUID()); + } + } + return false; + } + + static boolean isSharedNickname(List rows, String nickname) { + if (nickname == null || nickname.isBlank() || rows == null || rows.size() <= 1) { + return false; + } + String clean = nickname.replaceAll("[^a-zA-Z0-9_]", "").toLowerCase(Locale.ROOT); + int count = 0; + for (ParsedPartyLine r : rows) { + if (r == null || r.nickname() == null) { + continue; + } + String rClean = r.nickname().replaceAll("[^a-zA-Z0-9_]", "").toLowerCase(Locale.ROOT); + if (r.nickname().equalsIgnoreCase(nickname) || (!clean.isEmpty() && clean.equals(rClean))) { + count++; + } else if (clean.length() >= 4 && (clean.startsWith(rClean) || rClean.startsWith(clean))) { + count++; + } + } + return count > 1; + } + + static List matchCandidateRoster(List rows, List wynnPartyMembers, String localUsername) { + if (wynnPartyMembers == null || wynnPartyMembers.isEmpty() || rows == null || rows.isEmpty()) { + return List.of(); + } + Set seen = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + wynnPartyMembers = wynnPartyMembers.stream().filter(s -> s != null && !s.isBlank()).map(String::trim).filter(seen::add).toList(); + + int localRowIdx = -1; + if (localUsername != null) { + for (int i = 0; i < rows.size(); i++) { + if (isLocalRow(rows.get(i), localUsername)) { + localRowIdx = i; + break; + } + } + } + boolean scoreboardHasLocal = (localRowIdx >= 0); + + List> candidates = new ArrayList<>(); + + // 1. Equal size: party list and scoreboard have same count + if (wynnPartyMembers.size() == rows.size()) { + candidates.add(wynnPartyMembers); + if (scoreboardHasLocal && containsIgnoreCase(wynnPartyMembers, localUsername)) { + List aligned = new ArrayList<>(rows.size()); + List others = new ArrayList<>(wynnPartyMembers.size()); + for (String m : wynnPartyMembers) { + if (!m.equalsIgnoreCase(localUsername)) { + others.add(m); + } + } + int oIdx = 0; + for (int i = 0; i < rows.size(); i++) { + if (i == localRowIdx) { + aligned.add(localUsername); + } else if (oIdx < others.size()) { + aligned.add(others.get(oIdx++)); + } + } + if (aligned.size() == rows.size() && !candidates.contains(aligned)) { + candidates.add(aligned); + } + } + } + + // 2. Scoreboard omitted local player: wynnPartyMembers.size() - 1 == rows.size() + if (!scoreboardHasLocal && localUsername != null && containsIgnoreCase(wynnPartyMembers, localUsername)) { + if (wynnPartyMembers.size() - 1 == rows.size()) { + List withoutLocal = new ArrayList<>(rows.size()); + for (String m : wynnPartyMembers) { + if (!m.equalsIgnoreCase(localUsername)) { + withoutLocal.add(m); + } + } + candidates.add(withoutLocal); + } + } + + // 3. Wynntils omitted local player: wynnPartyMembers.size() + 1 == rows.size() + if (localUsername != null && !containsIgnoreCase(wynnPartyMembers, localUsername)) { + if (wynnPartyMembers.size() + 1 == rows.size()) { + if (scoreboardHasLocal) { + List aligned = new ArrayList<>(rows.size()); + int wIdx = 0; + for (int i = 0; i < rows.size(); i++) { + if (i == localRowIdx) { + aligned.add(localUsername); + } else if (wIdx < wynnPartyMembers.size()) { + aligned.add(wynnPartyMembers.get(wIdx++)); + } + } + candidates.add(aligned); + } + List localFirst = new ArrayList<>(rows.size()); + localFirst.add(localUsername); + localFirst.addAll(wynnPartyMembers); + if (!candidates.contains(localFirst)) { + candidates.add(localFirst); + } + + List localLast = new ArrayList<>(rows.size()); + localLast.addAll(wynnPartyMembers); + localLast.add(localUsername); + if (!candidates.contains(localLast)) { + candidates.add(localLast); + } + } + } + + List bestCandidate = null; + int bestScore = -1; + + for (List candidate : candidates) { + int score = scoreCandidateRoster(rows, candidate, localUsername); + if (score > bestScore) { + bestScore = score; + bestCandidate = candidate; + } + } + + if (bestCandidate != null && bestScore >= 0) { + return bestCandidate; + } + + return candidates.isEmpty() ? List.of() : candidates.get(0); + } + + static int scoreCandidateRoster(List rows, List candidate, String localUsername) { + if (candidate == null || rows == null || candidate.size() != rows.size()) { + return -1; + } + int score = 0; + for (int i = 0; i < rows.size(); i++) { + ParsedPartyLine row = rows.get(i); + String expectedUser = candidate.get(i); + boolean isLocal = isLocalRow(row, localUsername); + + if (localUsername != null && expectedUser.equalsIgnoreCase(localUsername)) { + if (isLocal) { + score += 10; + } else { + return -1; + } + continue; + } else if (isLocal) { + return -1; + } + + String known = PlayerNameResolver.canonicalize(row.nickname()); + if (known != null && known.equalsIgnoreCase(expectedUser)) { + score += 5; + continue; + } + + String expectedLower = expectedUser.toLowerCase(Locale.ROOT); + String nickLower = row.nickname().toLowerCase(Locale.ROOT); + String cleanExpected = expectedLower.replaceAll("[^a-z0-9_]", ""); + String cleanNick = nickLower.replaceAll("[^a-z0-9_]", ""); + + if (expectedLower.equals(nickLower) || cleanExpected.equals(cleanNick)) { + score += 5; + } else if (cleanExpected.startsWith(cleanNick) && cleanNick.length() >= 3) { + score += 3; + } else if (cleanNick.startsWith(cleanExpected) && cleanExpected.length() >= 3) { + score += 3; + } + } + return score; + } + + static boolean isLocalRow(ParsedPartyLine row, String localUsername) { + if (localUsername == null || localUsername.isBlank() || row == null || row.nickname() == null) { + return false; + } + if (row.nickname().equalsIgnoreCase(localUsername)) { + return true; + } + String known = PlayerNameResolver.canonicalize(row.nickname()); + if (known != null && known.equalsIgnoreCase(localUsername)) { + return true; + } + String cleanLocal = localUsername.replaceAll("[^a-zA-Z0-9_]", "").toLowerCase(Locale.ROOT); + String cleanNick = row.nickname().replaceAll("[^a-zA-Z0-9_]", "").toLowerCase(Locale.ROOT); + if (!cleanNick.isEmpty() && cleanNick.length() >= 3 && cleanLocal.startsWith(cleanNick)) { + return true; + } + return false; + } + + public static List debugState() { + List lines = new ArrayList<>(); + lines.add("--- Party Health Tracker ---"); + + Minecraft mc = Minecraft.getInstance(); + if (mc == null || mc.level == null) { + lines.add("Level: null"); + return lines; + } + + Scoreboard scoreboard = ScoreboardCapture.hasSidebar() ? ScoreboardCapture.scoreboard() : mc.level.getScoreboard(); + Objective sidebar = (scoreboard != null) ? scoreboard.getDisplayObjective(DisplaySlot.SIDEBAR) : null; + lines.add("Sidebar: " + (sidebar != null ? sidebar.getName() : "none")); + + List wynnMembers = WynntilsPartyBridge.getPartyMembers(); + lines.add("Wynntils party (" + wynnMembers.size() + "): " + String.join(", ", wynnMembers)); + + WynntilsPartyBridge.LiveHealth localLive = WynntilsPartyBridge.getLocalPlayerHealth(); + lines.add("Local LiveHealth: " + (localLive != null ? (localLive.current() + "/" + localLive.max() + " (" + (int) (localLive.percent() * 100) + "%)") : "null")); + + lines.add("Tracked Health (" + HEALTH_BY_UUID.size() + "):"); + for (Map.Entry entry : HEALTH_BY_UUID.entrySet()) { + PlayerHealthData data = entry.getValue(); + Player worldPlayer = mc.level.getPlayerByUUID(entry.getKey()); + String worldInfo = (worldPlayer != null) ? "in-world (" + worldPlayer.getGameProfile().name() + ")" : "not-loaded"; + lines.add(String.format(Locale.ROOT, " [Slot %d] %s (%s): %d/%d (%.1f%%) overMax=%b %s", data.partySlot(), data.name(), entry.getKey().toString().substring(0, 8), data.hp(), data.maxHp(), data.percent() * 100f, data.overMax(), worldInfo)); + } + + lines.add("Loaded real players (" + mc.level.players().size() + "):"); + for (Player p : mc.level.players()) { + if (isRealPlayer(mc, p)) { + lines.add(" " + p.getGameProfile().name() + " (" + p.getUUID().toString().substring(0, 8) + ") custom=" + (p.getCustomName() != null ? p.getCustomName().getString() : "none")); + } + } + + return lines; + } + + private static boolean containsIgnoreCase(List list, String target) { + return list != null && target != null && list.stream().anyMatch(target::equalsIgnoreCase); + } + + private static UUID findTabUuid(Minecraft mc, String name, Set claimed) { + if (mc == null || mc.getConnection() == null || name == null) { + return null; + } + for (PlayerInfo info : mc.getConnection().getOnlinePlayers()) { + if (info.getProfile() != null && info.getProfile().name() != null && info.getProfile().name().equalsIgnoreCase(name)) { + UUID tabUuid = info.getProfile().id(); + if (tabUuid != null && (claimed == null || !claimed.contains(tabUuid))) { + return tabUuid; + } + } + } + return null; + } + + private static UUID claimOfflineUuid(Minecraft mc, String name, Set claimed) { + if (name == null || name.isBlank()) { + return null; + } + UUID target = UUID_BY_PLAYER_NAME.get(name.toLowerCase(Locale.ROOT)); + if (target == null || claimed.contains(target)) { + target = findTabUuid(mc, name, claimed); + } + if (target != null && !claimed.contains(target)) { + claimed.add(target); + UUID_BY_PLAYER_NAME.put(name.toLowerCase(Locale.ROOT), target); + return target; + } + return null; + } + + static String resolvePlayerRealName(Minecraft mc, UUID uuid, List candidateRoster, int rowIndex) { + if (mc != null && uuid != null) { + if (mc.level != null) { + Player player = mc.level.getPlayerByUUID(uuid); + if (player != null && player.getGameProfile() != null && player.getGameProfile().name() != null) { + String name = player.getGameProfile().name(); + UUID_BY_PLAYER_NAME.put(name.toLowerCase(Locale.ROOT), uuid); + return name; + } + } + if (mc.getConnection() != null) { + PlayerInfo info = mc.getConnection().getPlayerInfo(uuid); + if (info != null && info.getProfile() != null && info.getProfile().name() != null) { + String name = info.getProfile().name(); + UUID_BY_PLAYER_NAME.put(name.toLowerCase(Locale.ROOT), uuid); + return name; + } + } + } + if (candidateRoster != null && rowIndex >= 0 && rowIndex < candidateRoster.size()) { + String cand = candidateRoster.get(rowIndex); + if (cand != null && !cand.isBlank()) { + if (uuid != null) { + UUID_BY_PLAYER_NAME.put(cand.toLowerCase(Locale.ROOT), uuid); + } + return cand; + } + } + return null; + } + + static int resolvePartySlot(String realName, String nickname, List partyMembers, int fallbackSlot, boolean sharedNick) { + if (partyMembers != null && !partyMembers.isEmpty()) { + // 1. Direct match with real account name (authoritative) + if (realName != null && !realName.isBlank()) { + for (int i = 0; i < partyMembers.size(); i++) { + String member = partyMembers.get(i); + if (member != null && member.equalsIgnoreCase(realName)) { + return i; + } + } + } + // 2. Nickname match ONLY when nickname is not shared across party members + if (!sharedNick && nickname != null && !nickname.isBlank()) { + for (int i = 0; i < partyMembers.size(); i++) { + String member = partyMembers.get(i); + if (member != null && member.equalsIgnoreCase(nickname)) { + return i; + } + } + String canonical = PlayerNameResolver.canonicalize(nickname); + if (canonical != null && !canonical.equalsIgnoreCase(nickname)) { + for (int i = 0; i < partyMembers.size(); i++) { + String member = partyMembers.get(i); + if (member != null && member.equalsIgnoreCase(canonical)) { + return i; + } + } + } + } + } + return fallbackSlot; + } + + private record BracketedNumber(int value, int start, int end) { + } + + private static BracketedNumber lastBracketedNumber(String text) { + Matcher matcher = BRACKETED_NUMBER.matcher(text); + BracketedNumber last = null; + while (matcher.find()) { + try { + last = new BracketedNumber(Integer.parseInt(matcher.group(1)), matcher.start(), matcher.end()); + } catch (NumberFormatException ignored) { + } + } + return last; + } +} diff --git a/src/tel/eden/mod/party/PartyHighlightManager.java b/src/tel/eden/mod/party/PartyHighlightManager.java new file mode 100644 index 0000000..69a7a31 --- /dev/null +++ b/src/tel/eden/mod/party/PartyHighlightManager.java @@ -0,0 +1,74 @@ +package tel.eden.mod.party; + +import java.util.List; +import net.minecraft.client.Minecraft; +import net.minecraft.world.entity.player.Player; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.config.BridgeConfig; + +public final class PartyHighlightManager { + public static final List DEFAULT_PALETTE = List.of(0xFF2222, // Slot 0 (Party Leader): Crimson Red + 0x1E56FF, // Slot 1: Deep Royal Blue + 0xFFA6D5, // Slot 2: Light Pink + 0x55FF55, // Slot 3: Lime Green + 0xFF7700, // Slot 4: Vivid Electric Orange + 0x9933FF, // Slot 5: Royal Violet / Purple + 0xFF5588, // Slot 6: Coral Rose + 0x5522FF, // Slot 7: Electric Indigo + 0xCC4400, // Slot 8: Burnt Rust / Amber + 0x6C7CE4 // Slot 9: Periwinkle / Slate Blue + ); + + private PartyHighlightManager() { + } + + public static Integer getPartyOutlineColor(Player player) { + if (player == null) { + return null; + } + + EdenModClient client = EdenModClient.instance(); + BridgeConfig config = client != null ? client.config() : null; + if (config != null && !config.partyHighlightEnabled) { + return null; + } + + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.player != null && player == mc.player) { + return null; + } + + if (!WynntilsPartyBridge.isAvailable() || !WynntilsPartyBridge.isInParty()) { + return null; + } + + String name = null; + if (player.getGameProfile() != null) { + name = player.getGameProfile().name(); + } + if (name == null && player.getName() != null) { + name = player.getName().getString(); + } + if (name == null || name.isBlank()) { + return null; + } + + List members = WynntilsPartyBridge.getPartyMembers(); + return getColorForPlayerName(name, members, config); + } + + public static Integer getColorForPlayerName(String playerName, List members, BridgeConfig config) { + if (playerName == null || members == null || members.isEmpty()) { + return null; + } + + for (int i = 0; i < members.size(); i++) { + String member = members.get(i); + if (member != null && member.equalsIgnoreCase(playerName)) { + List palette = (config != null && config.partyColors != null && config.partyColors.size() == 10) ? config.partyColors : DEFAULT_PALETTE; + return palette.get(i % palette.size()); + } + } + return null; + } +} diff --git a/src/tel/eden/mod/party/WynntilsPartyBridge.java b/src/tel/eden/mod/party/WynntilsPartyBridge.java new file mode 100644 index 0000000..db85fa4 --- /dev/null +++ b/src/tel/eden/mod/party/WynntilsPartyBridge.java @@ -0,0 +1,207 @@ +package tel.eden.mod.party; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Collections; +import java.util.List; +import net.fabricmc.loader.api.FabricLoader; +import tel.eden.mod.EdenLogger; + +public final class WynntilsPartyBridge { + private static final EdenLogger LOGGER = EdenLogger.get(); + private static final String WYNNTILS_MODELS = "com.wynntils.core.components.Models"; + + public record LiveHealth(int current, int max, float percent) { + } + + private static boolean initialized = false; + private static boolean available = false; + private static Object partyModelInstance = null; + private static Method isInPartyMethod = null; + private static Method getPartyMembersMethod = null; + + private static Object characterStatsModelInstance = null; + private static Method characterStatsGetHealthMethod = null; + private static Object hadesServiceInstance = null; + private static Method hadesGetHadesUserMethod = null; + private static Method hadesUserGetHealthMethod = null; + private static Method cappedValueCurrentMethod = null; + private static Method cappedValueMaxMethod = null; + private static Method cappedValueGetProgressMethod = null; + + private WynntilsPartyBridge() { + } + + public static boolean isAvailable() { + ensureLoaded(); + return available; + } + + public static boolean isInParty() { + if (!isAvailable() || partyModelInstance == null || isInPartyMethod == null) { + return false; + } + try { + Object res = isInPartyMethod.invoke(partyModelInstance); + return res instanceof Boolean b && b; + } catch (Exception e) { + return false; + } + } + + public static List getPartyMembers() { + if (!isAvailable() || partyModelInstance == null || getPartyMembersMethod == null) { + return Collections.emptyList(); + } + try { + Object res = getPartyMembersMethod.invoke(partyModelInstance); + if (res instanceof List list) { + if (list.isEmpty()) { + return Collections.emptyList(); + } + List names = new java.util.ArrayList<>(list.size()); + java.util.Set seen = new java.util.TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (Object obj : list) { + if (obj != null) { + String s = obj.toString().trim(); + if (!s.isEmpty() && seen.add(s)) { + names.add(s); + } + } + } + return names; + } + } catch (Exception ignored) { + } + return Collections.emptyList(); + } + + public static LiveHealth getLocalPlayerHealth() { + if (!isAvailable() || characterStatsModelInstance == null || characterStatsGetHealthMethod == null) { + return null; + } + try { + Object opt = characterStatsGetHealthMethod.invoke(characterStatsModelInstance); + if (opt instanceof java.util.Optional optional && optional.isPresent()) { + return extractFromCappedValue(optional.get()); + } + } catch (Throwable ignored) { + } + return null; + } + + public static LiveHealth getHadesHealth(java.util.UUID uuid) { + if (!isAvailable() || hadesServiceInstance == null || hadesGetHadesUserMethod == null || uuid == null) { + return null; + } + try { + Object opt = hadesGetHadesUserMethod.invoke(hadesServiceInstance, uuid); + if (opt instanceof java.util.Optional optional && optional.isPresent()) { + Object hadesUser = optional.get(); + if (hadesUserGetHealthMethod == null) { + hadesUserGetHealthMethod = hadesUser.getClass().getMethod("getHealth"); + } + Object cv = hadesUserGetHealthMethod.invoke(hadesUser); + return extractFromCappedValue(cv); + } + } catch (Throwable ignored) { + } + return null; + } + + private static LiveHealth extractFromCappedValue(Object cappedValue) { + if (cappedValue == null) { + return null; + } + try { + if (cappedValueCurrentMethod == null) { + Class cvClass = cappedValue.getClass(); + cappedValueCurrentMethod = cvClass.getMethod("current"); + cappedValueMaxMethod = cvClass.getMethod("max"); + cappedValueGetProgressMethod = cvClass.getMethod("getProgress"); + } + Object curObj = cappedValueCurrentMethod.invoke(cappedValue); + Object maxObj = cappedValueMaxMethod.invoke(cappedValue); + Object progObj = cappedValueGetProgressMethod.invoke(cappedValue); + if (!(curObj instanceof Number curNum) || !(maxObj instanceof Number maxNum)) { + return null; + } + int current = curNum.intValue(); + int max = maxNum.intValue(); + double progress = (progObj instanceof Number progNum) ? progNum.doubleValue() : 0.0; + float percent = max > 0 ? (float) Math.max(0.0, Math.min(1.0, (progress > 0.001) ? progress : ((double) current / max))) : 1.0f; + return new LiveHealth(current, max, percent); + } catch (Throwable ignored) { + return null; + } + } + + private static synchronized void ensureLoaded() { + if (initialized) { + return; + } + initialized = true; + try { + if (!FabricLoader.getInstance().isModLoaded("wynntils")) { + available = false; + return; + } + Class modelsClass = Class.forName(WYNNTILS_MODELS); + Field partyField = modelsClass.getField("Party"); + partyModelInstance = partyField.get(null); + if (partyModelInstance != null) { + isInPartyMethod = partyModelInstance.getClass().getMethod("isInParty"); + getPartyMembersMethod = partyModelInstance.getClass().getMethod("getPartyMembers"); + available = true; + LOGGER.info("Wynntils PartyModel integration loaded successfully"); + } + + try { + Field charStatsField = modelsClass.getField("CharacterStats"); + characterStatsModelInstance = charStatsField.get(null); + if (characterStatsModelInstance != null) { + characterStatsGetHealthMethod = characterStatsModelInstance.getClass().getMethod("getHealth"); + } + } catch (Throwable ignored) { + } + + try { + Class servicesClass = Class.forName("com.wynntils.core.components.Services"); + Field hadesField = servicesClass.getField("Hades"); + hadesServiceInstance = hadesField.get(null); + if (hadesServiceInstance != null) { + hadesGetHadesUserMethod = hadesServiceInstance.getClass().getMethod("getHadesUser", java.util.UUID.class); + } + } catch (Throwable ignored) { + } + } catch (Throwable t) { + available = false; + LOGGER.warn("Wynntils PartyModel integration unavailable: {}", t.getMessage()); + } + } + + /** For testing purposes only: inject mock instance or reset state. */ + static synchronized void setTestInstance(Object instance, Method inParty, Method partyMembers) { + initialized = true; + available = (instance != null); + partyModelInstance = instance; + isInPartyMethod = inParty; + getPartyMembersMethod = partyMembers; + } + + static synchronized void reset() { + initialized = false; + available = false; + partyModelInstance = null; + isInPartyMethod = null; + getPartyMembersMethod = null; + characterStatsModelInstance = null; + characterStatsGetHealthMethod = null; + hadesServiceInstance = null; + hadesGetHadesUserMethod = null; + hadesUserGetHealthMethod = null; + cappedValueCurrentMethod = null; + cappedValueMaxMethod = null; + cappedValueGetProgressMethod = null; + } +} diff --git a/src/tel/eden/mod/render/EdenAvatarState.java b/src/tel/eden/mod/render/EdenAvatarState.java new file mode 100644 index 0000000..d669696 --- /dev/null +++ b/src/tel/eden/mod/render/EdenAvatarState.java @@ -0,0 +1,13 @@ +package tel.eden.mod.render; + +import java.util.UUID; + +/** + * Accessor interface duck-typed onto {@link net.minecraft.client.renderer.entity.state.AvatarRenderState} + * to carry player identity across the render pipeline without polling the world entity. + */ +public interface EdenAvatarState { + UUID edenmod$getPlayerUuid(); + + void edenmod$setPlayerUuid(UUID uuid); +} diff --git a/src/tel/eden/mod/war/AllianceMenuScraper.java b/src/tel/eden/mod/war/AllianceMenuScraper.java index 7443deb..bd21fd9 100644 --- a/src/tel/eden/mod/war/AllianceMenuScraper.java +++ b/src/tel/eden/mod/war/AllianceMenuScraper.java @@ -73,6 +73,8 @@ public static void onTick(Minecraft mc) { if (!(mc.screen instanceof AbstractContainerScreen screen) || !isDiplomacyTitle(screen)) { menuOpen = false; disconnectedWarningLogged = false; + pendingAllies = List.of(); + pendingSince = 0; return; } // Strategists can edit the alliance menu themselves, so let them keep it synced diff --git a/test/tel/eden/mod/chat/ChatReplyManagerTest.java b/test/tel/eden/mod/chat/ChatReplyManagerTest.java new file mode 100644 index 0000000..f36e2a6 --- /dev/null +++ b/test/tel/eden/mod/chat/ChatReplyManagerTest.java @@ -0,0 +1,110 @@ +package tel.eden.mod.chat; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class ChatReplyManagerTest { + + @BeforeEach + void setUp() { + ChatReplyManager.reset(); + } + + @Test + void recordsAndRetrievesDiscordMessages() { + ChatReplyManager.recordDiscordMessage("123456789", "Alice", "Hello there!"); + ChatReplyManager.ReplyInfo info = ChatReplyManager.getRecent("123456789"); + assertNotNull(info); + assertEquals("123456789", info.messageId()); + assertEquals("Alice", info.author()); + assertEquals("Hello there!", info.excerpt()); + } + + @Test + void truncatesLongExcerpt() { + String longMsg = "This is a very long message that should be truncated because it exceeds sixty characters in total length."; + ChatReplyManager.recordDiscordMessage("999", "Bob", longMsg); + ChatReplyManager.ReplyInfo info = ChatReplyManager.getRecent("999"); + assertNotNull(info); + assertTrue(info.excerpt().endsWith("...")); + assertTrue(info.excerpt().length() <= 60); + } + + @Test + void managesActiveReplyState() { + assertFalse(ChatReplyManager.hasActiveReply()); + assertNull(ChatReplyManager.activeReplyForTesting()); + + ChatReplyManager.recordDiscordMessage("111", "Charlie", "Msg"); + ChatReplyManager.setActiveReply("111"); + + assertTrue(ChatReplyManager.hasActiveReply()); + assertEquals("111", ChatReplyManager.activeReplyForTesting().messageId()); + assertEquals("Charlie", ChatReplyManager.activeReplyForTesting().author()); + + ChatReplyManager.clearActiveReply(); + assertFalse(ChatReplyManager.hasActiveReply()); + assertNull(ChatReplyManager.activeReplyForTesting()); + } + + @Test + void marksAndConsumesPendingReply() { + ChatReplyManager.recordDiscordMessage("222", "Dave", "Question"); + ChatReplyManager.setActiveReply("222"); + + ChatReplyManager.markPendingReply(); + assertFalse(ChatReplyManager.hasActiveReply()); + + // In headless test mc.getUser() is null, so username check is bypassed + String replyId = ChatReplyManager.consumePendingReply("AnyUser"); + assertEquals("222", replyId); + + // Second consume returns null (consumed) + assertNull(ChatReplyManager.consumePendingReply("AnyUser")); + } + + @Test + void detectsCloseButtonClick() { + ChatReplyManager.recordDiscordMessage("333", "Eve", "Test"); + ChatReplyManager.setActiveReply("333"); + int inputX = 4; + int inputY = 200; + int inputW = 300; + + // Close button is at bannerX + inputW - 14 to bannerX + inputW + // bannerX = 4, closeX = 4 + 300 - 14 = 290 + // bannerY = 200 - 14 = 186, bannerH = 14 (186 to 200) + + // Inside close button + assertTrue(ChatReplyManager.isCloseButtonClicked(inputX, inputY, inputW, 295, 190)); + + // Outside close button (to the left in the banner) + assertFalse(ChatReplyManager.isCloseButtonClicked(inputX, inputY, inputW, 100, 190)); + + // Outside vertically + assertFalse(ChatReplyManager.isCloseButtonClicked(inputX, inputY, inputW, 295, 180)); + assertFalse(ChatReplyManager.isCloseButtonClicked(inputX, inputY, inputW, 295, 205)); + } + + @Test + void invokesRegisteredInputHolder() { + AtomicBoolean invoked = new AtomicBoolean(false); + ChatReplyManager.ChatScreenInputHolder holder = () -> invoked.set(true); + + ChatReplyManager.registerInputHolder(holder); + ChatReplyManager.ensureGuildPrefix(); + assertTrue(invoked.get()); + + invoked.set(false); + ChatReplyManager.unregisterInputHolder(); + ChatReplyManager.ensureGuildPrefix(); + assertFalse(invoked.get()); + } +} diff --git a/test/tel/eden/mod/chat/PlayerNameResolverTest.java b/test/tel/eden/mod/chat/PlayerNameResolverTest.java index a42cfa5..45ff1ff 100644 --- a/test/tel/eden/mod/chat/PlayerNameResolverTest.java +++ b/test/tel/eden/mod/chat/PlayerNameResolverTest.java @@ -38,4 +38,53 @@ void resolveKnownAlsoResolvesTheRealUsernameItself() { assertEquals(Optional.of("Asthae2"), PlayerNameResolver.resolveKnown("Asthae2")); } + + @Test + void observeMessageLearnsFromHoverAndInsertion() { + net.minecraft.network.chat.Component hoverMsg = net.minecraft.network.chat.Component.literal("CustomNick").withStyle(s -> s.withHoverEvent(new net.minecraft.network.chat.HoverEvent.ShowText(net.minecraft.network.chat.Component.literal("CustomNick's real username is CustomUser")))); + PlayerNameResolver.observeMessage(hoverMsg); + assertEquals(Optional.of("CustomUser"), PlayerNameResolver.resolveKnown("CustomNick")); + + net.minecraft.network.chat.Component insertMsg = net.minecraft.network.chat.Component.literal("TestNick").withStyle(s -> s.withInsertion("TestReal")); + PlayerNameResolver.observeMessage(insertMsg); + assertEquals(Optional.of("TestReal"), PlayerNameResolver.resolveKnown("TestNick")); + } + + @Test + void observeMessageLearnsFromPartyPatterns() { + // Party join message + net.minecraft.network.chat.Component joinMsg = net.minecraft.network.chat.Component.empty().append(net.minecraft.network.chat.Component.literal("SoloNick").withStyle(s -> s.withInsertion("SoloReal"))).append(net.minecraft.network.chat.Component.literal(" has joined your party.")); + PlayerNameResolver.observeMessage(joinMsg); + assertEquals(Optional.of("SoloReal"), PlayerNameResolver.resolveKnown("SoloNick")); + + // Parenthesized nickname (real) + net.minecraft.network.chat.Component parenMsg = net.minecraft.network.chat.Component.literal("ParenNick (ParenReal): Hello party!"); + PlayerNameResolver.observeMessage(parenMsg); + assertEquals(Optional.of("ParenReal"), PlayerNameResolver.resolveKnown("ParenNick")); + } + + @Test + void resolveKnownMatchesTruncatedNicknames() { + PlayerNameResolver.recordAlias("get it twisted", "TruncatedUser"); + // Scoreboard truncation cases + assertEquals(Optional.of("TruncatedUser"), PlayerNameResolver.resolveKnown("get it t")); + assertEquals(Optional.of("TruncatedUser"), PlayerNameResolver.resolveKnown("get it")); + assertEquals(Optional.of("TruncatedUser"), PlayerNameResolver.resolveKnown("getitt")); + assertEquals("TruncatedUser", PlayerNameResolver.canonicalize("get it t")); + assertEquals("TruncatedUser", PlayerNameResolver.canonicalize("get it")); + } + + @Test + void learnFromTextIgnoresUnknownWordsAndLearnsKnownPlayer() { + // Conversational chat phrases after player names must not enter the cache + PlayerNameResolver.learnFromText("[Guild] Someone: we need tank/healer for raid or good/evil"); + assertEquals(Optional.empty(), PlayerNameResolver.resolveKnown("healer")); + assertEquals(Optional.empty(), PlayerNameResolver.resolveKnown("tank")); + assertEquals(Optional.empty(), PlayerNameResolver.resolveKnown("evil")); + + // When a player is known, the pair is recorded + PlayerNameResolver.recordAlias("knownUser", "KnownUser"); + PlayerNameResolver.learnFromText("KnownUser/NickBuddy joined!"); + assertEquals(Optional.of("KnownUser"), PlayerNameResolver.resolveKnown("NickBuddy")); + } } diff --git a/test/tel/eden/mod/chat/RaidCompletionParserTest.java b/test/tel/eden/mod/chat/RaidCompletionParserTest.java index 0bd44ae..cd28d00 100644 --- a/test/tel/eden/mod/chat/RaidCompletionParserTest.java +++ b/test/tel/eden/mod/chat/RaidCompletionParserTest.java @@ -85,6 +85,33 @@ void rejectsUnrelatedMessagesAndNull() { assertTrue(RaidCompletionParser.parse(null).isEmpty()); } + @Test + void rejectsShouts() { + Component shout = Component.literal("[WC1] Player shouts: Player1 finished The Wartorn Palace and claimed 2048x Emeralds"); + assertTrue(RaidCompletionParser.parse(shout).isEmpty()); + } + + @Test + void rejectsPartyChat() { + Component party = Component.literal(" Cartiye/pusheen: Player1 finished The Wartorn Palace and claimed 2048x Emeralds"); + assertTrue(RaidCompletionParser.parse(party).isEmpty()); + } + + @Test + void rejectsPlayerAuthoredChatSpoofsWithColons() { + Component guildChat = Component.literal("Troll: Player1 finished The Wartorn Palace and claimed 2048x Emeralds"); + assertTrue(RaidCompletionParser.parse(guildChat).isEmpty()); + + Component dmChat = Component.literal("Troll whispers: Player1 finished The Wartorn Palace and claimed 2048x Emeralds"); + assertTrue(RaidCompletionParser.parse(dmChat).isEmpty()); + } + + @Test + void rejectsPartiesWithMoreThanFourPlayers() { + Component fivePlayer = Component.literal("P1, P2, P3, P4, and P5 finished The Wartorn Palace and claimed 2048x Emeralds"); + assertTrue(RaidCompletionParser.parse(fivePlayer).isEmpty()); + } + @Test void isRaidCandidateGatesOnTheFinishedKeyword() { assertTrue(RaidCompletionParser.isRaidCandidate(Component.literal("X finished Y"))); diff --git a/test/tel/eden/mod/chat/RaidReadyTrackerTest.java b/test/tel/eden/mod/chat/RaidReadyTrackerTest.java new file mode 100644 index 0000000..ca15c1e --- /dev/null +++ b/test/tel/eden/mod/chat/RaidReadyTrackerTest.java @@ -0,0 +1,147 @@ +package tel.eden.mod.chat; + +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.assertTrue; + +import java.util.List; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class RaidReadyTrackerTest { + + @BeforeEach + void setUp() { + RaidReadyTracker.reset(); + } + + @Test + void extractsRealNameFromVariousNickFormats() { + Component dummy = Component.empty(); + + // RealIGN/nickname format + assertEquals("Tawnyy", RaidReadyTracker.extractRealName(dummy, "Tawnyy/20")); + assertEquals("Zasper0W", RaidReadyTracker.extractRealName(dummy, "Zasper0W/riptide guy")); + + // Plain IGN + assertEquals("YoureMomGood", RaidReadyTracker.extractRealName(dummy, "YoureMomGood")); + + // IGN(nickname) + assertEquals("PlayerOne", RaidReadyTracker.extractRealName(dummy, "PlayerOne(MyNickname)")); + + // nickname(IGN) where nickname is invalid IGN (e.g. 20) + assertEquals("RealPlayer", RaidReadyTracker.extractRealName(dummy, "20(RealPlayer)")); + + // nickname(IGN) where alias is known in PlayerNameResolver + PlayerNameResolver.recordAlias("SomeNick", "RealPlayer"); + assertEquals("RealPlayer", RaidReadyTracker.extractRealName(dummy, "SomeNick(RealPlayer)")); + } + + @Test + void tracksReadyAndUnreadyMessages() { + RaidReadyTracker.onSystemChat(Component.literal("YoureMomGood is ready!")); + assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("youremomgood")); + + RaidReadyTracker.onSystemChat(Component.literal("Tawnyy/20 is ready!")); + assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("tawnyy")); + + RaidReadyTracker.onSystemChat(Component.literal("Zasper0W/riptide guy is ready!")); + assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("zasper0w")); + + RaidReadyTracker.onSystemChat(Component.literal("Tawnyy/20 is no longer ready!")); + assertFalse(RaidReadyTracker.readyPlayersForTesting().contains("tawnyy")); + assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("youremomgood")); + assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("zasper0w")); + } + + @Test + void resetsOnNewRaidPromptAndMarksStarterReady() { + RaidReadyTracker.onSystemChat(Component.literal("YoureMomGood is ready!")); + RaidReadyTracker.onSystemChat(Component.literal("Tawnyy/20 is ready!")); + assertEquals(2, RaidReadyTracker.readyPlayersForTesting().size()); + + RaidReadyTracker.onSystemChat(Component.literal("Bynt would like to start Orphion's Nexus of Light!")); + assertEquals(1, RaidReadyTracker.readyPlayersForTesting().size()); + assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("bynt")); + + // Wynncraft starter with nickname: PlayerLeader/PlayerNick + RaidReadyTracker.onSystemChat(Component.literal(" PlayerLeader/PlayerNick would like to start The Nameless Anomaly!")); + assertEquals(1, RaidReadyTracker.readyPlayersForTesting().size()); + assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("playerleader")); + } + + @Test + void parsesPawnComponentColorsCorrectly() { + // 2 ready (green), 1 unready (gray), 1 empty (dark gray) + Component component = Component.empty().append(Component.literal("- Players: ")).append(Component.literal("♙").withStyle(ChatFormatting.GREEN)).append(Component.literal("♙").withStyle(ChatFormatting.GREEN)).append(Component.literal("♙").withStyle(ChatFormatting.GRAY)).append(Component.literal("♙").withStyle(ChatFormatting.DARK_GRAY)); + + RaidReadyTracker.PawnStatus status = RaidReadyTracker.parsePawnComponent(component); + assertNotNull(status); + assertEquals(2, status.ready()); + assertEquals(1, status.unready()); + assertEquals(1, status.empty()); + assertEquals(3, status.partySize()); + assertEquals(4, status.totalPieces()); + } + + @Test + void parsesPawnComponentLegacyColorsCorrectly() { + // 3 ready (green), 1 unready (gray) via legacy formatting codes + Component component = Component.literal("- Players: §a♙§a♙§a♙§7♙"); + + RaidReadyTracker.PawnStatus status = RaidReadyTracker.parsePawnComponent(component); + assertNotNull(status); + assertEquals(3, status.ready()); + assertEquals(1, status.unready()); + assertEquals(0, status.empty()); + assertEquals(4, status.partySize()); + } + + @Test + void parsesWynncraftPawnGlyphComponent() { + // Exact Wynncraft format: 1 ready, 1 unready, 2 empty using \uE085 + Component component = Component.empty().append(Component.literal("- Players: ")).append(Component.literal("\uE085").withStyle(ChatFormatting.GREEN)).append(Component.literal("\uE085").withStyle(ChatFormatting.GRAY)).append(Component.literal("\uE085").withStyle(ChatFormatting.DARK_GRAY)).append(Component.literal("\uE085").withStyle(ChatFormatting.DARK_GRAY)); + + RaidReadyTracker.PawnStatus status = RaidReadyTracker.parsePawnComponent(component); + assertNotNull(status); + assertEquals(1, status.ready()); + assertEquals(1, status.unready()); + assertEquals(2, status.empty()); + assertEquals(2, status.partySize()); + assertEquals(4, status.totalPieces()); + } + + @Test + void parsesWynncraftRawScoreboardLine() { + // Exact Wynncraft line from user logs: 1 ready (§a), 1 unready (§7), 2 empty (§8) + Component component = Component.literal("§e- §7Players: §a\uE085§7\uE085§8\uE085\uE085"); + + RaidReadyTracker.PawnStatus status = RaidReadyTracker.parsePawnComponent(component); + assertNotNull(status); + assertEquals(1, status.ready()); + assertEquals(1, status.unready()); + assertEquals(2, status.empty()); + assertEquals(2, status.partySize()); + assertEquals(4, status.totalPieces()); + } + + @Test + void debugStateReturnsUsefulDiagnostics() { + List lines = RaidReadyTracker.debugState(); + assertFalse(lines.isEmpty()); + assertTrue(lines.get(0).contains("Raid Ready Tracker")); + } + + @Test + void formatsWaitDurationCorrectly() { + assertEquals("30 seconds", RaidReadyTracker.formatWaitDuration(30)); + assertEquals("1 minute", RaidReadyTracker.formatWaitDuration(60)); + assertEquals("2 minutes", RaidReadyTracker.formatWaitDuration(120)); + assertEquals("1 minute 30 seconds", RaidReadyTracker.formatWaitDuration(90)); + assertEquals("2 minutes 15 seconds", RaidReadyTracker.formatWaitDuration(135)); + assertEquals("1 second", RaidReadyTracker.formatWaitDuration(1)); + } +} diff --git a/test/tel/eden/mod/party/PartyHealthBarRendererTest.java b/test/tel/eden/mod/party/PartyHealthBarRendererTest.java new file mode 100644 index 0000000..cfa6153 --- /dev/null +++ b/test/tel/eden/mod/party/PartyHealthBarRendererTest.java @@ -0,0 +1,56 @@ +package tel.eden.mod.party; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +public class PartyHealthBarRendererTest { + + @Test + public void disabledSettingReturnsOne() { + // Even at 100 blocks away, if slider is 0 (off), scale is 1.0 + float scale = PartyHealthBarRenderer.computeDistanceScale(100.0 * 100.0, 0); + assertEquals(1.0f, scale, 0.001f); + } + + @Test + public void closeDistanceReturnsOne() { + // Within 20 blocks, scale is always 1.0 + float scaleAt5m = PartyHealthBarRenderer.computeDistanceScale(5.0 * 5.0, 100); + float scaleAt20m = PartyHealthBarRenderer.computeDistanceScale(20.0 * 20.0, 100); + assertEquals(1.0f, scaleAt5m, 0.001f); + assertEquals(1.0f, scaleAt20m, 0.001f); + } + + @Test + public void farDistanceScalesAtFullIntensity() { + // At 40 blocks (excess = 20), +2.5% per block -> +50% scale (1.5x) + float scaleAt40m = PartyHealthBarRenderer.computeDistanceScale(40.0 * 40.0, 100); + assertEquals(1.5f, scaleAt40m, 0.01f); + + // At 60 blocks (excess = 40), +2.5% per block -> +100% scale (2.0x) + float scaleAt60m = PartyHealthBarRenderer.computeDistanceScale(60.0 * 60.0, 100); + assertEquals(2.0f, scaleAt60m, 0.01f); + } + + @Test + public void halfIntensityHalvesTheBoost() { + // At 60 blocks (excess = 40) with 50% slider -> +50% scale (1.5x) + float scaleAt60m = PartyHealthBarRenderer.computeDistanceScale(60.0 * 60.0, 50); + assertEquals(1.5f, scaleAt60m, 0.01f); + } + + @Test + public void doubleIntensityDoublesTheBoost() { + // At 60 blocks (excess = 40) with 200% slider -> +200% scale (3.0x) + float scaleAt60m = PartyHealthBarRenderer.computeDistanceScale(60.0 * 60.0, 200); + assertEquals(3.0f, scaleAt60m, 0.01f); + } + + @Test + public void extremeDistanceIsCapped() { + // At 200 blocks, max boost is capped at +2.5x -> 3.5x total + float scaleAt200m = PartyHealthBarRenderer.computeDistanceScale(200.0 * 200.0, 100); + assertEquals(3.5f, scaleAt200m, 0.01f); + } +} diff --git a/test/tel/eden/mod/party/PartyHealthTrackerTest.java b/test/tel/eden/mod/party/PartyHealthTrackerTest.java new file mode 100644 index 0000000..9f83d46 --- /dev/null +++ b/test/tel/eden/mod/party/PartyHealthTrackerTest.java @@ -0,0 +1,396 @@ +package tel.eden.mod.party; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.UUID; +import net.minecraft.network.chat.Component; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PartyHealthTrackerTest { + @BeforeEach + void setUp() { + PartyHealthTracker.reset(); + } + + @Test + void rejectsLinesOutsidePartySection() { + Component comp = Component.literal("12450 PlayerOne [106]"); + assertNull(PartyHealthTracker.parsePartyLine(comp, false)); + } + + @Test + void parsesOnlineMemberLine() { + Component comp = Component.literal("12450 PlayerOne [106]"); + PartyHealthTracker.ParsedPartyLine parsed = PartyHealthTracker.parsePartyLine(comp, true); + + assertNotNull(parsed); + assertEquals(12450, parsed.hp()); + assertEquals("PlayerOne", parsed.nickname()); + assertEquals(106, parsed.level()); + assertTrue(parsed.online()); + } + + @Test + void parsesOfflineMemberLine() { + Component comp = Component.literal("- PlayerTwo"); + PartyHealthTracker.ParsedPartyLine parsed = PartyHealthTracker.parsePartyLine(comp, true); + + assertNotNull(parsed); + assertEquals(0, parsed.hp()); + assertEquals("PlayerTwo", parsed.nickname()); + assertFalse(parsed.online()); + } + + @Test + void parsesMemberWithSpacesAndFormatting() { + Component comp = Component.literal(" 9800 PlayerThree [105] "); + PartyHealthTracker.ParsedPartyLine parsed = PartyHealthTracker.parsePartyLine(comp, true); + + assertNotNull(parsed); + assertEquals(9800, parsed.hp()); + assertEquals("PlayerThree", parsed.nickname()); + assertEquals(105, parsed.level()); + assertTrue(parsed.online()); + } + + @Test + void healthPercentClampsBetweenZeroAndOne() { + UUID uuid = UUID.randomUUID(); + PartyHealthTracker.PlayerHealthData health = new PartyHealthTracker.PlayerHealthData(uuid, "Player", 5000, 10000, 0.5f, false, 0); + + assertEquals(0.5f, health.percent(), 0.001f); + assertFalse(health.overMax()); + } + + @Test + void parsesWynncraftScoreboardLinesFromLiveDump() { + Component memberA = Component.literal("- [||17189||] PlayerA [120]"); + PartyHealthTracker.ParsedPartyLine parsedA = PartyHealthTracker.parsePartyLine(memberA, true); + assertNotNull(parsedA); + assertEquals(17189, parsedA.hp()); + assertEquals("PlayerA", parsedA.nickname()); + assertEquals(120, parsedA.level()); + assertTrue(parsedA.online()); + + Component memberB = Component.literal("- [||8725||] PlayerB [120]"); + PartyHealthTracker.ParsedPartyLine parsedB = PartyHealthTracker.parsePartyLine(memberB, true); + assertNotNull(parsedB); + assertEquals(8725, parsedB.hp()); + assertEquals("PlayerB", parsedB.nickname()); + assertEquals(120, parsedB.level()); + assertTrue(parsedB.online()); + } + + @Test + void parsesLegacyColorCodesInScoreboardLine() { + Component colored = Component.literal("- §c[§4||18630||] §fmeep §7[120]"); + PartyHealthTracker.ParsedPartyLine parsed = PartyHealthTracker.parsePartyLine(colored, true); + assertNotNull(parsed); + assertEquals(18630, parsed.hp()); + assertEquals("meep", parsed.nickname()); + assertEquals(120, parsed.level()); + assertTrue(parsed.online()); + assertTrue(parsed.fullHealthBar()); + } + + @Test + void rejectsNonPartyScoreboardLines() { + // Lines from dump that are not party members + assertNull(PartyHealthTracker.parsePartyLine(Component.literal("ÀÀ"), true)); + assertNull(PartyHealthTracker.parsePartyLine(Component.literal("Objectives:"), true)); + assertNull(PartyHealthTracker.parsePartyLine(Component.literal("- Finish Quests: 0/2"), true)); + assertNull(PartyHealthTracker.parsePartyLine(Component.literal("- Find Discoveries: 1/5"), true)); + assertNull(PartyHealthTracker.parsePartyLine(Component.literal("- Win Dungeons: 0/1"), true)); + assertNull(PartyHealthTracker.parsePartyLine(Component.literal("Guild Obj: 22/30"), true)); + assertNull(PartyHealthTracker.parsePartyLine(Component.literal("- Gather Fish: 0/200"), true)); + } + + @Test + void learnsAliasesFromSlashChatFormat() { + tel.eden.mod.chat.PlayerNameResolver.learnFromText("PlayerOne/NickOne is ready!"); + tel.eden.mod.chat.PlayerNameResolver.learnFromText("You need to wait 1 minute before joining the queue because OtherPlayer/PlayerA denied too many matches recently"); + tel.eden.mod.chat.PlayerNameResolver.learnFromText("LeaderPlayer/LeaderNick is visiting this island. Say hi!"); + + assertEquals("PlayerOne", tel.eden.mod.chat.PlayerNameResolver.resolveKnown("NickOne").orElse(null)); + assertEquals("OtherPlayer", tel.eden.mod.chat.PlayerNameResolver.resolveKnown("PlayerA").orElse(null)); + assertEquals("LeaderPlayer", tel.eden.mod.chat.PlayerNameResolver.resolveKnown("LeaderNick").orElse(null)); + } + + @Test + void ignoresUrlsAndDomainPathsInLearnFromText() { + tel.eden.mod.chat.PlayerNameResolver.learnFromText("Check out https://wynncraft.com/stats for info!"); + tel.eden.mod.chat.PlayerNameResolver.learnFromText("Visit github.com/repo or discord.gg/eden"); + + assertFalse(tel.eden.mod.chat.PlayerNameResolver.resolveKnown("stats").isPresent()); + assertFalse(tel.eden.mod.chat.PlayerNameResolver.resolveKnown("repo").isPresent()); + assertFalse(tel.eden.mod.chat.PlayerNameResolver.resolveKnown("eden").isPresent()); + } + + @Test + void matchesCandidateRosterWithLocalPlayerLast() { + // Scoreboard rows: + // Row 0: LeaderNick (LeaderPlayer) + // Row 1: LocalNick (LocalPlayer, local) + PartyHealthTracker.ParsedPartyLine leaderRow = new PartyHealthTracker.ParsedPartyLine(18630, "LeaderNick", 120, true, false, 1.0f); + PartyHealthTracker.ParsedPartyLine localRow = new PartyHealthTracker.ParsedPartyLine(1991, "LocalNick", 121, true, false, 0.15f); + java.util.List rows = java.util.List.of(leaderRow, localRow); + + // Record local player alias + tel.eden.mod.chat.PlayerNameResolver.recordAlias("LocalNick", "LocalPlayer"); + + // Wynntils returns only the other party members: ["LeaderPlayer"] + java.util.List wynnMembers = java.util.List.of("LeaderPlayer"); + java.util.List matched = PartyHealthTracker.matchCandidateRoster(rows, wynnMembers, "LocalPlayer"); + + assertEquals(2, matched.size()); + assertEquals("LeaderPlayer", matched.get(0)); + assertEquals("LocalPlayer", matched.get(1)); + } + + @Test + void calculatesLowHealthWithoutLockingMaxHp() { + PartyHealthTracker.MaxHpState state = new PartyHealthTracker.MaxHpState(); + PartyHealthTracker.HealthCalculation calc = PartyHealthTracker.calculateHealth(state, 1056, 120, false, 0.0f, 1000L); + + // Level 120 baseline is 50 + 120 * 115 = 13850 + assertEquals(13850, calc.maxHp()); + assertEquals(1056f / 13850f, calc.percent(), 0.001f); + assertTrue(calc.percent() < 0.10f); + assertFalse(calc.overMax()); + assertEquals(0, state.stableMaxHp); + } + + @Test + void detectsHealthDeficitFromStyledScoreboardRow() { + net.minecraft.network.chat.MutableComponent comp = Component.empty().append(Component.literal("- [").withStyle(net.minecraft.ChatFormatting.DARK_GRAY)).append(Component.literal("||").withStyle(net.minecraft.ChatFormatting.DARK_GRAY)).append(Component.literal("1056").withStyle(net.minecraft.ChatFormatting.DARK_GRAY)).append(Component.literal("||").withStyle(net.minecraft.ChatFormatting.DARK_GRAY)).append(Component.literal("] SamplePlayer [120]").withStyle(net.minecraft.ChatFormatting.WHITE)); + + PartyHealthTracker.ParsedPartyLine parsed = PartyHealthTracker.parsePartyLine(comp, true); + assertNotNull(parsed); + assertEquals(1056, parsed.hp()); + assertEquals("SamplePlayer", parsed.nickname()); + assertEquals(120, parsed.level()); + assertFalse(parsed.fullHealthBar()); + assertEquals(0.0f, parsed.visualFraction(), 0.001f); + } + + @Test + void calculatesOverPercentProperly() { + // Normal HP (no overhealth) + assertEquals(0.0f, PartyHealthTracker.PlayerHealthData.calculateOverPercent(8000, 8000, false), 0.001f); + assertEquals(0.0f, PartyHealthTracker.PlayerHealthData.calculateOverPercent(4000, 8000, false), 0.001f); + + // 50% overhealth (12000 / 8000) + assertEquals(0.50f, PartyHealthTracker.PlayerHealthData.calculateOverPercent(12000, 8000, true), 0.001f); + + // 100% overhealth (16000 / 8000) + assertEquals(1.00f, PartyHealthTracker.PlayerHealthData.calculateOverPercent(16000, 8000, true), 0.001f); + + // Greater than 2x max HP (18000 / 8000 capped at 100%) + assertEquals(1.00f, PartyHealthTracker.PlayerHealthData.calculateOverPercent(18000, 8000, true), 0.001f); + } + + @Test + void detectsTruncatedLocalPlayerRow() { + PartyHealthTracker.ParsedPartyLine truncatedRow = new PartyHealthTracker.ParsedPartyLine(9222, "LocalPlayerN", 121, true, true, 1.0f); + assertTrue(PartyHealthTracker.isLocalRow(truncatedRow, "LocalPlayerName")); + assertFalse(PartyHealthTracker.isLocalRow(truncatedRow, "OtherPlayer")); + } + + @Test + void matchesCandidateRosterWithDuplicatePartyLeaderAndTruncatedNicknames() { + // Scoreboard rows: + // Row 0: LeaderNick + // Row 1: SecondNick + // Row 2: LocalNick + // Row 3: get it t + PartyHealthTracker.ParsedPartyLine r0 = new PartyHealthTracker.ParsedPartyLine(20921, "LeaderNick", 121, true, true, 1.0f); + PartyHealthTracker.ParsedPartyLine r1 = new PartyHealthTracker.ParsedPartyLine(8127, "SecondNick", 120, true, true, 1.0f); + PartyHealthTracker.ParsedPartyLine r2 = new PartyHealthTracker.ParsedPartyLine(10183, "LocalNick", 121, true, true, 1.0f); + PartyHealthTracker.ParsedPartyLine r3 = new PartyHealthTracker.ParsedPartyLine(18454, "get it t", 120, true, true, 1.0f); + java.util.List rows = java.util.List.of(r0, r1, r2, r3); + + tel.eden.mod.chat.PlayerNameResolver.recordAlias("LeaderNick", "LeaderUser"); + tel.eden.mod.chat.PlayerNameResolver.recordAlias("SecondNick", "MemberTwo"); + tel.eden.mod.chat.PlayerNameResolver.recordAlias("LocalNick", "LocalUser"); + tel.eden.mod.chat.PlayerNameResolver.recordAlias("get it twisted", "MemberFour"); + + // Wynntils returns 5 elements with duplicate leader at end + java.util.List wynnMembers = java.util.List.of("LeaderUser", "MemberTwo", "LocalUser", "MemberFour", "LeaderUser"); + java.util.List matched = PartyHealthTracker.matchCandidateRoster(rows, wynnMembers, "LocalUser"); + + assertEquals(4, matched.size()); + assertEquals("LeaderUser", matched.get(0)); + assertEquals("MemberTwo", matched.get(1)); + assertEquals("LocalUser", matched.get(2)); + assertEquals("MemberFour", matched.get(3)); + } + + @Test + void detectsSharedNicknamesAcrossRows() { + PartyHealthTracker.ParsedPartyLine r0 = new PartyHealthTracker.ParsedPartyLine(31479, "UniquePlayer", 120, true, true, 1.0f); + PartyHealthTracker.ParsedPartyLine r1 = new PartyHealthTracker.ParsedPartyLine(8793, "get it t", 120, true, true, 1.0f); + PartyHealthTracker.ParsedPartyLine r2 = new PartyHealthTracker.ParsedPartyLine(59600, "get it t", 120, true, true, 1.0f); + PartyHealthTracker.ParsedPartyLine r3 = new PartyHealthTracker.ParsedPartyLine(12450, "get it t", 120, true, true, 1.0f); + java.util.List rows = java.util.List.of(r0, r1, r2, r3); + + assertFalse(PartyHealthTracker.isSharedNickname(rows, "UniquePlayer")); + assertTrue(PartyHealthTracker.isSharedNickname(rows, "get it t")); + assertTrue(PartyHealthTracker.isSharedNickname(rows, "get it")); + } + + @Test + void matchesCandidateRosterWithTripleSharedNicknameAndTrumpetOverhealth() { + // Scoreboard rows: + // Row 0: LocalUser (31479 HP) + // Row 1: get it t (MemberTwo, 8793 HP) + // Row 2: get it t (TrumpetUser, 59600 HP overhealth) + // Row 3: get it t (MemberFour, 12450 HP) + PartyHealthTracker.ParsedPartyLine r0 = new PartyHealthTracker.ParsedPartyLine(31479, "LocalUser", 120, true, true, 1.0f); + PartyHealthTracker.ParsedPartyLine r1 = new PartyHealthTracker.ParsedPartyLine(8793, "get it t", 120, true, true, 1.0f); + PartyHealthTracker.ParsedPartyLine r2 = new PartyHealthTracker.ParsedPartyLine(59600, "get it t", 120, true, true, 1.0f); + PartyHealthTracker.ParsedPartyLine r3 = new PartyHealthTracker.ParsedPartyLine(12450, "get it t", 120, true, true, 1.0f); + java.util.List rows = java.util.List.of(r0, r1, r2, r3); + + java.util.List wynnMembers = java.util.List.of("LocalUser", "MemberTwo", "TrumpetUser", "MemberFour"); + java.util.List matched = PartyHealthTracker.matchCandidateRoster(rows, wynnMembers, "LocalUser"); + + assertEquals(4, matched.size()); + assertEquals("LocalUser", matched.get(0)); + assertEquals("MemberTwo", matched.get(1)); + assertEquals("TrumpetUser", matched.get(2)); + assertEquals("MemberFour", matched.get(3)); + } + + @Test + void resolvesDistinctPartySlotsForSharedNicknamesUsingRealAccountNames() { + java.util.List partyMembers = java.util.List.of("LeaderUser", "MemberTwo", "MemberThree", "MemberFour"); + + // Suppose an alias exists that maps "get it t" to "LeaderUser" + tel.eden.mod.chat.PlayerNameResolver.recordAlias("get it t", "LeaderUser"); + + // When real account names are known: + int slotLeader = PartyHealthTracker.resolvePartySlot("LeaderUser", "get it t", partyMembers, 0, true); + int slotTwo = PartyHealthTracker.resolvePartySlot("MemberTwo", "get it t", partyMembers, 1, true); + int slotThree = PartyHealthTracker.resolvePartySlot("MemberThree", "get it t", partyMembers, 2, true); + int slotFour = PartyHealthTracker.resolvePartySlot("MemberFour", "get it t", partyMembers, 3, true); + + assertEquals(0, slotLeader); + assertEquals(1, slotTwo); + assertEquals(2, slotThree); + assertEquals(3, slotFour); + } + + @Test + void neverCollapsesSharedNicknameToSlotZeroWhenRealNameUnknown() { + java.util.List partyMembers = java.util.List.of("LeaderUser", "MemberTwo", "MemberThree", "MemberFour"); + + // Alias poisoned to leader + tel.eden.mod.chat.PlayerNameResolver.recordAlias("20", "LeaderUser"); + + // Non-leader members with shared nickname and null real name must fall back to their row index, not leader's slot 0 + int slot1 = PartyHealthTracker.resolvePartySlot(null, "20", partyMembers, 1, true); + int slot2 = PartyHealthTracker.resolvePartySlot(null, "20", partyMembers, 2, true); + int slot3 = PartyHealthTracker.resolvePartySlot(null, "20", partyMembers, 3, true); + + assertEquals(1, slot1); + assertEquals(2, slot2); + assertEquals(3, slot3); + } + + @Test + void resolvesUniqueNicknameUsingAliasWhenUnique() { + java.util.List partyMembers = java.util.List.of("LeaderUser", "MemberTwo", "MemberThree"); + tel.eden.mod.chat.PlayerNameResolver.recordAlias("CoolNick", "MemberTwo"); + + // For unique nickname, alias lookup is allowed when real name is unknown + int slot = PartyHealthTracker.resolvePartySlot(null, "CoolNick", partyMembers, 99, false); + assertEquals(1, slot); + } + + @Test + void adaptsMaxHpUpwardAfterTwoSecondsOfFullBar() { + PartyHealthTracker.MaxHpState state = new PartyHealthTracker.MaxHpState(); + // Initial full health at 10000 + PartyHealthTracker.calculateHealth(state, 10000, 120, true, 1.0f, 1000L); + assertEquals(10000, state.stableMaxHp); + + // Swap to higher HP build: 15000 HP, full health + PartyHealthTracker.calculateHealth(state, 15000, 120, true, 1.0f, 2000L); + assertEquals(10000, state.stableMaxHp); // not yet 2 seconds + + PartyHealthTracker.calculateHealth(state, 15000, 120, true, 1.0f, 3500L); + assertEquals(10000, state.stableMaxHp); // 1.5 seconds, still not 2s + + PartyHealthTracker.calculateHealth(state, 15000, 120, true, 1.0f, 4100L); + assertEquals(15000, state.stableMaxHp); // >2 seconds, adapted to 15000! + } + + @Test + void adaptsMaxHpDownwardOnlyAfterTwentySecondsOfFullBar() { + PartyHealthTracker.MaxHpState state = new PartyHealthTracker.MaxHpState(); + // Initial full health at 15000 + PartyHealthTracker.calculateHealth(state, 15000, 120, true, 1.0f, 1000L); + assertEquals(15000, state.stableMaxHp); + + // Swap to lower HP build: 10000 HP, full health + PartyHealthTracker.calculateHealth(state, 10000, 120, true, 1.0f, 2000L); + assertEquals(15000, state.stableMaxHp); + + // At 5 seconds (t = 7000): should still be 15000 + PartyHealthTracker.calculateHealth(state, 10000, 120, true, 1.0f, 7000L); + assertEquals(15000, state.stableMaxHp); + + // At 19 seconds (t = 21000): should still be 15000 + PartyHealthTracker.calculateHealth(state, 10000, 120, true, 1.0f, 21000L); + assertEquals(15000, state.stableMaxHp); + + // At 20.5 seconds (t = 22500): exactly adapted to 10000! + PartyHealthTracker.calculateHealth(state, 10000, 120, true, 1.0f, 22500L); + assertEquals(10000, state.stableMaxHp); + } + + @Test + void resetsDownwardTimerIfDamageTakenBeforeTwentySeconds() { + PartyHealthTracker.MaxHpState state = new PartyHealthTracker.MaxHpState(); + PartyHealthTracker.calculateHealth(state, 15000, 120, true, 1.0f, 1000L); + assertEquals(15000, state.stableMaxHp); + + // Lower HP at t = 2000 + PartyHealthTracker.calculateHealth(state, 10000, 120, true, 1.0f, 2000L); + assertEquals(15000, state.stableMaxHp); + + // Sustained for 15 seconds until t = 17000 + PartyHealthTracker.calculateHealth(state, 10000, 120, true, 1.0f, 17000L); + assertEquals(15000, state.stableMaxHp); + + // Takes damage at t = 18000: bar not full! + PartyHealthTracker.calculateHealth(state, 8000, 120, false, 0.8f, 18000L); + assertEquals(15000, state.stableMaxHp); + + // Heals back to full at t = 19000: timer resets + PartyHealthTracker.calculateHealth(state, 10000, 120, true, 1.0f, 19000L); + assertEquals(15000, state.stableMaxHp); + + // At t = 25000 (only 6s since re-full): still 15000 + PartyHealthTracker.calculateHealth(state, 10000, 120, true, 1.0f, 25000L); + assertEquals(15000, state.stableMaxHp); + + // At t = 39500 (20.5s since re-full): now adapts downward + PartyHealthTracker.calculateHealth(state, 10000, 120, true, 1.0f, 39500L); + assertEquals(10000, state.stableMaxHp); + } + + @Test + void fallsBackDeterministicallyWhenPartyMembersListEmpty() { + int slot = PartyHealthTracker.resolvePartySlot("RealName", "Nick", java.util.Collections.emptyList(), 2, false); + assertEquals(2, slot); + + int slotShared = PartyHealthTracker.resolvePartySlot("RealName", "20", java.util.Collections.emptyList(), 3, true); + assertEquals(3, slotShared); + } +} diff --git a/test/tel/eden/mod/party/PartyHighlightManagerTest.java b/test/tel/eden/mod/party/PartyHighlightManagerTest.java new file mode 100644 index 0000000..951324a --- /dev/null +++ b/test/tel/eden/mod/party/PartyHighlightManagerTest.java @@ -0,0 +1,108 @@ +package tel.eden.mod.party; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.List; +import org.junit.jupiter.api.Test; + +public class PartyHighlightManagerTest { + + @Test + public void paletteHasExactlyTenUniqueColors() { + List palette = PartyHighlightManager.DEFAULT_PALETTE; + assertEquals(10, palette.size(), "Palette must have exactly 10 colors for party slots 0-9"); + assertEquals(10, new HashSet<>(palette).size(), "All 10 colors must be distinct"); + } + + @Test + public void paletteMatchesUserRequestedSlots() { + List palette = PartyHighlightManager.DEFAULT_PALETTE; + assertEquals(0xFF2222, palette.get(0), "Slot 0 must be Red"); + assertEquals(0x1E56FF, palette.get(1), "Slot 1 must be Deep Royal Blue"); + assertEquals(0xFFA6D5, palette.get(2), "Slot 2 must be Light Pink"); + assertEquals(0x55FF55, palette.get(3), "Slot 3 must be Lime Green"); + assertEquals(0xFF7700, palette.get(4), "Slot 4 must be Vivid Electric Orange"); + } + + @Test + public void paletteExcludesForbiddenColors() { + for (int color : PartyHighlightManager.DEFAULT_PALETTE) { + assertNotEquals(0xFFFFFF, color, "Palette must not contain pure White"); + assertNotEquals(0x55FFFF, color, "Palette must not contain Wynncraft Guild Aqua"); + assertNotEquals(0x00FFFF, color, "Palette must not contain pure Cyan"); + assertNotEquals(0xFFFF55, color, "Palette must not contain Wynncraft Party Yellow"); + assertNotEquals(0xFFFF00, color, "Palette must not contain pure Yellow"); + + // Deconstruct RGB + int r = (color >> 16) & 0xFF; + int g = (color >> 8) & 0xFF; + int b = color & 0xFF; + + // Verify not yellowish (high red and high green together, low blue) + boolean isYellowish = (r > 200 && g > 200 && b < 100); + assertFalse(isYellowish, String.format("Color 0x%06X must not be yellowish", color)); + + // Verify not cyan/aqua (high green and high blue together, low red) + boolean isCyanish = (b > 180 && g > 180 && r < 100); + assertFalse(isCyanish, String.format("Color 0x%06X must not be cyanish/aqua", color)); + } + } + + @Test + public void colorAssignmentMatchesSlotPositions() { + List members = List.of("HostLeader", "MemberA", "MemberB", "MemberC", "MemberD"); + + // Slot 0 (Host / Leader) -> Crimson Red + Integer leaderColor = PartyHighlightManager.getColorForPlayerName("HostLeader", members, null); + assertNotNull(leaderColor); + assertEquals(PartyHighlightManager.DEFAULT_PALETTE.get(0), leaderColor); + + // Slot 1 -> Deep Royal Blue + Integer memberAColor = PartyHighlightManager.getColorForPlayerName("MemberA", members, null); + assertNotNull(memberAColor); + assertEquals(PartyHighlightManager.DEFAULT_PALETTE.get(1), memberAColor); + + // Slot 2 -> Light Pink + Integer memberBColor = PartyHighlightManager.getColorForPlayerName("MemberB", members, null); + assertNotNull(memberBColor); + assertEquals(PartyHighlightManager.DEFAULT_PALETTE.get(2), memberBColor); + + // Slot 3 -> Lime Green + Integer memberCColor = PartyHighlightManager.getColorForPlayerName("MemberC", members, null); + assertNotNull(memberCColor); + assertEquals(PartyHighlightManager.DEFAULT_PALETTE.get(3), memberCColor); + + // Slot 4 -> Vivid Electric Orange + Integer memberDColor = PartyHighlightManager.getColorForPlayerName("MemberD", members, null); + assertNotNull(memberDColor); + assertEquals(PartyHighlightManager.DEFAULT_PALETTE.get(4), memberDColor); + + // Case insensitivity + Integer caseTest = PartyHighlightManager.getColorForPlayerName("mEmBeRa", members, null); + assertEquals(memberAColor, caseTest); + + // Non-party member returns null + Integer nonMember = PartyHighlightManager.getColorForPlayerName("RandomPlayer", members, null); + assertNull(nonMember); + + // Null or empty handling + assertNull(PartyHighlightManager.getColorForPlayerName(null, members, null)); + assertNull(PartyHighlightManager.getColorForPlayerName("HostLeader", null, null)); + assertNull(PartyHighlightManager.getColorForPlayerName("HostLeader", List.of(), null)); + } + + @Test + public void wynntilsPartyBridgeFallbackSafe() { + // When Wynntils is absent or uninitialized in test environment + WynntilsPartyBridge.reset(); + assertFalse(WynntilsPartyBridge.isAvailable()); + assertFalse(WynntilsPartyBridge.isInParty()); + assertTrue(WynntilsPartyBridge.getPartyMembers().isEmpty()); + } +}