From eed02bab2721ecd20e77802fb4d427ee9b3ac28d Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Mon, 28 Sep 2026 23:50:34 +0300 Subject: [PATCH 01/10] Add party member colored outlines, RaidPartyTracker concurrency fix, v1.4.5-beta1 --- gradle.properties | 2 +- resources/edenmod.mixins.json | 1 + src/tel/eden/mod/chat/RaidPartyTracker.java | 62 +++++---- src/tel/eden/mod/config/BridgeConfig.java | 9 ++ src/tel/eden/mod/gui/BridgeConfigScreen.java | 1 + .../eden/mod/mixin/EntityOutlineMixin.java | 22 ++++ .../eden/mod/party/PartyHighlightManager.java | 74 +++++++++++ .../eden/mod/party/WynntilsPartyBridge.java | 96 ++++++++++++++ src/tel/eden/mod/war/AllianceMenuScraper.java | 2 + .../mod/party/PartyHighlightManagerTest.java | 120 ++++++++++++++++++ 10 files changed, 365 insertions(+), 24 deletions(-) create mode 100644 src/tel/eden/mod/mixin/EntityOutlineMixin.java create mode 100644 src/tel/eden/mod/party/PartyHighlightManager.java create mode 100644 src/tel/eden/mod/party/WynntilsPartyBridge.java create mode 100644 test/tel/eden/mod/party/PartyHighlightManagerTest.java diff --git a/gradle.properties b/gradle.properties index b8d5207..c95bafd 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.4.5-beta1 maven_group=tel.eden archives_base_name=edenmod modmenu_version=17.0.0 diff --git a/resources/edenmod.mixins.json b/resources/edenmod.mixins.json index 7bc536f..e4f8645 100644 --- a/resources/edenmod.mixins.json +++ b/resources/edenmod.mixins.json @@ -17,6 +17,7 @@ "ClientCommonPacketListenerMixin", "ClientPacketListenerMixin", "ConnectionScoreboardMixin", + "EntityOutlineMixin", "GroundItemVisibilityMixin", "GuiGraphicsMixin", "ItemDecorationMixin", 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/config/BridgeConfig.java b/src/tel/eden/mod/config/BridgeConfig.java index 4d85897..fc63065 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -189,6 +189,12 @@ 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; + + /** 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 +331,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<>(); } diff --git a/src/tel/eden/mod/gui/BridgeConfigScreen.java b/src/tel/eden/mod/gui/BridgeConfigScreen.java index c6c6a63..f1ba57a 100644 --- a/src/tel/eden/mod/gui/BridgeConfigScreen.java +++ b/src/tel/eden/mod/gui/BridgeConfigScreen.java @@ -128,6 +128,7 @@ protected void init() { 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)), () -> { }); + addToggleRow("Party member outlines", () -> config.partyHighlightEnabled, v -> config.partyHighlightEnabled = v, "On", "Off", true); // ------------------------------------------------------------------------ this.addRenderableWidget(Button.builder(Component.literal("Done"), button -> onClose()).bounds(cx, this.height - 30, cw, 20).build()); 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/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..c2ef025 --- /dev/null +++ b/src/tel/eden/mod/party/WynntilsPartyBridge.java @@ -0,0 +1,96 @@ +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"; + + 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 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; + } + } + + @SuppressWarnings("unchecked") + public static List getPartyMembers() { + if (!isAvailable() || partyModelInstance == null || getPartyMembersMethod == null) { + return Collections.emptyList(); + } + try { + Object res = getPartyMembersMethod.invoke(partyModelInstance); + if (res instanceof List list) { + return (List) list; + } + } catch (Exception ignored) { + } + return Collections.emptyList(); + } + + 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"); + } + } 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; + } +} 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/party/PartyHighlightManagerTest.java b/test/tel/eden/mod/party/PartyHighlightManagerTest.java new file mode 100644 index 0000000..7cba7e0 --- /dev/null +++ b/test/tel/eden/mod/party/PartyHighlightManagerTest.java @@ -0,0 +1,120 @@ +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 outlineThicknessSanitization() { + int defaultThickness = 2; + assertEquals(2, defaultThickness, "Default outline thickness should be 2px"); + + int thicknessAbove = Math.max(1, Math.min(6, 10)); + assertEquals(6, thicknessAbove, "Thickness above 6 should clamp to 6"); + + int thicknessBelow = Math.max(1, Math.min(6, 0)); + assertEquals(1, thicknessBelow, "Thickness below 1 should clamp to 1"); + } + + @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()); + } +} From 3939f26cd8679996b272a9ea5d9f9c84f6edcaea Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:30:10 +0300 Subject: [PATCH 02/10] Add custom chat emojis and type-safe Wynntils party member extraction --- .../assets/edenmod/textures/catplush/cry.png | Bin 0 -> 17180 bytes .../edenmod/textures/catplush/gregplush.png | Bin 0 -> 18323 bytes .../edenmod/textures/catplush/meeplaugh.png | Bin 0 -> 20056 bytes .../edenmod/textures/catplush/orphlush.png | Bin 0 -> 24371 bytes .../edenmod/textures/catplush/tccplush.png | Bin 0 -> 17647 bytes .../edenmod/textures/catplush/wtplush.png | Bin 0 -> 24135 bytes .../eden/mod/party/WynntilsPartyBridge.java | 20 ++++++++++++++++-- 7 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 resources/assets/edenmod/textures/catplush/cry.png create mode 100644 resources/assets/edenmod/textures/catplush/gregplush.png create mode 100644 resources/assets/edenmod/textures/catplush/meeplaugh.png create mode 100644 resources/assets/edenmod/textures/catplush/orphlush.png create mode 100644 resources/assets/edenmod/textures/catplush/tccplush.png create mode 100644 resources/assets/edenmod/textures/catplush/wtplush.png diff --git a/resources/assets/edenmod/textures/catplush/cry.png b/resources/assets/edenmod/textures/catplush/cry.png new file mode 100644 index 0000000000000000000000000000000000000000..6da44727852fe632cf18dce14d89a9b8ea59b632 GIT binary patch literal 17180 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 literal 0 HcmV?d00001 diff --git a/resources/assets/edenmod/textures/catplush/gregplush.png b/resources/assets/edenmod/textures/catplush/gregplush.png new file mode 100644 index 0000000000000000000000000000000000000000..bd7aa673210880ee0d457a2c1748c7ef28002ad4 GIT binary patch literal 18323 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

e!OByJ5#*a2 zz%oog*>Hmp&pfjaCiN8{FX4Q%tv-;E39og8KyYTnOrsP^`L1%U@<1+^H99&v0&+9! zu4ONC0x3Ju3~(}n{NZN*);-inUJJph%8jZPwI#b7d5=MIqM$rnm!YH8MpUt|qT|9%A|>=-cs z1r78^iFo{xSSrZ-*-r)I%stMKF_JNSw#*UN*0dlp0BfR&D$&5L`KXv(m7TN8%ICv=AH zs&#A8vurUYijy$YCQ>mQ2Y2p4M^`6|)@Do=iZB9(uv$eqpG8n};W$;@N+y;{qHSR} z+IzYo(s49(cj%JwMIBvwrn(4F8kxZ1ho8XIoJ=_I1w%P!Y;GfSrk{U1%KG?e3^Ffj_h zTtT^**KqBf-Q6(lIE=Urm;!-R$Ok}WWL(z+ZOh^m4~c2^k{oE$SHoTjW7v+Td(z-K2X z$Gu{)NL@>0M@NSY0`H{A$$v!|Kqp#zgz`zvdvb97GJL~>h$T_XPsyJ>@@=F|3nc<) zunaUMJ0M*Lleq~jShi53dnDs=4E6Qnz&($kJ&}Qxi6hzG0yCY&#Mm%06n~He#uj1k z1dfX%yY>P{CIDJ*!vJIH43My}aQzy@Mv7=(7{DymVEPhqCUj_-G<;!Lx+aiv0ASQ( z01g2W%>1UPyN6CEq8;{5OicVWReG1o1(8anLMkvNZJ*5oj5x&^;Md{fIx;53^Odg) zHdxHUT@T!=7r*6%T6f z0Zb&D0KWz#VnECQ{16jWH$w6>C}1D8e=|ZLR!0zX5+(xW0EH4ZbuGXx@Az#bZ5ubQ zJOf|(=da=R|9iX6GFsHr1g>hjXH+O80*huobqcT%`kpQ|qs(Cq^r)qQE){RJEh}A| z%F@~=sb5e7Sc)haPrz|bXx+#25`U#H>jl0Rq1j3vmt1kBIQyzA)qCFehc~r%b}r#I z4=0LQo^EeJB9lfcoz@6n)mjY+JC5G&1*jHEIPmndFcvLBB1OzXP9GBn=@{i2Ols7` zI1Hb`a3x}%Lc#~E5ae^vfgnq#TPrMGv=A+68yO4ePMP>GfA#?^Sic%hjKOM7!zD$Y5g#Nr-U zEzO{g9I>`mgzZi6TALsfmc{`PlIx91#DwIqT#lG4kchV;D;2(Ta36j+F^Vy200rQ> zKYKqimu|$UU)K47RBA@oH)aDmQHD{Y<2cRmMg=ivm9FkCml&29owA5dHaamnfPN75 z*E(x*cD7{wzj`@!+4nV~)|Sh!5L3B)P#DeL8c(EF7xVehPG&^!%9R)yn?OfmIdbJP z^26iW?5d0m;+zXEfEiCAy=(=H)>iC0*oUU>E?ANw8Ab&gZZd*&L#_%|axikJUl(sv z+ZRs7VlqI@b&%=m#I^(bkzLt?V4x3oJ#|Qzhb_MDa%{Z%N<8w#FGKhWaqZY?^bMd2 zV@u}*=n^n$8$^Bp9URc+eU41NV!6Oto0=6Nh@A!uFjr`-+pt~(AySz(xmZbII?}hF zopb%`c&SukEoK~#-~9{SQ}3J$FHmJOLcwg6Hp;nzeu!2oC359rVAk9#71&2qIUgns zll!Bi$Sha_v$YG3qmZyNSb4@K9C&&+Fnk2#gJW2J)_F)TT7i*#0h>0Tk7su80ZlZ? z)5sMHdM1X_MoUvW;;jiZEm#AmIE90|w<63>!fuMecWaoEWi0CHM5?(1ll}d8>cRUl zIdB-)uUd)q8#beV!Mzy#<$Y)&`~c5~*^~j?5S~kra5ffpwql_FAQYqEa-*b7qF5~G zAXKpwLAnxd@G;xbKOd=+bP1S)peZ=c+kBo_ykWf>85<3A#X^wF<$?|8ohQ$~_#(A* z)e4brZ8p}fTgM;Ywv8pzX*~YOL;TJ={zJkrySZWhAEl%rlw?-mGhQpBmd`>uCAh^2 z-1z#duxe2!S~786;hUexfe8yDo}dSSpyrciM`)Tr`1mV|95kxn(C7Ag!*j3Xa- zdO1ipr*))|dI6y|ftFQEk?ib1OmO55A4Z%s{mDr@eCJ&V+z{u#;SE6dLgdUCigpqc z`67JN!qPQo;N0^sLAt4314Yo%GRtREJlVmxj2Fu}Zn2E!rVL7@5)K|b$UG^-c+5^& zAvX8{TLl)lEN<@p`7iE%ddn4;g?<>KI-bRu8`eXb+}M2a7Pj@l2Li(|z6zj+x?oUR z7-`hXtwK%=V9|Fx1|X!PtRkap0kcfw4*I>ILV?9eDcgpJUl&mmy)9xc2(rK)Z;+ua)ua zj&0cU#CDX&hSA#Gglf@+lA#`Y4NRu+v<9e}(_!K?VSos*9w;}(aMkxdvn1;tIR~-RUY+x$f0EOQZmXH@-dBsyDr}pLc&dw3X@&1Oodr~*7 z**SHT7(sFTozLvUB!CBY_2Dz0yA6)firC`i7~8uGgMS)0*MfyfZv1-XmoYA!q zerq$@7B7Km#$bc%!q2c=(s}(}g}QCVVell%#Vl5CJ`=xl(~a2k>^}Vbwl4zXS%{j1 z87g?PiUhsmAQ*V?+&6P=Eqli6q+64m6Ad?w%tz3tH``Lr|m#_URTG~30$_T{LG4z+S zm^^YAXJ32)`jQEBnsNNm%{L-r0)O}Dlh}UvFfyivaz2M$JD$Ms&L_wf2sl1co(WN_ zAx>x^qf^-S!`pHD5C0t)8beDw4I{2l$W9?{1qjEhaKn15YKmh48lJ3f6T=~?eOGy$H)UtOvnnuw!kDCzP%h0-NBObf+UN~c;P%;2Z z#b9LO=iGP>8g*4 z#}E@H5LPj`eHRAz?9{GdBQQ~`mUR3enV`M_6;NZ)xXHm|3}(#I-XEt*yi|d($@QyS z4(on`OiA)EkjH_9)-Ia;D~6f+A1DhQZI(m{rL=2I4|Cd#U*BYOtauC2WbcIN_A ziWMZo5MFiyewi|Bl8~tcGNz51Hwd@48`&Trfr~GH>*si=G7i>~fGkX6;EBf(gVY(y zULmLR0TeZZmQ0(j@k|RGS<%r+zve~de1sKX5-gk%HI5`3KJelDw8onn{+06k>R4lS znWdi&@xG7@4+)?^{R>WXdDjVJ02+ojTy>o`YY1wMT%QzFaa5qqt>)%twsXf$RVr6k z3e)TWcc`Q?Vt~5yM|a_-XUC$nX?kEL!MFOOzIl?7$JcAF5+eqrE~&K?H$-#$l}olp2X7!_95&{V4+B0V&`^@ zZhwqwP@n=|>jRcS_&*{1(19x>EZ>L-axRiUAp%sLx;;;(T}byS00N^X7f;!@Hjj}zNouWx3CBxbDLnSTY zG?KHDB4;lfls`*OAR1)7Akf-r)xj4D36x-=gbi15jMN->nI4m3f^+Srp+JtLO*1NK z>W#ZRPKHIpjH4>xqrN1=Pk&157`25R^*sNs8a1aniD&-{Vu0zocY;}^j@3fIAQ!9e zgF0B%_gUW9y>VQlE!%XR{4CJK2yEHY13}mihGA=lQOrb5*g$gvWgcSbx#u8}PGDlF z9|sQZ!NTSQLa(IPT56=uOpanDkH9c=&VFz2BB*l>9Da(UxNR?Lm_%)G9Ja6E`B8=r zNhF%^9!WFKz$h1Qx_oVJ%9Wax-gP{R@CT=lS7fz7@Gp9#-X}+lN*u78};&;6VsdxhJ4KP)|r4ZV#qP%krfaqjRYEc3Ox%};F4?J zfXvU(>DWUh(055fWfJvu)m3YS3g2 zWT3QCRhTM}8Ry62Nkv(>j8W7pT03=^v{B(Q|5@nh8DRiZzGT#c*CJD@T!nLF98NL? zGZn|!p+2OhCNy4v?^d*d=~2rT6@pO{2+q;O1xh>j;uiz`IQKm_W7Wp9kZ8}qY;OV| zH6bgtX+Qj|a6ELy4oRt5jT+T+bQ9O_XN7;MpD;00)!fC|0 z8_U6*gB8NJz2z-@e01z?NcBV8FpZdH`=lc@s$-8UHi>n}L<}T2Ee9+>mDLcMH7KKu zm{mr?C?gS8VT5E|c#u@RDHTMhVEPKCK2~Y)CO4>p2UTQ>W!(Fr5oU`w9&L1jevpVCK zK%`D~dfo+aHq`mC`uKhJuRpC0@83_(uis@%JZ9Ur$%OF9GxA&>@CJSkF#u_0q4c0t z3GP9LRfxcW@Tx%Qg4Z#5FbngS29bcD8A%R!+H4~~BWfNDM`GWuJ?QT{fbNCeI+24~ z#!@~W&3Ni3WGV{SMC#xC`+A|B#T$pzXFo0aAM<=qJ2j}#jJfP=aXzQS15Ka>UoP-soa@{+K_i@epqu%LQ|ororY735+>^>iWU%! z-%AA=O6c@0QaS`PVY7OeS&$Tox;#XNATeVd!daVg9wbd*bR3Ht|E{{Ak6iULB8F_jIlf|zu}dnfr>^= zK%l3q3(FVwV)cSWSl-d4!`10{5>$fUXwX2{*7(8v=$}7(37DP0dDMROf?ztGANYq8 ziTM9;Yt<79>GsM7gN`8)Y4(~)1EcV;_wZi1;k@;{`p6>_Pu;im^_#a`^QnZeZc#CX z!0_PvJd~0;yOOfp=(baDm~2F#361y1#M04eSB)7-HFOa&cVkaO_Y0H=b47%#ipt0& ziiZy(J2il6v4BiHrQ2`R=}&j?3g=w6!4wTk_GrwXx-wXQ1eCyH+x9KFsa&bv0{oO> zfEVGWj2_hLwX5N_G~qBwY|@Y)ew43Uu||eSAenBt6bL>H64nnh#oH3p#mVj7~geq9}1Blj{zgK5vM!tv!(x;ADX#J&u6gs~N)N}^{ih7AeJpPknzHo!eW#)uw5*le46$DXm+(QpPW1Ojc?xr@{$x8*6IB(x+!4Mc1M}JQ|d74kTy9 zK(usliX>7?*SG~Qbamk-nVdR@KL{e*Un^lZ=BKhp48vfFL_*Fj@n*Ww<;lzdFFGH9W(;NiG`sb6*b-Q|YPmYt_iVU(+cxoA z@BTftee1UVSg88ln)9thI!P^9TdUQ|vScFJZQJ%{#`qNgE~gQU#q^Wr6u6n)RB2|A zA~=>v0TWvLZ;)mPR@dS+3kk50!(sw1lL)9;6hXi000xz$ICDQIy(93&OMnd<*F|Dk={9a~-7MnCB&s^VBD8+?%u~Dh6<1!(X+VE@ z|9#=gHER{kc*5JGe!MiRy1Tottkr5?QcSD}!!W42j?u`YKVEk&`kc~7^lL2 zxo*|U3?!AMrAtjlqMq0ya6K?D0-*MMXO4O^le&Fu2|#IUiFm?bjD62@-J2>?Q-*C? zl+zdiM9fnx%hk$BEdgG1redQwV!B9z95u8~l4Kwo5WsooUnsM=Tre>)5p3MFNgX(F zfW7HWZ{p{ibB;))Q^xW&tHfw->fTD|T_d3ma6=e&JQfJk)Yb!u^7-)vyJnhCQK(a(qam4rML9lEwQdlg<~X1k-__m4 zJ>MS;T<;C05buk{W8;Qt2-~)4p;zTfMQiA^HC(r(3p55+i~f3If~le$eQMlDWm%M9 zIvvND4cTT8T$|9#1VOwU%N0!Wg;G2OV^ywjqg$;V$WP@&YirvnW%9i;<>9l?Gc?VX z3gq+&0@zZ*&*qUYl;!q&@8b{_F1zOHuO!p)dwI>01_uXe=2P$ozVD$_m<#dpDqaGPL$ixRkMUb6oJ;|suEa{d`2%Yi#pKNht$E7$O$8CT!dOUDtKr?l{h1p->PlZ5<)qFNJv{t^d?t0$y{j5Oz}v<-i4xS-Rg3C7BSQ@@1D^#wR8xyh^qDmkSpzT*-ylrUiUNdOg== zNWVUXbUw?n4A1j^R;$)7l2U$SbnuACPL6W|awIeFbQ=7V0wk)@P??ruGQ~{9k&GoY zbel9O)~~dN{^(J@drAie)-H2ALw14Mz`8JZX#jXtk00VV}(!T?^)fniz5 z7E7>_addTe8>LFwGsdT`XH0yjSSZJ--bymbc%@NIL`zA-ER3#0uq@LNJgp0myI8+g zH0uR84Lp~eZBG^46OfG1*r8B2i7Kij4ZV1rCI8uW%AVGzJS zOTqr3R;=dv6fik3$h-KWz#?YhK#}SKPjh<3CBST=A@c)YyKbq#f;uSm_xIDxryhEr z^QbV7goct_PB&DD2A+tTYc^cL4F=Gdd%LccUNB=}a#_1I!0Irl=j|=TR-b=j!fWL~GAHLp=M;Gl6Z}mj+6H zF|2u^ANZ6X%A%I7^)4OtL=JtfV>niP$5)B8&El5*EdZbLgW&0Exr%f;jrNWX*VT>00CReQo?2nvlam4F>rNzTsZdkn?3=oBXEK|`k;DB|a~P(Q zsRfQ7{+}=igdh0Q_X9?ryT;(_pv^qfZ{C~@sG3nqd3&)~{2emm2Kx_+H^235xc&B@ z&@vKq_AG=+q1=EFODzbEPY~%QCoitYsmK7w>3Ks=A1b6ECUD9wjvVS^XJ2puOC}R` zK9~Pa7zDl4VT0=I!eDlce(o%sc|Z3emSs|{)@LV2hd8rrv2yhq|A{A_KyPoa{yr0r z>1BdiN|;p8L7C|~F*V9?JKc%D>=&5h5{~@;mT7ALe_vl8+j7;FqF5}3l}h!Ws%2ia$Wt~ev zpAj=Q0cHo8_-Y_tMDyoUicWaLh!snhM+K(!Y9Xr6r$P`HQUgF#s}=1IW`;mZbBkH2 z)cj;J`ClZ%-xLb@z;UXEE;nnGX3bmUsYlRkqS$~_tp!xX9FNEES-pDA>mPaWekCkR znwF*JH*2oV%h{V%weYH-mrc#pEAF6$eH@gdiB z|E63hhOX-g8vI62zGH23eoPeb31hLCkxVA{CKAbaJo3l`T1yN)Pw#x37{D~m4JDoa zbmF^)H86DROyJP~5MmdB=#@(t9U5YGBFV=_MuO&a<`#f|nwEg740B4~a=+qqUx-OfaWqT}|m9M9jch31FWBfNNl}g{___z>;m@d(N zZSY@C2B>TOKGob2;F5@xEM*B4oC-!qhS}TS`A*)_)fuLnoBqxV!dnBSf?PgNrB*D; zx}Kj1dJN%Au3$(-iKwPvUMv>7(RbZF)pCilcESdOP{ndo_%T!1%3dXk-YcpDP;Z{9 z?`ljTit3RB80$aGV4CdFM;{H_+uJ|oy6*p1u9O2RtaO|jm5#lz!HH~OJ zwOsjSxm-3{TU*G9bfSOWDNiSk0n{u#pl(nXs}&3l59z|Z%dWhF@N{ki@aMko2eoR= za2!VuzCL`GlJ{JmH_jNv>B+A9Q`dFBS1jg@=H_$|6>!V~vVM(TMh2MW^l<|RcFcqu zdYBv^XJ?&r9{;mXe3A_e4S!cE_J`$iIiTns4KfYd(KCNm2M&GiC|z4`2z{=B{P0QF zb^pFntx}<8K#mxIjipNdC`4=ZAC;!k70$rMu zOr|1Rziu^@=i~FAMt=NPUILKIw7wm6Vy%2}1T_Pb!zG4Tj89D!0_ z4`9QWE>#Su#(^4m=aP;vkuv_utrh-UqXQBm)Nvs6W-ENNR|MSx#&!LsrY18Ui+v}T z&%gWMzw>PZ7ohcC5xl!R37RpUO7!Au1GU3_W(jDB{|JbYW7&#TqG##KFr8}tPf+|; zDBaJCn(w`!^UuTaqx?M6_q^|{T)FbMvQtwUqa~S6D+($9Iyr%_c6w2sK)oE-kwmq~ zr;&}>$oe&eF%v_3o@A&zDK~7sBwnlfACq1{dl6`Ol#<6isM&pYHF%(!H|VNS))m;M zWyE5!pZE6m{`OsW-6e%Em=!k_!N*STJ+JWOwKe|)OMq$A&AO;2_*inf5aPy)b2o9T zBc-_MoUd4QVa+L0l>G%?vtbdKrV*GFv3_})+$6ift`;)U}j2`$| zc*4|Lo!q>(w^%i=KUO@>U>&rkR;8QM@5LO&CnK3ZJ?1P}Sc{py_Je7zu!VQR! z!io0`XaMJ zrCOQtTvx;_>f1R(M?tZkq^((Aot{7jb>lnU&U2Gfjtb>REW>1`5VVC9yrDIoP{#>L zgdp66!IWu=FqDB%Dk#R{*88Wj6K^Y(OH*MGa>fZH{xv#!r;COg&=y1WdImX6Dd1q&b%+3N}Agh3ozh;v=Uk5k}m*_Q1@ww=PtI@9G z!%Wx+Js%VO{j%%K^?YpGPU*V-BVBE6j~p5u)`G|mZQaqkny-nXEXl5(ZaI?%7SZVd z$@1!A07dom3@ItFAYVc)mxnQ)m5Y57V@F2BigV9r8C#%Oa8MfbRFKcg?v~b2m`Q{p zHVYc|wOM@1N&rJx;C_f!9_Tjf<$M9jcqyr{4N{o8Al483quBCLkbIM5ECdv4j{S#uk&`FZunF|34-sCb7f>yCI@6 z0s@H*I|vNDsSHyXrnlG2EvKK~XRUqCx#yPm<_&^?x`)Fp@7#0t*?a9(ziVxQ-?m?} zDJOY;fagGT-hN8}zv1n-1n?W)eoFwq;Z4J@OB2(4A3irLD8LJ& z{rV8VbHWytSxo)&o5{Z531HoZ^)vo`#fsH+pEU&#LO|0rOiWCmtx#zCjgsOTqoZSJ zZ*NBs1PH?ric%Z0016&`^rvXCp};dtJR*UJ`isMr?T-RmJA&8*(Cq9%=OD0@I zzvm$hgJft0lxJHF`OPH&8geBW_?UIqR$dU+_iaRSeG@!fBJM?e#R8#XJ6Gkm`E zvWvn(z9upRu^185?veDWzKQ2CPnb@3e#Y8~RG{D_uOlE3CfyEk+NRT3o>TK|2M_-I z5ux!}v|K|GYO3(zA@D_S?GH{f-r<( zn4tG0F7_SR%hw5$>pj{4nJ1#;>6eJl}x_guc8$U_|eU zw=7@0EZnp2w*>G@5kLb0DGCHnScp(j0Mf}-UFAtu-4JSTr((>VJ6AIeGdDRkKCp1f zoc@71{e4T9F6|i@80hFEl^!5Io(GUoLS!Tzu{cyBSsnm^7X*PDhM^S(VFf@5z{K## z@aW;ghX;4>-hJ%g;iHEf&p&eH*q}8$9Jqb}*9W{1@QDZvQWTgNl)#soL3qL^Zd{Uy zhzou_Il#=n;#H}LNZiw^^#N4?nyErJH5iK*bk0BPMO#*G+_-UBHk)0X$z&GIn>TMk zZ*Om}t_sb}2vt>~Xu2*GMG?7dfuA`HNr2NW|-a;Q& zS`5KVYI{oQq|vL|jwDm5f(C=fhdtYNt-bsAAK0~P=e>syAHMH~8*X^;&=DNb07n(@ ztPoyNhwr!u-hA~t1J{=~%w{sERIA7uCMHT#$Yu?E@$+Apkr1xF`sx`v3oKc(r162D zTPwg|86LneG6+L5$$U7jiw}PAgK7{2ia@C9?62|kii7-1FI{)a1?Ro!^tl6b&fBnI z!@6uH)6w18mCsE8w zthsXKlJ#euecHOQ@xeF${+nMjzUztI5AS;7$=hzc;g-7}+daA8b>RpB?~9-RjOPpC zUvtfS0=sHMS5uZ=nFm9J;O_cu*787(bv1y@2O)vB`+fN&IQr}1I!$~|-nvlrPfPg)Y{8v?x&ZEF%Q&l6s zvu;B7UFhuv5`9Krf7haAD;6)@w0Z3-F1+ZRhi{H+(lb$r-P8NYb^QQ>(OY3~|Mx~3p^@g*x)y!2%kUAbcU zs>|1}S-&)s$!VshLsbo40pz(&h9FYZ$ev3cAeqSIi`rx-lTSK8iczOpc#?j)n#WJN znnN-`)7MLJ1SWc^8YE6CB4jTN*pw}ot#Ei~#M!-P|NYjF-3z@ zcHxp)DH&wC)q4{fIRN=YhyztB1nH8@$!q_vS$6lS1evUzt1$XqOCxdnF5vFIH9B_p1kOxMaTmg zrXBX__xzT0fcT3@9Ml~3sozDPTPcP!wJ69_yLIgQk0ymhPl`zROL7MXOst9~V9>dW z0$t6D_QE2w*w%5*!g&j~TypVCe(>+teeK`A^Zkb((SV8v5)H)SrgL^THws$u9TkNM zp;Rh?L??YFceZI}p=b(9<OXu(T~~710DrJZLxU_QpysxP;=t20fSJpo>zY4)+iNd4|EzbeK4r<0?yfeW z8LDuq9z54)_oHTrN=B%Oqof@vlZqFq$}tc$Y(`YSFv|sp!0)EmJDpY=^Nkt>zi)5 z|4|^QlK#wI$mrU!ZV*CaYax`5O$<#`@YVnL5AE;%{_kG0X~V{M_4o8{BKOu*#d_qf zJMaF)U;eNEb;n=+)n5fk78&unjEF2~Fb|a) z-<~xWI8hVPJt&9}#{%Q4QuI>Nqo^kYicSV06Nst_bhWpM!s^Vtcfa$hf1Kanx9Ag} zxc<{a6@N?(ZTr)oxz>BnpS(L%brXJ|Lo>QDWqG*q#{cO$|NP57e#X`py&+$0Hv;yJ z=yD8IAwn(aox5=DU)*@}ZFgUK@w!8%255n3u$LPf4_s{0HM=Zz&0v{Kw*9|;=33Hm zw2I?r)~)Pb^nv%k`$K1+x$(+*^ZK)-kc6a#Dm5*%X~iZj{w%|HG)B@XA_=25(lrqj z4{^#scy3x)(>1tH!eQi)6pu>MKLuKi5J()*1JZjMz<|yRV89Ub=g%+x?i=6mzD%~O z>ocFf{vUT87(K4|KK|vCpYYyx^)+D_<}f}5T>H7}+b@0jt3G+!X&1cSF!H(&6{WI| zau~vPeFU0@;W6UWovR9MJ)3ixjzflKLkl<#Qs2@#X8ZXa@%P%@@?dx9q>JOiH?iuIIpFdZ%N>glxu+pJYur``Tp7>NmHc{{*nHmzbO0E)8 z5Tg-Jaf5n~`ZG3x;%Q#5#R8GQvDk8`Squn0`AN`%piv+ePT?)`CkiM5$@CD}LK}J& zGjsXNE_s`l&FP=~*Ux`s@4n$By#1=Hv_JjxKkx48=`rrV=ibT7F1u`k zS1k;9K>*jYp(+XtO@~G?SVcj(GKH)uaP7Z-j=F7_Dsr!R^#x~M`RdF6;he2o)^&Gx ziNLmD>IOpJgH8|FpunZ=c$$0}sfnFCG;rzjBX&t@GqvQWwR7qXre?w=BeQCeO3`k?kg(mfuxO*#x8^h;geX1L{((7(`q+!Cc1ly z`UU5m`DUSN?uY;OV;?y(Tps@HXTK17#`kf>8~^BC19O&M4JD%wkCdPzgbo`jEgZw- zzLdtMFg{+wRJCeZj(wP()2UXVS&l-oqw~72eB%SBtX_SE=lkuOH*bCN{s$iTx0`Re z;eQ+_uxa(>vU%yZcPs>fbkJn8Dz5#vf1@7jc@w!ey!N7VUVG&$KeloGnx(~jPQY^+ zzR|k1VEa+vrY5HYQef)`IEkZ0n*EHfTspa7!k_IXXlxWH)Gt+o??@}haa?x93=R%r ze0&_oj~~O>*d!d!jebUfZUI9#(9_e6`E%!?ySp20#SZAYfow*@f`$G1neL{m-uceA zPW*o#{@0Jwg1O+ri_TxWZqxsTnK7$w0LAv8!AB+}%!Gr<(J_RYj;d8fX{r@%#l4p#|;gxBQXzk&k`+%vo4CujCGI5$FB_6DL zr%7zsB*c)`@~K5;A-{4rQy#wLoY_FN>wbWfJHN5j-_NnDF!Fg`U4zF*v3>h496ER$ zJ9g~Ap~DAi;w|<=L@Qo?j{ZiL0x5(-#lXNkELyk_8#iynvel;$5f~RH}W#NK0z1)*a%?Rm2hl)Bpe$W+&eaW?9bkO)$a}~ z;vhumLc8*)^Z%zdS=b*aOXQcKR~HKFAP%rnlu>^+{J={`REjrUZlBNODrux$&P z;^E2NPlzDk;7#U?O>;NA_04bi`1-Xg7Mq45NQUXE3C}9Wu7O%hl+K1hP9{8-=`}^L zc1{%Cs|VrD+H-f}7R>Uz>8_{X(4ixE?9oSX?}PW_$f4t?Ry}6B9c$tx$Ln5wEn ziQsIs?uZCH-(gWm@HWr$F+4nog9rEHu6urpT%ips)@%@4x4tC*x+`Dtf0vyyxNpw# zd5cTF3M&Xg-=5-Y3XLrZMnsmUDxN(#wr}{r-fte-_vB|^efeci8es{(Ux7&qr)PFA zn+S6GpFcH-zP>(&KOZl7@Ek|@j;StOb>W*fkHNa*CpW);!MtqQG|KS(2|TfLy9NcB zW%IE3nm_!*e_nse>g8Eo6FQNE=K!V#En~8oMwAkSZY1NSsZ*OEGK&KEgr$*?9BIl4 z5(RR?`FF`mPeuM_HK+8u6IePYGwC#Yp@8c+iDElGHjW1$d=PitwGF#>@8(G)_iJx& zFM7I63Jcl)e6ex3M|XQRLNtcvdCmuBoIcTP|Uz~0#qyq zKYR2cY`f<^F?a5o?*92}i%&du$G45b+^IkmgUPb*W^!%TlvSRx?1>?(I7iwJi9mg^J=qT=D*|T(o%cvRm8QiUo?*x3zV!jzv*b;j0)O znnd81og;f6`pCb3@_&E$^4FZ}edpHy)D#tFR{=dA`|t-odHE$5y}Z4%LuJ{gYXD*G zdE9+gKoo@lToH?l96R)Iv4>AiFp-9zaP-(3V7-whob|_OH8Wwf4(>Fq_LJ45+=qcaP-(wjE#-p;K6+u9v;E?q{Sk5yfltb$zXWc!QhDPk4=gDhbR2M z9UF7*f-R~jcKRq!1^;%#mqW)c1Eoo*u7xb=OT=BsU!#U_Rf63BqV2TbEnIQMmH*S# z*>{0y6h+2tgMbcA$*~L?ADe_JJXFUIPwd-u&wFnAkB@!@7%|!mw7vVCFT3U)Z@ubI z`+B?0d}pVeAlC)R%V*md8z0B$$Oyk4JANEvU1u}-odKvY_C-8w9 zK>XPkzJ(wC@F#26t~vD|Iy>f^moYnwx>|st6`<)R#wW+nO|fjhiqg=NPi_0*SKj=< z9XD>j`1}>;y!V>7{`-avo4PgA5UQ%-;DP-Z85zZnojb7Oi6=O%YHVzrWf={c@B2K- z$wQL4l!{(42N5B1`8+y0I#A4IvH7%hSh#pDuP$0Kg+fvO_*c#WWFrEgg`pS*LRX44 zg$SOW$F`IA@ICmh#~grtA9~Fd%^u1KxkS$|fh5x7sVHP-Ym!LV6=1=49E5>jSFh!} z*sRQutd6#U4OTUn+t$%hfFX>msVaH+ zfjxfEJ^aMBAAS4gPk;We|NO0=yW)yV&hbNt!$*(fM?d-z%fG{i4>Nls8<<0qiiB*@ z1CqZXpUd(@6UD~|#ju$GXmvyrrm)gP;-ZR7RzrXP0Orn}gH@|nqpPzMUESSi)tFGg#e=qv_`;gD&IpLE2MwmL5k<0cHf9``HdjGp^%~UC@Xd|C*V{?Qc zJLL7N+BUCdI1~8E_rJxl;I2+AJvMy!FMf2_9Sb{(ouW`^M^BrHf!=nkTD1xrH*Un- zIdgd>OSGL#QF*e|2 z!JN-j6VnB*sKt6oS}|2m!WR)^8z{9ceeHB`#)B|Oe<)$`!Y&*sxfmKg3fnDU;et7s zC|4O-oL6!n$Ykh-a4I1}U!Z>=kJ0f3#`swIZ7;s)mD|de{xvOE%ph0HaR&{PY*Y=3 zFpyEC&HB!Fz6;;})~))&Me{Eo=x_U*iOHc=oxKZ0cTXQyESZN_UvfGY&FNxBn^cA)R}r~?+474Nr07*AWs7W5ka1x)&qt$-{iUOU!3qAH zUZwTt^0cw9BkjFxyskQZ{}bR#j9*A!znWYyuq|XYlKT!;F3RKkckM*rErX&LVPthS zolC-o2^A(Ijsz+JP$f^hP++zdvcllHsT_xR!5XZ3ZVi`L%7L)nupH`-xc448$@E_EqOZ6%LGW5~r^n z!13eb*nMC(rY1t@nGR%&8uF%&oCragN0mT>MV46Tz!nam2SS*E(EKo6B1OU9eE9Fo z4}9%aIPUwzZcmCIh*)|Ojd=qRY88z~hKH$l^`^AFrsuIVuuACJ%>{iuwhUdGEWUJmCw+p+uLP6%Zw5OgCjv+&3Z z94%1CcHmgPb$H*QFaPC7J{l^a(vHu6@kaCa-}!+*+O%cscUG@G_3dq4eLbP7iOC8j zG3c;-V8RJ8W?2|5S5a|oELhNm?rwu!wMm!KP_L616h7#;&pDp!*>CZ))_p#~n8o9o zKKOgmmFbP}prQf;!?@t=`B*wX3>D{yr-f4?%@0{~hC#7A51u{cR>qHiWACoV{!8~u z(BAv@cZuKs<7+;!V&#(m)7>*K14U8{nPL?R{ZbMqd`tvW7@M5Jbi)3m}wHK$(i16nM^BU1HGlMS%!ge_fZIHmfm{d56vDhp4DY za$Skfq!q6F70m4tqJZPp$N%Np*DhXh{(@rHg1$giCPqev9^HNaUB^H2@xKpsZvu9; z1TEjy{W~!AE;Vb4YG^|x5aHBB0{U#r2#ilnVz4rT^3-wkXQr@ZfaJ~uF$=&t8Nh{n zDDHF;8;Kn|@xz=*=ueL@iqLoRwD|gsnM_TeqZTK3G%8#1{Z8r`r)#loA4AtuEqU%# zzZ8irB-9Z5Wdj*PmL{SsfTgpGBmuu80|ObaEY>V&-{_7`>f7)8<|94|<;XF^n7Y4s z&mTa*W3Z~$JsEKY6P1T)=v!p=$y$f$e8gJ6YWa!O z6fVHg-B!ek1$}*|u2dI2w#y&#P?@&FnwrtpbQ49fB0Ry_pODy-pA zEa_{*s`(wzgJEbcX|un&`Vt(vY=mq_@56@g(gYyVp1U;TY?%0@C3u6nT(5efnufrd zh^L8npBw>DBS?M|>@iKeNxp<^M%IAmxhS?53g?}7&hp20-SKD$#+Wyu^QdW)ljC<) zDwWHK7F3aeLxscY9tDH(hSu*lBR91m8 z`83(YKBJ6d>My0lvQ|Z~>j0rg=vfV(>%y|i@EjLT)nTBgZM%%+r}qTbEKN;u0Vk(w zGs1~}LlwiwtCns~H6n=k`5I`QoNnn>EEX9~Mz4gA=;&?-RU_@_>|pSWnajuu*RBi2 z$N~XXL9C^_*Ct<$Xp?FTxlnZ-3l=O`oYj!2dR$#iPdiNak--{Jib^yRK~NrCsRGBgIi5lFgeHc@_?0HmvK&lJOrlz`xj;g-Vl(`j zkV;n7;#cAh1U|8d09*%}W+I?uR=?)SBisWyR|v^ejV3(C^Tom<_U5I1_mcRDh08Aq zF2?5&{(_T96fR6nZgq-$>IN`pK`-Vl8NjmjtFd|O8KCMC^(Aqoj=(jHqx*`$hiRx- zxNuQ-v8cAYGhEEwq4qQArz6aa4Z^z@ipTzpj8=&i^obad)Lz8QrV0fC!#BAzH zwvg5O#>f1<49-?n5xNAjt@TLzD=HK+ zBV`tkPFUiY@h2f`dU}VGK3KL24T`70v3(d+5sbi+^fkrvV>wDPn^-c1p6!4mjD&t2 zJa&vzrv{H5$JEFK#tx5RVrU#ABO^RfT>T=F>8uZgR3eU65G7wkat$hU3Ulk(XthKT z><|eqzcweKOfZU&J4q~yYm!P)!lw~%lSPUYM~q}){i8mnQA=M)A$$`tFZGl<9muFh zIFV%)>nwg~aHbETv~@kld4%zzV-d!W^}wHCoBBE)fr2tW461NOJL8Xe%Pl_h>PJ@*Z-cJu2UreRdsIv6 z-x0i2k@c2Zl!=Q?1*us9R9AOaVx62uB&KPl=p?8p$Gsdt@eE839mnonyYbZCr|{(N zCsCo~hlvSPrfe8m5mYvbCs&c!1Trsu*XDQx7kyC)H!tr)h@&{EBF?R6tbH`FG!SV_ zLNc+96Py}ArG+xeQ9FjJMc0m&v6><#K9BAd<@wiLm#TfFRzEYCME2J@(OCY8OuRVw zBXbGJ-(sO}U?55(i>(W#!Tyr(eb1KHWmul7E?KgqQ;2&M0$4?vmFZr#tPsM|JY)h0 z3XRT-v<5G8HB+hAMB(&jSHZzuhp_$rCvflW_oFf)**i@JzpBKdOtk4agaK8sppGb< zi6mDnr8N^ux%OajY8Xvy)1)Q|meMayRm7VA)~u9?)aO?4ZF#6sIk9dYoQS-T ztq4f!Mv>Z+A^_@Ra$XYwkohVJKu|6Kz4t@TlVEW~a9>(!47el`s8gyq_ky@3Z;PWc zi)^QS6hhy5DPKT5hFaV~GHF7J`pEQ6I$802KasWjRaL?^p?wtC&G&}#z!+1PmCI^%*KgMPe9#k z^!xOLE6nC3YoR{WO$eo~@hX({;q|2MP;J7Qd&DK<8aPbC`cnkZsx@)|j}l5eX(_Ok z;CigoEm*LCMT8(m;6w^{G&t2Av!EmIS~=RQpVjZu)G8HmD2?;VkDa zoQGlu0;6br<5_32VD{oy{~ZqcFo2Q6Qy>iWi(Hi z(l}uapOprJX%MzdY+Md=X%(Qe`JkVC{^ifJ42Pm2a#!gti2W56($+p^+nkQkQA+Idz|8F(nWB5U%5} z{g-w+kQN-@T3#+5xJbvO1P>*|s?*lu-l_W_9I7HAK(ztMdq!?lA`0pGidQj-fY?u8 zPt}?#a{=l!?-a{U2CiWOW0qedfYu@+Ozi_$&%z^_8jFku4l`XQQlk!VqHXO6bW`_T zTn*I^;K0L_U&iwFOH%f$)VpMuIR0+>XiOLeDx=KfdMm_1$SWRgVbRhGh=0pvq-N3T z9)TP@dWa=!mSfvp+o1ST#8C1gnMNijg38sO$X!+hiA&JxuV?Lz6C6)aBg<~$2#xmr zv;?5mt)Ya#qwVMt6i*|;CFj)-e;RH=?`Nv=D-}#pM?{oG9g9aUten-+x3C{@$>p5l z`AO1f{SDGgJN3+I4v;#RYQRn3=t{I-PD|jTOioBEA<;09rW*QKvvDmtx;x+vmZeup zRXKrFSES7!i-EYKOc3P0iOBj_NdW1V05;<+k`r-&#zK_=CFIaTh{WNt z6-$vD=mo5DBxR%We4Jw`-P4V!XVOuYd`_1~iFMzkX-%$razRFQwi$0cgM5>90^^cx z^a~#_4WL{>wqRn#nw8i&{1`~tkfxr>x$m+3uPK2w4#1TQ8hV`A|Kap3Kc)0UziV*O zHcntnFrt9~lAFXzUvNSWFjG5Goo|A7O@EDmi$lS)WI#8SD$XiuZ>X^T99@o4pADfh!#&pA&14N1-|0GHeXSNraRmlLcJd}|#iGFFV zMK=>b!}qZ=*Z>hz5-mTf>Jkx1tn(!Xz%(;SEI7S_6A?X20wA&Hr3hMAR8EK`>;csq z(%SO~g%uUjj+1U9LWodHLr>DvsfpekAM<^?ek@GaS&&tVqnTTfkG zPP`my3C?TiZeu-#WC9vk|1|yrA${J-v&TFAk{ssy*DlyJRPw1wajhReYUyjv)VSm! zc}Jt?q9B+P?TfVaymKOqBi$w5kvu+PFl@aN+j#j>sWd^{JO=NRBJNYsc?xJ!CDO)U zkd8o#DZwm`9vd6B9M2=oG_K{-q!2c4(8Q4h+B3%{0fSXrx1iYDE(04Rmjp$)q`ar9 z!BX?5ju4xVltHC-Os9W5Q%wo$kH+tx-A0P96ftq|hjv&A^#F_4E(R74L>~NT@?*C+ zbEMcc-JCI$DmLX0j!uqMNt|=ZAw~sAJdG@N25oF?Sgv6v1^OY;3b}?5>^nR)I9akB z(=>B@ajNwbVecZhPx2tim#E48`>8o?zG~EAWiH4zEBIJ6^wpWFX zDsM5c;@ndJj37{LnRd_2ujUbrO+to&F#$ymGc(IYG^m=&@W_Ps=;Ql`Gqm%)AmJB= z6kniA3=I#-{)In4DU)EVN|Jv4t^hnl-cFWb&KUetU3AHGuY_5q_I<9 zywHQAxLCY)33^t|hpSi!8H*7se^HCv(-LD)&jH+WSw2e`!f`zJ(BYwDKGe|T-OQ#j z=UX+B##0umLqkJHtZG!9A~j(RCr6w^92s~Zpqi#FRJkf}q}-m&F^vsj6s zk~+-o(-ODArjz;uyPTv-8-Zo2mW83Aq4MFwrQt9PsCXD{cHK12F}^6yFN?1q$1uE! ziHSXKgj+jlOh1)?tx)lZDpqV>ixnGJ!B+zIcDc0r(xu>^W{YbQ%(gZ`yxQZ#5B$0# z<_t}rDNIW{1wV}%2s88sK|o_+$s_cz>Fm>y8|Z{rodi`|ukDjCyG9T^imMQQ^61fH z$3lS;RgNRI(J*4ic8!L#*yo+@RCEHlWW2+N5AT_nnDBCiyjG+=c_T8aX}OI#cB#Q3 z4V!`(K?N{%ob!@%apUfTD3z-awh3L+ShTQZM!SYZ%HeF-Y$6LAJaw$Sq?(M4r;s_Z zou1xM3eCDga-W9kofM&q;~2c*6K`=>1F4*-(OJY%*zONQlqzK|A~|W5uz1}PY&@6t zT6Un(elAh!eJeIy3&%I;K7Ih(vRGFeA0OXMg-xT4tHUbg8bmEt+gn|mxHC&ZI`4r8 z9^U7BUWLMvvu^POl9@s^N_p2a7x{r6oPFWB2z2S(A%};QSCM>;$6bLF)Vf=|9Hr{U@Kqfqnb;kIY-RppAA5 zi321_v`aK+{>TrBB$0dNv7BwKFUU}%Dhjz!F zBQ|0p-(C6|tvrE=9zfLNDVY5E_NR7k*tBs;v5<)X5mM2cE)ddOC@NCSC{(QZIPW!= z;(xCFDy*Rigf4Y~wpSyFvIINO%8(w#XeX^{?jZ-D{x$Je#2m6=zd%{Smdex02`d)U zHIkYeaf@`T*wv`^Ax)IrBsw+`V?yA5B*2y^s1%Px;Bi8e8+h=A2NxF3y7(-#FPj6H zQ5I7)UW}DE0aKxxztX!Y55hgzfs2DjhX(I`)Zb4zRD>TPm8*pg$s?a0uy|KiGL8v< z;*B`)_7B<*mF)6MT-Q`7(Sr8F3qzAvbz_a5+Qh^wFG9|+b}nJz=xf8J?|38Ta38al(O7+Mu0k*G#- z$I7kT-#~_zihRAHMYMuus*GH9Y0Hg}XJP}g+0^e@_zhZ;?W zkMSf`faWJiKS{*rddY@#=1N4cEVqki=BlG|{-Fo$XQ5 zsaHkUnx%O0?_7p)(?vzG83*SFE`uN0ZOqOK`b|xy##ylprXU{~nQ3%Sav>W!vV|OS zg*@^(dM)s79lZJpj}Yyw&Dq7%m~x`QtYnQpIubaWs&dh4s((yLowloYRq$Ds$L+FX1r0hKI2y~wgHQD}2%khU z0Y)t56obPPM}P3+CwCYo+>nC^338-q8MU+mQoaD+CI?l3%8thm++|nkmXc{OTN6G+ zWKh(FXj|E?oa7(@=UU!`>y-g#5}oT8I4B7RPBz3PS6+tmUVRauQI!>n^m*_q6HqiF7}|rtZvu_R`G6AJ6(yta zUetnjFJWCzc-VK~2=3bUz=M{HAf*Wjn$ z{xOd1K7^uLi0a``lA|MY@)%a8p}-i8XFnM@X%yw`;LQmR2(=$CqHteRmM)fhT&+`0n(Zx z5djyB^8si3j`G2WAKL!trnQSME94c;~s-Ua4NA5d79xzR7vfX14$urf0 z7JCBJ{oM~Z>|H5WFi0urL?{_x@wzoQ{Uv9?=q~^i-*>Cr=~h>6$7^8aH>z5{Wo=bIzUL=E2#&_l!WT8od+Xls)&w0X&#@t1}z4{@jr27{wrz>`MrZ~!q%yDKRv&|vxGl1*h|Sm-JTm6q zyX_aZxdA4;0ItZw5ABpwp|u&<)Dmh5gc6vlNsd5|wt>3s$2+%Qe95|>EnP9^^frdw zu@I_5qoi%$=$q)J-l36DF#Xgg*Ch=E&9IJTXRpKDt`0nU_e0pXXFnXTj0~lgL5E+d zvY)%Xy%U|CUC=b0pOEwbTK#5DCYZ*~7!Gj9?&1@0g0Nv?8~q%V&c=?TR2WvISBwbY zssXBQ8O4q^EInmDmTy`Kbmf5znRq1*lT(5Xo4cS=?s2TYQB;v=jU8yRkuu52Idz^^ zvekTyj823*_8xrv{s*6Y$fLXxzzwS)ik(&gJRt`N!!WQthxGueT2l!ye)A80{H;^g zE#ACw#Zrygay+@IA4a1Yy-Be)HV5f}NO|(5{mOQ}kH8_NunJY98Uh-!tMWK&;klT% zYcYQQ;Nv*{)B%Lm0BvRg9c=}4cC^7zRX9{Ph{i&vt=ZJnijtd7@Xq5=uNuK%buH!7 zNZ5P|T$-SSH6?NZ%dsa5hj5JJl?ohM(FGOrmo3MlRZC&^8i^;0?URQk zsuZF$>?)q*m)Gu_#3{G{2W1Dz1&3#I9i4Kq!NS>lb6;DJyXB@m<*c*iIbUxgTwX4ydMv5*JO%)FFpuZKn`{ zM@|xnHi~Nm#DtKZ^rS_rnoYO(Jl=DMTFkuyRH|hbIm8VDC4?hD~-kB@VFH|ynX)zKiM|) zpbuESfXf~t?mpY#u}E)f(>j7^uSfcILIg5N#Ho}q`t9%Dd(-AqR;^pNdY;J)x6X0V zIN@~&zm$1U^X@e@&RU@*Ch==(E`tT9uEv5jD^NZ*j^RUt7(YA;-=d(51VmEx42G2v z9+8p6acP&x3(bVtC})+rk6c?I`j`4vinypE8rP*_FJAyV^w7Ps2a6Z>p>yF}hQL9T z;f1o;6c=hHwjFr~Q^VUZCXK_NCd}sK4|y6nj1<^Gh~4{+O?~N0-~7+9Dri?Pa)r7O z9@*$oFrl>xNbN+4A&EbL4Nbx;I<09(?@nMGN~c$QB69l@bB1?DgcUi0!A) zKQpcd-{YJDt2B&Edk1H^i9Z7#khO=qQ!91v`5o0|P)$5$K{(v=Dk!oT>~hL)HpK z^c=RILaH$|!yvP>EA2H+dm@vCm3~d@1O^TqJ?8(|z1l1L4qus4Cn7eE<=2k6)r7G7CI&|nLn}~#n@v1h2 z<1t1~dgaJhApx#UY3|e_@j;HAY>v{h4H#`%6gxZ6-cjVpV6LNGYTX=Cw1Hp>p;JNV zyHIJ|EvlbL;+IfFG7YBzH(XEn4GAxaeNQZycMp|C8{#I_S{+uUis4cv{Qi$0di-lQ z-gm2}DrL*p94cTJa2O}b?}^O7=}h67bby)|kRpJb`7U(bsam$R_Yn5na_j9kEnC$8 zc0&(!BPw=8=3%;VB1ifG>JY{8_VLpYVZ{DO*^0#nv3#AnP1U(*7czI0MTIB^$Q4Yi zTDe&ot)xr1KCHosD2+|tqiV&QFUgk8(2is^4rY)QL6b`bttcCZ(a_KT~vljnI3OlgtHY%zU3izDzPRbe;y^)uYkh|#~ zG`@FzZXYvmE_D-y(xXLCot$L7X!owYmFxcFhU=c#S>8@T05>4CErBa^kLL=!l?dsn z(@s9M6IFXo>i}#G5T7G3jVcSpPkj0_w|#!$yn*@awEXAd~C$I^}Bc9dfSgjepurD zTMIAZKLY@904K@-SI4;%wXxeRvMfuu{jtMN^63NxmazEYS$s{yp{es1aCtRoAWjq|9E8>(pRhwR*-0xPvC1>U&I0 z$da{zilbvy@A?~m^qo(A@xCuidKi@nyh;@!pc;Qv+n1C;Dl$w~TO78XmI!7ODUrRA zK3{{d4^i0>A_A&>n(5yS)# z58BCl?;10US`&b5=@PE#?QN;mCB@FrT31p``AK4K7TNR3>KBa!5PM1qMdI^Lb(-sF2wgyEEhD(U_^EV+5%?rCSU4@e8eqXu|9F z8)(RbNEdrxLWp4abgE7BFR=aKVfV&w-TCb=T>t1--4G*Gj!Sstn+wtt9&7(IDt=Fo z0MJALMC??LR4G?kuup#F=KF5VWCGPR&DXD4G0!yAfaCIPN5`u?AVtEBR#?0;Vnwl5 z->-SUyy;i&jGaHxj$C2AjBi}|C)?`pCs8^{;%sB%tX`Rn-yB1sreLaSVb6i%)vtc@ z$KUwMH+I}|dO6W`*S&sGKFQW{E&S9tlY}o!Jp4#Rv~qg2kjc|c>}>ZA zPU`uK_`Xq=OzJiL_qteo`Z{SR4&fMCFOm6oX$7{mekkun=TkiZ@>5W~e<2J;6pc@m z!zXs{AN}f0-}~y#w;le`xQ)qBaD8VMjSU=hQ+SdnA1u=zSMtLMMbeg{Iu*vJ%@&Yc*a3Lg6T z&fQ=5&zrBi;{p3;ijJ}!z;b<144yJZrM#E#(}ZwB#jo!9W^+>N+?WW6bI=y`LVL#Z74k3P14?@hOS|AwD#J8)kKs7z6zE|T|( z;sycjQb1^$kO)8$15G~1h-U_WPS>6eV6IUVNFtz#mu8}T^qIaqyJaBL{ zeCqJn*!S*u@V4u|_UQKp3IYN@o&`V zkm5j2x)cYs_v9R=!}Njd=_{2Lzx$etUVHx8n@%6-F6ahrU!Erj6?yIq&J9S?%##Wj zHOEL0L`f+DW%bax1WG0I*ON({p&}?1g@Y6 zR*n6~?|I^bn%qC>$(PUY^jf1nkS2nd8zi3z9wFr37-px5;)}NQo_6IG=e~OLhQ%xT z+H@_OQIN@M1O$??{Y1*tRuiFwnE=uvA>|rQO1n0Af|?ycGe5FORTWF;JEUF0^)09) z7TFK^9VeXxobLvYwt0uZMA^bb$qydixo_mr$M)ZI+xLF{{q4KQ4q8B!@TPCL_nj&srHyP3 zU6)@p_l!%=-EztDMSaVcEFI7@Ioj_=0zXy$NsXm*Y1BA>Em@JZNvPHKJplonps=4v zC3V&jzzp_foWQ~F6{!oMOnkZL%j8>*FWqbC+zu2+wzK0&a_s(rQ?%A^+N30Nz zp=TV&wdBOgR3aHx^mz|7hpfbi0OBJ`1dw}lJe`S8`u=o(ctRo|>mXEgC_1If5Qa*p z9iY_E*J-;ug7Xt0bmq}@=IMPKFTeP#3r<-+uyS5sTU&QWPSBpXEPpj(>jJrA9u#As zJqI|2EFGDnIzN$;ICEj`Mmm5b>$~f8Y zl(#^6|CX(VqZ5jEhJCD%9=S%=oua4^~~k7@O?tnQj$nqIa9W{j$q>oZ-#KUCIV%eLX%v; zf}vs*Vo!MuJ<3@SF!VeEH$cU*QL#K!-7pxLn6ie)CPp8BV*if2?%nn9t|y0f?>puk zR|H&(@>ylF4XtzuvXOX;+T@aE&rY_40MZlhr)7Sl&hvC856>_WH0TA45TQt-eC6`o zd{FwNCiFlPK2)leP$aGpK+CBpp1!eb`KsmfH=O&THJkeT+xptui@BcO4$bqbB9o=G zEXocNsbrJcX#Xd+4n!S7D1fFLoB>2ABig}>%j^5HxJ2Nn;mDCn;Ch;Mbay7QYuk05Y7Eb|bnGa}Ze7WSO|-Uz zf+@=iZQBkl%L*;q^%X_;hK5F}hYlV&IyyAD|G=J+{l9p8|G}pY32LAyL2cFZc0{yi zd!o@dviim%!E7X_2RwKNChVDRX`gK!H;9hdfs`hUx=D_Hm*ytv_X_Q5!*c~FSxCdd z5Ks*RYR2GgbF+qlf~p}$Tk{%*YG$)J%`kM;cW6;11L{spOu3E|QYAd9p;RG&h2y}d z@2 zn>V5GL+QMul0>9KvN2Z6#U&36Pbg(_G3u;7JRZM~@nEUHC3yUFh_-I^w|Gy$+vq&j zIGgPI7YYHSuir{6wBibB;)n^LksBn?p=keT%DIpeuF+&0u&Pe_p2;(k+@PF(%eLws z+SE<5FhVCX`N?C&J0H}6DV0zYO6bvc1VFf8yfuhzfus~ zuYv=#x@Y4t(`uLBi4W0Ces%p2WV{Z1E;Npcu*Qwc=LqUzfZq59@Jvus5Oo)wCodF zti)*2sdzz>HQJ!;HNCc4*{$Bk_jpd*bHM>lhAYhUDwRL7@@re8#^JttF>vC7qhac` zkqe-1;%ceG(@knC0%&#pr`3KPIKXVULM*DqY!2R~CVHR3Zl))FCISdsypPyF|8lxX zY<0Byd*eNxSMB*i05eTSnkZVCcugi>i{Cw+`%;V7`*af=e{MV{{r&U0J>LjmwuCU# z>rC%wbHBK?TD{>c{`P`u&sPFy)mjaD$i~p&fy8qmFbjk0_GR5>UF12OqtIfBLh3 z!1_nlsAo3r(P8B@FgXoZTz$DYj-nGY4bG|n3(YyHdu|a(PGuT>ntVTLpZw$}llRT* z=DPKbhZ^(lr;EPU2+4jH_8xJm<%?H&EO|DOUJaP ze%X`m0h*4f5UUX=Rzf7p@PjfGr}HZ#K#~CwSBOLm#p#-2NFGD;Ljuyee>$cf8|3(? zeX0X^67KvtHa))6;5*>jGBb+?oF0UE2=uvf5+PtTBNVvBkPL=b48l+(O)b&>|3C&y*6IBi8cvr-2k z+)*&AV$Q3$55;KUfx4qmcSrzyDB&ZrLeqtgjdn;BU-Ouz#bBm~hjsefi`}NA0nA+F z%-kSJv!!Fu3SemluB2H8EOQ2hn8%PJhKw6X35F}Kx}4qnv%7W8FUNp-w?g!zd_Mo> zIkQf^lOc6Zyb^t@YaAg)hP{_b|$F*=iFEf@G>AuO#ut#`t?ui%P+Z{zu^sU zXPVY0bIxR0J6zzJe={07d6h;(B(JSy=cU|ESOEADI^DD-LRca|7!JV_5!wqyv)~FH z!q#=zDuh-(A}0b*Gq9+E!M!`hs(XH*N2;Z)>lMDiV1LDNF@0cguLM&!<+6n*v)RHW zww;A*cW@cG;4B5#NgbLs%1io`?!FEu+Yg@Fg{Pl+fLCj!SNz|v+?%>@)h6}b?|fg^ zYf@Zs`Sr|V4s`5;#Z~Znh*WGtM+&Z!fx}YZu|T~V0JcDd#RxnFUqndfJR=tRDE+0- z{zn>sH9Kd3aM!`kt%H|3h$!5TzTQn>dIYu|AeJ>mvW{5#DCAN`Q|#Kc)oPp8CQH@f z9@n)WwH^DdN+}hFp%xYwQ5?puXCs};{Ci*DzDzaPq0$-2$bJX{X_k{QO`ydTm6*cW zE0^=@uDqNrK6TdYTrP9hd*Aip-xPE6Z+L3;242i(K-NRJL36xUpN5lgW;+A)Gm zG=!9A!S`$6ssWe#=x(>bc?pg;3Z@RC5bpyo?F3yRX@aA6ytwT-KfRV#GaA4GV)u5i z^tVy$-pST&{E1kyoCz<}i#RI5^Bl00z*R55itXFHH4B6NuChq?8lY@t9%wTv|(j$eMT1kzI!S ze*T2^oZ>sYl;`C0`4z%qKfU0>vwtjA`H!mQU8i>xlwAFrU+VttPYGxENj_cdf@4)- zdqCjtLwjzN&7B?bGnaL;rSt0i+!ZNt+JcCG^Dp1PjE)jV>4_R0e?`yj`cH!RJf~Hv zdA;V^EkQ;auwnb9==))J!!#x4=L#V0d=UVFt?^}OQn$JG@ z*MGpyUb+0E)2Ge;TNyj33~6|7UeQwH2PK_Ohk`L5)zQ-atfjx7LhZjIKUg1HIk?#@ zcJJJQ*S`66Zlio~`tJSP1xNgW>Fu4rW>vXv>z1LteYG}u-5cJ;?zr=Zx>PA6m-S4H zAU3Wn$6H_f`Xk&^a>XyHC4jDxF$ULlVG9lDAt0!+@BY_AETxy_qgv{AXy+F;%Y4SR zyjSer_>}(q$Nz|R7rgT_9v-pNdYzZCzauO?t2$azTE^DU&@di;_)!cF4nuHDd0v_` zIN{J>_0f^R$`{}N-Vc|XUQ{yphqb9bq_UoWs?_=J-p}qo`0S@;9Dh$(Y*<*D3&CIu z!Ga)AaTH05V`jFH{}9~#nsj^5Lm3aB3`@OltPS_Q;dj6K`8Q4~`TFI(vR4S~eqo|k+tYS6=&$giA5Q77K zW@!_i1)fTw>`S?5$(d~0?8OY+V+VQ<-f`c3tKRaicf2#Y>qkFSD#`;Y3#C1?F1-E? zuWlN)t^0dPX@Hi$ok*&nfUip~SsVQNGWeARa@whAJxZ~rC!`ft9#zxl22W8cAEbkFE{?KM|iIP({Gd{w>r zl5_Z}Bg0rQT1GZ%1%X~%4R8W6)nuKe*-y(bjfhq`fSm%ydm+`c>_BCWFJ$4T8MkYN zW&y8^O6j@c^xkJz-;BsFXdyB(iV=i0wqfH2JiB8zve`Dp5Hd7IV@tT7D|V#+#gE z{lM$9j{HNH<7|g%hs80oAO(f8tBrDUPRZc#6drl#A#}9OL>ODR@4ox-#P8PNyz|b% z<)<&!-K7d2{`ddDva$`d&&BViXE2g-6CL~yLv`=Ce5Va4#G%6nn>L9+44}qa-gvSh`Rvo;{N-7t}egwD)zRw z{XW~ZYrig}Gv9m7%^$m1hV>uVCXQ8!rQd)I>NN6@njzobgLO}DMY$@CYe`}8jzU3K zdiSb(zxlP-^{@Wz&V@p#_Ckhj|L(UK1`a}~1{~*Pg|nY80cZwYlEjA^bEh;{CJ7R8 z4#~PSFq_-%G|6=4-#JS$9Wx!(A_SEnqZG>q4IfIy$Y#?x<&;Hw`t)u#b7priTpGB! zyCd_BOsd!Vi@!ddN#_?z^8HXW@RWfgYw7vEeQhP#IwqkJg4-GoJ@SbD#y7vMZEi!w zk#qN5pVY-nX0%!wLF_k7RM3V7B?hfr8+P`MVC}QJ5yl)AvtTo*6pvV&s{ZaTZobBv zF}&yf!_Pi72T@rreBV2Dtt-#V!iSWV6BmGS*4oL8Fdtv{P!i`b72FgGZ6Rpn0d%P) zOU`A(`})4)m#YUOKQI9>%d&KLXPeG>o=KFXgwS!V%D9T+lxx5Jw%5P@C*1bLa4m@c z&zF9nYd#QbWt??kF^D9Q@VqwN6zD{-nRzc+9kw$Ht$GpHhvR(Ov!X_Qc0T2SYsFM7 zK|PWReZw{UX5BLoUItDoZ3LYx{rc!2o8MV{d=`uUdv?nCw^{l86Y%}Ne|GD}EGs&y zhX4Uw4s8mKcVE}AN$)Omdd-o&P(G6J-;)V0)J#@z% zt9Yh!rncF1088Ksj-KvWP%3YdDJRqpyM8s{b4Ky%qun2XuYa4o3=S^S<~6n=IQAg+ zH&)|@mz~SHF27oT{yiUZuD{>2Y=3=e+(;+I_}ANuf(@t~8z`t(c; zSXpvCjheNPfyc5K?yV-tG`nUpko4r~4kd+Q!SUJ8{0*kkPHc)9190*W6PN?y3|Nh0 z%c`HsJHGOdZ0@r8e>Ze+_jXGKqL{X&td9bI6^IhG8P@7ZS&rDVb$kA-GtO2Kt@pYU zVYC!+bSjj}CaL2WAzPwoco_%d8YwG6*W9`I*?)XVUv%EeR28Eyg ze)`;wj%zAU{U)Bz8dgWGAJhFI%#K`v;Q14i_p6V{Tii?Ook7et*>izp9+NY?0{hnWpyc8@iS&z9VBf?#`z-29<+- zs%UwCCL8R^Qd!1}Z+$(RU+TxQ{>^yZC9~Ouoej?75%`qza3Tl_F>^{bTHtolQ=B8k zCdM`YCvA$qrx`&8qJ^6F(6QoTz3laGVXozTB5h|jrFojiwNSy=(B${Vu^b&Z05`1P zUx*ufr=?sW6q!;lUgKs$H_=Q6bHIU6zztGZ^^Lo@kQDw7FE3;>Rz8!GWc72)!DubQ zp1lVVH4@EcF-tSWnAGA=_w5_r-8F5V#qC0T-Z@wCh4W88FxrSdSoVX#TqbP=<(hVx z^RnsP(;wKibKjEpU;G}VM~B3@OXi6^U;nC(w>_n1v4cp3J0Yx5NHQ1+g+%s&A~m2* zfJjl3E7{tQ7k4XXCfK(|X-~8$=_{#g!UXMS%s2wJAgypuu4`0u(|Yx_5ij z9S&iR8^KlKaV~ITKjB>U2{zVLt;QIn^tM4aa(HCpj#lvNRrj#0=QLCpAdU#oLaraj zx(&~wTn)_k!&*(!vZj={+v2Go7dmEG2m7nB<8-2QAdr3gtG3%Sebw$->CbC{FXA{r z|HufrIeboM&%7JXNF$%l%7OKp<-x6kU5nF&%U}Dt^LXi@FEXZgLFFTZFCagqwIl>W znf|=T0gly3UScK=>d)XgwF%j12Gj(DFA{i`6{peFxmeq7Cx(VA`$Op~FrRTo)@w{yKvt2y zuB64S_sZZMoL(-ejT!fyjcp;o+mR-ntPV}g~7qn*3nW;JhOSb5>6IjTM_H?7JBD& zA>tNR%s(XxYr)64=F*njlGU0HD#NdowV_ox#83L$pZ&7emYLq3&7P}k&b7G#OI_8- ziW%#6qa##stR!$NIe}A?KGkP1HTf|pS#G*Pw{Qi&F#^h?hkjK?LCrj1O6RFKpr8sT zc&N0jhpLQYxKSVH?v2Yk982M!WSku*Q`Y3=C1eyL4nx%Ib&QUVaKZ!*4i3+XV`&c# z4r&-XgYVL{FHK$)uULrh_TY(Q}8VPvQ#E3 zSR+t~Bd#s7l0zn|*dr~zv$IgZwb$IB*6&b=RCg2K*G!MH1a(V6k!RoZ<|AKEv(1_@ z9t zjUdG>T?-^H4-ZwcxsE@(^QZTR^G;jFKeXa>ZCMsr7$aZaf%EMh=uVYUyJjJeMwIOE zW*5|G*bc%^wq=;IEGQvl7=~Opu0SI;wq07dOR#Hex>ivmGgeIJeYv!DJQuDfj13@6 zD%k?b!&K~mDTmrxMpURJ!Z<+Wmsr>sBxKOpu~M9Qwi1~XtdwnBDA|Y(6s`2$_q^`w zY{SD(Y66%{Jm7d`e{x_?M{gWjGms<0H33|GSk?$U*T?VHt<&HBzaP|=mw6MnY>P30 z$PW-yt6)(85mlvM8)05LwQByt1=}9{gzNjrB47vs}NgN_Y@iL2f**xH-8N9U8iskF&(T`^S=-q7V_BCjeLzouq!?b7z zx}rTu$G&ktr>YR2y8w(|f5~;$R0)&(i2@?hw;|&+V5J%=?I9|pAluUjJsS~YkbZ=o zOaXWQ=Q|iHHDF1Cb~(-B51RFLz|8){I3(A{xIgrIgy>+)dT;gT+_VL)gb@T84a0A& z!PU2(&OY=l}kTtRv%SJyL>G zw=l2j;fkFDSiWHo=D+#R*ul}1>Tze}+=~8zrSVEyEE5ZggIhLYx;&*2QxdFS-s)oDxM!MN!11N^p(M#wooWgrj%;%_rWa zSF{z;QS%`hF|te{@9)Fj^^db3yz7skMQ72j+jq{+bX+F1iWQF;t>rOUL>5Oxg)

|6y<>_*GTc_>}OB> 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ℑSW$SE;tFIDw>rHa-^T{tsQ*K*-qHGa;WD2c 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%0AoFH#Y9HMKJZ!w+ z20W{S_u4HxNv2}4*VES8e`SrEE=#7FUdkFM3JuES0FTjI;lz*=RsAZbzdL#z}&#Dgn+RLLZQvEP^alrIqB7j=Z1GC0<)ql~yJdJg*>? zePQ=MPX^c8U^<;*JRTcmU1siQQ9FXdGT6!}R7S&n)Cx=pFu|c-ww9qoHQIe;k3 z5=oju8SNGTlbiq~QB_JC!R{1|@TcQu$8rd6bj3toA*kq_063X>dd<8+=Ty!hI)lJ_&0p>H~mp}ap9Nt4)y6UvrZnEbWG&k$l zE)IfOC;nd{#zz`N}EjZ`S+uz*WVx4Zwo?KgR?`~}V`a-MoS&VVL zf3PnYvj~D~M*w|JXE~kq!a!3vsFeEiv?@OHaz0hf&^MaHs>7{b(@PE)K$_p|{{N33 zt(z^CmThRGMHq%A&vR!1gy7Bs(AS>t6QPjs z0-AjC2VRXv@GnmF7j&=SvYT3$Jq)DhFadhK5FI?ZLC;%h<<5JZm#i9 a$(rWHgw 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 getPartyMembers() { if (!isAvailable() || partyModelInstance == null || getPartyMembersMethod == null) { return Collections.emptyList(); @@ -45,7 +44,24 @@ public static List getPartyMembers() { try { Object res = getPartyMembersMethod.invoke(partyModelInstance); if (res instanceof List list) { - return (List) list; + if (list.isEmpty()) { + return Collections.emptyList(); + } + Object first = list.get(0); + if (first instanceof String) { + @SuppressWarnings("unchecked") + List typed = (List) res; + return typed; + } + // Wynntils may return List or similar — extract via toString. + LOGGER.warn("Wynntils party members returned non-String type: {}; falling back to toString()", first.getClass().getName()); + List names = new java.util.ArrayList<>(list.size()); + for (Object obj : list) { + if (obj != null) { + names.add(obj.toString()); + } + } + return names; } } catch (Exception ignored) { } From e084fba2e88e8273df93c1c6b96dd14cb3cb6ba5 Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:51:07 +0300 Subject: [PATCH 03/10] Add Discord chat reply UI, raid ready-up ping, :nukegrootplush:, and party disband --- .../textures/catplush/nukegrootplush.png | Bin 0 -> 38991 bytes src/tel/eden/mod/EdenModClient.java | 33 ++- src/tel/eden/mod/chat/ChatReplyManager.java | 208 ++++++++++++++++ .../eden/mod/chat/DiscordChatFormatter.java | 58 ++++- src/tel/eden/mod/chat/PlayerNameResolver.java | 2 +- src/tel/eden/mod/chat/RaidReadyTracker.java | 227 ++++++++++++++++++ src/tel/eden/mod/config/BridgeConfig.java | 3 + src/tel/eden/mod/gui/BridgeConfigScreen.java | 1 + src/tel/eden/mod/mixin/ChatScreenMixin.java | 42 ++++ .../eden/mod/net/BridgeWebSocketClient.java | 16 +- .../eden/mod/chat/ChatReplyManagerTest.java | 110 +++++++++ .../eden/mod/chat/RaidReadyTrackerTest.java | 62 +++++ 12 files changed, 744 insertions(+), 18 deletions(-) create mode 100644 resources/assets/edenmod/textures/catplush/nukegrootplush.png create mode 100644 src/tel/eden/mod/chat/ChatReplyManager.java create mode 100644 src/tel/eden/mod/chat/RaidReadyTracker.java create mode 100644 test/tel/eden/mod/chat/ChatReplyManagerTest.java create mode 100644 test/tel/eden/mod/chat/RaidReadyTrackerTest.java diff --git a/resources/assets/edenmod/textures/catplush/nukegrootplush.png b/resources/assets/edenmod/textures/catplush/nukegrootplush.png new file mode 100644 index 0000000000000000000000000000000000000000..fd5344de2260c6f0c1da22ae833d6087c9c15d98 GIT binary patch literal 38991 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 LNkvXXu0mjf);{Vh literal 0 HcmV?d00001 diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 19e927c..402f5e2 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -20,10 +20,12 @@ import tel.eden.mod.chat.LevelUp; import tel.eden.mod.chat.LevelUpParser; import tel.eden.mod.chat.OccurrenceSequencer; +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.chat.RankChange; import tel.eden.mod.chat.RankChangeParser; import tel.eden.mod.chat.ShoutParser; @@ -656,7 +658,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 -> { @@ -714,6 +716,7 @@ private void onClientTick(Minecraft client) { WarDPS.onTick(config); WarTracker.onTick(); RaidPartyTracker.onTick(); + RaidReadyTracker.onClientTick(); AttackMenuScraper.onTick(client); AllianceMenuScraper.onTick(client); GuildMenuScraper.onTick(client); @@ -860,8 +863,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 +1455,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 +2051,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 +2228,8 @@ 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(); + 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. @@ -2260,6 +2278,7 @@ public void handleSystemChat(Component message) { // 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 +2436,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(), line.message()); + current.sendGuildChat(line.username(), line.nickname(), line.message(), seq, replyToId); } } @@ -2438,6 +2458,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..843619e --- /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, String message) { + 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/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..ea1817d 100644 --- a/src/tel/eden/mod/chat/PlayerNameResolver.java +++ b/src/tel/eden/mod/chat/PlayerNameResolver.java @@ -71,7 +71,7 @@ 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; } for (PlayerInfo info : mc.getConnection().getOnlinePlayers()) { diff --git a/src/tel/eden/mod/chat/RaidReadyTracker.java b/src/tel/eden/mod/chat/RaidReadyTracker.java new file mode 100644 index 0000000..481eac3 --- /dev/null +++ b/src/tel/eden/mod/chat/RaidReadyTracker.java @@ -0,0 +1,227 @@ +package tel.eden.mod.chat; + +import java.util.HashSet; +import java.util.Locale; +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.sounds.SoundEvents; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.config.BridgeConfig; + +/** + * Tracks raid ready-up messages in chat, 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\\[!\\]\\s*]+)?([a-zA-Z0-9_/ ()-]+?)\\s+is ready!\\s*$", Pattern.CASE_INSENSITIVE); + private static final Pattern UNREADY_PATTERN = Pattern.compile("^(?:[\\u27A4\\u25C6\\u25B6\\[!\\]\\s*]+)?([a-zA-Z0-9_/ ()-]+?)\\s+is no longer ready!\\s*$", Pattern.CASE_INSENSITIVE); + private static final Pattern RAID_START_PATTERN = Pattern.compile("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 RaidReadyTracker() { + } + + public static void onSystemChat(Component message) { + String text = ChatText.normalizeWhitespace(message.getString()); + if (text.isEmpty()) { + return; + } + + if (RAID_START_PATTERN.matcher(text).find()) { + reset(); + return; + } + + Matcher readyMatcher = READY_PATTERN.matcher(text); + if (readyMatcher.matches()) { + 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()) { + String rawName = unreadyMatcher.group(1).trim(); + String realName = extractRealName(message, rawName); + if (realName != null) { + handlePlayerUnready(realName); + } + } + } + + public static void onClientTick() { + if (waitingSince > 0L && !alertSent) { + if (System.currentTimeMillis() - waitingSince >= 60_000L) { + alertSent = true; + triggerOneMinuteAlert(); + } + } + } + + 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() { + EdenModClient client = EdenModClient.instance(); + String self = client != null ? client.playerName() : null; + if (self == null) { + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.player != null) { + self = mc.player.getGameProfile().name(); + } + } + + if (self == null) { + return; + } + + String selfLower = self.toLowerCase(Locale.ROOT); + boolean selfReady = readyPlayers.contains(selfLower); + int othersReady = readyPlayers.size() - (selfReady ? 1 : 0); + + if (!selfReady && othersReady >= 3) { + if (waitingSince == 0L) { + waitingSince = System.currentTimeMillis(); + alertSent = false; + } + } else { + waitingSince = 0L; + alertSent = false; + } + } + + private static void triggerOneMinuteAlert() { + EdenModClient client = EdenModClient.instance(); + BridgeConfig config = client != null ? client.config() : null; + if (config == null || config.raidReadyPing) { + playPingSound(0.9f); + } + + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.player != null) { + 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 1 minute.").withStyle(ChatFormatting.YELLOW)); + mc.player.displayClientMessage(alert, false); + } + } + + 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; + } + + /** + * 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 cand1 = s.substring(0, slash).trim(); + String cand2 = s.substring(slash + 1).trim(); + String known = PlayerNameResolver.resolveKnown(cand2).orElse(null); + if (known != null) { + return known; + } + if (ChatText.IGN.matcher(cand1).matches()) { + return cand1; + } + if (ChatText.IGN.matcher(cand2).matches()) { + return cand2; + } + } + + // 3. "IGN(nickname)" or "nickname(IGN)" format + int paren = s.indexOf('('); + if (paren > 0 && s.endsWith(")")) { + String cand1 = s.substring(0, paren).trim(); + String cand2 = s.substring(paren + 1, s.length() - 1).trim(); + String known1 = PlayerNameResolver.resolveKnown(cand1).orElse(null); + if (known1 != null) { + return known1; + } + String known2 = PlayerNameResolver.resolveKnown(cand2).orElse(null); + if (known2 != null) { + return known2; + } + if (ChatText.IGN.matcher(cand1).matches()) { + return cand1; + } + if (ChatText.IGN.matcher(cand2).matches()) { + return cand2; + } + } + + // 4. Fall back to PlayerNameResolver + String resolved = PlayerNameResolver.resolve(message, s); + if (resolved != null && ChatText.IGN.matcher(resolved).matches()) { + return resolved; + } + return s; + } + + // 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 fc63065..00190b4 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -192,6 +192,9 @@ public void sanitize() { /** Unique party member outline highlights in raids/dungeons (requires Wynntils). */ public boolean partyHighlightEnabled = true; + /** Send a ping sound when party members ready up, and alert if waiting 1 minute. */ + public boolean raidReadyPing = true; + /** Custom palette for the 10 party slots. Defaults to PartyHighlightManager.DEFAULT_PALETTE. */ public List partyColors = new ArrayList<>(tel.eden.mod.party.PartyHighlightManager.DEFAULT_PALETTE); diff --git a/src/tel/eden/mod/gui/BridgeConfigScreen.java b/src/tel/eden/mod/gui/BridgeConfigScreen.java index f1ba57a..bd2285d 100644 --- a/src/tel/eden/mod/gui/BridgeConfigScreen.java +++ b/src/tel/eden/mod/gui/BridgeConfigScreen.java @@ -129,6 +129,7 @@ protected void init() { addButtonRow("Emote wheel favorites", () -> Component.literal("Edit..."), () -> this.minecraft.setScreen(new tel.eden.mod.emote.EmoteConfigScreen(this, config)), () -> { }); addToggleRow("Party member outlines", () -> config.partyHighlightEnabled, v -> config.partyHighlightEnabled = v, "On", "Off", true); + addToggleRow("Raid ready-up ping", () -> config.raidReadyPing, v -> config.raidReadyPing = v, "On", "Off", true); // ------------------------------------------------------------------------ this.addRenderableWidget(Button.builder(Component.literal("Done"), button -> onClose()).bounds(cx, this.height - 30, cw, 20).build()); diff --git a/src/tel/eden/mod/mixin/ChatScreenMixin.java b/src/tel/eden/mod/mixin/ChatScreenMixin.java index 6bf0f5a..22d8ad1 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,38 @@ 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()) { + ChatReplyManager.markPendingReply(); + } } @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 +254,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 +320,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/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/test/tel/eden/mod/chat/ChatReplyManagerTest.java b/test/tel/eden/mod/chat/ChatReplyManagerTest.java new file mode 100644 index 0000000..bff8291 --- /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", "Answer"); + assertEquals("222", replyId); + + // Second consume returns null (consumed) + assertNull(ChatReplyManager.consumePendingReply("AnyUser", "Answer")); + } + + @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/RaidReadyTrackerTest.java b/test/tel/eden/mod/chat/RaidReadyTrackerTest.java new file mode 100644 index 0000000..88fd65b --- /dev/null +++ b/test/tel/eden/mod/chat/RaidReadyTrackerTest.java @@ -0,0 +1,62 @@ +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.assertTrue; + +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("Tawnyy/20 is no longer ready!")); + assertFalse(RaidReadyTracker.readyPlayersForTesting().contains("tawnyy")); + assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("youremomgood")); + } + + @Test + void resetsOnNewRaidPrompt() { + 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(0, RaidReadyTracker.readyPlayersForTesting().size()); + } +} From f539154677e9ba830fd0d37c06b1c08a30ac4b75 Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:04:20 +0300 Subject: [PATCH 04/10] Improve raid ready tracking with scoreboard pawn parsing and debug command --- src/tel/eden/mod/EdenModClient.java | 10 +- src/tel/eden/mod/chat/PlayerNameResolver.java | 4 + src/tel/eden/mod/chat/RaidReadyTracker.java | 379 ++++++++++++++++-- .../eden/mod/chat/RaidReadyTrackerTest.java | 79 +++- 4 files changed, 425 insertions(+), 47 deletions(-) diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 402f5e2..a748a1b 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -673,12 +673,15 @@ 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)); } return 1; })).then(ClientCommandManager.literal("help").executes(ctx -> { @@ -780,6 +783,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; diff --git a/src/tel/eden/mod/chat/PlayerNameResolver.java b/src/tel/eden/mod/chat/PlayerNameResolver.java index ea1817d..b93a0db 100644 --- a/src/tel/eden/mod/chat/PlayerNameResolver.java +++ b/src/tel/eden/mod/chat/PlayerNameResolver.java @@ -88,6 +88,10 @@ private static String resolveFromTabList(String displayed) { if (hoverResolved != null && ChatText.IGN.matcher(hoverResolved).matches()) { return hoverResolved; } + String profileName = info.getProfile().name(); + if (profileName != null && ChatText.IGN.matcher(profileName).matches()) { + 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. diff --git a/src/tel/eden/mod/chat/RaidReadyTracker.java b/src/tel/eden/mod/chat/RaidReadyTracker.java index 481eac3..d9b65d7 100644 --- a/src/tel/eden/mod/chat/RaidReadyTracker.java +++ b/src/tel/eden/mod/chat/RaidReadyTracker.java @@ -1,7 +1,10 @@ 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; @@ -10,22 +13,48 @@ 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, plays a ping sound when someone readies, - * and sends an alert if 3 party members have been waiting for the player for 1 minute. + * 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\\[!\\]\\s*]+)?([a-zA-Z0-9_/ ()-]+?)\\s+is ready!\\s*$", Pattern.CASE_INSENSITIVE); - private static final Pattern UNREADY_PATTERN = Pattern.compile("^(?:[\\u27A4\\u25C6\\u25B6\\[!\\]\\s*]+)?([a-zA-Z0-9_/ ()-]+?)\\s+is no longer ready!\\s*$", Pattern.CASE_INSENSITIVE); - private static final Pattern RAID_START_PATTERN = Pattern.compile("would like to start\\s+(.+?)!", Pattern.CASE_INSENSITIVE); + 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() { } @@ -36,13 +65,37 @@ public static void onSystemChat(Component message) { return; } - if (RAID_START_PATTERN.matcher(text).find()) { - reset(); + 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) { @@ -53,6 +106,9 @@ public static void onSystemChat(Component message) { 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) { @@ -62,6 +118,11 @@ public static void onSystemChat(Component message) { } public static void onClientTick() { + tickCounter++; + if (tickCounter % 10 == 0) { + checkWaitingState(); + } + if (waitingSince > 0L && !alertSent) { if (System.currentTimeMillis() - waitingSince >= 60_000L) { alertSent = true; @@ -88,12 +149,27 @@ private static void handlePlayerUnready(String realName) { } 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) { - Minecraft mc = Minecraft.getInstance(); - if (mc != null && mc.player != null) { - self = mc.player.getGameProfile().name(); + try { + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.player != null) { + self = mc.player.getGameProfile().name(); + } + } catch (Throwable ignored) { } } @@ -103,7 +179,25 @@ private static void checkWaitingState() { String selfLower = self.toLowerCase(Locale.ROOT); boolean selfReady = readyPlayers.contains(selfLower); - int othersReady = readyPlayers.size() - (selfReady ? 1 : 0); + 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) { @@ -116,6 +210,147 @@ private static void checkWaitingState() { } } + 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 triggerOneMinuteAlert() { EdenModClient client = EdenModClient.instance(); BridgeConfig config = client != null ? client.config() : null; @@ -123,10 +358,13 @@ private static void triggerOneMinuteAlert() { playPingSound(0.9f); } - Minecraft mc = Minecraft.getInstance(); - if (mc != null && mc.player != null) { - 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 1 minute.").withStyle(ChatFormatting.YELLOW)); - mc.player.displayClientMessage(alert, false); + try { + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.player != null) { + 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 1 minute.").withStyle(ChatFormatting.YELLOW)); + mc.player.displayClientMessage(alert, false); + } + } catch (Throwable ignored) { } } @@ -144,6 +382,65 @@ 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 + ")"); + + if (pawns == null) { + lines.add("1-min waiting alert: inactive (no raid ready prompt on scoreboard)"); + } else if (waitingSince > 0L) { + long elapsedSec = (System.currentTimeMillis() - waitingSince) / 1000L; + lines.add("1-min waiting alert: ACTIVE (" + elapsedSec + "s elapsed, alertSent=" + alertSent + ")"); + } else { + lines.add("1-min waiting alert: inactive" + (othersReady >= 3 && selfReady ? " (you are already ready)" : (othersReady < 3 ? " (need 3 others ready)" : ""))); + } + return lines; } /** @@ -165,42 +462,22 @@ public static String extractRealName(Component message, String displayed) { // 2. "realIGN/nickname" format int slash = s.indexOf('/'); if (slash > 0) { - String cand1 = s.substring(0, slash).trim(); - String cand2 = s.substring(slash + 1).trim(); - String known = PlayerNameResolver.resolveKnown(cand2).orElse(null); - if (known != null) { - return known; - } - if (ChatText.IGN.matcher(cand1).matches()) { - return cand1; - } - if (ChatText.IGN.matcher(cand2).matches()) { - return cand2; + 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 cand1 = s.substring(0, paren).trim(); - String cand2 = s.substring(paren + 1, s.length() - 1).trim(); - String known1 = PlayerNameResolver.resolveKnown(cand1).orElse(null); - if (known1 != null) { - return known1; - } - String known2 = PlayerNameResolver.resolveKnown(cand2).orElse(null); - if (known2 != null) { - return known2; - } - if (ChatText.IGN.matcher(cand1).matches()) { - return cand1; - } - if (ChatText.IGN.matcher(cand2).matches()) { - return cand2; + 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 + // 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; @@ -208,6 +485,24 @@ public static String extractRealName(Component message, String displayed) { 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; diff --git a/test/tel/eden/mod/chat/RaidReadyTrackerTest.java b/test/tel/eden/mod/chat/RaidReadyTrackerTest.java index 88fd65b..886fdda 100644 --- a/test/tel/eden/mod/chat/RaidReadyTrackerTest.java +++ b/test/tel/eden/mod/chat/RaidReadyTrackerTest.java @@ -2,8 +2,11 @@ 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; @@ -45,18 +48,90 @@ void tracksReadyAndUnreadyMessages() { 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 resetsOnNewRaidPrompt() { + 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(0, RaidReadyTracker.readyPlayersForTesting().size()); + assertEquals(1, RaidReadyTracker.readyPlayersForTesting().size()); + assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("bynt")); + + // Wynncraft starter with nickname: Cartiye/kyousuke + RaidReadyTracker.onSystemChat(Component.literal(" Cartiye/kyousuke would like to start The Nameless Anomaly!")); + assertEquals(1, RaidReadyTracker.readyPlayersForTesting().size()); + assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("cartiye")); + } + + @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")); } } From 1898da494eb3626d4868d70ae900338ed71e1588 Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:16:31 +0300 Subject: [PATCH 05/10] Add overhead party health bars and configurable raid ready reminder --- resources/edenmod.mixins.json | 3 + src/tel/eden/mod/EdenModClient.java | 5 + src/tel/eden/mod/chat/ChatText.java | 6 +- src/tel/eden/mod/chat/PlayerNameResolver.java | 23 + src/tel/eden/mod/chat/RaidReadyTracker.java | 51 +- src/tel/eden/mod/config/BridgeConfig.java | 11 +- src/tel/eden/mod/gui/BridgeConfigScreen.java | 80 +++ .../mod/mixin/AvatarRenderStateMixin.java | 23 + .../eden/mod/mixin/AvatarRendererMixin.java | 24 + .../mod/mixin/LivingEntityRendererMixin.java | 23 + .../mod/party/PartyHealthBarRenderer.java | 178 +++++++ .../eden/mod/party/PartyHealthTracker.java | 457 ++++++++++++++++++ src/tel/eden/mod/render/EdenAvatarState.java | 13 + .../eden/mod/chat/RaidReadyTrackerTest.java | 16 +- .../mod/party/PartyHealthTrackerTest.java | 142 ++++++ 15 files changed, 1040 insertions(+), 15 deletions(-) create mode 100644 src/tel/eden/mod/mixin/AvatarRenderStateMixin.java create mode 100644 src/tel/eden/mod/mixin/AvatarRendererMixin.java create mode 100644 src/tel/eden/mod/mixin/LivingEntityRendererMixin.java create mode 100644 src/tel/eden/mod/party/PartyHealthBarRenderer.java create mode 100644 src/tel/eden/mod/party/PartyHealthTracker.java create mode 100644 src/tel/eden/mod/render/EdenAvatarState.java create mode 100644 test/tel/eden/mod/party/PartyHealthTrackerTest.java diff --git a/resources/edenmod.mixins.json b/resources/edenmod.mixins.json index e4f8645..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", @@ -22,6 +24,7 @@ "GuiGraphicsMixin", "ItemDecorationMixin", "ItemRenderMixin", + "LivingEntityRendererMixin", "TitleScreenMixin" ], "injectors": { diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index a748a1b..8c73d00 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -20,12 +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; @@ -720,6 +722,7 @@ private void onClientTick(Minecraft client) { WarTracker.onTick(); RaidPartyTracker.onTick(); RaidReadyTracker.onClientTick(); + PartyHealthTracker.tick(); AttackMenuScraper.onTick(client); AllianceMenuScraper.onTick(client); GuildMenuScraper.onTick(client); @@ -2233,6 +2236,7 @@ private synchronized void disconnect() { // 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 @@ -2277,6 +2281,7 @@ private static void sendStreamCommand() { /** Called from the chat-capture mixin for every non-overlay system-chat component. */ public void handleSystemChat(Component message) { + PlayerNameResolver.learnFromText(message.getString()); 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). diff --git a/src/tel/eden/mod/chat/ChatText.java b/src/tel/eden/mod/chat/ChatText.java index 71dda51..1e67bf5 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); diff --git a/src/tel/eden/mod/chat/PlayerNameResolver.java b/src/tel/eden/mod/chat/PlayerNameResolver.java index b93a0db..21f8857 100644 --- a/src/tel/eden/mod/chat/PlayerNameResolver.java +++ b/src/tel/eden/mod/chat/PlayerNameResolver.java @@ -110,6 +110,29 @@ public static void recordAlias(String displayed, String username) { USERNAME_BY_DISPLAY.put(key(username), username); } + private static final java.util.regex.Pattern SLASH_ALIAS_PATTERN = java.util.regex.Pattern.compile("(? 0L && !alertSent) { - if (System.currentTimeMillis() - waitingSince >= 60_000L) { + 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; - triggerOneMinuteAlert(); + triggerWaitingReminderAlert(waitSec); } } } @@ -351,23 +354,52 @@ private static Scoreboard sidebarScoreboard() { } } - private static void triggerOneMinuteAlert() { + private static void triggerWaitingReminderAlert(int waitSeconds) { EdenModClient client = EdenModClient.instance(); BridgeConfig config = client != null ? client.config() : null; if (config == null || config.raidReadyPing) { - playPingSound(0.9f); + playAnvilDropSound(); } try { Minecraft mc = Minecraft.getInstance(); if (mc != null && mc.player != null) { - 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 1 minute.").withStyle(ChatFormatting.YELLOW)); + 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(); @@ -432,13 +464,16 @@ public static List debugState() { 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("1-min waiting alert: inactive (no raid ready prompt on scoreboard)"); + lines.add("waiting alert: inactive (no raid ready prompt on scoreboard)"); } else if (waitingSince > 0L) { long elapsedSec = (System.currentTimeMillis() - waitingSince) / 1000L; - lines.add("1-min waiting alert: ACTIVE (" + elapsedSec + "s elapsed, alertSent=" + alertSent + ")"); + lines.add("waiting alert: ACTIVE (" + elapsedSec + "s / " + waitSec + "s elapsed, alertSent=" + alertSent + ")"); } else { - lines.add("1-min waiting alert: inactive" + (othersReady >= 3 && selfReady ? " (you are already ready)" : (othersReady < 3 ? " (need 3 others ready)" : ""))); + lines.add("waiting alert: inactive" + (othersReady >= 3 && selfReady ? " (you are already ready)" : (othersReady < 3 ? " (need 3 others ready)" : ""))); } return lines; } diff --git a/src/tel/eden/mod/config/BridgeConfig.java b/src/tel/eden/mod/config/BridgeConfig.java index 00190b4..b5db030 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -192,9 +192,18 @@ public void sanitize() { /** Unique party member outline highlights in raids/dungeons (requires Wynntils). */ public boolean partyHighlightEnabled = true; - /** Send a ping sound when party members ready up, and alert if waiting 1 minute. */ + /** 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; + + /** 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); diff --git a/src/tel/eden/mod/gui/BridgeConfigScreen.java b/src/tel/eden/mod/gui/BridgeConfigScreen.java index bd2285d..f9eb4e9 100644 --- a/src/tel/eden/mod/gui/BridgeConfigScreen.java +++ b/src/tel/eden/mod/gui/BridgeConfigScreen.java @@ -129,7 +129,12 @@ protected void init() { addButtonRow("Emote wheel favorites", () -> Component.literal("Edit..."), () -> this.minecraft.setScreen(new tel.eden.mod.emote.EmoteConfigScreen(this, config)), () -> { }); addToggleRow("Party member outlines", () -> config.partyHighlightEnabled, v -> config.partyHighlightEnabled = v, "On", "Off", true); + addToggleRow("Party health bars", () -> config.partyHealthBarEnabled, v -> config.partyHealthBarEnabled = v, "On", "Off", true); + HealthBarScaleSlider scaleSlider = new HealthBarScaleSlider(CONTROL_W, 20); + addSliderRow("Party health bar size", scaleSlider, scaleSlider::syncFromConfig, () -> config.partyHealthBarScale = 100); addToggleRow("Raid ready-up ping", () -> config.raidReadyPing, v -> config.raidReadyPing = v, "On", "Off", true); + RaidReminderSlider reminderSlider = new RaidReminderSlider(CONTROL_W, 20); + addSliderRow("Ready reminder delay", reminderSlider, reminderSlider::syncFromConfig, () -> config.raidReadyReminderSeconds = 60); // ------------------------------------------------------------------------ this.addRenderableWidget(Button.builder(Component.literal("Done"), button -> onClose()).bounds(cx, this.height - 30, cw, 20).build()); @@ -511,4 +516,79 @@ protected void applyValue() { updateMessage(); } } + + private final class RaidReminderSlider extends AbstractSliderButton { + private static final int MIN = 10; + private static final int MAX = 300; + + private RaidReminderSlider(int width, int height) { + super(0, 0, width, height, Component.empty(), 0.0d); + syncFromConfig(); + } + + private void syncFromConfig() { + int current = Math.max(MIN, Math.min(MAX, config.raidReadyReminderSeconds)); + this.value = (current - MIN) / (double) (MAX - MIN); + updateMessage(); + } + + @Override + protected void updateMessage() { + int sec = config.raidReadyReminderSeconds; + if (sec == 60) { + setMessage(Component.literal("1 min")); + } else if (sec % 60 == 0) { + setMessage(Component.literal((sec / 60) + " mins")); + } else if (sec < 60) { + setMessage(Component.literal(sec + "s")); + } else { + setMessage(Component.literal((sec / 60) + "m " + (sec % 60) + "s")); + } + } + + @Override + protected void applyValue() { + int snapped = MIN + (int) Math.round(this.value * (MAX - MIN)); + snapped = Math.round(snapped / 5.0f) * 5; + snapped = Math.max(MIN, Math.min(MAX, snapped)); + if (snapped != config.raidReadyReminderSeconds) { + config.raidReadyReminderSeconds = snapped; + config.save(); + } + updateMessage(); + } + } + + private final class HealthBarScaleSlider extends AbstractSliderButton { + private static final int MIN = 50; + private static final int MAX = 200; + + private HealthBarScaleSlider(int width, int height) { + super(0, 0, width, height, Component.empty(), 0.0d); + syncFromConfig(); + } + + private void syncFromConfig() { + int current = Math.max(MIN, Math.min(MAX, config.partyHealthBarScale)); + this.value = (current - MIN) / (double) (MAX - MIN); + updateMessage(); + } + + @Override + protected void updateMessage() { + setMessage(Component.literal(config.partyHealthBarScale + "%")); + } + + @Override + protected void applyValue() { + int snapped = MIN + (int) Math.round(this.value * (MAX - MIN)); + snapped = Math.round(snapped / 5.0f) * 5; + snapped = Math.max(MIN, Math.min(MAX, snapped)); + if (snapped != config.partyHealthBarScale) { + config.partyHealthBarScale = 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..5c6153c --- /dev/null +++ b/src/tel/eden/mod/mixin/AvatarRendererMixin.java @@ -0,0 +1,24 @@ +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.party.PartyHealthBarRenderer; +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/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/party/PartyHealthBarRenderer.java b/src/tel/eden/mod/party/PartyHealthBarRenderer.java new file mode 100644 index 0000000..e3cf5df --- /dev/null +++ b/src/tel/eden/mod/party/PartyHealthBarRenderer.java @@ -0,0 +1,178 @@ +package tel.eden.mod.party; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import java.util.List; +import java.util.UUID; +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.world.phys.Vec3; +import tel.eden.mod.EdenModClient; +import tel.eden.mod.config.BridgeConfig; +import tel.eden.mod.render.EdenAvatarState; + +/** + * Renders an Eden-themed overhead health bar above party members' nametags. + * + *

Theme elements: + *

    + *
  • Obsidian slate border with dark crimson deficit underlay
  • + *
  • Dynamic health progression: Emerald Green (>50%) -> Amber (>25%) -> Crimson (<=25%)
  • + *
  • Overheal/overMax renders in radiant Eden Gold
  • + *
  • Party slot pip on the left border matching the player's party outline color
  • + *
+ */ +public final class PartyHealthBarRenderer { + private static final float NAMETAG_SCALE = 0.025f; + private static final float BAR_WIDTH = 38.0f; + private static final float BAR_HEIGHT = 4.0f; + private static final float BORDER = 1.0f; + private static final int FULL_BRIGHT = 0xF000F0; + + private static final int BORDER_COLOR = 0xFF0A0F14; + private static final int BG_COLOR = 0xFF141A22; + private static final int DEFICIT_COLOR = 0xFF2A1215; + private static final int COLOR_EMERALD = 0xFF10B981; + private static final int COLOR_AMBER = 0xFFF59E0B; + private static final int COLOR_CRIMSON = 0xFFEF4444; + private static final int COLOR_GOLD = 0xFFFBBF24; + private static final int DIVIDER_COLOR = 0x66000000; + private static final boolean WYNNTILS_LOADED = FabricLoader.getInstance().isModLoaded("wynntils"); + + private PartyHealthBarRenderer() { + } + + 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())) { + 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 effectiveScale = NAMETAG_SCALE * scaleFactor; + 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, health, config); + poseStack.popPose(); + } + + 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, PartyHealthTracker.PlayerHealthData health, BridgeConfig config) { + float rawPercent = health.percent(); + float percent = Float.isNaN(rawPercent) ? 1.0f : Math.max(0.0f, Math.min(1.0f, rawPercent)); + float fillWidth = Math.round(BAR_WIDTH * percent); + float x = -BAR_WIDTH / 2.0f; + float y = 0.0f; + + int fillColor; + if (health.overMax()) { + fillColor = COLOR_GOLD; + } else if (percent > 0.50f) { + fillColor = COLOR_EMERALD; + } else if (percent > 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_EMERALD; + + collector.submitCustomGeometry(poseStack, RenderTypes.textBackground(), (pose, vertices) -> { + // 1. Obsidian Outer Border + drawQuad(vertices, pose, FULL_BRIGHT, x - BORDER, y - BORDER, BAR_WIDTH + (BORDER * 2.0f), BORDER, 0.01f, BORDER_COLOR); + drawQuad(vertices, pose, FULL_BRIGHT, x - BORDER, y + BAR_HEIGHT, BAR_WIDTH + (BORDER * 2.0f), BORDER, 0.01f, BORDER_COLOR); + drawQuad(vertices, pose, FULL_BRIGHT, x - BORDER, y, BORDER, BAR_HEIGHT, 0.01f, BORDER_COLOR); + drawQuad(vertices, pose, FULL_BRIGHT, x + BAR_WIDTH, y, BORDER, BAR_HEIGHT, 0.01f, BORDER_COLOR); + + // 2. Party Slot Gem/Pip on the left edge (aligned with bar height) + float pipSize = BAR_HEIGHT; + float pipX = x - BORDER - pipSize - 2.0f; + float pipY = y; + + // Pip obsidian border edges + drawQuad(vertices, pose, FULL_BRIGHT, pipX - BORDER, pipY - BORDER, pipSize + (BORDER * 2.0f), BORDER, 0.01f, BORDER_COLOR); + drawQuad(vertices, pose, FULL_BRIGHT, pipX - BORDER, pipY + pipSize, pipSize + (BORDER * 2.0f), BORDER, 0.01f, BORDER_COLOR); + drawQuad(vertices, pose, FULL_BRIGHT, pipX - BORDER, pipY, BORDER, pipSize, 0.01f, BORDER_COLOR); + drawQuad(vertices, pose, FULL_BRIGHT, pipX + pipSize, pipY, BORDER, pipSize, 0.01f, BORDER_COLOR); + + // Pip slot color fill + drawQuad(vertices, pose, FULL_BRIGHT, pipX, pipY, pipSize, pipSize, 0.002f, slotColor); + + // 3. Dark Deficit Background (missing HP) + if (fillWidth < BAR_WIDTH) { + drawQuad(vertices, pose, FULL_BRIGHT, x + fillWidth, y, BAR_WIDTH - fillWidth, BAR_HEIGHT, 0.001f, DEFICIT_COLOR); + } + + // 4. Health Fill + if (fillWidth > 0.0f) { + drawQuad(vertices, pose, FULL_BRIGHT, x, y, fillWidth, BAR_HEIGHT, 0.002f, fillColor); + } + + // 5. Tactical Quarter Dividers (25%, 50%, 75%) + for (float mark = 0.25f; mark < 1.0f; mark += 0.25f) { + float markX = x + (BAR_WIDTH * mark); + drawQuad(vertices, pose, FULL_BRIGHT, markX, y, 0.5f, BAR_HEIGHT, 0.003f, DIVIDER_COLOR); + } + }); + } + + private static void drawQuad(VertexConsumer vertices, PoseStack.Pose pose, int light, float x, float y, float width, float height, float z, int color) { + vertices.addVertex(pose, x, y, z).setLight(light).setColor(color); + vertices.addVertex(pose, x, y + height, z).setLight(light).setColor(color); + vertices.addVertex(pose, x + width, y + height, z).setLight(light).setColor(color); + vertices.addVertex(pose, x + width, y, z).setLight(light).setColor(color); + } +} diff --git a/src/tel/eden/mod/party/PartyHealthTracker.java b/src/tel/eden/mod/party/PartyHealthTracker.java new file mode 100644 index 0000000..1503c96 --- /dev/null +++ b/src/tel/eden/mod/party/PartyHealthTracker.java @@ -0,0 +1,457 @@ +package tel.eden.mod.party; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.minecraft.client.Minecraft; +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 REFRESH_INTERVAL_MS = 250L; + + public record PlayerHealthData(UUID uuid, String name, int hp, int maxHp, float percent, boolean overMax, int partySlot) { + } + + record ParsedPartyLine(int hp, String nickname, int level, boolean online, boolean fullHealthBar) { + } + + private static class MaxHpState { + int stableMaxHp; + int candidateHp; + long candidateSinceMs; + + MaxHpState(int initialHp) { + this.stableMaxHp = initialHp; + this.candidateHp = initialHp; + this.candidateSinceMs = System.currentTimeMillis(); + } + } + + private static final Map HEALTH_BY_UUID = new ConcurrentHashMap<>(); + private static final Map MAX_HP_BY_UUID = 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(); + lastRefreshMs = 0L; + } + + 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<>(); + + for (int i = 0; i < parsedRows.size(); i++) { + ParsedPartyLine row = parsedRows.get(i); + Component comp = rowComponents.get(i); + + if (!row.online()) { + continue; + } + + if (i < candidateRoster.size() && candidateRoster.get(i) != null) { + PlayerNameResolver.recordAlias(row.nickname(), candidateRoster.get(i)); + } + + UUID uuid = resolvePlayerUuid(mc, row.nickname(), comp, wynnPartyMembers, i, parsedRows.size()); + if (uuid == null) { + continue; + } + seenUuids.add(uuid); + + MaxHpState maxState = MAX_HP_BY_UUID.computeIfAbsent(uuid, k -> new MaxHpState(row.hp())); + int maxHp = updateMaxHp(maxState, row.hp(), row.fullHealthBar(), now); + + float percent = maxHp > 0 ? Math.max(0.0f, Math.min(1.0f, row.hp() / (float) maxHp)) : 1.0f; + boolean overMax = maxHp > 0 && row.hp() > maxHp; + + int slot = resolvePartySlot(row.nickname(), wynnPartyMembers, i); + HEALTH_BY_UUID.put(uuid, new PlayerHealthData(uuid, row.nickname(), row.hp(), maxHp, percent, overMax, slot)); + } + + HEALTH_BY_UUID.keySet().retainAll(seenUuids); + MAX_HP_BY_UUID.keySet().retainAll(seenUuids); + } + + static ParsedPartyLine parsePartyLine(Component component, boolean inPartySection) { + if (component == null || !inPartySection) { + return null; + } + String text = component.getString().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); + } + } + return null; + } + + Matcher hpMatcher = FIRST_NUMBER.matcher(text); + hpMatcher.region(0, level.start()); + if (!hpMatcher.find()) { + return null; + } + + int hp = Integer.parseInt(hpMatcher.group(1)); + + int nickStart = hpMatcher.end(); + while (nickStart < level.start() && !isNicknameChar(text.charAt(nickStart))) { + nickStart++; + } + int nickEnd = level.start(); + while (nickEnd > nickStart && !isNicknameChar(text.charAt(nickEnd - 1))) { + nickEnd--; + } + if (nickStart >= nickEnd) { + return null; + } + + String nickname = text.substring(nickStart, nickEnd).trim(); + if (nickname.isEmpty()) { + return null; + } + + // Check if health bracket contains full health (all red characters) + boolean fullBar = isFullHealthBar(component, text, hpMatcher.start(), hpMatcher.end(), level.start()); + + return new ParsedPartyLine(hp, nickname, level.value(), true, fullBar); + } + + private static boolean isNicknameChar(char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_'; + } + + private static boolean isFullHealthBar(Component component, String text, int hpStart, int hpEnd, int levelStart) { + int barStart = text.lastIndexOf('[', hpStart); + int barEnd = text.indexOf(']', hpEnd); + if (barStart < 0 || barEnd < 0 || barEnd >= levelStart || barEnd <= barStart) { + return false; + } + + int[] nonRed = new int[1]; + int[] red = new int[1]; + int[] cursor = new int[1]; + + component.visit((style, str) -> { + if (str == null || str.isEmpty()) { + return java.util.Optional.empty(); + } + TextColor color = style.getColor(); + int rgb = color != null ? color.getValue() : -1; + for (int j = 0; j < str.length(); j++) { + int idx = cursor[0]++; + if (idx <= barStart || idx >= barEnd) { + continue; + } + char c = str.charAt(j); + if (Character.isWhitespace(c) || c == '[' || c == ']') { + continue; + } + if (isRedColor(rgb)) { + red[0]++; + } else { + nonRed[0]++; + } + } + return java.util.Optional.empty(); + }, Style.EMPTY); + + return red[0] > 0 && nonRed[0] == 0; + } + + private static boolean isRedColor(int rgb) { + if (rgb < 0) { + return false; + } + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + return r >= 130 && g <= 100 && b <= 100 && r > g && r > b; + } + + private static int updateMaxHp(MaxHpState state, int currentHp, boolean fullBar, long now) { + if (fullBar) { + if (state.stableMaxHp <= 0) { + state.stableMaxHp = currentHp; + state.candidateHp = currentHp; + state.candidateSinceMs = now; + return currentHp; + } + if (currentHp != state.candidateHp) { + state.candidateHp = currentHp; + state.candidateSinceMs = now; + } else if (now - state.candidateSinceMs >= FULL_BAR_STABLE_MS) { + state.stableMaxHp = currentHp; + } + } else { + state.candidateHp = currentHp; + state.candidateSinceMs = now; + } + + return state.stableMaxHp > 0 ? state.stableMaxHp : currentHp; + } + + private static UUID resolvePlayerUuid(Minecraft mc, String displayedNick, Component rowComponent, List partyMembers, int rowIndex, int totalRows) { + if (mc.level == null) { + return null; + } + + // 1. Check hover/insertion on scoreboard row + String realName = ChatText.resolveRealNameAnywhere(rowComponent, displayedNick); + if (realName == null) { + realName = PlayerNameResolver.resolveKnown(displayedNick).orElse(displayedNick); + } + + // 2. Match with world players + for (Player player : mc.level.players()) { + String pName = player.getGameProfile().name(); + if (pName.equalsIgnoreCase(realName) || pName.equalsIgnoreCase(displayedNick)) { + return player.getUUID(); + } + } + + // 3. Fallback: if only 1 non-local player is in the world and only 1 non-local party row + List otherPlayers = new ArrayList<>(); + for (Player p : mc.level.players()) { + if (mc.player == null || !p.getUUID().equals(mc.player.getUUID())) { + otherPlayers.add(p); + } + } + if (otherPlayers.size() == 1 && totalRows <= 2) { + Player solo = otherPlayers.get(0); + PlayerNameResolver.recordAlias(displayedNick, solo.getGameProfile().name()); + return solo.getUUID(); + } + + return null; + } + + static List matchCandidateRoster(List rows, List wynnPartyMembers, String localUsername) { + if (wynnPartyMembers == null || wynnPartyMembers.isEmpty()) { + return List.of(); + } + + List> candidates = new ArrayList<>(); + if (wynnPartyMembers.size() == rows.size()) { + candidates.add(wynnPartyMembers); + } + + if (localUsername != null && !containsIgnoreCase(wynnPartyMembers, localUsername)) { + if (wynnPartyMembers.size() + 1 == rows.size()) { + List localFirst = new ArrayList<>(rows.size()); + localFirst.add(localUsername); + localFirst.addAll(wynnPartyMembers); + candidates.add(localFirst); + + List localLast = new ArrayList<>(rows.size()); + localLast.addAll(wynnPartyMembers); + localLast.add(localUsername); + candidates.add(localLast); + } + } + + for (List candidate : candidates) { + if (candidateMatches(rows, candidate, localUsername)) { + return candidate; + } + } + + return candidates.isEmpty() ? List.of() : candidates.get(0); + } + + private static boolean candidateMatches(List rows, List candidate, String localUsername) { + for (int i = 0; i < rows.size(); i++) { + ParsedPartyLine row = rows.get(i); + String expectedUser = candidate.get(i); + + if (localUsername != null && expectedUser.equalsIgnoreCase(localUsername)) { + if (isLocalRow(row, localUsername)) { + return true; + } + } + + String known = PlayerNameResolver.canonicalize(row.nickname()); + if (known != null && known.equalsIgnoreCase(expectedUser)) { + return true; + } + } + return false; + } + + private static boolean isLocalRow(ParsedPartyLine row, String localUsername) { + if (row.nickname().equalsIgnoreCase(localUsername)) { + return true; + } + String known = PlayerNameResolver.canonicalize(row.nickname()); + if (known != null && known.equalsIgnoreCase(localUsername)) { + return true; + } + try { + Minecraft mc = Minecraft.getInstance(); + if (mc != null && mc.player != null) { + int localHp = (int) Math.ceil(mc.player.getHealth()); + if (row.hp() == localHp) { + return true; + } + } + } catch (Throwable ignored) { + } + return false; + } + + private static boolean containsIgnoreCase(List list, String target) { + if (list == null || target == null) { + return false; + } + for (String s : list) { + if (s.equalsIgnoreCase(target)) { + return true; + } + } + return false; + } + + private static int resolvePartySlot(String name, List partyMembers, int fallbackSlot) { + if (partyMembers != null && !partyMembers.isEmpty()) { + String canonical = PlayerNameResolver.canonicalize(name); + for (int i = 0; i < partyMembers.size(); i++) { + String member = partyMembers.get(i); + if (member != null && (member.equalsIgnoreCase(name) || (canonical != 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/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/test/tel/eden/mod/chat/RaidReadyTrackerTest.java b/test/tel/eden/mod/chat/RaidReadyTrackerTest.java index 886fdda..ca15c1e 100644 --- a/test/tel/eden/mod/chat/RaidReadyTrackerTest.java +++ b/test/tel/eden/mod/chat/RaidReadyTrackerTest.java @@ -67,10 +67,10 @@ void resetsOnNewRaidPromptAndMarksStarterReady() { assertEquals(1, RaidReadyTracker.readyPlayersForTesting().size()); assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("bynt")); - // Wynncraft starter with nickname: Cartiye/kyousuke - RaidReadyTracker.onSystemChat(Component.literal(" Cartiye/kyousuke would like to start The Nameless Anomaly!")); + // 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("cartiye")); + assertTrue(RaidReadyTracker.readyPlayersForTesting().contains("playerleader")); } @Test @@ -134,4 +134,14 @@ void debugStateReturnsUsefulDiagnostics() { 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/PartyHealthTrackerTest.java b/test/tel/eden/mod/party/PartyHealthTrackerTest.java new file mode 100644 index 0000000..523b319 --- /dev/null +++ b/test/tel/eden/mod/party/PartyHealthTrackerTest.java @@ -0,0 +1,142 @@ +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 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); + PartyHealthTracker.ParsedPartyLine localRow = new PartyHealthTracker.ParsedPartyLine(1991, "LocalNick", 121, true, false); + 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)); + } +} From 9c1bc80041a2fcbc10fee9298afde5cff419229c Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:05:14 +0300 Subject: [PATCH 06/10] Fix party slot disambiguation, render distance persistence, and add distance size scaling --- .../textures/gui/healthbars/party_bar.png | Bin 0 -> 315 bytes src/tel/eden/mod/EdenModClient.java | 5 +- src/tel/eden/mod/chat/ChatText.java | 2 +- src/tel/eden/mod/chat/PlayerNameResolver.java | 160 ++- src/tel/eden/mod/config/BridgeConfig.java | 12 + src/tel/eden/mod/gui/BridgeConfigScreen.java | 40 + .../mod/party/PartyHealthBarRenderer.java | 189 ++-- .../eden/mod/party/PartyHealthTracker.java | 927 +++++++++++++++--- .../eden/mod/party/WynntilsPartyBridge.java | 107 +- .../eden/mod/chat/PlayerNameResolverTest.java | 35 + .../mod/party/PartyHealthBarRendererTest.java | 56 ++ .../mod/party/PartyHealthTrackerTest.java | 258 ++++- 12 files changed, 1572 insertions(+), 219 deletions(-) create mode 100644 resources/assets/edenmod/textures/gui/healthbars/party_bar.png create mode 100644 test/tel/eden/mod/party/PartyHealthBarRendererTest.java 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 0000000000000000000000000000000000000000..4e65f6d802ebbc7edf4332732f6845d69f29e679 GIT binary patch literal 315 zcmV-B0mS}^P)m%#jtkuk(I&XQ)d zwb8ciso}RByb%73ZGZEwtiT7-wc+le#)Yc>%m%mckxsxquWpU~83|y!;2!A8r`+A& zGtqt!li)P+5CG)<=Wvwaa5&|AkcXkeoE{{6Hm?T?pJp!74giQm(~i2XFMTr{h9UpI zKz;G>M;R@*;L0Lp1)hB)q`}?}n*?VPoc;F!0000000000004mefiE=TuaY&ZYNr4I N002ovPDHLkV1liZk`w>{ literal 0 HcmV?d00001 diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 8c73d00..7d85b14 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -685,6 +685,9 @@ public void release() { 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 -> { showHelp(ctx.getSource()); @@ -2281,7 +2284,7 @@ private static void sendStreamCommand() { /** Called from the chat-capture mixin for every non-overlay system-chat component. */ public void handleSystemChat(Component message) { - PlayerNameResolver.learnFromText(message.getString()); + 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). diff --git a/src/tel/eden/mod/chat/ChatText.java b/src/tel/eden/mod/chat/ChatText.java index 1e67bf5..5559a05 100644 --- a/src/tel/eden/mod/chat/ChatText.java +++ b/src/tel/eden/mod/chat/ChatText.java @@ -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/PlayerNameResolver.java b/src/tel/eden/mod/chat/PlayerNameResolver.java index 21f8857..221ffda 100644 --- a/src/tel/eden/mod/chat/PlayerNameResolver.java +++ b/src/tel/eden/mod/chat/PlayerNameResolver.java @@ -1,20 +1,32 @@ package tel.eden.mod.chat; import java.util.Locale; +import java.util.Map; import java.util.Optional; 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("(?= 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 +94,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); @@ -74,28 +123,29 @@ private static String resolveFromTabList(String displayed) { 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) { - 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) { + String profileName = (info.getProfile() != null) ? info.getProfile().name() : null; + if (profileName == null || !ChatText.IGN.matcher(profileName).matches()) { continue; } - String hoverResolved = ChatText.resolveRealNameAnywhere(tabName, displayed); - if (hoverResolved != null && ChatText.IGN.matcher(hoverResolved).matches()) { - return hoverResolved; + if (profileName.equalsIgnoreCase(displayed)) { + return profileName; } - String profileName = info.getProfile().name(); - if (profileName != null && ChatText.IGN.matcher(profileName).matches()) { + 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; @@ -108,16 +158,84 @@ 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); + } } - private static final java.util.regex.Pattern SLASH_ALIAS_PATTERN = java.util.regex.Pattern.compile("(? 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; } - java.util.regex.Matcher matcher = SLASH_ALIAS_PATTERN.matcher(text); + Matcher matcher = SLASH_ALIAS_PATTERN.matcher(text); while (matcher.find()) { String real = matcher.group(1); String nick = matcher.group(2); @@ -136,4 +254,8 @@ private static boolean isUrlToken(String token) { 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/config/BridgeConfig.java b/src/tel/eden/mod/config/BridgeConfig.java index b5db030..310cbb3 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -198,6 +198,16 @@ public void sanitize() { /** 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; @@ -382,6 +392,8 @@ 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)); 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 f9eb4e9..3edd32c 100644 --- a/src/tel/eden/mod/gui/BridgeConfigScreen.java +++ b/src/tel/eden/mod/gui/BridgeConfigScreen.java @@ -130,8 +130,11 @@ protected void init() { }); addToggleRow("Party member outlines", () -> config.partyHighlightEnabled, v -> config.partyHighlightEnabled = v, "On", "Off", true); addToggleRow("Party health bars", () -> config.partyHealthBarEnabled, v -> config.partyHealthBarEnabled = v, "On", "Off", true); + addToggleRow("Show own health bar (F5)", () -> config.partyHealthBarShowSelf, v -> config.partyHealthBarShowSelf = v, "On", "Off", true); HealthBarScaleSlider scaleSlider = new HealthBarScaleSlider(CONTROL_W, 20); addSliderRow("Party health bar size", scaleSlider, scaleSlider::syncFromConfig, () -> config.partyHealthBarScale = 100); + HealthBarDistanceScaleSlider distanceScaleSlider = new HealthBarDistanceScaleSlider(CONTROL_W, 20); + addSliderRow("Distance size boost", distanceScaleSlider, distanceScaleSlider::syncFromConfig, () -> config.partyHealthBarDistanceScale = 0); addToggleRow("Raid ready-up ping", () -> config.raidReadyPing, v -> config.raidReadyPing = v, "On", "Off", true); RaidReminderSlider reminderSlider = new RaidReminderSlider(CONTROL_W, 20); addSliderRow("Ready reminder delay", reminderSlider, reminderSlider::syncFromConfig, () -> config.raidReadyReminderSeconds = 60); @@ -591,4 +594,41 @@ protected void applyValue() { updateMessage(); } } + + private final class HealthBarDistanceScaleSlider extends AbstractSliderButton { + private static final int MIN = 0; + private static final int MAX = 200; + + private HealthBarDistanceScaleSlider(int width, int height) { + super(0, 0, width, height, Component.empty(), 0.0d); + syncFromConfig(); + } + + private void syncFromConfig() { + int current = Math.max(MIN, Math.min(MAX, config.partyHealthBarDistanceScale)); + this.value = (current - MIN) / (double) (MAX - MIN); + updateMessage(); + } + + @Override + protected void updateMessage() { + if (config.partyHealthBarDistanceScale <= 0) { + setMessage(Component.literal("Off")); + } else { + setMessage(Component.literal(config.partyHealthBarDistanceScale + "%")); + } + } + + @Override + protected void applyValue() { + int snapped = MIN + (int) Math.round(this.value * (MAX - MIN)); + snapped = Math.round(snapped / 5.0f) * 5; + snapped = Math.max(MIN, Math.min(MAX, snapped)); + if (snapped != config.partyHealthBarDistanceScale) { + config.partyHealthBarDistanceScale = snapped; + config.save(); + } + updateMessage(); + } + } } diff --git a/src/tel/eden/mod/party/PartyHealthBarRenderer.java b/src/tel/eden/mod/party/PartyHealthBarRenderer.java index e3cf5df..92489f8 100644 --- a/src/tel/eden/mod/party/PartyHealthBarRenderer.java +++ b/src/tel/eden/mod/party/PartyHealthBarRenderer.java @@ -3,49 +3,65 @@ 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 Eden-themed overhead health bar above party members' nametags. + * Renders an overhead health bar above party members' nametags. * - *

Theme elements: + *

Features: *

    - *
  • Obsidian slate border with dark crimson deficit underlay
  • - *
  • Dynamic health progression: Emerald Green (>50%) -> Amber (>25%) -> Crimson (<=25%)
  • - *
  • Overheal/overMax renders in radiant Eden Gold
  • - *
  • Party slot pip on the left border matching the player's party outline color
  • + *
  • 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 = 38.0f; - private static final float BAR_HEIGHT = 4.0f; - private static final float BORDER = 1.0f; + 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 BORDER_COLOR = 0xFF0A0F14; - private static final int BG_COLOR = 0xFF141A22; - private static final int DEFICIT_COLOR = 0xFF2A1215; - private static final int COLOR_EMERALD = 0xFF10B981; + 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 int DIVIDER_COLOR = 0x66000000; 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; @@ -64,7 +80,9 @@ public static void renderIfVisible(AvatarRenderState state, PoseStack poseStack, Minecraft mc = Minecraft.getInstance(); if (mc.player != null && uuid.equals(mc.player.getUUID())) { - return; + if (config == null || !config.partyHealthBarShowSelf) { + return; + } } PartyHealthTracker.PlayerHealthData health = PartyHealthTracker.getHealth(uuid); @@ -76,16 +94,33 @@ public static void renderIfVisible(AvatarRenderState state, PoseStack poseStack, poseStack.pushPose(); float scaleFactor = (config != null ? Math.max(50, Math.min(200, config.partyHealthBarScale)) : 100) / 100.0f; - float effectiveScale = NAMETAG_SCALE * scaleFactor; + float distanceMultiplier = computeDistanceScale(state, config); + 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, health, config); + submitEdenBar(submitNodeCollector, poseStack, uuid, health, config); poseStack.popPose(); } + static float computeDistanceScale(AvatarRenderState state, BridgeConfig config) { + if (state == null || config == null) { + return 1.0f; + } + return computeDistanceScale(state.distanceToCameraSq, config.partyHealthBarDistanceScale); + } + + 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) { @@ -108,19 +143,48 @@ private static float computeWorldYOffset(AvatarRenderState state) { return pixelOffset * NAMETAG_SCALE; } - private static void submitEdenBar(SubmitNodeCollector collector, PoseStack poseStack, PartyHealthTracker.PlayerHealthData health, BridgeConfig config) { + private static void submitEdenBar(SubmitNodeCollector collector, PoseStack poseStack, UUID uuid, PartyHealthTracker.PlayerHealthData health, BridgeConfig config) { float rawPercent = health.percent(); - float percent = Float.isNaN(rawPercent) ? 1.0f : Math.max(0.0f, Math.min(1.0f, rawPercent)); - float fillWidth = Math.round(BAR_WIDTH * 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 (health.overMax()) { - fillColor = COLOR_GOLD; - } else if (percent > 0.50f) { - fillColor = COLOR_EMERALD; - } else if (percent > 0.25f) { + if (displayedPercent > 0.50f) { + fillColor = COLOR_PROVI_GREEN; + } else if (displayedPercent > 0.25f) { fillColor = COLOR_AMBER; } else { fillColor = COLOR_CRIMSON; @@ -128,51 +192,52 @@ private static void submitEdenBar(SubmitNodeCollector collector, PoseStack poseS 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_EMERALD; - - collector.submitCustomGeometry(poseStack, RenderTypes.textBackground(), (pose, vertices) -> { - // 1. Obsidian Outer Border - drawQuad(vertices, pose, FULL_BRIGHT, x - BORDER, y - BORDER, BAR_WIDTH + (BORDER * 2.0f), BORDER, 0.01f, BORDER_COLOR); - drawQuad(vertices, pose, FULL_BRIGHT, x - BORDER, y + BAR_HEIGHT, BAR_WIDTH + (BORDER * 2.0f), BORDER, 0.01f, BORDER_COLOR); - drawQuad(vertices, pose, FULL_BRIGHT, x - BORDER, y, BORDER, BAR_HEIGHT, 0.01f, BORDER_COLOR); - drawQuad(vertices, pose, FULL_BRIGHT, x + BAR_WIDTH, y, BORDER, BAR_HEIGHT, 0.01f, BORDER_COLOR); - - // 2. Party Slot Gem/Pip on the left edge (aligned with bar height) - float pipSize = BAR_HEIGHT; - float pipX = x - BORDER - pipSize - 2.0f; - float pipY = y; - - // Pip obsidian border edges - drawQuad(vertices, pose, FULL_BRIGHT, pipX - BORDER, pipY - BORDER, pipSize + (BORDER * 2.0f), BORDER, 0.01f, BORDER_COLOR); - drawQuad(vertices, pose, FULL_BRIGHT, pipX - BORDER, pipY + pipSize, pipSize + (BORDER * 2.0f), BORDER, 0.01f, BORDER_COLOR); - drawQuad(vertices, pose, FULL_BRIGHT, pipX - BORDER, pipY, BORDER, pipSize, 0.01f, BORDER_COLOR); - drawQuad(vertices, pose, FULL_BRIGHT, pipX + pipSize, pipY, BORDER, pipSize, 0.01f, BORDER_COLOR); - - // Pip slot color fill - drawQuad(vertices, pose, FULL_BRIGHT, pipX, pipY, pipSize, pipSize, 0.002f, slotColor); - - // 3. Dark Deficit Background (missing HP) - if (fillWidth < BAR_WIDTH) { - drawQuad(vertices, pose, FULL_BRIGHT, x + fillWidth, y, BAR_WIDTH - fillWidth, BAR_HEIGHT, 0.001f, DEFICIT_COLOR); + 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); } - // 4. Health Fill - if (fillWidth > 0.0f) { - drawQuad(vertices, pose, FULL_BRIGHT, x, y, fillWidth, BAR_HEIGHT, 0.002f, 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); } - // 5. Tactical Quarter Dividers (25%, 50%, 75%) + // 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); - drawQuad(vertices, pose, FULL_BRIGHT, markX, y, 0.5f, BAR_HEIGHT, 0.003f, DIVIDER_COLOR); + 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 drawQuad(VertexConsumer vertices, PoseStack.Pose pose, int light, float x, float y, float width, float height, float z, int color) { - vertices.addVertex(pose, x, y, z).setLight(light).setColor(color); - vertices.addVertex(pose, x, y + height, z).setLight(light).setColor(color); - vertices.addVertex(pose, x + width, y + height, z).setLight(light).setColor(color); - vertices.addVertex(pose, x + width, y, z).setLight(light).setColor(color); + 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 index 1503c96..90ccb06 100644 --- a/src/tel/eden/mod/party/PartyHealthTracker.java +++ b/src/tel/eden/mod/party/PartyHealthTracker.java @@ -1,16 +1,22 @@ 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; @@ -32,28 +38,48 @@ 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, int partySlot) { + 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 ParsedPartyLine(int hp, String nickname, int level, boolean online, boolean fullHealthBar) { + record HealthCalculation(int maxHp, float percent, boolean overMax) { } - private static class MaxHpState { + static class MaxHpState { int stableMaxHp; int candidateHp; long candidateSinceMs; + long downwardCandidateSinceMs; - MaxHpState(int initialHp) { - this.stableMaxHp = initialHp; - this.candidateHp = initialHp; - this.candidateSinceMs = System.currentTimeMillis(); + 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; @@ -83,7 +109,10 @@ public static void tick() { 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) { @@ -143,49 +172,143 @@ private static void updatePartyHealth(long now) { return; } - List wynnPartyMembers = WynntilsPartyBridge.getPartyMembers(); + List rawWynnPartyMembers = WynntilsPartyBridge.getPartyMembers(); + List wynnPartyMembers = deduplicatePreservingOrder(rawWynnPartyMembers); 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); - Component comp = rowComponents.get(i); - if (!row.online()) { continue; } - if (i < candidateRoster.size() && candidateRoster.get(i) != null) { - PlayerNameResolver.recordAlias(row.nickname(), candidateRoster.get(i)); - } - - UUID uuid = resolvePlayerUuid(mc, row.nickname(), comp, wynnPartyMembers, i, parsedRows.size()); + UUID uuid = i < rowUuids.length ? rowUuids[i] : null; if (uuid == null) { continue; } seenUuids.add(uuid); - MaxHpState maxState = MAX_HP_BY_UUID.computeIfAbsent(uuid, k -> new MaxHpState(row.hp())); - int maxHp = updateMaxHp(maxState, row.hp(), row.fullHealthBar(), now); + if (mc.player != null && uuid.equals(mc.player.getUUID()) && HEALTH_BY_UUID.containsKey(uuid)) { + // Local player already accurately tracked from Wynntils CharacterStats + continue; + } - float percent = maxHp > 0 ? Math.max(0.0f, Math.min(1.0f, row.hp() / (float) maxHp)) : 1.0f; - boolean overMax = maxHp > 0 && row.hp() > maxHp; + 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(); + } - int slot = resolvePartySlot(row.nickname(), wynnPartyMembers, i); - HEALTH_BY_UUID.put(uuid, new PlayerHealthData(uuid, row.nickname(), row.hp(), maxHp, percent, overMax, slot)); + 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); - MAX_HP_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; } - String text = component.getString().trim(); + 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; } @@ -196,205 +319,644 @@ static ParsedPartyLine parsePartyLine(Component component, boolean inPartySectio if (text.startsWith("-")) { String name = text.substring(1).trim(); if (ChatText.IGN.matcher(name).matches()) { - return new ParsedPartyLine(0, name, 0, false, false); + return new ParsedPartyLine(0, name, 0, false, false, 0.0f); } } return null; } - Matcher hpMatcher = FIRST_NUMBER.matcher(text); - hpMatcher.region(0, level.start()); - if (!hpMatcher.find()) { - 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(); } - int hp = Integer.parseInt(hpMatcher.group(1)); - - int nickStart = hpMatcher.end(); - while (nickStart < level.start() && !isNicknameChar(text.charAt(nickStart))) { + // Trim any non-nickname characters from nickname bounds + int nickStart = 0; + while (nickStart < nickname.length() && !isNicknameChar(nickname.charAt(nickStart))) { nickStart++; } - int nickEnd = level.start(); - while (nickEnd > nickStart && !isNicknameChar(text.charAt(nickEnd - 1))) { + int nickEnd = nickname.length(); + while (nickEnd > nickStart && !isNicknameChar(nickname.charAt(nickEnd - 1))) { nickEnd--; } if (nickStart >= nickEnd) { return null; } - - String nickname = text.substring(nickStart, nickEnd).trim(); + nickname = nickname.substring(nickStart, nickEnd).trim(); if (nickname.isEmpty()) { return null; } - // Check if health bracket contains full health (all red characters) - boolean fullBar = isFullHealthBar(component, text, hpMatcher.start(), hpMatcher.end(), level.start()); - - return new ParsedPartyLine(hp, nickname, level.value(), true, fullBar); + 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 boolean isFullHealthBar(Component component, String text, int hpStart, int hpEnd, int levelStart) { - int barStart = text.lastIndexOf('[', hpStart); - int barEnd = text.indexOf(']', hpEnd); - if (barStart < 0 || barEnd < 0 || barEnd >= levelStart || barEnd <= barStart) { - return false; + 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[] nonRed = new int[1]; - int[] red = new int[1]; - int[] cursor = new int[1]; + int healthChars = 0; + int deficitChars = 0; - component.visit((style, str) -> { - if (str == null || str.isEmpty()) { - return java.util.Optional.empty(); + for (int i = barStart; i < barEnd; i++) { + StyledChar sc = styledChars.get(i); + char c = sc.ch(); + if (Character.isWhitespace(c) || c == '[' || c == ']') { + continue; } - TextColor color = style.getColor(); - int rgb = color != null ? color.getValue() : -1; - for (int j = 0; j < str.length(); j++) { - int idx = cursor[0]++; - if (idx <= barStart || idx >= barEnd) { - continue; - } - char c = str.charAt(j); - if (Character.isWhitespace(c) || c == '[' || c == ']') { - continue; - } - if (isRedColor(rgb)) { - red[0]++; - } else { - nonRed[0]++; - } + int rgb = sc.color(); + if (isHealthColor(rgb)) { + healthChars++; + } else if (isDeficitColor(rgb)) { + deficitChars++; } - return java.util.Optional.empty(); - }, Style.EMPTY); + } + + 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); + } - return red[0] > 0 && nonRed[0] == 0; + 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 isRedColor(int rgb) { + 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 r >= 130 && g <= 100 && b <= 100 && r > g && r > b; + 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); } - private static int updateMaxHp(MaxHpState state, int currentHp, boolean fullBar, long now) { + 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; - return currentHp; - } - if (currentHp != state.candidateHp) { + state.downwardCandidateSinceMs = 0L; + } else if (currentHp != state.candidateHp) { state.candidateHp = currentHp; state.candidateSinceMs = now; - } else if (now - state.candidateSinceMs >= FULL_BAR_STABLE_MS) { + 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; } - return state.stableMaxHp > 0 ? state.stableMaxHp : currentHp; + 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 UUID resolvePlayerUuid(Minecraft mc, String displayedNick, Component rowComponent, List partyMembers, int rowIndex, int totalRows) { - if (mc.level == null) { - return null; + 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; + } - // 1. Check hover/insertion on scoreboard row - String realName = ChatText.resolveRealNameAnywhere(rowComponent, displayedNick); - if (realName == null) { - realName = PlayerNameResolver.resolveKnown(displayedNick).orElse(displayedNick); + 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]; } - // 2. Match with world players - for (Player player : mc.level.players()) { - String pName = player.getGameProfile().name(); - if (pName.equalsIgnoreCase(realName) || pName.equalsIgnoreCase(displayedNick)) { - return player.getUUID(); + 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); } } - // 3. Fallback: if only 1 non-local player is in the world and only 1 non-local party row - List otherPlayers = new ArrayList<>(); + // 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 (mc.player == null || !p.getUUID().equals(mc.player.getUUID())) { - otherPlayers.add(p); + if (p.getGameProfile() != null && p.getGameProfile().name().equalsIgnoreCase(username)) { + return claimed.contains(p.getUUID()); } } - if (otherPlayers.size() == 1 && totalRows <= 2) { - Player solo = otherPlayers.get(0); - PlayerNameResolver.recordAlias(displayedNick, solo.getGameProfile().name()); - return solo.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; + } - return null; + static List deduplicatePreservingOrder(List list) { + if (list == null || list.isEmpty()) { + return List.of(); + } + List result = new ArrayList<>(list.size()); + Set seen = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + for (String item : list) { + if (item != null && !item.isBlank()) { + String trimmed = item.trim(); + if (seen.add(trimmed)) { + result.add(trimmed); + } + } + } + return result; } static List matchCandidateRoster(List rows, List wynnPartyMembers, String localUsername) { - if (wynnPartyMembers == null || wynnPartyMembers.isEmpty()) { + if (wynnPartyMembers == null || wynnPartyMembers.isEmpty() || rows == null || rows.isEmpty()) { return List.of(); } + wynnPartyMembers = deduplicatePreservingOrder(wynnPartyMembers); + + 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); - candidates.add(localFirst); + if (!candidates.contains(localFirst)) { + candidates.add(localFirst); + } List localLast = new ArrayList<>(rows.size()); localLast.addAll(wynnPartyMembers); localLast.add(localUsername); - candidates.add(localLast); + if (!candidates.contains(localLast)) { + candidates.add(localLast); + } } } + List bestCandidate = null; + int bestScore = -1; + for (List candidate : candidates) { - if (candidateMatches(rows, candidate, localUsername)) { - return candidate; + 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); } - private static boolean candidateMatches(List rows, List candidate, String localUsername) { + 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 (isLocalRow(row, localUsername)) { - return true; + 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)) { - return true; + 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 false; + return score; } - private static boolean isLocalRow(ParsedPartyLine row, String localUsername) { + 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; } @@ -402,19 +964,52 @@ private static boolean isLocalRow(ParsedPartyLine row, String localUsername) { if (known != null && known.equalsIgnoreCase(localUsername)) { return true; } - try { - Minecraft mc = Minecraft.getInstance(); - if (mc != null && mc.player != null) { - int localHp = (int) Math.ceil(mc.player.getHealth()); - if (row.hp() == localHp) { - return true; - } - } - } catch (Throwable ignored) { + 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) { if (list == null || target == null) { return false; @@ -427,13 +1022,95 @@ private static boolean containsIgnoreCase(List list, String target) { return false; } - private static int resolvePartySlot(String name, List partyMembers, int fallbackSlot) { + 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()) { - String canonical = PlayerNameResolver.canonicalize(name); - for (int i = 0; i < partyMembers.size(); i++) { - String member = partyMembers.get(i); - if (member != null && (member.equalsIgnoreCase(name) || (canonical != null && member.equalsIgnoreCase(canonical)))) { - return i; + // 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; + } + } } } } diff --git a/src/tel/eden/mod/party/WynntilsPartyBridge.java b/src/tel/eden/mod/party/WynntilsPartyBridge.java index d4ea3c4..8e54f7f 100644 --- a/src/tel/eden/mod/party/WynntilsPartyBridge.java +++ b/src/tel/eden/mod/party/WynntilsPartyBridge.java @@ -11,12 +11,24 @@ 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() { } @@ -47,18 +59,14 @@ public static List getPartyMembers() { if (list.isEmpty()) { return Collections.emptyList(); } - Object first = list.get(0); - if (first instanceof String) { - @SuppressWarnings("unchecked") - List typed = (List) res; - return typed; - } - // Wynntils may return List or similar — extract via toString. - LOGGER.warn("Wynntils party members returned non-String type: {}; falling back to toString()", first.getClass().getName()); 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) { - names.add(obj.toString()); + String s = obj.toString().trim(); + if (!s.isEmpty() && seen.add(s)) { + names.add(s); + } } } return names; @@ -68,6 +76,60 @@ public static List getPartyMembers() { 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"); + } + int current = (int) cappedValueCurrentMethod.invoke(cappedValue); + int max = (int) cappedValueMaxMethod.invoke(cappedValue); + double progress = (double) cappedValueGetProgressMethod.invoke(cappedValue); + 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; @@ -87,6 +149,25 @@ private static synchronized void ensureLoaded() { 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()); @@ -108,5 +189,13 @@ static synchronized void reset() { 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/test/tel/eden/mod/chat/PlayerNameResolverTest.java b/test/tel/eden/mod/chat/PlayerNameResolverTest.java index a42cfa5..a7e2d4f 100644 --- a/test/tel/eden/mod/chat/PlayerNameResolverTest.java +++ b/test/tel/eden/mod/chat/PlayerNameResolverTest.java @@ -38,4 +38,39 @@ 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")); + } } 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 index 523b319..9f83d46 100644 --- a/test/tel/eden/mod/party/PartyHealthTrackerTest.java +++ b/test/tel/eden/mod/party/PartyHealthTrackerTest.java @@ -86,6 +86,18 @@ void parsesWynncraftScoreboardLinesFromLiveDump() { 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 @@ -124,8 +136,8 @@ void matchesCandidateRosterWithLocalPlayerLast() { // Scoreboard rows: // Row 0: LeaderNick (LeaderPlayer) // Row 1: LocalNick (LocalPlayer, local) - PartyHealthTracker.ParsedPartyLine leaderRow = new PartyHealthTracker.ParsedPartyLine(18630, "LeaderNick", 120, true, false); - PartyHealthTracker.ParsedPartyLine localRow = new PartyHealthTracker.ParsedPartyLine(1991, "LocalNick", 121, true, false); + 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 @@ -139,4 +151,246 @@ void matchesCandidateRosterWithLocalPlayerLast() { 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); + } } From 102d40043ac2a4001b5ca9e607d7001b37dc0784 Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:45:41 +0300 Subject: [PATCH 07/10] fix: reject chat spoofing and enforce max party size in raid completion parser --- .../eden/mod/chat/RaidCompletionParser.java | 11 ++++++-- .../mod/chat/RaidCompletionParserTest.java | 27 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) 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/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"))); From 1b3956353dce4bc24460ac9b3c67b5c6d982ac41 Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:14:22 +0300 Subject: [PATCH 08/10] Add category filter dropdown to settings and bump to v1.5.0-beta1 --- gradle.properties | 2 +- src/tel/eden/mod/gui/BridgeConfigScreen.java | 189 +++++++++++++------ 2 files changed, 128 insertions(+), 63 deletions(-) diff --git a/gradle.properties b/gradle.properties index c95bafd..71ad6e2 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.5-beta1 +mod_version=1.5.0-beta1 maven_group=tel.eden archives_base_name=edenmod modmenu_version=17.0.0 diff --git a/src/tel/eden/mod/gui/BridgeConfigScreen.java b/src/tel/eden/mod/gui/BridgeConfigScreen.java index 3edd32c..dffb46c 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,53 +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)), () -> { - }); - addToggleRow("Party member outlines", () -> config.partyHighlightEnabled, v -> config.partyHighlightEnabled = v, "On", "Off", true); - addToggleRow("Party health bars", () -> config.partyHealthBarEnabled, v -> config.partyHealthBarEnabled = v, "On", "Off", true); - addToggleRow("Show own health bar (F5)", () -> config.partyHealthBarShowSelf, v -> config.partyHealthBarShowSelf = v, "On", "Off", true); + + // 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); HealthBarScaleSlider scaleSlider = new HealthBarScaleSlider(CONTROL_W, 20); - addSliderRow("Party health bar size", scaleSlider, scaleSlider::syncFromConfig, () -> config.partyHealthBarScale = 100); + addSliderRow(Category.PARTY_RAIDS, "Party health bar size", scaleSlider, scaleSlider::syncFromConfig, () -> config.partyHealthBarScale = 100); HealthBarDistanceScaleSlider distanceScaleSlider = new HealthBarDistanceScaleSlider(CONTROL_W, 20); - addSliderRow("Distance size boost", distanceScaleSlider, distanceScaleSlider::syncFromConfig, () -> config.partyHealthBarDistanceScale = 0); - addToggleRow("Raid ready-up ping", () -> config.raidReadyPing, v -> config.raidReadyPing = v, "On", "Off", true); + 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); RaidReminderSlider reminderSlider = new RaidReminderSlider(CONTROL_W, 20); - addSliderRow("Ready reminder delay", reminderSlider, reminderSlider::syncFromConfig, () -> config.raidReadyReminderSeconds = 60); + 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(); @@ -176,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() { @@ -185,7 +232,7 @@ private void saveConfig() { } private void refreshRows() { - for (SettingRow row : rows) { + for (SettingRow row : allRows) { row.refresh.run(); } } @@ -256,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() { @@ -305,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) { @@ -341,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); @@ -364,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) { @@ -408,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()); @@ -433,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; @@ -447,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; From 502a73cbdfdee968f7d05825aaff9f6c1c5dd738 Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:34:03 +0300 Subject: [PATCH 09/10] Bump version to 1.5.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 71ad6e2..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.5.0-beta1 +mod_version=1.5.0 maven_group=tel.eden archives_base_name=edenmod modmenu_version=17.0.0 From 1f23ed7c72370c40deea23dc04f33a784eb9bb07 Mon Sep 17 00:00:00 2001 From: burgerfan41 <87437687+burgerfan41@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:06:34 +0300 Subject: [PATCH 10/10] cut down on useless code, fix sum lil bugs --- src/tel/eden/mod/EdenModClient.java | 2 +- src/tel/eden/mod/chat/ChatReplyManager.java | 2 +- src/tel/eden/mod/chat/PlayerNameResolver.java | 34 ++++- src/tel/eden/mod/config/BridgeConfig.java | 1 + src/tel/eden/mod/gui/BridgeConfigScreen.java | 125 +++++------------- .../eden/mod/mixin/AvatarRendererMixin.java | 1 - src/tel/eden/mod/mixin/ChatScreenMixin.java | 7 +- .../mod/party/PartyHealthBarRenderer.java | 9 +- .../eden/mod/party/PartyHealthTracker.java | 33 +---- .../eden/mod/party/WynntilsPartyBridge.java | 12 +- .../eden/mod/chat/ChatReplyManagerTest.java | 4 +- .../eden/mod/chat/PlayerNameResolverTest.java | 14 ++ .../mod/party/PartyHighlightManagerTest.java | 12 -- 13 files changed, 105 insertions(+), 151 deletions(-) diff --git a/src/tel/eden/mod/EdenModClient.java b/src/tel/eden/mod/EdenModClient.java index 7d85b14..adb4e3d 100644 --- a/src/tel/eden/mod/EdenModClient.java +++ b/src/tel/eden/mod/EdenModClient.java @@ -2448,7 +2448,7 @@ 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; - String replyToId = ChatReplyManager.consumePendingReply(line.username(), line.message()); + String replyToId = ChatReplyManager.consumePendingReply(line.username()); current.sendGuildChat(line.username(), line.nickname(), line.message(), seq, replyToId); } } diff --git a/src/tel/eden/mod/chat/ChatReplyManager.java b/src/tel/eden/mod/chat/ChatReplyManager.java index 843619e..0dc446b 100644 --- a/src/tel/eden/mod/chat/ChatReplyManager.java +++ b/src/tel/eden/mod/chat/ChatReplyManager.java @@ -111,7 +111,7 @@ public static synchronized void markPendingReply() { * 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, String message) { + public static synchronized String consumePendingReply(String username) { if (pendingReplyId == null) { return null; } diff --git a/src/tel/eden/mod/chat/PlayerNameResolver.java b/src/tel/eden/mod/chat/PlayerNameResolver.java index 221ffda..e75aa80 100644 --- a/src/tel/eden/mod/chat/PlayerNameResolver.java +++ b/src/tel/eden/mod/chat/PlayerNameResolver.java @@ -3,6 +3,7 @@ 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; @@ -19,6 +20,7 @@ public final class PlayerNameResolver { 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() { } @@ -235,20 +237,46 @@ 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; } - recordAlias(nick, real); + 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) { - String lower = token.toLowerCase(Locale.ROOT); - return lower.equals("http") || lower.equals("https") || lower.equals("com") || lower.equals("org") || lower.equals("net") || lower.equals("io") || lower.equals("gg"); + return token != null && URL_TOKENS.contains(token.toLowerCase(Locale.ROOT)); } private static String key(String name) { diff --git a/src/tel/eden/mod/config/BridgeConfig.java b/src/tel/eden/mod/config/BridgeConfig.java index 310cbb3..f24709f 100644 --- a/src/tel/eden/mod/config/BridgeConfig.java +++ b/src/tel/eden/mod/config/BridgeConfig.java @@ -394,6 +394,7 @@ public static BridgeConfig load() { 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 dffb46c..d40999b 100644 --- a/src/tel/eden/mod/gui/BridgeConfigScreen.java +++ b/src/tel/eden/mod/gui/BridgeConfigScreen.java @@ -137,12 +137,12 @@ protected void init() { 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); - HealthBarScaleSlider scaleSlider = new HealthBarScaleSlider(CONTROL_W, 20); + 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); - HealthBarDistanceScaleSlider distanceScaleSlider = new HealthBarDistanceScaleSlider(CONTROL_W, 20); + 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); - RaidReminderSlider reminderSlider = new RaidReminderSlider(CONTROL_W, 20); + 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 @@ -585,112 +585,57 @@ protected void applyValue() { } } - private final class RaidReminderSlider extends AbstractSliderButton { - private static final int MIN = 10; - private static final int MAX = 300; - - private RaidReminderSlider(int width, int height) { - super(0, 0, width, height, Component.empty(), 0.0d); - syncFromConfig(); - } - - private void syncFromConfig() { - int current = Math.max(MIN, Math.min(MAX, config.raidReadyReminderSeconds)); - this.value = (current - MIN) / (double) (MAX - MIN); - updateMessage(); - } - - @Override - protected void updateMessage() { - int sec = config.raidReadyReminderSeconds; - if (sec == 60) { - setMessage(Component.literal("1 min")); - } else if (sec % 60 == 0) { - setMessage(Component.literal((sec / 60) + " mins")); - } else if (sec < 60) { - setMessage(Component.literal(sec + "s")); - } else { - setMessage(Component.literal((sec / 60) + "m " + (sec % 60) + "s")); - } - } - - @Override - protected void applyValue() { - int snapped = MIN + (int) Math.round(this.value * (MAX - MIN)); - snapped = Math.round(snapped / 5.0f) * 5; - snapped = Math.max(MIN, Math.min(MAX, snapped)); - if (snapped != config.raidReadyReminderSeconds) { - config.raidReadyReminderSeconds = snapped; - config.save(); - } - 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 HealthBarScaleSlider extends AbstractSliderButton { - private static final int MIN = 50; - private static final int MAX = 200; + 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 HealthBarScaleSlider(int width, int height) { + 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, config.partyHealthBarScale)); - this.value = (current - MIN) / (double) (MAX - MIN); + int current = Math.max(min, Math.min(max, getter.get())); + this.value = (current - min) / (double) (max - min); updateMessage(); } @Override protected void updateMessage() { - setMessage(Component.literal(config.partyHealthBarScale + "%")); + setMessage(labeler.apply(getter.get())); } @Override protected void applyValue() { - int snapped = MIN + (int) Math.round(this.value * (MAX - MIN)); - snapped = Math.round(snapped / 5.0f) * 5; - snapped = Math.max(MIN, Math.min(MAX, snapped)); - if (snapped != config.partyHealthBarScale) { - config.partyHealthBarScale = snapped; - config.save(); + int snapped = min + (int) Math.round(this.value * (max - min)); + if (step > 1) { + snapped = Math.round((float) snapped / step) * step; } - updateMessage(); - } - } - - private final class HealthBarDistanceScaleSlider extends AbstractSliderButton { - private static final int MIN = 0; - private static final int MAX = 200; - - private HealthBarDistanceScaleSlider(int width, int height) { - super(0, 0, width, height, Component.empty(), 0.0d); - syncFromConfig(); - } - - private void syncFromConfig() { - int current = Math.max(MIN, Math.min(MAX, config.partyHealthBarDistanceScale)); - this.value = (current - MIN) / (double) (MAX - MIN); - updateMessage(); - } - - @Override - protected void updateMessage() { - if (config.partyHealthBarDistanceScale <= 0) { - setMessage(Component.literal("Off")); - } else { - setMessage(Component.literal(config.partyHealthBarDistanceScale + "%")); - } - } - - @Override - protected void applyValue() { - int snapped = MIN + (int) Math.round(this.value * (MAX - MIN)); - snapped = Math.round(snapped / 5.0f) * 5; - snapped = Math.max(MIN, Math.min(MAX, snapped)); - if (snapped != config.partyHealthBarDistanceScale) { - config.partyHealthBarDistanceScale = snapped; + 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/AvatarRendererMixin.java b/src/tel/eden/mod/mixin/AvatarRendererMixin.java index 5c6153c..f393aa1 100644 --- a/src/tel/eden/mod/mixin/AvatarRendererMixin.java +++ b/src/tel/eden/mod/mixin/AvatarRendererMixin.java @@ -10,7 +10,6 @@ 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; import tel.eden.mod.render.EdenAvatarState; @Mixin(AvatarRenderer.class) diff --git a/src/tel/eden/mod/mixin/ChatScreenMixin.java b/src/tel/eden/mod/mixin/ChatScreenMixin.java index 22d8ad1..bb301a1 100644 --- a/src/tel/eden/mod/mixin/ChatScreenMixin.java +++ b/src/tel/eden/mod/mixin/ChatScreenMixin.java @@ -183,7 +183,12 @@ public abstract class ChatScreenMixin { @Inject(method = "handleChatInput", at = @At("HEAD")) private void edenmod$captureReplyOnSend(String message, boolean addToHistory, CallbackInfo ci) { if (ChatReplyManager.hasActiveReply()) { - ChatReplyManager.markPendingReply(); + String trimmed = message != null ? message.trim() : ""; + if (trimmed.startsWith("/g ") && trimmed.length() > 3 && !trimmed.substring(3).isBlank()) { + ChatReplyManager.markPendingReply(); + } else { + ChatReplyManager.clearActiveReply(); + } } } diff --git a/src/tel/eden/mod/party/PartyHealthBarRenderer.java b/src/tel/eden/mod/party/PartyHealthBarRenderer.java index 92489f8..d5e4672 100644 --- a/src/tel/eden/mod/party/PartyHealthBarRenderer.java +++ b/src/tel/eden/mod/party/PartyHealthBarRenderer.java @@ -94,7 +94,7 @@ public static void renderIfVisible(AvatarRenderState state, PoseStack poseStack, poseStack.pushPose(); float scaleFactor = (config != null ? Math.max(50, Math.min(200, config.partyHealthBarScale)) : 100) / 100.0f; - float distanceMultiplier = computeDistanceScale(state, config); + 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); @@ -105,13 +105,6 @@ public static void renderIfVisible(AvatarRenderState state, PoseStack poseStack, poseStack.popPose(); } - static float computeDistanceScale(AvatarRenderState state, BridgeConfig config) { - if (state == null || config == null) { - return 1.0f; - } - return computeDistanceScale(state.distanceToCameraSq, config.partyHealthBarDistanceScale); - } - static float computeDistanceScale(double distanceToCameraSq, int distanceScalePercent) { if (distanceScalePercent <= 0 || distanceToCameraSq <= (DISTANCE_SCALE_START * DISTANCE_SCALE_START)) { return 1.0f; diff --git a/src/tel/eden/mod/party/PartyHealthTracker.java b/src/tel/eden/mod/party/PartyHealthTracker.java index 90ccb06..6937755 100644 --- a/src/tel/eden/mod/party/PartyHealthTracker.java +++ b/src/tel/eden/mod/party/PartyHealthTracker.java @@ -172,8 +172,7 @@ private static void updatePartyHealth(long now) { return; } - List rawWynnPartyMembers = WynntilsPartyBridge.getPartyMembers(); - List wynnPartyMembers = deduplicatePreservingOrder(rawWynnPartyMembers); + List wynnPartyMembers = WynntilsPartyBridge.getPartyMembers(); String localName = (mc.player != null && mc.player.getGameProfile() != null) ? mc.player.getGameProfile().name() : null; List candidateRoster = matchCandidateRoster(parsedRows, wynnPartyMembers, localName); @@ -787,28 +786,12 @@ static boolean isSharedNickname(List rows, String nickname) { return count > 1; } - static List deduplicatePreservingOrder(List list) { - if (list == null || list.isEmpty()) { - return List.of(); - } - List result = new ArrayList<>(list.size()); - Set seen = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); - for (String item : list) { - if (item != null && !item.isBlank()) { - String trimmed = item.trim(); - if (seen.add(trimmed)) { - result.add(trimmed); - } - } - } - return result; - } - static List matchCandidateRoster(List rows, List wynnPartyMembers, String localUsername) { if (wynnPartyMembers == null || wynnPartyMembers.isEmpty() || rows == null || rows.isEmpty()) { return List.of(); } - wynnPartyMembers = deduplicatePreservingOrder(wynnPartyMembers); + 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) { @@ -1011,15 +994,7 @@ public static List debugState() { } private static boolean containsIgnoreCase(List list, String target) { - if (list == null || target == null) { - return false; - } - for (String s : list) { - if (s.equalsIgnoreCase(target)) { - return true; - } - } - return false; + return list != null && target != null && list.stream().anyMatch(target::equalsIgnoreCase); } private static UUID findTabUuid(Minecraft mc, String name, Set claimed) { diff --git a/src/tel/eden/mod/party/WynntilsPartyBridge.java b/src/tel/eden/mod/party/WynntilsPartyBridge.java index 8e54f7f..db85fa4 100644 --- a/src/tel/eden/mod/party/WynntilsPartyBridge.java +++ b/src/tel/eden/mod/party/WynntilsPartyBridge.java @@ -120,9 +120,15 @@ private static LiveHealth extractFromCappedValue(Object cappedValue) { cappedValueMaxMethod = cvClass.getMethod("max"); cappedValueGetProgressMethod = cvClass.getMethod("getProgress"); } - int current = (int) cappedValueCurrentMethod.invoke(cappedValue); - int max = (int) cappedValueMaxMethod.invoke(cappedValue); - double progress = (double) cappedValueGetProgressMethod.invoke(cappedValue); + 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) { diff --git a/test/tel/eden/mod/chat/ChatReplyManagerTest.java b/test/tel/eden/mod/chat/ChatReplyManagerTest.java index bff8291..f36e2a6 100644 --- a/test/tel/eden/mod/chat/ChatReplyManagerTest.java +++ b/test/tel/eden/mod/chat/ChatReplyManagerTest.java @@ -63,11 +63,11 @@ void marksAndConsumesPendingReply() { assertFalse(ChatReplyManager.hasActiveReply()); // In headless test mc.getUser() is null, so username check is bypassed - String replyId = ChatReplyManager.consumePendingReply("AnyUser", "Answer"); + String replyId = ChatReplyManager.consumePendingReply("AnyUser"); assertEquals("222", replyId); // Second consume returns null (consumed) - assertNull(ChatReplyManager.consumePendingReply("AnyUser", "Answer")); + assertNull(ChatReplyManager.consumePendingReply("AnyUser")); } @Test diff --git a/test/tel/eden/mod/chat/PlayerNameResolverTest.java b/test/tel/eden/mod/chat/PlayerNameResolverTest.java index a7e2d4f..45ff1ff 100644 --- a/test/tel/eden/mod/chat/PlayerNameResolverTest.java +++ b/test/tel/eden/mod/chat/PlayerNameResolverTest.java @@ -73,4 +73,18 @@ void resolveKnownMatchesTruncatedNicknames() { 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/party/PartyHighlightManagerTest.java b/test/tel/eden/mod/party/PartyHighlightManagerTest.java index 7cba7e0..951324a 100644 --- a/test/tel/eden/mod/party/PartyHighlightManagerTest.java +++ b/test/tel/eden/mod/party/PartyHighlightManagerTest.java @@ -97,18 +97,6 @@ public void colorAssignmentMatchesSlotPositions() { assertNull(PartyHighlightManager.getColorForPlayerName("HostLeader", List.of(), null)); } - @Test - public void outlineThicknessSanitization() { - int defaultThickness = 2; - assertEquals(2, defaultThickness, "Default outline thickness should be 2px"); - - int thicknessAbove = Math.max(1, Math.min(6, 10)); - assertEquals(6, thicknessAbove, "Thickness above 6 should clamp to 6"); - - int thicknessBelow = Math.max(1, Math.min(6, 0)); - assertEquals(1, thicknessBelow, "Thickness below 1 should clamp to 1"); - } - @Test public void wynntilsPartyBridgeFallbackSafe() { // When Wynntils is absent or uninitialized in test environment